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

This commit is contained in:
Nathnael
2026-07-09 08:51:50 +00:00
181 changed files with 7344 additions and 1613 deletions

View File

@@ -53,11 +53,11 @@ import ContractClearanceListPage from "./pages/contracts/ContractClearanceListPa
import ContractClearanceDetailPage from "./pages/contracts/ContractClearanceDetailPage";
import GlDjiboutiClearanceListPage from "./pages/contracts/GlDjiboutiClearanceListPage";
import GlClearanceDetailPage from "./pages/contracts/GlClearanceDetailPage";
// Hidden for now — Shipment Requests pages disabled (imports kept commented).
// import ShipmentRequestsPage from "./pages/contracts/ShipmentRequestsPage";
// import ShipmentRequestDetailPage from "./pages/contracts/ShipmentRequestDetailPage";
import ShipmentRequestsPage from "./pages/contracts/ShipmentRequestsPage";
import ShipmentRequestDetailPage from "./pages/contracts/ShipmentRequestDetailPage";
import GlCreateBookingForm from "./components/contracts/GlCreateBookingForm";
import DocumentClearanceDetailPage from "./pages/bookings/DocumentClearanceDetailPage";
import DocumentClearanceListPage from "./pages/bookings/DocumentClearanceListPage";
import CustomerDetailPage from "./pages/customers/CustomerDetailPage";
import CustomersPage from "./pages/customers/CustomersPage";
import InvoiceDetailPage from "./pages/invoices/InvoiceDetailPage";
@@ -187,13 +187,18 @@ const buildSidebarSections = (demoItems: SidebarItem[]): SidebarSection[] => [
FREIGHT_PERMS.contracts.clearanceEtActions,
],
},
// Hidden for now — Shipment Requests nav item disabled.
// {
// label: "Shipment Requests",
// href: "/dashboard/shipment-requests",
// icon: <Send />,
// permission: FREIGHT_PERMS.contracts.createBooking,
// },
{
label: "Shipment Requests",
href: "/dashboard/shipment-requests",
icon: <Send />,
permission: FREIGHT_PERMS.contracts.createBooking,
},
{
label: "Self-Clearance Review",
href: "/dashboard/contracts/ops-clearance",
icon: <ShieldCheck />,
permission: FREIGHT_PERMS.contracts.opsClearanceReview,
},
{
label: "GL Djibouti Clearance",
href: "/dashboard/gl-djibouti/clearance",
@@ -752,7 +757,6 @@ const App = () => {
</RequirePermission>
}
/>
{/* Hidden for now — Shipment Requests pages disabled.
<Route
path="shipment-requests"
element={
@@ -773,7 +777,6 @@ const App = () => {
</RequirePermission>
}
/>
*/}
{/* GL (Path B) contract clearance review hub */}
<Route
path="contracts/clearance"
@@ -829,10 +832,17 @@ const App = () => {
</RequirePermission>
}
/>
{/* Path A ops queue out of scope for now → fold into the GL hub. */}
{/* Path A — Operations reviews per-booking self-clearance documents
(GENERAL contracts without customs). */}
<Route
path="contracts/ops-clearance"
element={<Navigate to="/dashboard/contracts/clearance" replace />}
element={
<RequirePermission
permission={FREIGHT_PERMS.contracts.opsClearanceReview}
>
<DocumentClearanceListPage opsMode />
</RequirePermission>
}
/>
<Route
path="contracts/:id/create-booking"

View File

@@ -47,6 +47,12 @@ export interface ClearanceReviewSectionProps {
queriesLocked?: boolean;
/** Read-only audit view — no approve/query actions. */
readOnly?: boolean;
/**
* GENERAL customs bookings use the phased milestone workflow (same as
* ONE_TIME contracts): hide the legacy output-documents upload block and the
* finalize button — declaration/duty/transit run in the phased action panel.
*/
phasedCustoms?: boolean;
}
const STATUS_META: Record<
@@ -73,6 +79,7 @@ export function ClearanceReviewSection({
approvalsLocked = false,
queriesLocked = false,
readOnly = false,
phasedCustoms = false,
}: ClearanceReviewSectionProps) {
const qc = useQueryClient();
const [queryNotes, setQueryNotes] = useState<Record<string, string>>({});
@@ -240,7 +247,7 @@ export function ClearanceReviewSection({
</Stack>
</SectionCard>
{clearance.outputCode && (
{clearance.outputCode && !phasedCustoms && (
<SectionCard
icon={Upload}
title="Customs output documents"
@@ -341,7 +348,7 @@ export function ClearanceReviewSection({
</SectionCard>
)}
{finalizeMutation.isError && (
{!phasedCustoms && finalizeMutation.isError && (
<Alert color="red" radius="md" icon={<AlertCircle size={16} />}>
{finalizeMutation.error instanceof Error
? finalizeMutation.error.message
@@ -349,8 +356,10 @@ export function ClearanceReviewSection({
</Alert>
)}
<Paper withBorder radius="md" p="md">
<Group justify="space-between" wrap="nowrap">
{phasedCustoms ? (
// Phased (GENERAL customs) — no legacy finalize; the milestone steps in
// the action panel drive the workflow, same as ONE_TIME contracts.
<Paper withBorder radius="md" p="md">
<Group gap={8} wrap="nowrap" style={{ minWidth: 0 }}>
<ThemeIcon
variant="light"
@@ -358,26 +367,50 @@ export function ClearanceReviewSection({
radius="md"
size={28}
>
<FileCheck2 size={15} />
{clearance.allApproved ? (
<CheckCircle2 size={15} />
) : (
<FileCheck2 size={15} />
)}
</ThemeIcon>
<Text fz="12.5px" c="dimmed">
{clearance.allApproved
? "All required documents are approved — you can finalize."
: "Approve every required document to unlock finalization."}
? "All required documents are approved. Continue declaration, duty, and transit in the action panel."
: "Approve every required document to unlock the customs milestone steps."}
</Text>
</Group>
<Button
color="edr-green"
radius="md"
leftSection={<CheckCircle2 size={16} />}
disabled={!clearance.allApproved}
loading={finalizeMutation.isPending}
onClick={() => finalizeMutation.mutate()}
>
Finalize clearance
</Button>
</Group>
</Paper>
</Paper>
) : (
<Paper withBorder radius="md" p="md">
<Group justify="space-between" wrap="nowrap">
<Group gap={8} wrap="nowrap" style={{ minWidth: 0 }}>
<ThemeIcon
variant="light"
color={clearance.allApproved ? "edr-green" : "gray"}
radius="md"
size={28}
>
<FileCheck2 size={15} />
</ThemeIcon>
<Text fz="12.5px" c="dimmed">
{clearance.allApproved
? "All required documents are approved — you can finalize."
: "Approve every required document to unlock finalization."}
</Text>
</Group>
<Button
color="edr-green"
radius="md"
leftSection={<CheckCircle2 size={16} />}
disabled={!clearance.allApproved}
loading={finalizeMutation.isPending}
onClick={() => finalizeMutation.mutate()}
>
Finalize clearance
</Button>
</Group>
</Paper>
)}
{viewer}
</Stack>
);

View File

@@ -50,6 +50,13 @@ import {
import { contractsService } from "@/services/contracts.service";
import { bookingsService } from "@/services/bookings.service";
/** Today as `yyyy-MM-dd` in the browser's local zone (a DateInput `minDate`). */
function todayISODate(): string {
const now = new Date();
const tz = now.getTimezoneOffset() * 60000;
return new Date(now.getTime() - tz).toISOString().slice(0, 10);
}
/**
* Export customs flow, ordered per the stakeholder process:
* customer docs → RO (DJ) → declaration (ET, auto-releases) → create booking (ET)
@@ -937,6 +944,7 @@ export function ReleaseOrderCard({
);
const [loading, setLoading] = useState(false);
const [amendLoading, setAmendLoading] = useState(false);
const minVesselDate = useMemo(todayISODate, []);
return (
<Paper withBorder radius="md" p="md">
@@ -949,6 +957,7 @@ export function ReleaseOrderCard({
label="Vessel departure date"
value={vesselDate}
onChange={(v) => setVesselDate(v ? new Date(v) : null)}
minDate={minVesselDate}
size="sm"
/>
<Group>

View File

@@ -1,4 +1,4 @@
import { useState } from "react";
import { useMemo, useState } from "react";
import { Button, Group, Modal, Stack, Text } from "@mantine/core";
import { DateInput } from "@mantine/dates";
import { Ship, Upload } from "lucide-react";
@@ -40,6 +40,13 @@ export function GlClearanceUploadModal({
vesselDepartureDate ? new Date(vesselDepartureDate) : null,
);
const [loading, setLoading] = useState(false);
// Earliest selectable vessel date (today, local) — refreshed on each open.
const todayISODate = useMemo(() => {
if (!opened) return undefined;
const now = new Date();
const tz = now.getTimezoneOffset() * 60000;
return new Date(now.getTime() - tz).toISOString().slice(0, 10);
}, [opened]);
const isDo = kind === "do";
const isRo = kind === "ro";
@@ -115,6 +122,7 @@ export function GlClearanceUploadModal({
label="Vessel departure date"
value={vesselDate}
onChange={(v) => setVesselDate(v ? new Date(v) : null)}
minDate={todayISODate}
size="sm"
required
/>
@@ -123,6 +131,7 @@ export function GlClearanceUploadModal({
label="Vessel arrival date (optional)"
value={vesselDate}
onChange={(v) => setVesselDate(v ? new Date(v) : null)}
minDate={todayISODate}
size="sm"
clearable
/>

View File

@@ -64,6 +64,21 @@ import {
/** All booking-window times are communicated in East Africa Time. */
const EAT_TZ = "Africa/Addis_Ababa";
// ISO 6346: 4-letter owner/category code + 6-digit serial + check digit.
// Same rule the customer portal shipment form enforces.
const ISO_CONTAINER_NUMBER_REGEX = /^[A-Z]{4}\d{7}$/;
interface UnitErrors {
containerNumber?: string;
vgmTons?: string;
}
interface BulkErrors {
quantity?: string;
hazardous?: string;
reefer?: string;
}
function fmtWindowOpensAt(iso: string): string {
const date = new Date(iso).toLocaleDateString("en-GB", {
weekday: "short",
@@ -372,6 +387,72 @@ export default function GlCreateBookingForm() {
prev.map((l, i) => (i === idx ? { ...l, ...patch } : l)),
);
// Same client-side validation as the customer portal shipment form: ISO
// container numbers (unique within the shipment) and a positive VGM per unit;
// bulk needs a positive quantity with hazardous/reefer portions bounded by it.
const [showErrors, setShowErrors] = useState(false);
const unitErrors = useMemo<UnitErrors[][]>(() => {
if (!isContainer) return [];
const numberCounts = new Map<string, number>();
containerLines.forEach((line) =>
line.units.forEach((u) => {
const key = u.containerNumber.trim().toUpperCase();
if (!key) return;
numberCounts.set(key, (numberCounts.get(key) ?? 0) + 1);
}),
);
return containerLines.map((line) =>
line.units.map((u) => {
const errs: UnitErrors = {};
const key = u.containerNumber.trim().toUpperCase();
if (!key) {
errs.containerNumber = "Container number is required.";
} else if (!ISO_CONTAINER_NUMBER_REGEX.test(key)) {
errs.containerNumber =
"Enter a valid ISO container number (e.g. ABCD1234567).";
} else if ((numberCounts.get(key) ?? 0) > 1) {
errs.containerNumber = "Duplicate container number in this shipment.";
}
const vgm = Number(u.vgmTons);
if (String(u.vgmTons).trim() === "" || Number.isNaN(vgm) || vgm <= 0) {
errs.vgmTons = "Enter a valid VGM.";
}
return errs;
}),
);
}, [isContainer, containerLines]);
const bulkErrors = useMemo<BulkErrors[]>(() => {
if (isContainer) return [];
return bulkLines.map((line) => {
const errs: BulkErrors = {};
const qty = Number(line.cargoWeightTons || line.itemCount || 0);
if (Number.isNaN(qty) || qty <= 0) {
errs.quantity = "Enter a quantity greater than 0.";
}
const h = Number(line.hazardousQuantity || 0);
if (Number.isNaN(h) || h < 0) {
errs.hazardous = "Enter a valid hazardous quantity.";
} else if (qty > 0 && h > qty) {
errs.hazardous = `Can't exceed the cargo quantity (${qty}).`;
}
const r = Number(line.reeferQuantity || 0);
if (Number.isNaN(r) || r < 0) {
errs.reefer = "Enter a valid refrigerated quantity.";
} else if (qty > 0 && r > qty) {
errs.reefer = `Can't exceed the cargo quantity (${qty}).`;
}
return errs;
});
}, [isContainer, bulkLines]);
const cargoValid = isContainer
? unitErrors.every((line) =>
line.every((e) => !e.containerNumber && !e.vgmTons),
)
: bulkErrors.every((e) => !e.quantity && !e.hazardous && !e.reefer);
const canSubmit =
windowOpen &&
Boolean(scheduledDate) &&
@@ -401,7 +482,7 @@ export default function GlCreateBookingForm() {
hazardousQuantity: l.units.filter((u) => u.hazardous).length,
reeferQuantity: l.units.filter((u) => u.reefer).length,
units: l.units.map((u) => ({
containerNumber: u.containerNumber,
containerNumber: u.containerNumber.trim().toUpperCase(),
...(u.sealNumber ? { sealNumber: u.sealNumber } : {}),
vgmTons: Number(u.vgmTons) || 0,
})),
@@ -458,6 +539,13 @@ export default function GlCreateBookingForm() {
const overweightLines = validation?.overweightLines ?? [];
const openPriceModal = () => {
// Surface the per-field errors (portal-parity validation) instead of
// sending an invalid payload to the price preview.
if (!cargoValid) {
setShowErrors(true);
return;
}
setShowErrors(false);
setPriceOpen(true);
const payload = buildPayload();
if (payload) {
@@ -467,7 +555,7 @@ export default function GlCreateBookingForm() {
};
const handleSubmit = () => {
if (!contract || !windowOpen) return;
if (!contract || !windowOpen || !cargoValid) return;
// Never book past unresolved 20ft pairing hard-blocks.
if (pairingErrors.length > 0) return;
// A line above the container type's max capacity can never book.
@@ -483,8 +571,13 @@ export default function GlCreateBookingForm() {
} catch {
// Non-fatal
}
navigate(`/dashboard/bookings/${booking.id}/clearance`);
}
if (contract.contractKind === "GENERAL") {
// GENERAL per-booking clearance: land on the booking's clearance
// detail — the same page the Shipments tab on the hub opens.
navigate(`/dashboard/clearance/${booking.id}`);
} else {
// ONE_TIME customs keeps its clearance on the contract.
navigate(`/dashboard/contracts/clearance/${contract.id}`);
}
},
@@ -692,6 +785,11 @@ export default function GlCreateBookingForm() {
label={unitIdx === 0 ? "Container number *" : undefined}
placeholder="e.g. MSCU1234567"
value={unit.containerNumber}
error={
showErrors
? unitErrors[lineIdx]?.[unitIdx]?.containerNumber
: undefined
}
onChange={(e) =>
patchUnit(lineIdx, unitIdx, {
containerNumber: e.currentTarget.value,
@@ -720,6 +818,11 @@ export default function GlCreateBookingForm() {
min={0}
decimalScale={2}
value={unit.vgmTons}
error={
showErrors
? unitErrors[lineIdx]?.[unitIdx]?.vgmTons
: undefined
}
onChange={(v) =>
patchUnit(lineIdx, unitIdx, { vgmTons: v })
}
@@ -808,6 +911,7 @@ export default function GlCreateBookingForm() {
min={0}
decimalScale={2}
value={line.cargoWeightTons}
error={showErrors ? bulkErrors[idx]?.quantity : undefined}
onChange={(v) => patchBulk(idx, { cargoWeightTons: v })}
radius={10}
styles={fieldStyles}
@@ -818,6 +922,7 @@ export default function GlCreateBookingForm() {
placeholder="e.g. 500"
min={0}
value={line.itemCount}
error={showErrors ? bulkErrors[idx]?.quantity : undefined}
onChange={(v) => patchBulk(idx, { itemCount: v })}
radius={10}
styles={fieldStyles}
@@ -828,6 +933,7 @@ export default function GlCreateBookingForm() {
label="Hazardous quantity"
min={0}
value={line.hazardousQuantity}
error={showErrors ? bulkErrors[idx]?.hazardous : undefined}
onChange={(v) => patchBulk(idx, { hazardousQuantity: v })}
radius={10}
styles={fieldStyles}
@@ -838,6 +944,7 @@ export default function GlCreateBookingForm() {
label="Refrigerated quantity"
min={0}
value={line.reeferQuantity}
error={showErrors ? bulkErrors[idx]?.reefer : undefined}
onChange={(v) => patchBulk(idx, { reeferQuantity: v })}
radius={10}
styles={fieldStyles}
@@ -905,26 +1012,39 @@ export default function GlCreateBookingForm() {
marginTop: 24,
}}
>
<Group justify="flex-end" maw={896} mx="auto">
<Button
variant="default"
radius="md"
onClick={() =>
navigate(`/dashboard/contracts/clearance/${contract.id}`)
}
>
Cancel
</Button>
<Button
color="edr-green"
radius="md"
leftSection={<Receipt size={16} />}
disabled={!canSubmit}
onClick={openPriceModal}
>
Review price &amp; book
</Button>
</Group>
<Box maw={896} mx="auto">
{showErrors && !cargoValid ? (
<Alert
color="red"
variant="light"
radius="md"
icon={<AlertCircle size={16} />}
mb="sm"
>
Fix the highlighted cargo fields before reviewing the price.
</Alert>
) : null}
<Group justify="flex-end">
<Button
variant="default"
radius="md"
onClick={() =>
navigate(`/dashboard/contracts/clearance/${contract.id}`)
}
>
Cancel
</Button>
<Button
color="edr-green"
radius="md"
leftSection={<Receipt size={16} />}
disabled={!canSubmit}
onClick={openPriceModal}
>
Review price &amp; book
</Button>
</Group>
</Box>
</Box>
<Modal

View File

@@ -99,6 +99,7 @@ function computeImportActiveStep(
bookingCreated: boolean,
bookingMilestones: MilestoneRow[],
t1Uploaded: boolean,
freightPaid: boolean,
): number {
if (!isMilestoneDone(clearance.milestones, "DOCUMENTS_APPROVED")) return 0;
if (!isMilestoneDone(clearance.milestones, "DECLARED")) return 1;
@@ -119,9 +120,12 @@ function computeImportActiveStep(
if (!clearance.preClearanceFinalized) return 5;
if (!isMilestoneDone(clearance.milestones, "DO_COLLECTED")) return 6;
if (!bookingCreated) return 7;
if (!clearance.gatepassGranted) return 8;
if (!t1Uploaded && !clearance.t1?.closed) return 9;
if (!clearance.t1?.closed) return 10;
// The customer pays the train/freight charges on the booking. Until that
// settles the gate pass is not granted for this booking, so the flow stops here.
if (!freightPaid) return 8;
if (!clearance.gatepassGranted) return 9;
if (!t1Uploaded && !clearance.t1?.closed) return 10;
if (!clearance.t1?.closed) return 11;
// Risk is "assigned" when the booking milestone says so OR the clearance view
// already carries a riskLevel. The ET page derives its bookingMilestones from a
// separately-fetched booking id that can lag or mismatch the booking carrying
@@ -129,15 +133,15 @@ function computeImportActiveStep(
const riskAssigned =
Boolean(clearance.riskLevel) ||
isBookingMilestoneDone(bookingMilestones, "RISK_ASSIGNED");
if (!riskAssigned) return 11;
if (!riskAssigned) return 12;
// Additional duty round is optional — resolved once skipped or paid.
const secondDutyResolved =
clearance.secondDuty?.skipped ||
clearance.secondDuty?.paid ||
isBookingMilestoneDone(bookingMilestones, "SECOND_DUTY_PAID");
if (!secondDutyResolved) return 12;
if (!clearance.importReleaseGranted) return 13;
return 14;
if (!secondDutyResolved) return 13;
if (!clearance.importReleaseGranted) return 14;
return 15;
}
function t1FilesFromWorkflow(
@@ -243,6 +247,13 @@ export function PhasedClearanceActionPanel({
const riskAssigned =
Boolean(clearance.riskLevel) ||
isBookingMilestoneDone(bookingMilestones, "RISK_ASSIGNED");
// Freight (train + service) charges settled on the booking. The gate pass is
// only granted to a booking that has paid, so a granted gate pass is server
// proof of payment — it keeps the stepper moving on a page whose
// bookingMilestones have not loaded yet or point at a different booking.
const freightPaid =
isBookingMilestoneDone(bookingMilestones, "FREIGHT_PAYMENT_SETTLED") ||
Boolean(clearance.gatepassGranted);
const activeStep = useMemo(
() =>
isImport
@@ -251,9 +262,17 @@ export function PhasedClearanceActionPanel({
effectiveBookingCreated,
bookingMilestones,
t1Uploaded,
freightPaid,
)
: 0,
[clearance, isImport, effectiveBookingCreated, bookingMilestones, t1Uploaded],
[
clearance,
isImport,
effectiveBookingCreated,
bookingMilestones,
t1Uploaded,
freightPaid,
],
);
if (isImport) {
@@ -554,14 +573,26 @@ export function PhasedClearanceActionPanel({
)}
</Stepper.Step>
<Stepper.Step
label="Freight payment"
description="Customer pays the train and service charges"
icon={freightPaid ? <CheckCircle2 size={14} /> : <Receipt size={14} />}
>
<StepStatus
done={freightPaid}
pendingLabel="Waiting for the customer to pay the train and service charges. The gate pass is not granted until this settles."
doneLabel="Train and service charges settled."
/>
</Stepper.Step>
<Stepper.Step
label="Gate pass"
description="Secured on the train schedule after wagon allocation"
description="Secured on the train schedule after payment and wagon allocation"
icon={
clearance.gatepassGranted ? <CheckCircle2 size={14} /> : <Truck size={14} />
}
>
<ImportGatepassStep clearance={clearance} />
<ImportGatepassStep clearance={clearance} freightPaid={freightPaid} />
</Stepper.Step>
<Stepper.Step
@@ -927,8 +958,16 @@ function ImportT1CloseStep({
/**
* Gate pass status, read-only. Secured on the train schedule's "Save as
* Secured" action (train-scheduling-v2) — clearance no longer grants it directly.
* The train may be secured while this booking still owes freight charges; the
* booking only picks the gate pass up once its payment settles.
*/
function ImportGatepassStep({ clearance }: { clearance: ClearanceViewLike }) {
function ImportGatepassStep({
clearance,
freightPaid,
}: {
clearance: ClearanceViewLike;
freightPaid: boolean;
}) {
const scheduleId = clearance.train?.scheduleId ?? null;
if (clearance.gatepassGranted) {
@@ -943,6 +982,16 @@ function ImportGatepassStep({ clearance }: { clearance: ClearanceViewLike }) {
);
}
if (!freightPaid) {
return (
<StepStatus
done={false}
pendingLabel="Blocked — the customer must pay the train and service charges before the gate pass is granted for this shipment."
doneLabel=""
/>
);
}
const wagonAllocated = Boolean(clearance.train?.wagonAllocated);
return (
@@ -1015,6 +1064,18 @@ function RiskStep({
);
}
// Customs cannot rate cargo still under transit — the server rejects the
// assignment until the T1 is closed, so do not offer the control yet.
if (!clearance.t1?.closed) {
return (
<StepStatus
done={false}
pendingLabel="Available once the T1 is closed."
doneLabel=""
/>
);
}
if (!canAct || !bookingId) {
return (
<StepStatus

View File

@@ -307,13 +307,24 @@ const RuleEngineFormDialog = ({
);
}
const isNumber = field.type === "number";
return (
<TextInput
key={field.name}
label={label}
type={field.type === "number" ? "number" : field.type === "date" ? "date" : "text"}
description={field.description}
type={isNumber ? "number" : field.type === "date" ? "date" : "text"}
// Every rule-engine number (sizes, capacities, counts, points, rates,
// display order) is a non-negative magnitude — reject negatives outright
// rather than letting a typed "-" reach the API.
min={isNumber ? 0 : undefined}
value={String(values[field.name] ?? "")}
onChange={(e) => setField(field.name, e.currentTarget.value)}
onChange={(e) => {
const next = e.currentTarget.value;
if (isNumber && next.trim().startsWith("-")) return;
setField(field.name, next);
}}
placeholder={field.placeholder}
required={field.required}
size="md"

View File

@@ -392,6 +392,7 @@ export default function BookingWindowSettingsModal({
}
min={1}
clampBehavior="none"
allowNegative={false}
allowDecimal={false}
/>
) : (
@@ -410,6 +411,7 @@ export default function BookingWindowSettingsModal({
}
min={0}
clampBehavior="none"
allowNegative={false}
allowDecimal={false}
/>
)}

View File

@@ -98,6 +98,7 @@ export default function DurationField({
emitNative(v === "" ? "" : Number(v), unit)
}
clampBehavior="none"
allowNegative={false}
allowDecimal
min={min != null ? convert(min, nativeUnit, unit) : 0}
disabled={disabled}

View File

@@ -67,9 +67,13 @@ export default function EditScheduleDateModal({
);
const [value, setValue] = useState("");
// Earliest selectable departure, refreshed each time the modal opens.
const [minValue, setMinValue] = useState("");
useEffect(() => {
if (opened) setValue(toLocalInputValue(currentDate));
if (!opened) return;
setValue(toLocalInputValue(currentDate));
setMinValue(toLocalInputValue(new Date().toISOString()));
}, [opened, currentDate]);
const handleSave = async () => {
@@ -77,6 +81,13 @@ export default function EditScheduleDateModal({
toast({ title: "Pick a departure date", variant: "destructive" });
return;
}
if (new Date(value).getTime() < Date.now()) {
toast({
title: "Departure date must be in the future",
variant: "destructive",
});
return;
}
try {
await save.mutateAsync({
id: scheduleId,
@@ -124,6 +135,7 @@ export default function EditScheduleDateModal({
<TextInput
label="Departure date"
type="datetime-local"
min={minValue}
value={value}
onChange={(e) => setValue(e.currentTarget.value)}
/>

View File

@@ -0,0 +1,404 @@
import { useMemo } from "react";
import {
Alert,
Badge,
Box,
Group,
Paper,
Progress,
Stack,
Text,
ThemeIcon,
Tooltip,
} from "@mantine/core";
import {
Crown,
Container,
Boxes,
FlaskConical,
Layers,
Ruler,
Scale,
Sparkles,
TrainFront,
Trophy,
XCircle,
} from "lucide-react";
import type { BatchBoardScheduleDetail } from "@/types/trainScheduling";
import {
simulateBatch,
limitsFromDetail,
type BlockingAxis,
type ForecastRow,
} from "./batchForecast";
type Props = {
data: BatchBoardScheduleDetail;
bookings: BatchBoardScheduleDetail["pendingContract"]["bookings"];
};
const cardVar = (color: string, shade: number) =>
`var(--mantine-color-${color}-${shade})`;
const fmtTons = (n: number) =>
`${n.toLocaleString(undefined, { maximumFractionDigits: 1 })} t`;
const fmtMeters = (n: number) =>
`${n.toLocaleString(undefined, { maximumFractionDigits: 1 })} m`;
const AXIS_LABEL: Record<BlockingAxis, string> = {
wagons: "wagon slots full",
weight: "over max pull weight",
length: "over train length",
};
/** One capacity axis as a labelled meter (used vs cap). */
function AxisMeter({
icon: Icon,
label,
used,
cap,
fmt,
color,
}: {
icon: typeof Scale;
label: string;
used: number;
cap: number | null;
fmt: (n: number) => string;
color: string;
}) {
const pct = cap && cap > 0 ? Math.min(100, (used / cap) * 100) : 0;
const near = pct >= 90;
return (
<Box style={{ flex: 1, minWidth: 150 }}>
<Group justify="space-between" mb={4} wrap="nowrap">
<Group gap={5} wrap="nowrap">
<Icon size={13} color={cardVar(color, 6)} />
<Text size="xs" c="dimmed" fw={600}>
{label}
</Text>
</Group>
<Text size="xs" fw={700} c={near ? `${color}.8` : "dark.4"}>
{fmt(used)}
{cap != null ? ` / ${fmt(cap)}` : ""}
</Text>
</Group>
<Progress
value={pct}
size="md"
radius="xl"
color={near ? color : "edr-green"}
/>
</Box>
);
}
function FreightIcon({ type }: { type: string | null }) {
const Icon = type === "BULK" ? Boxes : Container;
return (
<Tooltip label={type === "BULK" ? "Bulk" : "Container"} withArrow>
<ThemeIcon size="sm" radius="sm" variant="light" color="gray">
<Icon size={13} />
</ThemeIcon>
</Tooltip>
);
}
/** A single forecast row: rank, booking, capacity contribution, projected verdict. */
function ForecastCard({ row }: { row: ForecastRow }) {
const { booking, rank, selected, blockedBy } = row;
const gov = booking.isGovernment;
return (
<Paper
radius="md"
p="sm"
withBorder
style={{
borderColor: selected
? cardVar("edr-green", 3)
: cardVar("gray", 2),
background: selected
? `linear-gradient(90deg, ${cardVar("edr-green", 0)} 0%, var(--mantine-color-white) 55%)`
: "var(--mantine-color-white)",
opacity: selected ? 1 : 0.92,
}}
>
<Group justify="space-between" wrap="nowrap" gap="sm">
<Group wrap="nowrap" gap="sm" style={{ minWidth: 0 }}>
<ThemeIcon
size={32}
radius="xl"
variant={selected && rank <= 3 ? "filled" : "light"}
color={gov ? "grape" : selected ? "edr-green" : "gray"}
style={{ flexShrink: 0, fontWeight: 800 }}
>
{gov ? (
<Crown size={15} />
) : (
<Text fw={800} size="sm">
{rank}
</Text>
)}
</ThemeIcon>
<Stack gap={2} style={{ minWidth: 0 }}>
<Group gap={6} wrap="nowrap">
<Text fw={700} size="sm" truncate>
{booking.reference}
</Text>
<FreightIcon type={booking.freightType} />
{gov ? (
<Tooltip label="Government — boards first" withArrow>
<ThemeIcon size="xs" radius="sm" variant="light" color="grape">
<Crown size={10} />
</ThemeIcon>
</Tooltip>
) : null}
</Group>
<Text size="xs" c="dimmed" truncate>
{booking.company}
</Text>
</Stack>
</Group>
<Group wrap="nowrap" gap="lg" style={{ flexShrink: 0 }}>
{/* score */}
<Group gap={4} wrap="nowrap" w={70} justify="flex-end">
<Trophy size={12} color={cardVar("edr-green", 6)} />
<Text fw={800} size="sm" c="edr-green.7">
{booking.priorityScore}
</Text>
</Group>
{/* wagons + weight this booking adds */}
<Group gap={4} wrap="nowrap" w={64} justify="flex-end">
<TrainFront size={13} color={cardVar("gray", 6)} />
<Text fw={700} size="sm">
{booking.wagons}w
</Text>
</Group>
<Text size="xs" c="dimmed" w={64} ta="right">
{fmtTons(booking.weightTons)}
</Text>
{/* verdict */}
<Box w={150} style={{ textAlign: "right" }}>
{selected ? (
<Badge
variant="light"
color="edr-green"
radius="sm"
leftSection={<Sparkles size={11} />}
>
Would board
</Badge>
) : (
<Tooltip
label={
blockedBy
? `Doesn't fit — ${AXIS_LABEL[blockedBy]}`
: "Below the capacity line"
}
withArrow
>
<Badge variant="light" color="gray" radius="sm">
Waiting list
</Badge>
</Tooltip>
)}
</Box>
</Group>
</Group>
</Paper>
);
}
/** Cut line between the simulated batch and the simulated waiting list. */
function CutLine({ full }: { full: boolean }) {
return (
<Group gap="xs" my={2} wrap="nowrap">
<Box style={{ flex: 1, height: 2, background: cardVar("orange", 3) }} />
<Group gap={6} wrap="nowrap">
<ThemeIcon size="sm" radius="xl" variant="light" color="orange">
<Layers size={12} />
</ThemeIcon>
<Text size="xs" fw={700} c="orange.7">
Forecast capacity line{full ? " · TRAIN FULL" : ""}
</Text>
</Group>
<Box style={{ flex: 1, height: 2, background: cardVar("orange", 3) }} />
</Group>
);
}
/**
* Forecast / "what-if" panel. Simulates the batch engine's greedy fill on the
* current pool and shows the projected winners + waiting list BEFORE document
* review closes. Not the real selection — the engine commits that when staff run
* the batch after the review window ends.
*/
export function ForecastPanel({ data, bookings }: Props) {
const limits = useMemo(() => limitsFromDetail(data), [data]);
const sim = useMemo(
() => simulateBatch(bookings, limits),
[bookings, limits],
);
const noCaps =
limits.maxWagons == null &&
limits.maxWeightTons == null &&
limits.maxLengthMeters == null;
return (
<Stack gap="lg">
{/* Header + explainer */}
<Paper radius="lg" withBorder p="lg">
<Group justify="space-between" wrap="wrap" gap="md" mb="md">
<Group gap="sm">
<ThemeIcon variant="light" color="violet" radius="md" size="lg">
<FlaskConical size={18} />
</ThemeIcon>
<Stack gap={2}>
<Group gap={8}>
<Text fw={700}>Forecast batch (simulated)</Text>
<Badge variant="light" color="violet" radius="sm" size="sm">
Preview
</Badge>
</Group>
<Text size="xs" c="dimmed" maw={520}>
What the batch engine would pick if it ran now greedy fill by
priority until the train is full. The real selection happens when
document review ends and staff run the batch.
</Text>
</Stack>
</Group>
<Group gap="lg">
<Stack gap={0} align="flex-end">
<Text size="xl" fw={800} c="edr-green.7">
{sim.selected.length}
</Text>
<Text size="xs" c="dimmed">
would board
</Text>
</Stack>
<Stack gap={0} align="flex-end">
<Text size="xl" fw={800} c="gray.7">
{sim.waiting.length}
</Text>
<Text size="xs" c="dimmed">
waiting list
</Text>
</Stack>
</Group>
</Group>
{/* Three capacity axes */}
<Group gap="lg" align="flex-end" wrap="wrap">
<AxisMeter
icon={TrainFront}
label="Wagon slots"
used={sim.usedWagons}
cap={limits.maxWagons}
fmt={(n) => `${n}`}
color="edr-green"
/>
<AxisMeter
icon={Scale}
label="Max pull weight"
used={sim.usedWeightTons}
cap={limits.maxWeightTons}
fmt={fmtTons}
color="orange"
/>
<AxisMeter
icon={Ruler}
label="Train length"
used={sim.usedLengthMeters}
cap={limits.maxLengthMeters}
fmt={fmtMeters}
color="blue"
/>
</Group>
{noCaps ? (
<Alert
color="yellow"
mt="md"
radius="md"
icon={<XCircle size={16} />}
>
No locomotive / capacity limits on this schedule yet forecast can't
draw the capacity line. Assign a locomotive to simulate the fill.
</Alert>
) : null}
</Paper>
{sim.rows.length === 0 ? (
<Paper radius="lg" withBorder p="xl">
<Text c="dimmed" ta="center">
No eligible bookings to forecast yet.
</Text>
</Paper>
) : (
<Stack gap={6}>
{/* WOULD BOARD */}
{sim.selected.length > 0 ? (
<Stack gap={6}>
<Group gap="xs">
<ThemeIcon
size="sm"
radius="sm"
variant="light"
color="edr-green"
>
<Sparkles size={13} />
</ThemeIcon>
<Text fw={700} size="sm">
Projected batch{" "}
<Text span c="dimmed" fw={500}>
({sim.selected.length}) top priority, fits capacity
</Text>
</Text>
</Group>
{sim.selected.map((r) => (
<ForecastCard key={r.booking.id} row={r} />
))}
</Stack>
) : null}
<CutLine full={sim.full} />
{/* WAITING LIST */}
{sim.waiting.length > 0 ? (
<Stack gap={6}>
<Group gap="xs">
<ThemeIcon size="sm" radius="sm" variant="light" color="gray">
<Layers size={13} />
</ThemeIcon>
<Text fw={700} size="sm">
Projected waiting list{" "}
<Text span c="dimmed" fw={500}>
({sim.waiting.length}) boards only if a slot frees up
</Text>
</Text>
</Group>
{sim.waiting.map((r) => (
<ForecastCard key={r.booking.id} row={r} />
))}
</Stack>
) : null}
{/* INELIGIBLE (expired / pending contract) */}
{sim.ineligible.length > 0 ? (
<Text size="xs" c="dimmed" mt={4}>
{sim.ineligible.length} booking
{sim.ineligible.length === 1 ? "" : "s"} not in the forecast
(expired or contract not signed).
</Text>
) : null}
</Stack>
)}
</Stack>
);
}
export default ForecastPanel;

View File

@@ -1,9 +1,10 @@
import { useMemo } from "react";
import { useMemo, useState } from "react";
import {
Box,
Group,
Paper,
Progress,
SegmentedControl,
Stack,
Text,
ThemeIcon,
@@ -15,9 +16,11 @@ import {
Clock,
Container,
Crown,
FlaskConical,
Hourglass,
Layers,
ListOrdered,
Radio,
TrainFront,
Trophy,
XCircle,
@@ -30,6 +33,8 @@ import type {
BatchBoardScheduleDetail,
} from "@/types/trainScheduling";
import { WindowPhasePill } from "./batchVisuals";
import { ForecastPanel } from "./ForecastPanel";
import { forecastIsLive } from "./batchForecast";
/**
* Priority Tracking tab — live, glanceable ranking of every booking on this
@@ -266,6 +271,15 @@ export function PriorityTrackingTab({ data, bookings }: Props) {
const phase = data.windowPhase;
const isPayPhase = phase === "PAYMENT";
// Before the batch is committed (pre-window / open / doc-review) the real
// selection doesn't exist yet — offer a simulated forecast of who WOULD board.
// Default to it while it's live; let staff flip to the current live state.
const forecastAvailable = forecastIsLive(phase);
const [view, setView] = useState<"forecast" | "live">(
forecastAvailable ? "forecast" : "live",
);
const showForecast = forecastAvailable && view === "forecast";
// Rank exactly as the batch engine does: government first, then priority score
// desc, then oldest (fullyExecutedAt / selectedForBatchAt as the tiebreak the
// backend uses). The board already returns them in this order, but re-sort
@@ -320,8 +334,51 @@ export function PriorityTrackingTab({ data, bookings }: Props) {
let rankNo = 0;
const viewToggle = forecastAvailable ? (
<SegmentedControl
value={view}
onChange={(v) => setView(v as "forecast" | "live")}
size="sm"
radius="md"
data={[
{
value: "forecast",
label: (
<Group gap={6} wrap="nowrap">
<FlaskConical size={13} />
<Text size="xs" fw={600}>
Forecast
</Text>
</Group>
),
},
{
value: "live",
label: (
<Group gap={6} wrap="nowrap">
<Radio size={13} />
<Text size="xs" fw={600}>
Live state
</Text>
</Group>
),
},
]}
/>
) : null;
if (showForecast) {
return (
<Stack gap="lg">
{viewToggle ? <Group justify="flex-end">{viewToggle}</Group> : null}
<ForecastPanel data={data} bookings={ranked} />
</Stack>
);
}
return (
<Stack gap="lg">
{viewToggle ? <Group justify="flex-end">{viewToggle}</Group> : null}
{/* Header: phase + capacity meter */}
<Paper radius="lg" withBorder p="lg">
<Group justify="space-between" wrap="wrap" gap="md">

View File

@@ -1,9 +1,19 @@
import { useState } from "react";
import { useMemo, useState } from "react";
import { Button, Group, Modal, Stack, Text, TextInput, Textarea } from "@mantine/core";
import toast from "react-hot-toast";
import { trainSchedulingService } from "@/services/trainScheduling.service";
/** `min` for a `datetime-local` input: now, in the browser's local zone. */
function nowLocalDateTime(): string {
const now = new Date();
const pad = (n: number) => String(n).padStart(2, "0");
return (
`${now.getFullYear()}-${pad(now.getMonth() + 1)}-${pad(now.getDate())}` +
`T${pad(now.getHours())}:${pad(now.getMinutes())}`
);
}
export function RescheduleTrainDialog({
scheduleId,
currentBookingIds,
@@ -20,12 +30,21 @@ export function RescheduleTrainDialog({
const [newDepartureDate, setNewDepartureDate] = useState("");
const [reason, setReason] = useState("");
const [loading, setLoading] = useState(false);
// Earliest selectable departure, refreshed each time the dialog opens.
const minDepartureDate = useMemo(
() => (opened ? nowLocalDateTime() : ""),
[opened],
);
const handleSubmit = async () => {
if (!newDepartureDate) {
toast.error("Select a new departure date");
return;
}
if (new Date(newDepartureDate).getTime() < Date.now()) {
toast.error("New departure must be in the future");
return;
}
setLoading(true);
try {
await trainSchedulingService.maintenanceReschedule(scheduleId, {
@@ -53,6 +72,7 @@ export function RescheduleTrainDialog({
<TextInput
label="New departure"
type="datetime-local"
min={minDepartureDate}
value={newDepartureDate}
onChange={(e) => setNewDepartureDate(e.target.value)}
/>

View File

@@ -0,0 +1,189 @@
import type {
BatchBoardBookingDetail,
BatchBoardScheduleDetail,
} from "@/types/trainScheduling";
/**
* Client-side forecast of what the batch engine WOULD select if it ran right now.
*
* The real selection only happens once the document-review window closes and staff
* hit "run batch". Before that, operations can only see the *current* per-booking
* state (READY / SELECTED / …). This module simulates the engine's greedy fill so
* the board can show the likely winners + waiting list live, during OPEN and
* DOC_REVIEW, before anything is committed.
*
* It mirrors the engine (booking-batch.service): rank government-first, then
* priority score desc, then oldest booked; greedily board each booking while it
* fits ALL THREE capacity axes at once — wagon slots, max pull weight (tons), and
* train length (metres). The first booking that busts any axis, and everyone after
* it, drops to the waiting list. Purely a projection; the server stays the source
* of truth for the real run.
*/
export interface ForecastLimits {
/** Wagon-slot cap (schedule.maxWagons), or null if unknown. */
maxWagons: number | null;
/** Locomotive max pull weight in tons, or null. */
maxWeightTons: number | null;
/** Max train length in metres, or null. */
maxLengthMeters: number | null;
}
/** Which capacity axis stopped a booking from boarding (for the "why not" hint). */
export type BlockingAxis = "wagons" | "weight" | "length";
export interface ForecastRow {
booking: BatchBoardBookingDetail;
/** 1-based rank across the whole eligible pool. */
rank: number;
/** True → boards in the simulated batch; false → simulated waiting list. */
selected: boolean;
/** Cumulative wagons/weight/length AFTER this booking (only when selected). */
cumulativeWagons: number;
cumulativeWeightTons: number;
cumulativeLengthMeters: number;
/** If not selected, the first axis that would have overflowed. */
blockedBy: BlockingAxis | null;
}
export interface ForecastResult {
rows: ForecastRow[];
selected: ForecastRow[];
waiting: ForecastRow[];
/** Bookings excluded from the sim entirely (expired / no signed contract). */
ineligible: BatchBoardBookingDetail[];
limits: ForecastLimits;
/** Totals of the simulated batch. */
usedWagons: number;
usedWeightTons: number;
usedLengthMeters: number;
/** True once any axis is at/over its cap — train is "full" in the sim. */
full: boolean;
}
/** Engine rank order: government first, then priority desc, then oldest booked. */
export function rankBookings(
bookings: BatchBoardBookingDetail[],
): BatchBoardBookingDetail[] {
const time = (b: BatchBoardBookingDetail) =>
b.fullyExecutedAt
? new Date(b.fullyExecutedAt).getTime()
: Number.MAX_SAFE_INTEGER;
return [...bookings].sort((a, b) => {
if (a.isGovernment !== b.isGovernment) return a.isGovernment ? -1 : 1;
if (b.priorityScore !== a.priorityScore)
return b.priorityScore - a.priorityScore;
return time(a) - time(b);
});
}
/**
* A booking can compete in the batch only once its contract is signed. Expired
* bookings and pending-contract bookings never board, so they're pulled out of the
* sim (surfaced separately so they don't vanish from the board).
*/
function isEligible(b: BatchBoardBookingDetail): boolean {
return b.state !== "EXPIRED" && b.state !== "PENDING_CONTRACT";
}
const round2 = (n: number) => Math.round(n * 100) / 100;
/** Would adding `add` to `used` exceed `cap`? (cap null ⇒ axis unconstrained.) */
function overflows(used: number, add: number, cap: number | null): boolean {
return cap != null && used + add > cap;
}
export function simulateBatch(
bookings: BatchBoardBookingDetail[],
limits: ForecastLimits,
): ForecastResult {
const ranked = rankBookings(bookings);
const eligible = ranked.filter(isEligible);
const ineligible = ranked.filter((b) => !isEligible(b));
const rows: ForecastRow[] = [];
let wagons = 0;
let weight = 0;
let length = 0;
// Once the train is full we stop boarding, but keep ranking the rest as waiting.
let full = false;
eligible.forEach((booking, i) => {
let blockedBy: BlockingAxis | null = null;
if (!full) {
if (overflows(wagons, booking.wagons, limits.maxWagons))
blockedBy = "wagons";
else if (overflows(weight, booking.weightTons, limits.maxWeightTons))
blockedBy = "weight";
else if (overflows(length, booking.lengthMeters, limits.maxLengthMeters))
blockedBy = "length";
}
// Strict fill: the first booking that doesn't fit closes the train, so lower-
// priority bookings can't leapfrog it even if they'd individually fit. Matches
// the engine's greedy pass.
const selected = !full && blockedBy === null;
if (selected) {
wagons += booking.wagons;
weight = round2(weight + booking.weightTons);
length = round2(length + booking.lengthMeters);
} else {
full = true;
}
rows.push({
booking,
rank: i + 1,
selected,
cumulativeWagons: selected ? wagons : 0,
cumulativeWeightTons: selected ? weight : 0,
cumulativeLengthMeters: selected ? length : 0,
blockedBy: selected ? null : (blockedBy ?? firstBindingAxis(limits)),
});
});
return {
rows,
selected: rows.filter((r) => r.selected),
waiting: rows.filter((r) => !r.selected),
ineligible,
limits,
usedWagons: wagons,
usedWeightTons: weight,
usedLengthMeters: length,
full,
};
}
/** When the train closed on an earlier booking, name the tightest axis for the hint. */
function firstBindingAxis(limits: ForecastLimits): BlockingAxis {
if (limits.maxWagons != null) return "wagons";
if (limits.maxWeightTons != null) return "weight";
return "length";
}
/** Pull the three capacity caps off the board detail response. */
export function limitsFromDetail(
data: BatchBoardScheduleDetail,
): ForecastLimits {
return {
maxWagons: data.capacity.maxWagons ?? null,
maxWeightTons:
data.capacity.maxWeightTons ??
data.locomotive?.maxPullWeightTons ??
null,
maxLengthMeters:
data.capacity.maxLengthMeters ??
data.locomotive?.maxTrainLengthMeters ??
null,
};
}
/**
* The forecast is meaningful before the batch is committed — i.e. while bookings
* are still being taken or reviewed. Once the engine has run (PAYMENT onward) the
* real per-booking state is the truth, so we stop showing the projection.
*/
export function forecastIsLive(
phase: BatchBoardScheduleDetail["windowPhase"],
): boolean {
return phase === "PRE_WINDOW" || phase === "OPEN" || phase === "DOC_REVIEW";
}

View File

@@ -11,6 +11,7 @@ import {
Table,
Tabs,
Text,
Tooltip,
} from '@mantine/core';
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { FileText } from 'lucide-react';
@@ -22,7 +23,7 @@ import {
type ContainerItem,
type ContainerItemStage,
} from '@/services/warehouse.service';
import { extractErrorMessage } from './options';
import { extractDownloadErrorMessage, extractErrorMessage } from './options';
import { openPdfBlob } from './pdf';
interface ContainerItemsModalProps {
@@ -36,6 +37,7 @@ const STAGE_TABS: Array<{ value: string; label: string }> = [
{ value: 'ALL', label: 'All' },
{ value: 'RECEIVED', label: 'Received' },
{ value: 'GRN', label: "GRN'd" },
{ value: 'ASSIGNED', label: 'Assigned' },
{ value: 'LOADED', label: 'Loaded' },
{ value: 'LEFT', label: 'Left' },
{ value: 'DELIVERED', label: 'Delivered' },
@@ -45,13 +47,15 @@ const STAGE_COLOR: Record<ContainerItemStage, string> = {
PENDING: 'gray',
RECEIVED: 'blue',
GRN: 'teal',
ASSIGNED: 'indigo',
LOADED: 'grape',
LEFT: 'orange',
DELIVERED: 'green',
};
/** Loadable = not yet on a truck (before LOADED). */
const isLoadable = (i: ContainerItem) => i.stage === 'PENDING' || i.stage === 'RECEIVED' || i.stage === 'GRN';
/** Loadable = not yet loaded (PENDING/RECEIVED/GRN, or customer-ASSIGNED awaiting load). */
const isLoadable = (i: ContainerItem) =>
i.stage === 'PENDING' || i.stage === 'RECEIVED' || i.stage === 'GRN' || i.stage === 'ASSIGNED';
export function ContainerItemsModal({ opened, onClose, bookingId, bookingReference }: ContainerItemsModalProps) {
const { toast } = useToast();
@@ -76,8 +80,13 @@ export function ContainerItemsModal({ opened, onClose, bookingId, bookingReferen
() => (tab === 'ALL' ? items : items.filter((i) => i.stage === tab)),
[items, tab],
);
// Only arrived, not-yet-departed trucks can be loaded.
const truckOptions = trucks
.filter((t) => !(t as { departedAt?: string }).departedAt)
.filter(
(t) =>
Boolean((t as { arrivedAt?: string }).arrivedAt) &&
!(t as { departedAt?: string }).departedAt,
)
.map((t) => ({ value: t.id, label: `${t.plateNumber} · ${t.driverName}` }));
const loadMutation = useMutation({
@@ -90,12 +99,29 @@ export function ContainerItemsModal({ opened, onClose, bookingId, bookingReferen
onError: (e) => toast({ variant: 'destructive', title: 'Load failed', description: extractErrorMessage(e) }),
});
const requestSign = async () => {
try {
const res = await warehouseService.requestHandoverSignature(bookingId as string);
queryClient.invalidateQueries({ queryKey: itemsKey });
if (res.alreadySigned) {
toast({ title: 'Handover already signed', description: 'You can generate the exit paper now.' });
} else {
toast({
title: 'Handover not signed',
description: `Signature request sent to the customer${res.reference ? ` (${res.reference})` : ''}.`,
});
}
} catch (e) {
toast({ variant: 'destructive', title: 'Could not request signature', description: extractErrorMessage(e) });
}
};
const openExitPaper = async (assignmentId: string, plate: string) => {
try {
const res = await warehouseService.downloadTruckExitPaper(assignmentId);
openPdfBlob(res.data, `exit-${plate}.pdf`);
} catch (e) {
toast({ variant: 'destructive', title: 'Exit paper not ready', description: extractErrorMessage(e) });
toast({ variant: 'destructive', title: 'Exit paper not ready', description: await extractDownloadErrorMessage(e) });
}
};
@@ -163,16 +189,28 @@ export function ContainerItemsModal({ opened, onClose, bookingId, bookingReferen
<Table.Td>{i.contractId ? <Badge variant="outline" color="indigo">Contract</Badge> : '—'}</Table.Td>
<Table.Td>{i.hasLastMile ? <Badge variant="light" color="cyan">EDR</Badge> : <Badge variant="light" color="gray">Self-haul</Badge>}</Table.Td>
<Table.Td ta="right">
{i.truckAssignmentId && (
<Button
size="compact-xs"
variant="light"
color="orange"
leftSection={<FileText size={13} />}
onClick={() => openExitPaper(i.truckAssignmentId as string, i.truckPlate ?? '')}
{i.loaded && i.truckAssignmentId && (
<Tooltip
label="Sign the handover first — a truck can't get its exit paper until the handover is signed."
disabled={i.handoverSigned}
withArrow
multiline
w={240}
>
Exit Paper
</Button>
<Button
size="compact-xs"
variant="light"
color={i.handoverSigned ? 'orange' : 'gray'}
leftSection={<FileText size={13} />}
onClick={() =>
i.handoverSigned
? openExitPaper(i.truckAssignmentId as string, i.truckPlate ?? '')
: requestSign()
}
>
Exit Paper
</Button>
</Tooltip>
)}
</Table.Td>
</Table.Tr>

View File

@@ -102,7 +102,7 @@ export function CreateWarehouseModal({ opened, onClose, warehouse }: CreateWareh
await updateMutation.mutateAsync({ id: warehouse.id, payload: { ...payload, status: form.status } });
toast({ title: 'Warehouse updated' });
} else {
await createMutation.mutateAsync(payload);
await createMutation.mutateAsync({ ...payload, status: form.status });
toast({ title: 'Warehouse created' });
}
onClose();
@@ -149,15 +149,13 @@ export function CreateWarehouseModal({ opened, onClose, warehouse }: CreateWareh
onChange={(value) => setForm((f) => ({ ...f, type: (value as WarehouseType) ?? 'OPEN_WAREHOUSE' }))}
allowDeselect={false}
/>
{isEdit && (
<Select
label="Status"
data={statusOptions}
value={form.status}
onChange={(value) => setForm((f) => ({ ...f, status: (value as 'ACTIVE' | 'INACTIVE') ?? 'ACTIVE' }))}
allowDeselect={false}
/>
)}
<Select
label="Status"
data={statusOptions}
value={form.status}
onChange={(value) => setForm((f) => ({ ...f, status: (value as 'ACTIVE' | 'INACTIVE') ?? 'ACTIVE' }))}
allowDeselect={false}
/>
</Group>
<TextInput

View File

@@ -17,7 +17,6 @@ import { InventoryHistoryModal } from './InventoryHistoryModal';
import { LoadInventoryModal } from './LoadInventoryModal';
import { MoveInventoryModal } from './MoveInventoryModal';
import { ReleaseOrderModal } from './ReleaseOrderModal';
import { ReserveInventoryModal } from './ReserveInventoryModal';
import { WarehouseInventoryTable } from './WarehouseInventoryTable';
import { extractErrorMessage } from './options';
import { openPdfBlob } from './pdf';
@@ -29,12 +28,11 @@ interface InventoryWorkbenchProps {
onLastMile?: (item: WarehouseInventoryItem) => void;
}
/** Inventory table + all lifecycle actions (advance / move / reserve / history). */
/** Inventory table + all lifecycle actions (advance / move / history). */
export function InventoryWorkbench({ items, isLoading, onLastMile }: InventoryWorkbenchProps) {
const { toast } = useToast();
const [busyId, setBusyId] = useState<string | null>(null);
const [moveItem, setMoveItem] = useState<WarehouseInventoryItem | null>(null);
const [reserveItem, setReserveItem] = useState<WarehouseInventoryItem | null>(null);
const [loadItem, setLoadItem] = useState<WarehouseInventoryItem | null>(null);
const [historyItem, setHistoryItem] = useState<WarehouseInventoryItem | null>(null);
const [viewItem, setViewItem] = useState<WarehouseInventoryItem | null>(null);
@@ -171,7 +169,7 @@ export function InventoryWorkbench({ items, isLoading, onLastMile }: InventoryWo
const storeInventory = async (item: WarehouseInventoryItem) => {
setBusyId(item.id);
try {
const stored = await storeMutation.mutateAsync(item.id);
const stored = await storeMutation.mutateAsync({ id: item.id });
toast({
title: 'Inventory stored',
description: [stored.warehouse?.code, stored.yard?.code, stored.zone?.code].filter(Boolean).join(' / '),
@@ -187,9 +185,6 @@ export function InventoryWorkbench({ items, isLoading, onLastMile }: InventoryWo
switch (action) {
case 'store':
return storeInventory(item);
case 'reserve':
setReserveItem(item);
return;
case 'ready-for-loading':
return runDirect(item, () => readyMutation.mutateAsync(item.id), 'Ready for loading');
case 'load':
@@ -258,11 +253,6 @@ export function InventoryWorkbench({ items, isLoading, onLastMile }: InventoryWo
</Stack>
<MoveInventoryModal opened={Boolean(moveItem)} onClose={() => setMoveItem(null)} item={moveItem} />
<ReserveInventoryModal
opened={Boolean(reserveItem)}
onClose={() => setReserveItem(null)}
item={reserveItem}
/>
<LoadInventoryModal opened={Boolean(loadItem)} onClose={() => setLoadItem(null)} item={loadItem} />
<InventoryHistoryModal
opened={Boolean(historyItem)}

View File

@@ -7,6 +7,7 @@ import {
Checkbox,
Group,
Loader,
Menu,
Modal,
NumberInput,
ScrollArea,
@@ -20,6 +21,7 @@ import {
Tooltip,
} from '@mantine/core';
import {
ArrowRightLeft,
ChevronDown,
ChevronRight,
ClipboardCheck,
@@ -27,6 +29,8 @@ import {
FileText,
History,
Info,
MapPin,
MoreHorizontal,
PackageCheck,
PackageOpen,
PackageSearch,
@@ -61,7 +65,6 @@ import type {
} from '@/types/warehouse';
import { BookingSelect } from './BookingSelect';
import { DeliverInventoryModal } from './DeliverInventoryModal';
import { TruckDispatchModal } from './TruckDispatchModal';
import { ContainerItemsModal } from './ContainerItemsModal';
import { FeePreviewModal } from './FeePreviewModal';
import { InspectionReportModal } from './InspectionReportModal';
@@ -69,7 +72,9 @@ import { InventoryDetailModal } from './InventoryDetailModal';
import { InventoryHistoryModal } from './InventoryHistoryModal';
import { InventoryWorkbench } from './InventoryWorkbench';
import { InventoryInquiryDetailModal } from './InventoryInquiryDetailModal';
import { MoveInventoryModal } from './MoveInventoryModal';
import { ReleaseOrderModal } from './ReleaseOrderModal';
import { StoreInventoryModal } from './StoreInventoryModal';
import { WarehouseInquiryTable } from './WarehouseInquiryTable';
import { extractErrorMessage, formatDate, formatNumber, inventoryStatusOptions } from './options';
import { openPdfBlob } from './pdf';
@@ -2182,7 +2187,6 @@ function ImportUnloadedQueueTab({ enabled }: { enabled: boolean }) {
const inspectMutation = useMutation(
api.warehouses.bulkMarkInspected.mutationOptions(),
);
const storeMutation = useMutation(api.warehouses.store.mutationOptions());
const readyMutation = useMutation(api.warehouses.markReadyForPickup.mutationOptions());
const [selected, setSelected] = useState<Set<string>>(new Set());
const [inspectId, setInspectId] = useState<string | null>(null);
@@ -2192,8 +2196,9 @@ function ImportUnloadedQueueTab({ enabled }: { enabled: boolean }) {
const [feeItem, setFeeItem] = useState<WarehouseInventoryItem | null>(null);
const [releaseItem, setReleaseItem] = useState<WarehouseInventoryItem | null>(null);
const [deliverItem, setDeliverItem] = useState<WarehouseInventoryItem | null>(null);
const [loadTruckItem, setLoadTruckItem] = useState<WarehouseInventoryItem | null>(null);
const [containerItemsItem, setContainerItemsItem] = useState<WarehouseInventoryItem | null>(null);
const [storeItem, setStoreItem] = useState<WarehouseInventoryItem | null>(null);
const [moveItem, setMoveItem] = useState<WarehouseInventoryItem | null>(null);
const allSelected = rows.length > 0 && selected.size === rows.length;
const someSelected = selected.size > 0 && !allSelected;
@@ -2413,49 +2418,16 @@ function ImportUnloadedQueueTab({ enabled }: { enabled: boolean }) {
<Eye size={16} />
</ActionIcon>
</Tooltip>
{r.currentStatus === 'UNLOADED' && (
{/* Primary stage action stays visible; the rest live under the kebab. */}
{r.currentStatus === 'READY_FOR_PICKUP' && !r.releaseDate && r.hasAssignedTruck && (
<Button
size="compact-xs"
variant="light"
color="blue"
loading={busyId === r.id}
onClick={() => runRowAction(r, 'Inventory stored', () => storeMutation.mutateAsync(r.id))}
color="yellow"
leftSection={<Truck size={14} />}
onClick={() => setReleaseItem(toInventoryItem(r))}
>
Store
</Button>
)}
{['UNLOADED', 'STORED'].includes(r.currentStatus) && r.inspectionStatus === 'PASSED' && (
<Button
size="compact-xs"
variant="light"
color="orange"
loading={busyId === r.id}
onClick={() => runRowAction(r, 'Ready for pickup', () => readyMutation.mutateAsync(r.id))}
>
Ready Pickup
</Button>
)}
{r.currentStatus === 'READY_FOR_PICKUP' && !r.releaseDate && (
<>
<Button
size="compact-xs"
variant="light"
color="yellow"
onClick={() => setReleaseItem(toInventoryItem(r))}
>
{r.releaseOrderReference ? 'Truck Leaving' : 'Truck Arrival'}
</Button>
</>
)}
{r.currentStatus === 'READY_FOR_PICKUP' && (
<Button
size="compact-xs"
variant="light"
color="green"
loading={busyId === r.id}
onClick={() => setLoadTruckItem(toInventoryItem(r))}
>
Truck_dispatch
{r.releaseOrderReference ? 'Truck Leaving' : 'Truck Arrival'}
</Button>
)}
{r.currentStatus === 'READY_FOR_PICKUP' && r.releaseDate && (
@@ -2470,40 +2442,64 @@ function ImportUnloadedQueueTab({ enabled }: { enabled: boolean }) {
Exit Paper
</Button>
)}
{r.currentStatus === 'READY_FOR_PICKUP' && r.releaseDate && (
<Button
size="compact-xs"
variant="light"
color="green"
onClick={() => setDeliverItem(toInventoryItem(r))}
>
Deliver
</Button>
)}
{r.inspectionStatus === 'PASSED' && (
<Button
size="compact-xs"
variant="light"
color="teal"
leftSection={<FileText size={14} />}
onClick={() => openHandoverDocument(r)}
>
{r.handoverDocumentReference ? 'View Handover' : 'Handover'}
</Button>
)}
<Button size="compact-xs" variant="light" color="orange" onClick={() => setInspectId(r.id)}>
Inspect / Report
</Button>
<Tooltip label="Storage / fee preview" withArrow>
<ActionIcon variant="subtle" color="teal" onClick={() => setFeeItem(toInventoryItem(r))}>
<PackageCheck size={16} />
</ActionIcon>
</Tooltip>
<Tooltip label="History" withArrow>
<ActionIcon variant="subtle" color="gray" onClick={() => setHistoryItem(toInventoryItem(r))}>
<History size={16} />
</ActionIcon>
</Tooltip>
<Menu shadow="md" width={240} position="bottom-end" withinPortal>
<Menu.Target>
<ActionIcon variant="subtle" color="gray" aria-label="More actions" loading={busyId === r.id}>
<MoreHorizontal size={16} />
</ActionIcon>
</Menu.Target>
<Menu.Dropdown>
{r.currentStatus === 'UNLOADED' && (
<Menu.Item leftSection={<MapPin size={14} />} onClick={() => setStoreItem(toInventoryItem(r))}>
Store
</Menu.Item>
)}
{r.currentStatus !== 'UNLOADED' && (
<Menu.Item leftSection={<ArrowRightLeft size={14} />} onClick={() => setMoveItem(toInventoryItem(r))}>
Move
</Menu.Item>
)}
{['UNLOADED', 'STORED'].includes(r.currentStatus) && r.inspectionStatus === 'PASSED' && (
<Menu.Item onClick={() => runRowAction(r, 'Ready for pickup', () => readyMutation.mutateAsync(r.id))}>
Ready for pickup
</Menu.Item>
)}
{r.currentStatus === 'READY_FOR_PICKUP' && !r.releaseDate && (
<Menu.Item
leftSection={<Truck size={14} />}
disabled={!r.hasAssignedTruck}
onClick={() => setReleaseItem(toInventoryItem(r))}
>
{r.hasAssignedTruck
? r.releaseOrderReference
? 'Truck leaving'
: 'Truck arrival'
: 'Truck arrival — assign a truck first'}
</Menu.Item>
)}
{r.currentStatus === 'READY_FOR_PICKUP' && r.releaseDate && (
<Menu.Item leftSection={<FileText size={14} />} onClick={() => openReleaseDocument(r)}>
Exit paper
</Menu.Item>
)}
{r.currentStatus === 'READY_FOR_PICKUP' && r.releaseDate && (
<Menu.Item onClick={() => setDeliverItem(toInventoryItem(r))}>Deliver</Menu.Item>
)}
{r.inspectionStatus === 'PASSED' && (
<Menu.Item leftSection={<FileText size={14} />} onClick={() => openHandoverDocument(r)}>
{r.handoverDocumentReference ? 'View handover' : 'Handover'}
</Menu.Item>
)}
<Menu.Item onClick={() => setInspectId(r.id)}>Inspect / report</Menu.Item>
<Menu.Divider />
<Menu.Item leftSection={<PackageCheck size={14} />} onClick={() => setFeeItem(toInventoryItem(r))}>
Storage / fee preview
</Menu.Item>
<Menu.Item leftSection={<History size={14} />} onClick={() => setHistoryItem(toInventoryItem(r))}>
History
</Menu.Item>
</Menu.Dropdown>
</Menu>
</Group>
</Table.Td>
</Table.Tr>
@@ -2526,13 +2522,9 @@ function ImportUnloadedQueueTab({ enabled }: { enabled: boolean }) {
inventoryId={feeItem?.id ?? null}
/>
<ReleaseOrderModal opened={Boolean(releaseItem)} onClose={() => setReleaseItem(null)} item={releaseItem} />
<StoreInventoryModal opened={Boolean(storeItem)} onClose={() => setStoreItem(null)} item={storeItem} />
<MoveInventoryModal opened={Boolean(moveItem)} onClose={() => setMoveItem(null)} item={moveItem} />
<DeliverInventoryModal opened={Boolean(deliverItem)} onClose={() => setDeliverItem(null)} item={deliverItem} />
<TruckDispatchModal
opened={Boolean(loadTruckItem)}
onClose={() => setLoadTruckItem(null)}
bookingId={loadTruckItem?.booking?.id ?? null}
bookingReference={loadTruckItem?.booking?.reference ?? null}
/>
<ContainerItemsModal
opened={Boolean(containerItemsItem)}
onClose={() => setContainerItemsItem(null)}

View File

@@ -1,5 +1,5 @@
import { useEffect, useState } from 'react';
import { Alert, Button, Group, Modal, NumberInput, Select, SimpleGrid, Stack, Text, TextInput } from '@mantine/core';
import { Alert, Button, Group, Modal, MultiSelect, NumberInput, Select, SimpleGrid, Stack, Text, TextInput } from '@mantine/core';
import { Info, Scale } from 'lucide-react';
import { useMutation, useQuery } from '@tanstack/react-query';
@@ -28,29 +28,6 @@ export interface ReleaseOrderTruckPrefill {
containerNumber?: string | null;
}
const REGISTERED_FIRST_LAST_MILE_TRUCKS = [
['03-ET A45843', '43495'], ['03-ET A45866', '43470'], ['03-ET A45853', '43508'], ['03-ET A45849', '43414'],
['03-ET A45845', '43492'], ['03-ET A45820', '43487'], ['03-ET A45842', '43478'], ['03-ET A45841', '43515'],
['03-ET A45832', '43504'], ['03-ET A45856', '43510'], ['03-ET A45865', '43490'], ['03-ET A45855', '43485'],
['03-ET A45840', '43499'], ['03-ET A45867', '43493'], ['03-ET A45833', '43466'], ['03-ET A45858', '43496'],
['03-ET A45819', '43474'], ['03-ET A45834', '43502'], ['03-ET A45868', '43469'], ['03-ET A45831', '43488'],
['03-ET A45828', '43479'], ['03-ET A45850', '43505'], ['03-ET A45823', '43480'], ['03-ET A45838', '43472'],
['03-ET A45854', '43500'], ['03-ET A45839', '43486'], ['03-ET A45861', '43513'], ['03-ET A45830', '43501'],
['03-ET A45826', '43498'], ['03-ET A45836', '43467'], ['03-ET A45822', '43512'], ['03-ET A45821', '43210'],
['03-ET A45837', '43475'], ['03-ET A45860', '43497'], ['03-ET A45863', '43477'], ['03-ET A45825', '43483'],
['03-ET A45829', '43473'], ['03-ET A45824', '43491'], ['03-ET A45857', '43481'], ['03-ET A45851', '43509'],
['03-ET A45827', '43468'], ['03-ET A45859', '43887'], ['03-ET A45846', '43471'], ['03-ET A45847', '43511'],
['03-ET A45852', '43484'], ['03-ET A45844', '43476'], ['03-ET A45835', '43482'], ['03-ET A45864', '43503'],
['03-ET A45848', '43494'], ['03-ET A45862', '43465'], ['03-ET A39105', '41218'], ['03-ET A39098', '41220'],
['03-ET A29900', '41865'], ['03-ET A39097', '41226'], ['03-ET A39104', '41225'], ['03-ET A39103', '41223'],
['03-ET A39106', '41221'], ['03-ET A39107', '41215'], ['03-ET A39094', '41222'], ['03-ET A39099', '41216'],
['03-ET A39092', '41224'], ['03-ET A31801', '41214'],
].map(([powerPlate, trailerPlate], index) => ({
value: powerPlate,
label: `${index + 1}. ${powerPlate} / ${trailerPlate}`,
trailerPlate,
}));
const toIsoDateTime = (value: string) => {
if (!value) return undefined;
const date = new Date(value);
@@ -141,6 +118,13 @@ export function ReleaseOrderModal({ opened, onClose, item, truckPrefill }: Relea
queryFn: () => warehouseService.getCustomerTrucks(bookingId as string),
enabled: opened && Boolean(bookingId),
});
// Per-container cargo weights — the truck's net (gross tare) must equal the
// total cargo weight of the containers selected as loaded on it.
const { data: containerWeights = [] } = useQuery({
queryKey: ['release-container-weights', bookingId],
queryFn: () => warehouseService.getContainerWeights(bookingId as string),
enabled: opened && Boolean(bookingId),
});
const [reference, setReference] = useState('');
const [truckPlateNumber, setTruckPlateNumber] = useState('');
const [trailerPlateNumber, setTrailerPlateNumber] = useState('');
@@ -210,22 +194,39 @@ export function ReleaseOrderModal({ opened, onClose, item, truckPrefill }: Relea
truckType: t.truckType,
})),
];
const truckSelectOptions = [
...assignedTruckOptions,
...REGISTERED_FIRST_LAST_MILE_TRUCKS.map((t) => ({
value: t.value,
label: t.label,
trailerPlate: t.trailerPlate,
driverName: '',
driverPhone: '',
truckType: '',
})),
];
// Only trucks actually assigned to THIS booking (last-mile prefill or customer
// portal) are selectable. No global fleet list — if nothing is assigned, the
// operator types the plate manually in the field below.
const truckSelectOptions = assignedTruckOptions;
// Neither a last-mile truck nor a customer truck has been assigned yet.
const noTruckAssigned = assignedTruckOptions.length === 0 && !isCustomerAssignedTruck;
const isTruckIdentityLocked = isEntranceLocked || isCustomerAssignedTruck || hasLastMileTruckPrefill;
const isDriverNameLocked = isEntranceLocked || isCustomerAssignedTruck || Boolean(truckPrefill?.driverName);
const systemNetWeight = item?.weight == null ? netWeight : Number(item.weight);
// Which containers ride this truck, and their combined cargo weight. When the
// booking has container weights, that sum is the authoritative net; the
// operator selects the containers loaded on the truck at exit.
const hasContainerWeights = containerWeights.length > 0;
const containerWeightByNumber = new Map(
containerWeights.map((c) => [c.containerNumber.toUpperCase(), Number(c.weightTons) || 0]),
);
const containerSelectData = containerWeights.map((c) => ({
value: c.containerNumber,
label: `${c.containerNumber} · ${(Number(c.weightTons) || 0).toLocaleString()} t`,
}));
const selectedContainerNumbers = containerNumbers.map((n) => n.trim()).filter(Boolean);
const selectedCargoWeight = Number(
selectedContainerNumbers
.reduce((sum, n) => sum + (containerWeightByNumber.get(n.toUpperCase()) ?? 0), 0)
.toFixed(3),
);
const useContainerNet = hasContainerWeights && selectedContainerNumbers.length > 0;
const systemNetWeight = useContainerNet
? selectedCargoWeight
: item?.weight == null
? netWeight
: Number(item.weight);
const computedNetWeight =
tareWeight !== '' && grossWeight !== '' ? Number((Number(grossWeight) - Number(tareWeight)).toFixed(3)) : null;
const weightMismatch =
@@ -246,6 +247,10 @@ export function ReleaseOrderModal({ opened, onClose, item, truckPrefill }: Relea
toast({ variant: 'destructive', title: 'Gate out time and gross weight are required' });
return;
}
if (isExitStep && hasContainerWeights && selectedContainerNumbers.length === 0) {
toast({ variant: 'destructive', title: 'Select the containers loaded on this truck' });
return;
}
if (isExitStep && systemNetWeight === '') {
toast({ variant: 'destructive', title: 'System recorded net weight is missing' });
return;
@@ -336,23 +341,27 @@ export function ReleaseOrderModal({ opened, onClose, item, truckPrefill }: Relea
Truck is not assigned yet assign a last-mile or customer truck, or enter the plate manually below.
</Alert>
)}
<Select
label="Registered first / last-mile truck"
placeholder="Select truck or type plate manually below"
searchable
clearable
data={truckSelectOptions}
disabled={isTruckIdentityLocked}
value={truckSelectOptions.some((truck) => truck.value === truckPlateNumber) ? truckPlateNumber : null}
onChange={(value) => {
const truck = truckSelectOptions.find((row) => row.value === value);
setTruckPlateNumber(truck?.value ?? '');
setTrailerPlateNumber(truck?.trailerPlate ?? '');
if (truck?.driverName) setDriverName(truck.driverName);
if (truck?.driverPhone) setDriverPhone(truck.driverPhone);
if (truck?.truckType) setTruckType(truck.truckType);
}}
/>
{truckSelectOptions.length > 0 && (
<Select
label="Assigned first / last-mile truck"
placeholder="Select the assigned truck"
searchable
clearable
// Enabled at arrival so the operator picks which assigned truck came;
// only locked on the exit (leaving) step once identity is captured.
disabled={isEntranceLocked}
data={truckSelectOptions}
value={truckSelectOptions.some((truck) => truck.value === truckPlateNumber) ? truckPlateNumber : null}
onChange={(value) => {
const truck = truckSelectOptions.find((row) => row.value === value);
setTruckPlateNumber(truck?.value ?? '');
setTrailerPlateNumber(truck?.trailerPlate ?? '');
if (truck?.driverName) setDriverName(truck.driverName);
if (truck?.driverPhone) setDriverPhone(truck.driverPhone);
if (truck?.truckType) setTruckType(truck.truckType);
}}
/>
)}
<Group grow>
<TextInput
label="Truck plate number"
@@ -376,30 +385,51 @@ export function ReleaseOrderModal({ opened, onClose, item, truckPrefill }: Relea
<TextInput label="Driver phone" value={driverPhone} onChange={(e) => setDriverPhone(e.currentTarget.value)} readOnly={isEntranceLocked} />
<TextInput label="Truck type" value={truckType} onChange={(e) => setTruckType(e.currentTarget.value)} readOnly={isTruckIdentityLocked} />
</Group>
<Group grow>
<Stack gap={6}>
<SimpleGrid cols={containerNumbers.length > 1 ? 2 : 1} spacing="sm">
{containerNumbers.map((containerNumber, index) => (
<TextInput
key={index}
label={containerNumbers.length > 1 ? `Container number ${index + 1}` : 'Container number'}
value={containerNumber}
onChange={(e) =>
setContainerNumbers((numbers) =>
numbers.map((number, numberIndex) => (numberIndex === index ? e.currentTarget.value : number)),
)
}
readOnly={isTruckIdentityLocked}
/>
))}
</SimpleGrid>
</Stack>
<Group grow align="flex-start">
{hasContainerWeights ? (
<MultiSelect
label="Containers on this truck"
description={
isExitStep
? 'Select the containers loaded on this truck — their cargo weight must match gross tare.'
: 'Containers this truck will carry.'
}
placeholder="Select containers"
searchable
data={containerSelectData}
value={selectedContainerNumbers}
onChange={(values) => setContainerNumbers(values.length ? values : [''])}
/>
) : (
<Stack gap={6}>
<SimpleGrid cols={containerNumbers.length > 1 ? 2 : 1} spacing="sm">
{containerNumbers.map((containerNumber, index) => (
<TextInput
key={index}
label={containerNumbers.length > 1 ? `Container number ${index + 1}` : 'Container number'}
value={containerNumber}
onChange={(e) =>
setContainerNumbers((numbers) =>
numbers.map((number, numberIndex) => (numberIndex === index ? e.currentTarget.value : number)),
)
}
readOnly={isTruckIdentityLocked}
/>
))}
</SimpleGrid>
</Stack>
)}
<TextInput label="Gate in time" type="datetime-local" value={gateInTime} onChange={(e) => setGateInTime(e.currentTarget.value)} readOnly={isEntranceLocked} />
</Group>
<Group grow>
<NumberInput label="Tare weight (t)" required min={0} value={tareWeight} onChange={(v) => setTareWeight(v === '' ? '' : Number(v))} readOnly={isEntranceLocked} />
<NumberInput label="Gross weight (t)" required={isExitStep} min={0} value={grossWeight} onChange={(v) => setGrossWeight(v === '' ? '' : Number(v))} disabled={!isExitStep} />
<NumberInput label="Recorded net weight (system t)" min={0} value={systemNetWeight} readOnly />
<NumberInput
label={useContainerNet ? 'Selected cargo net (t)' : 'Recorded net weight (system t)'}
min={0}
value={systemNetWeight}
readOnly
/>
</Group>
<Group justify="space-between">
<Text size="sm" c={weightMismatch ? 'red' : 'dimmed'}>

View File

@@ -0,0 +1,143 @@
import { useEffect, useMemo, useState } from 'react';
import { Alert, Button, Group, Modal, Select, Stack, Text } from '@mantine/core';
import { Info } from 'lucide-react';
import { useMutation, useQuery } from '@tanstack/react-query';
import { api } from '@/services/api';
import { useToast } from '@/hooks/use-toast';
import type { WarehouseInventoryItem } from '@/types/warehouse';
import { extractErrorMessage } from './options';
interface StoreInventoryModalProps {
opened: boolean;
onClose: () => void;
item: WarehouseInventoryItem | null;
}
/**
* Store an unloaded import item. The operator may pick warehouse → yard → zone
* explicitly; leaving them blank falls back to the backend auto allocation.
*/
export function StoreInventoryModal({ opened, onClose, item }: StoreInventoryModalProps) {
const { toast } = useToast();
const storeMutation = useMutation(api.warehouses.store.mutationOptions());
const [warehouseId, setWarehouseId] = useState('');
const [yardId, setYardId] = useState('');
const [zoneId, setZoneId] = useState('');
useEffect(() => {
if (opened) {
setWarehouseId('');
setYardId('');
setZoneId('');
}
}, [opened]);
const warehousesQuery = useQuery(
api.warehouses.list.queryOptions({ input: { filter: { status: 'ACTIVE' } } }),
);
const yardsQuery = useQuery(
api.warehouses.listYards.queryOptions({
input: { warehouseId },
enabled: Boolean(warehouseId),
}),
);
const zonesQuery = useQuery(
api.warehouses.listZones.queryOptions({
input: { yardId },
enabled: Boolean(yardId),
}),
);
const warehouseOptions = useMemo(
() => (warehousesQuery.data ?? []).map((w) => ({ value: w.id, label: `${w.name} (${w.code})` })),
[warehousesQuery.data],
);
const yardOptions = useMemo(
() => (yardsQuery.data ?? []).filter((y) => y.status === 'ACTIVE').map((y) => ({ value: y.id, label: `${y.name} (${y.code})` })),
[yardsQuery.data],
);
const zoneOptions = useMemo(
() => (zonesQuery.data ?? []).filter((z) => z.status === 'ACTIVE').map((z) => ({ value: z.id, label: `${z.name} (${z.code})` })),
[zonesQuery.data],
);
const isManual = Boolean(warehouseId || yardId || zoneId);
const manualComplete = Boolean(warehouseId && yardId && zoneId);
const handleSubmit = async () => {
if (!item) return;
if (isManual && !manualComplete) {
toast({ variant: 'destructive', title: 'Pick warehouse, yard and zone — or clear all to auto-allocate' });
return;
}
try {
await storeMutation.mutateAsync({
id: item.id,
payload: manualComplete ? { warehouseId, yardId, zoneId } : undefined,
});
toast({ title: manualComplete ? 'Inventory stored at selected location' : 'Inventory stored (auto-allocated)' });
onClose();
} catch (error) {
toast({ variant: 'destructive', title: 'Store failed', description: extractErrorMessage(error) });
}
};
return (
<Modal opened={opened} onClose={onClose} title="Store inventory" centered size="lg">
<Stack gap="md">
<Alert icon={<Info size={16} />} color="blue" variant="light">
<Text size="sm">
Choose a warehouse, yard and zone to store this item at a specific location, or leave them
blank to let the system auto-allocate by rule / available capacity.
</Text>
</Alert>
<Select
label="Warehouse"
placeholder="Auto-allocate"
searchable
clearable
data={warehouseOptions}
value={warehouseId || null}
onChange={(v) => {
setWarehouseId(v ?? '');
setYardId('');
setZoneId('');
}}
/>
<Select
label="Yard"
placeholder={!warehouseId ? 'Select a warehouse first' : 'Select yard'}
searchable
clearable
disabled={!warehouseId}
data={yardOptions}
value={yardId || null}
onChange={(v) => {
setYardId(v ?? '');
setZoneId('');
}}
/>
<Select
label="Zone"
placeholder={!yardId ? 'Select a yard first' : 'Select zone'}
searchable
clearable
disabled={!yardId}
data={zoneOptions}
value={zoneId || null}
onChange={(v) => setZoneId(v ?? '')}
/>
<Group justify="flex-end" mt="sm">
<Button variant="default" onClick={onClose} disabled={storeMutation.isPending}>
Cancel
</Button>
<Button onClick={handleSubmit} loading={storeMutation.isPending}>
{manualComplete ? 'Store here' : 'Store (auto)'}
</Button>
</Group>
</Stack>
</Modal>
);
}

View File

@@ -41,7 +41,6 @@ const itemKind = (item: WarehouseInventoryItem) => {
const actionColor: Record<InventoryAction, string> = {
store: 'blue',
reserve: 'grape',
'ready-for-loading': 'cyan',
load: 'teal',
dispatch: 'edr-green',

View File

@@ -57,3 +57,24 @@ export const extractErrorMessage = (error: unknown, fallback = 'Something went w
const rawMessage = data?.message ?? data?.error ?? (error as Error)?.message;
return Array.isArray(rawMessage) ? rawMessage.join(', ') : rawMessage ? String(rawMessage) : fallback;
};
/**
* Error extractor for blob-download requests. When `responseType: 'blob'`, axios
* delivers the JSON error body as a Blob, so `extractErrorMessage` can't read
* `.message`. Decode the Blob to text, parse it, then fall back to the sync path.
*/
export const extractDownloadErrorMessage = async (error: unknown, fallback = 'Something went wrong') => {
const responseData = (error as { response?: { data?: unknown } })?.response?.data;
if (responseData instanceof Blob) {
try {
const text = await responseData.text();
const parsed = JSON.parse(text) as Record<string, unknown>;
const raw = parsed?.message ?? parsed?.error;
if (Array.isArray(raw)) return raw.join(', ');
if (raw) return String(raw);
} catch {
/* not JSON — fall through */
}
}
return extractErrorMessage(error, fallback);
};

View File

@@ -146,6 +146,7 @@ export const FREIGHT_PERMS = {
},
tracking: {
view: "edr_freight_app:tracking:view",
manage: "edr_freight_app:tracking:manage",
},
fuel: {
view: "edr_freight_app:fuel:view",

View File

@@ -176,6 +176,7 @@ export default function DocumentClearanceDetailPage() {
hideSummary
approvalsLocked={isPhasedGeneral && docsPhaseComplete}
queriesLocked={queriesLocked}
phasedCustoms={isPhasedGeneral}
onChanged={() => void refetch()}
/>
</Grid.Col>

View File

@@ -128,7 +128,16 @@ function DirectionIcon({ direction }: { direction: string }) {
);
}
export default function DocumentClearanceListPage() {
export default function DocumentClearanceListPage({
opsMode = false,
}: {
/**
* true → Operations self-clearance queue: NON-customs bookings whose
* per-booking clearance docs the operations team reviews (GENERAL Path A).
* false → legacy GL queue: customs bookings only.
*/
opsMode?: boolean;
}) {
const navigate = useNavigate();
const [pageTab, setPageTab] = useState<PageTab>("queue");
const [activeTab, setActiveTab] = useState<ClearanceTabKey>("all");
@@ -139,7 +148,7 @@ export default function DocumentClearanceListPage() {
const isHistory = pageTab === "history";
const { data, isLoading, isError, isFetching, refetch } = useQuery({
queryKey: ["clearance", "list", isHistory],
queryKey: ["clearance", "list", isHistory, opsMode],
queryFn: () =>
bookingsService.list({
status: isHistory ? CLEARANCE_HISTORY_STATUS : CLEARANCE_REVIEW_STATUS,
@@ -148,8 +157,10 @@ export default function DocumentClearanceListPage() {
});
const allRows = useMemo(() => {
// GL clearance queue: customs bookings only
const rows = (data?.items ?? []).map(toClearanceRow).filter((r) => r.hasCustoms);
// opsMode: self-clearance (non-customs) bookings; else customs bookings only.
const rows = (data?.items ?? [])
.map(toClearanceRow)
.filter((r) => (opsMode ? !r.hasCustoms : r.hasCustoms));
if (isHistory) {
return [...rows].sort((a, b) => {
@@ -159,7 +170,7 @@ export default function DocumentClearanceListPage() {
});
}
return rows;
}, [data?.items, isHistory]);
}, [data?.items, isHistory, opsMode]);
const tabCounts = useMemo(
() => ({
@@ -310,8 +321,12 @@ export default function DocumentClearanceListPage() {
<PageContainer>
<Stack gap="lg">
<PageHeader
title="Document Clearance"
subtitle="Review customer documents, raise queries, and finalize clearance for each booking."
title={opsMode ? "Self-Clearance Review" : "Document Clearance"}
subtitle={
opsMode
? "Review the customer's own clearance documents per shipment booking, raise queries, and finalize."
: "Review customer documents, raise queries, and finalize clearance for each booking."
}
meta={statusBadge}
action={
<ActionIcon

View File

@@ -25,6 +25,7 @@ import {
PackagePlus,
RefreshCw,
Search,
Send,
ShieldCheck,
ShipWheel,
Table as TableIcon,
@@ -49,11 +50,13 @@ import {
useContractClearanceQueue,
useEtClearanceQueue,
} from "@/hooks/contracts/useContracts";
import { useBookingEtClearanceQueue } from "@/hooks/bookings/useBookings";
import type { BookingDetail } from "@/types/booking";
import { FREIGHT_PERMS, hasPermission } from "@/lib/permissions";
import { GlUpcomingWindowsSection } from "@/components/contracts/GlUpcomingWindowsSection";
type ViewMode = "table" | "cards";
type QueueTab = "all" | "et";
type QueueTab = "all" | "et" | "shipments";
interface ClearanceRow {
id: string;
@@ -194,6 +197,10 @@ export default function ContractClearanceListPage() {
const { user } = useAuth();
const canReview = hasPermission(user, FREIGHT_PERMS.contracts.clearanceReview);
const canEt = hasPermission(user, FREIGHT_PERMS.contracts.clearanceEtActions);
const canCreateBooking = hasPermission(
user,
FREIGHT_PERMS.contracts.createBooking,
);
const defaultQueue: QueueTab = canReview ? "all" : "et";
const [queueTab, setQueueTab] = useState<QueueTab>(defaultQueue);
@@ -202,16 +209,29 @@ export default function ContractClearanceListPage() {
const { pagination, setPagination } = usePagination({ pageSize: 10 });
const { data: allData, isLoading: allLoading, isError: allError, isFetching: allFetching, refetch: refetchAll } =
useContractClearanceQueue(queueTab === "all");
useContractClearanceQueue(queueTab === "all" || queueTab === "shipments");
const { data: etData, isLoading: etLoading, isError: etError, isFetching: etFetching, refetch: refetchEt } =
useEtClearanceQueue(queueTab === "et");
const {
data: bookingQueue,
isLoading: bookingsLoading,
isError: bookingsError,
isFetching: bookingsFetching,
refetch: refetchBookings,
} = useBookingEtClearanceQueue(queueTab === "shipments");
const data = queueTab === "et" ? etData : allData;
const isLoading = queueTab === "et" ? etLoading : allLoading;
const isError = queueTab === "et" ? etError : allError;
const isFetching = queueTab === "et" ? etFetching : allFetching;
const isFetching =
queueTab === "et"
? etFetching
: queueTab === "shipments"
? bookingsFetching
: allFetching;
const refetch = () => {
if (queueTab === "et") void refetchEt();
else if (queueTab === "shipments") void refetchBookings();
else void refetchAll();
};
@@ -239,9 +259,43 @@ export default function ContractClearanceListPage() {
),
});
}
if (canReview || canEt) {
opts.push({
value: "shipments",
label: (
<Group gap={6} wrap="nowrap">
<PackageCheck size={15} />
<Box visibleFrom="sm">Shipments</Box>
</Group>
),
});
}
return opts;
}, [canReview, canEt]);
// GENERAL-contract shipment bookings in per-booking clearance (ET queue).
const bookingRows = useMemo(() => {
const rows = (bookingQueue ?? []).map((b: BookingDetail) => ({
id: b.id,
reference: b.reference,
customerLabel: b.company?.name ?? b.governmentInstitution ?? "—",
originLabel: b.originYard?.name ?? "—",
destinationLabel: b.destinationYard?.name ?? "—",
tradeDirection: b.tradeDirection ?? "—",
freightType: b.freightType ?? "—",
status: b.status,
}));
const q = query.trim().toLowerCase();
if (!q) return rows;
return rows.filter(
(r) =>
r.reference.toLowerCase().includes(q) ||
r.customerLabel.toLowerCase().includes(q) ||
r.originLabel.toLowerCase().includes(q) ||
r.destinationLabel.toLowerCase().includes(q),
);
}, [bookingQueue, query]);
const allRows = useMemo(
() => (data?.items ?? []).map(toClearanceRow),
[data?.items],
@@ -269,7 +323,7 @@ export default function ContractClearanceListPage() {
);
}, [allRows, query]);
const total = rows.length;
const total = queueTab === "shipments" ? bookingRows.length : rows.length;
const pageCount = Math.max(1, Math.ceil(total / pagination.pageSize));
const pagedRows = useMemo(() => {
@@ -410,16 +464,29 @@ export default function ContractClearanceListPage() {
</Badge>
}
action={
<ActionIcon
variant="default"
size="lg"
radius="md"
onClick={() => refetch()}
loading={isFetching}
aria-label="Refresh"
>
<RefreshCw size={16} />
</ActionIcon>
<Group gap="sm" wrap="nowrap">
{canCreateBooking ? (
<Button
variant="filled"
color="edr-green"
radius="md"
leftSection={<Send size={15} />}
onClick={() => navigate("/dashboard/shipment-requests")}
>
Shipment requests
</Button>
) : null}
<ActionIcon
variant="default"
size="lg"
radius="md"
onClick={() => refetch()}
loading={isFetching}
aria-label="Refresh"
>
<RefreshCw size={16} />
</ActionIcon>
</Group>
}
/>
@@ -528,7 +595,14 @@ export default function ContractClearanceListPage() {
</Group>
</Box>
{view === "table" ? (
{queueTab === "shipments" ? (
<ShipmentBookingsTable
rows={bookingRows}
loading={bookingsLoading}
error={bookingsError}
onOpen={(id) => navigate(`/dashboard/clearance/${id}`)}
/>
) : view === "table" ? (
<Box style={{ overflowX: "auto" }} px="xs" pb="xs">
<DataTable<ClearanceRow, unknown>
columns={columns}
@@ -567,6 +641,143 @@ export default function ContractClearanceListPage() {
);
}
interface ShipmentBookingRow {
id: string;
reference: string;
customerLabel: string;
originLabel: string;
destinationLabel: string;
tradeDirection: string;
freightType: string;
status: string;
}
const prettyStatus = (s: string) =>
s
.toLowerCase()
.replace(/_/g, " ")
.replace(/^\w/, (c) => c.toUpperCase());
const shipmentStatusColor = (s: string) => {
if (s === "AWAITING_DOCUMENTS") return "yellow";
if (s === "DOCUMENTS_UNDER_REVIEW") return "blue";
if (s === "CLEARANCE_READY") return "edr-green";
return "gray";
};
/** GENERAL-contract shipment bookings currently in per-booking clearance. */
function ShipmentBookingsTable({
rows,
loading,
error,
onOpen,
}: {
rows: ShipmentBookingRow[];
loading: boolean;
error: boolean;
onOpen: (id: string) => void;
}) {
const columns = useMemo<ColumnDef<ShipmentBookingRow>[]>(
() => [
{
id: "booking",
header: () => <span className={bookingTable.headerCell}>Booking</span>,
cell: ({ row }) => (
<div className="flex items-center gap-3 py-1.5">
<div className={bookingTable.rowIcon}>
<PackageCheck className="size-4" strokeWidth={1.75} />
</div>
<div className="min-w-0">
<p className="truncate font-medium text-foreground">
{row.original.reference}
</p>
<p className="mt-0.5 flex items-center gap-1 truncate text-xs text-muted-foreground">
<User className="size-3 shrink-0 opacity-70" />
{row.original.customerLabel}
</p>
</div>
</div>
),
},
{
id: "route",
header: () => <span className={bookingTable.headerCell}>Route</span>,
cell: ({ row }) => (
<Group gap={6} wrap="nowrap">
<Text size="sm" className="truncate">
{row.original.originLabel}
</Text>
<ArrowRight size={13} className="shrink-0 text-muted-foreground" />
<Text size="sm" className="truncate">
{row.original.destinationLabel}
</Text>
</Group>
),
},
{
id: "kind",
header: () => <span className={bookingTable.headerCell}>Type</span>,
cell: ({ row }) => (
<Group gap={6} wrap="nowrap">
<Badge variant="light" color="gray" radius="sm">
{prettyStatus(row.original.tradeDirection)}
</Badge>
<Badge variant="outline" color="gray" radius="sm">
{prettyStatus(row.original.freightType)}
</Badge>
</Group>
),
},
{
id: "status",
header: () => <span className={bookingTable.headerCell}>Status</span>,
cell: ({ row }) => (
<Badge
variant="light"
color={shipmentStatusColor(row.original.status)}
radius="sm"
>
{prettyStatus(row.original.status)}
</Badge>
),
},
{
id: "chevron",
header: "",
cell: () => (
<Group justify="flex-end" pr="xs">
<ChevronRight size={16} className="text-muted-foreground" />
</Group>
),
},
],
[],
);
if (!loading && !error && rows.length === 0) {
return (
<Stack align="center" gap={8} py={48}>
<ThemeIcon variant="light" color="gray" radius="xl" size={48}>
<Inbox size={22} />
</ThemeIcon>
<Text c="dimmed">No shipment bookings in clearance.</Text>
</Stack>
);
}
return (
<Box style={{ overflowX: "auto" }} px="xs" pb="xs">
<DataTable<ShipmentBookingRow, unknown>
columns={columns}
data={rows}
status={loading ? "loading" : error ? "error" : "success"}
onRowClick={(row) => onOpen(row.id)}
containerClassName="border-0 shadow-none bg-transparent"
/>
</Box>
);
}
function ClearanceCardGrid({
rows,
loading,

View File

@@ -188,7 +188,12 @@ export default function GlClearanceDetailPage() {
<Grid>
<Grid.Col span={{ base: 12, lg: 7 }}>
{data.kind === "booking" ? (
<ClearanceReviewSection bookingId={id!} hideSummary readOnly />
<ClearanceReviewSection
bookingId={id!}
hideSummary
readOnly
phasedCustoms
/>
) : (
<ContractClearanceReviewSection
contractId={id!}

View File

@@ -1,62 +1,99 @@
import { useNavigate } from "react-router-dom";
import { Badge, Card, Group, Loader, Stack, Text } from "@mantine/core";
import { ChevronRight, Ship } from "lucide-react";
import { ChevronRight, PackageCheck, Ship } from "lucide-react";
import { PageContainer } from "@/components/page/PageContainer";
import { PageHeader } from "@/components/page/PageHeader";
import { useDjClearanceQueue } from "@/hooks/contracts/useContracts";
import { useBookingDjClearanceQueue } from "@/hooks/bookings/useBookings";
export default function GlDjiboutiClearanceListPage() {
const navigate = useNavigate();
const { data: contractQueue, isLoading: contractsLoading } = useDjClearanceQueue();
const { data: bookingQueue, isLoading: bookingsLoading } =
useBookingDjClearanceQueue();
const contractItems = contractQueue?.items ?? [];
const bookingItems = bookingQueue ?? [];
return (
<PageContainer>
<PageHeader
title="GL Djibouti — Clearance"
subtitle="Customs contracts handed off to Djibouti GL."
subtitle="Customs contracts and shipment bookings handed off to Djibouti GL."
/>
{contractsLoading ? (
{contractsLoading || bookingsLoading ? (
<Group justify="center" py={60}>
<Loader color="edr-green" />
</Group>
) : (
<Stack gap="sm">
{contractItems.length === 0 ? (
{contractItems.length === 0 && bookingItems.length === 0 ? (
<Text c="dimmed" ta="center" py="xl">
No Djibouti customs contracts yet.
No Djibouti customs work yet.
</Text>
) : (
contractItems.map((c) => (
<Card
key={c.id}
withBorder
radius="md"
padding="md"
style={{ cursor: "pointer" }}
onClick={() => navigate(`/dashboard/gl-djibouti/clearance/${c.id}`)}
>
<Group justify="space-between" wrap="nowrap">
<Group gap="sm">
<Ship size={18} className="text-[color:var(--freight-brand)]" />
<div>
<Text fw={700}>{c.reference}</Text>
<Text size="sm" c="dimmed">
{c.tradeDirection} · {c.status}
</Text>
</div>
<>
{contractItems.map((c) => (
<Card
key={c.id}
withBorder
radius="md"
padding="md"
style={{ cursor: "pointer" }}
onClick={() => navigate(`/dashboard/gl-djibouti/clearance/${c.id}`)}
>
<Group justify="space-between" wrap="nowrap">
<Group gap="sm">
<Ship size={18} className="text-[color:var(--freight-brand)]" />
<div>
<Text fw={700}>{c.reference}</Text>
<Text size="sm" c="dimmed">
{c.tradeDirection} · {c.status}
</Text>
</div>
</Group>
<Group gap="xs">
<Badge variant="light" color="edr-green">
Contract
</Badge>
<ChevronRight size={18} className="text-muted-foreground" />
</Group>
</Group>
<Group gap="xs">
<Badge variant="light" color="edr-green">
Contract
</Badge>
<ChevronRight size={18} className="text-muted-foreground" />
</Card>
))}
{bookingItems.map((b) => (
<Card
key={b.id}
withBorder
radius="md"
padding="md"
style={{ cursor: "pointer" }}
onClick={() => navigate(`/dashboard/clearance/${b.id}`)}
>
<Group justify="space-between" wrap="nowrap">
<Group gap="sm">
<PackageCheck
size={18}
className="text-[color:var(--freight-brand)]"
/>
<div>
<Text fw={700}>{b.reference}</Text>
<Text size="sm" c="dimmed">
{b.tradeDirection} · {b.status}
</Text>
</div>
</Group>
<Group gap="xs">
<Badge variant="light" color="blue">
Shipment
</Badge>
<ChevronRight size={18} className="text-muted-foreground" />
</Group>
</Group>
</Group>
</Card>
))
</Card>
))}
</>
)}
</Stack>
)}

View File

@@ -107,6 +107,10 @@ const normalizePayload = (values: Record<string, FormValue>) =>
.filter(([, value]) => value !== '' && !(Array.isArray(value) && value.length === 0)),
);
/** Render a spec value inherited from the wagon type; em dash when the type isn't loaded. */
const fmtTypeSpec = (value: number | undefined | null, unit: string) =>
value == null ? '—' : `${Number(value)} ${unit}`;
const extractBackendErrors = (error: unknown) => {
const responseData = (error as { response?: { data?: unknown } })?.response?.data;
const data = responseData && typeof responseData === 'object' ? responseData as Record<string, unknown> : undefined;
@@ -536,7 +540,7 @@ export function WagonTypesCrudPage() {
name: '',
capacityTons: 0,
lengthMeters: 0,
maxWagonsPerTrain: '',
tareWeightTons: '',
supportedLoadTypes: '',
isActive: true,
});
@@ -587,7 +591,7 @@ export function WagonTypesCrudPage() {
name: '',
capacityTons: 0,
lengthMeters: 0,
maxWagonsPerTrain: '',
tareWeightTons: '',
supportedLoadTypes: '',
isActive: true,
});
@@ -601,7 +605,7 @@ export function WagonTypesCrudPage() {
name: type.name ?? '',
capacityTons: type.capacityTons ?? 0,
lengthMeters: type.lengthMeters ?? 0,
maxWagonsPerTrain: type.maxWagonsPerTrain ?? '',
tareWeightTons: type.tareWeightTons ?? '',
supportedLoadTypes: type.supportedLoadTypes?.join(', ') ?? '',
isActive: type.isActive,
});
@@ -610,10 +614,17 @@ export function WagonTypesCrudPage() {
const validateWagonType = () => {
const errors: Record<string, string> = {};
// normalizePayload strips empty strings, so a blank numeric field would be
// dropped from the payload rather than rejected. Each must be a positive
// number here — the API's @Min(0.001) agrees.
const positive = (value: FormValue) => Number.isFinite(Number(value)) && Number(value) > 0;
if (!String(form.code ?? '').trim()) errors.code = 'Code is required';
if (!String(form.name ?? '').trim()) errors.name = 'Name is required';
if (!Number.isFinite(Number(form.capacityTons))) errors.capacityTons = 'Capacity must be a valid number';
if (!Number.isFinite(Number(form.lengthMeters))) errors.lengthMeters = 'Length must be a valid number';
if (!positive(form.capacityTons)) errors.capacityTons = 'Capacity must be greater than 0';
if (!positive(form.lengthMeters)) errors.lengthMeters = 'Length must be greater than 0';
if (!positive(form.tareWeightTons))
errors.tareWeightTons = 'Tare weight is required and must be greater than 0';
return errors;
};
@@ -710,6 +721,7 @@ export function WagonTypesCrudPage() {
</MantineButton>
</MantineTable.Th>
<MantineTable.Th>Length (m)</MantineTable.Th>
<MantineTable.Th>Tare weight (tons)</MantineTable.Th>
<MantineTable.Th>Load types</MantineTable.Th>
<MantineTable.Th>Status</MantineTable.Th>
<MantineTable.Th ta="right">Actions</MantineTable.Th>
@@ -722,6 +734,7 @@ export function WagonTypesCrudPage() {
<MantineTable.Td>{type.name}</MantineTable.Td>
<MantineTable.Td>{type.capacityTons}</MantineTable.Td>
<MantineTable.Td>{type.lengthMeters}</MantineTable.Td>
<MantineTable.Td>{type.tareWeightTons ?? '-'}</MantineTable.Td>
<MantineTable.Td>{type.supportedLoadTypes?.join(', ') || '-'}</MantineTable.Td>
<MantineTable.Td>
<MantineBadge color={type.isActive === false ? 'gray' : 'edr-green'} variant="light">
@@ -750,7 +763,7 @@ export function WagonTypesCrudPage() {
))}
{!query.isLoading && filtered.length === 0 ? (
<MantineTable.Tr>
<MantineTable.Td colSpan={7}>
<MantineTable.Td colSpan={8}>
<Text ta="center" c="dimmed" py="xl">
No wagon types found.
</Text>
@@ -759,7 +772,7 @@ export function WagonTypesCrudPage() {
) : null}
{query.isLoading ? (
<MantineTable.Tr>
<MantineTable.Td colSpan={7}>
<MantineTable.Td colSpan={8}>
<Text ta="center" c="dimmed" py="xl">
Loading...
</Text>
@@ -815,10 +828,13 @@ export function WagonTypesCrudPage() {
onChange={(value) => setForm((current) => ({ ...current, lengthMeters: value }))}
/>
<NumberInput
label="Max wagons per train"
label="Tare weight (tons)"
description="Empty wagon weight — counts against the locomotive's pull limit alongside the cargo"
required
min={0}
value={form.maxWagonsPerTrain === '' ? '' : Number(form.maxWagonsPerTrain)}
onChange={(value) => setForm((current) => ({ ...current, maxWagonsPerTrain: value }))}
value={form.tareWeightTons === '' || form.tareWeightTons == null ? '' : Number(form.tareWeightTons)}
error={fieldErrors.tareWeightTons}
onChange={(value) => setForm((current) => ({ ...current, tareWeightTons: value }))}
/>
<MantineSelect
label="Status"
@@ -910,7 +926,17 @@ export function WagonsCrudPage() {
? `${wagon.currentLocationYard.label ?? wagon.currentLocationYard.code} (${wagon.currentLocationYard.country ?? '-'})`
: '-',
},
{ key: 'maxPayloadWeight', label: 'Max payload' },
{
// Read-only: the spec lives on the wagon type, so it is displayed, never edited here.
key: 'tareWeight',
label: 'Tare weight',
render: (wagon) => fmtTypeSpec(wagon.wagonType?.tareWeightTons, 't'),
},
{
key: 'maxPayloadWeight',
label: 'Max payload',
render: (wagon) => fmtTypeSpec(wagon.wagonType?.capacityTons, 't'),
},
{ key: 'status', label: 'Status', render: (wagon) => statusBadge(wagon.status) },
]}
fields={[
@@ -921,11 +947,6 @@ export function WagonsCrudPage() {
type: 'select',
required: true,
options: wagonTypeOptions,
onValueChange: (value, current) => {
const selectedType = wagonTypes.find((type: any) => type.id === value);
if (!selectedType || Number(current.maxPayloadWeight) > 0) return {};
return { maxPayloadWeight: Number(selectedType.capacityTons) };
},
},
{
key: 'currentLocationYardId',
@@ -934,8 +955,6 @@ export function WagonsCrudPage() {
required: true,
options: yardOptions,
},
{ key: 'tareWeight', label: 'Tare weight', type: 'number', required: true },
{ key: 'maxPayloadWeight', label: 'Max payload weight', type: 'number', required: true },
{
key: 'status',
label: 'Status',
@@ -951,7 +970,7 @@ export function WagonsCrudPage() {
},
{ key: 'notes', label: 'Notes' },
]}
emptyValues={{ wagonNumber: '', wagonTypeId: '', currentLocationYardId: '', tareWeight: 0, maxPayloadWeight: 0, status: 'AVAILABLE', notes: '' }}
emptyValues={{ wagonNumber: '', wagonTypeId: '', currentLocationYardId: '', status: 'AVAILABLE', notes: '' }}
/>
);
}
@@ -1118,6 +1137,16 @@ export function LocomotivesCrudPage() {
{ key: 'status', label: 'Status', render: (locomotive) => statusBadge(locomotive.status) },
{ key: 'maxPullWeightTons', label: 'Max pull (tons)' },
{ key: 'maxTrainLengthMeters', label: 'Max length (m)' },
{
key: 'overageToleranceTons',
label: 'Weight tolerance (t)',
render: (locomotive) => locomotive.overageToleranceTons ?? '-',
},
{
key: 'overageToleranceMeters',
label: 'Length tolerance (m)',
render: (locomotive) => locomotive.overageToleranceMeters ?? '-',
},
]}
fields={[
{ key: 'code', label: 'Code', required: true },
@@ -1146,6 +1175,11 @@ export function LocomotivesCrudPage() {
},
{ key: 'maxPullWeightTons', label: 'Max pulling weight (tons)', type: 'number', required: true },
{ key: 'maxTrainLengthMeters', label: 'Max train length (meters)', type: 'number', required: true },
// Scheduling accepts a consist up to (max + tolerance) on each axis: 37 PW2
// wagons gross 3,522.4T against a 3,500T pull limit and only board because
// of the weight tolerance.
{ key: 'overageToleranceTons', label: 'Weight tolerance (tons over max pull)', type: 'number' },
{ key: 'overageToleranceMeters', label: 'Length tolerance (meters over max length)', type: 'number' },
{ key: 'powerKw', label: 'Power (kW)', type: 'number' },
{ key: 'tractionForceKn', label: 'Traction force (kN)', type: 'number' },
{ key: 'maxSpeedKmh', label: 'Max speed (km/h)', type: 'number' },
@@ -1157,6 +1191,8 @@ export function LocomotivesCrudPage() {
status: 'AVAILABLE',
maxPullWeightTons: 0,
maxTrainLengthMeters: 760,
overageToleranceTons: '',
overageToleranceMeters: '',
powerKw: '',
tractionForceKn: '',
maxSpeedKmh: '',

View File

@@ -27,6 +27,8 @@ import {
import { Activity, Pencil, Plus, Radio, Trash2 } from "lucide-react";
import Breadcrumbs from "@/components/ui/Breadcrumbs";
import { useToast } from "@/hooks/use-toast";
import { useAuth } from "@/auth/useAuth";
import { FREIGHT_PERMS, hasPermission } from "@/lib/permissions";
import { vehiclesService } from "@/services/vehicles.service";
import { gpsTrackingService, type GpsDevice } from "@/services/gps-tracking.service";
import { freightBrand } from "@/theme/freight-brand";
@@ -151,6 +153,8 @@ function RouteTrail({ path }: { path: LatLng[] }) {
export function TrackingPage() {
const { toast } = useToast();
const qc = useQueryClient();
const { user } = useAuth();
const canManage = hasPermission(user, FREIGHT_PERMS.tracking.manage);
const [selectedId, setSelectedId] = useState<string | null>(null);
const [hoverId, setHoverId] = useState<string | null>(null);
const [mapsReady, setMapsReady] = useState(false);
@@ -288,9 +292,11 @@ export function TrackingPage() {
<Text fw={700} size="xl">Real-Time Vehicle Tracking</Text>
<Text c="dimmed" size="sm">Live GPS positions from GT06 trackers</Text>
</div>
<Button leftSection={<Plus size={16} />} color="edr-green" onClick={openRegister}>
Register tracker
</Button>
{canManage && (
<Button leftSection={<Plus size={16} />} color="edr-green" onClick={openRegister}>
Register tracker
</Button>
)}
</Group>
<Grid>
@@ -358,9 +364,11 @@ export function TrackingPage() {
<Badge color={selected.online ? "edr-green" : "gray"} leftSection={<Activity size={12} />}>
{selected.online ? "Live" : "Offline"}
</Badge>
<ActionIcon variant="subtle" color="red" aria-label="Remove tracker" onClick={() => deleteMutation.mutate(selected.id)}>
<Trash2 size={16} />
</ActionIcon>
{canManage && (
<ActionIcon variant="subtle" color="red" aria-label="Remove tracker" onClick={() => deleteMutation.mutate(selected.id)}>
<Trash2 size={16} />
</ActionIcon>
)}
</Group>
</Group>
@@ -393,6 +401,7 @@ export function TrackingPage() {
data={vehicleOptions}
value={selected.vehicleId ?? null}
onChange={(v) => assignMutation.mutate({ id: selected.id, vehicleId: v })}
disabled={!canManage}
searchable
clearable
/>
@@ -421,14 +430,16 @@ export function TrackingPage() {
<Table.Td align="right">
<Group gap={6} justify="flex-end" wrap="nowrap">
<Badge color={d.online ? "edr-green" : "gray"} size="sm">{d.online ? "Live" : "Offline"}</Badge>
<ActionIcon
variant="subtle"
size="sm"
aria-label="Edit tracker"
onClick={(e) => { e.stopPropagation(); openEdit(d); }}
>
<Pencil size={15} />
</ActionIcon>
{canManage && (
<ActionIcon
variant="subtle"
size="sm"
aria-label="Edit tracker"
onClick={(e) => { e.stopPropagation(); openEdit(d); }}
>
<Pencil size={15} />
</ActionIcon>
)}
</Group>
</Table.Td>
</Table.Tr>

View File

@@ -158,6 +158,8 @@ export const FLEET_RESOURCES: FleetResourceConfig[] = [
{ id: "status", header: "Status", accessorKey: "status", format: "statusBadge" },
{ id: "maxPullWeightTons", header: "Max pull (tons)", accessorKey: "maxPullWeightTons", format: "number" },
{ id: "maxTrainLengthMeters", header: "Max length (m)", accessorKey: "maxTrainLengthMeters", format: "number" },
{ id: "overageToleranceTons", header: "Weight tolerance (t)", accessorKey: "overageToleranceTons", format: "number" },
{ id: "overageToleranceMeters", header: "Length tolerance (m)", accessorKey: "overageToleranceMeters", format: "number" },
],
// Code is auto-generated server-side (LOCO-NNN) — omitted from the form.
formFields: [
@@ -167,6 +169,11 @@ export const FLEET_RESOURCES: FleetResourceConfig[] = [
{ name: "currentYardId", label: "Current Yard", type: "select", dynamicOptions: "yards" },
{ name: "maxPullWeightTons", label: "Max pulling weight (tons)", type: "number", required: true },
{ name: "maxTrainLengthMeters", label: "Max train length (meters)", type: "number", required: true },
// Scheduling accepts a consist up to (max + tolerance) on each axis: 37 PW2
// wagons gross 3,522.4T against a 3,500T pull limit and only board because
// of the weight tolerance.
{ name: "overageToleranceTons", label: "Weight tolerance (tons over max pull)", type: "number" },
{ name: "overageToleranceMeters", label: "Length tolerance (meters over max length)", type: "number" },
{ name: "powerKw", label: "Power (kW)", type: "number" },
{ name: "tractionForceKn", label: "Traction force (kN)", type: "number" },
{ name: "maxSpeedKmh", label: "Max speed (km/h)", type: "number" },
@@ -178,6 +185,8 @@ export const FLEET_RESOURCES: FleetResourceConfig[] = [
currentYardId: "",
maxPullWeightTons: 2500,
maxTrainLengthMeters: 760,
overageToleranceTons: "",
overageToleranceMeters: "",
powerKw: "",
tractionForceKn: "",
maxSpeedKmh: "",
@@ -254,17 +263,16 @@ export const FLEET_RESOURCES: FleetResourceConfig[] = [
cardSubtitleKey: "currentYard",
searchKeys: ["wagonNumber", "wagonTypeId", "trainId", "status", "currentYardId"],
columns: [
// Tare weight and payload capacity are not wagon columns — they belong to the
// wagon type and are shown through it (see WagonsCrudPage in FleetCrudPages).
{ id: "wagonNumber", header: "Number", accessorKey: "wagonNumber", format: "code" },
{ id: "wagonTypeId", header: "Type", accessorKey: "wagonTypeId", format: "entityLabel" },
{ id: "maxPayloadWeight", header: "Max payload", accessorKey: "maxPayloadWeight", format: "number" },
{ id: "currentYard", header: "Current Yard", accessorKey: "currentYard", format: "entityLabel" },
{ id: "status", header: "Status", accessorKey: "status", format: "statusBadge" },
],
formFields: [
{ name: "wagonNumber", label: "Wagon number", type: "text", required: true },
{ name: "wagonTypeId", label: "Wagon type", type: "select", required: true, dynamicOptions: "wagonTypes" },
{ name: "tareWeight", label: "Tare weight", type: "number", required: true },
{ name: "maxPayloadWeight", label: "Max payload weight", type: "number", required: true },
{ name: "currentYardId", label: "Current Yard", type: "select", dynamicOptions: "yards" },
{ name: "status", label: "Status", type: "select", required: true, options: WAGON_STATUS_OPTIONS },
{ name: "notes", label: "Notes", type: "textarea" },
@@ -272,8 +280,6 @@ export const FLEET_RESOURCES: FleetResourceConfig[] = [
emptyValues: {
wagonNumber: "",
wagonTypeId: "",
tareWeight: 0,
maxPayloadWeight: 0,
currentYardId: "",
status: Freight.WagonStatus.Available,
notes: "",

View File

@@ -286,6 +286,9 @@ const toReleaseInventoryItem = (row: ImportUnloadedItem): WarehouseInventoryItem
handoverDocumentReference: row.handoverDocumentReference,
handoverDocumentDate: row.handoverDocumentDate,
deliveredAt: row.deliveredAt,
// Carries the saved [Exit Inspection] block so truck-leaving prefills the
// details captured at arrival (plate, driver, tare, gate-in).
notes: row.notes,
booking: row.bookingId
? {
id: row.bookingId,

View File

@@ -279,7 +279,7 @@ export const RULE_ENGINE_RESOURCES: RuleEngineResourceConfig[] = [
{ id: "name", header: "Name", accessorKey: "name" },
{ id: "capacityTons", header: "Capacity (t)", accessorKey: "capacityTons", format: "number" },
{ id: "lengthMeters", header: "Length (m)", accessorKey: "lengthMeters", format: "number" },
{ id: "maxWagonsPerTrain", header: "Max / train", accessorKey: "maxWagonsPerTrain", format: "number" },
{ id: "tareWeightTons", header: "Tare (t)", accessorKey: "tareWeightTons", format: "number" },
{
id: "supportedLoadTypes",
header: "Load types",
@@ -291,11 +291,14 @@ export const RULE_ENGINE_RESOURCES: RuleEngineResourceConfig[] = [
{ name: "name", label: "Name", type: "text", required: true },
{ name: "capacityTons", label: "Capacity (tons)", type: "number", required: true },
{ name: "lengthMeters", label: "Length (meters)", type: "number", required: true },
// The locomotive's pull limit is a GROSS limit, so capacity planning charges
// `cargo + wagons × tare` against it. The API rejects a create without this.
{
name: "maxWagonsPerTrain",
label: "Max wagons per train",
name: "tareWeightTons",
label: "Tare weight (tons)",
type: "number",
optional: true,
required: true,
description: "Empty wagon weight — counts against the locomotive's pull limit alongside the cargo",
},
{
name: "supportedLoadTypes",

View File

@@ -175,6 +175,7 @@ function CapacityChip({
);
}
/** Gross weight (wagon tare + cargo) against the locomotive's pull limit. */
function weightPctOf(s: BatchBoardSchedule) {
return s.capacity.maxWeightTons && s.capacity.maxWeightTons > 0
? (s.capacity.usedWeightTons / s.capacity.maxWeightTons) * 100
@@ -185,6 +186,11 @@ function lengthPctOf(s: BatchBoardSchedule) {
? (s.capacity.allocatedLengthMeters / s.capacity.maxLengthMeters) * 100
: null;
}
function wagonPctOf(s: BatchBoardSchedule) {
return s.capacity.maxWagons && s.capacity.maxWagons > 0
? (s.capacity.allocatedWagons / s.capacity.maxWagons) * 100
: null;
}
function ScheduleCard({ schedule }: { schedule: BatchBoardSchedule }) {
const navigate = useNavigate();
@@ -192,6 +198,7 @@ function ScheduleCard({ schedule }: { schedule: BatchBoardSchedule }) {
const lengthPct = lengthPctOf(schedule);
const weightPct = weightPctOf(schedule);
const wagonPct = wagonPctOf(schedule);
const totalBookings = totalBookingCount(counts);
@@ -275,7 +282,7 @@ function ScheduleCard({ schedule }: { schedule: BatchBoardSchedule }) {
</Alert>
) : null}
{/* capacity: weight + length rings + wagons (numbers preserved) */}
{/* capacity: the three axes a train is limited by — gross weight, wagon slots, length */}
<Box
py="sm"
px="xs"
@@ -289,26 +296,35 @@ function ScheduleCard({ schedule }: { schedule: BatchBoardSchedule }) {
{weightPct != null ? (
<CapacityRing
pct={weightPct}
label="WEIGHT"
label="GROSS WT"
current={fmtTons(capacity.usedWeightTons)}
max={fmtTons(capacity.maxWeightTons ?? 0)}
/>
) : null}
<Stack gap={0} align="center" style={{ flex: 1 }}>
<ThemeIcon size={34} radius="md" variant="light" color="gray">
<Package size={17} />
</ThemeIcon>
<Text fw={800} size="26px" c="dark.5" lh={1.1} mt={6}>
{capacity.allocatedWagons}
</Text>
<Text size="9px" fw={700} c="gray.6" tt="uppercase" style={{ letterSpacing: 0.5 }}>
Wagons
</Text>
<Text size="xs" c="dimmed">
allocated
</Text>
</Stack>
{wagonPct != null ? (
<CapacityRing
pct={wagonPct}
label="WAGONS"
current={String(capacity.allocatedWagons)}
max={String(capacity.maxWagons ?? 0)}
/>
) : (
<Stack gap={0} align="center" style={{ flex: 1 }}>
<ThemeIcon size={34} radius="md" variant="light" color="gray">
<Package size={17} />
</ThemeIcon>
<Text fw={800} size="26px" c="dark.5" lh={1.1} mt={6}>
{capacity.allocatedWagons}
</Text>
<Text size="9px" fw={700} c="gray.6" tt="uppercase" style={{ letterSpacing: 0.5 }}>
Wagons
</Text>
<Text size="xs" c="dimmed">
allocated
</Text>
</Stack>
)}
{lengthPct != null ? (
<CapacityRing
@@ -519,30 +535,42 @@ export default function BatchBoardPage() {
id: "capacity",
header: "Capacity",
meta: { headerClassName, cellClassName },
cell: ({ row }) => (
<Group gap={6} wrap="nowrap">
<CapacityChip icon={Weight} pct={weightPctOf(row.original)} text="wt" />
<CapacityChip icon={Ruler} pct={lengthPctOf(row.original)} text="len" />
<Group
gap={4}
wrap="nowrap"
style={{
padding: "2px 8px",
borderRadius: 8,
background: "var(--mantine-color-edr-green-0)",
border: "1px solid var(--mantine-color-edr-green-1)",
}}
>
<Package size={12} color="var(--mantine-color-edr-green-7)" />
<Text size="xs" fw={700} c="edr-green.8" lh={1.2}>
{row.original.capacity.allocatedWagons}
</Text>
<Text size="10px" c="dimmed" lh={1.2}>
wgn
</Text>
cell: ({ row }) => {
const { allocatedWagons, maxWagons } = row.original.capacity;
const wagonPct = wagonPctOf(row.original);
return (
<Group gap={6} wrap="nowrap">
<CapacityChip icon={Weight} pct={weightPctOf(row.original)} text="gross" />
<CapacityChip icon={Ruler} pct={lengthPctOf(row.original)} text="len" />
{wagonPct != null ? (
<CapacityChip
icon={Package}
pct={wagonPct}
text={`${allocatedWagons}/${maxWagons} wgn`}
/>
) : (
<Group
gap={4}
wrap="nowrap"
style={{
padding: "2px 8px",
borderRadius: 8,
background: "var(--mantine-color-edr-green-0)",
border: "1px solid var(--mantine-color-edr-green-1)",
}}
>
<Package size={12} color="var(--mantine-color-edr-green-7)" />
<Text size="xs" fw={700} c="edr-green.8" lh={1.2}>
{allocatedWagons}
</Text>
<Text size="10px" c="dimmed" lh={1.2}>
wgn
</Text>
</Group>
)}
</Group>
</Group>
),
);
},
},
{
id: "bookings",

View File

@@ -892,7 +892,9 @@ export default function BatchScheduleDetailPage() {
items={[
{
label: "Allocated wagons",
value: data.capacity.allocatedWagons,
value: data.capacity.maxWagons
? `${data.capacity.allocatedWagons} / ${data.capacity.maxWagons}`
: data.capacity.allocatedWagons,
hint: "on this train",
icon: Boxes,
},
@@ -904,10 +906,11 @@ export default function BatchScheduleDetailPage() {
icon: Ruler,
},
{
label: "Weight",
label: "Gross weight",
value: data.capacity.maxWeightTons
? `${fmtTons(data.capacity.usedWeightTons)} / ${fmtTons(data.capacity.maxWeightTons)}`
: fmtTons(data.capacity.usedWeightTons),
hint: "wagon tare + cargo",
icon: Weight,
},
{

View File

@@ -55,6 +55,13 @@ import { useToast } from "@/hooks/use-toast";
import type { TrainScheduleListItem } from "@/types/trainScheduling";
import { DataTable, DataTableFooter, usePagination } from "@edr/ui-common";
/** `min` for a `datetime-local` input: now, in the browser's local zone. */
const nowLocalDateTime = () => {
const now = new Date();
now.setMinutes(now.getMinutes() - now.getTimezoneOffset());
return now.toISOString().slice(0, 16);
};
const splitDate = (value?: string | null) => {
if (!value) return { day: "—", time: "" };
const date = new Date(value);
@@ -103,6 +110,12 @@ export default function TrainScheduleV2ListPage() {
const [routeId, setRouteId] = useState("");
const [scheduleDate, setScheduleDate] = useState("");
const [locomotiveIds, setLocomotiveIds] = useState<string[]>([]);
// Recomputed each time the create modal opens so a long-lived tab can't keep
// offering a stale "now" as the earliest selectable departure.
const minScheduleDate = useMemo(
() => (createOpen ? nowLocalDateTime() : ""),
[createOpen],
);
const schedulesQuery = useQuery(
api.trainScheduling.scheduleList.queryOptions({ input: {} }),
@@ -443,6 +456,13 @@ export default function TrainScheduleV2ListPage() {
});
return;
}
if (new Date(scheduleDate).getTime() < Date.now()) {
toast({
title: "Departure date must be in the future",
variant: "destructive",
});
return;
}
try {
const created = await create.mutateAsync({
payload: {
@@ -691,6 +711,7 @@ export default function TrainScheduleV2ListPage() {
<TextInput
label="Departure date"
type="datetime-local"
min={minScheduleDate}
value={scheduleDate}
onChange={(e) => setScheduleDate(e.currentTarget.value)}
/>

View File

@@ -107,6 +107,7 @@ export default function TrainSchedulingGlobalRulesPage() {
setForm((current) => ({ ...current, maxTrainLengthMeters: value }))
}
clampBehavior="none"
allowNegative={false}
allowDecimal
min={1}
disabled={loading}
@@ -119,6 +120,7 @@ export default function TrainSchedulingGlobalRulesPage() {
setForm((current) => ({ ...current, maxTrainWeightTons: value }))
}
clampBehavior="none"
allowNegative={false}
allowDecimal
min={1}
disabled={loading}
@@ -130,6 +132,7 @@ export default function TrainSchedulingGlobalRulesPage() {
setForm((current) => ({ ...current, maxWagonsPerTrain: value }))
}
clampBehavior="none"
allowNegative={false}
allowDecimal
min={1}
disabled={loading}
@@ -145,6 +148,7 @@ export default function TrainSchedulingGlobalRulesPage() {
}))
}
clampBehavior="none"
allowNegative={false}
allowDecimal
min={0.001}
disabled={loading}
@@ -160,6 +164,7 @@ export default function TrainSchedulingGlobalRulesPage() {
}))
}
clampBehavior="none"
allowNegative={false}
allowDecimal
min={0}
disabled={loading}
@@ -203,6 +208,7 @@ export default function TrainSchedulingGlobalRulesPage() {
setForm((current) => ({ ...current, windowOpenHour: value }))
}
clampBehavior="none"
allowNegative={false}
allowDecimal
min={0}
max={23}
@@ -216,6 +222,7 @@ export default function TrainSchedulingGlobalRulesPage() {
setForm((current) => ({ ...current, windowCloseHour: value }))
}
clampBehavior="none"
allowNegative={false}
allowDecimal
min={0}
max={23}

View File

@@ -102,6 +102,7 @@ import type {
LoadInventoryPayload,
LoadPassedExportResult,
MoveInventoryPayload,
StoreInventoryPayload,
PayInvoicePayload,
ReadyToLoadRow,
ReceiveInventoryPayload,
@@ -1054,10 +1055,13 @@ export const api = {
() => [["warehouse-inventory"], ["warehouses"]],
),
store: endpoint<string, WarehouseInventoryItem>(
store: endpoint<
{ id: string; payload?: StoreInventoryPayload },
WarehouseInventoryItem
>(
"warehouse-inventory",
"store",
(id) => warehouseService.store(id).then((r) => r.data),
({ id, payload }) => warehouseService.store(id, payload).then((r) => r.data),
undefined,
() => INVENTORY_INVALIDATIONS,
),

View File

@@ -482,10 +482,25 @@ export const contractsService = {
postContract<Freight.IContract>(C.OPS_CLEARANCE_FINALIZE(id)),
// ── Booking under contract (GL ET — Path B) ──
createBookingUnderContract: (
// The API returns { booking, warnings } (CreateBookingUnderContractResult) —
// unwrap to the booking itself so callers can use its id directly.
createBookingUnderContract: async (
id: string,
payload: Freight.CreateBookingUnderContractDto,
) => postContract<{ id: string; reference: string }>(C.BOOKINGS(id), payload),
): Promise<{ id: string; reference: string; warnings?: string[] }> => {
const result = await postContract<{
booking?: { id: string; reference: string };
id?: string;
reference?: string;
warnings?: string[];
}>(C.BOOKINGS(id), payload);
const booking = result.booking ?? result;
return {
id: booking.id ?? "",
reference: booking.reference ?? "",
warnings: result.warnings,
};
},
/**
* Pre-create validation + authoritative price preview: the same

View File

@@ -27,6 +27,10 @@ export interface Locomotive {
currentYard?: { id: string; label?: string; code?: string } | null;
maxPullWeightTons: number;
maxTrainLengthMeters: number;
/** Tons a train may exceed maxPullWeightTons by before scheduling blocks it. */
overageToleranceTons?: number | null;
/** Metres a train may exceed maxTrainLengthMeters by before scheduling blocks it. */
overageToleranceMeters?: number | null;
powerKw?: number | null;
tractionForceKn?: number | null;
maxSpeedKmh?: number | null;

View File

@@ -39,11 +39,16 @@ export interface SaveRoutePayload {
status?: RouteStatus;
}
/**
* Human-readable route label: yard names, not yard codes. Staff read
* "Addis Ababa → Dire Dawa", not "ADDIS_ABABA → DIRE_DAWA". Falls back to the
* code only when a yard has no label.
*/
export function formatRouteLabel(route: RouteRecord): string {
const origin =
route.originYard?.code ?? route.originYard?.label ?? 'Origin';
route.originYard?.label ?? route.originYard?.code ?? 'Origin';
const dest =
route.destinationYard?.code ?? route.destinationYard?.label ?? 'Destination';
route.destinationYard?.label ?? route.destinationYard?.code ?? 'Destination';
return `${origin}${dest}`;
}

View File

@@ -8,7 +8,8 @@ export interface WagonType {
name: string;
capacityTons: number;
lengthMeters: number;
maxWagonsPerTrain?: number | null;
/** Empty wagon weight; required, since pull limits apply to tare + cargo. */
tareWeightTons: number;
supportedLoadTypes: string[];
isActive: boolean;
}

View File

@@ -15,14 +15,16 @@ export interface Wagon {
label: string;
country?: string;
} | null;
/** Owns this wagon's spec — tare, capacity, length are read from here, never off the wagon. */
wagonType?: {
id: string;
code: string;
name: string;
supportedLoadTypes?: string[];
tareWeightTons?: number;
capacityTons?: number;
lengthMeters?: number;
} | null;
tareWeight: number;
maxPayloadWeight: number;
status: Freight.WagonStatus;
currentYardId: string | null;
currentYard?: { id: string; label?: string; code?: string } | null;

View File

@@ -30,6 +30,7 @@ import type {
LoadableWagon,
LoadInventoryPayload,
MoveInventoryPayload,
StoreInventoryPayload,
ReceiveInventoryPayload,
ReleaseOrderPayload,
DeliverInventoryPayload,
@@ -63,7 +64,7 @@ import type {
WarehouseZone,
} from '@/types/warehouse';
export type ContainerItemStage = 'PENDING' | 'RECEIVED' | 'GRN' | 'LOADED' | 'LEFT' | 'DELIVERED';
export type ContainerItemStage = 'PENDING' | 'RECEIVED' | 'GRN' | 'ASSIGNED' | 'LOADED' | 'LEFT' | 'DELIVERED';
export interface ContainerItem {
containerNumber: string;
@@ -74,9 +75,12 @@ export interface ContainerItem {
truckPlate: string | null;
truckArrived: boolean;
truckLeft: boolean;
/** Operator has loaded this container onto the truck (customer assignment alone is not "loaded"). */
loaded: boolean;
bookingReference: string | null;
contractId: string | null;
hasLastMile: boolean;
handoverSigned: boolean;
}
/** A pre-dispatch EXPORT train that has inventory waiting to be loaded. */
@@ -135,6 +139,26 @@ export const warehouseService = {
return data?.data ?? data ?? [];
},
/** Ask the customer to sign the booking's handover (creates one if none, then notifies). */
requestHandoverSignature: async (
bookingId: string,
): Promise<{ notified: boolean; reference: string | null; alreadySigned: boolean }> => {
const { data } = await apiClient.post(
`/warehouse-inventory/bookings/${bookingId}/request-handover-signature`,
);
return data?.data ?? data;
},
/** A booking's containers with VGM cargo weight (tonnes) for exit weighing. */
getContainerWeights: async (
bookingId: string,
): Promise<Array<{ containerNumber: string; weightTons: number }>> => {
const { data } = await apiClient.get(
`/warehouse-inventory/bookings/${bookingId}/container-weights`,
);
return data?.data ?? data ?? [];
},
/** Booking container numbers not yet loaded onto any truck. */
getLoadableContainers: async (bookingId: string): Promise<string[]> => {
const { data } = await apiClient.get(
@@ -235,8 +259,8 @@ export const warehouseService = {
}),
// ── Lifecycle (Batch 2) ──────────────────────────────────────────────────
store: (id: string) =>
apiClient.post<WarehouseInventoryItem>(URL_CONSTANTS.WAREHOUSE_INVENTORY.STORE(id)),
store: (id: string, payload?: StoreInventoryPayload) =>
apiClient.post<WarehouseInventoryItem>(URL_CONSTANTS.WAREHOUSE_INVENTORY.STORE(id), payload),
reserve: (payload: ReserveInventoryPayload) =>
apiClient.post<WarehouseInventoryItem>(URL_CONSTANTS.WAREHOUSE_INVENTORY.RESERVE, payload),
markReadyForLoading: (id: string) =>

View File

@@ -280,9 +280,14 @@ export interface BatchBoardSchedule {
capacity: {
allocatedWagons: number;
allocatedLengthMeters: number;
/** Train-length cap: locomotive floored by global rules, plus overage tolerance. */
maxLengthMeters: number | null;
/** GROSS tons on the train — wagon tare + cargo, since the pull limit hauls both. */
usedWeightTons: number;
/** Pull-weight cap: locomotive floored by global rules, plus overage tolerance. */
maxWeightTons: number | null;
/** Wagon-slot cap for the train, derived from train length and the shortest wagon type. */
maxWagons: number | null;
};
counts: {
allocated: number;

View File

@@ -41,7 +41,6 @@ export type InventoryStatus = (typeof INVENTORY_STATUSES)[number];
export type InventoryAction =
| 'store'
| 'reserve'
| 'ready-for-loading'
| 'load'
| 'dispatch'
@@ -59,7 +58,7 @@ export const INVENTORY_NEXT_ACTION: Record<InventoryStatus, InventoryAction | nu
UNLOADED: 'store',
UNLOADED_AT_DJIBOUTI_PORT: null,
RECEIVED: 'store',
STORED: 'reserve',
STORED: 'ready-for-loading',
RESERVED: 'ready-for-loading',
ARRIVED_AT_WAREHOUSE: null,
UNDER_INSPECTION: null,
@@ -85,6 +84,11 @@ export function getNextInventoryAction(item: WarehouseInventoryItem): InventoryA
// Import goods skip storage; they need inspection before pickup.
if (isImport) return inspected ? 'ready-for-pickup' : null;
return 'store';
case 'STORED':
// Reserve is retired: a stored export item goes straight to loading prep
// once inspection passes. Import STORED is handled via the import queue.
if (isImport) return null;
return inspected ? 'ready-for-loading' : null;
case 'RESERVED':
// Export loading is gated on a passed inspection.
return inspected ? 'ready-for-loading' : null;
@@ -589,12 +593,21 @@ export interface ImportUnloadedItem {
customerTruckType: string | null;
customerTruckContainerNumber: string | null;
customerTruckAssignedAt: string | null;
hasAssignedTruck: boolean;
currentStatus: string;
releaseDate: string | null;
releaseOrderReference: string | null;
handoverDocumentReference: string | null;
handoverDocumentDate: string | null;
deliveredAt: string | null;
notes: string | null;
}
/** Optional explicit storage location; blank → backend auto-allocates. */
export interface StoreInventoryPayload {
warehouseId?: string;
yardId?: string;
zoneId?: string;
}
export interface ImportTrainItem {