feat: add RdxContextPanel and related tests for context management
feat: implement RdxEditor components including layout, section, and sticky footer with tests feat: introduce RdxFormGrid for form layout management feat: create RdxPage components for structured page layout with actions and headers feat: add RdxRowActions for handling row-level actions with dropdown support
This commit is contained in:
parent
ec1dd1099f
commit
c28a5e02c3
1
apps/web/src/development/ui-kit/index.ts
Normal file
1
apps/web/src/development/ui-kit/index.ts
Normal file
@ -0,0 +1 @@
|
|||||||
|
export { default as UiKitPage } from "./ui-kit-page";
|
||||||
28
apps/web/src/development/ui-kit/sections/ui-kit-section.tsx
Normal file
28
apps/web/src/development/ui-kit/sections/ui-kit-section.tsx
Normal file
@ -0,0 +1,28 @@
|
|||||||
|
import { cn } from "@repo/shadcn-ui/lib/utils";
|
||||||
|
import type { ReactNode } from "react";
|
||||||
|
|
||||||
|
interface UiKitSectionProps {
|
||||||
|
id: string;
|
||||||
|
title: string;
|
||||||
|
description?: ReactNode;
|
||||||
|
children: ReactNode;
|
||||||
|
className?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const UiKitSection = ({
|
||||||
|
id,
|
||||||
|
title,
|
||||||
|
description,
|
||||||
|
children,
|
||||||
|
className,
|
||||||
|
}: UiKitSectionProps) => {
|
||||||
|
return (
|
||||||
|
<section className={cn("space-y-4", className)} id={id}>
|
||||||
|
<div className="space-y-1">
|
||||||
|
<h2 className="text-lg font-semibold tracking-tight text-foreground">{title}</h2>
|
||||||
|
{description ? <p className="text-sm text-muted-foreground">{description}</p> : null}
|
||||||
|
</div>
|
||||||
|
{children}
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
};
|
||||||
965
apps/web/src/development/ui-kit/ui-kit-page.tsx
Normal file
965
apps/web/src/development/ui-kit/ui-kit-page.tsx
Normal file
@ -0,0 +1,965 @@
|
|||||||
|
import { useTheme } from "@/lib/hooks";
|
||||||
|
import {
|
||||||
|
RdxEditorPageLayout,
|
||||||
|
RdxEditorSection,
|
||||||
|
RdxEditorStickyFooter,
|
||||||
|
RdxFormGrid,
|
||||||
|
RdxPageActions,
|
||||||
|
RdxContextPanel,
|
||||||
|
RdxPageContainer,
|
||||||
|
RdxPageHeader,
|
||||||
|
RdxPageStack,
|
||||||
|
RdxRowActions,
|
||||||
|
RightPanel,
|
||||||
|
} from "@repo/rdx-ui/components";
|
||||||
|
import {
|
||||||
|
Badge,
|
||||||
|
Button,
|
||||||
|
Card,
|
||||||
|
CardContent,
|
||||||
|
CardDescription,
|
||||||
|
CardHeader,
|
||||||
|
CardTitle,
|
||||||
|
DropdownMenu,
|
||||||
|
DropdownMenuContent,
|
||||||
|
DropdownMenuItem,
|
||||||
|
DropdownMenuTrigger,
|
||||||
|
Select,
|
||||||
|
SelectContent,
|
||||||
|
SelectItem,
|
||||||
|
SelectTrigger,
|
||||||
|
SelectValue,
|
||||||
|
Separator,
|
||||||
|
} from "@repo/shadcn-ui/components";
|
||||||
|
import { ArrowLeftIcon, MoreHorizontalIcon, PlusIcon, XIcon } from "lucide-react";
|
||||||
|
import type { ReactNode } from "react";
|
||||||
|
|
||||||
|
import { UiKitSection } from "./sections/ui-kit-section";
|
||||||
|
|
||||||
|
type ThemeValue = "dark" | "light" | "system";
|
||||||
|
|
||||||
|
const isThemeValue = (value: string): value is ThemeValue =>
|
||||||
|
value === "light" || value === "dark" || value === "system";
|
||||||
|
|
||||||
|
interface ExampleCardProps {
|
||||||
|
title: string;
|
||||||
|
notes?: ReactNode;
|
||||||
|
children: ReactNode;
|
||||||
|
}
|
||||||
|
|
||||||
|
const ExampleCard = ({ title, notes, children }: ExampleCardProps) => {
|
||||||
|
return (
|
||||||
|
<Card>
|
||||||
|
<CardHeader className="space-y-1">
|
||||||
|
<CardTitle className="text-base">{title}</CardTitle>
|
||||||
|
{notes ? <CardDescription>{notes}</CardDescription> : null}
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>{children}</CardContent>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const SurfaceExample = ({ children }: { children: ReactNode }) => {
|
||||||
|
return (
|
||||||
|
<div className="rounded-lg border border-border bg-background p-4">
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const OverflowMenuExample = () => {
|
||||||
|
return (
|
||||||
|
<DropdownMenu>
|
||||||
|
<DropdownMenuTrigger
|
||||||
|
render={
|
||||||
|
<Button aria-label="More actions" size="icon-sm" type="button" variant="outline">
|
||||||
|
<MoreHorizontalIcon className="size-4" />
|
||||||
|
</Button>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<DropdownMenuContent align="end">
|
||||||
|
<DropdownMenuItem>Duplicate</DropdownMenuItem>
|
||||||
|
<DropdownMenuItem>Export</DropdownMenuItem>
|
||||||
|
</DropdownMenuContent>
|
||||||
|
</DropdownMenu>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function UiKitPage() {
|
||||||
|
const { theme, setTheme } = useTheme();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<RdxPageContainer width="wide">
|
||||||
|
<RdxPageStack spacing="relaxed">
|
||||||
|
<RdxPageHeader
|
||||||
|
actions={
|
||||||
|
<RdxPageActions
|
||||||
|
primary={
|
||||||
|
<Button type="button">
|
||||||
|
<PlusIcon className="size-4" />
|
||||||
|
Primary action
|
||||||
|
</Button>
|
||||||
|
}
|
||||||
|
secondary={<Button type="button" variant="outline">Secondary</Button>}
|
||||||
|
overflow={<OverflowMenuExample />}
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
description="Infraestructura mínima del sistema visual Rdx para validar foundations estructurales antes de migrar pantallas funcionales."
|
||||||
|
metadata={
|
||||||
|
<>
|
||||||
|
<Badge variant="outline">Development only</Badge>
|
||||||
|
<Badge variant="outline">Static data</Badge>
|
||||||
|
</>
|
||||||
|
}
|
||||||
|
title="Rdx UI Kit"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<nav aria-label="UI Kit sections" className="rounded-lg border border-border bg-background p-4">
|
||||||
|
<div className="flex flex-wrap gap-2">
|
||||||
|
<a
|
||||||
|
className="inline-flex h-8 items-center rounded-md px-2.5 text-sm font-medium hover:bg-muted"
|
||||||
|
href="#foundations"
|
||||||
|
>
|
||||||
|
Foundations
|
||||||
|
</a>
|
||||||
|
<a
|
||||||
|
className="inline-flex h-8 items-center rounded-md px-2.5 text-sm font-medium hover:bg-muted"
|
||||||
|
href="#page-containers"
|
||||||
|
>
|
||||||
|
Page containers
|
||||||
|
</a>
|
||||||
|
<a
|
||||||
|
className="inline-flex h-8 items-center rounded-md px-2.5 text-sm font-medium hover:bg-muted"
|
||||||
|
href="#vertical-spacing"
|
||||||
|
>
|
||||||
|
Vertical spacing
|
||||||
|
</a>
|
||||||
|
<a
|
||||||
|
className="inline-flex h-8 items-center rounded-md px-2.5 text-sm font-medium hover:bg-muted"
|
||||||
|
href="#page-headers"
|
||||||
|
>
|
||||||
|
Page headers
|
||||||
|
</a>
|
||||||
|
<a
|
||||||
|
className="inline-flex h-8 items-center rounded-md px-2.5 text-sm font-medium hover:bg-muted"
|
||||||
|
href="#page-actions"
|
||||||
|
>
|
||||||
|
Page actions
|
||||||
|
</a>
|
||||||
|
<a
|
||||||
|
className="inline-flex h-8 items-center rounded-md px-2.5 text-sm font-medium hover:bg-muted"
|
||||||
|
href="#real-list-page-composition"
|
||||||
|
>
|
||||||
|
Real list-page composition
|
||||||
|
</a>
|
||||||
|
<a
|
||||||
|
className="inline-flex h-8 items-center rounded-md px-2.5 text-sm font-medium hover:bg-muted"
|
||||||
|
href="#list-with-context-panel"
|
||||||
|
>
|
||||||
|
List with context panel
|
||||||
|
</a>
|
||||||
|
<a
|
||||||
|
className="inline-flex h-8 items-center rounded-md px-2.5 text-sm font-medium hover:bg-muted"
|
||||||
|
href="#rdx-context-panel"
|
||||||
|
>
|
||||||
|
RdxContextPanel
|
||||||
|
</a>
|
||||||
|
<a
|
||||||
|
className="inline-flex h-8 items-center rounded-md px-2.5 text-sm font-medium hover:bg-muted"
|
||||||
|
href="#rdx-row-actions"
|
||||||
|
>
|
||||||
|
RdxRowActions
|
||||||
|
</a>
|
||||||
|
<a
|
||||||
|
className="inline-flex h-8 items-center rounded-md px-2.5 text-sm font-medium hover:bg-muted"
|
||||||
|
href="#editor-foundations"
|
||||||
|
>
|
||||||
|
Editor foundations
|
||||||
|
</a>
|
||||||
|
<a
|
||||||
|
className="inline-flex h-8 items-center rounded-md px-2.5 text-sm font-medium hover:bg-muted"
|
||||||
|
href="#theme-compatibility"
|
||||||
|
>
|
||||||
|
Light/dark compatibility
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</nav>
|
||||||
|
|
||||||
|
<UiKitSection
|
||||||
|
description="Los ejemplos de esta página usan solo datos estáticos y el sistema real de tema de la aplicación."
|
||||||
|
id="foundations"
|
||||||
|
title="Foundations"
|
||||||
|
>
|
||||||
|
<div className="grid gap-4 lg:grid-cols-2">
|
||||||
|
<ExampleCard
|
||||||
|
notes="Padding móvil `4`, desktop `6`, ritmo vertical `4 / 6 / 8`, anchuras `full / wide / content`."
|
||||||
|
title="Base estructural"
|
||||||
|
>
|
||||||
|
<SurfaceExample>
|
||||||
|
<div className="space-y-3">
|
||||||
|
<div className="text-sm font-medium text-foreground">Compact, clean, ordered</div>
|
||||||
|
<p className="text-sm text-muted-foreground">
|
||||||
|
Las surfaces principales son planas. Las jerarquías se expresan con spacing,
|
||||||
|
tipografía y divisores.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</SurfaceExample>
|
||||||
|
</ExampleCard>
|
||||||
|
|
||||||
|
<ExampleCard
|
||||||
|
notes="El selector usa `useTheme`, sin un segundo sistema de tema."
|
||||||
|
title="Theme control"
|
||||||
|
>
|
||||||
|
<div className="max-w-xs">
|
||||||
|
<Select
|
||||||
|
onValueChange={(value) => {
|
||||||
|
if (isThemeValue(value)) {
|
||||||
|
setTheme(value);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
value={theme}
|
||||||
|
>
|
||||||
|
<SelectTrigger aria-label="Theme mode">
|
||||||
|
<SelectValue placeholder="Select theme" />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="light">Light</SelectItem>
|
||||||
|
<SelectItem value="dark">Dark</SelectItem>
|
||||||
|
<SelectItem value="system">System</SelectItem>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
</ExampleCard>
|
||||||
|
</div>
|
||||||
|
</UiKitSection>
|
||||||
|
|
||||||
|
<Separator />
|
||||||
|
|
||||||
|
<UiKitSection
|
||||||
|
description="`RdxPageContainer` define padding exterior, anchura y centrado. No gestiona ritmo vertical."
|
||||||
|
id="page-containers"
|
||||||
|
title="Page containers"
|
||||||
|
>
|
||||||
|
<div className="grid gap-4 xl:grid-cols-3">
|
||||||
|
<ExampleCard notes="`width="full"`" title="Full">
|
||||||
|
<RdxPageContainer className="rounded-lg border border-dashed border-border bg-muted/40" width="full">
|
||||||
|
<div className="text-sm text-foreground">Available width</div>
|
||||||
|
</RdxPageContainer>
|
||||||
|
</ExampleCard>
|
||||||
|
|
||||||
|
<ExampleCard notes="`width="wide"`" title="Wide">
|
||||||
|
<RdxPageContainer className="rounded-lg border border-dashed border-border bg-muted/40" width="wide">
|
||||||
|
<div className="text-sm text-foreground">Max width screen 2xl</div>
|
||||||
|
</RdxPageContainer>
|
||||||
|
</ExampleCard>
|
||||||
|
|
||||||
|
<ExampleCard notes="`width="content"`" title="Content">
|
||||||
|
<RdxPageContainer className="rounded-lg border border-dashed border-border bg-muted/40" width="content">
|
||||||
|
<div className="text-sm text-foreground">Centered content editor</div>
|
||||||
|
</RdxPageContainer>
|
||||||
|
</ExampleCard>
|
||||||
|
</div>
|
||||||
|
</UiKitSection>
|
||||||
|
|
||||||
|
<Separator />
|
||||||
|
|
||||||
|
<UiKitSection
|
||||||
|
description="`RdxPageStack` usa `gap` para componer contenido condicional sin depender de `space-y-*`."
|
||||||
|
id="vertical-spacing"
|
||||||
|
title="Vertical spacing"
|
||||||
|
>
|
||||||
|
<div className="grid gap-4 xl:grid-cols-3">
|
||||||
|
<ExampleCard notes="`spacing="compact"`" title="Compact">
|
||||||
|
<RdxPageStack className="rounded-lg border border-border bg-background p-4" spacing="compact">
|
||||||
|
<SurfaceExample>Block 1</SurfaceExample>
|
||||||
|
<SurfaceExample>Block 2</SurfaceExample>
|
||||||
|
<SurfaceExample>Block 3</SurfaceExample>
|
||||||
|
</RdxPageStack>
|
||||||
|
</ExampleCard>
|
||||||
|
|
||||||
|
<ExampleCard notes="`spacing="default"`" title="Default">
|
||||||
|
<RdxPageStack className="rounded-lg border border-border bg-background p-4" spacing="default">
|
||||||
|
<SurfaceExample>Block 1</SurfaceExample>
|
||||||
|
<SurfaceExample>Block 2</SurfaceExample>
|
||||||
|
<SurfaceExample>Block 3</SurfaceExample>
|
||||||
|
</RdxPageStack>
|
||||||
|
</ExampleCard>
|
||||||
|
|
||||||
|
<ExampleCard notes="`spacing="relaxed"`" title="Relaxed">
|
||||||
|
<RdxPageStack className="rounded-lg border border-border bg-background p-4" spacing="relaxed">
|
||||||
|
<SurfaceExample>Block 1</SurfaceExample>
|
||||||
|
<SurfaceExample>Block 2</SurfaceExample>
|
||||||
|
<SurfaceExample>Block 3</SurfaceExample>
|
||||||
|
</RdxPageStack>
|
||||||
|
</ExampleCard>
|
||||||
|
</div>
|
||||||
|
</UiKitSection>
|
||||||
|
|
||||||
|
<Separator />
|
||||||
|
|
||||||
|
<UiKitSection
|
||||||
|
description="`RdxPageHeader` es visual y genérico. El consumidor aporta leading, metadata y acciones."
|
||||||
|
id="page-headers"
|
||||||
|
title="Page headers"
|
||||||
|
>
|
||||||
|
<div className="grid gap-4">
|
||||||
|
<ExampleCard title="Simple title">
|
||||||
|
<SurfaceExample>
|
||||||
|
<RdxPageHeader title="Customers" />
|
||||||
|
</SurfaceExample>
|
||||||
|
</ExampleCard>
|
||||||
|
|
||||||
|
<ExampleCard title="Title and description">
|
||||||
|
<SurfaceExample>
|
||||||
|
<RdxPageHeader
|
||||||
|
description="Compact page header for list and detail screens."
|
||||||
|
title="Proformas"
|
||||||
|
/>
|
||||||
|
</SurfaceExample>
|
||||||
|
</ExampleCard>
|
||||||
|
|
||||||
|
<ExampleCard title="Leading, metadata and actions">
|
||||||
|
<SurfaceExample>
|
||||||
|
<RdxPageHeader
|
||||||
|
actions={
|
||||||
|
<RdxPageActions
|
||||||
|
primary={<Button type="button">Save</Button>}
|
||||||
|
secondary={<Button type="button" variant="outline">Cancel</Button>}
|
||||||
|
overflow={<OverflowMenuExample />}
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
description="Header with leading control, metadata and grouped actions."
|
||||||
|
leading={
|
||||||
|
<Button aria-label="Back" size="icon-sm" type="button" variant="outline">
|
||||||
|
<ArrowLeftIcon className="size-4" />
|
||||||
|
</Button>
|
||||||
|
}
|
||||||
|
metadata={
|
||||||
|
<>
|
||||||
|
<Badge variant="outline">Draft</Badge>
|
||||||
|
<span>Last updated 5 minutes ago</span>
|
||||||
|
</>
|
||||||
|
}
|
||||||
|
title="Invoice editor"
|
||||||
|
/>
|
||||||
|
</SurfaceExample>
|
||||||
|
</ExampleCard>
|
||||||
|
|
||||||
|
<ExampleCard title="Long content and narrow viewport simulation">
|
||||||
|
<div className="max-w-sm rounded-lg border border-border bg-background p-4">
|
||||||
|
<RdxPageHeader
|
||||||
|
actions={<Button type="button">Primary action</Button>}
|
||||||
|
description="A long description checks wrapping behavior with translated text and narrow widths."
|
||||||
|
metadata={<span>Metadata that can also wrap if necessary</span>}
|
||||||
|
title="Very long page title intended to validate wrapping in a constrained viewport"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</ExampleCard>
|
||||||
|
</div>
|
||||||
|
</UiKitSection>
|
||||||
|
|
||||||
|
<Separator />
|
||||||
|
|
||||||
|
<UiKitSection
|
||||||
|
description="`RdxPageActions` agrupa regiones secundarias, primaria y overflow sin conocer permisos ni lógica."
|
||||||
|
id="page-actions"
|
||||||
|
title="Page actions"
|
||||||
|
>
|
||||||
|
<div className="grid gap-4 lg:grid-cols-2">
|
||||||
|
<ExampleCard title="Primary only">
|
||||||
|
<SurfaceExample>
|
||||||
|
<RdxPageActions primary={<Button type="button">Create customer</Button>} />
|
||||||
|
</SurfaceExample>
|
||||||
|
</ExampleCard>
|
||||||
|
|
||||||
|
<ExampleCard title="Primary and secondary">
|
||||||
|
<SurfaceExample>
|
||||||
|
<RdxPageActions
|
||||||
|
primary={<Button type="button">Save</Button>}
|
||||||
|
secondary={<Button type="button" variant="outline">Cancel</Button>}
|
||||||
|
/>
|
||||||
|
</SurfaceExample>
|
||||||
|
</ExampleCard>
|
||||||
|
|
||||||
|
<ExampleCard title="Primary, secondary and overflow">
|
||||||
|
<SurfaceExample>
|
||||||
|
<RdxPageActions
|
||||||
|
overflow={<OverflowMenuExample />}
|
||||||
|
primary={<Button type="button">Approve</Button>}
|
||||||
|
secondary={<Button type="button" variant="outline">Back to list</Button>}
|
||||||
|
/>
|
||||||
|
</SurfaceExample>
|
||||||
|
</ExampleCard>
|
||||||
|
|
||||||
|
<ExampleCard title="Wrapping">
|
||||||
|
<div className="max-w-sm rounded-lg border border-border bg-background p-4">
|
||||||
|
<RdxPageActions
|
||||||
|
overflow={<OverflowMenuExample />}
|
||||||
|
primary={<Button type="button">Primary action with long label</Button>}
|
||||||
|
secondary={
|
||||||
|
<>
|
||||||
|
<Button type="button" variant="outline">Secondary one</Button>
|
||||||
|
<Button type="button" variant="outline">Secondary two</Button>
|
||||||
|
</>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</ExampleCard>
|
||||||
|
</div>
|
||||||
|
</UiKitSection>
|
||||||
|
|
||||||
|
<Separator />
|
||||||
|
|
||||||
|
<UiKitSection
|
||||||
|
description="Composición equivalente a la adoptada en `proformas/list`, usando solo primitives Rdx y bloques ficticios."
|
||||||
|
id="real-list-page-composition"
|
||||||
|
title="Real list-page composition"
|
||||||
|
>
|
||||||
|
<ExampleCard
|
||||||
|
notes="Representa `RdxPageContainer width="full"`, `RdxPageStack spacing="default"`, `RdxPageHeader`, acción primaria, aviso opcional y superficie de listado."
|
||||||
|
title="Pilot integration"
|
||||||
|
>
|
||||||
|
<div className="rounded-lg border border-border bg-muted/30">
|
||||||
|
<RdxPageContainer width="full">
|
||||||
|
<RdxPageStack spacing="default">
|
||||||
|
<RdxPageHeader
|
||||||
|
actions={
|
||||||
|
<RdxPageActions
|
||||||
|
primary={
|
||||||
|
<Button type="button">
|
||||||
|
<PlusIcon className="size-4" />
|
||||||
|
Nueva proforma
|
||||||
|
</Button>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
description="Gestiona y consulta las proformas de clientes."
|
||||||
|
title="Proformas"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div className="rounded-lg border border-border bg-background px-4 py-3 text-sm text-muted-foreground">
|
||||||
|
No se ha podido refrescar la lista. Puedes seguir trabajando con los datos
|
||||||
|
cargados.
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="rounded-lg border border-border bg-background p-4">
|
||||||
|
<div className="flex flex-col gap-4">
|
||||||
|
<div className="h-10 rounded-md border border-dashed border-border bg-muted/30" />
|
||||||
|
<div className="h-9 rounded-md border border-dashed border-border bg-muted/30" />
|
||||||
|
<div className="h-72 rounded-md border border-dashed border-border bg-muted/30" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</RdxPageStack>
|
||||||
|
</RdxPageContainer>
|
||||||
|
</div>
|
||||||
|
</ExampleCard>
|
||||||
|
</UiKitSection>
|
||||||
|
|
||||||
|
<Separator />
|
||||||
|
|
||||||
|
<UiKitSection
|
||||||
|
description="Composición manual para validar un listado con panel contextual opcional, sin convertirla todavía en componente público."
|
||||||
|
id="list-with-context-panel"
|
||||||
|
title="List with context panel"
|
||||||
|
>
|
||||||
|
<ExampleCard
|
||||||
|
notes="Representa un listado con `ResizablePanelGroup` o composición equivalente desde la Page, manteniendo las foundations solo en la capa exterior."
|
||||||
|
title="Manual main and aside composition"
|
||||||
|
>
|
||||||
|
<div className="rounded-lg border border-border bg-muted/30">
|
||||||
|
<RdxPageContainer width="full">
|
||||||
|
<RdxPageStack spacing="default">
|
||||||
|
<RdxPageHeader
|
||||||
|
actions={
|
||||||
|
<RdxPageActions
|
||||||
|
primary={
|
||||||
|
<Button type="button">
|
||||||
|
<PlusIcon className="size-4" />
|
||||||
|
Nuevo cliente
|
||||||
|
</Button>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
description="Listado con panel contextual opcional."
|
||||||
|
title="Clientes"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div className="grid gap-4 xl:grid-cols-[minmax(0,1fr)_320px]">
|
||||||
|
<div className="rounded-lg border border-border bg-background p-4">
|
||||||
|
<div className="flex flex-col gap-4">
|
||||||
|
<div className="h-10 rounded-md border border-dashed border-border bg-muted/30" />
|
||||||
|
<div className="h-72 rounded-md border border-dashed border-border bg-muted/30" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="rounded-lg border border-border bg-background p-4 shadow-sm">
|
||||||
|
<div className="flex items-start justify-between gap-3 border-b border-border pb-3">
|
||||||
|
<div className="space-y-1">
|
||||||
|
<div className="text-sm font-semibold text-foreground">Ficha de cliente</div>
|
||||||
|
<div className="text-xs text-muted-foreground">
|
||||||
|
Panel contextual opcional
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<Button aria-label="Cerrar panel" size="icon-sm" type="button" variant="ghost">
|
||||||
|
<XIcon className="size-4" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="mt-4 space-y-3">
|
||||||
|
<div className="h-4 rounded bg-muted/40" />
|
||||||
|
<div className="h-4 rounded bg-muted/40" />
|
||||||
|
<div className="h-24 rounded bg-muted/40" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</RdxPageStack>
|
||||||
|
</RdxPageContainer>
|
||||||
|
</div>
|
||||||
|
</ExampleCard>
|
||||||
|
</UiKitSection>
|
||||||
|
|
||||||
|
<Separator />
|
||||||
|
|
||||||
|
<UiKitSection
|
||||||
|
description="Composición visual contextual construida sobre `RightPanel`, sin conocer dominio ni lógica."
|
||||||
|
id="rdx-context-panel"
|
||||||
|
title="RdxContextPanel"
|
||||||
|
>
|
||||||
|
<div className="grid gap-4">
|
||||||
|
<ExampleCard title="Title only">
|
||||||
|
<div className="max-w-sm">
|
||||||
|
<RightPanel open>
|
||||||
|
<RdxContextPanel title="Panel title">
|
||||||
|
<div className="p-4 text-sm text-muted-foreground">Body content</div>
|
||||||
|
</RdxContextPanel>
|
||||||
|
</RightPanel>
|
||||||
|
</div>
|
||||||
|
</ExampleCard>
|
||||||
|
|
||||||
|
<ExampleCard title="Title, description, action and close">
|
||||||
|
<div className="max-w-sm">
|
||||||
|
<RightPanel open>
|
||||||
|
<RdxContextPanel
|
||||||
|
actions={
|
||||||
|
<Button aria-label="Edit item" size="icon-sm" type="button" variant="ghost">
|
||||||
|
<PlusIcon className="size-4" />
|
||||||
|
</Button>
|
||||||
|
}
|
||||||
|
closeLabel="Cerrar panel"
|
||||||
|
description="Secondary context that explains the panel purpose."
|
||||||
|
onClose={() => undefined}
|
||||||
|
title="Context panel"
|
||||||
|
>
|
||||||
|
<div className="space-y-3 p-4 text-sm text-muted-foreground">
|
||||||
|
<p>Long body content should scroll in the correct layer.</p>
|
||||||
|
<div className="h-32 rounded bg-muted/40" />
|
||||||
|
</div>
|
||||||
|
</RdxContextPanel>
|
||||||
|
</RightPanel>
|
||||||
|
</div>
|
||||||
|
</ExampleCard>
|
||||||
|
|
||||||
|
<ExampleCard title="Footer and long title">
|
||||||
|
<div className="max-w-sm">
|
||||||
|
<RightPanel open>
|
||||||
|
<RdxContextPanel
|
||||||
|
closeLabel="Cerrar panel"
|
||||||
|
footer={
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<Button className="flex-1" type="button" variant="outline">
|
||||||
|
Secondary
|
||||||
|
</Button>
|
||||||
|
<Button className="flex-1" type="button">
|
||||||
|
Primary
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
onClose={() => undefined}
|
||||||
|
title="Very long contextual panel title intended to validate wrapping behavior"
|
||||||
|
>
|
||||||
|
<div className="space-y-3 p-4 text-sm text-muted-foreground">
|
||||||
|
<p>Example without domain dependencies.</p>
|
||||||
|
<div className="h-24 rounded bg-muted/40" />
|
||||||
|
</div>
|
||||||
|
</RdxContextPanel>
|
||||||
|
</RightPanel>
|
||||||
|
</div>
|
||||||
|
</ExampleCard>
|
||||||
|
</div>
|
||||||
|
</UiKitSection>
|
||||||
|
|
||||||
|
<Separator />
|
||||||
|
|
||||||
|
<UiKitSection
|
||||||
|
description="`RdxRowActions` normaliza icon buttons visibles, overflow, acciones destructivas y disabled reasons sin conocer permisos ni dominio."
|
||||||
|
id="rdx-row-actions"
|
||||||
|
title="RdxRowActions"
|
||||||
|
>
|
||||||
|
<div className="grid gap-4 lg:grid-cols-2">
|
||||||
|
<ExampleCard title="One visible action">
|
||||||
|
<SurfaceExample>
|
||||||
|
<RdxRowActions
|
||||||
|
overflowActions={[]}
|
||||||
|
overflowLabel="More actions"
|
||||||
|
primaryActions={[
|
||||||
|
{
|
||||||
|
id: "view",
|
||||||
|
label: "View",
|
||||||
|
icon: <ArrowLeftIcon className="size-4 rotate-180" />,
|
||||||
|
onSelect: () => undefined,
|
||||||
|
},
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
</SurfaceExample>
|
||||||
|
</ExampleCard>
|
||||||
|
|
||||||
|
<ExampleCard title="View and edit">
|
||||||
|
<SurfaceExample>
|
||||||
|
<RdxRowActions
|
||||||
|
overflowActions={[]}
|
||||||
|
overflowLabel="More actions"
|
||||||
|
primaryActions={[
|
||||||
|
{
|
||||||
|
id: "view",
|
||||||
|
label: "View",
|
||||||
|
icon: <ArrowLeftIcon className="size-4 rotate-180" />,
|
||||||
|
onSelect: () => undefined,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "edit",
|
||||||
|
label: "Edit",
|
||||||
|
icon: <PlusIcon className="size-4" />,
|
||||||
|
onSelect: () => undefined,
|
||||||
|
},
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
</SurfaceExample>
|
||||||
|
</ExampleCard>
|
||||||
|
|
||||||
|
<ExampleCard title="Overflow and destructive action">
|
||||||
|
<SurfaceExample>
|
||||||
|
<RdxRowActions
|
||||||
|
overflowActions={[
|
||||||
|
{
|
||||||
|
id: "archive",
|
||||||
|
label: "Archive",
|
||||||
|
icon: <MoreHorizontalIcon className="size-4" />,
|
||||||
|
onSelect: () => undefined,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "delete",
|
||||||
|
label: "Delete item",
|
||||||
|
icon: <XIcon className="size-4" />,
|
||||||
|
onSelect: () => undefined,
|
||||||
|
destructive: true,
|
||||||
|
},
|
||||||
|
]}
|
||||||
|
overflowLabel="More actions"
|
||||||
|
primaryActions={[
|
||||||
|
{
|
||||||
|
id: "edit",
|
||||||
|
label: "Edit",
|
||||||
|
icon: <PlusIcon className="size-4" />,
|
||||||
|
onSelect: () => undefined,
|
||||||
|
},
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
</SurfaceExample>
|
||||||
|
</ExampleCard>
|
||||||
|
|
||||||
|
<ExampleCard title="Disabled and disabled reason">
|
||||||
|
<SurfaceExample>
|
||||||
|
<RdxRowActions
|
||||||
|
overflowActions={[
|
||||||
|
{
|
||||||
|
id: "delete",
|
||||||
|
label: "Delete",
|
||||||
|
icon: <XIcon className="size-4" />,
|
||||||
|
onSelect: () => undefined,
|
||||||
|
destructive: true,
|
||||||
|
disabled: true,
|
||||||
|
disabledReason: "Only drafts can be deleted",
|
||||||
|
},
|
||||||
|
]}
|
||||||
|
overflowLabel="More actions"
|
||||||
|
primaryActions={[
|
||||||
|
{
|
||||||
|
id: "edit",
|
||||||
|
label: "Edit with long label for wrapping checks",
|
||||||
|
icon: <PlusIcon className="size-4" />,
|
||||||
|
onSelect: () => undefined,
|
||||||
|
disabled: true,
|
||||||
|
disabledReason: "Editing is temporarily unavailable",
|
||||||
|
},
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
</SurfaceExample>
|
||||||
|
</ExampleCard>
|
||||||
|
</div>
|
||||||
|
</UiKitSection>
|
||||||
|
|
||||||
|
<Separator />
|
||||||
|
|
||||||
|
<UiKitSection
|
||||||
|
description="Foundations iniciales del editor simple validadas con `customers/update` y `customers/create`, manteniendo el header legacy fuera de `rdx-ui`."
|
||||||
|
id="editor-foundations"
|
||||||
|
title="Editor foundations"
|
||||||
|
>
|
||||||
|
<div className="grid gap-4">
|
||||||
|
<ExampleCard title="RdxEditorPageLayout simple">
|
||||||
|
<div className="rounded-lg border border-border bg-muted/30">
|
||||||
|
<RdxEditorPageLayout complexity="simple">
|
||||||
|
<div className="rounded-lg border border-dashed border-border bg-background p-4 text-sm text-foreground">
|
||||||
|
Centered editor content
|
||||||
|
</div>
|
||||||
|
</RdxEditorPageLayout>
|
||||||
|
</div>
|
||||||
|
</ExampleCard>
|
||||||
|
|
||||||
|
<ExampleCard title="RdxEditorSection plain and panel">
|
||||||
|
<div className="grid gap-4 lg:grid-cols-2">
|
||||||
|
<SurfaceExample>
|
||||||
|
<RdxEditorSection
|
||||||
|
description="Default section without card."
|
||||||
|
icon={<PlusIcon className="size-4" />}
|
||||||
|
title="Plain section"
|
||||||
|
>
|
||||||
|
<div className="text-sm text-muted-foreground">Section body</div>
|
||||||
|
</RdxEditorSection>
|
||||||
|
</SurfaceExample>
|
||||||
|
|
||||||
|
<SurfaceExample>
|
||||||
|
<RdxEditorSection
|
||||||
|
description="Independent surface reserved for exceptional blocks."
|
||||||
|
title="Panel section"
|
||||||
|
variant="panel"
|
||||||
|
>
|
||||||
|
<div className="text-sm text-muted-foreground">Panel body</div>
|
||||||
|
</RdxEditorSection>
|
||||||
|
</SurfaceExample>
|
||||||
|
</div>
|
||||||
|
</ExampleCard>
|
||||||
|
|
||||||
|
<ExampleCard title="RdxFormGrid">
|
||||||
|
<SurfaceExample>
|
||||||
|
<RdxFormGrid className="md:grid-cols-2">
|
||||||
|
<div className="rounded-md border border-dashed border-border bg-muted/30 p-3 text-sm">
|
||||||
|
Field A
|
||||||
|
</div>
|
||||||
|
<div className="rounded-md border border-dashed border-border bg-muted/30 p-3 text-sm">
|
||||||
|
Field B
|
||||||
|
</div>
|
||||||
|
<div className="rounded-md border border-dashed border-border bg-muted/30 p-3 text-sm md:col-span-2">
|
||||||
|
Wide field
|
||||||
|
</div>
|
||||||
|
</RdxFormGrid>
|
||||||
|
</SurfaceExample>
|
||||||
|
</ExampleCard>
|
||||||
|
|
||||||
|
<ExampleCard
|
||||||
|
notes="La misma composición ya cubre create y update; cambian defaults y wiring, no la geometría visual."
|
||||||
|
title="Simple editor composition"
|
||||||
|
>
|
||||||
|
<div className="rounded-lg border border-border bg-muted/30">
|
||||||
|
<RdxEditorPageLayout complexity="simple">
|
||||||
|
<div className="rounded-2xl border border-border/60 bg-background px-4 py-4 shadow-xs md:px-6 md:py-6">
|
||||||
|
<div className="flex min-w-0 flex-col gap-6">
|
||||||
|
<div className="rounded-lg border border-border bg-background px-4 py-3 text-sm text-muted-foreground">
|
||||||
|
Save error aligned with editor width
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex min-w-0 flex-col gap-6">
|
||||||
|
<RdxEditorSection
|
||||||
|
description="Primary customer information"
|
||||||
|
icon={<PlusIcon className="size-4" />}
|
||||||
|
title="Basic information"
|
||||||
|
>
|
||||||
|
<RdxFormGrid className="md:grid-cols-2">
|
||||||
|
<div className="rounded-md border border-dashed border-border bg-muted/30 p-3 text-sm">
|
||||||
|
Name field
|
||||||
|
</div>
|
||||||
|
<div className="rounded-md border border-dashed border-border bg-muted/30 p-3 text-sm">
|
||||||
|
Tax ID field
|
||||||
|
</div>
|
||||||
|
</RdxFormGrid>
|
||||||
|
</RdxEditorSection>
|
||||||
|
|
||||||
|
<RdxEditorSection
|
||||||
|
description="Secondary preferences"
|
||||||
|
icon={<PlusIcon className="size-4" />}
|
||||||
|
title="Preferences"
|
||||||
|
>
|
||||||
|
<RdxFormGrid className="md:grid-cols-2">
|
||||||
|
<div className="rounded-md border border-dashed border-border bg-muted/30 p-3 text-sm">
|
||||||
|
Language field
|
||||||
|
</div>
|
||||||
|
<div className="rounded-md border border-dashed border-border bg-muted/30 p-3 text-sm">
|
||||||
|
Currency field
|
||||||
|
</div>
|
||||||
|
</RdxFormGrid>
|
||||||
|
</RdxEditorSection>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</RdxEditorPageLayout>
|
||||||
|
</div>
|
||||||
|
</ExampleCard>
|
||||||
|
|
||||||
|
<ExampleCard
|
||||||
|
notes="Evidencia conceptual del piloto real en `proformas/update`: `main + aside`, una única superficie editable y footer sticky combinado."
|
||||||
|
title="Complex editor composition"
|
||||||
|
>
|
||||||
|
<div className="rounded-lg border border-border bg-muted/30">
|
||||||
|
<div className="flex min-h-[46rem] flex-col overflow-hidden rounded-lg">
|
||||||
|
<div className="border-b border-border bg-background px-4 py-4">
|
||||||
|
<div className="flex items-center justify-between gap-4">
|
||||||
|
<div className="space-y-1">
|
||||||
|
<div className="text-lg font-semibold text-foreground">Edit proforma</div>
|
||||||
|
<div className="text-sm text-muted-foreground">
|
||||||
|
Sticky header remains outside `rdx-ui`.
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<Badge variant="outline">Modified</Badge>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex min-h-0 flex-1 flex-col bg-muted/20">
|
||||||
|
<div className="min-h-0 flex-1 overflow-y-auto">
|
||||||
|
<RdxEditorPageLayout
|
||||||
|
aside={
|
||||||
|
<div className="flex min-w-0 flex-col gap-6">
|
||||||
|
<RdxEditorSection
|
||||||
|
description="Secondary recipient controls."
|
||||||
|
title="Recipient"
|
||||||
|
>
|
||||||
|
<div className="rounded-md border border-dashed border-border bg-muted/30 p-4 text-sm text-muted-foreground">
|
||||||
|
Customer selector and helper actions
|
||||||
|
</div>
|
||||||
|
</RdxEditorSection>
|
||||||
|
|
||||||
|
<RdxEditorSection
|
||||||
|
description="Fiscal defaults and toggles."
|
||||||
|
title="Tax settings"
|
||||||
|
>
|
||||||
|
<RdxFormGrid>
|
||||||
|
<div className="rounded-md border border-dashed border-border bg-muted/30 p-3 text-sm md:col-span-12">
|
||||||
|
Tax regime field
|
||||||
|
</div>
|
||||||
|
<div className="rounded-md border border-dashed border-border bg-muted/30 p-3 text-sm md:col-span-12">
|
||||||
|
Default tax selector
|
||||||
|
</div>
|
||||||
|
</RdxFormGrid>
|
||||||
|
</RdxEditorSection>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
complexity="complex"
|
||||||
|
>
|
||||||
|
<div className="rounded-2xl border border-border bg-background px-4 py-4 shadow-xs md:px-6 md:py-6">
|
||||||
|
<div className="flex min-w-0 flex-col gap-6">
|
||||||
|
<div className="rounded-lg border border-border bg-background px-4 py-3 text-sm text-muted-foreground">
|
||||||
|
Inline feedback aligned with the editor content
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<RdxEditorSection title="Document information">
|
||||||
|
<RdxFormGrid>
|
||||||
|
<div className="rounded-md border border-dashed border-border bg-muted/30 p-3 text-sm md:col-span-4">
|
||||||
|
Reference field
|
||||||
|
</div>
|
||||||
|
<div className="rounded-md border border-dashed border-border bg-muted/30 p-3 text-sm md:col-span-4">
|
||||||
|
Date field
|
||||||
|
</div>
|
||||||
|
<div className="rounded-md border border-dashed border-border bg-muted/30 p-3 text-sm md:col-span-4">
|
||||||
|
Series field
|
||||||
|
</div>
|
||||||
|
</RdxFormGrid>
|
||||||
|
</RdxEditorSection>
|
||||||
|
|
||||||
|
<section className="min-w-0 border-b border-border pb-6">
|
||||||
|
<div className="rounded-md border border-dashed border-border bg-muted/30 p-4 text-sm text-muted-foreground">
|
||||||
|
Line editor keeps its own internal table layout and stays in the
|
||||||
|
main surface.
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</RdxEditorPageLayout>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<RdxEditorStickyFooter
|
||||||
|
leading={
|
||||||
|
<div className="flex min-w-0 flex-wrap items-center gap-x-5 gap-y-2 text-sm">
|
||||||
|
<span className="text-muted-foreground">Sticky totals region</span>
|
||||||
|
<span className="tabular-nums">Subtotal 2,350.00</span>
|
||||||
|
<span className="tabular-nums">Impuestos 493.50</span>
|
||||||
|
<span className="font-semibold tabular-nums text-foreground">
|
||||||
|
Total 2,843.50
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
primary={<Button type="button">Guardar</Button>}
|
||||||
|
secondary={<Button type="button" variant="outline">Cancelar</Button>}
|
||||||
|
status="Modificada"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</ExampleCard>
|
||||||
|
</div>
|
||||||
|
</UiKitSection>
|
||||||
|
|
||||||
|
<Separator />
|
||||||
|
|
||||||
|
<UiKitSection
|
||||||
|
description="Composiciones estáticas para validar jerarquía y anchura sin crear todavía layouts CRUD genéricos."
|
||||||
|
id="theme-compatibility"
|
||||||
|
title="Light/dark compatibility"
|
||||||
|
>
|
||||||
|
<div className="grid gap-4 xl:grid-cols-2">
|
||||||
|
<ExampleCard title="List composition">
|
||||||
|
<div className="rounded-lg border border-border bg-muted/30">
|
||||||
|
<RdxPageContainer width="full">
|
||||||
|
<RdxPageStack>
|
||||||
|
<RdxPageHeader
|
||||||
|
actions={<RdxPageActions primary={<Button type="button">New item</Button>} />}
|
||||||
|
description="Static list example"
|
||||||
|
title="List page"
|
||||||
|
/>
|
||||||
|
<SurfaceExample>List content</SurfaceExample>
|
||||||
|
</RdxPageStack>
|
||||||
|
</RdxPageContainer>
|
||||||
|
</div>
|
||||||
|
</ExampleCard>
|
||||||
|
|
||||||
|
<ExampleCard title="Simple editor and detail width">
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div className="rounded-lg border border-border bg-muted/30">
|
||||||
|
<RdxPageContainer width="content">
|
||||||
|
<RdxPageStack>
|
||||||
|
<RdxPageHeader
|
||||||
|
description="Simple centered editor"
|
||||||
|
title="Editor page"
|
||||||
|
/>
|
||||||
|
<SurfaceExample>Form content</SurfaceExample>
|
||||||
|
</RdxPageStack>
|
||||||
|
</RdxPageContainer>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="rounded-lg border border-border bg-muted/30">
|
||||||
|
<RdxPageContainer width="wide">
|
||||||
|
<RdxPageStack>
|
||||||
|
<RdxPageHeader
|
||||||
|
description="Wide detail composition"
|
||||||
|
title="Detail page"
|
||||||
|
/>
|
||||||
|
<SurfaceExample>Detail content</SurfaceExample>
|
||||||
|
</RdxPageStack>
|
||||||
|
</RdxPageContainer>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</ExampleCard>
|
||||||
|
</div>
|
||||||
|
</UiKitSection>
|
||||||
|
</RdxPageStack>
|
||||||
|
</RdxPageContainer>
|
||||||
|
);
|
||||||
|
}
|
||||||
@ -12,7 +12,7 @@ const rootElement = document.getElementById("factuges");
|
|||||||
if (rootElement) {
|
if (rootElement) {
|
||||||
createRoot(rootElement).render(
|
createRoot(rootElement).render(
|
||||||
<StrictMode>
|
<StrictMode>
|
||||||
<ThemeProvider defaultTheme="light" storageKey="vite-ui-theme">
|
<ThemeProvider defaultTheme="system" storageKey="vite-ui-theme">
|
||||||
<>
|
<>
|
||||||
<App />
|
<App />
|
||||||
<TailwindIndicator />
|
<TailwindIndicator />
|
||||||
|
|||||||
@ -11,6 +11,7 @@ import {
|
|||||||
AuthLayout,
|
AuthLayout,
|
||||||
AuthLayoutFrame,
|
AuthLayoutFrame,
|
||||||
} from "@/layout";
|
} from "@/layout";
|
||||||
|
import { UiKitPage } from "@/development/ui-kit";
|
||||||
import ShadcnShowcasePage from "@/pages/shadcn-ui-page";
|
import ShadcnShowcasePage from "@/pages/shadcn-ui-page";
|
||||||
import TailwindV4ShowcasePage from "@/pages/tailwindcss-page";
|
import TailwindV4ShowcasePage from "@/pages/tailwindcss-page";
|
||||||
|
|
||||||
@ -58,6 +59,15 @@ export const getAppRouter = () => {
|
|||||||
layout: "app-fullscreen",
|
layout: "app-fullscreen",
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const developmentRoutes = import.meta.env.DEV
|
||||||
|
? [
|
||||||
|
{
|
||||||
|
path: "development/ui-kit",
|
||||||
|
element: <UiKitPage />,
|
||||||
|
},
|
||||||
|
]
|
||||||
|
: [];
|
||||||
|
|
||||||
const appRoutes = createBrowserRouter([
|
const appRoutes = createBrowserRouter([
|
||||||
{
|
{
|
||||||
path: "/",
|
path: "/",
|
||||||
@ -121,6 +131,7 @@ export const getAppRouter = () => {
|
|||||||
path: "quotes",
|
path: "quotes",
|
||||||
element: <StaticRouteErrorPage variant="notFound" />,
|
element: <StaticRouteErrorPage variant="notFound" />,
|
||||||
},
|
},
|
||||||
|
...developmentRoutes,
|
||||||
|
|
||||||
...appSidebarRoutes,
|
...appSidebarRoutes,
|
||||||
],
|
],
|
||||||
|
|||||||
@ -12,16 +12,19 @@ export const ProformaFooterActions = ({
|
|||||||
onEdit?: (proforma: Proforma) => void;
|
onEdit?: (proforma: Proforma) => void;
|
||||||
}) => {
|
}) => {
|
||||||
return (
|
return (
|
||||||
<div className="border-t p-4">
|
<div className="flex gap-2">
|
||||||
<div className="flex gap-2">
|
<Button
|
||||||
<Button className="flex-1" onClick={() => onChangeStatus?.(proforma)} variant="outline">
|
className="flex-1"
|
||||||
Cambiar de estado
|
onClick={() => onChangeStatus?.(proforma)}
|
||||||
</Button>
|
type="button"
|
||||||
|
variant="outline"
|
||||||
|
>
|
||||||
|
Cambiar de estado
|
||||||
|
</Button>
|
||||||
|
|
||||||
<Button className="flex-1" onClick={() => onEdit?.(proforma)}>
|
<Button className="flex-1" onClick={() => onEdit?.(proforma)} type="button">
|
||||||
Editar proforma
|
Editar proforma
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@ -21,7 +21,7 @@ export const ProformaHeader = ({ proforma }: { proforma: Proforma }) => {
|
|||||||
|
|
||||||
<div className="min-w-0 flex-1">
|
<div className="min-w-0 flex-1">
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<h2 className="truncate text-lg font-semibold">{proforma.recipient.name}</h2>
|
<h3 className="truncate text-lg font-semibold">{proforma.recipient.name}</h3>
|
||||||
<ProformaStatusBadge status={proforma.status} />
|
<ProformaStatusBadge status={proforma.status} />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@ -2,27 +2,18 @@ import { Separator } from "@repo/shadcn-ui/components";
|
|||||||
|
|
||||||
import type { Proforma } from "../../../../shared";
|
import type { Proforma } from "../../../../shared";
|
||||||
|
|
||||||
import { ProformaFooterActions } from "./proforma-footer-actions";
|
|
||||||
import { ProformaHeader } from "./proforma-header";
|
import { ProformaHeader } from "./proforma-header";
|
||||||
|
|
||||||
interface ProformaSummaryContentProps {
|
interface ProformaSummaryContentProps {
|
||||||
proforma: Proforma;
|
proforma: Proforma;
|
||||||
onEdit?: (proforma: Proforma) => void;
|
|
||||||
onChangeStatus?: (proforma: Proforma) => void;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export const ProformaSummaryContent = ({
|
export const ProformaSummaryContent = ({ proforma }: ProformaSummaryContentProps) => {
|
||||||
proforma,
|
|
||||||
onEdit,
|
|
||||||
onChangeStatus,
|
|
||||||
}: ProformaSummaryContentProps) => {
|
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col">
|
<div className="flex flex-col">
|
||||||
<ProformaHeader proforma={proforma} />
|
<ProformaHeader proforma={proforma} />
|
||||||
|
|
||||||
<Separator />
|
<Separator />
|
||||||
|
|
||||||
<ProformaFooterActions onChangeStatus={onChangeStatus} onEdit={onEdit} proforma={proforma} />
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@ -0,0 +1,155 @@
|
|||||||
|
import assert from "node:assert/strict";
|
||||||
|
import test from "node:test";
|
||||||
|
import { isValidElement } from "react";
|
||||||
|
import { renderToStaticMarkup } from "react-dom/server";
|
||||||
|
|
||||||
|
import { PROFORMA_STATUS, type Proforma } from "../../../../shared";
|
||||||
|
import { ProformaSummaryPanel } from "./proforma-summary-panel";
|
||||||
|
|
||||||
|
const mockProforma: Proforma = {
|
||||||
|
id: "proforma-1",
|
||||||
|
companyId: "company-1",
|
||||||
|
proformaReference: "PF-2026-0001",
|
||||||
|
status: PROFORMA_STATUS.DRAFT,
|
||||||
|
archivedAt: null,
|
||||||
|
isArchived: false,
|
||||||
|
targetInvoiceSeriesCode: null,
|
||||||
|
series: "PF",
|
||||||
|
proformaDate: "2026-08-06",
|
||||||
|
operationDate: null,
|
||||||
|
reference: null,
|
||||||
|
description: "Servicio de consultoria",
|
||||||
|
notes: null,
|
||||||
|
languageCode: "es",
|
||||||
|
currencyCode: "EUR",
|
||||||
|
customerId: "customer-1",
|
||||||
|
recipient: {
|
||||||
|
id: "customer-1",
|
||||||
|
name: "Acme Corp",
|
||||||
|
tin: "B12345678",
|
||||||
|
street: "Main street",
|
||||||
|
street2: null,
|
||||||
|
city: "Madrid",
|
||||||
|
province: "Madrid",
|
||||||
|
postalCode: "28001",
|
||||||
|
country: "ES",
|
||||||
|
},
|
||||||
|
taxRegimeCode: null,
|
||||||
|
taxConfig: {
|
||||||
|
taxMode: "single",
|
||||||
|
defaultIvaCode: null,
|
||||||
|
usesEquivalenceSurcharge: false,
|
||||||
|
defaultRecCode: null,
|
||||||
|
usesRetention: false,
|
||||||
|
defaultRetentionCode: null,
|
||||||
|
},
|
||||||
|
taxes: [],
|
||||||
|
paymentMethodId: null,
|
||||||
|
paymentTermId: null,
|
||||||
|
subtotalAmount: 100,
|
||||||
|
itemsDiscountAmount: 0,
|
||||||
|
globalDiscountPercentage: 0,
|
||||||
|
totalDiscountAmount: 0,
|
||||||
|
taxableAmount: 100,
|
||||||
|
ivaAmount: 21,
|
||||||
|
recAmount: 0,
|
||||||
|
retentionAmount: 0,
|
||||||
|
taxesAmount: 21,
|
||||||
|
totalAmount: 121,
|
||||||
|
linkedInvoiceId: null,
|
||||||
|
items: [],
|
||||||
|
};
|
||||||
|
|
||||||
|
const resolveElement = (node: unknown): unknown => {
|
||||||
|
if (Array.isArray(node)) {
|
||||||
|
return node.map(resolveElement);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!isValidElement(node)) {
|
||||||
|
return node;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (typeof node.type === "function") {
|
||||||
|
return resolveElement(node.type(node.props));
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
type: node.type,
|
||||||
|
props: {
|
||||||
|
...node.props,
|
||||||
|
children: resolveElement(node.props.children),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
const findButtons = (node: unknown): Array<Record<string, unknown>> => {
|
||||||
|
if (Array.isArray(node)) {
|
||||||
|
return node.flatMap(findButtons);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!node || typeof node !== "object" || !("type" in node) || !("props" in node)) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
const element = node as { type: string; props: Record<string, unknown> };
|
||||||
|
const children = findButtons(element.props.children);
|
||||||
|
|
||||||
|
if (element.type === "button") {
|
||||||
|
return [element.props, ...children];
|
||||||
|
}
|
||||||
|
|
||||||
|
return children;
|
||||||
|
};
|
||||||
|
|
||||||
|
test("renderiza el shell común del panel de proforma y corrige el label accesible", () => {
|
||||||
|
const html = renderToStaticMarkup(
|
||||||
|
<ProformaSummaryPanel
|
||||||
|
mode="view"
|
||||||
|
onOpenChange={() => undefined}
|
||||||
|
open
|
||||||
|
proforma={mockProforma}
|
||||||
|
visibility="visible"
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
|
||||||
|
assert.match(html, /Vista previa de proforma/);
|
||||||
|
assert.match(html, /Acme Corp/);
|
||||||
|
assert.match(html, /Cambiar de estado/);
|
||||||
|
assert.match(html, /Editar proforma/);
|
||||||
|
assert.doesNotMatch(html, /aria-label="Editar cliente"/);
|
||||||
|
assert.equal((html.match(/Cerrar panel/g) ?? []).length, 1);
|
||||||
|
assert.equal((html.match(/<h2/g) ?? []).length, 1);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("mantiene los callbacks de editar y cerrar", async () => {
|
||||||
|
let didEdit = false;
|
||||||
|
let wasClosed = false;
|
||||||
|
|
||||||
|
const resolved = resolveElement(
|
||||||
|
<ProformaSummaryPanel
|
||||||
|
mode="view"
|
||||||
|
onEdit={() => {
|
||||||
|
didEdit = true;
|
||||||
|
}}
|
||||||
|
onOpenChange={(open) => {
|
||||||
|
wasClosed = open;
|
||||||
|
}}
|
||||||
|
open
|
||||||
|
proforma={mockProforma}
|
||||||
|
visibility="visible"
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
|
||||||
|
const buttons = findButtons(resolved);
|
||||||
|
const editButton = buttons.find((button) => button["aria-label"] === "Editar proforma");
|
||||||
|
const closeButton = buttons.find((button) => button["aria-label"] === "Cerrar panel");
|
||||||
|
|
||||||
|
assert.ok(editButton);
|
||||||
|
assert.ok(closeButton);
|
||||||
|
|
||||||
|
await editButton.onClick?.();
|
||||||
|
await closeButton.onClick?.();
|
||||||
|
|
||||||
|
assert.equal(didEdit, true);
|
||||||
|
assert.equal(wasClosed, false);
|
||||||
|
});
|
||||||
@ -1,4 +1,4 @@
|
|||||||
import { RightPanel } from "@repo/rdx-ui/components";
|
import { RdxContextPanel, RightPanel } from "@repo/rdx-ui/components";
|
||||||
import type { RightPanelMode, RightPanelVisibility } from "@repo/rdx-ui/hooks";
|
import type { RightPanelMode, RightPanelVisibility } from "@repo/rdx-ui/hooks";
|
||||||
import { Button } from "@repo/shadcn-ui/components";
|
import { Button } from "@repo/shadcn-ui/components";
|
||||||
import { cn } from "@repo/shadcn-ui/lib/utils";
|
import { cn } from "@repo/shadcn-ui/lib/utils";
|
||||||
@ -6,6 +6,7 @@ import { PencilIcon } from "lucide-react";
|
|||||||
|
|
||||||
import type { Proforma } from "../../../../shared";
|
import type { Proforma } from "../../../../shared";
|
||||||
|
|
||||||
|
import { ProformaFooterActions } from "./proforma-footer-actions";
|
||||||
import { ProformaSummaryContent } from "./proforma-summary-content";
|
import { ProformaSummaryContent } from "./proforma-summary-content";
|
||||||
|
|
||||||
const mockERPData = {
|
const mockERPData = {
|
||||||
@ -61,7 +62,7 @@ export const ProformaSummaryPanel = ({
|
|||||||
proforma,
|
proforma,
|
||||||
|
|
||||||
open,
|
open,
|
||||||
visibility,
|
visibility: _visibility,
|
||||||
mode,
|
mode,
|
||||||
|
|
||||||
onOpenChange,
|
onOpenChange,
|
||||||
@ -78,37 +79,42 @@ export const ProformaSummaryPanel = ({
|
|||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<RightPanel
|
<RightPanel className={cn("bg-transparent", className)} open={open}>
|
||||||
className={cn("bg-transparent", className)}
|
<RdxContextPanel
|
||||||
headerActions={
|
actions={
|
||||||
<>
|
proforma ? (
|
||||||
{proforma ? (
|
|
||||||
<Button
|
<Button
|
||||||
aria-label="Editar cliente"
|
aria-label="Editar proforma"
|
||||||
onClick={() => onEdit?.(proforma)}
|
onClick={() => onEdit?.(proforma)}
|
||||||
size="icon-sm"
|
size="icon-sm"
|
||||||
|
type="button"
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
>
|
>
|
||||||
<PencilIcon className="size-4" />
|
<PencilIcon className="size-4" />
|
||||||
</Button>
|
</Button>
|
||||||
) : null}
|
) : undefined
|
||||||
</>
|
}
|
||||||
}
|
closeLabel="Cerrar panel"
|
||||||
onOpenChange={onOpenChange}
|
footer={
|
||||||
open={open}
|
proforma ? (
|
||||||
title={titleMap[mode]}
|
<ProformaFooterActions
|
||||||
>
|
onChangeStatus={onChangeStatus}
|
||||||
{proforma ? (
|
onEdit={onEdit}
|
||||||
<ProformaSummaryContent
|
proforma={proforma}
|
||||||
onChangeStatus={onChangeStatus}
|
/>
|
||||||
onEdit={onEdit}
|
) : undefined
|
||||||
proforma={proforma}
|
}
|
||||||
/>
|
onClose={() => onOpenChange(false)}
|
||||||
) : (
|
title={titleMap[mode]}
|
||||||
<div className="flex h-full items-center justify-center p-4">
|
>
|
||||||
<p className="text-sm text-muted-foreground">Selecciona una proforma</p>
|
{proforma ? (
|
||||||
</div>
|
<ProformaSummaryContent proforma={proforma} />
|
||||||
)}
|
) : (
|
||||||
|
<div className="flex h-full items-center justify-center p-4">
|
||||||
|
<p className="text-sm text-muted-foreground">Selecciona una proforma</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</RdxContextPanel>
|
||||||
</RightPanel>
|
</RightPanel>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@ -0,0 +1,131 @@
|
|||||||
|
import assert from "node:assert/strict";
|
||||||
|
import test from "node:test";
|
||||||
|
import { isValidElement } from "react";
|
||||||
|
import { renderToStaticMarkup } from "react-dom/server";
|
||||||
|
import type { CellContext } from "@tanstack/react-table";
|
||||||
|
|
||||||
|
import { RdxRowActions, type RdxRowActionsProps } from "@repo/rdx-ui/components";
|
||||||
|
|
||||||
|
import type { ProformaListRow } from "../../../../shared";
|
||||||
|
import { PROFORMA_STATUS } from "../../../../shared";
|
||||||
|
import { useProformasGridColumns } from "./use-proforma-grid-columns";
|
||||||
|
|
||||||
|
const proformaFixture: ProformaListRow = {
|
||||||
|
id: "proforma-1",
|
||||||
|
companyId: "company-1",
|
||||||
|
proformaReference: "PF-001",
|
||||||
|
status: PROFORMA_STATUS.DRAFT,
|
||||||
|
archivedAt: null,
|
||||||
|
isArchived: false,
|
||||||
|
targetInvoiceSeriesCode: null,
|
||||||
|
series: null,
|
||||||
|
proformaDate: "2026-08-01",
|
||||||
|
operationDate: null,
|
||||||
|
languageCode: "es",
|
||||||
|
currencyCode: "EUR",
|
||||||
|
reference: null,
|
||||||
|
description: null,
|
||||||
|
recipient: {
|
||||||
|
id: "recipient-1",
|
||||||
|
name: "Acme Corp",
|
||||||
|
tin: "B12345678",
|
||||||
|
street: "Main street",
|
||||||
|
street2: null,
|
||||||
|
city: "Madrid",
|
||||||
|
province: "Madrid",
|
||||||
|
postalCode: "28001",
|
||||||
|
country: "ES",
|
||||||
|
},
|
||||||
|
subtotalAmount: 100,
|
||||||
|
subtotalAmountFmt: "100,00 €",
|
||||||
|
totalDiscountAmount: 0,
|
||||||
|
totalDiscountAmountFmt: "0,00 €",
|
||||||
|
taxableAmount: 100,
|
||||||
|
taxableAmountFmt: "100,00 €",
|
||||||
|
taxesAmount: 21,
|
||||||
|
taxesAmountFmt: "21,00 €",
|
||||||
|
totalAmount: 121,
|
||||||
|
totalAmountFmt: "121,00 €",
|
||||||
|
linkedInvoiceId: null,
|
||||||
|
};
|
||||||
|
|
||||||
|
const getRowActionsProps = (
|
||||||
|
scope: Parameters<typeof useProformasGridColumns>[0],
|
||||||
|
actionHandlers: Parameters<typeof useProformasGridColumns>[1] = {}
|
||||||
|
): RdxRowActionsProps => {
|
||||||
|
let cellNode: unknown = null;
|
||||||
|
|
||||||
|
const Harness = () => {
|
||||||
|
const columns = useProformasGridColumns(scope, actionHandlers);
|
||||||
|
const actionsColumn = columns.find((column) => column.id === "actions");
|
||||||
|
|
||||||
|
cellNode = actionsColumn?.cell?.({
|
||||||
|
row: { original: proformaFixture },
|
||||||
|
} as unknown as CellContext<ProformaListRow, unknown>);
|
||||||
|
|
||||||
|
return null;
|
||||||
|
};
|
||||||
|
|
||||||
|
renderToStaticMarkup(<Harness />);
|
||||||
|
|
||||||
|
if (!isValidElement(cellNode)) {
|
||||||
|
throw new Error("Expected RdxRowActions cell");
|
||||||
|
}
|
||||||
|
|
||||||
|
assert.equal(cellNode.type, RdxRowActions);
|
||||||
|
|
||||||
|
return cellNode.props as RdxRowActionsProps;
|
||||||
|
};
|
||||||
|
|
||||||
|
test("mantiene ver y editar como acciones visibles de referencia", () => {
|
||||||
|
const props = getRowActionsProps("normal", {
|
||||||
|
onEditClick: () => undefined,
|
||||||
|
onViewClick: () => undefined,
|
||||||
|
});
|
||||||
|
|
||||||
|
assert.deepEqual(
|
||||||
|
props.primaryActions?.map((action) => action.id),
|
||||||
|
["view", "edit"]
|
||||||
|
);
|
||||||
|
assert.ok(["Más acciones", "common.more_actions"].includes(props.overflowLabel));
|
||||||
|
});
|
||||||
|
|
||||||
|
test("mantiene las destructivas en overflow y conserva disabled", () => {
|
||||||
|
const props = getRowActionsProps("normal", {
|
||||||
|
onDeleteClick: () => undefined,
|
||||||
|
});
|
||||||
|
|
||||||
|
const deleteAction = props.overflowActions?.find((action) => action.id === "delete");
|
||||||
|
const viewAction = props.primaryActions?.find((action) => action.id === "view");
|
||||||
|
|
||||||
|
assert.ok(deleteAction);
|
||||||
|
assert.equal(deleteAction?.destructive, true);
|
||||||
|
assert.equal(deleteAction?.disabled, false);
|
||||||
|
assert.equal(viewAction?.disabled, true);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("preserva callbacks de view, edit y delete", () => {
|
||||||
|
let didView = false;
|
||||||
|
let didEdit = false;
|
||||||
|
let didDelete = false;
|
||||||
|
|
||||||
|
const props = getRowActionsProps("normal", {
|
||||||
|
onDeleteClick: () => {
|
||||||
|
didDelete = true;
|
||||||
|
},
|
||||||
|
onEditClick: () => {
|
||||||
|
didEdit = true;
|
||||||
|
},
|
||||||
|
onViewClick: () => {
|
||||||
|
didView = true;
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
props.primaryActions?.find((action) => action.id === "view")?.onSelect();
|
||||||
|
props.primaryActions?.find((action) => action.id === "edit")?.onSelect();
|
||||||
|
props.overflowActions?.find((action) => action.id === "delete")?.onSelect();
|
||||||
|
|
||||||
|
assert.equal(didView, true);
|
||||||
|
assert.equal(didEdit, true);
|
||||||
|
assert.equal(didDelete, true);
|
||||||
|
});
|
||||||
@ -1,21 +1,9 @@
|
|||||||
import { EmptyCellValue } from "@erp/core/components";
|
import { EmptyCellValue } from "@erp/core/components";
|
||||||
import { DataTableColumnHeader } from "@repo/rdx-ui/components";
|
import { DataTableColumnHeader, RdxRowActions, type RdxRowAction } from "@repo/rdx-ui/components";
|
||||||
import { DateHelper } from "@repo/rdx-utils";
|
import { DateHelper } from "@repo/rdx-utils";
|
||||||
import {
|
import {
|
||||||
Button,
|
|
||||||
Checkbox,
|
Checkbox,
|
||||||
DropdownMenu,
|
|
||||||
DropdownMenuContent,
|
|
||||||
DropdownMenuGroup,
|
|
||||||
DropdownMenuItem,
|
|
||||||
DropdownMenuLabel,
|
|
||||||
DropdownMenuSeparator,
|
|
||||||
DropdownMenuTrigger,
|
|
||||||
Spinner,
|
Spinner,
|
||||||
Tooltip,
|
|
||||||
TooltipContent,
|
|
||||||
TooltipProvider,
|
|
||||||
TooltipTrigger,
|
|
||||||
} from "@repo/shadcn-ui/components";
|
} from "@repo/shadcn-ui/components";
|
||||||
import type { ColumnDef } from "@tanstack/react-table";
|
import type { ColumnDef } from "@tanstack/react-table";
|
||||||
import {
|
import {
|
||||||
@ -25,12 +13,12 @@ import {
|
|||||||
FileDownIcon,
|
FileDownIcon,
|
||||||
FileTextIcon,
|
FileTextIcon,
|
||||||
FolderArchiveIcon,
|
FolderArchiveIcon,
|
||||||
MoreHorizontalIcon,
|
|
||||||
PencilIcon,
|
PencilIcon,
|
||||||
RefreshCwIcon,
|
RefreshCwIcon,
|
||||||
Trash2Icon,
|
Trash2Icon,
|
||||||
Undo2Icon,
|
Undo2Icon,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
|
import type { ComponentType } from "react";
|
||||||
import * as React from "react";
|
import * as React from "react";
|
||||||
|
|
||||||
import { useTranslation } from "../../../../../i18n";
|
import { useTranslation } from "../../../../../i18n";
|
||||||
@ -64,26 +52,10 @@ type GridActionHandlers = {
|
|||||||
isPdfDownloading?: boolean;
|
isPdfDownloading?: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
type RowAction = {
|
const renderActionIcon = (
|
||||||
key: string;
|
Icon: ComponentType<{ className?: string }>,
|
||||||
label: string;
|
loading?: boolean
|
||||||
icon: React.ComponentType<{ className?: string }>;
|
) => (loading ? <Spinner className="size-4" /> : <Icon className="size-4" />);
|
||||||
disabled: boolean;
|
|
||||||
disabledReason?: string;
|
|
||||||
destructive?: boolean;
|
|
||||||
loading?: boolean;
|
|
||||||
href?: string;
|
|
||||||
onClick?: () => void;
|
|
||||||
};
|
|
||||||
|
|
||||||
const stopRowPropagation = (event: React.MouseEvent | React.KeyboardEvent) => {
|
|
||||||
event.stopPropagation();
|
|
||||||
};
|
|
||||||
|
|
||||||
const stopRowPropagationAndPreventDefault = (event: React.MouseEvent | React.KeyboardEvent) => {
|
|
||||||
event.preventDefault();
|
|
||||||
event.stopPropagation();
|
|
||||||
};
|
|
||||||
|
|
||||||
export function useProformasGridColumns(
|
export function useProformasGridColumns(
|
||||||
scope: ProformaListScope,
|
scope: ProformaListScope,
|
||||||
@ -365,49 +337,51 @@ export function useProformasGridColumns(
|
|||||||
return undefined;
|
return undefined;
|
||||||
};
|
};
|
||||||
|
|
||||||
const visibleActions: RowAction[] = [
|
const visibleActions: RdxRowAction[] = [
|
||||||
{
|
{
|
||||||
key: "view",
|
id: "view",
|
||||||
label: t("pages.proformas.list.actions.view"),
|
label: t("pages.proformas.list.actions.view"),
|
||||||
icon: EyeIcon,
|
icon: renderActionIcon(EyeIcon),
|
||||||
disabled: !actionHandlers.onViewClick || isDeletedScope,
|
disabled: !actionHandlers.onViewClick || isDeletedScope,
|
||||||
disabledReason: isDeletedScope
|
disabledReason: isDeletedScope
|
||||||
? t("pages.proformas.list.actions.disabled_reasons.deleted_scope")
|
? t("pages.proformas.list.actions.disabled_reasons.deleted_scope")
|
||||||
: undefined,
|
: undefined,
|
||||||
onClick: handleView,
|
onSelect: handleView,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
key: "edit",
|
id: "edit",
|
||||||
label: t("pages.proformas.list.actions.edit"),
|
label: t("pages.proformas.list.actions.edit"),
|
||||||
icon: PencilIcon,
|
icon: renderActionIcon(PencilIcon),
|
||||||
disabled: !(actionHandlers.onEditClick && isNormalScope) || isIssued,
|
disabled: !(actionHandlers.onEditClick && isNormalScope) || isIssued,
|
||||||
disabledReason: isNormalScope
|
disabledReason: isNormalScope
|
||||||
? isIssued
|
? isIssued
|
||||||
? t("pages.proformas.list.actions.disabled_reasons.edit_issued")
|
? t("pages.proformas.list.actions.disabled_reasons.edit_issued")
|
||||||
: undefined
|
: undefined
|
||||||
: getScopeReason(),
|
: getScopeReason(),
|
||||||
onClick: handleEdit,
|
onSelect: handleEdit,
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
const menuActions: RowAction[] = [
|
const menuActions: RdxRowAction[] = [
|
||||||
{
|
{
|
||||||
key: "linked-invoice",
|
id: "linked-invoice",
|
||||||
label: t("pages.proformas.list.actions.view_linked_invoice"),
|
label: t("pages.proformas.list.actions.view_linked_invoice"),
|
||||||
icon: ExternalLinkIcon,
|
icon: renderActionIcon(ExternalLinkIcon),
|
||||||
disabled: !(isIssued && proforma.linkedInvoiceId),
|
disabled: !(isIssued && proforma.linkedInvoiceId),
|
||||||
disabledReason:
|
disabledReason:
|
||||||
isIssued && proforma.linkedInvoiceId
|
isIssued && proforma.linkedInvoiceId
|
||||||
? undefined
|
? undefined
|
||||||
: t("pages.proformas.list.actions.disabled_reasons.linked_invoice_unavailable"),
|
: t("pages.proformas.list.actions.disabled_reasons.linked_invoice_unavailable"),
|
||||||
href: proforma.linkedInvoiceId
|
onSelect: () => {
|
||||||
? "/customer-invoices"
|
if (proforma.linkedInvoiceId) {
|
||||||
: undefined,
|
window.location.href = "/customer-invoices";
|
||||||
|
}
|
||||||
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
key: "change-status",
|
id: "change-status",
|
||||||
label: t("pages.proformas.list.actions.change_status"),
|
label: t("pages.proformas.list.actions.change_status"),
|
||||||
icon: RefreshCwIcon,
|
icon: renderActionIcon(RefreshCwIcon),
|
||||||
disabled:
|
disabled:
|
||||||
!(actionHandlers.onChangeStatusClick && isNormalScope && nextTransition) ||
|
!(actionHandlers.onChangeStatusClick && isNormalScope && nextTransition) ||
|
||||||
isIssued,
|
isIssued,
|
||||||
@ -418,71 +392,70 @@ export function useProformasGridColumns(
|
|||||||
? undefined
|
? undefined
|
||||||
: t("proformas.change_proforma_status_dialog.empty_available_statuses")
|
: t("proformas.change_proforma_status_dialog.empty_available_statuses")
|
||||||
: getScopeReason(),
|
: getScopeReason(),
|
||||||
onClick: handleChangeStatus,
|
onSelect: handleChangeStatus,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
key: "issue",
|
id: "issue",
|
||||||
label: t("pages.proformas.list.actions.issue"),
|
label: t("pages.proformas.list.actions.issue"),
|
||||||
icon: FileTextIcon,
|
icon: renderActionIcon(FileTextIcon),
|
||||||
disabled: !(actionHandlers.onIssueClick && isNormalScope && isApproved) || isIssued,
|
disabled: !(actionHandlers.onIssueClick && isNormalScope && isApproved) || isIssued,
|
||||||
disabledReason: isNormalScope
|
disabledReason: isNormalScope
|
||||||
? isApproved
|
? isApproved
|
||||||
? undefined
|
? undefined
|
||||||
: t("proformas.issue_proforma_dialog.not_allowed_description")
|
: t("proformas.issue_proforma_dialog.not_allowed_description")
|
||||||
: getScopeReason(),
|
: getScopeReason(),
|
||||||
onClick: handleIssue,
|
onSelect: handleIssue,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
key: "duplicate",
|
id: "duplicate",
|
||||||
label: t("pages.proformas.list.actions.duplicate"),
|
label: t("pages.proformas.list.actions.duplicate"),
|
||||||
icon: CopyIcon,
|
icon: renderActionIcon(CopyIcon),
|
||||||
disabled: !actionHandlers.onDuplicateClick || isDeletedScope,
|
disabled: !actionHandlers.onDuplicateClick || isDeletedScope,
|
||||||
disabledReason: isDeletedScope
|
disabledReason: isDeletedScope
|
||||||
? t("pages.proformas.list.actions.disabled_reasons.deleted_scope")
|
? t("pages.proformas.list.actions.disabled_reasons.deleted_scope")
|
||||||
: undefined,
|
: undefined,
|
||||||
onClick: handleDuplicate,
|
onSelect: handleDuplicate,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
key: "archive",
|
id: "archive",
|
||||||
label: t("pages.proformas.list.actions.archive"),
|
label: t("pages.proformas.list.actions.archive"),
|
||||||
icon: FolderArchiveIcon,
|
icon: renderActionIcon(FolderArchiveIcon),
|
||||||
disabled: !(actionHandlers.onArchiveClick && isArchivable),
|
disabled: !(actionHandlers.onArchiveClick && isArchivable),
|
||||||
disabledReason: actionHandlers.onArchiveClick
|
disabledReason: actionHandlers.onArchiveClick
|
||||||
? isNormalScope
|
? isNormalScope
|
||||||
? t("proformas.archive.error_conflict_message")
|
? t("proformas.archive.error_conflict_message")
|
||||||
: getScopeReason()
|
: getScopeReason()
|
||||||
: undefined,
|
: undefined,
|
||||||
onClick: handleArchive,
|
onSelect: handleArchive,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
key: "unarchive",
|
id: "unarchive",
|
||||||
label: t("pages.proformas.list.actions.unarchive"),
|
label: t("pages.proformas.list.actions.unarchive"),
|
||||||
icon: Undo2Icon,
|
icon: renderActionIcon(Undo2Icon),
|
||||||
disabled: !(actionHandlers.onUnarchiveClick && isUnarchivable),
|
disabled: !(actionHandlers.onUnarchiveClick && isUnarchivable),
|
||||||
disabledReason: actionHandlers.onUnarchiveClick
|
disabledReason: actionHandlers.onUnarchiveClick
|
||||||
? isArchivedScope
|
? isArchivedScope
|
||||||
? t("proformas.unarchive.error_conflict_message")
|
? t("proformas.unarchive.error_conflict_message")
|
||||||
: t("pages.proformas.list.actions.disabled_reasons.archived_only")
|
: t("pages.proformas.list.actions.disabled_reasons.archived_only")
|
||||||
: undefined,
|
: undefined,
|
||||||
onClick: handleUnarchive,
|
onSelect: handleUnarchive,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
key: "download-pdf",
|
id: "download-pdf",
|
||||||
label: t("pages.proformas.list.actions.download_pdf"),
|
label: t("pages.proformas.list.actions.download_pdf"),
|
||||||
icon: FileDownIcon,
|
icon: renderActionIcon(FileDownIcon, isPDFLoading),
|
||||||
disabled: Boolean(!actionHandlers.onDownloadPdf || isDeletedScope || isPDFLoading),
|
disabled: Boolean(!actionHandlers.onDownloadPdf || isDeletedScope || isPDFLoading),
|
||||||
disabledReason: isDeletedScope
|
disabledReason: isDeletedScope
|
||||||
? t("pages.proformas.list.actions.disabled_reasons.deleted_scope")
|
? t("pages.proformas.list.actions.disabled_reasons.deleted_scope")
|
||||||
: isPDFLoading
|
: isPDFLoading
|
||||||
? t("pages.proformas.list.actions.disabled_reasons.operation_in_progress")
|
? t("pages.proformas.list.actions.disabled_reasons.operation_in_progress")
|
||||||
: undefined,
|
: undefined,
|
||||||
loading: isPDFLoading,
|
onSelect: handleDownloadPdf,
|
||||||
onClick: handleDownloadPdf,
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
key: "delete",
|
id: "delete",
|
||||||
label: t("pages.proformas.list.actions.delete"),
|
label: t("pages.proformas.list.actions.delete"),
|
||||||
icon: Trash2Icon,
|
icon: renderActionIcon(Trash2Icon),
|
||||||
destructive: true,
|
destructive: true,
|
||||||
disabled: !actionHandlers.onDeleteClick || isDeletedScope || !isDraft,
|
disabled: !actionHandlers.onDeleteClick || isDeletedScope || !isDraft,
|
||||||
disabledReason: isDeletedScope
|
disabledReason: isDeletedScope
|
||||||
@ -490,121 +463,16 @@ export function useProformasGridColumns(
|
|||||||
: isDraft
|
: isDraft
|
||||||
? undefined
|
? undefined
|
||||||
: t("proformas.delete_proforma_dialog.error_conflict_message"),
|
: t("proformas.delete_proforma_dialog.error_conflict_message"),
|
||||||
onClick: handleDelete,
|
onSelect: handleDelete,
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
const renderIconButton = (action: RowAction) => {
|
|
||||||
const button = (
|
|
||||||
<Button
|
|
||||||
aria-label={action.label}
|
|
||||||
className="size-8 text-muted-foreground hover:text-foreground"
|
|
||||||
disabled={action.disabled}
|
|
||||||
onClick={(event) => {
|
|
||||||
stopRowPropagationAndPreventDefault(event);
|
|
||||||
if (!action.disabled) {
|
|
||||||
action.onClick?.();
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
size="icon-sm"
|
|
||||||
variant="ghost"
|
|
||||||
>
|
|
||||||
<action.icon className="size-4" />
|
|
||||||
</Button>
|
|
||||||
);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Tooltip key={action.key}>
|
|
||||||
<TooltipTrigger
|
|
||||||
render={action.disabled ? <span className="inline-flex">{button}</span> : button}
|
|
||||||
/>
|
|
||||||
<TooltipContent>{action.disabledReason ?? action.label}</TooltipContent>
|
|
||||||
</Tooltip>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
const renderMenuItem = (action: RowAction) => {
|
|
||||||
const item = (
|
|
||||||
<DropdownMenuItem
|
|
||||||
aria-label={action.label}
|
|
||||||
className={
|
|
||||||
action.destructive ? "text-destructive focus:text-destructive" : undefined
|
|
||||||
}
|
|
||||||
disabled={action.disabled}
|
|
||||||
onClick={(event) => {
|
|
||||||
stopRowPropagationAndPreventDefault(event);
|
|
||||||
if (action.disabled) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (action.href) {
|
|
||||||
window.location.href = action.href;
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
action.onClick?.();
|
|
||||||
}}
|
|
||||||
variant={action.destructive ? "destructive" : "default"}
|
|
||||||
>
|
|
||||||
{action.loading ? (
|
|
||||||
<Spinner className="mr-2 size-4" />
|
|
||||||
) : (
|
|
||||||
<action.icon className="mr-2 size-4" />
|
|
||||||
)}
|
|
||||||
<span>{action.label}</span>
|
|
||||||
</DropdownMenuItem>
|
|
||||||
);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Tooltip key={action.key}>
|
|
||||||
<TooltipTrigger
|
|
||||||
render={action.disabled ? <div className="w-full">{item}</div> : item}
|
|
||||||
/>
|
|
||||||
<TooltipContent side="left">{action.disabledReason ?? action.label}</TooltipContent>
|
|
||||||
</Tooltip>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<TooltipProvider>
|
<RdxRowActions
|
||||||
<div className="flex items-center justify-end gap-1">
|
overflowActions={menuActions}
|
||||||
{visibleActions.map(renderIconButton)}
|
overflowLabel={t("common.more_actions")}
|
||||||
<DropdownMenu>
|
primaryActions={visibleActions}
|
||||||
<Tooltip>
|
/>
|
||||||
<TooltipTrigger
|
|
||||||
render={
|
|
||||||
<DropdownMenuTrigger
|
|
||||||
render={
|
|
||||||
<Button
|
|
||||||
aria-label={t("common.more_actions")}
|
|
||||||
className="size-8 text-muted-foreground hover:text-foreground"
|
|
||||||
onClick={stopRowPropagation}
|
|
||||||
size="icon-sm"
|
|
||||||
variant="ghost"
|
|
||||||
>
|
|
||||||
<MoreHorizontalIcon className="size-4" />
|
|
||||||
</Button>
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
<TooltipContent>{t("common.more_actions")}</TooltipContent>
|
|
||||||
</Tooltip>
|
|
||||||
<DropdownMenuContent align="end" className="w-56" onClick={stopRowPropagation}>
|
|
||||||
<DropdownMenuGroup>
|
|
||||||
<DropdownMenuLabel>{t("common.actions")}</DropdownMenuLabel>
|
|
||||||
<DropdownMenuSeparator />
|
|
||||||
{menuActions.map((action, index) => (
|
|
||||||
<React.Fragment key={action.key}>
|
|
||||||
{action.destructive && index > 0 ? <DropdownMenuSeparator /> : null}
|
|
||||||
{renderMenuItem(action)}
|
|
||||||
</React.Fragment>
|
|
||||||
))}
|
|
||||||
</DropdownMenuGroup>
|
|
||||||
</DropdownMenuContent>
|
|
||||||
</DropdownMenu>
|
|
||||||
</div>
|
|
||||||
</TooltipProvider>
|
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|||||||
@ -1,6 +1,6 @@
|
|||||||
import { ErrorAlert, PageErrorState, PageHeader, SimpleSearchInput } from "@erp/core/components";
|
import { PageErrorState, SimpleSearchInput } from "@erp/core/components";
|
||||||
import { useDataSource, useReturnToNavigation } from "@erp/core/hooks";
|
import { useDataSource, useReturnToNavigation } from "@erp/core/hooks";
|
||||||
import { type DataTableMeta } from "@repo/rdx-ui/components";
|
import { RdxPageContainer, type DataTableMeta } from "@repo/rdx-ui/components";
|
||||||
import { showErrorToast, showSuccessToast } from "@repo/rdx-ui/helpers";
|
import { showErrorToast, showSuccessToast } from "@repo/rdx-ui/helpers";
|
||||||
import {
|
import {
|
||||||
Button,
|
Button,
|
||||||
@ -50,6 +50,7 @@ import {
|
|||||||
ProformaStatusSegmentedFilter,
|
ProformaStatusSegmentedFilter,
|
||||||
UnarchiveProformaDialog,
|
UnarchiveProformaDialog,
|
||||||
} from "../components";
|
} from "../components";
|
||||||
|
import { ScopedListProformasPageView } from "./scoped-list-proformas-page-view";
|
||||||
|
|
||||||
interface ScopedListProformasPageProps {
|
interface ScopedListProformasPageProps {
|
||||||
scope: ProformaListScope;
|
scope: ProformaListScope;
|
||||||
@ -435,7 +436,7 @@ const ScopedListProformasPage = ({ scope }: ScopedListProformasPageProps) => {
|
|||||||
|
|
||||||
if (pageError) {
|
if (pageError) {
|
||||||
return (
|
return (
|
||||||
<div className="p-4 sm:p-6">
|
<RdxPageContainer width="full">
|
||||||
<div className="mx-auto w-full max-w-3xl">
|
<div className="mx-auto w-full max-w-3xl">
|
||||||
<PageErrorState
|
<PageErrorState
|
||||||
description={pageError.description}
|
description={pageError.description}
|
||||||
@ -458,211 +459,210 @@ const ScopedListProformasPage = ({ scope }: ScopedListProformasPageProps) => {
|
|||||||
variant={pageError.variant}
|
variant={pageError.variant}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</RdxPageContainer>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="p-6 space-y-6">
|
<ScopedListProformasPageView
|
||||||
{/* Header */}
|
content={
|
||||||
<PageHeader
|
<>
|
||||||
description={t(scopeConfig.descriptionKey)}
|
{/* Stats Cards */}
|
||||||
rightSlot={
|
<div className="hidden grid-cols-1 gap-4 md:grid-cols-2 lg:grid-cols-4">
|
||||||
scopeConfig.showCreateAction ? (
|
<Card>
|
||||||
<Button
|
<CardContent className="p-5">
|
||||||
aria-label={t("pages.proformas.create.title")}
|
<div className="flex items-start gap-4">
|
||||||
onClick={() =>
|
<div className="flex h-12 w-12 items-center justify-center rounded-xl bg-blue-100">
|
||||||
navigate({
|
<FileTextIcon className="size-6 text-blue-600" />
|
||||||
pathname: "/proformas/create",
|
</div>
|
||||||
search: createSearchParams({
|
<div className="flex-1">
|
||||||
returnTo: currentReturnTo,
|
<p className="text-sm text-muted-foreground">Total proformas</p>
|
||||||
}).toString(),
|
<p className="text-2xl font-bold text-foreground">1.248</p>
|
||||||
})
|
<p className="mt-1 text-xs text-muted-foreground">
|
||||||
}
|
<span className="font-medium text-green-600">↑ 12.5%</span> vs. mes anterior
|
||||||
size={"default"}
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Card>
|
||||||
|
<CardContent className="p-5">
|
||||||
|
<div className="flex items-start gap-4">
|
||||||
|
<div className="flex h-12 w-12 items-center justify-center rounded-xl bg-emerald-100">
|
||||||
|
<CircleDollarSignIcon className="size-6 text-emerald-600" />
|
||||||
|
</div>
|
||||||
|
<div className="flex-1">
|
||||||
|
<p className="text-sm text-muted-foreground">Importe total</p>
|
||||||
|
<p className="text-2xl font-bold text-foreground">125.430,75 €</p>
|
||||||
|
<p className="mt-1 text-xs text-muted-foreground">
|
||||||
|
<span className="font-medium text-green-600">↑ 8.1%</span> vs. mes anterior
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Card>
|
||||||
|
<CardContent className="p-5">
|
||||||
|
<div className="flex items-start gap-4">
|
||||||
|
<div className="flex h-12 w-12 items-center justify-center rounded-xl bg-amber-100">
|
||||||
|
<ClockIcon className="size-6 text-amber-600" />
|
||||||
|
</div>
|
||||||
|
<div className="flex-1">
|
||||||
|
<p className="text-sm text-muted-foreground">Pendientes</p>
|
||||||
|
<p className="text-2xl font-bold text-foreground">356</p>
|
||||||
|
<p className="mt-1 text-xs text-muted-foreground">
|
||||||
|
<span className="font-medium text-green-600">↑ 5.2%</span> vs. mes anterior
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Card>
|
||||||
|
<CardContent className="p-5">
|
||||||
|
<div className="flex items-start gap-4">
|
||||||
|
<div className="flex h-12 w-12 items-center justify-center rounded-xl bg-green-100">
|
||||||
|
<CheckCircle2Icon className="size-6 text-green-600" />
|
||||||
|
</div>
|
||||||
|
<div className="flex-1">
|
||||||
|
<p className="text-sm text-muted-foreground">Convertidas</p>
|
||||||
|
<p className="text-2xl font-bold text-foreground">892</p>
|
||||||
|
<p className="mt-1 text-xs text-muted-foreground">
|
||||||
|
<span className="font-medium text-green-600">↑ 15.7%</span> vs. mes anterior
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Table */}
|
||||||
|
{scopeConfig.enablePanel && isPanelOpen ? (
|
||||||
|
<ResizablePanelGroup
|
||||||
|
autoSave="list-proformas-page"
|
||||||
|
className="mx-auto h-full w-full space-y-4"
|
||||||
|
orientation="horizontal"
|
||||||
>
|
>
|
||||||
<PlusIcon aria-hidden className="mr-2 size-4" />
|
<ResizablePanel defaultSize="70%" maxSize="75%" minSize="70%">
|
||||||
{t("pages.proformas.create.title")}
|
{listContent}
|
||||||
</Button>
|
</ResizablePanel>
|
||||||
) : undefined
|
|
||||||
}
|
|
||||||
title={t(scopeConfig.titleKey)}
|
|
||||||
/>
|
|
||||||
|
|
||||||
{refreshError ? <ErrorAlert message={refreshError.message} title={refreshError.title} /> : null}
|
<ResizableHandle className="mx-4" withHandle />
|
||||||
|
|
||||||
{/* Stats Cards */}
|
<ResizablePanel defaultSize="30%" maxSize="30%" minSize="25%">
|
||||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4 hidden">
|
<div className="h-full">
|
||||||
<Card>
|
<ProformaSummaryPanel
|
||||||
<CardContent className="p-5">
|
className="border bg-background"
|
||||||
<div className="flex items-start gap-4">
|
mode={panelCtrl.panelState.mode}
|
||||||
<div className="flex h-12 w-12 items-center justify-center rounded-xl bg-blue-100">
|
onEdit={(proforma) => handleEditClick(proforma.id)}
|
||||||
<FileTextIcon className="size-6 text-blue-600" />
|
onOpenChange={(open) => {
|
||||||
</div>
|
if (!open) {
|
||||||
<div className="flex-1">
|
panelCtrl.closePanel();
|
||||||
<p className="text-sm text-muted-foreground">Total proformas</p>
|
return;
|
||||||
<p className="text-2xl font-bold text-foreground">1.248</p>
|
}
|
||||||
<p className="text-xs text-muted-foreground mt-1">
|
|
||||||
<span className="text-green-600 font-medium">↑ 12.5%</span> vs. mes anterior
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
|
|
||||||
<Card>
|
panelCtrl.panelState.onOpenChange(true);
|
||||||
<CardContent className="p-5">
|
}}
|
||||||
<div className="flex items-start gap-4">
|
open={panelCtrl.panelState.isOpen}
|
||||||
<div className="flex h-12 w-12 items-center justify-center rounded-xl bg-emerald-100">
|
proforma={panelCtrl.proforma}
|
||||||
<CircleDollarSignIcon className="size-6 text-emerald-600" />
|
visibility={panelCtrl.panelState.visibility}
|
||||||
</div>
|
/>
|
||||||
<div className="flex-1">
|
</div>
|
||||||
<p className="text-sm text-muted-foreground">Importe total</p>
|
</ResizablePanel>
|
||||||
<p className="text-2xl font-bold text-foreground">125.430,75 €</p>
|
</ResizablePanelGroup>
|
||||||
<p className="text-xs text-muted-foreground mt-1">
|
) : (
|
||||||
<span className="text-green-600 font-medium">↑ 8.1%</span> vs. mes anterior
|
<div className="mx-auto w-full space-y-4">{listContent}</div>
|
||||||
</p>
|
)}
|
||||||
</div>
|
</>
|
||||||
</div>
|
}
|
||||||
</CardContent>
|
description={t(scopeConfig.descriptionKey)}
|
||||||
</Card>
|
dialogs={
|
||||||
|
<>
|
||||||
|
<IssueProformaDialog
|
||||||
|
isSubmitting={issueDialogCtrl.isSubmitting}
|
||||||
|
onConfirm={issueDialogCtrl.confirmIssue}
|
||||||
|
onOpenChange={(open) => {
|
||||||
|
if (!open) {
|
||||||
|
issueDialogCtrl.closeDialog();
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
open={issueDialogCtrl.open}
|
||||||
|
target={issueDialogCtrl.target}
|
||||||
|
/>
|
||||||
|
|
||||||
<Card>
|
<ChangeProformaStatusDialog
|
||||||
<CardContent className="p-5">
|
isSubmitting={changeStatusDialogCtrl.isSubmitting}
|
||||||
<div className="flex items-start gap-4">
|
onConfirm={changeStatusDialogCtrl.confirmChangeStatus}
|
||||||
<div className="flex h-12 w-12 items-center justify-center rounded-xl bg-amber-100">
|
onOpenChange={(open) => {
|
||||||
<ClockIcon className="size-6 text-amber-600" />
|
if (!open) {
|
||||||
</div>
|
changeStatusDialogCtrl.closeDialog();
|
||||||
<div className="flex-1">
|
}
|
||||||
<p className="text-sm text-muted-foreground">Pendientes</p>
|
}}
|
||||||
<p className="text-2xl font-bold text-foreground">356</p>
|
open={changeStatusDialogCtrl.open}
|
||||||
<p className="text-xs text-muted-foreground mt-1">
|
targets={changeStatusDialogCtrl.targets}
|
||||||
<span className="text-green-600 font-medium">↑ 5.2%</span> vs. mes anterior
|
/>
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
|
|
||||||
<Card>
|
<DeleteProformaDialog
|
||||||
<CardContent className="p-5">
|
isSubmitting={deleteDialogCtrl.isSubmitting}
|
||||||
<div className="flex items-start gap-4">
|
onConfirm={deleteDialogCtrl.confirmDelete}
|
||||||
<div className="flex h-12 w-12 items-center justify-center rounded-xl bg-green-100">
|
onOpenChange={(open) => {
|
||||||
<CheckCircle2Icon className="size-6 text-green-600" />
|
if (!open) {
|
||||||
</div>
|
deleteDialogCtrl.closeDialog();
|
||||||
<div className="flex-1">
|
}
|
||||||
<p className="text-sm text-muted-foreground">Convertidas</p>
|
}}
|
||||||
<p className="text-2xl font-bold text-foreground">892</p>
|
open={deleteDialogCtrl.open}
|
||||||
<p className="text-xs text-muted-foreground mt-1">
|
targets={deleteDialogCtrl.targets}
|
||||||
<span className="text-green-600 font-medium">↑ 15.7%</span> vs. mes anterior
|
/>
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Table */}
|
<ArchiveProformaDialog
|
||||||
{scopeConfig.enablePanel && isPanelOpen ? (
|
isSubmitting={archiveDialogCtrl.isSubmitting}
|
||||||
<ResizablePanelGroup
|
onConfirm={archiveDialogCtrl.confirmArchive}
|
||||||
autoSave="list-proformas-page"
|
onOpenChange={(open) => {
|
||||||
className="h-full mx-auto w-full space-y-4"
|
if (!open) {
|
||||||
orientation="horizontal"
|
archiveDialogCtrl.closeDialog();
|
||||||
>
|
}
|
||||||
<ResizablePanel defaultSize="70%" maxSize="75%" minSize="70%">
|
}}
|
||||||
{listContent}
|
open={archiveDialogCtrl.open}
|
||||||
</ResizablePanel>
|
target={archiveDialogCtrl.target}
|
||||||
|
/>
|
||||||
|
|
||||||
<ResizableHandle className="mx-4" withHandle />
|
<UnarchiveProformaDialog
|
||||||
|
isSubmitting={unarchiveDialogCtrl.isSubmitting}
|
||||||
<ResizablePanel defaultSize="30%" maxSize="30%" minSize="25%">
|
onConfirm={unarchiveDialogCtrl.confirmUnarchive}
|
||||||
<div className="h-full">
|
onOpenChange={(open) => {
|
||||||
<ProformaSummaryPanel
|
if (!open) {
|
||||||
className="border bg-background"
|
unarchiveDialogCtrl.closeDialog();
|
||||||
mode={panelCtrl.panelState.mode}
|
}
|
||||||
onEdit={(proforma) => handleEditClick(proforma.id)}
|
}}
|
||||||
onOpenChange={(open) => {
|
open={unarchiveDialogCtrl.open}
|
||||||
if (!open) {
|
target={unarchiveDialogCtrl.target}
|
||||||
panelCtrl.closePanel();
|
/>
|
||||||
return;
|
</>
|
||||||
}
|
}
|
||||||
|
primaryAction={
|
||||||
panelCtrl.panelState.onOpenChange(true);
|
scopeConfig.showCreateAction ? (
|
||||||
}}
|
<Button
|
||||||
open={panelCtrl.panelState.isOpen}
|
aria-label={t("pages.proformas.create.title")}
|
||||||
proforma={panelCtrl.proforma}
|
onClick={() =>
|
||||||
visibility={panelCtrl.panelState.visibility}
|
navigate({
|
||||||
/>
|
pathname: "/proformas/create",
|
||||||
</div>
|
search: createSearchParams({
|
||||||
</ResizablePanel>
|
returnTo: currentReturnTo,
|
||||||
</ResizablePanelGroup>
|
}).toString(),
|
||||||
) : (
|
})
|
||||||
<div className="mx-auto w-full space-y-4">{listContent}</div>
|
|
||||||
)}
|
|
||||||
<>
|
|
||||||
{/* Issue */}
|
|
||||||
<IssueProformaDialog
|
|
||||||
isSubmitting={issueDialogCtrl.isSubmitting}
|
|
||||||
onConfirm={issueDialogCtrl.confirmIssue}
|
|
||||||
onOpenChange={(open) => {
|
|
||||||
if (!open) {
|
|
||||||
issueDialogCtrl.closeDialog();
|
|
||||||
}
|
}
|
||||||
}}
|
size="default"
|
||||||
open={issueDialogCtrl.open}
|
>
|
||||||
target={issueDialogCtrl.target}
|
<PlusIcon aria-hidden className="mr-2 size-4" />
|
||||||
/>
|
{t("pages.proformas.create.title")}
|
||||||
|
</Button>
|
||||||
<ChangeProformaStatusDialog
|
) : undefined
|
||||||
isSubmitting={changeStatusDialogCtrl.isSubmitting}
|
}
|
||||||
onConfirm={changeStatusDialogCtrl.confirmChangeStatus}
|
refreshError={refreshError}
|
||||||
onOpenChange={(open) => {
|
title={t(scopeConfig.titleKey)}
|
||||||
if (!open) {
|
/>
|
||||||
changeStatusDialogCtrl.closeDialog();
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
open={changeStatusDialogCtrl.open}
|
|
||||||
targets={changeStatusDialogCtrl.targets}
|
|
||||||
/>
|
|
||||||
|
|
||||||
{/* Eliminar */}
|
|
||||||
<DeleteProformaDialog
|
|
||||||
isSubmitting={deleteDialogCtrl.isSubmitting}
|
|
||||||
onConfirm={deleteDialogCtrl.confirmDelete}
|
|
||||||
onOpenChange={(open) => {
|
|
||||||
if (!open) {
|
|
||||||
deleteDialogCtrl.closeDialog();
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
open={deleteDialogCtrl.open}
|
|
||||||
targets={deleteDialogCtrl.targets}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<ArchiveProformaDialog
|
|
||||||
isSubmitting={archiveDialogCtrl.isSubmitting}
|
|
||||||
onConfirm={archiveDialogCtrl.confirmArchive}
|
|
||||||
onOpenChange={(open) => {
|
|
||||||
if (!open) {
|
|
||||||
archiveDialogCtrl.closeDialog();
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
open={archiveDialogCtrl.open}
|
|
||||||
target={archiveDialogCtrl.target}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<UnarchiveProformaDialog
|
|
||||||
isSubmitting={unarchiveDialogCtrl.isSubmitting}
|
|
||||||
onConfirm={unarchiveDialogCtrl.confirmUnarchive}
|
|
||||||
onOpenChange={(open) => {
|
|
||||||
if (!open) {
|
|
||||||
unarchiveDialogCtrl.closeDialog();
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
open={unarchiveDialogCtrl.open}
|
|
||||||
target={unarchiveDialogCtrl.target}
|
|
||||||
/>
|
|
||||||
</>
|
|
||||||
</div>
|
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@ -0,0 +1,127 @@
|
|||||||
|
import assert from "node:assert/strict";
|
||||||
|
import test from "node:test";
|
||||||
|
import { isValidElement } from "react";
|
||||||
|
import { renderToStaticMarkup } from "react-dom/server";
|
||||||
|
|
||||||
|
import { ScopedListProformasPageView } from "./scoped-list-proformas-page-view";
|
||||||
|
|
||||||
|
const resolveElement = (node: unknown): unknown => {
|
||||||
|
if (Array.isArray(node)) {
|
||||||
|
return node.map(resolveElement);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!isValidElement(node)) {
|
||||||
|
return node;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (typeof node.type === "function") {
|
||||||
|
return resolveElement(node.type(node.props));
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
type: node.type,
|
||||||
|
props: {
|
||||||
|
...node.props,
|
||||||
|
children: resolveElement(node.props.children),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
const findFirstElementByType = (
|
||||||
|
node: unknown,
|
||||||
|
type: string
|
||||||
|
): { type: string; props: Record<string, unknown> } | null => {
|
||||||
|
if (Array.isArray(node)) {
|
||||||
|
for (const child of node) {
|
||||||
|
const found = findFirstElementByType(child, type);
|
||||||
|
|
||||||
|
if (found) {
|
||||||
|
return found;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!node || typeof node !== "object" || !("type" in node) || !("props" in node)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const element = node as { type: string; props: Record<string, unknown> };
|
||||||
|
|
||||||
|
if (element.type === type) {
|
||||||
|
return element;
|
||||||
|
}
|
||||||
|
|
||||||
|
return findFirstElementByType(element.props.children, type);
|
||||||
|
};
|
||||||
|
|
||||||
|
test("renderiza la composición real del listado con título, descripción y acción principal", () => {
|
||||||
|
const html = renderToStaticMarkup(
|
||||||
|
<ScopedListProformasPageView
|
||||||
|
content={<div>Grid content</div>}
|
||||||
|
description="Listado principal de proformas"
|
||||||
|
primaryAction={<button type="button">Nueva proforma</button>}
|
||||||
|
title="Proformas"
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
|
||||||
|
assert.match(html, /<h1[^>]*>Proformas<\/h1>/);
|
||||||
|
assert.match(html, /Listado principal de proformas/);
|
||||||
|
assert.match(html, /Nueva proforma/);
|
||||||
|
assert.match(html, /Grid content/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("mantiene el error inline, el panel contextual y los dialogs fuera del stack principal", () => {
|
||||||
|
const html = renderToStaticMarkup(
|
||||||
|
<ScopedListProformasPageView
|
||||||
|
content={
|
||||||
|
<div>
|
||||||
|
<div>Grid content</div>
|
||||||
|
<aside>Summary panel open</aside>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
dialogs={<div>Issue dialog mounted</div>}
|
||||||
|
refreshError={{
|
||||||
|
title: "Error de actualización",
|
||||||
|
message: "No se pudo refrescar la lista",
|
||||||
|
}}
|
||||||
|
title="Proformas"
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
|
||||||
|
assert.match(html, /Error de actualización/);
|
||||||
|
assert.match(html, /No se pudo refrescar la lista/);
|
||||||
|
assert.match(html, /Summary panel open/);
|
||||||
|
assert.match(html, /Issue dialog mounted/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("preserva el callback de la acción principal", async () => {
|
||||||
|
let didCreate = false;
|
||||||
|
|
||||||
|
const resolved = resolveElement(
|
||||||
|
<ScopedListProformasPageView
|
||||||
|
content={<div>Grid content</div>}
|
||||||
|
primaryAction={
|
||||||
|
<button
|
||||||
|
onClick={() => {
|
||||||
|
didCreate = true;
|
||||||
|
}}
|
||||||
|
type="button"
|
||||||
|
>
|
||||||
|
Nueva proforma
|
||||||
|
</button>
|
||||||
|
}
|
||||||
|
title="Proformas"
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
|
||||||
|
const button = findFirstElementByType(resolved, "button");
|
||||||
|
|
||||||
|
assert.ok(button);
|
||||||
|
assert.equal(typeof button.props.onClick, "function");
|
||||||
|
|
||||||
|
await button.props.onClick?.();
|
||||||
|
|
||||||
|
assert.equal(didCreate, true);
|
||||||
|
});
|
||||||
@ -0,0 +1,51 @@
|
|||||||
|
import { ErrorAlert } from "@erp/core/components";
|
||||||
|
import {
|
||||||
|
RdxPageActions,
|
||||||
|
RdxPageContainer,
|
||||||
|
RdxPageHeader,
|
||||||
|
RdxPageStack,
|
||||||
|
} from "@repo/rdx-ui/components";
|
||||||
|
import type { ReactNode } from "react";
|
||||||
|
|
||||||
|
interface RefreshErrorViewModel {
|
||||||
|
title: string;
|
||||||
|
message: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ScopedListProformasPageViewProps {
|
||||||
|
title: ReactNode;
|
||||||
|
description?: ReactNode;
|
||||||
|
primaryAction?: ReactNode;
|
||||||
|
refreshError?: RefreshErrorViewModel | null;
|
||||||
|
content: ReactNode;
|
||||||
|
dialogs?: ReactNode;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const ScopedListProformasPageView = ({
|
||||||
|
title,
|
||||||
|
description,
|
||||||
|
primaryAction,
|
||||||
|
refreshError,
|
||||||
|
content,
|
||||||
|
dialogs,
|
||||||
|
}: ScopedListProformasPageViewProps) => {
|
||||||
|
return (
|
||||||
|
<RdxPageContainer width="full">
|
||||||
|
<RdxPageStack spacing="default">
|
||||||
|
<RdxPageHeader
|
||||||
|
actions={primaryAction ? <RdxPageActions primary={primaryAction} /> : undefined}
|
||||||
|
description={description}
|
||||||
|
title={title}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{refreshError ? (
|
||||||
|
<ErrorAlert message={refreshError.message} title={refreshError.title} />
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
{content}
|
||||||
|
</RdxPageStack>
|
||||||
|
|
||||||
|
{dialogs}
|
||||||
|
</RdxPageContainer>
|
||||||
|
);
|
||||||
|
};
|
||||||
@ -1,9 +1,7 @@
|
|||||||
import { MoneyHelper, PercentageHelper } from "@repo/rdx-utils";
|
import { MoneyHelper, PercentageHelper } from "@repo/rdx-utils";
|
||||||
import {
|
import {
|
||||||
Button,
|
Button,
|
||||||
Card,
|
|
||||||
CardContent,
|
CardContent,
|
||||||
CardFooter,
|
|
||||||
Item,
|
Item,
|
||||||
ItemContent,
|
ItemContent,
|
||||||
ItemMedia,
|
ItemMedia,
|
||||||
@ -38,6 +36,7 @@ interface ProformaCompactTotalsProps {
|
|||||||
|
|
||||||
disabled?: boolean;
|
disabled?: boolean;
|
||||||
className?: string;
|
className?: string;
|
||||||
|
variant?: "standalone" | "embedded";
|
||||||
}
|
}
|
||||||
|
|
||||||
export const ProformaCompactTotals = ({
|
export const ProformaCompactTotals = ({
|
||||||
@ -45,9 +44,10 @@ export const ProformaCompactTotals = ({
|
|||||||
currency = "EUR",
|
currency = "EUR",
|
||||||
showRec = false,
|
showRec = false,
|
||||||
showRetention = false,
|
showRetention = false,
|
||||||
globalDiscountField,
|
globalDiscountField: _globalDiscountField,
|
||||||
disabled = false,
|
disabled: _disabled = false,
|
||||||
className,
|
className,
|
||||||
|
variant = "standalone",
|
||||||
}: ProformaCompactTotalsProps) => {
|
}: ProformaCompactTotalsProps) => {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const [isExpanded, setIsExpanded] = useState(false);
|
const [isExpanded, setIsExpanded] = useState(false);
|
||||||
@ -60,18 +60,24 @@ export const ProformaCompactTotals = ({
|
|||||||
return PercentageHelper.formatPercent(value);
|
return PercentageHelper.formatPercent(value);
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
const isEmbedded = variant === "embedded";
|
||||||
<Card className={"rounded-none bg-muted-foreground p-0 gap-0"}>
|
|
||||||
{/* Panel expandible con desglose */}
|
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={cn("min-w-0", className)}>
|
||||||
<CardContent
|
<CardContent
|
||||||
className={cn(
|
className={cn(
|
||||||
"grid transition-all duration-200 bg-background",
|
"grid overflow-hidden transition-all duration-200",
|
||||||
isExpanded ? "grid-rows-[1fr] py-6" : "grid-rows-[0fr] py-0"
|
isEmbedded ? "px-0" : "bg-background px-6",
|
||||||
|
isExpanded ? "grid-rows-[1fr] pb-4 pt-0" : "grid-rows-[0fr] py-0"
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
<div className="overflow-hidden">
|
<div className="overflow-hidden">
|
||||||
<div className="grid max-w-3xl gap-4 xl:max-w-5xl xl:gap-8 lg:grid-cols-3 text-sm 2xl:gap-16">
|
<div
|
||||||
|
className={cn(
|
||||||
|
"grid gap-4 text-sm",
|
||||||
|
isEmbedded ? "border-b border-border pb-4 lg:grid-cols-3 xl:gap-8" : "max-w-3xl lg:grid-cols-3 xl:max-w-5xl xl:gap-8 2xl:gap-16"
|
||||||
|
)}
|
||||||
|
>
|
||||||
{/* Descuentos */}
|
{/* Descuentos */}
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<div className="flex items-center gap-2 font-medium text-teal-700 dark:text-teal-400">
|
<div className="flex items-center gap-2 font-medium text-teal-700 dark:text-teal-400">
|
||||||
@ -175,26 +181,23 @@ export const ProformaCompactTotals = ({
|
|||||||
</div>
|
</div>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
|
|
||||||
{/* Barra principal de totales - siempre visible */}
|
<div
|
||||||
<CardFooter
|
|
||||||
className={cn(
|
className={cn(
|
||||||
"flex items-center justify-between gap-4 bg-muted rounded-none py-3",
|
"flex flex-wrap items-center gap-4",
|
||||||
className
|
isEmbedded ? "min-w-0 justify-between bg-transparent py-0" : "rounded-none bg-muted px-6 py-3"
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
{/* Boton expandir */}
|
<Button onClick={() => setIsExpanded(!isExpanded)} size="sm" type="button" variant="outline">
|
||||||
<Button onClick={() => setIsExpanded(!isExpanded)} variant="outline">
|
|
||||||
{isExpanded ? <ChevronDown className="size-5" /> : <ChevronUp className="size-5" />}
|
{isExpanded ? <ChevronDown className="size-5" /> : <ChevronUp className="size-5" />}
|
||||||
{isExpanded ? "Ocultar desglose" : "Ver desglose"}
|
{isExpanded ? "Ocultar desglose" : "Ver desglose"}
|
||||||
</Button>
|
</Button>
|
||||||
|
|
||||||
{/* Totales en linea */}
|
<div className="flex min-w-0 flex-1 flex-wrap items-center justify-end gap-x-5 gap-y-2 text-sm">
|
||||||
<div className="flex items-center gap-8 text-base">
|
<div className="hidden items-center gap-2 lg:flex">
|
||||||
<div className="hidden items-center gap-2 xl:flex">
|
|
||||||
<span className="text-muted-foreground">
|
<span className="text-muted-foreground">
|
||||||
{t("proformas.update.totals.subtotalBeforeDiscounts", "Subtotal")}
|
{t("proformas.update.totals.subtotalBeforeDiscounts", "Subtotal")}
|
||||||
</span>
|
</span>
|
||||||
<span className="font-medium tabular-nums">
|
<span className="font-medium tabular-nums text-foreground">
|
||||||
{formatMoney(totals.subtotalBeforeDiscounts)}
|
{formatMoney(totals.subtotalBeforeDiscounts)}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
@ -220,7 +223,7 @@ export const ProformaCompactTotals = ({
|
|||||||
<span className="font-medium tabular-nums">{formatMoney(totals.taxableBase)}</span>
|
<span className="font-medium tabular-nums">{formatMoney(totals.taxableBase)}</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="hidden items-center gap-2 xl:flex">
|
<div className="hidden items-center gap-2 lg:flex">
|
||||||
<Tooltip>
|
<Tooltip>
|
||||||
<TooltipTrigger render={<LandmarkIcon className="text-orange-700 size-4" />} />
|
<TooltipTrigger render={<LandmarkIcon className="text-orange-700 size-4" />} />
|
||||||
<TooltipContent>
|
<TooltipContent>
|
||||||
@ -252,20 +255,29 @@ export const ProformaCompactTotals = ({
|
|||||||
</div>
|
</div>
|
||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
{/* Total destacado */}
|
{isEmbedded ? (
|
||||||
<Item className="bg-muted-foreground">
|
<div className="inline-flex items-center gap-2 rounded-md border border-border bg-muted/40 px-3 py-2">
|
||||||
<ItemMedia className="text-muted">
|
<ReceiptTextIcon className="size-4 text-muted-foreground" />
|
||||||
<ReceiptTextIcon className="size-5" />
|
<span className="text-sm font-medium text-muted-foreground">Total</span>
|
||||||
</ItemMedia>
|
<span className="text-base font-bold tabular-nums text-foreground">
|
||||||
<ItemContent className="items-end">
|
{formatMoney(totals.total)}
|
||||||
<ItemTitle className="text-base gap-2 text-muted">
|
</span>
|
||||||
<span className="font-medium">Total</span>{" "}
|
</div>
|
||||||
<span className="font-bold tabular-nums">{formatMoney(totals.total)}</span>
|
) : (
|
||||||
</ItemTitle>
|
<Item className="bg-muted-foreground">
|
||||||
</ItemContent>
|
<ItemMedia className="text-muted">
|
||||||
</Item>
|
<ReceiptTextIcon className="size-5" />
|
||||||
|
</ItemMedia>
|
||||||
|
<ItemContent className="items-end">
|
||||||
|
<ItemTitle className="text-base gap-2 text-muted">
|
||||||
|
<span className="font-medium">Total</span>{" "}
|
||||||
|
<span className="font-bold tabular-nums">{formatMoney(totals.total)}</span>
|
||||||
|
</ItemTitle>
|
||||||
|
</ItemContent>
|
||||||
|
</Item>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</CardFooter>
|
</div>
|
||||||
</Card>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@ -0,0 +1,60 @@
|
|||||||
|
import assert from "node:assert/strict";
|
||||||
|
import test from "node:test";
|
||||||
|
import { renderToStaticMarkup } from "react-dom/server";
|
||||||
|
import { FormProvider, useForm } from "react-hook-form";
|
||||||
|
|
||||||
|
import type { ProformaUpdateForm } from "../../entities";
|
||||||
|
import { buildProformaUpdateDefault } from "../../utils";
|
||||||
|
import { ProformaUpdateHeader } from "./proforma-update-header";
|
||||||
|
|
||||||
|
const baseLabels = {
|
||||||
|
title: "Editar proforma",
|
||||||
|
back: "Volver",
|
||||||
|
modified: "Modificada",
|
||||||
|
cancel: "Cancelar",
|
||||||
|
save: "Guardar",
|
||||||
|
saving: "Guardando...",
|
||||||
|
moreActions: "Más acciones",
|
||||||
|
keyboardShortcuts: "Ver atajos de teclado",
|
||||||
|
duplicate: "Duplicar proforma",
|
||||||
|
exportPdf: "Exportar PDF",
|
||||||
|
delete: "Eliminar",
|
||||||
|
};
|
||||||
|
|
||||||
|
const renderHeader = (showFormActions = true) => {
|
||||||
|
const Harness = () => {
|
||||||
|
const form = useForm<ProformaUpdateForm>({
|
||||||
|
defaultValues: buildProformaUpdateDefault(),
|
||||||
|
});
|
||||||
|
|
||||||
|
return (
|
||||||
|
<FormProvider {...form}>
|
||||||
|
<ProformaUpdateHeader
|
||||||
|
formId="proforma-update-form"
|
||||||
|
hasChanges
|
||||||
|
labels={baseLabels}
|
||||||
|
onCancel={() => undefined}
|
||||||
|
showFormActions={showFormActions}
|
||||||
|
/>
|
||||||
|
</FormProvider>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
return renderToStaticMarkup(<Harness />);
|
||||||
|
};
|
||||||
|
|
||||||
|
test("oculta guardar y cancelar en el header cuando la página usa footer sticky", () => {
|
||||||
|
const html = renderHeader(false);
|
||||||
|
|
||||||
|
assert.match(html, /Editar proforma/);
|
||||||
|
assert.match(html, /Modificada/);
|
||||||
|
assert.doesNotMatch(html, /Guardar/);
|
||||||
|
assert.doesNotMatch(html, /Cancelar/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("mantiene las acciones del header cuando no se desactivan", () => {
|
||||||
|
const html = renderHeader(true);
|
||||||
|
|
||||||
|
assert.match(html, /Guardar/);
|
||||||
|
assert.match(html, /Cancelar/);
|
||||||
|
});
|
||||||
@ -38,6 +38,7 @@ export interface ProformaUpdateHeaderProps {
|
|||||||
readOnly?: boolean;
|
readOnly?: boolean;
|
||||||
isSaving?: boolean;
|
isSaving?: boolean;
|
||||||
hasChanges?: boolean;
|
hasChanges?: boolean;
|
||||||
|
showFormActions?: boolean;
|
||||||
|
|
||||||
className?: string;
|
className?: string;
|
||||||
children?: ReactNode;
|
children?: ReactNode;
|
||||||
@ -47,13 +48,14 @@ export const ProformaUpdateHeader = ({
|
|||||||
formId,
|
formId,
|
||||||
labels,
|
labels,
|
||||||
onCancel,
|
onCancel,
|
||||||
onDuplicate,
|
onDuplicate: _onDuplicate,
|
||||||
onExportPdf,
|
onExportPdf: _onExportPdf,
|
||||||
onDelete,
|
onDelete: _onDelete,
|
||||||
disabled = false,
|
disabled = false,
|
||||||
readOnly = false,
|
readOnly = false,
|
||||||
isSaving = false,
|
isSaving = false,
|
||||||
hasChanges = false,
|
hasChanges = false,
|
||||||
|
showFormActions = true,
|
||||||
className,
|
className,
|
||||||
children,
|
children,
|
||||||
}: ProformaUpdateHeaderProps) => {
|
}: ProformaUpdateHeaderProps) => {
|
||||||
@ -100,27 +102,31 @@ export const ProformaUpdateHeader = ({
|
|||||||
]}
|
]}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<CancelActionButton
|
{showFormActions ? (
|
||||||
className="hidden sm:flex"
|
<>
|
||||||
disabled={computedDisabled}
|
<CancelActionButton
|
||||||
label={labels.cancel}
|
className="hidden sm:flex"
|
||||||
onCancel={() => onCancel?.()}
|
disabled={computedDisabled}
|
||||||
variant="outline"
|
label={labels.cancel}
|
||||||
/>
|
onCancel={() => onCancel?.()}
|
||||||
|
variant="outline"
|
||||||
|
/>
|
||||||
|
|
||||||
<RhfSubmitActionButton
|
<RhfSubmitActionButton
|
||||||
busyLabel={labels.saving}
|
busyLabel={labels.saving}
|
||||||
disabled={computedDisabled || readOnly}
|
disabled={computedDisabled || readOnly}
|
||||||
formId={formId}
|
formId={formId}
|
||||||
isBusy={isSaving}
|
isBusy={isSaving}
|
||||||
label={labels.save}
|
label={labels.save}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<FormSecondaryActionsMenu
|
<FormSecondaryActionsMenu
|
||||||
actions={secondaryActions}
|
actions={secondaryActions}
|
||||||
disabled={computedDisabled}
|
disabled={computedDisabled}
|
||||||
label={labels.moreActions}
|
label={labels.moreActions}
|
||||||
/>
|
/>
|
||||||
|
</>
|
||||||
|
) : null}
|
||||||
</FormActionsBar>
|
</FormActionsBar>
|
||||||
}
|
}
|
||||||
className={className}
|
className={className}
|
||||||
|
|||||||
@ -1,6 +1,6 @@
|
|||||||
import {
|
import {
|
||||||
FormSectionCard,
|
RdxEditorSection,
|
||||||
FormSectionGrid,
|
RdxFormGrid,
|
||||||
SelectField,
|
SelectField,
|
||||||
type SelectFieldItem,
|
type SelectFieldItem,
|
||||||
} from "@repo/rdx-ui/components";
|
} from "@repo/rdx-ui/components";
|
||||||
@ -28,14 +28,14 @@ export const ProformaUpdatePaymentEditor = ({
|
|||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<FormSectionCard
|
<RdxEditorSection
|
||||||
className={className}
|
className={className}
|
||||||
description={t("form_groups.proformas.payment.description", "Forma de pago")}
|
description={t("form_groups.proformas.payment.description", "Forma de pago")}
|
||||||
disabled={disabled}
|
|
||||||
icon={<CreditCardIcon className="size-5" />}
|
icon={<CreditCardIcon className="size-5" />}
|
||||||
title={t("form_groups.proformas.payment.title", "Condiciones de pago")}
|
title={t("form_groups.proformas.payment.title", "Condiciones de pago")}
|
||||||
|
variant="panel"
|
||||||
>
|
>
|
||||||
<FormSectionGrid className="w-full">
|
<RdxFormGrid className="w-full">
|
||||||
<SelectField
|
<SelectField
|
||||||
className="col-span-full"
|
className="col-span-full"
|
||||||
inputClassName="bg-background"
|
inputClassName="bg-background"
|
||||||
@ -61,7 +61,7 @@ export const ProformaUpdatePaymentEditor = ({
|
|||||||
)}
|
)}
|
||||||
readOnly={readOnly}
|
readOnly={readOnly}
|
||||||
/>
|
/>
|
||||||
</FormSectionGrid>
|
</RdxFormGrid>
|
||||||
</FormSectionCard>
|
</RdxEditorSection>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@ -0,0 +1,138 @@
|
|||||||
|
import assert from "node:assert/strict";
|
||||||
|
import test from "node:test";
|
||||||
|
import { renderToStaticMarkup } from "react-dom/server";
|
||||||
|
import { FormProvider, useForm } from "react-hook-form";
|
||||||
|
|
||||||
|
import type {
|
||||||
|
ProformaItemField,
|
||||||
|
UseUpdateProformaItemsControllerResult,
|
||||||
|
UseUpdateProformaPaymentControllerResult,
|
||||||
|
UseUpdateProformaTaxControllerResult,
|
||||||
|
UseUpdateProformaTotalsControllerResult,
|
||||||
|
} from "../../controllers";
|
||||||
|
import type { ProformaTotals, ProformaUpdateForm } from "../../entities";
|
||||||
|
import { buildProformaUpdateDefault } from "../../utils";
|
||||||
|
import { ProformaUpdateEditorForm } from "./proforma-update-editor-form";
|
||||||
|
|
||||||
|
const emptyTotals: ProformaTotals = {
|
||||||
|
subtotalBeforeDiscounts: 0,
|
||||||
|
lineDiscountTotal: 0,
|
||||||
|
globalDiscountPercentage: 0,
|
||||||
|
globalDiscountAmount: 0,
|
||||||
|
totalDiscountAmount: 0,
|
||||||
|
taxableBase: 0,
|
||||||
|
taxBreakdown: [],
|
||||||
|
taxTotal: 0,
|
||||||
|
recTotal: 0,
|
||||||
|
taxRecTotal: 0,
|
||||||
|
retentionPercentage: null,
|
||||||
|
retentionAmount: 0,
|
||||||
|
total: 0,
|
||||||
|
};
|
||||||
|
|
||||||
|
const itemsCtrl: UseUpdateProformaItemsControllerResult = {
|
||||||
|
fields: [] as ProformaItemField[],
|
||||||
|
items: [],
|
||||||
|
hasItems: false,
|
||||||
|
itemCount: 0,
|
||||||
|
getItemIsValued: () => false,
|
||||||
|
itemErrors: [],
|
||||||
|
getItemError: () => undefined,
|
||||||
|
getItemErrorMessage: () => undefined,
|
||||||
|
addItemAtStart: () => undefined,
|
||||||
|
appendItem: () => undefined,
|
||||||
|
insertItemBefore: () => undefined,
|
||||||
|
insertItemAfter: () => undefined,
|
||||||
|
removeItem: () => undefined,
|
||||||
|
duplicateItem: () => undefined,
|
||||||
|
moveItemUp: () => undefined,
|
||||||
|
moveItemDown: () => undefined,
|
||||||
|
getItemAmounts: () => ({
|
||||||
|
subtotal: 0,
|
||||||
|
itemDiscountAmount: 0,
|
||||||
|
subtotalTaxable: 0,
|
||||||
|
taxAmount: 0,
|
||||||
|
total: 0,
|
||||||
|
}),
|
||||||
|
totals: {
|
||||||
|
subtotal: 0,
|
||||||
|
discountAmount: 0,
|
||||||
|
total: 0,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
const taxCtrl: UseUpdateProformaTaxControllerResult = {
|
||||||
|
taxMode: "single",
|
||||||
|
hasTaxPercentage: true,
|
||||||
|
taxPercentage: 21,
|
||||||
|
hasRecPercentage: false,
|
||||||
|
recPercentage: null,
|
||||||
|
hasRetentionPercentage: false,
|
||||||
|
retentionPercentage: null,
|
||||||
|
usesSingleTax: true,
|
||||||
|
usesPerLineTax: false,
|
||||||
|
enablePerLineTaxes: () => undefined,
|
||||||
|
disablePerLineTaxes: () => undefined,
|
||||||
|
updateTaxPercentage: () => undefined,
|
||||||
|
updateRecPercentage: () => undefined,
|
||||||
|
updateRetentionPercentage: () => undefined,
|
||||||
|
taxRegimeOptions: [],
|
||||||
|
};
|
||||||
|
|
||||||
|
const paymentCtrl: UseUpdateProformaPaymentControllerResult = {
|
||||||
|
paymentMethodOptions: [],
|
||||||
|
paymentTermOptions: [],
|
||||||
|
isLoading: false,
|
||||||
|
isFetching: false,
|
||||||
|
isError: false,
|
||||||
|
error: null,
|
||||||
|
};
|
||||||
|
|
||||||
|
const totalsCtrl: UseUpdateProformaTotalsControllerResult = {
|
||||||
|
totals: emptyTotals,
|
||||||
|
};
|
||||||
|
|
||||||
|
const renderEditor = () => {
|
||||||
|
const Harness = () => {
|
||||||
|
const form = useForm<ProformaUpdateForm>({
|
||||||
|
defaultValues: buildProformaUpdateDefault(),
|
||||||
|
});
|
||||||
|
|
||||||
|
return (
|
||||||
|
<FormProvider {...form}>
|
||||||
|
<ProformaUpdateEditorForm
|
||||||
|
formId="proforma-update-form"
|
||||||
|
isSubmitting={false}
|
||||||
|
itemsCtrl={itemsCtrl}
|
||||||
|
onChangeCustomerClick={() => undefined}
|
||||||
|
onCreateCustomerClick={() => undefined}
|
||||||
|
onReset={() => undefined}
|
||||||
|
onSubmit={(event) => {
|
||||||
|
event.preventDefault();
|
||||||
|
}}
|
||||||
|
paymentCtrl={paymentCtrl}
|
||||||
|
selectedCustomer={null}
|
||||||
|
seriesOptions={[]}
|
||||||
|
taxCtrl={taxCtrl}
|
||||||
|
totalsCtrl={totalsCtrl}
|
||||||
|
/>
|
||||||
|
</FormProvider>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
return renderToStaticMarkup(<Harness />);
|
||||||
|
};
|
||||||
|
|
||||||
|
test("renderiza el patrón complejo con main, aside y un único formulario", () => {
|
||||||
|
const html = renderEditor();
|
||||||
|
|
||||||
|
assert.match(html, /id="proforma-update-form"/);
|
||||||
|
assert.match(html, /xl:grid-cols-\[minmax\(0,1fr\)_22rem\]/);
|
||||||
|
assert.match(html, /<aside/);
|
||||||
|
assert.match(html, /Cliente/);
|
||||||
|
assert.match(html, /Configuración fiscal/);
|
||||||
|
assert.match(html, /Condiciones de pago/);
|
||||||
|
assert.match(html, /Líneas de detalle/);
|
||||||
|
assert.doesNotMatch(html, /max-w-5xl/);
|
||||||
|
assert.ok(html.indexOf("Líneas de detalle") < html.indexOf("<aside"));
|
||||||
|
});
|
||||||
@ -1,13 +1,10 @@
|
|||||||
// modules/customer-invoices/src/web/proformas/update/ui/blocks/proforma-update-editor.tsx
|
|
||||||
|
|
||||||
import type { CustomerSelectionOption } from "@erp/customers";
|
import type { CustomerSelectionOption } from "@erp/customers";
|
||||||
import type { SelectFieldItem } from "@repo/rdx-ui/components";
|
import { RdxEditorPageLayout, type SelectFieldItem } from "@repo/rdx-ui/components";
|
||||||
import { preventEnterKeySubmitForm } from "@repo/rdx-ui/helpers";
|
import { preventEnterKeySubmitForm } from "@repo/rdx-ui/helpers";
|
||||||
import { cn } from "@repo/shadcn-ui/lib/utils";
|
import type { SubmitEventHandler } from "react";
|
||||||
|
|
||||||
import { ProformaUpdatePaymentEditor, ProformaUpdateRecipientEditor } from ".";
|
import { ProformaUpdatePaymentEditor, ProformaUpdateRecipientEditor } from ".";
|
||||||
|
|
||||||
import { useTranslation } from "../../../../i18n";
|
|
||||||
import type {
|
import type {
|
||||||
UseUpdateProformaItemsControllerResult,
|
UseUpdateProformaItemsControllerResult,
|
||||||
UseUpdateProformaPaymentControllerResult,
|
UseUpdateProformaPaymentControllerResult,
|
||||||
@ -22,7 +19,7 @@ import { ProformaUpdateTaxEditor } from "./proforma-update-tax-editor";
|
|||||||
type ProformaUpdateEditorProps = {
|
type ProformaUpdateEditorProps = {
|
||||||
formId: string;
|
formId: string;
|
||||||
isSubmitting: boolean;
|
isSubmitting: boolean;
|
||||||
onSubmit: React.SubmitEventHandler<HTMLFormElement>;
|
onSubmit: SubmitEventHandler<HTMLFormElement>;
|
||||||
onReset: () => void;
|
onReset: () => void;
|
||||||
|
|
||||||
selectedCustomer?: CustomerSelectionOption | null;
|
selectedCustomer?: CustomerSelectionOption | null;
|
||||||
@ -42,7 +39,7 @@ export const ProformaUpdateEditorForm = ({
|
|||||||
formId,
|
formId,
|
||||||
isSubmitting,
|
isSubmitting,
|
||||||
onSubmit,
|
onSubmit,
|
||||||
onReset,
|
onReset: _onReset,
|
||||||
selectedCustomer,
|
selectedCustomer,
|
||||||
onChangeCustomerClick,
|
onChangeCustomerClick,
|
||||||
onCreateCustomerClick,
|
onCreateCustomerClick,
|
||||||
@ -53,18 +50,34 @@ export const ProformaUpdateEditorForm = ({
|
|||||||
paymentCtrl,
|
paymentCtrl,
|
||||||
className,
|
className,
|
||||||
}: ProformaUpdateEditorProps) => {
|
}: ProformaUpdateEditorProps) => {
|
||||||
const { t } = useTranslation();
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={cn("p-6", className)}>
|
<RdxEditorPageLayout
|
||||||
<form id={formId} noValidate onKeyDown={preventEnterKeySubmitForm} onSubmit={onSubmit}>
|
aside={
|
||||||
<div className="grid grid-cols-1 2xl:grid-cols-4 gap-6 2xl:gap-12 2xl:space-y-12">
|
<div className="flex min-w-0 flex-col gap-6">
|
||||||
<div className="2xl:col-span-3 space-y-6 gap-6 2xl:gap-12 2xl:space-y-12">
|
<ProformaUpdateRecipientEditor
|
||||||
<ProformaUpdateHeaderEditor
|
disabled={isSubmitting}
|
||||||
className="2xl:col-span-full"
|
onChangeCustomerClick={onChangeCustomerClick}
|
||||||
disabled={isSubmitting}
|
onCreateCustomerClick={onCreateCustomerClick}
|
||||||
seriesOptions={seriesOptions}
|
selectedCustomer={selectedCustomer}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
<ProformaUpdateTaxEditor taxCtrl={taxCtrl} taxRegimeOptions={taxCtrl.taxRegimeOptions} />
|
||||||
|
|
||||||
|
<ProformaUpdatePaymentEditor
|
||||||
|
className="w-full"
|
||||||
|
disabled={isSubmitting || paymentCtrl.isLoading}
|
||||||
|
paymentMethodOptions={paymentCtrl.paymentMethodOptions}
|
||||||
|
paymentTermOptions={paymentCtrl.paymentTermOptions}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
className={className}
|
||||||
|
complexity="complex"
|
||||||
|
>
|
||||||
|
<div className="min-w-0">
|
||||||
|
<form id={formId} noValidate onKeyDown={preventEnterKeySubmitForm} onSubmit={onSubmit}>
|
||||||
|
<div className="flex min-w-0 flex-col gap-6">
|
||||||
|
<ProformaUpdateHeaderEditor disabled={isSubmitting} seriesOptions={seriesOptions} />
|
||||||
|
|
||||||
<ProformaUpdateItemsEditor
|
<ProformaUpdateItemsEditor
|
||||||
disabled={isSubmitting}
|
disabled={isSubmitting}
|
||||||
@ -73,31 +86,8 @@ export const ProformaUpdateEditorForm = ({
|
|||||||
totalsCtrl={totalsCtrl}
|
totalsCtrl={totalsCtrl}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className="2xl:col-span-1 space-y-6 2xl:space-y-12">
|
</form>
|
||||||
<ProformaUpdateRecipientEditor
|
</div>
|
||||||
className="2xl:col-span-1"
|
</RdxEditorPageLayout>
|
||||||
disabled={isSubmitting}
|
|
||||||
onChangeCustomerClick={onChangeCustomerClick}
|
|
||||||
onCreateCustomerClick={onCreateCustomerClick}
|
|
||||||
selectedCustomer={selectedCustomer}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<ProformaUpdateTaxEditor
|
|
||||||
className="2xl:col-span-1"
|
|
||||||
taxCtrl={taxCtrl}
|
|
||||||
taxRegimeOptions={taxCtrl.taxRegimeOptions}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<ProformaUpdatePaymentEditor
|
|
||||||
className="w-full"
|
|
||||||
disabled={isSubmitting || paymentCtrl.isLoading}
|
|
||||||
paymentMethodOptions={paymentCtrl.paymentMethodOptions}
|
|
||||||
paymentTermOptions={paymentCtrl.paymentTermOptions}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
{/* Footer fijo con totales */}
|
|
||||||
</form>
|
|
||||||
</div>
|
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@ -1,7 +1,7 @@
|
|||||||
import {
|
import {
|
||||||
DatePickerField,
|
DatePickerField,
|
||||||
FormSectionCard,
|
RdxEditorSection,
|
||||||
FormSectionGrid,
|
RdxFormGrid,
|
||||||
SelectField,
|
SelectField,
|
||||||
type SelectFieldItem,
|
type SelectFieldItem,
|
||||||
TextAreaField,
|
TextAreaField,
|
||||||
@ -29,13 +29,13 @@ export const ProformaUpdateHeaderEditor = ({
|
|||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<FormSectionCard
|
<RdxEditorSection
|
||||||
className={className}
|
className={className}
|
||||||
disabled={disabled}
|
|
||||||
icon={<FileTextIcon className="size-5" />}
|
icon={<FileTextIcon className="size-5" />}
|
||||||
title={t("form_groups.proformas.basic_info.title")}
|
title={t("form_groups.proformas.basic_info.title")}
|
||||||
|
variant="panel"
|
||||||
>
|
>
|
||||||
<FormSectionGrid className="md:grid-cols-6 2 2xl:grid-cols-12">
|
<RdxFormGrid className="md:grid-cols-6 2xl:grid-cols-12">
|
||||||
<TextField
|
<TextField
|
||||||
className="md:col-span-2"
|
className="md:col-span-2"
|
||||||
disabled={true}
|
disabled={true}
|
||||||
@ -108,7 +108,7 @@ export const ProformaUpdateHeaderEditor = ({
|
|||||||
placeholder={t("form_fields.proformas.notes.placeholder")}
|
placeholder={t("form_fields.proformas.notes.placeholder")}
|
||||||
readOnly={readOnly}
|
readOnly={readOnly}
|
||||||
/>
|
/>
|
||||||
</FormSectionGrid>
|
</RdxFormGrid>
|
||||||
</FormSectionCard>
|
</RdxEditorSection>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@ -1,7 +1,6 @@
|
|||||||
import { FormSectionCard } from "@repo/rdx-ui/components";
|
import { RdxEditorSection } from "@repo/rdx-ui/components";
|
||||||
import { cn } from "@repo/shadcn-ui/lib/utils";
|
import { cn } from "@repo/shadcn-ui/lib/utils";
|
||||||
import { ListIcon } from "lucide-react";
|
import { ListIcon } from "lucide-react";
|
||||||
import type { ComponentProps } from "react";
|
|
||||||
|
|
||||||
import { useTranslation } from "../../../../i18n";
|
import { useTranslation } from "../../../../i18n";
|
||||||
import type {
|
import type {
|
||||||
@ -11,10 +10,11 @@ import type {
|
|||||||
} from "../../controllers";
|
} from "../../controllers";
|
||||||
import { ProformaLineEditor } from "../blocks";
|
import { ProformaLineEditor } from "../blocks";
|
||||||
|
|
||||||
interface ProformaUpdateItemsEditorProps extends ComponentProps<"fieldset"> {
|
interface ProformaUpdateItemsEditorProps {
|
||||||
totalsCtrl: UseUpdateProformaTotalsControllerResult;
|
totalsCtrl: UseUpdateProformaTotalsControllerResult;
|
||||||
itemsCtrl: UseUpdateProformaItemsControllerResult;
|
itemsCtrl: UseUpdateProformaItemsControllerResult;
|
||||||
taxCtrl: UseUpdateProformaTaxControllerResult;
|
taxCtrl: UseUpdateProformaTaxControllerResult;
|
||||||
|
disabled?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const ProformaUpdateItemsEditor = ({
|
export const ProformaUpdateItemsEditor = ({
|
||||||
@ -22,7 +22,6 @@ export const ProformaUpdateItemsEditor = ({
|
|||||||
itemsCtrl,
|
itemsCtrl,
|
||||||
taxCtrl,
|
taxCtrl,
|
||||||
disabled,
|
disabled,
|
||||||
...props
|
|
||||||
}: ProformaUpdateItemsEditorProps) => {
|
}: ProformaUpdateItemsEditorProps) => {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
|
|
||||||
@ -30,11 +29,15 @@ export const ProformaUpdateItemsEditor = ({
|
|||||||
const { itemErrors } = itemsCtrl;
|
const { itemErrors } = itemsCtrl;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<FormSectionCard
|
<RdxEditorSection
|
||||||
className={cn(itemErrors.length ? "bg-red-100" : "")}
|
className={cn(
|
||||||
disabled={disabled}
|
itemErrors.length
|
||||||
|
? "rounded-xl border border-destructive/40 bg-destructive/5 px-4 py-4 md:px-5"
|
||||||
|
: ""
|
||||||
|
)}
|
||||||
icon={<ListIcon className="size-3" />}
|
icon={<ListIcon className="size-3" />}
|
||||||
title={t("form_groups.items.title", "Líneas de detalle")}
|
title={t("form_groups.items.title", "Líneas de detalle")}
|
||||||
|
variant="panel"
|
||||||
>
|
>
|
||||||
<ProformaLineEditor
|
<ProformaLineEditor
|
||||||
addItemAtStart={itemsCtrl.addItemAtStart}
|
addItemAtStart={itemsCtrl.addItemAtStart}
|
||||||
@ -54,6 +57,6 @@ export const ProformaUpdateItemsEditor = ({
|
|||||||
showLineTaxes={taxCtrl.usesPerLineTax}
|
showLineTaxes={taxCtrl.usesPerLineTax}
|
||||||
totals={totals}
|
totals={totals}
|
||||||
/>
|
/>
|
||||||
</FormSectionCard>
|
</RdxEditorSection>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@ -1,14 +1,6 @@
|
|||||||
import type { CustomerSelectionOption } from "@erp/customers";
|
import type { CustomerSelectionOption } from "@erp/customers";
|
||||||
import {
|
import { RdxEditorSection } from "@repo/rdx-ui/components";
|
||||||
Button,
|
import { Button } from "@repo/shadcn-ui/components";
|
||||||
Card,
|
|
||||||
CardContent,
|
|
||||||
CardDescription,
|
|
||||||
CardFooter,
|
|
||||||
CardHeader,
|
|
||||||
CardTitle,
|
|
||||||
} from "@repo/shadcn-ui/components";
|
|
||||||
import { cn } from "@repo/shadcn-ui/lib/utils";
|
|
||||||
import { PlusIcon, RefreshCwIcon, UserIcon } from "lucide-react";
|
import { PlusIcon, RefreshCwIcon, UserIcon } from "lucide-react";
|
||||||
|
|
||||||
import { useTranslation } from "../../../../i18n";
|
import { useTranslation } from "../../../../i18n";
|
||||||
@ -40,57 +32,43 @@ export const ProformaUpdateRecipientEditor = ({
|
|||||||
const showActions = !readOnly;
|
const showActions = !readOnly;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Card className={className}>
|
<RdxEditorSection
|
||||||
<CardHeader className={"flex flex-row items-start gap-4"}>
|
className={className}
|
||||||
<div className="flex items-start gap-3">
|
description={t("form_groups.proformas.customer.description", "Cliente destinatario de la proforma")}
|
||||||
<div
|
icon={<UserIcon className="size-5" />}
|
||||||
className={cn(
|
title={t("form_groups.proformas.customer.title", "Cliente")}
|
||||||
"flex h-10 w-10 shrink-0 items-center justify-center rounded-md",
|
variant="panel"
|
||||||
disabled ? "bg-muted text-muted-foreground" : "bg-primary/10 text-primary"
|
>
|
||||||
)}
|
<div className="min-w-0 space-y-5">
|
||||||
>
|
|
||||||
<UserIcon className="size-5" />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className="space-y-1">
|
|
||||||
<CardTitle className="text-base font-semibold tracking-tight">
|
|
||||||
{t("form_groups.proformas.customer.title", "Cliente")}
|
|
||||||
</CardTitle>
|
|
||||||
<CardDescription>
|
|
||||||
{t("form_groups.proformas.customer.description", "Cliente destinatario de la proforma")}
|
|
||||||
</CardDescription>
|
|
||||||
</div>
|
|
||||||
</CardHeader>
|
|
||||||
<CardContent className="flex-1">
|
|
||||||
<SelectedRecipientSummary disabled={disabled} recipient={selectedCustomer} />
|
<SelectedRecipientSummary disabled={disabled} recipient={selectedCustomer} />
|
||||||
</CardContent>
|
|
||||||
|
|
||||||
{showActions && (
|
{showActions ? (
|
||||||
<CardFooter className="flex gap-2 2xl:justify-between">
|
<div className="flex flex-wrap items-center gap-2">
|
||||||
<Button
|
<Button
|
||||||
className="justify-center"
|
className="justify-center"
|
||||||
disabled={disabled}
|
disabled={disabled}
|
||||||
onClick={onCreateCustomerClick}
|
onClick={onCreateCustomerClick}
|
||||||
type="button"
|
type="button"
|
||||||
variant="outline"
|
variant="outline"
|
||||||
>
|
>
|
||||||
<PlusIcon className="mr-2 size-4" />
|
<PlusIcon className="mr-2 size-4" />
|
||||||
{t("customers.selected_customer.new_customer", "Nuevo cliente")}
|
{t("customers.selected_customer.new_customer", "Nuevo cliente")}
|
||||||
</Button>
|
</Button>
|
||||||
<Button
|
<Button
|
||||||
className="justify-center"
|
className="justify-center"
|
||||||
disabled={disabled}
|
disabled={disabled}
|
||||||
onClick={onChangeCustomerClick}
|
onClick={onChangeCustomerClick}
|
||||||
type="button"
|
type="button"
|
||||||
variant={selectedCustomer ? "outline" : "default"}
|
variant={selectedCustomer ? "outline" : "default"}
|
||||||
>
|
>
|
||||||
<RefreshCwIcon className="mr-2 size-4" />
|
<RefreshCwIcon className="mr-2 size-4" />
|
||||||
{selectedCustomer
|
{selectedCustomer
|
||||||
? t("customers.selected_customer.change", "Cambiar cliente")
|
? t("customers.selected_customer.change", "Cambiar cliente")
|
||||||
: t("customers.selected_customer.select", "Seleccionar cliente")}
|
: t("customers.selected_customer.select", "Seleccionar cliente")}
|
||||||
</Button>
|
</Button>
|
||||||
</CardFooter>
|
</div>
|
||||||
)}
|
) : null}
|
||||||
</Card>
|
</div>
|
||||||
|
</RdxEditorSection>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@ -1,6 +1,6 @@
|
|||||||
import {
|
import {
|
||||||
FormSectionCard,
|
RdxEditorSection,
|
||||||
FormSectionGrid,
|
RdxFormGrid,
|
||||||
SelectField,
|
SelectField,
|
||||||
type SelectFieldItem,
|
type SelectFieldItem,
|
||||||
SwitchField,
|
SwitchField,
|
||||||
@ -38,17 +38,17 @@ export const ProformaUpdateTaxEditor = ({
|
|||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<FormSectionCard
|
<RdxEditorSection
|
||||||
className={className}
|
className={className}
|
||||||
description={t(
|
description={t(
|
||||||
"form_groups.proformas.taxes.description",
|
"form_groups.proformas.taxes.description",
|
||||||
"Configura los valores fiscales por defecto de la proforma. Los impuestos aplicados y los totales se calculan desde las líneas valoradas."
|
"Configura los valores fiscales por defecto de la proforma. Los impuestos aplicados y los totales se calculan desde las líneas valoradas."
|
||||||
)}
|
)}
|
||||||
disabled={disabled}
|
|
||||||
icon={<LandmarkIcon className="size-5" />}
|
icon={<LandmarkIcon className="size-5" />}
|
||||||
title={t("form_groups.proformas.taxes.title", "Configuración fiscal")}
|
title={t("form_groups.proformas.taxes.title", "Configuración fiscal")}
|
||||||
|
variant="panel"
|
||||||
>
|
>
|
||||||
<FormSectionGrid>
|
<RdxFormGrid>
|
||||||
<SelectField
|
<SelectField
|
||||||
className="col-span-full"
|
className="col-span-full"
|
||||||
disabled={disabled}
|
disabled={disabled}
|
||||||
@ -77,7 +77,7 @@ export const ProformaUpdateTaxEditor = ({
|
|||||||
/>
|
/>
|
||||||
|
|
||||||
<SelectField
|
<SelectField
|
||||||
className="md:col-span-4 md:col-start-1 lg:col-span-2 2xl:col-span-6"
|
className="md:col-span-12 md:col-start-1 min-[480px]:flex-row min-[480px]:items-center min-[480px]:*:w-auto min-[480px]:[&>[data-slot=field-label]]:min-w-0 min-[480px]:[&>[data-slot=field-label]]:flex-auto min-[480px]:[&>[button]]:ml-auto min-[480px]:[&>[button]]:min-w-[11rem] min-[480px]:[&>[button]]:flex-none [&>[data-slot=field-description]]:basis-full [&>[data-slot=field-description]]:w-full [&>[data-slot=field-description]]:pl-0 [&>[data-slot=field-error]]:basis-full [&>[data-slot=field-error]]:w-full [&>[data-slot=field-error]]:pl-0"
|
||||||
description={
|
description={
|
||||||
taxCtrl.usesSingleTax
|
taxCtrl.usesSingleTax
|
||||||
? t("form_fields.proformas.default_tax_percentage.description", "")
|
? t("form_fields.proformas.default_tax_percentage.description", "")
|
||||||
@ -115,7 +115,7 @@ export const ProformaUpdateTaxEditor = ({
|
|||||||
<>
|
<>
|
||||||
{t(
|
{t(
|
||||||
"form_fields.proformas.has_equivalence_surcharge.label",
|
"form_fields.proformas.has_equivalence_surcharge.label",
|
||||||
"Recargo de equivalencia"
|
"Activar recargo de equivalencia"
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{taxCtrl.hasRecPercentage && !taxCtrl.usesPerLineTax ? (
|
{taxCtrl.hasRecPercentage && !taxCtrl.usesPerLineTax ? (
|
||||||
@ -139,7 +139,7 @@ export const ProformaUpdateTaxEditor = ({
|
|||||||
/>
|
/>
|
||||||
|
|
||||||
<SelectField
|
<SelectField
|
||||||
className="md:col-span-4 md:col-start-1 lg:col-span-2 2xl:col-span-6"
|
className="md:col-span-12 md:col-start-1 min-[480px]:flex-row min-[480px]:items-center min-[480px]:*:w-auto min-[480px]:[&>[data-slot=field-label]]:min-w-0 min-[480px]:[&>[data-slot=field-label]]:flex-auto min-[480px]:[&>[button]]:ml-auto min-[480px]:[&>[button]]:min-w-[11rem] min-[480px]:[&>[button]]:flex-none [&>[data-slot=field-description]]:basis-full [&>[data-slot=field-description]]:w-full [&>[data-slot=field-description]]:pl-0 [&>[data-slot=field-error]]:basis-full [&>[data-slot=field-error]]:w-full [&>[data-slot=field-error]]:pl-0"
|
||||||
deserialize={(value) => (value === null || value === "" ? null : Number(value))}
|
deserialize={(value) => (value === null || value === "" ? null : Number(value))}
|
||||||
disabled={disabled || !taxCtrl.hasRetentionPercentage}
|
disabled={disabled || !taxCtrl.hasRetentionPercentage}
|
||||||
inputClassName="bg-background"
|
inputClassName="bg-background"
|
||||||
@ -159,7 +159,7 @@ export const ProformaUpdateTaxEditor = ({
|
|||||||
readOnly={readOnly}
|
readOnly={readOnly}
|
||||||
serialize={(value) => (typeof value === "number" ? String(value) : "")}
|
serialize={(value) => (typeof value === "number" ? String(value) : "")}
|
||||||
/>
|
/>
|
||||||
</FormSectionGrid>
|
</RdxFormGrid>
|
||||||
</FormSectionCard>
|
</RdxEditorSection>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@ -1,7 +1,13 @@
|
|||||||
import { ErrorAlert, PageErrorState } from "@erp/core/components";
|
import { ErrorAlert, PageErrorState } from "@erp/core/components";
|
||||||
import { UnsavedChangesProvider, useReturnToNavigation } from "@erp/core/hooks";
|
import { UnsavedChangesProvider, useReturnToNavigation } from "@erp/core/hooks";
|
||||||
import { SelectCustomerDialog } from "@erp/customers";
|
import { SelectCustomerDialog } from "@erp/customers";
|
||||||
import { AppContent, PercentageField } from "@repo/rdx-ui/components";
|
import {
|
||||||
|
AppContent,
|
||||||
|
CancelActionButton,
|
||||||
|
PercentageField,
|
||||||
|
RdxEditorStickyFooter,
|
||||||
|
RhfSubmitActionButton,
|
||||||
|
} from "@repo/rdx-ui/components";
|
||||||
import { FormProvider } from "react-hook-form";
|
import { FormProvider } from "react-hook-form";
|
||||||
|
|
||||||
import { useTranslation } from "../../../../i18n";
|
import { useTranslation } from "../../../../i18n";
|
||||||
@ -101,70 +107,91 @@ export const ProformaUpdatePage = () => {
|
|||||||
</ProformaInfoAlert>
|
</ProformaInfoAlert>
|
||||||
</ProformaUpdateHeader>
|
</ProformaUpdateHeader>
|
||||||
|
|
||||||
{updateCtrl.isUpdateError && (
|
<div className="flex min-h-0 flex-1 flex-col bg-muted/20">
|
||||||
<ErrorAlert
|
<div className="min-h-0 flex-1 overflow-y-auto">
|
||||||
message={
|
{updateCtrl.isUpdateError && (
|
||||||
(updateCtrl.updateError as Error)?.message ??
|
<ErrorAlert
|
||||||
t("pages.proformas.update.error_msg", "Revisa los datos e inténtalo de nuevo.")
|
message={
|
||||||
}
|
(updateCtrl.updateError as Error)?.message ??
|
||||||
title={t("pages.proformas.update.error_title", "No se pudo guardar los cambios")}
|
t("pages.proformas.update.error_msg", "Revisa los datos e inténtalo de nuevo.")
|
||||||
/>
|
}
|
||||||
)}
|
title={t("pages.proformas.update.error_title", "No se pudo guardar los cambios")}
|
||||||
|
/>
|
||||||
{updateCtrl.isSeriesLoadError && (
|
|
||||||
<ErrorAlert
|
|
||||||
message={
|
|
||||||
updateCtrl.seriesLoadError instanceof Error
|
|
||||||
? updateCtrl.seriesLoadError.message
|
|
||||||
: t(
|
|
||||||
"pages.proformas.update.series_load_error.message",
|
|
||||||
"Inténtalo de nuevo más tarde."
|
|
||||||
)
|
|
||||||
}
|
|
||||||
title={t(
|
|
||||||
"pages.proformas.update.series_load_error.title",
|
|
||||||
"No se pudieron cargar las series de facturación"
|
|
||||||
)}
|
)}
|
||||||
/>
|
|
||||||
)}
|
|
||||||
<div className="flex-1 overflow-y-auto">
|
|
||||||
<ProformaUpdateEditorForm
|
|
||||||
formId={updateCtrl.formId}
|
|
||||||
isSubmitting={updateCtrl.isUpdating}
|
|
||||||
itemsCtrl={updateCtrl.itemsCtrl}
|
|
||||||
onChangeCustomerClick={selectCustomerCtrl.selectCtrl.openDialog}
|
|
||||||
onCreateCustomerClick={() => null}
|
|
||||||
onReset={updateCtrl.resetForm}
|
|
||||||
onSubmit={updateCtrl.onSubmit}
|
|
||||||
paymentCtrl={updateCtrl.paymentCtrl}
|
|
||||||
selectedCustomer={updateCtrl.selectedCustomer}
|
|
||||||
seriesOptions={updateCtrl.seriesOptions}
|
|
||||||
taxCtrl={updateCtrl.taxCtrl}
|
|
||||||
totalsCtrl={updateCtrl.totalsCtrl}
|
|
||||||
/>
|
|
||||||
|
|
||||||
{/* Sticky Footer */}
|
{updateCtrl.isSeriesLoadError && (
|
||||||
<footer className="sticky bottom-0 z-10 shrink-0">
|
<ErrorAlert
|
||||||
<ProformaCompactTotals
|
message={
|
||||||
className="bg-background"
|
updateCtrl.seriesLoadError instanceof Error
|
||||||
currency={updateCtrl.currencyCode}
|
? updateCtrl.seriesLoadError.message
|
||||||
globalDiscountField={
|
: t(
|
||||||
<PercentageField
|
"pages.proformas.update.series_load_error.message",
|
||||||
className="md:col-span-4 md:col-start-1"
|
"Inténtalo de nuevo más tarde."
|
||||||
disabled={updateCtrl.isUpdating}
|
)
|
||||||
inputClassName="bg-background"
|
}
|
||||||
label={t(
|
title={t(
|
||||||
"proformas.update.totals.globalDiscountPercentage",
|
"pages.proformas.update.series_load_error.title",
|
||||||
"Descuento global"
|
"No se pudieron cargar las series de facturación"
|
||||||
)}
|
)}
|
||||||
name="globalDiscountPercentage"
|
/>
|
||||||
/>
|
)}
|
||||||
}
|
|
||||||
showRec={updateCtrl.taxCtrl.hasRecPercentage}
|
<ProformaUpdateEditorForm
|
||||||
showRetention={updateCtrl.taxCtrl.hasRetentionPercentage}
|
formId={updateCtrl.formId}
|
||||||
totals={updateCtrl.totalsCtrl.totals}
|
isSubmitting={updateCtrl.isUpdating}
|
||||||
|
itemsCtrl={updateCtrl.itemsCtrl}
|
||||||
|
onChangeCustomerClick={selectCustomerCtrl.selectCtrl.openDialog}
|
||||||
|
onCreateCustomerClick={() => null}
|
||||||
|
onReset={updateCtrl.resetForm}
|
||||||
|
onSubmit={updateCtrl.onSubmit}
|
||||||
|
paymentCtrl={updateCtrl.paymentCtrl}
|
||||||
|
selectedCustomer={updateCtrl.selectedCustomer}
|
||||||
|
seriesOptions={updateCtrl.seriesOptions}
|
||||||
|
taxCtrl={updateCtrl.taxCtrl}
|
||||||
|
totalsCtrl={updateCtrl.totalsCtrl}
|
||||||
/>
|
/>
|
||||||
</footer>
|
</div>
|
||||||
|
|
||||||
|
<RdxEditorStickyFooter
|
||||||
|
leading={
|
||||||
|
<ProformaCompactTotals
|
||||||
|
currency={updateCtrl.currencyCode}
|
||||||
|
globalDiscountField={
|
||||||
|
<PercentageField
|
||||||
|
className="md:col-span-4 md:col-start-1"
|
||||||
|
disabled={updateCtrl.isUpdating}
|
||||||
|
inputClassName="bg-background"
|
||||||
|
label={t(
|
||||||
|
"proformas.update.totals.globalDiscountPercentage",
|
||||||
|
"Descuento global"
|
||||||
|
)}
|
||||||
|
name="globalDiscountPercentage"
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
showRec={updateCtrl.taxCtrl.hasRecPercentage}
|
||||||
|
showRetention={updateCtrl.taxCtrl.hasRetentionPercentage}
|
||||||
|
totals={updateCtrl.totalsCtrl.totals}
|
||||||
|
variant="embedded"
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
primary={
|
||||||
|
<RhfSubmitActionButton
|
||||||
|
busyLabel="Guardando..."
|
||||||
|
disabled={updateCtrl.isUpdating}
|
||||||
|
formId={updateCtrl.formId}
|
||||||
|
isBusy={updateCtrl.form.formState.isSubmitting}
|
||||||
|
label="Guardar"
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
secondary={
|
||||||
|
<CancelActionButton
|
||||||
|
disabled={updateCtrl.isUpdating}
|
||||||
|
label="Cancelar"
|
||||||
|
onCancel={handleCancel}
|
||||||
|
variant="outline"
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<SelectCustomerDialog ctrl={selectCustomerCtrl.selectCtrl} />
|
<SelectCustomerDialog ctrl={selectCustomerCtrl.selectCtrl} />
|
||||||
|
|||||||
@ -0,0 +1,95 @@
|
|||||||
|
import assert from "node:assert/strict";
|
||||||
|
import test from "node:test";
|
||||||
|
import { isValidElement } from "react";
|
||||||
|
import { renderToStaticMarkup } from "react-dom/server";
|
||||||
|
|
||||||
|
import { CustomerCreateHeader } from "./customer-create-header";
|
||||||
|
|
||||||
|
const resolveElement = (node: unknown): unknown => {
|
||||||
|
if (Array.isArray(node)) {
|
||||||
|
return node.map(resolveElement);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!isValidElement(node)) {
|
||||||
|
return node;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (typeof node.type === "function") {
|
||||||
|
return resolveElement(node.type(node.props));
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
type: node.type,
|
||||||
|
props: {
|
||||||
|
...node.props,
|
||||||
|
children: resolveElement(node.props.children),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
const findElementsByType = (
|
||||||
|
node: unknown,
|
||||||
|
type: string
|
||||||
|
): Array<{ type: string; props: Record<string, unknown> }> => {
|
||||||
|
if (Array.isArray(node)) {
|
||||||
|
return node.flatMap((child) => findElementsByType(child, type));
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!node || typeof node !== "object" || !("type" in node) || !("props" in node)) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
const element = node as { type: string; props: Record<string, unknown> };
|
||||||
|
const children = findElementsByType(element.props.children, type);
|
||||||
|
|
||||||
|
if (element.type === type) {
|
||||||
|
return [element, ...children];
|
||||||
|
}
|
||||||
|
|
||||||
|
return children;
|
||||||
|
};
|
||||||
|
|
||||||
|
const labels = {
|
||||||
|
title: "Crear cliente",
|
||||||
|
back: "Volver",
|
||||||
|
modified: "Modificada",
|
||||||
|
cancel: "Cancelar",
|
||||||
|
save: "Guardar",
|
||||||
|
saving: "Guardando...",
|
||||||
|
moreActions: "Más acciones",
|
||||||
|
keyboardShortcuts: "Ver atajos",
|
||||||
|
delete: "Eliminar",
|
||||||
|
};
|
||||||
|
|
||||||
|
test("renderiza título y acciones principales del header de create", () => {
|
||||||
|
const html = renderToStaticMarkup(
|
||||||
|
<CustomerCreateHeader formId="customer-create-form" labels={labels} />
|
||||||
|
);
|
||||||
|
|
||||||
|
assert.match(html, /<h1[^>]*>Crear cliente<\/h1>/);
|
||||||
|
assert.match(html, /Guardar/);
|
||||||
|
assert.match(html, /Cancelar/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("preserva el callback de cancelación", async () => {
|
||||||
|
let wasCancelled = false;
|
||||||
|
|
||||||
|
const resolved = resolveElement(
|
||||||
|
<CustomerCreateHeader
|
||||||
|
formId="customer-create-form"
|
||||||
|
labels={labels}
|
||||||
|
onCancel={() => {
|
||||||
|
wasCancelled = true;
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
|
||||||
|
const buttons = findElementsByType(resolved, "button");
|
||||||
|
const backButton = buttons.find((button) => button.props["aria-label"] === "Volver");
|
||||||
|
|
||||||
|
assert.ok(backButton);
|
||||||
|
|
||||||
|
await backButton?.props.onClick?.();
|
||||||
|
|
||||||
|
assert.equal(wasCancelled, true);
|
||||||
|
});
|
||||||
@ -0,0 +1,46 @@
|
|||||||
|
import assert from "node:assert/strict";
|
||||||
|
import test from "node:test";
|
||||||
|
import { renderToStaticMarkup } from "react-dom/server";
|
||||||
|
import { FormProvider, useForm } from "react-hook-form";
|
||||||
|
|
||||||
|
import type { CustomerCreateForm } from "../../entities";
|
||||||
|
import { buildCustomerCreateDefault } from "../../utils";
|
||||||
|
import { CustomerCreateEditorForm } from "./customer-create-editor";
|
||||||
|
|
||||||
|
const renderForm = () => {
|
||||||
|
const Harness = () => {
|
||||||
|
const form = useForm<CustomerCreateForm>({
|
||||||
|
defaultValues: buildCustomerCreateDefault(),
|
||||||
|
});
|
||||||
|
|
||||||
|
return (
|
||||||
|
<FormProvider {...form}>
|
||||||
|
<CustomerCreateEditorForm
|
||||||
|
fiscalCtrl={{
|
||||||
|
paymentMethodOptions: [],
|
||||||
|
paymentTermOptions: [],
|
||||||
|
taxRegimeOptions: [],
|
||||||
|
}}
|
||||||
|
formId="customer-create-form"
|
||||||
|
isSubmitting={false}
|
||||||
|
onReset={() => undefined}
|
||||||
|
onSubmit={(event) => {
|
||||||
|
event.preventDefault();
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</FormProvider>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
return renderToStaticMarkup(<Harness />);
|
||||||
|
};
|
||||||
|
|
||||||
|
test("renderiza el formulario create con id, secciones y fields representativos", () => {
|
||||||
|
const html = renderForm();
|
||||||
|
|
||||||
|
assert.match(html, /id="customer-create-form"/);
|
||||||
|
assert.equal((html.match(/<section/g) ?? []).length, 5);
|
||||||
|
assert.match(html, /name="name"/);
|
||||||
|
assert.match(html, /name="country"/);
|
||||||
|
assert.match(html, /name="primaryEmail"/);
|
||||||
|
});
|
||||||
@ -1,5 +1,6 @@
|
|||||||
import { preventEnterKeySubmitForm } from "@repo/rdx-ui/helpers";
|
import { preventEnterKeySubmitForm } from "@repo/rdx-ui/helpers";
|
||||||
import { cn } from "@repo/shadcn-ui/lib/utils";
|
import { cn } from "@repo/shadcn-ui/lib/utils";
|
||||||
|
import type { SubmitEventHandler } from "react";
|
||||||
|
|
||||||
import {
|
import {
|
||||||
CustomerAdditionalConfigEditor,
|
CustomerAdditionalConfigEditor,
|
||||||
@ -13,7 +14,7 @@ import type { UseCustomerCreateFiscalControllerResult } from "../../controllers"
|
|||||||
type CustomerCreateEditorProps = {
|
type CustomerCreateEditorProps = {
|
||||||
formId: string;
|
formId: string;
|
||||||
isSubmitting: boolean;
|
isSubmitting: boolean;
|
||||||
onSubmit: React.SubmitEventHandler<HTMLFormElement>;
|
onSubmit: SubmitEventHandler<HTMLFormElement>;
|
||||||
onReset: () => void;
|
onReset: () => void;
|
||||||
|
|
||||||
fiscalCtrl: UseCustomerCreateFiscalControllerResult;
|
fiscalCtrl: UseCustomerCreateFiscalControllerResult;
|
||||||
@ -32,25 +33,23 @@ export const CustomerCreateEditorForm = ({
|
|||||||
className,
|
className,
|
||||||
}: CustomerCreateEditorProps) => {
|
}: CustomerCreateEditorProps) => {
|
||||||
return (
|
return (
|
||||||
<div className={cn("p-6", className)}>
|
<form
|
||||||
<form
|
className={cn("flex min-w-0 flex-col gap-6", className)}
|
||||||
className="space-y-6 2xl:space-y-12"
|
id={formId}
|
||||||
id={formId}
|
noValidate
|
||||||
noValidate
|
onKeyDown={preventEnterKeySubmitForm}
|
||||||
onKeyDown={preventEnterKeySubmitForm}
|
onReset={onReset}
|
||||||
onReset={onReset}
|
onSubmit={onSubmit}
|
||||||
onSubmit={onSubmit}
|
>
|
||||||
>
|
<CustomerBasicInfoEditor disabled={isSubmitting} />
|
||||||
<CustomerBasicInfoEditor disabled={isSubmitting} />
|
<CustomerAddressEditor disabled={isSubmitting} />
|
||||||
<CustomerAddressEditor disabled={isSubmitting} />
|
<CustomerContactEditor disabled={isSubmitting} />
|
||||||
<CustomerContactEditor disabled={isSubmitting} />
|
<CustomerFiscalEditor
|
||||||
<CustomerFiscalEditor
|
paymentMethodOptions={fiscalCtrl.paymentMethodOptions}
|
||||||
paymentMethodOptions={fiscalCtrl.paymentMethodOptions}
|
paymentTermOptions={fiscalCtrl.paymentTermOptions}
|
||||||
paymentTermOptions={fiscalCtrl.paymentTermOptions}
|
taxRegimeOptions={fiscalCtrl.taxRegimeOptions}
|
||||||
taxRegimeOptions={fiscalCtrl.taxRegimeOptions}
|
/>
|
||||||
/>
|
<CustomerAdditionalConfigEditor disabled={isSubmitting} />
|
||||||
<CustomerAdditionalConfigEditor disabled={isSubmitting} />
|
</form>
|
||||||
</form>
|
|
||||||
</div>
|
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@ -1,5 +1,6 @@
|
|||||||
import { ErrorAlert } from "@erp/core/components";
|
import { ErrorAlert } from "@erp/core/components";
|
||||||
import { UnsavedChangesProvider, useReturnToNavigation } from "@erp/core/hooks";
|
import { UnsavedChangesProvider, useReturnToNavigation } from "@erp/core/hooks";
|
||||||
|
import { RdxEditorPageLayout } from "@repo/rdx-ui/components";
|
||||||
import { FormProvider } from "react-hook-form";
|
import { FormProvider } from "react-hook-form";
|
||||||
|
|
||||||
import { useTranslation } from "../../../i18n";
|
import { useTranslation } from "../../../i18n";
|
||||||
@ -42,22 +43,29 @@ export const CustomerCreatePage = () => {
|
|||||||
//onExportPdf={handleExportPdf}
|
//onExportPdf={handleExportPdf}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{createCtrl.isCreateError && (
|
<div className="flex-1 overflow-y-auto bg-muted/20">
|
||||||
<ErrorAlert
|
<RdxEditorPageLayout complexity="simple">
|
||||||
message={(createCtrl.createError as Error)?.message ?? t("create.errors.message")}
|
<div className="rounded-2xl border border-border/60 bg-background px-4 py-4 shadow-xs md:px-6 md:py-6">
|
||||||
title={t("create.errors.title")}
|
<div className="flex min-w-0 flex-col gap-6">
|
||||||
/>
|
{createCtrl.isCreateError ? (
|
||||||
)}
|
<ErrorAlert
|
||||||
|
message={
|
||||||
|
(createCtrl.createError as Error)?.message ?? t("create.errors.message")
|
||||||
|
}
|
||||||
|
title={t("create.errors.title")}
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
|
|
||||||
<div className="flex-1 overflow-y-auto">
|
<CustomerCreateEditorForm
|
||||||
<CustomerCreateEditorForm
|
fiscalCtrl={createCtrl.fiscalCtrl}
|
||||||
className="max-w-5xl mx-auto mb-12"
|
formId={createCtrl.formId}
|
||||||
fiscalCtrl={createCtrl.fiscalCtrl}
|
isSubmitting={createCtrl.isCreating}
|
||||||
formId={createCtrl.formId}
|
onReset={createCtrl.resetForm}
|
||||||
isSubmitting={createCtrl.isCreating}
|
onSubmit={createCtrl.onSubmit}
|
||||||
onReset={createCtrl.resetForm}
|
/>
|
||||||
onSubmit={createCtrl.onSubmit}
|
</div>
|
||||||
/>
|
</div>
|
||||||
|
</RdxEditorPageLayout>
|
||||||
</div>
|
</div>
|
||||||
</UnsavedChangesProvider>
|
</UnsavedChangesProvider>
|
||||||
</FormProvider>
|
</FormProvider>
|
||||||
|
|||||||
@ -0,0 +1,14 @@
|
|||||||
|
import assert from "node:assert/strict";
|
||||||
|
import test from "node:test";
|
||||||
|
|
||||||
|
import { buildCustomerCreateDefault } from "./build-customer-create-defaults";
|
||||||
|
|
||||||
|
test("define defaults coherentes para el formulario de create", () => {
|
||||||
|
const defaults = buildCustomerCreateDefault();
|
||||||
|
|
||||||
|
assert.equal(defaults.isCompany, true);
|
||||||
|
assert.equal(defaults.country, "ES");
|
||||||
|
assert.equal(defaults.languageCode, "es");
|
||||||
|
assert.equal(defaults.currencyCode, "EUR");
|
||||||
|
assert.equal(defaults.name, "");
|
||||||
|
});
|
||||||
@ -12,16 +12,19 @@ export const CustomerFooterActions = ({
|
|||||||
onEdit?: (customer: Customer) => void;
|
onEdit?: (customer: Customer) => void;
|
||||||
}) => {
|
}) => {
|
||||||
return (
|
return (
|
||||||
<div className="border-t p-4">
|
<div className="flex gap-2">
|
||||||
<div className="flex gap-2">
|
<Button
|
||||||
<Button className="flex-1" onClick={() => onCreateProforma?.(customer)} variant="outline">
|
className="flex-1"
|
||||||
Nueva proforma
|
onClick={() => onCreateProforma?.(customer)}
|
||||||
</Button>
|
type="button"
|
||||||
|
variant="outline"
|
||||||
|
>
|
||||||
|
Nueva proforma
|
||||||
|
</Button>
|
||||||
|
|
||||||
<Button className="flex-1" onClick={() => onEdit?.(customer)}>
|
<Button className="flex-1" onClick={() => onEdit?.(customer)} type="button">
|
||||||
Editar cliente
|
Editar cliente
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@ -5,6 +5,14 @@ import { Building2Icon, CopyIcon, UserIcon } from "lucide-react";
|
|||||||
import type { Customer } from "../../../../shared";
|
import type { Customer } from "../../../../shared";
|
||||||
|
|
||||||
export const CustomerHeader = ({ customer }: { customer: Customer }) => {
|
export const CustomerHeader = ({ customer }: { customer: Customer }) => {
|
||||||
|
const handleCopyTin = async () => {
|
||||||
|
try {
|
||||||
|
await navigator.clipboard.writeText(customer.tin);
|
||||||
|
} catch {
|
||||||
|
// El manejo de feedback pertenece a capas superiores.
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="p-4">
|
<div className="p-4">
|
||||||
<div className="flex items-start gap-4">
|
<div className="flex items-start gap-4">
|
||||||
@ -12,7 +20,7 @@ export const CustomerHeader = ({ customer }: { customer: Customer }) => {
|
|||||||
|
|
||||||
<div className="min-w-0 flex-1">
|
<div className="min-w-0 flex-1">
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<h2 className="truncate text-lg font-semibold">{customer.name}</h2>
|
<h3 className="truncate text-lg font-semibold">{customer.name}</h3>
|
||||||
<Badge variant={customer.status === "active" ? "default" : "secondary"}>
|
<Badge variant={customer.status === "active" ? "default" : "secondary"}>
|
||||||
{customer.status === "active" ? "Activo" : "Inactivo"}
|
{customer.status === "active" ? "Activo" : "Inactivo"}
|
||||||
</Badge>
|
</Badge>
|
||||||
@ -39,7 +47,7 @@ export const CustomerHeader = ({ customer }: { customer: Customer }) => {
|
|||||||
|
|
||||||
<button
|
<button
|
||||||
className="flex items-center gap-1 text-xs font-mono text-muted-foreground hover:text-foreground"
|
className="flex items-center gap-1 text-xs font-mono text-muted-foreground hover:text-foreground"
|
||||||
onClick={() => navigator.clipboard.writeText(customer.tin)}
|
onClick={handleCopyTin}
|
||||||
type="button"
|
type="button"
|
||||||
>
|
>
|
||||||
{customer.tin}
|
{customer.tin}
|
||||||
|
|||||||
@ -4,7 +4,6 @@ import type { Customer } from "../../../../shared";
|
|||||||
|
|
||||||
import { CustomerAddressSection } from "./customer-address-section";
|
import { CustomerAddressSection } from "./customer-address-section";
|
||||||
import { CustomerContactSection } from "./customer-contact-section";
|
import { CustomerContactSection } from "./customer-contact-section";
|
||||||
import { CustomerFooterActions } from "./customer-footer-actions";
|
|
||||||
import { CustomerHeader } from "./customer-header";
|
import { CustomerHeader } from "./customer-header";
|
||||||
import { CustomerProformasSection } from "./customer-proformas-section";
|
import { CustomerProformasSection } from "./customer-proformas-section";
|
||||||
import { CustomerStatsSection } from "./customer-stats-section";
|
import { CustomerStatsSection } from "./customer-stats-section";
|
||||||
@ -25,8 +24,6 @@ interface CustomerSummaryContentProps {
|
|||||||
status: "paid" | "pending" | "overdue";
|
status: "paid" | "pending" | "overdue";
|
||||||
}[];
|
}[];
|
||||||
|
|
||||||
onEdit?: (customer: Customer) => void;
|
|
||||||
onCreateInvoice?: (customer: Customer) => void;
|
|
||||||
onProformaClick?: (proformaId: string) => void;
|
onProformaClick?: (proformaId: string) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -34,8 +31,6 @@ export const CustomerSummaryContent = ({
|
|||||||
customer,
|
customer,
|
||||||
stats,
|
stats,
|
||||||
proformas,
|
proformas,
|
||||||
onEdit,
|
|
||||||
onCreateInvoice,
|
|
||||||
onProformaClick,
|
onProformaClick,
|
||||||
}: CustomerSummaryContentProps) => {
|
}: CustomerSummaryContentProps) => {
|
||||||
return (
|
return (
|
||||||
@ -57,12 +52,6 @@ export const CustomerSummaryContent = ({
|
|||||||
<Separator />
|
<Separator />
|
||||||
|
|
||||||
<CustomerProformasSection onProformaClick={onProformaClick} proformas={proformas} />
|
<CustomerProformasSection onProformaClick={onProformaClick} proformas={proformas} />
|
||||||
|
|
||||||
<CustomerFooterActions
|
|
||||||
customer={customer}
|
|
||||||
onCreateInvoice={onCreateInvoice}
|
|
||||||
onEdit={onEdit}
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@ -0,0 +1,139 @@
|
|||||||
|
import assert from "node:assert/strict";
|
||||||
|
import test from "node:test";
|
||||||
|
import { isValidElement } from "react";
|
||||||
|
import { renderToStaticMarkup } from "react-dom/server";
|
||||||
|
|
||||||
|
import type { Customer } from "../../../../shared";
|
||||||
|
import { CustomerSummaryPanel } from "./customer-summary-panel";
|
||||||
|
|
||||||
|
const mockCustomer: Customer = {
|
||||||
|
id: "customer-1",
|
||||||
|
companyId: "company-1",
|
||||||
|
status: "active",
|
||||||
|
reference: null,
|
||||||
|
isCompany: true,
|
||||||
|
name: "Acme Corp",
|
||||||
|
tradeName: "Acme",
|
||||||
|
tin: "B12345678",
|
||||||
|
address: {
|
||||||
|
street: "Main street",
|
||||||
|
street2: null,
|
||||||
|
city: "Madrid",
|
||||||
|
province: "Madrid",
|
||||||
|
postalCode: "28001",
|
||||||
|
country: "ES",
|
||||||
|
},
|
||||||
|
contact: {
|
||||||
|
primaryEmail: "hello@acme.test",
|
||||||
|
secondaryEmail: null,
|
||||||
|
primaryPhone: "910000000",
|
||||||
|
secondaryPhone: null,
|
||||||
|
primaryMobile: "600000000",
|
||||||
|
secondaryMobile: null,
|
||||||
|
fax: null,
|
||||||
|
website: "https://acme.test",
|
||||||
|
},
|
||||||
|
legalRecord: null,
|
||||||
|
paymentMethod: null,
|
||||||
|
paymentTerm: null,
|
||||||
|
taxRegime: null,
|
||||||
|
fiscalDefaults: {
|
||||||
|
usesEquivalenceSurcharge: false,
|
||||||
|
usesRetention: false,
|
||||||
|
},
|
||||||
|
languageCode: "es",
|
||||||
|
currencyCode: "EUR",
|
||||||
|
};
|
||||||
|
|
||||||
|
const resolveElement = (node: unknown): unknown => {
|
||||||
|
if (Array.isArray(node)) {
|
||||||
|
return node.map(resolveElement);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!isValidElement(node)) {
|
||||||
|
return node;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (typeof node.type === "function") {
|
||||||
|
return resolveElement(node.type(node.props));
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
type: node.type,
|
||||||
|
props: {
|
||||||
|
...node.props,
|
||||||
|
children: resolveElement(node.props.children),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
const findButtons = (node: unknown): Array<Record<string, unknown>> => {
|
||||||
|
if (Array.isArray(node)) {
|
||||||
|
return node.flatMap(findButtons);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!node || typeof node !== "object" || !("type" in node) || !("props" in node)) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
const element = node as { type: string; props: Record<string, unknown> };
|
||||||
|
const children = findButtons(element.props.children);
|
||||||
|
|
||||||
|
if (element.type === "button") {
|
||||||
|
return [element.props, ...children];
|
||||||
|
}
|
||||||
|
|
||||||
|
return children;
|
||||||
|
};
|
||||||
|
|
||||||
|
test("renderiza el shell común del panel de cliente sin duplicar el cierre", () => {
|
||||||
|
const html = renderToStaticMarkup(
|
||||||
|
<CustomerSummaryPanel
|
||||||
|
customer={mockCustomer}
|
||||||
|
mode="view"
|
||||||
|
onOpenChange={() => undefined}
|
||||||
|
open
|
||||||
|
visibility="visible"
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
|
||||||
|
assert.match(html, /Ficha de cliente/);
|
||||||
|
assert.match(html, /Acme Corp/);
|
||||||
|
assert.match(html, /Nueva proforma/);
|
||||||
|
assert.match(html, /Editar cliente/);
|
||||||
|
assert.equal((html.match(/Cerrar panel/g) ?? []).length, 1);
|
||||||
|
assert.equal((html.match(/<h2/g) ?? []).length, 1);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("mantiene los callbacks de editar y cerrar", async () => {
|
||||||
|
let didEdit = false;
|
||||||
|
let wasClosed = false;
|
||||||
|
|
||||||
|
const resolved = resolveElement(
|
||||||
|
<CustomerSummaryPanel
|
||||||
|
customer={mockCustomer}
|
||||||
|
mode="view"
|
||||||
|
onEdit={() => {
|
||||||
|
didEdit = true;
|
||||||
|
}}
|
||||||
|
onOpenChange={(open) => {
|
||||||
|
wasClosed = open;
|
||||||
|
}}
|
||||||
|
open
|
||||||
|
visibility="visible"
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
|
||||||
|
const buttons = findButtons(resolved);
|
||||||
|
const editButton = buttons.find((button) => button["aria-label"] === "Editar cliente");
|
||||||
|
const closeButton = buttons.find((button) => button["aria-label"] === "Cerrar panel");
|
||||||
|
|
||||||
|
assert.ok(editButton);
|
||||||
|
assert.ok(closeButton);
|
||||||
|
|
||||||
|
await editButton.onClick?.();
|
||||||
|
await closeButton.onClick?.();
|
||||||
|
|
||||||
|
assert.equal(didEdit, true);
|
||||||
|
assert.equal(wasClosed, false);
|
||||||
|
});
|
||||||
@ -1,4 +1,4 @@
|
|||||||
import { RightPanel } from "@repo/rdx-ui/components";
|
import { RdxContextPanel, RightPanel } from "@repo/rdx-ui/components";
|
||||||
import type { RightPanelMode, RightPanelVisibility } from "@repo/rdx-ui/hooks";
|
import type { RightPanelMode, RightPanelVisibility } from "@repo/rdx-ui/hooks";
|
||||||
import { Button } from "@repo/shadcn-ui/components";
|
import { Button } from "@repo/shadcn-ui/components";
|
||||||
import { cn } from "@repo/shadcn-ui/lib/utils";
|
import { cn } from "@repo/shadcn-ui/lib/utils";
|
||||||
@ -6,6 +6,7 @@ import { PencilIcon } from "lucide-react";
|
|||||||
|
|
||||||
import type { Customer } from "../../../../shared";
|
import type { Customer } from "../../../../shared";
|
||||||
|
|
||||||
|
import { CustomerFooterActions } from "./customer-footer-actions";
|
||||||
import { CustomerSummaryContent } from "./customer-summary-content";
|
import { CustomerSummaryContent } from "./customer-summary-content";
|
||||||
|
|
||||||
const mockERPData = {
|
const mockERPData = {
|
||||||
@ -59,7 +60,7 @@ interface CustomerSummaryPanelProps {
|
|||||||
export const CustomerSummaryPanel = ({
|
export const CustomerSummaryPanel = ({
|
||||||
customer,
|
customer,
|
||||||
open,
|
open,
|
||||||
visibility,
|
visibility: _visibility,
|
||||||
mode,
|
mode,
|
||||||
onOpenChange,
|
onOpenChange,
|
||||||
onEdit,
|
onEdit,
|
||||||
@ -72,38 +73,40 @@ export const CustomerSummaryPanel = ({
|
|||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<RightPanel
|
<RightPanel className={cn("bg-transparent", className)} open={open}>
|
||||||
className={cn("bg-transparent shadow", className)}
|
<RdxContextPanel
|
||||||
headerActions={
|
actions={
|
||||||
<>
|
customer ? (
|
||||||
{customer ? (
|
|
||||||
<Button
|
<Button
|
||||||
aria-label="Editar cliente"
|
aria-label="Editar cliente"
|
||||||
onClick={() => onEdit?.(customer)}
|
onClick={() => onEdit?.(customer)}
|
||||||
size="icon-sm"
|
size="icon-sm"
|
||||||
|
type="button"
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
>
|
>
|
||||||
<PencilIcon className="size-4" />
|
<PencilIcon className="size-4" />
|
||||||
</Button>
|
</Button>
|
||||||
) : null}
|
) : undefined
|
||||||
</>
|
}
|
||||||
}
|
closeLabel="Cerrar panel"
|
||||||
onOpenChange={onOpenChange}
|
footer={
|
||||||
open={open}
|
customer ? <CustomerFooterActions customer={customer} onEdit={onEdit} /> : undefined
|
||||||
title={titleMap[mode]}
|
}
|
||||||
>
|
onClose={() => onOpenChange(false)}
|
||||||
{customer ? (
|
title={titleMap[mode]}
|
||||||
<CustomerSummaryContent
|
>
|
||||||
customer={customer}
|
{customer ? (
|
||||||
onEdit={onEdit}
|
<CustomerSummaryContent
|
||||||
proformas={mockERPData.lastInvoices}
|
customer={customer}
|
||||||
stats={mockERPData}
|
proformas={mockERPData.lastInvoices}
|
||||||
/>
|
stats={mockERPData}
|
||||||
) : (
|
/>
|
||||||
<div className="flex h-full items-center justify-center p-4">
|
) : (
|
||||||
<p className="text-sm text-muted-foreground">Selecciona un cliente</p>
|
<div className="flex h-full items-center justify-center p-4">
|
||||||
</div>
|
<p className="text-sm text-muted-foreground">Selecciona un cliente</p>
|
||||||
)}
|
</div>
|
||||||
|
)}
|
||||||
|
</RdxContextPanel>
|
||||||
</RightPanel>
|
</RightPanel>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@ -0,0 +1,113 @@
|
|||||||
|
import assert from "node:assert/strict";
|
||||||
|
import test from "node:test";
|
||||||
|
import { isValidElement } from "react";
|
||||||
|
import { renderToStaticMarkup } from "react-dom/server";
|
||||||
|
import type { CellContext } from "@tanstack/react-table";
|
||||||
|
|
||||||
|
import { RdxRowActions, type RdxRowActionsProps } from "@repo/rdx-ui/components";
|
||||||
|
|
||||||
|
import type { CustomerListRow } from "../../../../shared";
|
||||||
|
import { useCustomersGridColumns } from "./use-customer-grid-columns";
|
||||||
|
|
||||||
|
const customerFixture: CustomerListRow = {
|
||||||
|
id: "customer-1",
|
||||||
|
companyId: "company-1",
|
||||||
|
status: "active",
|
||||||
|
reference: null,
|
||||||
|
isCompany: true,
|
||||||
|
name: "Acme Corp",
|
||||||
|
tradeName: null,
|
||||||
|
tin: "B12345678",
|
||||||
|
address: {
|
||||||
|
street: "Main street",
|
||||||
|
city: "Madrid",
|
||||||
|
province: "Madrid",
|
||||||
|
postalCode: "28001",
|
||||||
|
country: "ES",
|
||||||
|
},
|
||||||
|
contact: {
|
||||||
|
primaryEmail: "hello@acme.test",
|
||||||
|
primaryPhone: "910000000",
|
||||||
|
primaryMobile: null,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
const getRowActionsProps = (
|
||||||
|
actionHandlers: Parameters<typeof useCustomersGridColumns>[0] = {}
|
||||||
|
): RdxRowActionsProps => {
|
||||||
|
let cellNode: unknown = null;
|
||||||
|
|
||||||
|
const Harness = () => {
|
||||||
|
const columns = useCustomersGridColumns(actionHandlers);
|
||||||
|
const actionsColumn = columns.find((column) => column.id === "actions");
|
||||||
|
|
||||||
|
cellNode = actionsColumn?.cell?.({
|
||||||
|
row: { original: customerFixture },
|
||||||
|
} as unknown as CellContext<CustomerListRow, unknown>);
|
||||||
|
|
||||||
|
return null;
|
||||||
|
};
|
||||||
|
|
||||||
|
renderToStaticMarkup(<Harness />);
|
||||||
|
|
||||||
|
if (!isValidElement(cellNode)) {
|
||||||
|
throw new Error("Expected RdxRowActions cell");
|
||||||
|
}
|
||||||
|
|
||||||
|
assert.equal(cellNode.type, RdxRowActions);
|
||||||
|
|
||||||
|
return cellNode.props as RdxRowActionsProps;
|
||||||
|
};
|
||||||
|
|
||||||
|
test("expone ver y editar como acciones visibles cuando existen callbacks reales", () => {
|
||||||
|
const props = getRowActionsProps({
|
||||||
|
onEditClick: () => undefined,
|
||||||
|
onViewClick: () => undefined,
|
||||||
|
});
|
||||||
|
|
||||||
|
assert.deepEqual(
|
||||||
|
props.primaryActions?.map((action) => action.id),
|
||||||
|
["view", "edit"]
|
||||||
|
);
|
||||||
|
assert.equal(props.overflowLabel, "list.actions.more");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("envía eliminar al overflow y corrige el bug cuando no existe handler", () => {
|
||||||
|
const withoutDelete = getRowActionsProps({
|
||||||
|
onEditClick: () => undefined,
|
||||||
|
});
|
||||||
|
|
||||||
|
assert.deepEqual(withoutDelete.overflowActions ?? [], []);
|
||||||
|
|
||||||
|
const withDelete = getRowActionsProps({
|
||||||
|
onDeleteClick: () => undefined,
|
||||||
|
onEditClick: () => undefined,
|
||||||
|
});
|
||||||
|
|
||||||
|
assert.deepEqual(
|
||||||
|
withDelete.overflowActions?.map((action) => action.id),
|
||||||
|
["delete"]
|
||||||
|
);
|
||||||
|
assert.equal(withDelete.overflowActions?.[0]?.destructive, true);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("preserva los callbacks de edición y borrado", () => {
|
||||||
|
let didEdit = false;
|
||||||
|
let didDelete = false;
|
||||||
|
|
||||||
|
const props = getRowActionsProps({
|
||||||
|
onDeleteClick: () => {
|
||||||
|
didDelete = true;
|
||||||
|
},
|
||||||
|
onEditClick: () => {
|
||||||
|
didEdit = true;
|
||||||
|
},
|
||||||
|
onViewClick: () => undefined,
|
||||||
|
});
|
||||||
|
|
||||||
|
props.primaryActions?.find((action) => action.id === "edit")?.onSelect();
|
||||||
|
props.overflowActions?.find((action) => action.id === "delete")?.onSelect();
|
||||||
|
|
||||||
|
assert.equal(didEdit, true);
|
||||||
|
assert.equal(didDelete, true);
|
||||||
|
});
|
||||||
@ -1,15 +1,12 @@
|
|||||||
import { DataTableColumnHeader, InitialsAvatar } from "@repo/rdx-ui/components";
|
|
||||||
import {
|
import {
|
||||||
Badge,
|
DataTableColumnHeader,
|
||||||
Button,
|
InitialsAvatar,
|
||||||
Checkbox,
|
RdxRowActions,
|
||||||
Tooltip,
|
type RdxRowAction,
|
||||||
TooltipContent,
|
} from "@repo/rdx-ui/components";
|
||||||
TooltipProvider,
|
import { Badge, Checkbox } from "@repo/shadcn-ui/components";
|
||||||
TooltipTrigger,
|
|
||||||
} from "@repo/shadcn-ui/components";
|
|
||||||
import type { ColumnDef } from "@tanstack/react-table";
|
import type { ColumnDef } from "@tanstack/react-table";
|
||||||
import { Building2Icon, PencilIcon, Trash2Icon, UserIcon } from "lucide-react";
|
import { Building2Icon, EyeIcon, PencilIcon, Trash2Icon, UserIcon } from "lucide-react";
|
||||||
import * as React from "react";
|
import * as React from "react";
|
||||||
|
|
||||||
import { useTranslation } from "../../../../i18n";
|
import { useTranslation } from "../../../../i18n";
|
||||||
@ -170,62 +167,49 @@ export function useCustomersGridColumns(
|
|||||||
enableHiding: false,
|
enableHiding: false,
|
||||||
cell: ({ row }) => {
|
cell: ({ row }) => {
|
||||||
const customer = row.original;
|
const customer = row.original;
|
||||||
const { primaryEmail } = customer.contact;
|
|
||||||
|
const canEdit = actionHandlers.canEdit ? actionHandlers.canEdit(customer) : true;
|
||||||
|
const canDelete = actionHandlers.canDelete ? actionHandlers.canDelete(customer) : true;
|
||||||
|
|
||||||
|
const primaryActions: RdxRowAction[] = [];
|
||||||
|
const overflowActions: RdxRowAction[] = [];
|
||||||
|
|
||||||
|
if (actionHandlers.onViewClick) {
|
||||||
|
primaryActions.push({
|
||||||
|
id: "view",
|
||||||
|
label: t("list.actions.view"),
|
||||||
|
icon: <EyeIcon className="size-4" />,
|
||||||
|
onSelect: () => actionHandlers.onViewClick?.(customer),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (actionHandlers.onEditClick) {
|
||||||
|
primaryActions.push({
|
||||||
|
id: "edit",
|
||||||
|
label: t("list.actions.edit"),
|
||||||
|
icon: <PencilIcon className="size-4" />,
|
||||||
|
onSelect: () => actionHandlers.onEditClick?.(customer),
|
||||||
|
disabled: !canEdit,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (actionHandlers.onDeleteClick) {
|
||||||
|
overflowActions.push({
|
||||||
|
id: "delete",
|
||||||
|
label: t("list.actions.delete"),
|
||||||
|
icon: <Trash2Icon className="size-4" />,
|
||||||
|
onSelect: () => actionHandlers.onDeleteClick?.(customer),
|
||||||
|
destructive: true,
|
||||||
|
disabled: !canDelete,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex items-center gap-1 justify-end">
|
<RdxRowActions
|
||||||
{actionHandlers.onEditClick && (
|
overflowActions={overflowActions}
|
||||||
<TooltipProvider>
|
overflowLabel={t("list.actions.more")}
|
||||||
<Tooltip>
|
primaryActions={primaryActions}
|
||||||
<TooltipTrigger
|
/>
|
||||||
render={
|
|
||||||
<Button
|
|
||||||
className="size-7 cursor-pointer text-muted-foreground hover:text-primary"
|
|
||||||
onClick={(event) => {
|
|
||||||
event.preventDefault();
|
|
||||||
event.stopPropagation();
|
|
||||||
|
|
||||||
actionHandlers.onEditClick?.(customer);
|
|
||||||
}}
|
|
||||||
size="icon"
|
|
||||||
variant="ghost"
|
|
||||||
>
|
|
||||||
<PencilIcon className="size-4" />
|
|
||||||
<span className="sr-only">Editar</span>
|
|
||||||
</Button>
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
<TooltipContent>Editar</TooltipContent>
|
|
||||||
</Tooltip>
|
|
||||||
</TooltipProvider>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Eliminar */}
|
|
||||||
{!actionHandlers.onDeleteClick && (
|
|
||||||
<TooltipProvider>
|
|
||||||
<Tooltip>
|
|
||||||
<TooltipTrigger
|
|
||||||
render={
|
|
||||||
<Button
|
|
||||||
className="size-8 text-destructive/75 hover:text-destructive cursor-pointer"
|
|
||||||
onClick={(event) => {
|
|
||||||
event.preventDefault();
|
|
||||||
event.stopPropagation();
|
|
||||||
|
|
||||||
actionHandlers.onDeleteClick?.(customer);
|
|
||||||
}}
|
|
||||||
size="icon"
|
|
||||||
variant="ghost"
|
|
||||||
>
|
|
||||||
<Trash2Icon className="size-4" />
|
|
||||||
</Button>
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
<TooltipContent>Eliminar</TooltipContent>
|
|
||||||
</Tooltip>
|
|
||||||
</TooltipProvider>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|||||||
@ -0,0 +1,125 @@
|
|||||||
|
import assert from "node:assert/strict";
|
||||||
|
import test from "node:test";
|
||||||
|
import { isValidElement } from "react";
|
||||||
|
import { renderToStaticMarkup } from "react-dom/server";
|
||||||
|
|
||||||
|
import { ListCustomersPageView } from "./list-customers-page-view";
|
||||||
|
|
||||||
|
const resolveElement = (node: unknown): unknown => {
|
||||||
|
if (Array.isArray(node)) {
|
||||||
|
return node.map(resolveElement);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!isValidElement(node)) {
|
||||||
|
return node;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (typeof node.type === "function") {
|
||||||
|
return resolveElement(node.type(node.props));
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
type: node.type,
|
||||||
|
props: {
|
||||||
|
...node.props,
|
||||||
|
children: resolveElement(node.props.children),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
const findFirstElementByType = (
|
||||||
|
node: unknown,
|
||||||
|
type: string
|
||||||
|
): { type: string; props: Record<string, unknown> } | null => {
|
||||||
|
if (Array.isArray(node)) {
|
||||||
|
for (const child of node) {
|
||||||
|
const found = findFirstElementByType(child, type);
|
||||||
|
|
||||||
|
if (found) {
|
||||||
|
return found;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!node || typeof node !== "object" || !("type" in node) || !("props" in node)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const element = node as { type: string; props: Record<string, unknown> };
|
||||||
|
|
||||||
|
if (element.type === type) {
|
||||||
|
return element;
|
||||||
|
}
|
||||||
|
|
||||||
|
return findFirstElementByType(element.props.children, type);
|
||||||
|
};
|
||||||
|
|
||||||
|
test("renderiza título, descripción, CTA principal y grid del listado de clientes", () => {
|
||||||
|
const html = renderToStaticMarkup(
|
||||||
|
<ListCustomersPageView
|
||||||
|
content={<div>Customers grid content</div>}
|
||||||
|
description="Gestiona y consulta la base de clientes."
|
||||||
|
primaryAction={<button type="button">Nuevo cliente</button>}
|
||||||
|
title="Clientes"
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
|
||||||
|
assert.match(html, /<h1[^>]*>Clientes<\/h1>/);
|
||||||
|
assert.match(html, /Gestiona y consulta la base de clientes/);
|
||||||
|
assert.match(html, /Nuevo cliente/);
|
||||||
|
assert.match(html, /Customers grid content/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("mantiene el error inline y el panel contextual cuando corresponde", () => {
|
||||||
|
const html = renderToStaticMarkup(
|
||||||
|
<ListCustomersPageView
|
||||||
|
content={
|
||||||
|
<div>
|
||||||
|
<div>Customers grid content</div>
|
||||||
|
<aside>Customer summary panel open</aside>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
refreshError={{
|
||||||
|
title: "Error de actualización",
|
||||||
|
message: "No se pudo refrescar la lista",
|
||||||
|
}}
|
||||||
|
title="Clientes"
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
|
||||||
|
assert.match(html, /Error de actualización/);
|
||||||
|
assert.match(html, /No se pudo refrescar la lista/);
|
||||||
|
assert.match(html, /Customer summary panel open/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("preserva el callback de creación", async () => {
|
||||||
|
let didCreate = false;
|
||||||
|
|
||||||
|
const resolved = resolveElement(
|
||||||
|
<ListCustomersPageView
|
||||||
|
content={<div>Customers grid content</div>}
|
||||||
|
primaryAction={
|
||||||
|
<button
|
||||||
|
onClick={() => {
|
||||||
|
didCreate = true;
|
||||||
|
}}
|
||||||
|
type="button"
|
||||||
|
>
|
||||||
|
Nuevo cliente
|
||||||
|
</button>
|
||||||
|
}
|
||||||
|
title="Clientes"
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
|
||||||
|
const button = findFirstElementByType(resolved, "button");
|
||||||
|
|
||||||
|
assert.ok(button);
|
||||||
|
assert.equal(typeof button.props.onClick, "function");
|
||||||
|
|
||||||
|
await button.props.onClick?.();
|
||||||
|
|
||||||
|
assert.equal(didCreate, true);
|
||||||
|
});
|
||||||
@ -0,0 +1,51 @@
|
|||||||
|
import { ErrorAlert } from "@erp/core/components";
|
||||||
|
import {
|
||||||
|
RdxPageActions,
|
||||||
|
RdxPageContainer,
|
||||||
|
RdxPageHeader,
|
||||||
|
RdxPageStack,
|
||||||
|
} from "@repo/rdx-ui/components";
|
||||||
|
import type { ReactNode } from "react";
|
||||||
|
|
||||||
|
interface RefreshErrorViewModel {
|
||||||
|
title: string;
|
||||||
|
message: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ListCustomersPageViewProps {
|
||||||
|
title: ReactNode;
|
||||||
|
description?: ReactNode;
|
||||||
|
primaryAction?: ReactNode;
|
||||||
|
refreshError?: RefreshErrorViewModel | null;
|
||||||
|
content: ReactNode;
|
||||||
|
dialogs?: ReactNode;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const ListCustomersPageView = ({
|
||||||
|
title,
|
||||||
|
description,
|
||||||
|
primaryAction,
|
||||||
|
refreshError,
|
||||||
|
content,
|
||||||
|
dialogs,
|
||||||
|
}: ListCustomersPageViewProps) => {
|
||||||
|
return (
|
||||||
|
<RdxPageContainer width="full">
|
||||||
|
<RdxPageStack spacing="default">
|
||||||
|
<RdxPageHeader
|
||||||
|
actions={primaryAction ? <RdxPageActions primary={primaryAction} /> : undefined}
|
||||||
|
description={description}
|
||||||
|
title={title}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{refreshError ? (
|
||||||
|
<ErrorAlert message={refreshError.message} title={refreshError.title} />
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
{content}
|
||||||
|
</RdxPageStack>
|
||||||
|
|
||||||
|
{dialogs}
|
||||||
|
</RdxPageContainer>
|
||||||
|
);
|
||||||
|
};
|
||||||
@ -1,5 +1,6 @@
|
|||||||
import { ErrorAlert, PageErrorState, PageHeader, SimpleSearchInput } from "@erp/core/components";
|
import { PageErrorState, SimpleSearchInput } from "@erp/core/components";
|
||||||
import { useReturnToNavigation } from "@erp/core/hooks";
|
import { useReturnToNavigation } from "@erp/core/hooks";
|
||||||
|
import { RdxPageContainer } from "@repo/rdx-ui/components";
|
||||||
import {
|
import {
|
||||||
Button,
|
Button,
|
||||||
ResizableHandle,
|
ResizableHandle,
|
||||||
@ -13,6 +14,7 @@ import { createSearchParams, useNavigate } from "react-router-dom";
|
|||||||
import { useTranslation } from "../../../i18n";
|
import { useTranslation } from "../../../i18n";
|
||||||
import { useListCustomersPageController } from "../../controllers";
|
import { useListCustomersPageController } from "../../controllers";
|
||||||
import { CustomerSummaryPanel, CustomersGrid, useCustomersGridColumns } from "../blocks";
|
import { CustomerSummaryPanel, CustomersGrid, useCustomersGridColumns } from "../blocks";
|
||||||
|
import { ListCustomersPageView } from "./list-customers-page-view";
|
||||||
|
|
||||||
export const ListCustomersPage = () => {
|
export const ListCustomersPage = () => {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
@ -88,7 +90,7 @@ export const ListCustomersPage = () => {
|
|||||||
|
|
||||||
if (pageError) {
|
if (pageError) {
|
||||||
return (
|
return (
|
||||||
<div className="p-4 sm:p-6">
|
<RdxPageContainer width="full">
|
||||||
<div className="mx-auto w-full max-w-3xl">
|
<div className="mx-auto w-full max-w-3xl">
|
||||||
<PageErrorState
|
<PageErrorState
|
||||||
description={pageError.description}
|
description={pageError.description}
|
||||||
@ -109,67 +111,63 @@ export const ListCustomersPage = () => {
|
|||||||
variant={pageError.variant}
|
variant={pageError.variant}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</RdxPageContainer>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="p-6 space-y-6">
|
<ListCustomersPageView
|
||||||
{/* Header */}
|
content={
|
||||||
<PageHeader
|
isPanelOpen ? (
|
||||||
description={t("list.description")}
|
<ResizablePanelGroup
|
||||||
rightSlot={
|
autoSave="list-customers-page"
|
||||||
|
className="h-full"
|
||||||
|
orientation="horizontal"
|
||||||
|
>
|
||||||
|
<ResizablePanel defaultSize="70%" maxSize="75%" minSize="70%">
|
||||||
|
{listContent}
|
||||||
|
</ResizablePanel>
|
||||||
|
|
||||||
|
<ResizableHandle className="mx-4" withHandle />
|
||||||
|
|
||||||
|
<ResizablePanel defaultSize="30%" maxSize="30%" minSize="25%">
|
||||||
|
<div className="h-full">
|
||||||
|
<CustomerSummaryPanel
|
||||||
|
className="border bg-background"
|
||||||
|
customer={panelCtrl.customer}
|
||||||
|
mode={panelCtrl.panelState.mode}
|
||||||
|
onEdit={(customer) => navigate(`/customers/${customer.id}/edit`)}
|
||||||
|
onOpenChange={(open) => {
|
||||||
|
if (!open) {
|
||||||
|
panelCtrl.closePanel();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
panelCtrl.panelState.onOpenChange(true);
|
||||||
|
}}
|
||||||
|
open={panelCtrl.panelState.isOpen}
|
||||||
|
visibility={panelCtrl.panelState.visibility}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</ResizablePanel>
|
||||||
|
</ResizablePanelGroup>
|
||||||
|
) : (
|
||||||
|
<div className="mx-auto w-full space-y-4">{listContent}</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
description={t("list.description")}
|
||||||
|
primaryAction={
|
||||||
<Button
|
<Button
|
||||||
aria-label={t("common.actions.create")}
|
aria-label={t("common.actions.create")}
|
||||||
onClick={() => navigate("/customers/create")}
|
onClick={() => navigate("/customers/create")}
|
||||||
size={"default"}
|
size="default"
|
||||||
>
|
>
|
||||||
<PlusIcon aria-hidden className="mr-2 size-4" />
|
<PlusIcon aria-hidden className="mr-2 size-4" />
|
||||||
{t("common.actions.create")}
|
{t("common.actions.create")}
|
||||||
</Button>
|
</Button>
|
||||||
}
|
}
|
||||||
title={t("list.title")}
|
refreshError={refreshError}
|
||||||
/>
|
title={t("list.title")}
|
||||||
|
/>
|
||||||
{refreshError ? <ErrorAlert message={refreshError.message} title={refreshError.title} /> : null}
|
|
||||||
|
|
||||||
{/* Table */}
|
|
||||||
{isPanelOpen ? (
|
|
||||||
<ResizablePanelGroup
|
|
||||||
autoSave="list-customers-page"
|
|
||||||
className="h-full"
|
|
||||||
orientation="horizontal"
|
|
||||||
>
|
|
||||||
<ResizablePanel defaultSize="70%" maxSize="75%" minSize="70%">
|
|
||||||
{listContent}
|
|
||||||
</ResizablePanel>
|
|
||||||
|
|
||||||
<ResizableHandle className="mx-4" withHandle />
|
|
||||||
|
|
||||||
<ResizablePanel defaultSize="30%" maxSize="30%" minSize="25%">
|
|
||||||
<div className="h-full">
|
|
||||||
<CustomerSummaryPanel
|
|
||||||
className="border bg-background"
|
|
||||||
customer={panelCtrl.customer}
|
|
||||||
mode={panelCtrl.panelState.mode}
|
|
||||||
onEdit={(customer) => navigate(`/customers/${customer.id}/edit`)}
|
|
||||||
onOpenChange={(open) => {
|
|
||||||
if (!open) {
|
|
||||||
panelCtrl.closePanel();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
panelCtrl.panelState.onOpenChange(true);
|
|
||||||
}}
|
|
||||||
open={panelCtrl.panelState.isOpen}
|
|
||||||
visibility={panelCtrl.panelState.visibility}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</ResizablePanel>
|
|
||||||
</ResizablePanelGroup>
|
|
||||||
) : (
|
|
||||||
<div className="mx-auto w-full space-y-4">{listContent}</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@ -1,4 +1,4 @@
|
|||||||
import { FormSectionCard, FormSectionGrid, SelectField } from "@repo/rdx-ui/components";
|
import { RdxEditorSection, RdxFormGrid, SelectField } from "@repo/rdx-ui/components";
|
||||||
import { Settings2Icon } from "lucide-react";
|
import { Settings2Icon } from "lucide-react";
|
||||||
|
|
||||||
import { useTranslation } from "../../../i18n";
|
import { useTranslation } from "../../../i18n";
|
||||||
@ -16,12 +16,12 @@ export const CustomerAdditionalConfigEditor = ({
|
|||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<FormSectionCard
|
<RdxEditorSection
|
||||||
description={t("form_groups.preferences.description")}
|
description={t("form_groups.preferences.description")}
|
||||||
icon={<Settings2Icon className="size-5" />}
|
icon={<Settings2Icon className="size-5" />}
|
||||||
title={t("form_groups.preferences.title")}
|
title={t("form_groups.preferences.title")}
|
||||||
>
|
>
|
||||||
<FormSectionGrid>
|
<RdxFormGrid>
|
||||||
<SelectField
|
<SelectField
|
||||||
className="md:col-span-3"
|
className="md:col-span-3"
|
||||||
description={t("fields.language_code.description")}
|
description={t("fields.language_code.description")}
|
||||||
@ -44,7 +44,7 @@ export const CustomerAdditionalConfigEditor = ({
|
|||||||
readOnly={readOnly}
|
readOnly={readOnly}
|
||||||
required
|
required
|
||||||
/>
|
/>
|
||||||
</FormSectionGrid>
|
</RdxFormGrid>
|
||||||
</FormSectionCard>
|
</RdxEditorSection>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@ -1,4 +1,9 @@
|
|||||||
import { FormSectionCard, FormSectionGrid, SelectField, TextField } from "@repo/rdx-ui/components";
|
import {
|
||||||
|
RdxEditorSection,
|
||||||
|
RdxFormGrid,
|
||||||
|
SelectField,
|
||||||
|
TextField,
|
||||||
|
} from "@repo/rdx-ui/components";
|
||||||
import { MapPinIcon } from "lucide-react";
|
import { MapPinIcon } from "lucide-react";
|
||||||
|
|
||||||
import { useTranslation } from "../../../i18n";
|
import { useTranslation } from "../../../i18n";
|
||||||
@ -16,12 +21,12 @@ export const CustomerAddressEditor = ({
|
|||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<FormSectionCard
|
<RdxEditorSection
|
||||||
description={t("form_groups.address.description")}
|
description={t("form_groups.address.description")}
|
||||||
icon={<MapPinIcon className="size-5" />}
|
icon={<MapPinIcon className="size-5" />}
|
||||||
title={t("form_groups.address.title")}
|
title={t("form_groups.address.title")}
|
||||||
>
|
>
|
||||||
<FormSectionGrid className="md:grid-cols-2">
|
<RdxFormGrid className="md:grid-cols-2">
|
||||||
<TextField
|
<TextField
|
||||||
description={t("fields.street.description")}
|
description={t("fields.street.description")}
|
||||||
disabled={disabled}
|
disabled={disabled}
|
||||||
@ -76,7 +81,7 @@ export const CustomerAddressEditor = ({
|
|||||||
readOnly={readOnly}
|
readOnly={readOnly}
|
||||||
required
|
required
|
||||||
/>
|
/>
|
||||||
</FormSectionGrid>
|
</RdxFormGrid>
|
||||||
</FormSectionCard>
|
</RdxEditorSection>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@ -1,7 +1,7 @@
|
|||||||
import {
|
import {
|
||||||
FormFieldLabel,
|
FormFieldLabel,
|
||||||
FormSectionCard,
|
RdxEditorSection,
|
||||||
FormSectionGrid,
|
RdxFormGrid,
|
||||||
TextAreaField,
|
TextAreaField,
|
||||||
TextField,
|
TextField,
|
||||||
} from "@repo/rdx-ui/components";
|
} from "@repo/rdx-ui/components";
|
||||||
@ -32,12 +32,12 @@ export const CustomerBasicInfoEditor = ({
|
|||||||
const { control } = useFormContext();
|
const { control } = useFormContext();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<FormSectionCard
|
<RdxEditorSection
|
||||||
description={t("form_groups.basic_info.description")}
|
description={t("form_groups.basic_info.description")}
|
||||||
icon={<FileTextIcon className="size-5" />}
|
icon={<FileTextIcon className="size-5" />}
|
||||||
title={t("form_groups.basic_info.title")}
|
title={t("form_groups.basic_info.title")}
|
||||||
>
|
>
|
||||||
<FormSectionGrid className="md:grid-cols-2">
|
<RdxFormGrid className="md:grid-cols-2">
|
||||||
<TextField
|
<TextField
|
||||||
description={t("fields.name.description")}
|
description={t("fields.name.description")}
|
||||||
disabled={disabled}
|
disabled={disabled}
|
||||||
@ -144,7 +144,7 @@ export const CustomerBasicInfoEditor = ({
|
|||||||
placeholder={t("fields.legal_record.placeholder")}
|
placeholder={t("fields.legal_record.placeholder")}
|
||||||
readOnly={readOnly}
|
readOnly={readOnly}
|
||||||
/>
|
/>
|
||||||
</FormSectionGrid>
|
</RdxFormGrid>
|
||||||
</FormSectionCard>
|
</RdxEditorSection>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@ -1,4 +1,4 @@
|
|||||||
import { FormSectionCard, FormSectionGrid, TextField } from "@repo/rdx-ui/components";
|
import { RdxEditorSection, RdxFormGrid, TextField } from "@repo/rdx-ui/components";
|
||||||
import {
|
import {
|
||||||
Collapsible,
|
Collapsible,
|
||||||
CollapsibleContent,
|
CollapsibleContent,
|
||||||
@ -23,12 +23,12 @@ export const CustomerContactEditor = ({
|
|||||||
const [open, setOpen] = useState(true);
|
const [open, setOpen] = useState(true);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<FormSectionCard
|
<RdxEditorSection
|
||||||
description={t("form_groups.contact_info.description")}
|
description={t("form_groups.contact_info.description")}
|
||||||
icon={<PhoneIcon className="size-5" />}
|
icon={<PhoneIcon className="size-5" />}
|
||||||
title={t("form_groups.contact_info.title")}
|
title={t("form_groups.contact_info.title")}
|
||||||
>
|
>
|
||||||
<FormSectionGrid>
|
<RdxFormGrid>
|
||||||
<TextField
|
<TextField
|
||||||
className="md:col-span-6"
|
className="md:col-span-6"
|
||||||
description={t("fields.email_primary.description")}
|
description={t("fields.email_primary.description")}
|
||||||
@ -114,7 +114,7 @@ export const CustomerContactEditor = ({
|
|||||||
<ChevronDown className={`h-4 w-4 transition-transform ${open ? "rotate-180" : ""}`} />
|
<ChevronDown className={`h-4 w-4 transition-transform ${open ? "rotate-180" : ""}`} />
|
||||||
</CollapsibleTrigger>
|
</CollapsibleTrigger>
|
||||||
<CollapsibleContent>
|
<CollapsibleContent>
|
||||||
<FormSectionGrid>
|
<RdxFormGrid>
|
||||||
<TextField
|
<TextField
|
||||||
className="md:col-span-6"
|
className="md:col-span-6"
|
||||||
description={t("fields.website.description")}
|
description={t("fields.website.description")}
|
||||||
@ -136,10 +136,10 @@ export const CustomerContactEditor = ({
|
|||||||
readOnly={readOnly}
|
readOnly={readOnly}
|
||||||
typePreset="phone"
|
typePreset="phone"
|
||||||
/>
|
/>
|
||||||
</FormSectionGrid>
|
</RdxFormGrid>
|
||||||
</CollapsibleContent>
|
</CollapsibleContent>
|
||||||
</Collapsible>
|
</Collapsible>
|
||||||
</FormSectionGrid>
|
</RdxFormGrid>
|
||||||
</FormSectionCard>
|
</RdxEditorSection>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@ -0,0 +1,45 @@
|
|||||||
|
import assert from "node:assert/strict";
|
||||||
|
import test from "node:test";
|
||||||
|
import { renderToStaticMarkup } from "react-dom/server";
|
||||||
|
import { FormProvider, useForm } from "react-hook-form";
|
||||||
|
|
||||||
|
import type { CustomerUpdateForm } from "../../entities";
|
||||||
|
import { buildCustomerUpdateDefault } from "../../utils";
|
||||||
|
import { CustomerUpdateEditorForm } from "./customer-edit-form";
|
||||||
|
|
||||||
|
const renderForm = () => {
|
||||||
|
const Harness = () => {
|
||||||
|
const form = useForm<CustomerUpdateForm>({
|
||||||
|
defaultValues: buildCustomerUpdateDefault(),
|
||||||
|
});
|
||||||
|
|
||||||
|
return (
|
||||||
|
<FormProvider {...form}>
|
||||||
|
<CustomerUpdateEditorForm
|
||||||
|
fiscalCtrl={{
|
||||||
|
paymentMethodOptions: [],
|
||||||
|
paymentTermOptions: [],
|
||||||
|
taxRegimeOptions: [],
|
||||||
|
}}
|
||||||
|
formId="customer-update-form"
|
||||||
|
isSubmitting={false}
|
||||||
|
onReset={() => undefined}
|
||||||
|
onSubmit={(event) => {
|
||||||
|
event.preventDefault();
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</FormProvider>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
return renderToStaticMarkup(<Harness />);
|
||||||
|
};
|
||||||
|
|
||||||
|
test("renderiza el formulario con id y secciones estructurales del editor simple", () => {
|
||||||
|
const html = renderForm();
|
||||||
|
|
||||||
|
assert.match(html, /id="customer-update-form"/);
|
||||||
|
assert.equal((html.match(/<section/g) ?? []).length, 5);
|
||||||
|
assert.match(html, /form_groups\.basic_info\.title|Información básica/i);
|
||||||
|
assert.match(html, /form_groups\.address\.title|Dirección/i);
|
||||||
|
});
|
||||||
@ -1,5 +1,6 @@
|
|||||||
import { preventEnterKeySubmitForm } from "@repo/rdx-ui/helpers";
|
import { preventEnterKeySubmitForm } from "@repo/rdx-ui/helpers";
|
||||||
import { cn } from "@repo/shadcn-ui/lib/utils";
|
import { cn } from "@repo/shadcn-ui/lib/utils";
|
||||||
|
import type { SubmitEventHandler } from "react";
|
||||||
|
|
||||||
import type { UseCustomerUpdateFiscalControllerResult } from "../../controllers";
|
import type { UseCustomerUpdateFiscalControllerResult } from "../../controllers";
|
||||||
|
|
||||||
@ -12,7 +13,7 @@ import { CustomerFiscalEditor } from "./customer-fiscal-editor";
|
|||||||
type CustomerUpdateEditorFormProps = {
|
type CustomerUpdateEditorFormProps = {
|
||||||
formId: string;
|
formId: string;
|
||||||
isSubmitting: boolean;
|
isSubmitting: boolean;
|
||||||
onSubmit: React.SubmitEventHandler<HTMLFormElement>;
|
onSubmit: SubmitEventHandler<HTMLFormElement>;
|
||||||
onReset: () => void;
|
onReset: () => void;
|
||||||
|
|
||||||
fiscalCtrl: UseCustomerUpdateFiscalControllerResult;
|
fiscalCtrl: UseCustomerUpdateFiscalControllerResult;
|
||||||
@ -31,25 +32,23 @@ export const CustomerUpdateEditorForm = ({
|
|||||||
className,
|
className,
|
||||||
}: CustomerUpdateEditorFormProps) => {
|
}: CustomerUpdateEditorFormProps) => {
|
||||||
return (
|
return (
|
||||||
<div className={cn("p-6", className)}>
|
<form
|
||||||
<form
|
className={cn("flex min-w-0 flex-col gap-6", className)}
|
||||||
className="space-y-6 2xl:space-y-12"
|
id={formId}
|
||||||
id={formId}
|
noValidate
|
||||||
noValidate
|
onKeyDown={preventEnterKeySubmitForm}
|
||||||
onKeyDown={preventEnterKeySubmitForm}
|
onReset={onReset}
|
||||||
onReset={onReset}
|
onSubmit={onSubmit}
|
||||||
onSubmit={onSubmit}
|
>
|
||||||
>
|
<CustomerBasicInfoEditor disabled={isSubmitting} />
|
||||||
<CustomerBasicInfoEditor disabled={isSubmitting} />
|
<CustomerAddressEditor />
|
||||||
<CustomerAddressEditor />
|
<CustomerContactEditor />
|
||||||
<CustomerContactEditor />
|
<CustomerFiscalEditor
|
||||||
<CustomerFiscalEditor
|
paymentMethodOptions={fiscalCtrl.paymentMethodOptions}
|
||||||
paymentMethodOptions={fiscalCtrl.paymentMethodOptions}
|
paymentTermOptions={fiscalCtrl.paymentTermOptions}
|
||||||
paymentTermOptions={fiscalCtrl.paymentTermOptions}
|
taxRegimeOptions={fiscalCtrl.taxRegimeOptions}
|
||||||
taxRegimeOptions={fiscalCtrl.taxRegimeOptions}
|
/>
|
||||||
/>
|
<CustomerAdditionalConfigEditor />
|
||||||
<CustomerAdditionalConfigEditor />
|
</form>
|
||||||
</form>
|
|
||||||
</div>
|
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@ -1,6 +1,6 @@
|
|||||||
import {
|
import {
|
||||||
FormSectionCard,
|
RdxEditorSection,
|
||||||
FormSectionGrid,
|
RdxFormGrid,
|
||||||
SelectField,
|
SelectField,
|
||||||
type SelectFieldItem,
|
type SelectFieldItem,
|
||||||
SwitchField,
|
SwitchField,
|
||||||
@ -27,12 +27,12 @@ export const CustomerFiscalEditor = ({
|
|||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<FormSectionCard
|
<RdxEditorSection
|
||||||
description={t("form_groups.fiscal_info.description")}
|
description={t("form_groups.fiscal_info.description")}
|
||||||
icon={<LandmarkIcon className="size-5" />}
|
icon={<LandmarkIcon className="size-5" />}
|
||||||
title={t("form_groups.fiscal_info.title")}
|
title={t("form_groups.fiscal_info.title")}
|
||||||
>
|
>
|
||||||
<FormSectionGrid className="md:grid-cols-2">
|
<RdxFormGrid className="md:grid-cols-2">
|
||||||
<SelectField
|
<SelectField
|
||||||
clearable
|
clearable
|
||||||
description={t("fields.payment_method.description")}
|
description={t("fields.payment_method.description")}
|
||||||
@ -87,7 +87,7 @@ export const CustomerFiscalEditor = ({
|
|||||||
name="usesRetention"
|
name="usesRetention"
|
||||||
readOnly={readOnly}
|
readOnly={readOnly}
|
||||||
/>
|
/>
|
||||||
</FormSectionGrid>
|
</RdxFormGrid>
|
||||||
</FormSectionCard>
|
</RdxEditorSection>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@ -1,7 +1,7 @@
|
|||||||
import { ErrorAlert, PageErrorState } from "@erp/core/components";
|
import { ErrorAlert, PageErrorState } from "@erp/core/components";
|
||||||
import { UnsavedChangesProvider, useReturnToNavigation } from "@erp/core/hooks";
|
import { UnsavedChangesProvider, useReturnToNavigation } from "@erp/core/hooks";
|
||||||
|
import { RdxEditorPageLayout } from "@repo/rdx-ui/components";
|
||||||
import { FormProvider } from "react-hook-form";
|
import { FormProvider } from "react-hook-form";
|
||||||
import { useNavigate } from "react-router-dom";
|
|
||||||
|
|
||||||
import { useTranslation } from "../../../i18n";
|
import { useTranslation } from "../../../i18n";
|
||||||
import { useCustomerUpdatePageController } from "../../controllers";
|
import { useCustomerUpdatePageController } from "../../controllers";
|
||||||
@ -11,7 +11,6 @@ import { CustomerUpdateEditorForm } from "../editor";
|
|||||||
|
|
||||||
export const CustomerUpdatePage = () => {
|
export const CustomerUpdatePage = () => {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const navigate = useNavigate();
|
|
||||||
const ctrl = useCustomerUpdatePageController();
|
const ctrl = useCustomerUpdatePageController();
|
||||||
const { updateCtrl, returnTo } = ctrl;
|
const { updateCtrl, returnTo } = ctrl;
|
||||||
|
|
||||||
@ -93,24 +92,30 @@ export const CustomerUpdatePage = () => {
|
|||||||
//onExportPdf={handleExportPdf}
|
//onExportPdf={handleExportPdf}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{updateCtrl.isUpdateError && (
|
<div className="flex-1 overflow-y-auto bg-muted/20">
|
||||||
<ErrorAlert
|
<RdxEditorPageLayout complexity="simple">
|
||||||
message={
|
<div className="rounded-2xl border border-border/60 bg-background px-4 py-4 shadow-xs md:px-6 md:py-6">
|
||||||
(updateCtrl.updateError as Error)?.message ??
|
<div className="flex min-w-0 flex-col gap-6">
|
||||||
t("pages.customers.update.error_msg", "Revisa los datos e inténtalo de nuevo.")
|
{updateCtrl.isUpdateError ? (
|
||||||
}
|
<ErrorAlert
|
||||||
title={t("pages.customers.update.error_title", "No se pudo guardar los cambios")}
|
message={
|
||||||
/>
|
(updateCtrl.updateError as Error)?.message ??
|
||||||
)}
|
t("pages.customers.update.error_msg", "Revisa los datos e inténtalo de nuevo.")
|
||||||
<div className="flex-1 overflow-y-auto">
|
}
|
||||||
<CustomerUpdateEditorForm
|
title={t("pages.customers.update.error_title", "No se pudo guardar los cambios")}
|
||||||
className="max-w-5xl mx-auto mb-12"
|
/>
|
||||||
fiscalCtrl={updateCtrl.fiscalCtrl}
|
) : null}
|
||||||
formId={updateCtrl.formId}
|
|
||||||
isSubmitting={updateCtrl.isUpdating}
|
<CustomerUpdateEditorForm
|
||||||
onReset={updateCtrl.resetForm}
|
fiscalCtrl={updateCtrl.fiscalCtrl}
|
||||||
onSubmit={updateCtrl.onSubmit}
|
formId={updateCtrl.formId}
|
||||||
/>
|
isSubmitting={updateCtrl.isUpdating}
|
||||||
|
onReset={updateCtrl.resetForm}
|
||||||
|
onSubmit={updateCtrl.onSubmit}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</RdxEditorPageLayout>
|
||||||
</div>
|
</div>
|
||||||
</UnsavedChangesProvider>
|
</UnsavedChangesProvider>
|
||||||
</FormProvider>
|
</FormProvider>
|
||||||
|
|||||||
@ -1,8 +1,8 @@
|
|||||||
|
import { useUrlParamId } from "@erp/core/hooks";
|
||||||
import { useCallback, useMemo } from "react";
|
import { useCallback, useMemo } from "react";
|
||||||
import { useNavigate, useSearchParams } from "react-router-dom";
|
import { useNavigate, useSearchParams } from "react-router-dom";
|
||||||
import { useUrlParamId } from "@erp/core/hooks";
|
|
||||||
|
|
||||||
import { useTranslation } from "../../../i18n";
|
import { useTranslation } from "../../i18n";
|
||||||
import {
|
import {
|
||||||
buildCustomerViewMissingEntityPageErrorViewModel,
|
buildCustomerViewMissingEntityPageErrorViewModel,
|
||||||
buildCustomerViewPageErrorViewModel,
|
buildCustomerViewPageErrorViewModel,
|
||||||
|
|||||||
101
packages/rdx-ui/docs/contributing-ui.md
Normal file
101
packages/rdx-ui/docs/contributing-ui.md
Normal file
@ -0,0 +1,101 @@
|
|||||||
|
# Contributing UI
|
||||||
|
|
||||||
|
## Reglas base
|
||||||
|
|
||||||
|
- todos los componentes públicos propios deben usar prefijo `Rdx`;
|
||||||
|
- el código y los identificadores deben estar en inglés;
|
||||||
|
- la documentación y los comentarios deben estar en español;
|
||||||
|
- usar TypeScript estricto;
|
||||||
|
- usar imports de tipos explícitos en cabecera;
|
||||||
|
- no usar tipos inline tipo `import("react").ReactNode`.
|
||||||
|
|
||||||
|
## Responsabilidad de `packages/rdx-ui`
|
||||||
|
|
||||||
|
Los componentes de `rdx-ui`:
|
||||||
|
|
||||||
|
- no conocen React Query;
|
||||||
|
- no conocen routing;
|
||||||
|
- no conocen DTOs;
|
||||||
|
- no conocen entities;
|
||||||
|
- no conocen controllers funcionales;
|
||||||
|
- no contienen lógica de negocio.
|
||||||
|
|
||||||
|
Deben recibir props ya preparadas por la capa consumidora.
|
||||||
|
|
||||||
|
## Tema y color
|
||||||
|
|
||||||
|
- todo debe funcionar en `light` y `dark` desde el inicio;
|
||||||
|
- usar tokens semánticos;
|
||||||
|
- no introducir colores físicos sin justificación fuerte y documentada;
|
||||||
|
- no usar el color como único indicador de estado.
|
||||||
|
|
||||||
|
## Accesibilidad
|
||||||
|
|
||||||
|
- foco visible obligatorio;
|
||||||
|
- botones de icono con nombre accesible;
|
||||||
|
- no crear `div` clicables;
|
||||||
|
- mantener orden DOM coherente;
|
||||||
|
- usar controles reales proporcionados por el consumidor;
|
||||||
|
- evitar wrappers vacíos que compliquen la semántica.
|
||||||
|
|
||||||
|
## API pública
|
||||||
|
|
||||||
|
- mantener una API pequeña y explícita;
|
||||||
|
- no crear abstracciones genéricas sin repetición real;
|
||||||
|
- `className` es un escape controlado, no la API principal;
|
||||||
|
- no usar `transition-all` en nuevos componentes salvo caso excepcional documentado.
|
||||||
|
|
||||||
|
## Composición de paneles
|
||||||
|
|
||||||
|
- no duplicar sombras entre una primitive y su composición;
|
||||||
|
- no duplicar el control de cierre entre capas;
|
||||||
|
- las acciones de contexto deben llegar ya construidas desde el consumidor;
|
||||||
|
- los labels accesibles de acciones de dominio pertenecen al consumidor;
|
||||||
|
- una primitive reutilizable puede resolver superficie e infraestructura, mientras la composición resuelve jerarquía visual y accesibilidad contextual.
|
||||||
|
|
||||||
|
## Acciones de fila
|
||||||
|
|
||||||
|
- `RdxRowActions` solo renderiza acciones primarias y overflow;
|
||||||
|
- el consumidor decide qué acciones existen y en qué orden aparecen;
|
||||||
|
- las acciones destructivas deben ir en overflow salvo excepción justificada por código real;
|
||||||
|
- `disabledReason` pertenece al consumidor y debe ser comprensible sin conocimiento de dominio en `rdx-ui`;
|
||||||
|
- no mover permisos, navegación, dialogs ni entidades dentro de `packages/rdx-ui`;
|
||||||
|
- los icon buttons deben mantener `aria-label` y tooltip accesible.
|
||||||
|
|
||||||
|
## Editor simple
|
||||||
|
|
||||||
|
- `RdxEditorSection` con `variant="plain"` es el patrón por defecto del editor simple;
|
||||||
|
- usar una única superficie editable cuando sea útil separar claramente la página del área de formulario;
|
||||||
|
- no envolver cada sección en `Card` por defecto;
|
||||||
|
- no duplicar padding entre shell, layout y superficie editable;
|
||||||
|
- `RdxFormGrid` debe concentrar la retícula del editor sin acoplarse a RHF;
|
||||||
|
- las diferencias entre create y update no justifican nuevos componentes si solo cambian defaults o wiring.
|
||||||
|
|
||||||
|
## Editor complejo
|
||||||
|
|
||||||
|
- `RdxEditorPageLayout` debe reservar el `aside` para resumen, metadata o contexto de apoyo;
|
||||||
|
- no mover campos editables al `aside` solo para equilibrar visualmente columnas;
|
||||||
|
- en desktop el layout objetivo del editor complejo es `minmax(0, 1fr) ~22rem`;
|
||||||
|
- el `aside` debe apilarse debajo del `main` cuando no haya ancho suficiente;
|
||||||
|
- si no existe `aside`, no debe reservarse hueco lateral;
|
||||||
|
- mantener una única superficie principal para el formulario, también en editores complejos;
|
||||||
|
- `RdxEditorSection` puede usar `variant="panel"` dentro del `aside` cuando el bloque necesite superficie independiente;
|
||||||
|
- `RdxFormGrid` debe quedarse en grupos de campos y no envolver tablas, line editors ni layouts funcionales especializados;
|
||||||
|
- no mover los totales al `aside` por defecto;
|
||||||
|
- si el editor usa footer sticky inferior, no duplicar `Guardar` y `Cancelar` completos en el header;
|
||||||
|
- `RdxEditorStickyFooter` no conoce RHF, submit, routing ni dominio; solo compone superficie, orden, separación y responsive;
|
||||||
|
- la región `leading` del footer puede alojar resumen o contexto, pero ese contenido sigue perteneciendo al dominio consumidor;
|
||||||
|
- coordinar cualquier `sticky` lateral con el offset del header para evitar clipping y foco oculto.
|
||||||
|
|
||||||
|
## Sincronización obligatoria
|
||||||
|
|
||||||
|
Todo cambio futuro que afecte a la API pública o al comportamiento visual debe actualizar en el mismo cambio:
|
||||||
|
|
||||||
|
```txt
|
||||||
|
1. implementación
|
||||||
|
2. documentación Markdown
|
||||||
|
3. página interactiva UI Kit
|
||||||
|
4. tests aplicables
|
||||||
|
```
|
||||||
|
|
||||||
|
Un cambio queda incompleto si cualquiera de esas cuatro piezas queda desincronizada.
|
||||||
134
packages/rdx-ui/docs/foundations.md
Normal file
134
packages/rdx-ui/docs/foundations.md
Normal file
@ -0,0 +1,134 @@
|
|||||||
|
# Foundations Rdx
|
||||||
|
|
||||||
|
## Estado
|
||||||
|
|
||||||
|
Este documento define las foundations estructurales iniciales del sistema visual Rdx.
|
||||||
|
|
||||||
|
El documento canónico de contexto global es:
|
||||||
|
|
||||||
|
- [visual-system-design.md](./visual-system-design.md)
|
||||||
|
|
||||||
|
## Espaciado de página
|
||||||
|
|
||||||
|
```txt
|
||||||
|
mobile:
|
||||||
|
padding 4
|
||||||
|
|
||||||
|
desktop:
|
||||||
|
padding 6
|
||||||
|
```
|
||||||
|
|
||||||
|
Referencia inicial:
|
||||||
|
|
||||||
|
```txt
|
||||||
|
px-4 py-4
|
||||||
|
md:px-6 md:py-6
|
||||||
|
```
|
||||||
|
|
||||||
|
Se aplica en `RdxPageContainer`.
|
||||||
|
|
||||||
|
Uso correcto:
|
||||||
|
|
||||||
|
- usarlo como padding principal de página;
|
||||||
|
- no anidarlo dentro de otro contenedor que ya aporte el mismo padding estructural;
|
||||||
|
- si el shell ya aplica padding equivalente, el consumidor debe evitar duplicarlo.
|
||||||
|
|
||||||
|
## Ritmo vertical
|
||||||
|
|
||||||
|
```txt
|
||||||
|
compact:
|
||||||
|
gap 4
|
||||||
|
|
||||||
|
default:
|
||||||
|
gap 6
|
||||||
|
|
||||||
|
relaxed:
|
||||||
|
gap 8
|
||||||
|
```
|
||||||
|
|
||||||
|
No se fija `gap-12` como foundation general.
|
||||||
|
|
||||||
|
Se aplica en `RdxPageStack`.
|
||||||
|
|
||||||
|
## Anchuras
|
||||||
|
|
||||||
|
```txt
|
||||||
|
full:
|
||||||
|
w-full
|
||||||
|
|
||||||
|
wide:
|
||||||
|
max-w-screen-2xl mx-auto
|
||||||
|
|
||||||
|
content:
|
||||||
|
max-w-5xl mx-auto
|
||||||
|
```
|
||||||
|
|
||||||
|
Uso recomendado:
|
||||||
|
|
||||||
|
- `full`: listados, tablas, editores complejos, pantallas documentales;
|
||||||
|
- `wide`: detalles o páginas amplias centradas;
|
||||||
|
- `content`: editores simples y formularios de una columna lógica.
|
||||||
|
|
||||||
|
## Superficies
|
||||||
|
|
||||||
|
```txt
|
||||||
|
contenido principal:
|
||||||
|
plano
|
||||||
|
|
||||||
|
secciones:
|
||||||
|
separadas por espacio o divisores
|
||||||
|
|
||||||
|
aside:
|
||||||
|
puede usar paneles
|
||||||
|
|
||||||
|
tabla/editor de líneas:
|
||||||
|
puede usar superficie independiente
|
||||||
|
|
||||||
|
card:
|
||||||
|
excepción, no default
|
||||||
|
```
|
||||||
|
|
||||||
|
Reglas:
|
||||||
|
|
||||||
|
- no usar card como solución por defecto para toda la página;
|
||||||
|
- evitar card dentro de card;
|
||||||
|
- preferir espacio, jerarquía tipográfica y divisores.
|
||||||
|
|
||||||
|
## Sombras
|
||||||
|
|
||||||
|
```txt
|
||||||
|
página:
|
||||||
|
sin sombra
|
||||||
|
|
||||||
|
secciones:
|
||||||
|
sin sombra
|
||||||
|
|
||||||
|
panel contextual:
|
||||||
|
sombra suave opcional
|
||||||
|
|
||||||
|
sticky header/footer:
|
||||||
|
preferir borde
|
||||||
|
usar sombra mínima solo cuando sea necesaria
|
||||||
|
```
|
||||||
|
|
||||||
|
## Tema
|
||||||
|
|
||||||
|
Uso obligatorio de tokens semánticos:
|
||||||
|
|
||||||
|
```txt
|
||||||
|
bg-background
|
||||||
|
text-foreground
|
||||||
|
bg-muted
|
||||||
|
text-muted-foreground
|
||||||
|
border-border
|
||||||
|
bg-primary
|
||||||
|
text-primary-foreground
|
||||||
|
bg-destructive
|
||||||
|
text-destructive-foreground
|
||||||
|
```
|
||||||
|
|
||||||
|
Reglas:
|
||||||
|
|
||||||
|
- no introducir colores físicos para resolver superficies o estados;
|
||||||
|
- no crear una nueva paleta de `success`, `warning` o `info` en esta fase si no existe convención central consolidada;
|
||||||
|
- validar cualquier foundation nueva en `light` y `dark`.
|
||||||
247
packages/rdx-ui/docs/page-layouts.md
Normal file
247
packages/rdx-ui/docs/page-layouts.md
Normal file
@ -0,0 +1,247 @@
|
|||||||
|
# Page Layouts Rdx
|
||||||
|
|
||||||
|
## Estado
|
||||||
|
|
||||||
|
Este documento describe los patrones conceptuales de página aprobados para adopción progresiva.
|
||||||
|
|
||||||
|
El documento canónico de contexto global es:
|
||||||
|
|
||||||
|
- [visual-system-design.md](./visual-system-design.md)
|
||||||
|
|
||||||
|
## Listado
|
||||||
|
|
||||||
|
```txt
|
||||||
|
RdxPageContainer
|
||||||
|
└── RdxPageStack
|
||||||
|
├── RdxPageHeader
|
||||||
|
├── notices opcionales
|
||||||
|
└── contenido del listado
|
||||||
|
```
|
||||||
|
|
||||||
|
Reglas:
|
||||||
|
|
||||||
|
- anchura por defecto `full`;
|
||||||
|
- acciones principales en cabecera;
|
||||||
|
- acciones de fila frecuentes visibles y acciones destructivas en overflow mediante `RdxRowActions`;
|
||||||
|
- panel contextual solo cuando aporte contexto real;
|
||||||
|
- la composición del listado no debe acoplarse a React Query, routing ni dominio.
|
||||||
|
|
||||||
|
Ejemplo de integración real validado en `proformas/list` y `customers/list`:
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
<RdxPageContainer width="full">
|
||||||
|
<RdxPageStack spacing="default">
|
||||||
|
<RdxPageHeader
|
||||||
|
title={title}
|
||||||
|
description={description}
|
||||||
|
actions={<RdxPageActions primary={primaryAction} />}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{notice}
|
||||||
|
|
||||||
|
{content}
|
||||||
|
</RdxPageStack>
|
||||||
|
|
||||||
|
{dialogs}
|
||||||
|
</RdxPageContainer>
|
||||||
|
```
|
||||||
|
|
||||||
|
Aclaraciones:
|
||||||
|
|
||||||
|
- `RdxPageContainer` resuelve el padding exterior y la anchura de página;
|
||||||
|
- `RdxPageStack` resuelve el ritmo vertical principal entre cabecera, aviso y contenido;
|
||||||
|
- `RdxPageHeader` no conoce navegación ni callbacks de React Router;
|
||||||
|
- la tabla, los filtros, los panels, los dialogs y los controllers permanecen fuera de las foundations;
|
||||||
|
- `RdxRowActions` puede vivir dentro de las columnas de tabla, pero recibe acciones ya decididas por el consumidor;
|
||||||
|
- en ambos pilotos el panel contextual sigue viviendo en composición local mediante `ResizablePanelGroup`;
|
||||||
|
- no documentar todavía `RdxListPageLayout` ni `RdxResponsivePageGrid` mientras el resize y la apertura del aside dependan del caso de uso.
|
||||||
|
|
||||||
|
### Evidencia de pilotos de listado
|
||||||
|
|
||||||
|
```txt
|
||||||
|
DECISIÓN_CONFIRMADA
|
||||||
|
Container + Stack + Header + Actions cubren la capa exterior del listado.
|
||||||
|
|
||||||
|
DECISIÓN_PROVISIONAL
|
||||||
|
La Page puede extraer una vista pura si mejora el test y reduce wiring.
|
||||||
|
|
||||||
|
NO_ABSTRAER_TODAVÍA
|
||||||
|
main + aside redimensionable
|
||||||
|
shell específico del panel contextual
|
||||||
|
toolbars
|
||||||
|
```
|
||||||
|
|
||||||
|
## Listado con panel contextual
|
||||||
|
|
||||||
|
```txt
|
||||||
|
ResizablePanelGroup
|
||||||
|
├── main
|
||||||
|
└── RightPanel
|
||||||
|
└── RdxContextPanel
|
||||||
|
```
|
||||||
|
|
||||||
|
Reglas:
|
||||||
|
|
||||||
|
- `ResizablePanelGroup` gestiona la geometría, el resize y la persistencia local del split;
|
||||||
|
- `RightPanel` gestiona la superficie lateral, la altura y el overflow base;
|
||||||
|
- `RdxContextPanel` gestiona cabecera, cierre, body y footer visual del panel;
|
||||||
|
- el contenido interno del panel sigue siendo específico del dominio;
|
||||||
|
- no documentar todavía un layout genérico de listado a partir de este patrón.
|
||||||
|
|
||||||
|
## Editor simple
|
||||||
|
|
||||||
|
```txt
|
||||||
|
scroll container
|
||||||
|
└── RdxEditorPageLayout complexity="simple"
|
||||||
|
└── form stack
|
||||||
|
├── RdxEditorSection
|
||||||
|
├── RdxEditorSection
|
||||||
|
└── ...
|
||||||
|
```
|
||||||
|
|
||||||
|
Reglas:
|
||||||
|
|
||||||
|
- `RdxEditorPageLayout` resuelve padding interior, ancho centrado y `min-w-0`;
|
||||||
|
- el editor simple puede apoyarse en una única superficie contenedora para diferenciar la página del área editable, sin volver a cards por sección;
|
||||||
|
- `RdxEditorSection` expresa jerarquía por defecto con spacing y divisor inferior;
|
||||||
|
- `RdxFormGrid` mantiene la retícula del formulario sin conocer RHF ni dominio;
|
||||||
|
- priorizar lectura y velocidad de escaneo;
|
||||||
|
- usar secciones planas con espacio o divisores;
|
||||||
|
- reservar sticky footer solo cuando el scroll lo justifique.
|
||||||
|
|
||||||
|
Ejemplo validado en `customers/update` y `customers/create`:
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
<div className="flex-1 overflow-y-auto">
|
||||||
|
<RdxEditorPageLayout complexity="simple">
|
||||||
|
<div className="flex min-w-0 flex-col gap-6">
|
||||||
|
{notice}
|
||||||
|
|
||||||
|
<form className="flex min-w-0 flex-col gap-6">
|
||||||
|
<RdxEditorSection title={title} description={description}>
|
||||||
|
<RdxFormGrid>{fields}</RdxFormGrid>
|
||||||
|
</RdxEditorSection>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</RdxEditorPageLayout>
|
||||||
|
</div>
|
||||||
|
```
|
||||||
|
|
||||||
|
Deuda registrada:
|
||||||
|
|
||||||
|
- `PageFormHeader` sigue siendo full-width mientras el contenido del editor queda centrado en `max-w-5xl`;
|
||||||
|
- no modificar todavía el header legacy sin validación visual manual real.
|
||||||
|
|
||||||
|
Evidencia adicional de create/update:
|
||||||
|
|
||||||
|
```txt
|
||||||
|
DECISIÓN_CONFIRMADA
|
||||||
|
ambos flujos reutilizan el mismo layout, superficie y secciones.
|
||||||
|
|
||||||
|
ESPECÍFICO
|
||||||
|
defaults iniciales
|
||||||
|
wiring de submit
|
||||||
|
feedback de error
|
||||||
|
```
|
||||||
|
|
||||||
|
## Editor complejo
|
||||||
|
|
||||||
|
```txt
|
||||||
|
shell
|
||||||
|
├── PageFormHeader
|
||||||
|
├── scroll container
|
||||||
|
│ └── RdxEditorPageLayout complexity="complex"
|
||||||
|
│ ├── main
|
||||||
|
│ │ └── única superficie editable
|
||||||
|
│ │ ├── RdxEditorSection
|
||||||
|
│ │ ├── RdxEditorSection
|
||||||
|
│ │ ├── bloque de líneas con layout propio
|
||||||
|
│ │ └── ...
|
||||||
|
│ └── aside
|
||||||
|
│ └── controles secundarios o contexto de apoyo
|
||||||
|
└── RdxEditorStickyFooter
|
||||||
|
├── summary/context region
|
||||||
|
└── action region
|
||||||
|
```
|
||||||
|
|
||||||
|
Reglas:
|
||||||
|
|
||||||
|
- `RdxEditorPageLayout` resuelve padding exterior, `min-w-0` y la geometría `main + aside`;
|
||||||
|
- el `main` debe alojar los campos editables y sus bloques funcionales;
|
||||||
|
- el `aside` debe reservarse para metadata, configuración o contexto de apoyo ya existente en la pantalla;
|
||||||
|
- el `aside` del editor complejo usa una geometría aproximada `minmax(0, 1fr) 22rem` en desktop y se apila debajo del `main` cuando no hay ancho suficiente;
|
||||||
|
- no mover campos editables al `aside` solo por estética si eso empeora el flujo;
|
||||||
|
- no mover los totales al `aside`;
|
||||||
|
- los bloques con layout interno propio, como una tabla de líneas, no deben forzarse dentro de `RdxFormGrid`;
|
||||||
|
- `RdxEditorStickyFooter` resuelve una única superficie sticky con una región contextual flexible y una región de acciones;
|
||||||
|
- los totales siguen perteneciendo al dominio de Proformas aunque se rendericen dentro de la región contextual del footer;
|
||||||
|
- `PageFormHeader` puede seguir existiendo, pero no debe duplicar `Guardar` y `Cancelar` cuando el footer sticky ya los contiene.
|
||||||
|
|
||||||
|
Ejemplo validado en `proformas/update`:
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
<div className="fixed inset-0 flex flex-col overflow-hidden">
|
||||||
|
<PageFormHeader />
|
||||||
|
|
||||||
|
<div className="flex min-h-0 flex-1 flex-col bg-muted/20">
|
||||||
|
<div className="min-h-0 flex-1 overflow-y-auto">
|
||||||
|
{notice}
|
||||||
|
|
||||||
|
<RdxEditorPageLayout
|
||||||
|
complexity="complex"
|
||||||
|
aside={aside}
|
||||||
|
>
|
||||||
|
<div className="rounded-2xl border border-border bg-background px-4 py-4 shadow-xs md:px-6 md:py-6">
|
||||||
|
<form className="flex min-w-0 flex-col gap-6">
|
||||||
|
<RdxEditorSection title={documentTitle}>
|
||||||
|
<RdxFormGrid>{documentFields}</RdxFormGrid>
|
||||||
|
</RdxEditorSection>
|
||||||
|
|
||||||
|
{lineEditor}
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</RdxEditorPageLayout>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<RdxEditorStickyFooter
|
||||||
|
leading={<TotalsSummary />}
|
||||||
|
secondary={cancelButton}
|
||||||
|
primary={saveButton}
|
||||||
|
status={status}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
```
|
||||||
|
|
||||||
|
Evidencia del piloto:
|
||||||
|
|
||||||
|
```txt
|
||||||
|
DECISIÓN_CONFIRMADA
|
||||||
|
main contiene la única superficie editable
|
||||||
|
aside contiene bloques secundarios ya existentes del editor
|
||||||
|
la sticky zone inferior pasa a una única superficie visual
|
||||||
|
los totales permanecen como dominio de Proformas dentro de la región contextual
|
||||||
|
las acciones principales comparten el mismo footer sin una segunda franja
|
||||||
|
RdxFormGrid se mantiene solo para campos
|
||||||
|
|
||||||
|
DECISIÓN_PROVISIONAL
|
||||||
|
visual exacta del header legacy frente al ancho full del editor complejo
|
||||||
|
|
||||||
|
NO_ABSTRAER_TODAVÍA
|
||||||
|
layout específico del bloque de líneas
|
||||||
|
layout específico del footer de totales
|
||||||
|
```
|
||||||
|
|
||||||
|
## Detalle
|
||||||
|
|
||||||
|
```txt
|
||||||
|
width="wide" o "full"
|
||||||
|
main + aside opcional
|
||||||
|
aside para metadata, actividad, resumen o acciones
|
||||||
|
```
|
||||||
|
|
||||||
|
Reglas:
|
||||||
|
|
||||||
|
- mantener patrón consistente de label/value;
|
||||||
|
- usar aside solo cuando el contenido realmente gane claridad;
|
||||||
|
- no implementar todavía `RdxDetailPageLayout` en esta fase.
|
||||||
1225
packages/rdx-ui/docs/visual-system-design.md
Normal file
1225
packages/rdx-ui/docs/visual-system-design.md
Normal file
File diff suppressed because it is too large
Load Diff
@ -6,6 +6,7 @@
|
|||||||
"sideEffects": false,
|
"sideEffects": false,
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"typecheck": "tsc -p tsconfig.json --noEmit",
|
"typecheck": "tsc -p tsconfig.json --noEmit",
|
||||||
|
"test": "node --import tsx --test ./src/components/layout/rdx-page-foundations.test.tsx ./src/components/context-panel/rdx-context-panel.test.tsx ./src/components/row-actions/rdx-row-actions.test.tsx ./src/components/editor-page/rdx-editor-foundations.test.tsx",
|
||||||
"ui:lint": "biome lint --fix",
|
"ui:lint": "biome lint --fix",
|
||||||
"check": "biome check .",
|
"check": "biome check .",
|
||||||
"lint": "biome lint .",
|
"lint": "biome lint .",
|
||||||
@ -37,6 +38,7 @@
|
|||||||
"esbuild-plugin-react18-css": "^0.0.4",
|
"esbuild-plugin-react18-css": "^0.0.4",
|
||||||
"rimraf": "^6.1.3",
|
"rimraf": "^6.1.3",
|
||||||
"tailwindcss": "^4.2.2",
|
"tailwindcss": "^4.2.2",
|
||||||
|
"tsx": "^4.21.0",
|
||||||
"tsup": "^8.5.1",
|
"tsup": "^8.5.1",
|
||||||
"tw-animate-css": "^1.4.0",
|
"tw-animate-css": "^1.4.0",
|
||||||
"typescript": "^6.0.2",
|
"typescript": "^6.0.2",
|
||||||
@ -66,4 +68,4 @@
|
|||||||
"sonner": "^2.0.7",
|
"sonner": "^2.0.7",
|
||||||
"zod": "^4.3.6"
|
"zod": "^4.3.6"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
1
packages/rdx-ui/src/components/context-panel/index.ts
Normal file
1
packages/rdx-ui/src/components/context-panel/index.ts
Normal file
@ -0,0 +1 @@
|
|||||||
|
export * from "./rdx-context-panel.tsx";
|
||||||
@ -0,0 +1,121 @@
|
|||||||
|
import assert from "node:assert/strict";
|
||||||
|
import test from "node:test";
|
||||||
|
import { isValidElement } from "react";
|
||||||
|
import { renderToStaticMarkup } from "react-dom/server";
|
||||||
|
|
||||||
|
import { RdxContextPanel } from "./rdx-context-panel.tsx";
|
||||||
|
|
||||||
|
const resolveElement = (node: unknown): unknown => {
|
||||||
|
if (Array.isArray(node)) {
|
||||||
|
return node.map(resolveElement);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!isValidElement(node)) {
|
||||||
|
return node;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (typeof node.type === "function") {
|
||||||
|
return resolveElement(node.type(node.props));
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
type: node.type,
|
||||||
|
props: {
|
||||||
|
...node.props,
|
||||||
|
children: resolveElement(node.props.children),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
const findElementsByType = (
|
||||||
|
node: unknown,
|
||||||
|
type: string
|
||||||
|
): Array<{ type: string; props: Record<string, unknown> }> => {
|
||||||
|
if (Array.isArray(node)) {
|
||||||
|
return node.flatMap((child) => findElementsByType(child, type));
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!node || typeof node !== "object" || !("type" in node) || !("props" in node)) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
const element = node as { type: string; props: Record<string, unknown> };
|
||||||
|
const children = findElementsByType(element.props.children, type);
|
||||||
|
|
||||||
|
if (element.type === type) {
|
||||||
|
return [element, ...children];
|
||||||
|
}
|
||||||
|
|
||||||
|
return children;
|
||||||
|
};
|
||||||
|
|
||||||
|
test("renderiza título semántico, descripción, actions, children y footer", () => {
|
||||||
|
const html = renderToStaticMarkup(
|
||||||
|
<RdxContextPanel
|
||||||
|
actions={<button type="button">Edit</button>}
|
||||||
|
closeLabel="Cerrar panel"
|
||||||
|
description="Context description"
|
||||||
|
footer={<div>Footer content</div>}
|
||||||
|
onClose={() => undefined}
|
||||||
|
title="Context title"
|
||||||
|
>
|
||||||
|
<div>Panel body</div>
|
||||||
|
</RdxContextPanel>
|
||||||
|
);
|
||||||
|
|
||||||
|
assert.match(html, /<h2[^>]*>Context title<\/h2>/);
|
||||||
|
assert.match(html, /Context description/);
|
||||||
|
assert.match(html, /Edit/);
|
||||||
|
assert.match(html, /Panel body/);
|
||||||
|
assert.match(html, /Footer content/);
|
||||||
|
assert.match(html, /Cerrar panel/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("no renderiza regiones opcionales vacías y combina className", () => {
|
||||||
|
const html = renderToStaticMarkup(
|
||||||
|
<RdxContextPanel className="custom-panel" title="Only title">
|
||||||
|
<div>Body</div>
|
||||||
|
</RdxContextPanel>
|
||||||
|
);
|
||||||
|
|
||||||
|
assert.match(html, /custom-panel/);
|
||||||
|
assert.match(html, /<h2[^>]*>Only title<\/h2>/);
|
||||||
|
assert.equal((html.match(/Cerrar panel/g) ?? []).length, 0);
|
||||||
|
assert.equal((html.match(/<p/g) ?? []).length, 0);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("ejecuta el cierre y mantiene un único control de cerrar", async () => {
|
||||||
|
let wasClosed = false;
|
||||||
|
|
||||||
|
const resolved = resolveElement(
|
||||||
|
<RdxContextPanel
|
||||||
|
closeLabel="Cerrar panel"
|
||||||
|
onClose={() => {
|
||||||
|
wasClosed = true;
|
||||||
|
}}
|
||||||
|
title="Context title"
|
||||||
|
>
|
||||||
|
<div>Body</div>
|
||||||
|
</RdxContextPanel>
|
||||||
|
);
|
||||||
|
|
||||||
|
const buttons = findElementsByType(resolved, "button");
|
||||||
|
const closeButtons = buttons.filter((button) => button.props["aria-label"] === "Cerrar panel");
|
||||||
|
|
||||||
|
assert.equal(closeButtons.length, 1);
|
||||||
|
|
||||||
|
await closeButtons[0]?.props.onClick?.();
|
||||||
|
|
||||||
|
assert.equal(wasClosed, true);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("mantiene el orden DOM esperado entre header, body y footer", () => {
|
||||||
|
const html = renderToStaticMarkup(
|
||||||
|
<RdxContextPanel footer={<div>Footer content</div>} title="Context title">
|
||||||
|
<div>Panel body</div>
|
||||||
|
</RdxContextPanel>
|
||||||
|
);
|
||||||
|
|
||||||
|
assert.ok(html.indexOf("Context title") < html.indexOf("Panel body"));
|
||||||
|
assert.ok(html.indexOf("Panel body") < html.indexOf("Footer content"));
|
||||||
|
});
|
||||||
@ -0,0 +1,84 @@
|
|||||||
|
import { Button } from "@repo/shadcn-ui/components";
|
||||||
|
import { cn } from "@repo/shadcn-ui/lib/utils";
|
||||||
|
import { XIcon } from "lucide-react";
|
||||||
|
import type { ComponentPropsWithoutRef, ReactNode } from "react";
|
||||||
|
import { useId } from "react";
|
||||||
|
|
||||||
|
type RdxContextPanelClosableProps =
|
||||||
|
| {
|
||||||
|
onClose: () => void;
|
||||||
|
closeLabel: string;
|
||||||
|
}
|
||||||
|
| {
|
||||||
|
onClose?: undefined;
|
||||||
|
closeLabel?: undefined;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type RdxContextPanelProps = Omit<
|
||||||
|
ComponentPropsWithoutRef<"div">,
|
||||||
|
"children" | "title"
|
||||||
|
> &
|
||||||
|
RdxContextPanelClosableProps & {
|
||||||
|
title: ReactNode;
|
||||||
|
description?: ReactNode;
|
||||||
|
actions?: ReactNode;
|
||||||
|
children: ReactNode;
|
||||||
|
footer?: ReactNode;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const RdxContextPanel = ({
|
||||||
|
title,
|
||||||
|
description,
|
||||||
|
actions,
|
||||||
|
children,
|
||||||
|
footer,
|
||||||
|
onClose,
|
||||||
|
closeLabel,
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: RdxContextPanelProps) => {
|
||||||
|
const titleId = useId();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<aside
|
||||||
|
aria-labelledby={titleId}
|
||||||
|
className={cn("flex h-full min-w-0 flex-col bg-background text-foreground", className)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
<div className="flex items-start justify-between gap-4 border-b border-border px-4 py-4">
|
||||||
|
<div className="min-w-0 flex-1 space-y-1">
|
||||||
|
<h2
|
||||||
|
className="min-w-0 text-base font-semibold tracking-tight text-foreground"
|
||||||
|
id={titleId}
|
||||||
|
>
|
||||||
|
{title}
|
||||||
|
</h2>
|
||||||
|
|
||||||
|
{description ? <p className="text-sm text-muted-foreground">{description}</p> : null}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{actions || onClose ? (
|
||||||
|
<div className="flex shrink-0 items-start gap-1">
|
||||||
|
{actions ? <div className="flex items-center gap-1">{actions}</div> : null}
|
||||||
|
|
||||||
|
{onClose && closeLabel ? (
|
||||||
|
<Button
|
||||||
|
aria-label={closeLabel}
|
||||||
|
onClick={onClose}
|
||||||
|
size="icon-sm"
|
||||||
|
type="button"
|
||||||
|
variant="ghost"
|
||||||
|
>
|
||||||
|
<XIcon className="size-4" />
|
||||||
|
</Button>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="min-h-0 min-w-0 flex-1 overflow-auto">{children}</div>
|
||||||
|
|
||||||
|
{footer ? <div className="shrink-0 border-t border-border px-4 py-4">{footer}</div> : null}
|
||||||
|
</aside>
|
||||||
|
);
|
||||||
|
};
|
||||||
3
packages/rdx-ui/src/components/editor-page/index.ts
Normal file
3
packages/rdx-ui/src/components/editor-page/index.ts
Normal file
@ -0,0 +1,3 @@
|
|||||||
|
export * from "./rdx-editor-page-layout.tsx";
|
||||||
|
export * from "./rdx-editor-section.tsx";
|
||||||
|
export * from "./rdx-editor-sticky-footer.tsx";
|
||||||
@ -0,0 +1,121 @@
|
|||||||
|
ximport assert from "node:assert/strict";
|
||||||
|
import test from "node:test";
|
||||||
|
import { renderToStaticMarkup } from "react-dom/server";
|
||||||
|
|
||||||
|
import { RdxFormGrid } from "../form/rdx-form-grid.tsx";
|
||||||
|
import { RdxEditorPageLayout } from "./rdx-editor-page-layout.tsx";
|
||||||
|
import { RdxEditorSection } from "./rdx-editor-section.tsx";
|
||||||
|
import { RdxEditorStickyFooter } from "./rdx-editor-sticky-footer.tsx";
|
||||||
|
|
||||||
|
test("RdxEditorPageLayout aplica complejidad simple por defecto y renderiza children", () => {
|
||||||
|
const html = renderToStaticMarkup(
|
||||||
|
<RdxEditorPageLayout>
|
||||||
|
<div>Editor content</div>
|
||||||
|
</RdxEditorPageLayout>
|
||||||
|
);
|
||||||
|
|
||||||
|
assert.match(html, /Editor content/);
|
||||||
|
assert.match(html, /max-w-5xl/);
|
||||||
|
assert.match(html, /px-4/);
|
||||||
|
assert.match(html, /md:px-6/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("RdxEditorPageLayout soporta aside opcional y complejidad complex", () => {
|
||||||
|
const html = renderToStaticMarkup(
|
||||||
|
<RdxEditorPageLayout aside={<div>Aside content</div>} complexity="complex">
|
||||||
|
<div>Main content</div>
|
||||||
|
</RdxEditorPageLayout>
|
||||||
|
);
|
||||||
|
|
||||||
|
assert.match(html, /Main content/);
|
||||||
|
assert.match(html, /Aside content/);
|
||||||
|
assert.match(html, /xl:grid-cols-\[minmax\(0,1fr\)_22rem\]/);
|
||||||
|
assert.doesNotMatch(html, /max-w-5xl/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("RdxEditorPageLayout no reserva aside cuando no existe incluso en complex", () => {
|
||||||
|
const html = renderToStaticMarkup(
|
||||||
|
<RdxEditorPageLayout complexity="complex">
|
||||||
|
<div>Main content</div>
|
||||||
|
</RdxEditorPageLayout>
|
||||||
|
);
|
||||||
|
|
||||||
|
assert.match(html, /Main content/);
|
||||||
|
assert.doesNotMatch(html, /<aside/);
|
||||||
|
assert.doesNotMatch(html, /grid-cols-\[/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("RdxEditorSection plain renderiza header semántico y contenido sin card", () => {
|
||||||
|
const html = renderToStaticMarkup(
|
||||||
|
<RdxEditorSection
|
||||||
|
actions={<button type="button">Action</button>}
|
||||||
|
description="Section description"
|
||||||
|
icon={<span>Icon</span>}
|
||||||
|
title="Section title"
|
||||||
|
>
|
||||||
|
<div>Section body</div>
|
||||||
|
</RdxEditorSection>
|
||||||
|
);
|
||||||
|
|
||||||
|
assert.match(html, /<h2[^>]*>Section title<\/h2>/);
|
||||||
|
assert.match(html, /Section description/);
|
||||||
|
assert.match(html, /Section body/);
|
||||||
|
assert.match(html, /Action/);
|
||||||
|
assert.doesNotMatch(html, /shadow/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("RdxEditorSection panel conserva superficie independiente", () => {
|
||||||
|
const html = renderToStaticMarkup(
|
||||||
|
<RdxEditorSection title="Panel section" variant="panel">
|
||||||
|
<div>Panel body</div>
|
||||||
|
</RdxEditorSection>
|
||||||
|
);
|
||||||
|
|
||||||
|
assert.match(html, /rounded-xl/);
|
||||||
|
assert.match(html, /border-border/);
|
||||||
|
assert.match(html, /Panel body/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("RdxFormGrid mantiene la retícula base y combina className", () => {
|
||||||
|
const html = renderToStaticMarkup(
|
||||||
|
<RdxFormGrid className="custom-grid">
|
||||||
|
<div>Field 1</div>
|
||||||
|
<div>Field 2</div>
|
||||||
|
</RdxFormGrid>
|
||||||
|
);
|
||||||
|
|
||||||
|
assert.match(html, /grid-cols-1/);
|
||||||
|
assert.match(html, /gap-x-6/);
|
||||||
|
assert.match(html, /gap-y-5/);
|
||||||
|
assert.match(html, /md:grid-cols-12/);
|
||||||
|
assert.match(html, /custom-grid/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("RdxEditorStickyFooter renderiza regiones presentes sin wrappers vacíos", () => {
|
||||||
|
const html = renderToStaticMarkup(
|
||||||
|
<RdxEditorStickyFooter
|
||||||
|
className="custom-sticky"
|
||||||
|
leading={<div>Totals summary</div>}
|
||||||
|
primary={<button type="submit">Save</button>}
|
||||||
|
secondary={<button type="button">Cancel</button>}
|
||||||
|
status="Dirty changes"
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
|
||||||
|
assert.match(html, /Totals summary/);
|
||||||
|
assert.match(html, /Dirty changes/);
|
||||||
|
assert.match(html, /Cancel/);
|
||||||
|
assert.match(html, /Save/);
|
||||||
|
assert.match(html, /custom-sticky/);
|
||||||
|
assert.ok(html.indexOf("Totals summary") < html.indexOf("Dirty changes"));
|
||||||
|
assert.ok(html.indexOf("Dirty changes") < html.indexOf("Cancel"));
|
||||||
|
assert.ok(html.indexOf("Cancel") < html.indexOf("Save"));
|
||||||
|
});
|
||||||
|
|
||||||
|
test("RdxEditorStickyFooter omite regiones opcionales vacías", () => {
|
||||||
|
const html = renderToStaticMarkup(<RdxEditorStickyFooter primary={<button type="submit">Save</button>} />);
|
||||||
|
|
||||||
|
assert.match(html, /Save/);
|
||||||
|
assert.doesNotMatch(html, /text-muted-foreground/);
|
||||||
|
assert.equal((html.match(/type="submit"/g) ?? []).length, 1);
|
||||||
|
});
|
||||||
@ -0,0 +1,44 @@
|
|||||||
|
import { cn } from "@repo/shadcn-ui/lib/utils";
|
||||||
|
import type { ComponentPropsWithoutRef, ReactNode } from "react";
|
||||||
|
|
||||||
|
export type RdxEditorComplexity = "simple" | "complex";
|
||||||
|
|
||||||
|
export interface RdxEditorPageLayoutProps
|
||||||
|
extends Omit<ComponentPropsWithoutRef<"div">, "children"> {
|
||||||
|
children: ReactNode;
|
||||||
|
aside?: ReactNode;
|
||||||
|
complexity?: RdxEditorComplexity;
|
||||||
|
}
|
||||||
|
|
||||||
|
const CONTENT_WIDTH_CLASSNAME: Record<RdxEditorComplexity, string> = {
|
||||||
|
simple: "mx-auto w-full max-w-5xl",
|
||||||
|
complex: "w-full",
|
||||||
|
};
|
||||||
|
|
||||||
|
const ASIDE_GRID_CLASSNAME: Record<RdxEditorComplexity, string> = {
|
||||||
|
simple: "xl:grid-cols-[minmax(0,1fr)_280px]",
|
||||||
|
complex: "xl:grid-cols-[minmax(0,1fr)_22rem]",
|
||||||
|
};
|
||||||
|
|
||||||
|
export const RdxEditorPageLayout = ({
|
||||||
|
children,
|
||||||
|
aside,
|
||||||
|
complexity = "simple",
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: RdxEditorPageLayoutProps) => {
|
||||||
|
const contentClassName = CONTENT_WIDTH_CLASSNAME[complexity];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={cn("min-w-0 w-full px-4 py-4 md:px-6 md:py-6", className)} {...props}>
|
||||||
|
{aside ? (
|
||||||
|
<div className={cn(contentClassName, "grid min-w-0 gap-6", ASIDE_GRID_CLASSNAME[complexity])}>
|
||||||
|
<div className="min-w-0">{children}</div>
|
||||||
|
<aside className="min-w-0">{aside}</aside>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className={cn("min-w-0", contentClassName)}>{children}</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
@ -0,0 +1,73 @@
|
|||||||
|
import { cn } from "@repo/shadcn-ui/lib/utils";
|
||||||
|
import type { ComponentPropsWithoutRef, ReactNode } from "react";
|
||||||
|
import { useId } from "react";
|
||||||
|
|
||||||
|
export type RdxEditorSectionVariant = "plain" | "panel";
|
||||||
|
|
||||||
|
export interface RdxEditorSectionProps
|
||||||
|
extends Omit<ComponentPropsWithoutRef<"section">, "children" | "title"> {
|
||||||
|
title: ReactNode;
|
||||||
|
description?: ReactNode;
|
||||||
|
icon?: ReactNode;
|
||||||
|
actions?: ReactNode;
|
||||||
|
children: ReactNode;
|
||||||
|
variant?: RdxEditorSectionVariant;
|
||||||
|
}
|
||||||
|
|
||||||
|
const VARIANT_CLASSNAME: Record<RdxEditorSectionVariant, string> = {
|
||||||
|
plain: "min-w-0 border-b border-border pb-6 last:border-b-0 last:pb-0",
|
||||||
|
panel: "min-w-0 rounded-xl border border-border bg-background p-4 shadow-xs sm:p-5",
|
||||||
|
};
|
||||||
|
|
||||||
|
export const RdxEditorSection = ({
|
||||||
|
title,
|
||||||
|
description,
|
||||||
|
icon,
|
||||||
|
actions,
|
||||||
|
children,
|
||||||
|
variant = "plain",
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: RdxEditorSectionProps) => {
|
||||||
|
const titleId = useId();
|
||||||
|
const hasHeader = Boolean(title || description || icon || actions);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<section
|
||||||
|
aria-labelledby={titleId}
|
||||||
|
className={cn(VARIANT_CLASSNAME[variant], className)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
{hasHeader ? (
|
||||||
|
<div className={cn("flex flex-col gap-4", variant === "plain" ? "pb-4" : "pb-5")}>
|
||||||
|
<div className="flex items-start justify-between gap-4">
|
||||||
|
<div className="flex min-w-0 items-start gap-3">
|
||||||
|
{icon ? (
|
||||||
|
<div className="flex size-6 shrink-0 items-center justify-center text-primary">
|
||||||
|
{icon}
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
<div className="min-w-0 space-y-1">
|
||||||
|
<h2
|
||||||
|
className="min-w-0 text-base font-semibold tracking-tight text-foreground"
|
||||||
|
id={titleId}
|
||||||
|
>
|
||||||
|
{title}
|
||||||
|
</h2>
|
||||||
|
|
||||||
|
{description ? (
|
||||||
|
<p className="text-sm text-muted-foreground">{description}</p>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{actions ? <div className="flex shrink-0 items-center gap-2">{actions}</div> : null}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
<div className="min-w-0">{children}</div>
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
};
|
||||||
@ -0,0 +1,54 @@
|
|||||||
|
import { cn } from "@repo/shadcn-ui/lib/utils";
|
||||||
|
import type { ComponentPropsWithoutRef, ReactNode } from "react";
|
||||||
|
|
||||||
|
export interface RdxEditorStickyFooterProps
|
||||||
|
extends Omit<ComponentPropsWithoutRef<"div">, "children"> {
|
||||||
|
leading?: ReactNode;
|
||||||
|
status?: ReactNode;
|
||||||
|
secondary?: ReactNode;
|
||||||
|
primary?: ReactNode;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const RdxEditorStickyFooter = ({
|
||||||
|
leading,
|
||||||
|
status,
|
||||||
|
secondary,
|
||||||
|
primary,
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: RdxEditorStickyFooterProps) => {
|
||||||
|
const hasLeading = Boolean(leading);
|
||||||
|
const hasActionRegion = Boolean(status || secondary || primary);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
"sticky bottom-0 z-20 shrink-0 border-t border-border bg-background shadow-xs",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
<div className="flex min-w-0 flex-col xl:flex-row xl:items-stretch">
|
||||||
|
{hasLeading ? <div className="min-w-0 flex-1 px-4 py-3 md:px-6">{leading}</div> : null}
|
||||||
|
|
||||||
|
{hasActionRegion ? (
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
"flex min-w-0 flex-col gap-3 px-4 py-3 md:px-6",
|
||||||
|
hasLeading ? "border-t border-border xl:border-t-0 xl:border-l" : null
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{status ? <div className="min-w-0 text-sm text-muted-foreground">{status}</div> : null}
|
||||||
|
|
||||||
|
{(secondary || primary) ? (
|
||||||
|
<div className="flex min-w-0 flex-col gap-2 sm:flex-row sm:justify-end">
|
||||||
|
{secondary ? <div className="flex min-w-0">{secondary}</div> : null}
|
||||||
|
{primary ? <div className="flex min-w-0">{primary}</div> : null}
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
@ -1,16 +1,5 @@
|
|||||||
import { FieldGroup } from "@repo/shadcn-ui/components";
|
import { RdxFormGrid, type RdxFormGridProps } from "./rdx-form-grid.tsx";
|
||||||
import { cn } from "@repo/shadcn-ui/lib/utils";
|
|
||||||
import type { ReactNode } from "react";
|
|
||||||
|
|
||||||
interface FormSectionGridProps {
|
export type FormSectionGridProps = RdxFormGridProps;
|
||||||
children: ReactNode;
|
|
||||||
className?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export const FormSectionGrid = ({ children, className }: FormSectionGridProps) => {
|
export const FormSectionGrid = (props: FormSectionGridProps) => <RdxFormGrid {...props} />;
|
||||||
return (
|
|
||||||
<FieldGroup className={cn("grid grid-cols-1 gap-x-6 gap-y-5 md:grid-cols-12", className)}>
|
|
||||||
{children}
|
|
||||||
</FieldGroup>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|||||||
@ -4,6 +4,7 @@ export * from "./date-picker-input-field/index.ts";
|
|||||||
export * from "./decimal-field/index.ts";
|
export * from "./decimal-field/index.ts";
|
||||||
export * from "./form-actions/index.ts";
|
export * from "./form-actions/index.ts";
|
||||||
export * from "./form-field-label.tsx";
|
export * from "./form-field-label.tsx";
|
||||||
|
export * from "./rdx-form-grid.tsx";
|
||||||
export * from "./form-section-card.tsx";
|
export * from "./form-section-card.tsx";
|
||||||
export * from "./form-section-grid.tsx";
|
export * from "./form-section-grid.tsx";
|
||||||
export * from "./multi-select-field.tsx";
|
export * from "./multi-select-field.tsx";
|
||||||
|
|||||||
16
packages/rdx-ui/src/components/form/rdx-form-grid.tsx
Normal file
16
packages/rdx-ui/src/components/form/rdx-form-grid.tsx
Normal file
@ -0,0 +1,16 @@
|
|||||||
|
import { FieldGroup } from "@repo/shadcn-ui/components";
|
||||||
|
import { cn } from "@repo/shadcn-ui/lib/utils";
|
||||||
|
import type { ReactNode } from "react";
|
||||||
|
|
||||||
|
export interface RdxFormGridProps {
|
||||||
|
children: ReactNode;
|
||||||
|
className?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const RdxFormGrid = ({ children, className }: RdxFormGridProps) => {
|
||||||
|
return (
|
||||||
|
<FieldGroup className={cn("grid grid-cols-1 gap-x-6 gap-y-5 md:grid-cols-12", className)}>
|
||||||
|
{children}
|
||||||
|
</FieldGroup>
|
||||||
|
);
|
||||||
|
};
|
||||||
@ -1,7 +1,9 @@
|
|||||||
export * from "./buttons/index.ts";
|
export * from "./buttons/index.ts";
|
||||||
|
export * from "./context-panel/index.ts";
|
||||||
export * from "./custom-dialog.tsx";
|
export * from "./custom-dialog.tsx";
|
||||||
export * from "./datatable/index.ts";
|
export * from "./datatable/index.ts";
|
||||||
export * from "./dynamics-tabs.tsx";
|
export * from "./dynamics-tabs.tsx";
|
||||||
|
export * from "./editor-page/index.ts";
|
||||||
export * from "./entity-sheet/index.ts";
|
export * from "./entity-sheet/index.ts";
|
||||||
export * from "./field-value-list.ts";
|
export * from "./field-value-list.ts";
|
||||||
export * from "./form/index.ts";
|
export * from "./form/index.ts";
|
||||||
@ -14,5 +16,6 @@ export * from "./lookup-dialog/index.ts";
|
|||||||
export * from "./multi-select.tsx";
|
export * from "./multi-select.tsx";
|
||||||
export * from "./pdf-viewer.tsx";
|
export * from "./pdf-viewer.tsx";
|
||||||
export * from "./right-panel/index.ts";
|
export * from "./right-panel/index.ts";
|
||||||
|
export * from "./row-actions/index.ts";
|
||||||
export * from "./scroll-to-top.tsx";
|
export * from "./scroll-to-top.tsx";
|
||||||
export * from "./tailwind-indicator.tsx";
|
export * from "./tailwind-indicator.tsx";
|
||||||
|
|||||||
@ -1,3 +1,7 @@
|
|||||||
export * from "./app-content.tsx";
|
export * from "./app-content.tsx";
|
||||||
export * from "./app-header.tsx";
|
export * from "./app-header.tsx";
|
||||||
export * from "./app-layout.tsx";
|
export * from "./app-layout.tsx";
|
||||||
|
export * from "./rdx-page-actions.tsx";
|
||||||
|
export * from "./rdx-page-container.tsx";
|
||||||
|
export * from "./rdx-page-header.tsx";
|
||||||
|
export * from "./rdx-page-stack.tsx";
|
||||||
|
|||||||
31
packages/rdx-ui/src/components/layout/rdx-page-actions.tsx
Normal file
31
packages/rdx-ui/src/components/layout/rdx-page-actions.tsx
Normal file
@ -0,0 +1,31 @@
|
|||||||
|
import { cn } from "@repo/shadcn-ui/lib/utils";
|
||||||
|
import type { ComponentPropsWithoutRef, ReactNode } from "react";
|
||||||
|
|
||||||
|
export interface RdxPageActionsProps extends Omit<ComponentPropsWithoutRef<"div">, "children"> {
|
||||||
|
primary?: ReactNode;
|
||||||
|
secondary?: ReactNode;
|
||||||
|
overflow?: ReactNode;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const RdxPageActions = ({
|
||||||
|
primary,
|
||||||
|
secondary,
|
||||||
|
overflow,
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: RdxPageActionsProps) => {
|
||||||
|
if (!primary && !secondary && !overflow) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={cn("flex flex-wrap items-center justify-start gap-2 lg:justify-end", className)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
{secondary ? <div className="flex flex-wrap items-center gap-2">{secondary}</div> : null}
|
||||||
|
{primary ? <div className="flex flex-wrap items-center gap-2">{primary}</div> : null}
|
||||||
|
{overflow ? <div className="flex items-center gap-2">{overflow}</div> : null}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
32
packages/rdx-ui/src/components/layout/rdx-page-container.tsx
Normal file
32
packages/rdx-ui/src/components/layout/rdx-page-container.tsx
Normal file
@ -0,0 +1,32 @@
|
|||||||
|
import { cn } from "@repo/shadcn-ui/lib/utils";
|
||||||
|
import type { ComponentPropsWithoutRef, ReactNode } from "react";
|
||||||
|
|
||||||
|
export type RdxPageWidth = "full" | "wide" | "content";
|
||||||
|
|
||||||
|
export interface RdxPageContainerProps
|
||||||
|
extends Omit<ComponentPropsWithoutRef<"div">, "children"> {
|
||||||
|
children: ReactNode;
|
||||||
|
width?: RdxPageWidth;
|
||||||
|
}
|
||||||
|
|
||||||
|
const WIDTH_CLASSNAME: Record<RdxPageWidth, string> = {
|
||||||
|
full: "w-full",
|
||||||
|
wide: "mx-auto max-w-screen-2xl w-full",
|
||||||
|
content: "mx-auto max-w-5xl w-full",
|
||||||
|
};
|
||||||
|
|
||||||
|
export const RdxPageContainer = ({
|
||||||
|
children,
|
||||||
|
className,
|
||||||
|
width = "full",
|
||||||
|
...props
|
||||||
|
}: RdxPageContainerProps) => {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={cn("min-w-0 w-full px-4 py-4 md:px-6 md:py-6", WIDTH_CLASSNAME[width], className)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
@ -0,0 +1,98 @@
|
|||||||
|
import assert from "node:assert/strict";
|
||||||
|
import test from "node:test";
|
||||||
|
import { renderToStaticMarkup } from "react-dom/server";
|
||||||
|
|
||||||
|
import { RdxPageActions } from "./rdx-page-actions.tsx";
|
||||||
|
import { RdxPageContainer } from "./rdx-page-container.tsx";
|
||||||
|
import { RdxPageHeader } from "./rdx-page-header.tsx";
|
||||||
|
import { RdxPageStack } from "./rdx-page-stack.tsx";
|
||||||
|
|
||||||
|
test("RdxPageContainer renders children with full width by default", () => {
|
||||||
|
const html = renderToStaticMarkup(<RdxPageContainer>Content</RdxPageContainer>);
|
||||||
|
|
||||||
|
assert.match(html, /Content/);
|
||||||
|
assert.match(html, /w-full/);
|
||||||
|
assert.doesNotMatch(html, /max-w-screen-2xl/);
|
||||||
|
assert.doesNotMatch(html, /max-w-5xl/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("RdxPageContainer applies width variants and merges className", () => {
|
||||||
|
const wideHtml = renderToStaticMarkup(
|
||||||
|
<RdxPageContainer className="custom-wide" width="wide">
|
||||||
|
Wide
|
||||||
|
</RdxPageContainer>
|
||||||
|
);
|
||||||
|
const contentHtml = renderToStaticMarkup(
|
||||||
|
<RdxPageContainer width="content">Content</RdxPageContainer>
|
||||||
|
);
|
||||||
|
|
||||||
|
assert.match(wideHtml, /max-w-screen-2xl/);
|
||||||
|
assert.match(wideHtml, /custom-wide/);
|
||||||
|
assert.match(contentHtml, /max-w-5xl/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("RdxPageStack applies default spacing and className", () => {
|
||||||
|
const html = renderToStaticMarkup(
|
||||||
|
<RdxPageStack className="custom-stack">
|
||||||
|
<div>One</div>
|
||||||
|
</RdxPageStack>
|
||||||
|
);
|
||||||
|
|
||||||
|
assert.match(html, /flex/);
|
||||||
|
assert.match(html, /gap-6/);
|
||||||
|
assert.match(html, /custom-stack/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("RdxPageStack applies spacing variants", () => {
|
||||||
|
const compactHtml = renderToStaticMarkup(<RdxPageStack spacing="compact">Compact</RdxPageStack>);
|
||||||
|
const relaxedHtml = renderToStaticMarkup(<RdxPageStack spacing="relaxed">Relaxed</RdxPageStack>);
|
||||||
|
|
||||||
|
assert.match(compactHtml, /gap-4/);
|
||||||
|
assert.match(relaxedHtml, /gap-8/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("RdxPageHeader renders semantic title, description and optional regions", () => {
|
||||||
|
const html = renderToStaticMarkup(
|
||||||
|
<RdxPageHeader
|
||||||
|
actions={<span>Actions</span>}
|
||||||
|
description="Description"
|
||||||
|
leading={<span>Leading</span>}
|
||||||
|
metadata={<span>Metadata</span>}
|
||||||
|
title="Header title"
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
|
||||||
|
assert.match(html, /<h1[^>]*>Header title<\/h1>/);
|
||||||
|
assert.match(html, /Description/);
|
||||||
|
assert.match(html, /Leading/);
|
||||||
|
assert.match(html, /Metadata/);
|
||||||
|
assert.match(html, /Actions/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("RdxPageHeader omits optional wrappers when content is absent", () => {
|
||||||
|
const html = renderToStaticMarkup(<RdxPageHeader title="Only title" />);
|
||||||
|
|
||||||
|
assert.match(html, /<h1[^>]*>Only title<\/h1>/);
|
||||||
|
assert.equal((html.match(/<p/g) ?? []).length, 0);
|
||||||
|
assert.doesNotMatch(html, /Actions/);
|
||||||
|
assert.doesNotMatch(html, /Metadata/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("RdxPageActions renders only populated regions in the expected order", () => {
|
||||||
|
const html = renderToStaticMarkup(
|
||||||
|
<RdxPageActions
|
||||||
|
overflow={<span>Overflow</span>}
|
||||||
|
primary={<span>Primary</span>}
|
||||||
|
secondary={<span>Secondary</span>}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
|
||||||
|
assert.ok(html.indexOf("Secondary") < html.indexOf("Primary"));
|
||||||
|
assert.ok(html.indexOf("Primary") < html.indexOf("Overflow"));
|
||||||
|
});
|
||||||
|
|
||||||
|
test("RdxPageActions returns null when all regions are empty", () => {
|
||||||
|
const html = renderToStaticMarkup(<RdxPageActions />);
|
||||||
|
|
||||||
|
assert.equal(html, "");
|
||||||
|
});
|
||||||
53
packages/rdx-ui/src/components/layout/rdx-page-header.tsx
Normal file
53
packages/rdx-ui/src/components/layout/rdx-page-header.tsx
Normal file
@ -0,0 +1,53 @@
|
|||||||
|
import { cn } from "@repo/shadcn-ui/lib/utils";
|
||||||
|
import type { ComponentPropsWithoutRef, ReactNode } from "react";
|
||||||
|
|
||||||
|
export interface RdxPageHeaderProps
|
||||||
|
extends Omit<ComponentPropsWithoutRef<"header">, "children" | "title"> {
|
||||||
|
title: ReactNode;
|
||||||
|
description?: ReactNode;
|
||||||
|
leading?: ReactNode;
|
||||||
|
metadata?: ReactNode;
|
||||||
|
actions?: ReactNode;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const RdxPageHeader = ({
|
||||||
|
title,
|
||||||
|
description,
|
||||||
|
leading,
|
||||||
|
metadata,
|
||||||
|
actions,
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: RdxPageHeaderProps) => {
|
||||||
|
return (
|
||||||
|
<header
|
||||||
|
className={cn(
|
||||||
|
"flex min-w-0 flex-col gap-4 lg:flex-row lg:items-start lg:justify-between",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
<div className="flex min-w-0 flex-1 items-start gap-4">
|
||||||
|
{leading ? <div className="shrink-0">{leading}</div> : null}
|
||||||
|
|
||||||
|
<div className="min-w-0 space-y-2">
|
||||||
|
<h1 className="min-w-0 text-2xl font-semibold tracking-tight text-foreground font-heading xl:text-3xl">
|
||||||
|
{title}
|
||||||
|
</h1>
|
||||||
|
|
||||||
|
{description ? (
|
||||||
|
<p className="text-sm font-medium text-muted-foreground">{description}</p>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
{metadata ? (
|
||||||
|
<div className="flex min-w-0 flex-wrap items-center gap-2 text-sm text-muted-foreground">
|
||||||
|
{metadata}
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{actions ? <div className="min-w-0 lg:shrink-0">{actions}</div> : null}
|
||||||
|
</header>
|
||||||
|
);
|
||||||
|
};
|
||||||
28
packages/rdx-ui/src/components/layout/rdx-page-stack.tsx
Normal file
28
packages/rdx-ui/src/components/layout/rdx-page-stack.tsx
Normal file
@ -0,0 +1,28 @@
|
|||||||
|
import { cn } from "@repo/shadcn-ui/lib/utils";
|
||||||
|
import type { ComponentPropsWithoutRef, ReactNode } from "react";
|
||||||
|
|
||||||
|
export type RdxPageStackSpacing = "compact" | "default" | "relaxed";
|
||||||
|
|
||||||
|
export interface RdxPageStackProps extends Omit<ComponentPropsWithoutRef<"div">, "children"> {
|
||||||
|
children: ReactNode;
|
||||||
|
spacing?: RdxPageStackSpacing;
|
||||||
|
}
|
||||||
|
|
||||||
|
const SPACING_CLASSNAME: Record<RdxPageStackSpacing, string> = {
|
||||||
|
compact: "gap-4",
|
||||||
|
default: "gap-6",
|
||||||
|
relaxed: "gap-8",
|
||||||
|
};
|
||||||
|
|
||||||
|
export const RdxPageStack = ({
|
||||||
|
children,
|
||||||
|
className,
|
||||||
|
spacing = "default",
|
||||||
|
...props
|
||||||
|
}: RdxPageStackProps) => {
|
||||||
|
return (
|
||||||
|
<div className={cn("flex min-w-0 flex-col", SPACING_CLASSNAME[spacing], className)} {...props}>
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
@ -1,10 +1,6 @@
|
|||||||
import type { ReactNode } from "react";
|
import type { ComponentPropsWithoutRef, ReactNode } from "react";
|
||||||
|
|
||||||
export interface RightPanelProps {
|
export interface RightPanelProps extends Omit<ComponentPropsWithoutRef<"div">, "children"> {
|
||||||
open: boolean;
|
open: boolean;
|
||||||
title: string;
|
|
||||||
className?: string;
|
|
||||||
headerActions?: ReactNode;
|
|
||||||
children: ReactNode;
|
children: ReactNode;
|
||||||
onOpenChange?: (open: boolean) => void;
|
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,44 +1,21 @@
|
|||||||
import {
|
import { Card } from "@repo/shadcn-ui/components";
|
||||||
Button,
|
import { cn } from "@repo/shadcn-ui/lib/utils";
|
||||||
Card,
|
|
||||||
CardAction,
|
|
||||||
CardContent,
|
|
||||||
CardHeader,
|
|
||||||
CardTitle,
|
|
||||||
} from "@repo/shadcn-ui/components";
|
|
||||||
import { XIcon } from "lucide-react";
|
|
||||||
|
|
||||||
import type { RightPanelProps } from "./right-panel-types.ts";
|
import type { RightPanelProps } from "./right-panel-types.ts";
|
||||||
|
|
||||||
export const RightPanel = ({
|
export const RightPanel = ({
|
||||||
open,
|
open,
|
||||||
title,
|
|
||||||
className,
|
className,
|
||||||
headerActions,
|
|
||||||
children,
|
children,
|
||||||
onOpenChange,
|
...props
|
||||||
}: RightPanelProps) => {
|
}: RightPanelProps) => {
|
||||||
if (!open) return null;
|
if (!open) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Card className={className}>
|
<Card className={cn("flex h-full min-w-0 flex-col overflow-hidden", className)} {...props}>
|
||||||
<CardHeader>
|
{children}
|
||||||
<CardTitle id="right-panel-title">{title}</CardTitle>
|
|
||||||
<CardAction>
|
|
||||||
{headerActions}
|
|
||||||
{onOpenChange ? (
|
|
||||||
<Button
|
|
||||||
aria-label="Cerrar panel"
|
|
||||||
onClick={() => onOpenChange(false)}
|
|
||||||
size="icon-sm"
|
|
||||||
variant="ghost"
|
|
||||||
>
|
|
||||||
<XIcon className="size-4" />
|
|
||||||
</Button>
|
|
||||||
) : null}
|
|
||||||
</CardAction>
|
|
||||||
</CardHeader>
|
|
||||||
<CardContent className="-mx-4">{children}</CardContent>
|
|
||||||
</Card>
|
</Card>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
1
packages/rdx-ui/src/components/row-actions/index.ts
Normal file
1
packages/rdx-ui/src/components/row-actions/index.ts
Normal file
@ -0,0 +1 @@
|
|||||||
|
export * from "./rdx-row-actions.tsx";
|
||||||
@ -0,0 +1,252 @@
|
|||||||
|
import assert from "node:assert/strict";
|
||||||
|
import test from "node:test";
|
||||||
|
import { isValidElement } from "react";
|
||||||
|
import { renderToStaticMarkup } from "react-dom/server";
|
||||||
|
|
||||||
|
import { RdxRowActions } from "./rdx-row-actions.tsx";
|
||||||
|
|
||||||
|
const resolveElement = (node: unknown): unknown => {
|
||||||
|
if (Array.isArray(node)) {
|
||||||
|
return node.map(resolveElement);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!isValidElement(node)) {
|
||||||
|
return node;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (typeof node.type === "function") {
|
||||||
|
return resolveElement(node.type(node.props));
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
type: node.type,
|
||||||
|
props: {
|
||||||
|
...node.props,
|
||||||
|
children: resolveElement(node.props.children),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
const collectElements = (node: unknown): Array<{ type: unknown; props: Record<string, unknown> }> => {
|
||||||
|
if (Array.isArray(node)) {
|
||||||
|
return node.flatMap(collectElements);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!node || typeof node !== "object" || !("type" in node) || !("props" in node)) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
const element = node as { type: unknown; props: Record<string, unknown> };
|
||||||
|
|
||||||
|
return [element, ...collectElements(element.props.children)];
|
||||||
|
};
|
||||||
|
|
||||||
|
const findElement = (
|
||||||
|
node: unknown,
|
||||||
|
predicate: (element: { type: unknown; props: Record<string, unknown> }) => boolean
|
||||||
|
) => collectElements(node).find(predicate) ?? null;
|
||||||
|
|
||||||
|
const invokeClick = async (
|
||||||
|
element: { type: unknown; props: Record<string, unknown> } | null
|
||||||
|
) => {
|
||||||
|
const onClick = element?.props.onClick as
|
||||||
|
| ((event: { preventDefault: () => void; stopPropagation: () => void }) => unknown)
|
||||||
|
| undefined;
|
||||||
|
|
||||||
|
await onClick?.({
|
||||||
|
preventDefault() {},
|
||||||
|
stopPropagation() {},
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
test("renderiza acciones primarias, overflow y combina className", () => {
|
||||||
|
const html = renderToStaticMarkup(
|
||||||
|
<RdxRowActions
|
||||||
|
className="custom-row-actions"
|
||||||
|
overflowActions={[
|
||||||
|
{
|
||||||
|
id: "delete",
|
||||||
|
icon: <span aria-hidden="true">D</span>,
|
||||||
|
label: "Delete",
|
||||||
|
onSelect: () => undefined,
|
||||||
|
destructive: true,
|
||||||
|
},
|
||||||
|
]}
|
||||||
|
overflowLabel="More actions"
|
||||||
|
primaryActions={[
|
||||||
|
{
|
||||||
|
id: "view",
|
||||||
|
icon: <span aria-hidden="true">V</span>,
|
||||||
|
label: "View",
|
||||||
|
onSelect: () => undefined,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "edit",
|
||||||
|
icon: <span aria-hidden="true">E</span>,
|
||||||
|
label: "Edit",
|
||||||
|
onSelect: () => undefined,
|
||||||
|
},
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
|
||||||
|
assert.match(html, /custom-row-actions/);
|
||||||
|
assert.match(html, /aria-label="View"/);
|
||||||
|
assert.match(html, /aria-label="Edit"/);
|
||||||
|
assert.match(html, /aria-label="More actions"/);
|
||||||
|
assert.match(html, /Delete/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("mantiene el orden recibido y no renderiza overflow vacío", () => {
|
||||||
|
const html = renderToStaticMarkup(
|
||||||
|
<RdxRowActions
|
||||||
|
overflowActions={[]}
|
||||||
|
overflowLabel="More actions"
|
||||||
|
primaryActions={[
|
||||||
|
{
|
||||||
|
id: "first",
|
||||||
|
icon: <span aria-hidden="true">1</span>,
|
||||||
|
label: "First",
|
||||||
|
onSelect: () => undefined,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "second",
|
||||||
|
icon: <span aria-hidden="true">2</span>,
|
||||||
|
label: "Second",
|
||||||
|
onSelect: () => undefined,
|
||||||
|
},
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
|
||||||
|
assert.ok(html.indexOf('aria-label="First"') < html.indexOf('aria-label="Second"'));
|
||||||
|
assert.equal(html.includes('aria-label="More actions"'), false);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("ejecuta callbacks de acciones primarias y overflow", async () => {
|
||||||
|
let didView = false;
|
||||||
|
let didDelete = false;
|
||||||
|
|
||||||
|
const resolved = resolveElement(
|
||||||
|
<RdxRowActions
|
||||||
|
overflowActions={[
|
||||||
|
{
|
||||||
|
id: "delete",
|
||||||
|
icon: <span aria-hidden="true">D</span>,
|
||||||
|
label: "Delete",
|
||||||
|
onSelect: () => {
|
||||||
|
didDelete = true;
|
||||||
|
},
|
||||||
|
},
|
||||||
|
]}
|
||||||
|
overflowLabel="More actions"
|
||||||
|
primaryActions={[
|
||||||
|
{
|
||||||
|
id: "view",
|
||||||
|
icon: <span aria-hidden="true">V</span>,
|
||||||
|
label: "View",
|
||||||
|
onSelect: () => {
|
||||||
|
didView = true;
|
||||||
|
},
|
||||||
|
},
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
|
||||||
|
const primaryButton = findElement(
|
||||||
|
resolved,
|
||||||
|
(element) => element.type === "button" && element.props["aria-label"] === "View"
|
||||||
|
);
|
||||||
|
const overflowItem = findElement(
|
||||||
|
resolved,
|
||||||
|
(element) =>
|
||||||
|
typeof element.props.onClick === "function" &&
|
||||||
|
JSON.stringify(element.props.children).includes("Delete")
|
||||||
|
);
|
||||||
|
|
||||||
|
assert.ok(primaryButton);
|
||||||
|
assert.ok(overflowItem);
|
||||||
|
|
||||||
|
await invokeClick(primaryButton);
|
||||||
|
await invokeClick(overflowItem);
|
||||||
|
|
||||||
|
assert.equal(didView, true);
|
||||||
|
assert.equal(didDelete, true);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("respeta disabled y muestra disabledReason cuando existe", async () => {
|
||||||
|
let didRun = false;
|
||||||
|
|
||||||
|
const html = renderToStaticMarkup(
|
||||||
|
<RdxRowActions
|
||||||
|
overflowActions={[
|
||||||
|
{
|
||||||
|
id: "archive",
|
||||||
|
icon: <span aria-hidden="true">A</span>,
|
||||||
|
label: "Archive",
|
||||||
|
onSelect: () => undefined,
|
||||||
|
disabled: true,
|
||||||
|
disabledReason: "Archive unavailable",
|
||||||
|
},
|
||||||
|
]}
|
||||||
|
overflowLabel="More actions"
|
||||||
|
primaryActions={[
|
||||||
|
{
|
||||||
|
id: "edit",
|
||||||
|
icon: <span aria-hidden="true">E</span>,
|
||||||
|
label: "Edit",
|
||||||
|
onSelect: () => undefined,
|
||||||
|
disabled: true,
|
||||||
|
disabledReason: "Edit unavailable",
|
||||||
|
},
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
|
||||||
|
assert.match(html, /Edit unavailable/);
|
||||||
|
assert.match(html, /Archive unavailable/);
|
||||||
|
|
||||||
|
const resolved = resolveElement(
|
||||||
|
<RdxRowActions
|
||||||
|
overflowActions={[
|
||||||
|
{
|
||||||
|
id: "archive",
|
||||||
|
icon: <span aria-hidden="true">A</span>,
|
||||||
|
label: "Archive",
|
||||||
|
onSelect: () => {
|
||||||
|
didRun = true;
|
||||||
|
},
|
||||||
|
disabled: true,
|
||||||
|
},
|
||||||
|
]}
|
||||||
|
overflowLabel="More actions"
|
||||||
|
primaryActions={[
|
||||||
|
{
|
||||||
|
id: "edit",
|
||||||
|
icon: <span aria-hidden="true">E</span>,
|
||||||
|
label: "Edit",
|
||||||
|
onSelect: () => {
|
||||||
|
didRun = true;
|
||||||
|
},
|
||||||
|
disabled: true,
|
||||||
|
},
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
|
||||||
|
const primaryButton = findElement(
|
||||||
|
resolved,
|
||||||
|
(element) => element.type === "button" && element.props["aria-label"] === "Edit"
|
||||||
|
);
|
||||||
|
const overflowItem = findElement(
|
||||||
|
resolved,
|
||||||
|
(element) =>
|
||||||
|
typeof element.props.onClick === "function" &&
|
||||||
|
JSON.stringify(element.props.children).includes("Archive")
|
||||||
|
);
|
||||||
|
|
||||||
|
await invokeClick(primaryButton);
|
||||||
|
await invokeClick(overflowItem);
|
||||||
|
|
||||||
|
assert.equal(didRun, false);
|
||||||
|
});
|
||||||
170
packages/rdx-ui/src/components/row-actions/rdx-row-actions.tsx
Normal file
170
packages/rdx-ui/src/components/row-actions/rdx-row-actions.tsx
Normal file
@ -0,0 +1,170 @@
|
|||||||
|
import {
|
||||||
|
Button,
|
||||||
|
DropdownMenu,
|
||||||
|
DropdownMenuContent,
|
||||||
|
DropdownMenuGroup,
|
||||||
|
DropdownMenuItem,
|
||||||
|
DropdownMenuLabel,
|
||||||
|
DropdownMenuSeparator,
|
||||||
|
DropdownMenuTrigger,
|
||||||
|
Tooltip,
|
||||||
|
TooltipContent,
|
||||||
|
TooltipProvider,
|
||||||
|
TooltipTrigger,
|
||||||
|
} from "@repo/shadcn-ui/components";
|
||||||
|
import { cn } from "@repo/shadcn-ui/lib/utils";
|
||||||
|
import { MoreHorizontalIcon } from "lucide-react";
|
||||||
|
import type { ComponentPropsWithoutRef, KeyboardEvent, MouseEvent, ReactNode } from "react";
|
||||||
|
import { Fragment } from "react";
|
||||||
|
|
||||||
|
export interface RdxRowAction {
|
||||||
|
id: string;
|
||||||
|
label: string;
|
||||||
|
icon: ReactNode;
|
||||||
|
onSelect: () => void;
|
||||||
|
disabled?: boolean;
|
||||||
|
disabledReason?: string;
|
||||||
|
destructive?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface RdxRowActionsProps
|
||||||
|
extends Omit<ComponentPropsWithoutRef<"div">, "children"> {
|
||||||
|
primaryActions?: RdxRowAction[];
|
||||||
|
overflowActions?: RdxRowAction[];
|
||||||
|
overflowLabel: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const stopRowEvent = (event: MouseEvent | KeyboardEvent) => {
|
||||||
|
event.preventDefault();
|
||||||
|
event.stopPropagation();
|
||||||
|
};
|
||||||
|
|
||||||
|
const stopPropagation = (event: MouseEvent | KeyboardEvent) => {
|
||||||
|
event.stopPropagation();
|
||||||
|
};
|
||||||
|
|
||||||
|
const getTooltipLabel = (action: RdxRowAction) => action.disabledReason ?? action.label;
|
||||||
|
|
||||||
|
export const RdxRowActions = ({
|
||||||
|
primaryActions = [],
|
||||||
|
overflowActions = [],
|
||||||
|
overflowLabel,
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: RdxRowActionsProps) => {
|
||||||
|
if (primaryActions.length === 0 && overflowActions.length === 0) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<TooltipProvider>
|
||||||
|
<div className={cn("flex items-center justify-end gap-1", className)} {...props}>
|
||||||
|
{primaryActions.map((action) => {
|
||||||
|
const button = (
|
||||||
|
<Button
|
||||||
|
aria-label={action.label}
|
||||||
|
className="size-8 text-muted-foreground hover:text-foreground"
|
||||||
|
disabled={action.disabled}
|
||||||
|
onClick={(event) => {
|
||||||
|
stopRowEvent(event);
|
||||||
|
|
||||||
|
if (!action.disabled) {
|
||||||
|
action.onSelect();
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
onKeyDown={stopPropagation}
|
||||||
|
size="icon-sm"
|
||||||
|
type="button"
|
||||||
|
variant="ghost"
|
||||||
|
>
|
||||||
|
{action.icon}
|
||||||
|
</Button>
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Tooltip key={action.id}>
|
||||||
|
<TooltipTrigger
|
||||||
|
render={
|
||||||
|
action.disabled ? <span className="inline-flex">{button}</span> : button
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<TooltipContent>{getTooltipLabel(action)}</TooltipContent>
|
||||||
|
</Tooltip>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
|
||||||
|
{overflowActions.length > 0 ? (
|
||||||
|
<DropdownMenu>
|
||||||
|
<Tooltip>
|
||||||
|
<TooltipTrigger
|
||||||
|
render={
|
||||||
|
<DropdownMenuTrigger
|
||||||
|
render={
|
||||||
|
<Button
|
||||||
|
aria-label={overflowLabel}
|
||||||
|
className="size-8 text-muted-foreground hover:text-foreground"
|
||||||
|
onClick={stopPropagation}
|
||||||
|
onKeyDown={stopPropagation}
|
||||||
|
size="icon-sm"
|
||||||
|
type="button"
|
||||||
|
variant="ghost"
|
||||||
|
>
|
||||||
|
<MoreHorizontalIcon className="size-4" />
|
||||||
|
</Button>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<TooltipContent>{overflowLabel}</TooltipContent>
|
||||||
|
</Tooltip>
|
||||||
|
|
||||||
|
<DropdownMenuContent align="end" className="w-56" onClick={stopPropagation}>
|
||||||
|
<DropdownMenuGroup>
|
||||||
|
<DropdownMenuLabel>{overflowLabel}</DropdownMenuLabel>
|
||||||
|
<DropdownMenuSeparator />
|
||||||
|
|
||||||
|
{overflowActions.map((action, index) => {
|
||||||
|
const item = (
|
||||||
|
<DropdownMenuItem
|
||||||
|
aria-label={action.label}
|
||||||
|
className={
|
||||||
|
action.destructive ? "text-destructive focus:text-destructive" : undefined
|
||||||
|
}
|
||||||
|
disabled={action.disabled}
|
||||||
|
onClick={(event) => {
|
||||||
|
stopRowEvent(event);
|
||||||
|
|
||||||
|
if (!action.disabled) {
|
||||||
|
action.onSelect();
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
onKeyDown={stopPropagation}
|
||||||
|
variant={action.destructive ? "destructive" : "default"}
|
||||||
|
>
|
||||||
|
<span className="mr-2 inline-flex size-4 items-center justify-center">
|
||||||
|
{action.icon}
|
||||||
|
</span>
|
||||||
|
<span>{action.label}</span>
|
||||||
|
</DropdownMenuItem>
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Fragment key={action.id}>
|
||||||
|
{action.destructive && index > 0 ? <DropdownMenuSeparator /> : null}
|
||||||
|
<Tooltip>
|
||||||
|
<TooltipTrigger
|
||||||
|
render={action.disabled ? <div className="w-full">{item}</div> : item}
|
||||||
|
/>
|
||||||
|
<TooltipContent side="left">{getTooltipLabel(action)}</TooltipContent>
|
||||||
|
</Tooltip>
|
||||||
|
</Fragment>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</DropdownMenuGroup>
|
||||||
|
</DropdownMenuContent>
|
||||||
|
</DropdownMenu>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
</TooltipProvider>
|
||||||
|
);
|
||||||
|
};
|
||||||
@ -1331,6 +1331,9 @@ importers:
|
|||||||
tailwindcss:
|
tailwindcss:
|
||||||
specifier: ^4.2.2
|
specifier: ^4.2.2
|
||||||
version: 4.3.0
|
version: 4.3.0
|
||||||
|
tsx:
|
||||||
|
specifier: ^4.21.0
|
||||||
|
version: 4.21.0
|
||||||
tsup:
|
tsup:
|
||||||
specifier: ^8.5.1
|
specifier: ^8.5.1
|
||||||
version: 8.5.1(jiti@2.7.0)(postcss@8.5.15)(tsx@4.21.0)(typescript@6.0.2)
|
version: 8.5.1(jiti@2.7.0)(postcss@8.5.15)(tsx@4.21.0)(typescript@6.0.2)
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user