feat: add RdxContextPanel and related tests for context management
feat: implement RdxEditor components including layout, section, and sticky footer with tests feat: introduce RdxFormGrid for form layout management feat: create RdxPage components for structured page layout with actions and headers feat: add RdxRowActions for handling row-level actions with dropdown support
This commit is contained in:
parent
ec1dd1099f
commit
c28a5e02c3
1
apps/web/src/development/ui-kit/index.ts
Normal file
1
apps/web/src/development/ui-kit/index.ts
Normal file
@ -0,0 +1 @@
|
||||
export { default as UiKitPage } from "./ui-kit-page";
|
||||
28
apps/web/src/development/ui-kit/sections/ui-kit-section.tsx
Normal file
28
apps/web/src/development/ui-kit/sections/ui-kit-section.tsx
Normal file
@ -0,0 +1,28 @@
|
||||
import { cn } from "@repo/shadcn-ui/lib/utils";
|
||||
import type { ReactNode } from "react";
|
||||
|
||||
interface UiKitSectionProps {
|
||||
id: string;
|
||||
title: string;
|
||||
description?: ReactNode;
|
||||
children: ReactNode;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export const UiKitSection = ({
|
||||
id,
|
||||
title,
|
||||
description,
|
||||
children,
|
||||
className,
|
||||
}: UiKitSectionProps) => {
|
||||
return (
|
||||
<section className={cn("space-y-4", className)} id={id}>
|
||||
<div className="space-y-1">
|
||||
<h2 className="text-lg font-semibold tracking-tight text-foreground">{title}</h2>
|
||||
{description ? <p className="text-sm text-muted-foreground">{description}</p> : null}
|
||||
</div>
|
||||
{children}
|
||||
</section>
|
||||
);
|
||||
};
|
||||
965
apps/web/src/development/ui-kit/ui-kit-page.tsx
Normal file
965
apps/web/src/development/ui-kit/ui-kit-page.tsx
Normal file
@ -0,0 +1,965 @@
|
||||
import { useTheme } from "@/lib/hooks";
|
||||
import {
|
||||
RdxEditorPageLayout,
|
||||
RdxEditorSection,
|
||||
RdxEditorStickyFooter,
|
||||
RdxFormGrid,
|
||||
RdxPageActions,
|
||||
RdxContextPanel,
|
||||
RdxPageContainer,
|
||||
RdxPageHeader,
|
||||
RdxPageStack,
|
||||
RdxRowActions,
|
||||
RightPanel,
|
||||
} from "@repo/rdx-ui/components";
|
||||
import {
|
||||
Badge,
|
||||
Button,
|
||||
Card,
|
||||
CardContent,
|
||||
CardDescription,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuTrigger,
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
Separator,
|
||||
} from "@repo/shadcn-ui/components";
|
||||
import { ArrowLeftIcon, MoreHorizontalIcon, PlusIcon, XIcon } from "lucide-react";
|
||||
import type { ReactNode } from "react";
|
||||
|
||||
import { UiKitSection } from "./sections/ui-kit-section";
|
||||
|
||||
type ThemeValue = "dark" | "light" | "system";
|
||||
|
||||
const isThemeValue = (value: string): value is ThemeValue =>
|
||||
value === "light" || value === "dark" || value === "system";
|
||||
|
||||
interface ExampleCardProps {
|
||||
title: string;
|
||||
notes?: ReactNode;
|
||||
children: ReactNode;
|
||||
}
|
||||
|
||||
const ExampleCard = ({ title, notes, children }: ExampleCardProps) => {
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader className="space-y-1">
|
||||
<CardTitle className="text-base">{title}</CardTitle>
|
||||
{notes ? <CardDescription>{notes}</CardDescription> : null}
|
||||
</CardHeader>
|
||||
<CardContent>{children}</CardContent>
|
||||
</Card>
|
||||
);
|
||||
};
|
||||
|
||||
const SurfaceExample = ({ children }: { children: ReactNode }) => {
|
||||
return (
|
||||
<div className="rounded-lg border border-border bg-background p-4">
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const OverflowMenuExample = () => {
|
||||
return (
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger
|
||||
render={
|
||||
<Button aria-label="More actions" size="icon-sm" type="button" variant="outline">
|
||||
<MoreHorizontalIcon className="size-4" />
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
<DropdownMenuContent align="end">
|
||||
<DropdownMenuItem>Duplicate</DropdownMenuItem>
|
||||
<DropdownMenuItem>Export</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
);
|
||||
};
|
||||
|
||||
export default function UiKitPage() {
|
||||
const { theme, setTheme } = useTheme();
|
||||
|
||||
return (
|
||||
<RdxPageContainer width="wide">
|
||||
<RdxPageStack spacing="relaxed">
|
||||
<RdxPageHeader
|
||||
actions={
|
||||
<RdxPageActions
|
||||
primary={
|
||||
<Button type="button">
|
||||
<PlusIcon className="size-4" />
|
||||
Primary action
|
||||
</Button>
|
||||
}
|
||||
secondary={<Button type="button" variant="outline">Secondary</Button>}
|
||||
overflow={<OverflowMenuExample />}
|
||||
/>
|
||||
}
|
||||
description="Infraestructura mínima del sistema visual Rdx para validar foundations estructurales antes de migrar pantallas funcionales."
|
||||
metadata={
|
||||
<>
|
||||
<Badge variant="outline">Development only</Badge>
|
||||
<Badge variant="outline">Static data</Badge>
|
||||
</>
|
||||
}
|
||||
title="Rdx UI Kit"
|
||||
/>
|
||||
|
||||
<nav aria-label="UI Kit sections" className="rounded-lg border border-border bg-background p-4">
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<a
|
||||
className="inline-flex h-8 items-center rounded-md px-2.5 text-sm font-medium hover:bg-muted"
|
||||
href="#foundations"
|
||||
>
|
||||
Foundations
|
||||
</a>
|
||||
<a
|
||||
className="inline-flex h-8 items-center rounded-md px-2.5 text-sm font-medium hover:bg-muted"
|
||||
href="#page-containers"
|
||||
>
|
||||
Page containers
|
||||
</a>
|
||||
<a
|
||||
className="inline-flex h-8 items-center rounded-md px-2.5 text-sm font-medium hover:bg-muted"
|
||||
href="#vertical-spacing"
|
||||
>
|
||||
Vertical spacing
|
||||
</a>
|
||||
<a
|
||||
className="inline-flex h-8 items-center rounded-md px-2.5 text-sm font-medium hover:bg-muted"
|
||||
href="#page-headers"
|
||||
>
|
||||
Page headers
|
||||
</a>
|
||||
<a
|
||||
className="inline-flex h-8 items-center rounded-md px-2.5 text-sm font-medium hover:bg-muted"
|
||||
href="#page-actions"
|
||||
>
|
||||
Page actions
|
||||
</a>
|
||||
<a
|
||||
className="inline-flex h-8 items-center rounded-md px-2.5 text-sm font-medium hover:bg-muted"
|
||||
href="#real-list-page-composition"
|
||||
>
|
||||
Real list-page composition
|
||||
</a>
|
||||
<a
|
||||
className="inline-flex h-8 items-center rounded-md px-2.5 text-sm font-medium hover:bg-muted"
|
||||
href="#list-with-context-panel"
|
||||
>
|
||||
List with context panel
|
||||
</a>
|
||||
<a
|
||||
className="inline-flex h-8 items-center rounded-md px-2.5 text-sm font-medium hover:bg-muted"
|
||||
href="#rdx-context-panel"
|
||||
>
|
||||
RdxContextPanel
|
||||
</a>
|
||||
<a
|
||||
className="inline-flex h-8 items-center rounded-md px-2.5 text-sm font-medium hover:bg-muted"
|
||||
href="#rdx-row-actions"
|
||||
>
|
||||
RdxRowActions
|
||||
</a>
|
||||
<a
|
||||
className="inline-flex h-8 items-center rounded-md px-2.5 text-sm font-medium hover:bg-muted"
|
||||
href="#editor-foundations"
|
||||
>
|
||||
Editor foundations
|
||||
</a>
|
||||
<a
|
||||
className="inline-flex h-8 items-center rounded-md px-2.5 text-sm font-medium hover:bg-muted"
|
||||
href="#theme-compatibility"
|
||||
>
|
||||
Light/dark compatibility
|
||||
</a>
|
||||
</div>
|
||||
</nav>
|
||||
|
||||
<UiKitSection
|
||||
description="Los ejemplos de esta página usan solo datos estáticos y el sistema real de tema de la aplicación."
|
||||
id="foundations"
|
||||
title="Foundations"
|
||||
>
|
||||
<div className="grid gap-4 lg:grid-cols-2">
|
||||
<ExampleCard
|
||||
notes="Padding móvil `4`, desktop `6`, ritmo vertical `4 / 6 / 8`, anchuras `full / wide / content`."
|
||||
title="Base estructural"
|
||||
>
|
||||
<SurfaceExample>
|
||||
<div className="space-y-3">
|
||||
<div className="text-sm font-medium text-foreground">Compact, clean, ordered</div>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Las surfaces principales son planas. Las jerarquías se expresan con spacing,
|
||||
tipografía y divisores.
|
||||
</p>
|
||||
</div>
|
||||
</SurfaceExample>
|
||||
</ExampleCard>
|
||||
|
||||
<ExampleCard
|
||||
notes="El selector usa `useTheme`, sin un segundo sistema de tema."
|
||||
title="Theme control"
|
||||
>
|
||||
<div className="max-w-xs">
|
||||
<Select
|
||||
onValueChange={(value) => {
|
||||
if (isThemeValue(value)) {
|
||||
setTheme(value);
|
||||
}
|
||||
}}
|
||||
value={theme}
|
||||
>
|
||||
<SelectTrigger aria-label="Theme mode">
|
||||
<SelectValue placeholder="Select theme" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="light">Light</SelectItem>
|
||||
<SelectItem value="dark">Dark</SelectItem>
|
||||
<SelectItem value="system">System</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</ExampleCard>
|
||||
</div>
|
||||
</UiKitSection>
|
||||
|
||||
<Separator />
|
||||
|
||||
<UiKitSection
|
||||
description="`RdxPageContainer` define padding exterior, anchura y centrado. No gestiona ritmo vertical."
|
||||
id="page-containers"
|
||||
title="Page containers"
|
||||
>
|
||||
<div className="grid gap-4 xl:grid-cols-3">
|
||||
<ExampleCard notes="`width="full"`" title="Full">
|
||||
<RdxPageContainer className="rounded-lg border border-dashed border-border bg-muted/40" width="full">
|
||||
<div className="text-sm text-foreground">Available width</div>
|
||||
</RdxPageContainer>
|
||||
</ExampleCard>
|
||||
|
||||
<ExampleCard notes="`width="wide"`" title="Wide">
|
||||
<RdxPageContainer className="rounded-lg border border-dashed border-border bg-muted/40" width="wide">
|
||||
<div className="text-sm text-foreground">Max width screen 2xl</div>
|
||||
</RdxPageContainer>
|
||||
</ExampleCard>
|
||||
|
||||
<ExampleCard notes="`width="content"`" title="Content">
|
||||
<RdxPageContainer className="rounded-lg border border-dashed border-border bg-muted/40" width="content">
|
||||
<div className="text-sm text-foreground">Centered content editor</div>
|
||||
</RdxPageContainer>
|
||||
</ExampleCard>
|
||||
</div>
|
||||
</UiKitSection>
|
||||
|
||||
<Separator />
|
||||
|
||||
<UiKitSection
|
||||
description="`RdxPageStack` usa `gap` para componer contenido condicional sin depender de `space-y-*`."
|
||||
id="vertical-spacing"
|
||||
title="Vertical spacing"
|
||||
>
|
||||
<div className="grid gap-4 xl:grid-cols-3">
|
||||
<ExampleCard notes="`spacing="compact"`" title="Compact">
|
||||
<RdxPageStack className="rounded-lg border border-border bg-background p-4" spacing="compact">
|
||||
<SurfaceExample>Block 1</SurfaceExample>
|
||||
<SurfaceExample>Block 2</SurfaceExample>
|
||||
<SurfaceExample>Block 3</SurfaceExample>
|
||||
</RdxPageStack>
|
||||
</ExampleCard>
|
||||
|
||||
<ExampleCard notes="`spacing="default"`" title="Default">
|
||||
<RdxPageStack className="rounded-lg border border-border bg-background p-4" spacing="default">
|
||||
<SurfaceExample>Block 1</SurfaceExample>
|
||||
<SurfaceExample>Block 2</SurfaceExample>
|
||||
<SurfaceExample>Block 3</SurfaceExample>
|
||||
</RdxPageStack>
|
||||
</ExampleCard>
|
||||
|
||||
<ExampleCard notes="`spacing="relaxed"`" title="Relaxed">
|
||||
<RdxPageStack className="rounded-lg border border-border bg-background p-4" spacing="relaxed">
|
||||
<SurfaceExample>Block 1</SurfaceExample>
|
||||
<SurfaceExample>Block 2</SurfaceExample>
|
||||
<SurfaceExample>Block 3</SurfaceExample>
|
||||
</RdxPageStack>
|
||||
</ExampleCard>
|
||||
</div>
|
||||
</UiKitSection>
|
||||
|
||||
<Separator />
|
||||
|
||||
<UiKitSection
|
||||
description="`RdxPageHeader` es visual y genérico. El consumidor aporta leading, metadata y acciones."
|
||||
id="page-headers"
|
||||
title="Page headers"
|
||||
>
|
||||
<div className="grid gap-4">
|
||||
<ExampleCard title="Simple title">
|
||||
<SurfaceExample>
|
||||
<RdxPageHeader title="Customers" />
|
||||
</SurfaceExample>
|
||||
</ExampleCard>
|
||||
|
||||
<ExampleCard title="Title and description">
|
||||
<SurfaceExample>
|
||||
<RdxPageHeader
|
||||
description="Compact page header for list and detail screens."
|
||||
title="Proformas"
|
||||
/>
|
||||
</SurfaceExample>
|
||||
</ExampleCard>
|
||||
|
||||
<ExampleCard title="Leading, metadata and actions">
|
||||
<SurfaceExample>
|
||||
<RdxPageHeader
|
||||
actions={
|
||||
<RdxPageActions
|
||||
primary={<Button type="button">Save</Button>}
|
||||
secondary={<Button type="button" variant="outline">Cancel</Button>}
|
||||
overflow={<OverflowMenuExample />}
|
||||
/>
|
||||
}
|
||||
description="Header with leading control, metadata and grouped actions."
|
||||
leading={
|
||||
<Button aria-label="Back" size="icon-sm" type="button" variant="outline">
|
||||
<ArrowLeftIcon className="size-4" />
|
||||
</Button>
|
||||
}
|
||||
metadata={
|
||||
<>
|
||||
<Badge variant="outline">Draft</Badge>
|
||||
<span>Last updated 5 minutes ago</span>
|
||||
</>
|
||||
}
|
||||
title="Invoice editor"
|
||||
/>
|
||||
</SurfaceExample>
|
||||
</ExampleCard>
|
||||
|
||||
<ExampleCard title="Long content and narrow viewport simulation">
|
||||
<div className="max-w-sm rounded-lg border border-border bg-background p-4">
|
||||
<RdxPageHeader
|
||||
actions={<Button type="button">Primary action</Button>}
|
||||
description="A long description checks wrapping behavior with translated text and narrow widths."
|
||||
metadata={<span>Metadata that can also wrap if necessary</span>}
|
||||
title="Very long page title intended to validate wrapping in a constrained viewport"
|
||||
/>
|
||||
</div>
|
||||
</ExampleCard>
|
||||
</div>
|
||||
</UiKitSection>
|
||||
|
||||
<Separator />
|
||||
|
||||
<UiKitSection
|
||||
description="`RdxPageActions` agrupa regiones secundarias, primaria y overflow sin conocer permisos ni lógica."
|
||||
id="page-actions"
|
||||
title="Page actions"
|
||||
>
|
||||
<div className="grid gap-4 lg:grid-cols-2">
|
||||
<ExampleCard title="Primary only">
|
||||
<SurfaceExample>
|
||||
<RdxPageActions primary={<Button type="button">Create customer</Button>} />
|
||||
</SurfaceExample>
|
||||
</ExampleCard>
|
||||
|
||||
<ExampleCard title="Primary and secondary">
|
||||
<SurfaceExample>
|
||||
<RdxPageActions
|
||||
primary={<Button type="button">Save</Button>}
|
||||
secondary={<Button type="button" variant="outline">Cancel</Button>}
|
||||
/>
|
||||
</SurfaceExample>
|
||||
</ExampleCard>
|
||||
|
||||
<ExampleCard title="Primary, secondary and overflow">
|
||||
<SurfaceExample>
|
||||
<RdxPageActions
|
||||
overflow={<OverflowMenuExample />}
|
||||
primary={<Button type="button">Approve</Button>}
|
||||
secondary={<Button type="button" variant="outline">Back to list</Button>}
|
||||
/>
|
||||
</SurfaceExample>
|
||||
</ExampleCard>
|
||||
|
||||
<ExampleCard title="Wrapping">
|
||||
<div className="max-w-sm rounded-lg border border-border bg-background p-4">
|
||||
<RdxPageActions
|
||||
overflow={<OverflowMenuExample />}
|
||||
primary={<Button type="button">Primary action with long label</Button>}
|
||||
secondary={
|
||||
<>
|
||||
<Button type="button" variant="outline">Secondary one</Button>
|
||||
<Button type="button" variant="outline">Secondary two</Button>
|
||||
</>
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
</ExampleCard>
|
||||
</div>
|
||||
</UiKitSection>
|
||||
|
||||
<Separator />
|
||||
|
||||
<UiKitSection
|
||||
description="Composición equivalente a la adoptada en `proformas/list`, usando solo primitives Rdx y bloques ficticios."
|
||||
id="real-list-page-composition"
|
||||
title="Real list-page composition"
|
||||
>
|
||||
<ExampleCard
|
||||
notes="Representa `RdxPageContainer width="full"`, `RdxPageStack spacing="default"`, `RdxPageHeader`, acción primaria, aviso opcional y superficie de listado."
|
||||
title="Pilot integration"
|
||||
>
|
||||
<div className="rounded-lg border border-border bg-muted/30">
|
||||
<RdxPageContainer width="full">
|
||||
<RdxPageStack spacing="default">
|
||||
<RdxPageHeader
|
||||
actions={
|
||||
<RdxPageActions
|
||||
primary={
|
||||
<Button type="button">
|
||||
<PlusIcon className="size-4" />
|
||||
Nueva proforma
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
}
|
||||
description="Gestiona y consulta las proformas de clientes."
|
||||
title="Proformas"
|
||||
/>
|
||||
|
||||
<div className="rounded-lg border border-border bg-background px-4 py-3 text-sm text-muted-foreground">
|
||||
No se ha podido refrescar la lista. Puedes seguir trabajando con los datos
|
||||
cargados.
|
||||
</div>
|
||||
|
||||
<div className="rounded-lg border border-border bg-background p-4">
|
||||
<div className="flex flex-col gap-4">
|
||||
<div className="h-10 rounded-md border border-dashed border-border bg-muted/30" />
|
||||
<div className="h-9 rounded-md border border-dashed border-border bg-muted/30" />
|
||||
<div className="h-72 rounded-md border border-dashed border-border bg-muted/30" />
|
||||
</div>
|
||||
</div>
|
||||
</RdxPageStack>
|
||||
</RdxPageContainer>
|
||||
</div>
|
||||
</ExampleCard>
|
||||
</UiKitSection>
|
||||
|
||||
<Separator />
|
||||
|
||||
<UiKitSection
|
||||
description="Composición manual para validar un listado con panel contextual opcional, sin convertirla todavía en componente público."
|
||||
id="list-with-context-panel"
|
||||
title="List with context panel"
|
||||
>
|
||||
<ExampleCard
|
||||
notes="Representa un listado con `ResizablePanelGroup` o composición equivalente desde la Page, manteniendo las foundations solo en la capa exterior."
|
||||
title="Manual main and aside composition"
|
||||
>
|
||||
<div className="rounded-lg border border-border bg-muted/30">
|
||||
<RdxPageContainer width="full">
|
||||
<RdxPageStack spacing="default">
|
||||
<RdxPageHeader
|
||||
actions={
|
||||
<RdxPageActions
|
||||
primary={
|
||||
<Button type="button">
|
||||
<PlusIcon className="size-4" />
|
||||
Nuevo cliente
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
}
|
||||
description="Listado con panel contextual opcional."
|
||||
title="Clientes"
|
||||
/>
|
||||
|
||||
<div className="grid gap-4 xl:grid-cols-[minmax(0,1fr)_320px]">
|
||||
<div className="rounded-lg border border-border bg-background p-4">
|
||||
<div className="flex flex-col gap-4">
|
||||
<div className="h-10 rounded-md border border-dashed border-border bg-muted/30" />
|
||||
<div className="h-72 rounded-md border border-dashed border-border bg-muted/30" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="rounded-lg border border-border bg-background p-4 shadow-sm">
|
||||
<div className="flex items-start justify-between gap-3 border-b border-border pb-3">
|
||||
<div className="space-y-1">
|
||||
<div className="text-sm font-semibold text-foreground">Ficha de cliente</div>
|
||||
<div className="text-xs text-muted-foreground">
|
||||
Panel contextual opcional
|
||||
</div>
|
||||
</div>
|
||||
<Button aria-label="Cerrar panel" size="icon-sm" type="button" variant="ghost">
|
||||
<XIcon className="size-4" />
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="mt-4 space-y-3">
|
||||
<div className="h-4 rounded bg-muted/40" />
|
||||
<div className="h-4 rounded bg-muted/40" />
|
||||
<div className="h-24 rounded bg-muted/40" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</RdxPageStack>
|
||||
</RdxPageContainer>
|
||||
</div>
|
||||
</ExampleCard>
|
||||
</UiKitSection>
|
||||
|
||||
<Separator />
|
||||
|
||||
<UiKitSection
|
||||
description="Composición visual contextual construida sobre `RightPanel`, sin conocer dominio ni lógica."
|
||||
id="rdx-context-panel"
|
||||
title="RdxContextPanel"
|
||||
>
|
||||
<div className="grid gap-4">
|
||||
<ExampleCard title="Title only">
|
||||
<div className="max-w-sm">
|
||||
<RightPanel open>
|
||||
<RdxContextPanel title="Panel title">
|
||||
<div className="p-4 text-sm text-muted-foreground">Body content</div>
|
||||
</RdxContextPanel>
|
||||
</RightPanel>
|
||||
</div>
|
||||
</ExampleCard>
|
||||
|
||||
<ExampleCard title="Title, description, action and close">
|
||||
<div className="max-w-sm">
|
||||
<RightPanel open>
|
||||
<RdxContextPanel
|
||||
actions={
|
||||
<Button aria-label="Edit item" size="icon-sm" type="button" variant="ghost">
|
||||
<PlusIcon className="size-4" />
|
||||
</Button>
|
||||
}
|
||||
closeLabel="Cerrar panel"
|
||||
description="Secondary context that explains the panel purpose."
|
||||
onClose={() => undefined}
|
||||
title="Context panel"
|
||||
>
|
||||
<div className="space-y-3 p-4 text-sm text-muted-foreground">
|
||||
<p>Long body content should scroll in the correct layer.</p>
|
||||
<div className="h-32 rounded bg-muted/40" />
|
||||
</div>
|
||||
</RdxContextPanel>
|
||||
</RightPanel>
|
||||
</div>
|
||||
</ExampleCard>
|
||||
|
||||
<ExampleCard title="Footer and long title">
|
||||
<div className="max-w-sm">
|
||||
<RightPanel open>
|
||||
<RdxContextPanel
|
||||
closeLabel="Cerrar panel"
|
||||
footer={
|
||||
<div className="flex gap-2">
|
||||
<Button className="flex-1" type="button" variant="outline">
|
||||
Secondary
|
||||
</Button>
|
||||
<Button className="flex-1" type="button">
|
||||
Primary
|
||||
</Button>
|
||||
</div>
|
||||
}
|
||||
onClose={() => undefined}
|
||||
title="Very long contextual panel title intended to validate wrapping behavior"
|
||||
>
|
||||
<div className="space-y-3 p-4 text-sm text-muted-foreground">
|
||||
<p>Example without domain dependencies.</p>
|
||||
<div className="h-24 rounded bg-muted/40" />
|
||||
</div>
|
||||
</RdxContextPanel>
|
||||
</RightPanel>
|
||||
</div>
|
||||
</ExampleCard>
|
||||
</div>
|
||||
</UiKitSection>
|
||||
|
||||
<Separator />
|
||||
|
||||
<UiKitSection
|
||||
description="`RdxRowActions` normaliza icon buttons visibles, overflow, acciones destructivas y disabled reasons sin conocer permisos ni dominio."
|
||||
id="rdx-row-actions"
|
||||
title="RdxRowActions"
|
||||
>
|
||||
<div className="grid gap-4 lg:grid-cols-2">
|
||||
<ExampleCard title="One visible action">
|
||||
<SurfaceExample>
|
||||
<RdxRowActions
|
||||
overflowActions={[]}
|
||||
overflowLabel="More actions"
|
||||
primaryActions={[
|
||||
{
|
||||
id: "view",
|
||||
label: "View",
|
||||
icon: <ArrowLeftIcon className="size-4 rotate-180" />,
|
||||
onSelect: () => undefined,
|
||||
},
|
||||
]}
|
||||
/>
|
||||
</SurfaceExample>
|
||||
</ExampleCard>
|
||||
|
||||
<ExampleCard title="View and edit">
|
||||
<SurfaceExample>
|
||||
<RdxRowActions
|
||||
overflowActions={[]}
|
||||
overflowLabel="More actions"
|
||||
primaryActions={[
|
||||
{
|
||||
id: "view",
|
||||
label: "View",
|
||||
icon: <ArrowLeftIcon className="size-4 rotate-180" />,
|
||||
onSelect: () => undefined,
|
||||
},
|
||||
{
|
||||
id: "edit",
|
||||
label: "Edit",
|
||||
icon: <PlusIcon className="size-4" />,
|
||||
onSelect: () => undefined,
|
||||
},
|
||||
]}
|
||||
/>
|
||||
</SurfaceExample>
|
||||
</ExampleCard>
|
||||
|
||||
<ExampleCard title="Overflow and destructive action">
|
||||
<SurfaceExample>
|
||||
<RdxRowActions
|
||||
overflowActions={[
|
||||
{
|
||||
id: "archive",
|
||||
label: "Archive",
|
||||
icon: <MoreHorizontalIcon className="size-4" />,
|
||||
onSelect: () => undefined,
|
||||
},
|
||||
{
|
||||
id: "delete",
|
||||
label: "Delete item",
|
||||
icon: <XIcon className="size-4" />,
|
||||
onSelect: () => undefined,
|
||||
destructive: true,
|
||||
},
|
||||
]}
|
||||
overflowLabel="More actions"
|
||||
primaryActions={[
|
||||
{
|
||||
id: "edit",
|
||||
label: "Edit",
|
||||
icon: <PlusIcon className="size-4" />,
|
||||
onSelect: () => undefined,
|
||||
},
|
||||
]}
|
||||
/>
|
||||
</SurfaceExample>
|
||||
</ExampleCard>
|
||||
|
||||
<ExampleCard title="Disabled and disabled reason">
|
||||
<SurfaceExample>
|
||||
<RdxRowActions
|
||||
overflowActions={[
|
||||
{
|
||||
id: "delete",
|
||||
label: "Delete",
|
||||
icon: <XIcon className="size-4" />,
|
||||
onSelect: () => undefined,
|
||||
destructive: true,
|
||||
disabled: true,
|
||||
disabledReason: "Only drafts can be deleted",
|
||||
},
|
||||
]}
|
||||
overflowLabel="More actions"
|
||||
primaryActions={[
|
||||
{
|
||||
id: "edit",
|
||||
label: "Edit with long label for wrapping checks",
|
||||
icon: <PlusIcon className="size-4" />,
|
||||
onSelect: () => undefined,
|
||||
disabled: true,
|
||||
disabledReason: "Editing is temporarily unavailable",
|
||||
},
|
||||
]}
|
||||
/>
|
||||
</SurfaceExample>
|
||||
</ExampleCard>
|
||||
</div>
|
||||
</UiKitSection>
|
||||
|
||||
<Separator />
|
||||
|
||||
<UiKitSection
|
||||
description="Foundations iniciales del editor simple validadas con `customers/update` y `customers/create`, manteniendo el header legacy fuera de `rdx-ui`."
|
||||
id="editor-foundations"
|
||||
title="Editor foundations"
|
||||
>
|
||||
<div className="grid gap-4">
|
||||
<ExampleCard title="RdxEditorPageLayout simple">
|
||||
<div className="rounded-lg border border-border bg-muted/30">
|
||||
<RdxEditorPageLayout complexity="simple">
|
||||
<div className="rounded-lg border border-dashed border-border bg-background p-4 text-sm text-foreground">
|
||||
Centered editor content
|
||||
</div>
|
||||
</RdxEditorPageLayout>
|
||||
</div>
|
||||
</ExampleCard>
|
||||
|
||||
<ExampleCard title="RdxEditorSection plain and panel">
|
||||
<div className="grid gap-4 lg:grid-cols-2">
|
||||
<SurfaceExample>
|
||||
<RdxEditorSection
|
||||
description="Default section without card."
|
||||
icon={<PlusIcon className="size-4" />}
|
||||
title="Plain section"
|
||||
>
|
||||
<div className="text-sm text-muted-foreground">Section body</div>
|
||||
</RdxEditorSection>
|
||||
</SurfaceExample>
|
||||
|
||||
<SurfaceExample>
|
||||
<RdxEditorSection
|
||||
description="Independent surface reserved for exceptional blocks."
|
||||
title="Panel section"
|
||||
variant="panel"
|
||||
>
|
||||
<div className="text-sm text-muted-foreground">Panel body</div>
|
||||
</RdxEditorSection>
|
||||
</SurfaceExample>
|
||||
</div>
|
||||
</ExampleCard>
|
||||
|
||||
<ExampleCard title="RdxFormGrid">
|
||||
<SurfaceExample>
|
||||
<RdxFormGrid className="md:grid-cols-2">
|
||||
<div className="rounded-md border border-dashed border-border bg-muted/30 p-3 text-sm">
|
||||
Field A
|
||||
</div>
|
||||
<div className="rounded-md border border-dashed border-border bg-muted/30 p-3 text-sm">
|
||||
Field B
|
||||
</div>
|
||||
<div className="rounded-md border border-dashed border-border bg-muted/30 p-3 text-sm md:col-span-2">
|
||||
Wide field
|
||||
</div>
|
||||
</RdxFormGrid>
|
||||
</SurfaceExample>
|
||||
</ExampleCard>
|
||||
|
||||
<ExampleCard
|
||||
notes="La misma composición ya cubre create y update; cambian defaults y wiring, no la geometría visual."
|
||||
title="Simple editor composition"
|
||||
>
|
||||
<div className="rounded-lg border border-border bg-muted/30">
|
||||
<RdxEditorPageLayout complexity="simple">
|
||||
<div className="rounded-2xl border border-border/60 bg-background px-4 py-4 shadow-xs md:px-6 md:py-6">
|
||||
<div className="flex min-w-0 flex-col gap-6">
|
||||
<div className="rounded-lg border border-border bg-background px-4 py-3 text-sm text-muted-foreground">
|
||||
Save error aligned with editor width
|
||||
</div>
|
||||
|
||||
<div className="flex min-w-0 flex-col gap-6">
|
||||
<RdxEditorSection
|
||||
description="Primary customer information"
|
||||
icon={<PlusIcon className="size-4" />}
|
||||
title="Basic information"
|
||||
>
|
||||
<RdxFormGrid className="md:grid-cols-2">
|
||||
<div className="rounded-md border border-dashed border-border bg-muted/30 p-3 text-sm">
|
||||
Name field
|
||||
</div>
|
||||
<div className="rounded-md border border-dashed border-border bg-muted/30 p-3 text-sm">
|
||||
Tax ID field
|
||||
</div>
|
||||
</RdxFormGrid>
|
||||
</RdxEditorSection>
|
||||
|
||||
<RdxEditorSection
|
||||
description="Secondary preferences"
|
||||
icon={<PlusIcon className="size-4" />}
|
||||
title="Preferences"
|
||||
>
|
||||
<RdxFormGrid className="md:grid-cols-2">
|
||||
<div className="rounded-md border border-dashed border-border bg-muted/30 p-3 text-sm">
|
||||
Language field
|
||||
</div>
|
||||
<div className="rounded-md border border-dashed border-border bg-muted/30 p-3 text-sm">
|
||||
Currency field
|
||||
</div>
|
||||
</RdxFormGrid>
|
||||
</RdxEditorSection>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</RdxEditorPageLayout>
|
||||
</div>
|
||||
</ExampleCard>
|
||||
|
||||
<ExampleCard
|
||||
notes="Evidencia conceptual del piloto real en `proformas/update`: `main + aside`, una única superficie editable y footer sticky combinado."
|
||||
title="Complex editor composition"
|
||||
>
|
||||
<div className="rounded-lg border border-border bg-muted/30">
|
||||
<div className="flex min-h-[46rem] flex-col overflow-hidden rounded-lg">
|
||||
<div className="border-b border-border bg-background px-4 py-4">
|
||||
<div className="flex items-center justify-between gap-4">
|
||||
<div className="space-y-1">
|
||||
<div className="text-lg font-semibold text-foreground">Edit proforma</div>
|
||||
<div className="text-sm text-muted-foreground">
|
||||
Sticky header remains outside `rdx-ui`.
|
||||
</div>
|
||||
</div>
|
||||
<Badge variant="outline">Modified</Badge>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex min-h-0 flex-1 flex-col bg-muted/20">
|
||||
<div className="min-h-0 flex-1 overflow-y-auto">
|
||||
<RdxEditorPageLayout
|
||||
aside={
|
||||
<div className="flex min-w-0 flex-col gap-6">
|
||||
<RdxEditorSection
|
||||
description="Secondary recipient controls."
|
||||
title="Recipient"
|
||||
>
|
||||
<div className="rounded-md border border-dashed border-border bg-muted/30 p-4 text-sm text-muted-foreground">
|
||||
Customer selector and helper actions
|
||||
</div>
|
||||
</RdxEditorSection>
|
||||
|
||||
<RdxEditorSection
|
||||
description="Fiscal defaults and toggles."
|
||||
title="Tax settings"
|
||||
>
|
||||
<RdxFormGrid>
|
||||
<div className="rounded-md border border-dashed border-border bg-muted/30 p-3 text-sm md:col-span-12">
|
||||
Tax regime field
|
||||
</div>
|
||||
<div className="rounded-md border border-dashed border-border bg-muted/30 p-3 text-sm md:col-span-12">
|
||||
Default tax selector
|
||||
</div>
|
||||
</RdxFormGrid>
|
||||
</RdxEditorSection>
|
||||
</div>
|
||||
}
|
||||
complexity="complex"
|
||||
>
|
||||
<div className="rounded-2xl border border-border bg-background px-4 py-4 shadow-xs md:px-6 md:py-6">
|
||||
<div className="flex min-w-0 flex-col gap-6">
|
||||
<div className="rounded-lg border border-border bg-background px-4 py-3 text-sm text-muted-foreground">
|
||||
Inline feedback aligned with the editor content
|
||||
</div>
|
||||
|
||||
<RdxEditorSection title="Document information">
|
||||
<RdxFormGrid>
|
||||
<div className="rounded-md border border-dashed border-border bg-muted/30 p-3 text-sm md:col-span-4">
|
||||
Reference field
|
||||
</div>
|
||||
<div className="rounded-md border border-dashed border-border bg-muted/30 p-3 text-sm md:col-span-4">
|
||||
Date field
|
||||
</div>
|
||||
<div className="rounded-md border border-dashed border-border bg-muted/30 p-3 text-sm md:col-span-4">
|
||||
Series field
|
||||
</div>
|
||||
</RdxFormGrid>
|
||||
</RdxEditorSection>
|
||||
|
||||
<section className="min-w-0 border-b border-border pb-6">
|
||||
<div className="rounded-md border border-dashed border-border bg-muted/30 p-4 text-sm text-muted-foreground">
|
||||
Line editor keeps its own internal table layout and stays in the
|
||||
main surface.
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
</div>
|
||||
</RdxEditorPageLayout>
|
||||
</div>
|
||||
|
||||
<RdxEditorStickyFooter
|
||||
leading={
|
||||
<div className="flex min-w-0 flex-wrap items-center gap-x-5 gap-y-2 text-sm">
|
||||
<span className="text-muted-foreground">Sticky totals region</span>
|
||||
<span className="tabular-nums">Subtotal 2,350.00</span>
|
||||
<span className="tabular-nums">Impuestos 493.50</span>
|
||||
<span className="font-semibold tabular-nums text-foreground">
|
||||
Total 2,843.50
|
||||
</span>
|
||||
</div>
|
||||
}
|
||||
primary={<Button type="button">Guardar</Button>}
|
||||
secondary={<Button type="button" variant="outline">Cancelar</Button>}
|
||||
status="Modificada"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</ExampleCard>
|
||||
</div>
|
||||
</UiKitSection>
|
||||
|
||||
<Separator />
|
||||
|
||||
<UiKitSection
|
||||
description="Composiciones estáticas para validar jerarquía y anchura sin crear todavía layouts CRUD genéricos."
|
||||
id="theme-compatibility"
|
||||
title="Light/dark compatibility"
|
||||
>
|
||||
<div className="grid gap-4 xl:grid-cols-2">
|
||||
<ExampleCard title="List composition">
|
||||
<div className="rounded-lg border border-border bg-muted/30">
|
||||
<RdxPageContainer width="full">
|
||||
<RdxPageStack>
|
||||
<RdxPageHeader
|
||||
actions={<RdxPageActions primary={<Button type="button">New item</Button>} />}
|
||||
description="Static list example"
|
||||
title="List page"
|
||||
/>
|
||||
<SurfaceExample>List content</SurfaceExample>
|
||||
</RdxPageStack>
|
||||
</RdxPageContainer>
|
||||
</div>
|
||||
</ExampleCard>
|
||||
|
||||
<ExampleCard title="Simple editor and detail width">
|
||||
<div className="space-y-4">
|
||||
<div className="rounded-lg border border-border bg-muted/30">
|
||||
<RdxPageContainer width="content">
|
||||
<RdxPageStack>
|
||||
<RdxPageHeader
|
||||
description="Simple centered editor"
|
||||
title="Editor page"
|
||||
/>
|
||||
<SurfaceExample>Form content</SurfaceExample>
|
||||
</RdxPageStack>
|
||||
</RdxPageContainer>
|
||||
</div>
|
||||
|
||||
<div className="rounded-lg border border-border bg-muted/30">
|
||||
<RdxPageContainer width="wide">
|
||||
<RdxPageStack>
|
||||
<RdxPageHeader
|
||||
description="Wide detail composition"
|
||||
title="Detail page"
|
||||
/>
|
||||
<SurfaceExample>Detail content</SurfaceExample>
|
||||
</RdxPageStack>
|
||||
</RdxPageContainer>
|
||||
</div>
|
||||
</div>
|
||||
</ExampleCard>
|
||||
</div>
|
||||
</UiKitSection>
|
||||
</RdxPageStack>
|
||||
</RdxPageContainer>
|
||||
);
|
||||
}
|
||||
@ -12,7 +12,7 @@ const rootElement = document.getElementById("factuges");
|
||||
if (rootElement) {
|
||||
createRoot(rootElement).render(
|
||||
<StrictMode>
|
||||
<ThemeProvider defaultTheme="light" storageKey="vite-ui-theme">
|
||||
<ThemeProvider defaultTheme="system" storageKey="vite-ui-theme">
|
||||
<>
|
||||
<App />
|
||||
<TailwindIndicator />
|
||||
|
||||
@ -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,
|
||||
],
|
||||
|
||||
@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
@ -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>
|
||||
|
||||
|
||||
@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
@ -0,0 +1,155 @@
|
||||
import assert from "node:assert/strict";
|
||||
import test from "node:test";
|
||||
import { isValidElement } from "react";
|
||||
import { renderToStaticMarkup } from "react-dom/server";
|
||||
|
||||
import { PROFORMA_STATUS, type Proforma } from "../../../../shared";
|
||||
import { ProformaSummaryPanel } from "./proforma-summary-panel";
|
||||
|
||||
const mockProforma: Proforma = {
|
||||
id: "proforma-1",
|
||||
companyId: "company-1",
|
||||
proformaReference: "PF-2026-0001",
|
||||
status: PROFORMA_STATUS.DRAFT,
|
||||
archivedAt: null,
|
||||
isArchived: false,
|
||||
targetInvoiceSeriesCode: null,
|
||||
series: "PF",
|
||||
proformaDate: "2026-08-06",
|
||||
operationDate: null,
|
||||
reference: null,
|
||||
description: "Servicio de consultoria",
|
||||
notes: null,
|
||||
languageCode: "es",
|
||||
currencyCode: "EUR",
|
||||
customerId: "customer-1",
|
||||
recipient: {
|
||||
id: "customer-1",
|
||||
name: "Acme Corp",
|
||||
tin: "B12345678",
|
||||
street: "Main street",
|
||||
street2: null,
|
||||
city: "Madrid",
|
||||
province: "Madrid",
|
||||
postalCode: "28001",
|
||||
country: "ES",
|
||||
},
|
||||
taxRegimeCode: null,
|
||||
taxConfig: {
|
||||
taxMode: "single",
|
||||
defaultIvaCode: null,
|
||||
usesEquivalenceSurcharge: false,
|
||||
defaultRecCode: null,
|
||||
usesRetention: false,
|
||||
defaultRetentionCode: null,
|
||||
},
|
||||
taxes: [],
|
||||
paymentMethodId: null,
|
||||
paymentTermId: null,
|
||||
subtotalAmount: 100,
|
||||
itemsDiscountAmount: 0,
|
||||
globalDiscountPercentage: 0,
|
||||
totalDiscountAmount: 0,
|
||||
taxableAmount: 100,
|
||||
ivaAmount: 21,
|
||||
recAmount: 0,
|
||||
retentionAmount: 0,
|
||||
taxesAmount: 21,
|
||||
totalAmount: 121,
|
||||
linkedInvoiceId: null,
|
||||
items: [],
|
||||
};
|
||||
|
||||
const resolveElement = (node: unknown): unknown => {
|
||||
if (Array.isArray(node)) {
|
||||
return node.map(resolveElement);
|
||||
}
|
||||
|
||||
if (!isValidElement(node)) {
|
||||
return node;
|
||||
}
|
||||
|
||||
if (typeof node.type === "function") {
|
||||
return resolveElement(node.type(node.props));
|
||||
}
|
||||
|
||||
return {
|
||||
type: node.type,
|
||||
props: {
|
||||
...node.props,
|
||||
children: resolveElement(node.props.children),
|
||||
},
|
||||
};
|
||||
};
|
||||
|
||||
const findButtons = (node: unknown): Array<Record<string, unknown>> => {
|
||||
if (Array.isArray(node)) {
|
||||
return node.flatMap(findButtons);
|
||||
}
|
||||
|
||||
if (!node || typeof node !== "object" || !("type" in node) || !("props" in node)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const element = node as { type: string; props: Record<string, unknown> };
|
||||
const children = findButtons(element.props.children);
|
||||
|
||||
if (element.type === "button") {
|
||||
return [element.props, ...children];
|
||||
}
|
||||
|
||||
return children;
|
||||
};
|
||||
|
||||
test("renderiza el shell común del panel de proforma y corrige el label accesible", () => {
|
||||
const html = renderToStaticMarkup(
|
||||
<ProformaSummaryPanel
|
||||
mode="view"
|
||||
onOpenChange={() => undefined}
|
||||
open
|
||||
proforma={mockProforma}
|
||||
visibility="visible"
|
||||
/>
|
||||
);
|
||||
|
||||
assert.match(html, /Vista previa de proforma/);
|
||||
assert.match(html, /Acme Corp/);
|
||||
assert.match(html, /Cambiar de estado/);
|
||||
assert.match(html, /Editar proforma/);
|
||||
assert.doesNotMatch(html, /aria-label="Editar cliente"/);
|
||||
assert.equal((html.match(/Cerrar panel/g) ?? []).length, 1);
|
||||
assert.equal((html.match(/<h2/g) ?? []).length, 1);
|
||||
});
|
||||
|
||||
test("mantiene los callbacks de editar y cerrar", async () => {
|
||||
let didEdit = false;
|
||||
let wasClosed = false;
|
||||
|
||||
const resolved = resolveElement(
|
||||
<ProformaSummaryPanel
|
||||
mode="view"
|
||||
onEdit={() => {
|
||||
didEdit = true;
|
||||
}}
|
||||
onOpenChange={(open) => {
|
||||
wasClosed = open;
|
||||
}}
|
||||
open
|
||||
proforma={mockProforma}
|
||||
visibility="visible"
|
||||
/>
|
||||
);
|
||||
|
||||
const buttons = findButtons(resolved);
|
||||
const editButton = buttons.find((button) => button["aria-label"] === "Editar proforma");
|
||||
const closeButton = buttons.find((button) => button["aria-label"] === "Cerrar panel");
|
||||
|
||||
assert.ok(editButton);
|
||||
assert.ok(closeButton);
|
||||
|
||||
await editButton.onClick?.();
|
||||
await closeButton.onClick?.();
|
||||
|
||||
assert.equal(didEdit, true);
|
||||
assert.equal(wasClosed, false);
|
||||
});
|
||||
@ -1,4 +1,4 @@
|
||||
import { RightPanel } from "@repo/rdx-ui/components";
|
||||
import { RdxContextPanel, RightPanel } from "@repo/rdx-ui/components";
|
||||
import type { RightPanelMode, RightPanelVisibility } from "@repo/rdx-ui/hooks";
|
||||
import { 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>
|
||||
);
|
||||
};
|
||||
|
||||
@ -0,0 +1,131 @@
|
||||
import assert from "node:assert/strict";
|
||||
import test from "node:test";
|
||||
import { isValidElement } from "react";
|
||||
import { renderToStaticMarkup } from "react-dom/server";
|
||||
import type { CellContext } from "@tanstack/react-table";
|
||||
|
||||
import { RdxRowActions, type RdxRowActionsProps } from "@repo/rdx-ui/components";
|
||||
|
||||
import type { ProformaListRow } from "../../../../shared";
|
||||
import { PROFORMA_STATUS } from "../../../../shared";
|
||||
import { useProformasGridColumns } from "./use-proforma-grid-columns";
|
||||
|
||||
const proformaFixture: ProformaListRow = {
|
||||
id: "proforma-1",
|
||||
companyId: "company-1",
|
||||
proformaReference: "PF-001",
|
||||
status: PROFORMA_STATUS.DRAFT,
|
||||
archivedAt: null,
|
||||
isArchived: false,
|
||||
targetInvoiceSeriesCode: null,
|
||||
series: null,
|
||||
proformaDate: "2026-08-01",
|
||||
operationDate: null,
|
||||
languageCode: "es",
|
||||
currencyCode: "EUR",
|
||||
reference: null,
|
||||
description: null,
|
||||
recipient: {
|
||||
id: "recipient-1",
|
||||
name: "Acme Corp",
|
||||
tin: "B12345678",
|
||||
street: "Main street",
|
||||
street2: null,
|
||||
city: "Madrid",
|
||||
province: "Madrid",
|
||||
postalCode: "28001",
|
||||
country: "ES",
|
||||
},
|
||||
subtotalAmount: 100,
|
||||
subtotalAmountFmt: "100,00 €",
|
||||
totalDiscountAmount: 0,
|
||||
totalDiscountAmountFmt: "0,00 €",
|
||||
taxableAmount: 100,
|
||||
taxableAmountFmt: "100,00 €",
|
||||
taxesAmount: 21,
|
||||
taxesAmountFmt: "21,00 €",
|
||||
totalAmount: 121,
|
||||
totalAmountFmt: "121,00 €",
|
||||
linkedInvoiceId: null,
|
||||
};
|
||||
|
||||
const getRowActionsProps = (
|
||||
scope: Parameters<typeof useProformasGridColumns>[0],
|
||||
actionHandlers: Parameters<typeof useProformasGridColumns>[1] = {}
|
||||
): RdxRowActionsProps => {
|
||||
let cellNode: unknown = null;
|
||||
|
||||
const Harness = () => {
|
||||
const columns = useProformasGridColumns(scope, actionHandlers);
|
||||
const actionsColumn = columns.find((column) => column.id === "actions");
|
||||
|
||||
cellNode = actionsColumn?.cell?.({
|
||||
row: { original: proformaFixture },
|
||||
} as unknown as CellContext<ProformaListRow, unknown>);
|
||||
|
||||
return null;
|
||||
};
|
||||
|
||||
renderToStaticMarkup(<Harness />);
|
||||
|
||||
if (!isValidElement(cellNode)) {
|
||||
throw new Error("Expected RdxRowActions cell");
|
||||
}
|
||||
|
||||
assert.equal(cellNode.type, RdxRowActions);
|
||||
|
||||
return cellNode.props as RdxRowActionsProps;
|
||||
};
|
||||
|
||||
test("mantiene ver y editar como acciones visibles de referencia", () => {
|
||||
const props = getRowActionsProps("normal", {
|
||||
onEditClick: () => undefined,
|
||||
onViewClick: () => undefined,
|
||||
});
|
||||
|
||||
assert.deepEqual(
|
||||
props.primaryActions?.map((action) => action.id),
|
||||
["view", "edit"]
|
||||
);
|
||||
assert.ok(["Más acciones", "common.more_actions"].includes(props.overflowLabel));
|
||||
});
|
||||
|
||||
test("mantiene las destructivas en overflow y conserva disabled", () => {
|
||||
const props = getRowActionsProps("normal", {
|
||||
onDeleteClick: () => undefined,
|
||||
});
|
||||
|
||||
const deleteAction = props.overflowActions?.find((action) => action.id === "delete");
|
||||
const viewAction = props.primaryActions?.find((action) => action.id === "view");
|
||||
|
||||
assert.ok(deleteAction);
|
||||
assert.equal(deleteAction?.destructive, true);
|
||||
assert.equal(deleteAction?.disabled, false);
|
||||
assert.equal(viewAction?.disabled, true);
|
||||
});
|
||||
|
||||
test("preserva callbacks de view, edit y delete", () => {
|
||||
let didView = false;
|
||||
let didEdit = false;
|
||||
let didDelete = false;
|
||||
|
||||
const props = getRowActionsProps("normal", {
|
||||
onDeleteClick: () => {
|
||||
didDelete = true;
|
||||
},
|
||||
onEditClick: () => {
|
||||
didEdit = true;
|
||||
},
|
||||
onViewClick: () => {
|
||||
didView = true;
|
||||
},
|
||||
});
|
||||
|
||||
props.primaryActions?.find((action) => action.id === "view")?.onSelect();
|
||||
props.primaryActions?.find((action) => action.id === "edit")?.onSelect();
|
||||
props.overflowActions?.find((action) => action.id === "delete")?.onSelect();
|
||||
|
||||
assert.equal(didView, true);
|
||||
assert.equal(didEdit, true);
|
||||
assert.equal(didDelete, true);
|
||||
});
|
||||
@ -1,21 +1,9 @@
|
||||
import { EmptyCellValue } from "@erp/core/components";
|
||||
import { 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}
|
||||
/>
|
||||
);
|
||||
},
|
||||
},
|
||||
|
||||
@ -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)}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
@ -0,0 +1,127 @@
|
||||
import assert from "node:assert/strict";
|
||||
import test from "node:test";
|
||||
import { isValidElement } from "react";
|
||||
import { renderToStaticMarkup } from "react-dom/server";
|
||||
|
||||
import { ScopedListProformasPageView } from "./scoped-list-proformas-page-view";
|
||||
|
||||
const resolveElement = (node: unknown): unknown => {
|
||||
if (Array.isArray(node)) {
|
||||
return node.map(resolveElement);
|
||||
}
|
||||
|
||||
if (!isValidElement(node)) {
|
||||
return node;
|
||||
}
|
||||
|
||||
if (typeof node.type === "function") {
|
||||
return resolveElement(node.type(node.props));
|
||||
}
|
||||
|
||||
return {
|
||||
type: node.type,
|
||||
props: {
|
||||
...node.props,
|
||||
children: resolveElement(node.props.children),
|
||||
},
|
||||
};
|
||||
};
|
||||
|
||||
const findFirstElementByType = (
|
||||
node: unknown,
|
||||
type: string
|
||||
): { type: string; props: Record<string, unknown> } | null => {
|
||||
if (Array.isArray(node)) {
|
||||
for (const child of node) {
|
||||
const found = findFirstElementByType(child, type);
|
||||
|
||||
if (found) {
|
||||
return found;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
if (!node || typeof node !== "object" || !("type" in node) || !("props" in node)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const element = node as { type: string; props: Record<string, unknown> };
|
||||
|
||||
if (element.type === type) {
|
||||
return element;
|
||||
}
|
||||
|
||||
return findFirstElementByType(element.props.children, type);
|
||||
};
|
||||
|
||||
test("renderiza la composición real del listado con título, descripción y acción principal", () => {
|
||||
const html = renderToStaticMarkup(
|
||||
<ScopedListProformasPageView
|
||||
content={<div>Grid content</div>}
|
||||
description="Listado principal de proformas"
|
||||
primaryAction={<button type="button">Nueva proforma</button>}
|
||||
title="Proformas"
|
||||
/>
|
||||
);
|
||||
|
||||
assert.match(html, /<h1[^>]*>Proformas<\/h1>/);
|
||||
assert.match(html, /Listado principal de proformas/);
|
||||
assert.match(html, /Nueva proforma/);
|
||||
assert.match(html, /Grid content/);
|
||||
});
|
||||
|
||||
test("mantiene el error inline, el panel contextual y los dialogs fuera del stack principal", () => {
|
||||
const html = renderToStaticMarkup(
|
||||
<ScopedListProformasPageView
|
||||
content={
|
||||
<div>
|
||||
<div>Grid content</div>
|
||||
<aside>Summary panel open</aside>
|
||||
</div>
|
||||
}
|
||||
dialogs={<div>Issue dialog mounted</div>}
|
||||
refreshError={{
|
||||
title: "Error de actualización",
|
||||
message: "No se pudo refrescar la lista",
|
||||
}}
|
||||
title="Proformas"
|
||||
/>
|
||||
);
|
||||
|
||||
assert.match(html, /Error de actualización/);
|
||||
assert.match(html, /No se pudo refrescar la lista/);
|
||||
assert.match(html, /Summary panel open/);
|
||||
assert.match(html, /Issue dialog mounted/);
|
||||
});
|
||||
|
||||
test("preserva el callback de la acción principal", async () => {
|
||||
let didCreate = false;
|
||||
|
||||
const resolved = resolveElement(
|
||||
<ScopedListProformasPageView
|
||||
content={<div>Grid content</div>}
|
||||
primaryAction={
|
||||
<button
|
||||
onClick={() => {
|
||||
didCreate = true;
|
||||
}}
|
||||
type="button"
|
||||
>
|
||||
Nueva proforma
|
||||
</button>
|
||||
}
|
||||
title="Proformas"
|
||||
/>
|
||||
);
|
||||
|
||||
const button = findFirstElementByType(resolved, "button");
|
||||
|
||||
assert.ok(button);
|
||||
assert.equal(typeof button.props.onClick, "function");
|
||||
|
||||
await button.props.onClick?.();
|
||||
|
||||
assert.equal(didCreate, true);
|
||||
});
|
||||
@ -0,0 +1,51 @@
|
||||
import { ErrorAlert } from "@erp/core/components";
|
||||
import {
|
||||
RdxPageActions,
|
||||
RdxPageContainer,
|
||||
RdxPageHeader,
|
||||
RdxPageStack,
|
||||
} from "@repo/rdx-ui/components";
|
||||
import type { ReactNode } from "react";
|
||||
|
||||
interface RefreshErrorViewModel {
|
||||
title: string;
|
||||
message: string;
|
||||
}
|
||||
|
||||
export interface ScopedListProformasPageViewProps {
|
||||
title: ReactNode;
|
||||
description?: ReactNode;
|
||||
primaryAction?: ReactNode;
|
||||
refreshError?: RefreshErrorViewModel | null;
|
||||
content: ReactNode;
|
||||
dialogs?: ReactNode;
|
||||
}
|
||||
|
||||
export const ScopedListProformasPageView = ({
|
||||
title,
|
||||
description,
|
||||
primaryAction,
|
||||
refreshError,
|
||||
content,
|
||||
dialogs,
|
||||
}: ScopedListProformasPageViewProps) => {
|
||||
return (
|
||||
<RdxPageContainer width="full">
|
||||
<RdxPageStack spacing="default">
|
||||
<RdxPageHeader
|
||||
actions={primaryAction ? <RdxPageActions primary={primaryAction} /> : undefined}
|
||||
description={description}
|
||||
title={title}
|
||||
/>
|
||||
|
||||
{refreshError ? (
|
||||
<ErrorAlert message={refreshError.message} title={refreshError.title} />
|
||||
) : null}
|
||||
|
||||
{content}
|
||||
</RdxPageStack>
|
||||
|
||||
{dialogs}
|
||||
</RdxPageContainer>
|
||||
);
|
||||
};
|
||||
@ -1,9 +1,7 @@
|
||||
import { MoneyHelper, PercentageHelper } from "@repo/rdx-utils";
|
||||
import {
|
||||
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>
|
||||
);
|
||||
};
|
||||
|
||||
@ -0,0 +1,60 @@
|
||||
import assert from "node:assert/strict";
|
||||
import test from "node:test";
|
||||
import { renderToStaticMarkup } from "react-dom/server";
|
||||
import { FormProvider, useForm } from "react-hook-form";
|
||||
|
||||
import type { ProformaUpdateForm } from "../../entities";
|
||||
import { buildProformaUpdateDefault } from "../../utils";
|
||||
import { ProformaUpdateHeader } from "./proforma-update-header";
|
||||
|
||||
const baseLabels = {
|
||||
title: "Editar proforma",
|
||||
back: "Volver",
|
||||
modified: "Modificada",
|
||||
cancel: "Cancelar",
|
||||
save: "Guardar",
|
||||
saving: "Guardando...",
|
||||
moreActions: "Más acciones",
|
||||
keyboardShortcuts: "Ver atajos de teclado",
|
||||
duplicate: "Duplicar proforma",
|
||||
exportPdf: "Exportar PDF",
|
||||
delete: "Eliminar",
|
||||
};
|
||||
|
||||
const renderHeader = (showFormActions = true) => {
|
||||
const Harness = () => {
|
||||
const form = useForm<ProformaUpdateForm>({
|
||||
defaultValues: buildProformaUpdateDefault(),
|
||||
});
|
||||
|
||||
return (
|
||||
<FormProvider {...form}>
|
||||
<ProformaUpdateHeader
|
||||
formId="proforma-update-form"
|
||||
hasChanges
|
||||
labels={baseLabels}
|
||||
onCancel={() => undefined}
|
||||
showFormActions={showFormActions}
|
||||
/>
|
||||
</FormProvider>
|
||||
);
|
||||
};
|
||||
|
||||
return renderToStaticMarkup(<Harness />);
|
||||
};
|
||||
|
||||
test("oculta guardar y cancelar en el header cuando la página usa footer sticky", () => {
|
||||
const html = renderHeader(false);
|
||||
|
||||
assert.match(html, /Editar proforma/);
|
||||
assert.match(html, /Modificada/);
|
||||
assert.doesNotMatch(html, /Guardar/);
|
||||
assert.doesNotMatch(html, /Cancelar/);
|
||||
});
|
||||
|
||||
test("mantiene las acciones del header cuando no se desactivan", () => {
|
||||
const html = renderHeader(true);
|
||||
|
||||
assert.match(html, /Guardar/);
|
||||
assert.match(html, /Cancelar/);
|
||||
});
|
||||
@ -38,6 +38,7 @@ export interface ProformaUpdateHeaderProps {
|
||||
readOnly?: boolean;
|
||||
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}
|
||||
|
||||
@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
@ -0,0 +1,138 @@
|
||||
import assert from "node:assert/strict";
|
||||
import test from "node:test";
|
||||
import { renderToStaticMarkup } from "react-dom/server";
|
||||
import { FormProvider, useForm } from "react-hook-form";
|
||||
|
||||
import type {
|
||||
ProformaItemField,
|
||||
UseUpdateProformaItemsControllerResult,
|
||||
UseUpdateProformaPaymentControllerResult,
|
||||
UseUpdateProformaTaxControllerResult,
|
||||
UseUpdateProformaTotalsControllerResult,
|
||||
} from "../../controllers";
|
||||
import type { ProformaTotals, ProformaUpdateForm } from "../../entities";
|
||||
import { buildProformaUpdateDefault } from "../../utils";
|
||||
import { ProformaUpdateEditorForm } from "./proforma-update-editor-form";
|
||||
|
||||
const emptyTotals: ProformaTotals = {
|
||||
subtotalBeforeDiscounts: 0,
|
||||
lineDiscountTotal: 0,
|
||||
globalDiscountPercentage: 0,
|
||||
globalDiscountAmount: 0,
|
||||
totalDiscountAmount: 0,
|
||||
taxableBase: 0,
|
||||
taxBreakdown: [],
|
||||
taxTotal: 0,
|
||||
recTotal: 0,
|
||||
taxRecTotal: 0,
|
||||
retentionPercentage: null,
|
||||
retentionAmount: 0,
|
||||
total: 0,
|
||||
};
|
||||
|
||||
const itemsCtrl: UseUpdateProformaItemsControllerResult = {
|
||||
fields: [] as ProformaItemField[],
|
||||
items: [],
|
||||
hasItems: false,
|
||||
itemCount: 0,
|
||||
getItemIsValued: () => false,
|
||||
itemErrors: [],
|
||||
getItemError: () => undefined,
|
||||
getItemErrorMessage: () => undefined,
|
||||
addItemAtStart: () => undefined,
|
||||
appendItem: () => undefined,
|
||||
insertItemBefore: () => undefined,
|
||||
insertItemAfter: () => undefined,
|
||||
removeItem: () => undefined,
|
||||
duplicateItem: () => undefined,
|
||||
moveItemUp: () => undefined,
|
||||
moveItemDown: () => undefined,
|
||||
getItemAmounts: () => ({
|
||||
subtotal: 0,
|
||||
itemDiscountAmount: 0,
|
||||
subtotalTaxable: 0,
|
||||
taxAmount: 0,
|
||||
total: 0,
|
||||
}),
|
||||
totals: {
|
||||
subtotal: 0,
|
||||
discountAmount: 0,
|
||||
total: 0,
|
||||
},
|
||||
};
|
||||
|
||||
const taxCtrl: UseUpdateProformaTaxControllerResult = {
|
||||
taxMode: "single",
|
||||
hasTaxPercentage: true,
|
||||
taxPercentage: 21,
|
||||
hasRecPercentage: false,
|
||||
recPercentage: null,
|
||||
hasRetentionPercentage: false,
|
||||
retentionPercentage: null,
|
||||
usesSingleTax: true,
|
||||
usesPerLineTax: false,
|
||||
enablePerLineTaxes: () => undefined,
|
||||
disablePerLineTaxes: () => undefined,
|
||||
updateTaxPercentage: () => undefined,
|
||||
updateRecPercentage: () => undefined,
|
||||
updateRetentionPercentage: () => undefined,
|
||||
taxRegimeOptions: [],
|
||||
};
|
||||
|
||||
const paymentCtrl: UseUpdateProformaPaymentControllerResult = {
|
||||
paymentMethodOptions: [],
|
||||
paymentTermOptions: [],
|
||||
isLoading: false,
|
||||
isFetching: false,
|
||||
isError: false,
|
||||
error: null,
|
||||
};
|
||||
|
||||
const totalsCtrl: UseUpdateProformaTotalsControllerResult = {
|
||||
totals: emptyTotals,
|
||||
};
|
||||
|
||||
const renderEditor = () => {
|
||||
const Harness = () => {
|
||||
const form = useForm<ProformaUpdateForm>({
|
||||
defaultValues: buildProformaUpdateDefault(),
|
||||
});
|
||||
|
||||
return (
|
||||
<FormProvider {...form}>
|
||||
<ProformaUpdateEditorForm
|
||||
formId="proforma-update-form"
|
||||
isSubmitting={false}
|
||||
itemsCtrl={itemsCtrl}
|
||||
onChangeCustomerClick={() => undefined}
|
||||
onCreateCustomerClick={() => undefined}
|
||||
onReset={() => undefined}
|
||||
onSubmit={(event) => {
|
||||
event.preventDefault();
|
||||
}}
|
||||
paymentCtrl={paymentCtrl}
|
||||
selectedCustomer={null}
|
||||
seriesOptions={[]}
|
||||
taxCtrl={taxCtrl}
|
||||
totalsCtrl={totalsCtrl}
|
||||
/>
|
||||
</FormProvider>
|
||||
);
|
||||
};
|
||||
|
||||
return renderToStaticMarkup(<Harness />);
|
||||
};
|
||||
|
||||
test("renderiza el patrón complejo con main, aside y un único formulario", () => {
|
||||
const html = renderEditor();
|
||||
|
||||
assert.match(html, /id="proforma-update-form"/);
|
||||
assert.match(html, /xl:grid-cols-\[minmax\(0,1fr\)_22rem\]/);
|
||||
assert.match(html, /<aside/);
|
||||
assert.match(html, /Cliente/);
|
||||
assert.match(html, /Configuración fiscal/);
|
||||
assert.match(html, /Condiciones de pago/);
|
||||
assert.match(html, /Líneas de detalle/);
|
||||
assert.doesNotMatch(html, /max-w-5xl/);
|
||||
assert.ok(html.indexOf("Líneas de detalle") < html.indexOf("<aside"));
|
||||
});
|
||||
@ -1,13 +1,10 @@
|
||||
// modules/customer-invoices/src/web/proformas/update/ui/blocks/proforma-update-editor.tsx
|
||||
|
||||
import type { CustomerSelectionOption } from "@erp/customers";
|
||||
import type { 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>
|
||||
);
|
||||
};
|
||||
|
||||
@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
@ -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} />
|
||||
|
||||
@ -0,0 +1,95 @@
|
||||
import assert from "node:assert/strict";
|
||||
import test from "node:test";
|
||||
import { isValidElement } from "react";
|
||||
import { renderToStaticMarkup } from "react-dom/server";
|
||||
|
||||
import { CustomerCreateHeader } from "./customer-create-header";
|
||||
|
||||
const resolveElement = (node: unknown): unknown => {
|
||||
if (Array.isArray(node)) {
|
||||
return node.map(resolveElement);
|
||||
}
|
||||
|
||||
if (!isValidElement(node)) {
|
||||
return node;
|
||||
}
|
||||
|
||||
if (typeof node.type === "function") {
|
||||
return resolveElement(node.type(node.props));
|
||||
}
|
||||
|
||||
return {
|
||||
type: node.type,
|
||||
props: {
|
||||
...node.props,
|
||||
children: resolveElement(node.props.children),
|
||||
},
|
||||
};
|
||||
};
|
||||
|
||||
const findElementsByType = (
|
||||
node: unknown,
|
||||
type: string
|
||||
): Array<{ type: string; props: Record<string, unknown> }> => {
|
||||
if (Array.isArray(node)) {
|
||||
return node.flatMap((child) => findElementsByType(child, type));
|
||||
}
|
||||
|
||||
if (!node || typeof node !== "object" || !("type" in node) || !("props" in node)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const element = node as { type: string; props: Record<string, unknown> };
|
||||
const children = findElementsByType(element.props.children, type);
|
||||
|
||||
if (element.type === type) {
|
||||
return [element, ...children];
|
||||
}
|
||||
|
||||
return children;
|
||||
};
|
||||
|
||||
const labels = {
|
||||
title: "Crear cliente",
|
||||
back: "Volver",
|
||||
modified: "Modificada",
|
||||
cancel: "Cancelar",
|
||||
save: "Guardar",
|
||||
saving: "Guardando...",
|
||||
moreActions: "Más acciones",
|
||||
keyboardShortcuts: "Ver atajos",
|
||||
delete: "Eliminar",
|
||||
};
|
||||
|
||||
test("renderiza título y acciones principales del header de create", () => {
|
||||
const html = renderToStaticMarkup(
|
||||
<CustomerCreateHeader formId="customer-create-form" labels={labels} />
|
||||
);
|
||||
|
||||
assert.match(html, /<h1[^>]*>Crear cliente<\/h1>/);
|
||||
assert.match(html, /Guardar/);
|
||||
assert.match(html, /Cancelar/);
|
||||
});
|
||||
|
||||
test("preserva el callback de cancelación", async () => {
|
||||
let wasCancelled = false;
|
||||
|
||||
const resolved = resolveElement(
|
||||
<CustomerCreateHeader
|
||||
formId="customer-create-form"
|
||||
labels={labels}
|
||||
onCancel={() => {
|
||||
wasCancelled = true;
|
||||
}}
|
||||
/>
|
||||
);
|
||||
|
||||
const buttons = findElementsByType(resolved, "button");
|
||||
const backButton = buttons.find((button) => button.props["aria-label"] === "Volver");
|
||||
|
||||
assert.ok(backButton);
|
||||
|
||||
await backButton?.props.onClick?.();
|
||||
|
||||
assert.equal(wasCancelled, true);
|
||||
});
|
||||
@ -0,0 +1,46 @@
|
||||
import assert from "node:assert/strict";
|
||||
import test from "node:test";
|
||||
import { renderToStaticMarkup } from "react-dom/server";
|
||||
import { FormProvider, useForm } from "react-hook-form";
|
||||
|
||||
import type { CustomerCreateForm } from "../../entities";
|
||||
import { buildCustomerCreateDefault } from "../../utils";
|
||||
import { CustomerCreateEditorForm } from "./customer-create-editor";
|
||||
|
||||
const renderForm = () => {
|
||||
const Harness = () => {
|
||||
const form = useForm<CustomerCreateForm>({
|
||||
defaultValues: buildCustomerCreateDefault(),
|
||||
});
|
||||
|
||||
return (
|
||||
<FormProvider {...form}>
|
||||
<CustomerCreateEditorForm
|
||||
fiscalCtrl={{
|
||||
paymentMethodOptions: [],
|
||||
paymentTermOptions: [],
|
||||
taxRegimeOptions: [],
|
||||
}}
|
||||
formId="customer-create-form"
|
||||
isSubmitting={false}
|
||||
onReset={() => undefined}
|
||||
onSubmit={(event) => {
|
||||
event.preventDefault();
|
||||
}}
|
||||
/>
|
||||
</FormProvider>
|
||||
);
|
||||
};
|
||||
|
||||
return renderToStaticMarkup(<Harness />);
|
||||
};
|
||||
|
||||
test("renderiza el formulario create con id, secciones y fields representativos", () => {
|
||||
const html = renderForm();
|
||||
|
||||
assert.match(html, /id="customer-create-form"/);
|
||||
assert.equal((html.match(/<section/g) ?? []).length, 5);
|
||||
assert.match(html, /name="name"/);
|
||||
assert.match(html, /name="country"/);
|
||||
assert.match(html, /name="primaryEmail"/);
|
||||
});
|
||||
@ -1,5 +1,6 @@
|
||||
import { preventEnterKeySubmitForm } from "@repo/rdx-ui/helpers";
|
||||
import { 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>
|
||||
);
|
||||
};
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -0,0 +1,14 @@
|
||||
import assert from "node:assert/strict";
|
||||
import test from "node:test";
|
||||
|
||||
import { buildCustomerCreateDefault } from "./build-customer-create-defaults";
|
||||
|
||||
test("define defaults coherentes para el formulario de create", () => {
|
||||
const defaults = buildCustomerCreateDefault();
|
||||
|
||||
assert.equal(defaults.isCompany, true);
|
||||
assert.equal(defaults.country, "ES");
|
||||
assert.equal(defaults.languageCode, "es");
|
||||
assert.equal(defaults.currencyCode, "EUR");
|
||||
assert.equal(defaults.name, "");
|
||||
});
|
||||
@ -12,16 +12,19 @@ export const CustomerFooterActions = ({
|
||||
onEdit?: (customer: Customer) => void;
|
||||
}) => {
|
||||
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>
|
||||
);
|
||||
};
|
||||
|
||||
@ -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}
|
||||
|
||||
@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
@ -0,0 +1,139 @@
|
||||
import assert from "node:assert/strict";
|
||||
import test from "node:test";
|
||||
import { isValidElement } from "react";
|
||||
import { renderToStaticMarkup } from "react-dom/server";
|
||||
|
||||
import type { Customer } from "../../../../shared";
|
||||
import { CustomerSummaryPanel } from "./customer-summary-panel";
|
||||
|
||||
const mockCustomer: Customer = {
|
||||
id: "customer-1",
|
||||
companyId: "company-1",
|
||||
status: "active",
|
||||
reference: null,
|
||||
isCompany: true,
|
||||
name: "Acme Corp",
|
||||
tradeName: "Acme",
|
||||
tin: "B12345678",
|
||||
address: {
|
||||
street: "Main street",
|
||||
street2: null,
|
||||
city: "Madrid",
|
||||
province: "Madrid",
|
||||
postalCode: "28001",
|
||||
country: "ES",
|
||||
},
|
||||
contact: {
|
||||
primaryEmail: "hello@acme.test",
|
||||
secondaryEmail: null,
|
||||
primaryPhone: "910000000",
|
||||
secondaryPhone: null,
|
||||
primaryMobile: "600000000",
|
||||
secondaryMobile: null,
|
||||
fax: null,
|
||||
website: "https://acme.test",
|
||||
},
|
||||
legalRecord: null,
|
||||
paymentMethod: null,
|
||||
paymentTerm: null,
|
||||
taxRegime: null,
|
||||
fiscalDefaults: {
|
||||
usesEquivalenceSurcharge: false,
|
||||
usesRetention: false,
|
||||
},
|
||||
languageCode: "es",
|
||||
currencyCode: "EUR",
|
||||
};
|
||||
|
||||
const resolveElement = (node: unknown): unknown => {
|
||||
if (Array.isArray(node)) {
|
||||
return node.map(resolveElement);
|
||||
}
|
||||
|
||||
if (!isValidElement(node)) {
|
||||
return node;
|
||||
}
|
||||
|
||||
if (typeof node.type === "function") {
|
||||
return resolveElement(node.type(node.props));
|
||||
}
|
||||
|
||||
return {
|
||||
type: node.type,
|
||||
props: {
|
||||
...node.props,
|
||||
children: resolveElement(node.props.children),
|
||||
},
|
||||
};
|
||||
};
|
||||
|
||||
const findButtons = (node: unknown): Array<Record<string, unknown>> => {
|
||||
if (Array.isArray(node)) {
|
||||
return node.flatMap(findButtons);
|
||||
}
|
||||
|
||||
if (!node || typeof node !== "object" || !("type" in node) || !("props" in node)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const element = node as { type: string; props: Record<string, unknown> };
|
||||
const children = findButtons(element.props.children);
|
||||
|
||||
if (element.type === "button") {
|
||||
return [element.props, ...children];
|
||||
}
|
||||
|
||||
return children;
|
||||
};
|
||||
|
||||
test("renderiza el shell común del panel de cliente sin duplicar el cierre", () => {
|
||||
const html = renderToStaticMarkup(
|
||||
<CustomerSummaryPanel
|
||||
customer={mockCustomer}
|
||||
mode="view"
|
||||
onOpenChange={() => undefined}
|
||||
open
|
||||
visibility="visible"
|
||||
/>
|
||||
);
|
||||
|
||||
assert.match(html, /Ficha de cliente/);
|
||||
assert.match(html, /Acme Corp/);
|
||||
assert.match(html, /Nueva proforma/);
|
||||
assert.match(html, /Editar cliente/);
|
||||
assert.equal((html.match(/Cerrar panel/g) ?? []).length, 1);
|
||||
assert.equal((html.match(/<h2/g) ?? []).length, 1);
|
||||
});
|
||||
|
||||
test("mantiene los callbacks de editar y cerrar", async () => {
|
||||
let didEdit = false;
|
||||
let wasClosed = false;
|
||||
|
||||
const resolved = resolveElement(
|
||||
<CustomerSummaryPanel
|
||||
customer={mockCustomer}
|
||||
mode="view"
|
||||
onEdit={() => {
|
||||
didEdit = true;
|
||||
}}
|
||||
onOpenChange={(open) => {
|
||||
wasClosed = open;
|
||||
}}
|
||||
open
|
||||
visibility="visible"
|
||||
/>
|
||||
);
|
||||
|
||||
const buttons = findButtons(resolved);
|
||||
const editButton = buttons.find((button) => button["aria-label"] === "Editar cliente");
|
||||
const closeButton = buttons.find((button) => button["aria-label"] === "Cerrar panel");
|
||||
|
||||
assert.ok(editButton);
|
||||
assert.ok(closeButton);
|
||||
|
||||
await editButton.onClick?.();
|
||||
await closeButton.onClick?.();
|
||||
|
||||
assert.equal(didEdit, true);
|
||||
assert.equal(wasClosed, false);
|
||||
});
|
||||
@ -1,4 +1,4 @@
|
||||
import { RightPanel } from "@repo/rdx-ui/components";
|
||||
import { RdxContextPanel, RightPanel } from "@repo/rdx-ui/components";
|
||||
import type { RightPanelMode, RightPanelVisibility } from "@repo/rdx-ui/hooks";
|
||||
import { 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>
|
||||
);
|
||||
};
|
||||
|
||||
@ -0,0 +1,113 @@
|
||||
import assert from "node:assert/strict";
|
||||
import test from "node:test";
|
||||
import { isValidElement } from "react";
|
||||
import { renderToStaticMarkup } from "react-dom/server";
|
||||
import type { CellContext } from "@tanstack/react-table";
|
||||
|
||||
import { RdxRowActions, type RdxRowActionsProps } from "@repo/rdx-ui/components";
|
||||
|
||||
import type { CustomerListRow } from "../../../../shared";
|
||||
import { useCustomersGridColumns } from "./use-customer-grid-columns";
|
||||
|
||||
const customerFixture: CustomerListRow = {
|
||||
id: "customer-1",
|
||||
companyId: "company-1",
|
||||
status: "active",
|
||||
reference: null,
|
||||
isCompany: true,
|
||||
name: "Acme Corp",
|
||||
tradeName: null,
|
||||
tin: "B12345678",
|
||||
address: {
|
||||
street: "Main street",
|
||||
city: "Madrid",
|
||||
province: "Madrid",
|
||||
postalCode: "28001",
|
||||
country: "ES",
|
||||
},
|
||||
contact: {
|
||||
primaryEmail: "hello@acme.test",
|
||||
primaryPhone: "910000000",
|
||||
primaryMobile: null,
|
||||
},
|
||||
};
|
||||
|
||||
const getRowActionsProps = (
|
||||
actionHandlers: Parameters<typeof useCustomersGridColumns>[0] = {}
|
||||
): RdxRowActionsProps => {
|
||||
let cellNode: unknown = null;
|
||||
|
||||
const Harness = () => {
|
||||
const columns = useCustomersGridColumns(actionHandlers);
|
||||
const actionsColumn = columns.find((column) => column.id === "actions");
|
||||
|
||||
cellNode = actionsColumn?.cell?.({
|
||||
row: { original: customerFixture },
|
||||
} as unknown as CellContext<CustomerListRow, unknown>);
|
||||
|
||||
return null;
|
||||
};
|
||||
|
||||
renderToStaticMarkup(<Harness />);
|
||||
|
||||
if (!isValidElement(cellNode)) {
|
||||
throw new Error("Expected RdxRowActions cell");
|
||||
}
|
||||
|
||||
assert.equal(cellNode.type, RdxRowActions);
|
||||
|
||||
return cellNode.props as RdxRowActionsProps;
|
||||
};
|
||||
|
||||
test("expone ver y editar como acciones visibles cuando existen callbacks reales", () => {
|
||||
const props = getRowActionsProps({
|
||||
onEditClick: () => undefined,
|
||||
onViewClick: () => undefined,
|
||||
});
|
||||
|
||||
assert.deepEqual(
|
||||
props.primaryActions?.map((action) => action.id),
|
||||
["view", "edit"]
|
||||
);
|
||||
assert.equal(props.overflowLabel, "list.actions.more");
|
||||
});
|
||||
|
||||
test("envía eliminar al overflow y corrige el bug cuando no existe handler", () => {
|
||||
const withoutDelete = getRowActionsProps({
|
||||
onEditClick: () => undefined,
|
||||
});
|
||||
|
||||
assert.deepEqual(withoutDelete.overflowActions ?? [], []);
|
||||
|
||||
const withDelete = getRowActionsProps({
|
||||
onDeleteClick: () => undefined,
|
||||
onEditClick: () => undefined,
|
||||
});
|
||||
|
||||
assert.deepEqual(
|
||||
withDelete.overflowActions?.map((action) => action.id),
|
||||
["delete"]
|
||||
);
|
||||
assert.equal(withDelete.overflowActions?.[0]?.destructive, true);
|
||||
});
|
||||
|
||||
test("preserva los callbacks de edición y borrado", () => {
|
||||
let didEdit = false;
|
||||
let didDelete = false;
|
||||
|
||||
const props = getRowActionsProps({
|
||||
onDeleteClick: () => {
|
||||
didDelete = true;
|
||||
},
|
||||
onEditClick: () => {
|
||||
didEdit = true;
|
||||
},
|
||||
onViewClick: () => undefined,
|
||||
});
|
||||
|
||||
props.primaryActions?.find((action) => action.id === "edit")?.onSelect();
|
||||
props.overflowActions?.find((action) => action.id === "delete")?.onSelect();
|
||||
|
||||
assert.equal(didEdit, true);
|
||||
assert.equal(didDelete, true);
|
||||
});
|
||||
@ -1,15 +1,12 @@
|
||||
import { DataTableColumnHeader, InitialsAvatar } from "@repo/rdx-ui/components";
|
||||
import {
|
||||
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}
|
||||
/>
|
||||
);
|
||||
},
|
||||
},
|
||||
|
||||
@ -0,0 +1,125 @@
|
||||
import assert from "node:assert/strict";
|
||||
import test from "node:test";
|
||||
import { isValidElement } from "react";
|
||||
import { renderToStaticMarkup } from "react-dom/server";
|
||||
|
||||
import { ListCustomersPageView } from "./list-customers-page-view";
|
||||
|
||||
const resolveElement = (node: unknown): unknown => {
|
||||
if (Array.isArray(node)) {
|
||||
return node.map(resolveElement);
|
||||
}
|
||||
|
||||
if (!isValidElement(node)) {
|
||||
return node;
|
||||
}
|
||||
|
||||
if (typeof node.type === "function") {
|
||||
return resolveElement(node.type(node.props));
|
||||
}
|
||||
|
||||
return {
|
||||
type: node.type,
|
||||
props: {
|
||||
...node.props,
|
||||
children: resolveElement(node.props.children),
|
||||
},
|
||||
};
|
||||
};
|
||||
|
||||
const findFirstElementByType = (
|
||||
node: unknown,
|
||||
type: string
|
||||
): { type: string; props: Record<string, unknown> } | null => {
|
||||
if (Array.isArray(node)) {
|
||||
for (const child of node) {
|
||||
const found = findFirstElementByType(child, type);
|
||||
|
||||
if (found) {
|
||||
return found;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
if (!node || typeof node !== "object" || !("type" in node) || !("props" in node)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const element = node as { type: string; props: Record<string, unknown> };
|
||||
|
||||
if (element.type === type) {
|
||||
return element;
|
||||
}
|
||||
|
||||
return findFirstElementByType(element.props.children, type);
|
||||
};
|
||||
|
||||
test("renderiza título, descripción, CTA principal y grid del listado de clientes", () => {
|
||||
const html = renderToStaticMarkup(
|
||||
<ListCustomersPageView
|
||||
content={<div>Customers grid content</div>}
|
||||
description="Gestiona y consulta la base de clientes."
|
||||
primaryAction={<button type="button">Nuevo cliente</button>}
|
||||
title="Clientes"
|
||||
/>
|
||||
);
|
||||
|
||||
assert.match(html, /<h1[^>]*>Clientes<\/h1>/);
|
||||
assert.match(html, /Gestiona y consulta la base de clientes/);
|
||||
assert.match(html, /Nuevo cliente/);
|
||||
assert.match(html, /Customers grid content/);
|
||||
});
|
||||
|
||||
test("mantiene el error inline y el panel contextual cuando corresponde", () => {
|
||||
const html = renderToStaticMarkup(
|
||||
<ListCustomersPageView
|
||||
content={
|
||||
<div>
|
||||
<div>Customers grid content</div>
|
||||
<aside>Customer summary panel open</aside>
|
||||
</div>
|
||||
}
|
||||
refreshError={{
|
||||
title: "Error de actualización",
|
||||
message: "No se pudo refrescar la lista",
|
||||
}}
|
||||
title="Clientes"
|
||||
/>
|
||||
);
|
||||
|
||||
assert.match(html, /Error de actualización/);
|
||||
assert.match(html, /No se pudo refrescar la lista/);
|
||||
assert.match(html, /Customer summary panel open/);
|
||||
});
|
||||
|
||||
test("preserva el callback de creación", async () => {
|
||||
let didCreate = false;
|
||||
|
||||
const resolved = resolveElement(
|
||||
<ListCustomersPageView
|
||||
content={<div>Customers grid content</div>}
|
||||
primaryAction={
|
||||
<button
|
||||
onClick={() => {
|
||||
didCreate = true;
|
||||
}}
|
||||
type="button"
|
||||
>
|
||||
Nuevo cliente
|
||||
</button>
|
||||
}
|
||||
title="Clientes"
|
||||
/>
|
||||
);
|
||||
|
||||
const button = findFirstElementByType(resolved, "button");
|
||||
|
||||
assert.ok(button);
|
||||
assert.equal(typeof button.props.onClick, "function");
|
||||
|
||||
await button.props.onClick?.();
|
||||
|
||||
assert.equal(didCreate, true);
|
||||
});
|
||||
@ -0,0 +1,51 @@
|
||||
import { ErrorAlert } from "@erp/core/components";
|
||||
import {
|
||||
RdxPageActions,
|
||||
RdxPageContainer,
|
||||
RdxPageHeader,
|
||||
RdxPageStack,
|
||||
} from "@repo/rdx-ui/components";
|
||||
import type { ReactNode } from "react";
|
||||
|
||||
interface RefreshErrorViewModel {
|
||||
title: string;
|
||||
message: string;
|
||||
}
|
||||
|
||||
export interface ListCustomersPageViewProps {
|
||||
title: ReactNode;
|
||||
description?: ReactNode;
|
||||
primaryAction?: ReactNode;
|
||||
refreshError?: RefreshErrorViewModel | null;
|
||||
content: ReactNode;
|
||||
dialogs?: ReactNode;
|
||||
}
|
||||
|
||||
export const ListCustomersPageView = ({
|
||||
title,
|
||||
description,
|
||||
primaryAction,
|
||||
refreshError,
|
||||
content,
|
||||
dialogs,
|
||||
}: ListCustomersPageViewProps) => {
|
||||
return (
|
||||
<RdxPageContainer width="full">
|
||||
<RdxPageStack spacing="default">
|
||||
<RdxPageHeader
|
||||
actions={primaryAction ? <RdxPageActions primary={primaryAction} /> : undefined}
|
||||
description={description}
|
||||
title={title}
|
||||
/>
|
||||
|
||||
{refreshError ? (
|
||||
<ErrorAlert message={refreshError.message} title={refreshError.title} />
|
||||
) : null}
|
||||
|
||||
{content}
|
||||
</RdxPageStack>
|
||||
|
||||
{dialogs}
|
||||
</RdxPageContainer>
|
||||
);
|
||||
};
|
||||
@ -1,5 +1,6 @@
|
||||
import { ErrorAlert, PageErrorState, PageHeader, SimpleSearchInput } from "@erp/core/components";
|
||||
import { PageErrorState, SimpleSearchInput } from "@erp/core/components";
|
||||
import { useReturnToNavigation } from "@erp/core/hooks";
|
||||
import { 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")}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
@ -0,0 +1,45 @@
|
||||
import assert from "node:assert/strict";
|
||||
import test from "node:test";
|
||||
import { renderToStaticMarkup } from "react-dom/server";
|
||||
import { FormProvider, useForm } from "react-hook-form";
|
||||
|
||||
import type { CustomerUpdateForm } from "../../entities";
|
||||
import { buildCustomerUpdateDefault } from "../../utils";
|
||||
import { CustomerUpdateEditorForm } from "./customer-edit-form";
|
||||
|
||||
const renderForm = () => {
|
||||
const Harness = () => {
|
||||
const form = useForm<CustomerUpdateForm>({
|
||||
defaultValues: buildCustomerUpdateDefault(),
|
||||
});
|
||||
|
||||
return (
|
||||
<FormProvider {...form}>
|
||||
<CustomerUpdateEditorForm
|
||||
fiscalCtrl={{
|
||||
paymentMethodOptions: [],
|
||||
paymentTermOptions: [],
|
||||
taxRegimeOptions: [],
|
||||
}}
|
||||
formId="customer-update-form"
|
||||
isSubmitting={false}
|
||||
onReset={() => undefined}
|
||||
onSubmit={(event) => {
|
||||
event.preventDefault();
|
||||
}}
|
||||
/>
|
||||
</FormProvider>
|
||||
);
|
||||
};
|
||||
|
||||
return renderToStaticMarkup(<Harness />);
|
||||
};
|
||||
|
||||
test("renderiza el formulario con id y secciones estructurales del editor simple", () => {
|
||||
const html = renderForm();
|
||||
|
||||
assert.match(html, /id="customer-update-form"/);
|
||||
assert.equal((html.match(/<section/g) ?? []).length, 5);
|
||||
assert.match(html, /form_groups\.basic_info\.title|Información básica/i);
|
||||
assert.match(html, /form_groups\.address\.title|Dirección/i);
|
||||
});
|
||||
@ -1,5 +1,6 @@
|
||||
import { preventEnterKeySubmitForm } from "@repo/rdx-ui/helpers";
|
||||
import { 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>
|
||||
);
|
||||
};
|
||||
|
||||
@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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,
|
||||
|
||||
101
packages/rdx-ui/docs/contributing-ui.md
Normal file
101
packages/rdx-ui/docs/contributing-ui.md
Normal file
@ -0,0 +1,101 @@
|
||||
# Contributing UI
|
||||
|
||||
## Reglas base
|
||||
|
||||
- todos los componentes públicos propios deben usar prefijo `Rdx`;
|
||||
- el código y los identificadores deben estar en inglés;
|
||||
- la documentación y los comentarios deben estar en español;
|
||||
- usar TypeScript estricto;
|
||||
- usar imports de tipos explícitos en cabecera;
|
||||
- no usar tipos inline tipo `import("react").ReactNode`.
|
||||
|
||||
## Responsabilidad de `packages/rdx-ui`
|
||||
|
||||
Los componentes de `rdx-ui`:
|
||||
|
||||
- no conocen React Query;
|
||||
- no conocen routing;
|
||||
- no conocen DTOs;
|
||||
- no conocen entities;
|
||||
- no conocen controllers funcionales;
|
||||
- no contienen lógica de negocio.
|
||||
|
||||
Deben recibir props ya preparadas por la capa consumidora.
|
||||
|
||||
## Tema y color
|
||||
|
||||
- todo debe funcionar en `light` y `dark` desde el inicio;
|
||||
- usar tokens semánticos;
|
||||
- no introducir colores físicos sin justificación fuerte y documentada;
|
||||
- no usar el color como único indicador de estado.
|
||||
|
||||
## Accesibilidad
|
||||
|
||||
- foco visible obligatorio;
|
||||
- botones de icono con nombre accesible;
|
||||
- no crear `div` clicables;
|
||||
- mantener orden DOM coherente;
|
||||
- usar controles reales proporcionados por el consumidor;
|
||||
- evitar wrappers vacíos que compliquen la semántica.
|
||||
|
||||
## API pública
|
||||
|
||||
- mantener una API pequeña y explícita;
|
||||
- no crear abstracciones genéricas sin repetición real;
|
||||
- `className` es un escape controlado, no la API principal;
|
||||
- no usar `transition-all` en nuevos componentes salvo caso excepcional documentado.
|
||||
|
||||
## Composición de paneles
|
||||
|
||||
- no duplicar sombras entre una primitive y su composición;
|
||||
- no duplicar el control de cierre entre capas;
|
||||
- las acciones de contexto deben llegar ya construidas desde el consumidor;
|
||||
- los labels accesibles de acciones de dominio pertenecen al consumidor;
|
||||
- una primitive reutilizable puede resolver superficie e infraestructura, mientras la composición resuelve jerarquía visual y accesibilidad contextual.
|
||||
|
||||
## Acciones de fila
|
||||
|
||||
- `RdxRowActions` solo renderiza acciones primarias y overflow;
|
||||
- el consumidor decide qué acciones existen y en qué orden aparecen;
|
||||
- las acciones destructivas deben ir en overflow salvo excepción justificada por código real;
|
||||
- `disabledReason` pertenece al consumidor y debe ser comprensible sin conocimiento de dominio en `rdx-ui`;
|
||||
- no mover permisos, navegación, dialogs ni entidades dentro de `packages/rdx-ui`;
|
||||
- los icon buttons deben mantener `aria-label` y tooltip accesible.
|
||||
|
||||
## Editor simple
|
||||
|
||||
- `RdxEditorSection` con `variant="plain"` es el patrón por defecto del editor simple;
|
||||
- usar una única superficie editable cuando sea útil separar claramente la página del área de formulario;
|
||||
- no envolver cada sección en `Card` por defecto;
|
||||
- no duplicar padding entre shell, layout y superficie editable;
|
||||
- `RdxFormGrid` debe concentrar la retícula del editor sin acoplarse a RHF;
|
||||
- las diferencias entre create y update no justifican nuevos componentes si solo cambian defaults o wiring.
|
||||
|
||||
## Editor complejo
|
||||
|
||||
- `RdxEditorPageLayout` debe reservar el `aside` para resumen, metadata o contexto de apoyo;
|
||||
- no mover campos editables al `aside` solo para equilibrar visualmente columnas;
|
||||
- en desktop el layout objetivo del editor complejo es `minmax(0, 1fr) ~22rem`;
|
||||
- el `aside` debe apilarse debajo del `main` cuando no haya ancho suficiente;
|
||||
- si no existe `aside`, no debe reservarse hueco lateral;
|
||||
- mantener una única superficie principal para el formulario, también en editores complejos;
|
||||
- `RdxEditorSection` puede usar `variant="panel"` dentro del `aside` cuando el bloque necesite superficie independiente;
|
||||
- `RdxFormGrid` debe quedarse en grupos de campos y no envolver tablas, line editors ni layouts funcionales especializados;
|
||||
- no mover los totales al `aside` por defecto;
|
||||
- si el editor usa footer sticky inferior, no duplicar `Guardar` y `Cancelar` completos en el header;
|
||||
- `RdxEditorStickyFooter` no conoce RHF, submit, routing ni dominio; solo compone superficie, orden, separación y responsive;
|
||||
- la región `leading` del footer puede alojar resumen o contexto, pero ese contenido sigue perteneciendo al dominio consumidor;
|
||||
- coordinar cualquier `sticky` lateral con el offset del header para evitar clipping y foco oculto.
|
||||
|
||||
## Sincronización obligatoria
|
||||
|
||||
Todo cambio futuro que afecte a la API pública o al comportamiento visual debe actualizar en el mismo cambio:
|
||||
|
||||
```txt
|
||||
1. implementación
|
||||
2. documentación Markdown
|
||||
3. página interactiva UI Kit
|
||||
4. tests aplicables
|
||||
```
|
||||
|
||||
Un cambio queda incompleto si cualquiera de esas cuatro piezas queda desincronizada.
|
||||
134
packages/rdx-ui/docs/foundations.md
Normal file
134
packages/rdx-ui/docs/foundations.md
Normal file
@ -0,0 +1,134 @@
|
||||
# Foundations Rdx
|
||||
|
||||
## Estado
|
||||
|
||||
Este documento define las foundations estructurales iniciales del sistema visual Rdx.
|
||||
|
||||
El documento canónico de contexto global es:
|
||||
|
||||
- [visual-system-design.md](./visual-system-design.md)
|
||||
|
||||
## Espaciado de página
|
||||
|
||||
```txt
|
||||
mobile:
|
||||
padding 4
|
||||
|
||||
desktop:
|
||||
padding 6
|
||||
```
|
||||
|
||||
Referencia inicial:
|
||||
|
||||
```txt
|
||||
px-4 py-4
|
||||
md:px-6 md:py-6
|
||||
```
|
||||
|
||||
Se aplica en `RdxPageContainer`.
|
||||
|
||||
Uso correcto:
|
||||
|
||||
- usarlo como padding principal de página;
|
||||
- no anidarlo dentro de otro contenedor que ya aporte el mismo padding estructural;
|
||||
- si el shell ya aplica padding equivalente, el consumidor debe evitar duplicarlo.
|
||||
|
||||
## Ritmo vertical
|
||||
|
||||
```txt
|
||||
compact:
|
||||
gap 4
|
||||
|
||||
default:
|
||||
gap 6
|
||||
|
||||
relaxed:
|
||||
gap 8
|
||||
```
|
||||
|
||||
No se fija `gap-12` como foundation general.
|
||||
|
||||
Se aplica en `RdxPageStack`.
|
||||
|
||||
## Anchuras
|
||||
|
||||
```txt
|
||||
full:
|
||||
w-full
|
||||
|
||||
wide:
|
||||
max-w-screen-2xl mx-auto
|
||||
|
||||
content:
|
||||
max-w-5xl mx-auto
|
||||
```
|
||||
|
||||
Uso recomendado:
|
||||
|
||||
- `full`: listados, tablas, editores complejos, pantallas documentales;
|
||||
- `wide`: detalles o páginas amplias centradas;
|
||||
- `content`: editores simples y formularios de una columna lógica.
|
||||
|
||||
## Superficies
|
||||
|
||||
```txt
|
||||
contenido principal:
|
||||
plano
|
||||
|
||||
secciones:
|
||||
separadas por espacio o divisores
|
||||
|
||||
aside:
|
||||
puede usar paneles
|
||||
|
||||
tabla/editor de líneas:
|
||||
puede usar superficie independiente
|
||||
|
||||
card:
|
||||
excepción, no default
|
||||
```
|
||||
|
||||
Reglas:
|
||||
|
||||
- no usar card como solución por defecto para toda la página;
|
||||
- evitar card dentro de card;
|
||||
- preferir espacio, jerarquía tipográfica y divisores.
|
||||
|
||||
## Sombras
|
||||
|
||||
```txt
|
||||
página:
|
||||
sin sombra
|
||||
|
||||
secciones:
|
||||
sin sombra
|
||||
|
||||
panel contextual:
|
||||
sombra suave opcional
|
||||
|
||||
sticky header/footer:
|
||||
preferir borde
|
||||
usar sombra mínima solo cuando sea necesaria
|
||||
```
|
||||
|
||||
## Tema
|
||||
|
||||
Uso obligatorio de tokens semánticos:
|
||||
|
||||
```txt
|
||||
bg-background
|
||||
text-foreground
|
||||
bg-muted
|
||||
text-muted-foreground
|
||||
border-border
|
||||
bg-primary
|
||||
text-primary-foreground
|
||||
bg-destructive
|
||||
text-destructive-foreground
|
||||
```
|
||||
|
||||
Reglas:
|
||||
|
||||
- no introducir colores físicos para resolver superficies o estados;
|
||||
- no crear una nueva paleta de `success`, `warning` o `info` en esta fase si no existe convención central consolidada;
|
||||
- validar cualquier foundation nueva en `light` y `dark`.
|
||||
247
packages/rdx-ui/docs/page-layouts.md
Normal file
247
packages/rdx-ui/docs/page-layouts.md
Normal file
@ -0,0 +1,247 @@
|
||||
# Page Layouts Rdx
|
||||
|
||||
## Estado
|
||||
|
||||
Este documento describe los patrones conceptuales de página aprobados para adopción progresiva.
|
||||
|
||||
El documento canónico de contexto global es:
|
||||
|
||||
- [visual-system-design.md](./visual-system-design.md)
|
||||
|
||||
## Listado
|
||||
|
||||
```txt
|
||||
RdxPageContainer
|
||||
└── RdxPageStack
|
||||
├── RdxPageHeader
|
||||
├── notices opcionales
|
||||
└── contenido del listado
|
||||
```
|
||||
|
||||
Reglas:
|
||||
|
||||
- anchura por defecto `full`;
|
||||
- acciones principales en cabecera;
|
||||
- acciones de fila frecuentes visibles y acciones destructivas en overflow mediante `RdxRowActions`;
|
||||
- panel contextual solo cuando aporte contexto real;
|
||||
- la composición del listado no debe acoplarse a React Query, routing ni dominio.
|
||||
|
||||
Ejemplo de integración real validado en `proformas/list` y `customers/list`:
|
||||
|
||||
```tsx
|
||||
<RdxPageContainer width="full">
|
||||
<RdxPageStack spacing="default">
|
||||
<RdxPageHeader
|
||||
title={title}
|
||||
description={description}
|
||||
actions={<RdxPageActions primary={primaryAction} />}
|
||||
/>
|
||||
|
||||
{notice}
|
||||
|
||||
{content}
|
||||
</RdxPageStack>
|
||||
|
||||
{dialogs}
|
||||
</RdxPageContainer>
|
||||
```
|
||||
|
||||
Aclaraciones:
|
||||
|
||||
- `RdxPageContainer` resuelve el padding exterior y la anchura de página;
|
||||
- `RdxPageStack` resuelve el ritmo vertical principal entre cabecera, aviso y contenido;
|
||||
- `RdxPageHeader` no conoce navegación ni callbacks de React Router;
|
||||
- la tabla, los filtros, los panels, los dialogs y los controllers permanecen fuera de las foundations;
|
||||
- `RdxRowActions` puede vivir dentro de las columnas de tabla, pero recibe acciones ya decididas por el consumidor;
|
||||
- en ambos pilotos el panel contextual sigue viviendo en composición local mediante `ResizablePanelGroup`;
|
||||
- no documentar todavía `RdxListPageLayout` ni `RdxResponsivePageGrid` mientras el resize y la apertura del aside dependan del caso de uso.
|
||||
|
||||
### Evidencia de pilotos de listado
|
||||
|
||||
```txt
|
||||
DECISIÓN_CONFIRMADA
|
||||
Container + Stack + Header + Actions cubren la capa exterior del listado.
|
||||
|
||||
DECISIÓN_PROVISIONAL
|
||||
La Page puede extraer una vista pura si mejora el test y reduce wiring.
|
||||
|
||||
NO_ABSTRAER_TODAVÍA
|
||||
main + aside redimensionable
|
||||
shell específico del panel contextual
|
||||
toolbars
|
||||
```
|
||||
|
||||
## Listado con panel contextual
|
||||
|
||||
```txt
|
||||
ResizablePanelGroup
|
||||
├── main
|
||||
└── RightPanel
|
||||
└── RdxContextPanel
|
||||
```
|
||||
|
||||
Reglas:
|
||||
|
||||
- `ResizablePanelGroup` gestiona la geometría, el resize y la persistencia local del split;
|
||||
- `RightPanel` gestiona la superficie lateral, la altura y el overflow base;
|
||||
- `RdxContextPanel` gestiona cabecera, cierre, body y footer visual del panel;
|
||||
- el contenido interno del panel sigue siendo específico del dominio;
|
||||
- no documentar todavía un layout genérico de listado a partir de este patrón.
|
||||
|
||||
## Editor simple
|
||||
|
||||
```txt
|
||||
scroll container
|
||||
└── RdxEditorPageLayout complexity="simple"
|
||||
└── form stack
|
||||
├── RdxEditorSection
|
||||
├── RdxEditorSection
|
||||
└── ...
|
||||
```
|
||||
|
||||
Reglas:
|
||||
|
||||
- `RdxEditorPageLayout` resuelve padding interior, ancho centrado y `min-w-0`;
|
||||
- el editor simple puede apoyarse en una única superficie contenedora para diferenciar la página del área editable, sin volver a cards por sección;
|
||||
- `RdxEditorSection` expresa jerarquía por defecto con spacing y divisor inferior;
|
||||
- `RdxFormGrid` mantiene la retícula del formulario sin conocer RHF ni dominio;
|
||||
- priorizar lectura y velocidad de escaneo;
|
||||
- usar secciones planas con espacio o divisores;
|
||||
- reservar sticky footer solo cuando el scroll lo justifique.
|
||||
|
||||
Ejemplo validado en `customers/update` y `customers/create`:
|
||||
|
||||
```tsx
|
||||
<div className="flex-1 overflow-y-auto">
|
||||
<RdxEditorPageLayout complexity="simple">
|
||||
<div className="flex min-w-0 flex-col gap-6">
|
||||
{notice}
|
||||
|
||||
<form className="flex min-w-0 flex-col gap-6">
|
||||
<RdxEditorSection title={title} description={description}>
|
||||
<RdxFormGrid>{fields}</RdxFormGrid>
|
||||
</RdxEditorSection>
|
||||
</form>
|
||||
</div>
|
||||
</RdxEditorPageLayout>
|
||||
</div>
|
||||
```
|
||||
|
||||
Deuda registrada:
|
||||
|
||||
- `PageFormHeader` sigue siendo full-width mientras el contenido del editor queda centrado en `max-w-5xl`;
|
||||
- no modificar todavía el header legacy sin validación visual manual real.
|
||||
|
||||
Evidencia adicional de create/update:
|
||||
|
||||
```txt
|
||||
DECISIÓN_CONFIRMADA
|
||||
ambos flujos reutilizan el mismo layout, superficie y secciones.
|
||||
|
||||
ESPECÍFICO
|
||||
defaults iniciales
|
||||
wiring de submit
|
||||
feedback de error
|
||||
```
|
||||
|
||||
## Editor complejo
|
||||
|
||||
```txt
|
||||
shell
|
||||
├── PageFormHeader
|
||||
├── scroll container
|
||||
│ └── RdxEditorPageLayout complexity="complex"
|
||||
│ ├── main
|
||||
│ │ └── única superficie editable
|
||||
│ │ ├── RdxEditorSection
|
||||
│ │ ├── RdxEditorSection
|
||||
│ │ ├── bloque de líneas con layout propio
|
||||
│ │ └── ...
|
||||
│ └── aside
|
||||
│ └── controles secundarios o contexto de apoyo
|
||||
└── RdxEditorStickyFooter
|
||||
├── summary/context region
|
||||
└── action region
|
||||
```
|
||||
|
||||
Reglas:
|
||||
|
||||
- `RdxEditorPageLayout` resuelve padding exterior, `min-w-0` y la geometría `main + aside`;
|
||||
- el `main` debe alojar los campos editables y sus bloques funcionales;
|
||||
- el `aside` debe reservarse para metadata, configuración o contexto de apoyo ya existente en la pantalla;
|
||||
- el `aside` del editor complejo usa una geometría aproximada `minmax(0, 1fr) 22rem` en desktop y se apila debajo del `main` cuando no hay ancho suficiente;
|
||||
- no mover campos editables al `aside` solo por estética si eso empeora el flujo;
|
||||
- no mover los totales al `aside`;
|
||||
- los bloques con layout interno propio, como una tabla de líneas, no deben forzarse dentro de `RdxFormGrid`;
|
||||
- `RdxEditorStickyFooter` resuelve una única superficie sticky con una región contextual flexible y una región de acciones;
|
||||
- los totales siguen perteneciendo al dominio de Proformas aunque se rendericen dentro de la región contextual del footer;
|
||||
- `PageFormHeader` puede seguir existiendo, pero no debe duplicar `Guardar` y `Cancelar` cuando el footer sticky ya los contiene.
|
||||
|
||||
Ejemplo validado en `proformas/update`:
|
||||
|
||||
```tsx
|
||||
<div className="fixed inset-0 flex flex-col overflow-hidden">
|
||||
<PageFormHeader />
|
||||
|
||||
<div className="flex min-h-0 flex-1 flex-col bg-muted/20">
|
||||
<div className="min-h-0 flex-1 overflow-y-auto">
|
||||
{notice}
|
||||
|
||||
<RdxEditorPageLayout
|
||||
complexity="complex"
|
||||
aside={aside}
|
||||
>
|
||||
<div className="rounded-2xl border border-border bg-background px-4 py-4 shadow-xs md:px-6 md:py-6">
|
||||
<form className="flex min-w-0 flex-col gap-6">
|
||||
<RdxEditorSection title={documentTitle}>
|
||||
<RdxFormGrid>{documentFields}</RdxFormGrid>
|
||||
</RdxEditorSection>
|
||||
|
||||
{lineEditor}
|
||||
</form>
|
||||
</div>
|
||||
</RdxEditorPageLayout>
|
||||
</div>
|
||||
|
||||
<RdxEditorStickyFooter
|
||||
leading={<TotalsSummary />}
|
||||
secondary={cancelButton}
|
||||
primary={saveButton}
|
||||
status={status}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
```
|
||||
|
||||
Evidencia del piloto:
|
||||
|
||||
```txt
|
||||
DECISIÓN_CONFIRMADA
|
||||
main contiene la única superficie editable
|
||||
aside contiene bloques secundarios ya existentes del editor
|
||||
la sticky zone inferior pasa a una única superficie visual
|
||||
los totales permanecen como dominio de Proformas dentro de la región contextual
|
||||
las acciones principales comparten el mismo footer sin una segunda franja
|
||||
RdxFormGrid se mantiene solo para campos
|
||||
|
||||
DECISIÓN_PROVISIONAL
|
||||
visual exacta del header legacy frente al ancho full del editor complejo
|
||||
|
||||
NO_ABSTRAER_TODAVÍA
|
||||
layout específico del bloque de líneas
|
||||
layout específico del footer de totales
|
||||
```
|
||||
|
||||
## Detalle
|
||||
|
||||
```txt
|
||||
width="wide" o "full"
|
||||
main + aside opcional
|
||||
aside para metadata, actividad, resumen o acciones
|
||||
```
|
||||
|
||||
Reglas:
|
||||
|
||||
- mantener patrón consistente de label/value;
|
||||
- usar aside solo cuando el contenido realmente gane claridad;
|
||||
- no implementar todavía `RdxDetailPageLayout` en esta fase.
|
||||
1225
packages/rdx-ui/docs/visual-system-design.md
Normal file
1225
packages/rdx-ui/docs/visual-system-design.md
Normal file
File diff suppressed because it is too large
Load Diff
@ -6,6 +6,7 @@
|
||||
"sideEffects": false,
|
||||
"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"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
1
packages/rdx-ui/src/components/context-panel/index.ts
Normal file
1
packages/rdx-ui/src/components/context-panel/index.ts
Normal file
@ -0,0 +1 @@
|
||||
export * from "./rdx-context-panel.tsx";
|
||||
@ -0,0 +1,121 @@
|
||||
import assert from "node:assert/strict";
|
||||
import test from "node:test";
|
||||
import { isValidElement } from "react";
|
||||
import { renderToStaticMarkup } from "react-dom/server";
|
||||
|
||||
import { RdxContextPanel } from "./rdx-context-panel.tsx";
|
||||
|
||||
const resolveElement = (node: unknown): unknown => {
|
||||
if (Array.isArray(node)) {
|
||||
return node.map(resolveElement);
|
||||
}
|
||||
|
||||
if (!isValidElement(node)) {
|
||||
return node;
|
||||
}
|
||||
|
||||
if (typeof node.type === "function") {
|
||||
return resolveElement(node.type(node.props));
|
||||
}
|
||||
|
||||
return {
|
||||
type: node.type,
|
||||
props: {
|
||||
...node.props,
|
||||
children: resolveElement(node.props.children),
|
||||
},
|
||||
};
|
||||
};
|
||||
|
||||
const findElementsByType = (
|
||||
node: unknown,
|
||||
type: string
|
||||
): Array<{ type: string; props: Record<string, unknown> }> => {
|
||||
if (Array.isArray(node)) {
|
||||
return node.flatMap((child) => findElementsByType(child, type));
|
||||
}
|
||||
|
||||
if (!node || typeof node !== "object" || !("type" in node) || !("props" in node)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const element = node as { type: string; props: Record<string, unknown> };
|
||||
const children = findElementsByType(element.props.children, type);
|
||||
|
||||
if (element.type === type) {
|
||||
return [element, ...children];
|
||||
}
|
||||
|
||||
return children;
|
||||
};
|
||||
|
||||
test("renderiza título semántico, descripción, actions, children y footer", () => {
|
||||
const html = renderToStaticMarkup(
|
||||
<RdxContextPanel
|
||||
actions={<button type="button">Edit</button>}
|
||||
closeLabel="Cerrar panel"
|
||||
description="Context description"
|
||||
footer={<div>Footer content</div>}
|
||||
onClose={() => undefined}
|
||||
title="Context title"
|
||||
>
|
||||
<div>Panel body</div>
|
||||
</RdxContextPanel>
|
||||
);
|
||||
|
||||
assert.match(html, /<h2[^>]*>Context title<\/h2>/);
|
||||
assert.match(html, /Context description/);
|
||||
assert.match(html, /Edit/);
|
||||
assert.match(html, /Panel body/);
|
||||
assert.match(html, /Footer content/);
|
||||
assert.match(html, /Cerrar panel/);
|
||||
});
|
||||
|
||||
test("no renderiza regiones opcionales vacías y combina className", () => {
|
||||
const html = renderToStaticMarkup(
|
||||
<RdxContextPanel className="custom-panel" title="Only title">
|
||||
<div>Body</div>
|
||||
</RdxContextPanel>
|
||||
);
|
||||
|
||||
assert.match(html, /custom-panel/);
|
||||
assert.match(html, /<h2[^>]*>Only title<\/h2>/);
|
||||
assert.equal((html.match(/Cerrar panel/g) ?? []).length, 0);
|
||||
assert.equal((html.match(/<p/g) ?? []).length, 0);
|
||||
});
|
||||
|
||||
test("ejecuta el cierre y mantiene un único control de cerrar", async () => {
|
||||
let wasClosed = false;
|
||||
|
||||
const resolved = resolveElement(
|
||||
<RdxContextPanel
|
||||
closeLabel="Cerrar panel"
|
||||
onClose={() => {
|
||||
wasClosed = true;
|
||||
}}
|
||||
title="Context title"
|
||||
>
|
||||
<div>Body</div>
|
||||
</RdxContextPanel>
|
||||
);
|
||||
|
||||
const buttons = findElementsByType(resolved, "button");
|
||||
const closeButtons = buttons.filter((button) => button.props["aria-label"] === "Cerrar panel");
|
||||
|
||||
assert.equal(closeButtons.length, 1);
|
||||
|
||||
await closeButtons[0]?.props.onClick?.();
|
||||
|
||||
assert.equal(wasClosed, true);
|
||||
});
|
||||
|
||||
test("mantiene el orden DOM esperado entre header, body y footer", () => {
|
||||
const html = renderToStaticMarkup(
|
||||
<RdxContextPanel footer={<div>Footer content</div>} title="Context title">
|
||||
<div>Panel body</div>
|
||||
</RdxContextPanel>
|
||||
);
|
||||
|
||||
assert.ok(html.indexOf("Context title") < html.indexOf("Panel body"));
|
||||
assert.ok(html.indexOf("Panel body") < html.indexOf("Footer content"));
|
||||
});
|
||||
@ -0,0 +1,84 @@
|
||||
import { Button } from "@repo/shadcn-ui/components";
|
||||
import { cn } from "@repo/shadcn-ui/lib/utils";
|
||||
import { XIcon } from "lucide-react";
|
||||
import type { ComponentPropsWithoutRef, ReactNode } from "react";
|
||||
import { useId } from "react";
|
||||
|
||||
type RdxContextPanelClosableProps =
|
||||
| {
|
||||
onClose: () => void;
|
||||
closeLabel: string;
|
||||
}
|
||||
| {
|
||||
onClose?: undefined;
|
||||
closeLabel?: undefined;
|
||||
};
|
||||
|
||||
export type RdxContextPanelProps = Omit<
|
||||
ComponentPropsWithoutRef<"div">,
|
||||
"children" | "title"
|
||||
> &
|
||||
RdxContextPanelClosableProps & {
|
||||
title: ReactNode;
|
||||
description?: ReactNode;
|
||||
actions?: ReactNode;
|
||||
children: ReactNode;
|
||||
footer?: ReactNode;
|
||||
};
|
||||
|
||||
export const RdxContextPanel = ({
|
||||
title,
|
||||
description,
|
||||
actions,
|
||||
children,
|
||||
footer,
|
||||
onClose,
|
||||
closeLabel,
|
||||
className,
|
||||
...props
|
||||
}: RdxContextPanelProps) => {
|
||||
const titleId = useId();
|
||||
|
||||
return (
|
||||
<aside
|
||||
aria-labelledby={titleId}
|
||||
className={cn("flex h-full min-w-0 flex-col bg-background text-foreground", className)}
|
||||
{...props}
|
||||
>
|
||||
<div className="flex items-start justify-between gap-4 border-b border-border px-4 py-4">
|
||||
<div className="min-w-0 flex-1 space-y-1">
|
||||
<h2
|
||||
className="min-w-0 text-base font-semibold tracking-tight text-foreground"
|
||||
id={titleId}
|
||||
>
|
||||
{title}
|
||||
</h2>
|
||||
|
||||
{description ? <p className="text-sm text-muted-foreground">{description}</p> : null}
|
||||
</div>
|
||||
|
||||
{actions || onClose ? (
|
||||
<div className="flex shrink-0 items-start gap-1">
|
||||
{actions ? <div className="flex items-center gap-1">{actions}</div> : null}
|
||||
|
||||
{onClose && closeLabel ? (
|
||||
<Button
|
||||
aria-label={closeLabel}
|
||||
onClick={onClose}
|
||||
size="icon-sm"
|
||||
type="button"
|
||||
variant="ghost"
|
||||
>
|
||||
<XIcon className="size-4" />
|
||||
</Button>
|
||||
) : null}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
<div className="min-h-0 min-w-0 flex-1 overflow-auto">{children}</div>
|
||||
|
||||
{footer ? <div className="shrink-0 border-t border-border px-4 py-4">{footer}</div> : null}
|
||||
</aside>
|
||||
);
|
||||
};
|
||||
3
packages/rdx-ui/src/components/editor-page/index.ts
Normal file
3
packages/rdx-ui/src/components/editor-page/index.ts
Normal file
@ -0,0 +1,3 @@
|
||||
export * from "./rdx-editor-page-layout.tsx";
|
||||
export * from "./rdx-editor-section.tsx";
|
||||
export * from "./rdx-editor-sticky-footer.tsx";
|
||||
@ -0,0 +1,121 @@
|
||||
ximport assert from "node:assert/strict";
|
||||
import test from "node:test";
|
||||
import { renderToStaticMarkup } from "react-dom/server";
|
||||
|
||||
import { RdxFormGrid } from "../form/rdx-form-grid.tsx";
|
||||
import { RdxEditorPageLayout } from "./rdx-editor-page-layout.tsx";
|
||||
import { RdxEditorSection } from "./rdx-editor-section.tsx";
|
||||
import { RdxEditorStickyFooter } from "./rdx-editor-sticky-footer.tsx";
|
||||
|
||||
test("RdxEditorPageLayout aplica complejidad simple por defecto y renderiza children", () => {
|
||||
const html = renderToStaticMarkup(
|
||||
<RdxEditorPageLayout>
|
||||
<div>Editor content</div>
|
||||
</RdxEditorPageLayout>
|
||||
);
|
||||
|
||||
assert.match(html, /Editor content/);
|
||||
assert.match(html, /max-w-5xl/);
|
||||
assert.match(html, /px-4/);
|
||||
assert.match(html, /md:px-6/);
|
||||
});
|
||||
|
||||
test("RdxEditorPageLayout soporta aside opcional y complejidad complex", () => {
|
||||
const html = renderToStaticMarkup(
|
||||
<RdxEditorPageLayout aside={<div>Aside content</div>} complexity="complex">
|
||||
<div>Main content</div>
|
||||
</RdxEditorPageLayout>
|
||||
);
|
||||
|
||||
assert.match(html, /Main content/);
|
||||
assert.match(html, /Aside content/);
|
||||
assert.match(html, /xl:grid-cols-\[minmax\(0,1fr\)_22rem\]/);
|
||||
assert.doesNotMatch(html, /max-w-5xl/);
|
||||
});
|
||||
|
||||
test("RdxEditorPageLayout no reserva aside cuando no existe incluso en complex", () => {
|
||||
const html = renderToStaticMarkup(
|
||||
<RdxEditorPageLayout complexity="complex">
|
||||
<div>Main content</div>
|
||||
</RdxEditorPageLayout>
|
||||
);
|
||||
|
||||
assert.match(html, /Main content/);
|
||||
assert.doesNotMatch(html, /<aside/);
|
||||
assert.doesNotMatch(html, /grid-cols-\[/);
|
||||
});
|
||||
|
||||
test("RdxEditorSection plain renderiza header semántico y contenido sin card", () => {
|
||||
const html = renderToStaticMarkup(
|
||||
<RdxEditorSection
|
||||
actions={<button type="button">Action</button>}
|
||||
description="Section description"
|
||||
icon={<span>Icon</span>}
|
||||
title="Section title"
|
||||
>
|
||||
<div>Section body</div>
|
||||
</RdxEditorSection>
|
||||
);
|
||||
|
||||
assert.match(html, /<h2[^>]*>Section title<\/h2>/);
|
||||
assert.match(html, /Section description/);
|
||||
assert.match(html, /Section body/);
|
||||
assert.match(html, /Action/);
|
||||
assert.doesNotMatch(html, /shadow/);
|
||||
});
|
||||
|
||||
test("RdxEditorSection panel conserva superficie independiente", () => {
|
||||
const html = renderToStaticMarkup(
|
||||
<RdxEditorSection title="Panel section" variant="panel">
|
||||
<div>Panel body</div>
|
||||
</RdxEditorSection>
|
||||
);
|
||||
|
||||
assert.match(html, /rounded-xl/);
|
||||
assert.match(html, /border-border/);
|
||||
assert.match(html, /Panel body/);
|
||||
});
|
||||
|
||||
test("RdxFormGrid mantiene la retícula base y combina className", () => {
|
||||
const html = renderToStaticMarkup(
|
||||
<RdxFormGrid className="custom-grid">
|
||||
<div>Field 1</div>
|
||||
<div>Field 2</div>
|
||||
</RdxFormGrid>
|
||||
);
|
||||
|
||||
assert.match(html, /grid-cols-1/);
|
||||
assert.match(html, /gap-x-6/);
|
||||
assert.match(html, /gap-y-5/);
|
||||
assert.match(html, /md:grid-cols-12/);
|
||||
assert.match(html, /custom-grid/);
|
||||
});
|
||||
|
||||
test("RdxEditorStickyFooter renderiza regiones presentes sin wrappers vacíos", () => {
|
||||
const html = renderToStaticMarkup(
|
||||
<RdxEditorStickyFooter
|
||||
className="custom-sticky"
|
||||
leading={<div>Totals summary</div>}
|
||||
primary={<button type="submit">Save</button>}
|
||||
secondary={<button type="button">Cancel</button>}
|
||||
status="Dirty changes"
|
||||
/>
|
||||
);
|
||||
|
||||
assert.match(html, /Totals summary/);
|
||||
assert.match(html, /Dirty changes/);
|
||||
assert.match(html, /Cancel/);
|
||||
assert.match(html, /Save/);
|
||||
assert.match(html, /custom-sticky/);
|
||||
assert.ok(html.indexOf("Totals summary") < html.indexOf("Dirty changes"));
|
||||
assert.ok(html.indexOf("Dirty changes") < html.indexOf("Cancel"));
|
||||
assert.ok(html.indexOf("Cancel") < html.indexOf("Save"));
|
||||
});
|
||||
|
||||
test("RdxEditorStickyFooter omite regiones opcionales vacías", () => {
|
||||
const html = renderToStaticMarkup(<RdxEditorStickyFooter primary={<button type="submit">Save</button>} />);
|
||||
|
||||
assert.match(html, /Save/);
|
||||
assert.doesNotMatch(html, /text-muted-foreground/);
|
||||
assert.equal((html.match(/type="submit"/g) ?? []).length, 1);
|
||||
});
|
||||
@ -0,0 +1,44 @@
|
||||
import { cn } from "@repo/shadcn-ui/lib/utils";
|
||||
import type { ComponentPropsWithoutRef, ReactNode } from "react";
|
||||
|
||||
export type RdxEditorComplexity = "simple" | "complex";
|
||||
|
||||
export interface RdxEditorPageLayoutProps
|
||||
extends Omit<ComponentPropsWithoutRef<"div">, "children"> {
|
||||
children: ReactNode;
|
||||
aside?: ReactNode;
|
||||
complexity?: RdxEditorComplexity;
|
||||
}
|
||||
|
||||
const CONTENT_WIDTH_CLASSNAME: Record<RdxEditorComplexity, string> = {
|
||||
simple: "mx-auto w-full max-w-5xl",
|
||||
complex: "w-full",
|
||||
};
|
||||
|
||||
const ASIDE_GRID_CLASSNAME: Record<RdxEditorComplexity, string> = {
|
||||
simple: "xl:grid-cols-[minmax(0,1fr)_280px]",
|
||||
complex: "xl:grid-cols-[minmax(0,1fr)_22rem]",
|
||||
};
|
||||
|
||||
export const RdxEditorPageLayout = ({
|
||||
children,
|
||||
aside,
|
||||
complexity = "simple",
|
||||
className,
|
||||
...props
|
||||
}: RdxEditorPageLayoutProps) => {
|
||||
const contentClassName = CONTENT_WIDTH_CLASSNAME[complexity];
|
||||
|
||||
return (
|
||||
<div className={cn("min-w-0 w-full px-4 py-4 md:px-6 md:py-6", className)} {...props}>
|
||||
{aside ? (
|
||||
<div className={cn(contentClassName, "grid min-w-0 gap-6", ASIDE_GRID_CLASSNAME[complexity])}>
|
||||
<div className="min-w-0">{children}</div>
|
||||
<aside className="min-w-0">{aside}</aside>
|
||||
</div>
|
||||
) : (
|
||||
<div className={cn("min-w-0", contentClassName)}>{children}</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@ -0,0 +1,73 @@
|
||||
import { cn } from "@repo/shadcn-ui/lib/utils";
|
||||
import type { ComponentPropsWithoutRef, ReactNode } from "react";
|
||||
import { useId } from "react";
|
||||
|
||||
export type RdxEditorSectionVariant = "plain" | "panel";
|
||||
|
||||
export interface RdxEditorSectionProps
|
||||
extends Omit<ComponentPropsWithoutRef<"section">, "children" | "title"> {
|
||||
title: ReactNode;
|
||||
description?: ReactNode;
|
||||
icon?: ReactNode;
|
||||
actions?: ReactNode;
|
||||
children: ReactNode;
|
||||
variant?: RdxEditorSectionVariant;
|
||||
}
|
||||
|
||||
const VARIANT_CLASSNAME: Record<RdxEditorSectionVariant, string> = {
|
||||
plain: "min-w-0 border-b border-border pb-6 last:border-b-0 last:pb-0",
|
||||
panel: "min-w-0 rounded-xl border border-border bg-background p-4 shadow-xs sm:p-5",
|
||||
};
|
||||
|
||||
export const RdxEditorSection = ({
|
||||
title,
|
||||
description,
|
||||
icon,
|
||||
actions,
|
||||
children,
|
||||
variant = "plain",
|
||||
className,
|
||||
...props
|
||||
}: RdxEditorSectionProps) => {
|
||||
const titleId = useId();
|
||||
const hasHeader = Boolean(title || description || icon || actions);
|
||||
|
||||
return (
|
||||
<section
|
||||
aria-labelledby={titleId}
|
||||
className={cn(VARIANT_CLASSNAME[variant], className)}
|
||||
{...props}
|
||||
>
|
||||
{hasHeader ? (
|
||||
<div className={cn("flex flex-col gap-4", variant === "plain" ? "pb-4" : "pb-5")}>
|
||||
<div className="flex items-start justify-between gap-4">
|
||||
<div className="flex min-w-0 items-start gap-3">
|
||||
{icon ? (
|
||||
<div className="flex size-6 shrink-0 items-center justify-center text-primary">
|
||||
{icon}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<div className="min-w-0 space-y-1">
|
||||
<h2
|
||||
className="min-w-0 text-base font-semibold tracking-tight text-foreground"
|
||||
id={titleId}
|
||||
>
|
||||
{title}
|
||||
</h2>
|
||||
|
||||
{description ? (
|
||||
<p className="text-sm text-muted-foreground">{description}</p>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{actions ? <div className="flex shrink-0 items-center gap-2">{actions}</div> : null}
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<div className="min-w-0">{children}</div>
|
||||
</section>
|
||||
);
|
||||
};
|
||||
@ -0,0 +1,54 @@
|
||||
import { cn } from "@repo/shadcn-ui/lib/utils";
|
||||
import type { ComponentPropsWithoutRef, ReactNode } from "react";
|
||||
|
||||
export interface RdxEditorStickyFooterProps
|
||||
extends Omit<ComponentPropsWithoutRef<"div">, "children"> {
|
||||
leading?: ReactNode;
|
||||
status?: ReactNode;
|
||||
secondary?: ReactNode;
|
||||
primary?: ReactNode;
|
||||
}
|
||||
|
||||
export const RdxEditorStickyFooter = ({
|
||||
leading,
|
||||
status,
|
||||
secondary,
|
||||
primary,
|
||||
className,
|
||||
...props
|
||||
}: RdxEditorStickyFooterProps) => {
|
||||
const hasLeading = Boolean(leading);
|
||||
const hasActionRegion = Boolean(status || secondary || primary);
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"sticky bottom-0 z-20 shrink-0 border-t border-border bg-background shadow-xs",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<div className="flex min-w-0 flex-col xl:flex-row xl:items-stretch">
|
||||
{hasLeading ? <div className="min-w-0 flex-1 px-4 py-3 md:px-6">{leading}</div> : null}
|
||||
|
||||
{hasActionRegion ? (
|
||||
<div
|
||||
className={cn(
|
||||
"flex min-w-0 flex-col gap-3 px-4 py-3 md:px-6",
|
||||
hasLeading ? "border-t border-border xl:border-t-0 xl:border-l" : null
|
||||
)}
|
||||
>
|
||||
{status ? <div className="min-w-0 text-sm text-muted-foreground">{status}</div> : null}
|
||||
|
||||
{(secondary || primary) ? (
|
||||
<div className="flex min-w-0 flex-col gap-2 sm:flex-row sm:justify-end">
|
||||
{secondary ? <div className="flex min-w-0">{secondary}</div> : null}
|
||||
{primary ? <div className="flex min-w-0">{primary}</div> : null}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@ -1,16 +1,5 @@
|
||||
import { FieldGroup } from "@repo/shadcn-ui/components";
|
||||
import { 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} />;
|
||||
|
||||
@ -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";
|
||||
|
||||
16
packages/rdx-ui/src/components/form/rdx-form-grid.tsx
Normal file
16
packages/rdx-ui/src/components/form/rdx-form-grid.tsx
Normal file
@ -0,0 +1,16 @@
|
||||
import { FieldGroup } from "@repo/shadcn-ui/components";
|
||||
import { cn } from "@repo/shadcn-ui/lib/utils";
|
||||
import type { ReactNode } from "react";
|
||||
|
||||
export interface RdxFormGridProps {
|
||||
children: ReactNode;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export const RdxFormGrid = ({ children, className }: RdxFormGridProps) => {
|
||||
return (
|
||||
<FieldGroup className={cn("grid grid-cols-1 gap-x-6 gap-y-5 md:grid-cols-12", className)}>
|
||||
{children}
|
||||
</FieldGroup>
|
||||
);
|
||||
};
|
||||
@ -1,7 +1,9 @@
|
||||
export * from "./buttons/index.ts";
|
||||
export * from "./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";
|
||||
|
||||
@ -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";
|
||||
|
||||
31
packages/rdx-ui/src/components/layout/rdx-page-actions.tsx
Normal file
31
packages/rdx-ui/src/components/layout/rdx-page-actions.tsx
Normal file
@ -0,0 +1,31 @@
|
||||
import { cn } from "@repo/shadcn-ui/lib/utils";
|
||||
import type { ComponentPropsWithoutRef, ReactNode } from "react";
|
||||
|
||||
export interface RdxPageActionsProps extends Omit<ComponentPropsWithoutRef<"div">, "children"> {
|
||||
primary?: ReactNode;
|
||||
secondary?: ReactNode;
|
||||
overflow?: ReactNode;
|
||||
}
|
||||
|
||||
export const RdxPageActions = ({
|
||||
primary,
|
||||
secondary,
|
||||
overflow,
|
||||
className,
|
||||
...props
|
||||
}: RdxPageActionsProps) => {
|
||||
if (!primary && !secondary && !overflow) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn("flex flex-wrap items-center justify-start gap-2 lg:justify-end", className)}
|
||||
{...props}
|
||||
>
|
||||
{secondary ? <div className="flex flex-wrap items-center gap-2">{secondary}</div> : null}
|
||||
{primary ? <div className="flex flex-wrap items-center gap-2">{primary}</div> : null}
|
||||
{overflow ? <div className="flex items-center gap-2">{overflow}</div> : null}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
32
packages/rdx-ui/src/components/layout/rdx-page-container.tsx
Normal file
32
packages/rdx-ui/src/components/layout/rdx-page-container.tsx
Normal file
@ -0,0 +1,32 @@
|
||||
import { cn } from "@repo/shadcn-ui/lib/utils";
|
||||
import type { ComponentPropsWithoutRef, ReactNode } from "react";
|
||||
|
||||
export type RdxPageWidth = "full" | "wide" | "content";
|
||||
|
||||
export interface RdxPageContainerProps
|
||||
extends Omit<ComponentPropsWithoutRef<"div">, "children"> {
|
||||
children: ReactNode;
|
||||
width?: RdxPageWidth;
|
||||
}
|
||||
|
||||
const WIDTH_CLASSNAME: Record<RdxPageWidth, string> = {
|
||||
full: "w-full",
|
||||
wide: "mx-auto max-w-screen-2xl w-full",
|
||||
content: "mx-auto max-w-5xl w-full",
|
||||
};
|
||||
|
||||
export const RdxPageContainer = ({
|
||||
children,
|
||||
className,
|
||||
width = "full",
|
||||
...props
|
||||
}: RdxPageContainerProps) => {
|
||||
return (
|
||||
<div
|
||||
className={cn("min-w-0 w-full px-4 py-4 md:px-6 md:py-6", WIDTH_CLASSNAME[width], className)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@ -0,0 +1,98 @@
|
||||
import assert from "node:assert/strict";
|
||||
import test from "node:test";
|
||||
import { renderToStaticMarkup } from "react-dom/server";
|
||||
|
||||
import { RdxPageActions } from "./rdx-page-actions.tsx";
|
||||
import { RdxPageContainer } from "./rdx-page-container.tsx";
|
||||
import { RdxPageHeader } from "./rdx-page-header.tsx";
|
||||
import { RdxPageStack } from "./rdx-page-stack.tsx";
|
||||
|
||||
test("RdxPageContainer renders children with full width by default", () => {
|
||||
const html = renderToStaticMarkup(<RdxPageContainer>Content</RdxPageContainer>);
|
||||
|
||||
assert.match(html, /Content/);
|
||||
assert.match(html, /w-full/);
|
||||
assert.doesNotMatch(html, /max-w-screen-2xl/);
|
||||
assert.doesNotMatch(html, /max-w-5xl/);
|
||||
});
|
||||
|
||||
test("RdxPageContainer applies width variants and merges className", () => {
|
||||
const wideHtml = renderToStaticMarkup(
|
||||
<RdxPageContainer className="custom-wide" width="wide">
|
||||
Wide
|
||||
</RdxPageContainer>
|
||||
);
|
||||
const contentHtml = renderToStaticMarkup(
|
||||
<RdxPageContainer width="content">Content</RdxPageContainer>
|
||||
);
|
||||
|
||||
assert.match(wideHtml, /max-w-screen-2xl/);
|
||||
assert.match(wideHtml, /custom-wide/);
|
||||
assert.match(contentHtml, /max-w-5xl/);
|
||||
});
|
||||
|
||||
test("RdxPageStack applies default spacing and className", () => {
|
||||
const html = renderToStaticMarkup(
|
||||
<RdxPageStack className="custom-stack">
|
||||
<div>One</div>
|
||||
</RdxPageStack>
|
||||
);
|
||||
|
||||
assert.match(html, /flex/);
|
||||
assert.match(html, /gap-6/);
|
||||
assert.match(html, /custom-stack/);
|
||||
});
|
||||
|
||||
test("RdxPageStack applies spacing variants", () => {
|
||||
const compactHtml = renderToStaticMarkup(<RdxPageStack spacing="compact">Compact</RdxPageStack>);
|
||||
const relaxedHtml = renderToStaticMarkup(<RdxPageStack spacing="relaxed">Relaxed</RdxPageStack>);
|
||||
|
||||
assert.match(compactHtml, /gap-4/);
|
||||
assert.match(relaxedHtml, /gap-8/);
|
||||
});
|
||||
|
||||
test("RdxPageHeader renders semantic title, description and optional regions", () => {
|
||||
const html = renderToStaticMarkup(
|
||||
<RdxPageHeader
|
||||
actions={<span>Actions</span>}
|
||||
description="Description"
|
||||
leading={<span>Leading</span>}
|
||||
metadata={<span>Metadata</span>}
|
||||
title="Header title"
|
||||
/>
|
||||
);
|
||||
|
||||
assert.match(html, /<h1[^>]*>Header title<\/h1>/);
|
||||
assert.match(html, /Description/);
|
||||
assert.match(html, /Leading/);
|
||||
assert.match(html, /Metadata/);
|
||||
assert.match(html, /Actions/);
|
||||
});
|
||||
|
||||
test("RdxPageHeader omits optional wrappers when content is absent", () => {
|
||||
const html = renderToStaticMarkup(<RdxPageHeader title="Only title" />);
|
||||
|
||||
assert.match(html, /<h1[^>]*>Only title<\/h1>/);
|
||||
assert.equal((html.match(/<p/g) ?? []).length, 0);
|
||||
assert.doesNotMatch(html, /Actions/);
|
||||
assert.doesNotMatch(html, /Metadata/);
|
||||
});
|
||||
|
||||
test("RdxPageActions renders only populated regions in the expected order", () => {
|
||||
const html = renderToStaticMarkup(
|
||||
<RdxPageActions
|
||||
overflow={<span>Overflow</span>}
|
||||
primary={<span>Primary</span>}
|
||||
secondary={<span>Secondary</span>}
|
||||
/>
|
||||
);
|
||||
|
||||
assert.ok(html.indexOf("Secondary") < html.indexOf("Primary"));
|
||||
assert.ok(html.indexOf("Primary") < html.indexOf("Overflow"));
|
||||
});
|
||||
|
||||
test("RdxPageActions returns null when all regions are empty", () => {
|
||||
const html = renderToStaticMarkup(<RdxPageActions />);
|
||||
|
||||
assert.equal(html, "");
|
||||
});
|
||||
53
packages/rdx-ui/src/components/layout/rdx-page-header.tsx
Normal file
53
packages/rdx-ui/src/components/layout/rdx-page-header.tsx
Normal file
@ -0,0 +1,53 @@
|
||||
import { cn } from "@repo/shadcn-ui/lib/utils";
|
||||
import type { ComponentPropsWithoutRef, ReactNode } from "react";
|
||||
|
||||
export interface RdxPageHeaderProps
|
||||
extends Omit<ComponentPropsWithoutRef<"header">, "children" | "title"> {
|
||||
title: ReactNode;
|
||||
description?: ReactNode;
|
||||
leading?: ReactNode;
|
||||
metadata?: ReactNode;
|
||||
actions?: ReactNode;
|
||||
}
|
||||
|
||||
export const RdxPageHeader = ({
|
||||
title,
|
||||
description,
|
||||
leading,
|
||||
metadata,
|
||||
actions,
|
||||
className,
|
||||
...props
|
||||
}: RdxPageHeaderProps) => {
|
||||
return (
|
||||
<header
|
||||
className={cn(
|
||||
"flex min-w-0 flex-col gap-4 lg:flex-row lg:items-start lg:justify-between",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<div className="flex min-w-0 flex-1 items-start gap-4">
|
||||
{leading ? <div className="shrink-0">{leading}</div> : null}
|
||||
|
||||
<div className="min-w-0 space-y-2">
|
||||
<h1 className="min-w-0 text-2xl font-semibold tracking-tight text-foreground font-heading xl:text-3xl">
|
||||
{title}
|
||||
</h1>
|
||||
|
||||
{description ? (
|
||||
<p className="text-sm font-medium text-muted-foreground">{description}</p>
|
||||
) : null}
|
||||
|
||||
{metadata ? (
|
||||
<div className="flex min-w-0 flex-wrap items-center gap-2 text-sm text-muted-foreground">
|
||||
{metadata}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{actions ? <div className="min-w-0 lg:shrink-0">{actions}</div> : null}
|
||||
</header>
|
||||
);
|
||||
};
|
||||
28
packages/rdx-ui/src/components/layout/rdx-page-stack.tsx
Normal file
28
packages/rdx-ui/src/components/layout/rdx-page-stack.tsx
Normal file
@ -0,0 +1,28 @@
|
||||
import { cn } from "@repo/shadcn-ui/lib/utils";
|
||||
import type { ComponentPropsWithoutRef, ReactNode } from "react";
|
||||
|
||||
export type RdxPageStackSpacing = "compact" | "default" | "relaxed";
|
||||
|
||||
export interface RdxPageStackProps extends Omit<ComponentPropsWithoutRef<"div">, "children"> {
|
||||
children: ReactNode;
|
||||
spacing?: RdxPageStackSpacing;
|
||||
}
|
||||
|
||||
const SPACING_CLASSNAME: Record<RdxPageStackSpacing, string> = {
|
||||
compact: "gap-4",
|
||||
default: "gap-6",
|
||||
relaxed: "gap-8",
|
||||
};
|
||||
|
||||
export const RdxPageStack = ({
|
||||
children,
|
||||
className,
|
||||
spacing = "default",
|
||||
...props
|
||||
}: RdxPageStackProps) => {
|
||||
return (
|
||||
<div className={cn("flex min-w-0 flex-col", SPACING_CLASSNAME[spacing], className)} {...props}>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@ -1,10 +1,6 @@
|
||||
import type { ReactNode } from "react";
|
||||
import type { ComponentPropsWithoutRef, ReactNode } from "react";
|
||||
|
||||
export interface RightPanelProps {
|
||||
export interface RightPanelProps extends Omit<ComponentPropsWithoutRef<"div">, "children"> {
|
||||
open: boolean;
|
||||
title: string;
|
||||
className?: string;
|
||||
headerActions?: ReactNode;
|
||||
children: ReactNode;
|
||||
onOpenChange?: (open: boolean) => void;
|
||||
}
|
||||
|
||||
@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
1
packages/rdx-ui/src/components/row-actions/index.ts
Normal file
1
packages/rdx-ui/src/components/row-actions/index.ts
Normal file
@ -0,0 +1 @@
|
||||
export * from "./rdx-row-actions.tsx";
|
||||
@ -0,0 +1,252 @@
|
||||
import assert from "node:assert/strict";
|
||||
import test from "node:test";
|
||||
import { isValidElement } from "react";
|
||||
import { renderToStaticMarkup } from "react-dom/server";
|
||||
|
||||
import { RdxRowActions } from "./rdx-row-actions.tsx";
|
||||
|
||||
const resolveElement = (node: unknown): unknown => {
|
||||
if (Array.isArray(node)) {
|
||||
return node.map(resolveElement);
|
||||
}
|
||||
|
||||
if (!isValidElement(node)) {
|
||||
return node;
|
||||
}
|
||||
|
||||
if (typeof node.type === "function") {
|
||||
return resolveElement(node.type(node.props));
|
||||
}
|
||||
|
||||
return {
|
||||
type: node.type,
|
||||
props: {
|
||||
...node.props,
|
||||
children: resolveElement(node.props.children),
|
||||
},
|
||||
};
|
||||
};
|
||||
|
||||
const collectElements = (node: unknown): Array<{ type: unknown; props: Record<string, unknown> }> => {
|
||||
if (Array.isArray(node)) {
|
||||
return node.flatMap(collectElements);
|
||||
}
|
||||
|
||||
if (!node || typeof node !== "object" || !("type" in node) || !("props" in node)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const element = node as { type: unknown; props: Record<string, unknown> };
|
||||
|
||||
return [element, ...collectElements(element.props.children)];
|
||||
};
|
||||
|
||||
const findElement = (
|
||||
node: unknown,
|
||||
predicate: (element: { type: unknown; props: Record<string, unknown> }) => boolean
|
||||
) => collectElements(node).find(predicate) ?? null;
|
||||
|
||||
const invokeClick = async (
|
||||
element: { type: unknown; props: Record<string, unknown> } | null
|
||||
) => {
|
||||
const onClick = element?.props.onClick as
|
||||
| ((event: { preventDefault: () => void; stopPropagation: () => void }) => unknown)
|
||||
| undefined;
|
||||
|
||||
await onClick?.({
|
||||
preventDefault() {},
|
||||
stopPropagation() {},
|
||||
});
|
||||
};
|
||||
|
||||
test("renderiza acciones primarias, overflow y combina className", () => {
|
||||
const html = renderToStaticMarkup(
|
||||
<RdxRowActions
|
||||
className="custom-row-actions"
|
||||
overflowActions={[
|
||||
{
|
||||
id: "delete",
|
||||
icon: <span aria-hidden="true">D</span>,
|
||||
label: "Delete",
|
||||
onSelect: () => undefined,
|
||||
destructive: true,
|
||||
},
|
||||
]}
|
||||
overflowLabel="More actions"
|
||||
primaryActions={[
|
||||
{
|
||||
id: "view",
|
||||
icon: <span aria-hidden="true">V</span>,
|
||||
label: "View",
|
||||
onSelect: () => undefined,
|
||||
},
|
||||
{
|
||||
id: "edit",
|
||||
icon: <span aria-hidden="true">E</span>,
|
||||
label: "Edit",
|
||||
onSelect: () => undefined,
|
||||
},
|
||||
]}
|
||||
/>
|
||||
);
|
||||
|
||||
assert.match(html, /custom-row-actions/);
|
||||
assert.match(html, /aria-label="View"/);
|
||||
assert.match(html, /aria-label="Edit"/);
|
||||
assert.match(html, /aria-label="More actions"/);
|
||||
assert.match(html, /Delete/);
|
||||
});
|
||||
|
||||
test("mantiene el orden recibido y no renderiza overflow vacío", () => {
|
||||
const html = renderToStaticMarkup(
|
||||
<RdxRowActions
|
||||
overflowActions={[]}
|
||||
overflowLabel="More actions"
|
||||
primaryActions={[
|
||||
{
|
||||
id: "first",
|
||||
icon: <span aria-hidden="true">1</span>,
|
||||
label: "First",
|
||||
onSelect: () => undefined,
|
||||
},
|
||||
{
|
||||
id: "second",
|
||||
icon: <span aria-hidden="true">2</span>,
|
||||
label: "Second",
|
||||
onSelect: () => undefined,
|
||||
},
|
||||
]}
|
||||
/>
|
||||
);
|
||||
|
||||
assert.ok(html.indexOf('aria-label="First"') < html.indexOf('aria-label="Second"'));
|
||||
assert.equal(html.includes('aria-label="More actions"'), false);
|
||||
});
|
||||
|
||||
test("ejecuta callbacks de acciones primarias y overflow", async () => {
|
||||
let didView = false;
|
||||
let didDelete = false;
|
||||
|
||||
const resolved = resolveElement(
|
||||
<RdxRowActions
|
||||
overflowActions={[
|
||||
{
|
||||
id: "delete",
|
||||
icon: <span aria-hidden="true">D</span>,
|
||||
label: "Delete",
|
||||
onSelect: () => {
|
||||
didDelete = true;
|
||||
},
|
||||
},
|
||||
]}
|
||||
overflowLabel="More actions"
|
||||
primaryActions={[
|
||||
{
|
||||
id: "view",
|
||||
icon: <span aria-hidden="true">V</span>,
|
||||
label: "View",
|
||||
onSelect: () => {
|
||||
didView = true;
|
||||
},
|
||||
},
|
||||
]}
|
||||
/>
|
||||
);
|
||||
|
||||
const primaryButton = findElement(
|
||||
resolved,
|
||||
(element) => element.type === "button" && element.props["aria-label"] === "View"
|
||||
);
|
||||
const overflowItem = findElement(
|
||||
resolved,
|
||||
(element) =>
|
||||
typeof element.props.onClick === "function" &&
|
||||
JSON.stringify(element.props.children).includes("Delete")
|
||||
);
|
||||
|
||||
assert.ok(primaryButton);
|
||||
assert.ok(overflowItem);
|
||||
|
||||
await invokeClick(primaryButton);
|
||||
await invokeClick(overflowItem);
|
||||
|
||||
assert.equal(didView, true);
|
||||
assert.equal(didDelete, true);
|
||||
});
|
||||
|
||||
test("respeta disabled y muestra disabledReason cuando existe", async () => {
|
||||
let didRun = false;
|
||||
|
||||
const html = renderToStaticMarkup(
|
||||
<RdxRowActions
|
||||
overflowActions={[
|
||||
{
|
||||
id: "archive",
|
||||
icon: <span aria-hidden="true">A</span>,
|
||||
label: "Archive",
|
||||
onSelect: () => undefined,
|
||||
disabled: true,
|
||||
disabledReason: "Archive unavailable",
|
||||
},
|
||||
]}
|
||||
overflowLabel="More actions"
|
||||
primaryActions={[
|
||||
{
|
||||
id: "edit",
|
||||
icon: <span aria-hidden="true">E</span>,
|
||||
label: "Edit",
|
||||
onSelect: () => undefined,
|
||||
disabled: true,
|
||||
disabledReason: "Edit unavailable",
|
||||
},
|
||||
]}
|
||||
/>
|
||||
);
|
||||
|
||||
assert.match(html, /Edit unavailable/);
|
||||
assert.match(html, /Archive unavailable/);
|
||||
|
||||
const resolved = resolveElement(
|
||||
<RdxRowActions
|
||||
overflowActions={[
|
||||
{
|
||||
id: "archive",
|
||||
icon: <span aria-hidden="true">A</span>,
|
||||
label: "Archive",
|
||||
onSelect: () => {
|
||||
didRun = true;
|
||||
},
|
||||
disabled: true,
|
||||
},
|
||||
]}
|
||||
overflowLabel="More actions"
|
||||
primaryActions={[
|
||||
{
|
||||
id: "edit",
|
||||
icon: <span aria-hidden="true">E</span>,
|
||||
label: "Edit",
|
||||
onSelect: () => {
|
||||
didRun = true;
|
||||
},
|
||||
disabled: true,
|
||||
},
|
||||
]}
|
||||
/>
|
||||
);
|
||||
|
||||
const primaryButton = findElement(
|
||||
resolved,
|
||||
(element) => element.type === "button" && element.props["aria-label"] === "Edit"
|
||||
);
|
||||
const overflowItem = findElement(
|
||||
resolved,
|
||||
(element) =>
|
||||
typeof element.props.onClick === "function" &&
|
||||
JSON.stringify(element.props.children).includes("Archive")
|
||||
);
|
||||
|
||||
await invokeClick(primaryButton);
|
||||
await invokeClick(overflowItem);
|
||||
|
||||
assert.equal(didRun, false);
|
||||
});
|
||||
170
packages/rdx-ui/src/components/row-actions/rdx-row-actions.tsx
Normal file
170
packages/rdx-ui/src/components/row-actions/rdx-row-actions.tsx
Normal file
@ -0,0 +1,170 @@
|
||||
import {
|
||||
Button,
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuGroup,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuLabel,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuTrigger,
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
TooltipProvider,
|
||||
TooltipTrigger,
|
||||
} from "@repo/shadcn-ui/components";
|
||||
import { cn } from "@repo/shadcn-ui/lib/utils";
|
||||
import { MoreHorizontalIcon } from "lucide-react";
|
||||
import type { ComponentPropsWithoutRef, KeyboardEvent, MouseEvent, ReactNode } from "react";
|
||||
import { Fragment } from "react";
|
||||
|
||||
export interface RdxRowAction {
|
||||
id: string;
|
||||
label: string;
|
||||
icon: ReactNode;
|
||||
onSelect: () => void;
|
||||
disabled?: boolean;
|
||||
disabledReason?: string;
|
||||
destructive?: boolean;
|
||||
}
|
||||
|
||||
export interface RdxRowActionsProps
|
||||
extends Omit<ComponentPropsWithoutRef<"div">, "children"> {
|
||||
primaryActions?: RdxRowAction[];
|
||||
overflowActions?: RdxRowAction[];
|
||||
overflowLabel: string;
|
||||
}
|
||||
|
||||
const stopRowEvent = (event: MouseEvent | KeyboardEvent) => {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
};
|
||||
|
||||
const stopPropagation = (event: MouseEvent | KeyboardEvent) => {
|
||||
event.stopPropagation();
|
||||
};
|
||||
|
||||
const getTooltipLabel = (action: RdxRowAction) => action.disabledReason ?? action.label;
|
||||
|
||||
export const RdxRowActions = ({
|
||||
primaryActions = [],
|
||||
overflowActions = [],
|
||||
overflowLabel,
|
||||
className,
|
||||
...props
|
||||
}: RdxRowActionsProps) => {
|
||||
if (primaryActions.length === 0 && overflowActions.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<TooltipProvider>
|
||||
<div className={cn("flex items-center justify-end gap-1", className)} {...props}>
|
||||
{primaryActions.map((action) => {
|
||||
const button = (
|
||||
<Button
|
||||
aria-label={action.label}
|
||||
className="size-8 text-muted-foreground hover:text-foreground"
|
||||
disabled={action.disabled}
|
||||
onClick={(event) => {
|
||||
stopRowEvent(event);
|
||||
|
||||
if (!action.disabled) {
|
||||
action.onSelect();
|
||||
}
|
||||
}}
|
||||
onKeyDown={stopPropagation}
|
||||
size="icon-sm"
|
||||
type="button"
|
||||
variant="ghost"
|
||||
>
|
||||
{action.icon}
|
||||
</Button>
|
||||
);
|
||||
|
||||
return (
|
||||
<Tooltip key={action.id}>
|
||||
<TooltipTrigger
|
||||
render={
|
||||
action.disabled ? <span className="inline-flex">{button}</span> : button
|
||||
}
|
||||
/>
|
||||
<TooltipContent>{getTooltipLabel(action)}</TooltipContent>
|
||||
</Tooltip>
|
||||
);
|
||||
})}
|
||||
|
||||
{overflowActions.length > 0 ? (
|
||||
<DropdownMenu>
|
||||
<Tooltip>
|
||||
<TooltipTrigger
|
||||
render={
|
||||
<DropdownMenuTrigger
|
||||
render={
|
||||
<Button
|
||||
aria-label={overflowLabel}
|
||||
className="size-8 text-muted-foreground hover:text-foreground"
|
||||
onClick={stopPropagation}
|
||||
onKeyDown={stopPropagation}
|
||||
size="icon-sm"
|
||||
type="button"
|
||||
variant="ghost"
|
||||
>
|
||||
<MoreHorizontalIcon className="size-4" />
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
<TooltipContent>{overflowLabel}</TooltipContent>
|
||||
</Tooltip>
|
||||
|
||||
<DropdownMenuContent align="end" className="w-56" onClick={stopPropagation}>
|
||||
<DropdownMenuGroup>
|
||||
<DropdownMenuLabel>{overflowLabel}</DropdownMenuLabel>
|
||||
<DropdownMenuSeparator />
|
||||
|
||||
{overflowActions.map((action, index) => {
|
||||
const item = (
|
||||
<DropdownMenuItem
|
||||
aria-label={action.label}
|
||||
className={
|
||||
action.destructive ? "text-destructive focus:text-destructive" : undefined
|
||||
}
|
||||
disabled={action.disabled}
|
||||
onClick={(event) => {
|
||||
stopRowEvent(event);
|
||||
|
||||
if (!action.disabled) {
|
||||
action.onSelect();
|
||||
}
|
||||
}}
|
||||
onKeyDown={stopPropagation}
|
||||
variant={action.destructive ? "destructive" : "default"}
|
||||
>
|
||||
<span className="mr-2 inline-flex size-4 items-center justify-center">
|
||||
{action.icon}
|
||||
</span>
|
||||
<span>{action.label}</span>
|
||||
</DropdownMenuItem>
|
||||
);
|
||||
|
||||
return (
|
||||
<Fragment key={action.id}>
|
||||
{action.destructive && index > 0 ? <DropdownMenuSeparator /> : null}
|
||||
<Tooltip>
|
||||
<TooltipTrigger
|
||||
render={action.disabled ? <div className="w-full">{item}</div> : item}
|
||||
/>
|
||||
<TooltipContent side="left">{getTooltipLabel(action)}</TooltipContent>
|
||||
</Tooltip>
|
||||
</Fragment>
|
||||
);
|
||||
})}
|
||||
</DropdownMenuGroup>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
) : null}
|
||||
</div>
|
||||
</TooltipProvider>
|
||||
);
|
||||
};
|
||||
@ -1331,6 +1331,9 @@ importers:
|
||||
tailwindcss:
|
||||
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)
|
||||
|
||||
Loading…
Reference in New Issue
Block a user