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.
This commit is contained in:
David Arranz 2026-07-29 22:24:53 +02:00
parent 1e0aadbfc6
commit a264f43b95
29 changed files with 421 additions and 137 deletions

View File

@ -23,12 +23,17 @@ Both endpoints:
## Listing behavior ## Listing behavior
- `GET /proformas` excludes archived proformas by default - `GET /proformas` excludes archived proformas by default through `filters[]=archived_at IS_NULL`
- `GET /proformas?archived=false` lists only active proformas - the backend does not expose `archive_view` as a query param
- `GET /proformas?archived=true` lists only archived proformas - 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 ## Notes
- `archived` is a visibility dimension, not a business status - `archived` is a visibility dimension, not a business status
- V1 does not support archiving `sent`, `approved`, or `issued` - V1 does not support archiving `sent`, `approved`, or `issued`
- V1 does not add `archived_by` or `archive_reason` - V1 does not add `archived_by` or `archive_reason`
- soft-deleted proformas stay excluded structurally by `deleted_at`

View File

@ -156,3 +156,4 @@ Si devuelve filas:
- una proforma creada nace con `archived_at = NULL` - una proforma creada nace con `archived_at = NULL`
- el archivado posterior no altera `status` - el archivado posterior no altera `status`
- una proforma `draft` archivada puede borrarse sin desarchivarse antes - 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

View File

@ -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/:id` debe tratar la proforma borrada como inexistente
- `GET /proformas` no debe incluir proformas borradas - `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 - `PUT /proformas/:id` no debe editar proformas borradas
- `POST /proformas/:id/issue` no debe emitir proformas borradas - `POST /proformas/:id/issue` no debe emitir proformas borradas

View File

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

View File

@ -170,6 +170,8 @@ Preparar el esquema fisico para separar:
- `DELETE /proformas/:proforma_id` vuelve a estar operativo sobre persistencia V2 - `DELETE /proformas/:proforma_id` vuelve a estar operativo sobre persistencia V2
- el borrado es logico mediante `deleted_at` - el borrado es logico mediante `deleted_at`
- el archivado es logico mediante `archived_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` - el borrado de `draft` no depende de `archived_at`
- solo `draft` puede borrarse - solo `draft` puede borrarse
- `rejected` sigue fuera del alcance y queda reservado para archivado futuro - `rejected` sigue fuera del alcance y queda reservado para archivado futuro

View File

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

View File

