diff --git a/apps/web/package.json b/apps/web/package.json index 3fc20bf3..527add68 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -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" } -} \ No newline at end of file +} diff --git a/apps/web/src/register-modules.tsx b/apps/web/src/register-modules.tsx index fdfe57e6..3fd12f89 100644 --- a/apps/web/src/register-modules.tsx +++ b/apps/web/src/register-modules.tsx @@ -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, diff --git a/docs/document-series/README.md b/docs/document-series/README.md index ac85786e..d02bb8fc 100644 --- a/docs/document-series/README.md +++ b/docs/document-series/README.md @@ -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: diff --git a/docs/document-series/manual-validation.md b/docs/document-series/manual-validation.md index e6cd4c1a..339ed9ce 100644 --- a/docs/document-series/manual-validation.md +++ b/docs/document-series/manual-validation.md @@ -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. diff --git a/docs/frontend/ui-patterns.md b/docs/frontend/ui-patterns.md index a3716d09..65897ac1 100644 --- a/docs/frontend/ui-patterns.md +++ b/docs/frontend/ui-patterns.md @@ -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. diff --git a/docs/settings/frontend-erp-settings-module-integration-design.md b/docs/settings/frontend-erp-settings-module-integration-design.md index 0101cbf8..bc0dab31 100644 --- a/docs/settings/frontend-erp-settings-module-integration-design.md +++ b/docs/settings/frontend-erp-settings-module-integration-design.md @@ -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. diff --git a/modules/document-series/package.json b/modules/document-series/package.json index a71d6fd5..672038ca 100644 --- a/modules/document-series/package.json +++ b/modules/document-series/package.json @@ -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" } } diff --git a/modules/document-series/src/common/locales/en.json b/modules/document-series/src/common/locales/en.json new file mode 100644 index 00000000..eab60306 --- /dev/null +++ b/modules/document-series/src/common/locales/en.json @@ -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." + } + } + } +} diff --git a/modules/document-series/src/common/locales/es.json b/modules/document-series/src/common/locales/es.json new file mode 100644 index 00000000..40e75bb4 --- /dev/null +++ b/modules/document-series/src/common/locales/es.json @@ -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." + } + } + } +} diff --git a/modules/document-series/src/web/document-series-routes.tsx b/modules/document-series/src/web/document-series-routes.tsx new file mode 100644 index 00000000..215397cd --- /dev/null +++ b/modules/document-series/src/web/document-series-routes.tsx @@ -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: , + }, +]; diff --git a/modules/document-series/src/web/document-series/shared/adapters/document-series.adapter.ts b/modules/document-series/src/web/document-series/shared/adapters/document-series.adapter.ts new file mode 100644 index 00000000..e4fcd3c7 --- /dev/null +++ b/modules/document-series/src/web/document-series/shared/adapters/document-series.adapter.ts @@ -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, +}); diff --git a/modules/document-series/src/web/document-series/shared/api/document-series.api.ts b/modules/document-series/src/web/document-series/shared/api/document-series.api.ts new file mode 100644 index 00000000..3d4bb68a --- /dev/null +++ b/modules/document-series/src/web/document-series/shared/api/document-series.api.ts @@ -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) => + dataSource.getList("document-series", criteria); + +export const getDocumentSeries = (dataSource: IDataSource, id: string) => + dataSource.getOne("document-series", id); + +export const createDocumentSeries = ( + dataSource: IDataSource, + data: CreateDocumentSeriesRequestDTO +) => + dataSource.createOne( + "document-series", + data + ); + +export const updateDocumentSeries = ( + dataSource: IDataSource, + id: string, + data: UpdateDocumentSeriesRequestDTO +) => + dataSource.updateOne( + "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 }); diff --git a/modules/document-series/src/web/document-series/shared/entities/document-series.entity.ts b/modules/document-series/src/web/document-series/shared/entities/document-series.entity.ts new file mode 100644 index 00000000..b50deba9 --- /dev/null +++ b/modules/document-series/src/web/document-series/shared/entities/document-series.entity.ts @@ -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; +} diff --git a/modules/document-series/src/web/document-series/shared/hooks/index.ts b/modules/document-series/src/web/document-series/shared/hooks/index.ts new file mode 100644 index 00000000..7e572a78 --- /dev/null +++ b/modules/document-series/src/web/document-series/shared/hooks/index.ts @@ -0,0 +1,3 @@ +export * from "./keys"; +export * from "./use-document-series-list-query"; +export * from "./use-document-series-mutations"; diff --git a/modules/document-series/src/web/document-series/shared/hooks/keys.ts b/modules/document-series/src/web/document-series/shared/hooks/keys.ts new file mode 100644 index 00000000..0ce73688 --- /dev/null +++ b/modules/document-series/src/web/document-series/shared/hooks/keys.ts @@ -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): 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 }], +}; diff --git a/modules/document-series/src/web/document-series/shared/hooks/use-document-series-list-query.ts b/modules/document-series/src/web/document-series/shared/hooks/use-document-series-list-query.ts new file mode 100644 index 00000000..ecbd7070 --- /dev/null +++ b/modules/document-series/src/web/document-series/shared/hooks/use-document-series-list-query.ts @@ -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) => { + const dataSource = useDataSource(); + return useQuery({ + queryKey: documentSeriesKeys.list(criteria), + queryFn: async () => toDocumentSeriesList(await listDocumentSeries(dataSource, criteria)), + placeholderData: (previous) => previous, + }); +}; diff --git a/modules/document-series/src/web/document-series/shared/hooks/use-document-series-mutations.ts b/modules/document-series/src/web/document-series/shared/hooks/use-document-series-mutations.ts new file mode 100644 index 00000000..48a097b3 --- /dev/null +++ b/modules/document-series/src/web/document-series/shared/hooks/use-document-series-mutations.ts @@ -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) => + 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), + }); +}; diff --git a/modules/document-series/src/web/i18n.ts b/modules/document-series/src/web/i18n.ts new file mode 100644 index 00000000..fc64b579 --- /dev/null +++ b/modules/document-series/src/web/i18n.ts @@ -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 }); +}; diff --git a/modules/document-series/src/web/manifest.ts b/modules/document-series/src/web/manifest.ts new file mode 100644 index 00000000..722555ed --- /dev/null +++ b/modules/document-series/src/web/manifest.ts @@ -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; diff --git a/modules/document-series/src/web/settings/document-series/controllers/use-document-series-settings-controller.test.ts b/modules/document-series/src/web/settings/document-series/controllers/use-document-series-settings-controller.test.ts new file mode 100644 index 00000000..1a316940 --- /dev/null +++ b/modules/document-series/src/web/settings/document-series/controllers/use-document-series-settings-controller.test.ts @@ -0,0 +1,179 @@ +import assert from "node:assert/strict"; +import { mock, test } from "node:test"; + +const stateUpdates: Array = []; +const resets: Array = []; +const mutationCalls: Array = []; +const successToasts: Array = []; + +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) => () => handler(series), +}; +const advanceForm = { + ...form, + handleSubmit: (handler: (values: { nextNumber: number }) => Promise) => () => + handler({ nextNumber: 12 }), + setError: () => undefined, +}; +let formInvocation = 0; +let stateInvocation = 0; +let modeForTest: "create" | "edit" | null = null; + +mock.module("react", { + namedExports: { + useCallback: (callback: T) => callback, + useEffect: (callback: () => void) => callback(), + useId: () => "form-id", + useMemo: (callback: () => T) => callback(), + useState: (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, + }, + }); +}); diff --git a/modules/document-series/src/web/settings/document-series/controllers/use-document-series-settings-controller.ts b/modules/document-series/src/web/settings/document-series/controllers/use-document-series-settings-controller.ts new file mode 100644 index 00000000..d32a906b --- /dev/null +++ b/modules/document-series/src/web/settings/document-series/controllers/use-document-series-settings-controller.ts @@ -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 = { + 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("all"); + const [status, setStatusState] = useState("all"); + const [pageIndex, setPageIndex] = useState(0); + const [pageSize, setPageSizeState] = useState(10); + const [sort, setSortState] = useState(DEFAULT_SORT); + const [mode, setMode] = useState(null); + const [selectedSeries, setSelectedSeries] = useState(null); + const [advancing, setAdvancing] = useState(null); + const [seriesPendingDisable, setSeriesPendingDisable] = useState(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({ + 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( + () => ({ + 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) => { + 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) => { + 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 = (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]), + }; +}; diff --git a/modules/document-series/src/web/settings/document-series/document-series-settings-page.test.tsx b/modules/document-series/src/web/settings/document-series/document-series-settings-page.test.tsx new file mode 100644 index 00000000..04e64935 --- /dev/null +++ b/modules/document-series/src/web/settings/document-series/document-series-settings-page.test.tsx @@ -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 | undefined; +const dialogs: string[] = []; + +mock.module("@erp/core/components", { + namedExports: { + PageErrorState: () =>
, + SimpleSearchInput: () => , + }, +}); +mock.module("@repo/rdx-ui/components", { + namedExports: { + DataTable: (props: Record) => { + dataTableProps = props; + return
; + }, + DataTableColumnHeader: () => null, + RdxBadge: ({ children }: { children: ReactNode }) => children, + }, +}); +mock.module("@repo/shadcn-ui/components", { + namedExports: { + Button: ({ children }: { children: ReactNode }) => , + Card: ({ children }: { children: ReactNode }) =>
{children}
, + CardContent: ({ children }: { children: ReactNode }) =>
{children}
, + CardHeader: ({ children }: { children: ReactNode }) =>
{children}
, + Select: ({ children }: { children: ReactNode }) =>
{children}
, + SelectContent: ({ children }: { children: ReactNode }) =>
{children}
, + SelectItem: ({ children }: { children: ReactNode }) =>
{children}
, + SelectTrigger: ({ children }: { children: ReactNode }) => ( + + ), + 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(); + + assert.match(html, /data-table/); + assert.equal(dataTableProps?.manualPagination, true); + assert.equal(dataTableProps?.manualSorting, true); + assert.deepEqual(dialogs, ["form", "advance", "disable"]); +}); diff --git a/modules/document-series/src/web/settings/document-series/document-series-settings-page.tsx b/modules/document-series/src/web/settings/document-series/document-series-settings-page.tsx new file mode 100644 index 00000000..ae8707c9 --- /dev/null +++ b/modules/document-series/src/web/settings/document-series/document-series-settings-page.tsx @@ -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["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( + () => [ + { + accessorKey: "code", + enableSorting: true, + header: ({ column }) => ( + + ), + cell: ({ row }) => ( + + {row.original.code} + + ), + }, + { + accessorKey: "name", + enableSorting: true, + header: ({ column }) => ( + + ), + cell: ({ row }) => ( + + {row.original.name} + + ), + }, + { + accessorKey: "documentType", + enableSorting: true, + header: ({ column }) => ( + + ), + cell: ({ row }) => + row.original.documentType === "proforma" + ? t("settings.documentSeries.proforma") + : t("settings.documentSeries.issuedInvoice"), + }, + { + accessorKey: "nextNumber", + header: t("settings.documentSeries.nextNumber"), + cell: ({ row }) => ( + + {row.original.nextNumber} + + ), + }, + { + id: "format", + header: t("settings.documentSeries.format"), + cell: ({ row }) => {preview(row.original)}, + }, + { + id: "validity", + header: t("settings.documentSeries.validity"), + cell: ({ row }) => ( + + {row.original.validFrom ?? "—"}{" "} + {row.original.validTo ? `– ${row.original.validTo}` : ""} + + ), + }, + { + id: "default", + accessorKey: "isDefault", + enableSorting: true, + header: ({ column }) => ( + + ), + cell: ({ row }) => ( + + {row.original.isDefault ? ( + {t("settings.documentSeries.default")} + ) : null} + + ), + }, + { + id: "status", + accessorKey: "isActive", + enableSorting: true, + header: ({ column }) => ( + + ), + cell: ({ row }) => ( + + {row.original.isActive + ? t("settings.documentSeries.active") + : t("settings.documentSeries.inactive")} + + ), + }, + { + id: "actions", + enableSorting: false, + header: t("settings.documentSeries.actions"), + cell: ({ row }) => ( +
+ + + {row.original.isActive && !row.original.isDefault && ( + + )} + +
+ ), + }, + ], + [ctrl, t] + ); + if (ctrl.listQuery.isLoading) { + return ( + + {t("settings.documentSeries.loading")} + + ); + } + if (ctrl.listQuery.isError) { + return ( + + ); + } + return ( + <> + + +
+

+ {t("settings.documentSeries.title")} +

+

{t("settings.documentSeries.description")}

+
+ +
+ + +
+ +
+ + +
+ } + totalItems={ctrl.totalItems} + /> + + + {ctrl.mode && ( + + )} + + + + ); +}; diff --git a/modules/document-series/src/web/settings/document-series/entities/document-series-form.entity.ts b/modules/document-series/src/web/settings/document-series/entities/document-series-form.entity.ts new file mode 100644 index 00000000..3ccf8e15 --- /dev/null +++ b/modules/document-series/src/web/settings/document-series/entities/document-series-form.entity.ts @@ -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; +}; diff --git a/modules/document-series/src/web/settings/document-series/entities/document-series-form.schema.test.ts b/modules/document-series/src/web/settings/document-series/entities/document-series-form.schema.test.ts new file mode 100644 index 00000000..667a5478 --- /dev/null +++ b/modules/document-series/src/web/settings/document-series/entities/document-series-form.schema.test.ts @@ -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 + ); +}); diff --git a/modules/document-series/src/web/settings/document-series/entities/document-series-form.schema.ts b/modules/document-series/src/web/settings/document-series/entities/document-series-form.schema.ts new file mode 100644 index 00000000..68d2db6e --- /dev/null +++ b/modules/document-series/src/web/settings/document-series/entities/document-series-form.schema.ts @@ -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 = (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, + }) +); diff --git a/modules/document-series/src/web/settings/document-series/index.ts b/modules/document-series/src/web/settings/document-series/index.ts new file mode 100644 index 00000000..aae9a730 --- /dev/null +++ b/modules/document-series/src/web/settings/document-series/index.ts @@ -0,0 +1 @@ +export * from "./document-series-settings-page"; diff --git a/modules/document-series/src/web/settings/document-series/ui/document-series-dialogs.test.tsx b/modules/document-series/src/web/settings/document-series/ui/document-series-dialogs.test.tsx new file mode 100644 index 00000000..2883db66 --- /dev/null +++ b/modules/document-series/src/web/settings/document-series/ui/document-series-dialogs.test.tsx @@ -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> = []; +const buttons: Array> = []; + +mock.module("@erp/core", { namedExports: {} }); +mock.module("@repo/rdx-ui/components", { + namedExports: { + DatePickerField: (props: Record) => { + fields.push(props); + return null; + }, + RdxFormGrid: ({ children }: { children: ReactNode }) =>
{children}
, + SelectField: (props: Record) => { + fields.push(props); + return null; + }, + SwitchField: (props: Record) => { + fields.push(props); + return null; + }, + TextAreaField: (props: Record) => { + fields.push(props); + return null; + }, + TextField: (props: Record) => { + fields.push(props); + return null; + }, + }, +}); +mock.module("@repo/shadcn-ui/components", { + namedExports: { + AlertDialog: ({ children }: { children: ReactNode }) =>
{children}
, + AlertDialogContent: ({ children }: { children: ReactNode }) =>
{children}
, + AlertDialogDescription: ({ children }: { children: ReactNode }) =>

{children}

, + AlertDialogFooter: ({ children }: { children: ReactNode }) =>
{children}
, + AlertDialogHeader: ({ children }: { children: ReactNode }) =>
{children}
, + AlertDialogTitle: ({ children }: { children: ReactNode }) =>

{children}

, + Button: (props: Record) => { + buttons.push(props); + return + + + + + ); +}; + +export const AdvanceDocumentSeriesDialog = ({ + open, + series, + form, + pending, + onSubmit, + onClose, +}: { + open: boolean; + series: DocumentSeries | null; + form: UseFormReturn<{ nextNumber: number }>; + pending: boolean; + onSubmit: FormEventHandler; + onClose: () => void; +}) => { + const { t } = useTranslation(); + return ( + !nextOpen && onClose()} open={open}> + + + {t("settings.documentSeries.advance")} + {series?.code ?? ""} + + +
+ + disabled={pending} + inputMode="numeric" + label={t("settings.documentSeries.nextNumber")} + name="nextNumber" + typePreset="number" + /> + +
+ + + + +
+
+ ); +}; + +export const DisableDocumentSeriesDialog = ({ + open, + series, + pending, + onConfirm, + onClose, +}: { + open: boolean; + series: DocumentSeries | null; + pending: boolean; + onConfirm: () => void; + onClose: () => void; +}) => { + const { t } = useTranslation(); + return ( + !nextOpen && onClose()} open={open}> + + + {t("settings.documentSeries.disable")} + + {t( + series?.isDefault + ? "settings.documentSeries.disableDefaultWarning" + : "settings.documentSeries.disableWarning", + { name: series?.name ?? "" } + )} + + + + + + + + + ); +}; diff --git a/modules/document-series/src/web/settings/document-series/utils/build-document-series-request.test.ts b/modules/document-series/src/web/settings/document-series/utils/build-document-series-request.test.ts new file mode 100644 index 00000000..a5117618 --- /dev/null +++ b/modules/document-series/src/web/settings/document-series/utils/build-document-series-request.test.ts @@ -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, + } + ); +}); diff --git a/modules/document-series/src/web/settings/document-series/utils/build-document-series-request.ts b/modules/document-series/src/web/settings/document-series/utils/build-document-series-request.ts new file mode 100644 index 00000000..d64c5d52 --- /dev/null +++ b/modules/document-series/src/web/settings/document-series/utils/build-document-series-request.ts @@ -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, +}); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 12ee5201..9b46cc26 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -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 diff --git a/tests/e2e/document-series/document-series-settings.spec.ts b/tests/e2e/document-series/document-series-settings.spec.ts new file mode 100644 index 00000000..012218a0 --- /dev/null +++ b/tests/e2e/document-series/document-series-settings.spec.ts @@ -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(); + }); + });