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) => (
+ {children as ReactNode}
+ ),
+ 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 }) => (
+ {children}
+ ),
+ SelectTrigger: ({ children, ...props }: Record) => (
+ {children as ReactNode}
+ ),
+ 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")}
+
+
+
+
+
+
+ onOpenChange(false)} variant="outline">
+ {t("proformas.create_from_proforma.cancel")}
+
+
+ {isSubmitting ? (
+ <>
+
+ {t("proformas.create_from_proforma.creating")}
+ >
+ ) : (
+ t("proformas.create_from_proforma.submit")
+ )}
+
+
+
+
+ );
+};
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",