feat(series-documentales): implementar la página de configuración de series documentales con funcionalidad CRUD
* Añadir el componente `DocumentSeriesSettingsPage` para gestionar las series documentales. * Crear diálogos para añadir, editar, avanzar y deshabilitar series documentales. * Implementar esquemas de validación de formularios para crear y editar series documentales. * Añadir funciones de utilidad para construir los payloads de las peticiones de las operaciones sobre series documentales. * Escribir pruebas unitarias para los esquemas de formularios y las funciones de utilidad. * Implementar pruebas end-to-end para la configuración de series documentales, cubriendo la creación, edición, establecimiento como predeterminada, avance y deshabilitación.
This commit is contained in:
parent
2122ed562b
commit
78f5236fe9
@ -35,6 +35,7 @@
|
|||||||
"@erp/catalogs": "workspace:*",
|
"@erp/catalogs": "workspace:*",
|
||||||
"@erp/companies": "workspace:*",
|
"@erp/companies": "workspace:*",
|
||||||
"@erp/customer-invoices": "workspace:*",
|
"@erp/customer-invoices": "workspace:*",
|
||||||
|
"@erp/document-series": "workspace:*",
|
||||||
"@erp/customers": "workspace:*",
|
"@erp/customers": "workspace:*",
|
||||||
"@erp/identity": "workspace:*",
|
"@erp/identity": "workspace:*",
|
||||||
"@erp/suppliers": "workspace:*",
|
"@erp/suppliers": "workspace:*",
|
||||||
|
|||||||
@ -4,6 +4,7 @@ import { CompaniesModuleManifest } from "@erp/companies/client";
|
|||||||
import CoreModuleManifest, { type IModuleClient } from "@erp/core/client";
|
import CoreModuleManifest, { type IModuleClient } from "@erp/core/client";
|
||||||
import { CustomerInvoicesModuleManifest } from "@erp/customer-invoices/client";
|
import { CustomerInvoicesModuleManifest } from "@erp/customer-invoices/client";
|
||||||
import { CustomersModuleManifest } from "@erp/customers/client";
|
import { CustomersModuleManifest } from "@erp/customers/client";
|
||||||
|
import { DocumentSeriesModuleManifest } from "@erp/document-series/client";
|
||||||
import { IdentityModuleManifest } from "@erp/identity/client";
|
import { IdentityModuleManifest } from "@erp/identity/client";
|
||||||
import { SuppliersModuleManifest } from "@erp/suppliers/client";
|
import { SuppliersModuleManifest } from "@erp/suppliers/client";
|
||||||
|
|
||||||
@ -13,6 +14,7 @@ export const modules: IModuleClient[] = [
|
|||||||
AuditModuleManifest,
|
AuditModuleManifest,
|
||||||
CompaniesModuleManifest,
|
CompaniesModuleManifest,
|
||||||
CatalogsModuleManifest,
|
CatalogsModuleManifest,
|
||||||
|
DocumentSeriesModuleManifest,
|
||||||
CustomersModuleManifest,
|
CustomersModuleManifest,
|
||||||
SuppliersModuleManifest,
|
SuppliersModuleManifest,
|
||||||
CustomerInvoicesModuleManifest,
|
CustomerInvoicesModuleManifest,
|
||||||
|
|||||||
@ -25,6 +25,8 @@ Centraliza la gestion de series documentales y su numeracion transaccional para
|
|||||||
|
|
||||||
## Administración segura
|
## Administración segura
|
||||||
|
|
||||||
|
La administración está disponible en Settings > Billing > Series documentales (`/settings/billing/document-series`). No expone `branchId`, no usa `assign-next` y separa las operaciones de edición de formato, avance de contador, default y estado.
|
||||||
|
|
||||||
- El update ordinario solo admite nombre, descripción, formato y vigencia.
|
- El update ordinario solo admite nombre, descripción, formato y vigencia.
|
||||||
- Código, tipo documental y branch no se modifican tras crear la serie.
|
- Código, tipo documental y branch no se modifican tras crear la serie.
|
||||||
- El contador solo puede avanzar con `PATCH /document-series/:id/next-number`; no admite decrementos, reseteos ni reutilización de números.
|
- El contador solo puede avanzar con `PATCH /document-series/:id/next-number`; no admite decrementos, reseteos ni reutilización de números.
|
||||||
@ -32,6 +34,18 @@ Centraliza la gestion de series documentales y su numeracion transaccional para
|
|||||||
- El default es opcional y exclusivo por empresa y tipo; se cambia con `PATCH /document-series/:id/default`.
|
- El default es opcional y exclusivo por empresa y tipo; se cambia con `PATCH /document-series/:id/default`.
|
||||||
- Enable y disable son transiciones explícitas auditadas. Desactivar una serie default la deja sin default, sin seleccionar otra automáticamente.
|
- Enable y disable son transiciones explícitas auditadas. Desactivar una serie default la deja sin default, sin seleccionar otra automáticamente.
|
||||||
|
|
||||||
|
### UI de Settings
|
||||||
|
|
||||||
|
La administración vive en `/settings/billing/document-series`, bajo la sección Billing de Settings. `modules/document-series/src/web` es su propietario: expone la API web, adapters, entidades, React Query, controller, tabla y diálogos. `customer-invoices` consume series activas, pero no administra esta pantalla ni existe una dependencia inversa.
|
||||||
|
|
||||||
|
La pantalla usa el patrón canónico de Settings: listado `DataTable` con búsqueda, filtros, ordenación y paginación server-side; el controller posee los formularios y los diálogos solo presentan campos y errores. El flujo es `API → adapters/entities → React Query → controller → DataTable/dialogs`.
|
||||||
|
|
||||||
|
- Alta: permite código, tipo, formato, contador inicial, vigencia y estado inicial. `branchId` no se expone.
|
||||||
|
- Edición: solo permite nombre, descripción, prefijo, sufijo, padding y vigencia. Código y tipo quedan fijos tras crearla.
|
||||||
|
- El contador se avanza exclusivamente con su diálogo y operación específica; no se reduce ni se reinicia.
|
||||||
|
- Default y enable/disable son operaciones específicas. El default es opcional; desactivar una default la desmarca y reactivarla no la restaura.
|
||||||
|
- Cambiar formato no cambia snapshots ni referencias ya emitidas.
|
||||||
|
|
||||||
## Consumo desde otros módulos
|
## Consumo desde otros módulos
|
||||||
|
|
||||||
`document-series:general` expone:
|
`document-series:general` expone:
|
||||||
|
|||||||
@ -61,6 +61,16 @@ Confirmar que `document-series` es la fuente operativa unica para series y numer
|
|||||||
- comprobar que desactivar una default la desmarca y no selecciona otra;
|
- comprobar que desactivar una default la desmarca y no selecciona otra;
|
||||||
- comprobar que cambios de prefijo, sufijo o padding no alteran referencias ya persistidas.
|
- comprobar que cambios de prefijo, sufijo o padding no alteran referencias ya persistidas.
|
||||||
|
|
||||||
|
9. UI Settings
|
||||||
|
- abrir `/settings/billing/document-series` desde Settings > Billing;
|
||||||
|
- crear una serie y comprobar código, tipo, contador inicial, estado y default;
|
||||||
|
- editar nombre, formato y vigencia, verificando que código y tipo no están disponibles;
|
||||||
|
- avanzar el contador a un valor superior y comprobarlo en la tabla;
|
||||||
|
- cambiar la serie default y comprobar que la anterior deja de serlo;
|
||||||
|
- desactivar una default y comprobar la advertencia, que queda inactiva y sin default;
|
||||||
|
- reactivarla y comprobar que no recupera el default;
|
||||||
|
- comprobar búsqueda, filtros de tipo/estado, ordenación y paginación.
|
||||||
|
|
||||||
## Pendiente conocido
|
## Pendiente conocido
|
||||||
|
|
||||||
`branchId` aun no se propaga desde `customer-invoices` hasta `assignNextNumber(...)`. Toda la validacion de esta fase debe asumirse en alcance de empresa.
|
`branchId` aun no se propaga desde `customer-invoices` hasta `assignNextNumber(...)`. Toda la validacion de esta fase debe asumirse en alcance de empresa.
|
||||||
|
|||||||
@ -45,6 +45,8 @@ El error inicial bloqueante conserva el mismo shell:
|
|||||||
|
|
||||||
El controller de página concentra URL, filtros, consulta, estados y navegación. La Page define columnas y conecta callbacks; no reproduce reglas de dominio.
|
El controller de página concentra URL, filtros, consulta, estados y navegación. La Page define columnas y conecta callbacks; no reproduce reglas de dominio.
|
||||||
|
|
||||||
|
Los listados administrativos que editan en diálogo mantienen el formulario en el controller. La Page monta diálogos presentacionales con `FormProvider`; estos reciben `form`, estado pendiente y callbacks, pero no clientes HTTP ni mutations. `document-series` en Settings > Billing es la referencia para una edición general más operaciones administrativas separadas (contador, default y estado).
|
||||||
|
|
||||||
## 2. Create / Update
|
## 2. Create / Update
|
||||||
|
|
||||||
Estructura canónica: providers → RdxPageFrame (header/content/footer) → un único RdxPageContainer → un único RdxPageContent; los dialogs son hermanos del frame.
|
Estructura canónica: providers → RdxPageFrame (header/content/footer) → un único RdxPageContainer → un único RdxPageContent; los dialogs son hermanos del frame.
|
||||||
|
|||||||
@ -798,3 +798,7 @@ Dominio
|
|||||||
```
|
```
|
||||||
|
|
||||||
Si para añadir una Page de configuración hay que modificar `SettingsLayout`, importar su componente en `apps/web` o añadir un `switch` central, la integración está mal diseñada.
|
Si para añadir una Page de configuración hay que modificar `SettingsLayout`, importar su componente en `apps/web` o añadir un `switch` central, la integración está mal diseñada.
|
||||||
|
|
||||||
|
## 32. Referencia: series documentales
|
||||||
|
|
||||||
|
`document-series` publica la entrada `Series documentales` en la sección Billing y conserva la ruta y la administración completa en su módulo. La ruta es `/settings/billing/document-series`; la pantalla no recibe ni muestra `companyId` o `branchId`, pues usa el contexto de empresa activo. Su UI combina listado server-side y diálogos de alta, edición, avance de contador y confirmación de disable sin que el shell de Settings conozca sus contratos ni reglas.
|
||||||
|
|||||||
@ -15,10 +15,19 @@
|
|||||||
".": "./src/common/index.ts",
|
".": "./src/common/index.ts",
|
||||||
"./common": "./src/common/index.ts",
|
"./common": "./src/common/index.ts",
|
||||||
"./api": "./src/api/index.ts",
|
"./api": "./src/api/index.ts",
|
||||||
"./module": "./src/api/module.ts"
|
"./module": "./src/api/module.ts",
|
||||||
|
"./client": "./src/web/manifest.ts"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"react": "^19.2.5",
|
||||||
|
"react-dom": "^19.2.5"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@types/express": "^4.17.21",
|
"@types/express": "^4.17.21",
|
||||||
|
"@types/react": "^19.2.14",
|
||||||
|
"@types/react-dom": "^19.2.3",
|
||||||
|
"react": "^19.2.5",
|
||||||
|
"react-dom": "^19.2.5",
|
||||||
"rimraf": "^6.1.3",
|
"rimraf": "^6.1.3",
|
||||||
"typescript": "^6.0.2"
|
"typescript": "^6.0.2"
|
||||||
},
|
},
|
||||||
@ -29,8 +38,17 @@
|
|||||||
"@repo/rdx-audit": "workspace:*",
|
"@repo/rdx-audit": "workspace:*",
|
||||||
"@repo/rdx-ddd": "workspace:*",
|
"@repo/rdx-ddd": "workspace:*",
|
||||||
"@repo/rdx-utils": "workspace:*",
|
"@repo/rdx-utils": "workspace:*",
|
||||||
|
"@repo/i18next": "workspace:*",
|
||||||
|
"@repo/rdx-ui": "workspace:*",
|
||||||
|
"@repo/shadcn-ui": "workspace:*",
|
||||||
|
"@tanstack/react-query": "^5.98.0",
|
||||||
"express": "^4.22.1",
|
"express": "^4.22.1",
|
||||||
|
"lucide-react": "^1.8.0",
|
||||||
|
"react-hook-form": "^7.72.1",
|
||||||
|
"react-i18next": "^17.0.2",
|
||||||
|
"react-router-dom": "^7.14.0",
|
||||||
"sequelize": "^6.37.8",
|
"sequelize": "^6.37.8",
|
||||||
|
"use-debounce": "^10.1.1",
|
||||||
"zod": "^4.3.6"
|
"zod": "^4.3.6"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
54
modules/document-series/src/common/locales/en.json
Normal file
54
modules/document-series/src/common/locales/en.json
Normal file
@ -0,0 +1,54 @@
|
|||||||
|
{
|
||||||
|
"settings": {
|
||||||
|
"documentSeries": {
|
||||||
|
"title": "Document series",
|
||||||
|
"description": "Configure series for future proformas and issued invoices.",
|
||||||
|
"validity": "Validity",
|
||||||
|
"status": "Status",
|
||||||
|
"loading": "Loading document series…",
|
||||||
|
"loadErrorTitle": "Document series could not be loaded",
|
||||||
|
"loadErrorDescription": "Check your connection and try again.",
|
||||||
|
"retry": "Retry",
|
||||||
|
"search": "Search",
|
||||||
|
"all": "All",
|
||||||
|
"create": "New series",
|
||||||
|
"edit": "Edit series",
|
||||||
|
"advance": "Advance next number",
|
||||||
|
"default": "Set as default",
|
||||||
|
"enable": "Enable",
|
||||||
|
"disable": "Disable",
|
||||||
|
"active": "Active",
|
||||||
|
"inactive": "Inactive",
|
||||||
|
"empty": "There are no document series yet.",
|
||||||
|
"code": "Code",
|
||||||
|
"name": "Name",
|
||||||
|
"type": "Document type",
|
||||||
|
"nextNumber": "Next number",
|
||||||
|
"format": "Format",
|
||||||
|
"actions": "Actions",
|
||||||
|
"proforma": "Proforma",
|
||||||
|
"issuedInvoice": "Issued invoice",
|
||||||
|
"save": "Save",
|
||||||
|
"cancel": "Cancel",
|
||||||
|
"prefix": "Prefix",
|
||||||
|
"suffix": "Suffix",
|
||||||
|
"padding": "Padding",
|
||||||
|
"validFrom": "Valid from",
|
||||||
|
"validTo": "Valid to",
|
||||||
|
"descriptionField": "Description",
|
||||||
|
"numberMustAdvance": "The next number must be greater than the current one.",
|
||||||
|
"disableWarning": "{{name}} will be disabled.",
|
||||||
|
"disableDefaultWarning": "{{name}} is the default series. Confirm that you want to disable it.",
|
||||||
|
"messages": {
|
||||||
|
"createdTitle": "Series created",
|
||||||
|
"created": "The document series was created successfully.",
|
||||||
|
"updatedTitle": "Series updated",
|
||||||
|
"updated": "The document series was updated successfully.",
|
||||||
|
"advancedTitle": "Number updated",
|
||||||
|
"advanced": "The next number was updated successfully.",
|
||||||
|
"disabledTitle": "Series disabled",
|
||||||
|
"disabled": "The document series was disabled successfully."
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
54
modules/document-series/src/common/locales/es.json
Normal file
54
modules/document-series/src/common/locales/es.json
Normal file
@ -0,0 +1,54 @@
|
|||||||
|
{
|
||||||
|
"settings": {
|
||||||
|
"documentSeries": {
|
||||||
|
"title": "Series documentales",
|
||||||
|
"description": "Configura las series para futuras proformas y facturas emitidas.",
|
||||||
|
"validity": "Validez",
|
||||||
|
"status": "Estado",
|
||||||
|
"loading": "Cargando series documentales…",
|
||||||
|
"loadErrorTitle": "No se pudieron cargar las series",
|
||||||
|
"loadErrorDescription": "Comprueba la conexión e inténtalo de nuevo.",
|
||||||
|
"retry": "Reintentar",
|
||||||
|
"search": "Buscar",
|
||||||
|
"all": "Todos",
|
||||||
|
"create": "Nueva serie",
|
||||||
|
"edit": "Editar serie",
|
||||||
|
"advance": "Avanzar próximo número",
|
||||||
|
"default": "Establecer como predeterminada",
|
||||||
|
"enable": "Activar",
|
||||||
|
"disable": "Desactivar",
|
||||||
|
"active": "Activa",
|
||||||
|
"inactive": "Inactiva",
|
||||||
|
"empty": "Todavía no hay series documentales.",
|
||||||
|
"code": "Código",
|
||||||
|
"name": "Nombre",
|
||||||
|
"type": "Tipo documental",
|
||||||
|
"nextNumber": "Próximo número",
|
||||||
|
"format": "Formato",
|
||||||
|
"actions": "Acciones",
|
||||||
|
"proforma": "Proforma",
|
||||||
|
"issuedInvoice": "Factura emitida",
|
||||||
|
"save": "Guardar",
|
||||||
|
"cancel": "Cancelar",
|
||||||
|
"prefix": "Prefijo",
|
||||||
|
"suffix": "Sufijo",
|
||||||
|
"padding": "Relleno",
|
||||||
|
"validFrom": "Válida desde",
|
||||||
|
"validTo": "Válida hasta",
|
||||||
|
"descriptionField": "Descripción",
|
||||||
|
"numberMustAdvance": "El próximo número debe ser mayor que el actual.",
|
||||||
|
"disableWarning": "Se desactivará la serie {{name}}.",
|
||||||
|
"disableDefaultWarning": "{{name}} es la serie predeterminada. Confirma que deseas desactivarla.",
|
||||||
|
"messages": {
|
||||||
|
"createdTitle": "Serie creada",
|
||||||
|
"created": "La serie documental se ha creado correctamente.",
|
||||||
|
"updatedTitle": "Serie actualizada",
|
||||||
|
"updated": "La serie documental se ha actualizado correctamente.",
|
||||||
|
"advancedTitle": "Número actualizado",
|
||||||
|
"advanced": "El próximo número se ha actualizado correctamente.",
|
||||||
|
"disabledTitle": "Serie desactivada",
|
||||||
|
"disabled": "La serie documental se ha desactivado correctamente."
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
16
modules/document-series/src/web/document-series-routes.tsx
Normal file
16
modules/document-series/src/web/document-series-routes.tsx
Normal file
@ -0,0 +1,16 @@
|
|||||||
|
import type { ModuleClientParams, ModuleRouteObject } from "@erp/core/client";
|
||||||
|
import { lazy } from "react";
|
||||||
|
|
||||||
|
const DocumentSeriesSettingsPage = lazy(() =>
|
||||||
|
import("./settings/document-series").then((module) => ({
|
||||||
|
default: module.DocumentSeriesSettingsPage,
|
||||||
|
}))
|
||||||
|
);
|
||||||
|
|
||||||
|
export const DocumentSeriesRoutes = (_params: ModuleClientParams): ModuleRouteObject[] => [
|
||||||
|
{
|
||||||
|
path: "settings/billing/document-series",
|
||||||
|
handle: { layout: "app-sidebar", protected: true },
|
||||||
|
element: <DocumentSeriesSettingsPage />,
|
||||||
|
},
|
||||||
|
];
|
||||||
@ -0,0 +1,26 @@
|
|||||||
|
import type { DocumentSeriesResponseDTO, ListDocumentSeriesResponseDTO } from "../../../../common";
|
||||||
|
import type { DocumentSeries, DocumentSeriesList } from "../entities/document-series.entity";
|
||||||
|
|
||||||
|
export const toDocumentSeries = (dto: DocumentSeriesResponseDTO): DocumentSeries => ({
|
||||||
|
id: dto.id,
|
||||||
|
companyId: dto.company_id,
|
||||||
|
branchId: dto.branch_id,
|
||||||
|
documentType: dto.document_type,
|
||||||
|
code: dto.code,
|
||||||
|
name: dto.name,
|
||||||
|
description: dto.description,
|
||||||
|
prefix: dto.prefix,
|
||||||
|
suffix: dto.suffix,
|
||||||
|
nextNumber: dto.next_number,
|
||||||
|
padding: dto.padding,
|
||||||
|
validFrom: dto.valid_from,
|
||||||
|
validTo: dto.valid_to,
|
||||||
|
isDefault: dto.is_default,
|
||||||
|
isActive: dto.is_active,
|
||||||
|
hasAssignedNumbers: dto.has_assigned_numbers,
|
||||||
|
});
|
||||||
|
|
||||||
|
export const toDocumentSeriesList = (dto: ListDocumentSeriesResponseDTO): DocumentSeriesList => ({
|
||||||
|
items: dto.items.map(toDocumentSeries),
|
||||||
|
totalItems: dto.total,
|
||||||
|
});
|
||||||
@ -0,0 +1,54 @@
|
|||||||
|
import type { IDataSource } from "@erp/core/client";
|
||||||
|
|
||||||
|
import type {
|
||||||
|
CreateDocumentSeriesRequestDTO,
|
||||||
|
DocumentSeriesResponseDTO,
|
||||||
|
ListDocumentSeriesResponseDTO,
|
||||||
|
UpdateDocumentSeriesRequestDTO,
|
||||||
|
} from "../../../../common";
|
||||||
|
|
||||||
|
export const listDocumentSeries = (dataSource: IDataSource, criteria: Record<string, unknown>) =>
|
||||||
|
dataSource.getList<ListDocumentSeriesResponseDTO>("document-series", criteria);
|
||||||
|
|
||||||
|
export const getDocumentSeries = (dataSource: IDataSource, id: string) =>
|
||||||
|
dataSource.getOne<DocumentSeriesResponseDTO>("document-series", id);
|
||||||
|
|
||||||
|
export const createDocumentSeries = (
|
||||||
|
dataSource: IDataSource,
|
||||||
|
data: CreateDocumentSeriesRequestDTO
|
||||||
|
) =>
|
||||||
|
dataSource.createOne<CreateDocumentSeriesRequestDTO, DocumentSeriesResponseDTO>(
|
||||||
|
"document-series",
|
||||||
|
data
|
||||||
|
);
|
||||||
|
|
||||||
|
export const updateDocumentSeries = (
|
||||||
|
dataSource: IDataSource,
|
||||||
|
id: string,
|
||||||
|
data: UpdateDocumentSeriesRequestDTO
|
||||||
|
) =>
|
||||||
|
dataSource.updateOne<UpdateDocumentSeriesRequestDTO, DocumentSeriesResponseDTO>(
|
||||||
|
"document-series",
|
||||||
|
id,
|
||||||
|
data
|
||||||
|
);
|
||||||
|
|
||||||
|
export const advanceDocumentSeriesNumber = (
|
||||||
|
dataSource: IDataSource,
|
||||||
|
id: string,
|
||||||
|
nextNumber: number
|
||||||
|
) =>
|
||||||
|
dataSource.custom({
|
||||||
|
method: "patch",
|
||||||
|
path: `document-series/${id}/next-number`,
|
||||||
|
data: { next_number: nextNumber },
|
||||||
|
});
|
||||||
|
|
||||||
|
export const setDefaultDocumentSeries = (dataSource: IDataSource, id: string) =>
|
||||||
|
dataSource.custom({ method: "patch", path: `document-series/${id}/default`, data: undefined });
|
||||||
|
|
||||||
|
export const enableDocumentSeries = (dataSource: IDataSource, id: string) =>
|
||||||
|
dataSource.custom({ method: "patch", path: `document-series/${id}/enable`, data: undefined });
|
||||||
|
|
||||||
|
export const disableDocumentSeries = (dataSource: IDataSource, id: string) =>
|
||||||
|
dataSource.custom({ method: "patch", path: `document-series/${id}/disable`, data: undefined });
|
||||||
@ -0,0 +1,25 @@
|
|||||||
|
export type DocumentSeriesDocumentType = "proforma" | "issued_invoice";
|
||||||
|
|
||||||
|
export interface DocumentSeries {
|
||||||
|
id: string;
|
||||||
|
companyId: string;
|
||||||
|
branchId: string | null;
|
||||||
|
documentType: DocumentSeriesDocumentType;
|
||||||
|
code: string;
|
||||||
|
name: string;
|
||||||
|
description: string | null;
|
||||||
|
prefix: string | null;
|
||||||
|
suffix: string | null;
|
||||||
|
nextNumber: number;
|
||||||
|
padding: number;
|
||||||
|
validFrom: string | null;
|
||||||
|
validTo: string | null;
|
||||||
|
isDefault: boolean;
|
||||||
|
isActive: boolean;
|
||||||
|
hasAssignedNumbers: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface DocumentSeriesList {
|
||||||
|
items: DocumentSeries[];
|
||||||
|
totalItems: number;
|
||||||
|
}
|
||||||
@ -0,0 +1,3 @@
|
|||||||
|
export * from "./keys";
|
||||||
|
export * from "./use-document-series-list-query";
|
||||||
|
export * from "./use-document-series-mutations";
|
||||||
@ -0,0 +1,23 @@
|
|||||||
|
import { INITIAL_PAGE_INDEX, INITIAL_PAGE_SIZE } from "@repo/rdx-criteria";
|
||||||
|
import type { QueryKey } from "@tanstack/react-query";
|
||||||
|
|
||||||
|
import type { ListDocumentSeriesRequestDTO } from "../../../../common";
|
||||||
|
|
||||||
|
export const DOCUMENT_SERIES_LIST_QUERY_KEY_PREFIX = ["document-series"] as const;
|
||||||
|
export const DOCUMENT_SERIES_DETAIL_QUERY_KEY_PREFIX = ["document-series:detail"] as const;
|
||||||
|
|
||||||
|
export const documentSeriesKeys = {
|
||||||
|
all: DOCUMENT_SERIES_LIST_QUERY_KEY_PREFIX,
|
||||||
|
list: (criteria?: Partial<ListDocumentSeriesRequestDTO>): QueryKey => [
|
||||||
|
...DOCUMENT_SERIES_LIST_QUERY_KEY_PREFIX,
|
||||||
|
{
|
||||||
|
pageNumber: criteria?.pageNumber ?? INITIAL_PAGE_INDEX,
|
||||||
|
pageSize: criteria?.pageSize ?? INITIAL_PAGE_SIZE,
|
||||||
|
q: criteria?.q ?? "",
|
||||||
|
filters: criteria?.filters ?? [],
|
||||||
|
orderBy: criteria?.orderBy ?? "",
|
||||||
|
order: criteria?.order ?? "",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
detail: (id?: string): QueryKey => [...DOCUMENT_SERIES_DETAIL_QUERY_KEY_PREFIX, { id }],
|
||||||
|
};
|
||||||
@ -0,0 +1,17 @@
|
|||||||
|
import { useDataSource } from "@erp/core/hooks";
|
||||||
|
import { useQuery } from "@tanstack/react-query";
|
||||||
|
|
||||||
|
import type { ListDocumentSeriesRequestDTO } from "../../../../common";
|
||||||
|
import { toDocumentSeriesList } from "../adapters/document-series.adapter";
|
||||||
|
import { listDocumentSeries } from "../api/document-series.api";
|
||||||
|
|
||||||
|
import { documentSeriesKeys } from "./keys";
|
||||||
|
|
||||||
|
export const useDocumentSeriesListQuery = (criteria: Partial<ListDocumentSeriesRequestDTO>) => {
|
||||||
|
const dataSource = useDataSource();
|
||||||
|
return useQuery({
|
||||||
|
queryKey: documentSeriesKeys.list(criteria),
|
||||||
|
queryFn: async () => toDocumentSeriesList(await listDocumentSeries(dataSource, criteria)),
|
||||||
|
placeholderData: (previous) => previous,
|
||||||
|
});
|
||||||
|
};
|
||||||
@ -0,0 +1,70 @@
|
|||||||
|
import { useDataSource } from "@erp/core/hooks";
|
||||||
|
import { useMutation, useQueryClient } from "@tanstack/react-query";
|
||||||
|
|
||||||
|
import type {
|
||||||
|
CreateDocumentSeriesRequestDTO,
|
||||||
|
UpdateDocumentSeriesRequestDTO,
|
||||||
|
} from "../../../../common";
|
||||||
|
import {
|
||||||
|
advanceDocumentSeriesNumber,
|
||||||
|
createDocumentSeries,
|
||||||
|
disableDocumentSeries,
|
||||||
|
enableDocumentSeries,
|
||||||
|
setDefaultDocumentSeries,
|
||||||
|
updateDocumentSeries,
|
||||||
|
} from "../api/document-series.api";
|
||||||
|
|
||||||
|
import { documentSeriesKeys } from "./keys";
|
||||||
|
|
||||||
|
const invalidateLists = (queryClient: ReturnType<typeof useQueryClient>) =>
|
||||||
|
queryClient.invalidateQueries({ queryKey: documentSeriesKeys.all });
|
||||||
|
export const useCreateDocumentSeriesMutation = () => {
|
||||||
|
const dataSource = useDataSource();
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: (data: CreateDocumentSeriesRequestDTO) => createDocumentSeries(dataSource, data),
|
||||||
|
onSuccess: () => invalidateLists(queryClient),
|
||||||
|
});
|
||||||
|
};
|
||||||
|
export const useUpdateDocumentSeriesMutation = () => {
|
||||||
|
const dataSource = useDataSource();
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: ({ id, data }: { id: string; data: UpdateDocumentSeriesRequestDTO }) =>
|
||||||
|
updateDocumentSeries(dataSource, id, data),
|
||||||
|
onSuccess: () => invalidateLists(queryClient),
|
||||||
|
});
|
||||||
|
};
|
||||||
|
export const useAdvanceDocumentSeriesNumberMutation = () => {
|
||||||
|
const dataSource = useDataSource();
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: ({ id, nextNumber }: { id: string; nextNumber: number }) =>
|
||||||
|
advanceDocumentSeriesNumber(dataSource, id, nextNumber),
|
||||||
|
onSuccess: () => invalidateLists(queryClient),
|
||||||
|
});
|
||||||
|
};
|
||||||
|
export const useSetDefaultDocumentSeriesMutation = () => {
|
||||||
|
const dataSource = useDataSource();
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: ({ id }: { id: string }) => setDefaultDocumentSeries(dataSource, id),
|
||||||
|
onSuccess: () => invalidateLists(queryClient),
|
||||||
|
});
|
||||||
|
};
|
||||||
|
export const useEnableDocumentSeriesMutation = () => {
|
||||||
|
const dataSource = useDataSource();
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: ({ id }: { id: string }) => enableDocumentSeries(dataSource, id),
|
||||||
|
onSuccess: () => invalidateLists(queryClient),
|
||||||
|
});
|
||||||
|
};
|
||||||
|
export const useDisableDocumentSeriesMutation = () => {
|
||||||
|
const dataSource = useDataSource();
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: ({ id }: { id: string }) => disableDocumentSeries(dataSource, id),
|
||||||
|
onSuccess: () => invalidateLists(queryClient),
|
||||||
|
});
|
||||||
|
};
|
||||||
20
modules/document-series/src/web/i18n.ts
Normal file
20
modules/document-series/src/web/i18n.ts
Normal file
@ -0,0 +1,20 @@
|
|||||||
|
import { i18n, registerTranslations } from "@repo/i18next";
|
||||||
|
import { useEffect } from "react";
|
||||||
|
import { useTranslation as useI18NextTranslation } from "react-i18next";
|
||||||
|
|
||||||
|
import en from "../common/locales/en.json";
|
||||||
|
import es from "../common/locales/es.json";
|
||||||
|
|
||||||
|
import { MODULE_NAME } from "./manifest";
|
||||||
|
|
||||||
|
export const ensureModuleTranslations = () => {
|
||||||
|
registerTranslations(MODULE_NAME, "en", en);
|
||||||
|
registerTranslations(MODULE_NAME, "es", es);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const useTranslation = () => {
|
||||||
|
useEffect(() => {
|
||||||
|
ensureModuleTranslations();
|
||||||
|
}, []);
|
||||||
|
return useI18NextTranslation(MODULE_NAME, { i18n });
|
||||||
|
};
|
||||||
30
modules/document-series/src/web/manifest.ts
Normal file
30
modules/document-series/src/web/manifest.ts
Normal file
@ -0,0 +1,30 @@
|
|||||||
|
import type { IModuleClient } from "@erp/core/client";
|
||||||
|
import { ReceiptTextIcon } from "lucide-react";
|
||||||
|
|
||||||
|
import { DocumentSeriesRoutes } from "./document-series-routes";
|
||||||
|
import { ensureModuleTranslations } from "./i18n";
|
||||||
|
|
||||||
|
export const MODULE_NAME = "DocumentSeries";
|
||||||
|
ensureModuleTranslations();
|
||||||
|
|
||||||
|
export const DocumentSeriesModuleManifest: IModuleClient = {
|
||||||
|
name: MODULE_NAME,
|
||||||
|
version: "1.0.0",
|
||||||
|
dependencies: ["auth", "Core"],
|
||||||
|
protected: true,
|
||||||
|
layout: "app-sidebar",
|
||||||
|
settings: {
|
||||||
|
entries: [
|
||||||
|
{
|
||||||
|
id: "document-series",
|
||||||
|
icon: ReceiptTextIcon,
|
||||||
|
sectionId: "billing",
|
||||||
|
labelKey: "settings.documentSeries.title",
|
||||||
|
path: "/settings/billing/document-series",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
routes: (params) => DocumentSeriesRoutes(params),
|
||||||
|
};
|
||||||
|
|
||||||
|
export default DocumentSeriesModuleManifest;
|
||||||
@ -0,0 +1,179 @@
|
|||||||
|
import assert from "node:assert/strict";
|
||||||
|
import { mock, test } from "node:test";
|
||||||
|
|
||||||
|
const stateUpdates: Array<unknown> = [];
|
||||||
|
const resets: Array<unknown> = [];
|
||||||
|
const mutationCalls: Array<unknown> = [];
|
||||||
|
const successToasts: Array<unknown> = [];
|
||||||
|
|
||||||
|
const series = {
|
||||||
|
id: "series-a",
|
||||||
|
companyId: "company-a",
|
||||||
|
branchId: null,
|
||||||
|
documentType: "proforma" as const,
|
||||||
|
code: "P",
|
||||||
|
name: "Proformas",
|
||||||
|
description: null,
|
||||||
|
prefix: "P-",
|
||||||
|
suffix: null,
|
||||||
|
nextNumber: 10,
|
||||||
|
padding: 4,
|
||||||
|
validFrom: null,
|
||||||
|
validTo: null,
|
||||||
|
isActive: true,
|
||||||
|
isDefault: false,
|
||||||
|
hasAssignedNumbers: false,
|
||||||
|
};
|
||||||
|
|
||||||
|
const form = {
|
||||||
|
watch: () => true,
|
||||||
|
getValues: () => false,
|
||||||
|
setValue: () => undefined,
|
||||||
|
reset: (values: unknown) => resets.push(values),
|
||||||
|
clearErrors: () => undefined,
|
||||||
|
handleSubmit: (handler: (values: typeof series) => Promise<void>) => () => handler(series),
|
||||||
|
};
|
||||||
|
const advanceForm = {
|
||||||
|
...form,
|
||||||
|
handleSubmit: (handler: (values: { nextNumber: number }) => Promise<void>) => () =>
|
||||||
|
handler({ nextNumber: 12 }),
|
||||||
|
setError: () => undefined,
|
||||||
|
};
|
||||||
|
let formInvocation = 0;
|
||||||
|
let stateInvocation = 0;
|
||||||
|
let modeForTest: "create" | "edit" | null = null;
|
||||||
|
|
||||||
|
mock.module("react", {
|
||||||
|
namedExports: {
|
||||||
|
useCallback: <T>(callback: T) => callback,
|
||||||
|
useEffect: (callback: () => void) => callback(),
|
||||||
|
useId: () => "form-id",
|
||||||
|
useMemo: <T>(callback: () => T) => callback(),
|
||||||
|
useState: <T>(initial: T) => {
|
||||||
|
const value =
|
||||||
|
stateInvocation === 6
|
||||||
|
? modeForTest
|
||||||
|
: stateInvocation === 7 && modeForTest === "edit"
|
||||||
|
? series
|
||||||
|
: stateInvocation === 9
|
||||||
|
? series
|
||||||
|
: initial;
|
||||||
|
stateInvocation += 1;
|
||||||
|
return [value, (nextValue: T) => stateUpdates.push(nextValue)];
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
mock.module("use-debounce", { namedExports: { useDebounce: (value: string) => [value] } });
|
||||||
|
mock.module("@erp/core/client", {
|
||||||
|
namedExports: { getUserErrorMessage: () => ({ title: "error", description: "error" }) },
|
||||||
|
});
|
||||||
|
mock.module("@erp/core/hooks", {
|
||||||
|
namedExports: { useHookForm: () => (formInvocation++ === 0 ? form : advanceForm) },
|
||||||
|
});
|
||||||
|
mock.module("@repo/rdx-ui/helpers", {
|
||||||
|
namedExports: {
|
||||||
|
showErrorToast: () => undefined,
|
||||||
|
showSuccessToast: (...args: unknown[]) => successToasts.push(args),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
mock.module("../../../i18n", {
|
||||||
|
namedExports: { useTranslation: () => ({ t: (key: string) => key }) },
|
||||||
|
});
|
||||||
|
mock.module("../../../document-series/shared/hooks", {
|
||||||
|
namedExports: {
|
||||||
|
useDocumentSeriesListQuery: () => ({
|
||||||
|
data: { items: [series], totalItems: 1 },
|
||||||
|
refetch: async () => undefined,
|
||||||
|
}),
|
||||||
|
useCreateDocumentSeriesMutation: () => ({
|
||||||
|
isPending: false,
|
||||||
|
mutateAsync: async (data: unknown) => mutationCalls.push(["create", data]),
|
||||||
|
}),
|
||||||
|
useUpdateDocumentSeriesMutation: () => ({
|
||||||
|
isPending: false,
|
||||||
|
mutateAsync: async (data: unknown) => mutationCalls.push(["update", data]),
|
||||||
|
}),
|
||||||
|
useAdvanceDocumentSeriesNumberMutation: () => ({
|
||||||
|
isPending: false,
|
||||||
|
mutateAsync: async (data: unknown) => mutationCalls.push(["advance", data]),
|
||||||
|
}),
|
||||||
|
useSetDefaultDocumentSeriesMutation: () => ({
|
||||||
|
isPending: false,
|
||||||
|
mutate: (data: unknown) => mutationCalls.push(["default", data]),
|
||||||
|
}),
|
||||||
|
useEnableDocumentSeriesMutation: () => ({
|
||||||
|
isPending: false,
|
||||||
|
mutate: (data: unknown) => mutationCalls.push(["enable", data]),
|
||||||
|
}),
|
||||||
|
useDisableDocumentSeriesMutation: () => ({
|
||||||
|
isPending: false,
|
||||||
|
mutateAsync: async (data: unknown) => mutationCalls.push(["disable", data]),
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
test("coordina aperturas, reset, mutations administrativas y feedback", async () => {
|
||||||
|
stateInvocation = 0;
|
||||||
|
modeForTest = null;
|
||||||
|
const { useDocumentSeriesSettingsController } = await import(
|
||||||
|
"./use-document-series-settings-controller.ts"
|
||||||
|
);
|
||||||
|
const controller = useDocumentSeriesSettingsController();
|
||||||
|
|
||||||
|
controller.openCreate();
|
||||||
|
controller.openEdit(series);
|
||||||
|
controller.openAdvance(series);
|
||||||
|
controller.setDefault(series);
|
||||||
|
controller.enable(series);
|
||||||
|
controller.openDisable(series);
|
||||||
|
await controller.confirmDisable();
|
||||||
|
|
||||||
|
assert.ok(resets.some((value) => (value as { code?: string }).code === ""));
|
||||||
|
assert.ok(resets.some((value) => (value as { code?: string }).code === series.code));
|
||||||
|
assert.ok(
|
||||||
|
resets.some((value) => (value as { nextNumber?: number }).nextNumber === series.nextNumber)
|
||||||
|
);
|
||||||
|
assert.deepEqual(mutationCalls.slice(-3), [
|
||||||
|
["default", { id: series.id }],
|
||||||
|
["enable", { id: series.id }],
|
||||||
|
["disable", { id: series.id }],
|
||||||
|
]);
|
||||||
|
assert.ok(stateUpdates.includes("create"));
|
||||||
|
assert.ok(stateUpdates.includes("edit"));
|
||||||
|
assert.equal(successToasts.length, 1);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("envía create y edit mediante los builders del controller", async () => {
|
||||||
|
const { useDocumentSeriesSettingsController } = await import(
|
||||||
|
"./use-document-series-settings-controller.ts"
|
||||||
|
);
|
||||||
|
|
||||||
|
mutationCalls.length = 0;
|
||||||
|
successToasts.length = 0;
|
||||||
|
formInvocation = 0;
|
||||||
|
stateInvocation = 0;
|
||||||
|
modeForTest = "create";
|
||||||
|
useDocumentSeriesSettingsController().onSubmit(undefined as never);
|
||||||
|
await Promise.resolve();
|
||||||
|
assert.equal(mutationCalls[0]?.[0], "create");
|
||||||
|
|
||||||
|
mutationCalls.length = 0;
|
||||||
|
formInvocation = 0;
|
||||||
|
stateInvocation = 0;
|
||||||
|
modeForTest = "edit";
|
||||||
|
useDocumentSeriesSettingsController().onSubmit(undefined as never);
|
||||||
|
await Promise.resolve();
|
||||||
|
assert.equal(mutationCalls[0]?.[0], "update");
|
||||||
|
assert.deepEqual((mutationCalls[0] as [string, { id: string }])[1], {
|
||||||
|
id: series.id,
|
||||||
|
data: {
|
||||||
|
name: series.name,
|
||||||
|
description: series.description,
|
||||||
|
prefix: series.prefix,
|
||||||
|
suffix: series.suffix,
|
||||||
|
padding: series.padding,
|
||||||
|
valid_from: series.validFrom,
|
||||||
|
valid_to: series.validTo,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
});
|
||||||
@ -0,0 +1,294 @@
|
|||||||
|
import type { CriteriaDTO } from "@erp/core";
|
||||||
|
import { getUserErrorMessage } from "@erp/core/client";
|
||||||
|
import { useHookForm } from "@erp/core/hooks";
|
||||||
|
import type { DataTableSort } from "@repo/rdx-ui/components";
|
||||||
|
import { showErrorToast, showSuccessToast } from "@repo/rdx-ui/helpers";
|
||||||
|
import type { FormEvent } from "react";
|
||||||
|
import { useCallback, useEffect, useId, useMemo, useState } from "react";
|
||||||
|
import { useDebounce } from "use-debounce";
|
||||||
|
import { z } from "zod/v4";
|
||||||
|
|
||||||
|
import type { DocumentSeries } from "../../../document-series/shared/entities/document-series.entity";
|
||||||
|
import {
|
||||||
|
useAdvanceDocumentSeriesNumberMutation,
|
||||||
|
useCreateDocumentSeriesMutation,
|
||||||
|
useDisableDocumentSeriesMutation,
|
||||||
|
useDocumentSeriesListQuery,
|
||||||
|
useEnableDocumentSeriesMutation,
|
||||||
|
useSetDefaultDocumentSeriesMutation,
|
||||||
|
useUpdateDocumentSeriesMutation,
|
||||||
|
} from "../../../document-series/shared/hooks";
|
||||||
|
import { useTranslation } from "../../../i18n";
|
||||||
|
import type { DocumentSeriesFormValues } from "../entities/document-series-form.entity";
|
||||||
|
import {
|
||||||
|
DocumentSeriesCreateFormSchema,
|
||||||
|
DocumentSeriesEditFormSchema,
|
||||||
|
} from "../entities/document-series-form.schema";
|
||||||
|
import {
|
||||||
|
buildCreateDocumentSeriesRequest,
|
||||||
|
buildDocumentSeriesFormValues,
|
||||||
|
buildUpdateDocumentSeriesRequest,
|
||||||
|
} from "../utils/build-document-series-request";
|
||||||
|
|
||||||
|
export type DocumentSeriesStatusFilter = "all" | "active" | "inactive";
|
||||||
|
export type DocumentSeriesTypeFilter = "all" | "proforma" | "issued_invoice";
|
||||||
|
export type DocumentSeriesFormMode = "create" | "edit";
|
||||||
|
|
||||||
|
const DEFAULT_SORT = { field: "code", direction: "asc" } satisfies DataTableSort;
|
||||||
|
const DEFAULT_FORM_VALUES: DocumentSeriesFormValues = {
|
||||||
|
documentType: "proforma",
|
||||||
|
code: "",
|
||||||
|
name: "",
|
||||||
|
description: null,
|
||||||
|
prefix: null,
|
||||||
|
suffix: null,
|
||||||
|
nextNumber: 1,
|
||||||
|
padding: 0,
|
||||||
|
validFrom: null,
|
||||||
|
validTo: null,
|
||||||
|
isActive: true,
|
||||||
|
isDefault: false,
|
||||||
|
};
|
||||||
|
const SORT_FIELDS: Record<string, string> = {
|
||||||
|
code: "code",
|
||||||
|
name: "name",
|
||||||
|
documentType: "document_type",
|
||||||
|
status: "is_active",
|
||||||
|
default: "is_default",
|
||||||
|
};
|
||||||
|
|
||||||
|
export const useDocumentSeriesSettingsController = () => {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const formId = useId();
|
||||||
|
const [search, setSearchState] = useState("");
|
||||||
|
const [documentType, setDocumentTypeState] = useState<DocumentSeriesTypeFilter>("all");
|
||||||
|
const [status, setStatusState] = useState<DocumentSeriesStatusFilter>("all");
|
||||||
|
const [pageIndex, setPageIndex] = useState(0);
|
||||||
|
const [pageSize, setPageSizeState] = useState(10);
|
||||||
|
const [sort, setSortState] = useState<DataTableSort>(DEFAULT_SORT);
|
||||||
|
const [mode, setMode] = useState<DocumentSeriesFormMode | null>(null);
|
||||||
|
const [selectedSeries, setSelectedSeries] = useState<DocumentSeries | null>(null);
|
||||||
|
const [advancing, setAdvancing] = useState<DocumentSeries | null>(null);
|
||||||
|
const [seriesPendingDisable, setSeriesPendingDisable] = useState<DocumentSeries | null>(null);
|
||||||
|
const [debouncedSearch] = useDebounce(search, 300);
|
||||||
|
const createMutation = useCreateDocumentSeriesMutation();
|
||||||
|
const updateMutation = useUpdateDocumentSeriesMutation();
|
||||||
|
const advanceMutation = useAdvanceDocumentSeriesNumberMutation();
|
||||||
|
const defaultMutation = useSetDefaultDocumentSeriesMutation();
|
||||||
|
const enableMutation = useEnableDocumentSeriesMutation();
|
||||||
|
const disableMutation = useDisableDocumentSeriesMutation();
|
||||||
|
const isSaving = createMutation.isPending || updateMutation.isPending;
|
||||||
|
const form = useHookForm<DocumentSeriesFormValues>({
|
||||||
|
resolverSchema: DocumentSeriesCreateFormSchema,
|
||||||
|
initialValues: DEFAULT_FORM_VALUES,
|
||||||
|
disabled: isSaving,
|
||||||
|
});
|
||||||
|
const advanceForm = useHookForm<{ nextNumber: number }>({
|
||||||
|
resolverSchema: z.object({ nextNumber: z.number().int().min(1) }),
|
||||||
|
initialValues: { nextNumber: 1 },
|
||||||
|
disabled: advanceMutation.isPending,
|
||||||
|
});
|
||||||
|
const criteria = useMemo<CriteriaDTO>(
|
||||||
|
() => ({
|
||||||
|
q: debouncedSearch.trim() || undefined,
|
||||||
|
filters: [
|
||||||
|
...(documentType === "all"
|
||||||
|
? []
|
||||||
|
: [{ field: "document_type", operator: "EQUALS" as const, value: documentType }]),
|
||||||
|
...(status === "all"
|
||||||
|
? []
|
||||||
|
: [
|
||||||
|
{
|
||||||
|
field: "is_active",
|
||||||
|
operator: "EQUALS" as const,
|
||||||
|
value: String(status === "active"),
|
||||||
|
},
|
||||||
|
]),
|
||||||
|
],
|
||||||
|
pageNumber: pageIndex,
|
||||||
|
pageSize,
|
||||||
|
orderBy: SORT_FIELDS[sort.field] ?? DEFAULT_SORT.field,
|
||||||
|
order: sort.direction,
|
||||||
|
}),
|
||||||
|
[debouncedSearch, documentType, pageIndex, pageSize, sort, status]
|
||||||
|
);
|
||||||
|
const listQuery = useDocumentSeriesListQuery(criteria);
|
||||||
|
const resetForm = useCallback(
|
||||||
|
(values = DEFAULT_FORM_VALUES) => {
|
||||||
|
form.reset(values, { keepDirty: false });
|
||||||
|
form.clearErrors();
|
||||||
|
},
|
||||||
|
[form]
|
||||||
|
);
|
||||||
|
const closeFormDialog = useCallback(() => {
|
||||||
|
if (isSaving) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setMode(null);
|
||||||
|
setSelectedSeries(null);
|
||||||
|
resetForm();
|
||||||
|
}, [isSaving, resetForm]);
|
||||||
|
const openCreate = useCallback(() => {
|
||||||
|
setSelectedSeries(null);
|
||||||
|
setMode("create");
|
||||||
|
resetForm();
|
||||||
|
}, [resetForm]);
|
||||||
|
const openEdit = useCallback(
|
||||||
|
(series: DocumentSeries) => {
|
||||||
|
setSelectedSeries(series);
|
||||||
|
setMode("edit");
|
||||||
|
resetForm(buildDocumentSeriesFormValues(series));
|
||||||
|
},
|
||||||
|
[resetForm]
|
||||||
|
);
|
||||||
|
const isActive = form.watch("isActive");
|
||||||
|
useEffect(() => {
|
||||||
|
if (!isActive && form.getValues("isDefault")) {
|
||||||
|
form.setValue("isDefault", false, { shouldDirty: true, shouldValidate: true });
|
||||||
|
}
|
||||||
|
}, [form, isActive]);
|
||||||
|
const submit = form.handleSubmit(async (values) => {
|
||||||
|
try {
|
||||||
|
if (mode === "edit" && selectedSeries) {
|
||||||
|
const editValues = DocumentSeriesEditFormSchema.parse(values);
|
||||||
|
await updateMutation.mutateAsync({
|
||||||
|
id: selectedSeries.id,
|
||||||
|
data: buildUpdateDocumentSeriesRequest(editValues),
|
||||||
|
});
|
||||||
|
showSuccessToast(
|
||||||
|
t("settings.documentSeries.messages.updatedTitle"),
|
||||||
|
t("settings.documentSeries.messages.updated")
|
||||||
|
);
|
||||||
|
} else if (mode === "create") {
|
||||||
|
await createMutation.mutateAsync(buildCreateDocumentSeriesRequest(values));
|
||||||
|
showSuccessToast(
|
||||||
|
t("settings.documentSeries.messages.createdTitle"),
|
||||||
|
t("settings.documentSeries.messages.created")
|
||||||
|
);
|
||||||
|
}
|
||||||
|
closeFormDialog();
|
||||||
|
} catch (error: unknown) {
|
||||||
|
const message = getUserErrorMessage(error);
|
||||||
|
showErrorToast(message.title, message.description);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
const onSubmit = useCallback(
|
||||||
|
(event: FormEvent<HTMLFormElement>) => {
|
||||||
|
submit(event).catch((error: unknown) => {
|
||||||
|
const message = getUserErrorMessage(error);
|
||||||
|
showErrorToast(message.title, message.description);
|
||||||
|
});
|
||||||
|
},
|
||||||
|
[submit]
|
||||||
|
);
|
||||||
|
const openAdvance = useCallback(
|
||||||
|
(series: DocumentSeries) => {
|
||||||
|
advanceForm.reset({ nextNumber: series.nextNumber }, { keepDirty: false });
|
||||||
|
advanceForm.clearErrors();
|
||||||
|
setAdvancing(series);
|
||||||
|
},
|
||||||
|
[advanceForm]
|
||||||
|
);
|
||||||
|
const closeAdvance = useCallback(() => {
|
||||||
|
if (advanceMutation.isPending) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setAdvancing(null);
|
||||||
|
advanceForm.reset({ nextNumber: 1 }, { keepDirty: false });
|
||||||
|
advanceForm.clearErrors();
|
||||||
|
}, [advanceForm, advanceMutation.isPending]);
|
||||||
|
const advance = advanceForm.handleSubmit(async ({ nextNumber }) => {
|
||||||
|
if (!advancing || advanceMutation.isPending || nextNumber <= advancing.nextNumber) {
|
||||||
|
advanceForm.setError("nextNumber", {
|
||||||
|
message: t("settings.documentSeries.numberMustAdvance"),
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
await advanceMutation.mutateAsync({ id: advancing.id, nextNumber });
|
||||||
|
showSuccessToast(
|
||||||
|
t("settings.documentSeries.messages.advancedTitle"),
|
||||||
|
t("settings.documentSeries.messages.advanced")
|
||||||
|
);
|
||||||
|
closeAdvance();
|
||||||
|
} catch (error: unknown) {
|
||||||
|
const message = getUserErrorMessage(error);
|
||||||
|
showErrorToast(message.title, message.description);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
const onAdvanceSubmit = useCallback(
|
||||||
|
(event: FormEvent<HTMLFormElement>) => {
|
||||||
|
advance(event).catch((error: unknown) => {
|
||||||
|
const message = getUserErrorMessage(error);
|
||||||
|
showErrorToast(message.title, message.description);
|
||||||
|
});
|
||||||
|
},
|
||||||
|
[advance]
|
||||||
|
);
|
||||||
|
const confirmDisable = useCallback(async () => {
|
||||||
|
if (!seriesPendingDisable || disableMutation.isPending) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
await disableMutation.mutateAsync({ id: seriesPendingDisable.id });
|
||||||
|
setSeriesPendingDisable(null);
|
||||||
|
showSuccessToast(
|
||||||
|
t("settings.documentSeries.messages.disabledTitle"),
|
||||||
|
t("settings.documentSeries.messages.disabled")
|
||||||
|
);
|
||||||
|
} catch (error: unknown) {
|
||||||
|
const message = getUserErrorMessage(error);
|
||||||
|
showErrorToast(message.title, message.description);
|
||||||
|
}
|
||||||
|
}, [disableMutation, seriesPendingDisable, t]);
|
||||||
|
const resetPage = <T>(setter: (value: T) => void, value: T) => {
|
||||||
|
setter(value);
|
||||||
|
setPageIndex(0);
|
||||||
|
};
|
||||||
|
return {
|
||||||
|
list: listQuery.data?.items ?? [],
|
||||||
|
totalItems: listQuery.data?.totalItems ?? 0,
|
||||||
|
listQuery,
|
||||||
|
criteria,
|
||||||
|
search,
|
||||||
|
setSearch: (value: string) => resetPage(setSearchState, value),
|
||||||
|
documentType,
|
||||||
|
setDocumentType: (value: DocumentSeriesTypeFilter) => resetPage(setDocumentTypeState, value),
|
||||||
|
status,
|
||||||
|
setStatus: (value: DocumentSeriesStatusFilter) => resetPage(setStatusState, value),
|
||||||
|
pageIndex,
|
||||||
|
setPageIndex,
|
||||||
|
pageSize,
|
||||||
|
setPageSize: (value: number) => resetPage(setPageSizeState, value),
|
||||||
|
sort,
|
||||||
|
setSort: (value: DataTableSort) => resetPage(setSortState, value),
|
||||||
|
form,
|
||||||
|
formId,
|
||||||
|
mode,
|
||||||
|
selectedSeries,
|
||||||
|
isFormOpen: mode !== null,
|
||||||
|
isSaving,
|
||||||
|
openCreate,
|
||||||
|
openEdit,
|
||||||
|
closeFormDialog,
|
||||||
|
onSubmit,
|
||||||
|
advancing,
|
||||||
|
advanceForm,
|
||||||
|
openAdvance,
|
||||||
|
closeAdvance,
|
||||||
|
onAdvanceSubmit,
|
||||||
|
seriesPendingDisable,
|
||||||
|
openDisable: setSeriesPendingDisable,
|
||||||
|
closeDisable: () => !disableMutation.isPending && setSeriesPendingDisable(null),
|
||||||
|
confirmDisable,
|
||||||
|
isAdvancing: advanceMutation.isPending,
|
||||||
|
isDisabling: disableMutation.isPending,
|
||||||
|
defaultMutation,
|
||||||
|
enableMutation,
|
||||||
|
setDefault: (item: DocumentSeries) => defaultMutation.mutate({ id: item.id }),
|
||||||
|
enable: (item: DocumentSeries) => enableMutation.mutate({ id: item.id }),
|
||||||
|
retry: useCallback(async () => {
|
||||||
|
await listQuery.refetch();
|
||||||
|
}, [listQuery]),
|
||||||
|
};
|
||||||
|
};
|
||||||
@ -0,0 +1,114 @@
|
|||||||
|
import assert from "node:assert/strict";
|
||||||
|
import { mock, test } from "node:test";
|
||||||
|
|
||||||
|
import type { ReactNode } from "react";
|
||||||
|
import { renderToStaticMarkup } from "react-dom/server";
|
||||||
|
|
||||||
|
let dataTableProps: Record<string, unknown> | undefined;
|
||||||
|
const dialogs: string[] = [];
|
||||||
|
|
||||||
|
mock.module("@erp/core/components", {
|
||||||
|
namedExports: {
|
||||||
|
PageErrorState: () => <div data-testid="page-error" />,
|
||||||
|
SimpleSearchInput: () => <input />,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
mock.module("@repo/rdx-ui/components", {
|
||||||
|
namedExports: {
|
||||||
|
DataTable: (props: Record<string, unknown>) => {
|
||||||
|
dataTableProps = props;
|
||||||
|
return <div data-testid="data-table" />;
|
||||||
|
},
|
||||||
|
DataTableColumnHeader: () => null,
|
||||||
|
RdxBadge: ({ children }: { children: ReactNode }) => children,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
mock.module("@repo/shadcn-ui/components", {
|
||||||
|
namedExports: {
|
||||||
|
Button: ({ children }: { children: ReactNode }) => <button type="button">{children}</button>,
|
||||||
|
Card: ({ children }: { children: ReactNode }) => <section>{children}</section>,
|
||||||
|
CardContent: ({ children }: { children: ReactNode }) => <div>{children}</div>,
|
||||||
|
CardHeader: ({ children }: { children: ReactNode }) => <header>{children}</header>,
|
||||||
|
Select: ({ children }: { children: ReactNode }) => <div>{children}</div>,
|
||||||
|
SelectContent: ({ children }: { children: ReactNode }) => <div>{children}</div>,
|
||||||
|
SelectItem: ({ children }: { children: ReactNode }) => <div>{children}</div>,
|
||||||
|
SelectTrigger: ({ children }: { children: ReactNode }) => (
|
||||||
|
<button type="button">{children}</button>
|
||||||
|
),
|
||||||
|
SelectValue: () => null,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
mock.module("lucide-react", { namedExports: { PlusIcon: () => null } });
|
||||||
|
mock.module("../../i18n", {
|
||||||
|
namedExports: { useTranslation: () => ({ t: (key: string) => key }) },
|
||||||
|
});
|
||||||
|
mock.module("./controllers/use-document-series-settings-controller", {
|
||||||
|
namedExports: {
|
||||||
|
useDocumentSeriesSettingsController: () => ({
|
||||||
|
list: [],
|
||||||
|
totalItems: 0,
|
||||||
|
listQuery: { isLoading: false, isError: false, isFetching: false },
|
||||||
|
search: "",
|
||||||
|
setSearch: () => undefined,
|
||||||
|
documentType: "all",
|
||||||
|
setDocumentType: () => undefined,
|
||||||
|
status: "all",
|
||||||
|
setStatus: () => undefined,
|
||||||
|
pageIndex: 0,
|
||||||
|
setPageIndex: () => undefined,
|
||||||
|
pageSize: 10,
|
||||||
|
setPageSize: () => undefined,
|
||||||
|
sort: { field: "code", direction: "asc" },
|
||||||
|
setSort: () => undefined,
|
||||||
|
form: {},
|
||||||
|
formId: "series-form",
|
||||||
|
mode: "create",
|
||||||
|
isFormOpen: true,
|
||||||
|
isSaving: false,
|
||||||
|
openCreate: () => undefined,
|
||||||
|
openEdit: () => undefined,
|
||||||
|
closeFormDialog: () => undefined,
|
||||||
|
onSubmit: () => undefined,
|
||||||
|
advancing: null,
|
||||||
|
advanceForm: {},
|
||||||
|
openAdvance: () => undefined,
|
||||||
|
closeAdvance: () => undefined,
|
||||||
|
onAdvanceSubmit: () => undefined,
|
||||||
|
isAdvancing: false,
|
||||||
|
seriesPendingDisable: null,
|
||||||
|
openDisable: () => undefined,
|
||||||
|
closeDisable: () => undefined,
|
||||||
|
confirmDisable: () => undefined,
|
||||||
|
isDisabling: false,
|
||||||
|
setDefault: () => undefined,
|
||||||
|
enable: () => undefined,
|
||||||
|
retry: () => undefined,
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
mock.module("./ui/document-series-dialogs", {
|
||||||
|
namedExports: {
|
||||||
|
DocumentSeriesFormDialog: () => {
|
||||||
|
dialogs.push("form");
|
||||||
|
return null;
|
||||||
|
},
|
||||||
|
AdvanceDocumentSeriesDialog: () => {
|
||||||
|
dialogs.push("advance");
|
||||||
|
return null;
|
||||||
|
},
|
||||||
|
DisableDocumentSeriesDialog: () => {
|
||||||
|
dialogs.push("disable");
|
||||||
|
return null;
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
test("monta DataTable y los tres diálogos desde el controller", async () => {
|
||||||
|
const { DocumentSeriesSettingsPage } = await import("./document-series-settings-page.tsx");
|
||||||
|
const html = renderToStaticMarkup(<DocumentSeriesSettingsPage />);
|
||||||
|
|
||||||
|
assert.match(html, /data-table/);
|
||||||
|
assert.equal(dataTableProps?.manualPagination, true);
|
||||||
|
assert.equal(dataTableProps?.manualSorting, true);
|
||||||
|
assert.deepEqual(dialogs, ["form", "advance", "disable"]);
|
||||||
|
});
|
||||||
@ -0,0 +1,313 @@
|
|||||||
|
import { PageErrorState, SimpleSearchInput } from "@erp/core/components";
|
||||||
|
import {
|
||||||
|
DataTable,
|
||||||
|
DataTableColumnHeader,
|
||||||
|
type DataTableProps,
|
||||||
|
RdxBadge,
|
||||||
|
} from "@repo/rdx-ui/components";
|
||||||
|
import {
|
||||||
|
Button,
|
||||||
|
Card,
|
||||||
|
CardContent,
|
||||||
|
CardHeader,
|
||||||
|
Select,
|
||||||
|
SelectContent,
|
||||||
|
SelectItem,
|
||||||
|
SelectTrigger,
|
||||||
|
SelectValue,
|
||||||
|
} from "@repo/shadcn-ui/components";
|
||||||
|
import { PlusIcon } from "lucide-react";
|
||||||
|
import { useMemo } from "react";
|
||||||
|
|
||||||
|
import type { DocumentSeries } from "../../document-series/shared/entities/document-series.entity";
|
||||||
|
import { useTranslation } from "../../i18n";
|
||||||
|
|
||||||
|
import { useDocumentSeriesSettingsController } from "./controllers/use-document-series-settings-controller";
|
||||||
|
import {
|
||||||
|
AdvanceDocumentSeriesDialog,
|
||||||
|
DisableDocumentSeriesDialog,
|
||||||
|
DocumentSeriesFormDialog,
|
||||||
|
} from "./ui/document-series-dialogs";
|
||||||
|
|
||||||
|
type Columns = DataTableProps<DocumentSeries, unknown>["columns"];
|
||||||
|
const preview = (row: DocumentSeries) =>
|
||||||
|
`${row.prefix ?? ""}${String(row.nextNumber).padStart(row.padding, "0")}${row.suffix ?? ""}`;
|
||||||
|
|
||||||
|
export const DocumentSeriesSettingsPage = () => {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const ctrl = useDocumentSeriesSettingsController();
|
||||||
|
const columns = useMemo<Columns>(
|
||||||
|
() => [
|
||||||
|
{
|
||||||
|
accessorKey: "code",
|
||||||
|
enableSorting: true,
|
||||||
|
header: ({ column }) => (
|
||||||
|
<DataTableColumnHeader column={column} title={t("settings.documentSeries.code")} />
|
||||||
|
),
|
||||||
|
cell: ({ row }) => (
|
||||||
|
<span
|
||||||
|
className="font-mono font-medium"
|
||||||
|
data-testid={`document-series-code-${row.original.id}`}
|
||||||
|
>
|
||||||
|
{row.original.code}
|
||||||
|
</span>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
accessorKey: "name",
|
||||||
|
enableSorting: true,
|
||||||
|
header: ({ column }) => (
|
||||||
|
<DataTableColumnHeader column={column} title={t("settings.documentSeries.name")} />
|
||||||
|
),
|
||||||
|
cell: ({ row }) => (
|
||||||
|
<span className="font-medium" data-testid={`document-series-name-${row.original.id}`}>
|
||||||
|
{row.original.name}
|
||||||
|
</span>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
accessorKey: "documentType",
|
||||||
|
enableSorting: true,
|
||||||
|
header: ({ column }) => (
|
||||||
|
<DataTableColumnHeader column={column} title={t("settings.documentSeries.type")} />
|
||||||
|
),
|
||||||
|
cell: ({ row }) =>
|
||||||
|
row.original.documentType === "proforma"
|
||||||
|
? t("settings.documentSeries.proforma")
|
||||||
|
: t("settings.documentSeries.issuedInvoice"),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
accessorKey: "nextNumber",
|
||||||
|
header: t("settings.documentSeries.nextNumber"),
|
||||||
|
cell: ({ row }) => (
|
||||||
|
<span
|
||||||
|
className="tabular-nums"
|
||||||
|
data-testid={`document-series-next-number-${row.original.id}`}
|
||||||
|
>
|
||||||
|
{row.original.nextNumber}
|
||||||
|
</span>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "format",
|
||||||
|
header: t("settings.documentSeries.format"),
|
||||||
|
cell: ({ row }) => <span className="font-mono">{preview(row.original)}</span>,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "validity",
|
||||||
|
header: t("settings.documentSeries.validity"),
|
||||||
|
cell: ({ row }) => (
|
||||||
|
<span>
|
||||||
|
{row.original.validFrom ?? "—"}{" "}
|
||||||
|
{row.original.validTo ? `– ${row.original.validTo}` : ""}
|
||||||
|
</span>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "default",
|
||||||
|
accessorKey: "isDefault",
|
||||||
|
enableSorting: true,
|
||||||
|
header: ({ column }) => (
|
||||||
|
<DataTableColumnHeader column={column} title={t("settings.documentSeries.default")} />
|
||||||
|
),
|
||||||
|
cell: ({ row }) => (
|
||||||
|
<span data-testid={`document-series-default-state-${row.original.code}`}>
|
||||||
|
{row.original.isDefault ? (
|
||||||
|
<RdxBadge>{t("settings.documentSeries.default")}</RdxBadge>
|
||||||
|
) : null}
|
||||||
|
</span>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "status",
|
||||||
|
accessorKey: "isActive",
|
||||||
|
enableSorting: true,
|
||||||
|
header: ({ column }) => (
|
||||||
|
<DataTableColumnHeader column={column} title={t("settings.documentSeries.status")} />
|
||||||
|
),
|
||||||
|
cell: ({ row }) => (
|
||||||
|
<RdxBadge
|
||||||
|
data-testid={`document-series-status-${row.original.code}`}
|
||||||
|
variant={row.original.isActive ? "secondary" : "destructive"}
|
||||||
|
>
|
||||||
|
{row.original.isActive
|
||||||
|
? t("settings.documentSeries.active")
|
||||||
|
: t("settings.documentSeries.inactive")}
|
||||||
|
</RdxBadge>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "actions",
|
||||||
|
enableSorting: false,
|
||||||
|
header: t("settings.documentSeries.actions"),
|
||||||
|
cell: ({ row }) => (
|
||||||
|
<div className="space-x-1">
|
||||||
|
<Button
|
||||||
|
data-testid={`document-series-edit-${row.original.code}`}
|
||||||
|
onClick={() => ctrl.openEdit(row.original)}
|
||||||
|
size="sm"
|
||||||
|
variant="ghost"
|
||||||
|
>
|
||||||
|
{t("settings.documentSeries.edit")}
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
data-testid={`document-series-advance-${row.original.code}`}
|
||||||
|
onClick={() => ctrl.openAdvance(row.original)}
|
||||||
|
size="sm"
|
||||||
|
variant="ghost"
|
||||||
|
>
|
||||||
|
{t("settings.documentSeries.advance")}
|
||||||
|
</Button>
|
||||||
|
{row.original.isActive && !row.original.isDefault && (
|
||||||
|
<Button
|
||||||
|
data-testid={`document-series-default-${row.original.code}`}
|
||||||
|
onClick={() => ctrl.setDefault(row.original)}
|
||||||
|
size="sm"
|
||||||
|
variant="ghost"
|
||||||
|
>
|
||||||
|
{t("settings.documentSeries.default")}
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
<Button
|
||||||
|
data-testid={`document-series-${row.original.isActive ? "disable" : "enable"}-${row.original.code}`}
|
||||||
|
onClick={() =>
|
||||||
|
row.original.isActive ? ctrl.openDisable(row.original) : ctrl.enable(row.original)
|
||||||
|
}
|
||||||
|
size="sm"
|
||||||
|
variant="ghost"
|
||||||
|
>
|
||||||
|
{row.original.isActive
|
||||||
|
? t("settings.documentSeries.disable")
|
||||||
|
: t("settings.documentSeries.enable")}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
],
|
||||||
|
[ctrl, t]
|
||||||
|
);
|
||||||
|
if (ctrl.listQuery.isLoading) {
|
||||||
|
return (
|
||||||
|
<Card>
|
||||||
|
<CardContent className="p-6">{t("settings.documentSeries.loading")}</CardContent>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (ctrl.listQuery.isError) {
|
||||||
|
return (
|
||||||
|
<PageErrorState
|
||||||
|
description={t("settings.documentSeries.loadErrorDescription")}
|
||||||
|
primaryAction={{ label: t("settings.documentSeries.retry"), onClick: ctrl.retry }}
|
||||||
|
title={t("settings.documentSeries.loadErrorTitle")}
|
||||||
|
variant="unavailable"
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<Card>
|
||||||
|
<CardHeader className="flex flex-col gap-4 sm:flex-row sm:items-start sm:justify-between">
|
||||||
|
<div>
|
||||||
|
<h2 className="text-2xl font-semibold" data-testid="document-series-title">
|
||||||
|
{t("settings.documentSeries.title")}
|
||||||
|
</h2>
|
||||||
|
<p className="text-muted-foreground">{t("settings.documentSeries.description")}</p>
|
||||||
|
</div>
|
||||||
|
<Button data-testid="document-series-create" onClick={ctrl.openCreate}>
|
||||||
|
<PlusIcon aria-hidden className="size-4" />
|
||||||
|
{t("settings.documentSeries.create")}
|
||||||
|
</Button>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<DataTable
|
||||||
|
columns={columns}
|
||||||
|
data={ctrl.list}
|
||||||
|
enablePagination
|
||||||
|
isFetching={ctrl.listQuery.isFetching && !ctrl.listQuery.isLoading}
|
||||||
|
manualPagination
|
||||||
|
manualSorting
|
||||||
|
onPageChange={ctrl.setPageIndex}
|
||||||
|
onPageSizeChange={ctrl.setPageSize}
|
||||||
|
onSortChange={ctrl.setSort}
|
||||||
|
pageIndex={ctrl.pageIndex}
|
||||||
|
pageSize={ctrl.pageSize}
|
||||||
|
readOnly
|
||||||
|
sort={ctrl.sort}
|
||||||
|
topContent={
|
||||||
|
<div className="flex flex-wrap items-end gap-3">
|
||||||
|
<div className="min-w-64 flex-1 basis-72" data-testid="document-series-search">
|
||||||
|
<SimpleSearchInput
|
||||||
|
onSearchChange={ctrl.setSearch}
|
||||||
|
placeholder={t("settings.documentSeries.search")}
|
||||||
|
value={ctrl.search}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<Select
|
||||||
|
onValueChange={(value) =>
|
||||||
|
ctrl.setDocumentType(value as "all" | "proforma" | "issued_invoice")
|
||||||
|
}
|
||||||
|
value={ctrl.documentType}
|
||||||
|
>
|
||||||
|
<SelectTrigger className="w-44">
|
||||||
|
<SelectValue />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="all">{t("settings.documentSeries.all")}</SelectItem>
|
||||||
|
<SelectItem value="proforma">
|
||||||
|
{t("settings.documentSeries.proforma")}
|
||||||
|
</SelectItem>
|
||||||
|
<SelectItem value="issued_invoice">
|
||||||
|
{t("settings.documentSeries.issuedInvoice")}
|
||||||
|
</SelectItem>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
<Select
|
||||||
|
onValueChange={(value) => ctrl.setStatus(value as "all" | "active" | "inactive")}
|
||||||
|
value={ctrl.status}
|
||||||
|
>
|
||||||
|
<SelectTrigger className="w-36">
|
||||||
|
<SelectValue />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="all">{t("settings.documentSeries.all")}</SelectItem>
|
||||||
|
<SelectItem value="active">{t("settings.documentSeries.active")}</SelectItem>
|
||||||
|
<SelectItem value="inactive">
|
||||||
|
{t("settings.documentSeries.inactive")}
|
||||||
|
</SelectItem>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
totalItems={ctrl.totalItems}
|
||||||
|
/>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
{ctrl.mode && (
|
||||||
|
<DocumentSeriesFormDialog
|
||||||
|
form={ctrl.form}
|
||||||
|
formId={ctrl.formId}
|
||||||
|
mode={ctrl.mode}
|
||||||
|
onClose={ctrl.closeFormDialog}
|
||||||
|
onSubmit={ctrl.onSubmit}
|
||||||
|
open={ctrl.isFormOpen}
|
||||||
|
pending={ctrl.isSaving}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
<AdvanceDocumentSeriesDialog
|
||||||
|
form={ctrl.advanceForm}
|
||||||
|
onClose={ctrl.closeAdvance}
|
||||||
|
onSubmit={ctrl.onAdvanceSubmit}
|
||||||
|
open={Boolean(ctrl.advancing)}
|
||||||
|
pending={ctrl.isAdvancing}
|
||||||
|
series={ctrl.advancing}
|
||||||
|
/>
|
||||||
|
<DisableDocumentSeriesDialog
|
||||||
|
onClose={ctrl.closeDisable}
|
||||||
|
onConfirm={ctrl.confirmDisable}
|
||||||
|
open={Boolean(ctrl.seriesPendingDisable)}
|
||||||
|
pending={ctrl.isDisabling}
|
||||||
|
series={ctrl.seriesPendingDisable}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
};
|
||||||
@ -0,0 +1,14 @@
|
|||||||
|
export type DocumentSeriesFormValues = {
|
||||||
|
documentType: "proforma" | "issued_invoice";
|
||||||
|
code: string;
|
||||||
|
name: string;
|
||||||
|
description: string | null;
|
||||||
|
prefix: string | null;
|
||||||
|
suffix: string | null;
|
||||||
|
nextNumber: number;
|
||||||
|
padding: number;
|
||||||
|
validFrom: string | null;
|
||||||
|
validTo: string | null;
|
||||||
|
isActive: boolean;
|
||||||
|
isDefault: boolean;
|
||||||
|
};
|
||||||
@ -0,0 +1,85 @@
|
|||||||
|
import assert from "node:assert/strict";
|
||||||
|
import test from "node:test";
|
||||||
|
|
||||||
|
import {
|
||||||
|
DocumentSeriesCreateFormSchema,
|
||||||
|
DocumentSeriesEditFormSchema,
|
||||||
|
} from "./document-series-form.schema";
|
||||||
|
|
||||||
|
const validCreateValues = {
|
||||||
|
documentType: "proforma" as const,
|
||||||
|
code: "PRO",
|
||||||
|
name: "Proformas",
|
||||||
|
description: "Serie de proformas",
|
||||||
|
prefix: "P-",
|
||||||
|
suffix: "",
|
||||||
|
nextNumber: 1,
|
||||||
|
padding: 4,
|
||||||
|
validFrom: "2026-01-01",
|
||||||
|
validTo: "2026-12-31",
|
||||||
|
isActive: true,
|
||||||
|
isDefault: false,
|
||||||
|
};
|
||||||
|
|
||||||
|
test("el schema de create acepta una serie válida", () => {
|
||||||
|
assert.deepEqual(DocumentSeriesCreateFormSchema.parse(validCreateValues), validCreateValues);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("el schema de create rechaza campos obligatorios, contador y padding inválidos", () => {
|
||||||
|
assert.equal(
|
||||||
|
DocumentSeriesCreateFormSchema.safeParse({ ...validCreateValues, code: "" }).success,
|
||||||
|
false
|
||||||
|
);
|
||||||
|
assert.equal(
|
||||||
|
DocumentSeriesCreateFormSchema.safeParse({ ...validCreateValues, nextNumber: 0 }).success,
|
||||||
|
false
|
||||||
|
);
|
||||||
|
assert.equal(
|
||||||
|
DocumentSeriesCreateFormSchema.safeParse({ ...validCreateValues, padding: 21 }).success,
|
||||||
|
false
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("el schema de create impide una serie predeterminada inactiva y rangos inválidos", () => {
|
||||||
|
assert.equal(
|
||||||
|
DocumentSeriesCreateFormSchema.safeParse({
|
||||||
|
...validCreateValues,
|
||||||
|
isActive: false,
|
||||||
|
isDefault: true,
|
||||||
|
}).success,
|
||||||
|
false
|
||||||
|
);
|
||||||
|
assert.equal(
|
||||||
|
DocumentSeriesCreateFormSchema.safeParse({
|
||||||
|
...validCreateValues,
|
||||||
|
validFrom: "2026-12-31",
|
||||||
|
validTo: "2026-01-01",
|
||||||
|
}).success,
|
||||||
|
false
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("el schema de edit admite solo los valores editables y valida formato y vigencia", () => {
|
||||||
|
const editValues = {
|
||||||
|
name: validCreateValues.name,
|
||||||
|
description: null,
|
||||||
|
prefix: null,
|
||||||
|
suffix: "-2026",
|
||||||
|
padding: 5,
|
||||||
|
validFrom: null,
|
||||||
|
validTo: "2026-12-31",
|
||||||
|
};
|
||||||
|
assert.deepEqual(DocumentSeriesEditFormSchema.parse(editValues), editValues);
|
||||||
|
assert.equal(
|
||||||
|
DocumentSeriesEditFormSchema.safeParse({ ...editValues, padding: -1 }).success,
|
||||||
|
false
|
||||||
|
);
|
||||||
|
assert.equal(
|
||||||
|
DocumentSeriesEditFormSchema.safeParse({
|
||||||
|
...editValues,
|
||||||
|
validFrom: "2026-12-31",
|
||||||
|
validTo: "2026-01-01",
|
||||||
|
}).success,
|
||||||
|
false
|
||||||
|
);
|
||||||
|
});
|
||||||
@ -0,0 +1,44 @@
|
|||||||
|
import { z } from "zod/v4";
|
||||||
|
|
||||||
|
const DocumentSeriesFormBaseSchema = z.object({
|
||||||
|
documentType: z.enum(["proforma", "issued_invoice"]),
|
||||||
|
code: z.string().trim().min(1).max(20),
|
||||||
|
name: z.string().trim().min(1).max(255),
|
||||||
|
description: z.string().trim().max(4096).nullable(),
|
||||||
|
prefix: z.string().trim().max(50).nullable(),
|
||||||
|
suffix: z.string().trim().max(50).nullable(),
|
||||||
|
nextNumber: z.number().int().min(1),
|
||||||
|
padding: z.number().int().min(0).max(20),
|
||||||
|
validFrom: z.iso.date().nullable(),
|
||||||
|
validTo: z.iso.date().nullable(),
|
||||||
|
isActive: z.boolean(),
|
||||||
|
isDefault: z.boolean(),
|
||||||
|
});
|
||||||
|
|
||||||
|
const withValidityRange = <TScheme extends z.ZodObject>(schema: TScheme) =>
|
||||||
|
schema.refine(
|
||||||
|
(value) => !(value.validFrom && value.validTo) || value.validFrom <= value.validTo,
|
||||||
|
{
|
||||||
|
path: ["validTo"],
|
||||||
|
message: "Invalid validity range.",
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
export const DocumentSeriesCreateFormSchema = withValidityRange(
|
||||||
|
DocumentSeriesFormBaseSchema
|
||||||
|
).refine((value) => value.isActive || !value.isDefault, {
|
||||||
|
path: ["isDefault"],
|
||||||
|
message: "An inactive series cannot be default.",
|
||||||
|
});
|
||||||
|
|
||||||
|
export const DocumentSeriesEditFormSchema = withValidityRange(
|
||||||
|
DocumentSeriesFormBaseSchema.pick({
|
||||||
|
name: true,
|
||||||
|
description: true,
|
||||||
|
prefix: true,
|
||||||
|
suffix: true,
|
||||||
|
padding: true,
|
||||||
|
validFrom: true,
|
||||||
|
validTo: true,
|
||||||
|
})
|
||||||
|
);
|
||||||
@ -0,0 +1 @@
|
|||||||
|
export * from "./document-series-settings-page";
|
||||||
@ -0,0 +1,150 @@
|
|||||||
|
import assert from "node:assert/strict";
|
||||||
|
import { mock, test } from "node:test";
|
||||||
|
|
||||||
|
import type { ReactNode } from "react";
|
||||||
|
import { renderToStaticMarkup } from "react-dom/server";
|
||||||
|
|
||||||
|
const fields: Array<Record<string, unknown>> = [];
|
||||||
|
const buttons: Array<Record<string, unknown>> = [];
|
||||||
|
|
||||||
|
mock.module("@erp/core", { namedExports: {} });
|
||||||
|
mock.module("@repo/rdx-ui/components", {
|
||||||
|
namedExports: {
|
||||||
|
DatePickerField: (props: Record<string, unknown>) => {
|
||||||
|
fields.push(props);
|
||||||
|
return null;
|
||||||
|
},
|
||||||
|
RdxFormGrid: ({ children }: { children: ReactNode }) => <div>{children}</div>,
|
||||||
|
SelectField: (props: Record<string, unknown>) => {
|
||||||
|
fields.push(props);
|
||||||
|
return null;
|
||||||
|
},
|
||||||
|
SwitchField: (props: Record<string, unknown>) => {
|
||||||
|
fields.push(props);
|
||||||
|
return null;
|
||||||
|
},
|
||||||
|
TextAreaField: (props: Record<string, unknown>) => {
|
||||||
|
fields.push(props);
|
||||||
|
return null;
|
||||||
|
},
|
||||||
|
TextField: (props: Record<string, unknown>) => {
|
||||||
|
fields.push(props);
|
||||||
|
return null;
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
mock.module("@repo/shadcn-ui/components", {
|
||||||
|
namedExports: {
|
||||||
|
AlertDialog: ({ children }: { children: ReactNode }) => <div>{children}</div>,
|
||||||
|
AlertDialogContent: ({ children }: { children: ReactNode }) => <div>{children}</div>,
|
||||||
|
AlertDialogDescription: ({ children }: { children: ReactNode }) => <p>{children}</p>,
|
||||||
|
AlertDialogFooter: ({ children }: { children: ReactNode }) => <footer>{children}</footer>,
|
||||||
|
AlertDialogHeader: ({ children }: { children: ReactNode }) => <header>{children}</header>,
|
||||||
|
AlertDialogTitle: ({ children }: { children: ReactNode }) => <h2>{children}</h2>,
|
||||||
|
Button: (props: Record<string, unknown>) => {
|
||||||
|
buttons.push(props);
|
||||||
|
return <button type="button" />;
|
||||||
|
},
|
||||||
|
Dialog: ({ children }: { children: ReactNode }) => <div>{children}</div>,
|
||||||
|
DialogContent: ({ children }: { children: ReactNode }) => <div>{children}</div>,
|
||||||
|
DialogDescription: ({ children }: { children: ReactNode }) => <p>{children}</p>,
|
||||||
|
DialogFooter: ({ children }: { children: ReactNode }) => <footer>{children}</footer>,
|
||||||
|
DialogHeader: ({ children }: { children: ReactNode }) => <header>{children}</header>,
|
||||||
|
DialogTitle: ({ children }: { children: ReactNode }) => <h2>{children}</h2>,
|
||||||
|
Spinner: () => null,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
mock.module("react-hook-form", {
|
||||||
|
namedExports: { FormProvider: ({ children }: { children: ReactNode }) => children },
|
||||||
|
});
|
||||||
|
mock.module("../../../i18n", {
|
||||||
|
namedExports: { useTranslation: () => ({ t: (key: string) => key }) },
|
||||||
|
});
|
||||||
|
|
||||||
|
const reset = () => {
|
||||||
|
fields.length = 0;
|
||||||
|
buttons.length = 0;
|
||||||
|
};
|
||||||
|
const form = { watch: () => true };
|
||||||
|
|
||||||
|
test("el diálogo de formulario separa los campos create de edit y respeta pending", async () => {
|
||||||
|
const { DocumentSeriesFormDialog } = await import("./document-series-dialogs.tsx");
|
||||||
|
reset();
|
||||||
|
renderToStaticMarkup(
|
||||||
|
<DocumentSeriesFormDialog
|
||||||
|
form={form as never}
|
||||||
|
formId="form"
|
||||||
|
mode="create"
|
||||||
|
onClose={() => undefined}
|
||||||
|
onSubmit={() => undefined}
|
||||||
|
open
|
||||||
|
pending={false}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
assert.deepEqual(
|
||||||
|
fields.map((field) => field.name),
|
||||||
|
[
|
||||||
|
"documentType",
|
||||||
|
"code",
|
||||||
|
"name",
|
||||||
|
"prefix",
|
||||||
|
"suffix",
|
||||||
|
"nextNumber",
|
||||||
|
"padding",
|
||||||
|
"validFrom",
|
||||||
|
"validTo",
|
||||||
|
"description",
|
||||||
|
"isActive",
|
||||||
|
"isDefault",
|
||||||
|
]
|
||||||
|
);
|
||||||
|
reset();
|
||||||
|
renderToStaticMarkup(
|
||||||
|
<DocumentSeriesFormDialog
|
||||||
|
form={form as never}
|
||||||
|
formId="form"
|
||||||
|
mode="edit"
|
||||||
|
onClose={() => undefined}
|
||||||
|
onSubmit={() => undefined}
|
||||||
|
open
|
||||||
|
pending
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
assert.deepEqual(
|
||||||
|
fields.map((field) => field.name),
|
||||||
|
["name", "prefix", "suffix", "padding", "validFrom", "validTo", "description"]
|
||||||
|
);
|
||||||
|
assert.ok(fields.every((field) => field.disabled === true));
|
||||||
|
assert.ok(buttons.every((button) => button.disabled === true));
|
||||||
|
});
|
||||||
|
|
||||||
|
test("los diálogos de avance y disable reciben submit, pending y la advertencia default", async () => {
|
||||||
|
const { AdvanceDocumentSeriesDialog, DisableDocumentSeriesDialog } = await import(
|
||||||
|
"./document-series-dialogs.tsx"
|
||||||
|
);
|
||||||
|
const series = { id: "series-1", code: "P", name: "Proformas", isDefault: true };
|
||||||
|
reset();
|
||||||
|
renderToStaticMarkup(
|
||||||
|
<AdvanceDocumentSeriesDialog
|
||||||
|
form={form as never}
|
||||||
|
onClose={() => undefined}
|
||||||
|
onSubmit={() => undefined}
|
||||||
|
open
|
||||||
|
pending={false}
|
||||||
|
series={series as never}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
assert.equal(fields[0]?.name, "nextNumber");
|
||||||
|
reset();
|
||||||
|
const html = renderToStaticMarkup(
|
||||||
|
<DisableDocumentSeriesDialog
|
||||||
|
onClose={() => undefined}
|
||||||
|
onConfirm={() => undefined}
|
||||||
|
open
|
||||||
|
pending
|
||||||
|
series={series as never}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
assert.match(html, /disableDefaultWarning/);
|
||||||
|
assert.ok(buttons.every((button) => button.disabled === true));
|
||||||
|
});
|
||||||
@ -0,0 +1,282 @@
|
|||||||
|
import {
|
||||||
|
DatePickerField,
|
||||||
|
RdxFormGrid,
|
||||||
|
SelectField,
|
||||||
|
SwitchField,
|
||||||
|
TextAreaField,
|
||||||
|
TextField,
|
||||||
|
} from "@repo/rdx-ui/components";
|
||||||
|
import {
|
||||||
|
AlertDialog,
|
||||||
|
AlertDialogContent,
|
||||||
|
AlertDialogDescription,
|
||||||
|
AlertDialogFooter,
|
||||||
|
AlertDialogHeader,
|
||||||
|
AlertDialogTitle,
|
||||||
|
Button,
|
||||||
|
Dialog,
|
||||||
|
DialogContent,
|
||||||
|
DialogDescription,
|
||||||
|
DialogFooter,
|
||||||
|
DialogHeader,
|
||||||
|
DialogTitle,
|
||||||
|
Spinner,
|
||||||
|
} from "@repo/shadcn-ui/components";
|
||||||
|
import type { FormEventHandler } from "react";
|
||||||
|
import { FormProvider, type UseFormReturn } from "react-hook-form";
|
||||||
|
|
||||||
|
import type { DocumentSeries } from "../../../document-series/shared/entities/document-series.entity";
|
||||||
|
import { useTranslation } from "../../../i18n";
|
||||||
|
import type { DocumentSeriesFormMode } from "../controllers/use-document-series-settings-controller";
|
||||||
|
import type { DocumentSeriesFormValues } from "../entities/document-series-form.entity";
|
||||||
|
|
||||||
|
interface DocumentSeriesFormDialogProps {
|
||||||
|
open: boolean;
|
||||||
|
mode: DocumentSeriesFormMode;
|
||||||
|
form: UseFormReturn<DocumentSeriesFormValues>;
|
||||||
|
formId: string;
|
||||||
|
pending: boolean;
|
||||||
|
onSubmit: FormEventHandler<HTMLFormElement>;
|
||||||
|
onClose: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const DocumentSeriesFormDialog = ({
|
||||||
|
open,
|
||||||
|
mode,
|
||||||
|
form,
|
||||||
|
formId,
|
||||||
|
pending,
|
||||||
|
onSubmit,
|
||||||
|
onClose,
|
||||||
|
}: DocumentSeriesFormDialogProps) => {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const isEdit = mode === "edit";
|
||||||
|
return (
|
||||||
|
<Dialog onOpenChange={(nextOpen) => !nextOpen && onClose()} open={open}>
|
||||||
|
<DialogContent className="sm:max-w-2xl" data-testid="document-series-form-dialog">
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>
|
||||||
|
{t(isEdit ? "settings.documentSeries.edit" : "settings.documentSeries.create")}
|
||||||
|
</DialogTitle>
|
||||||
|
<DialogDescription>{t("settings.documentSeries.description")}</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
<FormProvider {...form}>
|
||||||
|
<form className="space-y-5" id={formId} noValidate onSubmit={onSubmit}>
|
||||||
|
<RdxFormGrid className="grid-cols-1 md:grid-cols-2">
|
||||||
|
{!isEdit && (
|
||||||
|
<>
|
||||||
|
<SelectField<DocumentSeriesFormValues>
|
||||||
|
disabled={pending}
|
||||||
|
items={[
|
||||||
|
{ value: "proforma", label: t("settings.documentSeries.proforma") },
|
||||||
|
{
|
||||||
|
value: "issued_invoice",
|
||||||
|
label: t("settings.documentSeries.issuedInvoice"),
|
||||||
|
},
|
||||||
|
]}
|
||||||
|
label={t("settings.documentSeries.type")}
|
||||||
|
name="documentType"
|
||||||
|
/>
|
||||||
|
<TextField<DocumentSeriesFormValues>
|
||||||
|
disabled={pending}
|
||||||
|
label={t("settings.documentSeries.code")}
|
||||||
|
name="code"
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
<TextField<DocumentSeriesFormValues>
|
||||||
|
disabled={pending}
|
||||||
|
label={t("settings.documentSeries.name")}
|
||||||
|
name="name"
|
||||||
|
/>
|
||||||
|
<TextField<DocumentSeriesFormValues>
|
||||||
|
disabled={pending}
|
||||||
|
label={t("settings.documentSeries.prefix")}
|
||||||
|
name="prefix"
|
||||||
|
/>
|
||||||
|
<TextField<DocumentSeriesFormValues>
|
||||||
|
disabled={pending}
|
||||||
|
label={t("settings.documentSeries.suffix")}
|
||||||
|
name="suffix"
|
||||||
|
/>
|
||||||
|
{!isEdit && (
|
||||||
|
<TextField<DocumentSeriesFormValues>
|
||||||
|
disabled={pending}
|
||||||
|
inputMode="numeric"
|
||||||
|
label={t("settings.documentSeries.nextNumber")}
|
||||||
|
name="nextNumber"
|
||||||
|
typePreset="number"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
<TextField<DocumentSeriesFormValues>
|
||||||
|
disabled={pending}
|
||||||
|
inputMode="numeric"
|
||||||
|
label={t("settings.documentSeries.padding")}
|
||||||
|
name="padding"
|
||||||
|
typePreset="number"
|
||||||
|
/>
|
||||||
|
<DatePickerField<DocumentSeriesFormValues>
|
||||||
|
disabled={pending}
|
||||||
|
label={t("settings.documentSeries.validFrom")}
|
||||||
|
name="validFrom"
|
||||||
|
/>
|
||||||
|
<DatePickerField<DocumentSeriesFormValues>
|
||||||
|
disabled={pending}
|
||||||
|
label={t("settings.documentSeries.validTo")}
|
||||||
|
name="validTo"
|
||||||
|
/>
|
||||||
|
<TextAreaField<DocumentSeriesFormValues>
|
||||||
|
className="md:col-span-2"
|
||||||
|
disabled={pending}
|
||||||
|
label={t("settings.documentSeries.descriptionField")}
|
||||||
|
name="description"
|
||||||
|
rows={3}
|
||||||
|
/>
|
||||||
|
{!isEdit && (
|
||||||
|
<>
|
||||||
|
<SwitchField
|
||||||
|
disabled={pending}
|
||||||
|
label={t("settings.documentSeries.active")}
|
||||||
|
name="isActive"
|
||||||
|
/>
|
||||||
|
<SwitchField
|
||||||
|
disabled={pending || !form.watch("isActive")}
|
||||||
|
label={t("settings.documentSeries.default")}
|
||||||
|
name="isDefault"
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</RdxFormGrid>
|
||||||
|
</form>
|
||||||
|
</FormProvider>
|
||||||
|
<DialogFooter>
|
||||||
|
<Button
|
||||||
|
data-testid="document-series-form-cancel"
|
||||||
|
disabled={pending}
|
||||||
|
onClick={onClose}
|
||||||
|
variant="outline"
|
||||||
|
>
|
||||||
|
{t("settings.documentSeries.cancel")}
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
data-testid="document-series-form-submit"
|
||||||
|
disabled={pending}
|
||||||
|
form={formId}
|
||||||
|
type="submit"
|
||||||
|
>
|
||||||
|
{pending && <Spinner aria-hidden className="mr-2 size-4" />}
|
||||||
|
{t("settings.documentSeries.save")}
|
||||||
|
</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const AdvanceDocumentSeriesDialog = ({
|
||||||
|
open,
|
||||||
|
series,
|
||||||
|
form,
|
||||||
|
pending,
|
||||||
|
onSubmit,
|
||||||
|
onClose,
|
||||||
|
}: {
|
||||||
|
open: boolean;
|
||||||
|
series: DocumentSeries | null;
|
||||||
|
form: UseFormReturn<{ nextNumber: number }>;
|
||||||
|
pending: boolean;
|
||||||
|
onSubmit: FormEventHandler<HTMLFormElement>;
|
||||||
|
onClose: () => void;
|
||||||
|
}) => {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
return (
|
||||||
|
<Dialog onOpenChange={(nextOpen) => !nextOpen && onClose()} open={open}>
|
||||||
|
<DialogContent data-testid="document-series-advance-dialog">
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>{t("settings.documentSeries.advance")}</DialogTitle>
|
||||||
|
<DialogDescription>{series?.code ?? ""}</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
<FormProvider {...form}>
|
||||||
|
<form id="advance-document-series" noValidate onSubmit={onSubmit}>
|
||||||
|
<TextField<{ nextNumber: number }>
|
||||||
|
disabled={pending}
|
||||||
|
inputMode="numeric"
|
||||||
|
label={t("settings.documentSeries.nextNumber")}
|
||||||
|
name="nextNumber"
|
||||||
|
typePreset="number"
|
||||||
|
/>
|
||||||
|
</form>
|
||||||
|
</FormProvider>
|
||||||
|
<DialogFooter>
|
||||||
|
<Button
|
||||||
|
data-testid="document-series-advance-cancel"
|
||||||
|
disabled={pending}
|
||||||
|
onClick={onClose}
|
||||||
|
variant="outline"
|
||||||
|
>
|
||||||
|
{t("settings.documentSeries.cancel")}
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
data-testid="document-series-advance-submit"
|
||||||
|
disabled={pending}
|
||||||
|
form="advance-document-series"
|
||||||
|
type="submit"
|
||||||
|
>
|
||||||
|
{t("settings.documentSeries.save")}
|
||||||
|
</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const DisableDocumentSeriesDialog = ({
|
||||||
|
open,
|
||||||
|
series,
|
||||||
|
pending,
|
||||||
|
onConfirm,
|
||||||
|
onClose,
|
||||||
|
}: {
|
||||||
|
open: boolean;
|
||||||
|
series: DocumentSeries | null;
|
||||||
|
pending: boolean;
|
||||||
|
onConfirm: () => void;
|
||||||
|
onClose: () => void;
|
||||||
|
}) => {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
return (
|
||||||
|
<AlertDialog onOpenChange={(nextOpen) => !nextOpen && onClose()} open={open}>
|
||||||
|
<AlertDialogContent data-testid="document-series-disable-dialog">
|
||||||
|
<AlertDialogHeader>
|
||||||
|
<AlertDialogTitle>{t("settings.documentSeries.disable")}</AlertDialogTitle>
|
||||||
|
<AlertDialogDescription>
|
||||||
|
{t(
|
||||||
|
series?.isDefault
|
||||||
|
? "settings.documentSeries.disableDefaultWarning"
|
||||||
|
: "settings.documentSeries.disableWarning",
|
||||||
|
{ name: series?.name ?? "" }
|
||||||
|
)}
|
||||||
|
</AlertDialogDescription>
|
||||||
|
</AlertDialogHeader>
|
||||||
|
<AlertDialogFooter>
|
||||||
|
<Button
|
||||||
|
data-testid="document-series-disable-cancel"
|
||||||
|
disabled={pending}
|
||||||
|
onClick={onClose}
|
||||||
|
variant="outline"
|
||||||
|
>
|
||||||
|
{t("settings.documentSeries.cancel")}
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
data-testid="document-series-disable-confirm"
|
||||||
|
disabled={pending || !series}
|
||||||
|
onClick={onConfirm}
|
||||||
|
variant="destructive"
|
||||||
|
>
|
||||||
|
{t("settings.documentSeries.disable")}
|
||||||
|
</Button>
|
||||||
|
</AlertDialogFooter>
|
||||||
|
</AlertDialogContent>
|
||||||
|
</AlertDialog>
|
||||||
|
);
|
||||||
|
};
|
||||||
@ -0,0 +1,66 @@
|
|||||||
|
import assert from "node:assert/strict";
|
||||||
|
import test from "node:test";
|
||||||
|
|
||||||
|
import {
|
||||||
|
buildDocumentSeriesFormValues,
|
||||||
|
buildUpdateDocumentSeriesRequest,
|
||||||
|
} from "./build-document-series-request";
|
||||||
|
|
||||||
|
test("el payload de update solo contiene configuración editable", () => {
|
||||||
|
const request = buildUpdateDocumentSeriesRequest({
|
||||||
|
name: "Nueva serie",
|
||||||
|
description: null,
|
||||||
|
prefix: "P-",
|
||||||
|
suffix: null,
|
||||||
|
padding: 4,
|
||||||
|
validFrom: "2026-01-01",
|
||||||
|
validTo: null,
|
||||||
|
});
|
||||||
|
|
||||||
|
assert.deepEqual(Object.keys(request).sort(), [
|
||||||
|
"description",
|
||||||
|
"name",
|
||||||
|
"padding",
|
||||||
|
"prefix",
|
||||||
|
"suffix",
|
||||||
|
"valid_from",
|
||||||
|
"valid_to",
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("materializa valores de formulario conservando fechas nulas, formato y contexto", () => {
|
||||||
|
assert.deepEqual(
|
||||||
|
buildDocumentSeriesFormValues({
|
||||||
|
id: "series-1",
|
||||||
|
companyId: "company-1",
|
||||||
|
branchId: null,
|
||||||
|
documentType: "issued_invoice",
|
||||||
|
code: "F",
|
||||||
|
name: "Facturas",
|
||||||
|
description: null,
|
||||||
|
prefix: "F-",
|
||||||
|
suffix: "-2026",
|
||||||
|
nextNumber: 12,
|
||||||
|
padding: 6,
|
||||||
|
validFrom: null,
|
||||||
|
validTo: null,
|
||||||
|
isActive: true,
|
||||||
|
isDefault: true,
|
||||||
|
hasAssignedNumbers: true,
|
||||||
|
}),
|
||||||
|
{
|
||||||
|
documentType: "issued_invoice",
|
||||||
|
code: "F",
|
||||||
|
name: "Facturas",
|
||||||
|
description: null,
|
||||||
|
prefix: "F-",
|
||||||
|
suffix: "-2026",
|
||||||
|
nextNumber: 12,
|
||||||
|
padding: 6,
|
||||||
|
validFrom: null,
|
||||||
|
validTo: null,
|
||||||
|
isActive: true,
|
||||||
|
isDefault: true,
|
||||||
|
}
|
||||||
|
);
|
||||||
|
});
|
||||||
@ -0,0 +1,53 @@
|
|||||||
|
import type {
|
||||||
|
CreateDocumentSeriesRequestDTO,
|
||||||
|
UpdateDocumentSeriesRequestDTO,
|
||||||
|
} from "../../../../common";
|
||||||
|
import type { DocumentSeries } from "../../../document-series/shared/entities/document-series.entity";
|
||||||
|
import type { DocumentSeriesFormValues } from "../entities/document-series-form.entity";
|
||||||
|
|
||||||
|
export const buildCreateDocumentSeriesRequest = (
|
||||||
|
values: DocumentSeriesFormValues
|
||||||
|
): CreateDocumentSeriesRequestDTO => ({
|
||||||
|
document_type: values.documentType,
|
||||||
|
code: values.code,
|
||||||
|
name: values.name,
|
||||||
|
description: values.description,
|
||||||
|
prefix: values.prefix,
|
||||||
|
suffix: values.suffix,
|
||||||
|
next_number: values.nextNumber,
|
||||||
|
padding: values.padding,
|
||||||
|
valid_from: values.validFrom,
|
||||||
|
valid_to: values.validTo,
|
||||||
|
is_active: values.isActive,
|
||||||
|
is_default: values.isDefault,
|
||||||
|
});
|
||||||
|
export const buildUpdateDocumentSeriesRequest = (
|
||||||
|
values: Pick<
|
||||||
|
DocumentSeriesFormValues,
|
||||||
|
"name" | "description" | "prefix" | "suffix" | "padding" | "validFrom" | "validTo"
|
||||||
|
>
|
||||||
|
): UpdateDocumentSeriesRequestDTO => ({
|
||||||
|
name: values.name,
|
||||||
|
description: values.description,
|
||||||
|
prefix: values.prefix,
|
||||||
|
suffix: values.suffix,
|
||||||
|
padding: values.padding,
|
||||||
|
valid_from: values.validFrom,
|
||||||
|
valid_to: values.validTo,
|
||||||
|
});
|
||||||
|
export const buildDocumentSeriesFormValues = (
|
||||||
|
series: DocumentSeries
|
||||||
|
): DocumentSeriesFormValues => ({
|
||||||
|
documentType: series.documentType,
|
||||||
|
code: series.code,
|
||||||
|
name: series.name,
|
||||||
|
description: series.description,
|
||||||
|
prefix: series.prefix,
|
||||||
|
suffix: series.suffix,
|
||||||
|
nextNumber: series.nextNumber,
|
||||||
|
padding: series.padding,
|
||||||
|
validFrom: series.validFrom,
|
||||||
|
validTo: series.validTo,
|
||||||
|
isActive: series.isActive,
|
||||||
|
isDefault: series.isDefault,
|
||||||
|
});
|
||||||
@ -270,6 +270,9 @@ importers:
|
|||||||
'@erp/customers':
|
'@erp/customers':
|
||||||
specifier: workspace:*
|
specifier: workspace:*
|
||||||
version: link:../../modules/customers
|
version: link:../../modules/customers
|
||||||
|
'@erp/document-series':
|
||||||
|
specifier: workspace:*
|
||||||
|
version: link:../../modules/document-series
|
||||||
'@erp/identity':
|
'@erp/identity':
|
||||||
specifier: workspace:*
|
specifier: workspace:*
|
||||||
version: link:../../modules/identity
|
version: link:../../modules/identity
|
||||||
@ -1050,6 +1053,9 @@ importers:
|
|||||||
'@erp/identity':
|
'@erp/identity':
|
||||||
specifier: workspace:*
|
specifier: workspace:*
|
||||||
version: link:../identity
|
version: link:../identity
|
||||||
|
'@repo/i18next':
|
||||||
|
specifier: workspace:*
|
||||||
|
version: link:../../packages/i18n
|
||||||
'@repo/rdx-audit':
|
'@repo/rdx-audit':
|
||||||
specifier: workspace:*
|
specifier: workspace:*
|
||||||
version: link:../../packages/rdx-audit
|
version: link:../../packages/rdx-audit
|
||||||
@ -1059,15 +1065,39 @@ importers:
|
|||||||
'@repo/rdx-ddd':
|
'@repo/rdx-ddd':
|
||||||
specifier: workspace:*
|
specifier: workspace:*
|
||||||
version: link:../../packages/rdx-ddd
|
version: link:../../packages/rdx-ddd
|
||||||
|
'@repo/rdx-ui':
|
||||||
|
specifier: workspace:*
|
||||||
|
version: link:../../packages/rdx-ui
|
||||||
'@repo/rdx-utils':
|
'@repo/rdx-utils':
|
||||||
specifier: workspace:*
|
specifier: workspace:*
|
||||||
version: link:../../packages/rdx-utils
|
version: link:../../packages/rdx-utils
|
||||||
|
'@repo/shadcn-ui':
|
||||||
|
specifier: workspace:*
|
||||||
|
version: link:../../packages/shadcn-ui
|
||||||
|
'@tanstack/react-query':
|
||||||
|
specifier: ^5.98.0
|
||||||
|
version: 5.100.14(react@19.2.6)
|
||||||
express:
|
express:
|
||||||
specifier: ^4.22.1
|
specifier: ^4.22.1
|
||||||
version: 4.22.2(supports-color@7.2.0)
|
version: 4.22.2(supports-color@7.2.0)
|
||||||
|
lucide-react:
|
||||||
|
specifier: ^1.8.0
|
||||||
|
version: 1.16.0(react@19.2.6)
|
||||||
|
react-hook-form:
|
||||||
|
specifier: ^7.72.1
|
||||||
|
version: 7.76.1(react@19.2.6)
|
||||||
|
react-i18next:
|
||||||
|
specifier: ^17.0.2
|
||||||
|
version: 17.0.8(i18next@26.4.0(typescript@6.0.2))(react-dom@19.2.6(react@19.2.6))(react@19.2.6)(typescript@6.0.2)
|
||||||
|
react-router-dom:
|
||||||
|
specifier: ^7.14.0
|
||||||
|
version: 7.15.1(react-dom@19.2.6(react@19.2.6))(react@19.2.6)
|
||||||
sequelize:
|
sequelize:
|
||||||
specifier: ^6.37.8
|
specifier: ^6.37.8
|
||||||
version: 6.37.8(mysql2@3.22.3(@types/node@25.9.1))(pg-hstore@2.3.4)(supports-color@7.2.0)
|
version: 6.37.8(mysql2@3.22.3(@types/node@25.9.1))(pg-hstore@2.3.4)(supports-color@7.2.0)
|
||||||
|
use-debounce:
|
||||||
|
specifier: ^10.1.1
|
||||||
|
version: 10.1.1(react@19.2.6)
|
||||||
zod:
|
zod:
|
||||||
specifier: ^4.3.6
|
specifier: ^4.3.6
|
||||||
version: 4.4.3
|
version: 4.4.3
|
||||||
@ -1075,6 +1105,18 @@ importers:
|
|||||||
'@types/express':
|
'@types/express':
|
||||||
specifier: ^4.17.21
|
specifier: ^4.17.21
|
||||||
version: 4.17.25
|
version: 4.17.25
|
||||||
|
'@types/react':
|
||||||
|
specifier: ^19.2.14
|
||||||
|
version: 19.2.15
|
||||||
|
'@types/react-dom':
|
||||||
|
specifier: ^19.2.3
|
||||||
|
version: 19.2.3(@types/react@19.2.15)
|
||||||
|
react:
|
||||||
|
specifier: ^19.2.5
|
||||||
|
version: 19.2.6
|
||||||
|
react-dom:
|
||||||
|
specifier: ^19.2.5
|
||||||
|
version: 19.2.6(react@19.2.6)
|
||||||
rimraf:
|
rimraf:
|
||||||
specifier: ^6.1.3
|
specifier: ^6.1.3
|
||||||
version: 6.1.3
|
version: 6.1.3
|
||||||
|
|||||||
91
tests/e2e/document-series/document-series-settings.spec.ts
Normal file
91
tests/e2e/document-series/document-series-settings.spec.ts
Normal file
@ -0,0 +1,91 @@
|
|||||||
|
import { randomUUID } from "node:crypto";
|
||||||
|
|
||||||
|
import { expect, test } from "@playwright/test";
|
||||||
|
|
||||||
|
import { loginViaUi } from "../support/login-via-ui";
|
||||||
|
|
||||||
|
const fillDocumentSeriesForm = async (
|
||||||
|
page: import("@playwright/test").Page,
|
||||||
|
values: { code: string; name: string; prefix: string; suffix: string; nextNumber: number }
|
||||||
|
) => {
|
||||||
|
await page.locator('input[name="code"]').fill(values.code);
|
||||||
|
await page.locator('input[name="name"]').fill(values.name);
|
||||||
|
await page.locator('input[name="prefix"]').fill(values.prefix);
|
||||||
|
await page.locator('input[name="suffix"]').fill(values.suffix);
|
||||||
|
await page.locator('input[name="nextNumber"]').fill(String(values.nextNumber));
|
||||||
|
await page.getByTestId("document-series-form-submit").click();
|
||||||
|
};
|
||||||
|
|
||||||
|
test.describe
|
||||||
|
.serial("document-series settings", () => {
|
||||||
|
test.beforeEach(async ({ page }) => {
|
||||||
|
await loginViaUi(page);
|
||||||
|
await page.goto("/settings/billing/document-series");
|
||||||
|
await expect(page.getByTestId("document-series-title")).toBeVisible();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("DOCUMENT-SERIES-SETTINGS-001: administra creación, edición, default, avance, estado y filtros", async ({
|
||||||
|
page,
|
||||||
|
}, testInfo) => {
|
||||||
|
const suffix = `${Date.now()}-${testInfo.workerIndex}-${randomUUID().slice(0, 8)}`;
|
||||||
|
const first = {
|
||||||
|
code: `E2EP${suffix}`,
|
||||||
|
name: `E2E Proforma ${suffix}`,
|
||||||
|
prefix: "P-",
|
||||||
|
suffix: "",
|
||||||
|
nextNumber: 10,
|
||||||
|
};
|
||||||
|
const second = {
|
||||||
|
code: `E2EQ${suffix}`,
|
||||||
|
name: `E2E Secundaria ${suffix}`,
|
||||||
|
prefix: "Q-",
|
||||||
|
suffix: "-X",
|
||||||
|
nextNumber: 20,
|
||||||
|
};
|
||||||
|
|
||||||
|
await page.getByTestId("document-series-create").click();
|
||||||
|
await expect(page.getByTestId("document-series-form-dialog")).toBeVisible();
|
||||||
|
await fillDocumentSeriesForm(page, first);
|
||||||
|
await expect(page.getByTestId(`document-series-code-${first.code}`)).toHaveText(first.code);
|
||||||
|
await expect(page.getByTestId(`document-series-next-number-${first.code}`)).toHaveText("10");
|
||||||
|
|
||||||
|
await page.getByTestId(`document-series-edit-${first.code}`).click();
|
||||||
|
await expect(page.locator('input[name="code"]')).toHaveCount(0);
|
||||||
|
await expect(page.locator('input[name="documentType"]')).toHaveCount(0);
|
||||||
|
await page.locator('input[name="name"]').fill(`${first.name} editada`);
|
||||||
|
await page.locator('input[name="padding"]').fill("5");
|
||||||
|
await page.getByTestId("document-series-form-submit").click();
|
||||||
|
await page.reload();
|
||||||
|
await expect(page.getByTestId(`document-series-name-${first.code}`)).toHaveText(
|
||||||
|
`${first.name} editada`
|
||||||
|
);
|
||||||
|
|
||||||
|
await page.getByTestId("document-series-create").click();
|
||||||
|
await fillDocumentSeriesForm(page, second);
|
||||||
|
await expect(page.getByTestId(`document-series-code-${second.code}`)).toHaveText(second.code);
|
||||||
|
await page.getByTestId(`document-series-default-${second.code}`).click();
|
||||||
|
await expect(page.getByTestId(`document-series-default-${second.code}`)).toHaveCount(0);
|
||||||
|
await expect(page.getByTestId(`document-series-default-state-${second.code}`)).toBeVisible();
|
||||||
|
await expect(page.getByTestId(`document-series-default-state-${first.code}`)).toBeEmpty();
|
||||||
|
|
||||||
|
await page.getByTestId(`document-series-advance-${second.code}`).click();
|
||||||
|
await expect(page.getByTestId("document-series-advance-dialog")).toBeVisible();
|
||||||
|
await page.locator('input[name="nextNumber"]').fill("25");
|
||||||
|
await page.getByTestId("document-series-advance-submit").click();
|
||||||
|
await expect(page.getByTestId(`document-series-next-number-${second.code}`)).toHaveText("25");
|
||||||
|
|
||||||
|
await page.getByTestId(`document-series-disable-${second.code}`).click();
|
||||||
|
await expect(page.getByTestId("document-series-disable-dialog")).toBeVisible();
|
||||||
|
await expect(page.getByTestId("document-series-disable-dialog")).toContainText(
|
||||||
|
/predeterminada|default/i
|
||||||
|
);
|
||||||
|
await page.getByTestId("document-series-disable-confirm").click();
|
||||||
|
await expect(page.getByTestId(`document-series-enable-${second.code}`)).toBeVisible();
|
||||||
|
await expect(page.getByTestId(`document-series-default-state-${second.code}`)).toBeEmpty();
|
||||||
|
await page.getByTestId(`document-series-enable-${second.code}`).click();
|
||||||
|
await expect(page.getByTestId(`document-series-disable-${second.code}`)).toBeVisible();
|
||||||
|
|
||||||
|
await page.getByTestId("document-series-search").locator("input").fill(second.code);
|
||||||
|
await expect(page.getByTestId(`document-series-code-${second.code}`)).toBeVisible();
|
||||||
|
});
|
||||||
|
});
|
||||||
Loading…
Reference in New Issue
Block a user