Refactor Proforma Detail Page: Modularize Components and Improve Structure

- Simplified the ProformaDetailPage by extracting various components into separate files for better maintainability and readability.
- Introduced ProformaDetailHeaderActions for managing header actions.
- Created ProformaDetailPreviewCard for displaying the PDF viewer.
- Added ProformaDetailSidebar to encapsulate billing and date information.
- Implemented ProformaDetailSummaryCard to summarize billing, dates, and notes.
- Enhanced ProformaDetailInspectorCard for consistent card layout across different sections.
- Updated formatting functions for date and money to improve code reuse.
- Removed redundant code and improved overall structure for better clarity.
This commit is contained in:
David Arranz 2026-08-01 18:59:22 +02:00
parent 1506a78582
commit f0b100995a
19 changed files with 721 additions and 441 deletions

View File

@ -5,15 +5,6 @@ type EmptyCellValueProps = {
value?: string;
};
export const EmptyCellValue = ({
className,
value = "\u2014",
}: EmptyCellValueProps) => (
<span
aria-label="Sin valor"
className={cn("inline-flex items-center text-muted-foreground", className)}
>
{value}
</span>
export const EmptyCellValue = ({ className, value = "\u2014" }: EmptyCellValueProps) => (
<span className={cn("inline-flex items-center text-muted-foreground", className)}>{value}</span>
);

View File

@ -0,0 +1,4 @@
export * from "./proforma-detail-header-actions";
export * from "./proforma-detail-preview-card";
export * from "./proforma-detail-sidebar";
export * from "./proforma-detail-summary-card";

View File

@ -0,0 +1,57 @@
import { Button, DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger } from "@repo/shadcn-ui/components";
import { DownloadIcon, MoreHorizontalIcon, PencilIcon, RefreshCwIcon } from "lucide-react";
type ProformaDetailHeaderActionsProps = {
primaryAction: {
label: string;
onClick: () => void;
};
isPrimaryLoading: boolean;
isArchived: boolean;
onDownload: () => void;
onDuplicate: () => void;
onArchiveToggle: () => void;
};
export const ProformaDetailHeaderActions = ({
primaryAction,
isPrimaryLoading,
isArchived,
onDownload,
onDuplicate,
onArchiveToggle,
}: ProformaDetailHeaderActionsProps) => {
return (
<div className="flex flex-wrap items-center gap-2 lg:justify-end">
<Button disabled={isPrimaryLoading} onClick={primaryAction.onClick} type="button">
{isPrimaryLoading ? (
<RefreshCwIcon className="mr-2 size-4 animate-spin" />
) : (
<PencilIcon className="mr-2 size-4" />
)}
{primaryAction.label}
</Button>
<Button onClick={onDownload} 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={onDuplicate}>Duplicar proforma</DropdownMenuItem>
<DropdownMenuItem onClick={onArchiveToggle}>
{isArchived ? "Desarchivar" : "Archivar"}
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</div>
);
};

View File

@ -0,0 +1,39 @@
import { PDFViewer } from "@repo/rdx-ui/components";
import { Card, CardContent } from "@repo/shadcn-ui/components";
type ProformaDetailPreviewCardProps = {
src: string | null;
isLoading: boolean;
error: string | null;
onOpen: () => void;
onDownload: () => void;
onPrint: () => void;
};
export const ProformaDetailPreviewCard = ({
src,
isLoading,
error,
onOpen,
onDownload,
onPrint,
}: ProformaDetailPreviewCardProps) => {
return (
<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={error}
loading={isLoading}
onDownload={onDownload}
onOpen={onOpen}
onPrint={onPrint}
src={src}
title="Vista previa"
/>
</CardContent>
</Card>
);
};

View File

@ -0,0 +1,28 @@
import type { Proforma } from "../../../shared";
import { ProformaDetailStatusInspectorCard } from "../components";
import { ProformaDetailSummaryCard } from "./proforma-detail-summary-card";
type ProformaDetailSidebarProps = {
proforma: Proforma;
paymentMethodLabel: string | null;
paymentTermLabel: string | null;
onEdit: () => void;
};
export const ProformaDetailSidebar = ({
proforma,
paymentMethodLabel,
paymentTermLabel,
}: ProformaDetailSidebarProps) => {
return (
<div className="space-y-4">
<ProformaDetailStatusInspectorCard proforma={proforma} />
<ProformaDetailSummaryCard
paymentMethodLabel={paymentMethodLabel}
paymentTermLabel={paymentTermLabel}
proforma={proforma}
/>
</div>
);
};

View File

@ -0,0 +1,159 @@
import { FieldValueList, FormSectionCard, FormSectionGrid } from "@repo/rdx-ui/components";
import { Separator } from "@repo/shadcn-ui/components";
import { LandmarkIcon } from "lucide-react";
import { type Proforma } from "../../../shared";
import { formatProformaDate, formatProformaMoney } from "../components/proforma-detail-formatters";
type ProformaDetailSummaryCardProps = {
proforma: Proforma;
paymentMethodLabel: string | null;
paymentTermLabel: string | null;
};
const SectionHeading = ({ title, description }: { title: string; description?: string }) => {
return (
<div className="space-y-1">
<h3 className="text-sm font-semibold tracking-tight text-foreground">{title}</h3>
{description ? <p className="text-xs leading-5 text-muted-foreground">{description}</p> : null}
</div>
);
};
export const ProformaDetailSummaryCard = ({
proforma,
paymentMethodLabel,
paymentTermLabel,
}: ProformaDetailSummaryCardProps) => {
const billingItems = [
{
label: "Método de pago",
value: paymentMethodLabel ?? "\u2014",
},
{
label: "Plazo",
value: paymentTermLabel ?? "\u2014",
},
{
label: "Moneda",
value: proforma.currencyCode,
},
];
const datesItems = [
{
label: "Fecha proforma",
value: formatProformaDate(proforma.proformaDate),
},
{
label: "Fecha operación",
value: formatProformaDate(proforma.operationDate),
},
{
label: "Serie",
value: proforma.series ?? "\u2014",
},
{
label: "Referencia",
value: proforma.reference ?? "\u2014",
},
];
const totalsItems = [
{
label: "Subtotal",
value: formatProformaMoney(proforma.subtotalAmount, proforma.currencyCode, proforma.languageCode),
},
{
label: "Descuentos",
value: `-${formatProformaMoney(proforma.totalDiscountAmount, proforma.currencyCode, proforma.languageCode)}`,
valueClassName: "text-red-600",
},
{
label: "IVA",
value: formatProformaMoney(proforma.ivaAmount, proforma.currencyCode, proforma.languageCode),
},
{
label: "Total",
value: formatProformaMoney(proforma.totalAmount, proforma.currencyCode, proforma.languageCode),
valueClassName: "text-lg font-semibold text-foreground",
},
];
return (
<FormSectionCard
className="shadow-sm"
contentClassName="space-y-5"
description="Resumen operativo de la proforma con las condiciones clave y sus importes."
icon={<LandmarkIcon className="size-5" />}
title="Resumen de proforma"
>
<FormSectionGrid className="gap-y-6 md:grid-cols-1">
<div className="space-y-3 md:col-span-12">
<SectionHeading
description="Condiciones de cobro configuradas actualmente para esta proforma."
title="Cobros"
/>
<FieldValueList
className="rounded-lg border bg-muted/20 p-4"
items={billingItems}
/>
</div>
<div className="md:col-span-12">
<Separator />
</div>
<div className="space-y-3 md:col-span-12">
<SectionHeading
description="Fechas principales y referencias internas del documento."
title="Fechas y referencias"
/>
<FieldValueList
className="rounded-lg border bg-muted/20 p-4"
items={datesItems}
/>
</div>
<div className="md:col-span-12">
<Separator />
</div>
<div className="space-y-3 md:col-span-12">
<SectionHeading
description="Texto libre que acompaña a la proforma y puede trasladarse al PDF."
title="Notas"
/>
<div className="rounded-lg border bg-muted/20 p-4 text-sm leading-6 text-muted-foreground">
<div className="space-y-1">
<p className="font-medium text-foreground">Descripción</p>
<p>{proforma.description ?? "Sin descripción."}</p>
</div>
<Separator className="my-4" />
<div className="space-y-1">
<p className="font-medium text-foreground">Observaciones</p>
<p>{proforma.notes ?? "Sin notas adicionales."}</p>
</div>
</div>
</div>
<div className="md:col-span-12">
<Separator />
</div>
<div className="space-y-3 md:col-span-12">
<SectionHeading
description="Desglose económico principal del documento."
title="Resumen económico"
/>
<FieldValueList
className="rounded-lg border bg-muted/20 p-4"
itemClassName="items-center"
items={totalsItems}
valueClassName="tabular-nums"
/>
</div>
</FormSectionGrid>
</FormSectionCard>
);
};

View File

@ -0,0 +1,9 @@
export * from "./proforma-detail-archived-badge";
export * from "./proforma-detail-billing-inspector-card";
export * from "./proforma-detail-dates-inspector-card";
export * from "./proforma-detail-header-meta";
export * from "./proforma-detail-inspector-card";
export * from "./proforma-detail-notes-inspector-card";
export * from "./proforma-detail-skeleton";
export * from "./proforma-detail-status-inspector-card";
export * from "./proforma-detail-totals-inspector-card";

View File

@ -0,0 +1,33 @@
import { Tooltip, TooltipContent, TooltipTrigger } from "@repo/shadcn-ui/components";
import { cn } from "@repo/shadcn-ui/lib/utils";
import { ArchiveIcon } from "lucide-react";
type ProformaDetailArchivedBadgeProps = {
className?: string;
};
export const ProformaDetailArchivedBadge = ({
className,
}: ProformaDetailArchivedBadgeProps) => {
return (
<Tooltip>
<TooltipTrigger
render={
<span
className={cn(
"inline-flex items-center gap-1.5 rounded-full border border-slate-300 bg-slate-100/90 px-2.5 py-1 text-xs font-medium leading-none whitespace-nowrap text-slate-700 ring-1 ring-inset ring-slate-300/90",
"[&>svg]:pointer-events-none [&>svg]:size-3.5 [&>svg]:shrink-0",
className
)}
>
<ArchiveIcon aria-hidden="true" />
Archivada
</span>
}
/>
<TooltipContent>
<p>Esta proforma está archivada y fuera del flujo principal.</p>
</TooltipContent>
</Tooltip>
);
};

View File

@ -0,0 +1,40 @@
import { FieldValueList } from "@repo/rdx-ui/components";
import type { Proforma } from "../../../shared";
import { ProformaDetailInspectorCard } from "./proforma-detail-inspector-card";
type ProformaDetailBillingInspectorCardProps = {
proforma: Proforma;
paymentMethodLabel: string | null;
paymentTermLabel: string | null;
onEdit: () => void;
};
export const ProformaDetailBillingInspectorCard = ({
proforma,
paymentMethodLabel,
paymentTermLabel,
onEdit,
}: ProformaDetailBillingInspectorCardProps) => {
const items = [
{
label: "Método de pago",
value: paymentMethodLabel ?? "\u2014",
},
{
label: "Plazo",
value: paymentTermLabel ?? "\u2014",
},
{
label: "Moneda",
value: proforma.currencyCode,
},
];
return (
<ProformaDetailInspectorCard onEdit={onEdit} title="Cobro">
<FieldValueList items={items} />
</ProformaDetailInspectorCard>
);
};

View File

@ -0,0 +1,41 @@
import { FieldValueList } from "@repo/rdx-ui/components";
import type { Proforma } from "../../../shared";
import { formatProformaDate } from "./proforma-detail-formatters";
import { ProformaDetailInspectorCard } from "./proforma-detail-inspector-card";
type ProformaDetailDatesInspectorCardProps = {
proforma: Proforma;
onEdit: () => void;
};
export const ProformaDetailDatesInspectorCard = ({
proforma,
onEdit,
}: ProformaDetailDatesInspectorCardProps) => {
const items = [
{
label: "Fecha proforma",
value: formatProformaDate(proforma.proformaDate),
},
{
label: "Fecha operación",
value: formatProformaDate(proforma.operationDate),
},
{
label: "Serie de la factura",
value: proforma.series ?? "\u2014",
},
{
label: "Referencia interna",
value: proforma.reference ?? "\u2014",
},
];
return (
<ProformaDetailInspectorCard onEdit={onEdit} title="Fechas y referencia">
<FieldValueList items={items} />
</ProformaDetailInspectorCard>
);
};

View File

@ -0,0 +1,17 @@
import { DateHelper, MoneyHelper } from "@repo/rdx-utils";
export const formatProformaDate = (value: string | null | undefined) => {
if (!value) {
return "\u2014";
}
return DateHelper.format(value);
};
export const formatProformaMoney = (
amount: number,
currencyCode: string,
languageCode?: string | null
) => {
return MoneyHelper.formatCurrency(amount, 2, currencyCode, languageCode ?? undefined);
};

View File

@ -0,0 +1,31 @@
import { useMemo } from "react";
import type { Proforma } from "../../../shared";
import { formatProformaDate, formatProformaMoney } from "./proforma-detail-formatters";
type ProformaDetailHeaderMetaProps = {
proforma: Proforma;
};
export const ProformaDetailHeaderMeta = ({ proforma }: ProformaDetailHeaderMetaProps) => {
const items = useMemo(
() => [
proforma.recipient.name ?? "Cliente sin nombre",
formatProformaDate(proforma.proformaDate),
`Total ${formatProformaMoney(proforma.totalAmount, proforma.currencyCode, proforma.languageCode)}`,
],
[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>
);
};

View File

@ -0,0 +1,29 @@
import { Button, Card, CardContent, CardHeader, CardTitle } from "@repo/shadcn-ui/components";
import { SquarePenIcon } from "lucide-react";
import type { ReactNode } from "react";
type ProformaDetailInspectorCardProps = {
title: string;
onEdit?: () => void;
children: ReactNode;
};
export const ProformaDetailInspectorCard = ({
title,
onEdit,
children,
}: ProformaDetailInspectorCardProps) => {
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>
);
};

View File

@ -0,0 +1,31 @@
import { Separator } from "@repo/shadcn-ui/components";
import type { Proforma } from "../../../shared";
import { ProformaDetailInspectorCard } from "./proforma-detail-inspector-card";
type ProformaDetailNotesInspectorCardProps = {
proforma: Proforma;
onEdit: () => void;
};
export const ProformaDetailNotesInspectorCard = ({
proforma,
onEdit,
}: ProformaDetailNotesInspectorCardProps) => {
return (
<ProformaDetailInspectorCard 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>
</ProformaDetailInspectorCard>
);
};

View File

@ -0,0 +1,32 @@
import { Card, CardContent, Skeleton } from "@repo/shadcn-ui/components";
export 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>
);
};

