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,
SidebarMenuButton,
SidebarMenuItem,
SidebarMenuSub,
SidebarMenuSubButton,
SidebarMenuSubItem,
} from "@repo/shadcn-ui/components";
import { cn } from "@repo/shadcn-ui/lib/utils";
import { ChevronDownIcon } from "lucide-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 {
sections: AppSidebarNavSection[];
@ -29,9 +36,11 @@ interface SidebarSectionAccentStyles {
}
export const AppSidebarNav = ({ sections }: AppSidebarNavProps) => {
const { pathname } = useLocation();
const [openSections, setOpenSections] = React.useState<Record<string, boolean>>(() =>
Object.fromEntries(sections.map((s) => [s.title, true]))
);
const [openItems, setOpenItems] = React.useState<Record<string, boolean>>({});
const sectionAccentStyles: Record<AppSidebarSectionAccent, SidebarSectionAccentStyles> = {
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 (
<nav className="space-y-2 pt-4">
{sections.map((section) => {
@ -121,32 +149,98 @@ export const AppSidebarNav = ({ sections }: AppSidebarNavProps) => {
<SidebarMenu className="px-1">
{section.items.map((item) => {
const Icon = item.icon;
const isActive = false;
const isActive = isRouteActive(item);
const hasChildren = Boolean(item.items?.length);
const isItemOpen = openItems[item.title] ?? isActive;
return (
<SidebarMenuItem key={item.href}>
<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>
}
/>
{hasChildren ? (
<Collapsible
onOpenChange={(open) =>
setOpenItems((current) => ({
...current,
[item.title]: open,
}))
}
open={isItemOpen}
>
<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={
<CollapsibleTrigger
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>
);
})}

View File

@ -23,8 +23,10 @@ import {
export interface AppSidebarNavItem {
title: string;
href: string;
icon: LucideIcon;
href?: string;
exact?: boolean;
items?: AppSidebarNavItem[];
}
export type AppSidebarSectionAccent = "blue" | "green" | "amber" | "violet";
@ -40,7 +42,32 @@ export const appSidebarNavSections: AppSidebarNavSection[] = [
title: "Ventas",
accent: "blue",
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: "Clientes", href: "/customers", icon: UsersIcon },
{ 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`.
- 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.
- `GET /proformas/archived`
## Endpoints
## Semántica
- `PATCH /proformas/:proforma_id/archive`
- `PATCH /proformas/:proforma_id/unarchive`
- Devuelve solo proformas con `archived_at IS NOT NULL`.
- 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
- return `404` when the proforma does not exist, belongs to another company, or is deleted
- 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
- `q`
- `status`
- `pageNumber`
- `pageSize`
- `orderBy`
- `order`
## Listing behavior
## UI
- `GET /proformas` excludes archived proformas by default through `filters[]=archived_at IS_NULL`
- the backend does not expose `archive_view` as a query param
- the frontend keeps `archiveView=active|archived|all` only as UI state and translates it to `Criteria filters[]`
- active view sends `archived_at IS_NULL`
- archived view sends `archived_at IS_NOT_NULL`
- all view sends no `archived_at` filter
- `q` always applies inside the scope produced by `filters[]`
## Notes
- `archived` is a visibility dimension, not a business status
- V1 does not support archiving `sent`, `approved`, or `issued`
- V1 does not add `archived_by` or `archive_reason`
- soft-deleted proformas stay excluded structurally by `deleted_at`
- Ruta frontend: `/proformas/archived`
- Reutiliza el listado compartido con scope `archived`.
- Mantiene selector de estado, paginación y orden.

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`
- `sent`, `approved`, `rejected` e `issued` devuelven conflicto
- `rejected` no se borra; queda reservado para una futura operacion de archivado
- 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`
- Devuelve solo proformas con `deleted_at IS NOT NULL`.
- Usa `paranoid: false` en persistencia para poder listarlas.
- La búsqueda `q` se ejecuta dentro del scope eliminado.
## Validaciones defensivas
## Query soportada
- si `linked_invoice_id` tiene valor, el borrado se bloquea
- si existe `issued_invoices.source_proforma_id = proforma.id`, el borrado se bloquea
## Contrato REST
- endpoint: `DELETE /proformas/:proforma_id`
- 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
- `q`
- `status`
- `pageNumber`
- `pageSize`
- `orderBy`
- `order`
## UI
- la accion `Eliminar` solo debe mostrarse para proformas `draft`
- en la vista de archivadas, `Eliminar` tambien debe mostrarse para `draft`
- la confirmacion debe advertir que la referencia no se reutilizara
- en esta fase la accion esta disponible en el listado activo y en archivadas para `draft`
- Ruta frontend: `/proformas/deleted`
- Vista read-only V1.
- No implementa restauración, purge ni force delete.

View File

@ -1,47 +1,26 @@
# Proforma List Contract
# Proformas activas
## UI model
## Endpoint
- 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
- `GET /proformas`
## 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`
- `status`
- `pageNumber`
- `pageSize`
- `orderBy`
- `order`
- `filters[]`
## Allowed filters
## UI
- `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
- Ruta frontend: `/proformas`
- Mantiene búsqueda, paginación, orden y selector de estado.
- No muestra selector visual de archivadas/todas.

View File

@ -4,6 +4,7 @@ import type { Collection, Result } from "@repo/rdx-utils";
import type { InvoiceStatus, Proforma } from "../../../domain";
import type { ProformaSummary } from "../models";
import type { ProformaListScope } from "../use-cases";
export interface IProformaRepository {
create(proforma: Proforma, transaction?: unknown): Promise<Result<void, Error>>;
@ -30,6 +31,7 @@ export interface IProformaRepository {
findByCriteriaInCompany(
companyId: UniqueID,
scope: ProformaListScope,
criteria: Criteria,
transaction: unknown
): 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 { ProformaSummary } from "../models";
import type { IProformaRepository } from "../repositories";
import type { ProformaListScope } from "../use-cases";
export interface IProformaFinder {
findProformaById(
@ -27,6 +28,7 @@ export interface IProformaFinder {
findProformasByCriteria(
companyId: UniqueID,
scope: ProformaListScope,
criteria: Criteria,
transaction?: unknown
): Promise<Result<Collection<ProformaSummary>, Error>>;
@ -61,9 +63,10 @@ export class ProformaFinder implements IProformaFinder {
async findProformasByCriteria(
companyId: UniqueID,
scope: ProformaListScope,
criteria: Criteria,
transaction?: unknown
): 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 { IProformaSummarySnapshotBuilder } from "../snapshot-builders";
export type ProformaListScope = "normal" | "archived" | "deleted";
type ListProformasUseCaseInput = {
companyId: UniqueID;
criteria: Criteria;
scope: ProformaListScope;
};
export class ListProformasUseCase {
@ -19,11 +22,16 @@ export class ListProformasUseCase {
) {}
public execute(params: ListProformasUseCaseInput) {
const { criteria, companyId } = params;
const { criteria, companyId, scope } = params;
return this.transactionManager.complete(async (transaction: unknown) => {
try {
const result = await this.finder.findProformasByCriteria(companyId, criteria, transaction);
const result = await this.finder.findProformasByCriteria(
companyId,
scope,
criteria,
transaction
);
if (result.isFailure) {
return Result.fail(result.error);

View File

@ -6,12 +6,11 @@ import {
} from "@erp/core/api";
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";
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;
@ -23,7 +22,10 @@ export class ListProformasController extends ExpressController {
"issued",
]);
public constructor(private readonly useCase: ListProformasUseCase) {
public constructor(
private readonly useCase: ListProformasUseCase,
private readonly scope: ProformaListScope
) {
super();
this.errorMapper = proformasApiErrorMapper;
@ -89,7 +91,7 @@ export class ListProformasController extends ExpressController {
}
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(
(data) =>

View File

@ -62,7 +62,30 @@ export const proformasRouter = (params: StartParams) => {
validateRequest(ListProformasRequestSchema, "query"),
async (req: Request, res: Response, next: NextFunction) => {
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);
}
);

View File

@ -26,6 +26,7 @@ import {
} from "sequelize";
import type { IProformaRepository, ProformaSummary } from "../../../../../application";
import type { ProformaListScope } from "../../../../../application";
import { INVOICE_STATUS, type InvoiceStatus, type Proforma } from "../../../../../domain";
import {
IssuedInvoiceModel,
@ -40,7 +41,6 @@ export class SequelizeProformaRepositoryV2
implements IProformaRepository
{
private static readonly ALLOWED_FILTERS = {
archived_at: new Set(["IS_NULL", "IS_NOT_NULL"]),
status: new Set(["EQUALS"]),
} as const;
@ -337,6 +337,7 @@ export class SequelizeProformaRepositoryV2
public async findByCriteriaInCompany(
companyId: UniqueID,
scope: ProformaListScope,
criteria: Criteria,
transaction: Transaction,
options: FindOptions<InferAttributes<ProformaModel>> = {}
@ -411,6 +412,7 @@ export class SequelizeProformaRepositoryV2
const baseWhere: WhereOptions<InferAttributes<ProformaModel>> = {
company_id: companyId.toString(),
};
const scopeWhere = this.buildScopeWhere(scope);
/**
* Composición defensiva del WHERE.
@ -421,6 +423,7 @@ export class SequelizeProformaRepositoryV2
const where: WhereOptions<InferAttributes<ProformaModel>> = {
[Op.and]: [
baseWhere,
scopeWhere,
...(options.where ? [options.where] : []),
...(criteriaQuery.where ? [criteriaQuery.where] : []),
],
@ -486,6 +489,7 @@ export class SequelizeProformaRepositoryV2
const findQuery: FindOptions<InferAttributes<ProformaModel>> = {
...options,
...criteriaQuery,
paranoid: scope !== "deleted",
where,
include,
order,
@ -502,6 +506,7 @@ export class SequelizeProformaRepositoryV2
* y `col` deben tiparse con `CountOptions`.
*/
const countQuery: CountOptions<InferAttributes<ProformaModel>> = {
paranoid: scope !== "deleted",
where,
include: getCountIncludes(include),
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,
} from "../../../../common";
import type { SequelizeProformaDomainMapper, SequelizeProformaSummaryMapper } from "../mappers";
import type { ProformaListScope } from "../../../../../application";
export class ProformaRepository
extends SequelizeRepository<Proforma>
@ -428,6 +429,7 @@ export class ProformaRepository
*/
public async findByCriteriaInCompany(
companyId: UniqueID,
_scope: ProformaListScope,
criteria: Criteria,
transaction: Transaction,
options: FindOptions<InferAttributes<CustomerInvoiceModel>> = {}

View File

@ -183,15 +183,16 @@
"description": "Manage your customer proformas",
"list": {
"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": {
"status": {
"label": "Status"
},
"archive_view": {
"active": "Active",
"archived": "Archived",
"all": "All"
}
},
"columns": {
@ -215,6 +216,14 @@
"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": {
"title": "New customer proforma",
"description": "Create a new customer proforma",

View File

@ -184,15 +184,16 @@
"description": "Gestiona tus proformas",
"list": {
"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": {
"status": {
"label": "Estado"
},
"archive_view": {
"active": "Activas",
"archived": "Archivadas",
"all": "Todas"
}
},
"columns": {
@ -216,6 +217,14 @@
"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": {
"title": "Nueva proforma",
"description": "Crear una nueva proforma",

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -13,9 +13,6 @@ import {
SelectItem,
SelectTrigger,
SelectValue,
Tabs,
TabsList,
TabsTrigger,
} from "@repo/shadcn-ui/components";
import {
CheckCircle2Icon,
@ -35,16 +32,59 @@ import { prepareDeleteProformaTargets } from "../../../delete/utils";
import { IssueProformaDialog } from "../../../issue-proforma";
import { prepareIssueProformaTarget } from "../../../issue-proforma/utils";
import type { ProformaListRow } from "../../../shared";
import type { ProformaListScope } from "../../types/proforma-list-filters";
import { useListProformasPageController } from "../../controllers";
import { ProformaSummaryPanel, ProformasGrid, useProformasGridColumns } from "../blocks";
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 navigate = useNavigate();
const scopeConfig = SCOPE_CONFIG[scope];
const { currentReturnTo } = useReturnToNavigation({
fallbackPath: "/proformas",
fallbackPath: scopeConfig.currentPath,
});
const {
@ -58,7 +98,7 @@ export const ListProformasPage = () => {
unarchiveDialogCtrl,
isPDFDownloading: isPdfDownloading,
pdfDownloadingId,
} = useListProformasPageController();
} = useListProformasPageController(scope);
const handleEditClick = (proformaId: string) => {
navigate({
@ -69,7 +109,7 @@ export const ListProformasPage = () => {
});
};
const columns = useProformasGridColumns({
const columns = useProformasGridColumns(scope, {
onEditClick: (proforma) => handleEditClick(proforma.id),
onIssueClick: (proformaRow) =>
issueDialogCtrl.openDialog(prepareIssueProformaTarget(proformaRow)),
@ -85,7 +125,7 @@ export const ListProformasPage = () => {
isPdfDownloading,
});
const isPanelOpen = panelCtrl.panelState.isOpen;
const isPanelOpen = scopeConfig.enablePanel && panelCtrl.panelState.isOpen;
const listContent = (
<div className="flex h-full min-w-0 flex-col gap-4 overflow-hidden">
@ -96,24 +136,6 @@ export const ListProformasPage = () => {
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
onValueChange={(value) => listCtrl.setStatusFilter(value ?? "all")}
value={listCtrl.statusFilter}
@ -142,12 +164,21 @@ export const ListProformasPage = () => {
onColumnVisibilityChange={listCtrl.tablePreferences.setColumnVisibility}
onPageChange={listCtrl.setPageIndex}
onPageSizeChange={listCtrl.setPageSize}
onRowClick={(proformaId) => panelCtrl.openProformaPanel(proformaId, "view")}
onRowClick={
scopeConfig.enablePanel
? (proformaId) => panelCtrl.openProformaPanel(proformaId, "view")
: undefined
}
onSortChange={listCtrl.setSort}
pageIndex={listCtrl.pageIndex}
pageSize={listCtrl.pageSize}
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 */}
<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">
{/* Header */}
<PageHeader
description={t("pages.proformas.list.description")}
description={t(scopeConfig.descriptionKey)}
rightSlot={
<Button
aria-label={t("pages.proformas.create.title")}
onClick={() =>
navigate({
pathname: "/proformas/create",
search: createSearchParams({
returnTo: currentReturnTo,
}).toString(),
})
}
size={"default"}
>
<PlusIcon aria-hidden className="mr-2 size-4" />
{t("pages.proformas.create.title")}
</Button>
scopeConfig.showCreateAction ? (
<Button
aria-label={t("pages.proformas.create.title")}
onClick={() =>
navigate({
pathname: "/proformas/create",
search: createSearchParams({
returnTo: currentReturnTo,
}).toString(),
})
}
size={"default"}
>
<PlusIcon aria-hidden className="mr-2 size-4" />
{t("pages.proformas.create.title")}
</Button>
) : undefined
}
title={t("pages.proformas.list.title")}
title={t(scopeConfig.titleKey)}
/>
{/* Stats Cards */}
@ -293,7 +326,7 @@ export const ListProformasPage = () => {
</div>
{/* Table */}
{isPanelOpen ? (
{scopeConfig.enablePanel && isPanelOpen ? (
<ResizablePanelGroup
autoSave="list-proformas-page"
className="h-full mx-auto w-full space-y-4"
@ -395,3 +428,9 @@ export const ListProformasPage = () => {
</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 { ProformaArchiveView, ProformaListStatusFilter } from "../types/proforma-list-filters";
import type { ProformaListStatusFilter } from "../types/proforma-list-filters";
type BuildListProformasCriteriaParams = {
archiveView: ProformaArchiveView;
status: ProformaListStatusFilter;
q: string;
pageNumber: number;
@ -12,18 +11,6 @@ type BuildListProformasCriteriaParams = {
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 [];
@ -35,7 +22,6 @@ function buildStatusFilters(status: ProformaListStatusFilter): NonNullable<Crite
export function buildListProformasCriteria(
params: BuildListProformasCriteriaParams
): CriteriaDTO {
const archiveFilters = buildArchiveFilters(params.archiveView);
const statusFilters = buildStatusFilters(params.status);
return {
@ -44,6 +30,6 @@ export function buildListProformasCriteria(
pageSize: params.pageSize,
orderBy: params.orderBy,
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 { ListProformasResponseDTO } from "../../../../common";
import type { ProformaListScope } from "../../list/types/proforma-list-filters";
/**
* Recupera una lista de proformas del sistema utilizando la
@ -15,6 +16,7 @@ import type { ListProformasResponseDTO } from "../../../../common";
export type ListProformasByCriteriaParams = {
criteria?: CriteriaDTO;
scope: ProformaListScope;
signal?: AbortSignal;
};
@ -24,8 +26,12 @@ export function getListProformasByCriteria(
dataSource: IDataSource,
params: ListProformasByCriteriaParams
): Promise<ListProformasResult> {
const { criteria, signal } = params || { criteria: undefined, signal: undefined };
return dataSource.getList<ListProformasResponseDTO>("proformas", {
const { criteria, scope, signal } = params;
const resource =
scope === "archived" ? "proformas/archived" : scope === "deleted" ? "proformas/deleted" : "proformas";
return dataSource.getList<ListProformasResponseDTO>(resource, {
signal,
...criteria,
});

View File

@ -1,6 +1,7 @@
import type { QueryKey } from "@tanstack/react-query";
import type { ProformasListRequestDTO } from "../../../../common";
import type { ProformaListScope } from "../../list/types/proforma-list-filters";
/**
* Prefijo base para listados
@ -11,9 +12,13 @@ export const DOCUMENT_SERIES_QUERY_KEY = ["customer-invoices", "document-series"
/**
* 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,
scope,
{
pageNumber: criteria?.pageNumber ?? 1,
pageSize: criteria?.pageSize ?? 5,

View File

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