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

@@ -7,6 +7,7 @@ import {
Logger,
Optional,
} from "@nestjs/common";
import { OnEvent } from "@nestjs/event-emitter";
import { BookingBatchService } from '../train-scheduling/booking-batch.service';
import { eatDay } from '../train-scheduling/batch-window.util';
@@ -349,6 +350,22 @@ export class BookingTransitionService {
return fresh;
}
/**
* Import EDR last-mile: every handover signed + every truck departed ⇒ the
* warehouses module delivered the goods and asks the booking to complete.
* Best-effort — a booking already COMPLETED (or not yet in transit) just logs.
*/
@OnEvent('import.handover.completed')
async onImportHandoverCompleted(payload: { bookingId: string }): Promise<void> {
try {
await this.complete(payload.bookingId);
} catch (err) {
this.logger.log(
`Booking ${payload.bookingId} not auto-completed on handover sign: ${(err as Error).message}`,
);
}
}
async complete(bookingId: string): Promise<Booking> {
const booking = await this.bookingsService.findById(bookingId);
assertBookingStatus(booking, ["IN_TRANSIT", "ARRIVED"]);

View File

@@ -1228,9 +1228,9 @@ export class BookingsService {
/**
* Batched version of the findById flag: marks each page item whose booking
* has a generated-but-unsigned SELF_HAUL handover, so list rows (portal
* dashboard) can show "Approve delivery" for exactly the generated→signed
* window. One query for the whole page.
* has a generated-but-unsigned handover (self-haul or EDR last-mile), so list
* rows (portal dashboard) can show "Approve delivery" for exactly the
* generated→signed window. One query for the whole page.
*/
private async attachHandoverFlags(bookings: Booking[]): Promise<void> {
const ids = bookings.map((b) => b.id);
@@ -1239,8 +1239,7 @@ export class BookingsService {
`SELECT DISTINCT booking_id AS "bookingId"
FROM freight.booking_handovers
WHERE booking_id = ANY($1::uuid[])
AND signed_at IS NULL AND deleted_at IS NULL
AND mile_type = 'SELF_HAUL'`,
AND signed_at IS NULL AND deleted_at IS NULL`,
[ids],
);
const pending = new Set(rows.map((r) => r.bookingId));
@@ -1583,14 +1582,12 @@ export class BookingsService {
schedule?.status ?? null;
}
// A generated-but-unsigned SELF_HAUL handover means the customer must approve
// delivery from the portal (booking-based, one per booking). EDR last-mile
// handovers are per delivering truck and signed by the receiver at the door,
// so they never surface the portal "Approve delivery" action.
// A generated-but-unsigned handover means the customer must approve delivery
// from the portal. Self-haul: booking-based, one per booking. EDR last-mile:
// per delivering truck (generated on truck exit), signed one by one.
const [pendingHandover] = await this.dataSource.query(
`SELECT 1 FROM freight.booking_handovers
WHERE booking_id = $1 AND signed_at IS NULL AND deleted_at IS NULL
AND mile_type = 'SELF_HAUL'
LIMIT 1`,
[id],
);

View File

@@ -486,6 +486,21 @@ export class WarehouseInventoryController {
return this.handoverService.list(bookingId);
}
@Post('handovers/:handoverId/sign')
@ApiOperation({ summary: 'Customer signs one handover (EDR last-mile: one signature per truck)' })
signHandover(
@Param('handoverId', ParseUUIDPipe) handoverId: string,
@Body() dto: ApproveDeliveryDto,
@Request() req: { user?: { id?: string; sub?: string } },
@CurrentUser() user: TCurrentUser,
) {
return this.inventoryService.signHandover(
handoverId,
user?.id ?? req.user?.id ?? req.user?.sub,
dto.signerName,
);
}
@Post('bookings/:bookingId/request-handover-signature')
@ApiOperation({ summary: 'Ask the customer to sign the handover (creates one if none, then notifies)' })
requestHandoverSignature(@Param('bookingId', ParseUUIDPipe) bookingId: string) {
@@ -513,9 +528,16 @@ export class WarehouseInventoryController {
}
@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);
@ApiOperation({ summary: 'View import goods handover document PDF (resolved by booking; ?handoverId= for the per-truck variant)' })
async bookingHandoverDocument(
@Param('bookingId', ParseUUIDPipe) bookingId: string,
@Res() res: Response,
@Query('handoverId') handoverId?: string,
) {
const { filename, buffer } = await this.inventoryService.handoverDocumentForBooking(
bookingId,
handoverId || undefined,
);
res.setHeader('Content-Type', 'application/pdf');
res.setHeader('Content-Disposition', `inline; filename="${filename}"`);
res.setHeader('Content-Length', buffer.length);

View File

@@ -1,4 +1,5 @@
import { BadRequestException, Injectable, Logger, NotFoundException } from '@nestjs/common';
import { EventEmitter2 } from '@nestjs/event-emitter';
import { Cron, CronExpression } from '@nestjs/schedule';
import {
Between,
@@ -25,6 +26,7 @@ import { CargoType } from '../rule-engine/entities/cargo-type.entity';
import { InterchangeDocumentsService } from '../interchange-documents/interchange-documents.service';
import type { InterchangeDocument } from '../interchange-documents/entities/interchange-document.entity';
import { LastMileService } from '../last-mile/last-mile.service';
import { UpdateLastMileDto } from '../last-mile/dto/update-last-mile.dto';
import { NotificationsService } from '../notifications/notifications.service';
import { sendCompanyChannels } from '../notifications/notify-company.util';
import {
@@ -409,6 +411,7 @@ export class WarehouseInventoryService {
private readonly signatures: SignaturesService,
private readonly handover: HandoverService,
private readonly inbox: NotificationInboxService,
private readonly events: EventEmitter2,
) {}
/**
@@ -3148,7 +3151,7 @@ export class WarehouseInventoryService {
// EDR last-mile: this truck is leaving — record its exit and the load it
// actually took. net_weight_tons drives the bulk drawdown (booking VGM
// minus everything already hauled away).
await manager.query(
const [edrDeparted] = (await manager.query(
`UPDATE freight.last_mile_vehicle_assignments va
SET departed_at = COALESCE($3::timestamptz, NOW()),
arrived_at = COALESCE(va.arrived_at, NOW()),
@@ -3162,7 +3165,8 @@ export class WarehouseInventoryService {
AND v.id = va.vehicle_id
AND (UPPER(v.power_plate_no) = UPPER($2) OR UPPER(v.plate_number) = UPPER($2))
AND va.departed_at IS NULL
AND va.deleted_at IS NULL`,
AND va.deleted_at IS NULL
RETURNING va.id`,
[
item.bookingId,
dto.truckPlateNumber.trim(),
@@ -3170,7 +3174,31 @@ export class WarehouseInventoryService {
grossTons,
netTons,
],
);
)) as [Array<{ id: string }>, unknown];
// EDR last-mile: the handover is generated the moment the truck exits
// (with its exit paper) — one per truck — and the customer is asked to
// sign it from the portal. Booking-level fallback when the plate matched
// no live assignment (e.g. exit re-recorded) but the booking is EDR-hauled.
for (const row of edrDeparted) {
await this.handover.ensureForDepartedEdrTruck(
item.bookingId,
{ truckPlate: dto.truckPlateNumber.trim(), edrAssignmentId: row.id },
manager,
);
}
if (!edrDeparted.length) {
const [lm]: Array<{ id: string }> = await manager.query(
`SELECT id FROM freight.last_mile WHERE booking_id = $1 AND deleted_at IS NULL LIMIT 1`,
[item.bookingId],
);
if (lm) {
await this.handover.ensureForDepartedEdrTruck(
item.bookingId,
{ truckPlate: dto.truckPlateNumber.trim() },
manager,
);
}
}
// Customer self-haul: the same exit record on the customer's own truck.
// Without it a self-haul bulk booking never draws down — hauled tonnage
// summed to zero and the booking could take unlimited trucks. Matched by
@@ -3220,11 +3248,43 @@ export class WarehouseInventoryService {
// the transaction and fire-and-forget: notifying must never fail the exit.
if (isTruckLeaving && item.bookingId) {
void this.notifyTruckDeparture(item.bookingId, dto.truckPlateNumber?.trim() ?? null, netTons);
} else if (item.bookingId) {
// Gate-in: same single-path hook for the arrival side (self-haul + EDR).
void this.notifyTruckArrival(item.bookingId, dto.truckPlateNumber?.trim() ?? null);
}
return this.findById(id);
}
/** Best-effort truck-arrival notification (gate-in), mirror of the departure one. */
private async notifyTruckArrival(bookingId: string, plateNumber: string | null): Promise<void> {
try {
const [booking]: Array<{ companyId: string | null; reference: string | null }> =
await this.dataSource.query(
`SELECT company_id AS "companyId", reference
FROM freight.bookings
WHERE id = $1 AND deleted_at IS NULL`,
[bookingId],
);
if (!booking?.companyId) return;
const ref = booking.reference ?? bookingId;
const truck = plateNumber ? `Truck ${plateNumber}` : 'A truck';
const body = `${truck} has arrived at the warehouse for booking ${ref}.`;
await this.inbox.notify({
recipients: { companyId: booking.companyId },
audience: NotificationAudience.PORTAL,
type: NotificationType.BOOKING_STATUS,
title: 'Truck arrived at the warehouse',
body,
link: `/bookings/${bookingId}`,
data: { bookingId, plateNumber, action: 'TRUCK_ARRIVED' },
});
await sendCompanyChannels(this.dataSource, this.notifications, booking.companyId, body);
} catch (err) {
this.logger.warn(`Truck-arrival notify failed for ${bookingId}: ${(err as Error).message}`);
}
}
/**
* Best-effort truck-departure notification to the booking's company across
* every channel: in-app (portal inbox) + SMS + email. Never throws — a missing
@@ -3906,8 +3966,205 @@ export class WarehouseInventoryService {
};
}
/** Handover PDF resolved by booking (for the portal, which only has bookingId). */
async handoverDocumentForBooking(bookingId: string): Promise<{ filename: string; buffer: Buffer }> {
/**
* Customer signs ONE handover from the portal (EDR last-mile: one per truck).
* When the last one is signed — and every EDR truck has left the warehouse —
* the delivery completes automatically: inventory + cargo delivered, last-mile
* leg DELIVERED (trucks freed), booking completed ("shipment delivered").
*/
async signHandover(
handoverId: string,
userId?: string,
signerName?: string,
): Promise<{
handoverId: string;
bookingId: string;
signedAt: string | null;
signerDisplayName: string;
allSigned: boolean;
}> {
if (!userId) {
throw new BadRequestException('Authentication is required to sign the handover');
}
const name = signerName?.trim();
if (!name) {
throw new BadRequestException('Please enter your full name to sign the handover');
}
const [h]: Array<{
bookingId: string;
reference: string;
truckPlate: string | null;
mileType: string;
edrAssignmentId: string | null;
}> = await this.dataSource.query(
`SELECT booking_id AS "bookingId", reference, truck_plate AS "truckPlate",
mile_type AS "mileType", edr_assignment_id AS "edrAssignmentId"
FROM freight.booking_handovers
WHERE id = $1 AND deleted_at IS NULL`,
[handoverId],
);
if (!h) throw new NotFoundException(`Handover ${handoverId} not found`);
// Same gate as approve-delivery: storage/demurrage must be settled first.
const [inv]: Array<{ id: string; warehouseId: string | null }> = await this.dataSource.query(
`SELECT id, warehouse_id AS "warehouseId" 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`,
[h.bookingId],
);
if (inv) await this.invoices.assertClearanceAllowed(inv.id);
const signed = await this.handover.sign(handoverId, userId, name);
const allSigned = await this.handover.isFullySigned(h.bookingId);
if (inv) {
await this.activityLog.record({
activityType: 'INVENTORY_RELEASED',
inventoryId: inv.id,
warehouseId: inv.warehouseId,
description: `Customer signed handover ${h.reference}${h.truckPlate ? ` (truck ${h.truckPlate})` : ''} as ${name}`,
performedBy: name,
});
}
// EDR last-mile delivers PER TRUCK: this signature confirms receipt of the
// goods THIS truck carried, so only its containers become DELIVERED now.
// (Self-haul keeps the single booking-level handover + manual Deliver.)
if (h.mileType === 'EDR_LAST_MILE') {
try {
await this.deliverEdrTruckContainers(h, name);
} catch (err) {
this.logger.warn(
`Per-truck auto-deliver after handover sign failed for ${h.bookingId}: ${(err as Error).message}`,
);
}
}
if (allSigned) {
void this.completeEdrDeliveryIfReady(h.bookingId, name).catch((err: Error) =>
this.logger.warn(`Auto-complete after handover sign failed for ${h.bookingId}: ${err.message}`),
);
}
return {
handoverId,
bookingId: h.bookingId,
signedAt: signed.signedAt ? new Date(signed.signedAt).toISOString() : null,
signerDisplayName: name,
allSigned,
};
}
/**
* EDR last-mile auto-completion: once every handover is signed and every EDR
* truck has departed, deliver the remaining inventory, mark the last-mile leg
* DELIVERED and complete the booking. Self-haul bookings keep their manual
* Deliver flow (no last_mile record ⇒ no-op).
*/
private async completeEdrDeliveryIfReady(bookingId: string, signerName: string): Promise<void> {
const [lm]: Array<{ id: string; status: string }> = await this.dataSource.query(
`SELECT id, status FROM freight.last_mile
WHERE booking_id = $1 AND deleted_at IS NULL LIMIT 1`,
[bookingId],
);
if (!lm) return;
const [pending]: Array<{ notDeparted: string }> = await this.dataSource.query(
`SELECT COUNT(*) FILTER (WHERE va.departed_at IS NULL) AS "notDeparted"
FROM freight.last_mile_vehicle_assignments va
JOIN freight.last_mile l ON l.id = va.last_mile_id AND l.deleted_at IS NULL
WHERE l.booking_id = $1 AND va.deleted_at IS NULL`,
[bookingId],
);
if (Number(pending?.notDeparted ?? 0) > 0) return;
if (!(await this.handover.isFullySigned(bookingId))) return;
const items: Array<{ id: string }> = await this.dataSource.query(
`SELECT id FROM freight.warehouse_inventory
WHERE booking_id = $1 AND status = 'READY_FOR_PICKUP' AND deleted_at IS NULL`,
[bookingId],
);
for (const it of items) {
try {
await this.deliver(it.id, {
receiverName: signerName,
remarks: 'Auto-delivered on customer handover signature',
performedBy: signerName,
} as DeliverInventoryDto);
} catch (err) {
this.logger.warn(`Auto-deliver of inventory ${it.id} failed: ${(err as Error).message}`);
}
}
if (lm.status !== 'DELIVERED') {
try {
await this.lastMileService.update(lm.id, { status: 'DELIVERED' } as UpdateLastMileDto);
} catch (err) {
this.logger.warn(`Auto-deliver of last-mile ${lm.id} failed: ${(err as Error).message}`);
}
}
// Booking → COMPLETED ("shipment delivered" notification) — owned by the
// bookings module; evented to avoid a warehouses→bookings service dependency.
this.events.emit('import.handover.completed', { bookingId });
}
/**
* EDR last-mile per-truck delivery: the customer signed THIS truck's handover,
* so only the container items that truck carried become DELIVERED. Bulk cargo
* (no container rows) is delivered by completeEdrDeliveryIfReady once every
* truck is signed off.
*/
private async deliverEdrTruckContainers(
h: { bookingId: string; edrAssignmentId: string | null; truckPlate: string | null },
signerName: string,
): Promise<void> {
if (!h.edrAssignmentId && !h.truckPlate) return;
const items: Array<{ id: string }> = await this.dataSource.query(
`SELECT DISTINCT inv.id
FROM freight.last_mile_vehicle_assignments va
JOIN freight.last_mile l ON l.id = va.last_mile_id AND l.deleted_at IS NULL
LEFT JOIN freight.last_mile_vehicle_containers vc
ON vc.assignment_id = va.id AND vc.deleted_at IS NULL
LEFT JOIN freight.vehicles v ON v.id = va.vehicle_id
JOIN freight.containers c
ON c.container_number = COALESCE(vc.container_number, va.container_number)
AND c.deleted_at IS NULL
JOIN freight.warehouse_inventory inv
ON inv.container_id = c.id AND inv.booking_id = l.booking_id AND inv.deleted_at IS NULL
WHERE l.booking_id = $1
AND va.deleted_at IS NULL
AND inv.status = 'READY_FOR_PICKUP'
AND (va.id = $2::uuid
OR ($2::uuid IS NULL
AND (UPPER(v.power_plate_no) = UPPER($3) OR UPPER(v.plate_number) = UPPER($3))))`,
[h.bookingId, h.edrAssignmentId, h.truckPlate ?? ''],
);
for (const it of items) {
try {
await this.deliver(it.id, {
receiverName: signerName,
remarks: `Auto-delivered on customer handover signature${h.truckPlate ? ` (truck ${h.truckPlate})` : ''}`,
performedBy: signerName,
} as DeliverInventoryDto);
} catch (err) {
this.logger.warn(
`Per-truck auto-deliver of inventory ${it.id} failed: ${(err as Error).message}`,
);
}
}
}
/**
* Handover PDF resolved by booking (for the portal, which only has bookingId).
* With `handoverId` the document is rendered for that specific handover — the
* per-truck EDR last-mile variant (truck plate + that truck's signature state).
*/
async handoverDocumentForBooking(
bookingId: string,
handoverId?: 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
@@ -3918,7 +4175,29 @@ export class WarehouseInventoryService {
if (!inv) {
throw new NotFoundException(`No warehouse inventory found for booking ${bookingId}`);
}
return this.handoverDocument(inv.id);
if (!handoverId) return this.handoverDocument(inv.id);
const [h]: Array<{
reference: string;
truckPlate: string | null;
signedAt: string | null;
signerName: string | null;
}> = await this.dataSource.query(
`SELECT reference, truck_plate AS "truckPlate",
signed_at AS "signedAt", signer_name AS "signerName"
FROM freight.booking_handovers
WHERE id = $1 AND booking_id = $2 AND deleted_at IS NULL`,
[handoverId, bookingId],
);
if (!h) {
throw new NotFoundException(`Handover ${handoverId} not found for booking ${bookingId}`);
}
return this.handoverDocument(inv.id, {
reference: h.reference,
truckPlate: h.truckPlate,
signedAt: h.signedAt ? new Date(h.signedAt) : null,
signerName: h.signerName,
});
}
/** Resolve the primary warehouse-inventory item for a booking (most recent). */
@@ -3946,7 +4225,15 @@ export class WarehouseInventoryService {
return this.releaseDocument(await this.primaryInventoryIdForBooking(bookingId));
}
async handoverDocument(id: string): Promise<{ filename: string; buffer: Buffer }> {
async handoverDocument(
id: string,
perTruck?: {
reference: string;
truckPlate: string | null;
signedAt: Date | null;
signerName: string | null;
},
): Promise<{ filename: string; buffer: Buffer }> {
const [row] = await this.dataSource.query(
`SELECT inv.id,
inv.booking_id AS "bookingId",
@@ -4033,12 +4320,14 @@ export class WarehouseInventoryService {
const bookingReference = row.bookingReference || row.bookingId || 'N/A';
const reference =
perTruck?.reference ||
this.extractHandoverDocumentLine(row.notes, 'Handover Reference') ||
`HND-${String(bookingReference).replace(/[^a-zA-Z0-9_-]+/g, '-')}`;
const generatedAtValue = this.extractHandoverDocumentLine(row.notes, 'Generated At');
const generatedAt = generatedAtValue ? new Date(generatedAtValue) : new Date();
const handedOverAt = Number.isNaN(generatedAt.getTime()) ? new Date() : generatedAt;
if (!generatedAtValue) {
// Per-truck renders must not stamp their reference into the shared item notes.
if (!generatedAtValue && !perTruck) {
await this.inventoryRepository.update(id, {
notes: this.replaceHandoverDocumentNote(row.notes, this.buildHandoverDocumentNote(reference, handedOverAt)),
});
@@ -4072,7 +4361,16 @@ export class WarehouseInventoryService {
releaseDate: row.releaseDate ? new Date(row.releaseDate) : null,
trainSchedule: row.trainSchedule ?? null,
lastMileDeliveryAddress: row.lastMileDeliveryAddress ?? null,
customerApproval: this.extractCustomerDeliveryApproval(row.notes),
truckPlate: perTruck?.truckPlate ?? null,
customerApproval: perTruck
? perTruck.signedAt
? {
approvedAt: perTruck.signedAt.toISOString(),
signerDisplayName: perTruck.signerName ?? '-',
signatureImageUrl: null,
}
: null
: this.extractCustomerDeliveryApproval(row.notes),
});
return {
@@ -4131,6 +4429,23 @@ export class WarehouseInventoryService {
);
}
}
// EDR last-mile: same per-truck rule — each assigned truck is weighed out
// separately, and deliver waits until the last one has left.
const [lm]: Array<{ total: string; left: string }> = await this.dataSource.query(
`SELECT COUNT(*) AS total,
COUNT(*) FILTER (WHERE va.departed_at IS NOT NULL) AS "left"
FROM freight.last_mile_vehicle_assignments va
JOIN freight.last_mile l ON l.id = va.last_mile_id AND l.deleted_at IS NULL
WHERE l.booking_id = $1 AND va.deleted_at IS NULL`,
[item.bookingId],
);
const lmTotal = Number(lm?.total ?? 0);
const lmLeft = Number(lm?.left ?? 0);
if (lmTotal > 0 && lmLeft < lmTotal) {
throw new BadRequestException(
`Deliver is available only after every assigned EDR truck has left (${lmLeft} of ${lmTotal} so far)`,
);
}
}
const receiverName = dto.receiverName.trim();
@@ -4225,6 +4540,12 @@ export class WarehouseInventoryService {
}
});
// "Approve delivery" nudge: on Deliver the customer is reminded to sign any
// handover still unsigned (per truck for EDR last-mile). Fire-and-forget.
if (item.bookingId) {
void this.handover.notifyUnsignedForBooking(item.bookingId).catch(() => undefined);
}
return this.findById(id);
}
@@ -4952,10 +5273,11 @@ export class WarehouseInventoryService {
releaseDate: Date | null;
trainSchedule: string | null;
lastMileDeliveryAddress: string | null;
truckPlate?: string | null;
customerApproval: {
approvedAt: string;
signerDisplayName: string;
signatureImageUrl: string;
signatureImageUrl: string | null;
} | null;
}): string {
const esc = (value: unknown) =>
@@ -5001,6 +5323,7 @@ export class WarehouseInventoryService {
['Release Order', data.releaseOrderReference],
['Release Date', fmt(data.releaseDate)],
['Last-mile Delivery Address', data.lastMileDeliveryAddress],
...(data.truckPlate ? [['Delivering Truck Plate', data.truckPlate]] : []),
];
const approval = data.customerApproval;

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`,