mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-29 19:58:11 +00:00
feat(last-mile): wire multi-container assignment, bulk drawdown + EDR exit papers
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) <noreply@anthropic.com>
This commit is contained in:
@@ -182,7 +182,7 @@ export class LastMileService {
|
|||||||
relations: {
|
relations: {
|
||||||
booking: { company: true, serviceType: true, originYard: true, destinationYard: true, cargoType: true, bookingContainers: { containerType: true, units: true } },
|
booking: { company: true, serviceType: true, originYard: true, destinationYard: true, cargoType: true, bookingContainers: { containerType: true, units: true } },
|
||||||
vehicle: true,
|
vehicle: true,
|
||||||
vehicleAssignments: { vehicle: true },
|
vehicleAssignments: { vehicle: true, containers: true },
|
||||||
},
|
},
|
||||||
order: { [sortBy]: sortOrder },
|
order: { [sortBy]: sortOrder },
|
||||||
skip: (page - 1) * pageSize,
|
skip: (page - 1) * pageSize,
|
||||||
@@ -207,7 +207,7 @@ export class LastMileService {
|
|||||||
relations: {
|
relations: {
|
||||||
booking: { company: true, serviceType: true, originYard: true, destinationYard: true, cargoType: true, bookingContainers: { containerType: true, units: true } },
|
booking: { company: true, serviceType: true, originYard: true, destinationYard: true, cargoType: true, bookingContainers: { containerType: true, units: true } },
|
||||||
vehicle: true,
|
vehicle: true,
|
||||||
vehicleAssignments: { vehicle: true },
|
vehicleAssignments: { vehicle: true, containers: true },
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -284,7 +284,7 @@ export class LastMileService {
|
|||||||
> {
|
> {
|
||||||
const [lm] = await this.lastMileRepository.findAll({
|
const [lm] = await this.lastMileRepository.findAll({
|
||||||
where: { bookingId },
|
where: { bookingId },
|
||||||
relations: { vehicle: true, vehicleAssignments: { vehicle: true } },
|
relations: { vehicle: true, vehicleAssignments: { vehicle: true, containers: true } },
|
||||||
take: 1,
|
take: 1,
|
||||||
});
|
});
|
||||||
if (!lm) return [];
|
if (!lm) return [];
|
||||||
|
|||||||
@@ -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<string | null>(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 (
|
||||||
|
<Modal
|
||||||
|
opened={opened}
|
||||||
|
onClose={onClose}
|
||||||
|
centered
|
||||||
|
radius="lg"
|
||||||
|
size="lg"
|
||||||
|
title={
|
||||||
|
<Text fw={600}>
|
||||||
|
Truck exit papers {record?.booking?.reference ? `· ${record.booking.reference}` : ""}
|
||||||
|
</Text>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{trucks.length === 0 ? (
|
||||||
|
<Alert variant="light" color="gray">
|
||||||
|
No trucks assigned to this delivery yet.
|
||||||
|
</Alert>
|
||||||
|
) : (
|
||||||
|
<Stack gap="sm">
|
||||||
|
<Table verticalSpacing="xs" highlightOnHover>
|
||||||
|
<Table.Thead>
|
||||||
|
<Table.Tr>
|
||||||
|
<Table.Th>Truck</Table.Th>
|
||||||
|
<Table.Th>Containers</Table.Th>
|
||||||
|
<Table.Th>Arrived</Table.Th>
|
||||||
|
<Table.Th>Left</Table.Th>
|
||||||
|
<Table.Th ta="right">Net</Table.Th>
|
||||||
|
<Table.Th ta="right">Exit paper</Table.Th>
|
||||||
|
</Table.Tr>
|
||||||
|
</Table.Thead>
|
||||||
|
<Table.Tbody>
|
||||||
|
{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 (
|
||||||
|
<Table.Tr key={t.id}>
|
||||||
|
<Table.Td>
|
||||||
|
<Text fw={600} size="sm">{plate}</Text>
|
||||||
|
</Table.Td>
|
||||||
|
<Table.Td>
|
||||||
|
<Text size="sm">{load}</Text>
|
||||||
|
</Table.Td>
|
||||||
|
<Table.Td>
|
||||||
|
<Text size="sm">{fmt(t.arrivedAt)}</Text>
|
||||||
|
</Table.Td>
|
||||||
|
<Table.Td>
|
||||||
|
{t.departedAt ? (
|
||||||
|
<Text size="sm">{fmt(t.departedAt)}</Text>
|
||||||
|
) : (
|
||||||
|
<Badge size="sm" variant="light" color="gray">
|
||||||
|
Still on site
|
||||||
|
</Badge>
|
||||||
|
)}
|
||||||
|
</Table.Td>
|
||||||
|
<Table.Td ta="right">
|
||||||
|
<Text size="sm">
|
||||||
|
{t.netWeightTons != null ? `${t.netWeightTons} t` : "—"}
|
||||||
|
</Text>
|
||||||
|
</Table.Td>
|
||||||
|
<Table.Td ta="right">
|
||||||
|
<Button
|
||||||
|
size="compact-xs"
|
||||||
|
variant="light"
|
||||||
|
color="orange"
|
||||||
|
leftSection={<FileText size={13} />}
|
||||||
|
loading={busyId === t.id}
|
||||||
|
onClick={() => download(t.id, plate)}
|
||||||
|
>
|
||||||
|
Exit Paper
|
||||||
|
</Button>
|
||||||
|
</Table.Td>
|
||||||
|
</Table.Tr>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</Table.Tbody>
|
||||||
|
</Table>
|
||||||
|
<Text size="xs" c="dimmed">
|
||||||
|
EDR handovers are generated at delivery, so an exit paper is not gated on a
|
||||||
|
signature — warehouse-fee clearance still applies.
|
||||||
|
</Text>
|
||||||
|
</Stack>
|
||||||
|
)}
|
||||||
|
</Modal>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -9,6 +9,7 @@ import {
|
|||||||
RefreshCw,
|
RefreshCw,
|
||||||
Ruler,
|
Ruler,
|
||||||
Trash,
|
Trash,
|
||||||
|
FileText,
|
||||||
Truck,
|
Truck,
|
||||||
X,
|
X,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
@@ -56,6 +57,7 @@ import {
|
|||||||
import { vehiclesService } from "@/services/vehicles.service";
|
import { vehiclesService } from "@/services/vehicles.service";
|
||||||
import { driversService, type Driver } from "@/services/drivers.service";
|
import { driversService, type Driver } from "@/services/drivers.service";
|
||||||
import { ReleaseOrderModal, type ReleaseOrderTruckPrefill } from "@/components/warehouses/ReleaseOrderModal";
|
import { ReleaseOrderModal, type ReleaseOrderTruckPrefill } from "@/components/warehouses/ReleaseOrderModal";
|
||||||
|
import { EdrTruckExitPapersModal } from "./EdrTruckExitPapersModal";
|
||||||
import { TruckDetentionModal } from "@/components/operations/TruckDetentionModal";
|
import { TruckDetentionModal } from "@/components/operations/TruckDetentionModal";
|
||||||
import { ProofOfDeliveryModal } from "@/components/operations/ProofOfDeliveryModal";
|
import { ProofOfDeliveryModal } from "@/components/operations/ProofOfDeliveryModal";
|
||||||
import { LastMileStepper, type LastMileStepState } from "@/components/operations/LastMileSteps";
|
import { LastMileStepper, type LastMileStepState } from "@/components/operations/LastMileSteps";
|
||||||
@@ -124,10 +126,22 @@ const containerCount = (record: LastMileRecord) =>
|
|||||||
(sum, c) => sum + (Number(c.quantity) || 0),
|
(sum, c) => sum + (Number(c.quantity) || 0),
|
||||||
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 requiredVehicles = (record: LastMileRecord) => {
|
||||||
const n = containerCount(record);
|
const lines = record.booking?.bookingContainers ?? [];
|
||||||
return n > 0 ? Math.ceil(n / CONTAINERS_PER_VEHICLE) : 0;
|
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
|
/** Real per-physical-container numbers on a booking, in order. Prefers each
|
||||||
@@ -581,9 +595,11 @@ const LastMilePage = () => {
|
|||||||
const [activeId, setActiveId] = useState<string | null>(null);
|
const [activeId, setActiveId] = useState<string | null>(null);
|
||||||
const [detentionRecord, setDetentionRecord] = useState<LastMileRecord | null>(null);
|
const [detentionRecord, setDetentionRecord] = useState<LastMileRecord | null>(null);
|
||||||
// Multi-vehicle assign: one row per truck — vehicle + the container it carries.
|
// 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<
|
const [vehicleRows, setVehicleRows] = useState<
|
||||||
Array<{ vehicleId: string | null; containerNumber: string }>
|
Array<{ vehicleId: string | null; containerNumbers: string[] }>
|
||||||
>([{ vehicleId: null, containerNumber: "" }]);
|
>([{ vehicleId: null, containerNumbers: [] }]);
|
||||||
|
|
||||||
// 2-step "Assign Mile" accept modal (arrival queue → vehicle)
|
// 2-step "Assign Mile" accept modal (arrival queue → vehicle)
|
||||||
const [acceptOpen, setAcceptOpen] = useState(false);
|
const [acceptOpen, setAcceptOpen] = useState(false);
|
||||||
@@ -1000,31 +1016,59 @@ const LastMilePage = () => {
|
|||||||
return filteredRecords.slice(start, start + pagination.pageSize);
|
return filteredRecords.slice(start, start + pagination.pageSize);
|
||||||
}, [filteredRecords, pagination]);
|
}, [filteredRecords, pagination]);
|
||||||
|
|
||||||
|
// Per-truck exit papers for an EDR delivery (one paper per assigned truck).
|
||||||
|
const [exitPapersOpen, setExitPapersOpen] = useState(false);
|
||||||
|
const [exitPapersRecord, setExitPapersRecord] = useState<LastMileRecord | null>(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 openAssign = (id: string | null) => {
|
||||||
const resolved = id ?? filteredRecords.find((r) => !isAssigned(r))?.id ?? null;
|
const resolved = id ?? filteredRecords.find((r) => !isAssigned(r))?.id ?? null;
|
||||||
const rec = records.find((r) => r.id === resolved);
|
const rec = records.find((r) => r.id === resolved);
|
||||||
// Prefill each row's container number from the booking's container numbers
|
// Prefill each row's container number from the booking's container numbers
|
||||||
// (by order) when the assignment doesn't already carry one.
|
// (by order) when the assignment doesn't already carry one.
|
||||||
const nums = rec ? bookingContainerNumbers(rec) : [];
|
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 =
|
const rows =
|
||||||
rec?.vehicleAssignments?.length
|
rec?.vehicleAssignments?.length
|
||||||
? rec.vehicleAssignments.map((a, i) => ({
|
? rec.vehicleAssignments.map((a, i) => ({
|
||||||
vehicleId: a.vehicleId,
|
vehicleId: a.vehicleId,
|
||||||
containerNumber: a.containerNumber ?? nums[i] ?? "",
|
containerNumbers: loadOf(a, i),
|
||||||
}))
|
}))
|
||||||
: rec?.vehicleId
|
: rec?.vehicleId
|
||||||
? [{ vehicleId: rec.vehicleId, containerNumber: nums[0] ?? "" }]
|
? [{ vehicleId: rec.vehicleId, containerNumbers: nums[0] ? [nums[0]] : [] }]
|
||||||
: [{ vehicleId: null, containerNumber: nums[0] ?? "" }];
|
: [{ vehicleId: null, containerNumbers: nums[0] ? [nums[0]] : [] }];
|
||||||
setBulkMode(false);
|
setBulkMode(false);
|
||||||
setActiveId(resolved);
|
setActiveId(resolved);
|
||||||
setVehicleRows(rows.length ? rows : [{ vehicleId: null, containerNumber: nums[0] ?? "" }]);
|
setVehicleRows(
|
||||||
|
rows.length ? rows : [{ vehicleId: null, containerNumbers: nums[0] ? [nums[0]] : [] }],
|
||||||
|
);
|
||||||
setAssignOpen(true);
|
setAssignOpen(true);
|
||||||
};
|
};
|
||||||
|
|
||||||
const openBulkAssign = () => {
|
const openBulkAssign = () => {
|
||||||
setBulkMode(true);
|
setBulkMode(true);
|
||||||
setActiveId(null);
|
setActiveId(null);
|
||||||
setVehicleRows([{ vehicleId: null, containerNumber: "" }]);
|
setVehicleRows([{ vehicleId: null, containerNumbers: [] }]);
|
||||||
setAssignOpen(true);
|
setAssignOpen(true);
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -1032,15 +1076,18 @@ const LastMilePage = () => {
|
|||||||
setAssignOpen(false);
|
setAssignOpen(false);
|
||||||
setBulkMode(false);
|
setBulkMode(false);
|
||||||
setActiveId(null);
|
setActiveId(null);
|
||||||
setVehicleRows([{ vehicleId: null, containerNumber: "" }]);
|
setVehicleRows([{ vehicleId: null, containerNumbers: [] }]);
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleAssign = () => {
|
const handleAssign = () => {
|
||||||
const seen = new Set<string>();
|
const seen = new Set<string>();
|
||||||
const vehicles = vehicleRows
|
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)))
|
.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 count = vehicles.length;
|
||||||
const targetIds = bulkMode
|
const targetIds = bulkMode
|
||||||
? selectedIds
|
? selectedIds
|
||||||
@@ -1419,6 +1466,16 @@ const LastMilePage = () => {
|
|||||||
>
|
>
|
||||||
Truck Leaving
|
Truck Leaving
|
||||||
</Menu.Item>
|
</Menu.Item>
|
||||||
|
<Menu.Item
|
||||||
|
leftSection={<FileText size={15} />}
|
||||||
|
disabled={!row.original.vehicleAssignments?.length}
|
||||||
|
onClick={() => {
|
||||||
|
setExitPapersRecord(row.original);
|
||||||
|
setExitPapersOpen(true);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Truck exit papers
|
||||||
|
</Menu.Item>
|
||||||
<Menu.Divider />
|
<Menu.Divider />
|
||||||
<Menu.Item
|
<Menu.Item
|
||||||
leftSection={<Eye size={15} />}
|
leftSection={<Eye size={15} />}
|
||||||
@@ -1738,9 +1795,24 @@ const LastMilePage = () => {
|
|||||||
const needed = requiredVehicles(activeRecord);
|
const needed = requiredVehicles(activeRecord);
|
||||||
const picked = vehicleRows.filter((r) => r.vehicleId).length;
|
const picked = vehicleRows.filter((r) => r.vehicleId).length;
|
||||||
if (needed === 0) {
|
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 (
|
return (
|
||||||
<Alert variant="light" color="gray" title="One truck (with trailer) carries 2 containers">
|
<Alert
|
||||||
No container count on this booking — assign trucks as needed.
|
variant="light"
|
||||||
|
color={done ? "green" : remainingTons ? "blue" : "gray"}
|
||||||
|
title={
|
||||||
|
remainingTons
|
||||||
|
? `${remainingTons.remainingTons} t remaining of ${remainingTons.totalTons} t`
|
||||||
|
: "No container count on this booking"
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{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."}
|
||||||
</Alert>
|
</Alert>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -1799,25 +1871,27 @@ const LastMilePage = () => {
|
|||||||
clearable
|
clearable
|
||||||
disabled={assignVehicleOptions.length === 0}
|
disabled={assignVehicleOptions.length === 0}
|
||||||
/>
|
/>
|
||||||
<Select
|
<MultiSelect
|
||||||
style={{ flex: 1 }}
|
style={{ flex: 1 }}
|
||||||
label={i === 0 ? "Container no." : undefined}
|
label={i === 0 ? "Containers (1x40ft or 2x20ft)" : undefined}
|
||||||
placeholder={containerOptions.length ? "Select container" : "No container numbers"}
|
placeholder={containerOptions.length ? "Select containers" : "No container numbers"}
|
||||||
|
// A truck takes at most two containers; a 40ft fills it (the
|
||||||
|
// API rejects a 40ft paired with anything).
|
||||||
|
maxValues={2}
|
||||||
data={[
|
data={[
|
||||||
...containerOptions.filter(
|
...containerOptions.filter(
|
||||||
(n) =>
|
(n) =>
|
||||||
n === row.containerNumber ||
|
row.containerNumbers.includes(n) ||
|
||||||
!vehicleRows.some((r, idx) => idx !== i && r.containerNumber === n),
|
// a container rides exactly one truck
|
||||||
|
!vehicleRows.some((r, idx) => idx !== i && r.containerNumbers.includes(n)),
|
||||||
),
|
),
|
||||||
// keep a manual/legacy value selectable even if not in the booking
|
// keep manual/legacy values selectable even if not in the booking
|
||||||
...(row.containerNumber && !containerOptions.includes(row.containerNumber)
|
...row.containerNumbers.filter((n) => !containerOptions.includes(n)),
|
||||||
? [row.containerNumber]
|
|
||||||
: []),
|
|
||||||
]}
|
]}
|
||||||
value={row.containerNumber || null}
|
value={row.containerNumbers}
|
||||||
onChange={(value) =>
|
onChange={(value) =>
|
||||||
setVehicleRows((prev) =>
|
setVehicleRows((prev) =>
|
||||||
prev.map((x, idx) => (idx === i ? { ...x, containerNumber: value ?? "" } : x)),
|
prev.map((x, idx) => (idx === i ? { ...x, containerNumbers: value } : x)),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
searchable
|
searchable
|
||||||
@@ -1840,14 +1914,14 @@ const LastMilePage = () => {
|
|||||||
size="xs"
|
size="xs"
|
||||||
leftSection={<Plus size={14} />}
|
leftSection={<Plus size={14} />}
|
||||||
onClick={() =>
|
onClick={() =>
|
||||||
setVehicleRows((prev) => [
|
setVehicleRows((prev) => {
|
||||||
...prev,
|
// Suggest the next unassigned container for the new truck.
|
||||||
{
|
const taken = new Set(prev.flatMap((r) => r.containerNumbers));
|
||||||
vehicleId: null,
|
const next = (activeRecord ? bookingContainerNumbers(activeRecord) : []).find(
|
||||||
containerNumber:
|
(n) => !taken.has(n),
|
||||||
(activeRecord ? bookingContainerNumbers(activeRecord)[prev.length] : "") ?? "",
|
);
|
||||||
},
|
return [...prev, { vehicleId: null, containerNumbers: next ? [next] : [] }];
|
||||||
])
|
})
|
||||||
}
|
}
|
||||||
disabled={
|
disabled={
|
||||||
assignVehicleOptions.length === 0 ||
|
assignVehicleOptions.length === 0 ||
|
||||||
@@ -2141,6 +2215,12 @@ const LastMilePage = () => {
|
|||||||
truckPrefill={releaseTruckPrefill}
|
truckPrefill={releaseTruckPrefill}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
<EdrTruckExitPapersModal
|
||||||
|
opened={exitPapersOpen}
|
||||||
|
onClose={() => setExitPapersOpen(false)}
|
||||||
|
record={exitPapersRecord}
|
||||||
|
/>
|
||||||
|
|
||||||
<TruckDetentionModal
|
<TruckDetentionModal
|
||||||
opened={Boolean(detentionRecord)}
|
opened={Boolean(detentionRecord)}
|
||||||
onClose={() => setDetentionRecord(null)}
|
onClose={() => setDetentionRecord(null)}
|
||||||
|
|||||||
@@ -67,8 +67,16 @@ export interface LastMileRecord {
|
|||||||
vehicleAssignments?: Array<{
|
vehicleAssignments?: Array<{
|
||||||
id: string;
|
id: string;
|
||||||
vehicleId: string;
|
vehicleId: string;
|
||||||
|
/** @deprecated Legacy single container — `containers` is authoritative. */
|
||||||
containerNumber?: string | null;
|
containerNumber?: string | null;
|
||||||
|
/** Containers riding this truck: one 40ft, or up to two 20ft. */
|
||||||
|
containers?: Array<{ id: string; containerNumber: string }>;
|
||||||
distanceKm?: number | null;
|
distanceKm?: number | null;
|
||||||
|
/** Per-truck arrival / exit, stamped by the warehouse weighing steps. */
|
||||||
|
arrivedAt?: string | null;
|
||||||
|
departedAt?: string | null;
|
||||||
|
grossWeightTons?: number | null;
|
||||||
|
netWeightTons?: number | null;
|
||||||
vehicle?: LastMileVehicle | null;
|
vehicle?: LastMileVehicle | null;
|
||||||
}>;
|
}>;
|
||||||
/** Present only when an invoice has actually been generated (not on distance). */
|
/** Present only when an invoice has actually been generated (not on distance). */
|
||||||
@@ -99,8 +107,13 @@ export const lastMileService = {
|
|||||||
api.delete<void>(LM.BY_ID(id)),
|
api.delete<void>(LM.BY_ID(id)),
|
||||||
setVehicles: (
|
setVehicles: (
|
||||||
id: string,
|
id: string,
|
||||||
vehicles: Array<{ vehicleId: string; containerNumber?: string | null }>,
|
vehicles: Array<{ vehicleId: string; containerNumbers?: string[] }>,
|
||||||
) => api.post<LastMileRecord>(`${LM.BASE}/${id}/vehicles`, { vehicles }),
|
) => api.post<LastMileRecord>(`${LM.BASE}/${id}/vehicles`, { vehicles }),
|
||||||
|
/** Bulk drawdown: tonnage still to be hauled on this booking. */
|
||||||
|
remainingTons: (bookingId: string) =>
|
||||||
|
api.get<{ totalTons: number; hauledTons: number; remainingTons: number; complete: boolean }>(
|
||||||
|
`${LM.BASE}/booking/${bookingId}/remaining-tons`,
|
||||||
|
),
|
||||||
setDistances: (
|
setDistances: (
|
||||||
id: string,
|
id: string,
|
||||||
distances: Array<{ vehicleId: string; distanceKm: number }>,
|
distances: Array<{ vehicleId: string; distanceKm: number }>,
|
||||||
|
|||||||
@@ -323,6 +323,11 @@ export const warehouseService = {
|
|||||||
apiClient.get<Blob>(`/warehouse-inventory/customer-truck-exit-paper/${assignmentId}`, {
|
apiClient.get<Blob>(`/warehouse-inventory/customer-truck-exit-paper/${assignmentId}`, {
|
||||||
responseType: 'blob',
|
responseType: 'blob',
|
||||||
}),
|
}),
|
||||||
|
/** Per-truck exit paper PDF for an EDR last-mile truck. */
|
||||||
|
downloadEdrTruckExitPaper: (assignmentId: string) =>
|
||||||
|
apiClient.get<Blob>(`/warehouse-inventory/edr-truck-exit-paper/${assignmentId}`, {
|
||||||
|
responseType: 'blob',
|
||||||
|
}),
|
||||||
deliver: (id: string, payload: DeliverInventoryPayload) =>
|
deliver: (id: string, payload: DeliverInventoryPayload) =>
|
||||||
apiClient.post<WarehouseInventoryItem>(URL_CONSTANTS.WAREHOUSE_INVENTORY.DELIVER(id), payload),
|
apiClient.post<WarehouseInventoryItem>(URL_CONSTANTS.WAREHOUSE_INVENTORY.DELIVER(id), payload),
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user