feat(warehouses): approve-delivery opens handover doc for review and signing

Customer reviews the generated handover before signing:
- add booking-scoped handover-document endpoint (portal only has bookingId)
- ApproveDeliveryModal renders the handover PDF, then applies the customer's
  saved signature on approve and returns the signed PDF
- ApproveDeliveryButton opens the modal instead of one-click silent signing

Handover generation + sign notification (in-app + SMS + email) and the
"Approve delivery" visibility on an awaiting-signature handover were committed
earlier; this wires the review-and-sign step.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Hagernesh
2026-07-08 13:15:39 +00:00
parent 3772db2c72
commit 246663641a
6 changed files with 232 additions and 69 deletions

View File

@@ -361,6 +361,16 @@ export class WarehouseInventoryController {
return this.handoverService.requestSignature(bookingId);
}
@Get('bookings/:bookingId/handover-document')
@ApiOperation({ summary: 'View import goods handover document PDF (resolved by booking)' })
async bookingHandoverDocument(@Param('bookingId', ParseUUIDPipe) bookingId: string, @Res() res: Response) {
const { filename, buffer } = await this.inventoryService.handoverDocumentForBooking(bookingId);
res.setHeader('Content-Type', 'application/pdf');
res.setHeader('Content-Disposition', `inline; filename="${filename}"`);
res.setHeader('Content-Length', buffer.length);
return res.send(buffer);
}
@Get('bookings/:bookingId/container-items')
@ApiOperation({ summary: 'Per-container/bulk items of a booking with lifecycle stage + refs' })
containerItems(@Param('bookingId', ParseUUIDPipe) bookingId: string) {

View File

@@ -3016,6 +3016,21 @@ export class WarehouseInventoryService {
};
}
/** Handover PDF resolved by booking (for the portal, which only has bookingId). */
async handoverDocumentForBooking(bookingId: string): Promise<{ filename: string; buffer: Buffer }> {
const [inv]: Array<{ id: string }> = await this.dataSource.query(
`SELECT id FROM freight.warehouse_inventory
WHERE booking_id = $1 AND deleted_at IS NULL
ORDER BY updated_at DESC NULLS LAST, created_at DESC
LIMIT 1`,
[bookingId],
);
if (!inv) {
throw new NotFoundException(`No warehouse inventory found for booking ${bookingId}`);
}
return this.handoverDocument(inv.id);
}
async handoverDocument(id: string): Promise<{ filename: string; buffer: Buffer }> {
const [row] = await this.dataSource.query(
`SELECT inv.id,

View File

@@ -1,11 +1,8 @@
import { Button, type ButtonProps } from "@mantine/core";
import { useMutation, useQueryClient } from "@tanstack/react-query";
import { CheckCircle2 } from "lucide-react";
import type { MouseEvent } from "react";
import toast from "react-hot-toast";
import { useNavigate } from "react-router-dom";
import { type MouseEvent, useState } from "react";
import { api } from "@/services/api";
import { ApproveDeliveryModal } from "./ApproveDeliveryModal";
type ApproveDeliveryButtonProps = ButtonProps & {
bookingId: string;
@@ -13,25 +10,6 @@ type ApproveDeliveryButtonProps = ButtonProps & {
onApproved?: () => void;
};
const errorMessage = (error: unknown) => {
const data = (error as { response?: { data?: { message?: string | string[] } } })
?.response?.data;
if (Array.isArray(data?.message)) return data.message.join(", ");
if (data?.message) return data.message;
return error instanceof Error ? error.message : "Could not approve delivery";
};
const downloadBlob = (blob: Blob, filename: string) => {
const url = URL.createObjectURL(blob);
const link = document.createElement("a");
link.href = url;
link.download = filename;
document.body.appendChild(link);
link.click();
link.remove();
URL.revokeObjectURL(url);
};
export function ApproveDeliveryButton({
bookingId,
stopPropagation,
@@ -40,56 +18,31 @@ export function ApproveDeliveryButton({
variant = "filled",
...props
}: ApproveDeliveryButtonProps) {
const navigate = useNavigate();
const queryClient = useQueryClient();
const handoverMutation = useMutation(api.bookings.downloadHandoverDocument.mutationOptions());
const mutation = useMutation({
...api.bookings.approveDelivery.mutationOptions(),
onSuccess: async (result) => {
try {
const blob = await handoverMutation.mutateAsync({ inventoryId: result.inventoryId });
downloadBlob(blob, `handover-${bookingId}.pdf`);
toast.success("Delivery approved and signed handover downloaded");
} catch {
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"] }),
]);
onApproved?.();
},
onError: (error) => {
const message = errorMessage(error);
toast.error(message);
if (message.toLowerCase().includes("save your signature")) {
navigate("/signature");
} else if (/demurrage|storage|warehouse invoice|fully paid/i.test(message)) {
navigate("/billing");
}
},
});
const [opened, setOpened] = useState(false);
const handleClick = (event: MouseEvent<HTMLButtonElement>) => {
if (stopPropagation) event.stopPropagation();
mutation.mutate({ id: bookingId });
setOpened(true);
};
return (
<Button
{...props}
size={size}
variant={variant}
color="edr-green"
leftSection={<CheckCircle2 size={16} />}
loading={mutation.isPending || handoverMutation.isPending}
onClick={handleClick}
>
Approve delivery
</Button>
<>
<Button
{...props}
size={size}
variant={variant}
color="edr-green"
leftSection={<CheckCircle2 size={16} />}
onClick={handleClick}
>
Approve delivery
</Button>
<ApproveDeliveryModal
bookingId={bookingId}
opened={opened}
onClose={() => setOpened(false)}
onApproved={onApproved}
/>
</>
);
}

View File

@@ -0,0 +1,171 @@
import { Alert, Button, Group, Loader, Modal, Stack, Text } from "@mantine/core";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { CheckCircle2, Info } from "lucide-react";
import { useEffect, useState } from "react";
import toast from "react-hot-toast";
import { useNavigate } from "react-router-dom";
import { api } from "@/services/api";
import { bookingsService } from "@/services/bookings.service";
type ApproveDeliveryModalProps = {
bookingId: string;
opened: boolean;
onClose: () => void;
onApproved?: () => void;
};
const errorMessage = (error: unknown) => {
const data = (error as { response?: { data?: { message?: string | string[] } } })
?.response?.data;
if (Array.isArray(data?.message)) return data.message.join(", ");
if (data?.message) return data.message;
return error instanceof Error ? error.message : "Could not approve delivery";
};
const downloadBlob = (blob: Blob, filename: string) => {
const url = URL.createObjectURL(blob);
const link = document.createElement("a");
link.href = url;
link.download = filename;
document.body.appendChild(link);
link.click();
link.remove();
URL.revokeObjectURL(url);
};
/**
* Approve-delivery flow: open the handover document for the customer to review,
* then apply their saved signature (approve) and hand back the signed PDF.
*/
export function ApproveDeliveryModal({
bookingId,
opened,
onClose,
onApproved,
}: ApproveDeliveryModalProps) {
const navigate = useNavigate();
const queryClient = useQueryClient();
const [pdfUrl, setPdfUrl] = useState<string | null>(null);
const {
data: docBlob,
isLoading,
isError,
} = useQuery({
queryKey: ["booking-handover-doc", bookingId],
queryFn: () => bookingsService.downloadBookingHandoverDocument(bookingId),
enabled: opened && Boolean(bookingId),
staleTime: 0,
});
useEffect(() => {
if (!docBlob) {
setPdfUrl(null);
return;
}
const url = URL.createObjectURL(docBlob);
setPdfUrl(url);
return () => URL.revokeObjectURL(url);
}, [docBlob]);
const handoverMutation = useMutation(
api.bookings.downloadHandoverDocument.mutationOptions(),
);
const approve = useMutation({
...api.bookings.approveDelivery.mutationOptions(),
onSuccess: async (result) => {
try {
const signed = await handoverMutation.mutateAsync({
inventoryId: result.inventoryId,
});
downloadBlob(signed, `handover-${bookingId}.pdf`);
toast.success("Delivery approved and signed handover downloaded");
} catch {
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"] }),
]);
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");
}
},
});
const busy = approve.isPending || handoverMutation.isPending;
return (
<Modal
opened={opened}
onClose={onClose}
title="Approve delivery — review & sign the handover"
size="xl"
centered
>
<Stack gap="md">
<Alert color="blue" variant="light" icon={<Info size={16} />}>
<Text size="sm">
Review the handover document below. Approving applies your saved signature
and confirms you received the goods.
</Text>
</Alert>
{isLoading ? (
<Group justify="center" py="xl">
<Loader size="sm" />
<Text size="sm" c="dimmed">
Loading handover document
</Text>
</Group>
) : isError || !pdfUrl ? (
<Text size="sm" c="red">
Could not load the handover document. It may not be generated yet.
</Text>
) : (
<iframe
title="Handover document"
src={pdfUrl}
style={{
width: "100%",
height: "60vh",
border: "1px solid var(--mantine-color-gray-3)",
borderRadius: 8,
}}
/>
)}
<Group justify="flex-end">
<Button variant="default" onClick={onClose} disabled={busy}>
Cancel
</Button>
<Button
color="edr-green"
leftSection={<CheckCircle2 size={16} />}
loading={busy}
disabled={isLoading || isError}
onClick={() => approve.mutate({ id: bookingId })}
>
Approve &amp; sign delivery
</Button>
</Group>
</Stack>
</Modal>
);
}

View File

@@ -264,6 +264,13 @@ export const api = {
bookingsService.downloadHandoverDocument(inventoryId),
),
downloadBookingHandoverDocument: endpoint<{ bookingId: string }, Blob>(
"bookings",
"downloadBookingHandoverDocument",
({ bookingId }) =>
bookingsService.downloadBookingHandoverDocument(bookingId),
),
create: endpoint<
{ payload: CreateBookingPayload; documents?: BookingDocuments },
Freight.IBooking

View File

@@ -174,6 +174,13 @@ export const bookingsService = {
);
return data;
},
downloadBookingHandoverDocument: async (bookingId: string): Promise<Blob> => {
const { data } = await client.get(
`/api/warehouse-inventory/bookings/${bookingId}/handover-document`,
{ responseType: "blob" },
);
return data;
},
tracking: async (id: string): Promise<Freight.IBookingTracking> => {
const { data } = await client.get(`/api/bookings/${id}/tracking`);
return data.data;