From 00777b3ba2f4efabf136cfa8c3a189033246c579 Mon Sep 17 00:00:00 2001 From: david Date: Tue, 1 Sep 2026 11:36:20 +0200 Subject: [PATCH] feat: add create recurring proforma dialog and related functionality - Implemented CreateRecurringProformaFromProformaDialog component for creating recurring proformas from existing proformas. - Added unit tests for the new dialog and its integration within the proforma detail page. - Created API functions for handling the creation of recurring proformas. - Developed hooks to manage the creation process and invalidate relevant queries upon success. - Enhanced recurring proforma detail page to display relevant information and actions. - Added utility functions for formatting recurring proforma schedules. - Introduced layout components for better organization of recurring proforma UI. --- docs/README.md | 1 + docs/frontend/diseno-cuadros-dialogo.md | 194 ++++++++++ .../express/presenters/index.ts | 16 +- ...ecurring-proformas-read.presenters.test.ts | 37 +- .../src/common/locales/en.json | 56 +++ .../src/common/locales/es.json | 56 +++ .../src/web/customer-invoice-routes.tsx | 24 +- .../create/ui/pages/proforma-create-page.tsx | 18 +- .../web/proformas/detail/controllers/index.ts | 1 + ...a-from-proforma-dialog-controller.test.tsx | 166 +++++++++ ...roforma-from-proforma-dialog-controller.ts | 162 +++++++++ ...m-proforma-dialog-controller.utils.test.ts | 73 ++++ ...a-from-proforma-dialog-controller.utils.ts | 74 ++++ .../use-proforma-detail-page-controller.ts | 12 + .../proforma-detail-view-models.entity.ts | 1 + .../blocks/proforma-detail-header-actions.tsx | 3 +- ...ing-proforma-from-proforma-dialog.test.tsx | 128 +++++++ ...ecurring-proforma-from-proforma-dialog.tsx | 330 ++++++++++++++++++ .../proformas/detail/ui/components/index.ts | 1 + .../ui/pages/proforma-detail-page.test.tsx | 122 +++++++ .../detail/ui/pages/proforma-detail-page.tsx | 22 +- .../build-proforma-detail-actions.test.ts | 49 +++ .../utils/build-proforma-detail-actions.ts | 10 + .../shared/ui/blocks/proforma-page-header.tsx | 7 +- .../ui/pages/proforma-update-page.test.tsx | 3 +- .../update/ui/pages/proforma-update-page.tsx | 80 +++-- .../recurring-proforma-create-route.test.tsx | 2 +- .../web/recurring-proformas/detail/index.ts | 2 +- .../recurring-proformas/detail/ui/index.ts | 1 + .../detail/ui/pages/index.ts | 1 + .../pages}/recurring-proforma-detail-page.tsx | 21 +- ...curring-proforma-from-proforma.api.test.ts | 42 +++ ...te-recurring-proforma-from-proforma.api.ts | 18 + ...-recurring-proforma-from-proforma.test.tsx | 64 ++++ ...create-recurring-proforma-from-proforma.ts | 44 +++ ...ecurring-proforma-schedule-summary.test.ts | 77 ++++ .../recurring-proforma-schedule-summary.ts | 74 ++++ .../shared/ui/blocks/index.ts | 1 + .../ui/blocks/recurring-proforma-layout.tsx | 9 + .../recurring-proformas/shared/ui/index.ts | 1 + .../components/layout/rdx-page-container.tsx | 10 +- .../layout/rdx-page-foundations.test.tsx | 36 ++ .../src/components/layout/rdx-page-header.tsx | 75 ++++ .../src/components/layout/rdx-page-stack.tsx | 3 +- 44 files changed, 2056 insertions(+), 71 deletions(-) create mode 100644 docs/frontend/diseno-cuadros-dialogo.md create mode 100644 modules/customer-invoices/src/web/proformas/detail/controllers/use-create-recurring-proforma-from-proforma-dialog-controller.test.tsx create mode 100644 modules/customer-invoices/src/web/proformas/detail/controllers/use-create-recurring-proforma-from-proforma-dialog-controller.ts create mode 100644 modules/customer-invoices/src/web/proformas/detail/controllers/use-create-recurring-proforma-from-proforma-dialog-controller.utils.test.ts create mode 100644 modules/customer-invoices/src/web/proformas/detail/controllers/use-create-recurring-proforma-from-proforma-dialog-controller.utils.ts create mode 100644 modules/customer-invoices/src/web/proformas/detail/ui/components/create-recurring-proforma-from-proforma-dialog.test.tsx create mode 100644 modules/customer-invoices/src/web/proformas/detail/ui/components/create-recurring-proforma-from-proforma-dialog.tsx create mode 100644 modules/customer-invoices/src/web/proformas/detail/ui/pages/proforma-detail-page.test.tsx create mode 100644 modules/customer-invoices/src/web/proformas/detail/utils/build-proforma-detail-actions.test.ts create mode 100644 modules/customer-invoices/src/web/recurring-proformas/detail/ui/index.ts create mode 100644 modules/customer-invoices/src/web/recurring-proformas/detail/ui/pages/index.ts rename modules/customer-invoices/src/web/recurring-proformas/detail/{ => ui/pages}/recurring-proforma-detail-page.tsx (93%) create mode 100644 modules/customer-invoices/src/web/recurring-proformas/shared/api/create-recurring-proforma-from-proforma.api.test.ts create mode 100644 modules/customer-invoices/src/web/recurring-proformas/shared/api/create-recurring-proforma-from-proforma.api.ts create mode 100644 modules/customer-invoices/src/web/recurring-proformas/shared/hooks/use-create-recurring-proforma-from-proforma.test.tsx create mode 100644 modules/customer-invoices/src/web/recurring-proformas/shared/hooks/use-create-recurring-proforma-from-proforma.ts create mode 100644 modules/customer-invoices/src/web/recurring-proformas/shared/recurring-proforma-schedule-summary.test.ts create mode 100644 modules/customer-invoices/src/web/recurring-proformas/shared/recurring-proforma-schedule-summary.ts create mode 100644 modules/customer-invoices/src/web/recurring-proformas/shared/ui/blocks/index.ts create mode 100644 modules/customer-invoices/src/web/recurring-proformas/shared/ui/blocks/recurring-proforma-layout.tsx create mode 100644 modules/customer-invoices/src/web/recurring-proformas/shared/ui/index.ts diff --git a/docs/README.md b/docs/README.md index d20681ad..4869a5ce 100644 --- a/docs/README.md +++ b/docs/README.md @@ -27,6 +27,7 @@ Este directorio reúne la documentación técnica, operativa y de apoyo del ERP. - [Flujo de auth y selección de empresa](./frontend/auth-and-company-selection.md) - [Diseño de pantallas de listado](./frontend/diseno-pantallas-listado.md) +- [Diseño de cuadros de diálogo de formulario](./frontend/diseno-cuadros-dialogo.md) - [Sistema modular de Settings](./settings/frontend-erp-settings-system-design.md) - [Integración de módulos con Settings](./settings/frontend-erp-settings-module-integration-design.md) - [README de `apps/web`](../apps/web/README.md) diff --git a/docs/frontend/diseno-cuadros-dialogo.md b/docs/frontend/diseno-cuadros-dialogo.md new file mode 100644 index 00000000..35aecd00 --- /dev/null +++ b/docs/frontend/diseno-cuadros-dialogo.md @@ -0,0 +1,194 @@ +# Diseño de cuadros de diálogo de formulario + +## Estado + +Este documento es la fuente de verdad para diseñar, implementar y revisar cuadros de diálogo que recogen o modifican datos en el frontend del ERP. + +Todo diálogo nuevo de creación, edición o configuración debe seguir esta guía, salvo que una necesidad funcional justifique y documente una excepción. + +## Referencia canónica + +La referencia productiva es el diálogo para crear una proforma recurrente desde una proforma: + +- componente: `modules/customer-invoices/src/web/proformas/detail/ui/components/create-recurring-proforma-from-proforma-dialog.tsx`; +- controller: `modules/customer-invoices/src/web/proformas/detail/controllers/use-create-recurring-proforma-from-proforma-dialog-controller.ts`; +- prueba de renderizado: `modules/customer-invoices/src/web/proformas/detail/ui/components/create-recurring-proforma-from-proforma-dialog.test.tsx`. + +La guía no obliga a reutilizar ese componente. Obliga a mantener su jerarquía visual, separación de responsabilidades y comportamiento accesible. + +## Cuándo aplicar este patrón + +Usar `Dialog` para una tarea breve y focalizada que: + +- se inicia desde la pantalla actual; +- requiere pocos datos para completar una única acción; +- no necesita una ruta propia ni navegación lateral; +- permite volver al contexto sin perder orientación. + +No usarlo para: + +- confirmaciones o acciones destructivas sin formulario: usar `AlertDialog`; +- ediciones documentales complejas, varios pasos o trabajo prolongado: usar una página de edición; +- vistas de solo lectura: usar un diálogo de consulta o el patrón existente del área. + +## Estructura visual obligatoria + +Un diálogo de formulario se organiza de arriba abajo en cuatro zonas: + +1. Cabecera diferenciada. +2. Campo de identidad destacado cuando exista, normalmente el nombre. +3. Bloques de información agrupados por intención. +4. Pie de acciones separado del contenido. + +```tsx + + + {title} + {description} + + +
+ + + + + + + + + +
+``` + +No añadir una zona si no aporta información o una acción real. La estructura debe mejorar la lectura, no convertir un formulario pequeño en una pantalla artificialmente compleja. + +## 1. Cabecera diferenciada + +La cabecera comunica qué se va a hacer antes de pedir datos. + +- Incluir título directo, orientado a la acción: `Crear plazo de pago`, `Modificar método de pago`. +- Incluir una descripción breve que explique el resultado o el contexto de origen. +- Separarla visualmente del formulario mediante borde inferior y fondo sutil del tema (`bg-muted/40`). +- Mantener los márgenes internos alineados con contenido y pie. +- No colocar acciones de formulario en la cabecera; el cierre estándar del diálogo se mantiene disponible. + +## 2. Campo de identidad destacado + +Cuando el recurso tenga un nombre, código o referencia que ayude a reconocerlo, ese campo va inmediatamente después de la cabecera. + +- Usar el nombre como primer campo si es obligatorio para crear o identificar el recurso. +- Dar al control una presencia moderada (`h-11` y fondo de formulario) sin crear un segundo título. +- Mantener el error de validación junto al campo. +- No destacar campos técnicos, identificadores internos ni valores que el usuario no pueda modificar. + +Si el diálogo no tiene un campo de identidad relevante, el primer bloque de información pasa a ocupar esa posición. + +## 3. Bloques de información + +Agrupar campos que responden a la misma pregunta del usuario. Por ejemplo: periodo de vigencia, recurrencia, condiciones comerciales o destinatario. + +Cada bloque debe: + +- usar un `section` con `aria-labelledby`; +- tener un título visible y conciso; +- mostrar un icono solo cuando ayude a identificar el tipo de información; +- usar borde, radio y fondo sutil para delimitarlo sin competir con la acción principal; +- contener entre uno y seis controles relacionados por norma general. + +Patrón base: + +```tsx +
+
+ + + +

{sectionTitle}