View File

@ -0,0 +1,76 @@
import { Alert, AlertDescription, AlertTitle } from "@repo/shadcn-ui/components";
import { cn } from "@repo/shadcn-ui/lib/utils";
import { CheckCircle2Icon } from "lucide-react";
import type { Proforma } from "../../../shared";
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 por el cliente y puede emitirse como factura.",
};
case "sent":
return {
title: "Pendiente de respuesta del cliente",
description:
"Se ha enviado al cliente y está a la espera de aceptación o rechazo por parte del cliente.",
};
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 modificarse antes de enviarla al cliente.",
};
}
};
type ProformaDetailStatusInspectorCardProps = {
proforma: Proforma;
};
export const ProformaDetailStatusInspectorCard = ({
proforma,
}: ProformaDetailStatusInspectorCardProps) => {
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>
);
};

View File

@ -0,0 +1,49 @@
import { FieldValueList } from "@repo/rdx-ui/components";
import type { Proforma } from "../../../shared";
import { formatProformaMoney } from "./proforma-detail-formatters";
import { ProformaDetailInspectorCard } from "./proforma-detail-inspector-card";
type ProformaDetailTotalsInspectorCardProps = {
proforma: Proforma;
};
export const ProformaDetailTotalsInspectorCard = ({
proforma,
}: ProformaDetailTotalsInspectorCardProps) => {
const items = [
{
label: "Subtotal",
value: formatProformaMoney(
proforma.subtotalAmount,
proforma.currencyCode,
proforma.languageCode
),
},
{
label: "Descuentos",
value: `-${formatProformaMoney(proforma.totalDiscountAmount, proforma.currencyCode, proforma.languageCode)}`,
valueClassName: "text-red-600",
},
{
label: "IVA",
value: formatProformaMoney(proforma.ivaAmount, proforma.currencyCode, proforma.languageCode),
},
{
label: "Total",
value: formatProformaMoney(
proforma.totalAmount,
proforma.currencyCode,
proforma.languageCode
),
valueClassName: "text-lg font-semibold text-foreground",
},
];
return (
<ProformaDetailInspectorCard title="Resumen económico">
<FieldValueList itemClassName="items-center" items={items} valueClassName="tabular-nums" />
</ProformaDetailInspectorCard>
);
};

