feat: add proforma detail page and related components

- Exported detail components in proformas index.
- Enhanced proforma grid with view action.
- Implemented navigation to proforma detail page.
- Created proforma detail controller for data fetching and actions.
- Developed proforma detail page with various inspector cards for displaying information.
- Added reusable ProformaPageHeader component for consistent header layout.
- Introduced FieldValueList component for displaying key-value pairs.
- Implemented PDFViewer component for previewing proforma PDFs.
This commit is contained in:
David Arranz 2026-07-30 21:04:26 +02:00
parent 451a1013d0
commit 1506a78582
18 changed files with 1187 additions and 11 deletions

View File

@ -26,6 +26,10 @@ const ProformaUpdatePage = lazy(() =>
import("./proformas/update").then((m) => ({ default: m.ProformaUpdatePage })) import("./proformas/update").then((m) => ({ default: m.ProformaUpdatePage }))
); );
const ProformaDetailPage = lazy(() =>
import("./proformas/detail").then((m) => ({ default: m.ProformaDetailPage }))
);
const IssuedInvoicesLayout = lazy(() => const IssuedInvoicesLayout = lazy(() =>
import("./issued-invoices/shared/ui").then((m) => ({ default: m.IssuedInvoicesLayout })) import("./issued-invoices/shared/ui").then((m) => ({ default: m.IssuedInvoicesLayout }))
); );
@ -93,6 +97,19 @@ export const CustomerInvoiceRoutes = (params: ModuleClientParams): RouteObject[]
), ),
}, },
{
path: "proformas/:id",
handle: {
layout: "app-fullscreen",
protected: true,
},
element: (
<ProformaLayout>
<ProformaDetailPage />
</ProformaLayout>
),
},
{ {
path: "customer-invoices", path: "customer-invoices",
handle: { handle: {

View File

@ -1,7 +1,9 @@
import { PageFormHeader, PageKeyboardShortcutsButton } from "@erp/core/components"; import { PageKeyboardShortcutsButton } from "@erp/core/components";
import { CancelActionButton, FormActionsBar, RhfSubmitActionButton } from "@repo/rdx-ui/components"; import { CancelActionButton, FormActionsBar, RhfSubmitActionButton } from "@repo/rdx-ui/components";
import type { ReactNode } from "react"; import type { ReactNode } from "react";
import { ProformaPageHeader } from "../../../shared";
export interface ProformaCreateHeaderProps { export interface ProformaCreateHeaderProps {
formId: string; formId: string;
isSaving: boolean; isSaving: boolean;
@ -16,7 +18,7 @@ export const ProformaCreateHeader = ({
children, children,
}: ProformaCreateHeaderProps) => { }: ProformaCreateHeaderProps) => {
return ( return (
<PageFormHeader <ProformaPageHeader
actions={ actions={
<FormActionsBar align="end" reverseOnMobile={false}> <FormActionsBar align="end" reverseOnMobile={false}>
<PageKeyboardShortcutsButton <PageKeyboardShortcutsButton
@ -45,11 +47,10 @@ export const ProformaCreateHeader = ({
/> />
</FormActionsBar> </FormActionsBar>
} }
backLabel="Volver"
onBack={onCancel} onBack={onCancel}
title="Nueva proforma" title="Nueva proforma"
> >
{children} {children}
</PageFormHeader> </ProformaPageHeader>
); );
}; };

View File

@ -0,0 +1 @@
export * from "./use-proforma-detail-page-controller";

View File

@ -0,0 +1,250 @@
import { useReturnToNavigation } from "@erp/core/hooks";
import {
getPaymentMethodOptions,
usePaymentMethodsListQuery,
} from "@erp/catalogs/client/payment-methods";
import {
getPaymentTermOptions,
usePaymentTermsListQuery,
} from "@erp/catalogs/client/payment-terms";
import { showErrorToast, showSuccessToast } from "@repo/rdx-ui/helpers";
import { useEffect, useMemo, useState } from "react";
import { createSearchParams, useNavigate, useParams } from "react-router-dom";
import { useTranslation } from "../../../i18n";
import { useDownloadProformaPDFController, useDownloadProformaPDFQuery } from "../../download-pdf";
import { useIssueProformaDialogController } from "../../issue-proforma";
import { prepareIssueProformaTarget } from "../../issue-proforma/utils";
import {
useDuplicateProformaByIdMutation,
useProformaArchiveMutation,
useProformaGetQuery,
useProformaUnarchiveMutation,
} from "../../shared";
const PROFORMAS_FALLBACK_PATH = "/proformas";
const ACTIVE_ONLY_FILTERS = [
{
field: "isActive",
operator: "EQUALS" as const,
value: "true",
},
];
export const useProformaDetailPageController = () => {
const { t } = useTranslation();
const navigate = useNavigate();
const { id = "" } = useParams<{ id: string }>();
const [previewZoom, setPreviewZoom] = useState("fit");
const { currentReturnTo, navigateBack } = useReturnToNavigation({
fallbackPath: PROFORMAS_FALLBACK_PATH,
});
const proformaQuery = useProformaGetQuery({
id,
enabled: Boolean(id),
});
const issueDialogCtrl = useIssueProformaDialogController();
const downloadCtrl = useDownloadProformaPDFController();
const previewPdfQuery = useDownloadProformaPDFQuery(id, {
enabled: Boolean(id),
});
const duplicateMutation = useDuplicateProformaByIdMutation();
const archiveMutation = useProformaArchiveMutation();
const unarchiveMutation = useProformaUnarchiveMutation();
const [previewPdfUrl, setPreviewPdfUrl] = useState<string | null>(null);
useEffect(() => {
if (!previewPdfQuery.data) {
setPreviewPdfUrl(null);
return;
}
const objectUrl = URL.createObjectURL(previewPdfQuery.data);
setPreviewPdfUrl(objectUrl);
return () => {
URL.revokeObjectURL(objectUrl);
};
}, [previewPdfQuery.data]);
const paymentMethodsQuery = usePaymentMethodsListQuery({
criteria: {
pageSize: 999,
filters: ACTIVE_ONLY_FILTERS,
},
});
const paymentTermsQuery = usePaymentTermsListQuery({
criteria: {
pageSize: 999,
filters: ACTIVE_ONLY_FILTERS,
},
});
const paymentMethodOptions = useMemo(() => {
return getPaymentMethodOptions(paymentMethodsQuery.data?.items ?? []);
}, [paymentMethodsQuery.data?.items]);
const paymentTermOptions = useMemo(() => {
return getPaymentTermOptions(paymentTermsQuery.data?.items ?? []);
}, [paymentTermsQuery.data?.items]);
const proforma = proformaQuery.data;
const paymentMethodLabel =
paymentMethodOptions.find((option) => option.value === proforma?.paymentMethodId)?.label ??
null;
const paymentTermLabel =
paymentTermOptions.find((option) => option.value === proforma?.paymentTermId)?.label ?? null;
const handleEdit = () => {
if (!proforma) {
return;
}
navigate({
pathname: `/proformas/${proforma.id}/edit`,
search: createSearchParams({
returnTo: currentReturnTo,
}).toString(),
});
};
const handleDownload = () => {
if (!proforma) {
return;
}
downloadCtrl.download(proforma.id, proforma.proformaReference);
};
const handleOpenPreviewPdf = () => {
if (previewPdfUrl) {
window.open(previewPdfUrl, "_blank", "noopener,noreferrer");
return;
}
handleDownload();
};
const handlePrintPreview = () => {
handleOpenPreviewPdf();
};
const handleDuplicate = async () => {
if (!proforma) {
return;
}
try {
const duplicated = await duplicateMutation.mutateAsync({ proformaId: proforma.id });
showSuccessToast(
t("proformas.duplicate.success_title"),
duplicated.proformaReference
? t("proformas.duplicate.success_with_reference", {
reference: duplicated.proformaReference,
})
: t("proformas.duplicate.success_description")
);
navigate({
pathname: `/proformas/${duplicated.id}/edit`,
search: createSearchParams({
returnTo: currentReturnTo,
}).toString(),
});
} catch (error) {
const message =
error instanceof Error
? error.message
: t("proformas.duplicate.error_unknown_message");
showErrorToast(t("proformas.duplicate.error_title"), message);
}
};
const handleArchiveToggle = async () => {
if (!proforma) {
return;
}
try {
if (proforma.isArchived) {
await unarchiveMutation.mutateAsync({ proformaId: proforma.id });
showSuccessToast("Proforma desarchivada", "La proforma vuelve a estar disponible.");
} else {
await archiveMutation.mutateAsync({ proformaId: proforma.id });
showSuccessToast("Proforma archivada", "La proforma se ha movido al archivo.");
}
await proformaQuery.refetch();
} catch (error) {
showErrorToast(
proforma.isArchived ? "No se pudo desarchivar" : "No se pudo archivar",
error instanceof Error ? error.message : "Inténtalo de nuevo más tarde."
);
}
};
const primaryAction = useMemo(() => {
if (!proforma) {
return {
label: "Editar",
onClick: () => undefined,
};
}
if (proforma.isArchived) {
return {
label: "Desarchivar",
onClick: handleArchiveToggle,
};
}
if (proforma.status === "approved") {
return {
label: "Emitir factura",
onClick: () => issueDialogCtrl.openDialog(prepareIssueProformaTarget(proforma)),
};
}
return {
label: "Editar",
onClick: handleEdit,
};
}, [handleArchiveToggle, issueDialogCtrl, proforma]);
return {
id,
proforma,
proformaQuery,
paymentMethodLabel,
paymentTermLabel,
previewZoom,
setPreviewZoom,
previewPdfQuery,
previewPdfUrl,
currentReturnTo,
navigateBack,
primaryAction,
issueDialogCtrl,
downloadCtrl,
duplicateMutation,
archiveMutation,
unarchiveMutation,
handleEdit,
handleDownload,
handleOpenPreviewPdf,
handlePrintPreview,
handleDuplicate,
handleArchiveToggle,
};
};

View File

@ -0,0 +1,2 @@
export * from "./controllers";
export * from "./ui";

View File

@ -0,0 +1 @@
export * from "./pages";

View File

@ -0,0 +1 @@
export * from "./proforma-detail-page";

View File

@ -0,0 +1,509 @@
import { ErrorAlert, NotFoundCard } from "@erp/core/components";
import { AppContent, BackHistoryButton, FieldValueList, PDFViewer } from "@repo/rdx-ui/components";
import { MoneyHelper } from "@repo/rdx-utils";
import {
Alert,
AlertDescription,
AlertTitle,
Button,
Card,
CardContent,
CardHeader,
CardTitle,
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
Separator,
Skeleton,
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "@repo/shadcn-ui/components";
import { cn } from "@repo/shadcn-ui/lib/utils";
import {
CheckCircle2Icon,
DownloadIcon,
MoreHorizontalIcon,
PencilIcon,
RefreshCwIcon,
SquarePenIcon,
} from "lucide-react";
import { type ReactNode, useMemo } from "react";
import { IssueProformaDialog } from "../../../issue-proforma";
import { ProformaStatusBadge } from "../../../list/ui/components";
import { type Proforma, type ProformaItem, ProformaPageHeader } from "../../../shared";
import { useProformaDetailPageController } from "../../controllers";
const ZOOM_OPTIONS = [
{ label: "Ajustar", value: "fit" },
{ label: "75%", value: "75" },
{ label: "90%", value: "90" },
{ label: "100%", value: "100" },
{ label: "125%", value: "125" },
];
const DOCUMENT_WIDTH = 794;
const DOCUMENT_HEIGHT = 1123;
const formatDate = (value: string | null | undefined) => {
if (!value) {
return "No informado";
}
const [year, month, day] = value.split("-");
if (!(year && month && day)) {
return value;
}
return `${day}/${month}/${year}`;
};
const formatMoney = (amount: number, currencyCode: string) => {
return MoneyHelper.formatCurrency(amount, 2, currencyCode);
};
const formatQuantity = (value: number | null) => {
if (value === null) {
return "-";
}
return new Intl.NumberFormat("es-ES", {
minimumFractionDigits: 0,
maximumFractionDigits: 2,
}).format(value);
};
const getStatusTone = (status: Proforma["status"]) => {
switch (status) {
case "approved":
return "border-emerald-200 bg-emerald-50 text-emerald-900";
case "sent":
return "border-amber-200 bg-amber-50 text-amber-900";
case "issued":
return "border-sky-200 bg-sky-50 text-sky-900";
case "rejected":
return "border-red-200 bg-red-50 text-red-900";
default:
return "border-slate-200 bg-slate-50 text-slate-900";
}
};
const getStatusSummary = (proforma: Proforma) => {
if (proforma.isArchived) {
return {
title: "Proforma archivada",
description: "Está fuera del flujo principal y puedes recuperarla cuando lo necesites.",
};
}
switch (proforma.status) {
case "approved":
return {
title: "Lista para convertirse en factura",
description: "La proforma ya está aprobada y puede emitirse como factura.",
};
case "sent":
return {
title: "Pendiente de respuesta del cliente",
description: "Se ha enviado y está a la espera de aceptación o rechazo.",
};
case "issued":
return {
title: "Convertida en factura",
description: "La proforma ya ha sido emitida y forma parte del flujo fiscal.",
};
case "rejected":
return {
title: "Requiere revisión",
description: "El cliente la ha rechazado y conviene revisar condiciones o importes.",
};
default:
return {
title: "Pendiente de revisión",
description: "Sigue en borrador y todavía puede ajustarse antes de enviarla.",
};
}
};
const InspectorCard = ({
title,
onEdit,
children,
}: {
title: string;
onEdit?: () => void;
children: ReactNode;
}) => {
return (
<Card className="shadow-sm">
<CardHeader className="flex flex-row items-start justify-between space-y-0 pb-3">
<CardTitle className="text-xl">{title}</CardTitle>
{onEdit ? (
<Button onClick={onEdit} size="icon-sm" type="button" variant="ghost">
<SquarePenIcon className="size-4" />
</Button>
) : null}
</CardHeader>
<CardContent>{children}</CardContent>
</Card>
);
};
const StatusInspectorCard = ({ proforma }: { proforma: Proforma }) => {
const summary = getStatusSummary(proforma);
return (
<Alert className={cn("shadow-sm", getStatusTone(proforma.status))}>
<CheckCircle2Icon className="size-4" />
<AlertTitle>{summary.title}</AlertTitle>
<AlertDescription>{summary.description}</AlertDescription>
</Alert>
);
};
const BillingInspectorCard = ({
proforma,
paymentMethodLabel,
paymentTermLabel,
onEdit,
}: {
proforma: Proforma;
paymentMethodLabel: string | null;
paymentTermLabel: string | null;
onEdit: () => void;
}) => {
const items = [
{
label: "Método de pago",
value: paymentMethodLabel ?? "No informado",
},
{
label: "Plazo",
value: paymentTermLabel ?? "No informado",
},
{
label: "Moneda",
value: proforma.currencyCode,
},
];
return (
<InspectorCard onEdit={onEdit} title="Cobro">
<FieldValueList items={items} />
</InspectorCard>
);
};
const DatesInspectorCard = ({ proforma, onEdit }: { proforma: Proforma; onEdit: () => void }) => {
const items = [
{
label: "Fecha proforma",
value: formatDate(proforma.proformaDate),
},
{
label: "Fecha operación",
value: formatDate(proforma.operationDate),
},
{
label: "Serie",
value: proforma.series ?? "No informada",
},
{
label: "Referencia interna",
value: proforma.reference ?? "Sin referencia",
},
];
return (
<InspectorCard onEdit={onEdit} title="Fechas y referencia">
<FieldValueList items={items} />
</InspectorCard>
);
};
const NotesInspectorCard = ({ proforma, onEdit }: { proforma: Proforma; onEdit: () => void }) => {
return (
<InspectorCard onEdit={onEdit} title="Notas">
<div className="space-y-4 text-sm leading-6 text-muted-foreground">
<div>
<p className="mb-1 font-medium text-foreground">Descripción</p>
<p>{proforma.description ?? "Sin descripción."}</p>
</div>
<Separator />
<div>
<p className="mb-1 font-medium text-foreground">Observaciones</p>
<p>{proforma.notes ?? "Sin notas adicionales."}</p>
</div>
</div>
</InspectorCard>
);
};
const TotalsInspectorCard = ({ proforma }: { proforma: Proforma }) => {
const items = [
{
label: "Subtotal",
value: formatMoney(proforma.subtotalAmount, proforma.currencyCode),
},
{
label: "Descuentos",
value: `-${formatMoney(proforma.totalDiscountAmount, proforma.currencyCode)}`,
valueClassName: "text-red-600",
},
{
label: "IVA",
value: formatMoney(proforma.ivaAmount, proforma.currencyCode),
},
{
label: "Total",
value: formatMoney(proforma.totalAmount, proforma.currencyCode),
valueClassName: "text-lg font-semibold text-foreground",
},
];
return (
<InspectorCard title="Resumen económico">
<FieldValueList itemClassName="items-center" items={items} valueClassName="tabular-nums" />
</InspectorCard>
);
};
const ProformaDetailSkeleton = () => {
return (
<div className="space-y-6 p-6">
<Card className="shadow-sm">
<CardContent className="space-y-4 p-6">
<Skeleton className="h-5 w-40" />
<Skeleton className="h-10 w-96" />
<Skeleton className="h-5 w-80" />
</CardContent>
</Card>
<div className="grid gap-6 xl:grid-cols-[minmax(0,1.8fr)_360px]">
<Card className="shadow-sm">
<CardContent className="space-y-4 p-6">
<Skeleton className="h-10 w-full" />
<Skeleton className="h-[840px] w-full" />
</CardContent>
</Card>
<div className="space-y-4">
<Skeleton className="h-28 w-full rounded-xl" />
<Skeleton className="h-40 w-full rounded-xl" />
<Skeleton className="h-44 w-full rounded-xl" />
<Skeleton className="h-48 w-full rounded-xl" />
<Skeleton className="h-36 w-full rounded-xl" />
</div>
</div>
</div>
);
};
const HeaderMeta = ({ proforma }: { proforma: Proforma }) => {
const items = useMemo(
() => [
proforma.recipient.name ?? "Cliente sin nombre",
formatDate(proforma.proformaDate),
`Total ${formatMoney(proforma.totalAmount, proforma.currencyCode)}`,
],
[proforma]
);
return (
<div className="flex flex-wrap items-center gap-x-3 gap-y-2 text-sm text-muted-foreground">
{items.map((item, index) => (
<div className="flex items-center gap-3" key={`${item}-${index}`}>
{index > 0 ? <span className="hidden text-border sm:inline">|</span> : null}
<span>{item}</span>
</div>
))}
</div>
);
};
export const ProformaDetailPage = () => {
const ctrl = useProformaDetailPageController();
if (ctrl.proformaQuery.isLoading) {
return <ProformaDetailSkeleton />;
}
if (ctrl.proformaQuery.isError) {
return (
<AppContent>
<ErrorAlert
message={
ctrl.proformaQuery.error instanceof Error
? ctrl.proformaQuery.error.message
: "No se pudo cargar la proforma."
}
title="Error al cargar la proforma"
/>
<div className="flex items-center justify-end">
<BackHistoryButton onClick={() => ctrl.navigateBack()} />
</div>
</AppContent>
);
}
if (!ctrl.proforma) {
return (
<AppContent>
<NotFoundCard
message="Revisa el identificador o vuelve al listado de proformas."
title="Proforma no encontrada"
/>
</AppContent>
);
}
return (
<div className="flex h-full flex-col">
<ProformaPageHeader
actions={
<div className="flex flex-wrap items-center gap-2 lg:justify-end">
<Button
disabled={ctrl.issueDialogCtrl.isSubmitting}
onClick={ctrl.primaryAction.onClick}
type="button"
>
{ctrl.issueDialogCtrl.isSubmitting ? (
<RefreshCwIcon className="mr-2 size-4 animate-spin" />
) : null}
{ctrl.primaryAction.label}
</Button>
<Button onClick={ctrl.handleEdit} type="button" variant="outline">
<PencilIcon className="mr-2 size-4" />
Editar
</Button>
<Button onClick={ctrl.handleDownload} type="button" variant="outline">
<DownloadIcon className="mr-2 size-4" />
Descargar PDF
</Button>
<DropdownMenu>
<DropdownMenuTrigger
render={
<Button size="icon" type="button" variant="outline">
<MoreHorizontalIcon className="size-4" />
</Button>
}
/>
<DropdownMenuContent align="end" className="w-56">
<DropdownMenuItem onClick={ctrl.handleDuplicate}>
Duplicar proforma
</DropdownMenuItem>
<DropdownMenuItem onClick={ctrl.handleArchiveToggle}>
{ctrl.proforma.isArchived ? "Desarchivar" : "Archivar"}
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</div>
}
backLabel="Volver al listado"
onBack={ctrl.navigateBack}
title={`Proforma ${ctrl.proforma.proformaReference}`}
>
<div className="border-b bg-background px-4 py-4 sm:px-6">
<div className="flex flex-wrap items-center gap-3">
<ProformaStatusBadge className="text-sm" status={ctrl.proforma.status} />
<HeaderMeta proforma={ctrl.proforma} />
</div>
</div>
</ProformaPageHeader>
<div className="flex-1 overflow-auto p-6">
<div className="grid items-start gap-6 xl:grid-cols-[minmax(0,2.2fr)_320px] 2xl:grid-cols-[minmax(0,2.5fr)_340px]">
<Card className="overflow-hidden border-border/80 shadow-sm">
<CardContent className="p-0">
<PDFViewer
className="h-[calc(100vh-11.5rem)] min-h-[720px]"
defaultZoom="fit"
description="Vista previa del PDF real generado para esta proforma."
error={
ctrl.previewPdfQuery.isError
? ctrl.previewPdfQuery.error instanceof Error
? ctrl.previewPdfQuery.error.message
: "No se pudo cargar el PDF."
: null
}
loading={ctrl.previewPdfQuery.isLoading || ctrl.previewPdfQuery.isFetching}
onDownload={ctrl.handleDownload}
onOpen={ctrl.handleOpenPreviewPdf}
onPrint={ctrl.handlePrintPreview}
src={ctrl.previewPdfUrl}
title="Vista previa"
/>
</CardContent>
</Card>
<div className="space-y-4">
<StatusInspectorCard proforma={ctrl.proforma} />
<BillingInspectorCard
onEdit={ctrl.handleEdit}
paymentMethodLabel={ctrl.paymentMethodLabel}
paymentTermLabel={ctrl.paymentTermLabel}
proforma={ctrl.proforma}
/>
<DatesInspectorCard onEdit={ctrl.handleEdit} proforma={ctrl.proforma} />
<NotesInspectorCard onEdit={ctrl.handleEdit} proforma={ctrl.proforma} />
<TotalsInspectorCard proforma={ctrl.proforma} />
<Card className="shadow-sm">
<CardHeader className="pb-3">
<CardTitle className="text-xl">Líneas incluidas</CardTitle>
</CardHeader>
<CardContent className="space-y-3">
<Table>
<TableHeader>
<TableRow>
<TableHead>Concepto</TableHead>
<TableHead className="text-right">Total</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{ctrl.proforma.items.slice(0, 4).map((item: ProformaItem) => (
<TableRow key={item.id}>
<TableCell className="max-w-[220px] truncate">
{item.description ?? "Sin descripción"}
</TableCell>
<TableCell className="text-right tabular-nums">
{formatMoney(item.totalAmount, ctrl.proforma.currencyCode)}
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</CardContent>
</Card>
</div>
</div>
</div>
<IssueProformaDialog
isSubmitting={ctrl.issueDialogCtrl.isSubmitting}
onConfirm={ctrl.issueDialogCtrl.confirmIssue}
onOpenChange={(open) => {
if (!open) {
ctrl.issueDialogCtrl.closeDialog();
}
}}
open={ctrl.issueDialogCtrl.open}
target={ctrl.issueDialogCtrl.target}
/>
</div>
);
};

View File

@ -1,3 +1,4 @@
export * from "./list"; export * from "./list";
export * from "./create"; export * from "./create";
export * from "./update"; export * from "./update";
export * from "./detail";

View File

@ -43,6 +43,7 @@ import type { ProformaListScope } from "../../../types/proforma-list-filters";
import { ProformaStatusBadge } from "../../components"; import { ProformaStatusBadge } from "../../components";
type GridActionHandlers = { type GridActionHandlers = {
onViewClick?: (proforma: ProformaListRow) => void;
onPreviewClick?: (proforma: ProformaListRow) => void; onPreviewClick?: (proforma: ProformaListRow) => void;
onEditClick?: (proforma: ProformaListRow) => void; onEditClick?: (proforma: ProformaListRow) => void;
onIssueClick?: (proforma: ProformaListRow) => void; onIssueClick?: (proforma: ProformaListRow) => void;
@ -297,6 +298,7 @@ export function useProformasGridColumns(
: []; : [];
const canEdit = isNormalScope && !isIssued && actionHandlers.onEditClick; const canEdit = isNormalScope && !isIssued && actionHandlers.onEditClick;
const canView = !isDeletedScope && actionHandlers.onViewClick;
const canDuplicate = !isDeletedScope && actionHandlers.onDuplicateClick; const canDuplicate = !isDeletedScope && actionHandlers.onDuplicateClick;
const canIssue = isNormalScope && !isIssued && isApproved && actionHandlers.onIssueClick; const canIssue = isNormalScope && !isIssued && isApproved && actionHandlers.onIssueClick;
const canDelete = !isDeletedScope && isDraft && actionHandlers.onDeleteClick; const canDelete = !isDeletedScope && isDraft && actionHandlers.onDeleteClick;
@ -307,6 +309,7 @@ export function useProformasGridColumns(
const stop = (e: React.MouseEvent | React.KeyboardEvent) => e.stopPropagation(); const stop = (e: React.MouseEvent | React.KeyboardEvent) => e.stopPropagation();
const nextTransition = availableTransitions[0] ?? null; const nextTransition = availableTransitions[0] ?? null;
const handleView = () => actionHandlers.onViewClick?.(proforma);
const handleEdit = () => actionHandlers.onEditClick?.(proforma); const handleEdit = () => actionHandlers.onEditClick?.(proforma);
const handleDuplicate = () => actionHandlers.onDuplicateClick?.(proforma); const handleDuplicate = () => actionHandlers.onDuplicateClick?.(proforma);
const handleArchive = () => actionHandlers.onArchiveClick?.(proforma); const handleArchive = () => actionHandlers.onArchiveClick?.(proforma);
@ -396,6 +399,13 @@ export function useProformasGridColumns(
})(); })();
const menuActions = [ const menuActions = [
canView
? {
icon: ExternalLinkIcon,
label: "Ver",
onClick: handleView,
}
: null,
!isIssued && !isIssued &&
nextTransition && nextTransition &&
actionHandlers.onChangeStatusClick && actionHandlers.onChangeStatusClick &&

View File

@ -238,6 +238,15 @@ const ScopedListProformasPage = ({ scope }: ScopedListProformasPageProps) => {
}); });
}; };
const handleOpenDetailClick = (proformaId: string) => {
navigate({
pathname: `/proformas/${proformaId}`,
search: createSearchParams({
returnTo: currentReturnTo,
}).toString(),
});
};
const handleDuplicateClick = async (proforma: ProformaListRow) => { const handleDuplicateClick = async (proforma: ProformaListRow) => {
try { try {
const duplicated = await duplicateMutation.mutateAsync({ proformaId: proforma.id }); const duplicated = await duplicateMutation.mutateAsync({ proformaId: proforma.id });
@ -265,6 +274,7 @@ const ScopedListProformasPage = ({ scope }: ScopedListProformasPageProps) => {
}; };
const columns = useProformasGridColumns(scope, { const columns = useProformasGridColumns(scope, {
onViewClick: (proforma) => handleOpenDetailClick(proforma.id),
onEditClick: (proforma) => handleEditClick(proforma.id), onEditClick: (proforma) => handleEditClick(proforma.id),
onDuplicateClick: handleDuplicateClick, onDuplicateClick: handleDuplicateClick,
onIssueClick: (proformaRow) => onIssueClick: (proformaRow) =>
@ -323,9 +333,7 @@ const ScopedListProformasPage = ({ scope }: ScopedListProformasPageProps) => {
onPageChange={listCtrl.setPageIndex} onPageChange={listCtrl.setPageIndex}
onPageSizeChange={listCtrl.setPageSize} onPageSizeChange={listCtrl.setPageSize}
onRowClick={ onRowClick={
scopeConfig.enablePanel scope !== "deleted" ? (proformaId) => handleOpenDetailClick(proformaId) : undefined
? (proformaId) => panelCtrl.openProformaPanel(proformaId, "view")
: undefined
} }
onSortChange={listCtrl.setSort} onSortChange={listCtrl.setSort}
pageIndex={listCtrl.pageIndex} pageIndex={listCtrl.pageIndex}

View File

@ -1 +1,2 @@
export * from "./proforma-layout"; export * from "./proforma-layout";
export * from "./proforma-page-header";

View File

@ -0,0 +1,42 @@
import { PageFormHeader } from "@erp/core/components";
import { cn } from "@repo/shadcn-ui/lib/utils";
import type { ReactNode } from "react";
export interface ProformaPageHeaderProps {
title: string;
backLabel?: string;
onBack?: () => void;
statusLabel?: string;
showStatus?: boolean;
actions?: ReactNode;
children?: ReactNode;
className?: string;
contentClassName?: string;
}
export const ProformaPageHeader = ({
title,
backLabel = "Volver",
onBack,
statusLabel,
showStatus = false,
actions,
children,
className,
contentClassName,
}: ProformaPageHeaderProps) => {
return (
<PageFormHeader
actions={actions}
backLabel={backLabel}
className={cn("shrink-0", className)}
contentClassName={contentClassName}
onBack={onBack}
showStatus={showStatus}
statusLabel={statusLabel}
title={title}
>
{children}
</PageFormHeader>
);
};

View File

@ -1,4 +1,4 @@
import { PageFormHeader, PageKeyboardShortcutsButton } from "@erp/core/components"; import { PageKeyboardShortcutsButton } from "@erp/core/components";
import { import {
CancelActionButton, CancelActionButton,
FormActionsBar, FormActionsBar,
@ -8,6 +8,8 @@ import {
} from "@repo/rdx-ui/components"; } from "@repo/rdx-ui/components";
import type { ReactNode } from "react"; import type { ReactNode } from "react";
import { ProformaPageHeader } from "../../../shared";
export interface ProformaUpdateHeaderLabels { export interface ProformaUpdateHeaderLabels {
title: string; title: string;
back: string; back: string;
@ -86,7 +88,7 @@ export const ProformaUpdateHeader = ({
]; ];
return ( return (
<PageFormHeader <ProformaPageHeader
actions={ actions={
<FormActionsBar align="end" reverseOnMobile={false}> <FormActionsBar align="end" reverseOnMobile={false}>
<PageKeyboardShortcutsButton <PageKeyboardShortcutsButton
@ -121,7 +123,6 @@ export const ProformaUpdateHeader = ({
/> />
</FormActionsBar> </FormActionsBar>
} }
backLabel={labels.back}
className={className} className={className}
onBack={onCancel} onBack={onCancel}
showStatus={hasChanges} showStatus={hasChanges}
@ -129,6 +130,6 @@ export const ProformaUpdateHeader = ({
title={labels.title} title={labels.title}
> >
{children} {children}
</PageFormHeader> </ProformaPageHeader>
); );
}; };

View File

@ -0,0 +1 @@
export * from "./field-value-list.tsx";

View File

@ -0,0 +1,45 @@
import { cn } from "@repo/shadcn-ui/lib/utils";
export interface FieldValueItem {
label: string;
value: string;
valueClassName?: string;
}
interface FieldValueListProps {
items: FieldValueItem[];
className?: string;
itemClassName?: string;
labelClassName?: string;
valueClassName?: string;
}
export const FieldValueList = ({
items,
className,
itemClassName,
labelClassName,
valueClassName,
}: FieldValueListProps) => {
return (
<dl className={cn("space-y-3", className)}>
{items.map((item) => (
<div
className={cn("flex items-start justify-between gap-4 text-sm", itemClassName)}
key={item.label}
>
<dt className={cn("text-muted-foreground", labelClassName)}>{item.label}</dt>
<dd
className={cn(
"text-right font-medium text-foreground text-balance",
valueClassName,
item.valueClassName
)}
>
{item.value}
</dd>
</div>
))}
</dl>
);
};

View File

@ -4,6 +4,7 @@ export * from "./datatable/index.ts";
export * from "./dynamics-tabs.tsx"; export * from "./dynamics-tabs.tsx";
export * from "./entity-sheet/index.ts"; export * from "./entity-sheet/index.ts";
export * from "./error-overlay.tsx"; export * from "./error-overlay.tsx";
export * from "./field-value-list.ts";
export * from "./form/index.ts"; export * from "./form/index.ts";
export * from "./full-screen-modal.tsx"; export * from "./full-screen-modal.tsx";
export * from "./initials-avatar.tsx"; export * from "./initials-avatar.tsx";
@ -12,6 +13,7 @@ export * from "./loading-overlay/index.ts";
export * from "./logo-verifactu.tsx"; export * from "./logo-verifactu.tsx";
export * from "./lookup-dialog/index.ts"; export * from "./lookup-dialog/index.ts";
export * from "./multi-select.tsx"; export * from "./multi-select.tsx";
export * from "./pdf-viewer.tsx";
export * from "./right-panel/index.ts"; export * from "./right-panel/index.ts";
export * from "./scroll-to-top.tsx"; export * from "./scroll-to-top.tsx";
export * from "./tailwind-indicator.tsx"; export * from "./tailwind-indicator.tsx";

View File

@ -0,0 +1,283 @@
import {
Button,
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
Spinner,
} from "@repo/shadcn-ui/components";
import { cn } from "@repo/shadcn-ui/lib/utils";
import { DownloadIcon, ExternalLinkIcon, PrinterIcon, ZoomInIcon, ZoomOutIcon } from "lucide-react";
import { useEffect, useMemo, useRef, useState } from "react";
const DEFAULT_ZOOM_OPTIONS = [50, 75, 100, 125, 150];
const FIT_ZOOM_VALUE = "fit";
const PDF_PAGE_WIDTH = 794;
const PDF_PAGE_HEIGHT = 1123;
type PDFViewerProps = {
src?: string | null;
title?: string;
description?: string;
className?: string;
loading?: boolean;
error?: string | null;
defaultZoom?: number | "fit";
zoomOptions?: number[];
onOpen?: () => void;
onDownload?: () => void;
onPrint?: () => void;
};
const clampZoom = (value: number, zoomOptions: number[]) => {
const min = Math.min(...zoomOptions);
const max = Math.max(...zoomOptions);
return Math.min(Math.max(value, min), max);
};
export function PDFViewer({
src,
title = "Vista previa",
description = "Documento PDF embebido en la pantalla.",
className,
loading = false,
error = null,
defaultZoom = FIT_ZOOM_VALUE,
zoomOptions = DEFAULT_ZOOM_OPTIONS,
onOpen,
onDownload,
onPrint,
}: PDFViewerProps) {
const viewportRef = useRef<HTMLDivElement | null>(null);
const [fitZoom, setFitZoom] = useState(100);
const [zoom, setZoom] = useState<string>(() =>
defaultZoom === FIT_ZOOM_VALUE ? FIT_ZOOM_VALUE : String(clampZoom(defaultZoom, zoomOptions))
);
const resolvedSrc = useMemo(() => {
if (!src) {
return null;
}
const separator = src.includes("#") ? "&" : "#";
return `${src}${separator}toolbar=0&navpanes=0&scrollbar=1&view=FitH`;
}, [src]);
useEffect(() => {
if (zoom !== FIT_ZOOM_VALUE) {
return;
}
const element = viewportRef.current;
if (!element) {
return;
}
const updateFitZoom = () => {
const styles = window.getComputedStyle(element);
const paddingX = Number.parseFloat(styles.paddingLeft) + Number.parseFloat(styles.paddingRight);
const paddingY = Number.parseFloat(styles.paddingTop) + Number.parseFloat(styles.paddingBottom);
const availableWidth = Math.max(element.clientWidth - paddingX, 320);
const availableHeight = Math.max(element.clientHeight - paddingY, 320);
const widthScale = availableWidth / PDF_PAGE_WIDTH;
const heightScale = availableHeight / PDF_PAGE_HEIGHT;
const nextScale = Math.min(widthScale, heightScale, 1);
setFitZoom(Math.max(Math.round(nextScale * 100), 25));
};
updateFitZoom();
const observer = new ResizeObserver(() => updateFitZoom());
observer.observe(element);
window.addEventListener("resize", updateFitZoom);
return () => {
observer.disconnect();
window.removeEventListener("resize", updateFitZoom);
};
}, [zoom]);
const effectiveZoom = zoom === FIT_ZOOM_VALUE ? fitZoom : Number(zoom);
const zoomScale = effectiveZoom / 100;
const minZoom = Math.min(...zoomOptions);
const maxZoom = Math.max(...zoomOptions);
const handleOpen = () => {
if (onOpen) {
onOpen();
return;
}
if (src) {
window.open(src, "_blank", "noopener,noreferrer");
}
};
const handleDownload = () => {
if (onDownload) {
onDownload();
return;
}
if (!src) {
return;
}
const link = document.createElement("a");
link.href = src;
link.download = "";
link.click();
};
const handlePrint = () => {
if (onPrint) {
onPrint();
return;
}
if (src) {
window.open(src, "_blank", "noopener,noreferrer");
}
};
return (
<div className={cn("flex h-full flex-col", className)}>
<div className="flex flex-col gap-3 border-b px-5 py-4 sm:flex-row sm:items-center sm:justify-between">
<div>
<h2 className="text-lg font-semibold tracking-tight">{title}</h2>
<p className="text-sm text-muted-foreground">{description}</p>
</div>
<div className="flex flex-wrap items-center gap-2">
<Button
disabled={loading || !src || (zoom !== FIT_ZOOM_VALUE && Number(zoom) <= minZoom)}
onClick={() =>
setZoom((current) => {
if (current === FIT_ZOOM_VALUE) {
return String(Math.max(fitZoom - 25, minZoom));
}
return String(clampZoom(Number(current) - 25, zoomOptions));
})
}
size="icon-sm"
type="button"
variant="outline"
>
<ZoomOutIcon className="size-4" />
</Button>
<Select onValueChange={setZoom} value={zoom}>
<SelectTrigger className="w-24 bg-background">
<SelectValue placeholder="Zoom" />
</SelectTrigger>
<SelectContent>
<SelectItem value={FIT_ZOOM_VALUE}>Ajustar</SelectItem>
{zoomOptions.map((option) => (
<SelectItem key={option} value={String(option)}>
{option}%
</SelectItem>
))}
</SelectContent>
</Select>
<Button
disabled={loading || !src || (zoom !== FIT_ZOOM_VALUE && Number(zoom) >= maxZoom)}
onClick={() =>
setZoom((current) => {
if (current === FIT_ZOOM_VALUE) {
return String(clampZoom(fitZoom + 25, zoomOptions));
}
return String(clampZoom(Number(current) + 25, zoomOptions));
})
}
size="icon-sm"
type="button"
variant="outline"
>
<ZoomInIcon className="size-4" />
</Button>
<Button disabled={loading || !src} onClick={handleOpen} type="button" variant="outline">
<ExternalLinkIcon className="mr-2 size-4" />
Abrir PDF
</Button>
<Button
disabled={loading || !src}
onClick={handleDownload}
type="button"
variant="outline"
>
<DownloadIcon className="mr-2 size-4" />
Descargar
</Button>
<Button
disabled={loading || !src}
onClick={handlePrint}
size="icon-sm"
type="button"
variant="outline"
>
<PrinterIcon className="size-4" />
</Button>
</div>
</div>
<div className="flex-1 overflow-auto bg-muted/30 p-4 md:p-5" ref={viewportRef}>
{loading ? (
<div className="flex h-full min-h-[480px] items-center justify-center rounded-xl border border-dashed bg-background/80">
<div className="flex items-center gap-3 text-sm text-muted-foreground">
<Spinner className="size-4" />
Cargando vista previa del PDF...
</div>
</div>
) : null}
{!loading && error ? (
<div className="flex h-full min-h-[480px] items-center justify-center rounded-xl border border-dashed bg-background/80 px-6 text-center">
<div className="space-y-2">
<p className="font-medium text-foreground">No se pudo cargar la vista previa</p>
<p className="text-sm text-muted-foreground">{error}</p>
</div>
</div>
) : null}
{!(loading || error) && resolvedSrc ? (
<div className="mx-auto flex min-h-full justify-center">
<div
className="origin-top rounded-xl border bg-background shadow-sm transition-transform"
style={{
transform: `scale(${zoomScale})`,
transformOrigin: "top center",
width: `${PDF_PAGE_WIDTH}px`,
}}
>
<iframe
className="w-full rounded-xl"
src={resolvedSrc}
style={{ height: `${PDF_PAGE_HEIGHT}px` }}
title={title}
/>
</div>
</div>
) : null}
{loading || error || resolvedSrc ? null : (
<div className="flex h-full min-h-[480px] items-center justify-center rounded-xl border border-dashed bg-background/80 px-6 text-center">
<div className="space-y-2">
<p className="font-medium text-foreground">Vista previa no disponible</p>
<p className="text-sm text-muted-foreground">
El PDF de la proforma todavía no está listo para mostrarse.
</p>
</div>
</div>
)}
</div>
</div>
);
}