diff --git a/docs/README.md b/docs/README.md index 2c1c70e8..d20681ad 100644 --- a/docs/README.md +++ b/docs/README.md @@ -26,6 +26,7 @@ Este directorio reúne la documentación técnica, operativa y de apoyo del ERP. #### Frontend - [Flujo de auth y selección de empresa](./frontend/auth-and-company-selection.md) +- [Diseño de pantallas de listado](./frontend/diseno-pantallas-listado.md) - [Sistema modular de Settings](./settings/frontend-erp-settings-system-design.md) - [Integración de módulos con Settings](./settings/frontend-erp-settings-module-integration-design.md) - [README de `apps/web`](../apps/web/README.md) diff --git a/docs/frontend/diseno-pantallas-listado.md b/docs/frontend/diseno-pantallas-listado.md new file mode 100644 index 00000000..cb71a299 --- /dev/null +++ b/docs/frontend/diseno-pantallas-listado.md @@ -0,0 +1,166 @@ +# Diseño de pantallas de listado + +## Estado + +Este documento es la fuente de verdad para diseñar, implementar y revisar pantallas de listado del frontend del ERP. + +Toda pantalla de listado nueva debe seguir esta guía salvo que exista una excepción funcional documentada antes de implementar el cambio. Las pantallas existentes se alinearán de forma incremental cuando se modifiquen. + +## Referencia canónica + +La referencia productiva es el listado de proformas: + +- página: `modules/customer-invoices/src/web/proformas/list/ui/pages/list-proformas-page.tsx`; +- controller de página: `modules/customer-invoices/src/web/proformas/list/controllers/use-list-proformas-page.controller.ts`; +- controller de datos: `modules/customer-invoices/src/web/proformas/list/controllers/use-list-proformas.controller.ts`; +- vista de shell: `modules/customer-invoices/src/web/proformas/list/ui/pages/scoped-list-proformas-page-view.tsx`. + +No se exige igualdad textual ni reutilización forzada de componentes de dominio. Se exige conservar la misma separación de responsabilidades y la misma jerarquía visual. + +## Estructura visual obligatoria + +Una página de listado se compone, por defecto, así: + +```tsx + + + } + /> + + {refreshError ? : null} + + + + {isEmpty ? : null} + + +``` + +Reglas: + +- `RdxPageHeader` vive dentro de `RdxPageStack`, no como un `header` HTML paralelo. +- Las acciones principales se presentan en el header mediante `RdxPageActions` o el mecanismo equivalente del shell existente. +- La búsqueda, filtros y controles de tabla viven en `DataTable.topContent` y preceden a la tabla. +- La tabla, el estado vacío y el error de refresco permanecen dentro del mismo stack para evitar saltos de layout. +- Debe respetarse el spacing del shell; no añadir wrappers de anchura, márgenes o stacks sin una necesidad de contenido demostrable. +- El CTA de creación se mantiene visible cuando hay datos, carga, vacío o un error de refresco. Sólo desaparece con un error inicial bloqueante. + +La pantalla puede usar una vista de composición específica del dominio, como `ScopedListProformasPageView`, cuando encapsule exclusivamente el shell anterior. No debe duplicar ese JSX en cada lista. + +## Ownership del controller de página + +Cada listado debe tener un controller de presentación cuando su página coordine consulta, URL, filtros, paginación o navegación. Su nombre sigue el patrón: + +```text +use-list--page-controller.ts +useListPageController +``` + +El controller de página concentra: + +- lectura y escritura de `searchParams` relevantes; +- estado de búsqueda y su debounce, si existe; +- filtros, orden y paginación server-side; +- composición del hook de datos existente de TanStack Query; +- adaptación mínima de resultados para la tabla; +- view state de carga, error inicial bloqueante y error durante refresco; +- callbacks de reintento, creación y navegación a detalle; +- inputs y handlers mínimos que necesita la página. + +La página queda declarativa: define columnas y compone el shell, el toolbar y la tabla con los datos y callbacks del controller. No debe tener `useSearchParams`, llamar directamente al hook de listado ni construir rutas de navegación desde eventos de la tabla. + +El controller no contiene reglas de dominio, cálculos de negocio, fetch manual, lógica de backend ni abstracciones genéricas de listados. Reutiliza los query hooks y query keys del dominio. + +## Consulta, filtros y paginación + +- Mantener la consulta remota en TanStack Query. +- Mantener paginación server-side mediante `manualPagination`, `pageIndex`, `pageSize`, `totalItems` y los callbacks correspondientes. +- Cambiar búsqueda, filtro, orden o tamaño de página actualiza la URL con la misma semántica del recurso y reinicia la página cuando corresponda. +- No duplicar datos remotos en estado local. +- No añadir filtros, columnas ni acciones por motivos visuales. +- Los filtros segmentados reutilizan el patrón visual de `ProformaStatusSegmentedFilter`, pero son componentes de dominio distintos si sus estados, traducciones o datos difieren. +- Nunca mostrar contadores globales de estado que el endpoint no proporciona. No se calculan a partir de una página parcial de resultados. + +## Tabla y navegación + +- Conservar columnas, orden, formato y acciones ya productivas salvo que la tarea cambie explícitamente su funcionalidad. +- El enlace de una celda y el click de fila deben llevar al mismo detalle cuando ambos existan. +- `DataTable.onRowClick` recibe el objeto de fila, no un identificador. La página lo adapta explícitamente: + +```tsx +onRowClick={(row) => controller.openDetailPage(row.id)} +``` + +- No pasar un callback que espera `id` directamente a `DataTable.onRowClick`; produciría rutas como `[object Object]`. +- La navegación de detalle y creación se define en el controller de página. La página sólo conecta el evento con el callback. +- No introducir N+1 para resolver nombres o datos de columnas. Si un dato no está en el contrato del listado, debe resolverse mediante una evolución explícita y justificada del contrato. + +## Estados de interfaz + +La lista distingue tres situaciones: + +| Situación | Comportamiento | +| --- | --- | +| Carga inicial | La tabla recibe su estado `loading`; se mantiene el shell de página. | +| Error inicial sin datos utilizables | Mostrar `PageErrorState` dentro de `RdxPageContainer`, con reintento. | +| Error al refrescar con datos utilizables | Mantener tabla y filtros; mostrar `ErrorAlert` inline en el stack. | + +Además: + +- el empty state se muestra cuando la respuesta utilizable tiene cero elementos; +- no crear un segundo sistema de skeleton, alertas o errores si ya existen `DataTable`, `PageErrorState` y `ErrorAlert`; +- las acciones deben reflejar loading y disabled cuando el contrato del componente lo permita; +- no ocultar acciones deshabilitadas si la UX requiere que sigan siendo visibles. + +## Accesibilidad e i18n + +- Los filtros tienen etiqueta accesible y controles navegables por teclado. +- El click de fila debe conservar activación con `Enter` y espacio provista por `DataTable`. +- No insertar textos visibles directamente: añadir y mantener traducciones en `es` y `en` cuando hagan falta. +- Mantener `tabular-nums` en valores monetarios y fechas numéricas cuando el patrón de tabla lo use. + +## Pruebas mínimas + +Al crear o refactorizar un listado, adaptar o añadir pruebas funcionales de página que cubran: + +- presencia y navegación del CTA de creación; +- búsqueda; +- filtro de estado u otros filtros ya existentes; +- paginación server-side; +- carga inicial, error bloqueante, error de refresco y vacío; +- renderizado de filas y formatos sensibles, como fechas sin zona horaria; +- navegación a detalle desde fila; +- que el callback de `DataTable.onRowClick` recibe una fila y usa su `id`. + +Los tests no deben depender de detalles internos irrelevantes del controller ni sustituir contratos de tabla por tipos incorrectos. + +## Validación obligatoria + +Antes de cerrar un cambio de listado, ejecutar en Linux y desde `/home/rodax/Documentos/uecko-erp`: + +```bash +pnpm test:frontend +pnpm --filter typecheck +pnpm --filter lint +git diff --check +git status --short +``` + +Si typecheck o lint fallan por problemas preexistentes ajenos, ejecutar también Biome sobre los archivos modificados y comunicar el fallo global sin atribuirlo al cambio. + +## Excepciones + +Una excepción requiere dejar constancia en la revisión o en documentación del área con: + +- el requisito funcional que impide seguir esta guía; +- el impacto visual o arquitectónico; +- el patrón alternativo elegido; +- las pruebas que lo cubren. + +No son excepciones válidas la preferencia local, la rapidez de implementación, copiar una pantalla antigua o evitar extraer lógica de una página monolítica. diff --git a/modules/customer-invoices/src/api/infrastructure/recurring-proformas/express/controllers/create-recurring-proforma.controller.test.ts b/modules/customer-invoices/src/api/infrastructure/recurring-proformas/express/controllers/create-recurring-proforma.controller.test.ts index 3c77581e..c363eb88 100644 --- a/modules/customer-invoices/src/api/infrastructure/recurring-proformas/express/controllers/create-recurring-proforma.controller.test.ts +++ b/modules/customer-invoices/src/api/infrastructure/recurring-proformas/express/controllers/create-recurring-proforma.controller.test.ts @@ -1,7 +1,11 @@ import assert from "node:assert/strict"; import test from "node:test"; +import { UniqueID } from "@repo/rdx-ddd"; +import { Maybe, Result } from "@repo/rdx-utils"; import { CreateRecurringProformaRequestSchema } from "../../../../../common"; +import { RecurringProforma } from "../../../../../domain"; +import { CreateRecurringProformaController } from "./recurring-proformas-read.controllers"; test("CreateRecurringProformaRequestSchema acepta un draft con solo name", () => { assert.deepEqual(CreateRecurringProformaRequestSchema.parse({ name: "Mantenimiento mensual" }), { name: "Mantenimiento mensual" }); @@ -10,3 +14,85 @@ test("CreateRecurringProformaRequestSchema acepta un draft con solo name", () => test("CreateRecurringProformaRequestSchema conserva null explícito en description", () => { assert.equal(CreateRecurringProformaRequestSchema.parse({ name: "Mantenimiento mensual", description: null }).description, null); }); + +test("CreateRecurringProformaController responde con el detail read model tras crear", async () => { + const companyId = UniqueID.generateNewID(); + const recurringProformaId = UniqueID.generateNewID(); + const createCalls: Array<{ companyId: UniqueID; name: string; description: string | null }> = []; + const detailCalls: Array<{ companyId: UniqueID; id: UniqueID }> = []; + let statusCode: number | undefined; + let body: any; + + const recurring = RecurringProforma.create( + { + companyId, + name: "Cuota", + description: Maybe.some("Texto"), + }, + recurringProformaId + ).data; + + const controller = new CreateRecurringProformaController( + async (input: { companyId: UniqueID; name: string; description: string | null }) => { + createCalls.push(input); + return Result.ok(recurring); + }, + async (input: { companyId: UniqueID; id: UniqueID }) => { + detailCalls.push(input); + return Result.ok({ + id: recurringProformaId, + name: "Cuota", + status: "draft", + customerId: null, + description: "Texto", + paymentTermId: null, + paymentMethodId: null, + language: null, + currency: null, + proformaSeries: null, + issuedInvoiceSeries: null, + globalDiscountPercentage: null, + schedule: null, + nextOccurrenceDate: null, + items: [], + createdAt: new Date("2027-01-01T10:00:00.000Z"), + updatedAt: new Date("2027-01-01T10:00:00.000Z"), + }); + } + ); + + const req = { + user: { companyId }, + body: { name: "Cuota", description: "Texto" }, + query: {}, + params: {}, + originalUrl: "/recurring-proformas", + method: "POST", + protocol: "http", + get: () => undefined, + }; + const res = { + status: (value: number) => { + statusCode = value; + return res; + }, + json: (value: unknown) => { + body = value; + return res; + }, + sendStatus: () => res, + type: () => res, + setHeader: () => undefined, + }; + + await controller.execute(req as never, res as never, () => undefined); + + assert.equal(statusCode, 201); + assert.equal(createCalls.length, 1); + assert.equal(detailCalls.length, 1); + assert.equal(createCalls[0]?.companyId.equals(companyId), true); + assert.equal(detailCalls[0]?.companyId.equals(companyId), true); + assert.equal(detailCalls[0]?.id.equals(recurringProformaId), true); + assert.equal(body.id, recurringProformaId.toPrimitive()); + assert.equal(body.description, "Texto"); +}); diff --git a/modules/customer-invoices/src/api/infrastructure/recurring-proformas/express/controllers/recurring-proformas-read.controllers.ts b/modules/customer-invoices/src/api/infrastructure/recurring-proformas/express/controllers/recurring-proformas-read.controllers.ts index 241a9268..fa61c119 100644 --- a/modules/customer-invoices/src/api/infrastructure/recurring-proformas/express/controllers/recurring-proformas-read.controllers.ts +++ b/modules/customer-invoices/src/api/infrastructure/recurring-proformas/express/controllers/recurring-proformas-read.controllers.ts @@ -128,6 +128,7 @@ export class ListRecurringProformaOccurrencesController extends RecurringProform export class CreateRecurringProformaController extends RecurringProformaController { public constructor( private readonly create: any, + private readonly detail: any, private readonly presenter = new RecurringProformaDetailPresenter() ) { super(); @@ -135,9 +136,15 @@ export class CreateRecurringProformaController extends RecurringProformaControll protected async executeImpl() { const companyId = this.company(); if (!companyId) return this.forbiddenError("Tenant ID not found"); - const result = await this.create({ companyId, ...this.req.body }); - return result.match( - (data: any) => this.created(this.presenter.present(data)), + const created = await this.create({ companyId, ...this.req.body }); + return created.match( + async (data: any) => { + const detail = await this.detail({ companyId, id: data.id }); + return detail.match( + (readModel: any) => this.created(this.presenter.present(readModel)), + (error: Error) => this.handleError(error) + ); + }, (error: Error) => this.handleError(error) ); } @@ -145,6 +152,7 @@ export class CreateRecurringProformaController extends RecurringProformaControll export class UpdateRecurringProformaController extends RecurringProformaController { public constructor( private readonly update: any, + private readonly detail: any, private readonly presenter = new RecurringProformaDetailPresenter() ) { super(); @@ -168,9 +176,15 @@ export class UpdateRecurringProformaController extends RecurringProformaControll language: body.language, proformaSeries: body.proforma_series, }; - const result = await this.update({ companyId, id: parsed.data, patch }); - return result.match( - (data: any) => this.ok(this.presenter.present(data)), + const updated = await this.update({ companyId, id: parsed.data, patch }); + return updated.match( + async () => { + const detail = await this.detail({ companyId, id: parsed.data }); + return detail.match( + (readModel: any) => this.ok(this.presenter.present(readModel)), + (error: Error) => this.handleError(error) + ); + }, (error: Error) => this.handleError(error) ); } diff --git a/modules/customer-invoices/src/api/infrastructure/recurring-proformas/express/controllers/update-recurring-proforma.controller.test.ts b/modules/customer-invoices/src/api/infrastructure/recurring-proformas/express/controllers/update-recurring-proforma.controller.test.ts index 88625b3b..ffcff189 100644 --- a/modules/customer-invoices/src/api/infrastructure/recurring-proformas/express/controllers/update-recurring-proforma.controller.test.ts +++ b/modules/customer-invoices/src/api/infrastructure/recurring-proformas/express/controllers/update-recurring-proforma.controller.test.ts @@ -1,6 +1,9 @@ import assert from "node:assert/strict"; import test from "node:test"; +import { UniqueID } from "@repo/rdx-ddd"; +import { Result } from "@repo/rdx-utils"; import { UpdateRecurringProformaRequestSchema } from "../../../../../common"; +import { UpdateRecurringProformaController } from "./recurring-proformas-read.controllers"; test("PATCH conserva omitted, null y value para payment_term_id", () => { assert.equal(UpdateRecurringProformaRequestSchema.parse({ description: "Nueva" }).payment_term_id, undefined); @@ -13,3 +16,90 @@ test("PATCH conserva omitted, null y value para language", () => { assert.equal(UpdateRecurringProformaRequestSchema.parse({ language: null }).language, null); assert.equal(UpdateRecurringProformaRequestSchema.parse({ language: "es" }).language, "es"); }); + +test("UpdateRecurringProformaController relee detail tras actualizar antes de responder", async () => { + const companyId = UniqueID.generateNewID(); + const recurringProformaId = UniqueID.generateNewID(); + const updateCalls: Array<{ companyId: UniqueID; id: UniqueID; patch: { description?: string | null; paymentTermId?: UniqueID | null; language?: string | null; proformaSeries?: string | null } }> = []; + const detailCalls: Array<{ companyId: UniqueID; id: UniqueID }> = []; + let statusCode: number | undefined; + let body: any; + + const controller = new UpdateRecurringProformaController( + async (input: { + companyId: UniqueID; + id: UniqueID; + patch: { + description?: string | null; + paymentTermId?: UniqueID | null; + language?: string | null; + proformaSeries?: string | null; + }; + }) => { + updateCalls.push(input); + return Result.ok({ + id: recurringProformaId, + }); + }, + async (input: { companyId: UniqueID; id: UniqueID }) => { + detailCalls.push(input); + return Result.ok({ + id: recurringProformaId, + name: "Cuota", + status: "draft", + customerId: null, + description: "Nueva", + paymentTermId: null, + paymentMethodId: null, + language: "es", + currency: null, + proformaSeries: "RP", + issuedInvoiceSeries: null, + globalDiscountPercentage: null, + schedule: null, + nextOccurrenceDate: null, + items: [], + createdAt: new Date("2027-01-01T10:00:00.000Z"), + updatedAt: new Date("2027-01-01T11:00:00.000Z"), + }); + } + ); + + const req = { + user: { companyId }, + body: { description: "Nueva", payment_term_id: null, language: "es", proforma_series: "RP" }, + query: {}, + params: { recurring_proforma_id: recurringProformaId.toPrimitive() }, + originalUrl: `/recurring-proformas/${recurringProformaId.toPrimitive()}`, + method: "PATCH", + protocol: "http", + get: () => undefined, + }; + const res = { + status: (value: number) => { + statusCode = value; + return res; + }, + json: (value: unknown) => { + body = value; + return res; + }, + sendStatus: () => res, + type: () => res, + setHeader: () => undefined, + }; + + await controller.execute(req as never, res as never, () => undefined); + + assert.equal(statusCode, 200); + assert.equal(updateCalls.length, 1); + assert.equal(detailCalls.length, 1); + assert.equal(updateCalls[0]?.companyId.equals(companyId), true); + assert.equal(updateCalls[0]?.id.equals(recurringProformaId), true); + assert.equal(updateCalls[0]?.patch.paymentTermId, null); + assert.equal(detailCalls[0]?.companyId.equals(companyId), true); + assert.equal(detailCalls[0]?.id.equals(recurringProformaId), true); + assert.equal(body.id, recurringProformaId.toPrimitive()); + assert.equal(body.language, "es"); + assert.equal(body.proforma_series, "RP"); +}); diff --git a/modules/customer-invoices/src/api/infrastructure/recurring-proformas/express/recurring-proformas.routes.ts b/modules/customer-invoices/src/api/infrastructure/recurring-proformas/express/recurring-proformas.routes.ts index 1563431c..5b344095 100644 --- a/modules/customer-invoices/src/api/infrastructure/recurring-proformas/express/recurring-proformas.routes.ts +++ b/modules/customer-invoices/src/api/infrastructure/recurring-proformas/express/recurring-proformas.routes.ts @@ -44,14 +44,14 @@ export const recurringProformasRouter = (params: StartParams) => { "/", validateRequest(CreateRecurringProformaRequestSchema, "body"), (req: Request, res: Response, next: NextFunction) => - new CreateRecurringProformaController(deps.create).execute(req, res, next) + new CreateRecurringProformaController(deps.create, deps.detail).execute(req, res, next) ); router.patch( "/:recurring_proforma_id", validateRequest(RecurringProformaIdParamsSchema, "params"), validateRequest(UpdateRecurringProformaRequestSchema, "body"), (req: Request, res: Response, next: NextFunction) => - new UpdateRecurringProformaController(deps.update).execute(req, res, next) + new UpdateRecurringProformaController(deps.update, deps.detail).execute(req, res, next) ); router.get( "/:recurring_proforma_id/activation-readiness", diff --git a/modules/customer-invoices/src/web/customer-invoice-routes.tsx b/modules/customer-invoices/src/web/customer-invoice-routes.tsx index 7d946a50..86646ab6 100644 --- a/modules/customer-invoices/src/web/customer-invoice-routes.tsx +++ b/modules/customer-invoices/src/web/customer-invoice-routes.tsx @@ -140,7 +140,7 @@ export const CustomerInvoiceRoutes = (params: ModuleClientParams): RouteObject[] { path: "recurring-proformas/create", handle: { - layout: "app-sidebar", + layout: "app-fullscreen", protected: true, }, element: , diff --git a/modules/customer-invoices/src/web/recurring-proformas/create/recurring-proforma-create-page.tsx b/modules/customer-invoices/src/web/recurring-proformas/create/recurring-proforma-create-page.tsx index 31ea4c16..7f5fa2ec 100644 --- a/modules/customer-invoices/src/web/recurring-proformas/create/recurring-proforma-create-page.tsx +++ b/modules/customer-invoices/src/web/recurring-proformas/create/recurring-proforma-create-page.tsx @@ -1,6 +1,16 @@ +import { PageFormHeader } from "@erp/core/components"; import { useHookForm } from "@erp/core/hooks"; import { RdxEditorPageLayout } from "@repo/rdx-ui/components"; -import { Button, Card, CardContent, CardHeader, CardTitle, Input, Label, Textarea } from "@repo/shadcn-ui/components"; +import { + Button, + Card, + CardContent, + CardHeader, + CardTitle, + Input, + Label, + Textarea, +} from "@repo/shadcn-ui/components"; import { useState } from "react"; import { useNavigate } from "react-router-dom"; @@ -13,6 +23,8 @@ import { RecurringProformaCreateFormSchema, } from "./recurring-proforma-create-utils"; +const RECURRING_PROFORMA_CREATE_FORM_ID = "recurring-proforma-create-form"; + export const RecurringProformaCreatePage = () => { const { t } = useTranslation(); const navigate = useNavigate(); @@ -30,38 +42,91 @@ export const RecurringProformaCreatePage = () => { const created = await mutation.mutateAsync(buildCreateRecurringProformaInput(values)); navigate(getCreatedRecurringProformaPath(created.id)); } catch (error) { - setSubmitError(error instanceof Error ? error.message : t("recurring_proformas.create.error")); + setSubmitError( + error instanceof Error ? error.message : t("recurring_proformas.create.error") + ); } }); return (
+ + + +
+ } + backLabel={t("recurring_proformas.create.cancel")} + onBack={() => navigate("/recurring-proformas")} + title={t("recurring_proformas.create.title")} + />
-
-
-

{t("recurring_proformas.create.title")}

-

{t("recurring_proformas.create.description")}

-
- {submitError ?

{submitError}

: null} + +

+ {t("recurring_proformas.create.description")} +

+ {submitError ? ( +

+ {submitError} +

+ ) : null} - {t("recurring_proformas.create.form_title")} + + {t("recurring_proformas.create.form_title")} +
- - - {form.formState.errors.name ?

{t("recurring_proformas.create.name_required")}

: null} + + + {form.formState.errors.name ? ( +

+ {t("recurring_proformas.create.name_required")} +

+ ) : null}
- -