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:
parent
7effe3aad8
commit
00777b3ba2
@ -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)
|
||||
|
||||
194
docs/frontend/diseno-cuadros-dialogo.md
Normal file
194
docs/frontend/diseno-cuadros-dialogo.md
Normal 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.
|
||||
@ -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(),
|
||||
};
|
||||
|
||||
@ -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");
|
||||
});
|
||||
|
||||
@ -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",
|
||||
|
||||
@ -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",
|
||||
|
||||
@ -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>
|
||||
),
|
||||
},
|
||||
|
||||
{
|
||||
|
||||
@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
@ -1 +1,2 @@
|
||||
export * from "./use-proforma-detail-page-controller";
|
||||
export * from "./use-create-recurring-proforma-from-proforma-dialog-controller";
|
||||
|
||||
@ -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();
|
||||
});
|
||||
@ -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,
|
||||
};
|
||||
};
|
||||
@ -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",
|
||||
},
|
||||
}
|
||||
);
|
||||
});
|
||||
@ -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,
|
||||
},
|
||||
});
|
||||
@ -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,
|
||||
|
||||
@ -135,6 +135,7 @@ export type ProformaDetailActionKey =
|
||||
| "change-status"
|
||||
| "issue"
|
||||
| "duplicate"
|
||||
| "create-recurring"
|
||||
| "open-pdf"
|
||||
| "download-pdf"
|
||||
| "archive"
|
||||
|
||||
@ -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;
|
||||
}
|
||||
|
||||
@ -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/);
|
||||
});
|
||||
@ -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>
|
||||
);
|
||||
};
|
||||
@ -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";
|
||||
|
||||
@ -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();
|
||||
});
|
||||
@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
@ -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,
|
||||
}
|
||||
);
|
||||
});
|
||||
@ -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,
|
||||
|
||||
@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
@ -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,
|
||||
},
|
||||
});
|
||||
|
||||
@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
@ -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";
|
||||
|
||||
|
||||
@ -1 +1 @@
|
||||
export * from "./recurring-proforma-detail-page";
|
||||
export * from "./ui";
|
||||
|
||||
@ -0,0 +1 @@
|
||||
export * from "./pages";
|
||||
@ -0,0 +1 @@
|
||||
export * from "./recurring-proforma-detail-page";
|
||||
@ -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>
|
||||
);
|
||||
};
|
||||
@ -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",
|
||||
},
|
||||
});
|
||||
});
|
||||
@ -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
|
||||
);
|
||||
@ -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();
|
||||
});
|
||||
@ -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 });
|
||||
},
|
||||
});
|
||||
};
|
||||
@ -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);
|
||||
});
|
||||
@ -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,
|
||||
});
|
||||
};
|
||||
@ -0,0 +1 @@
|
||||
export * from "./recurring-proforma-layout";
|
||||
@ -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>
|
||||
);
|
||||
}
|
||||
@ -0,0 +1 @@
|
||||
export * from "./blocks";
|
||||
@ -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}
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
@ -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",
|
||||
|
||||
Loading…
Reference in New Issue
Block a user