From c28a5e02c334d5cd905c44fb5112fe68914f62f1 Mon Sep 17 00:00:00 2001 From: david Date: Tue, 11 Aug 2026 00:01:33 +0200 Subject: [PATCH] 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 --- apps/web/src/development/ui-kit/index.ts | 1 + .../ui-kit/sections/ui-kit-section.tsx | 28 + .../src/development/ui-kit/ui-kit-page.tsx | 965 +++++++++++++ apps/web/src/main.tsx | 2 +- apps/web/src/routes/app-routes.tsx | 11 + .../proforma-footer-actions.tsx | 21 +- .../proforma-header.tsx | 2 +- .../proforma-summary-content.tsx | 11 +- .../proforma-summary-panel.test.tsx | 155 +++ .../proforma-summary-panel.tsx | 58 +- .../use-proforma-grid-columns.test.tsx | 131 ++ .../use-proforma-grid-columns.tsx | 226 +-- .../list/ui/pages/list-proformas-page.tsx | 390 +++--- .../scoped-list-proformas-page-view.test.tsx | 127 ++ .../pages/scoped-list-proformas-page-view.tsx | 51 + .../ui/blocks/proforma-compact-totals.tsx | 82 +- .../ui/blocks/proforma-update-header.test.tsx | 60 + .../ui/blocks/proforma-update-header.tsx | 50 +- .../ui/editors/proforma-payment-editor.tsx | 14 +- .../proforma-update-editor-form.test.tsx | 138 ++ .../editors/proforma-update-editor-form.tsx | 78 +- .../editors/proforma-update-header-editor.tsx | 14 +- .../editors/proforma-update-items-editor.tsx | 19 +- .../proforma-update-recipient-editor.tsx | 98 +- .../ui/editors/proforma-update-tax-editor.tsx | 20 +- .../update/ui/pages/proforma-update-page.tsx | 151 +- .../ui/blocks/customer-create-header.test.tsx | 95 ++ .../ui/editor/customer-create-editor.test.tsx | 46 + .../ui/editor/customer-create-editor.tsx | 41 +- .../create/ui/pages/customer-create-page.tsx | 38 +- .../build-customer-create-defaults.test.ts | 14 + .../customer-footer-actions.tsx | 21 +- .../customer-header.tsx | 12 +- .../customer-summary-content.tsx | 11 - .../customer-summary-panel.test.tsx | 139 ++ .../customer-summary-panel.tsx | 55 +- .../use-customer-grid-columns.test.tsx | 113 ++ .../use-customer-grid-columns.tsx | 112 +- .../pages/list-customers-page-view.test.tsx | 125 ++ .../ui/pages/list-customers-page-view.tsx | 51 + .../web/list/ui/pages/list-customers-page.tsx | 104 +- .../customer-additional-config-fields.tsx | 10 +- .../ui/editor/customer-address-editor.tsx | 15 +- .../ui/editor/customer-basic-info-editor.tsx | 12 +- .../ui/editor/customer-contact-fields.tsx | 14 +- .../ui/editor/customer-edit-form.test.tsx | 45 + .../update/ui/editor/customer-edit-form.tsx | 41 +- .../ui/editor/customer-fiscal-editor.tsx | 12 +- .../update/ui/pages/customer-update-page.tsx | 45 +- .../use-customer-view-page.controller.ts | 4 +- packages/rdx-ui/docs/contributing-ui.md | 101 ++ packages/rdx-ui/docs/foundations.md | 134 ++ packages/rdx-ui/docs/page-layouts.md | 247 ++++ packages/rdx-ui/docs/visual-system-design.md | 1225 +++++++++++++++++ packages/rdx-ui/package.json | 4 +- .../src/components/context-panel/index.ts | 1 + .../context-panel/rdx-context-panel.test.tsx | 121 ++ .../context-panel/rdx-context-panel.tsx | 84 ++ .../src/components/editor-page/index.ts | 3 + .../rdx-editor-foundations.test.tsx | 121 ++ .../editor-page/rdx-editor-page-layout.tsx | 44 + .../editor-page/rdx-editor-section.tsx | 73 + .../editor-page/rdx-editor-sticky-footer.tsx | 54 + .../src/components/form/form-section-grid.tsx | 17 +- packages/rdx-ui/src/components/form/index.ts | 1 + .../src/components/form/rdx-form-grid.tsx | 16 + packages/rdx-ui/src/components/index.ts | 3 + .../rdx-ui/src/components/layout/index.ts | 4 + .../components/layout/rdx-page-actions.tsx | 31 + .../components/layout/rdx-page-container.tsx | 32 + .../layout/rdx-page-foundations.test.tsx | 98 ++ .../src/components/layout/rdx-page-header.tsx | 53 + .../src/components/layout/rdx-page-stack.tsx | 28 + .../right-panel/right-panel-types.ts | 8 +- .../components/right-panel/right-panel.tsx | 39 +- .../src/components/row-actions/index.ts | 1 + .../row-actions/rdx-row-actions.test.tsx | 252 ++++ .../row-actions/rdx-row-actions.tsx | 170 +++ pnpm-lock.yaml | 3 + 79 files changed, 6045 insertions(+), 1001 deletions(-) create mode 100644 apps/web/src/development/ui-kit/index.ts create mode 100644 apps/web/src/development/ui-kit/sections/ui-kit-section.tsx create mode 100644 apps/web/src/development/ui-kit/ui-kit-page.tsx create mode 100644 modules/customer-invoices/src/web/proformas/list/ui/blocks/proforma-summary-panel/proforma-summary-panel.test.tsx create mode 100644 modules/customer-invoices/src/web/proformas/list/ui/blocks/proformas-grid/use-proforma-grid-columns.test.tsx create mode 100644 modules/customer-invoices/src/web/proformas/list/ui/pages/scoped-list-proformas-page-view.test.tsx create mode 100644 modules/customer-invoices/src/web/proformas/list/ui/pages/scoped-list-proformas-page-view.tsx create mode 100644 modules/customer-invoices/src/web/proformas/update/ui/blocks/proforma-update-header.test.tsx create mode 100644 modules/customer-invoices/src/web/proformas/update/ui/editors/proforma-update-editor-form.test.tsx create mode 100644 modules/customers/src/web/create/ui/blocks/customer-create-header.test.tsx create mode 100644 modules/customers/src/web/create/ui/editor/customer-create-editor.test.tsx create mode 100644 modules/customers/src/web/create/utils/build-customer-create-defaults.test.ts create mode 100644 modules/customers/src/web/list/ui/blocks/customer-summary-panel/customer-summary-panel.test.tsx create mode 100644 modules/customers/src/web/list/ui/blocks/customers-grid/use-customer-grid-columns.test.tsx create mode 100644 modules/customers/src/web/list/ui/pages/list-customers-page-view.test.tsx create mode 100644 modules/customers/src/web/list/ui/pages/list-customers-page-view.tsx create mode 100644 modules/customers/src/web/update/ui/editor/customer-edit-form.test.tsx create mode 100644 packages/rdx-ui/docs/contributing-ui.md create mode 100644 packages/rdx-ui/docs/foundations.md create mode 100644 packages/rdx-ui/docs/page-layouts.md create mode 100644 packages/rdx-ui/docs/visual-system-design.md create mode 100644 packages/rdx-ui/src/components/context-panel/index.ts create mode 100644 packages/rdx-ui/src/components/context-panel/rdx-context-panel.test.tsx create mode 100644 packages/rdx-ui/src/components/context-panel/rdx-context-panel.tsx create mode 100644 packages/rdx-ui/src/components/editor-page/index.ts create mode 100644 packages/rdx-ui/src/components/editor-page/rdx-editor-foundations.test.tsx create mode 100644 packages/rdx-ui/src/components/editor-page/rdx-editor-page-layout.tsx create mode 100644 packages/rdx-ui/src/components/editor-page/rdx-editor-section.tsx create mode 100644 packages/rdx-ui/src/components/editor-page/rdx-editor-sticky-footer.tsx create mode 100644 packages/rdx-ui/src/components/form/rdx-form-grid.tsx create mode 100644 packages/rdx-ui/src/components/layout/rdx-page-actions.tsx create mode 100644 packages/rdx-ui/src/components/layout/rdx-page-container.tsx create mode 100644 packages/rdx-ui/src/components/layout/rdx-page-foundations.test.tsx create mode 100644 packages/rdx-ui/src/components/layout/rdx-page-header.tsx create mode 100644 packages/rdx-ui/src/components/layout/rdx-page-stack.tsx create mode 100644 packages/rdx-ui/src/components/row-actions/index.ts create mode 100644 packages/rdx-ui/src/components/row-actions/rdx-row-actions.test.tsx create mode 100644 packages/rdx-ui/src/components/row-actions/rdx-row-actions.tsx diff --git a/apps/web/src/development/ui-kit/index.ts b/apps/web/src/development/ui-kit/index.ts new file mode 100644 index 00000000..f81ae283 --- /dev/null +++ b/apps/web/src/development/ui-kit/index.ts @@ -0,0 +1 @@ +export { default as UiKitPage } from "./ui-kit-page"; diff --git a/apps/web/src/development/ui-kit/sections/ui-kit-section.tsx b/apps/web/src/development/ui-kit/sections/ui-kit-section.tsx new file mode 100644 index 00000000..12bcfdb3 --- /dev/null +++ b/apps/web/src/development/ui-kit/sections/ui-kit-section.tsx @@ -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 ( +
+
+

{title}

+ {description ?

{description}

: null} +
+ {children} +
+ ); +}; diff --git a/apps/web/src/development/ui-kit/ui-kit-page.tsx b/apps/web/src/development/ui-kit/ui-kit-page.tsx new file mode 100644 index 00000000..29b2de4f --- /dev/null +++ b/apps/web/src/development/ui-kit/ui-kit-page.tsx @@ -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 ( + + + {title} + {notes ? {notes} : null} + + {children} + + ); +}; + +const SurfaceExample = ({ children }: { children: ReactNode }) => { + return ( +
+ {children} +
+ ); +}; + +const OverflowMenuExample = () => { + return ( + + + + + } + /> + + Duplicate + Export + + + ); +}; + +export default function UiKitPage() { + const { theme, setTheme } = useTheme(); + + return ( + + + + + Primary action + + } + secondary={} + overflow={} + /> + } + description="Infraestructura mínima del sistema visual Rdx para validar foundations estructurales antes de migrar pantallas funcionales." + metadata={ + <> + Development only + Static data + + } + title="Rdx UI Kit" + /> + + + + +
+ + +
+
Compact, clean, ordered
+

