Files
edr-platform/apps/edr-freight-web/portal/src/pages/contracts/ContractViewPage.tsx
2026-08-02 18:42:38 +00:00

517 lines
16 KiB
TypeScript

import { useCallback, useEffect, useRef, useState } from "react";
import { useNavigate, useParams } from "react-router-dom";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import {
Alert,
Box,
Button,
Checkbox,
Group,
Image,
Loader,
Modal,
Paper,
PinInput,
Stack,
Text,
TextInput,
} from "@mantine/core";
import {
ArrowLeft,
Download,
FileSignature,
Printer,
RotateCw,
ShieldCheck,
} from "lucide-react";
import toast from "react-hot-toast";
import { ContractSignSuccessModal } from "@/components/contracts/ContractSignSuccessModal";
import { ContractSignaturePad } from "@/components/bookings/ContractSignaturePad";
import { StampUpload } from "@/components/contracts/StampUpload";
import { contractsService } from "@/services/contracts.service";
import { api } from "@/services/api";
import { extractApiError } from "@/utils/result";
const CONSENT_TEXT = "I have read the entire contract and agree to its terms.";
/**
* Customer contract preview + sign. Customers must scroll through the full
* contract and accept the terms before signing.
*/
export default function ContractViewPage() {
const { id } = useParams<{ id: string }>();
const navigate = useNavigate();
const qc = useQueryClient();
const iframeRef = useRef<HTMLIFrameElement>(null);
const [signOpen, setSignOpen] = useState(false);
const [otpOpen, setOtpOpen] = useState(false);
const [otpCode, setOtpCode] = useState("");
const [otpError, setOtpError] = useState<string | null>(null);
const [successOpen, setSuccessOpen] = useState(false);
const [signerName, setSignerName] = useState("");
const [signatureData, setSignatureData] = useState<string | null>(null);
const [stampData, setStampData] = useState<string | null>(null);
const [drawNew, setDrawNew] = useState(false);
const [hasScrolledToBottom, setHasScrolledToBottom] = useState(false);
const [agreedToTerms, setAgreedToTerms] = useState(false);
// The signing OTP goes to the signed-in user's own registered phone AND email,
// resolved server-side from their account (the same contacts the server
// verifies against). One code covers both, so a delayed SMS doesn't strand the
// signer. The client never picks the contacts, so send and verify can't
// disagree; we only get back a masked hint of where it landed.
const [otpSentTo, setOtpSentTo] = useState<string | null>(null);
const { data, isLoading, isError, refetch } = useQuery({
queryKey: ["contract-view", id],
queryFn: () => contractsService.getContractView(id!),
enabled: Boolean(id),
});
const savedSignatureImage = data?.savedSignature?.signatureImageUrl ?? null;
const usingSaved = Boolean(savedSignatureImage) && !drawNew;
const canProceedToSign = hasScrolledToBottom && agreedToTerms;
const checkScrollBottom = useCallback(() => {
try {
const win = iframeRef.current?.contentWindow;
if (!win?.document?.documentElement) return;
const el = win.document.documentElement;
const threshold = 48;
if (el.scrollHeight <= el.clientHeight + threshold) {
setHasScrolledToBottom(true);
return;
}
if (el.scrollTop + el.clientHeight >= el.scrollHeight - threshold) {
setHasScrolledToBottom(true);
}
} catch {
/* srcDoc is same-origin; ignore edge cases */
}
}, []);
const handleIframeLoad = () => {
checkScrollBottom();
try {
const win = iframeRef.current?.contentWindow;
win?.addEventListener("scroll", checkScrollBottom);
} catch {
/* ignore */
}
};
useEffect(() => {
return () => {
try {
iframeRef.current?.contentWindow?.removeEventListener(
"scroll",
checkScrollBottom,
);
} catch {
/* ignore */
}
};
}, [checkScrollBottom]);
// Send (or resend) the fresh OTP challenge to the contract company's phone. On
// success we swap the signature modal for the OTP entry modal and remember the
// masked destination the server reported.
const sendOtpMutation = useMutation({
mutationFn: () => contractsService.sendSigningOtp(id!),
onSuccess: (res) => {
setOtpSentTo(res.sentTo);
setSignOpen(false);
setOtpError(null);
setOtpOpen(true);
},
onError: (err) =>
toast.error(
extractApiError(err).message ?? "Failed to send verification code",
),
});
const signMutation = useMutation({
mutationFn: () =>
contractsService.signContract(id!, {
role: "CUSTOMER",
signatureImageBase64: usingSaved
? (savedSignatureImage as string)
: (signatureData as string),
stampImageBase64: stampData as string,
signerDisplayName: signerName.trim(),
consentText: CONSENT_TEXT,
otp: otpCode.trim(),
}),
onSuccess: () => {
setOtpOpen(false);
setOtpCode("");
setSuccessOpen(true);
void refetch();
void qc.invalidateQueries({
queryKey: api.contracts.get.queryKey({ id: id! }),
});
},
onError: (err) =>
setOtpError(
extractApiError(err).message ?? "Failed to verify code and sign",
),
});
const openSign = () => {
if (!canProceedToSign) return;
setSignerName(data?.savedSignature?.signerDisplayName ?? "");
setSignatureData(null);
// Prefill with the reusable stamp saved on the profile; still replaceable.
setStampData(data?.savedSignature?.stampImageUrl ?? null);
setDrawNew(false);
setSignOpen(true);
};
const confirmSign = () => {
if (!signerName.trim()) return;
const image = usingSaved ? savedSignatureImage : signatureData;
if (!image || !stampData) return;
// The server resolves and validates the signer's own contacts; if the
// account has neither phone nor email it returns a clear 400 that surfaces
// via the mutation's onError.
setOtpCode("");
sendOtpMutation.mutate();
};
const confirmOtp = () => {
if (otpCode.trim().length !== 6) return;
setOtpError(null);
signMutation.mutate();
};
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]);
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>
);
}
return (
<Box
p={{ base: "md", md: "xl" }}
pb={data.canSignCustomer ? 120 : undefined}
>
<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(`/contracts/${id}`)}
>
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>
</Group>
</Group>
{/* Signed before company stamps were required — re-signing is the only
way to attach one, and EDR cannot counter-sign until it is there. */}
{data.canSignCustomer && data.status === "SIGNED_CUSTOMER" && (
<Alert color="orange" variant="light" radius="md" mb="md">
This contract was signed before a company stamp was required. Please
sign again and attach your stamp so EDR can counter-sign it.
</Alert>
)}
{data.canSignCustomer && !hasScrolledToBottom && (
<Alert color="blue" variant="light" radius="md" mb="md">
Please scroll through the entire contract before signing.
</Alert>
)}
<Paper withBorder radius="lg" p={0} style={{ overflow: "hidden" }}>
<iframe
ref={iframeRef}
srcDoc={data.html}
title="Contract document"
onLoad={handleIframeLoad}
style={{
width: "100%",
minHeight: "80vh",
border: "none",
background: "white",
}}
/>
</Paper>
</Box>
{data.canSignCustomer && hasScrolledToBottom && (
<Paper
withBorder
radius="lg"
p="md"
style={{
position: "fixed",
bottom: 0,
left: 0,
right: 0,
zIndex: 100,
borderTop: "1px solid var(--mantine-color-gray-3)",
background: "var(--mantine-color-body)",
paddingBottom: 32,
}}
>
<Box maw={920} mx="auto">
<Stack gap="sm">
<Checkbox
checked={agreedToTerms}
onChange={(e) => setAgreedToTerms(e.currentTarget.checked)}
disabled={!hasScrolledToBottom}
label={CONSENT_TEXT}
description={
hasScrolledToBottom
? "You may now sign the contract."
: "Read the full contract above before you can agree and sign."
}
/>
<Group justify="flex-end">
<Button
color="edr-green"
leftSection={<FileSignature size={16} />}
disabled={!canProceedToSign}
onClick={openSign}
>
{usingSaved ? "Approve & sign" : "Sign contract"}
</Button>
</Group>
</Stack>
</Box>
</Paper>
)}
<Modal
opened={signOpen}
onClose={() => setSignOpen(false)}
title={usingSaved ? "Approve signature" : "Sign contract"}
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} />
)}
<StampUpload
value={stampData}
onChange={setStampData}
description="Attach your official company stamp or seal — it is applied to the contract next to your signature."
/>
<Group justify="flex-end" gap="sm">
<Button variant="default" onClick={() => setSignOpen(false)}>
Cancel
</Button>
<Button
color="edr-green"
loading={sendOtpMutation.isPending}
disabled={
sendOtpMutation.isPending ||
!signerName.trim() ||
(!usingSaved && !signatureData) ||
!stampData
}
onClick={confirmSign}
>
Continue to verification
</Button>
</Group>
</Stack>
</Modal>
<Modal
opened={otpOpen}
onClose={() => setOtpOpen(false)}
title="Verify it's you"
centered
radius="lg"
>
<Stack gap="md">
<Group gap="sm" wrap="nowrap">
<Box
w={40}
h={40}
style={{
display: "flex",
alignItems: "center",
justifyContent: "center",
borderRadius: 12,
background: "var(--mantine-color-edr-green-0)",
flexShrink: 0,
}}
>
<ShieldCheck size={20} color="var(--mantine-color-edr-green-6)" />
</Box>
<Text size="sm" c="dimmed">
For security, enter the 6-digit code we sent to your registered
contacts
{otpSentTo ? (
<>
{" "}
<Text span fw={600} c="edr-text">
{otpSentTo}
</Text>
</>
) : null}{" "}
to confirm and apply your signature.
</Text>
</Group>
{otpError && (
<Alert color="red" variant="light" radius="md">
{otpError}
</Alert>
)}
<Stack gap={6}>
<Text size="sm" fw={500} c="edr-text">
Verification code
</Text>
<PinInput
length={6}
type="number"
oneTimeCode
value={otpCode}
placeholder="0"
disabled={signMutation.isPending}
styles={{ input: { textAlign: "center" } }}
onChange={setOtpCode}
/>
</Stack>
<Group justify="space-between" gap="sm">
<Button
variant="subtle"
color="gray"
size="compact-sm"
leftSection={<RotateCw size={14} />}
loading={sendOtpMutation.isPending}
disabled={sendOtpMutation.isPending || signMutation.isPending}
onClick={() => {
setOtpError(null);
sendOtpMutation.mutate();
}}
>
Resend code
</Button>
<Group gap="sm">
<Button
variant="default"
onClick={() => setOtpOpen(false)}
disabled={signMutation.isPending}
>
Cancel
</Button>
<Button
color="edr-green"
leftSection={<FileSignature size={16} />}
loading={signMutation.isPending}
disabled={signMutation.isPending || otpCode.trim().length !== 6}
onClick={confirmOtp}
>
Verify &amp; sign
</Button>
</Group>
</Group>
</Stack>
</Modal>
<ContractSignSuccessModal
opened={successOpen}
reference={data.reference}
message="Your signature has been recorded. EDR staff will counter-sign to complete the contract."
onClose={() => {
setSuccessOpen(false);
navigate(`/contracts/${id}`);
}}
/>
</Box>
);
}