mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-30 11:08:12 +00:00
Merge branch 'dev' into freight/feat/fixes-v1
This commit is contained in:
@@ -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 & 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 & book
|
||||
</Button>
|
||||
</Box>
|
||||
</Tooltip>
|
||||
</Group>
|
||||
</Box>
|
||||
</Box>
|
||||
|
||||
@@ -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(() => {
|
||||
|
||||
@@ -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}>
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -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'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"
|
||||
|
||||
@@ -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" />
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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}
|
||||
|
||||
@@ -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)}
|
||||
|
||||
@@ -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
|
||||
? {
|
||||
|
||||
@@ -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" />
|
||||
|
||||
@@ -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 }>,
|
||||
|
||||
@@ -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),
|
||||
|
||||
|
||||
@@ -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<{
|
||||
|
||||
@@ -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 & 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 & 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>
|
||||
);
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user