From a264f43b954165818116f2928c1207e8923cb88b Mon Sep 17 00:00:00 2001 From: david Date: Wed, 29 Jul 2026 22:24:53 +0200 Subject: [PATCH] feat: enhance proforma listing and archiving functionality - Updated proforma listing behavior to utilize `Criteria filters[]` for managing archived and active proformas. - Removed `archived` query parameter from backend; frontend now manages `archiveView` state. - Implemented new filters for `archived_at` and `status` in the proforma listing API. - Adjusted proforma creation and deletion contracts to reflect changes in archiving logic. - Introduced new utility functions for building proforma listing criteria based on UI state. - Updated frontend components to support new filtering options and maintain state in the URL. - Added SQL index for improved performance on proforma queries. - Created new TypeScript types for managing proforma list filters and criteria. --- .../proforma-archive-contract.md | 11 +- .../proforma-create-contract.md | 1 + .../proforma-delete-contract.md | 1 + .../proforma-list-contract.md | 47 +++++++ ...lit-proformas-issued-invoices-migration.md | 2 + .../sql/add-proforma-list-indexes.sql | 5 + modules/core/src/common/dto/critera.dto.ts | 48 ++++--- .../proformas/models/proforma-summary.ts | 3 +- .../use-cases/get-proforma-by-id.use-case.ts | 2 +- .../controllers/list-proformas.controller.ts | 63 +++++++-- .../proformas/express/proformas.routes.ts | 2 +- .../sequelize-proforma-v2-summary.mapper.ts | 9 +- .../repositories/proforma-v2.repository.ts | 37 ++++- .../proformas/list-proformas.request.dto.ts | 4 +- .../src/common/locales/en.json | 10 ++ .../src/common/locales/es.json | 10 ++ .../use-list-proformas.controller.ts | 129 ++++++++++-------- .../list/types/proforma-list-filters.ts | 5 + .../list/ui/pages/list-proformas-page.tsx | 36 +++-- .../utils/build-list-proformas-criteria.ts | 49 +++++++ .../shared/adapters/list-proformas.adapter.ts | 10 +- .../api/list-proformas-by-criteria.api.ts | 2 +- .../shared/entities/proforma-list.entity.ts | 1 - .../src/web/proformas/shared/hooks/keys.ts | 1 - .../shared/hooks/use-proformas-list-query.ts | 4 +- packages/rdx-criteria/src/codelytv/Filter.ts | 6 +- .../src/codelytv/FilterOperator.ts | 18 +++ .../src/criteria-from-url-converter.ts | 7 +- .../src/criteria-to-sequelize-converter.ts | 35 +++++ 29 files changed, 421 insertions(+), 137 deletions(-) create mode 100644 docs/customer-invoices/proforma-list-contract.md create mode 100644 docs/customer-invoices/sql/add-proforma-list-indexes.sql create mode 100644 modules/customer-invoices/src/web/proformas/list/types/proforma-list-filters.ts create mode 100644 modules/customer-invoices/src/web/proformas/list/utils/build-list-proformas-criteria.ts diff --git a/docs/customer-invoices/proforma-archive-contract.md b/docs/customer-invoices/proforma-archive-contract.md index a1f74be8..3cfb168c 100644 --- a/docs/customer-invoices/proforma-archive-contract.md +++ b/docs/customer-invoices/proforma-archive-contract.md @@ -23,12 +23,17 @@ Both endpoints: ## Listing behavior -- `GET /proformas` excludes archived proformas by default -- `GET /proformas?archived=false` lists only active proformas -- `GET /proformas?archived=true` lists only archived proformas +- `GET /proformas` excludes archived proformas by default through `filters[]=archived_at IS_NULL` +- the backend does not expose `archive_view` as a query param +- the frontend keeps `archiveView=active|archived|all` only as UI state and translates it to `Criteria filters[]` +- active view sends `archived_at IS_NULL` +- archived view sends `archived_at IS_NOT_NULL` +- all view sends no `archived_at` filter +- `q` always applies inside the scope produced by `filters[]` ## Notes - `archived` is a visibility dimension, not a business status - V1 does not support archiving `sent`, `approved`, or `issued` - V1 does not add `archived_by` or `archive_reason` +- soft-deleted proformas stay excluded structurally by `deleted_at` diff --git a/docs/customer-invoices/proforma-create-contract.md b/docs/customer-invoices/proforma-create-contract.md index 1e70cd9e..32dbea91 100644 --- a/docs/customer-invoices/proforma-create-contract.md +++ b/docs/customer-invoices/proforma-create-contract.md @@ -156,3 +156,4 @@ Si devuelve filas: - una proforma creada nace con `archived_at = NULL` - el archivado posterior no altera `status` - una proforma `draft` archivada puede borrarse sin desarchivarse antes +- en listados, la visibilidad archivada se expresa mediante `Criteria filters[]` sobre `archived_at`, no mediante un estado de negocio nuevo diff --git a/docs/customer-invoices/proforma-delete-contract.md b/docs/customer-invoices/proforma-delete-contract.md index a21dd4ae..21fad586 100644 --- a/docs/customer-invoices/proforma-delete-contract.md +++ b/docs/customer-invoices/proforma-delete-contract.md @@ -33,6 +33,7 @@ Definir el borrado funcional de proformas sin romper trazabilidad ni numeracion. - `GET /proformas/:id` debe tratar la proforma borrada como inexistente - `GET /proformas` no debe incluir proformas borradas +- `GET /proformas` puede listar activas, archivadas o todas segun `Criteria filters[]`, pero nunca borradas - `PUT /proformas/:id` no debe editar proformas borradas - `POST /proformas/:id/issue` no debe emitir proformas borradas diff --git a/docs/customer-invoices/proforma-list-contract.md b/docs/customer-invoices/proforma-list-contract.md new file mode 100644 index 00000000..6ecfd3e8 --- /dev/null +++ b/docs/customer-invoices/proforma-list-contract.md @@ -0,0 +1,47 @@ +# Proforma List Contract + +## UI model + +- frontend keeps `archiveView=active|archived|all` as semantic UI state +- frontend keeps `status=all|draft|sent|approved|rejected|issued` +- `archiveView` is not a backend query param + +## Backend contract + +`GET /proformas` stays based on `Criteria`: + +- `q` +- `pageNumber` +- `pageSize` +- `orderBy` +- `order` +- `filters[]` + +## Allowed filters + +- `archived_at IS_NULL` +- `archived_at IS_NOT_NULL` +- `status EQUALS draft|sent|approved|rejected|issued` + +## Scope rules + +- active view -> `archived_at IS_NULL` +- archived view -> `archived_at IS_NOT_NULL` +- all view -> no `archived_at` filter +- status `all` -> no `status` filter +- any concrete status -> `status EQUALS ` +- `q` applies after the scope is defined by `filters[]` + +## Resource visibility + +- `GET /proformas` defaults to active scope +- `GET /proformas/:id` returns archived proformas too +- soft-deleted proformas never appear in list results +- soft-deleted proformas are treated as non-existent by `GET /proformas/:id` + +## Frontend persistence + +- URL stores `archiveView` +- URL stores `status` +- URL stores `q` +- `localStorage["proformas:list:grid"]` keeps only table preferences diff --git a/docs/customer-invoices/split-proformas-issued-invoices-migration.md b/docs/customer-invoices/split-proformas-issued-invoices-migration.md index 8ba3bbd1..809c82ef 100644 --- a/docs/customer-invoices/split-proformas-issued-invoices-migration.md +++ b/docs/customer-invoices/split-proformas-issued-invoices-migration.md @@ -170,6 +170,8 @@ Preparar el esquema fisico para separar: - `DELETE /proformas/:proforma_id` vuelve a estar operativo sobre persistencia V2 - el borrado es logico mediante `deleted_at` - el archivado es logico mediante `archived_at` +- el listado V2 de proformas usa `Criteria filters[]` para `archived_at` y `status` +- `GET /proformas/:id` permite abrir proformas archivadas - el borrado de `draft` no depende de `archived_at` - solo `draft` puede borrarse - `rejected` sigue fuera del alcance y queda reservado para archivado futuro diff --git a/docs/customer-invoices/sql/add-proforma-list-indexes.sql b/docs/customer-invoices/sql/add-proforma-list-indexes.sql new file mode 100644 index 00000000..6178bcf0 --- /dev/null +++ b/docs/customer-invoices/sql/add-proforma-list-indexes.sql @@ -0,0 +1,5 @@ +-- Evaluate on the real environment before execution. +-- Adjust the last sort column if the canonical backend order changes. + +CREATE INDEX idx_proformas_company_archive_status_date +ON proformas (company_id, archived_at, status, proforma_date); diff --git a/modules/core/src/common/dto/critera.dto.ts b/modules/core/src/common/dto/critera.dto.ts index 5a56147d..26148df8 100644 --- a/modules/core/src/common/dto/critera.dto.ts +++ b/modules/core/src/common/dto/critera.dto.ts @@ -5,21 +5,31 @@ import { z } from "zod/v4"; Esquema del DTO para Criteria.fromPrimitives(...) No aplica defaults ni correciones: solo valida. */ -export const FilterPrimitiveSchema = z.object({ - // Campos mínimos ya normalizados por el conversor - field: z.string(), - operator: z.enum([ - "CONTAINS", - "NOT_CONTAINS", - "NOT_EQUALS", - "GREATER_THAN", - "GREATER_THAN_OR_EQUAL", - "LOWER_THAN", - "LOWER_THAN_OR_EQUAL", - "EQUALS", - ]), - value: z.string(), -}); +const FilterOperatorWithValueSchema = z.enum([ + "CONTAINS", + "NOT_CONTAINS", + "NOT_EQUALS", + "GREATER_THAN", + "GREATER_THAN_OR_EQUAL", + "LOWER_THAN", + "LOWER_THAN_OR_EQUAL", + "EQUALS", +]); + +const FilterOperatorWithoutValueSchema = z.enum(["IS_NULL", "IS_NOT_NULL"]); + +export const FilterPrimitiveSchema = z.union([ + z.object({ + field: z.string(), + operator: FilterOperatorWithValueSchema, + value: z.string(), + }), + z.object({ + field: z.string(), + operator: FilterOperatorWithoutValueSchema, + value: z.string().optional(), + }), +]); export const CriteriaSchema = z.object({ q: z.string().optional(), @@ -32,10 +42,14 @@ export const CriteriaSchema = z.object({ order: z.enum(["asc", "desc"]).optional(), // Ya son números (normalizados); validaciones lógicas - pageSize: z.number().int().min(1, { message: "pageSize must be a positive integer" }).optional(), - pageNumber: z + pageSize: z.coerce .number() .int() + .min(1, { message: "pageSize must be a positive integer" }) + .optional(), + pageNumber: z + .coerce.number() + .int() .min(0, { message: "pageNumber must be a non-negative integer" }) .optional(), }); diff --git a/modules/customer-invoices/src/api/application/proformas/models/proforma-summary.ts b/modules/customer-invoices/src/api/application/proformas/models/proforma-summary.ts index 49937cac..e18f0585 100644 --- a/modules/customer-invoices/src/api/application/proformas/models/proforma-summary.ts +++ b/modules/customer-invoices/src/api/application/proformas/models/proforma-summary.ts @@ -13,11 +13,10 @@ export type ProformaSummary = { id: UniqueID; companyId: UniqueID; - isProforma: boolean; proformaReference: InvoiceNumber; status: InvoiceStatus; archivedAt: Maybe; - series: Maybe; + targetInvoiceSeriesCode: Maybe; proformaDate: UtcDate; operationDate: Maybe; diff --git a/modules/customer-invoices/src/api/application/proformas/use-cases/get-proforma-by-id.use-case.ts b/modules/customer-invoices/src/api/application/proformas/use-cases/get-proforma-by-id.use-case.ts index f56f5a94..c829fcc4 100644 --- a/modules/customer-invoices/src/api/application/proformas/use-cases/get-proforma-by-id.use-case.ts +++ b/modules/customer-invoices/src/api/application/proformas/use-cases/get-proforma-by-id.use-case.ts @@ -33,7 +33,7 @@ export class GetProformaByIdUseCase { return this.deps.transactionManager.complete(async (transaction) => { try { - const proformaResult = await this.deps.finder.findProformaById( + const proformaResult = await this.deps.finder.findProformaByIdIncludingArchived( companyId, proformaId, transaction diff --git a/modules/customer-invoices/src/api/infrastructure/proformas/express/controllers/list-proformas.controller.ts b/modules/customer-invoices/src/api/infrastructure/proformas/express/controllers/list-proformas.controller.ts index e9050058..6c9edbff 100644 --- a/modules/customer-invoices/src/api/infrastructure/proformas/express/controllers/list-proformas.controller.ts +++ b/modules/customer-invoices/src/api/infrastructure/proformas/express/controllers/list-proformas.controller.ts @@ -10,6 +10,19 @@ import type { ListProformasUseCase } from "../../../../application/index.ts"; import { proformasApiErrorMapper } from "../proformas-api-error-mapper.ts"; export class ListProformasController extends ExpressController { + private static readonly ALLOWED_FILTERS = { + archived_at: new Set(["IS_NULL", "IS_NOT_NULL"]), + status: new Set(["EQUALS"]), + } as const; + + private static readonly ALLOWED_STATUSES = new Set([ + "draft", + "sent", + "approved", + "rejected", + "issued", + ]); + public constructor(private readonly useCase: ListProformasUseCase) { super(); this.errorMapper = proformasApiErrorMapper; @@ -23,32 +36,58 @@ export class ListProformasController extends ExpressController { } private getCriteriaWithDefaultOrder() { - if (this.criteria.hasOrder()) { - return this.criteria; - } + const { q: quicksearch, filters, pageSize, pageNumber, orderBy, orderType } = + this.criteria.toPrimitives(); - const { q: quicksearch, filters, pageSize, pageNumber } = this.criteria.toPrimitives(); - const archivedParam = this.req.query.archived; - const archivedFilter = - archivedParam === "true" - ? [{ field: "archived_at", operator: "NOT_NULL", value: "true" }] - : [{ field: "archived_at", operator: "NULL", value: "true" }]; return Criteria.fromPrimitives( - [...filters, ...archivedFilter], - "invoice_date", - "DESC", + filters, + orderBy || "proforma_date", + orderType || "DESC", pageSize, pageNumber, quicksearch ); } + private validateSupportedFilters() { + const { filters } = this.criteria.toPrimitives(); + + for (const filter of filters) { + const allowedOperators = + ListProformasController.ALLOWED_FILTERS[ + filter.field as keyof typeof ListProformasController.ALLOWED_FILTERS + ]; + + if (!allowedOperators) { + return `Filter field "${filter.field}" is not allowed in proformas list.`; + } + + if (!allowedOperators.has(filter.operator)) { + return `Operator "${filter.operator}" is not allowed for filter "${filter.field}".`; + } + + if ( + filter.field === "status" && + !ListProformasController.ALLOWED_STATUSES.has(filter.value ?? "") + ) { + return `Status "${filter.value}" is not allowed in proformas list.`; + } + } + + return null; + } + protected async executeImpl() { const companyId = this.getTenantId(); if (!companyId) { return this.forbiddenError("Tenant ID not found"); } + const filtersError = this.validateSupportedFilters(); + if (filtersError) { + return this.invalidInputError(filtersError); + } + const criteria = this.getCriteriaWithDefaultOrder(); const result = await this.useCase.execute({ criteria, companyId }); diff --git a/modules/customer-invoices/src/api/infrastructure/proformas/express/proformas.routes.ts b/modules/customer-invoices/src/api/infrastructure/proformas/express/proformas.routes.ts index eb2db872..de9f586f 100644 --- a/modules/customer-invoices/src/api/infrastructure/proformas/express/proformas.routes.ts +++ b/modules/customer-invoices/src/api/infrastructure/proformas/express/proformas.routes.ts @@ -59,7 +59,7 @@ export const proformasRouter = (params: StartParams) => { router.get( "/", //checkTabContext, - validateRequest(ListProformasRequestSchema, "params"), + validateRequest(ListProformasRequestSchema, "query"), async (req: Request, res: Response, next: NextFunction) => { const useCase = deps.useCases.listProformas(); const controller = new ListProformasController(useCase /*, deps.presenters.list */); diff --git a/modules/customer-invoices/src/api/infrastructure/proformas/persistence/sequelize/mappers/summary/sequelize-proforma-v2-summary.mapper.ts b/modules/customer-invoices/src/api/infrastructure/proformas/persistence/sequelize/mappers/summary/sequelize-proforma-v2-summary.mapper.ts index 12034bf6..ffb97334 100644 --- a/modules/customer-invoices/src/api/infrastructure/proformas/persistence/sequelize/mappers/summary/sequelize-proforma-v2-summary.mapper.ts +++ b/modules/customer-invoices/src/api/infrastructure/proformas/persistence/sequelize/mappers/summary/sequelize-proforma-v2-summary.mapper.ts @@ -59,10 +59,9 @@ export class SequelizeProformaV2SummaryMapper extends SequelizeQueryMapper< return Result.ok({ id: attributes.invoiceId!, companyId: attributes.companyId!, - isProforma: true, status: attributes.status!, archivedAt: attributes.archivedAt!, - series: attributes.series!, + targetInvoiceSeriesCode: attributes.targetInvoiceSeriesCode!, proformaReference: attributes.proformaReference!, proformaDate: attributes.proformaDate!, operationDate: attributes.operationDate!, @@ -89,11 +88,11 @@ export class SequelizeProformaV2SummaryMapper extends SequelizeQueryMapper< const customerId = extractOrPushError(UniqueID.create(raw.customer_id), "customer_id", errors); const status = extractOrPushError(InvoiceStatus.create(raw.status), "status", errors); const archivedAt = extractOrPushError( - maybeFromNullableResult(raw.archived_at, (value) => UtcDate.create(value.toISOString())), + maybeFromNullableResult(raw.archived_at, (value) => UtcDate.createFromISO(value)), "archived_at", errors ); - const series = extractOrPushError( + const targetInvoiceSeriesCode = extractOrPushError( maybeFromNullableResult(raw.target_invoice_series_code, (value) => InvoiceSerie.create(value) ), @@ -175,7 +174,7 @@ export class SequelizeProformaV2SummaryMapper extends SequelizeQueryMapper< customerId, status, archivedAt, - series, + targetInvoiceSeriesCode, proformaReference, proformaDate, operationDate, diff --git a/modules/customer-invoices/src/api/infrastructure/proformas/persistence/sequelize/repositories/proforma-v2.repository.ts b/modules/customer-invoices/src/api/infrastructure/proformas/persistence/sequelize/repositories/proforma-v2.repository.ts index f1efdfe0..e38071f1 100644 --- a/modules/customer-invoices/src/api/infrastructure/proformas/persistence/sequelize/repositories/proforma-v2.repository.ts +++ b/modules/customer-invoices/src/api/infrastructure/proformas/persistence/sequelize/repositories/proforma-v2.repository.ts @@ -39,6 +39,11 @@ export class SequelizeProformaRepositoryV2 extends SequelizeRepository implements IProformaRepository { + private static readonly ALLOWED_FILTERS = { + archived_at: new Set(["IS_NULL", "IS_NOT_NULL"]), + status: new Set(["EQUALS"]), + } as const; + constructor( private readonly domainMapper: SequelizeProformaV2DomainMapper, private readonly summaryMapper: SequelizeProformaV2SummaryMapper, @@ -339,6 +344,8 @@ export class SequelizeProformaRepositoryV2 const { CustomerModel } = this.database.models; try { + this.assertSupportedCriteriaFilters(criteria); + const criteriaConverter = new CriteriaToSequelizeConverter(); /** @@ -349,6 +356,10 @@ export class SequelizeProformaRepositoryV2 const criteriaQuery = criteriaConverter.convert(criteria, { searchableFields: ["proforma_reference", "reference", "description"], mappings: { + archived_at: { + type: "root", + column: "archived_at", + }, proforma_date: { type: "root", column: "proforma_date", @@ -365,6 +376,10 @@ export class SequelizeProformaRepositoryV2 type: "root", column: "description", }, + status: { + type: "root", + column: "status", + }, recipient_name: { type: "association", association: "current_customer", @@ -395,7 +410,6 @@ export class SequelizeProformaRepositoryV2 const baseWhere: WhereOptions> = { company_id: companyId.toString(), - archived_at: null, }; /** @@ -504,4 +518,25 @@ export class SequelizeProformaRepositoryV2 return Result.fail(translateSequelizeError(err)); } } + + private assertSupportedCriteriaFilters(criteria: Criteria) { + const { filters } = criteria.toPrimitives(); + + for (const filter of filters) { + const allowedOperators = + SequelizeProformaRepositoryV2.ALLOWED_FILTERS[ + filter.field as keyof typeof SequelizeProformaRepositoryV2.ALLOWED_FILTERS + ]; + + if (!allowedOperators) { + throw new Error(`Unsupported proformas list filter field "${filter.field}".`); + } + + if (!allowedOperators.has(filter.operator)) { + throw new Error( + `Unsupported operator "${filter.operator}" for proformas list filter "${filter.field}".` + ); + } + } + } } diff --git a/modules/customer-invoices/src/common/dto/request/proformas/list-proformas.request.dto.ts b/modules/customer-invoices/src/common/dto/request/proformas/list-proformas.request.dto.ts index 07df6a70..1b13b580 100644 --- a/modules/customer-invoices/src/common/dto/request/proformas/list-proformas.request.dto.ts +++ b/modules/customer-invoices/src/common/dto/request/proformas/list-proformas.request.dto.ts @@ -1,7 +1,5 @@ import { CriteriaSchema } from "@erp/core"; import { z } from "zod/v4"; -export const ListProformasRequestSchema = CriteriaSchema.extend({ - archived: z.union([z.literal("true"), z.literal("false")]).optional(), -}); +export const ListProformasRequestSchema = CriteriaSchema; export type ProformasListRequestDTO = z.infer; diff --git a/modules/customer-invoices/src/common/locales/en.json b/modules/customer-invoices/src/common/locales/en.json index 5df1f7d7..57c2243b 100644 --- a/modules/customer-invoices/src/common/locales/en.json +++ b/modules/customer-invoices/src/common/locales/en.json @@ -184,6 +184,16 @@ "list": { "title": "Customer proformas", "description": "List all customer proformas", + "filters": { + "status": { + "label": "Status" + }, + "archive_view": { + "active": "Active", + "archived": "Archived", + "all": "All" + } + }, "columns": { "proforma_reference": "Num.", "series": "Serie", diff --git a/modules/customer-invoices/src/common/locales/es.json b/modules/customer-invoices/src/common/locales/es.json index c099ac43..b1c0dc2b 100644 --- a/modules/customer-invoices/src/common/locales/es.json +++ b/modules/customer-invoices/src/common/locales/es.json @@ -185,6 +185,16 @@ "list": { "title": "Proformas", "description": "Lista todas las proformas", + "filters": { + "status": { + "label": "Estado" + }, + "archive_view": { + "active": "Activas", + "archived": "Archivadas", + "all": "Todas" + } + }, "columns": { "proforma_reference": "Num.", "series": "Serie", diff --git a/modules/customer-invoices/src/web/proformas/list/controllers/use-list-proformas.controller.ts b/modules/customer-invoices/src/web/proformas/list/controllers/use-list-proformas.controller.ts index eff16624..72719934 100644 --- a/modules/customer-invoices/src/web/proformas/list/controllers/use-list-proformas.controller.ts +++ b/modules/customer-invoices/src/web/proformas/list/controllers/use-list-proformas.controller.ts @@ -6,7 +6,7 @@ import { useDataTablePreferences, } from "@repo/rdx-ui/components"; import { NumberHelper } from "@repo/rdx-utils"; -import { useCallback, useMemo, useState } from "react"; +import { useCallback, useEffect, useMemo, useState } from "react"; import { useSearchParams } from "react-router-dom"; import { @@ -15,14 +15,15 @@ import { type ProformaStatus, useProformasListQuery, } from "../../shared"; - -type ProformaListStatusFilter = "all" | ProformaStatus; -type ProformaListArchivedFilter = "false" | "true"; +import type { + ProformaArchiveView, + ProformaListStatusFilter, +} from "../types/proforma-list-filters"; +import { buildListProformasCriteria } from "../utils/build-list-proformas-criteria"; // Datos por defecto mientras se carga la consulta o en caso de error. const EMPTY_PROFORMAS_LIST: ProformaList = { items: [], - archived: false, page: INITIAL_PAGE_INDEX, perPage: INITIAL_PAGE_SIZE, totalPages: 0, @@ -55,10 +56,13 @@ const isSortDirection = (value: string | null): value is DataTableSortDirection export const useListProformasController = () => { const [searchParams, setSearchParams] = useSearchParams(); - const [search, setSearch] = useState(""); - const [statusFilter, setStatusFilter] = useState("all"); - const [archivedFilter, setArchivedFilter] = useState( - (searchParams.get("archived") as ProformaListArchivedFilter | null) ?? "false" + const [search, setSearch] = useState(searchParams.get("q") ?? ""); + const [statusFilter, setStatusFilter] = useState( + ((searchParams.get("status") as ProformaStatus | "all" | null) ?? + "all") as ProformaListStatusFilter + ); + const [archiveView, setArchiveView] = useState( + (searchParams.get("archiveView") as ProformaArchiveView | null) ?? "active" ); const tablePreferences = useDataTablePreferences({ @@ -92,6 +96,15 @@ export const useListProformasController = () => { const debouncedSearch = useDebounce(search, 300); + useEffect(() => { + setSearch(searchParams.get("q") ?? ""); + setStatusFilter( + ((searchParams.get("status") as ProformaStatus | "all" | null) ?? + "all") as ProformaListStatusFilter + ); + setArchiveView((searchParams.get("archiveView") as ProformaArchiveView | null) ?? "active"); + }, [searchParams]); + // Criterios de ordenamiento const urlSortFieldValue = searchParams.get("sortField"); const urlSortDirectionValue = searchParams.get("sortDirection"); @@ -113,19 +126,17 @@ export const useListProformasController = () => { // Construir criterios de consulta const criteria = useMemo>( - () => ({ - q: debouncedSearch || "", - archived: archivedFilter, - pageNumber: pageIndex, - pageSize, - orderBy, - order, - filters: - statusFilter === "all" - ? [] - : [{ field: "status", operator: "EQUALS", value: statusFilter }], - }), - [archivedFilter, debouncedSearch, pageIndex, pageSize, orderBy, order, statusFilter] + () => + buildListProformasCriteria({ + archiveView, + order, + orderBy, + pageNumber: pageIndex, + pageSize, + q: debouncedSearch || "", + status: statusFilter, + }), + [archiveView, debouncedSearch, pageIndex, pageSize, orderBy, order, statusFilter] ); const query = useProformasListQuery({ criteria }); @@ -133,59 +144,57 @@ export const useListProformasController = () => { const setStatusFilterValue = useCallback( (value: string) => { const nextValue = (value || "all") as ProformaListStatusFilter; + if (statusFilter === nextValue) return; - setStatusFilter((prev) => { - if (prev === nextValue) return prev; - - // Reset page to 1 when status filter changes - setSearchParams((prev) => { - const params = new URLSearchParams(prev); - params.set("page", String(INITIAL_PAGE_INDEX + 1)); - return params; - }); - return nextValue; + setStatusFilter(nextValue); + setSearchParams((prev) => { + const params = new URLSearchParams(prev); + params.set("page", String(INITIAL_PAGE_INDEX + 1)); + if (nextValue === "all") { + params.delete("status"); + } else { + params.set("status", nextValue); + } + return params; }); }, - [setSearchParams] + [setSearchParams, statusFilter] ); - const setArchivedFilterValue = useCallback( + const setArchiveViewValue = useCallback( (value: string) => { - const nextValue = value === "true" ? "true" : "false"; + const nextValue = value === "archived" || value === "all" ? value : "active"; + if (archiveView === nextValue) return; - setArchivedFilter((prev) => { - if (prev === nextValue) return prev; - - setSearchParams((prevParams) => { - const params = new URLSearchParams(prevParams); - params.set("page", String(INITIAL_PAGE_INDEX + 1)); - params.set("archived", nextValue); - return params; - }); - - return nextValue; + setArchiveView(nextValue); + setSearchParams((prevParams) => { + const params = new URLSearchParams(prevParams); + params.set("page", String(INITIAL_PAGE_INDEX + 1)); + params.set("archiveView", nextValue); + return params; }); }, - [setSearchParams] + [archiveView, setSearchParams] ); const setSearchValue = useCallback( (value: string) => { const nextValue = value.trim().replace(/\s+/g, " "); + if (search === nextValue) return; - setSearch((prev) => { - if (prev === nextValue) return prev; - - // Reset page to 1 when search changes - setSearchParams((prev) => { - const params = new URLSearchParams(prev); - params.set("page", String(INITIAL_PAGE_INDEX + 1)); // Convert to 1-based for URL - return params; - }); - return nextValue; + setSearch(nextValue); + setSearchParams((prev) => { + const params = new URLSearchParams(prev); + params.set("page", String(INITIAL_PAGE_INDEX + 1)); // Convert to 1-based for URL + if (nextValue) { + params.set("q", nextValue); + } else { + params.delete("q"); + } + return params; }); }, - [setSearchParams] + [search, setSearchParams] ); const setPageIndexValue = useCallback( @@ -264,8 +273,8 @@ export const useListProformasController = () => { sort, setSort: setSortValue, - archivedFilter, - setArchivedFilter: setArchivedFilterValue, + archiveView, + setArchiveView: setArchiveViewValue, statusFilter, setStatusFilter: setStatusFilterValue, }; diff --git a/modules/customer-invoices/src/web/proformas/list/types/proforma-list-filters.ts b/modules/customer-invoices/src/web/proformas/list/types/proforma-list-filters.ts new file mode 100644 index 00000000..f3cfce48 --- /dev/null +++ b/modules/customer-invoices/src/web/proformas/list/types/proforma-list-filters.ts @@ -0,0 +1,5 @@ +import type { ProformaStatus } from "../../shared"; + +export type ProformaArchiveView = "active" | "archived" | "all"; + +export type ProformaListStatusFilter = "all" | ProformaStatus; diff --git a/modules/customer-invoices/src/web/proformas/list/ui/pages/list-proformas-page.tsx b/modules/customer-invoices/src/web/proformas/list/ui/pages/list-proformas-page.tsx index 31656917..9fa9e9b6 100644 --- a/modules/customer-invoices/src/web/proformas/list/ui/pages/list-proformas-page.tsx +++ b/modules/customer-invoices/src/web/proformas/list/ui/pages/list-proformas-page.tsx @@ -13,6 +13,9 @@ import { SelectItem, SelectTrigger, SelectValue, + Tabs, + TabsList, + TabsTrigger, } from "@repo/shadcn-ui/components"; import { CheckCircle2Icon, @@ -34,8 +37,7 @@ import { prepareIssueProformaTarget } from "../../../issue-proforma/utils"; import type { ProformaListRow } from "../../../shared"; import { useListProformasPageController } from "../../controllers"; import { ProformaSummaryPanel, ProformasGrid, useProformasGridColumns } from "../blocks"; -import { ArchiveProformaDialog, UnarchiveProformaDialog } from "../components"; -import { ProformaStatusBadge } from "../components"; +import { ArchiveProformaDialog, ProformaStatusBadge, UnarchiveProformaDialog } from "../components"; export const ListProformasPage = () => { const { t } = useTranslation(); @@ -94,19 +96,23 @@ export const ListProformasPage = () => { value={listCtrl.search} /> - + + + {t("pages.proformas.list.filters.archive_view.active")} + + + {t("pages.proformas.list.filters.archive_view.archived")} + + + {t("pages.proformas.list.filters.archive_view.all")} + + +