Merge branch 'dev' into freight/feat/fixes-v1

This commit is contained in:
Nathnael
2026-07-16 13:23:40 +00:00
68 changed files with 4040 additions and 426 deletions

View File

@@ -26,6 +26,7 @@ import {
TextInput,
ThemeIcon,
Title,
Tooltip,
} from "@mantine/core";
import {
AlertCircle,
@@ -642,6 +643,20 @@ export default function GlCreateBookingForm() {
});
}, [isContainer, contract, containerLines, contractWithReturn]);
// 20ft containers ride two per wagon, so an odd total leaves one unpaired and
// the booking can never be planned. The server rejects it too (the price
// modal's `pairingErrors`), but that only lands after GL has filled the whole
// form — mirror the customer portal (new-booking-form/schema.ts `calcWagons`)
// and block it inline instead. Size strings arrive as "20ft" from the contract
// scope but as a bare "20" from the rebook seed, so match on the leading digits.
const ft20Total = useMemo(() => {
if (!isContainer) return 0;
return containerLines
.filter((l) => parseInt(l.containerSize, 10) === 20)
.reduce((sum, l) => sum + Number(l.quantity || 0), 0);
}, [isContainer, containerLines]);
const hasOdd20ft = ft20Total % 2 === 1;
const bulkUom = contract ? bulkUnitOfMeasure(contract) : "PER_TON";
const bulkErrors = useMemo<BulkErrors>(() => {
@@ -688,7 +703,7 @@ export default function GlCreateBookingForm() {
)
: !bulkErrors.quantity && !bulkErrors.hazardous && !bulkErrors.reefer;
const formValid = cargoValid && !dateError && !routeError;
const formValid = cargoValid && !hasOdd20ft && !dateError && !routeError;
/** The create-booking DTO from the current form state — shared by the
* authoritative price preview and the actual submit so what GL confirms is
@@ -1286,6 +1301,21 @@ export default function GlCreateBookingForm() {
</Box>
))
)}
{hasOdd20ft ? (
<Alert
color="red"
variant="light"
radius="md"
icon={<AlertCircle size={16} />}
title={`Odd number of 20ft containers (${ft20Total})`}
>
20ft containers travel two per wagon, so they must be booked in
even numbers. Add one more 20ft container or remove one (e.g.
book {ft20Total + 1} or {ft20Total - 1} instead of {ft20Total})
the booking cannot be created with an unpaired 20ft container.
</Alert>
) : null}
</Stack>
</StepCard>
) : (
@@ -1530,14 +1560,27 @@ export default function GlCreateBookingForm() {
</Alert>
) : null}
<Group justify="flex-end">
<Button
color="edr-green"
radius="md"
leftSection={<Receipt size={16} />}
onClick={openPriceModal}
<Tooltip
label={`Book an even number of 20ft containers — ${ft20Total} is odd and would leave one unpaired.`}
withArrow
disabled={!hasOdd20ft}
>
Review price &amp; book
</Button>
{/* Mantine tooltips get no pointer events from a disabled button,
so the wrapper carries the hover target. */}
<Box>
<Button
color="edr-green"
radius="md"
leftSection={<Receipt size={16} />}
onClick={openPriceModal}
// Same hard block the customer portal applies at review time —
// an unpaired 20ft can never be planned onto a wagon.
disabled={hasOdd20ft}
>
Review price &amp; book
</Button>
</Box>
</Tooltip>
</Group>
</Box>
</Box>

View File

@@ -36,7 +36,9 @@ export function PinWagonsForm({
autoFillOnMount?: boolean;
}) {
const originYardId = schedule.originStation?.id;
const slots = schedule.trainSet?.wagons ?? [];
// Consist-only rows are the built train's coupled-but-empty wagons — display
// entries with no TrainSetWagon slot behind them, so nothing can be pinned.
const slots = (schedule.trainSet?.wagons ?? []).filter((w) => !w.consistOnly);
const [assignments, setAssignments] = useState<Record<string, string>>({});
const wagonOptionsByType = useMemo(() => {

View File

@@ -359,7 +359,9 @@ function WagonCar({
{isEmpty ? (
<Text size="xs" c="dimmed">
Empty slot available for allocation.
{wagon.consistOnly
? "Empty wagon — coupled on the train, no load planned."
: "Empty slot — available for allocation."}
</Text>
) : (
<Stack gap={6}>

View File

@@ -167,9 +167,9 @@ export const WagonCard = ({
<TrainFront size={18} />
</ThemeIcon>
<Text size="sm" c="dimmed">
Empty slot
{wagon.consistOnly ? "Empty wagon — coupled on the train" : "Empty slot"}
</Text>
{!isDispatched ? (
{!isDispatched && !wagon.consistOnly ? (
<Button
variant="subtle"
color="gray"

View File

@@ -13,3 +13,6 @@ export function fileViewUrl(fileId: string, download = false): string {
const base = `${API_BASE_URL}/api/files/${fileId}`;
return download ? `${base}?download=1` : base;
}

View File

@@ -5,6 +5,7 @@ import {
Alert,
Badge,
Box,
Button,
Grid,
Group,
Loader,
@@ -21,10 +22,18 @@ import {
CheckCircle2,
Clock,
PackageCheck,
RefreshCw,
ShieldCheck,
} from "lucide-react";
import { Link } from "react-router-dom";
import { useAuth } from "@/auth/useAuth";
import {
FREIGHT_PERMS,
hasPermission,
isDjiboutiGl,
} from "@/lib/permissions";
import { ClearanceOpsTabs } from "@/components/contracts/ClearanceOpsTabs";
import { PageContainer } from "@/components/page/PageContainer";
import { PageHeader } from "@/components/page/PageHeader";
@@ -44,6 +53,7 @@ import {
export default function ContractClearanceDetailPage() {
const { id } = useParams<{ id: string }>();
const { view, viewer } = useFileViewer();
const { user } = useAuth();
const { data: contract, refetch: refetchContract } = useContractDetail(id);
const {
@@ -106,6 +116,16 @@ export default function ContractClearanceDetailPage() {
const reviewReadOnly = shipmentLocked;
const queriesLocked = Boolean(clearance?.preClearanceFinalized);
const bookingHref = `/dashboard/contracts/${id}/create-booking`;
// The GL-created booking expired unpaid — the slot is free again and GL
// rebooks on the customer's behalf (customs bookings are never self-booked).
const bookingExpired = clearance?.linkedBookingStatus === "EXPIRED";
const canRebook =
bookingExpired &&
hasPermission(user, FREIGHT_PERMS.contracts.createBooking) &&
!isDjiboutiGl(user);
const rebookHref = linkedBookingId
? `${bookingHref}?copyFrom=${linkedBookingId}`
: bookingHref;
const { data: bookingMilestones, refetch: refetchBookingMilestones } =
useBookingMilestones(linkedBookingId);
@@ -165,7 +185,16 @@ export default function ContractClearanceDetailPage() {
{ label: reference },
]}
meta={
bookingAlreadyCreated ? (
bookingExpired ? (
<Badge
variant="light"
color="orange"
radius="sm"
leftSection={<RefreshCw size={13} />}
>
Payment expired rebook
</Badge>
) : bookingAlreadyCreated ? (
<Badge
variant="light"
color="blue"
@@ -211,7 +240,49 @@ export default function ContractClearanceDetailPage() {
it can actually create the booking without checking the schedule board. */}
{id ? <GlUpcomingWindowsSection contractId={id} /> : null}
{bookingAlreadyCreated ? (
{bookingExpired ? (
<Alert
color="orange"
radius="md"
icon={<RefreshCw size={16} />}
title="Booking expired — payment not received"
>
<Stack gap="sm" align="flex-start">
<Text size="sm">
The customer did not pay before the deadline, so the booking
expired and its train slot was released. The contract slot is
free again GL Ethiopia can rebook on the customer&apos;s
behalf without re-running clearance.
{linkedBookingId ? (
<>
{" "}
<Text
component={Link}
to={`/dashboard/bookings/${linkedBookingId}/clearance`}
inherit
fw={600}
c="orange.8"
>
View expired booking
</Text>
</>
) : null}
</Text>
{canRebook ? (
<Button
component={Link}
to={rebookHref}
color="grape"
radius="md"
size="sm"
leftSection={<RefreshCw size={15} />}
>
Rebook for customer
</Button>
) : null}
</Stack>
</Alert>
) : bookingAlreadyCreated ? (
<Alert
color="blue"
radius="md"

View File

@@ -92,6 +92,10 @@ interface ClearanceRow {
ready: boolean;
/** true once GL Ethiopia created the shipment booking. */
bookingCreated: boolean;
/** true when the created booking EXPIRED unpaid — GL must rebook. */
paymentExpired: boolean;
/** The expired booking, so rebook can copy its cargo. */
expiredBookingId: string | null;
}
function yardLabel(
@@ -145,6 +149,13 @@ function toClearanceRow(contract: Freight.IContract): ClearanceRow {
status: contract.status,
ready: contract.status === "CLEARANCE_READY_FOR_BOOKING",
bookingCreated: contract.status === "ACTIVE_SHIPMENT_IN_PROGRESS",
paymentExpired:
contract.status === "ACTIVE_SHIPMENT_IN_PROGRESS" &&
contract.latestCycleBookingStatus === "EXPIRED",
expiredBookingId:
contract.latestCycleBookingStatus === "EXPIRED"
? (contract.latestCycleBookingId ?? null)
: null,
};
}
@@ -186,6 +197,24 @@ function DirectionIcon({ direction }: { direction: string }) {
}
function StatusBadge({ row }: { row: ClearanceRow }) {
if (row.paymentExpired) {
return (
<Tooltip
label="The customer did not pay in time — the booking expired. GL rebooks on the customer's behalf."
withArrow
>
<Badge
size="sm"
variant="light"
color="orange"
radius="sm"
leftSection={<RefreshCw size={12} />}
>
Payment expired
</Badge>
</Tooltip>
);
}
if (row.bookingCreated) {
return (
<Tooltip label="GL Ethiopia created the shipment booking" withArrow>
@@ -519,6 +548,27 @@ export default function ContractClearanceListPage() {
Create booking
</Button>
</Group>
) : row.original.paymentExpired && canCreateBooking ? (
<Group justify="flex-end" pr="xs">
<Button
size="compact-sm"
color="grape"
radius="md"
leftSection={<RefreshCw size={14} />}
onClick={(e) => {
e.stopPropagation();
navigate(
`/dashboard/contracts/${row.original.id}/create-booking${
row.original.expiredBookingId
? `?copyFrom=${row.original.expiredBookingId}`
: ""
}`,
);
}}
>
Rebook
</Button>
</Group>
) : (
<Group justify="flex-end" pr="xs">
<ChevronRight size={16} className="text-muted-foreground" />

View File

@@ -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>
);
}

View File

@@ -208,6 +208,20 @@ const billingIssues = (r: FirstMileRecord) => {
];
return { zeroPrice, mixedCurrency: currencies.length > 1, currencies };
};
/**
* The mile bills as distance × pricePerKm in the vehicle's own currency, so a
* vehicle missing either field cannot produce an invoice line. Returns the
* human-readable gap, or null when the vehicle is billable.
*/
const pricingGap = (
v?: { pricePerKm?: number | string | null; currency?: string | null } | null,
): string | null => {
if (!v) return null;
const missing: string[] = [];
if (!(Number(v.pricePerKm) > 0)) missing.push("Price per KM");
if (!String(v.currency ?? "").trim()) missing.push("Currency");
return missing.length ? missing.join(" and ") : null;
};
const customerName = (r: FirstMileRecord) => r.booking?.company?.name ?? "—";
const pickupLocation = (r: FirstMileRecord) => r.booking?.firstMilePickupAddress ?? "—";
const cargoDesc = (r: FirstMileRecord) => {
@@ -681,6 +695,22 @@ const FirstMilePage = () => {
return opts;
}, [vehicleOptions, activeRecord]);
// Pricing gap per vehicle id — an unpriced vehicle is blocked from assignment
// below rather than silently billing 0 once distances are entered.
const pricingGapById = useMemo(() => {
const map = new Map<string, string | null>();
const add = (v?: { id: string; pricePerKm?: number | string | null; currency?: string | null } | null) => {
if (v?.id) map.set(v.id, pricingGap(v));
};
for (const v of Array.isArray(vehiclesData) ? vehiclesData : []) add(v);
for (const a of activeRecord?.vehicleAssignments ?? []) add(a.vehicle);
add(activeRecord?.vehicle);
return map;
}, [vehiclesData, activeRecord]);
const vehicleLabelFor = (id: string) =>
assignVehicleOptions.find((o) => o.value === id)?.label ?? id;
// Full booking (with container units) for the assign modal's container dropdown.
// Fetched on open so container numbers show regardless of what the list embeds.
const { data: assignBooking } = useQuery({
@@ -932,6 +962,21 @@ const FirstMilePage = () => {
if (!targetIds.length) return;
// Backstop for rows the Select guard never saw (pre-filled reassignments).
const unpriced = vehicles
.map((v) => ({ label: vehicleLabelFor(v.vehicleId), gap: pricingGapById.get(v.vehicleId) }))
.filter((v): v is { label: string; gap: string } => Boolean(v.gap));
if (unpriced.length) {
toast({
title: "Vehicle is not priced",
description: `${unpriced
.map((v) => `${v.label} (${v.gap} not set)`)
.join("; ")} — set it on the vehicle before assigning.`,
variant: "destructive",
});
return;
}
// Empty set = unassign all (setVehicles releases the removed vehicles).
Promise.all(targetIds.map((id) => setVehiclesMutation.mutateAsync({ id, vehicles })))
.then(() => {
@@ -1365,9 +1410,18 @@ const FirstMilePage = () => {
(o) => o.value === row.vehicleId || !vehicleRows.some((r) => r.vehicleId === o.value),
)}
value={row.vehicleId}
onChange={(v) =>
setVehicleRows((prev) => prev.map((x, idx) => (idx === i ? { ...x, vehicleId: v } : x)))
}
onChange={(v) => {
const gap = v ? pricingGapById.get(v) : null;
if (v && gap) {
toast({
title: "Vehicle is not priced",
description: `${vehicleLabelFor(v)}${gap} not set. Set it on the vehicle before assigning.`,
variant: "destructive",
});
return;
}
setVehicleRows((prev) => prev.map((x, idx) => (idx === i ? { ...x, vehicleId: v } : x)));
}}
searchable
clearable
disabled={assignVehicleOptions.length === 0}

View File

@@ -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
@@ -251,6 +265,20 @@ const billingIssues = (r: LastMileRecord) => {
];
return { zeroPrice, mixedCurrency: currencies.length > 1, currencies };
};
/**
* The mile bills as distance × pricePerKm in the vehicle's own currency, so a
* vehicle missing either field cannot produce an invoice line. Returns the
* human-readable gap, or null when the vehicle is billable.
*/
const pricingGap = (
v?: { pricePerKm?: number | string | null; currency?: string | null } | null,
): string | null => {
if (!v) return null;
const missing: string[] = [];
if (!(Number(v.pricePerKm) > 0)) missing.push("Price per KM");
if (!String(v.currency ?? "").trim()) missing.push("Currency");
return missing.length ? missing.join(" and ") : null;
};
const customerName = (r: LastMileRecord) => r.booking?.company?.name ?? "—";
const deliveryLocation = (r: LastMileRecord) => r.booking?.lastMileDeliveryAddress ?? "—";
const cargoDesc = (r: LastMileRecord) => {
@@ -567,9 +595,11 @@ const LastMilePage = () => {
const [activeId, setActiveId] = useState<string | null>(null);
const [detentionRecord, setDetentionRecord] = useState<LastMileRecord | null>(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);
@@ -870,6 +900,22 @@ const LastMilePage = () => {
return opts;
}, [vehicleOptions, activeRecord]);
// Pricing gap per vehicle id — an unpriced vehicle is blocked from assignment
// below rather than silently billing 0 once distances are entered.
const pricingGapById = useMemo(() => {
const map = new Map<string, string | null>();
const add = (v?: { id: string; pricePerKm?: number | string | null; currency?: string | null } | null) => {
if (v?.id) map.set(v.id, pricingGap(v));
};
for (const v of Array.isArray(vehiclesData) ? vehiclesData : []) add(v);
for (const a of activeRecord?.vehicleAssignments ?? []) add(a.vehicle);
add(activeRecord?.vehicle);
return map;
}, [vehiclesData, activeRecord]);
const vehicleLabelFor = (id: string) =>
assignVehicleOptions.find((o) => o.value === id)?.label ?? id;
// Full booking (with container units) for the assign modal's container dropdown.
// Fetched on open so container numbers show regardless of what the list embeds.
const { data: assignBooking } = useQuery({
@@ -970,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<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 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);
};
@@ -1002,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<string>();
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
@@ -1018,6 +1095,21 @@ const LastMilePage = () => {
if (!targetIds.length) return;
// Backstop for rows the Select guard never saw (pre-filled reassignments).
const unpriced = vehicles
.map((v) => ({ label: vehicleLabelFor(v.vehicleId), gap: pricingGapById.get(v.vehicleId) }))
.filter((v): v is { label: string; gap: string } => Boolean(v.gap));
if (unpriced.length) {
toast({
title: "Vehicle is not priced",
description: `${unpriced
.map((v) => `${v.label} (${v.gap} not set)`)
.join("; ")} — set it on the vehicle before assigning.`,
variant: "destructive",
});
return;
}
// Empty set = unassign all (setVehicles releases the removed vehicles).
Promise.all(targetIds.map((id) => setVehiclesMutation.mutateAsync({ id, vehicles })))
.then(() => {
@@ -1374,6 +1466,16 @@ const LastMilePage = () => {
>
Truck Leaving
</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.Item
leftSection={<Eye size={15} />}
@@ -1693,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 (
<Alert variant="light" color="gray" title="One truck (with trailer) carries 2 containers">
No container count on this booking assign trucks as needed.
<Alert
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>
);
}
@@ -1738,32 +1855,43 @@ const LastMilePage = () => {
(o) => o.value === row.vehicleId || !vehicleRows.some((r) => r.vehicleId === o.value),
)}
value={row.vehicleId}
onChange={(v) =>
setVehicleRows((prev) => prev.map((x, idx) => (idx === i ? { ...x, vehicleId: v } : x)))
}
onChange={(v) => {
const gap = v ? pricingGapById.get(v) : null;
if (v && gap) {
toast({
title: "Vehicle is not priced",
description: `${vehicleLabelFor(v)}${gap} not set. Set it on the vehicle before assigning.`,
variant: "destructive",
});
return;
}
setVehicleRows((prev) => prev.map((x, idx) => (idx === i ? { ...x, vehicleId: v } : x)));
}}
searchable
clearable
disabled={assignVehicleOptions.length === 0}
/>
<Select
<MultiSelect
style={{ flex: 1 }}
label={i === 0 ? "Container no." : undefined}
placeholder={containerOptions.length ? "Select container" : "No container numbers"}
label={i === 0 ? "Containers (1x40ft or 2x20ft)" : undefined}
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={[
...containerOptions.filter(
(n) =>
n === row.containerNumber ||
!vehicleRows.some((r, idx) => idx !== i && r.containerNumber === n),
row.containerNumbers.includes(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
...(row.containerNumber && !containerOptions.includes(row.containerNumber)
? [row.containerNumber]
: []),
// keep manual/legacy values selectable even if not in the booking
...row.containerNumbers.filter((n) => !containerOptions.includes(n)),
]}
value={row.containerNumber || null}
value={row.containerNumbers}
onChange={(value) =>
setVehicleRows((prev) =>
prev.map((x, idx) => (idx === i ? { ...x, containerNumber: value ?? "" } : x)),
prev.map((x, idx) => (idx === i ? { ...x, containerNumbers: value } : x)),
)
}
searchable
@@ -1786,14 +1914,14 @@ const LastMilePage = () => {
size="xs"
leftSection={<Plus size={14} />}
onClick={() =>
setVehicleRows((prev) => [
...prev,
{
vehicleId: null,
containerNumber:
(activeRecord ? bookingContainerNumbers(activeRecord)[prev.length] : "") ?? "",
},
])
setVehicleRows((prev) => {
// Suggest the next unassigned container for the new truck.
const taken = new Set(prev.flatMap((r) => r.containerNumbers));
const next = (activeRecord ? bookingContainerNumbers(activeRecord) : []).find(
(n) => !taken.has(n),
);
return [...prev, { vehicleId: null, containerNumbers: next ? [next] : [] }];
})
}
disabled={
assignVehicleOptions.length === 0 ||
@@ -2087,6 +2215,12 @@ const LastMilePage = () => {
truckPrefill={releaseTruckPrefill}
/>
<EdrTruckExitPapersModal
opened={exitPapersOpen}
onClose={() => setExitPapersOpen(false)}
record={exitPapersRecord}
/>
<TruckDetentionModal
opened={Boolean(detentionRecord)}
onClose={() => setDetentionRecord(null)}

View File

@@ -31,8 +31,6 @@ import { ruleEngineTable } from "@/components/ruleEngine/ruleEngineStyles";
import BuildTrainModal from "@/components/trainBuilder/BuildTrainModal";
import EditTrainDetailsModal from "@/components/trainBuilder/EditTrainDetailsModal";
import {
directionColor,
directionRowStyle,
trainStatusColor,
trainStatusLabel,
} from "@/components/trainBuilder/trainStatus";
@@ -164,14 +162,9 @@ export default function TrainBuilderListPage() {
return (
<Stack gap={2}>
{active?.trainNumber ? (
<Group gap={6} wrap="nowrap">
<Text size="sm" fw={700} ff="monospace" lh={1.2}>
{active.trainNumber}
</Text>
<Badge size="xs" variant="light" color={directionColor(active.direction)}>
{active.direction ?? "—"}
</Badge>
</Group>
<Text size="sm" fw={700} ff="monospace" lh={1.2}>
{active.trainNumber}
</Text>
) : null}
<Text size="xs" c="dimmed" ff="monospace" lh={1.2}>
IMP {row.original.importTrainNumber ?? "—"} · EXP{" "}
@@ -348,7 +341,6 @@ export default function TrainBuilderListPage() {
data={trains}
status={tableStatus}
onRowClick={(train) => navigate(`/dashboard/train-builder/${train.id}`)}
rowStyle={(train) => directionRowStyle(train.activeSchedule?.direction)}
error={
trainsQuery.isError
? {

View File

@@ -1,6 +1,7 @@
import type { ColumnDef } from "@edr/ui-common";
import {
ActionIcon,
Badge,
Box,
Button,
Card,
@@ -38,6 +39,10 @@ import { KpiStrip, PageContainer, PageHeader } from "@/components/page";
import RuleEngineListFooter from "@/components/ruleEngine/RuleEngineListFooter";
import { ruleEngineTable } from "@/components/ruleEngine/ruleEngineStyles";
import { FreightTypeBadge } from "@/components/trainScheduling/ScheduleStatusBadge";
import {
directionColor,
directionRowStyle,
} from "@/components/trainBuilder/trainStatus";
import BookingWindowSettingsModal from "@/components/trainScheduling/BookingWindowSettingsModal";
import EditScheduleDateModal from "@/components/trainScheduling/EditScheduleDateModal";
import { showScheduleWarnings } from "@/components/trainScheduling/locomotiveOptions";
@@ -303,9 +308,20 @@ export default function TrainScheduleV2ListPage() {
meta: { headerClassName, cellClassName },
cell: ({ row }) => (
<Stack gap={4}>
<Text size="sm" fw={600} lh={1.2}>
{row.original.routeName ?? "—"}
</Text>
<Group gap={6} wrap="nowrap">
<Text size="sm" fw={600} lh={1.2}>
{row.original.routeName ?? "—"}
</Text>
{row.original.direction ? (
<Badge
size="xs"
variant="light"
color={directionColor(row.original.direction)}
>
{row.original.direction}
</Badge>
) : null}
</Group>
<Box maw={220}>
<RouteCorridor
origin={row.original.origin}
@@ -660,6 +676,7 @@ export default function TrainScheduleV2ListPage() {
onRowClick={(schedule) =>
navigate(`/dashboard/operations/train-scheduling-v2/${schedule.id}`)
}
rowStyle={(schedule) => directionRowStyle(schedule.direction)}
error={
schedulesQuery.isError
? {
@@ -909,7 +926,18 @@ function ScheduleCard({
</Box>
<Group justify="space-between" align="center">
<FreightTypeBadge freightType={schedule.freightType} />
<Group gap={6} wrap="nowrap">
<FreightTypeBadge freightType={schedule.freightType} />
{schedule.direction ? (
<Badge
size="xs"
variant="light"
color={directionColor(schedule.direction)}
>
{schedule.direction}
</Badge>
) : null}
</Group>
<Group gap={6} wrap="nowrap">
<MetricChip value={schedule.bookingsCount} label="bkg" />
<MetricChip value={schedule.wagonCount} label="wgn" />

View File

@@ -67,8 +67,16 @@ export interface LastMileRecord {
vehicleAssignments?: Array<{
id: string;
vehicleId: string;
/** @deprecated Legacy single container — `containers` is authoritative. */
containerNumber?: string | null;
/** Containers riding this truck: one 40ft, or up to two 20ft. */
containers?: Array<{ id: string; containerNumber: string }>;
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;
}>;
/** 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)),
setVehicles: (
id: string,
vehicles: Array<{ vehicleId: string; containerNumber?: string | null }>,
vehicles: Array<{ vehicleId: string; containerNumbers?: string[] }>,
) => 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: (
id: string,
distances: Array<{ vehicleId: string; distanceKm: number }>,

View File

@@ -323,6 +323,11 @@ export const warehouseService = {
apiClient.get<Blob>(`/warehouse-inventory/customer-truck-exit-paper/${assignmentId}`, {
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) =>
apiClient.post<WarehouseInventoryItem>(URL_CONSTANTS.WAREHOUSE_INVENTORY.DELIVER(id), payload),

View File

@@ -168,6 +168,8 @@ export interface TrainScheduleListItem {
createdAt?: string | null;
scheduleDate: string;
trainNumber?: string | null;
/** Trade direction of this departure (IMPORT / EXPORT), when known. */
direction?: string | null;
routeName?: string | null;
origin: string | null;
destination: string | null;
@@ -608,6 +610,11 @@ export interface TrainScheduleDetail {
name: string;
} | null;
allocations: TrainScheduleWagonAllocation[];
/**
* Coupled-but-empty wagon of the built train — no TrainSetWagon slot
* behind it, so remove/edit actions do not apply.
*/
consistOnly?: boolean;
}>;
} | null;
bookings: Array<{

View File

@@ -21,6 +21,7 @@ import {
Textarea,
ThemeIcon,
Title,
Tooltip,
} from "@mantine/core";
import {
AlertCircle,
@@ -269,6 +270,19 @@ function NewShipmentBookingForm({
mode: "onChange",
});
// 20ft containers ride two per wagon, so an odd total leaves one unpaired and
// the booking can never be planned. The server's shipment validation reports
// it too, but only once the price modal opens — block it inline instead, the
// same way the direct-booking wizard does (new-booking-form `calcWagons`).
const watchedContainers = form.watch("containers");
const ft20Total =
contract.freightType === "CONTAINER"
? (watchedContainers ?? [])
.filter((l) => l.containerSize === "20ft")
.reduce((sum, l) => sum + Number(l.quantity || 0), 0)
: 0;
const hasOdd20ft = ft20Total % 2 === 1;
const submitMutation = useMutation({
mutationFn: (dto: Freight.CreateBookingUnderContractDto) =>
completeBookingId
@@ -367,6 +381,8 @@ function NewShipmentBookingForm({
// run it for every freight type; container contracts additionally get
// overweight warnings + 20ft pairing hard-blocks surfaced in the modal.
const handleReview = form.handleSubmit((values) => {
// An unpaired 20ft can never be planned onto a wagon — don't even price it.
if (hasOdd20ft) return;
setPendingValues(values);
validateMutation.reset();
validateMutation.mutate(buildDto(values));
@@ -483,15 +499,26 @@ function NewShipmentBookingForm({
}}
>
<Group justify="flex-end" className="mx-auto max-w-4xl">
<Button
type="button"
color="edr-green"
radius="md"
leftSection={<Receipt size={16} />}
onClick={handleReview}
<Tooltip
label={`Book an even number of 20ft containers — ${ft20Total} is odd and would leave one unpaired.`}
withArrow
disabled={!hasOdd20ft}
>
Review price &amp; book
</Button>
{/* Mantine tooltips get no pointer events from a disabled button,
so the wrapper carries the hover target. */}
<Box>
<Button
type="button"
color="edr-green"
radius="md"
leftSection={<Receipt size={16} />}
onClick={handleReview}
disabled={hasOdd20ft}
>
Review price &amp; book
</Button>
</Box>
</Tooltip>
</Group>
</Box>
</form>
@@ -1265,6 +1292,28 @@ function CargoStep({
This contract has no container sizes in scope.
</Text>
)}
{(() => {
const ft20 = lines
.filter((l) => l.containerSize === "20ft")
.reduce((sum, l) => sum + Number(l.quantity || 0), 0);
if (ft20 % 2 !== 1) return null;
return (
<Alert
color="red"
variant="light"
radius="md"
icon={<AlertCircle size={16} />}
title={`Odd number of 20ft containers (${ft20})`}
>
<Text fz={13}>
20ft containers travel two per wagon, so they must be booked in
even numbers. Please add one more 20ft container or remove one
(e.g. book {ft20 + 1} or {ft20 - 1} instead of {ft20}) the
booking cannot be submitted with an unpaired 20ft container.
</Text>
</Alert>
);
})()}
</Stack>
</StepCard>
);

View File

@@ -2,6 +2,7 @@ import { useState } from "react";
import { useNavigate, useParams } from "react-router-dom";
import { useMutation, useQuery } from "@tanstack/react-query";
import {
Alert,
Box,
Button,
Group,
@@ -13,7 +14,7 @@ import {
Textarea,
Title,
} from "@mantine/core";
import { ArrowLeft, CalendarDays, Send } from "lucide-react";
import { AlertCircle, ArrowLeft, CalendarDays, Send } from "lucide-react";
import toast from "react-hot-toast";
import type { Freight } from "@edr/types";
import { DatePickerInput } from "@mantine/dates";
@@ -98,7 +99,14 @@ export default function NewShipmentRequestPage() {
contract.cargoScope?.[0];
const isPerItem = bulkScope?.cargoType?.unitOfMeasure === "PER_ITEM";
// 20ft containers ride two per wagon, so an odd total can never be planned —
// and GL's create-booking form blocks it too, so an odd request would only
// dead-end there. Same even-number rule the booking forms apply.
const ft20Requested = isContainer ? Number(qtyBySize["20ft"]) || 0 : 0;
const hasOdd20ft = ft20Requested % 2 === 1;
const handleSubmit = () => {
if (hasOdd20ft) return;
const dto: Freight.CreateBookingRequestDto = {
contractRouteId: route?.id,
scheduledDate: hasCustoms ? undefined : scheduledDate || undefined,
@@ -202,6 +210,23 @@ export default function NewShipmentRequestPage() {
/>
)}
{hasOdd20ft ? (
<Alert
color="red"
variant="light"
radius="md"
icon={<AlertCircle size={16} />}
title={`Odd number of 20ft containers (${ft20Requested})`}
>
<Text fz={13}>
20ft containers travel two per wagon, so they must be requested
in even numbers. Please add one more 20ft container or remove
one (e.g. request {ft20Requested + 1} or {ft20Requested - 1}{" "}
instead of {ft20Requested}).
</Text>
</Alert>
) : null}
{capacity?.length ? (
<Text size="xs" c="dimmed">
Remaining capacity is shown on the contract GL will validate your request.
@@ -220,6 +245,7 @@ export default function NewShipmentRequestPage() {
leftSection={<Send size={16} />}
loading={submit.isPending}
onClick={handleSubmit}
disabled={hasOdd20ft}
>
Submit shipment request
</Button>