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.
This commit is contained in:
David Arranz 2026-09-01 11:36:20 +02:00
parent 7effe3aad8
commit 00777b3ba2
44 changed files with 2056 additions and 71 deletions

View File

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

View File

@ -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
<DialogContent className="gap-0 overflow-hidden p-0 sm:max-w-2xl">
<DialogHeader className="border-b bg-muted/40 px-6 py-5 sm:px-7">
<DialogTitle>{title}</DialogTitle>
<DialogDescription className="max-w-lg">{description}</DialogDescription>
</DialogHeader>
<form className="space-y-6 px-6 py-5 sm:px-7">
<IdentityField />
<InformationSection />
<ConfigurationSection />
</form>
<DialogFooter className="border-t bg-muted/30 px-6 py-4 sm:px-7">
<CancelAction />
<SubmitAction />
</DialogFooter>
</DialogContent>
```
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
<section aria-labelledby={sectionId} className="rounded-xl border bg-muted/20 p-4 sm:p-5">
<div className="mb-4 flex items-center gap-3">
<span className="flex size-8 items-center justify-center rounded-lg bg-primary/10 text-primary">
<SectionIcon aria-hidden className="size-4" />
</span>
<h3 className="font-medium text-sm" id={sectionId}>{sectionTitle}</h3>
</div>
<div className="grid gap-4 sm:grid-cols-2">
<Field />
<Field />
</div>
</section>
```
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.

View File

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

View File

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

View File

@ -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",

View File

@ -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",

View File

