feat: implement proforma deletion and listing enhancements

- Added endpoint for retrieving deleted proformas with logical deletion.
- Updated proforma listing to support active, archived, and deleted scopes.
- Refactored repository and service layers to accommodate new proforma list scope.
- Enhanced UI to display separate views for active, archived, and deleted proformas.
- Updated localization files to reflect changes in proforma management.
- Improved query handling to filter proformas based on their status and scope.
This commit is contained in:
David Arranz 2026-07-29 22:40:38 +02:00
parent a264f43b95
commit 1bf85a05ff
24 changed files with 483 additions and 283 deletions

View File

@ -8,13 +8,20 @@ import {
SidebarMenu, SidebarMenu,
SidebarMenuButton, SidebarMenuButton,
SidebarMenuItem, SidebarMenuItem,
SidebarMenuSub,
SidebarMenuSubButton,
SidebarMenuSubItem,
} from "@repo/shadcn-ui/components"; } from "@repo/shadcn-ui/components";
import { cn } from "@repo/shadcn-ui/lib/utils"; import { cn } from "@repo/shadcn-ui/lib/utils";
import { ChevronDownIcon } from "lucide-react"; import { ChevronDownIcon } from "lucide-react";
import * as React from "react"; import * as React from "react";
import { Link } from "react-router-dom"; import { Link, useLocation } from "react-router-dom";
import type { AppSidebarNavSection, AppSidebarSectionAccent } from "./app-sidebar.config"; import type {
AppSidebarNavItem,
AppSidebarNavSection,
AppSidebarSectionAccent,
} from "./app-sidebar.config";
interface AppSidebarNavProps { interface AppSidebarNavProps {
sections: AppSidebarNavSection[]; sections: AppSidebarNavSection[];
@ -29,9 +36,11 @@ interface SidebarSectionAccentStyles {
} }
export const AppSidebarNav = ({ sections }: AppSidebarNavProps) => { export const AppSidebarNav = ({ sections }: AppSidebarNavProps) => {
const { pathname } = useLocation();
const [openSections, setOpenSections] = React.useState<Record<string, boolean>>(() => const [openSections, setOpenSections] = React.useState<Record<string, boolean>>(() =>
Object.fromEntries(sections.map((s) => [s.title, true])) Object.fromEntries(sections.map((s) => [s.title, true]))
); );
const [openItems, setOpenItems] = React.useState<Record<string, boolean>>({});
const sectionAccentStyles: Record<AppSidebarSectionAccent, SidebarSectionAccentStyles> = { const sectionAccentStyles: Record<AppSidebarSectionAccent, SidebarSectionAccentStyles> = {
blue: { blue: {
@ -67,6 +76,25 @@ export const AppSidebarNav = ({ sections }: AppSidebarNavProps) => {
}, },
}; };
const isRouteActive = React.useCallback(
(item: AppSidebarNavItem): boolean => {
if (item.items?.length) {
return item.items.some((child) => isRouteActive(child));
}
if (!item.href) {
return false;
}
if (item.exact) {
return pathname === item.href;
}
return pathname === item.href || pathname.startsWith(`${item.href}/`);
},
[pathname]
);
return ( return (
<nav className="space-y-2 pt-4"> <nav className="space-y-2 pt-4">
{sections.map((section) => { {sections.map((section) => {
@ -121,32 +149,98 @@ export const AppSidebarNav = ({ sections }: AppSidebarNavProps) => {
<SidebarMenu className="px-1"> <SidebarMenu className="px-1">
{section.items.map((item) => { {section.items.map((item) => {
const Icon = item.icon; const Icon = item.icon;
const isActive = false; const isActive = isRouteActive(item);
const hasChildren = Boolean(item.items?.length);
const isItemOpen = openItems[item.title] ?? isActive;
return ( return (
<SidebarMenuItem key={item.href}> <SidebarMenuItem key={item.href}>
<SidebarMenuButton {hasChildren ? (
className={cn( <Collapsible
"h-9 rounded-md px-3 text-sm font-medium text-foreground/80", onOpenChange={(open) =>
"hover:bg-muted hover:text-foreground", setOpenItems((current) => ({
isActive && styles.activeItem ...current,
)} [item.title]: open,
isActive={isActive} }))
render={ }
<Link to={item.href}> open={isItemOpen}
<Icon >
aria-hidden="true" <SidebarMenuButton
className={cn( className={cn(
"size-4 shrink-0", "h-9 rounded-md px-3 text-sm font-medium text-foreground/80",
isActive ? styles.activeIcon : styles.inactiveIcon "hover:bg-muted hover:text-foreground",
)} isActive && styles.activeItem
/> )}
<span className="truncate group-data-[collapsible=icon]:hidden"> isActive={isActive}
{item.title} render={
</span> <CollapsibleTrigger
</Link> render={
} <button className="flex w-full items-center gap-2" type="button">
/> <Icon
aria-hidden="true"
className={cn(
"size-4 shrink-0",
isActive ? styles.activeIcon : styles.inactiveIcon
)}
/>
<span className="truncate group-data-[collapsible=icon]:hidden">
{item.title}
</span>
<ChevronDownIcon
aria-hidden="true"
className={cn(
"ml-auto size-3.5 transition-transform duration-200 group-data-[collapsible=icon]:hidden",
isItemOpen && "rotate-180"
)}
/>
</button>
}
/>
}
/>
<CollapsibleContent>
<SidebarMenuSub>
{item.items?.map((child) => {
const childIsActive = isRouteActive(child);
return (
<SidebarMenuSubItem key={child.href}>
<SidebarMenuSubButton
isActive={childIsActive}
render={<Link to={child.href ?? "#"} />}
>
<span>{child.title}</span>
</SidebarMenuSubButton>
</SidebarMenuSubItem>
);
})}
</SidebarMenuSub>
</CollapsibleContent>
</Collapsible>
) : (
<SidebarMenuButton
className={cn(
"h-9 rounded-md px-3 text-sm font-medium text-foreground/80",
"hover:bg-muted hover:text-foreground",
isActive && styles.activeItem
)}
isActive={isActive}
render={
<Link to={item.href ?? "#"}>
<Icon
aria-hidden="true"
className={cn(
"size-4 shrink-0",
isActive ? styles.activeIcon : styles.inactiveIcon
)}
/>
<span className="truncate group-data-[collapsible=icon]:hidden">
{item.title}
</span>
</Link>
}
/>
)}
</SidebarMenuItem> </SidebarMenuItem>
); );
})} })}

View File

@ -23,8 +23,10 @@ import {
export interface AppSidebarNavItem { export interface AppSidebarNavItem {
title: string; title: string;
href: string;
icon: LucideIcon; icon: LucideIcon;
href?: string;
exact?: boolean;
items?: AppSidebarNavItem[];
} }
export type AppSidebarSectionAccent = "blue" | "green" | "amber" | "violet"; export type AppSidebarSectionAccent = "blue" | "green" | "amber" | "violet";
@ -40,7 +42,32 @@ export const appSidebarNavSections: AppSidebarNavSection[] = [
title: "Ventas", title: "Ventas",
accent: "blue", accent: "blue",
items: [ items: [
{ title: "Proformas", href: "/proformas", icon: FileTextIcon }, {
title: "Proformas",
href: "/proformas",
icon: FileTextIcon,
items: [
{ title: "Proformas", href: "/proformas", icon: FileTextIcon, exact: true },
{
title: "Archivadas",
href: "/proformas/archived",
icon: FileTextIcon,
exact: true,
},
{
title: "Eliminadas",
href: "/proformas/deleted",
icon: FileTextIcon,
exact: true,
},
{
title: "Nueva proforma",
href: "/proformas/create",
icon: FileTextIcon,
exact: true,
},
],
},
{ title: "Pedidos de venta", href: "/sales-orders", icon: ShoppingCartIcon }, { title: "Pedidos de venta", href: "/sales-orders", icon: ShoppingCartIcon },
{ title: "Clientes", href: "/customers", icon: UsersIcon }, { title: "Clientes", href: "/customers", icon: UsersIcon },
{ title: "Facturación", href: "/customer-invoices", icon: ReceiptIcon }, { title: "Facturación", href: "/customer-invoices", icon: ReceiptIcon },

View File

@ -1,39 +1,26 @@
# Proforma archive contract # Proformas archivadas
## Rules ## Endpoint
- Archive uses `archived_at` and does not change `status`. - `GET /proformas/archived`
- Only `draft` and `rejected` proformas can be archived in V1.
- Only `draft` and `rejected` archived proformas can be unarchived in V1.
- Archived proformas remain available through `GET /proformas/:id`.
- Deleted proformas remain excluded from every flow.
- Numbering is never reused and `document_series.next_number` is untouched.
- Archived `draft` proformas can be deleted directly without unarchiving.
## Endpoints ## Semántica
- `PATCH /proformas/:proforma_id/archive` - Devuelve solo proformas con `archived_at IS NOT NULL`.
- `PATCH /proformas/:proforma_id/unarchive` - Excluye registros con `deleted_at IS NOT NULL`.
- La búsqueda `q` se ejecuta dentro del scope archivado.
Both endpoints: ## Query soportada
- return `200` with the updated proforma snapshot - `q`
- return `404` when the proforma does not exist, belongs to another company, or is deleted - `status`
- return `409` when the state is not allowed, when the proforma is already archived/non-archived, or when it is linked to an issued invoice - `pageNumber`
- `pageSize`
- `orderBy`
- `order`
## Listing behavior ## UI
- `GET /proformas` excludes archived proformas by default through `filters[]=archived_at IS_NULL` - Ruta frontend: `/proformas/archived`
- the backend does not expose `archive_view` as a query param - Reutiliza el listado compartido con scope `archived`.
- the frontend keeps `archiveView=active|archived|all` only as UI state and translates it to `Criteria filters[]` - Mantiene selector de estado, paginación y orden.
- 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

@ -1,45 +1,26 @@
# Proforma Delete Contract # Proformas eliminadas
## Objetivo ## Endpoint
Definir el borrado funcional de proformas sin romper trazabilidad ni numeracion. - `GET /proformas/deleted`
## Reglas funcionales ## Semántica
- solo se pueden borrar proformas en estado `draft` - Devuelve solo proformas con `deleted_at IS NOT NULL`.
- `sent`, `approved`, `rejected` e `issued` devuelven conflicto - Usa `paranoid: false` en persistencia para poder listarlas.
- `rejected` no se borra; queda reservado para una futura operacion de archivado - La búsqueda `q` se ejecuta dentro del scope eliminado.
- el borrado es logico mediante `deleted_at`
- el borrado de `draft` no depende de `archived_at`
- una proforma `draft` archivada puede borrarse directamente
- una proforma `rejected` archivada no puede borrarse
- la numeracion no se reutiliza
- no se decrementa `document_series.next_number`
- no se borran fisicamente `proforma_items` ni `proforma_taxes`
## Validaciones defensivas ## Query soportada
- si `linked_invoice_id` tiene valor, el borrado se bloquea - `q`
- si existe `issued_invoices.source_proforma_id = proforma.id`, el borrado se bloquea - `status`
- `pageNumber`
## Contrato REST - `pageSize`
- `orderBy`
- endpoint: `DELETE /proformas/:proforma_id` - `order`
- respuesta satisfactoria: `204 No Content`
- `404 Not Found` si no existe, no pertenece a la empresa activa o ya esta borrada
- `409 Conflict` si existe pero no cumple las reglas de borrado
## Efecto operativo
- `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
## UI ## UI
- la accion `Eliminar` solo debe mostrarse para proformas `draft` - Ruta frontend: `/proformas/deleted`
- en la vista de archivadas, `Eliminar` tambien debe mostrarse para `draft` - Vista read-only V1.
- la confirmacion debe advertir que la referencia no se reutilizara - No implementa restauración, purge ni force delete.
- en esta fase la accion esta disponible en el listado activo y en archivadas para `draft`

View File

@ -1,47 +1,26 @@
# Proforma List Contract # Proformas activas
## UI model ## Endpoint
- frontend keeps `archiveView=active|archived|all` as semantic UI state - `GET /proformas`
- frontend keeps `status=all|draft|sent|approved|rejected|issued`
- `archiveView` is not a backend query param
## Backend contract ## Semántica
`GET /proformas` stays based on `Criteria`: - Devuelve solo proformas con `archived_at IS NULL`.
- Devuelve solo proformas con `deleted_at IS NULL`.
- No admite filtros frontend sobre `archived_at` ni `deleted_at`.
## Query soportada
- `q` - `q`
- `status`
- `pageNumber` - `pageNumber`
- `pageSize` - `pageSize`
- `orderBy` - `orderBy`
- `order` - `order`
- `filters[]`
## Allowed filters ## UI
- `archived_at IS_NULL` - Ruta frontend: `/proformas`
- `archived_at IS_NOT_NULL` - Mantiene búsqueda, paginación, orden y selector de estado.
- `status EQUALS draft|sent|approved|rejected|issued` - No muestra selector visual de archivadas/todas.
## 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

@ -4,6 +4,7 @@ import type { Collection, Result } from "@repo/rdx-utils";
import type { InvoiceStatus, Proforma } from "../../../domain"; import type { InvoiceStatus, Proforma } from "../../../domain";
import type { ProformaSummary } from "../models"; import type { ProformaSummary } from "../models";
import type { ProformaListScope } from "../use-cases";
export interface IProformaRepository { export interface IProformaRepository {
create(proforma: Proforma, transaction?: unknown): Promise<Result<void, Error>>; create(proforma: Proforma, transaction?: unknown): Promise<Result<void, Error>>;
@ -30,6 +31,7 @@ export interface IProformaRepository {
findByCriteriaInCompany( findByCriteriaInCompany(
companyId: UniqueID, companyId: UniqueID,
scope: ProformaListScope,
criteria: Criteria, criteria: Criteria,
transaction: unknown transaction: unknown
): Promise<Result<Collection<ProformaSummary>, Error>>; ): Promise<Result<Collection<ProformaSummary>, Error>>;

View File

@ -5,6 +5,7 @@ import type { Collection, Result } from "@repo/rdx-utils";
import type { Proforma } from "../../../domain"; import type { Proforma } from "../../../domain";
import type { ProformaSummary } from "../models"; import type { ProformaSummary } from "../models";
import type { IProformaRepository } from "../repositories"; import type { IProformaRepository } from "../repositories";
import type { ProformaListScope } from "../use-cases";
export interface IProformaFinder { export interface IProformaFinder {
findProformaById( findProformaById(
@ -27,6 +28,7 @@ export interface IProformaFinder {
findProformasByCriteria( findProformasByCriteria(
companyId: UniqueID, companyId: UniqueID,
scope: ProformaListScope,
criteria: Criteria, criteria: Criteria,
transaction?: unknown transaction?: unknown
): Promise<Result<Collection<ProformaSummary>, Error>>; ): Promise<Result<Collection<ProformaSummary>, Error>>;
@ -61,9 +63,10 @@ export class ProformaFinder implements IProformaFinder {
async findProformasByCriteria( async findProformasByCriteria(
companyId: UniqueID, companyId: UniqueID,
scope: ProformaListScope,
criteria: Criteria, criteria: Criteria,
transaction?: unknown transaction?: unknown
): Promise<Result<Collection<ProformaSummary>, Error>> { ): Promise<Result<Collection<ProformaSummary>, Error>> {
return this.repository.findByCriteriaInCompany(companyId, criteria, transaction); return this.repository.findByCriteriaInCompany(companyId, scope, criteria, transaction);
} }
} }

View File

@ -6,9 +6,12 @@ import { Result } from "@repo/rdx-utils";
import type { IProformaFinder } from "../services"; import type { IProformaFinder } from "../services";
import type { IProformaSummarySnapshotBuilder } from "../snapshot-builders"; import type { IProformaSummarySnapshotBuilder } from "../snapshot-builders";
export type ProformaListScope = "normal" | "archived" | "deleted";
type ListProformasUseCaseInput = { type ListProformasUseCaseInput = {
companyId: UniqueID; companyId: UniqueID;
criteria: Criteria; criteria: Criteria;
scope: ProformaListScope;
}; };
export class ListProformasUseCase { export class ListProformasUseCase {
@ -19,11 +22,16 @@ export class ListProformasUseCase {
) {} ) {}
public execute(params: ListProformasUseCaseInput) { public execute(params: ListProformasUseCaseInput) {
const { criteria, companyId } = params; const { criteria, companyId, scope } = params;
return this.transactionManager.complete(async (transaction: unknown) => { return this.transactionManager.complete(async (transaction: unknown) => {
try { try {
const result = await this.finder.findProformasByCriteria(companyId, criteria, transaction); const result = await this.finder.findProformasByCriteria(
companyId,
scope,
criteria,
transaction
);
if (result.isFailure) { if (result.isFailure) {
return Result.fail(result.error); return Result.fail(result.error);

View File

@ -6,12 +6,11 @@ import {
} from "@erp/core/api"; } from "@erp/core/api";
import { Criteria } from "@repo/rdx-criteria/server"; import { Criteria } from "@repo/rdx-criteria/server";
import type { ListProformasUseCase } from "../../../../application/index.ts"; import type { ListProformasUseCase, ProformaListScope } 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 = { private static readonly ALLOWED_FILTERS = {
archived_at: new Set(["IS_NULL", "IS_NOT_NULL"]),
status: new Set(["EQUALS"]), status: new Set(["EQUALS"]),
} as const; } as const;
@ -23,7 +22,10 @@ export class ListProformasController extends ExpressController {
"issued", "issued",
]); ]);
public constructor(private readonly useCase: ListProformasUseCase) { public constructor(
private readonly useCase: ListProformasUseCase,
private readonly scope: ProformaListScope
) {
super(); super();
this.errorMapper = proformasApiErrorMapper; this.errorMapper = proformasApiErrorMapper;
@ -89,7 +91,7 @@ export class ListProformasController extends ExpressController {
} }
const criteria = this.getCriteriaWithDefaultOrder(); const criteria = this.getCriteriaWithDefaultOrder();
const result = await this.useCase.execute({ criteria, companyId }); const result = await this.useCase.execute({ criteria, companyId, scope: this.scope });
return result.match( return result.match(
(data) => (data) =>

View File

@ -62,7 +62,30 @@ export const proformasRouter = (params: StartParams) => {
validateRequest(ListProformasRequestSchema, "query"), 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, "normal" /*, deps.presenters.list */);
return controller.execute(req, res, next);
}
);
router.get(
"/archived",
validateRequest(ListProformasRequestSchema, "query"),
async (req: Request, res: Response, next: NextFunction) => {
const useCase = deps.useCases.listProformas();
const controller = new ListProformasController(
useCase,
"archived" /*, deps.presenters.list */
);
return controller.execute(req, res, next);
}
);
router.get(
"/deleted",
validateRequest(ListProformasRequestSchema, "query"),
async (req: Request, res: Response, next: NextFunction) => {
const useCase = deps.useCases.listProformas();
const controller = new ListProformasController(useCase, "deleted" /*, deps.presenters.list */);
return controller.execute(req, res, next); return controller.execute(req, res, next);
} }
); );

View File

@ -26,6 +26,7 @@ import {
} from "sequelize"; } from "sequelize";
import type { IProformaRepository, ProformaSummary } from "../../../../../application"; import type { IProformaRepository, ProformaSummary } from "../../../../../application";
import type { ProformaListScope } from "../../../../../application";
import { INVOICE_STATUS, type InvoiceStatus, type Proforma } from "../../../../../domain"; import { INVOICE_STATUS, type InvoiceStatus, type Proforma } from "../../../../../domain";
import { import {
IssuedInvoiceModel, IssuedInvoiceModel,
@ -40,7 +41,6 @@ export class SequelizeProformaRepositoryV2
implements IProformaRepository implements IProformaRepository
{ {
private static readonly ALLOWED_FILTERS = { private static readonly ALLOWED_FILTERS = {
archived_at: new Set(["IS_NULL", "IS_NOT_NULL"]),
status: new Set(["EQUALS"]), status: new Set(["EQUALS"]),
} as const; } as const;
@ -337,6 +337,7 @@ export class SequelizeProformaRepositoryV2
public async findByCriteriaInCompany( public async findByCriteriaInCompany(
companyId: UniqueID, companyId: UniqueID,
scope: ProformaListScope,
criteria: Criteria, criteria: Criteria,
transaction: Transaction, transaction: Transaction,
options: FindOptions<InferAttributes<ProformaModel>> = {} options: FindOptions<InferAttributes<ProformaModel>> = {}
@ -411,6 +412,7 @@ export class SequelizeProformaRepositoryV2
const baseWhere: WhereOptions<InferAttributes<ProformaModel>> = { const baseWhere: WhereOptions<InferAttributes<ProformaModel>> = {
company_id: companyId.toString(), company_id: companyId.toString(),
}; };
const scopeWhere = this.buildScopeWhere(scope);
/** /**
* Composición defensiva del WHERE. * Composición defensiva del WHERE.
@ -421,6 +423,7 @@ export class SequelizeProformaRepositoryV2
const where: WhereOptions<InferAttributes<ProformaModel>> = { const where: WhereOptions<InferAttributes<ProformaModel>> = {
[Op.and]: [ [Op.and]: [
baseWhere, baseWhere,
scopeWhere,
...(options.where ? [options.where] : []), ...(options.where ? [options.where] : []),
...(criteriaQuery.where ? [criteriaQuery.where] : []), ...(criteriaQuery.where ? [criteriaQuery.where] : []),
], ],
@ -486,6 +489,7 @@ export class SequelizeProformaRepositoryV2
const findQuery: FindOptions<InferAttributes<ProformaModel>> = { const findQuery: FindOptions<InferAttributes<ProformaModel>> = {
...options, ...options,
...criteriaQuery, ...criteriaQuery,
paranoid: scope !== "deleted",
where, where,
include, include,
order, order,
@ -502,6 +506,7 @@ export class SequelizeProformaRepositoryV2
* y `col` deben tiparse con `CountOptions`. * y `col` deben tiparse con `CountOptions`.
*/ */
const countQuery: CountOptions<InferAttributes<ProformaModel>> = { const countQuery: CountOptions<InferAttributes<ProformaModel>> = {
paranoid: scope !== "deleted",
where, where,
include: getCountIncludes(include), include: getCountIncludes(include),
distinct: true, distinct: true,
@ -539,4 +544,26 @@ export class SequelizeProformaRepositoryV2
} }
} }
} }
private buildScopeWhere(scope: ProformaListScope): WhereOptions<InferAttributes<ProformaModel>> {
switch (scope) {
case "archived":
return {
archived_at: {
[Op.ne]: null,
},
};
case "deleted":
return {
deleted_at: {
[Op.ne]: null,
},
};
case "normal":
default:
return {
archived_at: null,
};
}
}
} }

View File

@ -34,6 +34,7 @@ import {
IssuedInvoiceModel, IssuedInvoiceModel,
} from "../../../../common"; } from "../../../../common";
import type { SequelizeProformaDomainMapper, SequelizeProformaSummaryMapper } from "../mappers"; import type { SequelizeProformaDomainMapper, SequelizeProformaSummaryMapper } from "../mappers";
import type { ProformaListScope } from "../../../../../application";
export class ProformaRepository export class ProformaRepository
extends SequelizeRepository<Proforma> extends SequelizeRepository<Proforma>
@ -428,6 +429,7 @@ export class ProformaRepository
*/ */
public async findByCriteriaInCompany( public async findByCriteriaInCompany(
companyId: UniqueID, companyId: UniqueID,
_scope: ProformaListScope,
criteria: Criteria, criteria: Criteria,
transaction: Transaction, transaction: Transaction,
options: FindOptions<InferAttributes<CustomerInvoiceModel>> = {} options: FindOptions<InferAttributes<CustomerInvoiceModel>> = {}

View File

@ -183,15 +183,16 @@
"description": "Manage your customer proformas", "description": "Manage your customer proformas",
"list": { "list": {
"title": "Customer proformas", "title": "Customer proformas",
"description": "List all customer proformas", "description": "List non-archived and non-deleted customer proformas",
"loadErrorTitle": "Unable to load the proformas list",
"empty": {
"normal": "There are no proformas.",
"archived": "There are no archived proformas.",
"deleted": "There are no deleted proformas."
},
"filters": { "filters": {
"status": { "status": {
"label": "Status" "label": "Status"
},
"archive_view": {
"active": "Active",
"archived": "Archived",
"all": "All"
} }
}, },
"columns": { "columns": {
@ -215,6 +216,14 @@
"total_amount": "Total" "total_amount": "Total"
} }
}, },
"archived": {
"title": "Archived proformas",
"description": "Review archived proformas without mixing them into the operational list"
},
"deleted": {
"title": "Deleted proformas",
"description": "Review deleted proformas in read-only mode"
},
"create": { "create": {
"title": "New customer proforma", "title": "New customer proforma",
"description": "Create a new customer proforma", "description": "Create a new customer proforma",

View File

@ -184,15 +184,16 @@
"description": "Gestiona tus proformas", "description": "Gestiona tus proformas",
"list": { "list": {
"title": "Proformas", "title": "Proformas",
"description": "Lista todas las proformas", "description": "Lista las proformas no archivadas ni eliminadas",
"loadErrorTitle": "No se pudo cargar el listado de proformas",
"empty": {
"normal": "No hay proformas.",
"archived": "No hay proformas archivadas.",
"deleted": "No hay proformas eliminadas."
},
"filters": { "filters": {
"status": { "status": {
"label": "Estado" "label": "Estado"
},
"archive_view": {
"active": "Activas",
"archived": "Archivadas",
"all": "Todas"
} }
}, },
"columns": { "columns": {
@ -216,6 +217,14 @@
"total_amount": "Importe total" "total_amount": "Importe total"
} }
}, },
"archived": {
"title": "Proformas archivadas",
"description": "Consulta las proformas archivadas sin mezclar el listado operativo"
},
"deleted": {
"title": "Proformas eliminadas",
"description": "Consulta las proformas eliminadas en modo solo lectura"
},
"create": { "create": {
"title": "Nueva proforma", "title": "Nueva proforma",
"description": "Crear una nueva proforma", "description": "Crear una nueva proforma",

View File

@ -10,6 +10,14 @@ const ProformasListPage = lazy(() =>
import("./proformas/list").then((m) => ({ default: m.ListProformasPage })) import("./proformas/list").then((m) => ({ default: m.ListProformasPage }))
); );
const ArchivedProformasListPage = lazy(() =>
import("./proformas/list").then((m) => ({ default: m.ListArchivedProformasPage }))
);
const DeletedProformasListPage = lazy(() =>
import("./proformas/list").then((m) => ({ default: m.ListDeletedProformasPage }))
);
const ProformasCreatePage = lazy(() => const ProformasCreatePage = lazy(() =>
import("./proformas/create").then((m) => ({ default: m.ProformaCreatePage })) import("./proformas/create").then((m) => ({ default: m.ProformaCreatePage }))
); );
@ -48,6 +56,14 @@ export const CustomerInvoiceRoutes = (params: ModuleClientParams): RouteObject[]
path: "list", path: "list",
element: <ProformasListPage />, element: <ProformasListPage />,
}, },
{
path: "archived",
element: <ArchivedProformasListPage />,
},
{
path: "deleted",
element: <DeletedProformasListPage />,
},
], ],
}, },

View File

@ -13,13 +13,14 @@ import {
useProformaArchiveMutation, useProformaArchiveMutation,
useProformaUnarchiveMutation, useProformaUnarchiveMutation,
} from "../../shared"; } from "../../shared";
import type { ProformaListScope } from "../types/proforma-list-filters";
import { useListProformasController } from "./use-list-proformas.controller"; import { useListProformasController } from "./use-list-proformas.controller";
import { useProformaSummaryPanelController } from "./use-proforma-summary-panel.controller"; import { useProformaSummaryPanelController } from "./use-proforma-summary-panel.controller";
export const useListProformasPageController = () => { export const useListProformasPageController = (scope: ProformaListScope) => {
const { t } = useTranslation(); const { t } = useTranslation();
const listCtrl = useListProformasController(); const listCtrl = useListProformasController(scope);
const deleteDialogCtrl = useDeleteProformaDialogController(); const deleteDialogCtrl = useDeleteProformaDialogController();
const issueDialogCtrl = useIssueProformaDialogController(); const issueDialogCtrl = useIssueProformaDialogController();
const changeStatusDialogCtrl = useChangeProformaStatusDialogController(); const changeStatusDialogCtrl = useChangeProformaStatusDialogController();

View File

@ -15,10 +15,7 @@ import {
type ProformaStatus, type ProformaStatus,
useProformasListQuery, useProformasListQuery,
} from "../../shared"; } from "../../shared";
import type { import type { ProformaListScope, ProformaListStatusFilter } from "../types/proforma-list-filters";
ProformaArchiveView,
ProformaListStatusFilter,
} from "../types/proforma-list-filters";
import { buildListProformasCriteria } from "../utils/build-list-proformas-criteria"; 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.
@ -54,16 +51,13 @@ const isSortDirection = (value: string | null): value is DataTableSortDirection
return value === "asc" || value === "desc"; return value === "asc" || value === "desc";
}; };
export const useListProformasController = () => { export const useListProformasController = (scope: ProformaListScope) => {
const [searchParams, setSearchParams] = useSearchParams(); const [searchParams, setSearchParams] = useSearchParams();
const [search, setSearch] = useState(searchParams.get("q") ?? ""); const [search, setSearch] = useState(searchParams.get("q") ?? "");
const [statusFilter, setStatusFilter] = useState<ProformaListStatusFilter>( const [statusFilter, setStatusFilter] = useState<ProformaListStatusFilter>(
((searchParams.get("status") as ProformaStatus | "all" | null) ?? ((searchParams.get("status") as ProformaStatus | "all" | null) ??
"all") as ProformaListStatusFilter "all") as ProformaListStatusFilter
); );
const [archiveView, setArchiveView] = useState<ProformaArchiveView>(
(searchParams.get("archiveView") as ProformaArchiveView | null) ?? "active"
);
const tablePreferences = useDataTablePreferences({ const tablePreferences = useDataTablePreferences({
storageKey: "proformas:list:grid", storageKey: "proformas:list:grid",
@ -102,7 +96,6 @@ export const useListProformasController = () => {
((searchParams.get("status") as ProformaStatus | "all" | null) ?? ((searchParams.get("status") as ProformaStatus | "all" | null) ??
"all") as ProformaListStatusFilter "all") as ProformaListStatusFilter
); );
setArchiveView((searchParams.get("archiveView") as ProformaArchiveView | null) ?? "active");
}, [searchParams]); }, [searchParams]);
// Criterios de ordenamiento // Criterios de ordenamiento
@ -128,7 +121,6 @@ export const useListProformasController = () => {
const criteria = useMemo<NonNullable<ListProformasByCriteriaParams["criteria"]>>( const criteria = useMemo<NonNullable<ListProformasByCriteriaParams["criteria"]>>(
() => () =>
buildListProformasCriteria({ buildListProformasCriteria({
archiveView,
order, order,
orderBy, orderBy,
pageNumber: pageIndex, pageNumber: pageIndex,
@ -136,10 +128,10 @@ export const useListProformasController = () => {
q: debouncedSearch || "", q: debouncedSearch || "",
status: statusFilter, status: statusFilter,
}), }),
[archiveView, debouncedSearch, pageIndex, pageSize, orderBy, order, statusFilter] [debouncedSearch, pageIndex, pageSize, orderBy, order, statusFilter]
); );
const query = useProformasListQuery({ criteria }); const query = useProformasListQuery({ criteria, scope });
const setStatusFilterValue = useCallback( const setStatusFilterValue = useCallback(
(value: string) => { (value: string) => {
@ -161,22 +153,6 @@ export const useListProformasController = () => {
[setSearchParams, statusFilter] [setSearchParams, statusFilter]
); );
const setArchiveViewValue = useCallback(
(value: string) => {
const nextValue = value === "archived" || value === "all" ? value : "active";
if (archiveView === nextValue) return;
setArchiveView(nextValue);
setSearchParams((prevParams) => {
const params = new URLSearchParams(prevParams);
params.set("page", String(INITIAL_PAGE_INDEX + 1));
params.set("archiveView", nextValue);
return params;
});
},
[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, " ");
@ -272,9 +248,6 @@ export const useListProformasController = () => {
sort, sort,
setSort: setSortValue, setSort: setSortValue,
archiveView,
setArchiveView: setArchiveViewValue,
statusFilter, statusFilter,
setStatusFilter: setStatusFilterValue, setStatusFilter: setStatusFilterValue,
}; };

View File

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

View File

@ -30,6 +30,7 @@ import {
type ProformaListRow, type ProformaListRow,
type ProformaStatus, type ProformaStatus,
} from "../../../../shared"; } from "../../../../shared";
import type { ProformaListScope } from "../../../types/proforma-list-filters";
import { ProformaStatusBadge } from "../../components"; import { ProformaStatusBadge } from "../../components";
type GridActionHandlers = { type GridActionHandlers = {
@ -53,6 +54,7 @@ type GridActionHandlers = {
}; };
export function useProformasGridColumns( export function useProformasGridColumns(
scope: ProformaListScope,
actionHandlers: GridActionHandlers = {} actionHandlers: GridActionHandlers = {}
): ColumnDef<ProformaListRow, unknown>[] { ): ColumnDef<ProformaListRow, unknown>[] {
const { t } = useTranslation(); const { t } = useTranslation();
@ -282,16 +284,27 @@ export function useProformasGridColumns(
const isDraft = proforma.status === PROFORMA_STATUS.DRAFT; const isDraft = proforma.status === PROFORMA_STATUS.DRAFT;
const isIssued = proforma.status === PROFORMA_STATUS.ISSUED; const isIssued = proforma.status === PROFORMA_STATUS.ISSUED;
const isApproved = proforma.status === PROFORMA_STATUS.APPROVED; const isApproved = proforma.status === PROFORMA_STATUS.APPROVED;
const isNormalScope = scope === "normal";
const isArchivedScope = scope === "archived";
const isDeletedScope = scope === "deleted";
const isArchivable = const isArchivable =
isNormalScope &&
!proforma.isArchived && !proforma.isArchived &&
(proforma.status === PROFORMA_STATUS.DRAFT || (proforma.status === PROFORMA_STATUS.DRAFT ||
proforma.status === PROFORMA_STATUS.REJECTED); proforma.status === PROFORMA_STATUS.REJECTED);
const isUnarchivable = const isUnarchivable =
isArchivedScope &&
proforma.isArchived && proforma.isArchived &&
(proforma.status === PROFORMA_STATUS.DRAFT || (proforma.status === PROFORMA_STATUS.DRAFT ||
proforma.status === PROFORMA_STATUS.REJECTED); proforma.status === PROFORMA_STATUS.REJECTED);
const availableTransitions = const availableTransitions = isNormalScope
PROFORMA_STATUS_TRANSITIONS[proforma.status as ProformaStatus] ?? []; ? PROFORMA_STATUS_TRANSITIONS[proforma.status as ProformaStatus] ?? []
: [];
const canEdit = isNormalScope && !isIssued && actionHandlers.onEditClick;
const canIssue = isNormalScope && !isIssued && isApproved && actionHandlers.onIssueClick;
const canDelete = !isDeletedScope && isDraft && actionHandlers.onDeleteClick;
const canDownloadPdf = !isDeletedScope && actionHandlers.onDownloadPdf;
const isPDFLoading = const isPDFLoading =
actionHandlers.isPdfDownloading && actionHandlers.pdfDownloadingId === proforma.id; actionHandlers.isPdfDownloading && actionHandlers.pdfDownloadingId === proforma.id;
@ -299,7 +312,7 @@ export function useProformasGridColumns(
return ( return (
<ButtonGroup> <ButtonGroup>
{!isIssued && actionHandlers.onEditClick && ( {canEdit && (
<TooltipProvider> <TooltipProvider>
<Tooltip> <Tooltip>
<TooltipTrigger <TooltipTrigger
@ -374,7 +387,10 @@ export function useProformasGridColumns(
)} )}
{/* Cambiar estado */} {/* Cambiar estado */}
{!isIssued && availableTransitions.length && actionHandlers.onChangeStatusClick && ( {!isDeletedScope &&
!isIssued &&
availableTransitions.length > 0 &&
actionHandlers.onChangeStatusClick && (
<TooltipProvider key={availableTransitions[0]}> <TooltipProvider key={availableTransitions[0]}>
<Tooltip> <Tooltip>
<TooltipTrigger <TooltipTrigger
@ -403,7 +419,7 @@ export function useProformasGridColumns(
)} )}
{/* Emitir factura: solo si approved */} {/* Emitir factura: solo si approved */}
{!isIssued && isApproved && actionHandlers.onIssueClick && ( {canIssue && (
<TooltipProvider> <TooltipProvider>
<Tooltip> <Tooltip>
<TooltipTrigger <TooltipTrigger
@ -429,35 +445,37 @@ export function useProformasGridColumns(
)} )}
{/* Descargar en PDF */} {/* Descargar en PDF */}
<TooltipProvider> {canDownloadPdf && (
<Tooltip> <TooltipProvider>
<TooltipTrigger <Tooltip>
render={ <TooltipTrigger
<Button render={
className={"size-8"} <Button
disabled={isPDFLoading} className={"size-8"}
onClick={(e) => { disabled={isPDFLoading}
stop(e); onClick={(e) => {
actionHandlers.onDownloadPdf?.(proforma); stop(e);
}} actionHandlers.onDownloadPdf?.(proforma);
size="icon" }}
variant="ghost" size="icon"
> variant="ghost"
{isPDFLoading ? ( >
<Spinner className="size-4 cursor-progress" /> {isPDFLoading ? (
) : ( <Spinner className="size-4 cursor-progress" />
<FileDownIcon className="size-4 cursor-pointer" /> ) : (
)} <FileDownIcon className="size-4 cursor-pointer" />
<span className="sr-only">Descargar PDF</span> )}
</Button> <span className="sr-only">Descargar PDF</span>
} </Button>
/> }
<TooltipContent>Descargar PDF</TooltipContent> />
</Tooltip> <TooltipContent>Descargar PDF</TooltipContent>
</TooltipProvider> </Tooltip>
</TooltipProvider>
)}
{/* Eliminar */} {/* Eliminar */}
{isDraft && actionHandlers.onDeleteClick && ( {canDelete && (
<TooltipProvider> <TooltipProvider>
<Tooltip> <Tooltip>
<TooltipTrigger <TooltipTrigger
@ -486,6 +504,6 @@ export function useProformasGridColumns(
}, },
}, },
], ],
[t, actionHandlers] [t, actionHandlers, scope]
); );
} }

View File

@ -13,9 +13,6 @@ import {
SelectItem, SelectItem,
SelectTrigger, SelectTrigger,
SelectValue, SelectValue,
Tabs,
TabsList,
TabsTrigger,
} from "@repo/shadcn-ui/components"; } from "@repo/shadcn-ui/components";
import { import {
CheckCircle2Icon, CheckCircle2Icon,
@ -35,16 +32,59 @@ import { prepareDeleteProformaTargets } from "../../../delete/utils";
import { IssueProformaDialog } from "../../../issue-proforma"; import { IssueProformaDialog } from "../../../issue-proforma";
import { prepareIssueProformaTarget } from "../../../issue-proforma/utils"; import { prepareIssueProformaTarget } from "../../../issue-proforma/utils";
import type { ProformaListRow } from "../../../shared"; import type { ProformaListRow } from "../../../shared";
import type { ProformaListScope } from "../../types/proforma-list-filters";
import { useListProformasPageController } from "../../controllers"; import { useListProformasPageController } from "../../controllers";
import { ProformaSummaryPanel, ProformasGrid, useProformasGridColumns } from "../blocks"; import { ProformaSummaryPanel, ProformasGrid, useProformasGridColumns } from "../blocks";
import { ArchiveProformaDialog, ProformaStatusBadge, UnarchiveProformaDialog } from "../components"; import { ArchiveProformaDialog, ProformaStatusBadge, UnarchiveProformaDialog } from "../components";
export const ListProformasPage = () => { interface ScopedListProformasPageProps {
scope: ProformaListScope;
}
const SCOPE_CONFIG: Record<
ProformaListScope,
{
currentPath: string;
titleKey: string;
descriptionKey: string;
emptyStateKey: string;
showCreateAction: boolean;
enablePanel: boolean;
}
> = {
normal: {
currentPath: "/proformas",
titleKey: "pages.proformas.list.title",
descriptionKey: "pages.proformas.list.description",
emptyStateKey: "pages.proformas.list.empty.normal",
showCreateAction: true,
enablePanel: true,
},
archived: {
currentPath: "/proformas/archived",
titleKey: "pages.proformas.archived.title",
descriptionKey: "pages.proformas.archived.description",
emptyStateKey: "pages.proformas.list.empty.archived",
showCreateAction: false,
enablePanel: true,
},
deleted: {
currentPath: "/proformas/deleted",
titleKey: "pages.proformas.deleted.title",
descriptionKey: "pages.proformas.deleted.description",
emptyStateKey: "pages.proformas.list.empty.deleted",
showCreateAction: false,
enablePanel: false,
},
};
const ScopedListProformasPage = ({ scope }: ScopedListProformasPageProps) => {
const { t } = useTranslation(); const { t } = useTranslation();
const navigate = useNavigate(); const navigate = useNavigate();
const scopeConfig = SCOPE_CONFIG[scope];
const { currentReturnTo } = useReturnToNavigation({ const { currentReturnTo } = useReturnToNavigation({
fallbackPath: "/proformas", fallbackPath: scopeConfig.currentPath,
}); });
const { const {
@ -58,7 +98,7 @@ export const ListProformasPage = () => {
unarchiveDialogCtrl, unarchiveDialogCtrl,
isPDFDownloading: isPdfDownloading, isPDFDownloading: isPdfDownloading,
pdfDownloadingId, pdfDownloadingId,
} = useListProformasPageController(); } = useListProformasPageController(scope);
const handleEditClick = (proformaId: string) => { const handleEditClick = (proformaId: string) => {
navigate({ navigate({
@ -69,7 +109,7 @@ export const ListProformasPage = () => {
}); });
}; };
const columns = useProformasGridColumns({ const columns = useProformasGridColumns(scope, {
onEditClick: (proforma) => handleEditClick(proforma.id), onEditClick: (proforma) => handleEditClick(proforma.id),
onIssueClick: (proformaRow) => onIssueClick: (proformaRow) =>
issueDialogCtrl.openDialog(prepareIssueProformaTarget(proformaRow)), issueDialogCtrl.openDialog(prepareIssueProformaTarget(proformaRow)),
@ -85,7 +125,7 @@ export const ListProformasPage = () => {
isPdfDownloading, isPdfDownloading,
}); });
const isPanelOpen = panelCtrl.panelState.isOpen; const isPanelOpen = scopeConfig.enablePanel && panelCtrl.panelState.isOpen;
const listContent = ( const listContent = (
<div className="flex h-full min-w-0 flex-col gap-4 overflow-hidden"> <div className="flex h-full min-w-0 flex-col gap-4 overflow-hidden">
@ -96,24 +136,6 @@ export const ListProformasPage = () => {
value={listCtrl.search} value={listCtrl.search}
/> />
<Tabs
className="w-full sm:w-auto"
onValueChange={listCtrl.setArchiveView}
value={listCtrl.archiveView}
>
<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 <Select
onValueChange={(value) => listCtrl.setStatusFilter(value ?? "all")} onValueChange={(value) => listCtrl.setStatusFilter(value ?? "all")}
value={listCtrl.statusFilter} value={listCtrl.statusFilter}
@ -142,12 +164,21 @@ export const ListProformasPage = () => {
onColumnVisibilityChange={listCtrl.tablePreferences.setColumnVisibility} onColumnVisibilityChange={listCtrl.tablePreferences.setColumnVisibility}
onPageChange={listCtrl.setPageIndex} onPageChange={listCtrl.setPageIndex}
onPageSizeChange={listCtrl.setPageSize} onPageSizeChange={listCtrl.setPageSize}
onRowClick={(proformaId) => panelCtrl.openProformaPanel(proformaId, "view")} onRowClick={
scopeConfig.enablePanel
? (proformaId) => panelCtrl.openProformaPanel(proformaId, "view")
: undefined
}
onSortChange={listCtrl.setSort} onSortChange={listCtrl.setSort}
pageIndex={listCtrl.pageIndex} pageIndex={listCtrl.pageIndex}
pageSize={listCtrl.pageSize} pageSize={listCtrl.pageSize}
sort={listCtrl.sort} sort={listCtrl.sort}
/> />
{listCtrl.data.totalItems === 0 ? (
<div className="rounded border border-dashed border-border bg-muted/30 px-4 py-6 text-sm text-muted-foreground">
{t(scopeConfig.emptyStateKey)}
</div>
) : null}
{/* Explicación técnica */} {/* Explicación técnica */}
<div className="mt-8 rounded border border-border bg-card p-3 sm:p-4 text-xs sm:text-sm text-muted-foreground space-y-4"> <div className="mt-8 rounded border border-border bg-card p-3 sm:p-4 text-xs sm:text-sm text-muted-foreground space-y-4">
@ -200,25 +231,27 @@ export const ListProformasPage = () => {
<div className="p-6 space-y-6"> <div className="p-6 space-y-6">
{/* Header */} {/* Header */}
<PageHeader <PageHeader
description={t("pages.proformas.list.description")} description={t(scopeConfig.descriptionKey)}
rightSlot={ rightSlot={
<Button scopeConfig.showCreateAction ? (
aria-label={t("pages.proformas.create.title")} <Button
onClick={() => aria-label={t("pages.proformas.create.title")}
navigate({ onClick={() =>
pathname: "/proformas/create", navigate({
search: createSearchParams({ pathname: "/proformas/create",
returnTo: currentReturnTo, search: createSearchParams({
}).toString(), returnTo: currentReturnTo,
}) }).toString(),
} })
size={"default"} }
> size={"default"}
<PlusIcon aria-hidden className="mr-2 size-4" /> >
{t("pages.proformas.create.title")} <PlusIcon aria-hidden className="mr-2 size-4" />
</Button> {t("pages.proformas.create.title")}
</Button>
) : undefined
} }
title={t("pages.proformas.list.title")} title={t(scopeConfig.titleKey)}
/> />
{/* Stats Cards */} {/* Stats Cards */}
@ -293,7 +326,7 @@ export const ListProformasPage = () => {
</div> </div>
{/* Table */} {/* Table */}
{isPanelOpen ? ( {scopeConfig.enablePanel && isPanelOpen ? (
<ResizablePanelGroup <ResizablePanelGroup
autoSave="list-proformas-page" autoSave="list-proformas-page"
className="h-full mx-auto w-full space-y-4" className="h-full mx-auto w-full space-y-4"
@ -395,3 +428,9 @@ export const ListProformasPage = () => {
</div> </div>
); );
}; };
export const ListProformasPage = () => <ScopedListProformasPage scope="normal" />;
export const ListArchivedProformasPage = () => <ScopedListProformasPage scope="archived" />;
export const ListDeletedProformasPage = () => <ScopedListProformasPage scope="deleted" />;

View File

@ -1,9 +1,8 @@
import type { CriteriaDTO } from "@erp/core"; import type { CriteriaDTO } from "@erp/core";
import type { ProformaArchiveView, ProformaListStatusFilter } from "../types/proforma-list-filters"; import type { ProformaListStatusFilter } from "../types/proforma-list-filters";
type BuildListProformasCriteriaParams = { type BuildListProformasCriteriaParams = {
archiveView: ProformaArchiveView;
status: ProformaListStatusFilter; status: ProformaListStatusFilter;
q: string; q: string;
pageNumber: number; pageNumber: number;
@ -12,18 +11,6 @@ type BuildListProformasCriteriaParams = {
order: "asc" | "desc"; 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"]> { function buildStatusFilters(status: ProformaListStatusFilter): NonNullable<CriteriaDTO["filters"]> {
if (status === "all") { if (status === "all") {
return []; return [];
@ -35,7 +22,6 @@ function buildStatusFilters(status: ProformaListStatusFilter): NonNullable<Crite
export function buildListProformasCriteria( export function buildListProformasCriteria(
params: BuildListProformasCriteriaParams params: BuildListProformasCriteriaParams
): CriteriaDTO { ): CriteriaDTO {
const archiveFilters = buildArchiveFilters(params.archiveView);
const statusFilters = buildStatusFilters(params.status); const statusFilters = buildStatusFilters(params.status);
return { return {
@ -44,6 +30,6 @@ export function buildListProformasCriteria(
pageSize: params.pageSize, pageSize: params.pageSize,
orderBy: params.orderBy, orderBy: params.orderBy,
order: params.order, order: params.order,
filters: [...archiveFilters, ...statusFilters], filters: statusFilters,
}; };
} }

View File

@ -2,6 +2,7 @@ import type { CriteriaDTO } from "@erp/core";
import type { IDataSource } from "@erp/core/client"; import type { IDataSource } from "@erp/core/client";
import type { ListProformasResponseDTO } from "../../../../common"; import type { ListProformasResponseDTO } from "../../../../common";
import type { ProformaListScope } from "../../list/types/proforma-list-filters";
/** /**
* Recupera una lista de proformas del sistema utilizando la * Recupera una lista de proformas del sistema utilizando la
@ -15,6 +16,7 @@ import type { ListProformasResponseDTO } from "../../../../common";
export type ListProformasByCriteriaParams = { export type ListProformasByCriteriaParams = {
criteria?: CriteriaDTO; criteria?: CriteriaDTO;
scope: ProformaListScope;
signal?: AbortSignal; signal?: AbortSignal;
}; };
@ -24,8 +26,12 @@ export function getListProformasByCriteria(
dataSource: IDataSource, dataSource: IDataSource,
params: ListProformasByCriteriaParams params: ListProformasByCriteriaParams
): Promise<ListProformasResult> { ): Promise<ListProformasResult> {
const { criteria, signal } = params || { criteria: undefined, signal: undefined }; const { criteria, scope, signal } = params;
return dataSource.getList<ListProformasResponseDTO>("proformas", {
const resource =
scope === "archived" ? "proformas/archived" : scope === "deleted" ? "proformas/deleted" : "proformas";
return dataSource.getList<ListProformasResponseDTO>(resource, {
signal, signal,
...criteria, ...criteria,
}); });

View File

@ -1,6 +1,7 @@
import type { QueryKey } from "@tanstack/react-query"; import type { QueryKey } from "@tanstack/react-query";
import type { ProformasListRequestDTO } from "../../../../common"; import type { ProformasListRequestDTO } from "../../../../common";
import type { ProformaListScope } from "../../list/types/proforma-list-filters";
/** /**
* Prefijo base para listados * Prefijo base para listados
@ -11,9 +12,13 @@ export const DOCUMENT_SERIES_QUERY_KEY = ["customer-invoices", "document-series"
/** /**
* Query key para listado de proformas * Query key para listado de proformas
*/ */
export const LIST_PROFORMAS_QUERY_KEY = (criteria?: ProformasListRequestDTO): QueryKey => export const LIST_PROFORMAS_QUERY_KEY = (
scope: ProformaListScope,
criteria?: ProformasListRequestDTO
): QueryKey =>
[ [
...LIST_PROFORMAS_QUERY_KEY_PREFIX, ...LIST_PROFORMAS_QUERY_KEY_PREFIX,
scope,
{ {
pageNumber: criteria?.pageNumber ?? 1, pageNumber: criteria?.pageNumber ?? 1,
pageSize: criteria?.pageSize ?? 5, pageSize: criteria?.pageSize ?? 5,

View File

@ -7,10 +7,12 @@ import { getListProformasByCriteria } from "../api";
import type { ProformaList } from "../entities"; import type { ProformaList } from "../entities";
import { LIST_PROFORMAS_QUERY_KEY } from "./keys"; import { LIST_PROFORMAS_QUERY_KEY } from "./keys";
import type { ProformaListScope } from "../../list/types/proforma-list-filters";
export interface ProformasListQueryOptions { export interface ProformasListQueryOptions {
enabled?: boolean; enabled?: boolean;
criteria?: Partial<CriteriaDTO>; criteria?: Partial<CriteriaDTO>;
scope: ProformaListScope;
} }
export const useProformasListQuery = ( export const useProformasListQuery = (
@ -19,11 +21,12 @@ export const useProformasListQuery = (
const dataSource = useDataSource(); const dataSource = useDataSource();
const enabled = options?.enabled ?? true; const enabled = options?.enabled ?? true;
const criteria = options?.criteria ?? {}; const criteria = options?.criteria ?? {};
const scope = options?.scope ?? "normal";
return useQuery<ProformaList, DefaultError>({ return useQuery<ProformaList, DefaultError>({
queryKey: LIST_PROFORMAS_QUERY_KEY(criteria), queryKey: LIST_PROFORMAS_QUERY_KEY(scope, criteria),
queryFn: async ({ signal }) => { queryFn: async ({ signal }) => {
const dto = await getListProformasByCriteria(dataSource, { signal, criteria }); const dto = await getListProformasByCriteria(dataSource, { signal, criteria, scope });
return ListProformasAdapter.fromDto(dto); return ListProformasAdapter.fromDto(dto);
}, },
enabled, enabled,