diff --git a/apps/edr-freight-api/src/modules/bookings/bookings.controller.ts b/apps/edr-freight-api/src/modules/bookings/bookings.controller.ts index 106b7ed5b..eda249868 100644 --- a/apps/edr-freight-api/src/modules/bookings/bookings.controller.ts +++ b/apps/edr-freight-api/src/modules/bookings/bookings.controller.ts @@ -64,6 +64,7 @@ import { ContractViewDto } from './dto/contract-view.dto'; import { CustomerTruckAssignmentDto } from './dto/customer-truck-assignment.dto'; import { AddCustomerTruckDto } from './dto/add-customer-truck.dto'; import { DepartCustomerTruckDto } from './dto/depart-customer-truck.dto'; +import { LoadCustomerTruckDto } from './dto/load-customer-truck.dto'; import { CustomerTruckService } from './customer-truck.service'; import { GenerateGrnDto } from './dto/generate-grn.dto'; import { ContainerReceiptService } from './container-receipt.service'; @@ -358,6 +359,33 @@ export class BookingsController { return this.customerTruckService.removeTruck(id, assignmentId); } + @Get(':id/customer-trucks/loadable-containers') + @ApiOperation({ summary: 'Booking containers not yet loaded onto a truck' }) + async loadableContainers( + @Param('id', ParseUUIDPipe) id: string, + @CurrentUser() user: TCurrentUser, + ) { + const booking = await this.bookingsService.findById(id); + if (!hasFreightPermission(user, FREIGHT_PERMS.bookings.view)) { + await this.bookingsService.assertCustomerCanAccessBooking(user?.id, booking); + } + return this.customerTruckService.getLoadableContainers(id); + } + + @Post(':id/customer-trucks/:assignmentId/load') + @ApiOperation({ summary: 'Truck_dispatch: load selected containers onto a truck (staff)' }) + async loadCustomerTruck( + @Param('id', ParseUUIDPipe) id: string, + @Param('assignmentId', ParseUUIDPipe) assignmentId: string, + @Body() dto: LoadCustomerTruckDto, + @CurrentUser() user: TCurrentUser, + ) { + if (!hasFreightPermission(user, FREIGHT_PERMS.bookings.view)) { + throw new ForbiddenException('Only warehouse staff can load a truck'); + } + return this.customerTruckService.loadTruck(id, assignmentId, dto); + } + @Post(':id/customer-trucks/:assignmentId/depart') @ApiOperation({ summary: 'Register an import truck leaving: containers loaded + weighed gross (staff)', diff --git a/apps/edr-freight-api/src/modules/bookings/container-receipt.service.ts b/apps/edr-freight-api/src/modules/bookings/container-receipt.service.ts index fde3ab797..fea73603a 100644 --- a/apps/edr-freight-api/src/modules/bookings/container-receipt.service.ts +++ b/apps/edr-freight-api/src/modules/bookings/container-receipt.service.ts @@ -38,7 +38,7 @@ export class ContainerReceiptService { SET received_to_port = true, received_at = COALESCE(bcu.received_at, NOW()), updated_at = NOW() - FROM freight.booking_containers bc, + FROM freight.booking_container bc, freight.customer_truck_containers ctc WHERE bc.id = bcu.booking_container_id AND bc.booking_id = $1 @@ -60,7 +60,7 @@ export class ContainerReceiptService { bcu.received_at AS "receivedAt", bcu.grn_number AS "grnNumber" FROM freight.booking_container_units bcu - JOIN freight.booking_containers bc + JOIN freight.booking_container bc ON bc.id = bcu.booking_container_id AND bc.deleted_at IS NULL WHERE bc.booking_id = $1 AND bcu.deleted_at IS NULL @@ -92,7 +92,7 @@ export class ContainerReceiptService { const pending: ReceivedUnitRow[] = await manager.query( `SELECT bcu.id, bcu.container_number AS "containerNumber" FROM freight.booking_container_units bcu - JOIN freight.booking_containers bc + JOIN freight.booking_container bc ON bc.id = bcu.booking_container_id AND bc.deleted_at IS NULL WHERE bc.booking_id = $1 AND bcu.deleted_at IS NULL @@ -109,7 +109,7 @@ export class ContainerReceiptService { const [{ batches }]: Array<{ batches: string }> = await manager.query( `SELECT COUNT(DISTINCT bcu.grn_number) AS batches FROM freight.booking_container_units bcu - JOIN freight.booking_containers bc + JOIN freight.booking_container bc ON bc.id = bcu.booking_container_id AND bc.deleted_at IS NULL WHERE bc.booking_id = $1 AND bcu.grn_number IS NOT NULL AND bcu.deleted_at IS NULL`, [bookingId], @@ -129,7 +129,7 @@ export class ContainerReceiptService { const [{ remaining }]: Array<{ remaining: string }> = await manager.query( `SELECT COUNT(*) AS remaining FROM freight.booking_container_units bcu - JOIN freight.booking_containers bc + JOIN freight.booking_container bc ON bc.id = bcu.booking_container_id AND bc.deleted_at IS NULL WHERE bc.booking_id = $1 AND bcu.deleted_at IS NULL AND bcu.grn_number IS NULL`, [bookingId], diff --git a/apps/edr-freight-api/src/modules/bookings/customer-truck.service.ts b/apps/edr-freight-api/src/modules/bookings/customer-truck.service.ts index 5d0650219..6f0ec6f55 100644 --- a/apps/edr-freight-api/src/modules/bookings/customer-truck.service.ts +++ b/apps/edr-freight-api/src/modules/bookings/customer-truck.service.ts @@ -198,6 +198,87 @@ export class CustomerTruckService { return this.listTrucks(bookingId); } + /** Booking container numbers not yet loaded onto any truck. */ + async getLoadableContainers(bookingId: string): Promise { + const [all, assigned] = await Promise.all([ + this.bookingContainerNumbers(bookingId), + this.assignedContainerNumbers(bookingId), + ]); + const taken = new Set(assigned); + return all.filter((n) => !taken.has(n)); + } + + /** + * Truck_dispatch (load): assign the selected containers to a truck after it has + * arrived, and set a provisional gross weight from their VGM. The truck is + * weighed for real on departure. Locked once the truck has left. + */ + async loadTruck( + bookingId: string, + assignmentId: string, + dto: { containerNumbers: string[] }, + ): Promise { + await this.loadBookingGuard(bookingId); + const assignment = await this.assignments.findByIdWithContainers(assignmentId); + if (!assignment || assignment.bookingId !== bookingId) { + throw new NotFoundException('Truck assignment not found for this booking'); + } + if (assignment.departedAt) { + throw new ConflictException('This truck has already left — its load is locked'); + } + + const requested = (dto.containerNumbers ?? []).map((n) => n.trim().toUpperCase()); + if (!requested.length) { + throw new BadRequestException('Select at least one container to load onto the truck'); + } + const bookingNumbers = await this.bookingContainerNumbers(bookingId); + for (const n of requested) { + if (!bookingNumbers.includes(n)) { + throw new BadRequestException(`Container ${n} is not one of this booking's containers`); + } + } + const elsewhere = await this.assignedContainerNumbersExcept(bookingId, assignmentId); + for (const n of requested) { + if (elsewhere.includes(n)) { + throw new ConflictException(`Container ${n} is already loaded onto another truck`); + } + } + + const grossKg = await this.vgmKgForContainers(bookingId, requested); + await this.dataSource.transaction(async (manager) => { + await manager.getRepository(CustomerTruckContainer).softDelete({ assignmentId }); + await manager.getRepository(CustomerTruckContainer).save( + requested.map((containerNumber) => + manager.getRepository(CustomerTruckContainer).create({ + assignmentId, + bookingId, + containerNumber, + }), + ), + ); + // Provisional gross from the loaded containers' VGM — overridden by the + // weighed gross on departure. + await manager.getRepository(CustomerTruckAssignment).update(assignmentId, { + grossWeightKg: grossKg, + }); + }); + return this.listTrucks(bookingId); + } + + private async vgmKgForContainers(bookingId: string, numbers: string[]): Promise { + const [row]: Array<{ kg: string }> = await this.dataSource.query( + `SELECT COALESCE(SUM(bcu.vgm_tons), 0) * 1000 AS kg + FROM freight.booking_container_units bcu + JOIN freight.booking_container bc + ON bc.id = bcu.booking_container_id AND bc.deleted_at IS NULL + WHERE bc.booking_id = $1 + AND bcu.container_number = ANY($2::varchar[]) + AND bcu.deleted_at IS NULL`, + [bookingId, numbers], + ); + return Number(row?.kg ?? 0); + } + /** * Mark the truck carrying `containerNumber` as arrived. Called by the warehouse * receive flow. When every truck on the booking has arrived, the booking-level @@ -288,7 +369,7 @@ export class CustomerTruckService { const rows: Array<{ containerNumber: string }> = await this.dataSource.query( `SELECT bcu.container_number AS "containerNumber" FROM freight.booking_container_units bcu - JOIN freight.booking_containers bc + JOIN freight.booking_container bc ON bc.id = bcu.booking_container_id AND bc.deleted_at IS NULL WHERE bc.booking_id = $1 AND bcu.deleted_at IS NULL`, [bookingId], diff --git a/apps/edr-freight-api/src/modules/bookings/dto/load-customer-truck.dto.ts b/apps/edr-freight-api/src/modules/bookings/dto/load-customer-truck.dto.ts new file mode 100644 index 000000000..11c80f687 --- /dev/null +++ b/apps/edr-freight-api/src/modules/bookings/dto/load-customer-truck.dto.ts @@ -0,0 +1,13 @@ +import { ArrayMinSize, ArrayUnique, IsArray, Matches } from 'class-validator'; + +/** Containers loaded onto a truck at Truck_dispatch (after arrival, before it leaves). */ +export class LoadCustomerTruckDto { + @IsArray() + @ArrayMinSize(1) + @ArrayUnique() + @Matches(/^[A-Z]{4}\d{7}$/, { + each: true, + message: 'each container number must match ISO container format, e.g. ABCD1234567', + }) + containerNumbers!: string[]; +} diff --git a/apps/edr-freight-api/src/modules/warehouses/warehouse-inventory.controller.ts b/apps/edr-freight-api/src/modules/warehouses/warehouse-inventory.controller.ts index aab2fccbb..cd1aa432c 100644 --- a/apps/edr-freight-api/src/modules/warehouses/warehouse-inventory.controller.ts +++ b/apps/edr-freight-api/src/modules/warehouses/warehouse-inventory.controller.ts @@ -285,6 +285,19 @@ export class WarehouseInventoryController { return res.send(buffer); } + @Get('customer-truck-exit-paper/:assignmentId') + @ApiOperation({ summary: 'Per-truck exit paper PDF (containers loaded on one customer truck)' }) + async truckExitPaper( + @Param('assignmentId', ParseUUIDPipe) assignmentId: string, + @Res() res: Response, + ) { + const { filename, buffer } = await this.inventoryService.truckExitPaper(assignmentId); + res.setHeader('Content-Type', 'application/pdf'); + res.setHeader('Content-Disposition', `inline; filename="${filename}"`); + res.setHeader('Content-Length', buffer.length); + return res.send(buffer); + } + @Get(':id/grn-document') @ApiOperation({ summary: 'View goods received note PDF' }) async grnDocument(@Param('id', ParseUUIDPipe) id: string, @Res() res: Response) { diff --git a/apps/edr-freight-api/src/modules/warehouses/warehouse-inventory.service.ts b/apps/edr-freight-api/src/modules/warehouses/warehouse-inventory.service.ts index b16454f9d..8e9457831 100644 --- a/apps/edr-freight-api/src/modules/warehouses/warehouse-inventory.service.ts +++ b/apps/edr-freight-api/src/modules/warehouses/warehouse-inventory.service.ts @@ -930,7 +930,7 @@ export class WarehouseInventoryService { SET received_to_port = true, received_at = COALESCE(bcu.received_at, NOW()), updated_at = NOW() - FROM freight.booking_containers bc + FROM freight.booking_container bc WHERE bc.id = bcu.booking_container_id AND bc.booking_id = $1 AND bc.deleted_at IS NULL @@ -1801,7 +1801,7 @@ export class WarehouseInventoryService { SET received_to_port = true, received_at = COALESCE(bcu.received_at, NOW()), updated_at = NOW() - FROM freight.booking_containers bc, freight.containers cont + FROM freight.booking_container bc, freight.containers cont WHERE bc.id = bcu.booking_container_id AND bc.booking_id = $1 AND bc.deleted_at IS NULL @@ -2248,7 +2248,7 @@ export class WarehouseInventoryService { FROM freight.customer_truck_containers cc JOIN freight.booking_container_units bcu ON bcu.container_number = cc.container_number AND bcu.deleted_at IS NULL - JOIN freight.booking_containers bc + JOIN freight.booking_container bc ON bc.id = bcu.booking_container_id AND bc.deleted_at IS NULL AND bc.booking_id = c.booking_id WHERE cc.assignment_id = a.id AND cc.deleted_at IS NULL @@ -2308,6 +2308,115 @@ export class WarehouseInventoryService { }; } + /** + * Per-truck exit paper: one paper covering the containers loaded on a specific + * customer truck (used when multiple trucks leave separately). Gated on the + * handover being signed and warehouse fees paid. + */ + async truckExitPaper(assignmentId: string): Promise<{ filename: string; buffer: Buffer }> { + const [truck] = await this.dataSource.query( + `SELECT a.booking_id AS "bookingId", a.plate_number AS "plateNumber", + a.driver_name AS "driverName", a.truck_type AS "truckType", + a.gross_weight_kg AS "grossWeightKg", a.departed_at AS "departedAt", + b.reference AS "bookingReference", company.name AS "customerName" + FROM freight.customer_truck_assignments a + JOIN freight.bookings b ON b.id = a.booking_id AND b.deleted_at IS NULL + LEFT JOIN freight.companies company ON company.id = b.company_id + WHERE a.id = $1 AND a.deleted_at IS NULL`, + [assignmentId], + ); + if (!truck) throw new NotFoundException(`Truck assignment ${assignmentId} not found`); + + if (!(await this.handover.isFullySigned(truck.bookingId))) { + throw new BadRequestException('Handover must be signed before the exit paper can be generated'); + } + 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 created_at LIMIT 1`, + [truck.bookingId], + ); + if (inv?.id) await this.invoices.assertClearanceAllowed(inv.id); + + const containers: Array<{ containerNumber: string; goods: string | null }> = + await this.dataSource.query( + `SELECT c.container_number AS "containerNumber", + COALESCE(ct.cargo_type_name, b.cargo_free_text) AS goods + FROM freight.customer_truck_containers c + JOIN freight.bookings b ON b.id = c.booking_id + LEFT JOIN freight.cargo_types ct ON ct.id = b.cargo_type_id + WHERE c.assignment_id = $1 AND c.deleted_at IS NULL + ORDER BY c.container_number`, + [assignmentId], + ); + + const html = this.buildTruckExitPaperHtml({ + reference: `REL-${String(truck.bookingReference).replace(/^BK-?/i, '')}-${truck.plateNumber}`, + bookingReference: truck.bookingReference, + customerName: truck.customerName, + plateNumber: truck.plateNumber, + driverName: truck.driverName, + truckType: truck.truckType, + grossWeightKg: Number(truck.grossWeightKg ?? 0), + gateOut: truck.departedAt, + containers, + }); + return { + filename: `exit-${String(truck.plateNumber).replace(/[^a-zA-Z0-9_-]+/g, '-')}.pdf`, + buffer: await this.releaseDocuments.renderDocumentHtml(html, 'Warehouse exit paper'), + }; + } + + private buildTruckExitPaperHtml(data: { + reference: string; + bookingReference: string; + customerName: string | null; + plateNumber: string; + driverName: string; + truckType: string; + grossWeightKg: number; + gateOut: string | Date | null; + containers: Array<{ containerNumber: string; goods: string | null }>; + }): string { + const esc = (v: unknown) => + String(v ?? '-').replace(/&/g, '&').replace(//g, '>'); + const gateOut = data.gateOut ? new Date(data.gateOut).toLocaleString('en-GB') : '-'; + const rows: Array<[string, string]> = [ + ['Booking Reference', data.bookingReference], + ['Customer / Consignee', data.customerName ?? '-'], + ['Pickup Truck Plate', data.plateNumber], + ['Driver', data.driverName], + ['Truck Type', data.truckType], + ['Gross Weight (Loaded on Truck)', `${data.grossWeightKg.toLocaleString()} kg`], + ['Gate-Out Time', gateOut], + ['Clearance Status', 'CLEARED FOR WAREHOUSE EXIT'], + ]; + const containerRows = data.containers.length + ? data.containers + .map((c) => `${esc(c.containerNumber)}${esc(c.goods)}`) + .join('') + : 'No containers loaded on this truck.'; + return `Warehouse Exit Paper + + +
Ethio-Djibouti Railway S.C.
+

Warehouse Release / Exit Paper

+
Document / Release No. ${esc(data.reference)}
+
Release Particulars
+ ${rows.map(([l, v]) => ``).join('')}
${esc(l)}${esc(v)}
+
Containers Leaving on This Truck
+ + ${containerRows}
Container NumberGoods
+ `; + } + /** Hand import goods to the customer + capture proof of delivery (READY_FOR_PICKUP → DELIVERED). */ async grnDocument(id: string): Promise<{ filename: string; buffer: Buffer }> { const [row] = await this.dataSource.query( @@ -2405,9 +2514,17 @@ export class WarehouseInventoryService { return { filename: `grn-${String(row.grnNumber).replace(/[^a-zA-Z0-9_-]+/g, '-')}.pdf`, - // Generic render — NOT the release-order fallback (would mislabel the GRN - // as a "Gate Clearance / Release Order" when Chromium is unavailable). - buffer: await this.releaseDocuments.renderDocumentHtml(html, 'Goods Received Note'), + // Styled fallback titled as a GRN (not a release order) for Chromium-less render. + buffer: await this.releaseDocuments.renderStyledDocument( + html, + { + titleLines: ['GOODS RECEIVED', 'NOTE'], + subtitle: 'OFFICIAL WAREHOUSE GOODS RECEIVED NOTE', + sectionTitle: 'RECEIVED PARTICULARS', + refLabel: 'GRN No.', + }, + 'Goods Received Note', + ), }; } @@ -2612,9 +2729,17 @@ export class WarehouseInventoryService { return { filename: `handover-${String(reference).replace(/[^a-zA-Z0-9_-]+/g, '-')}.pdf`, - // Generic render — NOT the release-order fallback (would mislabel the - // handover as a "Gate Clearance / Release Order" when Chromium is down). - buffer: await this.releaseDocuments.renderDocumentHtml(html, 'Import Goods Handover'), + // Styled fallback titled as a handover (not a release order) for Chromium-less render. + buffer: await this.releaseDocuments.renderStyledDocument( + html, + { + titleLines: ['IMPORT GOODS', 'HANDOVER', 'DOCUMENT'], + subtitle: 'EDR TO CUSTOMER WAREHOUSE HANDOVER', + sectionTitle: 'HANDOVER PARTICULARS', + refLabel: 'Document / Handover No.', + }, + 'Import Goods Handover', + ), }; } diff --git a/apps/edr-freight-api/src/modules/warehouses/warehouse-release-document.service.ts b/apps/edr-freight-api/src/modules/warehouses/warehouse-release-document.service.ts index 68e630e0b..1d8e3d9ea 100644 --- a/apps/edr-freight-api/src/modules/warehouses/warehouse-release-document.service.ts +++ b/apps/edr-freight-api/src/modules/warehouses/warehouse-release-document.service.ts @@ -32,16 +32,38 @@ export class WarehouseReleaseDocumentService { return this.pdf.htmlToPdfBuffer(html, { label }); } - private htmlToBasicPdfBuffer(html: string): Buffer { + /** + * Render document HTML with a STYLED hand-built fallback (the release layout, + * but with a custom title + section heading) for when Chromium is unavailable. + * Handover / GRN use this so their fallback looks like a proper document — + * not a plain-text dump, and not mislabelled as a release order. + */ + renderStyledDocument( + html: string, + fallbackOpts: { titleLines?: string[]; subtitle?: string; sectionTitle?: string; refLabel?: string }, + label = 'Document', + ): Promise { + return this.pdf.htmlToPdfBuffer(html, { + label, + fallback: (preparedHtml) => this.htmlToBasicPdfBuffer(preparedHtml, fallbackOpts), + }); + } + + private htmlToBasicPdfBuffer( + html: string, + opts?: { titleLines?: string[]; subtitle?: string; sectionTitle?: string; refLabel?: string }, + ): Buffer { const doc = this.extractReleaseDocument(html); + const titleLines = (opts?.titleLines ?? ['WAREHOUSE GATE', 'CLEARANCE / RELEASE', 'ORDER']).slice(0, 3); + const subtitle = opts?.subtitle ?? 'OFFICIAL WAREHOUSE RELEASE AND EXIT AUTHORIZATION'; + const sectionTitle = opts?.sectionTitle ?? 'RELEASE PARTICULARS'; + const refLabel = opts?.refLabel ?? 'Document / Release No.'; const body: string[] = [ this.lineOp(36, 810, 559, 810, '0 0 0', 2.2), this.textOp('ETHIO-DJIBOUTI RAILWAY S.C.', 36, 787, 9, 'F2', '0.08 0.32 0.18'), - this.textOp('WAREHOUSE GATE', 36, 764, 24, 'F2', '0.02 0.08 0.16'), - this.textOp('CLEARANCE / RELEASE', 36, 742, 24, 'F2', '0.02 0.08 0.16'), - this.textOp('ORDER', 36, 720, 24, 'F2', '0.02 0.08 0.16'), - this.textOp('OFFICIAL WAREHOUSE RELEASE AND EXIT AUTHORIZATION', 36, 696, 8.5, 'F1', '0.25 0.34 0.45'), - this.textOp('Document / Release No.', 424, 781, 8.5, 'F1', '0.15 0.22 0.32'), + ...titleLines.map((line, i) => this.textOp(line, 36, 764 - i * 22, 24, 'F2', '0.02 0.08 0.16')), + this.textOp(subtitle, 36, 764 - titleLines.length * 22 + 2, 8.5, 'F1', '0.25 0.34 0.45'), + this.textOp(refLabel, 424, 781, 8.5, 'F1', '0.15 0.22 0.32'), this.textOp(doc.reference, 504 - doc.reference.length * 2.2, 761, 15, 'F2', '0.02 0.08 0.16'), this.textOp(`Issued: ${doc.issuedAt}`, 424, 742, 8.5, 'F1', '0.15 0.22 0.32'), this.lineOp(36, 682, 559, 682, '0.08 0.32 0.18', 2), @@ -50,7 +72,7 @@ export class WarehouseReleaseDocumentService { ...this.wrapLines(doc.notice, 68) .slice(0, 4) .map((line, index) => this.textOp(line, 52, 659 - index * 12, 9.2, 'F1')), - this.textOp('RELEASE PARTICULARS', 36, 604, 10, 'F2', '0.08 0.32 0.18'), + this.textOp(sectionTitle, 36, 604, 10, 'F2', '0.08 0.32 0.18'), ]; let y = 586; diff --git a/apps/edr-freight-web/backoffice/src/components/warehouses/ReceiveInventoryModal.tsx b/apps/edr-freight-web/backoffice/src/components/warehouses/ReceiveInventoryModal.tsx index 2002f3261..e1e12e915 100644 --- a/apps/edr-freight-web/backoffice/src/components/warehouses/ReceiveInventoryModal.tsx +++ b/apps/edr-freight-web/backoffice/src/components/warehouses/ReceiveInventoryModal.tsx @@ -61,6 +61,7 @@ import type { } from '@/types/warehouse'; import { BookingSelect } from './BookingSelect'; import { DeliverInventoryModal } from './DeliverInventoryModal'; +import { TruckDispatchModal } from './TruckDispatchModal'; import { FeePreviewModal } from './FeePreviewModal'; import { InspectionReportModal } from './InspectionReportModal'; import { InventoryDetailModal } from './InventoryDetailModal'; @@ -2151,7 +2152,6 @@ function ImportUnloadedQueueTab({ enabled }: { enabled: boolean }) { ); const storeMutation = useMutation(api.warehouses.store.mutationOptions()); const readyMutation = useMutation(api.warehouses.markReadyForPickup.mutationOptions()); - const dispatchMutation = useMutation(api.warehouses.dispatch.mutationOptions()); const [selected, setSelected] = useState>(new Set()); const [inspectId, setInspectId] = useState(null); const [busyId, setBusyId] = useState(null); @@ -2160,6 +2160,7 @@ function ImportUnloadedQueueTab({ enabled }: { enabled: boolean }) { const [feeItem, setFeeItem] = useState(null); const [releaseItem, setReleaseItem] = useState(null); const [deliverItem, setDeliverItem] = useState(null); + const [loadTruckItem, setLoadTruckItem] = useState(null); const allSelected = rows.length > 0 && selected.size === rows.length; const someSelected = selected.size > 0 && !allSelected; @@ -2419,7 +2420,7 @@ function ImportUnloadedQueueTab({ enabled }: { enabled: boolean }) { variant="light" color="green" loading={busyId === r.id} - onClick={() => runRowAction(r, 'Inventory dispatched', () => dispatchMutation.mutateAsync(r.id))} + onClick={() => setLoadTruckItem(toInventoryItem(r))} > Truck_dispatch @@ -2493,6 +2494,12 @@ function ImportUnloadedQueueTab({ enabled }: { enabled: boolean }) { /> setReleaseItem(null)} item={releaseItem} /> setDeliverItem(null)} item={deliverItem} /> + setLoadTruckItem(null)} + bookingId={loadTruckItem?.booking?.id ?? null} + bookingReference={loadTruckItem?.booking?.reference ?? null} + /> ); } diff --git a/apps/edr-freight-web/backoffice/src/components/warehouses/TruckDispatchModal.tsx b/apps/edr-freight-web/backoffice/src/components/warehouses/TruckDispatchModal.tsx new file mode 100644 index 000000000..73e37c1ec --- /dev/null +++ b/apps/edr-freight-web/backoffice/src/components/warehouses/TruckDispatchModal.tsx @@ -0,0 +1,147 @@ +import { Alert, Badge, Button, Group, Loader, Modal, MultiSelect, Stack, Text } from '@mantine/core'; +import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'; +import { FileText, Truck } from 'lucide-react'; +import { useState } from 'react'; + +import { useToast } from '@/hooks/use-toast'; +import { warehouseService } from '@/services/warehouse.service'; +import { extractErrorMessage } from './options'; +import { openPdfBlob } from './pdf'; + +interface TruckDispatchModalProps { + opened: boolean; + onClose: () => void; + bookingId: string | null; + bookingReference?: string | null; +} + +/** + * Truck_dispatch: after a self-haul truck arrives, staff select which of the + * booking's containers ride each truck. The loaded set drives the truck's gross + * weight; the truck is weighed for real on departure. + */ +export function TruckDispatchModal({ opened, onClose, bookingId, bookingReference }: TruckDispatchModalProps) { + const { toast } = useToast(); + const queryClient = useQueryClient(); + const [selectedByTruck, setSelectedByTruck] = useState>({}); + + const trucksKey = ['td-customer-trucks', bookingId]; + const loadableKey = ['td-loadable', bookingId]; + + const { data: trucks = [], isLoading: trucksLoading } = useQuery({ + queryKey: trucksKey, + queryFn: () => warehouseService.getCustomerTrucks(bookingId as string), + enabled: opened && Boolean(bookingId), + }); + const { data: loadable = [], isLoading: loadableLoading } = useQuery({ + queryKey: loadableKey, + queryFn: () => warehouseService.getLoadableContainers(bookingId as string), + enabled: opened && Boolean(bookingId), + }); + + const loadMutation = useMutation({ + mutationFn: ({ assignmentId, containerNumbers }: { assignmentId: string; containerNumbers: string[] }) => + warehouseService.loadTruck(bookingId as string, assignmentId, containerNumbers), + onSuccess: (_res, vars) => { + queryClient.invalidateQueries({ queryKey: trucksKey }); + queryClient.invalidateQueries({ queryKey: loadableKey }); + setSelectedByTruck((s) => ({ ...s, [vars.assignmentId]: [] })); + toast({ title: 'Truck loaded' }); + }, + onError: (e) => toast({ variant: 'destructive', title: 'Load failed', description: extractErrorMessage(e) }), + }); + + const openTruckExitPaper = async (assignmentId: string, plate: string) => { + try { + const res = await warehouseService.downloadTruckExitPaper(assignmentId); + openPdfBlob(res.data, `exit-${plate}.pdf`); + } catch (e) { + toast({ variant: 'destructive', title: 'Exit paper not ready', description: extractErrorMessage(e) }); + } + }; + + return ( + + + Truck_dispatch — load containers {bookingReference ? `· ${bookingReference}` : ''} + + } + > + {trucksLoading || loadableLoading ? ( + + + + ) : trucks.length === 0 ? ( + + No customer truck is assigned to this booking yet. + + ) : ( + + {trucks.map((t) => { + const alreadyLoaded = (t.containers ?? []).map((c) => c.containerNumber); + // Options = still-loadable + this truck's own already-loaded (so they stay visible). + const options = Array.from(new Set([...loadable, ...alreadyLoaded])); + const selected = selectedByTruck[t.id] ?? alreadyLoaded; + const departed = Boolean(t.arrivedAt) && Boolean((t as { departedAt?: string }).departedAt); + return ( + + + {t.plateNumber} + + {t.driverName} · {t.truckType} + {t.arrivedAt ? Arrived : Not arrived} + + + setSelectedByTruck((s) => ({ ...s, [t.id]: v }))} + searchable + disabled={departed || !t.arrivedAt} + nothingFoundMessage="No loadable containers" + /> + + + + + + ); + })} + + )} + + ); +} diff --git a/apps/edr-freight-web/backoffice/src/services/warehouse.service.ts b/apps/edr-freight-web/backoffice/src/services/warehouse.service.ts index 40b084d27..4d7a834ba 100644 --- a/apps/edr-freight-web/backoffice/src/services/warehouse.service.ts +++ b/apps/edr-freight-web/backoffice/src/services/warehouse.service.ts @@ -71,7 +71,28 @@ const cleanParams = (params: object) => export const warehouseService = { /** Customer self-haul trucks assigned to a booking (portal multi-truck). */ getCustomerTrucks: async (bookingId: string): Promise => { - const { data } = await apiClient.get(`/api/bookings/${bookingId}/customer-trucks`); + const { data } = await apiClient.get(`/bookings/${bookingId}/customer-trucks`); + return data?.data ?? data ?? []; + }, + + /** Booking container numbers not yet loaded onto any truck. */ + getLoadableContainers: async (bookingId: string): Promise => { + const { data } = await apiClient.get( + `/bookings/${bookingId}/customer-trucks/loadable-containers`, + ); + return data?.data ?? data ?? []; + }, + + /** Truck_dispatch: load selected containers onto a truck (after arrival). */ + loadTruck: async ( + bookingId: string, + assignmentId: string, + containerNumbers: string[], + ): Promise => { + const { data } = await apiClient.post( + `/bookings/${bookingId}/customer-trucks/${assignmentId}/load`, + { containerNumbers }, + ); return data?.data ?? data ?? []; }, @@ -155,6 +176,11 @@ export const warehouseService = { apiClient.get(URL_CONSTANTS.WAREHOUSE_INVENTORY.HANDOVER_DOCUMENT(id), { responseType: 'blob', }), + /** Per-truck exit paper PDF (containers loaded on one customer truck). */ + downloadTruckExitPaper: (assignmentId: string) => + apiClient.get(`/warehouse-inventory/customer-truck-exit-paper/${assignmentId}`, { + responseType: 'blob', + }), deliver: (id: string, payload: DeliverInventoryPayload) => apiClient.post(URL_CONSTANTS.WAREHOUSE_INVENTORY.DELIVER(id), payload),