@ -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: <RecurringProformasListPage />,
element: (
<RecurringProformaLayout>
<RecurringProformasListPage />
</RecurringProformaLayout>
),
},
{
path: "recurring-proformas/create",
@ -143,15 +151,23 @@ export const CustomerInvoiceRoutes = (params: ModuleClientParams): RouteObject[]
layout: "app-fullscreen",
protected: true,
},
element: <RecurringProformaCreatePage />,
element: (
<RecurringProformaLayout>
<RecurringProformaCreatePage />
</RecurringProformaLayout>
),
},
{
path: "recurring-proformas/:recurring_proforma_id",
handle: {
layout: "app-sidebar",
layout: "app-fullscreen",
protected: true,
},
element: <RecurringProformaDetailPage />,
element: (
<RecurringProformaLayout>
<RecurringProformaDetailPage />
</RecurringProformaLayout>
),
},
{

View File

@ -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 (
<div className="fixed inset-0 flex flex-col overflow-hidden">
<RdxPageStack className="fixed inset-0 min-h-0 overflow-hidden" spacing="none">
<FormProvider {...createCtrl.form}>
<UnsavedChangesProvider isDirty={createCtrl.form.formState.isDirty}>
<ProformaCreateHeader
@ -30,9 +30,13 @@ export const ProformaCreatePage = () => {
</ProformaInfoAlert>
</ProformaCreateHeader>
<div className="flex-1 overflow-y-auto bg-muted/20">
<RdxPageContainer
className="min-h-0 flex-1 overflow-y-auto bg-muted/20"
padding="none"
width="full"
>
<RdxEditorPageLayout complexity="simple">
<div className="flex min-w-0 flex-col gap-6">
<RdxPageStack spacing="default">
{createCtrl.isCreateError && (
<ErrorAlert
message={
@ -70,9 +74,9 @@ export const ProformaCreatePage = () => {
selectedCustomer={createCtrl.selectedCustomer}
targetInvoiceSeriesOptions={createCtrl.targetInvoiceSeriesOptions}
/>
</div>
</RdxPageStack>
</RdxEditorPageLayout>
</div>
</RdxPageContainer>
<SelectCustomerDialog
ctrl={selectCustomerCtrl.selectCtrl}
@ -81,6 +85,6 @@ export const ProformaCreatePage = () => {
<QuickCreateCustomerDialog ctrl={selectCustomerCtrl.createCtrl} />
</UnsavedChangesProvider>
</FormProvider>
</div>
</RdxPageStack>
);
};

View File

@ -1 +1,2 @@
export * from "./use-proforma-detail-page-controller";
export * from "./use-create-recurring-proforma-from-proforma-dialog-controller";

View File

@ -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<typeof useCreateRecurringProformaFromProformaDialogController>;
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<string, unknown> }) => {
const values = { ...initialValues };
return {
formState: { errors: {} },
getValues: (field: string) => values[field],
handleSubmit:
(onValid: (nextValues: typeof values) => Promise<void>) =>
async (event: ReturnType<typeof createSubmitEvent>) => {
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(<Harness />);
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(<Harness />);
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();
});

View File

@ -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<CreateRecurringProformaFromProformaForm>({
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<CreateRecurringProformaFromProformaForm>) => {
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<HTMLFormElement>) => {
event.stopPropagation();
return submitHandler(event);
};
return {
formId,
form,
open,
unit,
changeUnit,
isSubmitting: mutation.isPending,
openDialog,
closeDialog,
onSubmit,
};
};

View File

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

View File

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

View File

@ -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: <Se utiliza para reemplazar caracteres inválidos en el nombre del archivo>
@ -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,

View File

@ -135,6 +135,7 @@ export type ProformaDetailActionKey =
| "change-status"
| "issue"
| "duplicate"
| "create-recurring"
| "open-pdf"
| "download-pdf"
| "archive"

View File

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

View File

@ -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<string, unknown> =>
typeof value === "object" && value !== null;
const getTranslationValue = (key: string): unknown =>
key.split(".").reduce<unknown>((current, segment) => {
return isRecord(current) ? current[segment] : undefined;
}, esResources);
const getTranslation = (key: string, options: Record<string, string | number> = {}): 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<string, unknown>) => (
<button {...props}>{children as ReactNode}</button>
),
Checkbox: (props: Record<string, unknown>) => <input type="checkbox" {...props} />,
Dialog: ({ children }: { children?: ReactNode }) => <div>{children}</div>,
DialogContent: ({ children, ...props }: Record<string, unknown>) => (
<div {...props}>{children as ReactNode}</div>
),
DialogDescription: ({ children }: { children?: ReactNode }) => <p>{children}</p>,
DialogFooter: ({ children }: { children?: ReactNode }) => <div>{children}</div>,
DialogHeader: ({ children }: { children?: ReactNode }) => <div>{children}</div>,
DialogTitle: ({ children }: { children?: ReactNode }) => <h2>{children}</h2>,
Input: (props: Record<string, unknown>) => <input {...props} />,
Label: ({ children }: Record<string, unknown>) => <span>{children as ReactNode}</span>,
Select: ({ children }: { children?: ReactNode }) => <div>{children}</div>,
SelectContent: ({ children }: { children?: ReactNode }) => <div>{children}</div>,
SelectItem: ({ children, value }: { children?: ReactNode; value: string }) => (
<option value={value}>{children}</option>
),
SelectTrigger: ({ children, ...props }: Record<string, unknown>) => (
<button {...props}>{children as ReactNode}</button>
),
Spinner: () => <span>spinner</span>,
},
});
const { CreateRecurringProformaFromProformaDialog } = await import(
"./create-recurring-proforma-from-proforma-dialog"
);
const Harness = () => {
const form = useForm<CreateRecurringProformaFromProformaForm>({
defaultValues: {
name: "Recurrencia PF-2026-001",
startDate: "2026-09-01",
hasEndDate: false,
endDate: "",
unit,
interval: 1,
monthlyRule: unit === "month" ? "same_day" : null,
},
});
return (
<CreateRecurringProformaFromProformaDialog
form={form}
formId="create-recurring-proforma-from-proforma-form"
isSubmitting={false}
onOpenChange={() => undefined}
onSubmit={() => undefined}
onUnitChange={() => undefined}
open
unit={unit}
/>
);
};
const markup = renderToStaticMarkup(<Harness />);
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/);
});

View File