+
+ +
+ + +
+
+``` + +Reglas de composición: + +- En escritorio, los campos de igual importancia pueden compartir dos columnas. +- En móvil, las columnas deben apilarse de forma natural. Usar `sm:grid-cols-2`, nunca una anchura fija. +- Añadir `min-w-0` a las columnas cuando puedan contener textos localizados, selectores o controles flexibles. +- Los selectores e inputs ocupan todo el ancho de su columna. +- Un campo condicional se muestra dentro del bloque al que pertenece y se separa con borde superior si necesita distinguirse del resto. +- Los interruptores y checkboxes que afectan a otro campo deben vivir junto a ese campo, no en una fila independiente sin contexto. + +## 4. Pie de acciones + +El pie mantiene las decisiones finales fuera de los datos. + +- Separarlo mediante borde superior y fondo sutil (`bg-muted/30`). +- Colocar `Cancelar` como acción secundaria y la acción de envío como botón principal. +- Durante el envío, deshabilitar ambos botones y todos los controles editables. +- El botón principal debe reflejar el estado de progreso con texto o icono accesible. +- Asociar el botón de envío al `form` mediante su atributo `form` cuando el pie quede fuera del elemento `form`. + +## Accesibilidad, validación e i18n + +- Usar `DialogTitle` y `DialogDescription` siempre que el diálogo tenga contenido operativo. +- Asociar cada `Label` a un control mediante `htmlFor` e `id`. +- Los títulos de bloque usan `section` y `aria-labelledby`. +- Mantener los errores junto al control que los genera; no depender únicamente de avisos globales. +- Todo texto visible procede de i18n y debe existir en `es` y `en`. +- Los iconos decorativos usan `aria-hidden`; nunca sustituyen una etiqueta textual. +- No bloquear el cierre por teclado salvo mientras exista una operación de envío en curso. + +## Responsabilidades del código + +El componente de UI compone el diálogo y recibe estado ya preparado. No debe contener navegación, peticiones HTTP ni reglas de negocio. + +El controller del diálogo concentra: + +- valores iniciales y reinicio del formulario; +- validación de formulario; +- llamada al hook de mutación; +- estado de envío; +- cierre, notificaciones y navegación tras un resultado correcto. + +Las operaciones remotas permanecen en hooks de TanStack Query. Los DTOs de petición siguen siendo la fuente de verdad del formulario y de la validación. + +## Pruebas mínimas + +Cada diálogo nuevo o modificado debe cubrir, como mínimo: + +- título, descripción y campo de identidad cuando exista; +- campos obligatorios y campos condicionales; +- acciones de cancelar y enviar; +- estado de envío; +- traducciones reales o una prueba que compruebe que las claves usadas existen en el catálogo; +- resultado correcto y error de la mutación en el controller. + +No usar un diccionario de traducciones manual en el test si el objetivo es verificar que el catálogo productivo contiene las claves consumidas por el componente. + +## Lista de comprobación + +Antes de cerrar un cambio, confirmar: + +- La tarea cabe realmente en un diálogo. +- La cabecera explica la acción y está diferenciada del formulario. +- El nombre o campo de identidad aparece antes de los bloques de configuración. +- Cada bloque agrupa una única intención comprensible. +- El layout se apila correctamente en móvil. +- Los textos, estados disabled, errores y carga son accesibles. +- Existen traducciones en `es` y `en`. +- Las pruebas y la validación de formato se han ejecutado desde Linux. + +## Excepciones + +Una excepción debe justificarse en la revisión del cambio indicando: + +- qué requisito funcional impide usar esta estructura; +- qué patrón alternativo se emplea; +- cómo se mantiene la accesibilidad y el comportamiento responsive; +- qué pruebas cubren la variante. + +La preferencia visual local, copiar un diálogo antiguo o ahorrar un bloque de composición no son motivos válidos para omitir esta guía. diff --git a/modules/customer-invoices/src/api/infrastructure/recurring-proformas/express/presenters/index.ts b/modules/customer-invoices/src/api/infrastructure/recurring-proformas/express/presenters/index.ts index 040bbf99..0b1dafbc 100644 --- a/modules/customer-invoices/src/api/infrastructure/recurring-proformas/express/presenters/index.ts +++ b/modules/customer-invoices/src/api/infrastructure/recurring-proformas/express/presenters/index.ts @@ -1,5 +1,7 @@ const dateOnly = (value: Date | null) => (value ? value.toISOString().slice(0, 10) : null); const id = (value: { toPrimitive(): string } | null) => value?.toPrimitive() ?? null; +const primitive = (value: { toPrimitive(): string } | string | null) => + typeof value === "string" ? value : (value?.toPrimitive() ?? null); export class RecurringProformaListPresenter { public present(page: any) { return { @@ -37,10 +39,10 @@ export class RecurringProformaDetailPresenter { description: item.description, payment_term_id: id(item.paymentTermId), payment_method_id: id(item.paymentMethodId), - language: item.language, - currency: item.currency, - proforma_series: item.proformaSeries, - issued_invoice_series: item.issuedInvoiceSeries, + language: primitive(item.language), + currency: primitive(item.currency), + proforma_series: primitive(item.proformaSeries), + issued_invoice_series: primitive(item.issuedInvoiceSeries), global_discount_percentage: item.globalDiscountPercentage?.toObjectString() ?? null, schedule: item.schedule ? { @@ -52,7 +54,11 @@ export class RecurringProformaDetailPresenter { } : null, next_occurrence_date: dateOnly(item.nextOccurrenceDate), - items: item.items, + items: item.items.map((recurringItem: any) => ({ + ...recurringItem, + id: id(recurringItem.id), + description: primitive(recurringItem.description), + })), created_at: item.createdAt.toISOString(), updated_at: item.updatedAt.toISOString(), }; diff --git a/modules/customer-invoices/src/api/infrastructure/recurring-proformas/express/presenters/recurring-proformas-read.presenters.test.ts b/modules/customer-invoices/src/api/infrastructure/recurring-proformas/express/presenters/recurring-proformas-read.presenters.test.ts index 751d61a4..44e63894 100644 --- a/modules/customer-invoices/src/api/infrastructure/recurring-proformas/express/presenters/recurring-proformas-read.presenters.test.ts +++ b/modules/customer-invoices/src/api/infrastructure/recurring-proformas/express/presenters/recurring-proformas-read.presenters.test.ts @@ -2,7 +2,9 @@ import assert from "node:assert/strict"; import test from "node:test"; import { DiscountPercentage } from "@erp/core/api"; -import { UniqueID } from "@repo/rdx-ddd"; +import { CurrencyCode, LanguageCode, UniqueID } from "@repo/rdx-ddd"; + +import { InvoiceSerie, ItemDescription } from "../../../../domain"; import { RecurringProformaDetailPresenter } from "./index"; @@ -31,3 +33,36 @@ test("RecurringProformaDetailPresenter conserva PercentageSchema como strings va assert.equal(typeof dto.global_discount_percentage.value, "string"); assert.equal(typeof dto.global_discount_percentage.scale, "string"); }); + +test("RecurringProformaDetailPresenter serializa Value Objects antes de responder HTTP", () => { + const itemDescription = ItemDescription.create("Servicio mensual").data; + const language = LanguageCode.create("es").data; + const currency = CurrencyCode.create("EUR").data; + const proformaSeries = InvoiceSerie.create("PRO").data; + const issuedInvoiceSeries = InvoiceSerie.create("FAC").data; + const dto = new RecurringProformaDetailPresenter().present({ + id: UniqueID.generateNewID(), + name: "Cuota", + status: "draft", + customerId: null, + description: null, + paymentTermId: null, + paymentMethodId: null, + language, + currency, + proformaSeries, + issuedInvoiceSeries, + globalDiscountPercentage: null, + schedule: null, + nextOccurrenceDate: null, + items: [{ id: UniqueID.generateNewID(), position: 1, description: itemDescription }], + createdAt: new Date("2027-01-01T10:00:00.000Z"), + updatedAt: new Date("2027-01-01T10:00:00.000Z"), + }); + + assert.equal(dto.language, "es"); + assert.equal(dto.currency, "EUR"); + assert.equal(dto.proforma_series, "PRO"); + assert.equal(dto.issued_invoice_series, "FAC"); + assert.equal(dto.items[0]?.description, "Servicio mensual"); +}); diff --git a/modules/customer-invoices/src/common/locales/en.json b/modules/customer-invoices/src/common/locales/en.json index d62bd742..c033d12f 100644 --- a/modules/customer-invoices/src/common/locales/en.json +++ b/modules/customer-invoices/src/common/locales/en.json @@ -60,6 +60,25 @@ "completed": "Completed", "archived": "Archived" }, + "schedule_summary": { + "sentence": "The proforma will be generated {{cadence}}{{monthlyRule}}, {{period}}.", + "cadence": { + "week_one": "every week", + "week_other": "every {{count}} weeks", + "month_one": "every month", + "month_other": "every {{count}} months", + "year_one": "every year", + "year_other": "every {{count}} years" + }, + "monthly_rule": { + "same_day": "on the same day of each month", + "last_day_of_month": "on the last day of each month" + }, + "period": { + "indefinite": "starting on {{startDate}} with no end date", + "until": "starting on {{startDate}} until {{endDate}}" + } + }, "detail": { "context": "Recurring proforma", "back": "Back to list", @@ -221,6 +240,42 @@ } }, "proformas": { + "create_from_proforma": { + "title": "Create recurring proforma", + "description": "Configure the initial recurrence using this proforma as the source.", + "period_title": "Validity period", + "recurrence_title": "Recurrence settings", + "name": "Name", + "name_required": "Name is required.", + "start_date": "Start date", + "start_date_invalid": "Start date is required.", + "end_date": "End date", + "end_date_invalid": "End date is invalid.", + "no_end_date": "No end date", + "frequency": "Frequency", + "interval": "Interval", + "interval_invalid": "The interval must be an integer greater than 0.", + "monthly_rule": "Monthly rule", + "monthly_rules": { + "same_day": "Same day of the month", + "last_day_of_month": "Last day of the month" + }, + "preview": { + "title": "Recurrence preview" + }, + "units": { + "week": "Week", + "month": "Month", + "year": "Year" + }, + "submit": "Create recurrence", + "creating": "Creating...", + "cancel": "Cancel", + "success_title": "Recurring proforma created", + "success_description": "The recurrence has been created as a draft.", + "error_title": "The recurrence could not be created", + "error_description": "Review the data and try again." + }, "archive": { "confirm_title": "Archive proforma", "confirm_description": "The proforma will be hidden from the main list but kept for historical lookup.", @@ -569,6 +624,7 @@ "change_status": "Change status", "issue": "Issue invoice", "duplicate": "Duplicate", + "create_recurring": "Create recurrence from this proforma", "archive": "Archive", "unarchive": "Unarchive", "download_pdf": "Download PDF", diff --git a/modules/customer-invoices/src/common/locales/es.json b/modules/customer-invoices/src/common/locales/es.json index 9a351530..04cc827f 100644 --- a/modules/customer-invoices/src/common/locales/es.json +++ b/modules/customer-invoices/src/common/locales/es.json @@ -60,6 +60,25 @@ "completed": "Completada", "archived": "Archivada" }, + "schedule_summary": { + "sentence": "La proforma se generará {{cadence}}{{monthlyRule}}, {{period}}.", + "cadence": { + "week_one": "cada semana", + "week_other": "cada {{count}} semanas", + "month_one": "cada mes", + "month_other": "cada {{count}} meses", + "year_one": "cada año", + "year_other": "cada {{count}} años" + }, + "monthly_rule": { + "same_day": "el mismo día de cada mes", + "last_day_of_month": "el último día de cada mes" + }, + "period": { + "indefinite": "a partir del {{startDate}} y sin fecha de finalización", + "until": "a partir del {{startDate}} y hasta el {{endDate}}" + } + }, "detail": { "context": "Proforma recurrente", "back": "Volver al listado", @@ -221,6 +240,42 @@ } }, "proformas": { + "create_from_proforma": { + "title": "Crear proforma recurrente", + "description": "Configura la recurrencia inicial usando esta proforma como origen.", + "period_title": "Periodo de vigencia", + "recurrence_title": "Configuracion de recurrencia", + "name": "Nombre", + "name_required": "El nombre es obligatorio.", + "start_date": "Fecha de inicio", + "start_date_invalid": "La fecha de inicio es obligatoria.", + "end_date": "Fecha de fin", + "end_date_invalid": "La fecha de fin no es valida.", + "no_end_date": "Sin fecha de fin", + "frequency": "Frecuencia", + "interval": "Intervalo", + "interval_invalid": "El intervalo debe ser un entero mayor que 0.", + "monthly_rule": "Regla mensual", + "monthly_rules": { + "same_day": "Mismo dia del mes", + "last_day_of_month": "Ultimo dia del mes" + }, + "preview": { + "title": "Vista previa de la recurrencia" + }, + "units": { + "week": "Semana", + "month": "Mes", + "year": "Año" + }, + "submit": "Crear recurrencia", + "creating": "Creando...", + "cancel": "Cancelar", + "success_title": "Proforma recurrente creada", + "success_description": "La recurrencia se ha creado como borrador.", + "error_title": "No se pudo crear la recurrencia", + "error_description": "Revisa los datos e intentalo de nuevo." + }, "archive": { "confirm_title": "Archivar proforma", "confirm_description": "La proforma se ocultará del listado principal, pero seguirá conservada para consulta histórica.", @@ -569,6 +624,7 @@ "change_status": "Cambiar estado", "issue": "Emitir factura", "duplicate": "Duplicar", + "create_recurring": "Crear recurrencia desde esta proforma", "archive": "Archivar", "unarchive": "Desarchivar", "download_pdf": "Descargar PDF", diff --git a/modules/customer-invoices/src/web/customer-invoice-routes.tsx b/modules/customer-invoices/src/web/customer-invoice-routes.tsx index 86646ab6..69e4410b 100644 --- a/modules/customer-invoices/src/web/customer-invoice-routes.tsx +++ b/modules/customer-invoices/src/web/customer-invoice-routes.tsx @@ -6,6 +6,10 @@ const ProformaLayout = lazy(() => import("./proformas/shared/ui").then((m) => ({ default: m.ProformaLayout })) ); +const RecurringProformaLayout = lazy(() => + import("./recurring-proformas/shared/ui").then((m) => ({ default: m.RecurringProformaLayout })) +); + const ProformasListPage = lazy(() => import("./proformas/list").then((m) => ({ default: m.ListProformasPage })) ); @@ -135,7 +139,11 @@ export const CustomerInvoiceRoutes = (params: ModuleClientParams): RouteObject[] layout: "app-sidebar", protected: true, }, - element: , + element: ( + + + + ), }, { path: "recurring-proformas/create", @@ -143,15 +151,23 @@ export const CustomerInvoiceRoutes = (params: ModuleClientParams): RouteObject[] layout: "app-fullscreen", protected: true, }, - element: , + element: ( + + + + ), }, { path: "recurring-proformas/:recurring_proforma_id", handle: { - layout: "app-sidebar", + layout: "app-fullscreen", protected: true, }, - element: , + element: ( + + + + ), }, { diff --git a/modules/customer-invoices/src/web/proformas/create/ui/pages/proforma-create-page.tsx b/modules/customer-invoices/src/web/proformas/create/ui/pages/proforma-create-page.tsx index 986b690f..332e5b1d 100644 --- a/modules/customer-invoices/src/web/proformas/create/ui/pages/proforma-create-page.tsx +++ b/modules/customer-invoices/src/web/proformas/create/ui/pages/proforma-create-page.tsx @@ -1,7 +1,7 @@ import { ErrorAlert } from "@erp/core/components"; import { UnsavedChangesProvider, useReturnToNavigation } from "@erp/core/hooks"; import { QuickCreateCustomerDialog, SelectCustomerDialog } from "@erp/customers"; -import { RdxEditorPageLayout } from "@repo/rdx-ui/components"; +import { RdxEditorPageLayout, RdxPageContainer, RdxPageStack } from "@repo/rdx-ui/components"; import { FormProvider } from "react-hook-form"; import { ProformaInfoAlert } from "../../../update/ui/components"; @@ -16,7 +16,7 @@ export const ProformaCreatePage = () => { }); return ( -
+ { -
+ -
+ {createCtrl.isCreateError && ( { selectedCustomer={createCtrl.selectedCustomer} targetInvoiceSeriesOptions={createCtrl.targetInvoiceSeriesOptions} /> -
+
-
+ {
-
+ ); }; diff --git a/modules/customer-invoices/src/web/proformas/detail/controllers/index.ts b/modules/customer-invoices/src/web/proformas/detail/controllers/index.ts index bad74a30..b77bf276 100644 --- a/modules/customer-invoices/src/web/proformas/detail/controllers/index.ts +++ b/modules/customer-invoices/src/web/proformas/detail/controllers/index.ts @@ -1 +1,2 @@ export * from "./use-proforma-detail-page-controller"; +export * from "./use-create-recurring-proforma-from-proforma-dialog-controller"; diff --git a/modules/customer-invoices/src/web/proformas/detail/controllers/use-create-recurring-proforma-from-proforma-dialog-controller.test.tsx b/modules/customer-invoices/src/web/proformas/detail/controllers/use-create-recurring-proforma-from-proforma-dialog-controller.test.tsx new file mode 100644 index 00000000..fadd2a57 --- /dev/null +++ b/modules/customer-invoices/src/web/proformas/detail/controllers/use-create-recurring-proforma-from-proforma-dialog-controller.test.tsx @@ -0,0 +1,166 @@ +import assert from "node:assert/strict"; +import { mock, test } from "node:test"; + +import { renderToStaticMarkup } from "react-dom/server"; + +import type { useCreateRecurringProformaFromProformaDialogController } from "./use-create-recurring-proforma-from-proforma-dialog-controller"; + +type Mutation = { + isPending: boolean; + mutateAsync: (params: unknown) => Promise<{ id: string }>; +}; + +type Controller = ReturnType; + +const createSubmitEvent = () => ({ + preventDefault: () => undefined, + persist: () => undefined, + stopPropagation: () => undefined, +}); + +test("el controlador evita envios duplicados, limpia la regla residual y mantiene el formulario ante error", async () => { + let mutation: Mutation; + let controller!: Controller; + const navigations: string[] = []; + const errors: string[] = []; + + mock.module("@erp/core", { + namedExports: { + applyValidationErrorCollection: () => undefined, + }, + }); + mock.module("@erp/core/hooks", { + namedExports: { + useHookForm: ({ initialValues }: { initialValues: Record }) => { + const values = { ...initialValues }; + + return { + formState: { errors: {} }, + getValues: (field: string) => values[field], + handleSubmit: + (onValid: (nextValues: typeof values) => Promise) => + async (event: ReturnType) => { + event.preventDefault(); + await onValid({ ...values }); + }, + reset: (nextValues: typeof values) => Object.assign(values, nextValues), + setValue: (field: string, value: unknown) => { + values[field] = value; + }, + watch: (field: string) => values[field], + }; + }, + }, + }); + mock.module("@repo/rdx-ddd", { + namedExports: { + isValidationErrorCollection: () => false, + }, + }); + mock.module("@repo/rdx-ui/helpers", { + namedExports: { + focusFirstInputFormError: () => undefined, + getFirstFormErrorMessage: () => undefined, + showErrorToast: (_title: string, description: string) => errors.push(description), + showSuccessToast: () => undefined, + showWarningToast: () => undefined, + }, + }); + mock.module("../../../i18n", { + namedExports: { + useTranslation: () => ({ t: (key: string) => key }), + }, + }); + mock.module( + "../../../recurring-proformas/shared/hooks/use-create-recurring-proforma-from-proforma", + { + namedExports: { + useCreateRecurringProformaFromProforma: () => mutation, + }, + } + ); + mock.module("../../../recurring-proformas/shared/recurring-proforma-navigation", { + namedExports: { + buildRecurringProformaDetailPath: (id: string) => `/recurring-proformas/${id}`, + }, + }); + mock.module("react-router-dom", { + namedExports: { + useNavigate: () => (path: string) => navigations.push(path), + }, + }); + + const { useCreateRecurringProformaFromProformaDialogController } = await import( + "./use-create-recurring-proforma-from-proforma-dialog-controller" + ); + const Harness = () => { + controller = useCreateRecurringProformaFromProformaDialogController({ + proformaId: "proforma-id", + proformaReference: "PF-2026-001", + }); + return null; + }; + + let resolveCreation: (value: { id: string }) => void = () => { + throw new Error("La creación no se ha iniciado"); + }; + let invocationCount = 0; + mutation = { + isPending: true, + mutateAsync: async (params) => { + invocationCount += 1; + assert.deepEqual(params, { + proformaId: "proforma-id", + data: { + name: "Recurrencia PF-2026-001", + schedule: { + startDate: "2026-09-01", + endDate: null, + unit: "week", + interval: 1, + monthlyRule: null, + }, + }, + }); + + return await new Promise<{ id: string }>((resolve) => { + resolveCreation = resolve; + }); + }, + }; + + renderToStaticMarkup(); + const firstController = controller; + + firstController.changeUnit("week"); + assert.equal(firstController.form.getValues("monthlyRule"), null); + const firstSubmit = firstController.onSubmit(createSubmitEvent() as never); + await new Promise((resolve) => setImmediate(resolve)); + const secondSubmit = firstController.onSubmit(createSubmitEvent() as never); + await new Promise((resolve) => setImmediate(resolve)); + assert.equal(invocationCount, 1); + + resolveCreation({ id: "recurring-test-id" }); + await Promise.all([firstSubmit, secondSubmit]); + assert.deepEqual(navigations, ["/recurring-proformas/recurring-test-id"]); + + firstController.changeUnit("month"); + assert.equal(firstController.form.getValues("monthlyRule"), "same_day"); + + mutation = { + isPending: false, + mutateAsync: async () => { + throw new Error("No se pudo crear la recurrencia"); + }, + }; + renderToStaticMarkup(); + const failedController = controller; + failedController.form.setValue("name", "Nombre conservado"); + await failedController.onSubmit(createSubmitEvent() as never); + + assert.equal(failedController.form.getValues("name"), "Nombre conservado"); + assert.deepEqual(navigations, ["/recurring-proformas/recurring-test-id"]); + assert.deepEqual(errors, ["No se pudo crear la recurrencia"]); + + mock.restoreAll(); +}); diff --git a/modules/customer-invoices/src/web/proformas/detail/controllers/use-create-recurring-proforma-from-proforma-dialog-controller.ts b/modules/customer-invoices/src/web/proformas/detail/controllers/use-create-recurring-proforma-from-proforma-dialog-controller.ts new file mode 100644 index 00000000..7b0da479 --- /dev/null +++ b/modules/customer-invoices/src/web/proformas/detail/controllers/use-create-recurring-proforma-from-proforma-dialog-controller.ts @@ -0,0 +1,162 @@ +import { applyValidationErrorCollection } from "@erp/core"; +import { useHookForm } from "@erp/core/hooks"; +import type { ValidationErrorCollection } from "@repo/rdx-ddd"; +import { isValidationErrorCollection } from "@repo/rdx-ddd"; +import { + focusFirstInputFormError, + getFirstFormErrorMessage, + showErrorToast, + showSuccessToast, + showWarningToast, +} from "@repo/rdx-ui/helpers"; +import { type FormEvent, useId, useRef, useState } from "react"; +import type { FieldErrors } from "react-hook-form"; +import { useNavigate } from "react-router-dom"; + +import { useTranslation } from "../../../i18n"; +import { useCreateRecurringProformaFromProforma } from "../../../recurring-proformas/shared/hooks/use-create-recurring-proforma-from-proforma"; +import { buildRecurringProformaDetailPath } from "../../../recurring-proformas/shared/recurring-proforma-navigation"; + +import { + buildCreateRecurringProformaFromProformaDefaultValues, + buildCreateRecurringProformaFromProformaInput, + type CreateRecurringProformaFromProformaForm, + CreateRecurringProformaFromProformaFormSchema, +} from "./use-create-recurring-proforma-from-proforma-dialog-controller.utils"; + +const normalizeSubmitError = (error: unknown): Error | ValidationErrorCollection => { + if (isValidationErrorCollection(error)) { + return error satisfies ValidationErrorCollection; + } + + if (error instanceof Error) { + return error satisfies Error; + } + + return new Error("Unknown error"); +}; + +export interface CreateRecurringProformaFromProformaDialogControllerParams { + proformaId: string; + proformaReference: string | null; +} + +export const useCreateRecurringProformaFromProformaDialogController = ({ + proformaId, + proformaReference, +}: CreateRecurringProformaFromProformaDialogControllerParams) => { + const { t } = useTranslation(); + const navigate = useNavigate(); + const formId = useId(); + const mutation = useCreateRecurringProformaFromProforma(); + const [open, setOpen] = useState(false); + const submitInProgressRef = useRef(false); + const form = useHookForm({ + resolverSchema: CreateRecurringProformaFromProformaFormSchema, + initialValues: buildCreateRecurringProformaFromProformaDefaultValues(proformaReference), + disabled: mutation.isPending, + }); + + const unit = form.watch("unit"); + + const changeUnit = (nextUnit: CreateRecurringProformaFromProformaForm["unit"]) => { + form.setValue("unit", nextUnit, { + shouldDirty: true, + shouldTouch: true, + shouldValidate: true, + }); + + form.setValue( + "monthlyRule", + nextUnit === "month" ? (form.getValues("monthlyRule") ?? "same_day") : null, + { + shouldDirty: true, + shouldTouch: true, + shouldValidate: true, + } + ); + }; + + const openDialog = () => { + form.reset(buildCreateRecurringProformaFromProformaDefaultValues(proformaReference)); + setOpen(true); + }; + + const closeDialog = () => { + if (mutation.isPending) { + return; + } + + setOpen(false); + }; + + const submitHandler = form.handleSubmit( + async (values) => { + if (submitInProgressRef.current) { + return; + } + + submitInProgressRef.current = true; + + try { + const created = await mutation.mutateAsync({ + proformaId, + data: buildCreateRecurringProformaFromProformaInput(values), + }); + + showSuccessToast( + t("proformas.create_from_proforma.success_title"), + t("proformas.create_from_proforma.success_description") + ); + + setOpen(false); + navigate(buildRecurringProformaDetailPath(created.id)); + } catch (error: unknown) { + const normalizedError = normalizeSubmitError(error); + + if (isValidationErrorCollection(normalizedError)) { + applyValidationErrorCollection(form, normalizedError); + focusFirstInputFormError(form.formState.errors, form); + const errorMessage = getFirstFormErrorMessage(form.formState.errors, form); + showWarningToast( + t("proformas.create_from_proforma.error_title"), + errorMessage ?? t("proformas.create_from_proforma.error_description") + ); + return; + } + + showErrorToast( + t("proformas.create_from_proforma.error_title"), + normalizedError.message || t("proformas.create_from_proforma.error_description") + ); + } finally { + submitInProgressRef.current = false; + } + }, + (errors: FieldErrors) => { + focusFirstInputFormError(errors, form); + const errorMessage = getFirstFormErrorMessage(errors, form); + showWarningToast( + t("proformas.create_from_proforma.error_title"), + errorMessage ?? t("proformas.create_from_proforma.error_description") + ); + } + ); + + const onSubmit = (event: FormEvent) => { + event.stopPropagation(); + return submitHandler(event); + }; + + return { + formId, + form, + open, + unit, + changeUnit, + isSubmitting: mutation.isPending, + openDialog, + closeDialog, + onSubmit, + }; +}; diff --git a/modules/customer-invoices/src/web/proformas/detail/controllers/use-create-recurring-proforma-from-proforma-dialog-controller.utils.test.ts b/modules/customer-invoices/src/web/proformas/detail/controllers/use-create-recurring-proforma-from-proforma-dialog-controller.utils.test.ts new file mode 100644 index 00000000..125ca853 --- /dev/null +++ b/modules/customer-invoices/src/web/proformas/detail/controllers/use-create-recurring-proforma-from-proforma-dialog-controller.utils.test.ts @@ -0,0 +1,73 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +import { + buildCreateRecurringProformaFromProformaDefaultValues, + buildCreateRecurringProformaFromProformaInput, + buildDefaultRecurringProformaName, +} from "./use-create-recurring-proforma-from-proforma-dialog-controller.utils"; + +test("propone un nombre estable a partir de la referencia de la proforma", () => { + assert.equal(buildDefaultRecurringProformaName("PF-2026-001"), "Recurrencia PF-2026-001"); + assert.equal(buildDefaultRecurringProformaName(null), ""); +}); + +test("crea los defaults esperados para el dialogo from-proforma", () => { + const defaults = buildCreateRecurringProformaFromProformaDefaultValues("PF-2026-001"); + + assert.deepEqual(defaults, { + name: "Recurrencia PF-2026-001", + startDate: "2026-09-01", + hasEndDate: false, + endDate: "", + unit: "month", + interval: 1, + monthlyRule: "same_day", + }); +}); + +test("genera un payload minimo con endDate null y monthlyRule solo cuando aplica", () => { + assert.deepEqual( + buildCreateRecurringProformaFromProformaInput({ + name: " Recurrencia PF-2026-001 ", + startDate: "2026-09-01", + hasEndDate: false, + endDate: "", + unit: "week", + interval: 2, + monthlyRule: null, + }), + { + name: "Recurrencia PF-2026-001", + schedule: { + startDate: "2026-09-01", + endDate: null, + unit: "week", + interval: 2, + monthlyRule: null, + }, + } + ); + + assert.deepEqual( + buildCreateRecurringProformaFromProformaInput({ + name: "Recurrencia PF-2026-001", + startDate: "2026-09-01", + hasEndDate: true, + endDate: "2026-12-31", + unit: "month", + interval: 1, + monthlyRule: "last_day_of_month", + }), + { + name: "Recurrencia PF-2026-001", + schedule: { + startDate: "2026-09-01", + endDate: "2026-12-31", + unit: "month", + interval: 1, + monthlyRule: "last_day_of_month", + }, + } + ); +}); diff --git a/modules/customer-invoices/src/web/proformas/detail/controllers/use-create-recurring-proforma-from-proforma-dialog-controller.utils.ts b/modules/customer-invoices/src/web/proformas/detail/controllers/use-create-recurring-proforma-from-proforma-dialog-controller.utils.ts new file mode 100644 index 00000000..32bad7e6 --- /dev/null +++ b/modules/customer-invoices/src/web/proformas/detail/controllers/use-create-recurring-proforma-from-proforma-dialog-controller.utils.ts @@ -0,0 +1,74 @@ +import { DateHelper } from "@repo/rdx-utils"; +import { z } from "zod/v4"; + +import type { CreateRecurringProformaFromProformaRequestDTO } from "../../../../common/dto/request/recurring-proformas/create-recurring-proforma-from-proforma.request.dto"; + +const dateOnlyField = z.iso.date("required"); + +export const CreateRecurringProformaFromProformaFormSchema = z + .object({ + name: z.string().trim().min(1, "required"), + startDate: dateOnlyField, + hasEndDate: z.boolean(), + endDate: z.string().trim(), + unit: z.enum(["week", "month", "year"]), + interval: z.coerce.number().int().positive(), + monthlyRule: z.enum(["same_day", "last_day_of_month"]).nullable(), + }) + .superRefine((value, context) => { + if (value.hasEndDate && !dateOnlyField.safeParse(value.endDate).success) { + context.addIssue({ + code: "custom", + message: "required", + path: ["endDate"], + }); + } + + if (value.unit === "month" && value.monthlyRule === null) { + context.addIssue({ + code: "custom", + message: "required", + path: ["monthlyRule"], + }); + } + + if (value.unit !== "month" && value.monthlyRule !== null) { + context.addIssue({ + code: "custom", + message: "invalid_type", + path: ["monthlyRule"], + }); + } + }); + +export type CreateRecurringProformaFromProformaForm = z.infer< + typeof CreateRecurringProformaFromProformaFormSchema +>; + +export const buildDefaultRecurringProformaName = (proformaReference: string | null): string => + proformaReference ? `Recurrencia ${proformaReference}` : ""; + +export const buildCreateRecurringProformaFromProformaDefaultValues = ( + proformaReference: string | null +): CreateRecurringProformaFromProformaForm => ({ + name: buildDefaultRecurringProformaName(proformaReference), + startDate: DateHelper.buildTodayIsoDate(), + hasEndDate: false, + endDate: "", + unit: "month", + interval: 1, + monthlyRule: "same_day", +}); + +export const buildCreateRecurringProformaFromProformaInput = ( + values: CreateRecurringProformaFromProformaForm +): CreateRecurringProformaFromProformaRequestDTO => ({ + name: values.name.trim(), + schedule: { + startDate: values.startDate, + endDate: values.hasEndDate ? values.endDate.trim() : null, + unit: values.unit, + interval: values.interval, + monthlyRule: values.unit === "month" ? values.monthlyRule : null, + }, +}); diff --git a/modules/customer-invoices/src/web/proformas/detail/controllers/use-proforma-detail-page-controller.ts b/modules/customer-invoices/src/web/proformas/detail/controllers/use-proforma-detail-page-controller.ts index f76ef80b..7a0179b5 100644 --- a/modules/customer-invoices/src/web/proformas/detail/controllers/use-proforma-detail-page-controller.ts +++ b/modules/customer-invoices/src/web/proformas/detail/controllers/use-proforma-detail-page-controller.ts @@ -38,6 +38,7 @@ import { buildProformaDetailHeaderViewModel, buildProformaDetailPageErrorViewModel, } from "../utils"; +import { useCreateRecurringProformaFromProformaDialogController } from "./use-create-recurring-proforma-from-proforma-dialog-controller"; const PROFORMAS_FALLBACK_PATH = "/proformas"; // biome-ignore lint/suspicious/noControlCharactersInRegex: @@ -97,6 +98,10 @@ export const useProformaDetailPageController = () => { const issueDialogCtrl = useIssueProformaDialogController(); const changeStatusDialogCtrl = useChangeProformaStatusDialogController(); + const createRecurringDialogCtrl = useCreateRecurringProformaFromProformaDialogController({ + proformaId: id, + proformaReference: proformaQuery.data?.proformaReference ?? null, + }); const previewPdfQuery = useDownloadProformaPDFQuery(id, { enabled: Boolean(id), }); @@ -447,6 +452,7 @@ export const useProformaDetailPageController = () => { changeStatus: t("pages.proformas.list.actions.change_status"), issue: t("pages.proformas.list.actions.issue"), duplicate: t("pages.proformas.list.actions.duplicate"), + createRecurring: t("pages.proformas.list.actions.create_recurring"), openPdf: t("proformas.detail.document.actions.open"), downloadPdf: t("pages.proformas.list.actions.download_pdf"), archive: t("pages.proformas.list.actions.archive"), @@ -467,6 +473,7 @@ export const useProformaDetailPageController = () => { loading: { issue: issueDialogCtrl.isSubmitting, duplicate: duplicateMutation.isPending, + "create-recurring": createRecurringDialogCtrl.isSubmitting, "open-pdf": previewPdfQuery.isFetching && !previewPdfBlob, "download-pdf": previewPdfQuery.isFetching && !previewPdfBlob, archive: archiveMutation.isPending, @@ -476,6 +483,7 @@ export const useProformaDetailPageController = () => { }, [ archiveMutation.isPending, duplicateMutation.isPending, + createRecurringDialogCtrl.isSubmitting, issueDialogCtrl.isSubmitting, previewPdfBlob, previewPdfQuery.isFetching, @@ -509,6 +517,9 @@ export const useProformaDetailPageController = () => { case "duplicate": handleDuplicate(); return; + case "create-recurring": + createRecurringDialogCtrl.openDialog(); + return; case "open-pdf": handleOpenPreviewPdf().catch((error: unknown) => { showErrorToast( @@ -551,6 +562,7 @@ export const useProformaDetailPageController = () => { dialogs: { issue: issueDialogCtrl, changeStatus: changeStatusDialogCtrl, + createRecurring: createRecurringDialogCtrl, }, viewer: { handleDownload, diff --git a/modules/customer-invoices/src/web/proformas/detail/entities/proforma-detail-view-models.entity.ts b/modules/customer-invoices/src/web/proformas/detail/entities/proforma-detail-view-models.entity.ts index b698c483..270be7a4 100644 --- a/modules/customer-invoices/src/web/proformas/detail/entities/proforma-detail-view-models.entity.ts +++ b/modules/customer-invoices/src/web/proformas/detail/entities/proforma-detail-view-models.entity.ts @@ -135,6 +135,7 @@ export type ProformaDetailActionKey = | "change-status" | "issue" | "duplicate" + | "create-recurring" | "open-pdf" | "download-pdf" | "archive" diff --git a/modules/customer-invoices/src/web/proformas/detail/ui/blocks/proforma-detail-header-actions.tsx b/modules/customer-invoices/src/web/proformas/detail/ui/blocks/proforma-detail-header-actions.tsx index 48aa32ba..64671a4a 100644 --- a/modules/customer-invoices/src/web/proformas/detail/ui/blocks/proforma-detail-header-actions.tsx +++ b/modules/customer-invoices/src/web/proformas/detail/ui/blocks/proforma-detail-header-actions.tsx @@ -44,6 +44,8 @@ export const ProformaDetailHeaderActions = ({ return FileOutputIcon; case "duplicate": return CopyIcon; + case "create-recurring": + return RefreshCwIcon; case "open-pdf": return ExternalLinkIcon; case "download-pdf": @@ -52,7 +54,6 @@ export const ProformaDetailHeaderActions = ({ return ArchiveIcon; case "unarchive": return Undo2Icon; - case "edit": default: return PencilIcon; } diff --git a/modules/customer-invoices/src/web/proformas/detail/ui/components/create-recurring-proforma-from-proforma-dialog.test.tsx b/modules/customer-invoices/src/web/proformas/detail/ui/components/create-recurring-proforma-from-proforma-dialog.test.tsx new file mode 100644 index 00000000..e0af54fc --- /dev/null +++ b/modules/customer-invoices/src/web/proformas/detail/ui/components/create-recurring-proforma-from-proforma-dialog.test.tsx @@ -0,0 +1,128 @@ +import assert from "node:assert/strict"; +import { mock, test } from "node:test"; + +import type { ReactNode } from "react"; +import { renderToStaticMarkup } from "react-dom/server"; +import { useForm } from "react-hook-form"; + +import esResources from "../../../../../common/locales/es.json"; +import type { CreateRecurringProformaFromProformaForm } from "../../controllers/use-create-recurring-proforma-from-proforma-dialog-controller.utils"; + +const isRecord = (value: unknown): value is Record => + typeof value === "object" && value !== null; + +const getTranslationValue = (key: string): unknown => + key.split(".").reduce((current, segment) => { + return isRecord(current) ? current[segment] : undefined; + }, esResources); + +const getTranslation = (key: string, options: Record = {}): string => { + const pluralKey = + typeof options.count === "number" ? `${key}_${options.count === 1 ? "one" : "other"}` : key; + const value = getTranslationValue(pluralKey) ?? getTranslationValue(key); + + return typeof value === "string" + ? value.replace(/{{(\w+)}}/g, (_, name: string) => String(options[name] ?? "")) + : key; +}; + +const renderDialog = async (unit: CreateRecurringProformaFromProformaForm["unit"]) => { + mock.module("../../../../i18n", { + namedExports: { + useTranslation: () => ({ + i18n: { language: "es" }, + t: getTranslation, + }), + }, + }); + + mock.module("@repo/shadcn-ui/components", { + namedExports: { + Button: ({ children, ...props }: Record) => ( + + ), + Checkbox: (props: Record) => , + Dialog: ({ children }: { children?: ReactNode }) =>
{children}
, + DialogContent: ({ children, ...props }: Record) => ( +
{children as ReactNode}
+ ), + DialogDescription: ({ children }: { children?: ReactNode }) =>

{children}

, + DialogFooter: ({ children }: { children?: ReactNode }) =>
{children}
, + DialogHeader: ({ children }: { children?: ReactNode }) =>
{children}
, + DialogTitle: ({ children }: { children?: ReactNode }) =>

{children}

, + Input: (props: Record) => , + Label: ({ children }: Record) => {children as ReactNode}, + Select: ({ children }: { children?: ReactNode }) =>
{children}
, + SelectContent: ({ children }: { children?: ReactNode }) =>
{children}
, + SelectItem: ({ children, value }: { children?: ReactNode; value: string }) => ( + + ), + SelectTrigger: ({ children, ...props }: Record) => ( + + ), + Spinner: () => spinner, + }, + }); + + const { CreateRecurringProformaFromProformaDialog } = await import( + "./create-recurring-proforma-from-proforma-dialog" + ); + + const Harness = () => { + const form = useForm({ + defaultValues: { + name: "Recurrencia PF-2026-001", + startDate: "2026-09-01", + hasEndDate: false, + endDate: "", + unit, + interval: 1, + monthlyRule: unit === "month" ? "same_day" : null, + }, + }); + + return ( + undefined} + onSubmit={() => undefined} + onUnitChange={() => undefined} + open + unit={unit} + /> + ); + }; + + const markup = renderToStaticMarkup(); + mock.restoreAll(); + + return markup; +}; + +test("renderiza el dialogo productivo con los campos esperados y la regla mensual", async () => { + const markup = await renderDialog("month"); + + assert.match(markup, /Crear proforma recurrente/); + assert.match(markup, /id="create-recurring-from-proforma-name"/); + assert.match(markup, /id="create-recurring-from-proforma-start-date"/); + assert.match(markup, /id="create-recurring-from-proforma-end-date"/); + assert.match(markup, /id="create-recurring-from-proforma-unit"/); + assert.match(markup, /id="create-recurring-from-proforma-interval"/); + assert.match(markup, /id="create-recurring-from-proforma-monthly-rule"/); + assert.match(markup, /Sin fecha de fin/); + assert.match( + markup, + /La proforma se generará cada mes el mismo día de cada mes, a partir del 1 de septiembre de 2026 y sin fecha de finalización\./ + ); + assert.match(markup, /data-slot="selected-unit">Mes/); + assert.match(markup, /data-slot="selected-monthly-rule">Mismo dia del mes/); + assert.doesNotMatch(markup, /proformas\.create_from_proforma/); +}); + +test("oculta la regla mensual cuando la unidad no es month", async () => { + const markup = await renderDialog("week"); + + assert.doesNotMatch(markup, /create-recurring-from-proforma-monthly-rule/); +}); diff --git a/modules/customer-invoices/src/web/proformas/detail/ui/components/create-recurring-proforma-from-proforma-dialog.tsx b/modules/customer-invoices/src/web/proformas/detail/ui/components/create-recurring-proforma-from-proforma-dialog.tsx new file mode 100644 index 00000000..55546b24 --- /dev/null +++ b/modules/customer-invoices/src/web/proformas/detail/ui/components/create-recurring-proforma-from-proforma-dialog.tsx @@ -0,0 +1,330 @@ +import { + Button, + Checkbox, + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, + Input, + Label, + Select, + SelectContent, + SelectItem, + SelectTrigger, + Spinner, +} from "@repo/shadcn-ui/components"; +import { CalendarDaysIcon, Repeat2Icon } from "lucide-react"; +import type { FormEvent } from "react"; +import type { UseFormReturn } from "react-hook-form"; +import { Controller } from "react-hook-form"; + +import { useTranslation } from "../../../../i18n"; +import { formatRecurringProformaScheduleSummary } from "../../../../recurring-proformas/shared/recurring-proforma-schedule-summary"; +import type { CreateRecurringProformaFromProformaForm } from "../../controllers/use-create-recurring-proforma-from-proforma-dialog-controller.utils"; + +interface CreateRecurringProformaFromProformaDialogProps { + formId: string; + open: boolean; + isSubmitting: boolean; + unit: CreateRecurringProformaFromProformaForm["unit"]; + form: UseFormReturn; + onUnitChange: (unit: CreateRecurringProformaFromProformaForm["unit"]) => void; + onOpenChange: (open: boolean) => void; + onSubmit: (event: FormEvent) => void; +} + +export const CreateRecurringProformaFromProformaDialog = ({ + formId, + open, + isSubmitting, + unit, + form, + onUnitChange, + onOpenChange, + onSubmit, +}: CreateRecurringProformaFromProformaDialogProps) => { + const { t, i18n } = useTranslation(); + const formValues = form.watch(); + const hasEndDate = form.watch("hasEndDate"); + const errors = form.formState.errors; + const scheduleSummary = formatRecurringProformaScheduleSummary( + { + startDate: formValues.startDate, + endDate: formValues.hasEndDate ? formValues.endDate : null, + unit, + interval: formValues.interval, + monthlyRule: formValues.monthlyRule, + }, + { + locale: i18n.language === "en" ? "en-US" : "es-ES", + t: (key, options) => String(t(key, options)), + } + ); + + return ( + { + if (isSubmitting) { + return; + } + + onOpenChange(nextOpen); + }} + open={open} + > + + + {t("proformas.create_from_proforma.title")} + + {t("proformas.create_from_proforma.description")} + + + +
+
+ + + {errors.name ? ( +

+ {t("proformas.create_from_proforma.name_required")} +

+ ) : null} +
+ +
+
+ + + +

+ {t("proformas.create_from_proforma.period_title")} +

+
+ +
+
+ + + {errors.startDate ? ( +

+ {t("proformas.create_from_proforma.start_date_invalid")} +

+ ) : null} +
+ +
+
+ + ( + + )} + /> +
+ + {errors.endDate ? ( +

+ {t("proformas.create_from_proforma.end_date_invalid")} +

+ ) : null} +
+
+
+ +
+
+ + + +

+ {t("proformas.create_from_proforma.recurrence_title")} +

+
+ +
+
+ + ( + + )} + /> +
+ +
+ + + {errors.interval ? ( +

+ {t("proformas.create_from_proforma.interval_invalid")} +

+ ) : null} +
+
+ + {unit === "month" ? ( +
+ + ( + + )} + /> +
+ ) : null} +
+ + {scheduleSummary ? ( + + ) : null} +
+ + + + + +
+
+ ); +}; diff --git a/modules/customer-invoices/src/web/proformas/detail/ui/components/index.ts b/modules/customer-invoices/src/web/proformas/detail/ui/components/index.ts index 14166ff8..a0cb85ca 100644 --- a/modules/customer-invoices/src/web/proformas/detail/ui/components/index.ts +++ b/modules/customer-invoices/src/web/proformas/detail/ui/components/index.ts @@ -1,4 +1,5 @@ export * from "./proforma-detail-archived-badge"; +export * from "./create-recurring-proforma-from-proforma-dialog"; export * from "./proforma-detail-field"; export * from "./proforma-detail-header-meta"; export * from "./proforma-detail-section"; diff --git a/modules/customer-invoices/src/web/proformas/detail/ui/pages/proforma-detail-page.test.tsx b/modules/customer-invoices/src/web/proformas/detail/ui/pages/proforma-detail-page.test.tsx new file mode 100644 index 00000000..0250a0b1 --- /dev/null +++ b/modules/customer-invoices/src/web/proformas/detail/ui/pages/proforma-detail-page.test.tsx @@ -0,0 +1,122 @@ +import assert from "node:assert/strict"; +import { mock, test } from "node:test"; + +import type { ReactNode } from "react"; +import { renderToStaticMarkup } from "react-dom/server"; + +test("cablea el dialogo de crear recurrencia desde el detalle de la proforma", async () => { + let capturedHeaderProps: Record | undefined; + let capturedDialogProps: Record | undefined; + + mock.module("@erp/core/components", { + namedExports: { + PageErrorState: () => null, + }, + }); + + mock.module("../../controllers", { + namedExports: { + useProformaDetailPageController: () => ({ + state: { isInitialLoading: false, pageError: null }, + header: {}, + document: {}, + general: {}, + actions: {}, + navigation: { + navigateBack: () => undefined, + openCustomerInNewTab: () => undefined, + openLinkedInvoiceInNewTab: () => undefined, + }, + handlers: { + executeAction: () => undefined, + }, + viewer: { + handleDownload: () => undefined, + handleOpenPreviewPdf: () => undefined, + handlePrintPreview: () => undefined, + handleRetryDocument: async () => undefined, + }, + issuanceReadiness: null, + documentState: {}, + dialogs: { + issue: { + blockerMessages: [], + readiness: null, + isReadinessLoading: false, + isSubmitting: false, + confirmIssue: () => undefined, + openEdit: () => undefined, + closeDialog: () => undefined, + retryReadiness: () => undefined, + open: false, + readinessError: null, + target: null, + }, + changeStatus: { + isSubmitting: false, + confirmChangeStatus: () => undefined, + closeDialog: () => undefined, + open: false, + targets: [], + }, + createRecurring: { + form: {}, + formId: "create-recurring-form", + isSubmitting: false, + changeUnit: () => undefined, + closeDialog: () => undefined, + onSubmit: () => undefined, + open: true, + unit: "month", + }, + }, + }), + }, + }); + + mock.module("../blocks", { + namedExports: { + ProformaDetailContent: ({ children }: { children?: ReactNode }) => children ?? null, + ProformaDetailHeader: (props: Record) => { + capturedHeaderProps = props; + return null; + }, + ProformaDetailInspector: () => null, + ProformaDocumentPane: () => null, + }, + }); + + mock.module("../components", { + namedExports: { + ProformaDetailSkeleton: () => null, + CreateRecurringProformaFromProformaDialog: (props: Record) => { + capturedDialogProps = props; + return null; + }, + }, + }); + + mock.module("../../../change-status", { + namedExports: { + ChangeProformaStatusDialog: () => null, + }, + }); + + mock.module("../../../issue-proforma", { + namedExports: { + IssueProformaDialog: () => null, + }, + }); + + const { ProformaDetailPage } = await import("./proforma-detail-page"); + + renderToStaticMarkup(); + + assert.ok(capturedHeaderProps); + assert.equal(capturedDialogProps?.formId, "create-recurring-form"); + assert.equal(capturedDialogProps?.open, true); + assert.equal(capturedDialogProps?.unit, "month"); + assert.equal(typeof capturedDialogProps?.onUnitChange, "function"); + + mock.restoreAll(); +}); diff --git a/modules/customer-invoices/src/web/proformas/detail/ui/pages/proforma-detail-page.tsx b/modules/customer-invoices/src/web/proformas/detail/ui/pages/proforma-detail-page.tsx index 053b8480..68725fd9 100644 --- a/modules/customer-invoices/src/web/proformas/detail/ui/pages/proforma-detail-page.tsx +++ b/modules/customer-invoices/src/web/proformas/detail/ui/pages/proforma-detail-page.tsx @@ -1,4 +1,5 @@ import { PageErrorState } from "@erp/core/components"; +import { RdxPageStack } from "@repo/rdx-ui/components"; import { ChangeProformaStatusDialog } from "../../../change-status"; import { IssueProformaDialog } from "../../../issue-proforma"; @@ -9,7 +10,7 @@ import { ProformaDetailInspector, ProformaDocumentPane, } from "../blocks"; -import { ProformaDetailSkeleton } from "../components"; +import { CreateRecurringProformaFromProformaDialog, ProformaDetailSkeleton } from "../components"; export const ProformaDetailPage = () => { const ctrl = useProformaDetailPageController(); @@ -40,7 +41,7 @@ export const ProformaDetailPage = () => { } return ( -
+ { open={ctrl.dialogs.changeStatus.open} targets={ctrl.dialogs.changeStatus.targets} /> -
+ + { + if (!open) { + ctrl.dialogs.createRecurring.closeDialog(); + } + }} + onSubmit={ctrl.dialogs.createRecurring.onSubmit} + onUnitChange={ctrl.dialogs.createRecurring.changeUnit} + open={ctrl.dialogs.createRecurring.open} + unit={ctrl.dialogs.createRecurring.unit} + /> + ); }; diff --git a/modules/customer-invoices/src/web/proformas/detail/utils/build-proforma-detail-actions.test.ts b/modules/customer-invoices/src/web/proformas/detail/utils/build-proforma-detail-actions.test.ts new file mode 100644 index 00000000..a7284297 --- /dev/null +++ b/modules/customer-invoices/src/web/proformas/detail/utils/build-proforma-detail-actions.test.ts @@ -0,0 +1,49 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +import { buildProformaDetailActions } from "./build-proforma-detail-actions"; + +const labels = { + edit: "Editar", + changeStatus: "Cambiar estado", + issue: "Emitir", + duplicate: "Duplicar", + createRecurring: "Crear recurrencia desde esta proforma", + openPdf: "Abrir PDF", + downloadPdf: "Descargar PDF", + archive: "Archivar", + unarchive: "Desarchivar", + disabledReasons: { + archivedOnly: "Solo archivadas", + archiveConflict: "No se puede archivar", + editIssued: "Emitida", + editArchived: "Archivada", + statusIssued: "Emitida", + statusArchived: "Archivada", + issueNotAllowed: "No permitido", + operationInProgress: "En curso", + }, +}; + +test("incluye la accion de crear recurrencia en el menu de detalle", () => { + const actions = buildProformaDetailActions({ + status: "draft", + isArchived: false, + canIssue: false, + labels, + loading: {}, + }); + + assert.deepEqual( + actions.items.find((item) => item.key === "create-recurring"), + { + key: "create-recurring", + label: "Crear recurrencia desde esta proforma", + placement: "menu", + visibility: "visible", + enabled: true, + loading: false, + disabledReason: null, + } + ); +}); diff --git a/modules/customer-invoices/src/web/proformas/detail/utils/build-proforma-detail-actions.ts b/modules/customer-invoices/src/web/proformas/detail/utils/build-proforma-detail-actions.ts index 87b75608..1785363d 100644 --- a/modules/customer-invoices/src/web/proformas/detail/utils/build-proforma-detail-actions.ts +++ b/modules/customer-invoices/src/web/proformas/detail/utils/build-proforma-detail-actions.ts @@ -11,6 +11,7 @@ export interface ProformaDetailActionLabels { changeStatus: string; issue: string; duplicate: string; + createRecurring: string; openPdf: string; downloadPdf: string; archive: string; @@ -121,6 +122,15 @@ export const buildProformaDetailActions = ({ loading.duplicate ?? false, null ), + buildAction( + "create-recurring", + labels.createRecurring, + "menu", + "visible", + true, + loading["create-recurring"] ?? false, + null + ), buildAction( "open-pdf", labels.openPdf, diff --git a/modules/customer-invoices/src/web/proformas/shared/ui/blocks/proforma-page-header.tsx b/modules/customer-invoices/src/web/proformas/shared/ui/blocks/proforma-page-header.tsx index 4b65b002..01930c5d 100644 --- a/modules/customer-invoices/src/web/proformas/shared/ui/blocks/proforma-page-header.tsx +++ b/modules/customer-invoices/src/web/proformas/shared/ui/blocks/proforma-page-header.tsx @@ -1,4 +1,4 @@ -import { PageFormHeader } from "@erp/core/components"; +import { RdxPageHeader } from "@repo/rdx-ui/components"; import { cn } from "@repo/shadcn-ui/lib/utils"; import type { ReactNode } from "react"; @@ -26,7 +26,7 @@ export const ProformaPageHeader = ({ contentClassName, }: ProformaPageHeaderProps) => { return ( - {children} - + ); }; diff --git a/modules/customer-invoices/src/web/proformas/update/ui/pages/proforma-update-page.test.tsx b/modules/customer-invoices/src/web/proformas/update/ui/pages/proforma-update-page.test.tsx index b1052a17..b91cd856 100644 --- a/modules/customer-invoices/src/web/proformas/update/ui/pages/proforma-update-page.test.tsx +++ b/modules/customer-invoices/src/web/proformas/update/ui/pages/proforma-update-page.test.tsx @@ -48,10 +48,11 @@ test("conecta la alta rápida directa y la alta rápida desde selector con callb mock.module("@repo/rdx-ui/components", { namedExports: { - AppContent: ({ children }: { children: ReactNode }) => children, CancelActionButton: () => null, PercentageField: () => null, RdxEditorStickyFooter: () => null, + RdxPageContainer: ({ children }: { children: ReactNode }) => children, + RdxPageStack: ({ children }: { children: ReactNode }) => children, RhfSubmitActionButton: () => null, }, }); diff --git a/modules/customer-invoices/src/web/proformas/update/ui/pages/proforma-update-page.tsx b/modules/customer-invoices/src/web/proformas/update/ui/pages/proforma-update-page.tsx index ce081aa1..6ef23f22 100644 --- a/modules/customer-invoices/src/web/proformas/update/ui/pages/proforma-update-page.tsx +++ b/modules/customer-invoices/src/web/proformas/update/ui/pages/proforma-update-page.tsx @@ -2,10 +2,11 @@ import { ErrorAlert, PageErrorState } from "@erp/core/components"; import { UnsavedChangesProvider, useReturnToNavigation } from "@erp/core/hooks"; import { QuickCreateCustomerDialog, SelectCustomerDialog } from "@erp/customers"; import { - AppContent, CancelActionButton, PercentageField, RdxEditorStickyFooter, + RdxPageContainer, + RdxPageStack, RhfSubmitActionButton, } from "@repo/rdx-ui/components"; import { showErrorToast } from "@repo/rdx-ui/helpers"; @@ -52,7 +53,7 @@ export const ProformaUpdatePage = () => { if (ctrl.pageError) { return ( - +
{ variant={ctrl.pageError.variant} />
-
+ ); } + const hasPageAlerts = updateCtrl.isUpdateError || updateCtrl.isSeriesLoadError; + return ( -
+ { />
-
- {updateCtrl.isUpdateError && ( - - )} + + {hasPageAlerts ? ( + + + {updateCtrl.isUpdateError && ( + + )} - {updateCtrl.isSeriesLoadError && ( - - )} + {updateCtrl.isSeriesLoadError && ( + + )} + + + ) : null} { taxCtrl={updateCtrl.taxCtrl} totalsCtrl={updateCtrl.totalsCtrl} /> -
+ { /> -
+
); }; diff --git a/modules/customer-invoices/src/web/recurring-proformas/create/recurring-proforma-create-route.test.tsx b/modules/customer-invoices/src/web/recurring-proformas/create/recurring-proforma-create-route.test.tsx index 62bbc177..0e0f54fb 100644 --- a/modules/customer-invoices/src/web/recurring-proformas/create/recurring-proforma-create-route.test.tsx +++ b/modules/customer-invoices/src/web/recurring-proformas/create/recurring-proforma-create-route.test.tsx @@ -5,8 +5,8 @@ import { mock, test } from "node:test"; import { i18n } from "@repo/i18next"; import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; -import { useForm } from "react-hook-form"; import { renderToReadableStream } from "react-dom/server"; +import { useForm } from "react-hook-form"; import { I18nextProvider } from "react-i18next"; import { RouterProvider, createMemoryRouter } from "react-router-dom"; diff --git a/modules/customer-invoices/src/web/recurring-proformas/detail/index.ts b/modules/customer-invoices/src/web/recurring-proformas/detail/index.ts index e4886d69..4aedf593 100644 --- a/modules/customer-invoices/src/web/recurring-proformas/detail/index.ts +++ b/modules/customer-invoices/src/web/recurring-proformas/detail/index.ts @@ -1 +1 @@ -export * from "./recurring-proforma-detail-page"; +export * from "./ui"; diff --git a/modules/customer-invoices/src/web/recurring-proformas/detail/ui/index.ts b/modules/customer-invoices/src/web/recurring-proformas/detail/ui/index.ts new file mode 100644 index 00000000..c4e34b27 --- /dev/null +++ b/modules/customer-invoices/src/web/recurring-proformas/detail/ui/index.ts @@ -0,0 +1 @@ +export * from "./pages"; diff --git a/modules/customer-invoices/src/web/recurring-proformas/detail/ui/pages/index.ts b/modules/customer-invoices/src/web/recurring-proformas/detail/ui/pages/index.ts new file mode 100644 index 00000000..e4886d69 --- /dev/null +++ b/modules/customer-invoices/src/web/recurring-proformas/detail/ui/pages/index.ts @@ -0,0 +1 @@ +export * from "./recurring-proforma-detail-page"; diff --git a/modules/customer-invoices/src/web/recurring-proformas/detail/recurring-proforma-detail-page.tsx b/modules/customer-invoices/src/web/recurring-proformas/detail/ui/pages/recurring-proforma-detail-page.tsx similarity index 93% rename from modules/customer-invoices/src/web/recurring-proformas/detail/recurring-proforma-detail-page.tsx rename to modules/customer-invoices/src/web/recurring-proformas/detail/ui/pages/recurring-proforma-detail-page.tsx index b2c1d47e..29d4e47a 100644 --- a/modules/customer-invoices/src/web/recurring-proformas/detail/recurring-proforma-detail-page.tsx +++ b/modules/customer-invoices/src/web/recurring-proformas/detail/ui/pages/recurring-proforma-detail-page.tsx @@ -1,4 +1,5 @@ -import { PageErrorState, PageFormHeader } from "@erp/core/components"; +import { PageErrorState } from "@erp/core/components"; +import { RdxPageStack } from "@repo/rdx-ui/components"; import { Badge, Button, @@ -10,17 +11,17 @@ import { import { ArrowLeftIcon } from "lucide-react"; import { useNavigate, useParams } from "react-router-dom"; -import { useTranslation } from "../../i18n"; -import { useRecurringProforma } from "../shared/hooks/use-recurring-proforma"; - +import { useTranslation } from "../../../../i18n"; +import { ProformaPageHeader } from "../../../../proformas/shared"; +import { useRecurringProforma } from "../../../shared/hooks/use-recurring-proforma"; import { buildRecurringProformaDetailMainView, formatRecurringProformaDateOnly, formatRecurringProformaOverride, formatRecurringProformaPercentage, getRecurringProformaDetailFeedbackState, -} from "./recurring-proforma-detail-utils"; -import { RecurringProformaOccurrencesSection } from "./recurring-proforma-occurrences-section"; +} from "../../recurring-proforma-detail-utils"; +import { RecurringProformaOccurrencesSection } from "../../recurring-proforma-occurrences-section"; type DetailFieldProps = { label: string; @@ -83,8 +84,8 @@ export const RecurringProformaDetailPage = () => { const discount = formatRecurringProformaPercentage(recurringProforma.global_discount_percentage); return ( -
- + navigate("/recurring-proformas")} title={mainView.name} @@ -95,7 +96,7 @@ export const RecurringProformaDetailPage = () => { {t("recurring_proformas.detail.context")}
- +
@@ -236,6 +237,6 @@ export const RecurringProformaDetailPage = () => { {t("recurring_proformas.detail.back")} -
+ ); }; diff --git a/modules/customer-invoices/src/web/recurring-proformas/shared/api/create-recurring-proforma-from-proforma.api.test.ts b/modules/customer-invoices/src/web/recurring-proformas/shared/api/create-recurring-proforma-from-proforma.api.test.ts new file mode 100644 index 00000000..d7993c2f --- /dev/null +++ b/modules/customer-invoices/src/web/recurring-proformas/shared/api/create-recurring-proforma-from-proforma.api.test.ts @@ -0,0 +1,42 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +import { createRecurringProformaFromProforma } from "./create-recurring-proforma-from-proforma.api"; + +test("createRecurringProformaFromProforma usa una sola POST a la subruta de la proforma", async () => { + let resource = ""; + let received: unknown; + const dataSource = { + createOne: async (nextResource: string, input: unknown) => { + resource = nextResource; + received = input; + return { id: "recurring-id" }; + }, + }; + + await createRecurringProformaFromProforma(dataSource as never, { + proformaId: "proforma-id", + data: { + name: "Recurrencia PF-2026-001", + schedule: { + startDate: "2026-09-01", + endDate: null, + unit: "month", + interval: 1, + monthlyRule: "same_day", + }, + }, + }); + + assert.equal(resource, "proformas/proforma-id/recurring-proforma"); + assert.deepEqual(received, { + name: "Recurrencia PF-2026-001", + schedule: { + startDate: "2026-09-01", + endDate: null, + unit: "month", + interval: 1, + monthlyRule: "same_day", + }, + }); +}); diff --git a/modules/customer-invoices/src/web/recurring-proformas/shared/api/create-recurring-proforma-from-proforma.api.ts b/modules/customer-invoices/src/web/recurring-proformas/shared/api/create-recurring-proforma-from-proforma.api.ts new file mode 100644 index 00000000..3f0c01fa --- /dev/null +++ b/modules/customer-invoices/src/web/recurring-proformas/shared/api/create-recurring-proforma-from-proforma.api.ts @@ -0,0 +1,18 @@ +import type { IDataSource } from "@erp/core/client"; + +import type { CreateRecurringProformaFromProformaRequestDTO } from "../../../../common/dto/request/recurring-proformas/create-recurring-proforma-from-proforma.request.dto"; +import type { RecurringProformaDetail } from "../recurring-proforma-list"; + +export interface CreateRecurringProformaFromProformaParams { + proformaId: string; + data: CreateRecurringProformaFromProformaRequestDTO; +} + +export const createRecurringProformaFromProforma = ( + dataSource: IDataSource, + params: CreateRecurringProformaFromProformaParams +): Promise => + dataSource.createOne( + `proformas/${params.proformaId}/recurring-proforma`, + params.data + ); diff --git a/modules/customer-invoices/src/web/recurring-proformas/shared/hooks/use-create-recurring-proforma-from-proforma.test.tsx b/modules/customer-invoices/src/web/recurring-proformas/shared/hooks/use-create-recurring-proforma-from-proforma.test.tsx new file mode 100644 index 00000000..962cfd68 --- /dev/null +++ b/modules/customer-invoices/src/web/recurring-proformas/shared/hooks/use-create-recurring-proforma-from-proforma.test.tsx @@ -0,0 +1,64 @@ +import assert from "node:assert/strict"; +import { mock, test } from "node:test"; + +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; +import { renderToStaticMarkup } from "react-dom/server"; + +test("invalida la lista de recurrencias despues de crearla desde una proforma", async () => { + mock.module("@erp/core/hooks", { + namedExports: { + useDataSource: () => ({}), + }, + }); + mock.module("../api/create-recurring-proforma-from-proforma.api", { + namedExports: { + createRecurringProformaFromProforma: async () => ({ id: "recurring-test-id" }), + }, + }); + + const { useCreateRecurringProformaFromProforma } = await import( + "./use-create-recurring-proforma-from-proforma" + ); + const { recurringProformaQueryKeys } = await import("./recurring-proforma-query-keys"); + const queryClient = new QueryClient(); + const invalidatedQueryKeys: (readonly unknown[])[] = []; + const originalInvalidateQueries = queryClient.invalidateQueries.bind(queryClient); + queryClient.invalidateQueries = async (filters) => { + if (filters?.queryKey) { + invalidatedQueryKeys.push(filters.queryKey); + } + + return await originalInvalidateQueries(filters); + }; + let mutation!: ReturnType; + + const Harness = () => { + mutation = useCreateRecurringProformaFromProforma(); + return null; + }; + + renderToStaticMarkup( + + + + ); + const createdMutation = mutation; + + await createdMutation.mutateAsync({ + proformaId: "proforma-id", + data: { + name: "Recurrencia PF-2026-001", + schedule: { + startDate: "2026-09-01", + endDate: null, + unit: "month", + interval: 1, + monthlyRule: "same_day", + }, + }, + }); + + assert.deepEqual(invalidatedQueryKeys, [recurringProformaQueryKeys.lists]); + + mock.restoreAll(); +}); diff --git a/modules/customer-invoices/src/web/recurring-proformas/shared/hooks/use-create-recurring-proforma-from-proforma.ts b/modules/customer-invoices/src/web/recurring-proformas/shared/hooks/use-create-recurring-proforma-from-proforma.ts new file mode 100644 index 00000000..a50acbe8 --- /dev/null +++ b/modules/customer-invoices/src/web/recurring-proformas/shared/hooks/use-create-recurring-proforma-from-proforma.ts @@ -0,0 +1,44 @@ +import { useDataSource } from "@erp/core/hooks"; +import { CreateRecurringProformaFromProformaRequestSchema } from "@erp/customer-invoices/common"; +import { ValidationErrorCollection } from "@repo/rdx-ddd"; +import { type DefaultError, useMutation, useQueryClient } from "@tanstack/react-query"; +import type { ZodError } from "zod"; + +import type { RecurringProformaDetail } from "../recurring-proforma-list"; +import { + createRecurringProformaFromProforma, + type CreateRecurringProformaFromProformaParams, +} from "../api/create-recurring-proforma-from-proforma.api"; + +import { recurringProformaQueryKeys } from "./recurring-proforma-query-keys"; + +const toValidationErrors = (error: ZodError) => + error.issues.map((issue) => ({ + path: issue.path.join("."), + message: issue.message, + value: issue.input, + })); + +export const useCreateRecurringProformaFromProforma = () => { + const dataSource = useDataSource(); + const queryClient = useQueryClient(); + + return useMutation< + RecurringProformaDetail, + DefaultError, + CreateRecurringProformaFromProformaParams + >({ + mutationFn: async (params) => { + const result = CreateRecurringProformaFromProformaRequestSchema.safeParse(params.data); + + if (!result.success) { + throw new ValidationErrorCollection("Validation failed", toValidationErrors(result.error)); + } + + return await createRecurringProformaFromProforma(dataSource, params); + }, + onSuccess: async () => { + await queryClient.invalidateQueries({ queryKey: recurringProformaQueryKeys.lists }); + }, + }); +}; diff --git a/modules/customer-invoices/src/web/recurring-proformas/shared/recurring-proforma-schedule-summary.test.ts b/modules/customer-invoices/src/web/recurring-proformas/shared/recurring-proforma-schedule-summary.test.ts new file mode 100644 index 00000000..00a17fb9 --- /dev/null +++ b/modules/customer-invoices/src/web/recurring-proformas/shared/recurring-proforma-schedule-summary.test.ts @@ -0,0 +1,77 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +import { + type RecurringProformaScheduleSummary, + formatRecurringProformaScheduleSummary, +} from "./recurring-proforma-schedule-summary"; + +const TRANSLATIONS: Record = { + "recurring_proformas.schedule_summary.cadence.week_one": "cada semana", + "recurring_proformas.schedule_summary.cadence.week_other": "cada {{count}} semanas", + "recurring_proformas.schedule_summary.cadence.month_one": "cada mes", + "recurring_proformas.schedule_summary.cadence.month_other": "cada {{count}} meses", + "recurring_proformas.schedule_summary.cadence.year_one": "cada año", + "recurring_proformas.schedule_summary.cadence.year_other": "cada {{count}} años", + "recurring_proformas.schedule_summary.monthly_rule.same_day": "el mismo día de cada mes", + "recurring_proformas.schedule_summary.monthly_rule.last_day_of_month": + "el último día de cada mes", + "recurring_proformas.schedule_summary.period.indefinite": + "a partir del {{startDate}} y sin fecha de finalización", + "recurring_proformas.schedule_summary.period.until": + "a partir del {{startDate}} y hasta el {{endDate}}", + "recurring_proformas.schedule_summary.sentence": + "La proforma se generará {{cadence}}{{monthlyRule}}, {{period}}.", +}; + +const translate = (key: string, options: Record = {}) => { + const pluralKey = + typeof options.count === "number" ? `${key}_${options.count === 1 ? "one" : "other"}` : key; + const template = TRANSLATIONS[pluralKey] ?? TRANSLATIONS[key] ?? key; + + return template.replace(/{{(\w+)}}/g, (_, name: string) => String(options[name] ?? "")); +}; + +const buildSchedule = ( + overrides: Partial = {} +): RecurringProformaScheduleSummary => ({ + startDate: "2026-09-01", + endDate: null, + unit: "month", + interval: 1, + monthlyRule: "same_day", + ...overrides, +}); + +test("resume una recurrencia mensual indefinida con regla de último día", () => { + const summary = formatRecurringProformaScheduleSummary( + buildSchedule({ interval: 2, monthlyRule: "last_day_of_month" }), + { locale: "es-ES", t: translate } + ); + + assert.equal( + summary, + "La proforma se generará cada 2 meses el último día de cada mes, a partir del 1 de septiembre de 2026 y sin fecha de finalización." + ); +}); + +test("resume una recurrencia semanal con fecha de fin", () => { + const summary = formatRecurringProformaScheduleSummary( + buildSchedule({ endDate: "2026-12-31", unit: "week", interval: 1, monthlyRule: null }), + { locale: "es-ES", t: translate } + ); + + assert.equal( + summary, + "La proforma se generará cada semana, a partir del 1 de septiembre de 2026 y hasta el 31 de diciembre de 2026." + ); +}); + +test("no genera una vista previa para una programación incompleta", () => { + const summary = formatRecurringProformaScheduleSummary( + buildSchedule({ endDate: "", monthlyRule: null }), + { locale: "es-ES", t: translate } + ); + + assert.equal(summary, null); +}); diff --git a/modules/customer-invoices/src/web/recurring-proformas/shared/recurring-proforma-schedule-summary.ts b/modules/customer-invoices/src/web/recurring-proformas/shared/recurring-proforma-schedule-summary.ts new file mode 100644 index 00000000..e59eaaf8 --- /dev/null +++ b/modules/customer-invoices/src/web/recurring-proformas/shared/recurring-proforma-schedule-summary.ts @@ -0,0 +1,74 @@ +export type RecurringProformaScheduleSummary = { + startDate: string; + endDate: string | null; + unit: "week" | "month" | "year"; + interval: number; + monthlyRule: "same_day" | "last_day_of_month" | null; +}; + +type Translate = ( + key: string, + options?: { + count?: number; + startDate?: string; + endDate?: string; + cadence?: string; + monthlyRule?: string; + period?: string; + } +) => string; + +const isDateOnly = (value: string) => { + if (!/^\d{4}-\d{2}-\d{2}$/.test(value)) { + return false; + } + + const date = new Date(`${value}T00:00:00.000Z`); + return !Number.isNaN(date.getTime()) && date.toISOString().startsWith(value); +}; + +const formatDateOnly = (value: string, locale: string) => + new Intl.DateTimeFormat(locale, { + day: "numeric", + month: "long", + year: "numeric", + timeZone: "UTC", + }).format(new Date(`${value}T00:00:00.000Z`)); + +/** + * Resume una programación válida con una frase localizada para mostrarla al usuario. + */ +export const formatRecurringProformaScheduleSummary = ( + schedule: RecurringProformaScheduleSummary, + params: { locale: string; t: Translate } +): string | null => { + if ( + !isDateOnly(schedule.startDate) || + (schedule.endDate !== null && !isDateOnly(schedule.endDate)) || + !Number.isInteger(schedule.interval) || + schedule.interval < 1 || + (schedule.unit === "month" && schedule.monthlyRule === null) || + (schedule.unit !== "month" && schedule.monthlyRule !== null) + ) { + return null; + } + + const startDate = formatDateOnly(schedule.startDate, params.locale); + const endDate = schedule.endDate ? formatDateOnly(schedule.endDate, params.locale) : null; + const cadence = params.t(`recurring_proformas.schedule_summary.cadence.${schedule.unit}`, { + count: schedule.interval, + }); + const monthlyRule = + schedule.monthlyRule === null + ? "" + : ` ${params.t(`recurring_proformas.schedule_summary.monthly_rule.${schedule.monthlyRule}`)}`; + const period = endDate + ? params.t("recurring_proformas.schedule_summary.period.until", { startDate, endDate }) + : params.t("recurring_proformas.schedule_summary.period.indefinite", { startDate }); + + return params.t("recurring_proformas.schedule_summary.sentence", { + cadence, + monthlyRule, + period, + }); +}; diff --git a/modules/customer-invoices/src/web/recurring-proformas/shared/ui/blocks/index.ts b/modules/customer-invoices/src/web/recurring-proformas/shared/ui/blocks/index.ts new file mode 100644 index 00000000..61f9d676 --- /dev/null +++ b/modules/customer-invoices/src/web/recurring-proformas/shared/ui/blocks/index.ts @@ -0,0 +1 @@ +export * from "./recurring-proforma-layout"; diff --git a/modules/customer-invoices/src/web/recurring-proformas/shared/ui/blocks/recurring-proforma-layout.tsx b/modules/customer-invoices/src/web/recurring-proformas/shared/ui/blocks/recurring-proforma-layout.tsx new file mode 100644 index 00000000..fe8530e7 --- /dev/null +++ b/modules/customer-invoices/src/web/recurring-proformas/shared/ui/blocks/recurring-proforma-layout.tsx @@ -0,0 +1,9 @@ +import type { PropsWithChildren } from "react"; + +export function RecurringProformaLayout({ children }: PropsWithChildren) { + return ( +
+ {children} +
+ ); +} diff --git a/modules/customer-invoices/src/web/recurring-proformas/shared/ui/index.ts b/modules/customer-invoices/src/web/recurring-proformas/shared/ui/index.ts new file mode 100644 index 00000000..987a00f2 --- /dev/null +++ b/modules/customer-invoices/src/web/recurring-proformas/shared/ui/index.ts @@ -0,0 +1 @@ +export * from "./blocks"; diff --git a/packages/rdx-ui/src/components/layout/rdx-page-container.tsx b/packages/rdx-ui/src/components/layout/rdx-page-container.tsx index 4b462575..93bbbbe7 100644 --- a/packages/rdx-ui/src/components/layout/rdx-page-container.tsx +++ b/packages/rdx-ui/src/components/layout/rdx-page-container.tsx @@ -2,11 +2,13 @@ import { cn } from "@repo/shadcn-ui/lib/utils"; import type { ComponentPropsWithoutRef, ReactNode } from "react"; export type RdxPageWidth = "full" | "wide" | "content"; +export type RdxPageContainerPadding = "default" | "none"; export interface RdxPageContainerProps extends Omit, "children"> { children: ReactNode; width?: RdxPageWidth; + padding?: RdxPageContainerPadding; } const WIDTH_CLASSNAME: Record = { @@ -15,15 +17,21 @@ const WIDTH_CLASSNAME: Record = { content: "mx-auto max-w-5xl w-full", }; +const PADDING_CLASSNAME: Record = { + default: "px-4 py-4 md:px-6 md:py-6", + none: "", +}; + export const RdxPageContainer = ({ children, className, width = "full", + padding = "default", ...props }: RdxPageContainerProps) => { return (
{children} diff --git a/packages/rdx-ui/src/components/layout/rdx-page-foundations.test.tsx b/packages/rdx-ui/src/components/layout/rdx-page-foundations.test.tsx index bb46d391..97c64074 100644 --- a/packages/rdx-ui/src/components/layout/rdx-page-foundations.test.tsx +++ b/packages/rdx-ui/src/components/layout/rdx-page-foundations.test.tsx @@ -12,6 +12,7 @@ test("RdxPageContainer renders children with full width by default", () => { assert.match(html, /Content/); assert.match(html, /w-full/); + assert.match(html, /px-4/); assert.doesNotMatch(html, /max-w-screen-2xl/); assert.doesNotMatch(html, /max-w-5xl/); }); @@ -31,6 +32,16 @@ test("RdxPageContainer applies width variants and merges className", () => { assert.match(contentHtml, /max-w-5xl/); }); +test("RdxPageContainer permite eliminar el padding exterior", () => { + const html = renderToStaticMarkup( + Content without padding + ); + + assert.match(html, /Content without padding/); + assert.doesNotMatch(html, /px-4/); + assert.doesNotMatch(html, /py-4/); +}); + test("RdxPageStack applies default spacing and className", () => { const html = renderToStaticMarkup( @@ -44,9 +55,11 @@ test("RdxPageStack applies default spacing and className", () => { }); test("RdxPageStack applies spacing variants", () => { + const noneHtml = renderToStaticMarkup(None); const compactHtml = renderToStaticMarkup(Compact); const relaxedHtml = renderToStaticMarkup(Relaxed); + assert.match(noneHtml, /gap-0/); assert.match(compactHtml, /gap-4/); assert.match(relaxedHtml, /gap-8/); }); @@ -78,6 +91,29 @@ test("RdxPageHeader omits optional wrappers when content is absent", () => { assert.doesNotMatch(html, /Metadata/); }); +test("RdxPageHeader soporta el modo sticky de editor con navegación, estado y contenido secundario", () => { + const html = renderToStaticMarkup( + Actions} + backLabel="Volver" + onBack={() => undefined} + showStatus + statusLabel="Modificada" + sticky + title="Editar proforma" + > +
Secondary content
+
+ ); + + assert.match(html, /sticky top-0/); + assert.match(html, /aria-label="Volver"/); + assert.match(html, /Editar proforma/); + assert.match(html, /Modificada/); + assert.match(html, /Actions/); + assert.match(html, /Secondary content/); +}); + test("RdxPageActions renders only populated regions in the expected order", () => { const html = renderToStaticMarkup( void; + statusLabel?: ReactNode; + showStatus?: boolean; + sticky?: boolean; + contentClassName?: string; + children?: ReactNode; } export const RdxPageHeader = ({ @@ -16,9 +25,73 @@ export const RdxPageHeader = ({ leading, metadata, actions, + backLabel, + onBack, + statusLabel, + showStatus = false, + sticky = false, + contentClassName, + children, className, ...props }: RdxPageHeaderProps) => { + if (sticky) { + const shouldRenderBackButton = Boolean(backLabel || onBack); + + return ( +
+
+
+ {shouldRenderBackButton ? ( + + ) : null} + +
+

+ {title} +

+ + {statusLabel ? ( + + {statusLabel} + + ) : null} +
+
+ + {actions ?
{actions}
: null} +
+ + {children} +
+ ); + } + return (
{actions ?
{actions}
: null} + + {children}
); }; diff --git a/packages/rdx-ui/src/components/layout/rdx-page-stack.tsx b/packages/rdx-ui/src/components/layout/rdx-page-stack.tsx index b580f309..88bfbfbe 100644 --- a/packages/rdx-ui/src/components/layout/rdx-page-stack.tsx +++ b/packages/rdx-ui/src/components/layout/rdx-page-stack.tsx @@ -1,7 +1,7 @@ import { cn } from "@repo/shadcn-ui/lib/utils"; import type { ComponentPropsWithoutRef, ReactNode } from "react"; -export type RdxPageStackSpacing = "compact" | "default" | "relaxed"; +export type RdxPageStackSpacing = "none" | "compact" | "default" | "relaxed"; export interface RdxPageStackProps extends Omit, "children"> { children: ReactNode; @@ -9,6 +9,7 @@ export interface RdxPageStackProps extends Omit, } const SPACING_CLASSNAME: Record = { + none: "gap-0", compact: "gap-4", default: "gap-6", relaxed: "gap-8",