Proformas recurrentes
This commit is contained in:
parent
a746bb97ec
commit
7effe3aad8
@ -26,6 +26,7 @@ Este directorio reúne la documentación técnica, operativa y de apoyo del ERP.
|
|||||||
#### Frontend
|
#### Frontend
|
||||||
|
|
||||||
- [Flujo de auth y selección de empresa](./frontend/auth-and-company-selection.md)
|
- [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)
|
- [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)
|
- [Integración de módulos con Settings](./settings/frontend-erp-settings-module-integration-design.md)
|
||||||
- [README de `apps/web`](../apps/web/README.md)
|
- [README de `apps/web`](../apps/web/README.md)
|
||||||
|
|||||||
166
docs/frontend/diseno-pantallas-listado.md
Normal file
166
docs/frontend/diseno-pantallas-listado.md
Normal file
@ -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
|
||||||
|
<RdxPageContainer width="full">
|
||||||
|
<RdxPageStack>
|
||||||
|
<RdxPageHeader
|
||||||
|
title={title}
|
||||||
|
description={description}
|
||||||
|
actions={<RdxPageActions primary={primaryAction} />}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{refreshError ? <ErrorAlert {...refreshError} /> : null}
|
||||||
|
|
||||||
|
<DataTable
|
||||||
|
topContent={toolbarAndFilters}
|
||||||
|
{...tableProps}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{isEmpty ? <EmptyState /> : null}
|
||||||
|
</RdxPageStack>
|
||||||
|
</RdxPageContainer>
|
||||||
|
```
|
||||||
|
|
||||||
|
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-<recurso>-page-controller.ts
|
||||||
|
useList<Resource>PageController
|
||||||
|
```
|
||||||
|
|
||||||
|
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 <tests-dirigidos>
|
||||||
|
pnpm --filter <workspace> typecheck
|
||||||
|
pnpm --filter <workspace> 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.
|
||||||
@ -1,7 +1,11 @@
|
|||||||
import assert from "node:assert/strict";
|
import assert from "node:assert/strict";
|
||||||
import test from "node:test";
|
import test from "node:test";
|
||||||
|
import { UniqueID } from "@repo/rdx-ddd";
|
||||||
|
import { Maybe, Result } from "@repo/rdx-utils";
|
||||||
|
|
||||||
import { CreateRecurringProformaRequestSchema } from "../../../../../common";
|
import { CreateRecurringProformaRequestSchema } from "../../../../../common";
|
||||||
|
import { RecurringProforma } from "../../../../../domain";
|
||||||
|
import { CreateRecurringProformaController } from "./recurring-proformas-read.controllers";
|
||||||
|
|
||||||
test("CreateRecurringProformaRequestSchema acepta un draft con solo name", () => {
|
test("CreateRecurringProformaRequestSchema acepta un draft con solo name", () => {
|
||||||
assert.deepEqual(CreateRecurringProformaRequestSchema.parse({ name: "Mantenimiento mensual" }), { name: "Mantenimiento mensual" });
|
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", () => {
|
test("CreateRecurringProformaRequestSchema conserva null explícito en description", () => {
|
||||||
assert.equal(CreateRecurringProformaRequestSchema.parse({ name: "Mantenimiento mensual", description: null }).description, null);
|
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");
|
||||||
|
});
|
||||||
|
|||||||
@ -128,6 +128,7 @@ export class ListRecurringProformaOccurrencesController extends RecurringProform
|
|||||||
export class CreateRecurringProformaController extends RecurringProformaController {
|
export class CreateRecurringProformaController extends RecurringProformaController {
|
||||||
public constructor(
|
public constructor(
|
||||||
private readonly create: any,
|
private readonly create: any,
|
||||||
|
private readonly detail: any,
|
||||||
private readonly presenter = new RecurringProformaDetailPresenter()
|
private readonly presenter = new RecurringProformaDetailPresenter()
|
||||||
) {
|
) {
|
||||||
super();
|
super();
|
||||||
@ -135,9 +136,15 @@ export class CreateRecurringProformaController extends RecurringProformaControll
|
|||||||
protected async executeImpl() {
|
protected async executeImpl() {
|
||||||
const companyId = this.company();
|
const companyId = this.company();
|
||||||
if (!companyId) return this.forbiddenError("Tenant ID not found");
|
if (!companyId) return this.forbiddenError("Tenant ID not found");
|
||||||
const result = await this.create({ companyId, ...this.req.body });
|
const created = await this.create({ companyId, ...this.req.body });
|
||||||
return result.match(
|
return created.match(
|
||||||
(data: any) => this.created(this.presenter.present(data)),
|
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)
|
(error: Error) => this.handleError(error)
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@ -145,6 +152,7 @@ export class CreateRecurringProformaController extends RecurringProformaControll
|
|||||||
export class UpdateRecurringProformaController extends RecurringProformaController {
|
export class UpdateRecurringProformaController extends RecurringProformaController {
|
||||||
public constructor(
|
public constructor(
|
||||||
private readonly update: any,
|
private readonly update: any,
|
||||||
|
private readonly detail: any,
|
||||||
private readonly presenter = new RecurringProformaDetailPresenter()
|
private readonly presenter = new RecurringProformaDetailPresenter()
|
||||||
) {
|
) {
|
||||||
super();
|
super();
|
||||||
@ -168,9 +176,15 @@ export class UpdateRecurringProformaController extends RecurringProformaControll
|
|||||||
language: body.language,
|
language: body.language,
|
||||||
proformaSeries: body.proforma_series,
|
proformaSeries: body.proforma_series,
|
||||||
};
|
};
|
||||||
const result = await this.update({ companyId, id: parsed.data, patch });
|
const updated = await this.update({ companyId, id: parsed.data, patch });
|
||||||
return result.match(
|
return updated.match(
|
||||||
(data: any) => this.ok(this.presenter.present(data)),
|
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)
|
(error: Error) => this.handleError(error)
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,6 +1,9 @@
|
|||||||
import assert from "node:assert/strict";
|
import assert from "node:assert/strict";
|
||||||
import test from "node:test";
|
import test from "node:test";
|
||||||
|
import { UniqueID } from "@repo/rdx-ddd";
|
||||||
|
import { Result } from "@repo/rdx-utils";
|
||||||
import { UpdateRecurringProformaRequestSchema } from "../../../../../common";
|
import { UpdateRecurringProformaRequestSchema } from "../../../../../common";
|
||||||
|
import { UpdateRecurringProformaController } from "./recurring-proformas-read.controllers";
|
||||||
|
|
||||||
test("PATCH conserva omitted, null y value para payment_term_id", () => {
|
test("PATCH conserva omitted, null y value para payment_term_id", () => {
|
||||||
assert.equal(UpdateRecurringProformaRequestSchema.parse({ description: "Nueva" }).payment_term_id, undefined);
|
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: null }).language, null);
|
||||||
assert.equal(UpdateRecurringProformaRequestSchema.parse({ language: "es" }).language, "es");
|
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");
|
||||||
|
});
|
||||||
|
|||||||
@ -44,14 +44,14 @@ export const recurringProformasRouter = (params: StartParams) => {
|
|||||||
"/",
|
"/",
|
||||||
validateRequest(CreateRecurringProformaRequestSchema, "body"),
|
validateRequest(CreateRecurringProformaRequestSchema, "body"),
|
||||||
(req: Request, res: Response, next: NextFunction) =>
|
(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(
|
router.patch(
|
||||||
"/:recurring_proforma_id",
|
"/:recurring_proforma_id",
|
||||||
validateRequest(RecurringProformaIdParamsSchema, "params"),
|
validateRequest(RecurringProformaIdParamsSchema, "params"),
|
||||||
validateRequest(UpdateRecurringProformaRequestSchema, "body"),
|
validateRequest(UpdateRecurringProformaRequestSchema, "body"),
|
||||||
(req: Request, res: Response, next: NextFunction) =>
|
(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(
|
router.get(
|
||||||
"/:recurring_proforma_id/activation-readiness",
|
"/:recurring_proforma_id/activation-readiness",
|
||||||
|
|||||||
@ -140,7 +140,7 @@ export const CustomerInvoiceRoutes = (params: ModuleClientParams): RouteObject[]
|
|||||||
{
|
{
|
||||||
path: "recurring-proformas/create",
|
path: "recurring-proformas/create",
|
||||||
handle: {
|
handle: {
|
||||||
layout: "app-sidebar",
|
layout: "app-fullscreen",
|
||||||
protected: true,
|
protected: true,
|
||||||
},
|
},
|
||||||
element: <RecurringProformaCreatePage />,
|
element: <RecurringProformaCreatePage />,
|
||||||
|
|||||||
@ -1,6 +1,16 @@
|
|||||||
|
import { PageFormHeader } from "@erp/core/components";
|
||||||
import { useHookForm } from "@erp/core/hooks";
|
import { useHookForm } from "@erp/core/hooks";
|
||||||
import { RdxEditorPageLayout } from "@repo/rdx-ui/components";
|
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 { useState } from "react";
|
||||||
import { useNavigate } from "react-router-dom";
|
import { useNavigate } from "react-router-dom";
|
||||||
|
|
||||||
@ -13,6 +23,8 @@ import {
|
|||||||
RecurringProformaCreateFormSchema,
|
RecurringProformaCreateFormSchema,
|
||||||
} from "./recurring-proforma-create-utils";
|
} from "./recurring-proforma-create-utils";
|
||||||
|
|
||||||
|
const RECURRING_PROFORMA_CREATE_FORM_ID = "recurring-proforma-create-form";
|
||||||
|
|
||||||
export const RecurringProformaCreatePage = () => {
|
export const RecurringProformaCreatePage = () => {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
@ -30,38 +42,91 @@ export const RecurringProformaCreatePage = () => {
|
|||||||
const created = await mutation.mutateAsync(buildCreateRecurringProformaInput(values));
|
const created = await mutation.mutateAsync(buildCreateRecurringProformaInput(values));
|
||||||
navigate(getCreatedRecurringProformaPath(created.id));
|
navigate(getCreatedRecurringProformaPath(created.id));
|
||||||
} catch (error) {
|
} 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 (
|
return (
|
||||||
<div className="fixed inset-0 flex flex-col overflow-hidden">
|
<div className="fixed inset-0 flex flex-col overflow-hidden">
|
||||||
|
<PageFormHeader
|
||||||
|
actions={
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<Button
|
||||||
|
disabled={mutation.isPending}
|
||||||
|
onClick={() => navigate("/recurring-proformas")}
|
||||||
|
type="button"
|
||||||
|
variant="outline"
|
||||||
|
>
|
||||||
|
{t("recurring_proformas.create.cancel")}
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
disabled={mutation.isPending}
|
||||||
|
form={RECURRING_PROFORMA_CREATE_FORM_ID}
|
||||||
|
type="submit"
|
||||||
|
>
|
||||||
|
{mutation.isPending
|
||||||
|
? t("recurring_proformas.create.creating")
|
||||||
|
: t("recurring_proformas.create.submit")}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
backLabel={t("recurring_proformas.create.cancel")}
|
||||||
|
onBack={() => navigate("/recurring-proformas")}
|
||||||
|
title={t("recurring_proformas.create.title")}
|
||||||
|
/>
|
||||||
<div className="flex-1 overflow-y-auto bg-muted/20">
|
<div className="flex-1 overflow-y-auto bg-muted/20">
|
||||||
<RdxEditorPageLayout complexity="simple">
|
<RdxEditorPageLayout complexity="simple">
|
||||||
<form className="space-y-6" noValidate onSubmit={onSubmit}>
|
<form
|
||||||
<div>
|
className="space-y-6"
|
||||||
<h1 className="text-2xl font-semibold">{t("recurring_proformas.create.title")}</h1>
|
id={RECURRING_PROFORMA_CREATE_FORM_ID}
|
||||||
<p className="text-sm text-muted-foreground">{t("recurring_proformas.create.description")}</p>
|
noValidate
|
||||||
</div>
|
onSubmit={onSubmit}
|
||||||
{submitError ? <p className="rounded-md border border-destructive/30 p-3 text-sm text-destructive" role="alert">{submitError}</p> : null}
|
>
|
||||||
|
<p className="text-sm text-muted-foreground">
|
||||||
|
{t("recurring_proformas.create.description")}
|
||||||
|
</p>
|
||||||
|
{submitError ? (
|
||||||
|
<p
|
||||||
|
className="rounded-md border border-destructive/30 p-3 text-sm text-destructive"
|
||||||
|
role="alert"
|
||||||
|
>
|
||||||
|
{submitError}
|
||||||
|
</p>
|
||||||
|
) : null}
|
||||||
<Card>
|
<Card>
|
||||||
<CardHeader><CardTitle>{t("recurring_proformas.create.form_title")}</CardTitle></CardHeader>
|
<CardHeader>
|
||||||
|
<CardTitle>{t("recurring_proformas.create.form_title")}</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
<CardContent className="space-y-5">
|
<CardContent className="space-y-5">
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<Label htmlFor="recurring-proforma-name">{t("recurring_proformas.create.name")}</Label>
|
<Label htmlFor="recurring-proforma-name">
|
||||||
<Input disabled={mutation.isPending} id="recurring-proforma-name" {...form.register("name")} />
|
{t("recurring_proformas.create.name")}
|
||||||
{form.formState.errors.name ? <p className="text-sm text-destructive">{t("recurring_proformas.create.name_required")}</p> : null}
|
</Label>
|
||||||
|
<Input
|
||||||
|
disabled={mutation.isPending}
|
||||||
|
id="recurring-proforma-name"
|
||||||
|
{...form.register("name")}
|
||||||
|
/>
|
||||||
|
{form.formState.errors.name ? (
|
||||||
|
<p className="text-sm text-destructive">
|
||||||
|
{t("recurring_proformas.create.name_required")}
|
||||||
|
</p>
|
||||||
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<Label htmlFor="recurring-proforma-description">{t("recurring_proformas.create.description_label")}</Label>
|
<Label htmlFor="recurring-proforma-description">
|
||||||
<Textarea disabled={mutation.isPending} id="recurring-proforma-description" {...form.register("description")} />
|
{t("recurring_proformas.create.description_label")}
|
||||||
|
</Label>
|
||||||
|
<Textarea
|
||||||
|
disabled={mutation.isPending}
|
||||||
|
id="recurring-proforma-description"
|
||||||
|
{...form.register("description")}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
<div className="flex justify-end gap-3">
|
|
||||||
<Button disabled={mutation.isPending} onClick={() => navigate("/recurring-proformas")} type="button" variant="outline">{t("recurring_proformas.create.cancel")}</Button>
|
|
||||||
<Button disabled={mutation.isPending} type="submit">{mutation.isPending ? t("recurring_proformas.create.creating") : t("recurring_proformas.create.submit")}</Button>
|
|
||||||
</div>
|
|
||||||
</form>
|
</form>
|
||||||
</RdxEditorPageLayout>
|
</RdxEditorPageLayout>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@ -28,6 +28,7 @@ test("/recurring-proformas/create resuelve y renderiza el formulario real", asyn
|
|||||||
mock.module("@erp/core/hooks", {
|
mock.module("@erp/core/hooks", {
|
||||||
namedExports: {
|
namedExports: {
|
||||||
useDataSource: () => ({}),
|
useDataSource: () => ({}),
|
||||||
|
useDebounce: (value: unknown) => value,
|
||||||
useHookForm: ({ initialValues }: { initialValues: Record<string, string> }) =>
|
useHookForm: ({ initialValues }: { initialValues: Record<string, string> }) =>
|
||||||
useForm({ defaultValues: initialValues }),
|
useForm({ defaultValues: initialValues }),
|
||||||
},
|
},
|
||||||
@ -37,6 +38,10 @@ test("/recurring-proformas/create resuelve y renderiza el formulario real", asyn
|
|||||||
ensureModuleTranslations();
|
ensureModuleTranslations();
|
||||||
await i18n.changeLanguage("es");
|
await i18n.changeLanguage("es");
|
||||||
|
|
||||||
|
const recurringProformaCreateRoute = CustomerInvoiceRoutes({}).find(
|
||||||
|
(route) => route.path === "recurring-proformas/create"
|
||||||
|
);
|
||||||
|
|
||||||
const router = createMemoryRouter(CustomerInvoiceRoutes({}), {
|
const router = createMemoryRouter(CustomerInvoiceRoutes({}), {
|
||||||
initialEntries: ["/recurring-proformas/create"],
|
initialEntries: ["/recurring-proformas/create"],
|
||||||
});
|
});
|
||||||
@ -53,7 +58,12 @@ test("/recurring-proformas/create resuelve y renderiza el formulario real", asyn
|
|||||||
const markup = await readMarkup(stream);
|
const markup = await readMarkup(stream);
|
||||||
|
|
||||||
assert.equal(router.state.location.pathname, "/recurring-proformas/create");
|
assert.equal(router.state.location.pathname, "/recurring-proformas/create");
|
||||||
|
assert.deepEqual(recurringProformaCreateRoute?.handle, {
|
||||||
|
layout: "app-fullscreen",
|
||||||
|
protected: true,
|
||||||
|
});
|
||||||
assert.match(markup, /<h1[^>]*>Nueva proforma recurrente<\/h1>/);
|
assert.match(markup, /<h1[^>]*>Nueva proforma recurrente<\/h1>/);
|
||||||
|
assert.match(markup, /<form[^>]*id="recurring-proforma-create-form"/);
|
||||||
assert.match(markup, /<input[^>]*id="recurring-proforma-name"/);
|
assert.match(markup, /<input[^>]*id="recurring-proforma-name"/);
|
||||||
|
|
||||||
mock.restoreAll();
|
mock.restoreAll();
|
||||||
|
|||||||
@ -32,13 +32,16 @@ const EMPTY_RECURRING_PROFORMAS_LIST: RecurringProformaList = {
|
|||||||
const isStatus = (value: string | null): value is RecurringProformaStatus =>
|
const isStatus = (value: string | null): value is RecurringProformaStatus =>
|
||||||
value !== null && STATUSES.some((status) => status === value);
|
value !== null && STATUSES.some((status) => status === value);
|
||||||
|
|
||||||
|
const resolveStatusFilter = (value: string | null): RecurringProformaStatusFilter =>
|
||||||
|
isStatus(value) ? value : "all";
|
||||||
|
|
||||||
export const useListRecurringProformasPageController = () => {
|
export const useListRecurringProformasPageController = () => {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const [searchParams, setSearchParams] = useSearchParams();
|
const [searchParams, setSearchParams] = useSearchParams();
|
||||||
const [search, setSearch] = useState(searchParams.get("search") ?? "");
|
const [search, setSearch] = useState(searchParams.get("search") ?? "");
|
||||||
const [statusFilter, setStatusFilter] = useState<RecurringProformaStatusFilter>(
|
const [statusFilter, setStatusFilter] = useState<RecurringProformaStatusFilter>(
|
||||||
isStatus(searchParams.get("status")) ? searchParams.get("status")! : "all"
|
resolveStatusFilter(searchParams.get("status"))
|
||||||
);
|
);
|
||||||
|
|
||||||
const page = Math.max(1, Number(searchParams.get("page") ?? "1") || 1);
|
const page = Math.max(1, Number(searchParams.get("page") ?? "1") || 1);
|
||||||
@ -47,7 +50,7 @@ export const useListRecurringProformasPageController = () => {
|
|||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setSearch(searchParams.get("search") ?? "");
|
setSearch(searchParams.get("search") ?? "");
|
||||||
setStatusFilter(isStatus(searchParams.get("status")) ? searchParams.get("status")! : "all");
|
setStatusFilter(resolveStatusFilter(searchParams.get("status")));
|
||||||
}, [searchParams]);
|
}, [searchParams]);
|
||||||
|
|
||||||
const filters = useMemo<RecurringProformaListFilters>(
|
const filters = useMemo<RecurringProformaListFilters>(
|
||||||
@ -55,7 +58,7 @@ export const useListRecurringProformasPageController = () => {
|
|||||||
page,
|
page,
|
||||||
limit,
|
limit,
|
||||||
...(debouncedSearch ? { search: debouncedSearch } : {}),
|
...(debouncedSearch ? { search: debouncedSearch } : {}),
|
||||||
...(statusFilter !== "all" ? { status: statusFilter } : {}),
|
...(statusFilter === "all" ? {} : { status: statusFilter }),
|
||||||
}),
|
}),
|
||||||
[debouncedSearch, limit, page, statusFilter]
|
[debouncedSearch, limit, page, statusFilter]
|
||||||
);
|
);
|
||||||
|
|||||||
@ -2,243 +2,224 @@
|
|||||||
|
|
||||||
import assert from "node:assert/strict";
|
import assert from "node:assert/strict";
|
||||||
import { mock, test } from "node:test";
|
import { mock, test } from "node:test";
|
||||||
import { isValidElement } from "react";
|
|
||||||
|
import { i18n } from "@repo/i18next";
|
||||||
|
import type { ReactNode } from "react";
|
||||||
import { renderToStaticMarkup } from "react-dom/server";
|
import { renderToStaticMarkup } from "react-dom/server";
|
||||||
|
import { MemoryRouter } from "react-router-dom";
|
||||||
|
|
||||||
const resolveElement = (node: unknown): unknown => {
|
import { ensureModuleTranslations } from "../../i18n";
|
||||||
if (Array.isArray(node)) {
|
|
||||||
return node.map(resolveElement);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!isValidElement(node)) {
|
interface ControllerState {
|
||||||
return node;
|
listCtrl: {
|
||||||
}
|
data: {
|
||||||
|
page: number;
|
||||||
if (typeof node.type === "function") {
|
per_page: number;
|
||||||
return resolveElement(node.type(node.props));
|
total_items: number;
|
||||||
}
|
total_pages: number;
|
||||||
|
items: Array<Record<string, unknown>>;
|
||||||
return {
|
};
|
||||||
type: node.type,
|
isLoading: boolean;
|
||||||
props: {
|
isFetching: boolean;
|
||||||
...node.props,
|
pageIndex: number;
|
||||||
children: resolveElement(node.props.children),
|
pageSize: number;
|
||||||
},
|
search: string;
|
||||||
|
setSearchValue: (value: string) => void;
|
||||||
|
setPageIndex: (value: number) => void;
|
||||||
|
setPageSize: (value: number) => void;
|
||||||
|
statusFilter: string;
|
||||||
|
setStatusFilter: (value: string) => void;
|
||||||
};
|
};
|
||||||
|
pageError: { title: string; description: string } | null;
|
||||||
|
refreshError: { title: string; message: string } | null;
|
||||||
|
hasBlockingError: boolean;
|
||||||
|
retryList: () => Promise<void>;
|
||||||
|
isRetrying: boolean;
|
||||||
|
openCreatePage: () => void;
|
||||||
|
openDetailPage: (id: string) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface DataTableProps {
|
||||||
|
onPageChange: (pageIndex: number) => void;
|
||||||
|
onPageSizeChange: (pageSize: number) => void;
|
||||||
|
onRowClick: (row: { id: string }) => void;
|
||||||
|
pageSize: number;
|
||||||
|
topContent: ReactNode;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface SearchProps {
|
||||||
|
onSearchChange: (value: string) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ToggleGroupProps {
|
||||||
|
onValueChange: (value: string) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
let events = {
|
||||||
|
create: false,
|
||||||
|
detailId: "",
|
||||||
|
search: "",
|
||||||
|
status: "",
|
||||||
|
pageIndex: -1,
|
||||||
|
pageSize: -1,
|
||||||
};
|
};
|
||||||
|
let capturedDataTableProps: DataTableProps | undefined;
|
||||||
|
let capturedSearchProps: SearchProps | undefined;
|
||||||
|
let capturedToggleGroupProps: ToggleGroupProps | undefined;
|
||||||
|
let capturedCreateAction: (() => void) | undefined;
|
||||||
|
|
||||||
const findFirstElement = (
|
const createControllerState = (): ControllerState => ({
|
||||||
node: unknown,
|
listCtrl: {
|
||||||
predicate: (element: { type: string; props: Record<string, unknown> }) => boolean
|
data: {
|
||||||
): { type: string; props: Record<string, unknown> } | null => {
|
page: 1,
|
||||||
if (Array.isArray(node)) {
|
per_page: 20,
|
||||||
for (const child of node) {
|
total_items: 0,
|
||||||
const found = findFirstElement(child, predicate);
|
total_pages: 0,
|
||||||
|
items: [],
|
||||||
if (found) {
|
|
||||||
return found;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!node || typeof node !== "object" || !("type" in node) || !("props" in node)) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
const element = node as { type: string; props: Record<string, unknown> };
|
|
||||||
|
|
||||||
if (predicate(element)) {
|
|
||||||
return element;
|
|
||||||
}
|
|
||||||
|
|
||||||
return findFirstElement(element.props.children, predicate);
|
|
||||||
};
|
|
||||||
|
|
||||||
test("mantiene el layout canonico con header, filtros, tabla y empty state", async () => {
|
|
||||||
mock.module("./controllers/use-list-recurring-proformas-page-controller", {
|
|
||||||
namedExports: {
|
|
||||||
useListRecurringProformasPageController: () => ({
|
|
||||||
listCtrl: {
|
|
||||||
data: {
|
|
||||||
page: 1,
|
|
||||||
per_page: 20,
|
|
||||||
total_items: 0,
|
|
||||||
total_pages: 0,
|
|
||||||
items: [],
|
|
||||||
},
|
|
||||||
isLoading: false,
|
|
||||||
isFetching: false,
|
|
||||||
pageIndex: 0,
|
|
||||||
search: "",
|
|
||||||
setSearchValue: () => undefined,
|
|
||||||
setPageIndex: () => undefined,
|
|
||||||
setPageSize: () => undefined,
|
|
||||||
statusFilter: "all",
|
|
||||||
setStatusFilter: () => undefined,
|
|
||||||
},
|
|
||||||
pageError: null,
|
|
||||||
refreshError: {
|
|
||||||
title: "No se pudo cargar",
|
|
||||||
message: "Se mantienen los datos actuales",
|
|
||||||
},
|
|
||||||
hasBlockingError: false,
|
|
||||||
retryList: async () => undefined,
|
|
||||||
isRetrying: false,
|
|
||||||
openCreatePage: () => undefined,
|
|
||||||
openDetailPage: () => undefined,
|
|
||||||
}),
|
|
||||||
},
|
},
|
||||||
});
|
isLoading: false,
|
||||||
|
isFetching: false,
|
||||||
mock.module("@repo/rdx-ui/components", {
|
pageIndex: 0,
|
||||||
namedExports: {
|
pageSize: 20,
|
||||||
RdxPageContainer: ({ children }: { children: unknown }) => <div>{children}</div>,
|
search: "",
|
||||||
RdxPageStack: ({ children }: { children: unknown }) => <div>{children}</div>,
|
setSearchValue: (value) => {
|
||||||
RdxPageHeader: ({
|
events.search = value;
|
||||||
title,
|
|
||||||
description,
|
|
||||||
actions,
|
|
||||||
}: {
|
|
||||||
title: unknown;
|
|
||||||
description?: unknown;
|
|
||||||
actions?: unknown;
|
|
||||||
}) => (
|
|
||||||
<header>
|
|
||||||
<h1>{title}</h1>
|
|
||||||
<p>{description}</p>
|
|
||||||
{actions}
|
|
||||||
</header>
|
|
||||||
),
|
|
||||||
RdxPageActions: ({ primary }: { primary: unknown }) => <div>{primary}</div>,
|
|
||||||
DataTableColumnHeader: ({ title }: { title: string }) => <span>{title}</span>,
|
|
||||||
DataTable: ({ topContent }: { topContent?: unknown }) => (
|
|
||||||
<section>
|
|
||||||
{topContent}
|
|
||||||
<div>Tabla</div>
|
|
||||||
</section>
|
|
||||||
),
|
|
||||||
},
|
},
|
||||||
});
|
setPageIndex: (value) => {
|
||||||
|
events.pageIndex = value;
|
||||||
mock.module("@erp/core/components", {
|
|
||||||
namedExports: {
|
|
||||||
ErrorAlert: ({ title, message }: { title: string; message: string }) => (
|
|
||||||
<div>
|
|
||||||
<span>{title}</span>
|
|
||||||
<span>{message}</span>
|
|
||||||
</div>
|
|
||||||
),
|
|
||||||
SimpleSearchInput: () => <div>Buscador</div>,
|
|
||||||
PageErrorState: () => null,
|
|
||||||
},
|
},
|
||||||
});
|
setPageSize: (value) => {
|
||||||
|
events.pageSize = value;
|
||||||
mock.module("@repo/shadcn-ui/components", {
|
|
||||||
namedExports: {
|
|
||||||
Button: ({ children, onClick }: { children: unknown; onClick?: () => void }) => (
|
|
||||||
<button onClick={onClick} type="button">
|
|
||||||
{children}
|
|
||||||
</button>
|
|
||||||
),
|
|
||||||
Badge: ({ children }: { children: unknown }) => <span>{children}</span>,
|
|
||||||
ToggleGroup: ({ children }: { children: unknown }) => <div>{children}</div>,
|
|
||||||
ToggleGroupItem: ({ children }: { children: unknown }) => <button type="button">{children}</button>,
|
|
||||||
},
|
},
|
||||||
});
|
statusFilter: "all",
|
||||||
|
setStatusFilter: (value) => {
|
||||||
|
events.status = value;
|
||||||
|
},
|
||||||
|
},
|
||||||
|
pageError: null,
|
||||||
|
refreshError: null,
|
||||||
|
hasBlockingError: false,
|
||||||
|
retryList: async () => undefined,
|
||||||
|
isRetrying: false,
|
||||||
|
openCreatePage: () => {
|
||||||
|
events.create = true;
|
||||||
|
},
|
||||||
|
openDetailPage: (id) => {
|
||||||
|
events.detailId = id;
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
let controllerState = createControllerState();
|
||||||
|
|
||||||
|
mock.module("./controllers/use-list-recurring-proformas-page-controller", {
|
||||||
|
namedExports: {
|
||||||
|
useListRecurringProformasPageController: () => controllerState,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
mock.module("@repo/rdx-ui/components", {
|
||||||
|
namedExports: {
|
||||||
|
RdxPageContainer: ({ children }: { children: ReactNode }) => <div>{children}</div>,
|
||||||
|
RdxPageStack: ({ children }: { children: ReactNode }) => <div>{children}</div>,
|
||||||
|
RdxPageHeader: ({
|
||||||
|
title,
|
||||||
|
description,
|
||||||
|
actions,
|
||||||
|
}: {
|
||||||
|
title: ReactNode;
|
||||||
|
description?: ReactNode;
|
||||||
|
actions?: ReactNode;
|
||||||
|
}) => (
|
||||||
|
<header>
|
||||||
|
<h1>{title}</h1>
|
||||||
|
<p>{description}</p>
|
||||||
|
{actions}
|
||||||
|
</header>
|
||||||
|
),
|
||||||
|
RdxPageActions: ({ primary }: { primary: ReactNode }) => <div>{primary}</div>,
|
||||||
|
DataTableColumnHeader: ({ title }: { title: string }) => <span>{title}</span>,
|
||||||
|
DataTable: (props: DataTableProps) => {
|
||||||
|
capturedDataTableProps = props;
|
||||||
|
return <section>{props.topContent}</section>;
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
mock.module("@erp/core/components", {
|
||||||
|
namedExports: {
|
||||||
|
ErrorAlert: ({ title, message }: { title: string; message: string }) => (
|
||||||
|
<div>{`${title}: ${message}`}</div>
|
||||||
|
),
|
||||||
|
PageErrorState: ({ title, description }: { title: string; description: string }) => (
|
||||||
|
<div>{`${title}: ${description}`}</div>
|
||||||
|
),
|
||||||
|
SimpleSearchInput: (props: SearchProps) => {
|
||||||
|
capturedSearchProps = props;
|
||||||
|
return <div>Buscador</div>;
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
mock.module("@repo/shadcn-ui/components", {
|
||||||
|
namedExports: {
|
||||||
|
Button: ({ children, onClick }: { children: ReactNode; onClick?: () => void }) => {
|
||||||
|
capturedCreateAction = onClick;
|
||||||
|
return <button type="button">{children}</button>;
|
||||||
|
},
|
||||||
|
Badge: ({ children }: { children: ReactNode }) => <span>{children}</span>,
|
||||||
|
ToggleGroup: ({ children, onValueChange }: ToggleGroupProps & { children: ReactNode }) => {
|
||||||
|
capturedToggleGroupProps = { onValueChange };
|
||||||
|
return <div>{children}</div>;
|
||||||
|
},
|
||||||
|
ToggleGroupItem: ({ children }: { children: ReactNode }) => (
|
||||||
|
<button type="button">{children}</button>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const renderPage = async () => {
|
||||||
const { ListRecurringProformasPage } = await import("./list-recurring-proformas-page");
|
const { ListRecurringProformasPage } = await import("./list-recurring-proformas-page");
|
||||||
const html = renderToStaticMarkup(<ListRecurringProformasPage />);
|
|
||||||
|
|
||||||
assert.match(html, /Nueva proforma recurrente/);
|
ensureModuleTranslations();
|
||||||
|
await i18n.changeLanguage("es");
|
||||||
|
|
||||||
|
return renderToStaticMarkup(
|
||||||
|
<MemoryRouter>
|
||||||
|
<ListRecurringProformasPage />
|
||||||
|
</MemoryRouter>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
test("mantiene el layout canónico con filtros, tabla, refresh error y empty state", async () => {
|
||||||
|
controllerState = createControllerState();
|
||||||
|
controllerState.refreshError = {
|
||||||
|
title: "No se pudo cargar",
|
||||||
|
message: "Se mantienen los datos actuales",
|
||||||
|
};
|
||||||
|
|
||||||
|
const html = await renderPage();
|
||||||
|
|
||||||
assert.match(html, /Buscador/);
|
assert.match(html, /Buscador/);
|
||||||
assert.match(html, /Tabla/);
|
assert.match(html, /No se pudo cargar: Se mantienen los datos actuales/);
|
||||||
assert.match(html, /No se pudo cargar/);
|
|
||||||
assert.match(html, /Se mantienen los datos actuales/);
|
|
||||||
assert.match(html, /No hay proformas recurrentes/);
|
assert.match(html, /No hay proformas recurrentes/);
|
||||||
|
|
||||||
mock.restoreAll();
|
|
||||||
});
|
});
|
||||||
|
|
||||||
test("usa PageErrorState para el error inicial bloqueante", async () => {
|
test("usa PageErrorState para un error inicial bloqueante", async () => {
|
||||||
mock.module("./controllers/use-list-recurring-proformas-page-controller", {
|
controllerState = createControllerState();
|
||||||
namedExports: {
|
controllerState.hasBlockingError = true;
|
||||||
useListRecurringProformasPageController: () => ({
|
controllerState.pageError = {
|
||||||
listCtrl: {
|
title: "Error de carga",
|
||||||
data: {
|
description: "No se pudo cargar el listado",
|
||||||
page: 1,
|
};
|
||||||
per_page: 20,
|
|
||||||
total_items: 0,
|
|
||||||
total_pages: 0,
|
|
||||||
items: [],
|
|
||||||
},
|
|
||||||
},
|
|
||||||
pageError: {
|
|
||||||
title: "Error de carga",
|
|
||||||
description: "No se pudo cargar el listado",
|
|
||||||
},
|
|
||||||
refreshError: null,
|
|
||||||
hasBlockingError: true,
|
|
||||||
retryList: async () => undefined,
|
|
||||||
isRetrying: true,
|
|
||||||
openCreatePage: () => undefined,
|
|
||||||
openDetailPage: () => undefined,
|
|
||||||
}),
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
mock.module("@repo/rdx-ui/components", {
|
const html = await renderPage();
|
||||||
namedExports: {
|
|
||||||
RdxPageContainer: ({ children }: { children: unknown }) => <div>{children}</div>,
|
|
||||||
DataTableColumnHeader: ({ title }: { title: string }) => <span>{title}</span>,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
mock.module("@erp/core/components", {
|
assert.match(html, /Error de carga: No se pudo cargar el listado/);
|
||||||
namedExports: {
|
assert.doesNotMatch(html, /Buscador/);
|
||||||
PageErrorState: ({
|
|
||||||
title,
|
|
||||||
description,
|
|
||||||
primaryAction,
|
|
||||||
}: {
|
|
||||||
title: string;
|
|
||||||
description: string;
|
|
||||||
primaryAction: { label: string; isLoading?: boolean };
|
|
||||||
}) => (
|
|
||||||
<div>
|
|
||||||
<h1>{title}</h1>
|
|
||||||
<p>{description}</p>
|
|
||||||
<span>{primaryAction.label}</span>
|
|
||||||
<span>{String(primaryAction.isLoading)}</span>
|
|
||||||
</div>
|
|
||||||
),
|
|
||||||
SimpleSearchInput: () => null,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
mock.module("@repo/shadcn-ui/components", {
|
|
||||||
namedExports: {
|
|
||||||
Button: () => null,
|
|
||||||
Badge: () => null,
|
|
||||||
ToggleGroup: () => null,
|
|
||||||
ToggleGroupItem: () => null,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
const { ListRecurringProformasPage } = await import("./list-recurring-proformas-page");
|
|
||||||
const html = renderToStaticMarkup(<ListRecurringProformasPage />);
|
|
||||||
|
|
||||||
assert.match(html, /Error de carga/);
|
|
||||||
assert.match(html, /No se pudo cargar el listado/);
|
|
||||||
assert.match(html, /Reintentar/);
|
|
||||||
assert.match(html, /true/);
|
|
||||||
|
|
||||||
mock.restoreAll();
|
|
||||||
});
|
});
|
||||||
|
|
||||||
test("conecta CTA, navegación a detalle y handlers de búsqueda, filtro y paginación", async () => {
|
test("conecta CTA, detalle, búsqueda, filtro y paginación con el controller", async () => {
|
||||||
const events = {
|
events = {
|
||||||
create: false,
|
create: false,
|
||||||
detailId: "",
|
detailId: "",
|
||||||
search: "",
|
search: "",
|
||||||
@ -246,149 +227,47 @@ test("conecta CTA, navegación a detalle y handlers de búsqueda, filtro y pagin
|
|||||||
pageIndex: -1,
|
pageIndex: -1,
|
||||||
pageSize: -1,
|
pageSize: -1,
|
||||||
};
|
};
|
||||||
|
controllerState = createControllerState();
|
||||||
mock.module("./controllers/use-list-recurring-proformas-page-controller", {
|
controllerState.listCtrl.data = {
|
||||||
namedExports: {
|
page: 1,
|
||||||
useListRecurringProformasPageController: () => ({
|
per_page: 20,
|
||||||
listCtrl: {
|
total_items: 1,
|
||||||
data: {
|
total_pages: 1,
|
||||||
page: 1,
|
items: [
|
||||||
per_page: 20,
|
{
|
||||||
total_items: 1,
|
id: "rec-1",
|
||||||
total_pages: 1,
|
name: "Cuota mensual",
|
||||||
items: [
|
customer_id: null,
|
||||||
{
|
status: "active",
|
||||||
id: "rec-1",
|
schedule: {
|
||||||
name: "Cuota mensual",
|
start_date: "2026-09-01",
|
||||||
customer_id: null,
|
end_date: null,
|
||||||
status: "active",
|
unit: "month",
|
||||||
schedule: {
|
interval: 1,
|
||||||
start_date: "2026-09-01",
|
monthly_rule: "same_day",
|
||||||
end_date: null,
|
|
||||||
unit: "month",
|
|
||||||
interval: 1,
|
|
||||||
monthly_rule: "same_day",
|
|
||||||
},
|
|
||||||
next_occurrence_date: null,
|
|
||||||
updated_at: "2026-09-01",
|
|
||||||
},
|
|
||||||
],
|
|
||||||
},
|
|
||||||
isLoading: false,
|
|
||||||
isFetching: false,
|
|
||||||
pageIndex: 0,
|
|
||||||
search: "cuota",
|
|
||||||
setSearchValue: (value: string) => {
|
|
||||||
events.search = value;
|
|
||||||
},
|
|
||||||
setPageIndex: (value: number) => {
|
|
||||||
events.pageIndex = value;
|
|
||||||
},
|
|
||||||
setPageSize: (value: number) => {
|
|
||||||
events.pageSize = value;
|
|
||||||
},
|
|
||||||
statusFilter: "active",
|
|
||||||
setStatusFilter: (value: string) => {
|
|
||||||
events.status = value;
|
|
||||||
},
|
|
||||||
},
|
},
|
||||||
pageError: null,
|
next_occurrence_date: null,
|
||||||
refreshError: null,
|
updated_at: "2026-09-01",
|
||||||
hasBlockingError: false,
|
},
|
||||||
retryList: async () => undefined,
|
],
|
||||||
isRetrying: false,
|
};
|
||||||
openCreatePage: () => {
|
|
||||||
events.create = true;
|
await renderPage();
|
||||||
},
|
|
||||||
openDetailPage: (id: string) => {
|
capturedCreateAction?.();
|
||||||
events.detailId = id;
|
capturedDataTableProps?.onRowClick({ id: "rec-1" });
|
||||||
},
|
capturedSearchProps?.onSearchChange("cuota");
|
||||||
}),
|
capturedToggleGroupProps?.onValueChange("active");
|
||||||
},
|
capturedDataTableProps?.onPageChange(2);
|
||||||
|
capturedDataTableProps?.onPageSizeChange(50);
|
||||||
|
|
||||||
|
assert.deepEqual(events, {
|
||||||
|
create: true,
|
||||||
|
detailId: "rec-1",
|
||||||
|
search: "cuota",
|
||||||
|
status: "active",
|
||||||
|
pageIndex: 2,
|
||||||
|
pageSize: 50,
|
||||||
});
|
});
|
||||||
|
assert.equal(capturedDataTableProps?.pageSize, 20);
|
||||||
mock.module("@repo/rdx-ui/components", {
|
|
||||||
namedExports: {
|
|
||||||
RdxPageContainer: ({ children }: { children: unknown }) => <div>{children}</div>,
|
|
||||||
RdxPageStack: ({ children }: { children: unknown }) => <div>{children}</div>,
|
|
||||||
RdxPageHeader: ({
|
|
||||||
title,
|
|
||||||
description,
|
|
||||||
actions,
|
|
||||||
}: {
|
|
||||||
title: unknown;
|
|
||||||
description?: unknown;
|
|
||||||
actions?: unknown;
|
|
||||||
}) => (
|
|
||||||
<header>
|
|
||||||
<h1>{title}</h1>
|
|
||||||
<p>{description}</p>
|
|
||||||
{actions}
|
|
||||||
</header>
|
|
||||||
),
|
|
||||||
RdxPageActions: ({ primary }: { primary: unknown }) => <div>{primary}</div>,
|
|
||||||
DataTableColumnHeader: ({ title }: { title: string }) => <span>{title}</span>,
|
|
||||||
DataTable: (props: Record<string, unknown>) => <section>{props.topContent as unknown}</section>,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
mock.module("@erp/core/components", {
|
|
||||||
namedExports: {
|
|
||||||
ErrorAlert: () => null,
|
|
||||||
PageErrorState: () => null,
|
|
||||||
SimpleSearchInput: (props: Record<string, unknown>) => <search-input {...props} />,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
mock.module("@repo/shadcn-ui/components", {
|
|
||||||
namedExports: {
|
|
||||||
Button: ({ children, onClick }: { children: unknown; onClick?: () => void }) => (
|
|
||||||
<button onClick={onClick} type="button">
|
|
||||||
{children}
|
|
||||||
</button>
|
|
||||||
),
|
|
||||||
Badge: ({ children }: { children: unknown }) => <span>{children}</span>,
|
|
||||||
ToggleGroup: (props: Record<string, unknown>) => <toggle-group {...props} />,
|
|
||||||
ToggleGroupItem: ({ children, value }: { children: unknown; value: string }) => (
|
|
||||||
<toggle-item data-value={value}>{children}</toggle-item>
|
|
||||||
),
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
const { ListRecurringProformasPage } = await import("./list-recurring-proformas-page");
|
|
||||||
const resolved = resolveElement(<ListRecurringProformasPage />);
|
|
||||||
|
|
||||||
const createButton = findFirstElement(
|
|
||||||
resolved,
|
|
||||||
(element) => element.type === "button" && element.props.children === "Nueva proforma recurrente"
|
|
||||||
);
|
|
||||||
const searchInput = findFirstElement(resolved, (element) => element.type === "search-input");
|
|
||||||
const toggleGroup = findFirstElement(resolved, (element) => element.type === "toggle-group");
|
|
||||||
const link = findFirstElement(
|
|
||||||
resolved,
|
|
||||||
(element) => element.type === "a" && element.props.href === "/recurring-proformas/rec-1"
|
|
||||||
);
|
|
||||||
const dataTable = findFirstElement(resolved, (element) => element.type === "section");
|
|
||||||
|
|
||||||
assert.ok(createButton);
|
|
||||||
assert.ok(searchInput);
|
|
||||||
assert.ok(toggleGroup);
|
|
||||||
assert.ok(link);
|
|
||||||
assert.ok(dataTable);
|
|
||||||
|
|
||||||
createButton.props.onClick?.();
|
|
||||||
searchInput.props.onSearchChange?.(" cuota mensual ");
|
|
||||||
toggleGroup.props.onValueChange?.("paused");
|
|
||||||
dataTable.props.onRowClick?.("rec-1");
|
|
||||||
dataTable.props.onPageChange?.(2);
|
|
||||||
dataTable.props.onPageSizeChange?.(50);
|
|
||||||
|
|
||||||
assert.equal(events.create, true);
|
|
||||||
assert.equal(events.search, " cuota mensual ");
|
|
||||||
assert.equal(events.status, "paused");
|
|
||||||
assert.equal(events.detailId, "rec-1");
|
|
||||||
assert.equal(events.pageIndex, 2);
|
|
||||||
assert.equal(events.pageSize, 50);
|
|
||||||
|
|
||||||
mock.restoreAll();
|
|
||||||
});
|
});
|
||||||
|
|||||||
@ -1,19 +1,12 @@
|
|||||||
import { PageErrorState, SimpleSearchInput } from "@erp/core/components";
|
import { PageErrorState, SimpleSearchInput } from "@erp/core/components";
|
||||||
import {
|
import { DataTable, DataTableColumnHeader, RdxPageContainer } from "@repo/rdx-ui/components";
|
||||||
DataTable,
|
import { Badge, Button } from "@repo/shadcn-ui/components";
|
||||||
DataTableColumnHeader,
|
|
||||||
RdxPageContainer,
|
|
||||||
} from "@repo/rdx-ui/components";
|
|
||||||
import { Badge, Button, ToggleGroup, ToggleGroupItem } from "@repo/shadcn-ui/components";
|
|
||||||
import type { ColumnDef } from "@tanstack/react-table";
|
import type { ColumnDef } from "@tanstack/react-table";
|
||||||
import { useMemo, useState } from "react";
|
import { useMemo, useState } from "react";
|
||||||
import { Link } from "react-router-dom";
|
import { Link } from "react-router-dom";
|
||||||
|
|
||||||
import { useTranslation } from "../../i18n";
|
import { useTranslation } from "../../i18n";
|
||||||
import type {
|
import type { RecurringProformaListItem } from "../shared/recurring-proforma-list";
|
||||||
RecurringProformaListItem,
|
|
||||||
RecurringProformaStatus,
|
|
||||||
} from "../shared/recurring-proforma-list";
|
|
||||||
import { buildRecurringProformaDetailPath } from "../shared/recurring-proforma-navigation";
|
import { buildRecurringProformaDetailPath } from "../shared/recurring-proforma-navigation";
|
||||||
import { useListRecurringProformasPageController } from "./controllers/use-list-recurring-proformas-page-controller";
|
import { useListRecurringProformasPageController } from "./controllers/use-list-recurring-proformas-page-controller";
|
||||||
|
|
||||||
@ -21,15 +14,22 @@ import {
|
|||||||
formatRecurringProformaSchedule,
|
formatRecurringProformaSchedule,
|
||||||
getRecurringProformaStatusKey,
|
getRecurringProformaStatusKey,
|
||||||
} from "./recurring-proforma-list-utils";
|
} from "./recurring-proforma-list-utils";
|
||||||
|
import { RecurringProformaStatusSegmentedFilter } from "./ui/components/recurring-proforma-status-segmented-filter";
|
||||||
import { ScopedListRecurringProformasPageView } from "./ui/pages/scoped-list-recurring-proformas-page-view";
|
import { ScopedListRecurringProformasPageView } from "./ui/pages/scoped-list-recurring-proformas-page-view";
|
||||||
|
|
||||||
const STATUSES = ["draft", "active", "paused", "completed", "archived"] as const;
|
|
||||||
|
|
||||||
export const ListRecurringProformasPage = () => {
|
export const ListRecurringProformasPage = () => {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const [isSearchPending, setIsSearchPending] = useState(false);
|
const [isSearchPending, setIsSearchPending] = useState(false);
|
||||||
const { listCtrl, pageError, hasBlockingError, retryList, isRetrying, refreshError, openCreatePage, openDetailPage } =
|
const {
|
||||||
useListRecurringProformasPageController();
|
listCtrl,
|
||||||
|
pageError,
|
||||||
|
hasBlockingError,
|
||||||
|
retryList,
|
||||||
|
isRetrying,
|
||||||
|
refreshError,
|
||||||
|
openCreatePage,
|
||||||
|
openDetailPage,
|
||||||
|
} = useListRecurringProformasPageController();
|
||||||
|
|
||||||
const columns = useMemo<ColumnDef<RecurringProformaListItem>[]>(
|
const columns = useMemo<ColumnDef<RecurringProformaListItem>[]>(
|
||||||
() => [
|
() => [
|
||||||
@ -100,9 +100,7 @@ export const ListRecurringProformasPage = () => {
|
|||||||
<PageErrorState
|
<PageErrorState
|
||||||
description={pageError.description}
|
description={pageError.description}
|
||||||
primaryAction={{
|
primaryAction={{
|
||||||
label: isRetrying
|
label: t("recurring_proformas.list.error.retry"),
|
||||||
? t("recurring_proformas.list.error.retry")
|
|
||||||
: t("recurring_proformas.list.error.retry"),
|
|
||||||
onClick: retryList,
|
onClick: retryList,
|
||||||
isLoading: isRetrying,
|
isLoading: isRetrying,
|
||||||
}}
|
}}
|
||||||
@ -127,9 +125,9 @@ export const ListRecurringProformasPage = () => {
|
|||||||
manualPagination
|
manualPagination
|
||||||
onPageChange={listCtrl.setPageIndex}
|
onPageChange={listCtrl.setPageIndex}
|
||||||
onPageSizeChange={listCtrl.setPageSize}
|
onPageSizeChange={listCtrl.setPageSize}
|
||||||
onRowClick={openDetailPage}
|
onRowClick={(row) => openDetailPage(row.id)}
|
||||||
pageIndex={listCtrl.pageIndex}
|
pageIndex={listCtrl.pageIndex}
|
||||||
pageSize={listCtrl.data.per_page}
|
pageSize={listCtrl.pageSize}
|
||||||
searching={isSearchPending}
|
searching={isSearchPending}
|
||||||
topContent={
|
topContent={
|
||||||
<div className="space-y-5">
|
<div className="space-y-5">
|
||||||
@ -145,21 +143,11 @@ export const ListRecurringProformasPage = () => {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<ToggleGroup
|
<RecurringProformaStatusSegmentedFilter
|
||||||
aria-label={t("recurring_proformas.list.status_filter")}
|
disabled={listCtrl.isLoading}
|
||||||
className="flex-wrap justify-start"
|
|
||||||
onValueChange={listCtrl.setStatusFilter}
|
onValueChange={listCtrl.setStatusFilter}
|
||||||
type="single"
|
|
||||||
value={listCtrl.statusFilter}
|
value={listCtrl.statusFilter}
|
||||||
variant="outline"
|
/>
|
||||||
>
|
|
||||||
<ToggleGroupItem value="all">{t("recurring_proformas.status.all")}</ToggleGroupItem>
|
|
||||||
{STATUSES.map((value) => (
|
|
||||||
<ToggleGroupItem key={value} value={value}>
|
|
||||||
{t(`recurring_proformas.status.${value}`)}
|
|
||||||
</ToggleGroupItem>
|
|
||||||
))}
|
|
||||||
</ToggleGroup>
|
|
||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
totalItems={listCtrl.data.total_items}
|
totalItems={listCtrl.data.total_items}
|
||||||
|
|||||||
@ -29,9 +29,25 @@ test("el listado renderiza el CTA productivo de creacion", async () => {
|
|||||||
queryClient.setQueryData(recurringProformaQueryKeys.list(filters), {
|
queryClient.setQueryData(recurringProformaQueryKeys.list(filters), {
|
||||||
page: 1,
|
page: 1,
|
||||||
per_page: 20,
|
per_page: 20,
|
||||||
total_items: 0,
|
total_items: 1,
|
||||||
total_pages: 0,
|
total_pages: 1,
|
||||||
items: [],
|
items: [
|
||||||
|
{
|
||||||
|
id: "rec-1",
|
||||||
|
name: "Cuota mensual",
|
||||||
|
customer_id: null,
|
||||||
|
status: "active",
|
||||||
|
schedule: {
|
||||||
|
start_date: "2026-09-01",
|
||||||
|
end_date: null,
|
||||||
|
unit: "month",
|
||||||
|
interval: 1,
|
||||||
|
monthly_rule: "same_day",
|
||||||
|
},
|
||||||
|
next_occurrence_date: null,
|
||||||
|
updated_at: "2026-09-01",
|
||||||
|
},
|
||||||
|
],
|
||||||
});
|
});
|
||||||
|
|
||||||
const markup = renderToStaticMarkup(
|
const markup = renderToStaticMarkup(
|
||||||
@ -45,10 +61,7 @@ test("el listado renderiza el CTA productivo de creacion", async () => {
|
|||||||
);
|
);
|
||||||
|
|
||||||
assert.match(markup, /<button[^>]*>Nueva proforma recurrente<\/button>/);
|
assert.match(markup, /<button[^>]*>Nueva proforma recurrente<\/button>/);
|
||||||
assert.match(
|
assert.match(markup, /<a[^>]*href="\/recurring-proformas\/[^"]+"[^>]*>Cuota mensual<\/a>/);
|
||||||
markup,
|
|
||||||
/<a[^>]*href="\/recurring-proformas\/[^"]+"[^>]*>Cuota mensual<\/a>/
|
|
||||||
);
|
|
||||||
assert.match(markup, /2026-09-01/);
|
assert.match(markup, /2026-09-01/);
|
||||||
|
|
||||||
mock.restoreAll();
|
mock.restoreAll();
|
||||||
|
|||||||
@ -0,0 +1,127 @@
|
|||||||
|
import { ToggleGroup, ToggleGroupItem } from "@repo/shadcn-ui/components";
|
||||||
|
import { cn } from "@repo/shadcn-ui/lib/utils";
|
||||||
|
import {
|
||||||
|
ArchiveIcon,
|
||||||
|
CheckCircle2Icon,
|
||||||
|
CirclePauseIcon,
|
||||||
|
FileTextIcon,
|
||||||
|
PlayCircleIcon,
|
||||||
|
} from "lucide-react";
|
||||||
|
import type { ElementType } from "react";
|
||||||
|
|
||||||
|
import { useTranslation } from "../../../../i18n";
|
||||||
|
import type { RecurringProformaStatus } from "../../../shared/recurring-proforma-list";
|
||||||
|
|
||||||
|
export type RecurringProformaStatusFilter = "all" | RecurringProformaStatus;
|
||||||
|
|
||||||
|
export type RecurringProformaStatusSegmentedFilterProps = {
|
||||||
|
value: RecurringProformaStatusFilter;
|
||||||
|
onValueChange: (value: RecurringProformaStatusFilter) => void;
|
||||||
|
disabled?: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
const RECURRING_PROFORMA_STATUS_FILTER_OPTIONS = [
|
||||||
|
{
|
||||||
|
value: "all",
|
||||||
|
labelKey: "recurring_proformas.status.all",
|
||||||
|
icon: null,
|
||||||
|
activeClassName:
|
||||||
|
"data-[state=on]:border-primary data-[state=on]:bg-primary data-[state=on]:text-primary-foreground",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
value: "draft",
|
||||||
|
labelKey: "recurring_proformas.status.draft",
|
||||||
|
icon: FileTextIcon,
|
||||||
|
activeClassName:
|
||||||
|
"data-[state=on]:border-slate-300 data-[state=on]:bg-slate-100 data-[state=on]:text-slate-700",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
value: "active",
|
||||||
|
labelKey: "recurring_proformas.status.active",
|
||||||
|
icon: PlayCircleIcon,
|
||||||
|
activeClassName:
|
||||||
|
"data-[state=on]:border-emerald-300 data-[state=on]:bg-emerald-50 data-[state=on]:text-emerald-700",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
value: "paused",
|
||||||
|
labelKey: "recurring_proformas.status.paused",
|
||||||
|
icon: CirclePauseIcon,
|
||||||
|
activeClassName:
|
||||||
|
"data-[state=on]:border-amber-300 data-[state=on]:bg-amber-50 data-[state=on]:text-amber-700",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
value: "completed",
|
||||||
|
labelKey: "recurring_proformas.status.completed",
|
||||||
|
icon: CheckCircle2Icon,
|
||||||
|
activeClassName:
|
||||||
|
"data-[state=on]:border-sky-300 data-[state=on]:bg-sky-50 data-[state=on]:text-sky-700",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
value: "archived",
|
||||||
|
labelKey: "recurring_proformas.status.archived",
|
||||||
|
icon: ArchiveIcon,
|
||||||
|
activeClassName:
|
||||||
|
"data-[state=on]:border-muted-foreground data-[state=on]:bg-muted data-[state=on]:text-foreground",
|
||||||
|
},
|
||||||
|
] as const satisfies readonly {
|
||||||
|
value: RecurringProformaStatusFilter;
|
||||||
|
labelKey: string;
|
||||||
|
icon: ElementType | null;
|
||||||
|
activeClassName: string;
|
||||||
|
}[];
|
||||||
|
|
||||||
|
const isRecurringProformaStatusFilter = (value: string): value is RecurringProformaStatusFilter =>
|
||||||
|
RECURRING_PROFORMA_STATUS_FILTER_OPTIONS.some((option) => option.value === value);
|
||||||
|
|
||||||
|
export const RecurringProformaStatusSegmentedFilter = ({
|
||||||
|
value,
|
||||||
|
onValueChange,
|
||||||
|
disabled = false,
|
||||||
|
}: RecurringProformaStatusSegmentedFilterProps) => {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
|
||||||
|
const handleValueChange = (nextValue: string) => {
|
||||||
|
if (!(nextValue && isRecurringProformaStatusFilter(nextValue)) || nextValue === value) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
onValueChange(nextValue);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ToggleGroup
|
||||||
|
aria-label={t("recurring_proformas.list.status_filter")}
|
||||||
|
className="w-full flex-wrap justify-start gap-1"
|
||||||
|
disabled={disabled}
|
||||||
|
onValueChange={handleValueChange}
|
||||||
|
size="sm"
|
||||||
|
spacing={1}
|
||||||
|
type="single"
|
||||||
|
value={value}
|
||||||
|
variant="outline"
|
||||||
|
>
|
||||||
|
{RECURRING_PROFORMA_STATUS_FILTER_OPTIONS.map((option) => (
|
||||||
|
<ToggleGroupItem
|
||||||
|
className={cn(
|
||||||
|
"group h-auto min-w-fit rounded-full border-transparent px-3 py-2 text-xs shadow-none transition-colors sm:text-sm",
|
||||||
|
"text-muted-foreground hover:border-border hover:bg-muted/60 hover:text-foreground",
|
||||||
|
"data-[state=on]:shadow-none",
|
||||||
|
option.activeClassName
|
||||||
|
)}
|
||||||
|
key={option.value}
|
||||||
|
onPointerDown={(event) => {
|
||||||
|
if (option.value === value) {
|
||||||
|
event.preventDefault();
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
value={option.value}
|
||||||
|
>
|
||||||
|
<span className="flex items-center gap-2">
|
||||||
|
{option.icon ? <option.icon className="size-3.5" /> : null}
|
||||||
|
<span>{t(option.labelKey)}</span>
|
||||||
|
</span>
|
||||||
|
</ToggleGroupItem>
|
||||||
|
))}
|
||||||
|
</ToggleGroup>
|
||||||
|
);
|
||||||
|
};
|
||||||
Loading…
Reference in New Issue
Block a user