+ Las surfaces principales son planas. Las jerarquías se expresan con spacing, + tipografía y divisores. +

+
+
+
+ + +
+ +
+
+
+
+ + + + +
+ + +
Available width
+
+
+ + + +
Max width screen 2xl
+
+
+ + + +
Centered content editor
+
+
+
+
+ + + + +
+ + + Block 1 + Block 2 + Block 3 + + + + + + Block 1 + Block 2 + Block 3 + + + + + + Block 1 + Block 2 + Block 3 + + +
+
+ + + + +
+ + + + + + + + + + + + + + + Save} + secondary={} + overflow={} + /> + } + description="Header with leading control, metadata and grouped actions." + leading={ + + } + metadata={ + <> + Draft + Last updated 5 minutes ago + + } + title="Invoice editor" + /> + + + + +
+ Primary action} + description="A long description checks wrapping behavior with translated text and narrow widths." + metadata={Metadata that can also wrap if necessary} + title="Very long page title intended to validate wrapping in a constrained viewport" + /> +
+
+
+
+ + + + +
+ + + Create customer} /> + + + + + + Save} + secondary={} + /> + + + + + + } + primary={} + secondary={} + /> + + + + +
+ } + primary={} + secondary={ + <> + + + + } + /> +
+
+
+
+ + + + + +
+ + + + + Nueva proforma + + } + /> + } + description="Gestiona y consulta las proformas de clientes." + title="Proformas" + /> + +
+ No se ha podido refrescar la lista. Puedes seguir trabajando con los datos + cargados. +
+ +
+
+
+
+
+
+
+ + +
+ + + + + + + +
+ + + + + Nuevo cliente + + } + /> + } + description="Listado con panel contextual opcional." + title="Clientes" + /> + +
+
+
+
+
+
+
+ +
+
+
+
Ficha de cliente
+
+ Panel contextual opcional +
+
+ +
+ +
+
+
+
+
+
+
+ + +
+ + + + + + +
+ +
+ + +
Body content
+
+
+
+
+ + +
+ + + + + } + closeLabel="Cerrar panel" + description="Secondary context that explains the panel purpose." + onClose={() => undefined} + title="Context panel" + > +
+

