mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-26 18:42:49 +00:00
266 lines
8.0 KiB
TypeScript
266 lines
8.0 KiB
TypeScript
import { useCallback, useRef, useState } from "react";
|
|
import { useNavigate, useParams } from "react-router-dom";
|
|
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
|
import {
|
|
Box,
|
|
Button,
|
|
Group,
|
|
Image,
|
|
Loader,
|
|
Modal,
|
|
Paper,
|
|
Stack,
|
|
Text,
|
|
TextInput,
|
|
} from "@mantine/core";
|
|
import { ArrowLeft, Download, FileSignature, Printer } from "lucide-react";
|
|
import toast from "react-hot-toast";
|
|
|
|
import { ContractSignSuccessModal } from "@/components/contracts/ContractSignSuccessModal";
|
|
import { ContractSignaturePad } from "@/components/bookings/ContractSignaturePad";
|
|
import { contractsService } from "@/services/contracts.service";
|
|
import { QUERY_KEYS } from "@/constants/QUERY_KEYS";
|
|
import { useAuth } from "@/auth/useAuth";
|
|
import { FREIGHT_PERMS, hasPermission } from "@/lib/permissions";
|
|
|
|
/**
|
|
* Staff contract preview + sign. Staff must open and read the generated
|
|
* contract here before signing.
|
|
*/
|
|
export default function ContractViewPage() {
|
|
const { id } = useParams<{ id: string }>();
|
|
const navigate = useNavigate();
|
|
const qc = useQueryClient();
|
|
const iframeRef = useRef<HTMLIFrameElement>(null);
|
|
const { user } = useAuth();
|
|
|
|
const [signOpen, setSignOpen] = useState(false);
|
|
const [successOpen, setSuccessOpen] = useState(false);
|
|
const [signerName, setSignerName] = useState("");
|
|
const [signatureData, setSignatureData] = useState<string | null>(null);
|
|
const [drawNew, setDrawNew] = useState(false);
|
|
|
|
const { data, isLoading, isError, refetch } = useQuery({
|
|
queryKey: [...QUERY_KEYS.CONTRACTS.byId(id ?? ""), "contract-view"],
|
|
queryFn: () => contractsService.getContractView(id!),
|
|
enabled: Boolean(id),
|
|
});
|
|
|
|
const savedSignatureImage = data?.savedSignature?.signatureImageUrl ?? null;
|
|
const usingSaved = Boolean(savedSignatureImage) && !drawNew;
|
|
|
|
const signMutation = useMutation({
|
|
mutationFn: () =>
|
|
contractsService.signContract(id!, {
|
|
role: "STAFF",
|
|
signatureImageBase64: usingSaved
|
|
? (savedSignatureImage as string)
|
|
: (signatureData ?? ""),
|
|
signerDisplayName: signerName.trim(),
|
|
consentText: "I confirm this contract on behalf of EDR.",
|
|
}),
|
|
onSuccess: () => {
|
|
setSignOpen(false);
|
|
setSuccessOpen(true);
|
|
void refetch();
|
|
void qc.invalidateQueries({ queryKey: QUERY_KEYS.CONTRACTS.byId(id!) });
|
|
void qc.invalidateQueries({ queryKey: QUERY_KEYS.CONTRACTS.ROOT });
|
|
},
|
|
onError: () => toast.error("Failed to sign contract"),
|
|
});
|
|
|
|
const handlePrint = () => iframeRef.current?.contentWindow?.print();
|
|
|
|
const downloadPdf = useCallback(async () => {
|
|
if (!id) return;
|
|
try {
|
|
const blob = await contractsService.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("Could not download contract PDF.");
|
|
}
|
|
}, [id, data?.reference]);
|
|
|
|
const openSign = () => {
|
|
setSignerName(data?.savedSignature?.signerDisplayName ?? "");
|
|
setSignatureData(null);
|
|
setDrawNew(false);
|
|
setSignOpen(true);
|
|
};
|
|
|
|
const confirmSign = () => {
|
|
if (!signerName.trim()) return;
|
|
const image = usingSaved ? savedSignatureImage : signatureData;
|
|
if (!image) return;
|
|
signMutation.mutate();
|
|
};
|
|
|
|
if (isLoading) {
|
|
return (
|
|
<Group justify="center" mih="40vh" align="center">
|
|
<Loader color="edr-green" />
|
|
</Group>
|
|
);
|
|
}
|
|
|
|
if (isError || !data) {
|
|
return (
|
|
<Box p="xl">
|
|
<Text c="dimmed">Could not load contract.</Text>
|
|
<Button variant="default" mt="md" onClick={() => navigate(-1)}>
|
|
Go back
|
|
</Button>
|
|
</Box>
|
|
);
|
|
}
|
|
|
|
// Counter-signature permission is split per freight type — a bulk signer must
|
|
// not sign a container contract (API enforces the same on POST /contract/sign).
|
|
const maySign = hasPermission(
|
|
user,
|
|
FREIGHT_PERMS.contracts.signStaff[
|
|
data.freightType === "BULK" ? "bulk" : "container"
|
|
],
|
|
);
|
|
|
|
return (
|
|
<Box p={{ base: "md", md: "xl" }}>
|
|
<Box maw={920} mx="auto">
|
|
<Group justify="space-between" wrap="wrap" gap="sm" mb="md">
|
|
<Button
|
|
variant="subtle"
|
|
color="gray"
|
|
leftSection={<ArrowLeft size={16} />}
|
|
onClick={() =>
|
|
navigate(`/dashboard/contract-requests/${data.contractId}`)
|
|
}
|
|
>
|
|
Back to contract
|
|
</Button>
|
|
<Group gap="sm">
|
|
<Button
|
|
variant="default"
|
|
leftSection={<Printer size={16} />}
|
|
onClick={handlePrint}
|
|
>
|
|
Print
|
|
</Button>
|
|
<Button
|
|
variant="default"
|
|
leftSection={<Download size={16} />}
|
|
onClick={() => void downloadPdf()}
|
|
>
|
|
Download PDF
|
|
</Button>
|
|
{data.canSignStaff && maySign && (
|
|
<Button
|
|
color="edr-green"
|
|
leftSection={<FileSignature size={16} />}
|
|
onClick={openSign}
|
|
>
|
|
{usingSaved ? "Approve & sign" : "Sign as staff"}
|
|
</Button>
|
|
)}
|
|
</Group>
|
|
</Group>
|
|
|
|
<Paper withBorder radius="lg" p={0} style={{ overflow: "hidden" }}>
|
|
<iframe
|
|
ref={iframeRef}
|
|
srcDoc={data.html}
|
|
title="Contract document"
|
|
style={{
|
|
width: "100%",
|
|
minHeight: "80vh",
|
|
border: "none",
|
|
background: "white",
|
|
}}
|
|
/>
|
|
</Paper>
|
|
</Box>
|
|
|
|
<Modal
|
|
opened={signOpen}
|
|
onClose={() => setSignOpen(false)}
|
|
title="Sign contract as staff"
|
|
centered
|
|
radius="lg"
|
|
>
|
|
<Stack gap="md">
|
|
<Text size="sm" c="dimmed">
|
|
{data.reference} — your signature is stored securely on the
|
|
contract.
|
|
</Text>
|
|
<TextInput
|
|
label="Full name"
|
|
value={signerName}
|
|
onChange={(e) => setSignerName(e.currentTarget.value)}
|
|
/>
|
|
{usingSaved ? (
|
|
<Stack gap="xs">
|
|
<Paper
|
|
withBorder
|
|
radius="md"
|
|
p="xs"
|
|
style={{ borderStyle: "dashed" }}
|
|
>
|
|
<Image
|
|
src={savedSignatureImage ?? undefined}
|
|
alt="Saved signature"
|
|
fit="contain"
|
|
h={140}
|
|
/>
|
|
</Paper>
|
|
<Button
|
|
variant="subtle"
|
|
size="compact-xs"
|
|
color="edr-green"
|
|
onClick={() => {
|
|
setDrawNew(true);
|
|
setSignatureData(null);
|
|
}}
|
|
>
|
|
Draw a new signature instead
|
|
</Button>
|
|
</Stack>
|
|
) : (
|
|
<ContractSignaturePad onChange={setSignatureData} />
|
|
)}
|
|
<Group justify="flex-end" gap="sm">
|
|
<Button variant="default" onClick={() => setSignOpen(false)}>
|
|
Cancel
|
|
</Button>
|
|
<Button
|
|
color="edr-green"
|
|
loading={signMutation.isPending}
|
|
disabled={
|
|
signMutation.isPending ||
|
|
!signerName.trim() ||
|
|
(!usingSaved && !signatureData)
|
|
}
|
|
onClick={confirmSign}
|
|
>
|
|
{usingSaved ? "Approve & sign" : "Confirm signature"}
|
|
</Button>
|
|
</Group>
|
|
</Stack>
|
|
</Modal>
|
|
|
|
<ContractSignSuccessModal
|
|
opened={successOpen}
|
|
reference={data.reference}
|
|
message="The contract has been counter-signed. The customer will be notified of the next steps."
|
|
onClose={() => {
|
|
setSuccessOpen(false);
|
|
navigate(`/dashboard/contract-requests/${data.contractId}`);
|
|
}}
|
|
/>
|
|
</Box>
|
|
);
|
|
}
|