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)
|
- [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 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)
|
- [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)
|
- [Integración de módulos con Settings](./settings/frontend-erp-settings-module-integration-design.md)
|
||||||
- [README de `apps/web`](../apps/web/README.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 dateOnly = (value: Date | null) => (value ? value.toISOString().slice(0, 10) : null);
|
||||||
const id = (value: { toPrimitive(): string } | null) => value?.toPrimitive() ?? 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 {
|
export class RecurringProformaListPresenter {
|
||||||
public present(page: any) {
|
public present(page: any) {
|
||||||
return {
|
return {
|
||||||
@ -37,10 +39,10 @@ export class RecurringProformaDetailPresenter {
|
|||||||
description: item.description,
|
description: item.description,
|
||||||
payment_term_id: id(item.paymentTermId),
|
payment_term_id: id(item.paymentTermId),
|
||||||
payment_method_id: id(item.paymentMethodId),
|
payment_method_id: id(item.paymentMethodId),
|
||||||
language: item.language,
|
language: primitive(item.language),
|
||||||
currency: item.currency,
|
currency: primitive(item.currency),
|
||||||
proforma_series: item.proformaSeries,
|
proforma_series: primitive(item.proformaSeries),
|
||||||
issued_invoice_series: item.issuedInvoiceSeries,
|
issued_invoice_series: primitive(item.issuedInvoiceSeries),
|
||||||
global_discount_percentage: item.globalDiscountPercentage?.toObjectString() ?? null,
|
global_discount_percentage: item.globalDiscountPercentage?.toObjectString() ?? null,
|
||||||
schedule: item.schedule
|
schedule: item.schedule
|
||||||
? {
|
? {
|
||||||
@ -52,7 +54,11 @@ export class RecurringProformaDetailPresenter {
|
|||||||
}
|
}
|
||||||
: null,
|
: null,
|
||||||
next_occurrence_date: dateOnly(item.nextOccurrenceDate),
|
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(),
|
created_at: item.createdAt.toISOString(),
|
||||||
updated_at: item.updatedAt.toISOString(),
|
updated_at: item.updatedAt.toISOString(),
|
||||||
};
|
};
|
||||||
|
|||||||
@ -2,7 +2,9 @@ import assert from "node:assert/strict";
|
|||||||
import test from "node:test";
|
import test from "node:test";
|
||||||
|
|
||||||
import { DiscountPercentage } from "@erp/core/api";
|
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";
|
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.value, "string");
|
||||||
assert.equal(typeof dto.global_discount_percentage.scale, "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",
|
"completed": "Completed",
|
||||||
"archived": "Archived"
|
"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": {
|
"detail": {
|
||||||
"context": "Recurring proforma",
|
"context": "Recurring proforma",
|
||||||
"back": "Back to list",
|
"back": "Back to list",
|
||||||
@ -221,6 +240,42 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"proformas": {
|
"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": {
|
"archive": {
|
||||||
"confirm_title": "Archive proforma",
|
"confirm_title": "Archive proforma",
|
||||||
"confirm_description": "The proforma will be hidden from the main list but kept for historical lookup.",
|
"confirm_description": "The proforma will be hidden from the main list but kept for historical lookup.",
|
||||||
@ -569,6 +624,7 @@
|
|||||||
"change_status": "Change status",
|
"change_status": "Change status",
|
||||||
"issue": "Issue invoice",
|
"issue": "Issue invoice",
|
||||||
"duplicate": "Duplicate",
|
"duplicate": "Duplicate",
|
||||||
|
"create_recurring": "Create recurrence from this proforma",
|
||||||
"archive": "Archive",
|
"archive": "Archive",
|
||||||
"unarchive": "Unarchive",
|
"unarchive": "Unarchive",
|
||||||
"download_pdf": "Download PDF",
|
"download_pdf": "Download PDF",
|
||||||
|
|||||||
@ -60,6 +60,25 @@
|
|||||||
"completed": "Completada",
|
"completed": "Completada",
|
||||||
"archived": "Archivada"
|
"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": {
|
"detail": {
|
||||||
"context": "Proforma recurrente",
|
"context": "Proforma recurrente",
|
||||||
"back": "Volver al listado",
|
"back": "Volver al listado",
|
||||||
@ -221,6 +240,42 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"proformas": {
|
"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": {
|
"archive": {
|
||||||
"confirm_title": "Archivar proforma",
|
"confirm_title": "Archivar proforma",
|
||||||
"confirm_description": "La proforma se ocultará del listado principal, pero seguirá conservada para consulta histórica.",
|
"confirm_description": "La proforma se ocultará del listado principal, pero seguirá conservada para consulta histórica.",
|
||||||
@ -569,6 +624,7 @@
|
|||||||
"change_status": "Cambiar estado",
|
"change_status": "Cambiar estado",
|
||||||
"issue": "Emitir factura",
|
"issue": "Emitir factura",
|
||||||
"duplicate": "Duplicar",
|
"duplicate": "Duplicar",
|
||||||
|
"create_recurring": "Crear recurrencia desde esta proforma",
|
||||||
"archive": "Archivar",
|
"archive": "Archivar",
|
||||||
"unarchive": "Desarchivar",
|
"unarchive": "Desarchivar",
|
||||||
"download_pdf": "Descargar PDF",
|
"download_pdf": "Descargar PDF",
|
||||||
|
|||||||
@ -6,6 +6,10 @@ const ProformaLayout = lazy(() =>
|
|||||||
import("./proformas/shared/ui").then((m) => ({ default: m.ProformaLayout }))
|
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(() =>
|
const ProformasListPage = lazy(() =>
|
||||||
import("./proformas/list").then((m) => ({ default: m.ListProformasPage }))
|
import("./proformas/list").then((m) => ({ default: m.ListProformasPage }))
|
||||||
);
|
);
|
||||||
@ -135,7 +139,11 @@ export const CustomerInvoiceRoutes = (params: ModuleClientParams): RouteObject[]
|
|||||||
layout: "app-sidebar",
|
layout: "app-sidebar",
|
||||||
protected: true,
|
protected: true,
|
||||||
},
|
},
|
||||||
element: <RecurringProformasListPage />,
|
element: (
|
||||||
|
<RecurringProformaLayout>
|
||||||
|
<RecurringProformasListPage />
|
||||||
|
</RecurringProformaLayout>
|
||||||
|
),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
path: "recurring-proformas/create",
|
path: "recurring-proformas/create",
|
||||||
@ -143,15 +151,23 @@ export const CustomerInvoiceRoutes = (params: ModuleClientParams): RouteObject[]
|
|||||||
layout: "app-fullscreen",
|
layout: "app-fullscreen",
|
||||||
protected: true,
|
protected: true,
|
||||||
},
|
},
|
||||||
element: <RecurringProformaCreatePage />,
|
element: (
|
||||||
|
<RecurringProformaLayout>
|
||||||
|
<RecurringProformaCreatePage />
|
||||||
|
</RecurringProformaLayout>
|
||||||
|
),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
path: "recurring-proformas/:recurring_proforma_id",
|
path: "recurring-proformas/:recurring_proforma_id",
|
||||||
handle: {
|
handle: {
|
||||||
layout: "app-sidebar",
|
layout: "app-fullscreen",
|
||||||
protected: true,
|
protected: true,
|
||||||
},
|
},
|
||||||
element: <RecurringProformaDetailPage />,
|
element: (
|
||||||
|
<RecurringProformaLayout>
|
||||||
|
<RecurringProformaDetailPage />
|
||||||
|
</RecurringProformaLayout>
|
||||||
|
),
|
||||||
},
|
},
|
||||||
|
|
||||||
{
|
{
|
||||||
|
|||||||
@ -1,7 +1,7 @@
|
|||||||
import { ErrorAlert } from "@erp/core/components";
|
import { ErrorAlert } from "@erp/core/components";
|
||||||
import { UnsavedChangesProvider, useReturnToNavigation } from "@erp/core/hooks";
|
import { UnsavedChangesProvider, useReturnToNavigation } from "@erp/core/hooks";
|
||||||
import { QuickCreateCustomerDialog, SelectCustomerDialog } from "@erp/customers";
|
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 { FormProvider } from "react-hook-form";
|
||||||
|
|
||||||
import { ProformaInfoAlert } from "../../../update/ui/components";
|
import { ProformaInfoAlert } from "../../../update/ui/components";
|
||||||
@ -16,7 +16,7 @@ export const ProformaCreatePage = () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
return (
|
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}>
|
<FormProvider {...createCtrl.form}>
|
||||||
<UnsavedChangesProvider isDirty={createCtrl.form.formState.isDirty}>
|
<UnsavedChangesProvider isDirty={createCtrl.form.formState.isDirty}>
|
||||||
<ProformaCreateHeader
|
<ProformaCreateHeader
|
||||||
@ -30,9 +30,13 @@ export const ProformaCreatePage = () => {
|
|||||||
</ProformaInfoAlert>
|
</ProformaInfoAlert>
|
||||||
</ProformaCreateHeader>
|
</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">
|
<RdxEditorPageLayout complexity="simple">
|
||||||
<div className="flex min-w-0 flex-col gap-6">
|
<RdxPageStack spacing="default">
|
||||||
{createCtrl.isCreateError && (
|
{createCtrl.isCreateError && (
|
||||||
<ErrorAlert
|
<ErrorAlert
|
||||||
message={
|
message={
|
||||||
@ -70,9 +74,9 @@ export const ProformaCreatePage = () => {
|
|||||||
selectedCustomer={createCtrl.selectedCustomer}
|
selectedCustomer={createCtrl.selectedCustomer}
|
||||||
targetInvoiceSeriesOptions={createCtrl.targetInvoiceSeriesOptions}
|
targetInvoiceSeriesOptions={createCtrl.targetInvoiceSeriesOptions}
|
||||||
/>
|
/>
|
||||||
</div>
|
</RdxPageStack>
|
||||||
</RdxEditorPageLayout>
|
</RdxEditorPageLayout>
|
||||||
</div>
|
</RdxPageContainer>
|
||||||
|
|
||||||
<SelectCustomerDialog
|
<SelectCustomerDialog
|
||||||
ctrl={selectCustomerCtrl.selectCtrl}
|
ctrl={selectCustomerCtrl.selectCtrl}
|
||||||
@ -81,6 +85,6 @@ export const ProformaCreatePage = () => {
|
|||||||
<QuickCreateCustomerDialog ctrl={selectCustomerCtrl.createCtrl} />
|
<QuickCreateCustomerDialog ctrl={selectCustomerCtrl.createCtrl} />
|
||||||
</UnsavedChangesProvider>
|
</UnsavedChangesProvider>
|
||||||
</FormProvider>
|
</FormProvider>
|
||||||
</div>
|
</RdxPageStack>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@ -1 +1,2 @@
|
|||||||
export * from "./use-proforma-detail-page-controller";
|
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,
|
buildProformaDetailHeaderViewModel,
|
||||||
buildProformaDetailPageErrorViewModel,
|
buildProformaDetailPageErrorViewModel,
|
||||||
} from "../utils";
|
} from "../utils";
|
||||||
|
import { useCreateRecurringProformaFromProformaDialogController } from "./use-create-recurring-proforma-from-proforma-dialog-controller";
|
||||||
|
|
||||||
const PROFORMAS_FALLBACK_PATH = "/proformas";
|
const PROFORMAS_FALLBACK_PATH = "/proformas";
|
||||||
// biome-ignore lint/suspicious/noControlCharactersInRegex: <Se utiliza para reemplazar caracteres inválidos en el nombre del archivo>
|
// 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 issueDialogCtrl = useIssueProformaDialogController();
|
||||||
const changeStatusDialogCtrl = useChangeProformaStatusDialogController();
|
const changeStatusDialogCtrl = useChangeProformaStatusDialogController();
|
||||||
|
const createRecurringDialogCtrl = useCreateRecurringProformaFromProformaDialogController({
|
||||||
|
proformaId: id,
|
||||||
|
proformaReference: proformaQuery.data?.proformaReference ?? null,
|
||||||
|
});
|
||||||
const previewPdfQuery = useDownloadProformaPDFQuery(id, {
|
const previewPdfQuery = useDownloadProformaPDFQuery(id, {
|
||||||
enabled: Boolean(id),
|
enabled: Boolean(id),
|
||||||
});
|
});
|
||||||
@ -447,6 +452,7 @@ export const useProformaDetailPageController = () => {
|
|||||||
changeStatus: t("pages.proformas.list.actions.change_status"),
|
changeStatus: t("pages.proformas.list.actions.change_status"),
|
||||||
issue: t("pages.proformas.list.actions.issue"),
|
issue: t("pages.proformas.list.actions.issue"),
|
||||||
duplicate: t("pages.proformas.list.actions.duplicate"),
|
duplicate: t("pages.proformas.list.actions.duplicate"),
|
||||||
|
createRecurring: t("pages.proformas.list.actions.create_recurring"),
|
||||||
openPdf: t("proformas.detail.document.actions.open"),
|
openPdf: t("proformas.detail.document.actions.open"),
|
||||||
downloadPdf: t("pages.proformas.list.actions.download_pdf"),
|
downloadPdf: t("pages.proformas.list.actions.download_pdf"),
|
||||||
archive: t("pages.proformas.list.actions.archive"),
|
archive: t("pages.proformas.list.actions.archive"),
|
||||||
@ -467,6 +473,7 @@ export const useProformaDetailPageController = () => {
|
|||||||
loading: {
|
loading: {
|
||||||
issue: issueDialogCtrl.isSubmitting,
|
issue: issueDialogCtrl.isSubmitting,
|
||||||
duplicate: duplicateMutation.isPending,
|
duplicate: duplicateMutation.isPending,
|
||||||
|
"create-recurring": createRecurringDialogCtrl.isSubmitting,
|
||||||
"open-pdf": previewPdfQuery.isFetching && !previewPdfBlob,
|
"open-pdf": previewPdfQuery.isFetching && !previewPdfBlob,
|
||||||
"download-pdf": previewPdfQuery.isFetching && !previewPdfBlob,
|
"download-pdf": previewPdfQuery.isFetching && !previewPdfBlob,
|
||||||
archive: archiveMutation.isPending,
|
archive: archiveMutation.isPending,
|
||||||
@ -476,6 +483,7 @@ export const useProformaDetailPageController = () => {
|
|||||||
}, [
|
}, [
|
||||||
archiveMutation.isPending,
|
archiveMutation.isPending,
|
||||||
duplicateMutation.isPending,
|
duplicateMutation.isPending,
|
||||||
|
createRecurringDialogCtrl.isSubmitting,
|
||||||
issueDialogCtrl.isSubmitting,
|
issueDialogCtrl.isSubmitting,
|
||||||
previewPdfBlob,
|
previewPdfBlob,
|
||||||
previewPdfQuery.isFetching,
|
previewPdfQuery.isFetching,
|
||||||
@ -509,6 +517,9 @@ export const useProformaDetailPageController = () => {
|
|||||||
case "duplicate":
|
case "duplicate":
|
||||||
handleDuplicate();
|
handleDuplicate();
|
||||||
return;
|
return;
|
||||||
|
case "create-recurring":
|
||||||
|
createRecurringDialogCtrl.openDialog();
|
||||||
|
return;
|
||||||
case "open-pdf":
|
case "open-pdf":
|
||||||
handleOpenPreviewPdf().catch((error: unknown) => {
|
handleOpenPreviewPdf().catch((error: unknown) => {
|
||||||
showErrorToast(
|
showErrorToast(
|
||||||
@ -551,6 +562,7 @@ export const useProformaDetailPageController = () => {
|
|||||||
dialogs: {
|
dialogs: {
|
||||||
issue: issueDialogCtrl,
|
issue: issueDialogCtrl,
|
||||||
changeStatus: changeStatusDialogCtrl,
|
changeStatus: changeStatusDialogCtrl,
|
||||||
|
createRecurring: createRecurringDialogCtrl,
|
||||||
},
|
},
|
||||||
viewer: {
|
viewer: {
|
||||||
handleDownload,
|
handleDownload,
|
||||||
|
|||||||
@ -135,6 +135,7 @@ export type ProformaDetailActionKey =
|
|||||||
| "change-status"
|
| "change-status"
|
||||||
| "issue"
|
| "issue"
|
||||||
| "duplicate"
|
| "duplicate"
|
||||||
|
| "create-recurring"
|
||||||
| "open-pdf"
|
| "open-pdf"
|
||||||
| "download-pdf"
|
| "download-pdf"
|
||||||
| "archive"
|
| "archive"
|
||||||
|
|||||||
@ -44,6 +44,8 @@ export const ProformaDetailHeaderActions = ({
|
|||||||
return FileOutputIcon;
|
return FileOutputIcon;
|
||||||
case "duplicate":
|
case "duplicate":
|
||||||
return CopyIcon;
|
return CopyIcon;
|
||||||
|
case "create-recurring":
|
||||||
|
return RefreshCwIcon;
|
||||||
case "open-pdf":
|
case "open-pdf":
|
||||||
return ExternalLinkIcon;
|
return ExternalLinkIcon;
|
||||||
case "download-pdf":
|
case "download-pdf":
|
||||||
@ -52,7 +54,6 @@ export const ProformaDetailHeaderActions = ({
|
|||||||
return ArchiveIcon;
|
return ArchiveIcon;
|
||||||
case "unarchive":
|
case "unarchive":
|
||||||
return Undo2Icon;
|
return Undo2Icon;
|
||||||
case "edit":
|
|
||||||
default:
|
default:
|
||||||
return PencilIcon;
|
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 "./proforma-detail-archived-badge";
|
||||||
|
export * from "./create-recurring-proforma-from-proforma-dialog";
|
||||||
export * from "./proforma-detail-field";
|
export * from "./proforma-detail-field";
|
||||||
export * from "./proforma-detail-header-meta";
|
export * from "./proforma-detail-header-meta";
|
||||||
export * from "./proforma-detail-section";
|
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 { PageErrorState } from "@erp/core/components";
|
||||||
|
import { RdxPageStack } from "@repo/rdx-ui/components";
|
||||||
|
|
||||||
import { ChangeProformaStatusDialog } from "../../../change-status";
|
import { ChangeProformaStatusDialog } from "../../../change-status";
|
||||||
import { IssueProformaDialog } from "../../../issue-proforma";
|
import { IssueProformaDialog } from "../../../issue-proforma";
|
||||||
@ -9,7 +10,7 @@ import {
|
|||||||
ProformaDetailInspector,
|
ProformaDetailInspector,
|
||||||
ProformaDocumentPane,
|
ProformaDocumentPane,
|
||||||
} from "../blocks";
|
} from "../blocks";
|
||||||
import { ProformaDetailSkeleton } from "../components";
|
import { CreateRecurringProformaFromProformaDialog, ProformaDetailSkeleton } from "../components";
|
||||||
|
|
||||||
export const ProformaDetailPage = () => {
|
export const ProformaDetailPage = () => {
|
||||||
const ctrl = useProformaDetailPageController();
|
const ctrl = useProformaDetailPageController();
|
||||||
@ -40,7 +41,7 @@ export const ProformaDetailPage = () => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
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
|
<ProformaDetailHeader
|
||||||
actions={ctrl.actions}
|
actions={ctrl.actions}
|
||||||
header={ctrl.header}
|
header={ctrl.header}
|
||||||
@ -98,6 +99,21 @@ export const ProformaDetailPage = () => {
|
|||||||
open={ctrl.dialogs.changeStatus.open}
|
open={ctrl.dialogs.changeStatus.open}
|
||||||
targets={ctrl.dialogs.changeStatus.targets}
|
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;
|
changeStatus: string;
|
||||||
issue: string;
|
issue: string;
|
||||||
duplicate: string;
|
duplicate: string;
|
||||||
|
createRecurring: string;
|
||||||
openPdf: string;
|
openPdf: string;
|
||||||
downloadPdf: string;
|
downloadPdf: string;
|
||||||
archive: string;
|
archive: string;
|
||||||
@ -121,6 +122,15 @@ export const buildProformaDetailActions = ({
|
|||||||
loading.duplicate ?? false,
|
loading.duplicate ?? false,
|
||||||
null
|
null
|
||||||
),
|
),
|
||||||
|
buildAction(
|
||||||
|
"create-recurring",
|
||||||
|
labels.createRecurring,
|
||||||
|
"menu",
|
||||||
|
"visible",
|
||||||
|
true,
|
||||||
|
loading["create-recurring"] ?? false,
|
||||||
|
null
|
||||||
|
),
|
||||||
buildAction(
|
buildAction(
|
||||||
"open-pdf",
|
"open-pdf",
|
||||||
labels.openPdf,
|
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 { cn } from "@repo/shadcn-ui/lib/utils";
|
||||||
import type { ReactNode } from "react";
|
import type { ReactNode } from "react";
|
||||||
|
|
||||||
@ -26,7 +26,7 @@ export const ProformaPageHeader = ({
|
|||||||
contentClassName,
|
contentClassName,
|
||||||
}: ProformaPageHeaderProps) => {
|
}: ProformaPageHeaderProps) => {
|
||||||
return (
|
return (
|
||||||
<PageFormHeader
|
<RdxPageHeader
|
||||||
actions={actions}
|
actions={actions}
|
||||||
backLabel={backLabel}
|
backLabel={backLabel}
|
||||||
className={cn("shrink-0", className)}
|
className={cn("shrink-0", className)}
|
||||||
@ -34,9 +34,10 @@ export const ProformaPageHeader = ({
|
|||||||
onBack={onBack}
|
onBack={onBack}
|
||||||
showStatus={showStatus}
|
showStatus={showStatus}
|
||||||
statusLabel={statusLabel}
|
statusLabel={statusLabel}
|
||||||
|
sticky
|
||||||
title={title}
|
title={title}
|
||||||
>
|
>
|
||||||
{children}
|
{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", {
|
mock.module("@repo/rdx-ui/components", {
|
||||||
namedExports: {
|
namedExports: {
|
||||||
AppContent: ({ children }: { children: ReactNode }) => children,
|
|
||||||
CancelActionButton: () => null,
|
CancelActionButton: () => null,
|
||||||
PercentageField: () => null,
|
PercentageField: () => null,
|
||||||
RdxEditorStickyFooter: () => null,
|
RdxEditorStickyFooter: () => null,
|
||||||
|
RdxPageContainer: ({ children }: { children: ReactNode }) => children,
|
||||||
|
RdxPageStack: ({ children }: { children: ReactNode }) => children,
|
||||||
RhfSubmitActionButton: () => null,
|
RhfSubmitActionButton: () => null,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|||||||
@ -2,10 +2,11 @@ import { ErrorAlert, PageErrorState } from "@erp/core/components";
|
|||||||
import { UnsavedChangesProvider, useReturnToNavigation } from "@erp/core/hooks";
|
import { UnsavedChangesProvider, useReturnToNavigation } from "@erp/core/hooks";
|
||||||
import { QuickCreateCustomerDialog, SelectCustomerDialog } from "@erp/customers";
|
import { QuickCreateCustomerDialog, SelectCustomerDialog } from "@erp/customers";
|
||||||
import {
|
import {
|
||||||
AppContent,
|
|
||||||
CancelActionButton,
|
CancelActionButton,
|
||||||
PercentageField,
|
PercentageField,
|
||||||
RdxEditorStickyFooter,
|
RdxEditorStickyFooter,
|
||||||
|
RdxPageContainer,
|
||||||
|
RdxPageStack,
|
||||||
RhfSubmitActionButton,
|
RhfSubmitActionButton,
|
||||||
} from "@repo/rdx-ui/components";
|
} from "@repo/rdx-ui/components";
|
||||||
import { showErrorToast } from "@repo/rdx-ui/helpers";
|
import { showErrorToast } from "@repo/rdx-ui/helpers";
|
||||||
@ -52,7 +53,7 @@ export const ProformaUpdatePage = () => {
|
|||||||
|
|
||||||
if (ctrl.pageError) {
|
if (ctrl.pageError) {
|
||||||
return (
|
return (
|
||||||
<AppContent className="p-4 sm:p-6">
|
<RdxPageContainer width="full">
|
||||||
<div className="mx-auto w-full max-w-3xl">
|
<div className="mx-auto w-full max-w-3xl">
|
||||||
<PageErrorState
|
<PageErrorState
|
||||||
description={ctrl.pageError.description}
|
description={ctrl.pageError.description}
|
||||||
@ -88,12 +89,14 @@ export const ProformaUpdatePage = () => {
|
|||||||
variant={ctrl.pageError.variant}
|
variant={ctrl.pageError.variant}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</AppContent>
|
</RdxPageContainer>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const hasPageAlerts = updateCtrl.isUpdateError || updateCtrl.isSeriesLoadError;
|
||||||
|
|
||||||
return (
|
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}>
|
<FormProvider {...updateCtrl.form}>
|
||||||
<UnsavedChangesProvider isDirty={updateCtrl.form.formState.isDirty}>
|
<UnsavedChangesProvider isDirty={updateCtrl.form.formState.isDirty}>
|
||||||
<ProformaUpdateHeader
|
<ProformaUpdateHeader
|
||||||
@ -139,35 +142,48 @@ export const ProformaUpdatePage = () => {
|
|||||||
/>
|
/>
|
||||||
|
|
||||||
<div className="flex min-h-0 flex-1 flex-col bg-muted/20">
|
<div className="flex min-h-0 flex-1 flex-col bg-muted/20">
|
||||||
<div className="min-h-0 flex-1 overflow-y-auto">
|
<RdxPageContainer className="min-h-0 flex-1 overflow-y-auto" padding="none" width="full">
|
||||||
{updateCtrl.isUpdateError && (
|
{hasPageAlerts ? (
|
||||||
<ErrorAlert
|
<RdxPageContainer className="pb-0" width="full">
|
||||||
message={
|
<RdxPageStack spacing="compact">
|
||||||
(updateCtrl.updateError as Error)?.message ??
|
{updateCtrl.isUpdateError && (
|
||||||
t("pages.proformas.update.error_msg", "Revisa los datos e inténtalo de nuevo.")
|
<ErrorAlert
|
||||||
}
|
message={
|
||||||
title={t("pages.proformas.update.error_title", "No se pudo guardar los cambios")}
|
(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 && (
|
{updateCtrl.isSeriesLoadError && (
|
||||||
<ErrorAlert
|
<ErrorAlert
|
||||||
message={
|
message={
|
||||||
updateCtrl.seriesLoadError instanceof Error
|
updateCtrl.seriesLoadError instanceof Error
|
||||||
? updateCtrl.seriesLoadError.message
|
? updateCtrl.seriesLoadError.message
|
||||||
: t(
|
: t(
|
||||||
"pages.proformas.update.series_load_error.message",
|
"pages.proformas.update.series_load_error.message",
|
||||||
"Inténtalo de nuevo más tarde."
|
"Inténtalo de nuevo más tarde."
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
title={t(
|
title={t(
|
||||||
"pages.proformas.update.series_load_error.title",
|
"pages.proformas.update.series_load_error.title",
|
||||||
"No se pudieron cargar las series de facturación"
|
"No se pudieron cargar las series de facturación"
|
||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
</RdxPageStack>
|
||||||
|
</RdxPageContainer>
|
||||||
|
) : null}
|
||||||
|
|
||||||
<ProformaUpdateEditorForm
|
<ProformaUpdateEditorForm
|
||||||
|
className={hasPageAlerts ? "pt-0" : undefined}
|
||||||
formId={updateCtrl.formId}
|
formId={updateCtrl.formId}
|
||||||
isSubmitting={updateCtrl.isUpdating}
|
isSubmitting={updateCtrl.isUpdating}
|
||||||
itemsCtrl={updateCtrl.itemsCtrl}
|
itemsCtrl={updateCtrl.itemsCtrl}
|
||||||
@ -182,7 +198,7 @@ export const ProformaUpdatePage = () => {
|
|||||||
taxCtrl={updateCtrl.taxCtrl}
|
taxCtrl={updateCtrl.taxCtrl}
|
||||||
totalsCtrl={updateCtrl.totalsCtrl}
|
totalsCtrl={updateCtrl.totalsCtrl}
|
||||||
/>
|
/>
|
||||||
</div>
|
</RdxPageContainer>
|
||||||
|
|
||||||
<RdxEditorStickyFooter
|
<RdxEditorStickyFooter
|
||||||
leading={
|
leading={
|
||||||
@ -246,6 +262,6 @@ export const ProformaUpdatePage = () => {
|
|||||||
/>
|
/>
|
||||||
</UnsavedChangesProvider>
|
</UnsavedChangesProvider>
|
||||||
</FormProvider>
|
</FormProvider>
|
||||||
</div>
|
</RdxPageStack>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@ -5,8 +5,8 @@ import { mock, test } from "node:test";
|
|||||||
|
|
||||||
import { i18n } from "@repo/i18next";
|
import { i18n } from "@repo/i18next";
|
||||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||||
import { useForm } from "react-hook-form";
|
|
||||||
import { renderToReadableStream } from "react-dom/server";
|
import { renderToReadableStream } from "react-dom/server";
|
||||||
|
import { useForm } from "react-hook-form";
|
||||||
import { I18nextProvider } from "react-i18next";
|
import { I18nextProvider } from "react-i18next";
|
||||||
import { RouterProvider, createMemoryRouter } from "react-router-dom";
|
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 {
|
import {
|
||||||
Badge,
|
Badge,
|
||||||
Button,
|
Button,
|
||||||
@ -10,17 +11,17 @@ import {
|
|||||||
import { ArrowLeftIcon } from "lucide-react";
|
import { ArrowLeftIcon } from "lucide-react";
|
||||||
import { useNavigate, useParams } from "react-router-dom";
|
import { useNavigate, useParams } from "react-router-dom";
|
||||||
|
|
||||||
import { useTranslation } from "../../i18n";
|
import { useTranslation } from "../../../../i18n";
|
||||||
import { useRecurringProforma } from "../shared/hooks/use-recurring-proforma";
|
import { ProformaPageHeader } from "../../../../proformas/shared";
|
||||||
|
import { useRecurringProforma } from "../../../shared/hooks/use-recurring-proforma";
|
||||||
import {
|
import {
|
||||||
buildRecurringProformaDetailMainView,
|
buildRecurringProformaDetailMainView,
|
||||||
formatRecurringProformaDateOnly,
|
formatRecurringProformaDateOnly,
|
||||||
formatRecurringProformaOverride,
|
formatRecurringProformaOverride,
|
||||||
formatRecurringProformaPercentage,
|
formatRecurringProformaPercentage,
|
||||||
getRecurringProformaDetailFeedbackState,
|
getRecurringProformaDetailFeedbackState,
|
||||||
} from "./recurring-proforma-detail-utils";
|
} from "../../recurring-proforma-detail-utils";
|
||||||
import { RecurringProformaOccurrencesSection } from "./recurring-proforma-occurrences-section";
|
import { RecurringProformaOccurrencesSection } from "../../recurring-proforma-occurrences-section";
|
||||||
|
|
||||||
type DetailFieldProps = {
|
type DetailFieldProps = {
|
||||||
label: string;
|
label: string;
|
||||||
@ -83,8 +84,8 @@ export const RecurringProformaDetailPage = () => {
|
|||||||
const discount = formatRecurringProformaPercentage(recurringProforma.global_discount_percentage);
|
const discount = formatRecurringProformaPercentage(recurringProforma.global_discount_percentage);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-6 p-4 sm:p-6">
|
<RdxPageStack className="fixed inset-0 min-h-0 overflow-hidden" spacing="none">
|
||||||
<PageFormHeader
|
<ProformaPageHeader
|
||||||
backLabel={t("recurring_proformas.detail.back")}
|
backLabel={t("recurring_proformas.detail.back")}
|
||||||
onBack={() => navigate("/recurring-proformas")}
|
onBack={() => navigate("/recurring-proformas")}
|
||||||
title={mainView.name}
|
title={mainView.name}
|
||||||
@ -95,7 +96,7 @@ export const RecurringProformaDetailPage = () => {
|
|||||||
{t("recurring_proformas.detail.context")}
|
{t("recurring_proformas.detail.context")}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</PageFormHeader>
|
</ProformaPageHeader>
|
||||||
|
|
||||||
<div className="grid gap-6 xl:grid-cols-2">
|
<div className="grid gap-6 xl:grid-cols-2">
|
||||||
<Card>
|
<Card>
|
||||||
@ -236,6 +237,6 @@ export const RecurringProformaDetailPage = () => {
|
|||||||
<ArrowLeftIcon aria-hidden className="size-4" />
|
<ArrowLeftIcon aria-hidden className="size-4" />
|
||||||
{t("recurring_proformas.detail.back")}
|
{t("recurring_proformas.detail.back")}
|
||||||
</Button>
|
</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";
|
import type { ComponentPropsWithoutRef, ReactNode } from "react";
|
||||||
|
|
||||||
export type RdxPageWidth = "full" | "wide" | "content";
|
export type RdxPageWidth = "full" | "wide" | "content";
|
||||||
|
export type RdxPageContainerPadding = "default" | "none";
|
||||||
|
|
||||||
export interface RdxPageContainerProps
|
export interface RdxPageContainerProps
|
||||||
extends Omit<ComponentPropsWithoutRef<"div">, "children"> {
|
extends Omit<ComponentPropsWithoutRef<"div">, "children"> {
|
||||||
children: ReactNode;
|
children: ReactNode;
|
||||||
width?: RdxPageWidth;
|
width?: RdxPageWidth;
|
||||||
|
padding?: RdxPageContainerPadding;
|
||||||
}
|
}
|
||||||
|
|
||||||
const WIDTH_CLASSNAME: Record<RdxPageWidth, string> = {
|
const WIDTH_CLASSNAME: Record<RdxPageWidth, string> = {
|
||||||
@ -15,15 +17,21 @@ const WIDTH_CLASSNAME: Record<RdxPageWidth, string> = {
|
|||||||
content: "mx-auto max-w-5xl w-full",
|
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 = ({
|
export const RdxPageContainer = ({
|
||||||
children,
|
children,
|
||||||
className,
|
className,
|
||||||
width = "full",
|
width = "full",
|
||||||
|
padding = "default",
|
||||||
...props
|
...props
|
||||||
}: RdxPageContainerProps) => {
|
}: RdxPageContainerProps) => {
|
||||||
return (
|
return (
|
||||||
<div
|
<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}
|
{...props}
|
||||||
>
|
>
|
||||||
{children}
|
{children}
|
||||||
|
|||||||
@ -12,6 +12,7 @@ test("RdxPageContainer renders children with full width by default", () => {
|
|||||||
|
|
||||||
assert.match(html, /Content/);
|
assert.match(html, /Content/);
|
||||||
assert.match(html, /w-full/);
|
assert.match(html, /w-full/);
|
||||||
|
assert.match(html, /px-4/);
|
||||||
assert.doesNotMatch(html, /max-w-screen-2xl/);
|
assert.doesNotMatch(html, /max-w-screen-2xl/);
|
||||||
assert.doesNotMatch(html, /max-w-5xl/);
|
assert.doesNotMatch(html, /max-w-5xl/);
|
||||||
});
|
});
|
||||||
@ -31,6 +32,16 @@ test("RdxPageContainer applies width variants and merges className", () => {
|
|||||||
assert.match(contentHtml, /max-w-5xl/);
|
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", () => {
|
test("RdxPageStack applies default spacing and className", () => {
|
||||||
const html = renderToStaticMarkup(
|
const html = renderToStaticMarkup(
|
||||||
<RdxPageStack className="custom-stack">
|
<RdxPageStack className="custom-stack">
|
||||||
@ -44,9 +55,11 @@ test("RdxPageStack applies default spacing and className", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
test("RdxPageStack applies spacing variants", () => {
|
test("RdxPageStack applies spacing variants", () => {
|
||||||
|
const noneHtml = renderToStaticMarkup(<RdxPageStack spacing="none">None</RdxPageStack>);
|
||||||
const compactHtml = renderToStaticMarkup(<RdxPageStack spacing="compact">Compact</RdxPageStack>);
|
const compactHtml = renderToStaticMarkup(<RdxPageStack spacing="compact">Compact</RdxPageStack>);
|
||||||
const relaxedHtml = renderToStaticMarkup(<RdxPageStack spacing="relaxed">Relaxed</RdxPageStack>);
|
const relaxedHtml = renderToStaticMarkup(<RdxPageStack spacing="relaxed">Relaxed</RdxPageStack>);
|
||||||
|
|
||||||
|
assert.match(noneHtml, /gap-0/);
|
||||||
assert.match(compactHtml, /gap-4/);
|
assert.match(compactHtml, /gap-4/);
|
||||||
assert.match(relaxedHtml, /gap-8/);
|
assert.match(relaxedHtml, /gap-8/);
|
||||||
});
|
});
|
||||||
@ -78,6 +91,29 @@ test("RdxPageHeader omits optional wrappers when content is absent", () => {
|
|||||||
assert.doesNotMatch(html, /Metadata/);
|
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", () => {
|
test("RdxPageActions renders only populated regions in the expected order", () => {
|
||||||
const html = renderToStaticMarkup(
|
const html = renderToStaticMarkup(
|
||||||
<RdxPageActions
|
<RdxPageActions
|
||||||
|
|||||||
@ -1,4 +1,6 @@
|
|||||||
|
import { Badge, Button } from "@repo/shadcn-ui/components";
|
||||||
import { cn } from "@repo/shadcn-ui/lib/utils";
|
import { cn } from "@repo/shadcn-ui/lib/utils";
|
||||||
|
import { ArrowLeftIcon } from "lucide-react";
|
||||||
import type { ComponentPropsWithoutRef, ReactNode } from "react";
|
import type { ComponentPropsWithoutRef, ReactNode } from "react";
|
||||||
|
|
||||||
export interface RdxPageHeaderProps
|
export interface RdxPageHeaderProps
|
||||||
@ -8,6 +10,13 @@ export interface RdxPageHeaderProps
|
|||||||
leading?: ReactNode;
|
leading?: ReactNode;
|
||||||
metadata?: ReactNode;
|
metadata?: ReactNode;
|
||||||
actions?: ReactNode;
|
actions?: ReactNode;
|
||||||
|
backLabel?: string;
|
||||||
|
onBack?: () => void;
|
||||||
|
statusLabel?: ReactNode;
|
||||||
|
showStatus?: boolean;
|
||||||
|
sticky?: boolean;
|
||||||
|
contentClassName?: string;
|
||||||
|
children?: ReactNode;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const RdxPageHeader = ({
|
export const RdxPageHeader = ({
|
||||||
@ -16,9 +25,73 @@ export const RdxPageHeader = ({
|
|||||||
leading,
|
leading,
|
||||||
metadata,
|
metadata,
|
||||||
actions,
|
actions,
|
||||||
|
backLabel,
|
||||||
|
onBack,
|
||||||
|
statusLabel,
|
||||||
|
showStatus = false,
|
||||||
|
sticky = false,
|
||||||
|
contentClassName,
|
||||||
|
children,
|
||||||
className,
|
className,
|
||||||
...props
|
...props
|
||||||
}: RdxPageHeaderProps) => {
|
}: 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 (
|
return (
|
||||||
<header
|
<header
|
||||||
className={cn(
|
className={cn(
|
||||||
@ -48,6 +121,8 @@ export const RdxPageHeader = ({
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{actions ? <div className="min-w-0 lg:shrink-0">{actions}</div> : null}
|
{actions ? <div className="min-w-0 lg:shrink-0">{actions}</div> : null}
|
||||||
|
|
||||||
|
{children}
|
||||||
</header>
|
</header>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@ -1,7 +1,7 @@
|
|||||||
import { cn } from "@repo/shadcn-ui/lib/utils";
|
import { cn } from "@repo/shadcn-ui/lib/utils";
|
||||||
import type { ComponentPropsWithoutRef, ReactNode } from "react";
|
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"> {
|
export interface RdxPageStackProps extends Omit<ComponentPropsWithoutRef<"div">, "children"> {
|
||||||
children: ReactNode;
|
children: ReactNode;
|
||||||
@ -9,6 +9,7 @@ export interface RdxPageStackProps extends Omit<ComponentPropsWithoutRef<"div">,
|
|||||||
}
|
}
|
||||||
|
|
||||||
const SPACING_CLASSNAME: Record<RdxPageStackSpacing, string> = {
|
const SPACING_CLASSNAME: Record<RdxPageStackSpacing, string> = {
|
||||||
|
none: "gap-0",
|
||||||
compact: "gap-4",
|
compact: "gap-4",
|
||||||
default: "gap-6",
|
default: "gap-6",
|
||||||
relaxed: "gap-8",
|
relaxed: "gap-8",
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user