mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-30 08:48: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:
@@ -7,6 +7,7 @@ import {
|
|||||||
Logger,
|
Logger,
|
||||||
Optional,
|
Optional,
|
||||||
} from "@nestjs/common";
|
} from "@nestjs/common";
|
||||||
|
import { OnEvent } from "@nestjs/event-emitter";
|
||||||
|
|
||||||
import { BookingBatchService } from '../train-scheduling/booking-batch.service';
|
import { BookingBatchService } from '../train-scheduling/booking-batch.service';
|
||||||
import { eatDay } from '../train-scheduling/batch-window.util';
|
import { eatDay } from '../train-scheduling/batch-window.util';
|
||||||
@@ -349,6 +350,22 @@ export class BookingTransitionService {
|
|||||||
return fresh;
|
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> {
|
async complete(bookingId: string): Promise<Booking> {
|
||||||
const booking = await this.bookingsService.findById(bookingId);
|
const booking = await this.bookingsService.findById(bookingId);
|
||||||
assertBookingStatus(booking, ["IN_TRANSIT", "ARRIVED"]);
|
assertBookingStatus(booking, ["IN_TRANSIT", "ARRIVED"]);
|
||||||
|
|||||||
@@ -1228,9 +1228,9 @@ export class BookingsService {
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Batched version of the findById flag: marks each page item whose booking
|
* Batched version of the findById flag: marks each page item whose booking
|
||||||
* has a generated-but-unsigned SELF_HAUL handover, so list rows (portal
|
* has a generated-but-unsigned handover (self-haul or EDR last-mile), so list
|
||||||
* dashboard) can show "Approve delivery" for exactly the generated→signed
|
* rows (portal dashboard) can show "Approve delivery" for exactly the
|
||||||
* window. One query for the whole page.
|
* generated→signed window. One query for the whole page.
|
||||||
*/
|
*/
|
||||||
private async attachHandoverFlags(bookings: Booking[]): Promise<void> {
|
private async attachHandoverFlags(bookings: Booking[]): Promise<void> {
|
||||||
const ids = bookings.map((b) => b.id);
|
const ids = bookings.map((b) => b.id);
|
||||||
@@ -1239,8 +1239,7 @@ export class BookingsService {
|
|||||||
`SELECT DISTINCT booking_id AS "bookingId"
|
`SELECT DISTINCT booking_id AS "bookingId"
|
||||||
FROM freight.booking_handovers
|
FROM freight.booking_handovers
|
||||||
WHERE booking_id = ANY($1::uuid[])
|
WHERE booking_id = ANY($1::uuid[])
|
||||||
AND signed_at IS NULL AND deleted_at IS NULL
|
AND signed_at IS NULL AND deleted_at IS NULL`,
|
||||||
AND mile_type = 'SELF_HAUL'`,
|
|
||||||
[ids],
|
[ids],
|
||||||
);
|
);
|
||||||
const pending = new Set(rows.map((r) => r.bookingId));
|
const pending = new Set(rows.map((r) => r.bookingId));
|
||||||
@@ -1583,14 +1582,12 @@ export class BookingsService {
|
|||||||
schedule?.status ?? null;
|
schedule?.status ?? null;
|
||||||
}
|
}
|
||||||
|
|
||||||
// A generated-but-unsigned SELF_HAUL handover means the customer must approve
|
// A generated-but-unsigned handover means the customer must approve delivery
|
||||||
// delivery from the portal (booking-based, one per booking). EDR last-mile
|
// from the portal. Self-haul: booking-based, one per booking. EDR last-mile:
|
||||||
// handovers are per delivering truck and signed by the receiver at the door,
|
// per delivering truck (generated on truck exit), signed one by one.
|
||||||
// so they never surface the portal "Approve delivery" action.
|
|
||||||
const [pendingHandover] = await this.dataSource.query(
|
const [pendingHandover] = await this.dataSource.query(
|
||||||
`SELECT 1 FROM freight.booking_handovers
|
`SELECT 1 FROM freight.booking_handovers
|
||||||
WHERE booking_id = $1 AND signed_at IS NULL AND deleted_at IS NULL
|
WHERE booking_id = $1 AND signed_at IS NULL AND deleted_at IS NULL
|
||||||
AND mile_type = 'SELF_HAUL'
|
|
||||||
LIMIT 1`,
|
LIMIT 1`,
|
||||||
[id],
|
[id],
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -486,6 +486,21 @@ export class WarehouseInventoryController {
|
|||||||
return this.handoverService.list(bookingId);
|
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')
|
@Post('bookings/:bookingId/request-handover-signature')
|
||||||
@ApiOperation({ summary: 'Ask the customer to sign the handover (creates one if none, then notifies)' })
|
@ApiOperation({ summary: 'Ask the customer to sign the handover (creates one if none, then notifies)' })
|
||||||
requestHandoverSignature(@Param('bookingId', ParseUUIDPipe) bookingId: string) {
|
requestHandoverSignature(@Param('bookingId', ParseUUIDPipe) bookingId: string) {
|
||||||
@@ -513,9 +528,16 @@ export class WarehouseInventoryController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Get('bookings/:bookingId/handover-document')
|
@Get('bookings/:bookingId/handover-document')
|
||||||
@ApiOperation({ summary: 'View import goods handover document PDF (resolved by booking)' })
|
@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) {
|
async bookingHandoverDocument(
|
||||||
const { filename, buffer } = await this.inventoryService.handoverDocumentForBooking(bookingId);
|
@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-Type', 'application/pdf');
|
||||||
res.setHeader('Content-Disposition', `inline; filename="${filename}"`);
|
res.setHeader('Content-Disposition', `inline; filename="${filename}"`);
|
||||||
res.setHeader('Content-Length', buffer.length);
|
res.setHeader('Content-Length', buffer.length);
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { BadRequestException, Injectable, Logger, NotFoundException } from '@nestjs/common';
|
import { BadRequestException, Injectable, Logger, NotFoundException } from '@nestjs/common';
|
||||||
|
import { EventEmitter2 } from '@nestjs/event-emitter';
|
||||||
import { Cron, CronExpression } from '@nestjs/schedule';
|
import { Cron, CronExpression } from '@nestjs/schedule';
|
||||||
import {
|
import {
|
||||||
Between,
|
Between,
|
||||||
@@ -25,6 +26,7 @@ import { CargoType } from '../rule-engine/entities/cargo-type.entity';
|
|||||||
import { InterchangeDocumentsService } from '../interchange-documents/interchange-documents.service';
|
import { InterchangeDocumentsService } from '../interchange-documents/interchange-documents.service';
|
||||||
import type { InterchangeDocument } from '../interchange-documents/entities/interchange-document.entity';
|
import type { InterchangeDocument } from '../interchange-documents/entities/interchange-document.entity';
|
||||||
import { LastMileService } from '../last-mile/last-mile.service';
|
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 { NotificationsService } from '../notifications/notifications.service';
|
||||||
import { sendCompanyChannels } from '../notifications/notify-company.util';
|
import { sendCompanyChannels } from '../notifications/notify-company.util';
|
||||||
import {
|
import {
|
||||||
@@ -409,6 +411,7 @@ export class WarehouseInventoryService {
|
|||||||
private readonly signatures: SignaturesService,
|
private readonly signatures: SignaturesService,
|
||||||
private readonly handover: HandoverService,
|
private readonly handover: HandoverService,
|
||||||
private readonly inbox: NotificationInboxService,
|
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
|
// 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
|
// actually took. net_weight_tons drives the bulk drawdown (booking VGM
|
||||||
// minus everything already hauled away).
|
// minus everything already hauled away).
|
||||||
await manager.query(
|
const [edrDeparted] = (await manager.query(
|
||||||
`UPDATE freight.last_mile_vehicle_assignments va
|
`UPDATE freight.last_mile_vehicle_assignments va
|
||||||
SET departed_at = COALESCE($3::timestamptz, NOW()),
|
SET departed_at = COALESCE($3::timestamptz, NOW()),
|
||||||
arrived_at = COALESCE(va.arrived_at, NOW()),
|
arrived_at = COALESCE(va.arrived_at, NOW()),
|
||||||
@@ -3162,7 +3165,8 @@ export class WarehouseInventoryService {
|
|||||||
AND v.id = va.vehicle_id
|
AND v.id = va.vehicle_id
|
||||||
AND (UPPER(v.power_plate_no) = UPPER($2) OR UPPER(v.plate_number) = UPPER($2))
|
AND (UPPER(v.power_plate_no) = UPPER($2) OR UPPER(v.plate_number) = UPPER($2))
|
||||||
AND va.departed_at IS NULL
|
AND va.departed_at IS NULL
|
||||||
AND va.deleted_at IS NULL`,
|
AND va.deleted_at IS NULL
|
||||||
|
RETURNING va.id`,
|
||||||
[
|
[
|
||||||
item.bookingId,
|
item.bookingId,
|
||||||
dto.truckPlateNumber.trim(),
|
dto.truckPlateNumber.trim(),
|
||||||
@@ -3170,7 +3174,31 @@ export class WarehouseInventoryService {
|
|||||||
grossTons,
|
grossTons,
|
||||||
netTons,
|
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.
|
// 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
|
// Without it a self-haul bulk booking never draws down — hauled tonnage
|
||||||
// summed to zero and the booking could take unlimited trucks. Matched by
|
// 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.
|
// the transaction and fire-and-forget: notifying must never fail the exit.
|
||||||
if (isTruckLeaving && item.bookingId) {
|
if (isTruckLeaving && item.bookingId) {
|
||||||
void this.notifyTruckDeparture(item.bookingId, dto.truckPlateNumber?.trim() ?? null, netTons);
|
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);
|
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
|
* Best-effort truck-departure notification to the booking's company across
|
||||||
* every channel: in-app (portal inbox) + SMS + email. Never throws — a missing
|
* 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(
|
const [inv]: Array<{ id: string }> = await this.dataSource.query(
|
||||||
`SELECT id FROM freight.warehouse_inventory
|
`SELECT id FROM freight.warehouse_inventory
|
||||||
WHERE booking_id = $1 AND deleted_at IS NULL
|
WHERE booking_id = $1 AND deleted_at IS NULL
|
||||||
@@ -3918,7 +4175,29 @@ export class WarehouseInventoryService {
|
|||||||
if (!inv) {
|
if (!inv) {
|
||||||
throw new NotFoundException(`No warehouse inventory found for booking ${bookingId}`);
|
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). */
|
/** 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));
|
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(
|
const [row] = await this.dataSource.query(
|
||||||
`SELECT inv.id,
|
`SELECT inv.id,
|
||||||
inv.booking_id AS "bookingId",
|
inv.booking_id AS "bookingId",
|
||||||
@@ -4033,12 +4320,14 @@ export class WarehouseInventoryService {
|
|||||||
|
|
||||||
const bookingReference = row.bookingReference || row.bookingId || 'N/A';
|
const bookingReference = row.bookingReference || row.bookingId || 'N/A';
|
||||||
const reference =
|
const reference =
|
||||||
|
perTruck?.reference ||
|
||||||
this.extractHandoverDocumentLine(row.notes, 'Handover Reference') ||
|
this.extractHandoverDocumentLine(row.notes, 'Handover Reference') ||
|
||||||
`HND-${String(bookingReference).replace(/[^a-zA-Z0-9_-]+/g, '-')}`;
|
`HND-${String(bookingReference).replace(/[^a-zA-Z0-9_-]+/g, '-')}`;
|
||||||
const generatedAtValue = this.extractHandoverDocumentLine(row.notes, 'Generated At');
|
const generatedAtValue = this.extractHandoverDocumentLine(row.notes, 'Generated At');
|
||||||
const generatedAt = generatedAtValue ? new Date(generatedAtValue) : new Date();
|
const generatedAt = generatedAtValue ? new Date(generatedAtValue) : new Date();
|
||||||
const handedOverAt = Number.isNaN(generatedAt.getTime()) ? new Date() : generatedAt;
|
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, {
|
await this.inventoryRepository.update(id, {
|
||||||
notes: this.replaceHandoverDocumentNote(row.notes, this.buildHandoverDocumentNote(reference, handedOverAt)),
|
notes: this.replaceHandoverDocumentNote(row.notes, this.buildHandoverDocumentNote(reference, handedOverAt)),
|
||||||
});
|
});
|
||||||
@@ -4072,7 +4361,16 @@ export class WarehouseInventoryService {
|
|||||||
releaseDate: row.releaseDate ? new Date(row.releaseDate) : null,
|
releaseDate: row.releaseDate ? new Date(row.releaseDate) : null,
|
||||||
trainSchedule: row.trainSchedule ?? null,
|
trainSchedule: row.trainSchedule ?? null,
|
||||||
lastMileDeliveryAddress: row.lastMileDeliveryAddress ?? 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 {
|
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();
|
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);
|
return this.findById(id);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -4952,10 +5273,11 @@ export class WarehouseInventoryService {
|
|||||||
releaseDate: Date | null;
|
releaseDate: Date | null;
|
||||||
trainSchedule: string | null;
|
trainSchedule: string | null;
|
||||||
lastMileDeliveryAddress: string | null;
|
lastMileDeliveryAddress: string | null;
|
||||||
|
truckPlate?: string | null;
|
||||||
customerApproval: {
|
customerApproval: {
|
||||||
approvedAt: string;
|
approvedAt: string;
|
||||||
signerDisplayName: string;
|
signerDisplayName: string;
|
||||||
signatureImageUrl: string;
|
signatureImageUrl: string | null;
|
||||||
} | null;
|
} | null;
|
||||||
}): string {
|
}): string {
|
||||||
const esc = (value: unknown) =>
|
const esc = (value: unknown) =>
|
||||||
@@ -5001,6 +5323,7 @@ export class WarehouseInventoryService {
|
|||||||
['Release Order', data.releaseOrderReference],
|
['Release Order', data.releaseOrderReference],
|
||||||
['Release Date', fmt(data.releaseDate)],
|
['Release Date', fmt(data.releaseDate)],
|
||||||
['Last-mile Delivery Address', data.lastMileDeliveryAddress],
|
['Last-mile Delivery Address', data.lastMileDeliveryAddress],
|
||||||
|
...(data.truckPlate ? [['Delivering Truck Plate', data.truckPlate]] : []),
|
||||||
];
|
];
|
||||||
const approval = data.customerApproval;
|
const approval = data.customerApproval;
|
||||||
|
|
||||||
|
|||||||
@@ -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 { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||||
import { CheckCircle2, Info } from "lucide-react";
|
import { CheckCircle2, Info } from "lucide-react";
|
||||||
import { useEffect, useState } from "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,
|
* 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({
|
export function ApproveDeliveryModal({
|
||||||
bookingId,
|
bookingId,
|
||||||
@@ -48,15 +62,33 @@ export function ApproveDeliveryModal({
|
|||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
const [pdfUrl, setPdfUrl] = useState<string | null>(null);
|
const [pdfUrl, setPdfUrl] = useState<string | null>(null);
|
||||||
const [signerName, setSignerName] = useState("");
|
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 {
|
const {
|
||||||
data: docBlob,
|
data: docBlob,
|
||||||
isLoading,
|
isLoading,
|
||||||
isError,
|
isError,
|
||||||
} = useQuery({
|
} = useQuery({
|
||||||
queryKey: ["booking-handover-doc", bookingId],
|
queryKey: ["booking-handover-doc", bookingId, edrMode ? selected?.id : "booking"],
|
||||||
queryFn: () => bookingsService.downloadBookingHandoverDocument(bookingId),
|
queryFn: () =>
|
||||||
enabled: opened && Boolean(bookingId),
|
bookingsService.downloadBookingHandoverDocument(
|
||||||
|
bookingId,
|
||||||
|
edrMode ? selected?.id : undefined,
|
||||||
|
),
|
||||||
|
enabled: opened && Boolean(bookingId) && (!edrMode || Boolean(selected)),
|
||||||
staleTime: 0,
|
staleTime: 0,
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -70,10 +102,32 @@ export function ApproveDeliveryModal({
|
|||||||
return () => URL.revokeObjectURL(url);
|
return () => URL.revokeObjectURL(url);
|
||||||
}, [docBlob]);
|
}, [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(
|
const handoverMutation = useMutation(
|
||||||
api.bookings.downloadHandoverDocument.mutationOptions(),
|
api.bookings.downloadHandoverDocument.mutationOptions(),
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// Booking-level (self-haul) approval — signs every handover at once.
|
||||||
const approve = useMutation({
|
const approve = useMutation({
|
||||||
...api.bookings.approveDelivery.mutationOptions(),
|
...api.bookings.approveDelivery.mutationOptions(),
|
||||||
onSuccess: async (result) => {
|
onSuccess: async (result) => {
|
||||||
@@ -87,30 +141,35 @@ export function ApproveDeliveryModal({
|
|||||||
toast.success("Delivery approved and handover signed");
|
toast.success("Delivery approved and handover signed");
|
||||||
toast.error("Signed handover document could not be downloaded");
|
toast.error("Signed handover document could not be downloaded");
|
||||||
}
|
}
|
||||||
await Promise.all([
|
await invalidateBooking();
|
||||||
queryClient.invalidateQueries({
|
|
||||||
queryKey: api.bookings.get.queryKey({ id: bookingId }),
|
|
||||||
}),
|
|
||||||
queryClient.invalidateQueries({ queryKey: api.bookings.list.queryKey() }),
|
|
||||||
queryClient.invalidateQueries({ queryKey: ["companies", "getDashboard"] }),
|
|
||||||
]);
|
|
||||||
onApproved?.();
|
onApproved?.();
|
||||||
onClose();
|
onClose();
|
||||||
},
|
},
|
||||||
onError: (error) => {
|
onError: onSignError,
|
||||||
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;
|
// 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 (
|
return (
|
||||||
<Modal
|
<Modal
|
||||||
@@ -123,12 +182,42 @@ export function ApproveDeliveryModal({
|
|||||||
<Stack gap="md">
|
<Stack gap="md">
|
||||||
<Alert color="blue" variant="light" icon={<Info size={16} />}>
|
<Alert color="blue" variant="light" icon={<Info size={16} />}>
|
||||||
<Text size="sm">
|
<Text size="sm">
|
||||||
Review the handover document below, then type your full name to sign and
|
{edrMode
|
||||||
confirm you received the goods. Your saved signature is applied automatically
|
? "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."
|
||||||
if you have one.
|
: "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>
|
</Text>
|
||||||
</Alert>
|
</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 ? (
|
{isLoading ? (
|
||||||
<Group justify="center" py="xl">
|
<Group justify="center" py="xl">
|
||||||
<Loader size="sm" />
|
<Loader size="sm" />
|
||||||
@@ -170,10 +259,21 @@ export function ApproveDeliveryModal({
|
|||||||
color="edr-green"
|
color="edr-green"
|
||||||
leftSection={<CheckCircle2 size={16} />}
|
leftSection={<CheckCircle2 size={16} />}
|
||||||
loading={busy}
|
loading={busy}
|
||||||
disabled={isLoading || isError || !signerName.trim()}
|
disabled={
|
||||||
onClick={() => approve.mutate({ id: bookingId, signerName: signerName.trim() })}
|
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>
|
</Button>
|
||||||
</Group>
|
</Group>
|
||||||
</Stack>
|
</Stack>
|
||||||
|
|||||||
@@ -137,6 +137,26 @@ export interface ApproveDeliveryResponse {
|
|||||||
signerDisplayName: string;
|
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 {
|
export interface CustomerTruckAssignmentPayload {
|
||||||
truckPlateNumber: string;
|
truckPlateNumber: string;
|
||||||
driverName: string;
|
driverName: string;
|
||||||
@@ -206,13 +226,36 @@ export const bookingsService = {
|
|||||||
);
|
);
|
||||||
return data;
|
return data;
|
||||||
},
|
},
|
||||||
downloadBookingHandoverDocument: async (bookingId: string): Promise<Blob> => {
|
downloadBookingHandoverDocument: async (
|
||||||
|
bookingId: string,
|
||||||
|
handoverId?: string,
|
||||||
|
): Promise<Blob> => {
|
||||||
const { data } = await client.get(
|
const { data } = await client.get(
|
||||||
`/api/warehouse-inventory/bookings/${bookingId}/handover-document`,
|
`/api/warehouse-inventory/bookings/${bookingId}/handover-document`,
|
||||||
{ responseType: "blob" },
|
{ responseType: "blob", params: handoverId ? { handoverId } : undefined },
|
||||||
);
|
);
|
||||||
return data;
|
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> => {
|
downloadBookingGrnDocument: async (bookingId: string): Promise<Blob> => {
|
||||||
const { data } = await client.get(
|
const { data } = await client.get(
|
||||||
`/api/warehouse-inventory/bookings/${bookingId}/grn-document`,
|
`/api/warehouse-inventory/bookings/${bookingId}/grn-document`,
|
||||||
|
|||||||
Reference in New Issue
Block a user