feat(last-mile): customer-signed LM contract gates the advance invoice

Approval now snapshots the rate estimate and generates a last-mile
contract instead of invoicing immediately. The customer picks a delivery
date on the confirm form, then reviews and signs the contract in the
portal (saved signature or drawn); the signed PDF is stored as
LM_<CustomerName>.pdf and only then is the advance invoice issued.
Backoffice shows signature status and the contract download.
This commit is contained in:
Hagernesh
2026-08-06 07:10:53 +00:00
parent 4716aa14c3
commit 4786c896b5
14 changed files with 937 additions and 24 deletions

View File

@@ -94,6 +94,20 @@ export function LastMileRequestsPanel() {
const invalidate = () =>
qc.invalidateQueries({ queryKey: QUERY_KEYS.LAST_MILE_REQUESTS.ROOT });
const downloadContract = async (r: LastMileRequest) => {
try {
const { data: blob } = await lastMileRequestsService.contractDocument(r.id);
const url = URL.createObjectURL(blob);
const a = document.createElement("a");
a.href = url;
a.download = `LM_${r.booking?.company?.name?.replace(/[^A-Za-z0-9._-]+/g, "_") ?? r.id}.pdf`;
a.click();
URL.revokeObjectURL(url);
} catch {
toast({ title: "Contract PDF not available", variant: "destructive" });
}
};
const approve = useMutation({
mutationFn: () =>
lastMileRequestsService.approve(approveTarget!.id, Number(advanceAmount)),
@@ -141,8 +155,16 @@ export function LastMileRequestsPanel() {
id: "containers",
header: () => <span>Requested Containers</span>,
cell: ({ row }) => {
const nums = row.original.requestedContainerNumbers;
return <Text size="sm">{nums?.length ? nums.join(", ") : "—"}</Text>;
const r = row.original;
const nums = r.requestedContainerNumbers;
return (
<Stack gap={0}>
<Text size="sm">{nums?.length ? nums.join(", ") : "—"}</Text>
{r.requestedDeliveryDate && (
<Text size="xs" c="dimmed">Delivery: {r.requestedDeliveryDate}</Text>
)}
</Stack>
);
},
},
{
@@ -162,6 +184,24 @@ export function LastMileRequestsPanel() {
);
},
},
{
id: "contract",
header: () => <span>LM Contract</span>,
cell: ({ row }) => {
const r = row.original;
if (r.status !== "APPROVED") return <Text size="sm" c="dimmed"></Text>;
return (
<Stack gap={4} align="flex-start">
<Badge color={r.customerSignedAt ? "green" : "yellow"} variant="light" size="sm">
{r.customerSignedAt ? "Signed" : "Awaiting signature"}
</Badge>
<Button size="compact-xs" variant="subtle" onClick={() => void downloadContract(r)}>
Download PDF
</Button>
</Stack>
);
},
},
...(canApprove
? [
{

View File

@@ -714,6 +714,7 @@ export const URL_CONSTANTS = {
PRICE_ESTIMATE: (id: string) => `/last-mile-requests/${id}/price-estimate`,
APPROVE: (id: string) => `/last-mile-requests/${id}/approve`,
REJECT: (id: string) => `/last-mile-requests/${id}/reject`,
CONTRACT_DOCUMENT: (id: string) => `/last-mile-requests/${id}/contract/document`,
},
DRIVERS: {

View File

@@ -28,6 +28,9 @@ export interface LastMileRequest {
reviewedAt?: string | null;
rejectionReason?: string | null;
resultingLastMileId?: string | null;
requestedDeliveryDate?: string | null;
customerSignedAt?: string | null;
signerDisplayName?: string | null;
createdAt: string;
updatedAt: string;
}
@@ -58,4 +61,6 @@ export const lastMileRequestsService = {
api.post<LastMileRequest>(LMR.APPROVE(id), { advanceAmount }),
reject: (id: string, reason: string) =>
api.post<LastMileRequest>(LMR.REJECT(id), { reason }),
contractDocument: (id: string) =>
api.get<Blob>(LMR.CONTRACT_DOCUMENT(id), { responseType: 'blob' }),
};

View File

@@ -47,6 +47,7 @@ import BookingDetailPage from "./pages/bookings/BookingDetailPage";
import BookingsListPage from "./pages/bookings/BookingsListPage";
import EditBookingPage from "./pages/bookings/EditBookingPage";
import LastMileConfirmPage from "./pages/bookings/last-mile-confirm/LastMileConfirmPage";
import LastMileContractPage from "./pages/bookings/last-mile-contract/LastMileContractPage";
import ContractDetailPage from "./pages/contracts/ContractDetailPage";
import ContractViewPage from "./pages/contracts/ContractViewPage";
import ContractsList from "./pages/contracts/ContractsList";
@@ -323,6 +324,10 @@ const App = () => {
path="/bookings/:id/last-mile-confirm"
element={<LastMileConfirmPage />}
/>
<Route
path="/bookings/:id/last-mile-contract"
element={<LastMileContractPage />}
/>
<Route
path="/bookings/:id/contract"
element={<BookingContractPage />}

View File

@@ -221,5 +221,8 @@ export const URL_CONSTANTS = {
LAST_MILE_REQUESTS: {
BY_ID: (id: string) => `/last-mile-requests/${id}`,
SUBMIT: (id: string) => `/last-mile-requests/${id}/submit`,
CONTRACT_VIEW: (id: string) => `/last-mile-requests/${id}/contract/view`,
CONTRACT_DOCUMENT: (id: string) => `/last-mile-requests/${id}/contract/document`,
CONTRACT_SIGN: (id: string) => `/last-mile-requests/${id}/contract/sign`,
},
};

View File

@@ -1,4 +1,5 @@
import { Button, Center, Checkbox, Loader, Stack, Text } from "@mantine/core";
import { DatePickerInput } from "@mantine/dates";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { useState } from "react";
import { useNavigate, useParams, useSearchParams } from "react-router-dom";
@@ -31,6 +32,7 @@ export default function LastMileConfirmPage() {
const queryClient = useQueryClient();
const [selected, setSelected] = useState<string[]>([]);
const [deliveryDate, setDeliveryDate] = useState<string | null>(null);
const {
data: request,
@@ -51,7 +53,7 @@ export default function LastMileConfirmPage() {
const containerNumbers = booking?.containerNumbers ?? [];
const submitMutation = useMutation({
mutationFn: () => lastMileRequestsService.submit(requestId!, selected),
mutationFn: () => lastMileRequestsService.submit(requestId!, selected, deliveryDate!),
onSuccess: () => {
toast.success("Last-mile confirmation submitted");
queryClient.invalidateQueries({ queryKey: ["last-mile-request", requestId] });
@@ -101,6 +103,23 @@ export default function LastMileConfirmPage() {
Reason: {request.rejectionReason}
</Text>
)}
{request.status === "APPROVED" && (
<>
<Text mt={8} c="dimmed" fz="sm">
{request.customerSignedAt
? "You have signed the last-mile contract."
: "Review and sign the last-mile contract to receive your advance invoice."}
</Text>
<Button
mt={16}
onClick={() =>
navigate(`/bookings/${id}/last-mile-contract?requestId=${requestId}`)
}
>
{request.customerSignedAt ? "View LM contract" : "View and Sign LM"}
</Button>
</>
)}
</SectionCard>
</PageShell>
);
@@ -146,9 +165,24 @@ export default function LastMileConfirmPage() {
))}
</Stack>
<DatePickerInput
mt={16}
label="Delivery date"
description="When should we deliver? Pick a date after the train departs Djibouti."
placeholder="Pick the last-mile delivery date"
value={deliveryDate}
onChange={(date) =>
setDeliveryDate(
date ? new Date(date).toISOString().slice(0, 10) : null,
)
}
minDate={new Date()}
required
/>
<Button
mt={20}
disabled={selected.length === 0}
disabled={selected.length === 0 || !deliveryDate}
loading={submitMutation.isPending}
onClick={() => submitMutation.mutate()}
>

View File

@@ -0,0 +1,240 @@
import { useCallback, useRef, useState } from "react";
import { useNavigate, useParams, useSearchParams } 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 { ContractSignaturePad } from "@/components/bookings/ContractSignaturePad";
import {
lastMileRequestsService,
type SignLastMileContractPayload,
} from "@/services/last-mile-requests.service";
import { Button } from "@edr/ui-common";
const CONSENT_TEXT = "I agree to the terms of this last-mile delivery contract.";
export default function LastMileContractPage() {
const { id } = useParams<{ id: string }>();
const [searchParams] = useSearchParams();
const requestId = searchParams.get("requestId");
const navigate = useNavigate();
const qc = useQueryClient();
const iframeRef = useRef<HTMLIFrameElement>(null);
const [signOpen, setSignOpen] = useState(false);
const [agreed, setAgreed] = useState(false);
const [signerName, setSignerName] = useState("");
const [signatureData, setSignatureData] = useState<string | null>(null);
// When a saved signature exists we offer it for approval first; the customer
// can switch to drawing a fresh one.
const [drawNew, setDrawNew] = useState(false);
const { data, isLoading, isError, refetch } = useQuery({
queryKey: ["last-mile-contract-view", requestId],
queryFn: () => lastMileRequestsService.getContractView(requestId!),
enabled: Boolean(requestId),
});
const savedSignature = data?.savedSignature ?? null;
const savedSignatureImage = savedSignature?.signatureImageUrl ?? null;
const usingSaved = Boolean(savedSignatureImage) && !drawNew;
const openSign = () => {
setSignerName(savedSignature?.signerDisplayName ?? "");
setSignatureData(null);
setDrawNew(false);
setAgreed(false);
setSignOpen(true);
};
const signMutation = useMutation({
mutationFn: (payload: SignLastMileContractPayload) =>
lastMileRequestsService.signContract(requestId!, payload),
onSuccess: () => {
toast.success("Contract signed — your advance invoice is ready");
setSignOpen(false);
void refetch();
qc.invalidateQueries({ queryKey: ["last-mile-request", requestId] });
navigate("/billing");
},
onError: () => toast.error("Failed to sign contract"),
});
const confirmSign = () => {
if (!signerName.trim() || !agreed) return;
const image = usingSaved ? savedSignatureImage : signatureData;
if (!image) return;
signMutation.mutate({
signatureImageBase64: image,
signerDisplayName: signerName.trim(),
consentText: CONSENT_TEXT,
});
};
const downloadPdf = useCallback(async () => {
if (!requestId) return;
try {
const blob = await lastMileRequestsService.downloadContractDocument(requestId);
const url = URL.createObjectURL(blob);
const a = document.createElement("a");
a.href = url;
a.download = `LM_${data?.bookingReference ?? requestId}.pdf`;
a.click();
URL.revokeObjectURL(url);
} catch {
toast.error("PDF not ready yet. Contact EDR if this persists.");
}
}, [requestId, data?.bookingReference]);
const handlePrint = useCallback(() => {
if (iframeRef.current?.contentWindow) {
iframeRef.current.contentWindow.print();
}
}, []);
if (!requestId) {
return (
<div className="p-8">
<p className="text-muted-foreground">Missing request id.</p>
</div>
);
}
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="p-8">
<p className="text-muted-foreground">
Could not load the last-mile contract. It becomes available once your
request is approved.
</p>
<Button variant="outline" className="mt-4" onClick={() => navigate(-1)}>
Go back
</Button>
</div>
);
}
return (
<div className="min-h-screen bg-muted/30 p-4 md:p-8">
<div className="mx-auto max-w-4xl">
<div className="mb-4 flex flex-wrap items-center justify-between gap-3 print:hidden">
<Button variant="ghost" size="sm" onClick={() => navigate(`/bookings/${id}`)}>
<ArrowLeft className="mr-2 size-4" />
Back to booking
</Button>
<div className="flex flex-wrap gap-2">
<Button variant="outline" size="sm" onClick={handlePrint}>
<Printer className="mr-2 size-4" />
Print
</Button>
<Button variant="outline" size="sm" onClick={downloadPdf}>
<Download className="mr-2 size-4" />
PDF
</Button>
{data.canSign && (
<Button size="sm" onClick={openSign}>
<FileSignature className="mr-2 size-4" />
{usingSaved ? "Approve & sign" : "Sign contract"}
</Button>
)}
</div>
</div>
{data.customerSignedAt && (
<p className="mb-3 text-sm text-muted-foreground print:hidden">
Signed by {data.signerDisplayName} on{" "}
{new Date(data.customerSignedAt).toLocaleDateString()}.
</p>
)}
<iframe
ref={iframeRef}
srcDoc={data.html}
className="w-full rounded-lg border bg-white shadow-sm"
style={{ minHeight: "80vh" }}
title="Last-mile contract document"
/>
</div>
{signOpen && (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50 p-4 print:hidden">
<div className="w-full max-w-md rounded-xl bg-background p-6 shadow-xl">
<h2 className="text-lg font-semibold">
{usingSaved ? "Approve signature" : "Sign contract"}
</h2>
<p className="mt-1 text-sm text-muted-foreground">
Booking {data.bookingReference ?? data.bookingId} signing issues
your advance invoice.
</p>
<div className="mt-4 space-y-3">
<label className="text-sm font-medium" htmlFor="lmSigner">
Full name
</label>
<input
id="lmSigner"
className="w-full rounded-md border px-3 py-2 text-sm"
value={signerName}
onChange={(e) => setSignerName(e.target.value)}
/>
{usingSaved ? (
<div className="space-y-2">
<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"
className="text-xs text-primary underline"
onClick={() => {
setDrawNew(true);
setSignatureData(null);
}}
>
Draw a new signature instead
</button>
</div>
) : (
<ContractSignaturePad onChange={setSignatureData} />
)}
<label className="flex items-start gap-2 text-sm">
<input
type="checkbox"
className="mt-0.5"
checked={agreed}
onChange={(e) => setAgreed(e.target.checked)}
/>
<span>{CONSENT_TEXT}</span>
</label>
</div>
<div className="mt-6 flex justify-end gap-2">
<Button variant="outline" onClick={() => setSignOpen(false)}>
Cancel
</Button>
<Button
disabled={
signMutation.isPending ||
(!usingSaved && !signatureData) ||
!agreed ||
!signerName.trim()
}
onClick={confirmSign}
>
{usingSaved ? "Approve & sign" : "Confirm signature"}
</Button>
</div>
</div>
</div>
)}
</div>
);
}

View File

@@ -10,11 +10,35 @@ export interface LastMileRequest {
trainScheduleId: string;
status: "AWAITING_CONFIRMATION" | "SUBMITTED" | "APPROVED" | "REJECTED";
requestedContainerNumbers?: string[] | null;
requestedDeliveryDate?: string | null;
customerSignedAt?: string | null;
rejectionReason?: string | null;
createdAt: string;
updatedAt: string;
}
export interface LastMileContractView {
requestId: string;
bookingId: string;
bookingReference?: string | null;
status: LastMileRequest["status"];
html: string;
customerSignedAt: string | null;
signerDisplayName: string | null;
canSign: boolean;
savedSignature?: {
signerDisplayName: string;
signatureImageUrl: string | null;
stampImageUrl: string | null;
};
}
export interface SignLastMileContractPayload {
signatureImageBase64?: string;
signerDisplayName: string;
consentText?: string;
}
export const lastMileRequestsService = {
/** One last-mile confirmation request, by id. */
get: async (id: string): Promise<LastMileRequest> => {
@@ -22,12 +46,36 @@ export const lastMileRequestsService = {
return data.data ?? data;
},
/** Confirm which containers on the booking go via EDR last-mile. */
/** Confirm which containers go via EDR last-mile and the requested delivery date. */
submit: async (
id: string,
containerNumbers: string[],
deliveryDate: string,
): Promise<LastMileRequest> => {
const { data } = await client.post(L.SUBMIT(id), { containerNumbers });
const { data } = await client.post(L.SUBMIT(id), { containerNumbers, deliveryDate });
return data.data ?? data;
},
/** LM contract view model + rendered HTML + saved signature. */
getContractView: async (id: string): Promise<LastMileContractView> => {
const { data } = await client.get(L.CONTRACT_VIEW(id));
return data.data ?? data;
},
/** Agree & sign the LM contract — the advance invoice is issued right after. */
signContract: async (
id: string,
payload: SignLastMileContractPayload,
): Promise<LastMileRequest> => {
const { data } = await client.post(L.CONTRACT_SIGN(id), payload);
return data.data ?? data;
},
/** The LM contract PDF (LM_<CustomerName>.pdf). */
downloadContractDocument: async (id: string): Promise<Blob> => {
const { data } = await client.get(L.CONTRACT_DOCUMENT(id), {
responseType: "blob",
});
return data;
},
};