Repaso de UI de PROFORMAS

This commit is contained in:
David Arranz 2026-04-05 20:17:47 +02:00
parent 772cf2a253
commit bd1266a6a2
28 changed files with 612 additions and 277 deletions

View File

@ -166,7 +166,7 @@
"noUnsafeOptionalChaining": "error",
"noUnusedLabels": "error",
"noUnusedVariables": "warn",
"useExhaustiveDependencies": "error",
"useExhaustiveDependencies": "info",
"useHookAtTopLevel": "error",
"useIsNan": "error",
"useJsxKeyInIterable": "error",

View File

@ -3,11 +3,11 @@ import { lazy } from "react";
import { Outlet, type RouteObject } from "react-router-dom";
const ProformaLayout = lazy(() =>
import("./proformas/shared2").then((m) => ({ default: m.ProformaLayout }))
import("./proformas/shared").then((m) => ({ default: m.ProformaLayout }))
);
const ProformasListPage = lazy(() =>
import("./proformas/list").then((m) => ({ default: m.ProformaListPage }))
import("./proformas/list").then((m) => ({ default: m.ListProformasPage }))
);
/*const ProformasCreatePage = lazy(() =>

View File

@ -2,9 +2,9 @@ import { showErrorToast, showSuccessToast } from "@repo/rdx-ui/helpers";
import * as React from "react";
import { useTranslation } from "../../../i18n";
import type { ProformaListRow } from "../../shared2";
import type { PROFORMA_STATUS } from "../../shared2/entities";
import { useChangeProformaStatusMutation } from "../../shared2/hooks";
import type { ProformaListRow } from "../../shared";
import type { PROFORMA_STATUS } from "../../shared/entities";
import { useChangeProformaStatusMutation } from "../../shared/hooks";
interface ChangeStatusDialogState {
open: boolean;

View File

@ -7,7 +7,7 @@ import {
XCircleIcon,
} from "lucide-react";
import type { ProformaStatus } from "../../shared2";
import type { ProformaStatus } from "../../shared";
export const getProformaStatusButtonVariant = (
status: ProformaStatus

View File

@ -11,7 +11,7 @@ import {
import { useState } from "react";
import { useTranslation } from "../../../i18n";
import { PROFORMA_STATUS, PROFORMA_STATUS_TRANSITIONS, type ProformaListRow } from "../../shared2";
import { PROFORMA_STATUS, PROFORMA_STATUS_TRANSITIONS, type ProformaListRow } from "../../shared";
import { getProformaStatusIcon } from "../helpers";
import { StatusNode, TimelineConnector } from "./components";

View File

@ -4,7 +4,7 @@ import { cn } from "@repo/shadcn-ui/lib/utils";
import { CheckCircle2, type LucideIcon } from "lucide-react";
import { useTranslation } from "../../../../i18n";
import type { PROFORMA_STATUS } from "../../../shared2";
import type { PROFORMA_STATUS } from "../../../shared";
interface StatusNodeProps {
status: PROFORMA_STATUS;

View File

@ -2,7 +2,7 @@ import { showErrorToast, showSuccessToast } from "@repo/rdx-ui/helpers";
import React from "react";
import { useTranslation } from "../../../i18n";
import { type ProformaListRow, useDeleteProformaMutation } from "../../shared2";
import { type ProformaListRow, useDeleteProformaMutation } from "../../shared";
interface DeleteProformaDialogState {
open: boolean;

View File

@ -10,7 +10,7 @@ import {
} from "@repo/shadcn-ui/components";
import { useTranslation } from "../../../../i18n";
import type { ProformaListRow } from "../../../shared2";
import type { ProformaListRow } from "../../../shared";
interface DeleteProformaDialogProps {
open: boolean;

View File

@ -1,2 +1,3 @@
export * from "./use-list-proformas.controller.ts";
export * from "./use-list-proformas-page.controller.ts";
export * from "./use-list-proformas.controller";
export * from "./use-list-proformas-page.controller";
export * from "./use-proforma-summary-panel.controller";

View File

@ -1,62 +1,24 @@
import React from "react";
import { useChangeProformaStatusDialogController } from "../../change-status";
import { useDeleteProformaDialogController } from "../../delete";
import { useProformaIssueDialogController } from "../../issue-proforma";
import type { ProformaListRow } from "../../shared2";
import {
type PROFORMA_STATUS,
PROFORMA_STATUS_TRANSITIONS,
} from "../../shared2/entities/proforma-status.entity";
import type { RightPanelMode } from "@repo/rdx-ui/hooks";
import { useSearchParams } from "react-router-dom";
import { useListProformasController } from "./use-list-proformas.controller";
import { useProformaSummaryPanelController } from "./use-proforma-summary-panel.controller";
export function useListProformasPageController() {
export const useListProformasPageController = () => {
const listCtrl = useListProformasController();
const [searchParams] = useSearchParams();
// Controlador de diálogos
const issueDialogCtrl = useProformaIssueDialogController();
const changeStatusDialogCtrl = useChangeProformaStatusDialogController();
const deleteDialogCtrl = useDeleteProformaDialogController();
const proformaId = searchParams.get("proformaId") ?? "";
const panelMode = (searchParams.get("panel") as RightPanelMode | null) ?? "view";
const handleOpenIssueProformaDialog = React.useCallback(
(proforma: ProformaListRow) => {
issueDialogCtrl.openDialog(proforma);
},
[issueDialogCtrl]
);
const handleOpenChangeProformaStatusDialog = React.useCallback(
(proforma: ProformaListRow, nextStatus: string) => {
const proforma_status = proforma.status as PROFORMA_STATUS;
const transitions = PROFORMA_STATUS_TRANSITIONS[proforma_status] ?? [];
if (!transitions.includes(nextStatus as PROFORMA_STATUS)) {
console.warn(`Transición inválida: ${proforma.status}${nextStatus}`);
return;
}
changeStatusDialogCtrl.openDialog([proforma], nextStatus as PROFORMA_STATUS);
},
[changeStatusDialogCtrl]
);
const handleOpenDeleteProformaDialog = React.useCallback(
(proforma: ProformaListRow) => {
deleteDialogCtrl.openDialog([proforma]);
},
[deleteDialogCtrl]
);
const panelCtrl = useProformaSummaryPanelController({
initialProformaId: proformaId,
initialMode: panelMode,
initialOpen: proformaId !== "",
});
return {
listCtrl,
issueDialogCtrl,
changeStatusDialogCtrl,
deleteDialogCtrl,
handleIssueProforma: handleOpenIssueProformaDialog,
handleChangeStatusProforma: handleOpenChangeProformaStatusDialog,
handleDeleteProforma: handleOpenDeleteProformaDialog,
panelCtrl,
};
}
};

View File

@ -1,50 +1,85 @@
import type { CriteriaDTO } from "@erp/core";
import { useDebounce } from "@repo/rdx-ui/components";
import { useMemo, useState } from "react";
import { useProformasListQuery } from "../../shared2";
import {
type ListProformasByCriteriaParams,
type ProformaList,
type ProformaStatus,
useProformasListQuery,
} from "../../shared";
type ProformaListStatusFilter = "all" | ProformaStatus;
const EMPTY_PROFORMAS_LIST: ProformaList = {
items: [],
page: 0,
perPage: 5,
totalPages: 0,
totalItems: 0,
};
export const useListProformasController = () => {
const [pageIndex, setPageIndex] = useState(0);
const [pageSize, setPageSize] = useState(10);
const [pageSize, setPageSize] = useState(5);
const [search, setSearch] = useState("");
const [status, setStatus] = useState("all");
const [statusFilter, setStatusFilter] = useState<ProformaListStatusFilter>("all");
const debouncedQ = useDebounce(search, 300);
const debouncedSearch = useDebounce(search, 300);
const criteria = useMemo<CriteriaDTO>(() => {
const baseFilters =
status === "all" ? [] : [{ field: "status", operator: "EQUALS", value: status }];
return {
q: debouncedQ || "",
pageSize,
const criteria = useMemo<NonNullable<ListProformasByCriteriaParams["criteria"]>>(
() => ({
q: debouncedSearch || "",
pageNumber: pageIndex,
pageSize,
order: "desc",
orderBy: "invoice_date",
filters: baseFilters,
};
}, [pageSize, pageIndex, debouncedQ, status]);
orderBy: "invoiceDate",
filters:
statusFilter === "all" ? [] : [{ field: "status", operator: "eq", value: statusFilter }],
}),
[debouncedSearch, pageIndex, pageSize, statusFilter]
);
const query = useProformasListQuery({ criteria });
const setStatusFilterValue = (value: string) => {
const nextValue = (value || "all") as ProformaListStatusFilter;
setStatusFilter((prev) => {
if (prev === nextValue) return prev;
// Sólo si la búsqueda realmente cambia,
// reseteamos la página a 0 para evitar inconsistencias
setPageIndex(0);
return nextValue;
});
};
const setSearchValue = (value: string) => {
setSearch(value.trim().replace(/\s+/g, " "));
setPageIndex(0);
const nextValue = value.trim().replace(/\s+/g, " ");
setSearch((prev) => {
if (prev === nextValue) return prev;
// Sólo si la búsqueda realmente cambia,
// reseteamos la página a 0 para evitar inconsistencias
setPageIndex(0);
return nextValue;
});
};
const setPageSizeValue = (value: number) => {
setPageSize(value);
setPageIndex(0);
};
setPageSize((prev) => {
if (prev === value) return prev;
const setStatusFilter = (newStatus: string) => {
setStatus(newStatus);
setPageIndex(0);
// Sólo si el tamaño de página realmente cambia,
// reseteamos la página a 0 para evitar inconsistencias
setPageIndex(0);
return value;
});
};
return {
data: query.data,
data: query.data ?? EMPTY_PROFORMAS_LIST,
isLoading: query.isLoading,
isFetching: query.isFetching,
@ -61,7 +96,7 @@ export const useListProformasController = () => {
search,
setSearchValue,
status,
setStatusFilter,
statusFilter,
setStatusFilter: setStatusFilterValue,
};
};

View File

@ -0,0 +1,49 @@
import { type RightPanelMode, useRightPanelState } from "@repo/rdx-ui/hooks";
import { useCallback, useState } from "react";
import { useProformaGetQuery } from "../../shared";
interface Options {
initialProformaId?: string;
initialMode?: RightPanelMode;
initialOpen?: boolean;
}
export const useProformaSummaryPanelController = ({
initialProformaId = "",
initialMode = "view",
initialOpen = false,
}: Options = {}) => {
const [proformaId, setProformaId] = useState(initialProformaId);
const panelState = useRightPanelState({
defaultMode: initialMode,
defaultVisibility: initialOpen ? "visible" : "hidden",
});
const query = useProformaGetQuery({
id: proformaId,
enabled: Boolean(proformaId),
});
const openProformaPanel = useCallback(
(id: string, mode: RightPanelMode = "view") => {
setProformaId(id);
panelState.open(mode);
},
[panelState.open]
);
const closePanel = useCallback(() => {
panelState.close();
setProformaId("");
}, [panelState.close]);
return {
proforma: query.data,
proformaId,
openProformaPanel,
closePanel,
panelState,
};
};

View File

@ -1 +1,3 @@
export * from "./proformas-grid";
export * from "./proforma-summary-panel/proforma-summary-panel";
export * from "./proformas-grid/proformas-grid";
export * from "./proformas-grid/use-proforma-grid-columns";

View File

@ -0,0 +1 @@
export * from "./proforma-summary-panel";

View File

@ -0,0 +1,27 @@
import { Button } from "@repo/shadcn-ui/components";
import type { Proforma } from "../../../../shared";
export const ProformaFooterActions = ({
proforma,
onChangeStatus,
onEdit,
}: {
proforma: Proforma;
onChangeStatus?: (proforma: Proforma) => void;
onEdit?: (proforma: Proforma) => void;
}) => {
return (
<div className="border-t p-4">
<div className="flex gap-2">
<Button className="flex-1" onClick={() => onChangeStatus?.(proforma)} variant="outline">
Cambiar de estado
</Button>
<Button className="flex-1" onClick={() => onEdit?.(proforma)}>
Editar proforma
</Button>
</div>
</div>
);
};

View File

@ -0,0 +1,69 @@
import { Avatar, AvatarFallback, Badge } from "@repo/shadcn-ui/components";
import { Building2Icon, CopyIcon, UserIcon } from "lucide-react";
import type { Proforma } from "../../../../shared";
import { Initials, ProformaStatusBadge } from "../../components";
export const ProformaHeader = ({ proforma }: { proforma: Proforma }) => {
const handleCopyTin = async () => {
try {
await navigator.clipboard.writeText(proforma.recipient.tin);
} catch {
// Silencio o toast fuera de este componente
}
};
return (
<div className="p-4">
<div className="flex items-start gap-4">
<Avatar className="size-10 border-2 border-background shadow-sm">
<AvatarFallback
className={
proforma.status !== "draft"
? "bg-blue-100 text-blue-700"
: "bg-muted text-muted-foreground"
}
>
<Initials name={proforma.recipient.name} />
</AvatarFallback>
</Avatar>
<div className="min-w-0 flex-1">
<div className="flex items-center gap-2">
<h2 className="truncate text-lg font-semibold">{proforma.recipient.name}</h2>
<ProformaStatusBadge status={proforma.status} />
</div>
{proforma.description && (
<p className="truncate text-sm text-muted-foreground">{proforma.description}</p>
)}
<div className="mt-2 flex items-center gap-2">
<Badge className="gap-1" variant="outline">
{proforma.isProforma ? (
<>
<Building2Icon className="size-3" />
Empresa
</>
) : (
<>
<UserIcon className="size-3" />
Particular
</>
)}
</Badge>
<button
className="flex items-center gap-1 text-xs font-mono text-muted-foreground hover:text-foreground"
onClick={handleCopyTin}
type="button"
>
{proforma.recipient.tin}
<CopyIcon className="size-3" />
</button>
</div>
</div>
</div>
</div>
);
};

View File

@ -0,0 +1,28 @@
import { Separator } from "@repo/shadcn-ui/components";
import type { Proforma } from "../../../../shared";
import { ProformaFooterActions } from "./proforma-footer-actions";
import { ProformaHeader } from "./proforma-header";
interface ProformaSummaryContentProps {
proforma: Proforma;
onEdit?: (proforma: Proforma) => void;
onChangeStatus?: (proforma: Proforma) => void;
}
export const ProformaSummaryContent = ({
proforma,
onEdit,
onChangeStatus,
}: ProformaSummaryContentProps) => {
return (
<div className="flex flex-col">
<ProformaHeader proforma={proforma} />
<Separator />
<ProformaFooterActions onChangeStatus={onChangeStatus} onEdit={onEdit} proforma={proforma} />
</div>
);
};

View File

@ -0,0 +1,114 @@
import { RightPanel } from "@repo/rdx-ui/components";
import type { RightPanelMode, RightPanelVisibility } from "@repo/rdx-ui/hooks";
import { Button } from "@repo/shadcn-ui/components";
import { cn } from "@repo/shadcn-ui/lib/utils";
import { PencilIcon } from "lucide-react";
import type { Proforma } from "../../../../shared";
import { ProformaSummaryContent } from "./proforma-summary-content";
const mockERPData = {
totalPurchases: 45750.8,
purchasesThisYear: 12350.25,
lastInvoices: [
{
id: "1",
number: "FAC-2024-0156",
date: "2024-01-15",
amount: 1250.0,
status: "paid" as const,
},
{
id: "2",
number: "FAC-2024-0142",
date: "2024-01-08",
amount: 890.5,
status: "paid" as const,
},
{
id: "3",
number: "FAC-2024-0128",
date: "2023-12-22",
amount: 2100.0,
status: "pending" as const,
},
{
id: "4",
number: "FAC-2023-0098",
date: "2023-11-30",
amount: 750.25,
status: "overdue" as const,
},
],
};
interface ProformaSummaryPanelProps {
proforma?: Proforma;
open: boolean;
visibility: RightPanelVisibility;
mode: RightPanelMode;
onOpenChange: (open: boolean) => void;
onEdit?: (proforma: Proforma) => void;
onChangeStatus?: (proforma: Proforma) => void;
className?: string;
}
export const ProformaSummaryPanel = ({
proforma,
open,
visibility,
mode,
onOpenChange,
onEdit,
onChangeStatus,
className,
}: ProformaSummaryPanelProps) => {
const titleMap: Record<RightPanelMode, string> = {
view: "Vista previa de proforma",
edit: "Editar proforma",
create: "Nuevo proforma",
};
return (
<RightPanel
className={cn("bg-transparent", className)}
headerActions={
<>
{proforma ? (
<Button
aria-label="Editar cliente"
onClick={() => onEdit?.(proforma)}
size="icon-sm"
variant="ghost"
>
<PencilIcon className="size-4" />
</Button>
) : null}
</>
}
onOpenChange={onOpenChange}
open={open}
title={titleMap[mode]}
>
{proforma ? (
<ProformaSummaryContent
onChangeStatus={onChangeStatus}
onEdit={onEdit}
proforma={proforma}
/>
) : (
<div className="flex h-full items-center justify-center p-4">
<p className="text-sm text-muted-foreground">Selecciona una proforma</p>
</div>
)}
</RightPanel>
);
};

View File

@ -1,13 +1,13 @@
import { DataTable, SkeletonDataTable } from "@repo/rdx-ui/components";
import type { ColumnDef } from "@tanstack/react-table";
import { useNavigate } from "react-router-dom";
import { useTranslation } from "../../../../../i18n";
import type { ProformaList, ProformaListRow } from "../../../../shared2";
import type { ProformaList, ProformaListRow } from "../../../../shared";
interface ProformasGridProps {
data?: ProformaList;
loading: boolean;
fetching?: boolean;
columns: ColumnDef<ProformaListRow, unknown>[];
@ -29,15 +29,14 @@ export const ProformasGrid = ({
onPageSizeChange,
onRowClick,
}: ProformasGridProps) => {
const navigate = useNavigate();
const { t } = useTranslation();
const { items, total_items } = data || { items: [], total_items: 0 };
const { items, totalItems } = data || { items: [], totalItems: 0 };
if (loading)
return (
<SkeletonDataTable
columns={columns.length}
footerProps={{ pageIndex, pageSize, totalItems: total_items ?? 0 }}
footerProps={{ pageIndex, pageSize, totalItems: totalItems ?? 0 }}
rows={Math.max(6, pageSize)}
showFooter
/>
@ -55,7 +54,7 @@ export const ProformasGrid = ({
onRowClick={(row, _index) => onRowClick?.(row.id)}
pageIndex={pageIndex}
pageSize={pageSize}
totalItems={total_items}
totalItems={totalItems}
/>
);
};

View File

@ -21,14 +21,20 @@ import {
PROFORMA_STATUS_TRANSITIONS,
type ProformaListRow,
type ProformaStatus,
} from "../../../../shared2";
} from "../../../../shared";
import { ProformaStatusBadge } from "../../components";
type GridActionHandlers = {
onPreviewClick?: (proforma: ProformaListRow) => void;
onEditClick?: (proforma: ProformaListRow) => void;
onIssueClick?: (proforma: ProformaListRow) => void;
onChangeStatusClick?: (proforma: ProformaListRow, nextStatus: string) => void;
onDeleteClick?: (proforma: ProformaListRow) => void;
onChangeStatusClick?: (proforma: ProformaListRow, nextStatus: ProformaStatus) => void;
onLinkedInvoiceClick?: (proforma: ProformaListRow) => void;
getNextStatus?: (proforma: ProformaListRow) => ProformaStatus | null;
canIssue?: (proforma: ProformaListRow) => boolean;
canEdit?: (proforma: ProformaListRow) => boolean;
canDelete?: (proforma: ProformaListRow) => boolean;
};
export function useProformasGridColumns(
@ -61,7 +67,7 @@ export function useProformasGridColumns(
enableHiding: false,
},*/
{
accessorKey: "invoice_number",
accessorKey: "invoiceNumber",
header: ({ column }) => {
return (
<Button
@ -74,7 +80,7 @@ export function useProformasGridColumns(
</Button>
);
},
cell: ({ row }) => <div className="font-medium">{row.getValue("invoice_number")}</div>,
cell: ({ row }) => <div className="font-medium">{row.getValue("invoiceNumber")}</div>,
},
// Estado
@ -85,7 +91,7 @@ export function useProformasGridColumns(
const proforma = row.original;
const isIssued = proforma.status === "issued";
const invoiceId = proforma.linked_invoice_id;
const invoiceId = proforma.linkedInvoiceId;
return (
<div className="flex items-center gap-2">
@ -99,13 +105,12 @@ export function useProformasGridColumns(
<Button
asChild
className="size-6 text-foreground hover:text-primary"
onClick={() => actionHandlers.onLinkedInvoiceClick?.(proforma)}
size="icon"
variant="ghost"
>
<a href={`/facturas/${invoiceId}`}>
<ExternalLinkIcon />
<span className="sr-only">Ver factura {invoiceId}</span>
</a>
<ExternalLinkIcon />
<span className="sr-only">Ver factura {invoiceId}</span>
</Button>
</TooltipTrigger>
<TooltipContent>Ver factura {invoiceId}</TooltipContent>
@ -119,7 +124,7 @@ export function useProformasGridColumns(
// Cliente
{
accessorKey: "client_name",
accessorKey: "recipientName",
header: ({ column }) => {
return (
<Button
@ -136,12 +141,15 @@ export function useProformasGridColumns(
const proforma = row.original;
return (
<div>
<a
<button
className="text-primary hover:underline font-semibold"
href={`/customers/${proforma.customer_id}`}
onClick={() => actionHandlers.onPreviewClick?.(proforma)}
type="button"
>
{proforma.recipient.name}
</a>
</button>
<br />
<div className="text-xs text-muted-foreground">{proforma.recipient.tin}</div>
</div>
);
@ -156,7 +164,7 @@ export function useProformasGridColumns(
header: "Reference",
},
{
accessorKey: "invoice_date",
accessorKey: "invoiceDate",
header: ({ column }) => {
return (
<Button
@ -171,7 +179,7 @@ export function useProformasGridColumns(
},
},
{
accessorKey: "operation_date",
accessorKey: "operationDate",
header: ({ column }) => {
return (
<Button
@ -186,32 +194,32 @@ export function useProformasGridColumns(
},
},
{
accessorKey: "subtotal_amount_fmt",
accessorKey: "subtotalAmountFmt",
header: () => <div className="text-right">Subtotal</div>,
cell: ({ row }) => (
<div className="text-right tabular-nums">{row.getValue("subtotal_amount_fmt")}</div>
<div className="text-right tabular-nums">{row.getValue("subtotalAmountFmt")}</div>
),
},
{
accessorKey: "discount_amount_fmt",
accessorKey: "discountAmountFmt",
header: () => <div className="text-right">Descuentos</div>,
cell: ({ row }) => (
<div className="text-right tabular-nums">{row.getValue("discount_amount_fmt")}</div>
<div className="text-right tabular-nums">{row.getValue("discountAmountFmt")}</div>
),
},
{
accessorKey: "taxes_amount_fmt",
accessorKey: "taxesAmountFmt",
header: () => <div className="text-right">Impuestos</div>,
cell: ({ row }) => (
<div className="text-right tabular-nums">{row.getValue("taxes_amount_fmt")}</div>
<div className="text-right tabular-nums">{row.getValue("taxesAmountFmt")}</div>
),
},
{
accessorKey: "total_amount_fmt",
accessorKey: "totalAmountFmt",
header: () => <div className="text-right">Importe total</div>,
cell: ({ row }) => (
<div className="text-right tabular-nums font-medium">
{row.getValue("total_amount_fmt")}
{row.getValue("totalAmountFmt")}
</div>
),
},

View File

@ -1 +1,2 @@
export * from "./initials";
export * from "./proforma-status-badge";

View File

@ -0,0 +1,4 @@
export const Initials = ({ name }: { name: string }) => {
const parts = name.trim().split(/\s+/).slice(0, 2);
return <> {parts.map((p) => p[0]?.toUpperCase() ?? "").join("") || "?"} </>;
};

View File

@ -7,10 +7,10 @@ import {
getProformaStatusColor,
getProformaStatusIcon,
} from "../../../change-status/helpers";
import type { ProformaStatus } from "../../../shared2";
import type { ProformaStatus } from "../../../shared";
export type ProformaStatusBadgeProps = {
status: string | ProformaStatus; // permitir cualquier valor
status: ProformaStatus;
className?: string;
};

View File

@ -1 +1 @@
export * from "./proforma-list-page";
export * from "./list-proformas-page";

View File

@ -0,0 +1,177 @@
import { ErrorAlert, PageHeader, SimpleSearchInput } from "@erp/core/components";
import { AppContent, AppHeader, BackHistoryButton } from "@repo/rdx-ui/components";
import {
Button,
ResizableHandle,
ResizablePanel,
ResizablePanelGroup,
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@repo/shadcn-ui/components";
import { FilterIcon, PlusIcon } from "lucide-react";
import { useNavigate } from "react-router-dom";
import { useTranslation } from "../../../../i18n";
import { ChangeStatusDialog } from "../../../change-status";
import { ProformaIssueDialog } from "../../../issue-proforma";
import { useListProformasPageController } from "../../controllers";
import { ProformaSummaryPanel, ProformasGrid, useProformasGridColumns } from "../blocks";
export const ListProformasPage = () => {
const { t } = useTranslation();
const navigate = useNavigate();
const { listCtrl, panelCtrl } = useListProformasPageController();
const columns = useProformasGridColumns({
onEditClick: (proforma) => navigate(`/proformas/${proforma.id}/edit`),
onIssueClick: handleIssueProforma,
onDeleteClick: handleDeleteProforma,
onChangeStatusClick: handleChangeStatusProforma,
});
const isPanelOpen = panelCtrl.panelState.isOpen;
const listContent = (
<div className="flex h-full min-w-0 flex-col gap-4 overflow-hidden">
<div className="flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-between">
<SimpleSearchInput
loading={listCtrl.isLoading}
onSearchChange={listCtrl.setSearchValue}
value={listCtrl.search}
/>
<Select onValueChange={listCtrl.setStatusFilter} value={listCtrl.statusFilter}>
<SelectTrigger className="w-full sm:w-48">
<FilterIcon aria-hidden className="mr-2 size-4" />
<SelectValue placeholder={t("filters.status")} />
</SelectTrigger>
<SelectContent>
<SelectItem value="all">{t("catalog.proformas.status.all.label")}</SelectItem>
<SelectItem value="draft">{t("catalog.proformas.status.draft.label")}</SelectItem>
<SelectItem value="sent">{t("catalog.proformas.status.sent.label")}</SelectItem>
<SelectItem value="approved">{t("catalog.proformas.status.approved.label")}</SelectItem>
<SelectItem value="rejected">{t("catalog.proformas.status.rejected.label")}</SelectItem>
<SelectItem value="issued">{t("catalog.proformas.status.issued.label")}</SelectItem>
</SelectContent>
</Select>
</div>
<div className="min-h-0 flex-1 overflow-auto">
<ProformasGrid
columns={columns}
data={listCtrl.data}
fetching={listCtrl.isFetching}
loading={listCtrl.isLoading}
onPageChange={listCtrl.setPageIndex}
onPageSizeChange={listCtrl.setPageSize}
onRowClick={(proformaId) => panelCtrl.openProformaPanel(proformaId, "view")}
pageIndex={listCtrl.pageIndex}
pageSize={listCtrl.pageSize}
/>
</div>
</div>
);
if (listCtrl.isError) {
return (
<AppContent>
<ErrorAlert
message={(listCtrl.error as Error)?.message || "Error al cargar el listado"}
title={t("pages.proformas.list.loadErrorTitle")}
/>
<BackHistoryButton />
</AppContent>
);
}
return (
<>
<AppHeader>
<PageHeader
description={t("pages.proformas.list.description")}
rightSlot={
<Button
aria-label={t("pages.proformas.create.title")}
onClick={() => navigate("/proformas/create")}
>
<PlusIcon aria-hidden className="mr-2 size-4" />
{t("pages.proformas.create.title")}
</Button>
}
title={t("pages.proformas.list.title")}
/>
</AppHeader>
<AppContent>
{isPanelOpen ? (
<ResizablePanelGroup
autoSave="list-proformas-page"
className="h-full"
orientation="horizontal"
>
<ResizablePanel defaultSize="70%" maxSize="75%" minSize="70%">
{listContent}
</ResizablePanel>
<ResizableHandle className="mx-4" withHandle />
<ResizablePanel defaultSize="30%" maxSize="30%" minSize="25%">
<div className="h-full">
<ProformaSummaryPanel
className="border bg-background"
mode={panelCtrl.panelState.mode}
onEdit={(proforma) => navigate(`/proformas/${proforma.id}/edit`)}
onOpenChange={(open) => {
if (!open) {
panelCtrl.closePanel();
return;
}
panelCtrl.panelState.onOpenChange(true);
}}
open={panelCtrl.panelState.isOpen}
proforma={panelCtrl.proforma}
visibility={panelCtrl.panelState.visibility}
/>
</div>
</ResizablePanel>
</ResizablePanelGroup>
) : (
<div className="flex min-h-0 flex-1 overflow-hidden">{listContent}</div>
)}
<>
{/* Emitir factura */}
<ProformaIssueDialog
isSubmitting={issueDialogCtrl.isSubmitting}
onConfirm={issueDialogCtrl.confirmIssue}
onOpenChange={(open) => !open && issueDialogCtrl.closeDialog()}
open={issueDialogCtrl.open}
proforma={issueDialogCtrl.proforma}
/>
{/* Cambiar estado */}
<ChangeStatusDialog
isSubmitting={changeStatusDialogCtrl.isSubmitting}
onConfirm={changeStatusDialogCtrl.confirmChangeStatus}
onOpenChange={(open) => !open && changeStatusDialogCtrl.closeDialog()}
open={changeStatusDialogCtrl.open}
proformas={changeStatusDialogCtrl.proformas} // ← recibe el status seleccionado
/>
{/* Eliminar */}
<DeleteProformaDialog
isSubmitting={deleteDialogCtrl.isSubmitting}
onConfirm={deleteDialogCtrl.confirmDelete}
onOpenChange={(open) => !open && deleteDialogCtrl.closeDialog()}
open={deleteDialogCtrl.open}
proformas={deleteDialogCtrl.proformas}
requireSecondConfirm={true}
/>
</>
</AppContent>
</>
);
};

View File

@ -1,151 +0,0 @@
import { ErrorAlert, PageHeader, SimpleSearchInput } from "@erp/core/components";
import { AppContent, AppHeader, BackHistoryButton } from "@repo/rdx-ui/components";
import {
Button,
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@repo/shadcn-ui/components";
import { FilterIcon, PlusIcon } from "lucide-react";
import { useNavigate } from "react-router-dom";
import { useTranslation } from "../../../../i18n";
import { ChangeStatusDialog } from "../../../change-status";
import { DeleteProformaDialog } from "../../../delete/ui/components";
import { ProformaIssueDialog } from "../../../issue-proforma";
import { useListProformasPageController } from "../../controllers";
import { ProformasGrid, useProformasGridColumns } from "../blocks";
export const ProformaListPage = () => {
const { t } = useTranslation();
const navigate = useNavigate();
const {
listCtrl,
issueDialogCtrl,
changeStatusDialogCtrl,
deleteDialogCtrl,
handleChangeStatusProforma,
handleDeleteProforma,
handleIssueProforma,
} = useListProformasPageController();
const columns = useProformasGridColumns({
onEditClick: (proforma) => navigate(`/proformas/${proforma.id}/edit`),
onIssueClick: handleIssueProforma,
onDeleteClick: handleDeleteProforma,
onChangeStatusClick: handleChangeStatusProforma,
});
if (listCtrl.isError) {
return (
<AppContent>
<ErrorAlert
message={(listCtrl.error as Error)?.message || "Error al cargar el listado"}
title={t("pages.proformas.list.loadErrorTitle")}
/>
<BackHistoryButton />
</AppContent>
);
}
return (
<>
<AppHeader>
<PageHeader
description={t("pages.proformas.list.description")}
rightSlot={
<Button
aria-label={t("pages.proformas.create.title")}
onClick={() => navigate("/proformas/create")}
>
<PlusIcon aria-hidden className="mr-2 size-4" />
{t("pages.proformas.create.title")}
</Button>
}
title={t("pages.proformas.list.title")}
/>
</AppHeader>
<AppContent>
<div className="flex min-h-0 flex-1 overflow-hidden">
{/* Search and filters */}
<div className="h-full min-w-0 overflow-auto w-full">
<div className="flex items-center justify-between gap-16">
<SimpleSearchInput
loading={listCtrl.isLoading}
onSearchChange={listCtrl.setSearchValue}
value={listCtrl.search}
/>
<Select defaultValue="all" onValueChange={listCtrl.setStatusFilter}>
<SelectTrigger className="w-full sm:w-48">
<FilterIcon aria-hidden className="mr-2 size-4" />
<SelectValue placeholder={t("filters.status")} />
</SelectTrigger>
<SelectContent>
<SelectItem value="all">{t("catalog.proformas.status.all.label")}</SelectItem>
<SelectItem value="draft">{t("catalog.proformas.status.draft.label")}</SelectItem>
<SelectItem value="sent">{t("catalog.proformas.status.sent.label")}</SelectItem>
<SelectItem value="approved">
{t("catalog.proformas.status.approved.label")}
</SelectItem>
<SelectItem value="rejected">
{t("catalog.proformas.status.rejected.label")}
</SelectItem>
<SelectItem value="issued">
{t("catalog.proformas.status.issued.label")}
</SelectItem>
</SelectContent>
</Select>
</div>
<ProformasGrid
columns={columns}
data={listCtrl.data}
loading={listCtrl.isLoading}
onPageChange={listCtrl.setPageIndex}
onPageSizeChange={listCtrl.setPageSize}
pageIndex={listCtrl.pageIndex}
pageSize={listCtrl.pageSize}
// acciones rápidas del grid → page controller
//onRowClick={(id) => navigate(`/proformas/${id}`)}
/>
</div>
{/* Emitir factura */}
<ProformaIssueDialog
isSubmitting={issueDialogCtrl.isSubmitting}
onConfirm={issueDialogCtrl.confirmIssue}
onOpenChange={(open) => !open && issueDialogCtrl.closeDialog()}
open={issueDialogCtrl.open}
proforma={issueDialogCtrl.proforma}
/>
{/* Cambiar estado */}
<ChangeStatusDialog
isSubmitting={changeStatusDialogCtrl.isSubmitting}
onConfirm={changeStatusDialogCtrl.confirmChangeStatus}
onOpenChange={(open) => !open && changeStatusDialogCtrl.closeDialog()}
open={changeStatusDialogCtrl.open}
proformas={changeStatusDialogCtrl.proformas} // ← recibe el status seleccionado
/>
{/* Eliminar */}
<DeleteProformaDialog
isSubmitting={deleteDialogCtrl.isSubmitting}
onConfirm={deleteDialogCtrl.confirmDelete}
onOpenChange={(open) => !open && deleteDialogCtrl.closeDialog()}
open={deleteDialogCtrl.open}
proformas={deleteDialogCtrl.proformas}
requireSecondConfirm={true}
/>
</div>
</AppContent>
</>
);
};

View File

@ -8,7 +8,7 @@ import { useNavigate } from "react-router-dom";
import { useTranslation } from "../../../i18n";
import { ProformaDtoAdapter } from "../../adapters";
import { useUpdateProforma } from "../../shared2/hooks/use-proforma-update-mutation";
import { useUpdateProforma } from "../../shared/hooks/use-proforma-update-mutation";
import {
type Proforma,
type ProformaFormData,

View File

@ -11,4 +11,13 @@ export enum PROFORMA_STATUS {
ISSUED = "issued",
}
// Transiciones válidas según reglas del dominio
export const PROFORMA_STATUS_TRANSITIONS: Record<PROFORMA_STATUS, PROFORMA_STATUS[]> = {
[PROFORMA_STATUS.DRAFT]: [PROFORMA_STATUS.SENT],
[PROFORMA_STATUS.SENT]: [PROFORMA_STATUS.APPROVED, PROFORMA_STATUS.REJECTED],
[PROFORMA_STATUS.APPROVED]: [PROFORMA_STATUS.ISSUED, PROFORMA_STATUS.DRAFT],
[PROFORMA_STATUS.REJECTED]: [PROFORMA_STATUS.DRAFT],
[PROFORMA_STATUS.ISSUED]: [],
};
export type ProformaStatus = `${PROFORMA_STATUS}`;