mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-09-07 13:05:44 +00:00
265 lines
9.2 KiB
TypeScript
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>
|
|
);
|
|
}
|