From d5182c5d85b665c077d543701d298dbb14fb2ffc Mon Sep 17 00:00:00 2001 From: Hagernesh Date: Thu, 16 Jul 2026 09:03:04 +0000 Subject: [PATCH] feat(last-mile): wire multi-container assignment, bulk drawdown + EDR exit papers MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Frontend for the per-truck EDR backend: - Assign modal takes a container LOAD per truck (MultiSelect, max 2) instead of a single container, so a truck can carry one 40ft or two 20ft. Sends containerNumbers[]; the API enforces the size rule. - requiredVehicles is now size-aware: a 40ft fills a truck (1 each), two 20ft share one. It previously assumed ceil(containers / 2) regardless of size, so a 2x40ft booking under-counted the trucks needed. - Bulk bookings show the drawdown in the assign modal — remaining vs total tonnage, what's been hauled, and a "fully hauled" state when it reaches 0. - New "Truck exit papers" row action opens a per-truck list (plate, containers, arrival, exit, net weight) with a per-truck exit-paper download. - last-mile findAll/findById now load each assignment's containers so the UI can hydrate a truck's load. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../modules/last-mile/last-mile.service.ts | 6 +- .../operations/EdrTruckExitPapersModal.tsx | 131 ++++++++++++++++ .../src/pages/operations/LastMilePage.tsx | 148 ++++++++++++++---- .../src/services/last-mile.service.ts | 15 +- .../src/services/warehouse.service.ts | 5 + 5 files changed, 267 insertions(+), 38 deletions(-) create mode 100644 apps/edr-freight-web/backoffice/src/pages/operations/EdrTruckExitPapersModal.tsx diff --git a/apps/edr-freight-api/src/modules/last-mile/last-mile.service.ts b/apps/edr-freight-api/src/modules/last-mile/last-mile.service.ts index 474f943a9..1ee9d7191 100644 --- a/apps/edr-freight-api/src/modules/last-mile/last-mile.service.ts +++ b/apps/edr-freight-api/src/modules/last-mile/last-mile.service.ts @@ -182,7 +182,7 @@ export class LastMileService { relations: { booking: { company: true, serviceType: true, originYard: true, destinationYard: true, cargoType: true, bookingContainers: { containerType: true, units: true } }, vehicle: true, - vehicleAssignments: { vehicle: true }, + vehicleAssignments: { vehicle: true, containers: true }, }, order: { [sortBy]: sortOrder }, skip: (page - 1) * pageSize, @@ -207,7 +207,7 @@ export class LastMileService { relations: { booking: { company: true, serviceType: true, originYard: true, destinationYard: true, cargoType: true, bookingContainers: { containerType: true, units: true } }, vehicle: true, - vehicleAssignments: { vehicle: true }, + vehicleAssignments: { vehicle: true, containers: true }, }, }); @@ -284,7 +284,7 @@ export class LastMileService { > { const [lm] = await this.lastMileRepository.findAll({ where: { bookingId }, - relations: { vehicle: true, vehicleAssignments: { vehicle: true } }, + relations: { vehicle: true, vehicleAssignments: { vehicle: true, containers: true } }, take: 1, }); if (!lm) return []; diff --git a/apps/edr-freight-web/backoffice/src/pages/operations/EdrTruckExitPapersModal.tsx b/apps/edr-freight-web/backoffice/src/pages/operations/EdrTruckExitPapersModal.tsx new file mode 100644 index 000000000..b35af1e7c --- /dev/null +++ b/apps/edr-freight-web/backoffice/src/pages/operations/EdrTruckExitPapersModal.tsx @@ -0,0 +1,131 @@ +import { useState } from "react"; +import { Alert, Badge, Button, Modal, Stack, Table, Text } from "@mantine/core"; +import { FileText } from "lucide-react"; + +import { useToast } from "@/hooks/use-toast"; +import { warehouseService } from "@/services/warehouse.service"; +import type { LastMileRecord } from "@/services/last-mile.service"; +import { extractDownloadErrorMessage } from "@/components/warehouses/options"; +import { openPdfBlob } from "@/components/warehouses/pdf"; + +interface EdrTruckExitPapersModalProps { + opened: boolean; + onClose: () => void; + record: LastMileRecord | null; +} + +const fmt = (value?: string | null) => + value ? new Date(value).toLocaleString() : "—"; + +/** + * Per-truck exit papers for an EDR last-mile delivery. Each assigned truck has + * its own arrival, exit and weighed load, so each gets its own paper. + */ +export function EdrTruckExitPapersModal({ opened, onClose, record }: EdrTruckExitPapersModalProps) { + const { toast } = useToast(); + const [busyId, setBusyId] = useState(null); + const trucks = record?.vehicleAssignments ?? []; + + const download = async (assignmentId: string, plate: string) => { + setBusyId(assignmentId); + try { + const res = await warehouseService.downloadEdrTruckExitPaper(assignmentId); + openPdfBlob(res.data, `exit-${plate || assignmentId}.pdf`); + } catch (e) { + toast({ + variant: "destructive", + title: "Exit paper not ready", + description: await extractDownloadErrorMessage(e), + }); + } finally { + setBusyId(null); + } + }; + + return ( + + Truck exit papers {record?.booking?.reference ? `· ${record.booking.reference}` : ""} + + } + > + {trucks.length === 0 ? ( + + No trucks assigned to this delivery yet. + + ) : ( + + + + + Truck + Containers + Arrived + Left + Net + Exit paper + + + + {trucks.map((t) => { + const plate = t.vehicle?.powerPlateNo || t.vehicle?.plateNumber || "—"; + const load = t.containers?.length + ? t.containers.map((c) => c.containerNumber).join(", ") + : (t.containerNumber ?? "bulk"); + return ( + + + {plate} + + + {load} + + + {fmt(t.arrivedAt)} + + + {t.departedAt ? ( + {fmt(t.departedAt)} + ) : ( + + Still on site + + )} + + + + {t.netWeightTons != null ? `${t.netWeightTons} t` : "—"} + + + + + + + ); + })} + +
+ + EDR handovers are generated at delivery, so an exit paper is not gated on a + signature — warehouse-fee clearance still applies. + +
+ )} +
+ ); +} diff --git a/apps/edr-freight-web/backoffice/src/pages/operations/LastMilePage.tsx b/apps/edr-freight-web/backoffice/src/pages/operations/LastMilePage.tsx index cd5d22c6b..3f96edd2f 100644 --- a/apps/edr-freight-web/backoffice/src/pages/operations/LastMilePage.tsx +++ b/apps/edr-freight-web/backoffice/src/pages/operations/LastMilePage.tsx @@ -9,6 +9,7 @@ import { RefreshCw, Ruler, Trash, + FileText, Truck, X, } from "lucide-react"; @@ -56,6 +57,7 @@ import { import { vehiclesService } from "@/services/vehicles.service"; import { driversService, type Driver } from "@/services/drivers.service"; import { ReleaseOrderModal, type ReleaseOrderTruckPrefill } from "@/components/warehouses/ReleaseOrderModal"; +import { EdrTruckExitPapersModal } from "./EdrTruckExitPapersModal"; import { TruckDetentionModal } from "@/components/operations/TruckDetentionModal"; import { ProofOfDeliveryModal } from "@/components/operations/ProofOfDeliveryModal"; import { LastMileStepper, type LastMileStepState } from "@/components/operations/LastMileSteps"; @@ -124,10 +126,22 @@ const containerCount = (record: LastMileRecord) => (sum, c) => sum + (Number(c.quantity) || 0), 0, ); -/** Trucks needed for a booking = ceil(containers / 2). 0 when no container data. */ +/** + * Trucks needed for a booking, by container SIZE: a 40ft fills a truck (1 each), + * two 20ft share one. Falls back to ceil(n / 2) when no size is recorded. + * 0 when the booking has no container data (bulk). + */ const requiredVehicles = (record: LastMileRecord) => { - const n = containerCount(record); - return n > 0 ? Math.ceil(n / CONTAINERS_PER_VEHICLE) : 0; + const lines = record.booking?.bookingContainers ?? []; + if (!containerCount(record)) return 0; + let forty = 0; + let others = 0; + for (const c of lines) { + const qty = Number(c.quantity) || 0; + if ((c.containerSize ?? '').includes('40')) forty += qty; + else others += qty; + } + return forty + Math.ceil(others / CONTAINERS_PER_VEHICLE); }; /** Real per-physical-container numbers on a booking, in order. Prefers each @@ -581,9 +595,11 @@ const LastMilePage = () => { const [activeId, setActiveId] = useState(null); const [detentionRecord, setDetentionRecord] = useState(null); // Multi-vehicle assign: one row per truck — vehicle + the container it carries. + // One row per truck. A truck carries one 40ft or up to two 20ft, so the load + // is a list, not a single container. const [vehicleRows, setVehicleRows] = useState< - Array<{ vehicleId: string | null; containerNumber: string }> - >([{ vehicleId: null, containerNumber: "" }]); + Array<{ vehicleId: string | null; containerNumbers: string[] }> + >([{ vehicleId: null, containerNumbers: [] }]); // 2-step "Assign Mile" accept modal (arrival queue → vehicle) const [acceptOpen, setAcceptOpen] = useState(false); @@ -1000,31 +1016,59 @@ const LastMilePage = () => { return filteredRecords.slice(start, start + pagination.pageSize); }, [filteredRecords, pagination]); + // Per-truck exit papers for an EDR delivery (one paper per assigned truck). + const [exitPapersOpen, setExitPapersOpen] = useState(false); + const [exitPapersRecord, setExitPapersRecord] = useState(null); + + // Bulk drawdown: how much tonnage is still to be hauled on the booking being + // assigned. Bulk has no containers, so trucks keep going until this hits 0. + const assignBookingId = activeRecord?.booking?.id ?? null; + const { data: remainingTons } = useQuery({ + queryKey: ["last-mile", "remaining-tons", assignBookingId], + queryFn: () => lastMileService.remainingTons(assignBookingId as string).then((r) => r.data), + enabled: assignOpen && !bulkMode && Boolean(assignBookingId), + }); + const openAssign = (id: string | null) => { const resolved = id ?? filteredRecords.find((r) => !isAssigned(r))?.id ?? null; const rec = records.find((r) => r.id === resolved); // Prefill each row's container number from the booking's container numbers // (by order) when the assignment doesn't already carry one. const nums = rec ? bookingContainerNumbers(rec) : []; + // Prefer the truck's own container list; fall back to the legacy scalar, then + // to the booking's containers by order. + const loadOf = ( + a: { containers?: Array<{ containerNumber: string }>; containerNumber?: string | null }, + i: number, + ) => + a.containers?.length + ? a.containers.map((c) => c.containerNumber) + : a.containerNumber + ? [a.containerNumber] + : nums[i] + ? [nums[i]] + : []; const rows = rec?.vehicleAssignments?.length ? rec.vehicleAssignments.map((a, i) => ({ vehicleId: a.vehicleId, - containerNumber: a.containerNumber ?? nums[i] ?? "", + containerNumbers: loadOf(a, i), })) : rec?.vehicleId - ? [{ vehicleId: rec.vehicleId, containerNumber: nums[0] ?? "" }] - : [{ vehicleId: null, containerNumber: nums[0] ?? "" }]; + ? [{ vehicleId: rec.vehicleId, containerNumbers: nums[0] ? [nums[0]] : [] }] + : [{ vehicleId: null, containerNumbers: nums[0] ? [nums[0]] : [] }]; setBulkMode(false); setActiveId(resolved); - setVehicleRows(rows.length ? rows : [{ vehicleId: null, containerNumber: nums[0] ?? "" }]); + setVehicleRows( + rows.length ? rows : [{ vehicleId: null, containerNumbers: nums[0] ? [nums[0]] : [] }], + ); setAssignOpen(true); }; const openBulkAssign = () => { setBulkMode(true); setActiveId(null); - setVehicleRows([{ vehicleId: null, containerNumber: "" }]); + setVehicleRows([{ vehicleId: null, containerNumbers: [] }]); setAssignOpen(true); }; @@ -1032,15 +1076,18 @@ const LastMilePage = () => { setAssignOpen(false); setBulkMode(false); setActiveId(null); - setVehicleRows([{ vehicleId: null, containerNumber: "" }]); + setVehicleRows([{ vehicleId: null, containerNumbers: [] }]); }; const handleAssign = () => { const seen = new Set(); const vehicles = vehicleRows - .filter((r): r is { vehicleId: string; containerNumber: string } => Boolean(r.vehicleId)) + .filter((r): r is { vehicleId: string; containerNumbers: string[] } => Boolean(r.vehicleId)) .filter((r) => (seen.has(r.vehicleId) ? false : seen.add(r.vehicleId))) - .map((r) => ({ vehicleId: r.vehicleId, containerNumber: r.containerNumber.trim() || null })); + .map((r) => ({ + vehicleId: r.vehicleId, + containerNumbers: r.containerNumbers.map((n) => n.trim()).filter(Boolean), + })); const count = vehicles.length; const targetIds = bulkMode ? selectedIds @@ -1419,6 +1466,16 @@ const LastMilePage = () => { > Truck Leaving + } + disabled={!row.original.vehicleAssignments?.length} + onClick={() => { + setExitPapersRecord(row.original); + setExitPapersOpen(true); + }} + > + Truck exit papers + } @@ -1738,9 +1795,24 @@ const LastMilePage = () => { const needed = requiredVehicles(activeRecord); const picked = vehicleRows.filter((r) => r.vehicleId).length; if (needed === 0) { + // Bulk: no containers — trucks haul loose tonnage until the + // booking's total is drawn down to zero by departing trucks. + const done = remainingTons?.complete; return ( - - No container count on this booking — assign trucks as needed. + + {remainingTons + ? done + ? "Fully hauled — no tonnage left to assign trucks for." + : `Bulk booking: ${remainingTons.hauledTons} t hauled so far. Keep assigning trucks until the remaining tonnage reaches 0 — each truck's net weight is deducted when it leaves.` + : "Assign trucks as needed."} ); } @@ -1799,25 +1871,27 @@ const LastMilePage = () => { clearable disabled={assignVehicleOptions.length === 0} /> -