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:
parent
451a1013d0
commit
1506a78582
@ -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: {
|
||||||
|
|||||||
@ -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>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@ -0,0 +1 @@
|
|||||||
|
export * from "./use-proforma-detail-page-controller";
|
||||||
@ -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,
|
||||||
|
};
|
||||||
|
};
|
||||||
@ -0,0 +1,2 @@
|
|||||||
|
export * from "./controllers";
|
||||||
|
export * from "./ui";
|
||||||
@ -0,0 +1 @@
|
|||||||
|
export * from "./pages";
|
||||||
@ -0,0 +1 @@
|
|||||||
|
export * from "./proforma-detail-page";
|
||||||
@ -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>
|
||||||
|
);
|
||||||
|
};
|
||||||
@ -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";
|
||||||
|
|||||||
@ -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 &&
|
||||||
|
|||||||
@ -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}
|
||||||
|
|||||||
@ -1 +1,2 @@
|
|||||||
export * from "./proforma-layout";
|
export * from "./proforma-layout";
|
||||||
|
export * from "./proforma-page-header";
|
||||||
|
|||||||
@ -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>
|
||||||
|
);
|
||||||
|
};
|
||||||
@ -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>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
1
packages/rdx-ui/src/components/field-value-list.ts
Normal file
1
packages/rdx-ui/src/components/field-value-list.ts
Normal file
@ -0,0 +1 @@
|
|||||||
|
export * from "./field-value-list.tsx";
|
||||||
45
packages/rdx-ui/src/components/field-value-list.tsx
Normal file
45
packages/rdx-ui/src/components/field-value-list.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
};
|
||||||
@ -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";
|
||||||
|
|||||||
283
packages/rdx-ui/src/components/pdf-viewer.tsx
Normal file
283
packages/rdx-ui/src/components/pdf-viewer.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
Loading…
Reference in New Issue
Block a user