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' }),
};