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

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