Long body content should scroll in the correct layer.

+
+
+ + +
+ + + +
+ + + + +
+ } + onClose={() => undefined} + title="Very long contextual panel title intended to validate wrapping behavior" + > +
+

Example without domain dependencies.

+
+
+ + +
+
+
+ + + + + +
+ + + , + onSelect: () => undefined, + }, + ]} + /> + + + + + + , + onSelect: () => undefined, + }, + { + id: "edit", + label: "Edit", + icon: , + onSelect: () => undefined, + }, + ]} + /> + + + + + + , + onSelect: () => undefined, + }, + { + id: "delete", + label: "Delete item", + icon: , + onSelect: () => undefined, + destructive: true, + }, + ]} + overflowLabel="More actions" + primaryActions={[ + { + id: "edit", + label: "Edit", + icon: , + onSelect: () => undefined, + }, + ]} + /> + + + + + + , + 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: , + onSelect: () => undefined, + disabled: true, + disabledReason: "Editing is temporarily unavailable", + }, + ]} + /> + + +
+
+ + + + +
+ +
+ +
+ Centered editor content +
+
+
+
+ + +
+ + } + title="Plain section" + > +
Section body
+
+
+ + + +
Panel body
+
+
+
+
+ + + + +
+ Field A +
+
+ Field B +
+
+ Wide field +
+
+
+
+ + +
+ +
+
+
+ Save error aligned with editor width +
+ +
+ } + title="Basic information" + > + +
+ Name field +
+
+ Tax ID field +
+
+
+ + } + title="Preferences" + > + +
+ Language field +
+
+ Currency field +
+
+
+
+
+
+
+
+
+ + +
+
+
+
+
+
Edit proforma
+
+ Sticky header remains outside `rdx-ui`. +
+
+ Modified +
+
+ +
+
+ + +
+ Customer selector and helper actions +
+
+ + + +
+ Tax regime field +
+
+ Default tax selector +
+
+
+
+ } + complexity="complex" + > +
+
+
+ Inline feedback aligned with the editor content +
+ + + +
+ Reference field +
+
+ Date field +
+
+ Series field +
+
+
+ +
+
+ Line editor keeps its own internal table layout and stays in the + main surface. +
+
+
+
+ +
+ + + Sticky totals region + Subtotal 2,350.00 + Impuestos 493.50 + + Total 2,843.50 + +
+ } + primary={} + secondary={} + status="Modificada" + /> +
+
+
+ +
+ + + + + +
+ +
+ + + New item} />} + description="Static list example" + title="List page" + /> + List content + + +
+
+ + +
+
+ + + + Form content + + +
+ +
+ + + + Detail content + + +
+
+
+
+
+ + + ); +} diff --git a/apps/web/src/main.tsx b/apps/web/src/main.tsx index 4d5bdbe4..60a60605 100644 --- a/apps/web/src/main.tsx +++ b/apps/web/src/main.tsx @@ -12,7 +12,7 @@ const rootElement = document.getElementById("factuges"); if (rootElement) { createRoot(rootElement).render( - + <> diff --git a/apps/web/src/routes/app-routes.tsx b/apps/web/src/routes/app-routes.tsx index ff0da0dc..a9e569c0 100644 --- a/apps/web/src/routes/app-routes.tsx +++ b/apps/web/src/routes/app-routes.tsx @@ -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: , + }, + ] + : []; + const appRoutes = createBrowserRouter([ { path: "/", @@ -121,6 +131,7 @@ export const getAppRouter = () => { path: "quotes", element: , }, + ...developmentRoutes, ...appSidebarRoutes, ], diff --git a/modules/customer-invoices/src/web/proformas/list/ui/blocks/proforma-summary-panel/proforma-footer-actions.tsx b/modules/customer-invoices/src/web/proformas/list/ui/blocks/proforma-summary-panel/proforma-footer-actions.tsx index c1705012..e32c6132 100644 --- a/modules/customer-invoices/src/web/proformas/list/ui/blocks/proforma-summary-panel/proforma-footer-actions.tsx +++ b/modules/customer-invoices/src/web/proformas/list/ui/blocks/proforma-summary-panel/proforma-footer-actions.tsx @@ -12,16 +12,19 @@ export const ProformaFooterActions = ({ onEdit?: (proforma: Proforma) => void; }) => { return ( -
-
- +
+ - -
+
); }; diff --git a/modules/customer-invoices/src/web/proformas/list/ui/blocks/proforma-summary-panel/proforma-header.tsx b/modules/customer-invoices/src/web/proformas/list/ui/blocks/proforma-summary-panel/proforma-header.tsx index 6ad38ebf..6ca30308 100644 --- a/modules/customer-invoices/src/web/proformas/list/ui/blocks/proforma-summary-panel/proforma-header.tsx +++ b/modules/customer-invoices/src/web/proformas/list/ui/blocks/proforma-summary-panel/proforma-header.tsx @@ -21,7 +21,7 @@ export const ProformaHeader = ({ proforma }: { proforma: Proforma }) => {
-

{proforma.recipient.name}

+

{proforma.recipient.name}

diff --git a/modules/customer-invoices/src/web/proformas/list/ui/blocks/proforma-summary-panel/proforma-summary-content.tsx b/modules/customer-invoices/src/web/proformas/list/ui/blocks/proforma-summary-panel/proforma-summary-content.tsx index 24a89d0c..f679bf1a 100644 --- a/modules/customer-invoices/src/web/proformas/list/ui/blocks/proforma-summary-panel/proforma-summary-content.tsx +++ b/modules/customer-invoices/src/web/proformas/list/ui/blocks/proforma-summary-panel/proforma-summary-content.tsx @@ -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 (
- -
); }; diff --git a/modules/customer-invoices/src/web/proformas/list/ui/blocks/proforma-summary-panel/proforma-summary-panel.test.tsx b/modules/customer-invoices/src/web/proformas/list/ui/blocks/proforma-summary-panel/proforma-summary-panel.test.tsx new file mode 100644 index 00000000..c4b1fc83 --- /dev/null +++ b/modules/customer-invoices/src/web/proformas/list/ui/blocks/proforma-summary-panel/proforma-summary-panel.test.tsx @@ -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> => { + 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 }; + 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( + 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(/

{ + let didEdit = false; + let wasClosed = false; + + const resolved = resolveElement( + { + 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); +}); diff --git a/modules/customer-invoices/src/web/proformas/list/ui/blocks/proforma-summary-panel/proforma-summary-panel.tsx b/modules/customer-invoices/src/web/proformas/list/ui/blocks/proforma-summary-panel/proforma-summary-panel.tsx index 6aa63024..9f125e4e 100644 --- a/modules/customer-invoices/src/web/proformas/list/ui/blocks/proforma-summary-panel/proforma-summary-panel.tsx +++ b/modules/customer-invoices/src/web/proformas/list/ui/blocks/proforma-summary-panel/proforma-summary-panel.tsx @@ -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 ( - - {proforma ? ( + + onEdit?.(proforma)} size="icon-sm" + type="button" variant="ghost" > - ) : null} - - } - onOpenChange={onOpenChange} - open={open} - title={titleMap[mode]} - > - {proforma ? ( - - ) : ( -
-

Selecciona una proforma

-
- )} + ) : undefined + } + closeLabel="Cerrar panel" + footer={ + proforma ? ( + + ) : undefined + } + onClose={() => onOpenChange(false)} + title={titleMap[mode]} + > + {proforma ? ( + + ) : ( +
+

Selecciona una proforma

+
+ )} +
); }; diff --git a/modules/customer-invoices/src/web/proformas/list/ui/blocks/proformas-grid/use-proforma-grid-columns.test.tsx b/modules/customer-invoices/src/web/proformas/list/ui/blocks/proformas-grid/use-proforma-grid-columns.test.tsx new file mode 100644 index 00000000..c6539118 --- /dev/null +++ b/modules/customer-invoices/src/web/proformas/list/ui/blocks/proformas-grid/use-proforma-grid-columns.test.tsx @@ -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[0], + actionHandlers: Parameters[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); + + return null; + }; + + renderToStaticMarkup(); + + 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); +}); diff --git a/modules/customer-invoices/src/web/proformas/list/ui/blocks/proformas-grid/use-proforma-grid-columns.tsx b/modules/customer-invoices/src/web/proformas/list/ui/blocks/proformas-grid/use-proforma-grid-columns.tsx index 6981c936..9be62aee 100644 --- a/modules/customer-invoices/src/web/proformas/list/ui/blocks/proformas-grid/use-proforma-grid-columns.tsx +++ b/modules/customer-invoices/src/web/proformas/list/ui/blocks/proformas-grid/use-proforma-grid-columns.tsx @@ -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 ? : ); 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 = ( - - ); - - return ( - - {button} : button} - /> - {action.disabledReason ?? action.label} - - ); - }; - - const renderMenuItem = (action: RowAction) => { - const item = ( - { - stopRowPropagationAndPreventDefault(event); - if (action.disabled) { - return; - } - - if (action.href) { - window.location.href = action.href; - return; - } - - action.onClick?.(); - }} - variant={action.destructive ? "destructive" : "default"} - > - {action.loading ? ( - - ) : ( - - )} - {action.label} - - ); - - return ( - - {item}

: item} - /> - {action.disabledReason ?? action.label} - - ); - }; - return ( - -
- {visibleActions.map(renderIconButton)} - - - - - - } - /> - } - /> - {t("common.more_actions")} - - - - {t("common.actions")} - - {menuActions.map((action, index) => ( - - {action.destructive && index > 0 ? : null} - {renderMenuItem(action)} - - ))} - - - -
-
+ ); }, }, diff --git a/modules/customer-invoices/src/web/proformas/list/ui/pages/list-proformas-page.tsx b/modules/customer-invoices/src/web/proformas/list/ui/pages/list-proformas-page.tsx index cdaf135f..1ca6436e 100644 --- a/modules/customer-invoices/src/web/proformas/list/ui/pages/list-proformas-page.tsx +++ b/modules/customer-invoices/src/web/proformas/list/ui/pages/list-proformas-page.tsx @@ -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 ( -
+
{ variant={pageError.variant} />
-
+ ); } return ( -
- {/* Header */} - - navigate({ - pathname: "/proformas/create", - search: createSearchParams({ - returnTo: currentReturnTo, - }).toString(), - }) - } - size={"default"} + + {/* Stats Cards */} +
+ + +
+
+ +
+
+

