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:
David Arranz 2026-08-11 00:01:33 +02:00
parent ec1dd1099f
commit c28a5e02c3
79 changed files with 6045 additions and 1001 deletions

View File

@ -0,0 +1 @@
export { default as UiKitPage } from "./ui-kit-page";

View 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>
);
};

View 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=&quot;full&quot;`" 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=&quot;wide&quot;`" 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=&quot;content&quot;`" 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=&quot;compact&quot;`" 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=&quot;default&quot;`" 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=&quot;relaxed&quot;`" 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=&quot;full&quot;`, `RdxPageStack spacing=&quot;default&quot;`, `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>
);
}

View File

@ -12,7 +12,7 @@ const rootElement = document.getElementById("factuges");
if (rootElement) {
createRoot(rootElement).render(
<StrictMode>
<ThemeProvider defaultTheme="light" storageKey="vite-ui-theme">
<ThemeProvider defaultTheme="system" storageKey="vite-ui-theme">
<>
<App />
<TailwindIndicator />

View File

@ -11,6 +11,7 @@ import {
AuthLayout,
AuthLayoutFrame,
} from "@/layout";
import { UiKitPage } from "@/development/ui-kit";
import ShadcnShowcasePage from "@/pages/shadcn-ui-page";
import TailwindV4ShowcasePage from "@/pages/tailwindcss-page";
@ -58,6 +59,15 @@ export const getAppRouter = () => {
layout: "app-fullscreen",
});
const developmentRoutes = import.meta.env.DEV
? [
{
path: "development/ui-kit",
element: <UiKitPage />,
},
]
: [];
const appRoutes = createBrowserRouter([
{
path: "/",
@ -121,6 +131,7 @@ export const getAppRouter = () => {
path: "quotes",
element: <StaticRouteErrorPage variant="notFound" />,
},
...developmentRoutes,
...appSidebarRoutes,
],

View File

@ -12,16 +12,19 @@ export const ProformaFooterActions = ({
onEdit?: (proforma: Proforma) => void;
}) => {
return (
<div className="border-t p-4">
<div className="flex gap-2">
<Button className="flex-1" onClick={() => onChangeStatus?.(proforma)} variant="outline">
Cambiar de estado
</Button>
<div className="flex gap-2">
<Button
className="flex-1"
onClick={() => onChangeStatus?.(proforma)}
type="button"
variant="outline"
>
Cambiar de estado
</Button>
<Button className="flex-1" onClick={() => onEdit?.(proforma)}>
Editar proforma
</Button>
</div>
<Button className="flex-1" onClick={() => onEdit?.(proforma)} type="button">
Editar proforma
</Button>
</div>
);
};

View File

@ -21,7 +21,7 @@ export const ProformaHeader = ({ proforma }: { proforma: Proforma }) => {
<div className="min-w-0 flex-1">
<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} />
</div>

View File

@ -2,27 +2,18 @@ import { Separator } from "@repo/shadcn-ui/components";
import type { Proforma } from "../../../../shared";
import { ProformaFooterActions } from "./proforma-footer-actions";
import { ProformaHeader } from "./proforma-header";
interface ProformaSummaryContentProps {
proforma: Proforma;
onEdit?: (proforma: Proforma) => void;
onChangeStatus?: (proforma: Proforma) => void;
}
export const ProformaSummaryContent = ({
proforma,
onEdit,
onChangeStatus,
}: ProformaSummaryContentProps) => {
export const ProformaSummaryContent = ({ proforma }: ProformaSummaryContentProps) => {
return (
<div className="flex flex-col">
<ProformaHeader proforma={proforma} />
<Separator />
<ProformaFooterActions onChangeStatus={onChangeStatus} onEdit={onEdit} proforma={proforma} />
</div>
);
};

View File

@ -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);
});

View File

@ -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 { Button } from "@repo/shadcn-ui/components";
import { cn } from "@repo/shadcn-ui/lib/utils";
@ -6,6 +6,7 @@ import { PencilIcon } from "lucide-react";
import type { Proforma } from "../../../../shared";
import { ProformaFooterActions } from "./proforma-footer-actions";
import { ProformaSummaryContent } from "./proforma-summary-content";
const mockERPData = {
@ -61,7 +62,7 @@ export const ProformaSummaryPanel = ({
proforma,
open,
visibility,
visibility: _visibility,
mode,
onOpenChange,
@ -78,37 +79,42 @@ export const ProformaSummaryPanel = ({
};
return (
<RightPanel
className={cn("bg-transparent", className)}
headerActions={
<>
{proforma ? (
<RightPanel className={cn("bg-transparent", className)} open={open}>
<RdxContextPanel
actions={
proforma ? (
<Button
aria-label="Editar cliente"
aria-label="Editar proforma"
onClick={() => onEdit?.(proforma)}
size="icon-sm"
type="button"
variant="ghost"
>
<PencilIcon className="size-4" />
</Button>
) : null}
</>
}
onOpenChange={onOpenChange}
open={open}
title={titleMap[mode]}
>
{proforma ? (
<ProformaSummaryContent
onChangeStatus={onChangeStatus}
onEdit={onEdit}
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>
)}
) : undefined
}
closeLabel="Cerrar panel"
footer={
proforma ? (
<ProformaFooterActions
onChangeStatus={onChangeStatus}
onEdit={onEdit}
proforma={proforma}
/>
) : undefined
}
onClose={() => onOpenChange(false)}
title={titleMap[mode]}
>
{proforma ? (
<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>
);
};

View File

@ -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);
});

View File

@ -1,21 +1,9 @@
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 {
Button,
Checkbox,
DropdownMenu,
DropdownMenuContent,
DropdownMenuGroup,
DropdownMenuItem,
DropdownMenuLabel,
DropdownMenuSeparator,
DropdownMenuTrigger,
Spinner,
Tooltip,
TooltipContent,
TooltipProvider,
TooltipTrigger,
} from "@repo/shadcn-ui/components";
import type { ColumnDef } from "@tanstack/react-table";
import {
@ -25,12 +13,12 @@ import {
FileDownIcon,
FileTextIcon,
FolderArchiveIcon,
MoreHorizontalIcon,
PencilIcon,
RefreshCwIcon,
Trash2Icon,
Undo2Icon,
} from "lucide-react";
import type { ComponentType } from "react";
import * as React from "react";
import { useTranslation } from "../../../../../i18n";
@ -64,26 +52,10 @@ type GridActionHandlers = {
isPdfDownloading?: boolean;
};
type RowAction = {
key: string;
label: string;
icon: React.ComponentType<{ className?: string }>;
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();
};
const renderActionIcon = (
Icon: ComponentType<{ className?: string }>,
loading?: boolean
) => (loading ? <Spinner className="size-4" /> : <Icon className="size-4" />);
export function useProformasGridColumns(
scope: ProformaListScope,
@ -365,49 +337,51 @@ export function useProformasGridColumns(
return undefined;
};
const visibleActions: RowAction[] = [
const visibleActions: RdxRowAction[] = [
{
key: "view",
id: "view",
label: t("pages.proformas.list.actions.view"),
icon: EyeIcon,
icon: renderActionIcon(EyeIcon),
disabled: !actionHandlers.onViewClick || isDeletedScope,
disabledReason: isDeletedScope
? t("pages.proformas.list.actions.disabled_reasons.deleted_scope")
: undefined,
onClick: handleView,
onSelect: handleView,
},
{
key: "edit",
id: "edit",
label: t("pages.proformas.list.actions.edit"),
icon: PencilIcon,
icon: renderActionIcon(PencilIcon),
disabled: !(actionHandlers.onEditClick && isNormalScope) || isIssued,
disabledReason: isNormalScope
? isIssued
? t("pages.proformas.list.actions.disabled_reasons.edit_issued")
: undefined
: 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"),
icon: ExternalLinkIcon,
icon: renderActionIcon(ExternalLinkIcon),
disabled: !(isIssued && proforma.linkedInvoiceId),
disabledReason:
isIssued && proforma.linkedInvoiceId
? undefined
: t("pages.proformas.list.actions.disabled_reasons.linked_invoice_unavailable"),
href: proforma.linkedInvoiceId
? "/customer-invoices"
: undefined,
onSelect: () => {
if (proforma.linkedInvoiceId) {
window.location.href = "/customer-invoices";
}
},
},
{
key: "change-status",
id: "change-status",
label: t("pages.proformas.list.actions.change_status"),
icon: RefreshCwIcon,
icon: renderActionIcon(RefreshCwIcon),
disabled:
!(actionHandlers.onChangeStatusClick && isNormalScope && nextTransition) ||
isIssued,
@ -418,71 +392,70 @@ export function useProformasGridColumns(
? undefined
: t("proformas.change_proforma_status_dialog.empty_available_statuses")
: getScopeReason(),
onClick: handleChangeStatus,
onSelect: handleChangeStatus,
},
{
key: "issue",
id: "issue",
label: t("pages.proformas.list.actions.issue"),
icon: FileTextIcon,
icon: renderActionIcon(FileTextIcon),
disabled: !(actionHandlers.onIssueClick && isNormalScope && isApproved) || isIssued,
disabledReason: isNormalScope
? isApproved
? undefined
: t("proformas.issue_proforma_dialog.not_allowed_description")
: getScopeReason(),
onClick: handleIssue,
onSelect: handleIssue,
},
{
key: "duplicate",
id: "duplicate",
label: t("pages.proformas.list.actions.duplicate"),
icon: CopyIcon,
icon: renderActionIcon(CopyIcon),
disabled: !actionHandlers.onDuplicateClick || isDeletedScope,
disabledReason: isDeletedScope
? t("pages.proformas.list.actions.disabled_reasons.deleted_scope")
: undefined,
onClick: handleDuplicate,
onSelect: handleDuplicate,
},
{
key: "archive",
id: "archive",
label: t("pages.proformas.list.actions.archive"),
icon: FolderArchiveIcon,
icon: renderActionIcon(FolderArchiveIcon),
disabled: !(actionHandlers.onArchiveClick && isArchivable),
disabledReason: actionHandlers.onArchiveClick
? isNormalScope
? t("proformas.archive.error_conflict_message")
: getScopeReason()
: undefined,
onClick: handleArchive,
onSelect: handleArchive,
},
{
key: "unarchive",
id: "unarchive",
label: t("pages.proformas.list.actions.unarchive"),
icon: Undo2Icon,
icon: renderActionIcon(Undo2Icon),
disabled: !(actionHandlers.onUnarchiveClick && isUnarchivable),
disabledReason: actionHandlers.onUnarchiveClick
? isArchivedScope
? t("proformas.unarchive.error_conflict_message")
: t("pages.proformas.list.actions.disabled_reasons.archived_only")
: undefined,
onClick: handleUnarchive,
onSelect: handleUnarchive,
},
{
key: "download-pdf",
id: "download-pdf",
label: t("pages.proformas.list.actions.download_pdf"),
icon: FileDownIcon,
icon: renderActionIcon(FileDownIcon, isPDFLoading),
disabled: Boolean(!actionHandlers.onDownloadPdf || isDeletedScope || isPDFLoading),
disabledReason: isDeletedScope
? t("pages.proformas.list.actions.disabled_reasons.deleted_scope")
: isPDFLoading
? t("pages.proformas.list.actions.disabled_reasons.operation_in_progress")
: undefined,
loading: isPDFLoading,
onClick: handleDownloadPdf,
onSelect: handleDownloadPdf,
},
{
key: "delete",
id: "delete",
label: t("pages.proformas.list.actions.delete"),
icon: Trash2Icon,
icon: renderActionIcon(Trash2Icon),
destructive: true,
disabled: !actionHandlers.onDeleteClick || isDeletedScope || !isDraft,
disabledReason: isDeletedScope
@ -490,121 +463,16 @@ export function useProformasGridColumns(
: isDraft
? undefined
: 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 (
<TooltipProvider>
<div className="flex items-center justify-end gap-1">
{visibleActions.map(renderIconButton)}
<DropdownMenu>
<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>
<RdxRowActions
overflowActions={menuActions}
overflowLabel={t("common.more_actions")}
primaryActions={visibleActions}
/>
);
},
},

View File

@ -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 { 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 {
Button,
@ -50,6 +50,7 @@ import {
ProformaStatusSegmentedFilter,
UnarchiveProformaDialog,
} from "../components";
import { ScopedListProformasPageView } from "./scoped-list-proformas-page-view";
interface ScopedListProformasPageProps {
scope: ProformaListScope;
@ -435,7 +436,7 @@ const ScopedListProformasPage = ({ scope }: ScopedListProformasPageProps) => {
if (pageError) {
return (
<div className="p-4 sm:p-6">
<RdxPageContainer width="full">
<div className="mx-auto w-full max-w-3xl">
<PageErrorState
description={pageError.description}
@ -458,211 +459,210 @@ const ScopedListProformasPage = ({ scope }: ScopedListProformasPageProps) => {
variant={pageError.variant}
/>
</div>
</div>
</RdxPageContainer>
);
}
return (
<div className="p-6 space-y-6">
{/* Header */}
<PageHeader
description={t(scopeConfig.descriptionKey)}
rightSlot={
scopeConfig.showCreateAction ? (
<Button
aria-label={t("pages.proformas.create.title")}
onClick={() =>
navigate({
pathname: "/proformas/create",
search: createSearchParams({
returnTo: currentReturnTo,
}).toString(),
})
}
size={"default"}
<ScopedListProformasPageView
content={
<>
{/* Stats Cards */}
<div className="hidden grid-cols-1 gap-4 md:grid-cols-2 lg:grid-cols-4">
<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-blue-100">
<FileTextIcon className="size-6 text-blue-600" />
</div>
<div className="flex-1">
<p className="text-sm text-muted-foreground">Total proformas</p>
<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
</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" />
{t("pages.proformas.create.title")}
</Button>
) : undefined
}
title={t(scopeConfig.titleKey)}
/>
<ResizablePanel defaultSize="70%" maxSize="75%" minSize="70%">
{listContent}
</ResizablePanel>
{refreshError ? <ErrorAlert message={refreshError.message} title={refreshError.title} /> : null}
<ResizableHandle className="mx-4" withHandle />
{/* Stats Cards */}
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4 hidden">
<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-blue-100">
<FileTextIcon className="size-6 text-blue-600" />
</div>
<div className="flex-1">
<p className="text-sm text-muted-foreground">Total proformas</p>
<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>
<ResizablePanel defaultSize="30%" maxSize="30%" minSize="25%">
<div className="h-full">
<ProformaSummaryPanel
className="border bg-background"
mode={panelCtrl.panelState.mode}
onEdit={(proforma) => handleEditClick(proforma.id)}
onOpenChange={(open) => {
if (!open) {
panelCtrl.closePanel();
return;
}
<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="text-xs text-muted-foreground mt-1">
<span className="text-green-600 font-medium">↑ 8.1%</span> vs. mes anterior
</p>
</div>
</div>
</CardContent>
</Card>
panelCtrl.panelState.onOpenChange(true);
}}
open={panelCtrl.panelState.isOpen}
proforma={panelCtrl.proforma}
visibility={panelCtrl.panelState.visibility}
/>
</div>
</ResizablePanel>
</ResizablePanelGroup>
) : (
<div className="mx-auto w-full space-y-4">{listContent}</div>
)}
</>
}
description={t(scopeConfig.descriptionKey)}
dialogs={
<>
<IssueProformaDialog
isSubmitting={issueDialogCtrl.isSubmitting}
onConfirm={issueDialogCtrl.confirmIssue}
onOpenChange={(open) => {
if (!open) {
issueDialogCtrl.closeDialog();
}
}}
open={issueDialogCtrl.open}
target={issueDialogCtrl.target}
/>
<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="text-xs text-muted-foreground mt-1">
<span className="text-green-600 font-medium">↑ 5.2%</span> vs. mes anterior
</p>
</div>
</div>
</CardContent>
</Card>
<ChangeProformaStatusDialog
isSubmitting={changeStatusDialogCtrl.isSubmitting}
onConfirm={changeStatusDialogCtrl.confirmChangeStatus}
onOpenChange={(open) => {
if (!open) {
changeStatusDialogCtrl.closeDialog();
}
}}
open={changeStatusDialogCtrl.open}
targets={changeStatusDialogCtrl.targets}
/>
<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="text-xs text-muted-foreground mt-1">
<span className="text-green-600 font-medium">↑ 15.7%</span> vs. mes anterior
</p>
</div>
</div>
</CardContent>
</Card>
</div>
<DeleteProformaDialog
isSubmitting={deleteDialogCtrl.isSubmitting}
onConfirm={deleteDialogCtrl.confirmDelete}
onOpenChange={(open) => {
if (!open) {
deleteDialogCtrl.closeDialog();
}
}}
open={deleteDialogCtrl.open}
targets={deleteDialogCtrl.targets}
/>
{/* Table */}
{scopeConfig.enablePanel && isPanelOpen ? (
<ResizablePanelGroup
autoSave="list-proformas-page"
className="h-full mx-auto w-full space-y-4"
orientation="horizontal"
>
<ResizablePanel defaultSize="70%" maxSize="75%" minSize="70%">
{listContent}
</ResizablePanel>
<ArchiveProformaDialog
isSubmitting={archiveDialogCtrl.isSubmitting}
onConfirm={archiveDialogCtrl.confirmArchive}
onOpenChange={(open) => {
if (!open) {
archiveDialogCtrl.closeDialog();
}
}}
open={archiveDialogCtrl.open}
target={archiveDialogCtrl.target}
/>
<ResizableHandle className="mx-4" withHandle />
<ResizablePanel defaultSize="30%" maxSize="30%" minSize="25%">
<div className="h-full">
<ProformaSummaryPanel
className="border bg-background"
mode={panelCtrl.panelState.mode}
onEdit={(proforma) => handleEditClick(proforma.id)}
onOpenChange={(open) => {
if (!open) {
panelCtrl.closePanel();
return;
}
panelCtrl.panelState.onOpenChange(true);
}}
open={panelCtrl.panelState.isOpen}
proforma={panelCtrl.proforma}
visibility={panelCtrl.panelState.visibility}
/>
</div>
</ResizablePanel>
</ResizablePanelGroup>
) : (
<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();
<UnarchiveProformaDialog
isSubmitting={unarchiveDialogCtrl.isSubmitting}
onConfirm={unarchiveDialogCtrl.confirmUnarchive}
onOpenChange={(open) => {
if (!open) {
unarchiveDialogCtrl.closeDialog();
}
}}
open={unarchiveDialogCtrl.open}
target={unarchiveDialogCtrl.target}
/>
</>
}
primaryAction={
scopeConfig.showCreateAction ? (
<Button
aria-label={t("pages.proformas.create.title")}
onClick={() =>
navigate({
pathname: "/proformas/create",
search: createSearchParams({
returnTo: currentReturnTo,
}).toString(),
})
}
}}
open={issueDialogCtrl.open}
target={issueDialogCtrl.target}
/>
<ChangeProformaStatusDialog
isSubmitting={changeStatusDialogCtrl.isSubmitting}
onConfirm={changeStatusDialogCtrl.confirmChangeStatus}
onOpenChange={(open) => {
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>
size="default"
>
<PlusIcon aria-hidden className="mr-2 size-4" />
{t("pages.proformas.create.title")}
</Button>
) : undefined
}
refreshError={refreshError}
title={t(scopeConfig.titleKey)}
/>
);
};

View File

@ -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);
});

View File

@ -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>
);
};

View File

@ -1,9 +1,7 @@
import { MoneyHelper, PercentageHelper } from "@repo/rdx-utils";
import {
Button,
Card,
CardContent,
CardFooter,
Item,
ItemContent,
ItemMedia,
@ -38,6 +36,7 @@ interface ProformaCompactTotalsProps {
disabled?: boolean;
className?: string;
variant?: "standalone" | "embedded";
}
export const ProformaCompactTotals = ({
@ -45,9 +44,10 @@ export const ProformaCompactTotals = ({
currency = "EUR",
showRec = false,
showRetention = false,
globalDiscountField,
disabled = false,
globalDiscountField: _globalDiscountField,
disabled: _disabled = false,
className,
variant = "standalone",
}: ProformaCompactTotalsProps) => {
const { t } = useTranslation();
const [isExpanded, setIsExpanded] = useState(false);
@ -60,18 +60,24 @@ export const ProformaCompactTotals = ({
return PercentageHelper.formatPercent(value);
};
return (
<Card className={"rounded-none bg-muted-foreground p-0 gap-0"}>
{/* Panel expandible con desglose */}
const isEmbedded = variant === "embedded";
return (
<div className={cn("min-w-0", className)}>
<CardContent
className={cn(
"grid transition-all duration-200 bg-background",
isExpanded ? "grid-rows-[1fr] py-6" : "grid-rows-[0fr] py-0"
"grid overflow-hidden transition-all duration-200",
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="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 */}
<div className="space-y-2">
<div className="flex items-center gap-2 font-medium text-teal-700 dark:text-teal-400">
@ -175,26 +181,23 @@ export const ProformaCompactTotals = ({
</div>
</CardContent>
{/* Barra principal de totales - siempre visible */}
<CardFooter
<div
className={cn(
"flex items-center justify-between gap-4 bg-muted rounded-none py-3",
className
"flex flex-wrap items-center gap-4",
isEmbedded ? "min-w-0 justify-between bg-transparent py-0" : "rounded-none bg-muted px-6 py-3"
)}
>
{/* Boton expandir */}
<Button onClick={() => setIsExpanded(!isExpanded)} variant="outline">
<Button onClick={() => setIsExpanded(!isExpanded)} size="sm" type="button" variant="outline">
{isExpanded ? <ChevronDown className="size-5" /> : <ChevronUp className="size-5" />}
{isExpanded ? "Ocultar desglose" : "Ver desglose"}
</Button>
{/* Totales en linea */}
<div className="flex items-center gap-8 text-base">
<div className="hidden items-center gap-2 xl:flex">
<div className="flex min-w-0 flex-1 flex-wrap items-center justify-end gap-x-5 gap-y-2 text-sm">
<div className="hidden items-center gap-2 lg:flex">
<span className="text-muted-foreground">
{t("proformas.update.totals.subtotalBeforeDiscounts", "Subtotal")}
</span>
<span className="font-medium tabular-nums">
<span className="font-medium tabular-nums text-foreground">
{formatMoney(totals.subtotalBeforeDiscounts)}
</span>
</div>
@ -220,7 +223,7 @@ export const ProformaCompactTotals = ({
<span className="font-medium tabular-nums">{formatMoney(totals.taxableBase)}</span>
</div>
<div className="hidden items-center gap-2 xl:flex">
<div className="hidden items-center gap-2 lg:flex">
<Tooltip>
<TooltipTrigger render={<LandmarkIcon className="text-orange-700 size-4" />} />
<TooltipContent>
@ -252,20 +255,29 @@ export const ProformaCompactTotals = ({
</div>
) : null}
{/* Total destacado */}
<Item className="bg-muted-foreground">
<ItemMedia className="text-muted">
<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>
{isEmbedded ? (
<div className="inline-flex items-center gap-2 rounded-md border border-border bg-muted/40 px-3 py-2">
<ReceiptTextIcon className="size-4 text-muted-foreground" />
<span className="text-sm font-medium text-muted-foreground">Total</span>
<span className="text-base font-bold tabular-nums text-foreground">
{formatMoney(totals.total)}
</span>
</div>
) : (
<Item className="bg-muted-foreground">
<ItemMedia className="text-muted">
<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>
</CardFooter>
</Card>
</div>
</div>
);
};

View File

@ -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/);
});

View File

@ -38,6 +38,7 @@ export interface ProformaUpdateHeaderProps {
readOnly?: boolean;
isSaving?: boolean;
hasChanges?: boolean;
showFormActions?: boolean;
className?: string;
children?: ReactNode;
@ -47,13 +48,14 @@ export const ProformaUpdateHeader = ({
formId,
labels,
onCancel,
onDuplicate,
onExportPdf,
onDelete,
onDuplicate: _onDuplicate,
onExportPdf: _onExportPdf,
onDelete: _onDelete,
disabled = false,
readOnly = false,
isSaving = false,
hasChanges = false,
showFormActions = true,
className,
children,
}: ProformaUpdateHeaderProps) => {
@ -100,27 +102,31 @@ export const ProformaUpdateHeader = ({
]}
/>
<CancelActionButton
className="hidden sm:flex"
disabled={computedDisabled}
label={labels.cancel}
onCancel={() => onCancel?.()}
variant="outline"
/>
{showFormActions ? (
<>
<CancelActionButton
className="hidden sm:flex"
disabled={computedDisabled}
label={labels.cancel}
onCancel={() => onCancel?.()}
variant="outline"
/>
<RhfSubmitActionButton
busyLabel={labels.saving}
disabled={computedDisabled || readOnly}
formId={formId}
isBusy={isSaving}
label={labels.save}
/>
<RhfSubmitActionButton
busyLabel={labels.saving}
disabled={computedDisabled || readOnly}
formId={formId}
isBusy={isSaving}
label={labels.save}
/>
<FormSecondaryActionsMenu
actions={secondaryActions}
disabled={computedDisabled}
label={labels.moreActions}
/>
<FormSecondaryActionsMenu
actions={secondaryActions}
disabled={computedDisabled}
label={labels.moreActions}
/>
</>
) : null}
</FormActionsBar>
}
className={className}

View File

@ -1,6 +1,6 @@
import {
FormSectionCard,
FormSectionGrid,
RdxEditorSection,
RdxFormGrid,
SelectField,
type SelectFieldItem,
} from "@repo/rdx-ui/components";
@ -28,14 +28,14 @@ export const ProformaUpdatePaymentEditor = ({
const { t } = useTranslation();
return (
<FormSectionCard
<RdxEditorSection
className={className}
description={t("form_groups.proformas.payment.description", "Forma de pago")}
disabled={disabled}
icon={<CreditCardIcon className="size-5" />}
title={t("form_groups.proformas.payment.title", "Condiciones de pago")}
variant="panel"
>
<FormSectionGrid className="w-full">
<RdxFormGrid className="w-full">
<SelectField
className="col-span-full"
inputClassName="bg-background"
@ -61,7 +61,7 @@ export const ProformaUpdatePaymentEditor = ({
)}
readOnly={readOnly}
/>
</FormSectionGrid>
</FormSectionCard>
</RdxFormGrid>
</RdxEditorSection>
);
};

View File

@ -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"));
});

View File

@ -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 { SelectFieldItem } from "@repo/rdx-ui/components";
import { RdxEditorPageLayout, type SelectFieldItem } from "@repo/rdx-ui/components";
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 { useTranslation } from "../../../../i18n";
import type {
UseUpdateProformaItemsControllerResult,
UseUpdateProformaPaymentControllerResult,
@ -22,7 +19,7 @@ import { ProformaUpdateTaxEditor } from "./proforma-update-tax-editor";
type ProformaUpdateEditorProps = {
formId: string;
isSubmitting: boolean;
onSubmit: React.SubmitEventHandler<HTMLFormElement>;
onSubmit: SubmitEventHandler<HTMLFormElement>;
onReset: () => void;
selectedCustomer?: CustomerSelectionOption | null;
@ -42,7 +39,7 @@ export const ProformaUpdateEditorForm = ({
formId,
isSubmitting,
onSubmit,
onReset,
onReset: _onReset,
selectedCustomer,
onChangeCustomerClick,
onCreateCustomerClick,
@ -53,18 +50,34 @@ export const ProformaUpdateEditorForm = ({
paymentCtrl,
className,
}: ProformaUpdateEditorProps) => {
const { t } = useTranslation();
return (
<div className={cn("p-6", className)}>
<form id={formId} noValidate onKeyDown={preventEnterKeySubmitForm} onSubmit={onSubmit}>
<div className="grid grid-cols-1 2xl:grid-cols-4 gap-6 2xl:gap-12 2xl:space-y-12">
<div className="2xl:col-span-3 space-y-6 gap-6 2xl:gap-12 2xl:space-y-12">
<ProformaUpdateHeaderEditor
className="2xl:col-span-full"
disabled={isSubmitting}
seriesOptions={seriesOptions}
/>
<RdxEditorPageLayout
aside={
<div className="flex min-w-0 flex-col gap-6">
<ProformaUpdateRecipientEditor
disabled={isSubmitting}
onChangeCustomerClick={onChangeCustomerClick}
onCreateCustomerClick={onCreateCustomerClick}
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
disabled={isSubmitting}
@ -73,31 +86,8 @@ export const ProformaUpdateEditorForm = ({
totalsCtrl={totalsCtrl}
/>
</div>
<div className="2xl:col-span-1 space-y-6 2xl:space-y-12">
<ProformaUpdateRecipientEditor
className="2xl:col-span-1"
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>
</form>
</div>
</RdxEditorPageLayout>
);
};

View File

@ -1,7 +1,7 @@
import {
DatePickerField,
FormSectionCard,
FormSectionGrid,
RdxEditorSection,
RdxFormGrid,
SelectField,
type SelectFieldItem,
TextAreaField,
@ -29,13 +29,13 @@ export const ProformaUpdateHeaderEditor = ({
const { t } = useTranslation();
return (
<FormSectionCard
<RdxEditorSection
className={className}
disabled={disabled}
icon={<FileTextIcon className="size-5" />}
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
className="md:col-span-2"
disabled={true}
@ -108,7 +108,7 @@ export const ProformaUpdateHeaderEditor = ({
placeholder={t("form_fields.proformas.notes.placeholder")}
readOnly={readOnly}
/>
</FormSectionGrid>
</FormSectionCard>
</RdxFormGrid>
</RdxEditorSection>
);
};

View File

@ -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 { ListIcon } from "lucide-react";
import type { ComponentProps } from "react";
import { useTranslation } from "../../../../i18n";
import type {
@ -11,10 +10,11 @@ import type {
} from "../../controllers";
import { ProformaLineEditor } from "../blocks";
interface ProformaUpdateItemsEditorProps extends ComponentProps<"fieldset"> {
interface ProformaUpdateItemsEditorProps {
totalsCtrl: UseUpdateProformaTotalsControllerResult;
itemsCtrl: UseUpdateProformaItemsControllerResult;
taxCtrl: UseUpdateProformaTaxControllerResult;
disabled?: boolean;
}
export const ProformaUpdateItemsEditor = ({
@ -22,7 +22,6 @@ export const ProformaUpdateItemsEditor = ({
itemsCtrl,
taxCtrl,
disabled,
...props
}: ProformaUpdateItemsEditorProps) => {
const { t } = useTranslation();
@ -30,11 +29,15 @@ export const ProformaUpdateItemsEditor = ({
const { itemErrors } = itemsCtrl;
return (
<FormSectionCard
className={cn(itemErrors.length ? "bg-red-100" : "")}
disabled={disabled}
<RdxEditorSection
className={cn(
itemErrors.length
? "rounded-xl border border-destructive/40 bg-destructive/5 px-4 py-4 md:px-5"
: ""
)}
icon={<ListIcon className="size-3" />}
title={t("form_groups.items.title", "Líneas de detalle")}
variant="panel"
>
<ProformaLineEditor
addItemAtStart={itemsCtrl.addItemAtStart}
@ -54,6 +57,6 @@ export const ProformaUpdateItemsEditor = ({
showLineTaxes={taxCtrl.usesPerLineTax}
totals={totals}
/>
</FormSectionCard>
</RdxEditorSection>
);
};

View File

@ -1,14 +1,6 @@
import type { CustomerSelectionOption } from "@erp/customers";
import {
Button,
Card,
CardContent,
CardDescription,
CardFooter,
CardHeader,
CardTitle,
} from "@repo/shadcn-ui/components";
import { cn } from "@repo/shadcn-ui/lib/utils";
import { RdxEditorSection } from "@repo/rdx-ui/components";
import { Button } from "@repo/shadcn-ui/components";
import { PlusIcon, RefreshCwIcon, UserIcon } from "lucide-react";
import { useTranslation } from "../../../../i18n";
@ -40,57 +32,43 @@ export const ProformaUpdateRecipientEditor = ({
const showActions = !readOnly;
return (
<Card className={className}>
<CardHeader className={"flex flex-row items-start gap-4"}>
<div className="flex items-start gap-3">
<div
className={cn(
"flex h-10 w-10 shrink-0 items-center justify-center rounded-md",
disabled ? "bg-muted text-muted-foreground" : "bg-primary/10 text-primary"
)}
>
<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">
<RdxEditorSection
className={className}
description={t("form_groups.proformas.customer.description", "Cliente destinatario de la proforma")}
icon={<UserIcon className="size-5" />}
title={t("form_groups.proformas.customer.title", "Cliente")}
variant="panel"
>
<div className="min-w-0 space-y-5">
<SelectedRecipientSummary disabled={disabled} recipient={selectedCustomer} />
</CardContent>
{showActions && (
<CardFooter className="flex gap-2 2xl:justify-between">
<Button
className="justify-center"
disabled={disabled}
onClick={onCreateCustomerClick}
type="button"
variant="outline"
>
<PlusIcon className="mr-2 size-4" />
{t("customers.selected_customer.new_customer", "Nuevo cliente")}
</Button>
<Button
className="justify-center"
disabled={disabled}
onClick={onChangeCustomerClick}
type="button"
variant={selectedCustomer ? "outline" : "default"}
>
<RefreshCwIcon className="mr-2 size-4" />
{selectedCustomer
? t("customers.selected_customer.change", "Cambiar cliente")
: t("customers.selected_customer.select", "Seleccionar cliente")}
</Button>
</CardFooter>
)}
</Card>
{showActions ? (
<div className="flex flex-wrap items-center gap-2">
<Button
className="justify-center"
disabled={disabled}
onClick={onCreateCustomerClick}
type="button"
variant="outline"
>
<PlusIcon className="mr-2 size-4" />
{t("customers.selected_customer.new_customer", "Nuevo cliente")}
</Button>
<Button
className="justify-center"
disabled={disabled}
onClick={onChangeCustomerClick}
type="button"
variant={selectedCustomer ? "outline" : "default"}
>
<RefreshCwIcon className="mr-2 size-4" />
{selectedCustomer
? t("customers.selected_customer.change", "Cambiar cliente")
: t("customers.selected_customer.select", "Seleccionar cliente")}
</Button>
</div>
) : null}
</div>
</RdxEditorSection>
);
};

View File

@ -1,6 +1,6 @@
import {
FormSectionCard,
FormSectionGrid,
RdxEditorSection,
RdxFormGrid,
SelectField,
type SelectFieldItem,
SwitchField,
@ -38,17 +38,17 @@ export const ProformaUpdateTaxEditor = ({
const { t } = useTranslation();
return (
<FormSectionCard
<RdxEditorSection
className={className}
description={t(
"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."
)}
disabled={disabled}
icon={<LandmarkIcon className="size-5" />}
title={t("form_groups.proformas.taxes.title", "Configuración fiscal")}
variant="panel"
>
<FormSectionGrid>
<RdxFormGrid>
<SelectField
className="col-span-full"
disabled={disabled}
@ -77,7 +77,7 @@ export const ProformaUpdateTaxEditor = ({
/>
<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={
taxCtrl.usesSingleTax
? t("form_fields.proformas.default_tax_percentage.description", "")
@ -115,7 +115,7 @@ export const ProformaUpdateTaxEditor = ({
<>
{t(
"form_fields.proformas.has_equivalence_surcharge.label",
"Recargo de equivalencia"
"Activar recargo de equivalencia"
)}
{taxCtrl.hasRecPercentage && !taxCtrl.usesPerLineTax ? (
@ -139,7 +139,7 @@ export const ProformaUpdateTaxEditor = ({
/>
<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))}
disabled={disabled || !taxCtrl.hasRetentionPercentage}
inputClassName="bg-background"
@ -159,7 +159,7 @@ export const ProformaUpdateTaxEditor = ({
readOnly={readOnly}
serialize={(value) => (typeof value === "number" ? String(value) : "")}
/>
</FormSectionGrid>
</FormSectionCard>
</RdxFormGrid>
</RdxEditorSection>
);
};

View File

@ -1,7 +1,13 @@
import { ErrorAlert, PageErrorState } from "@erp/core/components";
import { UnsavedChangesProvider, useReturnToNavigation } from "@erp/core/hooks";
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 { useTranslation } from "../../../../i18n";
@ -101,70 +107,91 @@ export const ProformaUpdatePage = () => {
</ProformaInfoAlert>
</ProformaUpdateHeader>
{updateCtrl.isUpdateError && (
<ErrorAlert
message={
(updateCtrl.updateError as Error)?.message ??
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 min-h-0 flex-1 flex-col bg-muted/20">
<div className="min-h-0 flex-1 overflow-y-auto">
{updateCtrl.isUpdateError && (
<ErrorAlert
message={
(updateCtrl.updateError as Error)?.message ??
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")}
/>
)}
/>
)}
<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 */}
<footer className="sticky bottom-0 z-10 shrink-0">
<ProformaCompactTotals
className="bg-background"
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}
{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"
)}
/>
)}
<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}
/>
</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>
<SelectCustomerDialog ctrl={selectCustomerCtrl.selectCtrl} />

View File

@ -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);
});

View File

@ -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"/);
});

View File

@ -1,5 +1,6 @@
import { preventEnterKeySubmitForm } from "@repo/rdx-ui/helpers";
import { cn } from "@repo/shadcn-ui/lib/utils";
import type { SubmitEventHandler } from "react";
import {
CustomerAdditionalConfigEditor,
@ -13,7 +14,7 @@ import type { UseCustomerCreateFiscalControllerResult } from "../../controllers"
type CustomerCreateEditorProps = {
formId: string;
isSubmitting: boolean;
onSubmit: React.SubmitEventHandler<HTMLFormElement>;
onSubmit: SubmitEventHandler<HTMLFormElement>;
onReset: () => void;
fiscalCtrl: UseCustomerCreateFiscalControllerResult;
@ -32,25 +33,23 @@ export const CustomerCreateEditorForm = ({
className,
}: CustomerCreateEditorProps) => {
return (
<div className={cn("p-6", className)}>
<form
className="space-y-6 2xl:space-y-12"
id={formId}
noValidate
onKeyDown={preventEnterKeySubmitForm}
onReset={onReset}
onSubmit={onSubmit}
>
<CustomerBasicInfoEditor disabled={isSubmitting} />
<CustomerAddressEditor disabled={isSubmitting} />
<CustomerContactEditor disabled={isSubmitting} />
<CustomerFiscalEditor
paymentMethodOptions={fiscalCtrl.paymentMethodOptions}
paymentTermOptions={fiscalCtrl.paymentTermOptions}
taxRegimeOptions={fiscalCtrl.taxRegimeOptions}
/>
<CustomerAdditionalConfigEditor disabled={isSubmitting} />
</form>
</div>
<form
className={cn("flex min-w-0 flex-col gap-6", className)}
id={formId}
noValidate
onKeyDown={preventEnterKeySubmitForm}
onReset={onReset}
onSubmit={onSubmit}
>
<CustomerBasicInfoEditor disabled={isSubmitting} />
<CustomerAddressEditor disabled={isSubmitting} />
<CustomerContactEditor disabled={isSubmitting} />
<CustomerFiscalEditor
paymentMethodOptions={fiscalCtrl.paymentMethodOptions}
paymentTermOptions={fiscalCtrl.paymentTermOptions}
taxRegimeOptions={fiscalCtrl.taxRegimeOptions}
/>
<CustomerAdditionalConfigEditor disabled={isSubmitting} />
</form>
);
};

View File

@ -1,5 +1,6 @@
import { ErrorAlert } from "@erp/core/components";
import { UnsavedChangesProvider, useReturnToNavigation } from "@erp/core/hooks";
import { RdxEditorPageLayout } from "@repo/rdx-ui/components";
import { FormProvider } from "react-hook-form";
import { useTranslation } from "../../../i18n";
@ -42,22 +43,29 @@ export const CustomerCreatePage = () => {
//onExportPdf={handleExportPdf}
/>
{createCtrl.isCreateError && (
<ErrorAlert
message={(createCtrl.createError as Error)?.message ?? t("create.errors.message")}
title={t("create.errors.title")}
/>
)}
<div className="flex-1 overflow-y-auto bg-muted/20">
<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">
{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
className="max-w-5xl mx-auto mb-12"
fiscalCtrl={createCtrl.fiscalCtrl}
formId={createCtrl.formId}
isSubmitting={createCtrl.isCreating}
onReset={createCtrl.resetForm}
onSubmit={createCtrl.onSubmit}
/>
<CustomerCreateEditorForm
fiscalCtrl={createCtrl.fiscalCtrl}
formId={createCtrl.formId}
isSubmitting={createCtrl.isCreating}
onReset={createCtrl.resetForm}
onSubmit={createCtrl.onSubmit}
/>
</div>
</div>
</RdxEditorPageLayout>
</div>
</UnsavedChangesProvider>
</FormProvider>

View File

@ -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, "");
});

View File

@ -12,16 +12,19 @@ export const CustomerFooterActions = ({
onEdit?: (customer: Customer) => void;
}) => {
return (
<div className="border-t p-4">
<div className="flex gap-2">
<Button className="flex-1" onClick={() => onCreateProforma?.(customer)} variant="outline">
Nueva proforma
</Button>
<div className="flex gap-2">
<Button
className="flex-1"
onClick={() => onCreateProforma?.(customer)}
type="button"
variant="outline"
>
Nueva proforma
</Button>
<Button className="flex-1" onClick={() => onEdit?.(customer)}>
Editar cliente
</Button>
</div>
<Button className="flex-1" onClick={() => onEdit?.(customer)} type="button">
Editar cliente
</Button>
</div>
);
};

View File

@ -5,6 +5,14 @@ import { Building2Icon, CopyIcon, UserIcon } from "lucide-react";
import type { Customer } from "../../../../shared";
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 (
<div className="p-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="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"}>
{customer.status === "active" ? "Activo" : "Inactivo"}
</Badge>
@ -39,7 +47,7 @@ export const CustomerHeader = ({ customer }: { customer: Customer }) => {
<button
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"
>
{customer.tin}

View File

@ -4,7 +4,6 @@ import type { Customer } from "../../../../shared";
import { CustomerAddressSection } from "./customer-address-section";
import { CustomerContactSection } from "./customer-contact-section";
import { CustomerFooterActions } from "./customer-footer-actions";
import { CustomerHeader } from "./customer-header";
import { CustomerProformasSection } from "./customer-proformas-section";
import { CustomerStatsSection } from "./customer-stats-section";
@ -25,8 +24,6 @@ interface CustomerSummaryContentProps {
status: "paid" | "pending" | "overdue";
}[];
onEdit?: (customer: Customer) => void;
onCreateInvoice?: (customer: Customer) => void;
onProformaClick?: (proformaId: string) => void;
}
@ -34,8 +31,6 @@ export const CustomerSummaryContent = ({
customer,
stats,
proformas,
onEdit,
onCreateInvoice,
onProformaClick,
}: CustomerSummaryContentProps) => {
return (
@ -57,12 +52,6 @@ export const CustomerSummaryContent = ({
<Separator />
<CustomerProformasSection onProformaClick={onProformaClick} proformas={proformas} />
<CustomerFooterActions
customer={customer}
onCreateInvoice={onCreateInvoice}
onEdit={onEdit}
/>
</div>
);
};

View File

@ -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);
});

View File

@ -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 { Button } from "@repo/shadcn-ui/components";
import { cn } from "@repo/shadcn-ui/lib/utils";
@ -6,6 +6,7 @@ import { PencilIcon } from "lucide-react";
import type { Customer } from "../../../../shared";
import { CustomerFooterActions } from "./customer-footer-actions";
import { CustomerSummaryContent } from "./customer-summary-content";
const mockERPData = {
@ -59,7 +60,7 @@ interface CustomerSummaryPanelProps {
export const CustomerSummaryPanel = ({
customer,
open,
visibility,
visibility: _visibility,
mode,
onOpenChange,
onEdit,
@ -72,38 +73,40 @@ export const CustomerSummaryPanel = ({
};
return (
<RightPanel
className={cn("bg-transparent shadow", className)}
headerActions={
<>
{customer ? (
<RightPanel className={cn("bg-transparent", className)} open={open}>
<RdxContextPanel
actions={
customer ? (
<Button
aria-label="Editar cliente"
onClick={() => onEdit?.(customer)}
size="icon-sm"
type="button"
variant="ghost"
>
<PencilIcon className="size-4" />
</Button>
) : null}
</>
}
onOpenChange={onOpenChange}
open={open}
title={titleMap[mode]}
>
{customer ? (
<CustomerSummaryContent
customer={customer}
onEdit={onEdit}
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>
)}
) : undefined
}
closeLabel="Cerrar panel"
footer={
customer ? <CustomerFooterActions customer={customer} onEdit={onEdit} /> : undefined
}
onClose={() => onOpenChange(false)}
title={titleMap[mode]}
>
{customer ? (
<CustomerSummaryContent
customer={customer}
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>
)}
</RdxContextPanel>
</RightPanel>
);
};

View File

@ -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);
});

View File

@ -1,15 +1,12 @@
import { DataTableColumnHeader, InitialsAvatar } from "@repo/rdx-ui/components";
import {
Badge,
Button,
Checkbox,
Tooltip,
TooltipContent,
TooltipProvider,
TooltipTrigger,
} from "@repo/shadcn-ui/components";
DataTableColumnHeader,
InitialsAvatar,
RdxRowActions,
type RdxRowAction,
} from "@repo/rdx-ui/components";
import { Badge, Checkbox } from "@repo/shadcn-ui/components";
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 { useTranslation } from "../../../../i18n";
@ -170,62 +167,49 @@ export function useCustomersGridColumns(
enableHiding: false,
cell: ({ row }) => {
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 (
<div className="flex items-center gap-1 justify-end">
{actionHandlers.onEditClick && (
<TooltipProvider>
<Tooltip>
<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>
<RdxRowActions
overflowActions={overflowActions}
overflowLabel={t("list.actions.more")}
primaryActions={primaryActions}
/>
);
},
},

View File

@ -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);
});

View File

@ -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>
);
};

View File

@ -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 { RdxPageContainer } from "@repo/rdx-ui/components";
import {
Button,
ResizableHandle,
@ -13,6 +14,7 @@ import { createSearchParams, useNavigate } from "react-router-dom";
import { useTranslation } from "../../../i18n";
import { useListCustomersPageController } from "../../controllers";
import { CustomerSummaryPanel, CustomersGrid, useCustomersGridColumns } from "../blocks";
import { ListCustomersPageView } from "./list-customers-page-view";
export const ListCustomersPage = () => {
const { t } = useTranslation();
@ -88,7 +90,7 @@ export const ListCustomersPage = () => {
if (pageError) {
return (
<div className="p-4 sm:p-6">
<RdxPageContainer width="full">
<div className="mx-auto w-full max-w-3xl">
<PageErrorState
description={pageError.description}
@ -109,67 +111,63 @@ export const ListCustomersPage = () => {
variant={pageError.variant}
/>
</div>
</div>
</RdxPageContainer>
);
}
return (
<div className="p-6 space-y-6">
{/* Header */}
<PageHeader
description={t("list.description")}
rightSlot={
<ListCustomersPageView
content={
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>
)
}
description={t("list.description")}
primaryAction={
<Button
aria-label={t("common.actions.create")}
onClick={() => navigate("/customers/create")}
size={"default"}
size="default"
>
<PlusIcon aria-hidden className="mr-2 size-4" />
{t("common.actions.create")}
</Button>
}
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>
}
refreshError={refreshError}
title={t("list.title")}
/>
);
};

View File

@ -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 { useTranslation } from "../../../i18n";
@ -16,12 +16,12 @@ export const CustomerAdditionalConfigEditor = ({
const { t } = useTranslation();
return (
<FormSectionCard
<RdxEditorSection
description={t("form_groups.preferences.description")}
icon={<Settings2Icon className="size-5" />}
title={t("form_groups.preferences.title")}
>
<FormSectionGrid>
<RdxFormGrid>
<SelectField
className="md:col-span-3"
description={t("fields.language_code.description")}
@ -44,7 +44,7 @@ export const CustomerAdditionalConfigEditor = ({
readOnly={readOnly}
required
/>
</FormSectionGrid>
</FormSectionCard>
</RdxFormGrid>
</RdxEditorSection>
);
};

View File

@ -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 { useTranslation } from "../../../i18n";
@ -16,12 +21,12 @@ export const CustomerAddressEditor = ({
const { t } = useTranslation();
return (
<FormSectionCard
<RdxEditorSection
description={t("form_groups.address.description")}
icon={<MapPinIcon className="size-5" />}
title={t("form_groups.address.title")}
>
<FormSectionGrid className="md:grid-cols-2">
<RdxFormGrid className="md:grid-cols-2">
<TextField
description={t("fields.street.description")}
disabled={disabled}
@ -76,7 +81,7 @@ export const CustomerAddressEditor = ({
readOnly={readOnly}
required
/>
</FormSectionGrid>
</FormSectionCard>
</RdxFormGrid>
</RdxEditorSection>
);
};

View File

@ -1,7 +1,7 @@
import {
FormFieldLabel,
FormSectionCard,
FormSectionGrid,
RdxEditorSection,
RdxFormGrid,
TextAreaField,
TextField,
} from "@repo/rdx-ui/components";
@ -32,12 +32,12 @@ export const CustomerBasicInfoEditor = ({
const { control } = useFormContext();
return (
<FormSectionCard
<RdxEditorSection
description={t("form_groups.basic_info.description")}
icon={<FileTextIcon className="size-5" />}
title={t("form_groups.basic_info.title")}
>
<FormSectionGrid className="md:grid-cols-2">
<RdxFormGrid className="md:grid-cols-2">
<TextField
description={t("fields.name.description")}
disabled={disabled}
@ -144,7 +144,7 @@ export const CustomerBasicInfoEditor = ({
placeholder={t("fields.legal_record.placeholder")}
readOnly={readOnly}
/>
</FormSectionGrid>
</FormSectionCard>
</RdxFormGrid>
</RdxEditorSection>
);
};

View File

@ -1,4 +1,4 @@
import { FormSectionCard, FormSectionGrid, TextField } from "@repo/rdx-ui/components";
import { RdxEditorSection, RdxFormGrid, TextField } from "@repo/rdx-ui/components";
import {
Collapsible,
CollapsibleContent,
@ -23,12 +23,12 @@ export const CustomerContactEditor = ({
const [open, setOpen] = useState(true);
return (
<FormSectionCard
<RdxEditorSection
description={t("form_groups.contact_info.description")}
icon={<PhoneIcon className="size-5" />}
title={t("form_groups.contact_info.title")}
>
<FormSectionGrid>
<RdxFormGrid>
<TextField
className="md:col-span-6"
description={t("fields.email_primary.description")}
@ -114,7 +114,7 @@ export const CustomerContactEditor = ({
<ChevronDown className={`h-4 w-4 transition-transform ${open ? "rotate-180" : ""}`} />
</CollapsibleTrigger>
<CollapsibleContent>
<FormSectionGrid>
<RdxFormGrid>
<TextField
className="md:col-span-6"
description={t("fields.website.description")}
@ -136,10 +136,10 @@ export const CustomerContactEditor = ({
readOnly={readOnly}
typePreset="phone"
/>
</FormSectionGrid>
</RdxFormGrid>
</CollapsibleContent>
</Collapsible>
</FormSectionGrid>
</FormSectionCard>
</RdxFormGrid>
</RdxEditorSection>
);
};

View File

@ -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);
});

View File

@ -1,5 +1,6 @@
import { preventEnterKeySubmitForm } from "@repo/rdx-ui/helpers";
import { cn } from "@repo/shadcn-ui/lib/utils";
import type { SubmitEventHandler } from "react";
import type { UseCustomerUpdateFiscalControllerResult } from "../../controllers";
@ -12,7 +13,7 @@ import { CustomerFiscalEditor } from "./customer-fiscal-editor";
type CustomerUpdateEditorFormProps = {
formId: string;
isSubmitting: boolean;
onSubmit: React.SubmitEventHandler<HTMLFormElement>;
onSubmit: SubmitEventHandler<HTMLFormElement>;
onReset: () => void;
fiscalCtrl: UseCustomerUpdateFiscalControllerResult;
@ -31,25 +32,23 @@ export const CustomerUpdateEditorForm = ({
className,
}: CustomerUpdateEditorFormProps) => {
return (
<div className={cn("p-6", className)}>
<form
className="space-y-6 2xl:space-y-12"
id={formId}
noValidate
onKeyDown={preventEnterKeySubmitForm}
onReset={onReset}
onSubmit={onSubmit}
>
<CustomerBasicInfoEditor disabled={isSubmitting} />
<CustomerAddressEditor />
<CustomerContactEditor />
<CustomerFiscalEditor
paymentMethodOptions={fiscalCtrl.paymentMethodOptions}
paymentTermOptions={fiscalCtrl.paymentTermOptions}
taxRegimeOptions={fiscalCtrl.taxRegimeOptions}
/>
<CustomerAdditionalConfigEditor />
</form>
</div>
<form
className={cn("flex min-w-0 flex-col gap-6", className)}
id={formId}
noValidate
onKeyDown={preventEnterKeySubmitForm}
onReset={onReset}
onSubmit={onSubmit}
>
<CustomerBasicInfoEditor disabled={isSubmitting} />
<CustomerAddressEditor />
<CustomerContactEditor />
<CustomerFiscalEditor
paymentMethodOptions={fiscalCtrl.paymentMethodOptions}
paymentTermOptions={fiscalCtrl.paymentTermOptions}
taxRegimeOptions={fiscalCtrl.taxRegimeOptions}
/>
<CustomerAdditionalConfigEditor />
</form>
);
};

View File

@ -1,6 +1,6 @@
import {
FormSectionCard,
FormSectionGrid,
RdxEditorSection,
RdxFormGrid,
SelectField,
type SelectFieldItem,
SwitchField,
@ -27,12 +27,12 @@ export const CustomerFiscalEditor = ({
const { t } = useTranslation();
return (
<FormSectionCard
<RdxEditorSection
description={t("form_groups.fiscal_info.description")}
icon={<LandmarkIcon className="size-5" />}
title={t("form_groups.fiscal_info.title")}
>
<FormSectionGrid className="md:grid-cols-2">
<RdxFormGrid className="md:grid-cols-2">
<SelectField
clearable
description={t("fields.payment_method.description")}
@ -87,7 +87,7 @@ export const CustomerFiscalEditor = ({
name="usesRetention"
readOnly={readOnly}
/>
</FormSectionGrid>
</FormSectionCard>
</RdxFormGrid>
</RdxEditorSection>
);
};

View File

@ -1,7 +1,7 @@
import { ErrorAlert, PageErrorState } from "@erp/core/components";
import { UnsavedChangesProvider, useReturnToNavigation } from "@erp/core/hooks";
import { RdxEditorPageLayout } from "@repo/rdx-ui/components";
import { FormProvider } from "react-hook-form";
import { useNavigate } from "react-router-dom";
import { useTranslation } from "../../../i18n";
import { useCustomerUpdatePageController } from "../../controllers";
@ -11,7 +11,6 @@ import { CustomerUpdateEditorForm } from "../editor";
export const CustomerUpdatePage = () => {
const { t } = useTranslation();
const navigate = useNavigate();
const ctrl = useCustomerUpdatePageController();
const { updateCtrl, returnTo } = ctrl;
@ -93,24 +92,30 @@ export const CustomerUpdatePage = () => {
//onExportPdf={handleExportPdf}
/>
{updateCtrl.isUpdateError && (
<ErrorAlert
message={
(updateCtrl.updateError as Error)?.message ??
t("pages.customers.update.error_msg", "Revisa los datos e inténtalo de nuevo.")
}
title={t("pages.customers.update.error_title", "No se pudo guardar los cambios")}
/>
)}
<div className="flex-1 overflow-y-auto">
<CustomerUpdateEditorForm
className="max-w-5xl mx-auto mb-12"
fiscalCtrl={updateCtrl.fiscalCtrl}
formId={updateCtrl.formId}
isSubmitting={updateCtrl.isUpdating}
onReset={updateCtrl.resetForm}
onSubmit={updateCtrl.onSubmit}
/>
<div className="flex-1 overflow-y-auto bg-muted/20">
<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">
{updateCtrl.isUpdateError ? (
<ErrorAlert
message={
(updateCtrl.updateError as Error)?.message ??
t("pages.customers.update.error_msg", "Revisa los datos e inténtalo de nuevo.")
}
title={t("pages.customers.update.error_title", "No se pudo guardar los cambios")}
/>
) : null}
<CustomerUpdateEditorForm
fiscalCtrl={updateCtrl.fiscalCtrl}
formId={updateCtrl.formId}
isSubmitting={updateCtrl.isUpdating}
onReset={updateCtrl.resetForm}
onSubmit={updateCtrl.onSubmit}
/>
</div>
</div>
</RdxEditorPageLayout>
</div>
</UnsavedChangesProvider>
</FormProvider>

View File

@ -1,8 +1,8 @@
import { useUrlParamId } from "@erp/core/hooks";
import { useCallback, useMemo } from "react";
import { useNavigate, useSearchParams } from "react-router-dom";
import { useUrlParamId } from "@erp/core/hooks";
import { useTranslation } from "../../../i18n";
import { useTranslation } from "../../i18n";
import {
buildCustomerViewMissingEntityPageErrorViewModel,
buildCustomerViewPageErrorViewModel,

View 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.

View 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`.

View 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.

File diff suppressed because it is too large Load Diff

View File

@ -6,6 +6,7 @@
"sideEffects": false,
"scripts": {
"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",
"check": "biome check .",
"lint": "biome lint .",
@ -37,6 +38,7 @@
"esbuild-plugin-react18-css": "^0.0.4",
"rimraf": "^6.1.3",
"tailwindcss": "^4.2.2",
"tsx": "^4.21.0",
"tsup": "^8.5.1",
"tw-animate-css": "^1.4.0",
"typescript": "^6.0.2",
@ -66,4 +68,4 @@
"sonner": "^2.0.7",
"zod": "^4.3.6"
}
}
}

View File

@ -0,0 +1 @@
export * from "./rdx-context-panel.tsx";

View File

@ -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"));
});

View File

@ -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>
);
};

View 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";

View File

@ -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);
});

View File

@ -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>
);
};

View File

@ -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>
);
};

View File

@ -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>
);
};

View File

@ -1,16 +1,5 @@
import { FieldGroup } from "@repo/shadcn-ui/components";
import { cn } from "@repo/shadcn-ui/lib/utils";
import type { ReactNode } from "react";
import { RdxFormGrid, type RdxFormGridProps } from "./rdx-form-grid.tsx";
interface FormSectionGridProps {
children: ReactNode;
className?: string;
}
export type FormSectionGridProps = RdxFormGridProps;
export const FormSectionGrid = ({ children, className }: FormSectionGridProps) => {
return (
<FieldGroup className={cn("grid grid-cols-1 gap-x-6 gap-y-5 md:grid-cols-12", className)}>
{children}
</FieldGroup>
);
};
export const FormSectionGrid = (props: FormSectionGridProps) => <RdxFormGrid {...props} />;

View File

@ -4,6 +4,7 @@ export * from "./date-picker-input-field/index.ts";
export * from "./decimal-field/index.ts";
export * from "./form-actions/index.ts";
export * from "./form-field-label.tsx";
export * from "./rdx-form-grid.tsx";
export * from "./form-section-card.tsx";
export * from "./form-section-grid.tsx";
export * from "./multi-select-field.tsx";

View 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>
);
};

View File

@ -1,7 +1,9 @@
export * from "./buttons/index.ts";
export * from "./context-panel/index.ts";
export * from "./custom-dialog.tsx";
export * from "./datatable/index.ts";
export * from "./dynamics-tabs.tsx";
export * from "./editor-page/index.ts";
export * from "./entity-sheet/index.ts";
export * from "./field-value-list.ts";
export * from "./form/index.ts";
@ -14,5 +16,6 @@ export * from "./lookup-dialog/index.ts";
export * from "./multi-select.tsx";
export * from "./pdf-viewer.tsx";
export * from "./right-panel/index.ts";
export * from "./row-actions/index.ts";
export * from "./scroll-to-top.tsx";
export * from "./tailwind-indicator.tsx";

View File

@ -1,3 +1,7 @@
export * from "./app-content.tsx";
export * from "./app-header.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";

View 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>
);
};

View 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>
);
};

View File

@ -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, "");
});

View 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>
);
};

View 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>
);
};

View File

@ -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;
title: string;
className?: string;
headerActions?: ReactNode;
children: ReactNode;
onOpenChange?: (open: boolean) => void;
}

View File

@ -1,44 +1,21 @@
import {
Button,
Card,
CardAction,
CardContent,
CardHeader,
CardTitle,
} from "@repo/shadcn-ui/components";
import { XIcon } from "lucide-react";
import { Card } from "@repo/shadcn-ui/components";
import { cn } from "@repo/shadcn-ui/lib/utils";
import type { RightPanelProps } from "./right-panel-types.ts";
export const RightPanel = ({
open,
title,
className,
headerActions,
children,
onOpenChange,
...props
}: RightPanelProps) => {
if (!open) return null;
if (!open) {
return null;
}
return (
<Card className={className}>
<CardHeader>
<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 className={cn("flex h-full min-w-0 flex-col overflow-hidden", className)} {...props}>
{children}
</Card>
);
};

View File

@ -0,0 +1 @@
export * from "./rdx-row-actions.tsx";

View File

@ -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);
});

View 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>
);
};

View File

@ -1331,6 +1331,9 @@ importers:
tailwindcss:
specifier: ^4.2.2
version: 4.3.0
tsx:
specifier: ^4.21.0
version: 4.21.0
tsup:
specifier: ^8.5.1
version: 8.5.1(jiti@2.7.0)(postcss@8.5.15)(tsx@4.21.0)(typescript@6.0.2)