mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-30 00:38:11 +00:00
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:
@@ -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 & sign delivery
|
||||
{edrMode && selected?.truckPlate
|
||||
? `Sign for truck ${selected.truckPlate}`
|
||||
: "Approve & sign delivery"}
|
||||
</Button>
|
||||
</Group>
|
||||
</Stack>
|
||||
|
||||
@@ -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`,
|
||||
|
||||
Reference in New Issue
Block a user