From 246663641a7ca36ef8d1fc27427595fc22359fce Mon Sep 17 00:00:00 2001 From: Hagernesh Date: Wed, 8 Jul 2026 13:15:39 +0000 Subject: [PATCH 1/3] 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) --- .../warehouse-inventory.controller.ts | 10 + .../warehouses/warehouse-inventory.service.ts | 15 ++ .../delivery/ApproveDeliveryButton.tsx | 91 +++------- .../delivery/ApproveDeliveryModal.tsx | 171 ++++++++++++++++++ .../portal/src/services/api.ts | 7 + .../portal/src/services/bookings.service.ts | 7 + 6 files changed, 232 insertions(+), 69 deletions(-) create mode 100644 apps/edr-freight-web/portal/src/pages/bookings/delivery/ApproveDeliveryModal.tsx diff --git a/apps/edr-freight-api/src/modules/warehouses/warehouse-inventory.controller.ts b/apps/edr-freight-api/src/modules/warehouses/warehouse-inventory.controller.ts index 9eb4ea502..242cecc76 100644 --- a/apps/edr-freight-api/src/modules/warehouses/warehouse-inventory.controller.ts +++ b/apps/edr-freight-api/src/modules/warehouses/warehouse-inventory.controller.ts @@ -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) { diff --git a/apps/edr-freight-api/src/modules/warehouses/warehouse-inventory.service.ts b/apps/edr-freight-api/src/modules/warehouses/warehouse-inventory.service.ts index 14f4e27f3..a7bc2353a 100644 --- a/apps/edr-freight-api/src/modules/warehouses/warehouse-inventory.service.ts +++ b/apps/edr-freight-api/src/modules/warehouses/warehouse-inventory.service.ts @@ -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, diff --git a/apps/edr-freight-web/portal/src/pages/bookings/delivery/ApproveDeliveryButton.tsx b/apps/edr-freight-web/portal/src/pages/bookings/delivery/ApproveDeliveryButton.tsx index e9ee31722..c3a0a8ccd 100644 --- a/apps/edr-freight-web/portal/src/pages/bookings/delivery/ApproveDeliveryButton.tsx +++ b/apps/edr-freight-web/portal/src/pages/bookings/delivery/ApproveDeliveryButton.tsx @@ -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) => { if (stopPropagation) event.stopPropagation(); - mutation.mutate({ id: bookingId }); + setOpened(true); }; return ( - + <> + + setOpened(false)} + onApproved={onApproved} + /> + ); } diff --git a/apps/edr-freight-web/portal/src/pages/bookings/delivery/ApproveDeliveryModal.tsx b/apps/edr-freight-web/portal/src/pages/bookings/delivery/ApproveDeliveryModal.tsx new file mode 100644 index 000000000..a3723b650 --- /dev/null +++ b/apps/edr-freight-web/portal/src/pages/bookings/delivery/ApproveDeliveryModal.tsx @@ -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(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 ( + + + }> + + Review the handover document below. Approving applies your saved signature + and confirms you received the goods. + + + + {isLoading ? ( + + + + Loading handover document… + + + ) : isError || !pdfUrl ? ( + + Could not load the handover document. It may not be generated yet. + + ) : ( +