View File

@ -1 +1,3 @@
export * from "./blocks";
export * from "./components";
export * from "./pages";

View File

@ -1,331 +1,20 @@
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 { AppContent, BackHistoryButton } from "@repo/rdx-ui/components";
import { IssueProformaDialog } from "../../../issue-proforma";
import { ProformaStatusBadge } from "../../../list/ui/components";
import { type Proforma, type ProformaItem, ProformaPageHeader } from "../../../shared";
import { 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>
);
};
import {
ProformaDetailHeaderActions,
ProformaDetailPreviewCard,
ProformaDetailSidebar,
} from "../blocks";
import {
ProformaDetailArchivedBadge,
ProformaDetailHeaderMeta,
ProformaDetailSkeleton,
} from "../components";
export const ProformaDetailPage = () => {
const ctrl = useProformaDetailPageController();
@ -368,46 +57,14 @@ export const ProformaDetailPage = () => {
<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>
<ProformaDetailHeaderActions
isArchived={ctrl.proforma.isArchived}
isPrimaryLoading={ctrl.issueDialogCtrl.isSubmitting}
onArchiveToggle={ctrl.handleArchiveToggle}
onDownload={ctrl.handleDownload}
onDuplicate={ctrl.handleDuplicate}
primaryAction={ctrl.primaryAction}
/>
}
backLabel="Volver al listado"
onBack={ctrl.navigateBack}
@ -416,80 +73,35 @@ export const ProformaDetailPage = () => {
<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} />
{ctrl.proforma.isArchived ? <ProformaDetailArchivedBadge /> : null}
<ProformaDetailHeaderMeta 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>
<ProformaDetailPreviewCard
error={
ctrl.previewPdfQuery.isError
? ctrl.previewPdfQuery.error instanceof Error
? ctrl.previewPdfQuery.error.message
: "No se pudo cargar el PDF."
: null
}
isLoading={ctrl.previewPdfQuery.isLoading || ctrl.previewPdfQuery.isFetching}
onDownload={ctrl.handleDownload}
onOpen={ctrl.handleOpenPreviewPdf}
onPrint={ctrl.handlePrintPreview}
src={ctrl.previewPdfUrl}
/>
<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>
<ProformaDetailSidebar
onEdit={ctrl.handleEdit}
paymentMethodLabel={ctrl.paymentMethodLabel}
paymentTermLabel={ctrl.paymentTermLabel}
proforma={ctrl.proforma}
/>
</div>
</div>