Files
edr-platform/apps/edr-freight-web/backoffice/src/pages/bookings/BookingContractPage.tsx

265 lines
9.2 KiB
TypeScript

import { useCallback, useRef, useState } from "react";
import { useNavigate, useParams } from "react-router-dom";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import {
ArrowLeft,
Download,
FileSignature,
Loader2,
Printer,
} from "lucide-react";
import toast from "react-hot-toast";
import Breadcrumbs from "@/components/ui/Breadcrumbs";
import { ContractSignaturePad } from "@/components/bookings/ContractSignaturePad";
import { bookingSurface } from "@/components/bookings/booking-ui.styles";
import { QUERY_KEYS } from "@/constants/QUERY_KEYS";
import { invalidateBookingDetail } from "@/utils/queryInvalidation";
import {
bookingsService,
type SignContractPayload,
} from "@/services/bookings.service";
import { cn } from "@/lib/utils";
import {
Button,
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
Input,
Label,
} from "@edr/ui-common";
export default function BookingContractPage() {
const { id } = useParams<{ id: string }>();
const navigate = useNavigate();
const qc = useQueryClient();
const iframeRef = useRef<HTMLIFrameElement>(null);
const [signOpen, setSignOpen] = useState(false);
const [signerName, setSignerName] = useState("");
const [signatureData, setSignatureData] = useState<string | null>(null);
// When the user has a saved signature we offer it for approval first; they
// can switch to drawing a fresh one.
const [drawNew, setDrawNew] = useState(false);
const { data, isLoading, isError } = useQuery({
queryKey: [...QUERY_KEYS.BOOKINGS.byId(id ?? ""), "contract-view"],
queryFn: () => bookingsService.getContractView(id!),
enabled: Boolean(id),
});
// Backoffice only ever signs as STAFF — customers sign in the portal.
const canSign = Boolean(data?.canSignStaff);
const savedSignature = data?.savedSignature ?? null;
const savedSignatureImage = savedSignature?.signatureImageUrl ?? null;
// Show the approval view only while a saved signature exists and the user
// hasn't opted to draw a new one.
const usingSaved = Boolean(savedSignatureImage) && !drawNew;
const signMutation = useMutation({
mutationFn: (payload: SignContractPayload) =>
bookingsService.signContract(id!, payload),
onSuccess: async () => {
toast.success("Signature recorded");
setSignOpen(false);
await invalidateBookingDetail(qc, id!);
qc.invalidateQueries({
queryKey: [...QUERY_KEYS.BOOKINGS.byId(id ?? ""), "contract-view"],
});
},
onError: () => toast.error("Failed to sign contract"),
});
const downloadPdf = useCallback(async () => {
if (!id) return;
try {
const blob = await bookingsService.downloadContractDocument(id);
const url = URL.createObjectURL(blob);
const a = document.createElement("a");
a.href = url;
a.download = `contract-${data?.reference ?? id}.pdf`;
a.click();
URL.revokeObjectURL(url);
} catch {
toast.error("Contract PDF not available. Ask staff to generate it first.");
}
}, [id, data?.reference]);
const handlePrint = () => {
iframeRef.current?.contentWindow?.focus();
iframeRef.current?.contentWindow?.print();
};
const openSign = () => {
// Prefill from the saved signature when available so the user only has to
// approve it; otherwise start with an empty pad.
setSignerName(savedSignature?.signerDisplayName ?? "");
setSignatureData(null);
setDrawNew(false);
setSignOpen(true);
};
const confirmSign = () => {
if (!canSign || !signerName.trim()) return;
// Approve the saved signature, or submit the freshly drawn one.
const image = usingSaved ? savedSignatureImage : signatureData;
if (!image) return;
signMutation.mutate({
role: "STAFF",
signatureImageBase64: image,
signerDisplayName: signerName.trim(),
consentText: "I agree to the terms of this contract.",
});
};
if (isLoading) {
return (
<div className="flex min-h-[40vh] items-center justify-center">
<Loader2 className="size-8 animate-spin text-primary" />
</div>
);
}
if (isError || !data) {
return (
<div className={bookingSurface.pageInner}>
<p className="text-muted-foreground">Could not load contract.</p>
<Button variant="outline" className="mt-4" onClick={() => navigate(-1)}>
Go back
</Button>
</div>
);
}
return (
<div className={bookingSurface.page}>
<div className={cn(bookingSurface.pageInner, "print:p-0")}>
<div className="print:hidden">
<Breadcrumbs
items={[
{ label: "Booking requests", href: "/dashboard/booking-requests" },
{
label: data.reference,
href: `/dashboard/booking-requests/${id}`,
},
{ label: "Contract" },
]}
/>
</div>
<div style={{boxShadow:"3px 3px 20px 1px lightgrey"}} className="sticky top-0 z-10 mb-6 flex flex-wrap items-center justify-between gap-3 rounded-xl border bg-background/95 p-4 shadow-sm backdrop-blur print:hidden">
<Button variant="ghost" size="sm" className="gap-2" onClick={() => navigate(-1)}>
<ArrowLeft className="size-4" />
Back
</Button>
<div className="flex flex-wrap gap-2">
<Button variant="outline" size="sm" className="gap-2" onClick={handlePrint}>
<Printer className="size-4" />
Print
</Button>
<Button variant="outline" size="sm" className="gap-2" onClick={downloadPdf}>
<Download className="size-4" />
Download PDF
</Button>
{canSign && (
<Button size="sm" className="gap-2" onClick={openSign}>
<FileSignature className="size-4" />
{usingSaved ? "Approve & sign" : "Sign contract"}
</Button>
)}
</div>
</div>
{!data.hasContractDocument && (
<div className="mb-4 rounded-lg border border-amber-200 bg-amber-50 px-4 py-3 text-sm text-amber-900 print:hidden">
A PDF has not been stored yet. Download will generate the latest
contract document automatically.
</div>
)}
<iframe
ref={iframeRef}
title={`Contract ${data.reference}`}
srcDoc={data.html}
sandbox="allow-same-origin"
className="mx-auto block min-h-[297mm] w-full max-w-[210mm] rounded-xl border bg-white shadow-sm print:h-[297mm] print:border-0 print:shadow-none"
/>
<Dialog open={signOpen} onOpenChange={setSignOpen}>
<DialogContent className="sm:max-w-md">
<DialogHeader>
<DialogTitle>Staff signature</DialogTitle>
<DialogDescription>
{usingSaved
? `Review your saved signature and approve it to execute the contract for ${data.reference}.`
: `Sign to execute the contract for ${data.reference}.`}
</DialogDescription>
</DialogHeader>
<div className="space-y-4">
<div className="space-y-2">
<Label htmlFor="signerName">Full name</Label>
<Input
id="signerName"
value={signerName}
onChange={(e) => setSignerName(e.target.value)}
placeholder="As shown on the contract"
/>
</div>
{usingSaved ? (
<div className="space-y-2">
<Label>Saved signature</Label>
<div className="overflow-hidden rounded-lg border-2 border-dashed border-border bg-white">
<img
src={savedSignatureImage ?? undefined}
alt="Saved signature"
className="mx-auto h-36 w-full object-contain"
/>
</div>
<Button
type="button"
variant="link"
size="sm"
className="h-auto p-0 text-xs"
onClick={() => {
setDrawNew(true);
setSignatureData(null);
}}
>
Draw a new signature instead
</Button>
</div>
) : (
<ContractSignaturePad onChange={setSignatureData} />
)}
</div>
<DialogFooter>
<Button variant="outline" onClick={() => setSignOpen(false)}>
Cancel
</Button>
<Button
disabled={
signMutation.isPending ||
(!usingSaved && !signatureData) ||
!signerName.trim()
}
onClick={confirmSign}
>
{signMutation.isPending ? (
<Loader2 className="size-4 animate-spin" />
) : usingSaved ? (
"Approve & sign"
) : (
"Confirm signature"
)}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</div>
</div>
);
}