mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-26 18:42:49 +00:00
517 lines
16 KiB
TypeScript
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 & 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>
|
|
);
|
|
}
|