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
|
## 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`
|
||||||
|
|||||||
@ -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
|
||||||
|
|||||||
@ -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
|
||||||
|
|
||||||
|
|||||||
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
|
- `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
|
||||||
|
|||||||
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(...)
|
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(),
|
||||||
});
|
});
|
||||||
|
|||||||
@ -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>;
|
||||||
|
|||||||
@ -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
|
||||||
|
|||||||
@ -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 });
|
||||||
|
|
||||||
|
|||||||
@ -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 */);
|
||||||
|
|||||||
@ -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,
|
||||||
|
|||||||
@ -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}".`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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>;
|
||||||
|
|||||||
@ -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",
|
||||||
|
|||||||
@ -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",
|
||||||
|
|||||||
@ -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,
|
||||||
};
|
};
|
||||||
|
|||||||
@ -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,
|
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>
|
||||||
|
|||||||
@ -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.
|
* - 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,
|
||||||
|
|||||||
@ -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;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@ -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;
|
||||||
|
|||||||
@ -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 ?? "",
|
||||||
|
|||||||
@ -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
|
||||||
|
|||||||
@ -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 ?? "")
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -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();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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")
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user