@ -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<CreateRecurringProformaFromProformaForm>;
onUnitChange: (unit: CreateRecurringProformaFromProformaForm["unit"]) => void;
onOpenChange: (open: boolean) => void;
onSubmit: (event: FormEvent<HTMLFormElement>) => 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 (
<Dialog
onOpenChange={(nextOpen) => {
if (isSubmitting) {
return;
}
onOpenChange(nextOpen);
}}
open={open}
>
<DialogContent className="gap-0 overflow-hidden p-0 sm:max-w-2xl">
<DialogHeader className="border-b bg-muted/40 px-6 py-5 sm:px-7">
<DialogTitle>{t("proformas.create_from_proforma.title")}</DialogTitle>
<DialogDescription className="max-w-lg">
{t("proformas.create_from_proforma.description")}
</DialogDescription>
</DialogHeader>
<form className="space-y-6 px-6 py-5 sm:px-7" id={formId} noValidate onSubmit={onSubmit}>
<div className="space-y-2.5">
<Label htmlFor="create-recurring-from-proforma-name">
{t("proformas.create_from_proforma.name")}
</Label>
<Input
className="h-11 bg-background"
disabled={isSubmitting}
id="create-recurring-from-proforma-name"
{...form.register("name")}
/>
{errors.name ? (
<p className="text-sm text-destructive">
{t("proformas.create_from_proforma.name_required")}
</p>
) : null}
</div>
<section
aria-labelledby="create-recurring-from-proforma-period-title"
className="rounded-xl border bg-muted/20 p-4 sm:p-5"
>
<div className="mb-4 flex items-center gap-3">
<span className="flex size-8 items-center justify-center rounded-lg bg-primary/10 text-primary">
<CalendarDaysIcon aria-hidden className="size-4" />
</span>
<h3 className="font-medium text-sm" id="create-recurring-from-proforma-period-title">
{t("proformas.create_from_proforma.period_title")}
</h3>
</div>
<div className="grid gap-4 sm:grid-cols-2">
<div className="min-w-0 space-y-2">
<Label htmlFor="create-recurring-from-proforma-start-date">
{t("proformas.create_from_proforma.start_date")}
</Label>
<Input
className="bg-background"
disabled={isSubmitting}
id="create-recurring-from-proforma-start-date"
type="date"
{...form.register("startDate")}
/>
{errors.startDate ? (
<p className="text-sm text-destructive">
{t("proformas.create_from_proforma.start_date_invalid")}
</p>
) : null}
</div>
<div className="min-w-0 space-y-2">
<div className="flex flex-wrap items-center justify-between gap-x-3 gap-y-2">
<Label htmlFor="create-recurring-from-proforma-end-date">
{t("proformas.create_from_proforma.end_date")}
</Label>
<Controller
control={form.control}
name="hasEndDate"
render={({ field }) => (
<label
className="flex cursor-pointer items-center gap-2 text-muted-foreground text-xs"
htmlFor="create-recurring-from-proforma-no-end-date"
>
<Checkbox
checked={!field.value}
disabled={isSubmitting}
id="create-recurring-from-proforma-no-end-date"
onCheckedChange={(checked) => field.onChange(!checked)}
/>
{t("proformas.create_from_proforma.no_end_date")}
</label>
)}
/>
</div>
<Input
className="bg-background"
disabled={isSubmitting || !hasEndDate}
id="create-recurring-from-proforma-end-date"
type="date"
{...form.register("endDate")}
/>
{errors.endDate ? (
<p className="text-sm text-destructive">
{t("proformas.create_from_proforma.end_date_invalid")}
</p>
) : null}
</div>
</div>
</section>
<section
aria-labelledby="create-recurring-from-proforma-recurrence-title"
className="rounded-xl border bg-muted/20 p-4 sm:p-5"
>
<div className="mb-4 flex items-center gap-3">
<span className="flex size-8 items-center justify-center rounded-lg bg-primary/10 text-primary">
<Repeat2Icon aria-hidden className="size-4" />
</span>
<h3
className="font-medium text-sm"
id="create-recurring-from-proforma-recurrence-title"
>
{t("proformas.create_from_proforma.recurrence_title")}
</h3>
</div>
<div className="grid gap-4 sm:grid-cols-2">
<div className="min-w-0 space-y-2">
<Label htmlFor="create-recurring-from-proforma-unit">
{t("proformas.create_from_proforma.frequency")}
</Label>
<Controller
control={form.control}
name="unit"
render={({ field }) => (
<Select
disabled={isSubmitting}
onValueChange={(value) => {
if (value) {
onUnitChange(value);
}
}}
value={field.value}
>
<SelectTrigger
className="w-full bg-background"
id="create-recurring-from-proforma-unit"
>
<span
className="flex flex-1 items-center text-left"
data-slot="selected-unit"
>
{t(`proformas.create_from_proforma.units.${field.value}`)}
</span>
</SelectTrigger>
<SelectContent>
<SelectItem value="week">
{t("proformas.create_from_proforma.units.week")}
</SelectItem>
<SelectItem value="month">
{t("proformas.create_from_proforma.units.month")}
</SelectItem>
<SelectItem value="year">
{t("proformas.create_from_proforma.units.year")}
</SelectItem>
</SelectContent>
</Select>
)}
/>
</div>
<div className="min-w-0 space-y-2">
<Label htmlFor="create-recurring-from-proforma-interval">
{t("proformas.create_from_proforma.interval")}
</Label>
<Input
className="bg-background"
disabled={isSubmitting}
id="create-recurring-from-proforma-interval"
min={1}
step={1}
type="number"
{...form.register("interval", { valueAsNumber: true })}
/>
{errors.interval ? (
<p className="text-sm text-destructive">
{t("proformas.create_from_proforma.interval_invalid")}
</p>
) : null}
</div>
</div>
{unit === "month" ? (
<div className="mt-4 min-w-0 space-y-2 border-t pt-4">
<Label htmlFor="create-recurring-from-proforma-monthly-rule">
{t("proformas.create_from_proforma.monthly_rule")}
</Label>
<Controller
control={form.control}
name="monthlyRule"
render={({ field }) => (
<Select
disabled={isSubmitting}
onValueChange={field.onChange}
value={field.value ?? "same_day"}
>
<SelectTrigger
className="w-full bg-background"
id="create-recurring-from-proforma-monthly-rule"
>
<span
className="flex flex-1 items-center text-left"
data-slot="selected-monthly-rule"
>
{t(
`proformas.create_from_proforma.monthly_rules.${field.value ?? "same_day"}`
)}
</span>
</SelectTrigger>
<SelectContent>
<SelectItem value="same_day">
{t("proformas.create_from_proforma.monthly_rules.same_day")}
</SelectItem>
<SelectItem value="last_day_of_month">
{t("proformas.create_from_proforma.monthly_rules.last_day_of_month")}
</SelectItem>
</SelectContent>
</Select>
)}
/>
</div>
) : null}
</section>
{scheduleSummary ? (
<aside
aria-live="polite"
className="rounded-xl border border-primary/15 bg-primary/5 px-4 py-3.5"
>
<p className="font-medium text-primary text-sm">
{t("proformas.create_from_proforma.preview.title")}
</p>
<p className="mt-1 text-muted-foreground text-sm leading-6">{scheduleSummary}</p>
</aside>
) : null}
</form>
<DialogFooter className="border-t bg-muted/30 px-6 py-4 sm:px-7">
<Button disabled={isSubmitting} onClick={() => onOpenChange(false)} variant="outline">
{t("proformas.create_from_proforma.cancel")}
</Button>
<Button disabled={isSubmitting} form={formId} type="submit">
{isSubmitting ? (
<>
<Spinner aria-hidden className="mr-2 size-4" />
<span aria-live="polite">{t("proformas.create_from_proforma.creating")}</span>
</>
) : (
t("proformas.create_from_proforma.submit")
)}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
};

