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:
parent
1e0aadbfc6
commit
a264f43b95
@ -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`
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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
|
||||
|
||||
|
||||
47
docs/customer-invoices/proforma-list-contract.md
Normal file
47
docs/customer-invoices/proforma-list-contract.md
Normal 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
|
||||
@ -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
|
||||
|
||||
5
docs/customer-invoices/sql/add-proforma-list-indexes.sql
Normal file
5
docs/customer-invoices/sql/add-proforma-list-indexes.sql
Normal 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);
|
||||
@ -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(),
|
||||
});
|
||||
|
||||
@ -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>;
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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 });
|
||||
|
||||
|
||||
@ -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 */);
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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}".`
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -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>;
|
||||
|
||||
@ -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",
|
||||
|
||||
@ -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",
|
||||
|
||||
@ -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,
|
||||
};
|
||||
|
||||
@ -0,0 +1,5 @@
|
||||
import type { ProformaStatus } from "../../shared";
|
||||
|
||||
export type ProformaArchiveView = "active" | "archived" | "all";
|
||||
|
||||
export type ProformaListStatusFilter = "all" | ProformaStatus;
|
||||
@ -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>
|
||||
|
||||
@ -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],
|
||||
};
|
||||
}
|
||||
@ -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,
|
||||
|
||||
@ -14,7 +14,7 @@ import type { ListProformasResponseDTO } from "../../../../common";
|
||||
*/
|
||||
|
||||
export type ListProformasByCriteriaParams = {
|
||||
criteria?: CriteriaDTO & { archived?: "true" | "false" };
|
||||
criteria?: CriteriaDTO;
|
||||
signal?: AbortSignal;
|
||||
};
|
||||
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -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 ?? "",
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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 ?? "")
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@ -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();
|
||||
}
|
||||
}
|
||||
|
||||
@ -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")
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Loading…
Reference in New Issue
Block a user