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:
David Arranz 2026-09-18 22:57:37 +02:00
parent 2122ed562b
commit 78f5236fe9
33 changed files with 2173 additions and 2 deletions

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View 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."
}
}
}
}

View 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."
}
}
}
}

View 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 />,
},
];

View File

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

View File

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

View File

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

View File

@ -0,0 +1,3 @@
export * from "./keys";
export * from "./use-document-series-list-query";
export * from "./use-document-series-mutations";

View File

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

View File

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

View File

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

View 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 });
};

View 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;

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -0,0 +1 @@
export * from "./document-series-settings-page";

View File

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

View File

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

View File

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

View File

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

View File

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

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