View File

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

View File

@ -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<string, unknown> | undefined;
let capturedDialogProps: Record<string, unknown> | 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<string, unknown>) => {
capturedHeaderProps = props;
return null;
},
ProformaDetailInspector: () => null,
ProformaDocumentPane: () => null,
},
});
mock.module("../components", {
namedExports: {
ProformaDetailSkeleton: () => null,
CreateRecurringProformaFromProformaDialog: (props: Record<string, unknown>) => {
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(<ProformaDetailPage />);
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();
});

View File

@ -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 (
<div className="flex h-full min-h-0 flex-col overflow-hidden">
<RdxPageStack className="fixed inset-0 min-h-0 overflow-hidden" spacing="none">
<ProformaDetailHeader
actions={ctrl.actions}
header={ctrl.header}
@ -98,6 +99,21 @@ export const ProformaDetailPage = () => {
open={ctrl.dialogs.changeStatus.open}
targets={ctrl.dialogs.changeStatus.targets}
/>
</div>
<CreateRecurringProformaFromProformaDialog
form={ctrl.dialogs.createRecurring.form}
formId={ctrl.dialogs.createRecurring.formId}
isSubmitting={ctrl.dialogs.createRecurring.isSubmitting}
onOpenChange={(open) => {
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}
/>
</RdxPageStack>
);
};

View File

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

View File

@ -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,

View File

@ -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 (
<PageFormHeader
<RdxPageHeader
actions={actions}
backLabel={backLabel}
className={cn("shrink-0", className)}
@ -34,9 +34,10 @@ export const ProformaPageHeader = ({
onBack={onBack}
showStatus={showStatus}
statusLabel={statusLabel}
sticky
title={title}
>
{children}
</PageFormHeader>
</RdxPageHeader>
);
};

View File

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

View File

@ -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 (
<AppContent className="p-4 sm:p-6">
<RdxPageContainer width="full">
<div className="mx-auto w-full max-w-3xl">
<PageErrorState
description={ctrl.pageError.description}
@ -88,12 +89,14 @@ export const ProformaUpdatePage = () => {
variant={ctrl.pageError.variant}
/>
</div>
</AppContent>
</RdxPageContainer>
);
}
const hasPageAlerts = updateCtrl.isUpdateError || updateCtrl.isSeriesLoadError;
return (
<div className="fixed inset-0 flex flex-col overflow-hidden">
<RdxPageStack className="fixed inset-0 min-h-0 overflow-hidden" spacing="none">
<FormProvider {...updateCtrl.form}>
<UnsavedChangesProvider isDirty={updateCtrl.form.formState.isDirty}>
<ProformaUpdateHeader
@ -139,35 +142,48 @@ export const ProformaUpdatePage = () => {
/>
<div className="flex min-h-0 flex-1 flex-col bg-muted/20">
<div className="min-h-0 flex-1 overflow-y-auto">
{updateCtrl.isUpdateError && (
<ErrorAlert
message={
(updateCtrl.updateError as Error)?.message ??
t("pages.proformas.update.error_msg", "Revisa los datos e inténtalo de nuevo.")
}
title={t("pages.proformas.update.error_title", "No se pudo guardar los cambios")}
/>
)}
<RdxPageContainer className="min-h-0 flex-1 overflow-y-auto" padding="none" width="full">
{hasPageAlerts ? (
<RdxPageContainer className="pb-0" width="full">
<RdxPageStack spacing="compact">
{updateCtrl.isUpdateError && (
<ErrorAlert
message={
(updateCtrl.updateError as Error)?.message ??
t(
"pages.proformas.update.error_msg",
"Revisa los datos e inténtalo de nuevo."
)
}
title={t(
"pages.proformas.update.error_title",
"No se pudo guardar los cambios"
)}
/>
)}
{updateCtrl.isSeriesLoadError && (
<ErrorAlert
message={
updateCtrl.seriesLoadError instanceof Error
? updateCtrl.seriesLoadError.message
: t(
"pages.proformas.update.series_load_error.message",
"Inténtalo de nuevo más tarde."
)
}
title={t(
"pages.proformas.update.series_load_error.title",
"No se pudieron cargar las series de facturación"
)}
/>
)}
{updateCtrl.isSeriesLoadError && (
<ErrorAlert
message={
updateCtrl.seriesLoadError instanceof Error
? updateCtrl.seriesLoadError.message
: t(
"pages.proformas.update.series_load_error.message",
"Inténtalo de nuevo más tarde."
)
}
title={t(
"pages.proformas.update.series_load_error.title",
"No se pudieron cargar las series de facturación"
)}
/>
)}
</RdxPageStack>
</RdxPageContainer>
) : null}
<ProformaUpdateEditorForm
className={hasPageAlerts ? "pt-0" : undefined}
formId={updateCtrl.formId}
isSubmitting={updateCtrl.isUpdating}
itemsCtrl={updateCtrl.itemsCtrl}
@ -182,7 +198,7 @@ export const ProformaUpdatePage = () => {
taxCtrl={updateCtrl.taxCtrl}
totalsCtrl={updateCtrl.totalsCtrl}
/>
</div>
</RdxPageContainer>
<RdxEditorStickyFooter
leading={
@ -246,6 +262,6 @@ export const ProformaUpdatePage = () => {
/>
</UnsavedChangesProvider>
</FormProvider>
</div>
</RdxPageStack>
);
};

View File

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

View File

@ -1 +1 @@
export * from "./recurring-proforma-detail-page";
export * from "./ui";

View File

@ -0,0 +1 @@
export * from "./pages";

View File

@ -0,0 +1 @@
export * from "./recurring-proforma-detail-page";

View File

@ -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 (
<div className="space-y-6 p-4 sm:p-6">
<PageFormHeader
<RdxPageStack className="fixed inset-0 min-h-0 overflow-hidden" spacing="none">
<ProformaPageHeader
backLabel={t("recurring_proformas.detail.back")}
onBack={() => navigate("/recurring-proformas")}
title={mainView.name}
@ -95,7 +96,7 @@ export const RecurringProformaDetailPage = () => {
{t("recurring_proformas.detail.context")}
</span>
</div>
</PageFormHeader>
</ProformaPageHeader>
<div className="grid gap-6 xl:grid-cols-2">
<Card>
@ -236,6 +237,6 @@ export const RecurringProformaDetailPage = () => {
<ArrowLeftIcon aria-hidden className="size-4" />
{t("recurring_proformas.detail.back")}
</Button>
</div>
</RdxPageStack>
);
};

View File

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

View File

@ -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<RecurringProformaDetail> =>
dataSource.createOne<CreateRecurringProformaFromProformaRequestDTO, RecurringProformaDetail>(
`proformas/${params.proformaId}/recurring-proforma`,
params.data
);

View File

@ -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<typeof useCreateRecurringProformaFromProforma>;
const Harness = () => {
mutation = useCreateRecurringProformaFromProforma();
return null;
};
renderToStaticMarkup(
<QueryClientProvider client={queryClient}>
<Harness />
</QueryClientProvider>
);
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();
});

View File

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

View File

@ -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<string, string> = {
"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<string, string | number> = {}) => {
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> = {}
): 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);
});

View File

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

View File

@ -0,0 +1 @@
export * from "./recurring-proforma-layout";

View File

@ -0,0 +1,9 @@
import type { PropsWithChildren } from "react";
export function RecurringProformaLayout({ children }: PropsWithChildren) {
return (
<div className="flex flex-col h-full w-full" id="recurring_proformas ">
{children}
</div>
);
}

View File

@ -0,0 +1 @@
export * from "./blocks";

View File

@ -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<ComponentPropsWithoutRef<"div">, "children"> {
children: ReactNode;
width?: RdxPageWidth;
padding?: RdxPageContainerPadding;
}
const WIDTH_CLASSNAME: Record<RdxPageWidth, string> = {
@ -15,15 +17,21 @@ const WIDTH_CLASSNAME: Record<RdxPageWidth, string> = {
content: "mx-auto max-w-5xl w-full",
};
const PADDING_CLASSNAME: Record<RdxPageContainerPadding, string> = {
default: "px-4 py-4 md:px-6 md:py-6",
none: "",
};
export const RdxPageContainer = ({
children,
className,
width = "full",
padding = "default",
...props
}: RdxPageContainerProps) => {
return (
<div
className={cn("min-w-0 w-full px-4 py-4 md:px-6 md:py-6", WIDTH_CLASSNAME[width], className)}
className={cn("min-w-0 w-full", WIDTH_CLASSNAME[width], PADDING_CLASSNAME[padding], className)}
{...props}
>
{children}

View File

@ -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(
<RdxPageContainer padding="none">Content without padding</RdxPageContainer>
);
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(
<RdxPageStack className="custom-stack">
@ -44,9 +55,11 @@ test("RdxPageStack applies default spacing and className", () => {
});
test("RdxPageStack applies spacing variants", () => {
const noneHtml = renderToStaticMarkup(<RdxPageStack spacing="none">None</RdxPageStack>);
const compactHtml = renderToStaticMarkup(<RdxPageStack spacing="compact">Compact</RdxPageStack>);
const relaxedHtml = renderToStaticMarkup(<RdxPageStack spacing="relaxed">Relaxed</RdxPageStack>);
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(
<RdxPageHeader
actions={<span>Actions</span>}
backLabel="Volver"
onBack={() => undefined}
showStatus
statusLabel="Modificada"
sticky
title="Editar proforma"
>
<div>Secondary content</div>
</RdxPageHeader>
);
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(
<RdxPageActions

View File

@ -1,4 +1,6 @@
import { Badge, Button } from "@repo/shadcn-ui/components";
import { cn } from "@repo/shadcn-ui/lib/utils";
import { ArrowLeftIcon } from "lucide-react";
import type { ComponentPropsWithoutRef, ReactNode } from "react";
export interface RdxPageHeaderProps
@ -8,6 +10,13 @@ export interface RdxPageHeaderProps
leading?: ReactNode;
metadata?: ReactNode;
actions?: ReactNode;
backLabel?: string;
onBack?: () => 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 (
<header
className={cn(
"sticky top-0 z-50 shrink-0 bg-background ring-1 ring-foreground/10 shadow-xs",
className
)}
{...props}
>
<div
className={cn(
"flex min-h-14 items-center justify-between gap-6 border-b px-4 py-2 sm:px-6 md:min-h-18",
contentClassName
)}
>
<div className="flex min-w-0 items-center gap-3">
{shouldRenderBackButton ? (
<Button
aria-label={backLabel}
disabled={!onBack}
onClick={onBack}
size="icon"
type="button"
variant="ghost"
>
<ArrowLeftIcon aria-hidden="true" className="size-5" />
</Button>
) : null}
<div className="flex min-w-0 items-center gap-4">
<h1 className="min-w-0 truncate text-xl font-semibold tracking-tight text-foreground lg:text-2xl">
{title}
</h1>
{statusLabel ? (
<Badge
className={cn(
"border-amber-500 bg-amber-200 text-amber-900 transition-opacity duration-200",
showStatus ? "opacity-100" : "pointer-events-none opacity-0"
)}
>
{statusLabel}
</Badge>
) : null}
</div>
</div>
{actions ? <div className="flex items-center gap-4">{actions}</div> : null}
</div>
{children}
</header>
);
}
return (
<header
className={cn(
@ -48,6 +121,8 @@ export const RdxPageHeader = ({
</div>
{actions ? <div className="min-w-0 lg:shrink-0">{actions}</div> : null}
{children}
</header>
);
};

View File

@ -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<ComponentPropsWithoutRef<"div">, "children"> {
children: ReactNode;
@ -9,6 +9,7 @@ export interface RdxPageStackProps extends Omit<ComponentPropsWithoutRef<"div">,
}
const SPACING_CLASSNAME: Record<RdxPageStackSpacing, string> = {
none: "gap-0",
compact: "gap-4",
default: "gap-6",
relaxed: "gap-8",