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:
parent
a264f43b95
commit
1bf85a05ff
@ -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,10 +149,22 @@ 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}>
|
||||
{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",
|
||||
@ -133,7 +173,60 @@ export const AppSidebarNav = ({ sections }: AppSidebarNavProps) => {
|
||||
)}
|
||||
isActive={isActive}
|
||||
render={
|
||||
<Link to={item.href}>
|
||||
<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(
|
||||
@ -147,6 +240,7 @@ export const AppSidebarNav = ({ sections }: AppSidebarNavProps) => {
|
||||
</Link>
|
||||
}
|
||||
/>
|
||||
)}
|
||||
</SidebarMenuItem>
|
||||
);
|
||||
})}
|
||||
|
||||
@ -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 },
|
||||
|
||||
@ -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.
|
||||
|
||||
@ -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.
|
||||
|
||||
@ -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.
|
||||
|
||||
@ -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>>;
|
||||
|
||||
@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@ -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);
|
||||
|
||||
@ -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) =>
|
||||
|
||||
@ -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);
|
||||
}
|
||||
);
|
||||
|
||||
@ -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,
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -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>> = {}
|
||||
|
||||
@ -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",
|
||||
|
||||
@ -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",
|
||||
|
||||
@ -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 />,
|
||||
},
|
||||
],
|
||||
},
|
||||
|
||||
|
||||
@ -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();
|
||||
|
||||
@ -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,
|
||||
};
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -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,6 +445,7 @@ export function useProformasGridColumns(
|
||||
)}
|
||||
|
||||
{/* Descargar en PDF */}
|
||||
{canDownloadPdf && (
|
||||
<TooltipProvider>
|
||||
<Tooltip>
|
||||
<TooltipTrigger
|
||||
@ -455,9 +472,10 @@ export function useProformasGridColumns(
|
||||
<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]
|
||||
);
|
||||
}
|
||||
|
||||
@ -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,8 +231,9 @@ export const ListProformasPage = () => {
|
||||
<div className="p-6 space-y-6">
|
||||
{/* Header */}
|
||||
<PageHeader
|
||||
description={t("pages.proformas.list.description")}
|
||||
description={t(scopeConfig.descriptionKey)}
|
||||
rightSlot={
|
||||
scopeConfig.showCreateAction ? (
|
||||
<Button
|
||||
aria-label={t("pages.proformas.create.title")}
|
||||
onClick={() =>
|
||||
@ -217,8 +249,9 @@ export const ListProformasPage = () => {
|
||||
<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" />;
|
||||
|
||||
@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
@ -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,
|
||||
});
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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,
|
||||
|
||||
Loading…
Reference in New Issue
Block a user