Total proformas

+

1.248

+

+ ↑ 12.5% vs. mes anterior +

+
+
+
+
+ + + +
+
+ +
+
+

Importe total

+

125.430,75 €

+

+ ↑ 8.1% vs. mes anterior +

+
+
+
+
+ + + +
+
+ +
+
+

Pendientes

+

356

+

+ ↑ 5.2% vs. mes anterior +

+
+
+
+
+ + + +
+
+ +
+
+

Convertidas

+

892

+

+ ↑ 15.7% vs. mes anterior +

+
+
+
+
+
+ + {/* Table */} + {scopeConfig.enablePanel && isPanelOpen ? ( + - - {t("pages.proformas.create.title")} - - ) : undefined - } - title={t(scopeConfig.titleKey)} - /> + + {listContent} + - {refreshError ? : null} + - {/* Stats Cards */} -
- - -
-
- -
-
-

Total proformas

-

1.248

-

- ↑ 12.5% vs. mes anterior -

-
-
-
-
+ +
+ handleEditClick(proforma.id)} + onOpenChange={(open) => { + if (!open) { + panelCtrl.closePanel(); + return; + } - - -
-
- -
-
-

Importe total

-

125.430,75 €

-

- ↑ 8.1% vs. mes anterior -

-
-
-
-
+ panelCtrl.panelState.onOpenChange(true); + }} + open={panelCtrl.panelState.isOpen} + proforma={panelCtrl.proforma} + visibility={panelCtrl.panelState.visibility} + /> +
+
+ + ) : ( +
{listContent}
+ )} + + } + description={t(scopeConfig.descriptionKey)} + dialogs={ + <> + { + if (!open) { + issueDialogCtrl.closeDialog(); + } + }} + open={issueDialogCtrl.open} + target={issueDialogCtrl.target} + /> - - -
-
- -
-
-

