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/companies": "workspace:*",
|
||||
"@erp/customer-invoices": "workspace:*",
|
||||
"@erp/document-series": "workspace:*",
|
||||
"@erp/customers": "workspace:*",
|
||||
"@erp/identity": "workspace:*",
|
||||
"@erp/suppliers": "workspace:*",
|
||||
@ -63,4 +64,4 @@
|
||||
"tw-animate-css": "^1.4.0",
|
||||
"vite-plugin-html": "^3.2.2"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -4,6 +4,7 @@ import { CompaniesModuleManifest } from "@erp/companies/client";
|
||||
import CoreModuleManifest, { type IModuleClient } from "@erp/core/client";
|
||||
import { CustomerInvoicesModuleManifest } from "@erp/customer-invoices/client";
|
||||
import { CustomersModuleManifest } from "@erp/customers/client";
|
||||
import { DocumentSeriesModuleManifest } from "@erp/document-series/client";
|
||||
import { IdentityModuleManifest } from "@erp/identity/client";
|
||||
import { SuppliersModuleManifest } from "@erp/suppliers/client";
|
||||
|
||||
@ -13,6 +14,7 @@ export const modules: IModuleClient[] = [
|
||||
AuditModuleManifest,
|
||||
CompaniesModuleManifest,
|
||||
CatalogsModuleManifest,
|
||||
DocumentSeriesModuleManifest,
|
||||
CustomersModuleManifest,
|
||||
SuppliersModuleManifest,
|
||||
CustomerInvoicesModuleManifest,
|
||||
|
||||
@ -25,6 +25,8 @@ Centraliza la gestion de series documentales y su numeracion transaccional para
|
||||
|
||||
## 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.
|
||||
- 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.
|
||||
@ -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`.
|
||||
- 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
|
||||
|
||||
`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 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
|
||||
|
||||
`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.
|
||||
|
||||
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
|
||||
|
||||
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.
|
||||
|
||||
## 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",
|
||||
"./common": "./src/common/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": {
|
||||
"@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",
|
||||
"typescript": "^6.0.2"
|
||||
},
|
||||
@ -29,8 +38,17 @@
|
||||
"@repo/rdx-audit": "workspace:*",
|
||||
"@repo/rdx-ddd": "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",
|
||||
"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",
|
||||
"use-debounce": "^10.1.1",
|
||||
"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':
|
||||
specifier: workspace:*
|
||||
version: link:../../modules/customers
|
||||
'@erp/document-series':
|
||||
specifier: workspace:*
|
||||
version: link:../../modules/document-series
|
||||
'@erp/identity':
|
||||
specifier: workspace:*
|
||||
version: link:../../modules/identity
|
||||
@ -1050,6 +1053,9 @@ importers:
|
||||
'@erp/identity':
|
||||
specifier: workspace:*
|
||||
version: link:../identity
|
||||
'@repo/i18next':
|
||||
specifier: workspace:*
|
||||
version: link:../../packages/i18n
|
||||
'@repo/rdx-audit':
|
||||
specifier: workspace:*
|
||||
version: link:../../packages/rdx-audit
|
||||
@ -1059,15 +1065,39 @@ importers:
|
||||
'@repo/rdx-ddd':
|
||||
specifier: workspace:*
|
||||
version: link:../../packages/rdx-ddd
|
||||
'@repo/rdx-ui':
|
||||
specifier: workspace:*
|
||||
version: link:../../packages/rdx-ui
|
||||
'@repo/rdx-utils':
|
||||
specifier: workspace:*
|
||||
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:
|
||||
specifier: ^4.22.1
|
||||
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:
|
||||
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)
|
||||
use-debounce:
|
||||
specifier: ^10.1.1
|
||||
version: 10.1.1(react@19.2.6)
|
||||
zod:
|
||||
specifier: ^4.3.6
|
||||
version: 4.4.3
|
||||
@ -1075,6 +1105,18 @@ importers:
|
||||
'@types/express':
|
||||
specifier: ^4.17.21
|
||||
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:
|
||||
specifier: ^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