mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-29 21:08:12 +00:00
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:
@@ -361,6 +361,16 @@ export class WarehouseInventoryController {
|
|||||||
return this.handoverService.requestSignature(bookingId);
|
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')
|
@Get('bookings/:bookingId/container-items')
|
||||||
@ApiOperation({ summary: 'Per-container/bulk items of a booking with lifecycle stage + refs' })
|
@ApiOperation({ summary: 'Per-container/bulk items of a booking with lifecycle stage + refs' })
|
||||||
containerItems(@Param('bookingId', ParseUUIDPipe) bookingId: string) {
|
containerItems(@Param('bookingId', ParseUUIDPipe) bookingId: string) {
|
||||||
|
|||||||
@@ -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 }> {
|
async handoverDocument(id: string): Promise<{ filename: string; buffer: Buffer }> {
|
||||||
const [row] = await this.dataSource.query(
|
const [row] = await this.dataSource.query(
|
||||||
`SELECT inv.id,
|
`SELECT inv.id,
|
||||||
|
|||||||
@@ -1,11 +1,8 @@
|
|||||||
import { Button, type ButtonProps } from "@mantine/core";
|
import { Button, type ButtonProps } from "@mantine/core";
|
||||||
import { useMutation, useQueryClient } from "@tanstack/react-query";
|
|
||||||
import { CheckCircle2 } from "lucide-react";
|
import { CheckCircle2 } from "lucide-react";
|
||||||
import type { MouseEvent } from "react";
|
import { type MouseEvent, useState } from "react";
|
||||||
import toast from "react-hot-toast";
|
|
||||||
import { useNavigate } from "react-router-dom";
|
|
||||||
|
|
||||||
import { api } from "@/services/api";
|
import { ApproveDeliveryModal } from "./ApproveDeliveryModal";
|
||||||
|
|
||||||
type ApproveDeliveryButtonProps = ButtonProps & {
|
type ApproveDeliveryButtonProps = ButtonProps & {
|
||||||
bookingId: string;
|
bookingId: string;
|
||||||
@@ -13,25 +10,6 @@ type ApproveDeliveryButtonProps = ButtonProps & {
|
|||||||
onApproved?: () => 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);
|
|
||||||
};
|
|
||||||
|
|
||||||
export function ApproveDeliveryButton({
|
export function ApproveDeliveryButton({
|
||||||
bookingId,
|
bookingId,
|
||||||
stopPropagation,
|
stopPropagation,
|
||||||
@@ -40,56 +18,31 @@ export function ApproveDeliveryButton({
|
|||||||
variant = "filled",
|
variant = "filled",
|
||||||
...props
|
...props
|
||||||
}: ApproveDeliveryButtonProps) {
|
}: ApproveDeliveryButtonProps) {
|
||||||
const navigate = useNavigate();
|
const [opened, setOpened] = useState(false);
|
||||||
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 handleClick = (event: MouseEvent<HTMLButtonElement>) => {
|
const handleClick = (event: MouseEvent<HTMLButtonElement>) => {
|
||||||
if (stopPropagation) event.stopPropagation();
|
if (stopPropagation) event.stopPropagation();
|
||||||
mutation.mutate({ id: bookingId });
|
setOpened(true);
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Button
|
<>
|
||||||
{...props}
|
<Button
|
||||||
size={size}
|
{...props}
|
||||||
variant={variant}
|
size={size}
|
||||||
color="edr-green"
|
variant={variant}
|
||||||
leftSection={<CheckCircle2 size={16} />}
|
color="edr-green"
|
||||||
loading={mutation.isPending || handoverMutation.isPending}
|
leftSection={<CheckCircle2 size={16} />}
|
||||||
onClick={handleClick}
|
onClick={handleClick}
|
||||||
>
|
>
|
||||||
Approve delivery
|
Approve delivery
|
||||||
</Button>
|
</Button>
|
||||||
|
<ApproveDeliveryModal
|
||||||
|
bookingId={bookingId}
|
||||||
|
opened={opened}
|
||||||
|
onClose={() => setOpened(false)}
|
||||||
|
onApproved={onApproved}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 & sign delivery
|
||||||
|
</Button>
|
||||||
|
</Group>
|
||||||
|
</Stack>
|
||||||
|
</Modal>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -264,6 +264,13 @@ export const api = {
|
|||||||
bookingsService.downloadHandoverDocument(inventoryId),
|
bookingsService.downloadHandoverDocument(inventoryId),
|
||||||
),
|
),
|
||||||
|
|
||||||
|
downloadBookingHandoverDocument: endpoint<{ bookingId: string }, Blob>(
|
||||||
|
"bookings",
|
||||||
|
"downloadBookingHandoverDocument",
|
||||||
|
({ bookingId }) =>
|
||||||
|
bookingsService.downloadBookingHandoverDocument(bookingId),
|
||||||
|
),
|
||||||
|
|
||||||
create: endpoint<
|
create: endpoint<
|
||||||
{ payload: CreateBookingPayload; documents?: BookingDocuments },
|
{ payload: CreateBookingPayload; documents?: BookingDocuments },
|
||||||
Freight.IBooking
|
Freight.IBooking
|
||||||
|
|||||||
@@ -174,6 +174,13 @@ export const bookingsService = {
|
|||||||
);
|
);
|
||||||
return data;
|
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> => {
|
tracking: async (id: string): Promise<Freight.IBookingTracking> => {
|
||||||
const { data } = await client.get(`/api/bookings/${id}/tracking`);
|
const { data } = await client.get(`/api/bookings/${id}/tracking`);
|
||||||
return data.data;
|
return data.data;
|
||||||
|
|||||||
Reference in New Issue
Block a user