Pendientes

-

356

-

- ↑ 5.2% vs. mes anterior -

-
-
-
-
+ { + if (!open) { + changeStatusDialogCtrl.closeDialog(); + } + }} + open={changeStatusDialogCtrl.open} + targets={changeStatusDialogCtrl.targets} + /> - - -
-
- -
-
-

Convertidas

-

892

-

- ↑ 15.7% vs. mes anterior -

-
-
-
-
-
+ { + if (!open) { + deleteDialogCtrl.closeDialog(); + } + }} + open={deleteDialogCtrl.open} + targets={deleteDialogCtrl.targets} + /> - {/* Table */} - {scopeConfig.enablePanel && isPanelOpen ? ( - - - {listContent} - + { + if (!open) { + archiveDialogCtrl.closeDialog(); + } + }} + open={archiveDialogCtrl.open} + target={archiveDialogCtrl.target} + /> - - - -
- 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} - /> -
-
-
- ) : ( -
{listContent}
- )} - <> - {/* Issue */} - { - if (!open) { - issueDialogCtrl.closeDialog(); + { + if (!open) { + unarchiveDialogCtrl.closeDialog(); + } + }} + open={unarchiveDialogCtrl.open} + target={unarchiveDialogCtrl.target} + /> + + } + primaryAction={ + scopeConfig.showCreateAction ? ( +
+ size="default" + > + + {t("pages.proformas.create.title")} + + ) : undefined + } + refreshError={refreshError} + title={t(scopeConfig.titleKey)} + /> ); }; diff --git a/modules/customer-invoices/src/web/proformas/list/ui/pages/scoped-list-proformas-page-view.test.tsx b/modules/customer-invoices/src/web/proformas/list/ui/pages/scoped-list-proformas-page-view.test.tsx new file mode 100644 index 00000000..2093ea90 --- /dev/null +++ b/modules/customer-invoices/src/web/proformas/list/ui/pages/scoped-list-proformas-page-view.test.tsx @@ -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 } | 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 }; + + 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( + Grid content
} + description="Listado principal de proformas" + primaryAction={} + title="Proformas" + /> + ); + + assert.match(html, /]*>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( + +
Grid content
+ +
+ } + dialogs={
Issue dialog mounted
} + 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( + Grid content
} + primaryAction={ + + } + 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); +}); diff --git a/modules/customer-invoices/src/web/proformas/list/ui/pages/scoped-list-proformas-page-view.tsx b/modules/customer-invoices/src/web/proformas/list/ui/pages/scoped-list-proformas-page-view.tsx new file mode 100644 index 00000000..ba02ee37 --- /dev/null +++ b/modules/customer-invoices/src/web/proformas/list/ui/pages/scoped-list-proformas-page-view.tsx @@ -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 ( + + + : undefined} + description={description} + title={title} + /> + + {refreshError ? ( + + ) : null} + + {content} + + + {dialogs} + + ); +}; diff --git a/modules/customer-invoices/src/web/proformas/update/ui/blocks/proforma-compact-totals.tsx b/modules/customer-invoices/src/web/proformas/update/ui/blocks/proforma-compact-totals.tsx index 1cc9c79a..9894f7b7 100644 --- a/modules/customer-invoices/src/web/proformas/update/ui/blocks/proforma-compact-totals.tsx +++ b/modules/customer-invoices/src/web/proformas/update/ui/blocks/proforma-compact-totals.tsx @@ -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 ( - - {/* Panel expandible con desglose */} + const isEmbedded = variant === "embedded"; + return ( +
-
+
{/* Descuentos */}
@@ -175,26 +181,23 @@ export const ProformaCompactTotals = ({
- {/* Barra principal de totales - siempre visible */} - - {/* Boton expandir */} - - {/* Totales en linea */} -
-
+
+
{t("proformas.update.totals.subtotalBeforeDiscounts", "Subtotal")} - + {formatMoney(totals.subtotalBeforeDiscounts)}
@@ -220,7 +223,7 @@ export const ProformaCompactTotals = ({ {formatMoney(totals.taxableBase)}
-
+
} /> @@ -252,20 +255,29 @@ export const ProformaCompactTotals = ({
) : null} - {/* Total destacado */} - - - - - - - Total{" "} - {formatMoney(totals.total)} - - - + {isEmbedded ? ( +
+ + Total + + {formatMoney(totals.total)} + +
+ ) : ( + + + + + + + Total{" "} + {formatMoney(totals.total)} + + + + )}
- - +
+
); }; diff --git a/modules/customer-invoices/src/web/proformas/update/ui/blocks/proforma-update-header.test.tsx b/modules/customer-invoices/src/web/proformas/update/ui/blocks/proforma-update-header.test.tsx new file mode 100644 index 00000000..99df5a1b --- /dev/null +++ b/modules/customer-invoices/src/web/proformas/update/ui/blocks/proforma-update-header.test.tsx @@ -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({ + defaultValues: buildProformaUpdateDefault(), + }); + + return ( + + undefined} + showFormActions={showFormActions} + /> + + ); + }; + + return renderToStaticMarkup(); +}; + +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/); +}); diff --git a/modules/customer-invoices/src/web/proformas/update/ui/blocks/proforma-update-header.tsx b/modules/customer-invoices/src/web/proformas/update/ui/blocks/proforma-update-header.tsx index 5afa186b..6c0250c2 100644 --- a/modules/customer-invoices/src/web/proformas/update/ui/blocks/proforma-update-header.tsx +++ b/modules/customer-invoices/src/web/proformas/update/ui/blocks/proforma-update-header.tsx @@ -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 = ({ ]} /> - onCancel?.()} - variant="outline" - /> + {showFormActions ? ( + <> + onCancel?.()} + variant="outline" + /> - + - + + + ) : null} } className={className} diff --git a/modules/customer-invoices/src/web/proformas/update/ui/editors/proforma-payment-editor.tsx b/modules/customer-invoices/src/web/proformas/update/ui/editors/proforma-payment-editor.tsx index 493d6a2c..882fadf4 100644 --- a/modules/customer-invoices/src/web/proformas/update/ui/editors/proforma-payment-editor.tsx +++ b/modules/customer-invoices/src/web/proformas/update/ui/editors/proforma-payment-editor.tsx @@ -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 ( - } title={t("form_groups.proformas.payment.title", "Condiciones de pago")} + variant="panel" > - + - - + + ); }; diff --git a/modules/customer-invoices/src/web/proformas/update/ui/editors/proforma-update-editor-form.test.tsx b/modules/customer-invoices/src/web/proformas/update/ui/editors/proforma-update-editor-form.test.tsx new file mode 100644 index 00000000..89b3d87b --- /dev/null +++ b/modules/customer-invoices/src/web/proformas/update/ui/editors/proforma-update-editor-form.test.tsx @@ -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({ + defaultValues: buildProformaUpdateDefault(), + }); + + return ( + + undefined} + onCreateCustomerClick={() => undefined} + onReset={() => undefined} + onSubmit={(event) => { + event.preventDefault(); + }} + paymentCtrl={paymentCtrl} + selectedCustomer={null} + seriesOptions={[]} + taxCtrl={taxCtrl} + totalsCtrl={totalsCtrl} + /> + + ); + }; + + return renderToStaticMarkup(); +}; + +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, /
+ + ); +}; diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index ebb8f436..f734b456 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -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)