mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-30 12:18:11 +00:00
Merge branch 'dev' into freight/feat/fixes-v1
This commit is contained in:
@@ -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"
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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
|
||||
/>
|
||||
|
||||
@@ -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 & 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 & book
|
||||
</Button>
|
||||
</Group>
|
||||
</Box>
|
||||
</Box>
|
||||
|
||||
<Modal
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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}
|
||||
/>
|
||||
)}
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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)}
|
||||
/>
|
||||
|
||||
@@ -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;
|
||||
@@ -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">
|
||||
|
||||
@@ -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)}
|
||||
/>
|
||||
|
||||
@@ -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";
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)}
|
||||
|
||||
@@ -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)}
|
||||
|
||||
@@ -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'}>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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',
|
||||
|
||||
@@ -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);
|
||||
};
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -176,6 +176,7 @@ export default function DocumentClearanceDetailPage() {
|
||||
hideSummary
|
||||
approvalsLocked={isPhasedGeneral && docsPhaseComplete}
|
||||
queriesLocked={queriesLocked}
|
||||
phasedCustoms={isPhasedGeneral}
|
||||
onChanged={() => void refetch()}
|
||||
/>
|
||||
</Grid.Col>
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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!}
|
||||
|
||||
@@ -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>
|
||||
)}
|
||||
|
||||
@@ -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: '',
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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: "",
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
{
|
||||
|
||||
@@ -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)}
|
||||
/>
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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,
|
||||
),
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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}`;
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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) =>
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user