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

View File

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

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

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
- 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

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(...)
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(),
});

View File

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

View File

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

View File

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

View File

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

View File

@ -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,

View File

@ -39,6 +39,11 @@ export class SequelizeProformaRepositoryV2
extends SequelizeRepository<Proforma>
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<InferAttributes<ProformaModel>> = {
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}".`
);
}
}
}
}

View File

@ -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<typeof ListProformasRequestSchema>;

View File

@ -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",

View File

@ -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",

View File

@ -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<ProformaListStatusFilter>("all");
const [archivedFilter, setArchivedFilter] = useState<ProformaListArchivedFilter>(
(searchParams.get("archived") as ProformaListArchivedFilter | null) ?? "false"
const [search, setSearch] = useState(searchParams.get("q") ?? "");
const [statusFilter, setStatusFilter] = useState<ProformaListStatusFilter>(
((searchParams.get("status") as ProformaStatus | "all" | null) ??
"all") as ProformaListStatusFilter
);
const [archiveView, setArchiveView] = useState<ProformaArchiveView>(
(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<NonNullable<ListProformasByCriteriaParams["criteria"]>>(
() => ({
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,
};

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,
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}
/>
<Select
onValueChange={(value) => listCtrl.setArchivedFilter(value ?? "false")}
value={listCtrl.archivedFilter}
<Tabs
className="w-full sm:w-auto"
onValueChange={listCtrl.setArchiveView}
value={listCtrl.archiveView}
>
<SelectTrigger className="w-full sm:w-40">
<FilterIcon aria-hidden className="mr-2 size-4" />
<SelectValue placeholder="Archivadas" />
</SelectTrigger>
<SelectContent>
<SelectItem value="false">Activas</SelectItem>
<SelectItem value="true">Archivadas</SelectItem>
</SelectContent>
</Select>
<TabsList variant="line">
<TabsTrigger value="active">
{t("pages.proformas.list.filters.archive_view.active")}
</TabsTrigger>
<TabsTrigger value="archived">
{t("pages.proformas.list.filters.archive_view.archived")}
</TabsTrigger>
<TabsTrigger value="all">
{t("pages.proformas.list.filters.archive_view.all")}
</TabsTrigger>
</TabsList>
</Tabs>
<Select
onValueChange={(value) => listCtrl.setStatusFilter(value ?? "all")}
@ -114,7 +120,7 @@ export const ListProformasPage = () => {
>
<SelectTrigger className="w-full sm:w-48">
<FilterIcon aria-hidden className="mr-2 size-4" />
<SelectValue placeholder={t("filters.status")} />
<SelectValue placeholder={t("pages.proformas.list.filters.status.label")} />
</SelectTrigger>
<SelectContent>
<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.
*
* @param pageDto - lista de proformas desde la API.
* @param context - Contexto adicional opcional para la adaptación.
* @returns {ProformaList} Objeto adaptado a ProformaList.
*/
export const ListProformasAdapter = {
fromDto(dto: ListProformasResult, context?: unknown): ProformaList {
const archived = (context as { archived?: boolean } | undefined)?.archived ?? false;
fromDto(dto: ListProformasResult): ProformaList {
return {
archived,
page: dto.page,
perPage: dto.per_page,
totalPages: dto.total_pages,
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").
*
* @param rowDto - item de proforma desde la API.
* @param context - Contexto adicional opcional para la adaptación.
* @returns {ProformaListRow} Objeto adaptado a ProformaListRow.
*/
type ListProformasItemOutput = ListProformasResponseDTO["items"][number];
const ProformaListRowAdapter = {
fromDto(dto: ListProformasItemOutput, context?: unknown): ProformaListRow {
fromDto(dto: ListProformasItemOutput): ProformaListRow {
return {
id: dto.id,
companyId: dto.company_id,

View File

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

View File

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

View File

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

View File

@ -10,7 +10,7 @@ import { LIST_PROFORMAS_QUERY_KEY } from "./keys";
export interface ProformasListQueryOptions {
enabled?: boolean;
criteria?: Partial<CriteriaDTO> & { archived?: "true" | "false" };
criteria?: Partial<CriteriaDTO>;
}
export const useProformasListQuery = (
@ -24,7 +24,7 @@ export const useProformasListQuery = (
queryKey: LIST_PROFORMAS_QUERY_KEY(criteria),
queryFn: async ({ signal }) => {
const dto = await getListProformasByCriteria(dataSource, { signal, criteria });
return ListProformasAdapter.fromDto(dto, { archived: criteria.archived === "true" });
return ListProformasAdapter.fromDto(dto);
},
enabled,
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 = {
field: string;
operator: string;
value: string;
value?: string;
};
export class Filter {
@ -19,11 +19,11 @@ export class Filter {
this.value = value;
}
static fromPrimitives(field: string, operator: string, value: string): Filter {
static fromPrimitives(field: string, operator: string, value?: string): Filter {
return new Filter(
new FilterField(field),
new FilterOperator(Operator[operator as keyof typeof Operator]),
new FilterValue(value)
new FilterValue(value ?? "")
);
}

View File

@ -1,5 +1,6 @@
export enum Operator {
EQUAL = "=",
EQUALS = "EQUALS",
NOT_EQUAL = "!=",
GREATER_THAN = ">",
GREATER_THAN_OR_EQUAL = ">=",
@ -7,6 +8,8 @@ export enum Operator {
LOWER_THAN_OR_EQUAL = "<=",
CONTAINS = "CONTAINS",
NOT_CONTAINS = "NOT_CONTAINS",
IS_NULL = "IS_NULL",
IS_NOT_NULL = "IS_NOT_NULL",
}
export class FilterOperator {
@ -20,6 +23,13 @@ export class FilterOperator {
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 {
return this.value.valueOf() === Operator.NOT_EQUAL.valueOf();
}
@ -39,4 +49,12 @@ export class FilterOperator {
isLowerThanOrEqual(): boolean {
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(
(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,
value: unknown
): WhereOptions {
if (operator === Op.is) {
return this.buildNullWhereCondition(mapping, null);
}
if (operator === Op.not) {
return this.buildNullWhereCondition(mapping, { [Op.ne]: null });
}
switch (mapping.type) {
case "root":
return {
@ -196,6 +204,10 @@ export class CriteriaToSequelizeConverter implements ICriteriaToOrmConverter {
return Op.notLike;
case "NOT_EQUALS":
return Op.ne;
case "IS_NULL":
return Op.is;
case "IS_NOT_NULL":
return Op.not;
case "GREATER_THAN":
return Op.gt;
case "GREATER_THAN_OR_EQUAL":
@ -211,6 +223,7 @@ export class CriteriaToSequelizeConverter implements ICriteriaToOrmConverter {
}
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 (value === "true") return true;
if (value === "false") return false;
@ -245,4 +258,26 @@ export class CriteriaToSequelizeConverter implements ICriteriaToOrmConverter {
private assertNever(value: never): never {
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);
}
}
}