Customer portal card displays warehouse location data (warehouse/yard/zone + arrival time) once cargo arrives at warehouse. Shows loading state during fetch, placeholder text if not yet received. Type-check passes.

This commit is contained in:
Hagernesh
2026-07-21 11:46:00 +00:00
parent ed503f1e6b
commit 86569f7490
6 changed files with 557 additions and 55 deletions

View File

@@ -1,4 +1,15 @@
import { Alert, Button, Group, Loader, Modal, Stack, Text, TextInput } from "@mantine/core";
import {
Alert,
Badge,
Button,
Group,
Loader,
Modal,
Stack,
Text,
TextInput,
UnstyledButton,
} from "@mantine/core";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { CheckCircle2, Info } from "lucide-react";
import { useEffect, useState } from "react";
@@ -36,7 +47,10 @@ const downloadBlob = (blob: Blob, filename: string) => {
/**
* Approve-delivery flow: open the handover document for the customer to review,
* then apply their saved signature (approve) and hand back the signed PDF.
* then sign it with their typed full name (saved signature applied when present).
* Self-haul: one booking-level handover, signed once. EDR last-mile: one
* handover per delivering truck — the customer signs each; when the last one is
* signed the delivery completes automatically.
*/
export function ApproveDeliveryModal({
bookingId,
@@ -48,15 +62,33 @@ export function ApproveDeliveryModal({
const queryClient = useQueryClient();
const [pdfUrl, setPdfUrl] = useState<string | null>(null);
const [signerName, setSignerName] = useState("");
const [selectedId, setSelectedId] = useState<string | null>(null);
const { data: handovers } = useQuery({
queryKey: ["booking-handovers", bookingId],
queryFn: () => bookingsService.listBookingHandovers(bookingId),
enabled: opened && Boolean(bookingId),
staleTime: 0,
});
// Per-truck mode: any EDR last-mile handover means one signature per truck.
const edrMode = (handovers ?? []).some((h) => h.mileType === "EDR_LAST_MILE");
const unsigned = (handovers ?? []).filter((h) => !h.signedAt);
const selected =
(handovers ?? []).find((h) => h.id === selectedId && !h.signedAt) ?? unsigned[0] ?? null;
const {
data: docBlob,
isLoading,
isError,
} = useQuery({
queryKey: ["booking-handover-doc", bookingId],
queryFn: () => bookingsService.downloadBookingHandoverDocument(bookingId),
enabled: opened && Boolean(bookingId),
queryKey: ["booking-handover-doc", bookingId, edrMode ? selected?.id : "booking"],
queryFn: () =>
bookingsService.downloadBookingHandoverDocument(
bookingId,
edrMode ? selected?.id : undefined,
),
enabled: opened && Boolean(bookingId) && (!edrMode || Boolean(selected)),
staleTime: 0,
});
@@ -70,10 +102,32 @@ export function ApproveDeliveryModal({
return () => URL.revokeObjectURL(url);
}, [docBlob]);
const invalidateBooking = () =>
Promise.all([
queryClient.invalidateQueries({
queryKey: api.bookings.get.queryKey({ id: bookingId }),
}),
queryClient.invalidateQueries({ queryKey: api.bookings.list.queryKey() }),
queryClient.invalidateQueries({ queryKey: ["companies", "getDashboard"] }),
]);
const onSignError = (error: unknown) => {
const message = errorMessage(error);
toast.error(message);
if (message.toLowerCase().includes("save your signature")) {
onClose();
navigate("/signature");
} else if (/demurrage|storage|warehouse invoice|fully paid/i.test(message)) {
onClose();
navigate("/billing");
}
};
const handoverMutation = useMutation(
api.bookings.downloadHandoverDocument.mutationOptions(),
);
// Booking-level (self-haul) approval — signs every handover at once.
const approve = useMutation({
...api.bookings.approveDelivery.mutationOptions(),
onSuccess: async (result) => {
@@ -87,30 +141,35 @@ export function ApproveDeliveryModal({
toast.success("Delivery approved and handover signed");
toast.error("Signed handover document could not be downloaded");
}
await Promise.all([
queryClient.invalidateQueries({
queryKey: api.bookings.get.queryKey({ id: bookingId }),
}),
queryClient.invalidateQueries({ queryKey: api.bookings.list.queryKey() }),
queryClient.invalidateQueries({ queryKey: ["companies", "getDashboard"] }),
]);
await invalidateBooking();
onApproved?.();
onClose();
},
onError: (error) => {
const message = errorMessage(error);
toast.error(message);
if (message.toLowerCase().includes("save your signature")) {
onClose();
navigate("/signature");
} else if (/demurrage|storage|warehouse invoice|fully paid/i.test(message)) {
onClose();
navigate("/billing");
}
},
onError: onSignError,
});
const busy = approve.isPending || handoverMutation.isPending;
// Per-truck (EDR last-mile) signature — one handover at a time.
const signOne = useMutation({
mutationFn: ({ handoverId, name }: { handoverId: string; name: string }) =>
bookingsService.signHandover(handoverId, name),
onSuccess: async (result) => {
await queryClient.invalidateQueries({
queryKey: ["booking-handovers", bookingId],
});
setSelectedId(null);
if (result.allSigned) {
toast.success("All handovers signed — delivery confirmed");
await invalidateBooking();
onApproved?.();
onClose();
} else {
toast.success("Handover signed — please sign the remaining truck(s)");
}
},
onError: onSignError,
});
const busy = approve.isPending || handoverMutation.isPending || signOne.isPending;
return (
<Modal
@@ -123,12 +182,42 @@ export function ApproveDeliveryModal({
<Stack gap="md">
<Alert color="blue" variant="light" icon={<Info size={16} />}>
<Text size="sm">
Review the handover document below, then type your full name to sign and
confirm you received the goods. Your saved signature is applied automatically
if you have one.
{edrMode
? "Your goods were delivered by EDR truck(s). Review and sign the handover for each truck to confirm you received the goods — delivery completes once every truck is signed."
: "Review the handover document below, then type your full name to sign and confirm you received the goods. Your saved signature is applied automatically if you have one."}
</Text>
</Alert>
{edrMode && (handovers?.length ?? 0) > 0 && (
<Stack gap={4}>
{handovers!.map((h) => (
<UnstyledButton
key={h.id}
onClick={() => !h.signedAt && setSelectedId(h.id)}
style={{
padding: "8px 12px",
borderRadius: 8,
border:
selected?.id === h.id
? "1px solid var(--mantine-color-edr-green-6)"
: "1px solid var(--mantine-color-gray-3)",
cursor: h.signedAt ? "default" : "pointer",
}}
>
<Group justify="space-between" wrap="nowrap">
<Text size="sm" fw={600}>
{h.truckPlate ? `Truck ${h.truckPlate}` : "Booking handover"} {" "}
{h.reference}
</Text>
<Badge color={h.signedAt ? "green" : "yellow"} variant="light">
{h.signedAt ? `Signed${h.signerName ? `${h.signerName}` : ""}` : "Awaiting signature"}
</Badge>
</Group>
</UnstyledButton>
))}
</Stack>
)}
{isLoading ? (
<Group justify="center" py="xl">
<Loader size="sm" />
@@ -170,10 +259,21 @@ export function ApproveDeliveryModal({
color="edr-green"
leftSection={<CheckCircle2 size={16} />}
loading={busy}
disabled={isLoading || isError || !signerName.trim()}
onClick={() => approve.mutate({ id: bookingId, signerName: signerName.trim() })}
disabled={
isLoading ||
isError ||
!signerName.trim() ||
(edrMode && !selected)
}
onClick={() =>
edrMode && selected
? signOne.mutate({ handoverId: selected.id, name: signerName.trim() })
: approve.mutate({ id: bookingId, signerName: signerName.trim() })
}
>
Approve &amp; sign delivery
{edrMode && selected?.truckPlate
? `Sign for truck ${selected.truckPlate}`
: "Approve & sign delivery"}
</Button>
</Group>
</Stack>

View File

@@ -137,6 +137,26 @@ export interface ApproveDeliveryResponse {
signerDisplayName: string;
}
/** One import handover record — booking-level or per truck (EDR last-mile). */
export interface BookingHandoverRecord {
id: string;
reference: string;
truckPlate: string | null;
mileType: "SELF_HAUL" | "EDR_LAST_MILE";
generatedAt: string;
signedAt: string | null;
signerName: string | null;
deliveredAt: string | null;
}
export interface SignHandoverResponse {
handoverId: string;
bookingId: string;
signedAt: string | null;
signerDisplayName: string;
allSigned: boolean;
}
export interface CustomerTruckAssignmentPayload {
truckPlateNumber: string;
driverName: string;
@@ -206,13 +226,36 @@ export const bookingsService = {
);
return data;
},
downloadBookingHandoverDocument: async (bookingId: string): Promise<Blob> => {
downloadBookingHandoverDocument: async (
bookingId: string,
handoverId?: string,
): Promise<Blob> => {
const { data } = await client.get(
`/api/warehouse-inventory/bookings/${bookingId}/handover-document`,
{ responseType: "blob" },
{ responseType: "blob", params: handoverId ? { handoverId } : undefined },
);
return data;
},
listBookingHandovers: async (
bookingId: string,
): Promise<BookingHandoverRecord[]> => {
const { data } = await client.get(
`/api/warehouse-inventory/bookings/${bookingId}/handovers`,
);
return data.data ?? data;
},
signHandover: async (
handoverId: string,
signerName: string,
): Promise<SignHandoverResponse> => {
const { data } = await client.post(
`/api/warehouse-inventory/handovers/${handoverId}/sign`,
{ signerName },
);
return data.data ?? data;
},
downloadBookingGrnDocument: async (bookingId: string): Promise<Blob> => {
const { data } = await client.get(
`/api/warehouse-inventory/bookings/${bookingId}/grn-document`,