@ -5,21 +5,31 @@ import { z } from "zod/v4";
Esquema del DTO para Criteria.fromPrimitives(...) Esquema del DTO para Criteria.fromPrimitives(...)
No aplica defaults ni correciones: solo valida. No aplica defaults ni correciones: solo valida.
*/ */
export const FilterPrimitiveSchema = z.object({ const FilterOperatorWithValueSchema = z.enum([
// Campos mínimos ya normalizados por el conversor "CONTAINS",
field: z.string(), "NOT_CONTAINS",
operator: z.enum([ "NOT_EQUALS",
"CONTAINS", "GREATER_THAN",
"NOT_CONTAINS", "GREATER_THAN_OR_EQUAL",
"NOT_EQUALS", "LOWER_THAN",
"GREATER_THAN", "LOWER_THAN_OR_EQUAL",
"GREATER_THAN_OR_EQUAL", "EQUALS",
"LOWER_THAN", ]);
"LOWER_THAN_OR_EQUAL",
"EQUALS", const FilterOperatorWithoutValueSchema = z.enum(["IS_NULL", "IS_NOT_NULL"]);
]),
value: z.string(), 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({ export const CriteriaSchema = z.object({
q: z.string().optional(), q: z.string().optional(),
@ -32,10 +42,14 @@ export const CriteriaSchema = z.object({
order: z.enum(["asc", "desc"]).optional(), order: z.enum(["asc", "desc"]).optional(),
// Ya son números (normalizados); validaciones lógicas // Ya son números (normalizados); validaciones lógicas
pageSize: z.number().int().min(1, { message: "pageSize must be a positive integer" }).optional(), pageSize: z.coerce
pageNumber: z
.number() .number()
.int() .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" }) .min(0, { message: "pageNumber must be a non-negative integer" })
.optional(), .optional(),
}); });

View File

@ -13,11 +13,10 @@ export type ProformaSummary = {
id: UniqueID; id: UniqueID;
companyId: UniqueID; companyId: UniqueID;
isProforma: boolean;
proformaReference: InvoiceNumber; proformaReference: InvoiceNumber;
status: InvoiceStatus; status: InvoiceStatus;
archivedAt: Maybe<UtcDate>; archivedAt: Maybe<UtcDate>;
series: Maybe<InvoiceSerie>; targetInvoiceSeriesCode: Maybe<InvoiceSerie>;
proformaDate: UtcDate; proformaDate: UtcDate;
operationDate: Maybe<UtcDate>; operationDate: Maybe<UtcDate>;

View File

@ -33,7 +33,7 @@ export class GetProformaByIdUseCase {
return this.deps.transactionManager.complete(async (transaction) => { return this.deps.transactionManager.complete(async (transaction) => {
try { try {
const proformaResult = await this.deps.finder.findProformaById( const proformaResult = await this.deps.finder.findProformaByIdIncludingArchived(
companyId, companyId,
proformaId, proformaId,
transaction transaction

View File

@ -10,6 +10,19 @@ import type { ListProformasUseCase } from "../../../../application/index.ts";
import { proformasApiErrorMapper } from "../proformas-api-error-mapper.ts"; import { proformasApiErrorMapper } from "../proformas-api-error-mapper.ts";
export class ListProformasController extends ExpressController { 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) { public constructor(private readonly useCase: ListProformasUseCase) {
super(); super();
this.errorMapper = proformasApiErrorMapper; this.errorMapper = proformasApiErrorMapper;
@ -23,32 +36,58 @@ export class ListProformasController extends ExpressController {
} }
private getCriteriaWithDefaultOrder() { private getCriteriaWithDefaultOrder() {
if (this.criteria.hasOrder()) { const { q: quicksearch, filters, pageSize, pageNumber, orderBy, orderType } =
return this.criteria; 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( return Criteria.fromPrimitives(
[...filters, ...archivedFilter], filters,
"invoice_date", orderBy || "proforma_date",
"DESC", orderType || "DESC",
pageSize, pageSize,
pageNumber, pageNumber,
quicksearch 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() { protected async executeImpl() {
const companyId = this.getTenantId(); const companyId = this.getTenantId();
if (!companyId) { if (!companyId) {
return this.forbiddenError("Tenant ID not found"); return this.forbiddenError("Tenant ID not found");
} }
const filtersError = this.validateSupportedFilters();
if (filtersError) {
return this.invalidInputError(filtersError);
}
const criteria = this.getCriteriaWithDefaultOrder(); const criteria = this.getCriteriaWithDefaultOrder();
const result = await this.useCase.execute({ criteria, companyId }); const result = await this.useCase.execute({ criteria, companyId });

View File

@ -59,7 +59,7 @@ export const proformasRouter = (params: StartParams) => {
router.get( router.get(
"/", "/",
//checkTabContext, //checkTabContext,
validateRequest(ListProformasRequestSchema, "params"), validateRequest(ListProformasRequestSchema, "query"),
async (req: Request, res: Response, next: NextFunction) => { async (req: Request, res: Response, next: NextFunction) => {
const useCase = deps.useCases.listProformas(); const useCase = deps.useCases.listProformas();
const controller = new ListProformasController(useCase /*, deps.presenters.list */); const controller = new ListProformasController(useCase /*, deps.presenters.list */);

View File

@ -59,10 +59,9 @@ export class SequelizeProformaV2SummaryMapper extends SequelizeQueryMapper<
return Result.ok({ return Result.ok({
id: attributes.invoiceId!, id: attributes.invoiceId!,
companyId: attributes.companyId!, companyId: attributes.companyId!,
isProforma: true,
status: attributes.status!, status: attributes.status!,
archivedAt: attributes.archivedAt!, archivedAt: attributes.archivedAt!,
series: attributes.series!, targetInvoiceSeriesCode: attributes.targetInvoiceSeriesCode!,
proformaReference: attributes.proformaReference!, proformaReference: attributes.proformaReference!,
proformaDate: attributes.proformaDate!, proformaDate: attributes.proformaDate!,
operationDate: attributes.operationDate!, operationDate: attributes.operationDate!,
@ -89,11 +88,11 @@ export class SequelizeProformaV2SummaryMapper extends SequelizeQueryMapper<
const customerId = extractOrPushError(UniqueID.create(raw.customer_id), "customer_id", errors); const customerId = extractOrPushError(UniqueID.create(raw.customer_id), "customer_id", errors);
const status = extractOrPushError(InvoiceStatus.create(raw.status), "status", errors); const status = extractOrPushError(InvoiceStatus.create(raw.status), "status", errors);
const archivedAt = extractOrPushError( const archivedAt = extractOrPushError(
maybeFromNullableResult(raw.archived_at, (value) => UtcDate.create(value.toISOString())), maybeFromNullableResult(raw.archived_at, (value) => UtcDate.createFromISO(value)),
"archived_at", "archived_at",
errors errors
); );
const series = extractOrPushError( const targetInvoiceSeriesCode = extractOrPushError(
maybeFromNullableResult(raw.target_invoice_series_code, (value) => maybeFromNullableResult(raw.target_invoice_series_code, (value) =>
InvoiceSerie.create(value) InvoiceSerie.create(value)
), ),
@ -175,7 +174,7 @@ export class SequelizeProformaV2SummaryMapper extends SequelizeQueryMapper<
customerId, customerId,
status, status,
archivedAt, archivedAt,
series, targetInvoiceSeriesCode,
proformaReference, proformaReference,
proformaDate, proformaDate,
operationDate, operationDate,

View File

@ -39,6 +39,11 @@ export class SequelizeProformaRepositoryV2
extends SequelizeRepository<Proforma> extends SequelizeRepository<Proforma>
implements IProformaRepository implements IProformaRepository
{ {
private static readonly ALLOWED_FILTERS = {
archived_at: new Set(["IS_NULL", "IS_NOT_NULL"]),
status: new Set(["EQUALS"]),
} as const;
constructor( constructor(
private readonly domainMapper: SequelizeProformaV2DomainMapper, private readonly domainMapper: SequelizeProformaV2DomainMapper,
private readonly summaryMapper: SequelizeProformaV2SummaryMapper, private readonly summaryMapper: SequelizeProformaV2SummaryMapper,
@ -339,6 +344,8 @@ export class SequelizeProformaRepositoryV2
const { CustomerModel } = this.database.models; const { CustomerModel } = this.database.models;
try { try {
this.assertSupportedCriteriaFilters(criteria);
const criteriaConverter = new CriteriaToSequelizeConverter(); const criteriaConverter = new CriteriaToSequelizeConverter();
/** /**
@ -349,6 +356,10 @@ export class SequelizeProformaRepositoryV2
const criteriaQuery = criteriaConverter.convert(criteria, { const criteriaQuery = criteriaConverter.convert(criteria, {
searchableFields: ["proforma_reference", "reference", "description"], searchableFields: ["proforma_reference", "reference", "description"],
mappings: { mappings: {
archived_at: {
type: "root",
column: "archived_at",
},
proforma_date: { proforma_date: {
type: "root", type: "root",
column: "proforma_date", column: "proforma_date",
@ -365,6 +376,10 @@ export class SequelizeProformaRepositoryV2
type: "root", type: "root",
column: "description", column: "description",
}, },
status: {
type: "root",
column: "status",
},
recipient_name: { recipient_name: {
type: "association", type: "association",
association: "current_customer", association: "current_customer",
@ -395,7 +410,6 @@ export class SequelizeProformaRepositoryV2
const baseWhere: WhereOptions<InferAttributes<ProformaModel>> = { const baseWhere: WhereOptions<InferAttributes<ProformaModel>> = {
company_id: companyId.toString(), company_id: companyId.toString(),
archived_at: null,
}; };
/** /**
@ -504,4 +518,25 @@ export class SequelizeProformaRepositoryV2
return Result.fail(translateSequelizeError(err)); 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}".`
);
}
}
}
} }

View File

@ -1,7 +1,5 @@
import { CriteriaSchema } from "@erp/core"; import { CriteriaSchema } from "@erp/core";
import { z } from "zod/v4"; import { z } from "zod/v4";
export const ListProformasRequestSchema = CriteriaSchema.extend({ export const ListProformasRequestSchema = CriteriaSchema;
archived: z.union([z.literal("true"), z.literal("false")]).optional(),
});
export type ProformasListRequestDTO = z.infer<typeof ListProformasRequestSchema>; export type ProformasListRequestDTO = z.infer<typeof ListProformasRequestSchema>;

View File

@ -184,6 +184,16 @@
"list": { "list": {
"title": "Customer proformas", "title": "Customer proformas",
"description": "List all customer proformas", "description": "List all customer proformas",
"filters": {
"status": {
"label": "Status"
},
"archive_view": {
"active": "Active",
"archived": "Archived",
"all": "All"
}
},
"columns": { "columns": {
"proforma_reference": "Num.", "proforma_reference": "Num.",
"series": "Serie", "series": "Serie",

View File

@ -185,6 +185,16 @@
"list": { "list": {
"title": "Proformas", "title": "Proformas",
"description": "Lista todas las proformas", "description": "Lista todas las proformas",
"filters": {
"status": {
"label": "Estado"
},
"archive_view": {
"active": "Activas",
"archived": "Archivadas",
"all": "Todas"
}
},
"columns": { "columns": {
"proforma_reference": "Num.", "proforma_reference": "Num.",
"series": "Serie", "series": "Serie",

View File

@ -6,7 +6,7 @@ import {
useDataTablePreferences, useDataTablePreferences,
} from "@repo/rdx-ui/components"; } from "@repo/rdx-ui/components";
import { NumberHelper } from "@repo/rdx-utils"; 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 { useSearchParams } from "react-router-dom";
import { import {
@ -15,14 +15,15 @@ import {
type ProformaStatus, type ProformaStatus,
useProformasListQuery, useProformasListQuery,
} from "../../shared"; } from "../../shared";
import type {
type ProformaListStatusFilter = "all" | ProformaStatus; ProformaArchiveView,
type ProformaListArchivedFilter = "false" | "true"; 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. // Datos por defecto mientras se carga la consulta o en caso de error.
const EMPTY_PROFORMAS_LIST: ProformaList = { const EMPTY_PROFORMAS_LIST: ProformaList = {
items: [], items: [],
archived: false,
page: INITIAL_PAGE_INDEX, page: INITIAL_PAGE_INDEX,
perPage: INITIAL_PAGE_SIZE, perPage: INITIAL_PAGE_SIZE,
totalPages: 0, totalPages: 0,
@ -55,10 +56,13 @@ const isSortDirection = (value: string | null): value is DataTableSortDirection
export const useListProformasController = () => { export const useListProformasController = () => {
const [searchParams, setSearchParams] = useSearchParams(); const [searchParams, setSearchParams] = useSearchParams();
const [search, setSearch] = useState(""); const [search, setSearch] = useState(searchParams.get("q") ?? "");
const [statusFilter, setStatusFilter] = useState<ProformaListStatusFilter>("all"); const [statusFilter, setStatusFilter] = useState<ProformaListStatusFilter>(
const [archivedFilter, setArchivedFilter] = useState<ProformaListArchivedFilter>( ((searchParams.get("status") as ProformaStatus | "all" | null) ??
(searchParams.get("archived") as ProformaListArchivedFilter | null) ?? "false" "all") as ProformaListStatusFilter
);
const [archiveView, setArchiveView] = useState<ProformaArchiveView>(
(searchParams.get("archiveView") as ProformaArchiveView | null) ?? "active"
); );
const tablePreferences = useDataTablePreferences({ const tablePreferences = useDataTablePreferences({
@ -92,6 +96,15 @@ export const useListProformasController = () => {
const debouncedSearch = useDebounce(search, 300); 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 // Criterios de ordenamiento
const urlSortFieldValue = searchParams.get("sortField"); const urlSortFieldValue = searchParams.get("sortField");
const urlSortDirectionValue = searchParams.get("sortDirection"); const urlSortDirectionValue = searchParams.get("sortDirection");
@ -113,19 +126,17 @@ export const useListProformasController = () => {
// Construir criterios de consulta // Construir criterios de consulta
const criteria = useMemo<NonNullable<ListProformasByCriteriaParams["criteria"]>>( const criteria = useMemo<NonNullable<ListProformasByCriteriaParams["criteria"]>>(
() => ({ () =>
q: debouncedSearch || "", buildListProformasCriteria({
archived: archivedFilter, archiveView,
pageNumber: pageIndex, order,
pageSize, orderBy,
orderBy, pageNumber: pageIndex,
order, pageSize,
filters: q: debouncedSearch || "",
statusFilter === "all" status: statusFilter,
? [] }),
: [{ field: "status", operator: "EQUALS", value: statusFilter }], [archiveView, debouncedSearch, pageIndex, pageSize, orderBy, order, statusFilter]
}),
[archivedFilter, debouncedSearch, pageIndex, pageSize, orderBy, order, statusFilter]
); );
const query = useProformasListQuery({ criteria }); const query = useProformasListQuery({ criteria });
@ -133,59 +144,57 @@ export const useListProformasController = () => {
const setStatusFilterValue = useCallback( const setStatusFilterValue = useCallback(
(value: string) => { (value: string) => {
const nextValue = (value || "all") as ProformaListStatusFilter; const nextValue = (value || "all") as ProformaListStatusFilter;
if (statusFilter === nextValue) return;
setStatusFilter((prev) => { setStatusFilter(nextValue);
if (prev === nextValue) return prev; setSearchParams((prev) => {
const params = new URLSearchParams(prev);
// Reset page to 1 when status filter changes params.set("page", String(INITIAL_PAGE_INDEX + 1));
setSearchParams((prev) => { if (nextValue === "all") {
const params = new URLSearchParams(prev); params.delete("status");
params.set("page", String(INITIAL_PAGE_INDEX + 1)); } else {
return params; params.set("status", nextValue);
}); }
return nextValue; return params;
}); });
}, },
[setSearchParams] [setSearchParams, statusFilter]
); );
const setArchivedFilterValue = useCallback( const setArchiveViewValue = useCallback(
(value: string) => { (value: string) => {
const nextValue = value === "true" ? "true" : "false"; const nextValue = value === "archived" || value === "all" ? value : "active";
if (archiveView === nextValue) return;
setArchivedFilter((prev) => { setArchiveView(nextValue);
if (prev === nextValue) return prev; setSearchParams((prevParams) => {
const params = new URLSearchParams(prevParams);
setSearchParams((prevParams) => { params.set("page", String(INITIAL_PAGE_INDEX + 1));
const params = new URLSearchParams(prevParams); params.set("archiveView", nextValue);
params.set("page", String(INITIAL_PAGE_INDEX + 1)); return params;
params.set("archived", nextValue);
return params;
});
return nextValue;
}); });
}, },
[setSearchParams] [archiveView, setSearchParams]
); );
const setSearchValue = useCallback( const setSearchValue = useCallback(
(value: string) => { (value: string) => {
const nextValue = value.trim().replace(/\s+/g, " "); const nextValue = value.trim().replace(/\s+/g, " ");
if (search === nextValue) return;
setSearch((prev) => { setSearch(nextValue);
if (prev === nextValue) return prev; setSearchParams((prev) => {
const params = new URLSearchParams(prev);
// Reset page to 1 when search changes params.set("page", String(INITIAL_PAGE_INDEX + 1)); // Convert to 1-based for URL
setSearchParams((prev) => { if (nextValue) {
const params = new URLSearchParams(prev); params.set("q", nextValue);
params.set("page", String(INITIAL_PAGE_INDEX + 1)); // Convert to 1-based for URL } else {
return params; params.delete("q");
}); }
return nextValue; return params;
}); });
}, },
[setSearchParams] [search, setSearchParams]
); );
const setPageIndexValue = useCallback( const setPageIndexValue = useCallback(
@ -264,8 +273,8 @@ export const useListProformasController = () => {
sort, sort,
setSort: setSortValue, setSort: setSortValue,
archivedFilter, archiveView,
setArchivedFilter: setArchivedFilterValue, setArchiveView: setArchiveViewValue,
statusFilter, statusFilter,
setStatusFilter: setStatusFilterValue, setStatusFilter: setStatusFilterValue,
}; };

View File

@ -0,0 +1,5 @@
import type { ProformaStatus } from "../../shared";
export type ProformaArchiveView = "active" | "archived" | "all";
export type ProformaListStatusFilter = "all" | ProformaStatus;

View File

@ -13,6 +13,9 @@ import {
SelectItem, SelectItem,
SelectTrigger, SelectTrigger,
SelectValue, SelectValue,
Tabs,
TabsList,
TabsTrigger,
} from "@repo/shadcn-ui/components"; } from "@repo/shadcn-ui/components";
import { import {
CheckCircle2Icon, CheckCircle2Icon,
@ -34,8 +37,7 @@ import { prepareIssueProformaTarget } from "../../../issue-proforma/utils";
import type { ProformaListRow } from "../../../shared"; import type { ProformaListRow } from "../../../shared";
import { useListProformasPageController } from "../../controllers"; import { useListProformasPageController } from "../../controllers";
import { ProformaSummaryPanel, ProformasGrid, useProformasGridColumns } from "../blocks"; import { ProformaSummaryPanel, ProformasGrid, useProformasGridColumns } from "../blocks";
import { ArchiveProformaDialog, UnarchiveProformaDialog } from "../components"; import { ArchiveProformaDialog, ProformaStatusBadge, UnarchiveProformaDialog } from "../components";
import { ProformaStatusBadge } from "../components";
export const ListProformasPage = () => { export const ListProformasPage = () => {
const { t } = useTranslation(); const { t } = useTranslation();
@ -94,19 +96,23 @@ export const ListProformasPage = () => {
value={listCtrl.search} value={listCtrl.search}
/> />
<Select <Tabs
onValueChange={(value) => listCtrl.setArchivedFilter(value ?? "false")} className="w-full sm:w-auto"
value={listCtrl.archivedFilter} onValueChange={listCtrl.setArchiveView}
value={listCtrl.archiveView}
> >
<SelectTrigger className="w-full sm:w-40"> <TabsList variant="line">
<FilterIcon aria-hidden className="mr-2 size-4" /> <TabsTrigger value="active">
<SelectValue placeholder="Archivadas" /> {t("pages.proformas.list.filters.archive_view.active")}
</SelectTrigger> </TabsTrigger>
<SelectContent> <TabsTrigger value="archived">
<SelectItem value="false">Activas</SelectItem> {t("pages.proformas.list.filters.archive_view.archived")}
<SelectItem value="true">Archivadas</SelectItem> </TabsTrigger>
</SelectContent> <TabsTrigger value="all">
</Select> {t("pages.proformas.list.filters.archive_view.all")}
</TabsTrigger>
</TabsList>
</Tabs>
<Select <Select
onValueChange={(value) => listCtrl.setStatusFilter(value ?? "all")} onValueChange={(value) => listCtrl.setStatusFilter(value ?? "all")}
@ -114,7 +120,7 @@ export const ListProformasPage = () => {
> >
<SelectTrigger className="w-full sm:w-48"> <SelectTrigger className="w-full sm:w-48">
<FilterIcon aria-hidden className="mr-2 size-4" /> <FilterIcon aria-hidden className="mr-2 size-4" />
<SelectValue placeholder={t("filters.status")} /> <SelectValue placeholder={t("pages.proformas.list.filters.status.label")} />
</SelectTrigger> </SelectTrigger>
<SelectContent> <SelectContent>
<SelectItem value="all">{t("catalog.proformas.status.all.label")}</SelectItem> <SelectItem value="all">{t("catalog.proformas.status.all.label")}</SelectItem>

View File

@ -0,0 +1,49 @@
import type { CriteriaDTO } from "@erp/core";
import type { ProformaArchiveView, ProformaListStatusFilter } from "../types/proforma-list-filters";
type BuildListProformasCriteriaParams = {
archiveView: ProformaArchiveView;
status: ProformaListStatusFilter;
q: string;
pageNumber: number;
pageSize: number;
orderBy: string;
order: "asc" | "desc";
};
function buildArchiveFilters(archiveView: ProformaArchiveView): NonNullable<CriteriaDTO["filters"]> {
if (archiveView === "active") {
return [{ field: "archived_at", operator: "IS_NULL" }];
}
if (archiveView === "archived") {
return [{ field: "archived_at", operator: "IS_NOT_NULL" }];
}
return [];
}
function buildStatusFilters(status: ProformaListStatusFilter): NonNullable<CriteriaDTO["filters"]> {
if (status === "all") {
return [];
}
return [{ field: "status", operator: "EQUALS", value: status }];
}
export function buildListProformasCriteria(
params: BuildListProformasCriteriaParams
): CriteriaDTO {
const archiveFilters = buildArchiveFilters(params.archiveView);
const statusFilters = buildStatusFilters(params.status);
return {
q: params.q,
pageNumber: params.pageNumber,
pageSize: params.pageSize,
orderBy: params.orderBy,
order: params.order,
filters: [...archiveFilters, ...statusFilters],
};
}

View File

@ -13,20 +13,17 @@ import type { ProformaList, ProformaListRow, ProformaStatus } from "../entities"
* - items se transforma utilizando ProformaListRowAdapter para cada elemento. * - items se transforma utilizando ProformaListRowAdapter para cada elemento.
* *
* @param pageDto - lista de proformas desde la API. * @param pageDto - lista de proformas desde la API.
* @param context - Contexto adicional opcional para la adaptación.
* @returns {ProformaList} Objeto adaptado a ProformaList. * @returns {ProformaList} Objeto adaptado a ProformaList.
*/ */
export const ListProformasAdapter = { export const ListProformasAdapter = {
fromDto(dto: ListProformasResult, context?: unknown): ProformaList { fromDto(dto: ListProformasResult): ProformaList {
const archived = (context as { archived?: boolean } | undefined)?.archived ?? false;
return { return {
archived,
page: dto.page, page: dto.page,
perPage: dto.per_page, perPage: dto.per_page,
totalPages: dto.total_pages, totalPages: dto.total_pages,
totalItems: dto.total_items, totalItems: dto.total_items,
items: dto.items.map((rowDto) => ProformaListRowAdapter.fromDto(rowDto, context)), items: dto.items.map((rowDto) => ProformaListRowAdapter.fromDto(rowDto)),
}; };
}, },
}; };
@ -38,14 +35,13 @@ export const ListProformasAdapter = {
* - is_proforma se convierte a booleano (true si es "1", false si es "0"). * - is_proforma se convierte a booleano (true si es "1", false si es "0").
* *
* @param rowDto - item de proforma desde la API. * @param rowDto - item de proforma desde la API.
* @param context - Contexto adicional opcional para la adaptación.
* @returns {ProformaListRow} Objeto adaptado a ProformaListRow. * @returns {ProformaListRow} Objeto adaptado a ProformaListRow.
*/ */
type ListProformasItemOutput = ListProformasResponseDTO["items"][number]; type ListProformasItemOutput = ListProformasResponseDTO["items"][number];
const ProformaListRowAdapter = { const ProformaListRowAdapter = {
fromDto(dto: ListProformasItemOutput, context?: unknown): ProformaListRow { fromDto(dto: ListProformasItemOutput): ProformaListRow {
return { return {
id: dto.id, id: dto.id,
companyId: dto.company_id, companyId: dto.company_id,

View File

@ -14,7 +14,7 @@ import type { ListProformasResponseDTO } from "../../../../common";
*/ */
export type ListProformasByCriteriaParams = { export type ListProformasByCriteriaParams = {
criteria?: CriteriaDTO & { archived?: "true" | "false" }; criteria?: CriteriaDTO;
signal?: AbortSignal; signal?: AbortSignal;
}; };

View File

@ -7,7 +7,6 @@ import type { ProformaListRow } from "./proforma-list-row.entity";
export interface ProformaList { export interface ProformaList {
items: ProformaListRow[]; items: ProformaListRow[];
archived: boolean;
totalPages: number; totalPages: number;
totalItems: number; totalItems: number;
page: number; page: number;

View File

@ -15,7 +15,6 @@ export const LIST_PROFORMAS_QUERY_KEY = (criteria?: ProformasListRequestDTO): Qu
[ [
...LIST_PROFORMAS_QUERY_KEY_PREFIX, ...LIST_PROFORMAS_QUERY_KEY_PREFIX,
{ {
archived: criteria?.archived ?? "false",
pageNumber: criteria?.pageNumber ?? 1, pageNumber: criteria?.pageNumber ?? 1,
pageSize: criteria?.pageSize ?? 5, pageSize: criteria?.pageSize ?? 5,
q: criteria?.q ?? "", q: criteria?.q ?? "",

View File

@ -10,7 +10,7 @@ import { LIST_PROFORMAS_QUERY_KEY } from "./keys";
export interface ProformasListQueryOptions { export interface ProformasListQueryOptions {
enabled?: boolean; enabled?: boolean;
criteria?: Partial<CriteriaDTO> & { archived?: "true" | "false" }; criteria?: Partial<CriteriaDTO>;
} }
export const useProformasListQuery = ( export const useProformasListQuery = (
@ -24,7 +24,7 @@ export const useProformasListQuery = (
queryKey: LIST_PROFORMAS_QUERY_KEY(criteria), queryKey: LIST_PROFORMAS_QUERY_KEY(criteria),
queryFn: async ({ signal }) => { queryFn: async ({ signal }) => {
const dto = await getListProformasByCriteria(dataSource, { signal, criteria }); const dto = await getListProformasByCriteria(dataSource, { signal, criteria });
return ListProformasAdapter.fromDto(dto, { archived: criteria.archived === "true" }); return ListProformasAdapter.fromDto(dto);
}, },
enabled, enabled,
placeholderData: (previousData) => previousData, // Mantiene la página anterior durante refetch por cambio de criteria placeholderData: (previousData) => previousData, // Mantiene la página anterior durante refetch por cambio de criteria

View File

@ -5,7 +5,7 @@ import { FilterValue } from "./FilterValue";
export type FiltersPrimitives = { export type FiltersPrimitives = {
field: string; field: string;
operator: string; operator: string;
value: string; value?: string;
}; };
export class Filter { export class Filter {
@ -19,11 +19,11 @@ export class Filter {
this.value = value; this.value = value;
} }
static fromPrimitives(field: string, operator: string, value: string): Filter { static fromPrimitives(field: string, operator: string, value?: string): Filter {
return new Filter( return new Filter(
new FilterField(field), new FilterField(field),
new FilterOperator(Operator[operator as keyof typeof Operator]), new FilterOperator(Operator[operator as keyof typeof Operator]),
new FilterValue(value) new FilterValue(value ?? "")
); );
} }

View File

@ -1,5 +1,6 @@
export enum Operator { export enum Operator {
EQUAL = "=", EQUAL = "=",
EQUALS = "EQUALS",
NOT_EQUAL = "!=", NOT_EQUAL = "!=",
GREATER_THAN = ">", GREATER_THAN = ">",
GREATER_THAN_OR_EQUAL = ">=", GREATER_THAN_OR_EQUAL = ">=",
@ -7,6 +8,8 @@ export enum Operator {
LOWER_THAN_OR_EQUAL = "<=", LOWER_THAN_OR_EQUAL = "<=",
CONTAINS = "CONTAINS", CONTAINS = "CONTAINS",
NOT_CONTAINS = "NOT_CONTAINS", NOT_CONTAINS = "NOT_CONTAINS",
IS_NULL = "IS_NULL",
IS_NOT_NULL = "IS_NOT_NULL",
} }
export class FilterOperator { export class FilterOperator {
@ -20,6 +23,13 @@ export class FilterOperator {
return this.value.valueOf() === Operator.NOT_CONTAINS.valueOf(); return this.value.valueOf() === Operator.NOT_CONTAINS.valueOf();
} }
isEquals(): boolean {
return (
this.value.valueOf() === Operator.EQUAL.valueOf() ||
this.value.valueOf() === Operator.EQUALS.valueOf()
);
}
isNotEquals(): boolean { isNotEquals(): boolean {
return this.value.valueOf() === Operator.NOT_EQUAL.valueOf(); return this.value.valueOf() === Operator.NOT_EQUAL.valueOf();
} }
@ -39,4 +49,12 @@ export class FilterOperator {
isLowerThanOrEqual(): boolean { isLowerThanOrEqual(): boolean {
return this.value.valueOf() === Operator.LOWER_THAN_OR_EQUAL.valueOf(); return this.value.valueOf() === Operator.LOWER_THAN_OR_EQUAL.valueOf();
} }
isNull(): boolean {
return this.value.valueOf() === Operator.IS_NULL.valueOf();
}
isNotNull(): boolean {
return this.value.valueOf() === Operator.IS_NOT_NULL.valueOf();
}
} }

View File

@ -74,10 +74,13 @@ export class CriteriaFromUrlConverter {
} }
}); });
// @ts-expect-error
return Object.values(tempFilters).filter( return Object.values(tempFilters).filter(
(filter) => (filter) =>
filter.field !== undefined && filter.operator !== undefined && filter.value !== undefined filter.field !== undefined &&
filter.operator !== undefined &&
(filter.value !== undefined ||
filter.operator === "IS_NULL" ||
filter.operator === "IS_NOT_NULL")
); );
} }
} }

View File

@ -149,6 +149,14 @@ export class CriteriaToSequelizeConverter implements ICriteriaToOrmConverter {
operator: symbol, operator: symbol,
value: unknown value: unknown
): WhereOptions { ): WhereOptions {
if (operator === Op.is) {
return this.buildNullWhereCondition(mapping, null);
}
if (operator === Op.not) {
return this.buildNullWhereCondition(mapping, { [Op.ne]: null });
}
switch (mapping.type) { switch (mapping.type) {
case "root": case "root":
return { return {
@ -196,6 +204,10 @@ export class CriteriaToSequelizeConverter implements ICriteriaToOrmConverter {
return Op.notLike; return Op.notLike;
case "NOT_EQUALS": case "NOT_EQUALS":
return Op.ne; return Op.ne;
case "IS_NULL":
return Op.is;
case "IS_NOT_NULL":
return Op.not;
case "GREATER_THAN": case "GREATER_THAN":
return Op.gt; return Op.gt;
case "GREATER_THAN_OR_EQUAL": case "GREATER_THAN_OR_EQUAL":
@ -211,6 +223,7 @@ export class CriteriaToSequelizeConverter implements ICriteriaToOrmConverter {
} }
private transformValue(operator: symbol, value: unknown): unknown { private transformValue(operator: symbol, value: unknown): unknown {
if (operator === Op.is || operator === Op.not) return null;
if (operator === Op.like || operator === Op.notLike) return `%${value}%`; if (operator === Op.like || operator === Op.notLike) return `%${value}%`;
if (value === "true") return true; if (value === "true") return true;
if (value === "false") return false; if (value === "false") return false;
@ -245,4 +258,26 @@ export class CriteriaToSequelizeConverter implements ICriteriaToOrmConverter {
private assertNever(value: never): never { private assertNever(value: never): never {
throw new Error(`[CriteriaToSequelizeConverter] Unsupported field mapping: ${String(value)}`); throw new Error(`[CriteriaToSequelizeConverter] Unsupported field mapping: ${String(value)}`);
} }
private buildNullWhereCondition(
mapping: CriteriaFieldMapping,
value: unknown
): WhereOptions {
switch (mapping.type) {
case "root":
return {
[mapping.column]: value,
};
case "association":
return Sequelize.where(Sequelize.col(`${mapping.association}.${mapping.column}`), value) as
unknown as WhereOptions;
case "literal":
return Sequelize.where(Sequelize.literal(mapping.expression), value) as unknown as WhereOptions;
default:
return this.assertNever(mapping);
}
}
} }