Merge branch 'dev' of github.com:Tria-plc/edr-platform into origin/freight_feature/transit

This commit is contained in:
marshal
2026-09-02 22:38:15 +00:00
425 changed files with 30919 additions and 3238 deletions

View File

@@ -96,6 +96,8 @@ import TrainBuilderDetailPage from "./pages/trainBuilder/TrainBuilderDetailPage"
import TrainBuilderListPage from "./pages/trainBuilder/TrainBuilderListPage";
import ArrivalQueuePage from "./pages/warehouses/ArrivalQueuePage";
import ContainerReturnsPage from "./pages/warehouses/ContainerReturnsPage";
import EmptyReturnRequestsPage from "./pages/warehouses/EmptyReturnRequestsPage";
import RegisterFullContainersPage from "./pages/warehouses/RegisterFullContainersPage";
import DispatchQueuePage from "./pages/warehouses/DispatchQueuePage";
import ExportDjiboutiUnloadingQueuePage from "./pages/warehouses/ExportDjiboutiUnloadingQueuePage";
import ExportWarehouseFlowPage from "./pages/warehouses/ExportWarehouseFlowPage";
@@ -708,6 +710,29 @@ const App = () => {
</RequirePermission>
}
/>
<Route
path="empty-return-requests"
element={
<RequirePermission
permission={[
FREIGHT_PERMS.emptyReturnRequests.view,
FREIGHT_PERMS.warehouseInventory.view,
]}
>
<EmptyReturnRequestsPage />
</RequirePermission>
}
/>
<Route
path="register-full-containers"
element={
<RequirePermission
permission={FREIGHT_PERMS.warehouseInventory.receive}
>
<RegisterFullContainersPage />
</RequirePermission>
}
/>
<Route
path="loaded-inventory"
element={

View File

@@ -1,3 +1,5 @@
import { POSITION_COOKIE } from "@/shared/utils/positionCookie";
const DEFAULT_PATH = "/";
const SEVEN_DAYS_IN_SECONDS = 60 * 60 * 24 * 7;
@@ -32,6 +34,8 @@ export const clearSessionCookies = () => {
AUTH_TOKEN_COOKIE,
REFRESH_TOKEN_COOKIE,
AUTH_USER_COOKIE,
POSITION_COOKIE,
// Pre-rename name, still cleared so a stale value cannot outlive logout.
"current-position-id",
"selected-position-id",
].forEach(clearCookie);

View File

@@ -1,12 +1,32 @@
import { Banknote, Receipt } from "lucide-react";
import { Divider, Group, Paper, Stack, Text } from "@mantine/core";
import { useQuery } from "@tanstack/react-query";
import { api } from "@/services/api";
import type { BookingDetail } from "@/types/booking";
import { SectionCard } from "./detail/SectionCard";
import { detailStyles } from "./detail/booking-detail.styles";
export function BookingPricingSummary({ booking }: { booking: BookingDetail }) {
// The booking's own freight invoice: source `booking`, sourceId = booking id
// (which `search` matches). Newest first — a re-issue supersedes the old one.
const invoiceQuery = useQuery(
api.invoices.list.queryOptions({
input: {
filter: {
page: 1,
pageSize: 1,
sources: "booking",
search: booking.id,
sortBy: "createdAt",
sortOrder: "DESC",
},
},
}),
);
const invoiceNumber = invoiceQuery.data?.items[0]?.invoiceNumber ?? null;
const computed = Number(booking.totalAmount);
// The booking price is computed from the contract and is NOT staff-editable.
// A historical `adjustedTotalAmount` (from before adjustments were removed)
@@ -49,6 +69,7 @@ export function BookingPricingSummary({ booking }: { booking: BookingDetail }) {
</Paper>
<Row label="Payment status" value={booking.paymentStatus} />
{invoiceNumber && <Row label="Invoice number" value={invoiceNumber} mono />}
{booking.pnrCode && <Row label="PNR code" value={booking.pnrCode} mono />}
{lineItems.length > 0 && (

View File

@@ -0,0 +1,235 @@
import { useEffect, useState } from "react";
import { Button, Group, Modal, Select, Stack, Text, TextInput } from "@mantine/core";
import { DatePickerInput } from "@mantine/dates";
import { useMutation, useQuery } from "@tanstack/react-query";
import toast from "react-hot-toast";
import { api } from "@/auth/http";
import { toDayString } from "@/hooks/useListControls";
import { formatMoney } from "@/lib/format";
import {
hasOddFt20,
type RebookPartnerCandidate,
type WagonCancellation,
} from "./types";
/** Editable rebook unit — prefilled from the cancelled snapshot. */
interface RebookUnitDraft {
containerSize: string;
containerNumber: string;
sealNumber: string;
vgmTons: number | "";
}
const draftsFrom = (r: WagonCancellation): RebookUnitDraft[] =>
(r.cancelledQuantities?.units ?? []).map((u) => ({
containerSize: u.containerSize,
containerNumber: u.containerNumber,
sealNumber: u.sealNumber ?? "",
vgmTons: Number(u.vgmTons) || "",
}));
const containersPayload = (drafts: RebookUnitDraft[]) => {
const bySize = new Map<string, RebookUnitDraft[]>();
for (const d of drafts) {
bySize.set(d.containerSize, [...(bySize.get(d.containerSize) ?? []), d]);
}
return [...bySize.entries()].map(([containerSize, units]) => ({
containerSize,
units: units.map((u) => ({
containerNumber: u.containerNumber.trim(),
...(u.sealNumber.trim() ? { sealNumber: u.sealNumber.trim() } : {}),
...(u.vgmTons !== "" ? { vgmTons: Number(u.vgmTons) } : {}),
})),
}));
};
/**
* Staff/GL rebook of a CREDIT_AVAILABLE wagon cancellation: pick the shipment
* day, correct container details if they changed, and — for an odd-20ft
* credit — pick the consolidation partner that shares the wagon. The server
* creates the new booking under the contract and marks it PAID from the credit.
* Used by the wagon-cancellations list, the GL clearance page and the staff
* booking page, so every desk gets the same flow.
*/
export function RebookWagonCancellationModal({
cancellation,
onClose,
onRebooked,
}: {
cancellation: WagonCancellation | null;
onClose: () => void;
/** Called after a successful rebook with the new booking id (when the API returns it). */
onRebooked?: (result: { bookingId?: string }) => void;
}) {
const [date, setDate] = useState<Date | null>(null);
const [partnerId, setPartnerId] = useState<string | null>(null);
const [drafts, setDrafts] = useState<RebookUnitDraft[]>([]);
// Fresh form per row: the modal instance is long-lived on the host page.
useEffect(() => {
setDate(null);
setPartnerId(null);
setDrafts(cancellation ? draftsFrom(cancellation) : []);
}, [cancellation]);
const needsPartner = cancellation ? hasOddFt20(cancellation) : false;
const partners = useQuery({
queryKey: [
"wagon-cancellations",
cancellation?.id,
"rebook-partners",
date ? toDayString(date) : null,
],
enabled: Boolean(cancellation && needsPartner && date),
queryFn: async () => {
const res = await api.get<RebookPartnerCandidate[]>(
`/bookings/wagon-cancellations/${cancellation!.id}/rebook-partners`,
{ params: { scheduledDate: toDayString(date!) } },
);
return res.data;
},
});
const rebook = useMutation({
mutationFn: async () => {
const res = await api.post<{ bookingId?: string }>(
`/bookings/wagon-cancellations/${cancellation!.id}/rebook`,
{
scheduledDate: toDayString(date!),
...(drafts.length ? { containers: containersPayload(drafts) } : {}),
...(partnerId ? { partnerBookingId: partnerId } : {}),
},
);
return res.data ?? {};
},
});
const patchDraft = (i: number, patch: Partial<RebookUnitDraft>) =>
setDrafts((prev) => prev.map((x, idx) => (idx === i ? { ...x, ...patch } : x)));
return (
<Modal
opened={!!cancellation}
onClose={onClose}
title="Rebook cancelled wagons"
centered
radius="md"
>
{cancellation && (
<Stack gap="sm">
<Text size="sm">
{cancellation.booking?.reference ?? cancellation.bookingId} ·{" "}
{cancellation.wagonsCancelled} wagon(s) · credit{" "}
{formatMoney(cancellation.creditAmount, cancellation.feeCurrency, 2)}
</Text>
<DatePickerInput
label="Shipment day"
placeholder="Pick the day"
value={date}
onChange={(v) => {
setDate(v ? new Date(v) : null);
setPartnerId(null);
}}
minDate={new Date()}
radius="md"
/>
{needsPartner && (
<Select
label="Consolidation partner"
description="This credit has an odd 20ft container — pick the odd booking that shares its wagon. The rebooked booking is paid; it ships once the partner pays."
placeholder={
!date
? "Pick the day first"
: partners.isLoading
? "Loading…"
: "Pick the partner booking"
}
data={(partners.data ?? []).map((c) => ({
value: c.id,
label: `${c.reference} · ${c.companyName ?? "—"} · ${c.ft20Quantity}×20ft`,
}))}
value={partnerId}
onChange={setPartnerId}
disabled={!date}
searchable
radius="md"
/>
)}
{needsPartner &&
date &&
!partners.isLoading &&
(partners.data ?? []).length === 0 && (
<Text size="xs" c="orange">
No odd-20ft booking rides that day pick another day or wait for
a partner booking.
</Text>
)}
{drafts.length > 0 && (
<Stack gap={6}>
<Text size="xs" c="dimmed">
Correct the container details if they changed sizes and
quantities stay as cancelled.
</Text>
{drafts.map((d, i) => (
<Group key={i} gap={8} wrap="nowrap" align="flex-end">
<TextInput
label={`${d.containerSize} container`}
value={d.containerNumber}
onChange={(e) => patchDraft(i, { containerNumber: e.currentTarget.value })}
size="xs"
radius="md"
style={{ flex: 1.4 }}
/>
<TextInput
label="Seal no."
value={d.sealNumber}
onChange={(e) => patchDraft(i, { sealNumber: e.currentTarget.value })}
size="xs"
radius="md"
style={{ flex: 1 }}
/>
<TextInput
label="VGM (t)"
type="number"
value={d.vgmTons === "" ? "" : String(d.vgmTons)}
onChange={(e) => {
const raw = e.currentTarget.value;
patchDraft(i, { vgmTons: raw === "" ? "" : Number(raw) });
}}
size="xs"
radius="md"
style={{ width: 90 }}
/>
</Group>
))}
</Stack>
)}
<Group justify="flex-end" gap="sm">
<Button variant="default" radius="md" onClick={onClose}>
Close
</Button>
<Button
color="green"
radius="md"
disabled={!date || (needsPartner && !partnerId)}
loading={rebook.isPending}
onClick={async () => {
try {
const result = await rebook.mutateAsync();
toast.success("Credit rebooked as a new paid booking");
onClose();
onRebooked?.(result);
} catch {
// interceptor surfaces the reason
}
}}
>
Rebook
</Button>
</Group>
</Stack>
)}
</Modal>
);
}

View File

@@ -0,0 +1,141 @@
import { useState } from "react";
import { Badge, Button, Group, Stack, Text } from "@mantine/core";
import { useQuery, useQueryClient } from "@tanstack/react-query";
import { RotateCcw } from "lucide-react";
import { Link } from "react-router-dom";
import { api } from "@/auth/http";
import { useAuth } from "@/auth/useAuth";
import { SectionCard } from "@/components/bookings/detail/SectionCard";
import { formatDate, formatMoney } from "@/lib/format";
import { RebookWagonCancellationModal } from "./RebookWagonCancellationModal";
import {
canRebookWagonCancellations,
isRebookableCredit,
WAGON_CANCELLATION_STATUS_CHIP,
type WagonCancellation,
type WagonCancellationListResponse,
} from "./types";
/**
* Per-booking wagon-cancellation ledger with the GL/staff "Rebook" action.
* Rendered on the booking pages GL and staff actually work from, so a credit
* never sits unredeemed just because the customer cannot rebook it from the
* portal (customs bookings, odd-20ft credits) — whoever cancelled the wagons
* and whichever side was at fault. Renders nothing when the booking has no
* cancellations.
*/
export function WagonCancellationCreditCard({
bookingId,
onRebooked,
}: {
bookingId: string;
onRebooked?: () => void;
}) {
const { user } = useAuth();
const qc = useQueryClient();
const canRebook = canRebookWagonCancellations(user);
const [rebooking, setRebooking] = useState<WagonCancellation | null>(null);
const { data, refetch } = useQuery({
queryKey: ["bookings", bookingId, "wagon-cancellations"],
queryFn: async () => {
const res = await api.get<WagonCancellationListResponse | WagonCancellation[]>(
`/bookings/${bookingId}/wagon-cancellations`,
);
const body = res.data;
return Array.isArray(body) ? body : (body?.items ?? []);
},
});
const rows = data ?? [];
if (rows.length === 0) return null;
return (
<>
<SectionCard
icon={RotateCcw}
title="Cancelled wagons"
subtitle="Credits from cancelled wagons are rebooked here on the customer's behalf."
accent="grape"
>
<Stack gap="sm">
{rows.map((r) => {
const chip = WAGON_CANCELLATION_STATUS_CHIP[r.status] ?? {
label: r.status,
color: "gray",
};
const feeOpen =
r.fault === "CUSTOMER" && Number(r.feeAmount) > 0 && !r.feePaidAt;
return (
<Group key={r.id} justify="space-between" align="flex-start" wrap="nowrap">
<Stack gap={2} style={{ minWidth: 0 }}>
<Group gap={8} wrap="nowrap">
<Text size="sm" fw={600}>
{Number(r.wagonsCancelled)} wagon(s) · credit{" "}
{formatMoney(Number(r.creditAmount), r.feeCurrency, 2)}
</Text>
<Badge color={chip.color} variant="light" size="sm" radius="md">
{chip.label}
</Badge>
</Group>
<Text size="xs" c="dimmed">
Cancelled {formatDate(r.createdAt)}
{r.fault ? ` · ${r.fault === "EDR" ? "EDR fault (no fee)" : "customer fault"}` : ""}
{Number(r.feeAmount) > 0
? ` · fee ${formatMoney(Number(r.feeAmount), r.feeCurrency, 2)}${
r.feePaidAt ? " paid" : " unpaid"
}`
: ""}
{r.reason ? ` · ${r.reason}` : ""}
</Text>
{r.status === "FEE_PENDING" && (
<Text size="xs" c="orange">
The customer must pay the cancellation fee from the portal
Payments tab before the credit can be rebooked.
</Text>
)}
{isRebookableCredit(r) && feeOpen && (
<Text size="xs" c="orange">
The cancellation fee invoice is still open the rebook is
allowed once it is paid.
</Text>
)}
{r.rebookedBookingId && (
<Text size="xs">
Rebooked as{" "}
<Link to={`/dashboard/booking-requests/${r.rebookedBookingId}`}>
{r.rebookedBooking?.reference ?? r.rebookedBookingId}
</Link>
</Text>
)}
</Stack>
{isRebookableCredit(r) && canRebook && (
<Button
size="xs"
radius="md"
variant="light"
color="green"
leftSection={<RotateCcw size={14} />}
onClick={() => setRebooking(r)}
>
Rebook
</Button>
)}
</Group>
);
})}
</Stack>
</SectionCard>
<RebookWagonCancellationModal
cancellation={rebooking}
onClose={() => setRebooking(null)}
onRebooked={() => {
void refetch();
void qc.invalidateQueries({ queryKey: ["bookings"] });
onRebooked?.();
}}
/>
</>
);
}

View File

@@ -0,0 +1,3 @@
export * from "./types";
export * from "./RebookWagonCancellationModal";
export * from "./WagonCancellationCreditCard";

View File

@@ -0,0 +1,93 @@
import type { AuthUser } from "@/auth/types";
import { FREIGHT_PERMS, hasPermission, isDjiboutiGl } from "@/lib/permissions";
export type WagonCancellationStatus =
| "FEE_PENDING"
| "CREDIT_AVAILABLE"
| "REBOOKED"
| "WITHDRAWN"
| "EXPIRED";
export interface WagonCancellation {
id: string;
bookingId: string;
rebookedBookingId?: string | null;
wagonsCancelled: number;
weightTons: number;
creditAmount: number;
feeAmount: number;
feeCurrency: string;
feeInvoiceId?: string | null;
feePaidAt?: string | null;
fault?: "CUSTOMER" | "EDR" | string | null;
status: WagonCancellationStatus;
reason?: string | null;
rebookedAt?: string | null;
createdAt: string;
booking?: {
id: string;
reference: string;
customsClearingEnabled?: boolean;
company?: { name: string };
};
rebookedBooking?: { id: string; reference: string };
feeInvoice?: { invoiceNumber: string; status: string };
cancelledQuantities?: {
bySize?: Record<string, number>;
units?: Array<{
containerSize: string;
containerNumber: string;
sealNumber?: string | null;
vgmTons: number;
}>;
};
}
export interface WagonCancellationListResponse {
items: WagonCancellation[];
total: number;
}
export interface RebookPartnerCandidate {
id: string;
reference: string;
companyName: string | null;
status: string;
scheduledDate: string | null;
ft20Quantity: number;
}
export const WAGON_CANCELLATION_STATUS_CHIP: Record<
WagonCancellationStatus,
{ label: string; color: string }
> = {
FEE_PENDING: { label: "Fee pending", color: "yellow" },
CREDIT_AVAILABLE: { label: "Credit available", color: "edr-green" },
REBOOKED: { label: "Rebooked", color: "indigo" },
WITHDRAWN: { label: "Withdrawn", color: "gray" },
EXPIRED: { label: "Expired", color: "red" },
};
/** Odd 20ft in the credit ⇒ the rebooked booking shares a wagon and GL must pick the partner. */
export const hasOddFt20 = (r: WagonCancellation): boolean =>
Object.entries(r.cancelledQuantities?.bySize ?? {})
.filter(([size]) => parseInt(size, 10) === 20)
.reduce((sum, [, qty]) => sum + Number(qty || 0), 0) %
2 ===
1;
/**
* Who may redeem a cancelled-wagon credit from the backoffice: anyone holding
* the dedicated rebook key, plus GL Ethiopia through its booking-creation key
* (a rebook is a new booking under the contract). GL Djibouti never creates
* bookings. Mirrors the API's rebook gate.
*/
export const canRebookWagonCancellations = (
user: AuthUser | null | undefined,
): boolean =>
hasPermission(user, FREIGHT_PERMS.bookings.wagonCancellationRebook) ||
(hasPermission(user, FREIGHT_PERMS.contracts.createBooking) && !isDjiboutiGl(user));
/** A CREDIT_AVAILABLE row with real credit — whoever cancelled and whoever was at fault. */
export const isRebookableCredit = (r: WagonCancellation): boolean =>
r.status === "CREDIT_AVAILABLE" && Number(r.creditAmount) > 0;

View File

@@ -118,6 +118,7 @@ const blockNegative = (event: KeyboardEvent<HTMLInputElement>) => {
interface UnitErrors {
containerNumber?: string;
sealNumber?: string;
vgmTons?: string;
}
@@ -886,6 +887,11 @@ export default function GlCreateBookingForm() {
} else if ((numberCounts.get(key) ?? 0) > 1) {
errs.containerNumber = "Duplicate container number in this shipment.";
}
// Every container ships sealed and the yard checks the seal against
// the booking — required alongside number and VGM (portal parity).
if (!u.sealNumber.trim()) {
errs.sealNumber = "Seal number is required.";
}
const vgm = Number(u.vgmTons);
if (u.vgmTons.trim() === "" || Number.isNaN(vgm) || vgm <= 0) {
errs.vgmTons = "Enter a valid VGM.";
@@ -1047,6 +1053,12 @@ export default function GlCreateBookingForm() {
const dateError =
!isIntercity && !scheduledDate ? "Select a shipment date." : undefined;
// EXPORT completion locks the booking onto a train. Only raised once a day is
// chosen — the picker is hidden until then and the date error covers it.
const trainError =
isExportPick && scheduledDate && !trainScheduleId
? "Select a train for the shipment day."
: undefined;
const routeError =
multiRoute && !contractRouteId ? "Select a route." : undefined;
@@ -1065,7 +1077,7 @@ export default function GlCreateBookingForm() {
!e.returnQuantity,
) &&
unitErrors.every((line) =>
line.every((e) => !e.containerNumber && !e.vgmTons),
line.every((e) => !e.containerNumber && !e.sealNumber && !e.vgmTons),
) &&
!cargoDescriptionError
: !bulkErrors.quantity &&
@@ -1112,11 +1124,12 @@ export default function GlCreateBookingForm() {
line.units.some(
(u) =>
!ISO_CONTAINER_NUMBER_REGEX.test(u.containerNumber.trim().toUpperCase()) ||
!u.sealNumber.trim() ||
!(Number(u.vgmTons) > 0),
),
);
if (badUnit) {
return `Every ${partner.reference} container needs a valid container number and a VGM above 0.`;
return `Every ${partner.reference} container needs a valid container number, a seal number and a VGM above 0.`;
}
if (!partnerCargoDescription.trim()) {
return `Describe the cargo carried in ${partner.reference}'s containers.`;
@@ -1142,6 +1155,7 @@ export default function GlCreateBookingForm() {
cargoValid &&
!oddBlocksSubmit &&
!dateError &&
!trainError &&
!routeError &&
!partnerError &&
!currencyError;
@@ -1185,7 +1199,7 @@ export default function GlCreateBookingForm() {
: {}),
units: l.units.map((u) => ({
containerNumber: u.containerNumber.trim().toUpperCase(),
...(u.sealNumber ? { sealNumber: u.sealNumber } : {}),
sealNumber: u.sealNumber.trim(),
vgmTons: Number(u.vgmTons) || 0,
// Per-container handling — the server rolls these into the line
// counts and bills each surcharge on the ticked containers only.
@@ -1247,7 +1261,7 @@ export default function GlCreateBookingForm() {
reeferQuantity: Number(l.reeferQuantity || 0) || undefined,
units: l.units.map((u) => ({
containerNumber: u.containerNumber.trim().toUpperCase(),
...(u.sealNumber ? { sealNumber: u.sealNumber } : {}),
sealNumber: u.sealNumber.trim(),
vgmTons: Number(u.vgmTons) || 0,
isHazardous: Boolean(u.isHazardous),
isReefer: Boolean(u.isReefer),
@@ -1857,7 +1871,7 @@ export default function GlCreateBookingForm() {
Container number *
</Text>
<Text fz={12} fw={600} c="#10202F" style={{ flex: 1 }}>
Seal number
Seal number *
</Text>
<Text fz={12} fw={600} c="#10202F" style={{ flex: 1 }}>
VGM (tons) *
@@ -1901,8 +1915,13 @@ export default function GlCreateBookingForm() {
style={{ flex: 1 }}
/>
<TextInput
placeholder="Optional"
placeholder="e.g. SL0123456"
value={unit.sealNumber}
error={
showErrors
? unitErrors[lineIdx]?.[unitIdx]?.sealNumber
: undefined
}
onChange={(e) =>
patchUnit(lineIdx, unitIdx, {
sealNumber: e.currentTarget.value,
@@ -2305,12 +2324,19 @@ export default function GlCreateBookingForm() {
</Text>
)}
{isExportPick && scheduledDate ? (
<ExportTrainPicker
options={exportTrainsQuery.data ?? []}
loading={exportTrainsQuery.isLoading}
value={trainScheduleId}
onChange={setTrainScheduleId}
/>
<>
<ExportTrainPicker
options={exportTrainsQuery.data ?? []}
loading={exportTrainsQuery.isLoading}
value={trainScheduleId}
onChange={setTrainScheduleId}
/>
{showErrors && trainError && (
<Text fz="xs" c="red" mt={6}>
{trainError}
</Text>
)}
</>
) : null}
</Box>
</StepCard>
@@ -2401,7 +2427,11 @@ export default function GlCreateBookingForm() {
style={{ flexShrink: 0 }}
/>
<Text fz={13} fw={500} c="#C0392B">
Fix the highlighted fields to review the price.
{trainError
? "Select a train for the shipment day to review the price."
: dateError && isExportPick
? "Select a shipment day and a train to review the price."
: "Fix the highlighted fields to review the price."}
</Text>
</>
)}

View File

@@ -196,8 +196,13 @@ export function ConsolidationPartnerPanel({
}
/>
<TextInput
label="Seal number"
label="Seal number *"
value={unit.sealNumber}
error={
showErrors && !unit.sealNumber.trim()
? "Seal number is required."
: undefined
}
onChange={(e) =>
patchUnit(lineIdx, unitIdx, {
sealNumber: e.currentTarget.value,

View File

@@ -151,6 +151,11 @@ export async function parseContainerExcel(
numberCounts.set(containerNumber, (numberCounts.get(containerNumber) ?? 0) + 1);
}
const sealNumber = cell("sealNumber");
if (!sealNumber) {
errors.push(`Row ${rowNo}: seal number is required.`);
}
const vgmRaw = cell("vgmTons");
const vgm = Number(vgmRaw);
if (!vgmRaw || Number.isNaN(vgm) || vgm <= 0) {
@@ -160,7 +165,7 @@ export async function parseContainerExcel(
rows.push({
containerSize: size ?? "",
containerNumber,
sealNumber: cell("sealNumber"),
sealNumber,
vgmTons: vgmRaw,
hazardous: opts.includeHazardous && parseFlag(cell("hazardous")),
reefer: opts.includeReefer && parseFlag(cell("reefer")),

View File

@@ -0,0 +1,188 @@
import {
Avatar,
Badge,
Box,
Card,
Divider,
Group,
Stack,
Text,
} from "@mantine/core";
import { AtSign, Phone, ShieldAlert, UserRound } from "lucide-react";
import type { CustomerAccount } from "@/types/customer";
import { formatDate, humanize } from "./format";
/** First letters of the person's name; falls back to the login initial. */
function initials(account: CustomerAccount): string {
const letters = [account.firstName, account.lastName]
.map((n) => n?.trim()?.[0])
.filter(Boolean)
.join("");
return (letters || account.username?.[0] || "?").toUpperCase();
}
/** A labelled value; rendered only when there is something to show. */
function Field({
icon,
label,
value,
after,
}: {
icon: React.ReactNode;
label: string;
value?: string | null;
after?: React.ReactNode;
}) {
if (!value?.trim()) return null;
return (
<Group gap={10} wrap="nowrap" align="flex-start">
<Box c="edr-muted" mt={2}>
{icon}
</Box>
<Box style={{ minWidth: 0 }}>
<Text size="xs" c="edr-muted">
{label}
</Text>
<Group gap={6} wrap="wrap">
<Text size="sm" c="edr-text" style={{ wordBreak: "break-word" }}>
{value}
</Text>
{after}
</Group>
</Box>
</Group>
);
}
/**
* One portal login belonging to a customer.
*
* Distinct from the contact details on the Overview tab: those are the business
* contact info on the company row, this is the credential someone actually
* signs in with — the two drift apart routinely, and staff answering "the
* customer can't log in" need this one.
*/
export function AccountCard({ account }: { account: CustomerAccount }) {
const name =
`${account.firstName ?? ""} ${account.lastName ?? ""}`.trim() ||
account.username ||
"Unnamed account";
// No IAM row at all — the profile points at a user that is gone. Treated as a
// fault rather than a status: nothing below it can be trusted, so the card
// says so once, loudly, instead of drawing empty credential fields.
const orphaned = account.username === null;
return (
<Card>
<Stack gap="sm">
<Group gap="sm" wrap="nowrap" align="flex-start">
<Avatar radius="xl" color="edr-green" variant="light">
{initials(account)}
</Avatar>
<Box style={{ minWidth: 0, flex: 1 }}>
<Group gap={6} wrap="wrap">
<Text fw={600} c="edr-text" style={{ wordBreak: "break-word" }}>
{name}
</Text>
{account.isPrimaryContact && (
<Badge size="xs" color="edr-green" variant="light">
Primary contact
</Badge>
)}
</Group>
{account.jobTitle && (
<Text size="xs" c="edr-muted">
{account.jobTitle}
</Text>
)}
</Box>
</Group>
<Group gap={6} wrap="wrap">
{orphaned ? (
<Badge
size="xs"
color="red"
variant="light"
leftSection={<ShieldAlert size={11} />}
>
No IAM account
</Badge>
) : (
<>
<Badge
size="xs"
color={account.isActive ? "edr-green" : "orange"}
variant="light"
>
{account.isActive ? "Active" : "Inactive"}
</Badge>
{account.status && (
<Badge size="xs" color="gray" variant="light">
{humanize(account.status)}
</Badge>
)}
{/* Created but never activated by its owner — usually the actual
answer to "they say they never got in". */}
{account.hasSetPassword === false && (
<Badge size="xs" color="yellow" variant="light">
Password never set
</Badge>
)}
</>
)}
{account.onboardingCompleted ? (
<Badge size="xs" color="edr-green" variant="light">
Onboarding submitted
</Badge>
) : (
<Badge size="xs" color="yellow" variant="light">
Onboarding
{account.onboardingStep
? ` · ${humanize(account.onboardingStep)}`
: " in progress"}
</Badge>
)}
</Group>
{!orphaned && (
<>
<Divider />
<Stack gap="xs">
<Field
icon={<UserRound size={14} />}
label="Username"
value={account.username}
/>
<Field
icon={<AtSign size={14} />}
label="Email"
value={account.email}
/>
<Field
icon={<Phone size={14} />}
label="Phone"
value={account.phoneNumber}
after={
account.phoneVerified === false ? (
<Badge size="xs" color="gray" variant="light">
Unverified
</Badge>
) : undefined
}
/>
</Stack>
</>
)}
<Text size="xs" c="edr-muted">
Created {formatDate(account.createdAt)}
</Text>
</Stack>
</Card>
);
}
export default AccountCard;

View File

@@ -174,6 +174,10 @@ export function DiffRow({
from: string;
to: string;
}) {
// No real "before" (field went from unset straight to a value, e.g. the
// onboarding wizard's first save) — show the value alone rather than a
// fake "— → value" that implies a prior state that never existed.
const hadBefore = from !== "—";
const changed = from !== to;
return (
<Stack gap={2}>
@@ -181,24 +185,24 @@ export function DiffRow({
{label}
</Text>
<Group gap={8} wrap="nowrap" align="center">
<Text
size="sm"
c="dimmed"
td={changed ? "line-through" : undefined}
style={{ wordBreak: "break-word" }}
>
{from}
</Text>
{changed && (
<>
<Text size="sm" c="edr-muted">
</Text>
<Text size="sm" fw={600} c="edr-text">
{to}
</Text>
</>
{hadBefore && (
<Text
size="sm"
c="dimmed"
td={changed ? "line-through" : undefined}
style={{ wordBreak: "break-word" }}
>
{from}
</Text>
)}
{hadBefore && changed && (
<Text size="sm" c="edr-muted">
</Text>
)}
<Text size="sm" fw={changed ? 600 : undefined} c={changed ? "edr-text" : "dimmed"}>
{to}
</Text>
</Group>
</Stack>
);

View File

@@ -3,6 +3,12 @@ import type { ReactNode } from "react";
export interface TableCardProps {
children: ReactNode;
/**
* Optional heading row (title, chips, actions). Rendered in its own padded
* section above the table and OUTSIDE the scroll region — a header inside it
* would slide away from its own table on a narrow viewport.
*/
header?: ReactNode;
/**
* Minimum width (px) the table is forced to occupy. The Mantine `Table` is
* always `width: 100%`, so without a floor it can never overflow its
@@ -14,14 +20,27 @@ export interface TableCardProps {
}
/**
* Flush card shell for a `DataTable`: a borderless, padding-less card whose
* single child is a horizontally scrollable region. Pair with the table's
* `containerClassName="border-0 shadow-none bg-transparent"` so every table on
* the customer pages reads identically (same surface, same scroll behaviour).
* Flush card shell for a `DataTable`: a padding-less card whose table region
* runs edge to edge. Padding is applied per section rather than to the card, so
* the optional {@link TableCardProps.header} is inset like any other card
* content while the table's own rows and header cells reach both edges.
*
* Pair with the table's `containerClassName="border-0 shadow-none bg-transparent"`
* so every table on the customer pages reads identically (same surface, same
* scroll behaviour).
*/
export function TableCard({ children, minWidth = 860 }: TableCardProps) {
export function TableCard({
children,
minWidth = 860,
header,
}: TableCardProps) {
return (
<Card p={0}>
{header && (
<Box p="md" style={{ borderBottom: "1px solid var(--mantine-color-edr-border-0)" }}>
{header}
</Box>
)}
<Box style={{ overflowX: "auto" }} w="100%">
<Box miw={minWidth}>{children}</Box>
</Box>

View File

@@ -15,6 +15,7 @@ export {
ChangeRequestReview,
ChangeRequestPendingBadge,
} from "./ChangeRequestReview";
export { AccountCard } from "./AccountCard";
export { CompanyTimeline } from "./CompanyTimeline";
export {
RequestDocumentChangeModal,

View File

@@ -1,11 +1,38 @@
import type { ReactNode } from "react";
import { Badge, Center, Group, Loader, Modal, Text, Timeline } from "@mantine/core";
import { useQuery } from "@tanstack/react-query";
import { ArrowRight, PackageCheck, TrainFront, Wrench } from "lucide-react";
import { useMemo, useState, type ReactNode } from "react";
import { Freight } from "@edr/types";
import {
Badge,
Button,
Center,
Group,
Loader,
Modal,
SegmentedControl,
Stack,
Text,
Timeline,
} from "@mantine/core";
import { useInfiniteQuery } from "@tanstack/react-query";
import {
Activity,
ArrowRight,
CalendarClock,
Container,
FileEdit,
Link2,
Link2Off,
MapPin,
PackageCheck,
PackageX,
Pin,
PinOff,
Route,
TrainFront,
Trash2,
} from "lucide-react";
import { api } from "@/services/api";
import type { FleetRecord } from "@/services/fleet/fleet.service";
import type { WagonMovementRecord } from "@/services/wagon.service";
export interface WagonMovementHistoryModalProps {
opened: boolean;
@@ -15,43 +42,116 @@ export interface WagonMovementHistoryModalProps {
const asObj = (r: FleetRecord | null) => (r ?? {}) as Record<string, unknown>;
/** Chip style per wagon_movements ledger kind. */
const KIND_META: Record<string, { label: string; color: string; icon: ReactNode }> = {
LOADED: {
label: "Loaded leg",
color: "edr-green",
icon: <PackageCheck size={14} />,
},
EMPTY_REPOSITION: {
label: "Empty reposition",
color: "blue",
icon: <TrainFront size={14} />,
},
MANUAL: {
label: "Manual move",
color: "orange",
icon: <Wrench size={14} />,
},
MAINTENANCE: {
label: "Sent to maintenance",
color: "red",
icon: <Wrench size={14} />,
},
const PAGE_SIZE = 50;
type EventMeta = { label: string; color: string; icon: ReactNode };
/** Chip style per history event type. */
const EVENT_META: Record<Freight.WagonEventType, EventMeta> = {
REGISTERED: { label: "Registered", color: "gray", icon: <FileEdit size={14} /> },
DETAILS_UPDATED: { label: "Details updated", color: "gray", icon: <FileEdit size={14} /> },
DELETED: { label: "Deleted", color: "red", icon: <Trash2 size={14} /> },
PURGED: { label: "Purged", color: "red", icon: <Trash2 size={14} /> },
MOVED_MANUALLY: { label: "Moved manually", color: "orange", icon: <MapPin size={14} /> },
MOVED_WITH_TRAIN: { label: "Moved with train", color: "orange", icon: <TrainFront size={14} /> },
PASSED_CHECKPOINT: { label: "Passed checkpoint", color: "blue", icon: <Route size={14} /> },
CUT_AT_YARD: { label: "Cut at yard", color: "yellow", icon: <Link2Off size={14} /> },
SETTLED_ON_ARRIVAL: { label: "Arrived", color: "edr-green", icon: <MapPin size={14} /> },
RELEASED_AT_UNLOAD: { label: "Released after unload", color: "edr-green", icon: <PackageX size={14} /> },
RETURNED_ON_CANCEL: { label: "Schedule cancelled", color: "red", icon: <CalendarClock size={14} /> },
COUPLED_TO_TRAIN: { label: "Coupled to train", color: "indigo", icon: <Link2 size={14} /> },
UNCOUPLED_FROM_TRAIN: { label: "Uncoupled from train", color: "indigo", icon: <Link2Off size={14} /> },
SEQUENCE_CHANGED: { label: "Position changed", color: "indigo", icon: <Link2 size={14} /> },
TRAIN_MERGED: { label: "Train merged", color: "indigo", icon: <TrainFront size={14} /> },
TRAIN_DISBANDED: { label: "Train disbanded", color: "indigo", icon: <Link2Off size={14} /> },
PINNED_TO_SCHEDULE: { label: "Pinned to schedule", color: "cyan", icon: <Pin size={14} /> },
UNPINNED_FROM_SCHEDULE: { label: "Unpinned", color: "cyan", icon: <PinOff size={14} /> },
DISPATCHED: { label: "Dispatched", color: "cyan", icon: <TrainFront size={14} /> },
RELEASED_FROM_SCHEDULE: { label: "Released from schedule", color: "cyan", icon: <PinOff size={14} /> },
STATUS_CHANGED: { label: "Status changed", color: "violet", icon: <Activity size={14} /> },
CARGO_LOADED: { label: "Cargo loaded", color: "edr-green", icon: <PackageCheck size={14} /> },
CARGO_UNLOADED: { label: "Cargo unloaded", color: "teal", icon: <PackageX size={14} /> },
BOOKING_UNASSIGNED: { label: "Booking removed", color: "teal", icon: <PackageX size={14} /> },
BOOKING_CANCELLED: { label: "Booking cancelled", color: "red", icon: <PackageX size={14} /> },
LOAD_MOVED_IN: { label: "Load moved in", color: "teal", icon: <PackageCheck size={14} /> },
LOAD_MOVED_OUT: { label: "Load moved out", color: "teal", icon: <PackageX size={14} /> },
CONTAINER_PLACED: { label: "Container placed", color: "teal", icon: <Container size={14} /> },
CONTAINER_REMOVED: { label: "Container removed", color: "teal", icon: <Container size={14} /> },
};
const yardLabel = (
yard: { label?: string; code?: string } | null | undefined,
yardId: string | null,
) => yard?.label ?? yard?.code ?? yardId ?? "Unknown";
const CATEGORY_OPTIONS: Array<{ label: string; value: string }> = [
{ label: "All", value: "" },
{ label: "Yard", value: Freight.WagonEventCategory.Yard },
{ label: "Train", value: Freight.WagonEventCategory.Train },
{ label: "Schedule", value: Freight.WagonEventCategory.Schedule },
{ label: "Status", value: Freight.WagonEventCategory.Status },
{ label: "Cargo", value: Freight.WagonEventCategory.Cargo },
{ label: "Record", value: Freight.WagonEventCategory.Lifecycle },
];
const fmt = (iso: string) => {
const d = new Date(iso);
return Number.isNaN(d.getTime()) ? iso : d.toLocaleString();
};
const STATUS_LABEL: Record<string, string> = {
AVAILABLE: "Available",
ASSIGNED: "Assigned",
IMPORT_READY: "Import ready",
EXPORT_READY: "Export ready",
MAINTENANCE: "Maintenance",
DETAINED: "Detained",
OUT_OF_SERVICE: "Out of service",
};
const statusLabel = (v: string | null) => (v ? (STATUS_LABEL[v] ?? v) : null);
/** Headline for one event: the from → to pair that best describes it. */
const headline = (e: Freight.WagonHistoryEvent): { from: string | null; to: string | null } => {
const fromYard = e.fromYardLabel ?? e.fromYardId;
const toYard = e.toYardLabel ?? e.toYardId;
switch (e.category) {
case Freight.WagonEventCategory.Yard:
return { from: fromYard, to: toYard };
case Freight.WagonEventCategory.Status:
return { from: statusLabel(e.fromValue), to: statusLabel(e.toValue) };
case Freight.WagonEventCategory.Train:
if (e.type === "SEQUENCE_CHANGED") {
return { from: e.fromValue ? `#${e.fromValue}` : null, to: e.toValue ? `#${e.toValue}` : null };
}
if (e.type === "TRAIN_MERGED") return { from: null, to: e.toValue ?? e.trainCode };
return {
from: e.trainCode ? `Train ${e.trainCode}` : null,
to: e.type === "COUPLED_TO_TRAIN" && e.toValue ? `position #${e.toValue}` : null,
};
case Freight.WagonEventCategory.Schedule:
return { from: e.scheduleLabel ? `Run ${e.scheduleLabel}` : null, to: toYard };
case Freight.WagonEventCategory.Cargo:
if (e.type === "CONTAINER_PLACED" || e.type === "CONTAINER_REMOVED") {
return { from: e.toValue ?? e.fromValue, to: null };
}
if (e.type === "LOAD_MOVED_IN") return { from: e.fromValue ? `from ${e.fromValue}` : null, to: null };
if (e.type === "LOAD_MOVED_OUT") return { from: e.toValue ? `to ${e.toValue}` : null, to: null };
return { from: e.bookingReference ? `Booking ${e.bookingReference}` : null, to: null };
default:
return { from: null, to: null };
}
};
/** Secondary line: the linked records this event touched, deduplicated against the headline. */
const context = (e: Freight.WagonHistoryEvent): string[] => {
const parts: string[] = [];
if (e.trainCode && e.category !== Freight.WagonEventCategory.Train) parts.push(`Train ${e.trainCode}`);
if (e.scheduleLabel && e.category !== Freight.WagonEventCategory.Schedule) parts.push(`Run ${e.scheduleLabel}`);
if (e.bookingReference && e.category !== Freight.WagonEventCategory.Cargo) parts.push(`Booking ${e.bookingReference}`);
if (e.actorName) parts.push(`by ${e.actorName}`);
return parts;
};
/**
* Movement ledger for one wagon: every relocation between yards — booking legs,
* empty reposition rides, and manual staff corrections — newest first.
* Full history of one wagon every yard move, coupling, schedule pin and
* dispatch, status flip, cargo load/unload, container placement and record
* edit — newest first, filterable by category, paged with a cursor so a
* long-serving wagon never loads its whole life at once.
*/
const WagonMovementHistoryModal = ({
opened,
@@ -61,15 +161,27 @@ const WagonMovementHistoryModal = ({
const r = asObj(record);
const id = r.id ? String(r.id) : "";
const wagonNumber = r.wagonNumber ? String(r.wagonNumber) : "";
const [category, setCategory] = useState<string>("");
const { data, isLoading } = useQuery(
api.wagons.movements.queryOptions({
input: { id },
enabled: opened && Boolean(id),
const input = useMemo(
() => ({
id,
category: (category || undefined) as Freight.WagonEventCategory | undefined,
limit: PAGE_SIZE,
}),
[id, category],
);
const movements: WagonMovementRecord[] = data ?? [];
const { data, isLoading, isFetchingNextPage, hasNextPage, fetchNextPage } = useInfiniteQuery({
queryKey: [...api.wagons.history.queryKey(input), "infinite"],
queryFn: ({ pageParam }) =>
api.wagons.history.call({ ...input, cursor: pageParam || undefined }),
initialPageParam: "" as string,
getNextPageParam: (last) => last.nextCursor ?? undefined,
enabled: opened && Boolean(id),
});
const events = useMemo(() => data?.pages.flatMap((p) => p.items) ?? [], [data]);
return (
<Modal
@@ -80,63 +192,86 @@ const WagonMovementHistoryModal = ({
size="lg"
centered
>
{isLoading ? (
<Center py="xl">
<Loader size="sm" />
</Center>
) : movements.length === 0 ? (
<Text c="dimmed" ta="center" py="lg" size="sm">
No movements recorded yet. Every yard-to-yard move appears here a
booking's loaded leg, an empty reposition ride, or a manual correction.
</Text>
) : (
<Timeline active={movements.length} bulletSize={24} lineWidth={2}>
{movements.map((movement) => {
const meta = KIND_META[movement.kind] ?? {
label: movement.kind,
color: "gray",
icon: <TrainFront size={14} />,
};
const from = yardLabel(movement.fromYard, movement.fromYardId);
const to = yardLabel(movement.toYard, movement.toYardId);
return (
<Timeline.Item
key={movement.id}
bullet={meta.icon}
title={
<Group gap={6} wrap="nowrap">
<Text size="sm" fw={600}>
{from}
</Text>
{/* Status events (maintenance) sit in one yard — an arrow
pointing at the same yard reads as a broken row. */}
{movement.fromYardId !== movement.toYardId && (
<>
<ArrowRight size={13} />
<Stack gap="md">
<SegmentedControl
size="xs"
value={category}
onChange={setCategory}
data={CATEGORY_OPTIONS}
fullWidth
/>
{isLoading ? (
<Center py="xl">
<Loader size="sm" />
</Center>
) : events.length === 0 ? (
<Text c="dimmed" ta="center" py="lg" size="sm">
Nothing recorded yet{category ? " in this category" : ""}. Every yard move,
coupling, schedule pin, dispatch, status change and load appears here as it
happens.
</Text>
) : (
<Timeline active={events.length} bulletSize={24} lineWidth={2}>
{events.map((e) => {
const meta = EVENT_META[e.type] ?? {
label: e.type,
color: "gray",
icon: <TrainFront size={14} />,
};
const { from, to } = headline(e);
const extra = context(e);
return (
<Timeline.Item
key={e.id}
bullet={meta.icon}
title={
<Group gap={6} wrap="nowrap">
{from && (
<Text size="sm" fw={600}>
{from}
</Text>
)}
{from && to && from !== to && <ArrowRight size={13} />}
{to && to !== from && (
<Text size="sm" fw={600}>
{to}
</Text>
</>
)}
<Badge size="xs" variant="light" color={meta.color}>
{meta.label}
</Badge>
</Group>
}
>
{movement.note && (
<Text size="sm" c="dimmed">
{movement.note}
)}
<Badge size="xs" variant="light" color={meta.color}>
{meta.label}
</Badge>
</Group>
}
>
{e.reason && (
<Text size="sm" c="dimmed">
{e.reason}
</Text>
)}
<Text size="xs" mt={4} c="dimmed">
{fmt(e.occurredAt)}
{extra.length ? ` · ${extra.join(" · ")}` : ""}
</Text>
)}
<Text size="xs" mt={4} c="dimmed">
{fmt(movement.occurredAt)}
</Text>
</Timeline.Item>
);
})}
</Timeline>
)}
</Timeline.Item>
);
})}
</Timeline>
)}
{hasNextPage && (
<Center>
<Button
size="compact-sm"
variant="subtle"
loading={isFetchingNextPage}
onClick={() => void fetchNextPage()}
>
Load older events
</Button>
</Center>
)}
</Stack>
</Modal>
);
};

View File

@@ -22,6 +22,7 @@ import { type ReactNode } from "react";
import { useNavigate } from "react-router-dom";
import DocReviewAlertButton from "@/features/bookingWindows/DocReviewAlertButton";
import { PositionSelect } from "@/record-management/components/positionSelection";
import NotificationBellContainer from "@/features/notifications/NotificationBellContainer";
import type { PageMeta } from "./types";
@@ -111,6 +112,10 @@ const FreightDashboardHeader = ({
renders only during a review phase that still has undecided
requests, so it never competes for space otherwise. */}
<Group gap={10} wrap="nowrap" align="center">
{/* Staff holding two posts switch desks here. Renders nothing for the
single-position majority, so it costs the header no space. */}
<PositionSelect />
<DocReviewAlertButton />
<Tooltip label="Language" withArrow openDelay={300}>

View File

@@ -18,6 +18,7 @@ import {
Package,
PackageCheck,
PackageOpen,
PackagePlus,
Paperclip,
Receipt,
Stamp,
@@ -30,6 +31,7 @@ import {
SlidersHorizontal,
Train,
Truck,
Undo2,
Users,
Wallet,
LifeBuoy,
@@ -366,6 +368,24 @@ export const buildSidebarSections = (
icon: <Container />,
permission: FREIGHT_PERMS.warehouseInventory.view,
},
{
label: "Empty Return Requests",
href: "/dashboard/empty-return-requests",
icon: <Undo2 />,
// Visible to whoever already runs container returns, OR'd with
// the queue's own key — so the dedicated permission can be handed
// out per position later without the menu disappearing now.
permission: [
FREIGHT_PERMS.emptyReturnRequests.view,
FREIGHT_PERMS.warehouseInventory.view,
],
},
{
label: "Register Full Containers",
href: "/dashboard/register-full-containers",
icon: <PackagePlus />,
permission: FREIGHT_PERMS.warehouseInventory.receive,
},
{
label: "Terminal Inventory",
href: "/dashboard/warehouse-inventory?direction=IMPORT",

View File

@@ -277,8 +277,15 @@ export function AllocateBookingWizard({
if (!routeId || !scheduleDate || locomotiveIds.length < 2) {
throw new Error("Select route, date, and at least two locomotives");
}
// This ad-hoc path has no built train (and so no run number) and no voyage
// input, but voyage number is required at creation — default it to a
// date-stamped placeholder that staff can edit later on the schedule.
const voyageNumber = `V-${new Date(scheduleDate)
.toISOString()
.slice(0, 10)
.replace(/-/g, "")}`;
const created = await create.mutateAsync({
payload: { routeId, scheduleDate, locomotiveIds },
payload: { routeId, scheduleDate, voyageNumber, locomotiveIds },
});
showScheduleWarnings(created.warnings);
setSelectedScheduleId(created.id);

View File

@@ -382,7 +382,12 @@ export function IntercityRideAlongPanel({
</Table.Td>
<Table.Td>
<Group gap="xs" justify="flex-end">
{row.status === "PAID" && (
{/* Paid = PAYMENT status only; still show Load only
while the cargo has not ridden yet. */}
{row.paymentStatus === "PAID" &&
row.status !== "IN_TRANSIT" &&
row.status !== "ARRIVED" &&
row.status !== "COMPLETED" && (
<Tooltip
label={
canLoad

View File

@@ -1,6 +1,7 @@
import {
Alert,
Button,
Checkbox,
Divider,
Group,
Loader,
@@ -24,6 +25,11 @@ import { useEffect, useState } from "react";
import { Freight } from "@edr/types";
import { BookingStatusBadge } from "@/components/bookings/BookingStatusBadge";
import {
PartiallyLoadedDecisionModal,
parsePartiallyLoaded,
type PartiallyLoadedPayload,
} from "@/components/trainScheduling/PartiallyLoadedDecisionModal";
import { StationWorkControls } from "@/components/trainScheduling/StationWorkControls";
import { Chip } from "./trackPrimitives";
import { DIRECTION_TONE, track as T } from "./trackTheme";
@@ -98,6 +104,7 @@ export function LogPassYardWorkModal({
onClose,
scheduleId,
station,
stations,
isFinal,
alreadyLogged,
}: {
@@ -105,6 +112,8 @@ export function LogPassYardWorkModal({
onClose: () => void;
scheduleId: string;
station: TrackStation | null;
/** Whole corridor — used to find the yard the train has just left. */
stations: TrackStation[];
isFinal: boolean;
/** True when opened for the current station (pass already logged). */
alreadyLogged: boolean;
@@ -151,6 +160,33 @@ export function LogPassYardWorkModal({
const loadingStarted = Boolean(workLog?.loading?.startedAt);
const unloadingStarted = Boolean(workLog?.unloading?.startedAt);
// The yard the train is LEAVING by logging this pass. Its boarders have had
// their last chance to load, so this is where the leave-behind decision is
// made. The origin (seq 0) belongs to dispatch, so there is nothing before it.
const departedStation =
station && station.sequenceNo > 0
? stations.find((s) => s.sequenceNo === station.sequenceNo - 1)
: undefined;
const departedYard = departedStation
? yardWorkQuery.data?.yards.find((y) => y.yardId === departedStation.yardId)
: undefined;
const departedPendingBoarders: YardWorkBookingRow[] = (departedYard?.toLoad ?? []).filter(
(r) => !r.loadedAt,
);
// Ticked = rides on. Seeded to everyone each time the modal opens on a new
// station, so the default is the historic "nobody is left behind".
const [departedRidingIds, setDepartedRidingIds] = useState<Set<string>>(new Set());
useEffect(() => {
setDepartedRidingIds(new Set(departedPendingBoarders.map((r) => r.id)));
// Re-seed when the station changes or the rows finish loading.
}, [station?.sequenceNo, opened, departedPendingBoarders.length]);
const departedLeftBehind = departedPendingBoarders.filter(
(r) => !r.isGovernment && !departedRidingIds.has(r.id),
);
// Set when the pass is rejected because a booking at the departed yard is
// part-loaded — drives the EDR-fault / customer-fault decision.
const [partialGate, setPartialGate] = useState<PartiallyLoadedPayload | null>(null);
const doLogPass = () => {
if (!station) return;
recordCheckpoint.mutate(
@@ -159,11 +195,30 @@ export function LogPassYardWorkModal({
payload: {
sequenceNo: station.sequenceNo,
...(passAt ? { occurredAt: passAt.toISOString() } : {}),
// Logging THIS station means the train left the previous one, so the
// cargo that boarded back there has had its last chance to load.
// Only the ticked ones ride on; the rest are unassigned and returned
// to the pool. Government bookings always ride — the server refuses
// to unassign them.
...(departedYard
? {
loadedBookingIds: departedPendingBoarders
.filter((r) => r.isGovernment || departedRidingIds.has(r.id))
.map((r) => r.id),
}
: {}),
},
},
{
onSuccess: () => {
setJustLogged(true);
if (departedLeftBehind.length) {
toast({
title: `${departedLeftBehind.length} booking${departedLeftBehind.length === 1 ? "" : "s"} left behind at ${departedYard?.yard ?? "the previous yard"}`,
description:
"Removed from this train — wagons freed, bookings returned to the pool for a later schedule.",
});
}
toast({
title: isFinal
? "Train arrived — remaining bookings marked arrived, assets freed"
@@ -178,12 +233,21 @@ export function LogPassYardWorkModal({
});
void yardWorkQuery.refetch();
},
onError: (err) =>
onError: (err) => {
// A part-loaded booking at the departed yard blocks the pass until
// its never-loaded wagons are cut — offer the fault decision instead
// of a dead-end error.
const gate = parsePartiallyLoaded(err);
if (gate) {
setPartialGate(gate);
return;
}
toast({
title: "Could not log checkpoint",
description: parseError(err, "Please try again"),
variant: "destructive",
}),
});
},
},
);
};
@@ -558,6 +622,66 @@ export function LogPassYardWorkModal({
</>
)}
{!logged && departedStation && departedPendingBoarders.length > 0 ? (
<Stack
gap={10}
p={14}
style={{
background: T.amberDim,
border: `1px solid ${T.amberBorder}`,
borderRadius: 14,
}}
>
<Group gap={9} align="center" wrap="nowrap">
<PackageCheck size={16} color={T.amber} style={{ flexShrink: 0 }} />
<Text size="13px" fw={700} c={T.amber}>
{departedPendingBoarders.length} booking
{departedPendingBoarders.length === 1 ? "" : "s"} not loaded at{" "}
{departedStation.label}
</Text>
</Group>
<Text size="11.5px" c={T.amberText} lh={1.45}>
Logging this pass means the train has left {departedStation.label}. Untick
anything that never made it onto the train it is removed and returned to the
booking pool.
</Text>
{departedPendingBoarders.map((r) => (
<Group key={r.id} justify="space-between" wrap="nowrap">
<Checkbox
checked={r.isGovernment || departedRidingIds.has(r.id)}
disabled={r.isGovernment || !canLeave}
onChange={(e) => {
const next = new Set(departedRidingIds);
if (e.currentTarget.checked) next.add(r.id);
else next.delete(r.id);
setDepartedRidingIds(next);
}}
label={
<Text size="12.5px">
{r.reference ?? r.id}
{r.isGovernment ? (
<Text span size="11px" c={T.muted}>
{" "}
· government, cannot be removed
</Text>
) : null}
</Text>
}
/>
<Text size="11px" c={T.muted}>
{r.customer}
</Text>
</Group>
))}
{departedLeftBehind.length ? (
<Text size="11.5px" fw={700} c={T.amber}>
{departedLeftBehind.length} booking
{departedLeftBehind.length === 1 ? "" : "s"} will be removed from this train.
</Text>
) : null}
</Stack>
) : null}
{!logged ? (
<DateTimePicker
label={isFinal ? "Arrival time" : "Time at station"}
@@ -602,6 +726,13 @@ export function LogPassYardWorkModal({
</Group>
</Group>
</Stack>
{/* Part-loaded gate. Cutting the wagons does NOT log the pass — the
operator confirms the pass again once the consist is clean. */}
<PartiallyLoadedDecisionModal
payload={partialGate}
onClose={() => setPartialGate(null)}
onResolved={() => void yardWorkQuery.refetch()}
/>
</Modal>
);
}

View File

@@ -0,0 +1,250 @@
import {
Alert,
Button,
Divider,
Group,
Modal,
Paper,
Radio,
Stack,
Text,
Textarea,
} from "@mantine/core";
import { useMutation } from "@tanstack/react-query";
import { AlertTriangle, PackageX } from "lucide-react";
import { useEffect, useState } from "react";
import { useToast } from "@/hooks/use-toast";
import { api } from "@/services/api";
/**
* One partially-loaded booking, as the server reports it in the
* PARTIALLY_LOADED_BOOKINGS error payload.
*/
export interface PartiallyLoadedBooking {
bookingId: string;
reference: string;
loadedWagons: number;
totalWagons: number;
unloadedAllocationIds: string[];
}
export interface PartiallyLoadedPayload {
scheduleId: string;
boardingYardId: string;
yardLabel: string | null;
action: string;
bookings: PartiallyLoadedBooking[];
}
/**
* Reads the structured `partiallyLoaded` block off a rejected dispatch or
* log-pass. Returns null for every other error so callers can fall through to
* their normal toast.
*/
export function parsePartiallyLoaded(
error: unknown,
): PartiallyLoadedPayload | null {
const data = (
error as {
response?: {
data?: { code?: string; partiallyLoaded?: PartiallyLoadedPayload };
};
}
)?.response?.data;
if (data?.code !== "PARTIALLY_LOADED_BOOKINGS") return null;
return data.partiallyLoaded ?? null;
}
type Fault = "EDR" | "CUSTOMER";
/**
* The gate a partly-loaded booking hits at dispatch or log-pass.
*
* A booking with some wagons loaded and some never loaded can neither ride
* (the empty wagons would leave as ghosts) nor be left behind (unassigning
* would strand cargo physically on the train). So the operator decides here:
* cut the never-loaded wagons at EDR's fault (no fee, rebookable credit) or
* the customer's (cancellation fee invoiced) — or block, and go finish loading.
*
* Cancelling does NOT then log the pass. The modal closes, the caller refetches,
* and the operator clicks their action again with the gate cleared — two
* deliberate commits rather than one compound one.
*/
export function PartiallyLoadedDecisionModal({
payload,
onClose,
onResolved,
}: {
payload: PartiallyLoadedPayload | null;
onClose: () => void;
/** Cut succeeded — refetch, so the retry sees the cleared gate. */
onResolved: () => void;
}) {
const { toast } = useToast();
const [fault, setFault] = useState<Fault | null>(null);
const [reason, setReason] = useState("");
const cancelRemaining = useMutation(
api.trainScheduling.cancelRemainingWagons.mutationOptions(),
);
useEffect(() => {
setFault(null);
setReason("");
}, [payload?.boardingYardId, payload?.bookings.length]);
if (!payload) return null;
const { bookings, yardLabel, action } = payload;
const totalUnloaded = bookings.reduce(
(n, b) => n + b.unloadedAllocationIds.length,
0,
);
const where = yardLabel ? ` at ${yardLabel}` : "";
const submit = async () => {
if (!fault) return;
const edrFault = fault === "EDR";
try {
// Cut every partly-loaded booking's never-loaded wagons under the one
// decision — they are all stuck behind the same gate for the same reason.
for (const b of bookings) {
await cancelRemaining.mutateAsync({
bookingId: b.bookingId,
scheduleId: payload.scheduleId,
reason: reason.trim(),
edrFault,
wagonAllocationIds: b.unloadedAllocationIds,
});
}
toast({
title: `${totalUnloaded} wagon${totalUnloaded === 1 ? "" : "s"} cancelled`,
description: edrFault
? "EDR's fault — no fee charged; the credit is rebookable."
: "Customer's fault — the cancellation fee was invoiced; the credit is rebookable.",
});
onResolved();
onClose();
} catch (err) {
const message = (
err as { response?: { data?: { message?: string | string[] } } }
)?.response?.data?.message;
toast({
title: "Cancellation failed",
description: Array.isArray(message)
? message.join("; ")
: message || (err as Error)?.message || "Please try again",
variant: "destructive",
});
}
};
return (
<Modal
opened
onClose={onClose}
centered
radius="lg"
size="lg"
title={
<Group gap={8}>
<PackageX size={18} />
<Text fw={700}>Partly loaded a decision is needed</Text>
</Group>
}
>
<Stack gap="md">
<Alert
color="orange"
variant="light"
radius="md"
icon={<AlertTriangle size={18} />}
title={`Cannot ${action}${where}`}
>
<Text size="sm">
A booking with some wagons loaded and some never loaded can neither
ride nor be removed the loaded cargo is physically on the train.
</Text>
</Alert>
<Stack gap={8}>
{bookings.map((b) => (
<Paper key={b.bookingId} withBorder radius="md" p="sm">
<Group justify="space-between" wrap="nowrap">
<Text fw={700} size="sm">
{b.reference}
</Text>
<Text size="sm" c="dimmed">
{b.loadedWagons} of {b.totalWagons} wagons loaded
</Text>
</Group>
<Text size="xs" c="dimmed" mt={4}>
{b.unloadedAllocationIds.length} wagon
{b.unloadedAllocationIds.length === 1 ? "" : "s"} never loaded
to be cancelled. The {b.loadedWagons} loaded wagon
{b.loadedWagons === 1 ? "" : "s"} stay on the train.
</Text>
</Paper>
))}
</Stack>
<Divider />
<Radio.Group
label={`What happens to the ${totalUnloaded} never-loaded wagon${totalUnloaded === 1 ? "" : "s"}?`}
value={fault ?? ""}
onChange={(v) => setFault(v as Fault)}
>
<Stack gap={8} mt={8}>
<Radio
value="EDR"
label="Cancel — EDR's fault"
description="Wagon shortage, yard problem. No fee charged; the credit is rebookable."
/>
<Radio
value="CUSTOMER"
label="Cancel — customer's fault"
description="Cargo not ready, no-show. The cancellation fee is invoiced; the credit is rebookable."
/>
</Stack>
</Radio.Group>
{fault ? (
<Textarea
label="Reason"
placeholder="Why are these wagons not riding?"
value={reason}
onChange={(e) => setReason(e.currentTarget.value)}
minRows={2}
required
/>
) : null}
<Text size="xs" c="dimmed">
Cancelling does not {action} you will confirm that separately once
the wagons are cut.
</Text>
<Group justify="flex-end" gap="sm">
<Button variant="default" radius="md" onClick={onClose}>
Block go finish loading
</Button>
<Button
color="red"
radius="md"
disabled={!fault || !reason.trim()}
loading={cancelRemaining.isPending}
onClick={() => void submit()}
>
Cancel {totalUnloaded} wagon{totalUnloaded === 1 ? "" : "s"}
{fault === "EDR"
? " (no fee)"
: fault === "CUSTOMER"
? " (fee applies)"
: ""}
</Button>
</Group>
</Stack>
</Modal>
);
}

View File

@@ -534,7 +534,8 @@ function LocoDetailPanel({
<Text size="sm" c="gray.5">No locomotive assigned yet.</Text>
)}
<Divider color="rgba(255,255,255,0.1)" label="Train" labelPosition="left" />
<InfoRow label="Voyage / reference" value={schedule.reference} />
<InfoRow label="Voyage number" value={schedule.voyageNumber} />
<InfoRow label="Reference" value={schedule.reference} />
<InfoRow label="Train number" value={schedule.trainNumber} />
<InfoRow
label="Train"

View File

@@ -0,0 +1,347 @@
import { useEffect, useMemo, useState } from "react";
import { useMutation, useQuery } from "@tanstack/react-query";
import {
Alert,
Autocomplete,
Badge,
Button,
FileInput,
Group,
List,
Modal,
ScrollArea,
Select,
Stack,
Table,
Text,
} from "@mantine/core";
import { Upload } from "lucide-react";
import { localNowForInput } from "@/lib/format";
import { useToast } from "@/hooks/use-toast";
import { useWarehouseYards, useWarehouseZones } from "@/hooks/useWarehouses";
import { importOperationsService } from "@/services/importOperations.service";
import { warehouseService } from "@/services/warehouse.service";
import type { CreateEmptyContainerReturnPayload } from "@/types/importOperations";
import { parseContainerReturnExcel, type ParsedReturnRow } from "./container-return-excel";
import { useCompanyOptions } from "./useCompanyOptions";
interface BulkContainerReturnModalProps {
opened: boolean;
onClose: () => void;
onUploaded: () => void;
}
/**
* Backfill of empties physically in a yard but never entered in the system.
* The sheet carries per-container detail; the fields above the file are the
* defaults for every row whose cell is blank, so the common case is a sheet of
* container numbers plus one warehouse picked here.
*/
export default function BulkContainerReturnModal({
opened,
onClose,
onUploaded,
}: BulkContainerReturnModalProps) {
const { toast } = useToast();
const companies = useCompanyOptions();
const [file, setFile] = useState<File | null>(null);
const [rows, setRows] = useState<ParsedReturnRow[]>([]);
const [parseErrors, setParseErrors] = useState<string[]>([]);
const [parsing, setParsing] = useState(false);
const [company, setCompany] = useState("");
const [returnedBy, setReturnedBy] = useState<"EDR" | "CUSTOMER" | null>(null);
const [warehouseId, setWarehouseId] = useState<string | null>(null);
const [yardId, setYardId] = useState<string | null>(null);
const [zoneId, setZoneId] = useState<string | null>(null);
const [returnDate, setReturnDate] = useState(localNowForInput());
const { data: warehousesResponse } = useQuery({
queryKey: ["warehouses-list"],
queryFn: () => warehouseService.list({}),
});
const warehouses = ((warehousesResponse as any)?.data ?? warehousesResponse ?? []) as any[];
const { data: yards } = useWarehouseYards(warehouseId ?? undefined);
const { data: zones } = useWarehouseZones(yardId ?? undefined);
useEffect(() => {
setYardId(null);
setZoneId(null);
}, [warehouseId]);
useEffect(() => setZoneId(null), [yardId]);
const warehouseOptions = Array.isArray(warehouses)
? warehouses.map((wh) => ({ value: wh.id, label: wh.code ? `${wh.name} (${wh.code})` : wh.name }))
: [];
const yardOptions = (yards ?? [])
.filter((y) => y.status === "ACTIVE")
.map((y) => ({ value: y.id, label: `${y.name} (${y.code})` }));
const zoneOptions = (zones ?? [])
.filter((z) => z.status === "ACTIVE")
.map((z) => ({ value: z.id, label: `${z.name} (${z.code})` }));
const defaults = useMemo(
() => ({
facility: warehouses.find((wh) => wh.id === warehouseId)?.name ?? "",
yard: yards?.find((y) => y.id === yardId)?.name ?? "",
zone: zones?.find((z) => z.id === zoneId)?.name ?? "",
}),
[warehouses, warehouseId, yards, yardId, zones, zoneId],
);
const reset = () => {
setFile(null);
setRows([]);
setParseErrors([]);
};
const handleFile = async (next: File | null) => {
setFile(next);
setRows([]);
setParseErrors([]);
if (!next) return;
setParsing(true);
const result = await parseContainerReturnExcel(next);
setParsing(false);
setRows(result.rows);
setParseErrors(result.errors);
};
// Row cell wins; the field above the file fills the blanks.
const toPayload = (row: ParsedReturnRow): CreateEmptyContainerReturnPayload => {
const companyName = row.companyName || company;
return {
containerNumber: row.containerNumber,
containerSize: row.containerSize ?? undefined,
companyName: companyName || undefined,
customerId: companyName ? companies.resolveId(companyName) : undefined,
returnedBy: row.returnedBy ?? returnedBy ?? undefined,
returnDate: row.returnDate ?? new Date(returnDate).toISOString(),
facility: row.facility || defaults.facility || undefined,
yard: row.yard || defaults.yard || undefined,
zone: row.zone || defaults.zone || undefined,
condition: row.condition || undefined,
handoverNote: row.handoverNote || undefined,
};
};
const uploadMutation = useMutation({
mutationFn: () => importOperationsService.bulkCreateEmptyReturns(rows.map(toPayload)),
onSuccess: (created) => {
toast({ title: `${created.length} container return${created.length === 1 ? "" : "s"} recorded` });
reset();
onUploaded();
onClose();
},
onError: (error: any) => {
toast({
variant: "destructive",
title: "Bulk upload failed",
description: error?.response?.data?.message || error?.message,
});
},
});
// Every row needs a warehouse from somewhere — the API stores facility as
// free text, so a blank one would silently produce unplaceable containers.
const missingFacility = rows.filter((r) => !r.facility && !defaults.facility).length;
const missingReturnedBy = rows.filter((r) => !r.returnedBy && !returnedBy).length;
const blockers = [
missingFacility > 0 ? `${missingFacility} row(s) have no facility — pick a default warehouse.` : null,
missingReturnedBy > 0 ? `${missingReturnedBy} row(s) have no "Returned By" — pick a default.` : null,
].filter(Boolean) as string[];
return (
<Modal
opened={opened}
onClose={() => {
reset();
onClose();
}}
title="Bulk Upload Container Returns"
size="xl"
>
<Stack gap="md">
<Text size="sm" c="dimmed">
For empties already sitting in the yard but not yet on the system. Values below fill any
blank cell in the sheet.
</Text>
<Group grow align="flex-start">
<Autocomplete
label="Company"
description="Pick a registered customer, or type a company that is not on the system yet"
placeholder={companies.loading ? "Loading companies…" : "Search or type a company"}
data={companies.names}
value={company}
onChange={setCompany}
limit={20}
/>
<Select
label="Returned By"
placeholder="Select truck type"
value={returnedBy}
onChange={(v) => setReturnedBy(v as "EDR" | "CUSTOMER" | null)}
data={[
{ value: "EDR", label: "EDR Truck" },
{ value: "CUSTOMER", label: "Customer Truck" },
]}
/>
</Group>
<Group grow align="flex-start">
<Select
label="Warehouse"
placeholder="Select warehouse"
value={warehouseId}
onChange={setWarehouseId}
data={warehouseOptions}
searchable
/>
<Select
label="Yard"
placeholder={warehouseId ? "Select yard" : "Select warehouse first"}
value={yardId}
onChange={setYardId}
data={yardOptions}
disabled={!warehouseId}
searchable
/>
<Select
label="Zone"
placeholder={yardId ? "Select zone" : "Select yard first"}
value={zoneId}
onChange={setZoneId}
data={zoneOptions}
disabled={!yardId}
searchable
/>
</Group>
<Group grow align="flex-start">
<FileInput
label="Excel file"
placeholder="Select .xlsx or .xls"
accept=".xlsx,.xls"
leftSection={<Upload size={16} />}
value={file}
onChange={(next) => void handleFile(next)}
/>
<div>
<Text size="sm" fw={500} mb={4}>
Returned Date &amp; Time (default)
</Text>
<input
type="datetime-local"
value={returnDate}
onChange={(e) => setReturnDate(e.target.value)}
style={{ padding: "8px", borderRadius: "4px", border: "1px solid #ced4da", width: "100%" }}
/>
</div>
</Group>
{parsing && <Text size="sm">Reading file</Text>}
{parseErrors.length > 0 && (
<Alert color="red" title={`${parseErrors.length} problem(s) — nothing was imported`}>
<ScrollArea.Autosize mah={200}>
<List size="sm">
{parseErrors.map((err) => (
<List.Item key={err}>{err}</List.Item>
))}
</List>
</ScrollArea.Autosize>
</Alert>
)}
{blockers.length > 0 && (
<Alert color="yellow" title="Fill these in before uploading">
<List size="sm">
{blockers.map((b) => (
<List.Item key={b}>{b}</List.Item>
))}
</List>
</Alert>
)}
{rows.length > 0 && (
<Stack gap="xs">
<Group gap="xs">
<Text fw={600} size="sm">
Preview
</Text>
<Badge size="sm">{rows.length} containers</Badge>
</Group>
<ScrollArea.Autosize mah={300}>
<Table striped highlightOnHover withTableBorder>
<Table.Thead>
<Table.Tr>
<Table.Th>Container</Table.Th>
<Table.Th>Size</Table.Th>
<Table.Th>Company</Table.Th>
<Table.Th>Returned By</Table.Th>
<Table.Th>Date</Table.Th>
<Table.Th>Facility</Table.Th>
<Table.Th>Yard</Table.Th>
</Table.Tr>
</Table.Thead>
<Table.Tbody>
{rows.map((row) => {
const payload = toPayload(row);
return (
<Table.Tr key={row.containerNumber}>
<Table.Td>{payload.containerNumber}</Table.Td>
<Table.Td>{payload.containerSize ? `${payload.containerSize} ft` : "—"}</Table.Td>
<Table.Td>
<Group gap={4} wrap="nowrap">
<Text size="sm">{payload.companyName || "—"}</Text>
{payload.companyName && !payload.customerId && (
<Badge size="xs" color="orange" variant="light">
New
</Badge>
)}
</Group>
</Table.Td>
<Table.Td>{payload.returnedBy ?? "—"}</Table.Td>
<Table.Td>
{payload.returnDate
? new Date(payload.returnDate).toLocaleDateString()
: "—"}
</Table.Td>
<Table.Td>{payload.facility ?? "—"}</Table.Td>
<Table.Td>{payload.yard ?? "—"}</Table.Td>
</Table.Tr>
);
})}
</Table.Tbody>
</Table>
</ScrollArea.Autosize>
</Stack>
)}
<Group justify="flex-end" gap="sm">
<Button
variant="default"
onClick={() => {
reset();
onClose();
}}
disabled={uploadMutation.isPending}
>
Cancel
</Button>
<Button
onClick={() => uploadMutation.mutate()}
disabled={rows.length === 0 || blockers.length > 0}
loading={uploadMutation.isPending}
>
Upload {rows.length > 0 ? `${rows.length} containers` : ""}
</Button>
</Group>
</Stack>
</Modal>
);
}

View File

@@ -13,8 +13,19 @@ import { useMutation, useQuery } from '@tanstack/react-query';
import { api } from '@/services/api';
import { useToast } from '@/hooks/use-toast';
import type { SaveWarehousePayload, Warehouse, WarehouseType } from '@/types/warehouse';
import { extractErrorMessage, lettersOnly, statusOptions, warehouseTypeOptions } from './options';
import type {
SaveWarehousePayload,
Warehouse,
WarehouseFreightType,
WarehouseType,
} from '@/types/warehouse';
import {
extractErrorMessage,
lettersOnly,
statusOptions,
warehouseFreightTypeOptions,
warehouseTypeOptions,
} from './options';
interface CreateWarehouseModalProps {
opened: boolean;
@@ -26,6 +37,7 @@ interface FormState {
name: string;
code: string;
type: WarehouseType;
freightType: WarehouseFreightType | null;
stationId: string | null;
locationName: string;
capacityWeight: number | '';
@@ -38,6 +50,7 @@ const emptyForm = (): FormState => ({
name: '',
code: '',
type: 'OPEN_WAREHOUSE',
freightType: null,
stationId: null,
locationName: '',
capacityWeight: '',
@@ -66,6 +79,7 @@ export function CreateWarehouseModal({ opened, onClose, warehouse }: CreateWareh
name: warehouse.name,
code: warehouse.code,
type: warehouse.type,
freightType: warehouse.freightType ?? null,
stationId: warehouse.stationId ?? null,
locationName: warehouse.locationName ?? '',
capacityWeight: warehouse.capacityWeight ?? '',
@@ -90,6 +104,7 @@ export function CreateWarehouseModal({ opened, onClose, warehouse }: CreateWareh
name: form.name.trim(),
code: form.code.trim(),
type: form.type,
freightType: form.freightType,
stationId: form.stationId ?? undefined,
locationName: form.locationName.trim() || undefined,
capacityWeight: form.capacityWeight === '' ? undefined : Number(form.capacityWeight),
@@ -142,6 +157,14 @@ export function CreateWarehouseModal({ opened, onClose, warehouse }: CreateWareh
/>
<Group grow>
<Select
label="Freight type"
placeholder="Both"
data={warehouseFreightTypeOptions}
value={form.freightType}
onChange={(value) => setForm((f) => ({ ...f, freightType: value as WarehouseFreightType | null }))}
clearable
/>
<Select
label="Type"
data={warehouseTypeOptions}

View File

@@ -22,6 +22,7 @@ import {
type TrainLoadableItem,
} from '@/services/warehouse.service';
import { extractErrorMessage } from './options';
import { YardLoadingWindows } from './YardLoadingWindows';
const STAGE_COLOR: Record<string, string> = {
RECEIVED: 'blue',
@@ -161,6 +162,11 @@ function TrainRow({ train, expanded, onToggle }: { train: LoadableTrain; expande
</Alert>
) : (
<Stack gap="xs">
<YardLoadingWindows
scheduleId={train.scheduleId}
items={items}
logs={train.stationWorkLogs}
/>
{bookings.map((b) => (
<BookingBlock key={b.bookingId ?? b.bookingReference} scheduleId={train.scheduleId} booking={b} />
))}

View File

@@ -1,5 +1,6 @@
import { useEffect, useMemo, useState } from 'react';
import { Button, Group, Modal, Select, Stack, Textarea } from '@mantine/core';
import { Alert, Button, Group, List, Modal, Select, Stack, Textarea, Text } from '@mantine/core';
import { Layers } from 'lucide-react';
import { useMutation, useQuery } from '@tanstack/react-query';
@@ -7,6 +8,7 @@ import { api } from '@/services/api';
import { useToast } from '@/hooks/use-toast';
import type { WarehouseInventoryItem } from '@/types/warehouse';
import { extractErrorMessage } from './options';
import { SlotPicker } from './SlotPicker';
interface MoveInventoryModalProps {
opened: boolean;
@@ -20,6 +22,7 @@ export function MoveInventoryModal({ opened, onClose, item }: MoveInventoryModal
const [warehouseId, setWarehouseId] = useState('');
const [yardId, setYardId] = useState('');
const [zoneId, setZoneId] = useState('');
const [slotId, setSlotId] = useState('');
const [remarks, setRemarks] = useState('');
useEffect(() => {
@@ -27,10 +30,22 @@ export function MoveInventoryModal({ opened, onClose, item }: MoveInventoryModal
setWarehouseId('');
setYardId('');
setZoneId('');
setSlotId('');
setRemarks('');
}
}, [opened]);
// A container with boxes stacked on top of it cannot be lifted out — the API
// refuses the move, so the button says why instead of firing a 409.
const accessibilityQuery = useQuery(
api.warehouses.containerAccessibility.queryOptions({
input: { id: item?.id ?? '' },
enabled: opened && Boolean(item?.id),
}),
);
const accessibility = accessibilityQuery.data;
const blocked = accessibility ? !accessibility.accessible : false;
const warehousesQuery = useQuery(
api.warehouses.list.queryOptions({ input: { filter: { status: 'ACTIVE' } } }),
);
@@ -69,7 +84,13 @@ export function MoveInventoryModal({ opened, onClose, item }: MoveInventoryModal
try {
await moveMutation.mutateAsync({
id: item.id,
payload: { warehouseId, yardId, zoneId, remarks: remarks.trim() || undefined },
payload: {
warehouseId,
yardId,
zoneId,
slotId: slotId || undefined,
remarks: remarks.trim() || undefined,
},
});
toast({ title: 'Inventory moved' });
onClose();
@@ -81,6 +102,22 @@ export function MoveInventoryModal({ opened, onClose, item }: MoveInventoryModal
return (
<Modal opened={opened} onClose={onClose} title="Move inventory" centered size="lg">
<Stack gap="md">
{blocked && accessibility ? (
<Alert icon={<Layers size={16} />} color="orange" variant="light" title="Container is buried">
<Text size="sm">
It sits at {accessibility.stackCode} level {accessibility.level} with{' '}
{accessibility.blockingContainers.length} container(s) stacked on top. Move these out
first:
</Text>
<List size="sm" mt={4}>
{accessibility.blockingContainers.map((b) => (
<List.Item key={b.inventoryId}>
{b.containerNumber ?? 'Container'} level {b.level}
</List.Item>
))}
</List>
</Alert>
) : null}
<Select
label="Destination warehouse"
placeholder="Select warehouse"
@@ -115,8 +152,13 @@ export function MoveInventoryModal({ opened, onClose, item }: MoveInventoryModal
disabled={!yardId}
data={zoneOptions}
value={zoneId || null}
onChange={(v) => setZoneId(v ?? '')}
onChange={(v) => {
setZoneId(v ?? '');
setSlotId('');
}}
/>
{/* Container yards only — the picker hides itself where no stacks exist. */}
<SlotPicker zoneId={zoneId} value={slotId} onChange={setSlotId} label="Destination stack position" />
<Textarea
label="Remarks"
placeholder="Reason for the move"
@@ -129,7 +171,12 @@ export function MoveInventoryModal({ opened, onClose, item }: MoveInventoryModal
<Button variant="default" onClick={onClose} disabled={moveMutation.isPending}>
Cancel
</Button>
<Button onClick={handleSubmit} loading={moveMutation.isPending}>
<Button
onClick={handleSubmit}
loading={moveMutation.isPending}
disabled={blocked}
title={blocked ? 'Containers stacked above this one must be moved first' : undefined}
>
Move inventory
</Button>
</Group>

View File

@@ -58,6 +58,7 @@ import { QUERY_KEYS } from '@/constants/QUERY_KEYS';
import { useToast } from '@/hooks/use-toast';
import { useAllWarehouseYards, useAllWarehouseZones, useInventoryInquiry } from '@/hooks/useWarehouses';
import { firstMileService } from '@/services/first-mile.service';
import { bookingsService } from '@/services/bookings.service';
import { warehouseService } from '@/services/warehouse.service';
import type {
EligibleBooking,
@@ -90,6 +91,7 @@ import { MoveInventoryModal } from './MoveInventoryModal';
import { ReleaseOrderModal } from './ReleaseOrderModal';
import { StoreInventoryModal } from './StoreInventoryModal';
import { WarehouseInquiryTable } from './WarehouseInquiryTable';
import { YardLoadingWindows } from './YardLoadingWindows';
import { extractDownloadErrorMessage, extractErrorMessage, formatDate, formatNumber, inventoryStatusOptions, warehousesAtStation, yardsForBooking } from './options';
import { openPdfBlob } from './pdf';
import ListControls from '@/components/common/ListControls';
@@ -857,6 +859,8 @@ function EligibleTab({
const [truckForm, setTruckForm] = useState<TruckEntranceFormState>(emptyTruckEntrance());
const [lockedTruckFields, setLockedTruckFields] = useState<LockedTruckEntranceFields>({});
const [packagingFreightType, setPackagingFreightType] = useState<PackagingFreightType>('MIXED');
const [selectedCustomerTruckId, setSelectedCustomerTruckId] = useState<string | null>(null);
const [selectedContainerNumbers, setSelectedContainerNumbers] = useState<string[]>([]);
const locationReady = Boolean(location.warehouseId && location.yardId && location.zoneId);
const canReceiveBooking = (row: EligibleBooking) =>
@@ -943,6 +947,105 @@ function EligibleTab({
const pendingHasFirstMile = pendingReceiveRows.some((row) => row.hasFirstMile);
const pendingUsesFirstMile =
pendingReceiveRows.length > 0 && pendingReceiveRows.every((row) => row.hasFirstMile);
const pendingContainerBooking =
pendingReceiveRows.length === 1 && pendingReceiveRows[0]?.freightType === 'CONTAINER'
? pendingReceiveRows[0]
: null;
const { data: assignedCustomerTrucks = [] } = useQuery({
queryKey: ['receive-customer-trucks', pendingContainerBooking?.id],
queryFn: () => warehouseService.getCustomerTrucks(pendingContainerBooking?.id as string),
enabled: truckOpen && Boolean(pendingContainerBooking) && !pendingUsesFirstMile,
});
const pendingContainerUnits = (pendingContainerBooking?.containerUnits ?? []).filter(
(unit) => !unit.received,
);
const selectedCustomerTruck = assignedCustomerTrucks.find(
(truck) => truck.id === selectedCustomerTruckId,
);
const assignedNumbersForSelectedTruck = new Set(
(selectedCustomerTruck?.containers ?? []).map((container) => container.containerNumber.toUpperCase()),
);
const selectableContainerUnits = pendingContainerUnits.filter(
(unit) =>
assignedNumbersForSelectedTruck.size === 0 ||
assignedNumbersForSelectedTruck.has(unit.containerNumber.toUpperCase()),
);
const selectedContainerUnits = pendingContainerUnits.filter((unit) =>
selectedContainerNumbers.includes(unit.containerNumber),
);
const selectedContainerWeight = selectedContainerUnits.reduce(
(total, unit) => total + Number(unit.weightTons || 0),
0,
);
const containerCapacityError =
selectedContainerNumbers.length > 2
? 'A truck carries no more than 2 containers.'
: selectedContainerNumbers.length > 1 &&
selectedContainerUnits.some((unit) => !String(unit.containerSize ?? '').includes('20'))
? 'Truck capacity is either 1 x 40ft container or up to 2 x 20ft containers.'
: null;
useEffect(() => {
if (!truckOpen || pendingUsesFirstMile || !pendingContainerBooking) return;
if (selectedCustomerTruckId || assignedCustomerTrucks.length === 0) return;
const pendingNumbers = new Set(
pendingContainerUnits.map((unit) => unit.containerNumber.toUpperCase()),
);
const truck =
assignedCustomerTrucks.find(
(candidate) =>
!candidate.arrivedAt &&
(candidate.containers ?? []).some((container) =>
pendingNumbers.has(container.containerNumber.toUpperCase()),
),
) ?? assignedCustomerTrucks[0];
const truckContainers = (truck.containers ?? [])
.map((container) => container.containerNumber.toUpperCase())
.filter((number) => pendingNumbers.has(number));
setSelectedCustomerTruckId(truck.id);
setSelectedContainerNumbers(truckContainers);
setTruckForm((current) => ({
...current,
truckPlateNumber: truck.plateNumber,
driverName: truck.driverName,
truckType: truck.truckType,
assignedEquipmentNumber: truckContainers.join(', '),
unitCount: truckContainers.length,
netWeightKg: pendingContainerUnits
.filter((unit) => truckContainers.includes(unit.containerNumber.toUpperCase()))
.reduce((total, unit) => total + Number(unit.weightTons || 0), 0),
}));
setLockedTruckFields((current) => ({
...current,
truckPlateNumber: true,
driverName: true,
truckType: true,
assignedEquipmentNumber: true,
unitCount: true,
}));
}, [
assignedCustomerTrucks,
pendingContainerBooking,
pendingContainerUnits,
pendingUsesFirstMile,
selectedCustomerTruckId,
truckOpen,
]);
useEffect(() => {
if (!truckOpen || !pendingContainerBooking) return;
setTruckForm((current) => ({
...current,
assignedEquipmentNumber: selectedContainerNumbers.join(', '),
unitCount: selectedContainerNumbers.length,
netWeightKg: selectedContainerWeight,
}));
}, [
pendingContainerBooking,
selectedContainerNumbers,
selectedContainerWeight,
truckOpen,
]);
const toggleAll = () =>
setSelected(allSelected ? new Set() : new Set(selectableRows.map((r) => r.id)));
@@ -953,29 +1056,60 @@ function EligibleTab({
return next;
});
const receiveBookings = async (bookingIds: string[], truckEntrance?: TruckEntrancePayload) => {
const receiveBookings = async (
bookingIds: string[],
truckEntrance?: TruckEntrancePayload,
containerNumbers?: string[],
) => {
const documentBookingId = direction === 'EXPORT' && bookingIds.length === 1 ? bookingIds[0] : null;
const grnWindow = documentBookingId ? window.open('', '_blank') : null;
const acceptanceWindow = documentBookingId ? window.open('', '_blank') : null;
try {
const r = await bulkReceive.mutateAsync({
direction,
...location,
bookingIds,
...(containerNumbers?.length ? { containerNumbers } : {}),
...(truckEntrance ? { truckEntrance } : {}),
});
const receivedProgress = r.results.find(
(item) => item.receivedContainers != null && item.remainingContainers != null,
);
toast({
title: `${r.receivedCount} received at ${direction === 'EXPORT' ? 'facility' : 'warehouse'}`,
description: r.results.find((item) => item.grnNumber)?.grnNumber ?? skippedSummary(r.skippedCount, r.results),
description: receivedProgress
? `${containerNumbers?.length ?? 0} container(s) arrived. ${receivedProgress.remainingContainers} container(s) left. GRN ${receivedProgress.grnNumber}.`
: r.results.find((item) => item.grnNumber)?.grnNumber ?? skippedSummary(r.skippedCount, r.results),
});
const firstGrn = r.results.find((item) => item.inventoryId && item.grnNumber);
if (focusedBookingId && firstGrn?.inventoryId && firstGrn.grnNumber) {
const pdfWindow = window.open('', '_blank');
if (documentBookingId && firstGrn?.inventoryId && firstGrn.grnNumber) {
try {
const response = await warehouseService.downloadGrnDocument(firstGrn.inventoryId);
const opened = openPdfBlob(response.data, `grn-${firstGrn.grnNumber}.pdf`, pdfWindow);
const opened = openPdfBlob(response.data, `grn-${firstGrn.grnNumber}.pdf`, grnWindow);
toast({ title: opened ? 'GRN document opened' : 'GRN document downloaded' });
} catch (error) {
pdfWindow?.close();
grnWindow?.close();
toast({ variant: 'destructive', title: 'GRN document failed', description: await extractDownloadErrorMessage(error) });
}
try {
const acceptance = await bookingsService.downloadCarriageAcceptanceSheet(documentBookingId);
const opened = openPdfBlob(
acceptance,
`carriage-acceptance-${pendingReceiveRows[0]?.reference ?? documentBookingId}.pdf`,
acceptanceWindow,
);
toast({ title: opened ? 'Carriage acceptance sheet opened' : 'Carriage acceptance sheet downloaded' });
} catch (error) {
acceptanceWindow?.close();
toast({
variant: 'destructive',
title: 'Carriage acceptance sheet failed',
description: await extractDownloadErrorMessage(error),
});
}
} else {
grnWindow?.close();
acceptanceWindow?.close();
}
setSelected(new Set());
setTruckOpen(false);
@@ -983,8 +1117,12 @@ function EligibleTab({
setReceivedAt(null);
setLockedTruckFields({});
setPackagingFreightType('MIXED');
setSelectedCustomerTruckId(null);
setSelectedContainerNumbers([]);
onChanged?.();
} catch (error) {
grnWindow?.close();
acceptanceWindow?.close();
toast({ variant: 'destructive', title: 'Receive failed', description: extractErrorMessage(error) });
}
};
@@ -1011,6 +1149,18 @@ function EligibleTab({
void receiveBookings(filteredIds);
return;
}
if (
direction === 'EXPORT' &&
selectedRows.some((row) => row.freightType === 'CONTAINER') &&
selectedRows.length !== 1
) {
toast({
variant: 'destructive',
title: 'Receive one container booking per truck',
description: 'Select the arriving truck and its 1 x 40ft or up to 2 x 20ft containers.',
});
return;
}
const hasFirstMileRows = selectedRows.some((row) => row.hasFirstMile);
const hasCustomerTruckRows = selectedRows.some((row) => !row.hasFirstMile);
if (hasFirstMileRows && hasCustomerTruckRows) {
@@ -1040,6 +1190,8 @@ function EligibleTab({
...form,
};
setPendingReceiveIds(filteredIds);
setSelectedCustomerTruckId(null);
setSelectedContainerNumbers([]);
setReceivedAt(new Date().toISOString());
setTruckForm(normalizedForm);
setLockedTruckFields({
@@ -1072,7 +1224,70 @@ function EligibleTab({
toast({ variant: 'destructive', title: 'Gross weight and exit tare weight are required when weighing is Yes' });
return;
}
await receiveBookings(pendingReceiveIds, toTruckEntrancePayload(truckForm));
if (pendingContainerBooking && selectedContainerNumbers.length === 0) {
toast({ variant: 'destructive', title: 'Select the containers arriving on this truck' });
return;
}
if (containerCapacityError) {
toast({ variant: 'destructive', title: 'Truck capacity exceeded', description: containerCapacityError });
return;
}
await receiveBookings(
pendingReceiveIds,
toTruckEntrancePayload({
...truckForm,
...(pendingContainerBooking
? {
assignedEquipmentNumber: selectedContainerNumbers.join(', '),
unitCount: selectedContainerNumbers.length,
netWeightKg: selectedContainerWeight,
}
: {}),
}),
pendingContainerBooking ? selectedContainerNumbers : undefined,
);
};
const chooseCustomerTruck = (truckId: string | null) => {
setSelectedCustomerTruckId(truckId);
const truck = assignedCustomerTrucks.find((candidate) => candidate.id === truckId);
if (!truck) {
setSelectedContainerNumbers([]);
setLockedTruckFields((current) => ({
...current,
truckPlateNumber: false,
driverName: false,
truckType: false,
}));
return;
}
const pendingNumbers = new Set(
pendingContainerUnits.map((unit) => unit.containerNumber.toUpperCase()),
);
const containers = (truck.containers ?? [])
.map((container) => container.containerNumber.toUpperCase())
.filter((number) => pendingNumbers.has(number));
const weight = pendingContainerUnits
.filter((unit) => containers.includes(unit.containerNumber.toUpperCase()))
.reduce((total, unit) => total + Number(unit.weightTons || 0), 0);
setSelectedContainerNumbers(containers);
setTruckForm((current) => ({
...current,
truckPlateNumber: truck.plateNumber,
driverName: truck.driverName,
truckType: truck.truckType,
assignedEquipmentNumber: containers.join(', '),
unitCount: containers.length,
netWeightKg: weight,
}));
setLockedTruckFields((current) => ({
...current,
truckPlateNumber: true,
driverName: true,
truckType: true,
assignedEquipmentNumber: true,
unitCount: true,
}));
};
@@ -1295,6 +1510,76 @@ function EligibleTab({
: 'Register the customer or third-party truck and driver before export receiving and GRN.'}
</Text>
</Alert>
{pendingContainerBooking && (
<Stack gap="sm">
<Alert icon={<PackageCheck size={16} />} color="teal" variant="light">
<Group gap="xs">
<Text size="sm" fw={600}>
{pendingContainerBooking.receivedContainerCount + selectedContainerNumbers.length} containers arrived
</Text>
<Text size="sm">
· {Math.max(
0,
pendingContainerBooking.remainingContainerCount - selectedContainerNumbers.length,
)} left after this receipt
</Text>
<Badge variant="light" color="blue">
This truck: {selectedContainerNumbers.length}
</Badge>
</Group>
</Alert>
{assignedCustomerTrucks.length > 0 && !pendingUsesFirstMile && (
<Select
label="Arriving assigned truck"
description="Choose the physical truck at the gate; its assigned containers are selected below."
placeholder="Select truck"
data={assignedCustomerTrucks.map((truck) => ({
value: truck.id,
label: `${truck.plateNumber} · ${truck.driverName} · ${(truck.containers ?? [])
.map((container) => container.containerNumber)
.join(', ') || 'no containers'}`,
}))}
value={selectedCustomerTruckId}
onChange={chooseCustomerTruck}
searchable
required
/>
)}
<MultiSelect
label="Containers arriving on this truck"
description="Required: select either 1 x 40ft container or up to 2 x 20ft containers."
placeholder="Select the containers physically arriving"
data={selectableContainerUnits.map((unit) => {
const selected = selectedContainerNumbers.includes(unit.containerNumber);
const selectedHasNon20 = selectedContainerUnits.some(
(selectedUnit) => !String(selectedUnit.containerSize ?? '').includes('20'),
);
const candidateIs20 = String(unit.containerSize ?? '').includes('20');
return {
value: unit.containerNumber,
label: `${unit.containerNumber} · ${unit.containerSize ?? 'size unknown'} · ${Number(
unit.weightTons || 0,
).toLocaleString()} t`,
disabled:
!selected &&
(selectedContainerNumbers.length >= 2 ||
(selectedContainerNumbers.length === 1 &&
(selectedHasNon20 || !candidateIs20))),
};
})}
value={selectedContainerNumbers}
onChange={setSelectedContainerNumbers}
maxValues={2}
searchable
required
/>
{containerCapacityError && (
<Alert color="red" variant="light">
{containerCapacityError}
</Alert>
)}
</Stack>
)}
<Table.ScrollContainer minWidth={900}>
<Table withTableBorder highlightOnHover verticalSpacing="xs">
<Table.Thead>
@@ -1353,7 +1638,9 @@ function EligibleTab({
Cancel
</Button>
<Button leftSection={<Truck size={16} />} loading={bulkReceive.isPending} onClick={receive}>
Register Arrival & Generate GRN
{pendingContainerBooking
? 'Receive Selected Containers & Generate CAS + GRN'
: 'Register Arrival & Generate GRN'}
</Button>
</Group>
</Stack>
@@ -1598,6 +1885,11 @@ function ReadyToLoadTab({ enabled, onChanged }: { enabled: boolean; onChanged?:
queryFn: () => warehouseService.getLoadableTrains(),
enabled: enabled && trainPickerOpen,
});
const { data: pickerItems = [] } = useQuery({
queryKey: ['train-loadable-items', targetScheduleId],
queryFn: () => warehouseService.getTrainLoadableItems(targetScheduleId!),
enabled: Boolean(targetScheduleId) && trainPickerOpen,
});
const loadOntoTrain = useMutation({
mutationFn: async ({ scheduleId, onlyIds }: { scheduleId: string; onlyIds: string[] }) => {
const items = await warehouseService.getTrainLoadableItems(scheduleId);
@@ -1608,6 +1900,17 @@ function ReadyToLoadTab({ enabled, onChanged }: { enabled: boolean; onChanged?:
loadableIds = loadableIds.filter((id) => picked.has(id));
}
if (!loadableIds.length) {
const scope = onlyIds.length
? items.filter((i) => onlyIds.includes(i.id))
: items.filter((i) => i.status === 'READY_FOR_LOADING');
// A closed loading window is the blocker staff hit most, and the old
// wagon-only message sent them to fix the wrong thing.
const shut = scope.find((i) => !i.loadingWindowStarted);
if (shut) {
throw new Error(
`Start loading at ${shut.originYardLabel ?? 'the boarding yard'} first — the loading time window has not been started`,
);
}
throw new Error(
onlyIds.length
? 'None of the selected items have an allocated wagon on this train'
@@ -1701,6 +2004,13 @@ function ReadyToLoadTab({ enabled, onChanged }: { enabled: boolean; onChanged?:
searchable
/>
)}
{targetScheduleId ? (
<YardLoadingWindows
scheduleId={targetScheduleId}
items={pickerItems}
logs={trains.find((t) => t.scheduleId === targetScheduleId)?.stationWorkLogs}
/>
) : null}
<Group justify="flex-end">
<Button variant="default" onClick={() => setTrainPickerOpen(false)} disabled={loadOntoTrain.isPending}>
Cancel

View File

@@ -387,6 +387,9 @@ export function ReleaseOrderModal({ opened, onClose, item, truckPrefill }: Relea
const containerWeightByNumber = new Map(
containerWeights.map((c) => [c.containerNumber.toUpperCase(), Number(c.weightTons) || 0]),
);
const containerSizeByNumber = new Map(
containerWeights.map((c) => [c.containerNumber.toUpperCase(), c.containerSize ?? '']),
);
// A truck may only carry out its OWN assigned containers — when the selected
// truck has an assigned load, other trucks' containers are not offered.
const assignedLoad = (selectedOption?.containerNumbers ?? []).map((n) => n.toUpperCase());
@@ -398,7 +401,7 @@ export function ReleaseOrderModal({ opened, onClose, item, truckPrefill }: Relea
c.containerNumber,
{
value: c.containerNumber,
label: `${c.containerNumber} · ${(Number(c.weightTons) || 0).toLocaleString()} t`,
label: `${c.containerNumber} · ${c.containerSize ?? 'size unknown'} · ${(Number(c.weightTons) || 0).toLocaleString()} t`,
},
]),
).values(),
@@ -409,6 +412,15 @@ export function ReleaseOrderModal({ opened, onClose, item, truckPrefill }: Relea
containerNumbers.some((n) => n.trim().toUpperCase() === option.value.toUpperCase()),
);
const selectedContainerNumbers = containerNumbers.map((n) => n.trim()).filter(Boolean);
const selectedContainerSizes = selectedContainerNumbers.map(
(number) => containerSizeByNumber.get(number.toUpperCase()) ?? '',
);
const containerCapacityError =
selectedContainerNumbers.length > 2
? 'A truck carries no more than 2 containers.'
: selectedContainerNumbers.length > 1 && selectedContainerSizes.some((size) => !size.includes('20'))
? 'Truck capacity is either 1 x 40ft container or up to 2 x 20ft containers.'
: null;
const selectedCargoWeight = Number(
selectedContainerNumbers
.reduce((sum, n) => sum + (containerWeightByNumber.get(n.toUpperCase()) ?? 0), 0)
@@ -470,6 +482,10 @@ export function ReleaseOrderModal({ opened, onClose, item, truckPrefill }: Relea
toast({ variant: 'destructive', title: 'Select the containers loaded on this truck' });
return;
}
if (isExitStep && containerCapacityError) {
toast({ variant: 'destructive', title: 'Truck capacity exceeded', description: containerCapacityError });
return;
}
if (isExitStep && !skipWeighing && systemNetWeight === '') {
toast({ variant: 'destructive', title: 'System recorded net weight is missing' });
return;
@@ -636,14 +652,15 @@ export function ReleaseOrderModal({ opened, onClose, item, truckPrefill }: Relea
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.'
? 'Select what is leaving: 1 x 40ft or up to 2 x 20ft. Their cargo weight must match gross - tare.'
: 'Truck capacity: 1 x 40ft container or up to 2 x 20ft containers.'
}
placeholder="Select containers"
searchable
data={containerSelectData}
value={selectedContainerNumbers}
onChange={(values) => setContainerNumbers(values.length ? values : [''])}
maxValues={2}
disabled={hasTruckLeft}
/>
) : (
@@ -708,6 +725,11 @@ export function ReleaseOrderModal({ opened, onClose, item, truckPrefill }: Relea
</Text>
</Alert>
)}
{containerCapacityError && (
<Alert icon={<Info size={16} />} color="red" variant="light">
<Text size="sm">{containerCapacityError}</Text>
</Alert>
)}
<Group justify="flex-end" mt="sm">
<Button variant="default" onClick={onClose} disabled={releaseMutation.isPending || downloading}>
Cancel

View File

@@ -0,0 +1,87 @@
import { useMemo } from 'react';
import { Select, Text } from '@mantine/core';
import { useQuery } from '@tanstack/react-query';
import { api } from '@/services/api';
import type { ZoneLayout } from '@/types/warehouse';
interface SlotPickerProps {
zoneId: string;
value: string;
onChange: (slotId: string) => void;
label?: string;
disabled?: boolean;
}
/**
* The stack levels a container may actually be put on right now.
*
* Only the next fillable level of each stack is offered: a box cannot stand on
* level 2 while level 1 is empty, so listing level 3 of an empty stack would
* only produce a rejected request. The server enforces the same rule — this
* mirrors it so the operator never sees a 400 for a position the form offered.
*/
export function fillableLevels(layout: ZoneLayout | undefined) {
if (!layout) return [];
return layout.stacks
.filter((stack) => stack.isActive && stack.status === 'ACTIVE')
.flatMap((stack) => {
const occupied = stack.slots
.filter((slot) => slot.effectiveStatus === 'OCCUPIED')
.map((slot) => slot.level);
const top = occupied.length > 0 ? Math.max(...occupied) : 0;
if (top >= stack.maxStackHeight) return [];
const next = stack.slots.find(
(slot) => slot.level === top + 1 && slot.effectiveStatus === 'AVAILABLE',
);
if (!next) return [];
return [
{
value: next.slotId,
label: `${stack.code} — level ${next.level}${top > 0 ? ` (on ${top} container${top > 1 ? 's' : ''})` : ' (ground)'}`,
},
];
});
}
export function SlotPicker({ zoneId, value, onChange, label = 'Stack position', disabled }: SlotPickerProps) {
const { data, isLoading } = useQuery(
api.warehouses.zoneLayout.queryOptions({
input: { zoneId },
enabled: Boolean(zoneId),
}),
);
const options = useMemo(() => fillableLevels(data), [data]);
// A zone with no stacks configured keeps plain zone-level placement — showing
// an empty picker there would imply a choice that does not exist.
if (!zoneId || (!isLoading && (data?.stacks.length ?? 0) === 0)) return null;
return (
<Select
label={label}
description={
options.length === 0 && !isLoading ? (
<Text size="xs" c="orange">
Every stack in this zone is full or blocked the item will be stored at zone level.
</Text>
) : (
'Leave blank to take the lowest free level automatically.'
)
}
placeholder={isLoading ? 'Loading positions…' : 'Automatic (lowest free level)'}
searchable
clearable
disabled={disabled || isLoading || options.length === 0}
data={options}
value={value || null}
onChange={(v) => onChange(v ?? '')}
/>
);
}
export default SlotPicker;

View File

@@ -8,6 +8,7 @@ import { api } from '@/services/api';
import { useToast } from '@/hooks/use-toast';
import type { WarehouseInventoryItem } from '@/types/warehouse';
import { extractErrorMessage } from './options';
import { SlotPicker } from './SlotPicker';
interface StoreInventoryModalProps {
opened: boolean;
@@ -25,12 +26,14 @@ export function StoreInventoryModal({ opened, onClose, item }: StoreInventoryMod
const [warehouseId, setWarehouseId] = useState('');
const [yardId, setYardId] = useState('');
const [zoneId, setZoneId] = useState('');
const [slotId, setSlotId] = useState('');
useEffect(() => {
if (opened) {
setWarehouseId('');
setYardId('');
setZoneId('');
setSlotId('');
}
}, [opened]);
@@ -75,7 +78,7 @@ export function StoreInventoryModal({ opened, onClose, item }: StoreInventoryMod
try {
await storeMutation.mutateAsync({
id: item.id,
payload: manualComplete ? { warehouseId, yardId, zoneId } : undefined,
payload: manualComplete ? { warehouseId, yardId, zoneId, slotId: slotId || undefined } : undefined,
});
toast({ title: manualComplete ? 'Inventory stored at selected location' : 'Inventory stored (auto-allocated)' });
onClose();
@@ -127,8 +130,13 @@ export function StoreInventoryModal({ opened, onClose, item }: StoreInventoryMod
disabled={!yardId}
data={zoneOptions}
value={zoneId || null}
onChange={(v) => setZoneId(v ?? '')}
onChange={(v) => {
setZoneId(v ?? '');
setSlotId('');
}}
/>
{/* Container yards only — the picker hides itself where no stacks exist. */}
<SlotPicker zoneId={zoneId} value={slotId} onChange={setSlotId} />
<Group justify="flex-end" mt="sm">
<Button variant="default" onClick={onClose} disabled={storeMutation.isPending}>
Cancel

View File

@@ -1,6 +1,6 @@
import { useMemo } from 'react';
import { ActionIcon, Box, Card, Divider, Group, Progress, SimpleGrid, Stack, Text, Tooltip } from '@mantine/core';
import { Building2, Eye, MapPin, Package, Pencil, Weight } from 'lucide-react';
import { Building2, Eye, MapPin, Package, Pencil, Trash2, Weight } from 'lucide-react';
import { useQuery } from '@tanstack/react-query';
@@ -13,9 +13,11 @@ interface WarehouseCardViewProps {
warehouses: Warehouse[];
onView: (warehouse: Warehouse) => void;
onEdit: (warehouse: Warehouse) => void;
/** Omitted when the user lacks the delete permission. */
onDelete?: (warehouse: Warehouse) => void;
}
export function WarehouseCardView({ warehouses, onView, onEdit }: WarehouseCardViewProps) {
export function WarehouseCardView({ warehouses, onView, onEdit, onDelete }: WarehouseCardViewProps) {
const { data: stations } = useQuery(
api.stations.list.queryOptions({ staleTime: 5 * 60 * 1000 }),
);
@@ -130,6 +132,18 @@ export function WarehouseCardView({ warehouses, onView, onEdit }: WarehouseCardV
<Pencil size={16} />
</ActionIcon>
</Tooltip>
{onDelete ? (
<Tooltip label="Delete warehouse" withArrow>
<ActionIcon
variant="light"
color="red"
onClick={() => onDelete(warehouse)}
aria-label="Delete warehouse"
>
<Trash2 size={16} />
</ActionIcon>
</Tooltip>
) : null}
</Group>
</Stack>
</Card>

View File

@@ -8,6 +8,7 @@ import {
Package,
Pencil,
Scale,
Trash2,
Warehouse as WarehouseIcon,
} from 'lucide-react';
import { DataTable, type ColumnDef } from '@edr/ui-common';
@@ -24,6 +25,8 @@ interface WarehouseTableProps {
warehouses: Warehouse[];
onView: (warehouse: Warehouse) => void;
onEdit: (warehouse: Warehouse) => void;
/** Omitted when the user lacks the delete permission. */
onDelete?: (warehouse: Warehouse) => void;
}
const HEADER = bookingTable.headerCell;
@@ -63,7 +66,7 @@ function CapacityCell({
);
}
export function WarehouseTable({ warehouses, onView, onEdit }: WarehouseTableProps) {
export function WarehouseTable({ warehouses, onView, onEdit, onDelete }: WarehouseTableProps) {
const { data: stations } = useQuery(
api.stations.list.queryOptions({ staleTime: 5 * 60 * 1000 }),
);
@@ -179,6 +182,11 @@ export function WarehouseTable({ warehouses, onView, onEdit }: WarehouseTablePro
<ActionIcon variant="subtle" color="gray" onClick={() => onEdit(row.original)} title="Edit">
<Pencil size={16} />
</ActionIcon>
{onDelete ? (
<ActionIcon variant="subtle" color="red" onClick={() => onDelete(row.original)} title="Delete">
<Trash2 size={16} />
</ActionIcon>
) : null}
</Group>
),
},

View File

@@ -0,0 +1,77 @@
import { useMemo } from 'react';
import { Alert, Badge, Group, Stack, Text } from '@mantine/core';
import { Info, MapPin } from 'lucide-react';
import { StationWorkControls } from '@/components/trainScheduling/StationWorkControls';
import type { TrainLoadableItem } from '@/services/warehouse.service';
import type { StationWorkLog } from '@/types/trainScheduling';
/**
* The train schedule's per-yard loading window, shown where the warehouse
* actually loads. Cargo may only go onto a wagon inside a started window
* (assertStationWorkStarted on the API side), so the same Start/End loading
* controls the train schedule page carries belong here too — otherwise the
* warehouse operator sees a Load button that the server refuses.
*
* One block per boarding yard of the items waiting to load, since a train can
* pick cargo up at more than one stop and each stop has its own window.
*/
export function YardLoadingWindows({
scheduleId,
items,
logs,
}: {
scheduleId: string;
items: TrainLoadableItem[];
logs: Record<string, StationWorkLog> | null | undefined;
}) {
const yards = useMemo(() => {
const byYard = new Map<string, { label: string; count: number }>();
for (const item of items) {
// Everything still queued for this train, not only the ready ones — the
// operator starts the window before the cargo finishes becoming ready.
if (item.status === 'LOADED' || !item.originYardId) continue;
const entry = byYard.get(item.originYardId);
if (entry) entry.count += 1;
else byYard.set(item.originYardId, { label: item.originYardLabel ?? 'Boarding yard', count: 1 });
}
return [...byYard.entries()].map(([yardId, v]) => ({ yardId, ...v }));
}, [items]);
if (yards.length === 0) return null;
const anyOpen = yards.some((y) => logs?.[y.yardId]?.loading?.startedAt);
return (
<Stack gap="xs">
<Text size="sm" fw={600}>
Loading window
</Text>
{!anyOpen ? (
<Alert color="yellow" variant="light" icon={<Info size={16} />}>
Start loading at the yard before loading cargo the train schedule records the same
window, and the server refuses cargo outside it.
</Alert>
) : null}
{yards.map((yard) => (
<Stack key={yard.yardId} gap={6} p="xs" style={{ border: '1px solid var(--mantine-color-gray-3)', borderRadius: 8 }}>
<Group gap={8} align="center">
<MapPin size={13} />
<Text size="xs" fw={700}>
{yard.label}
</Text>
<Badge size="sm" radius="sm" variant="light" color="gray">
{yard.count} queued
</Badge>
</Group>
<StationWorkControls
scheduleId={scheduleId}
yardId={yard.yardId}
phase="loading"
log={logs?.[yard.yardId]?.loading}
/>
</Stack>
))}
</Stack>
);
}

View File

@@ -0,0 +1,119 @@
import { Badge, Group, Loader, Modal, Text } from '@mantine/core';
import { useQuery } from '@tanstack/react-query';
import { DataTable, type ColumnDef } from '@edr/ui-common';
import { formatDateTime, humanize } from '@/lib/format';
import { api } from '@/services/api';
import type { ZoneContentItem } from '@/types/warehouse';
/** Enough to name the zone and fetch it — satisfied by WarehouseZone and ZoneOccupancy alike. */
export interface ZoneRef {
id: string;
name: string;
code: string;
}
interface ZoneContentsModalProps {
opened: boolean;
onClose: () => void;
zone: ZoneRef | null;
}
const columns: ColumnDef<ZoneContentItem>[] = [
{
id: 'containerNumber',
header: 'Container No.',
// Bulk cargo has no container of its own — it still occupies the zone.
cell: ({ row }) => row.original.containerNumber ?? 'Bulk cargo',
},
{
id: 'unloadedAt',
header: 'Unloaded',
cell: ({ row }) => formatDateTime(row.original.unloadedAt),
},
{
id: 'containerType',
header: 'Type',
cell: ({ row }) => row.original.containerType ?? '—',
},
{
id: 'direction',
header: 'Import / Export',
cell: ({ row }) =>
row.original.direction ? (
<Badge color={row.original.direction === 'IMPORT' ? 'blue' : 'teal'} variant="light">
{humanize(row.original.direction)}
</Badge>
) : (
'—'
),
},
{
id: 'loadState',
header: 'Full / Empty',
cell: ({ row }) =>
row.original.loadState ? (
<Badge color={row.original.loadState === 'EMPTY' ? 'gray' : 'green'} variant="light">
{humanize(row.original.loadState)}
</Badge>
) : (
'—'
),
},
{
id: 'bookingReference',
header: 'Booking',
cell: ({ row }) => row.original.bookingReference ?? '—',
},
{
id: 'status',
header: 'Status',
cell: ({ row }) => humanize(row.original.status),
},
];
export function ZoneContentsModal({ opened, onClose, zone }: ZoneContentsModalProps) {
const { data, isLoading, isError } = useQuery(
api.warehouses.zoneContents.queryOptions({
input: { zoneId: zone?.id ?? '' },
enabled: opened && Boolean(zone?.id),
}),
);
const items = data ?? [];
return (
<Modal
opened={opened}
onClose={onClose}
size="xl"
title={
<Group gap="xs">
<Text fw={700}>{zone ? `${zone.name} (${zone.code})` : 'Zone'}</Text>
<Text size="sm" c="dimmed">
{items.length} item(s) in this zone
</Text>
</Group>
}
>
{isLoading ? (
<Group justify="center" py="xl">
<Loader />
</Group>
) : isError ? (
<Text c="red" ta="center" py="xl">
Failed to load zone contents.
</Text>
) : (
<DataTable
columns={columns}
data={items}
status="success"
emptyMessage="This zone is empty."
/>
)}
</Modal>
);
}
export default ZoneContentsModal;

View File

@@ -0,0 +1,407 @@
import { useMemo, useState } from 'react';
import {
ActionIcon,
Badge,
Button,
Card,
Group,
Loader,
Menu,
Modal,
NumberInput,
Paper,
SimpleGrid,
Stack,
Text,
TextInput,
Tooltip,
} from '@mantine/core';
import { useMutation, useQuery } from '@tanstack/react-query';
import { Ban, CircleSlash, Layers, MoreVertical, Plus, Trash2, Unlock } from 'lucide-react';
import { useAuth } from '@/auth/useAuth';
import { useToast } from '@/hooks/use-toast';
import { FREIGHT_PERMS, hasPermission } from '@/lib/permissions';
import { api } from '@/services/api';
import type { SlotEffectiveStatus, ZoneLayoutSlot, ZoneLayoutStack } from '@/types/warehouse';
import { extractErrorMessage } from './options';
import type { ZoneRef } from './ZoneContentsModal';
interface ZoneLayoutModalProps {
opened: boolean;
onClose: () => void;
zone: ZoneRef | null;
}
/** One colour per slot state, used by both the cell and the legend. */
const SLOT_TONE: Record<SlotEffectiveStatus, { color: string; label: string }> = {
OCCUPIED: { color: 'blue', label: 'Occupied' },
AVAILABLE: { color: 'teal', label: 'Free' },
RESERVED: { color: 'orange', label: 'Reserved' },
BLOCKED: { color: 'red', label: 'Blocked' },
INACTIVE: { color: 'gray', label: 'Inactive' },
};
/**
* A container stack seen from the side: level 3 on top, level 1 on the ground —
* the order the API already returns and the order the yard actually looks.
*/
function SlotCell({
slot,
onSetStatus,
canEdit,
}: {
slot: ZoneLayoutSlot;
canEdit: boolean;
onSetStatus: (slot: ZoneLayoutSlot, status: 'AVAILABLE' | 'BLOCKED' | 'RESERVED') => void;
}) {
const tone = SLOT_TONE[slot.effectiveStatus];
const occupied = slot.effectiveStatus === 'OCCUPIED';
return (
<Paper
withBorder
radius="sm"
px="xs"
py={6}
style={{ borderLeft: `4px solid var(--mantine-color-${tone.color}-6)` }}
>
<Group justify="space-between" wrap="nowrap" gap="xs">
<Group gap={6} wrap="nowrap" style={{ minWidth: 0 }}>
<Text size="xs" c="dimmed" fw={600} w={20}>
L{slot.level}
</Text>
<Text size="sm" truncate title={slot.containerNumber ?? tone.label}>
{occupied ? (slot.containerNumber ?? 'Container') : tone.label}
</Text>
</Group>
{/* An occupied level has no status to set — empty it by moving the box. */}
{canEdit && !occupied ? (
<Menu position="bottom-end" withinPortal>
<Menu.Target>
<ActionIcon variant="subtle" size="sm" aria-label={`Level ${slot.level} actions`}>
<MoreVertical size={14} />
</ActionIcon>
</Menu.Target>
<Menu.Dropdown>
<Menu.Item
leftSection={<Unlock size={14} />}
disabled={slot.effectiveStatus === 'AVAILABLE'}
onClick={() => onSetStatus(slot, 'AVAILABLE')}
>
Mark free
</Menu.Item>
<Menu.Item
leftSection={<CircleSlash size={14} />}
disabled={slot.effectiveStatus === 'RESERVED'}
onClick={() => onSetStatus(slot, 'RESERVED')}
>
Reserve
</Menu.Item>
<Menu.Item
leftSection={<Ban size={14} />}
color="red"
disabled={slot.effectiveStatus === 'BLOCKED'}
onClick={() => onSetStatus(slot, 'BLOCKED')}
>
Block
</Menu.Item>
</Menu.Dropdown>
</Menu>
) : null}
</Group>
</Paper>
);
}
function StackCard({
stack,
canEdit,
canDelete,
onSetSlotStatus,
onDelete,
}: {
stack: ZoneLayoutStack;
canEdit: boolean;
canDelete: boolean;
onSetSlotStatus: (slot: ZoneLayoutSlot, status: 'AVAILABLE' | 'BLOCKED' | 'RESERVED') => void;
onDelete: (stack: ZoneLayoutStack) => void;
}) {
const filled = stack.slots.filter((s) => s.effectiveStatus === 'OCCUPIED').length;
return (
<Card withBorder radius="md" padding="sm">
<Stack gap={8}>
<Group justify="space-between" wrap="nowrap" gap="xs">
<Group gap={6} wrap="nowrap" style={{ minWidth: 0 }}>
<Text fw={600} size="sm" truncate title={stack.name ?? stack.code}>
{stack.code}
</Text>
{stack.status !== 'ACTIVE' || !stack.isActive ? (
<Badge size="xs" color="gray" variant="light">
Inactive
</Badge>
) : null}
</Group>
<Group gap={4} wrap="nowrap">
<Badge size="sm" variant="light" color={filled === stack.maxStackHeight ? 'blue' : 'gray'}>
{filled}/{stack.maxStackHeight}
</Badge>
{canDelete ? (
<Tooltip label="Delete stack">
<ActionIcon
variant="subtle"
color="red"
size="sm"
onClick={() => onDelete(stack)}
aria-label={`Delete stack ${stack.code}`}
>
<Trash2 size={14} />
</ActionIcon>
</Tooltip>
) : null}
</Group>
</Group>
<Stack gap={4}>
{stack.slots.map((slot) => (
<SlotCell key={slot.slotId} slot={slot} canEdit={canEdit} onSetStatus={onSetSlotStatus} />
))}
</Stack>
</Stack>
</Card>
);
}
/**
* Physical layout of one zone — every ground stack with its levels, what stands
* on each, and the capacity numbers that are routinely confused (configured vs
* built vs full). Stacks are created and retired from here, since there is
* nowhere else the yard layout is visible.
*/
export function ZoneLayoutModal({ opened, onClose, zone }: ZoneLayoutModalProps) {
const { toast } = useToast();
const { user } = useAuth();
const canCreate = hasPermission(user, FREIGHT_PERMS.warehouseZones.create);
const canEdit = hasPermission(user, FREIGHT_PERMS.warehouseZones.update);
const canDelete = hasPermission(user, FREIGHT_PERMS.warehouseZones.delete);
const zoneId = zone?.id ?? '';
const { data, isLoading, isError, refetch } = useQuery(
api.warehouses.zoneLayout.queryOptions({
input: { zoneId },
enabled: opened && Boolean(zoneId),
}),
);
const [creating, setCreating] = useState(false);
const [code, setCode] = useState('');
const [height, setHeight] = useState<number>(3);
const createStack = useMutation(api.warehouses.createStack.mutationOptions());
const deleteStack = useMutation(api.warehouses.deleteStack.mutationOptions());
const updateSlot = useMutation(api.warehouses.updateSlot.mutationOptions());
const stacks = data?.stacks ?? [];
const summary = data?.summary;
const nextCode = useMemo(() => {
// Suggest the next number in the zone's own series (ZA-001 → ZA-002) so
// codes stay sortable, which is the order the placement engine walks.
const numbered = stacks
.map((s) => /^(.*?)(\d+)$/.exec(s.code))
.filter((m): m is RegExpExecArray => Boolean(m));
if (numbered.length === 0) return '';
const last = numbered[numbered.length - 1];
const width = last[2].length;
const next = Math.max(...numbered.map((m) => Number(m[2]))) + 1;
return `${last[1]}${String(next).padStart(width, '0')}`;
}, [stacks]);
const submitStack = () => {
const trimmed = code.trim();
if (!trimmed) {
toast({ variant: 'destructive', title: 'Stack code is required' });
return;
}
createStack.mutate(
{ zoneId, payload: { code: trimmed, maxStackHeight: height } },
{
onSuccess: () => {
toast({ title: `Stack ${trimmed} created with ${height} level(s)` });
setCode('');
setCreating(false);
},
onError: (error) =>
toast({
variant: 'destructive',
title: 'Could not create the stack',
description: extractErrorMessage(error),
}),
},
);
};
const removeStack = (stack: ZoneLayoutStack) => {
if (!window.confirm(`Delete stack ${stack.code}? It must be empty first.`)) return;
deleteStack.mutate(
{ id: stack.stackId, zoneId },
{
onSuccess: () => toast({ title: `Stack ${stack.code} deleted` }),
onError: (error) =>
toast({
variant: 'destructive',
title: 'Could not delete the stack',
description: extractErrorMessage(error),
}),
},
);
};
const setSlotStatus = (slot: ZoneLayoutSlot, status: 'AVAILABLE' | 'BLOCKED' | 'RESERVED') => {
updateSlot.mutate(
{ slotId: slot.slotId, zoneId, payload: { status, isActive: true } },
{
onSuccess: () => toast({ title: `Level ${slot.level} set to ${SLOT_TONE[status].label}` }),
onError: (error) =>
toast({
variant: 'destructive',
title: 'Could not update the level',
description: extractErrorMessage(error),
}),
},
);
};
return (
<Modal
opened={opened}
onClose={onClose}
size="xl"
title={
<Group gap="xs">
<Layers size={18} />
<Text fw={700}>{zone ? `${zone.name} (${zone.code}) layout` : 'Zone layout'}</Text>
</Group>
}
>
<Stack gap="md">
{summary ? (
<Card withBorder radius="md" padding="sm">
<Group gap="lg" wrap="wrap">
<Stat label="Configured capacity" value={summary.configuredCapacity ?? '—'} />
<Stat label="Slots built" value={summary.physicalSlotCount} />
<Stat label="Occupied" value={summary.occupiedSlotCount} color="blue" />
<Stat label="Free" value={summary.availableSlotCount} color="teal" />
<Stat label="Reserved" value={summary.reservedSlotCount} color="orange" />
<Stat label="Blocked" value={summary.blockedSlotCount} color="red" />
</Group>
{summary.inconsistent ? (
<Text size="xs" c="red" mt={6}>
{summary.physicalSlotCount} slots are built but the zone is configured for{' '}
{summary.configuredCapacity}. Raise the zone capacity or remove stacks the
configured figure was left as it is.
</Text>
) : null}
</Card>
) : null}
<Group justify="space-between">
<Group gap="xs">
{(Object.keys(SLOT_TONE) as SlotEffectiveStatus[]).map((key) => (
<Badge key={key} size="xs" variant="light" color={SLOT_TONE[key].color}>
{SLOT_TONE[key].label}
</Badge>
))}
</Group>
{canCreate ? (
<Button
size="xs"
leftSection={<Plus size={14} />}
variant={creating ? 'default' : 'filled'}
onClick={() => {
setCreating((open) => !open);
if (!creating && !code) setCode(nextCode);
}}
>
{creating ? 'Cancel' : 'Add stack'}
</Button>
) : null}
</Group>
{creating ? (
<Card withBorder radius="md" padding="sm">
<Group align="flex-end" gap="sm">
<TextInput
label="Stack code"
placeholder="ZA-001"
value={code}
onChange={(e) => setCode(e.currentTarget.value)}
style={{ flex: 1 }}
/>
<NumberInput
label="Levels"
description="One slot is created per level"
min={1}
max={10}
value={height}
onChange={(v) => setHeight(Number(v) || 1)}
w={140}
/>
<Button onClick={submitStack} loading={createStack.isPending}>
Create
</Button>
</Group>
</Card>
) : null}
{isLoading ? (
<Group justify="center" py="xl">
<Loader />
</Group>
) : isError ? (
<Stack align="center" py="xl" gap="xs">
<Text c="red">Failed to load the zone layout.</Text>
<Button variant="default" size="xs" onClick={() => void refetch()}>
Retry
</Button>
</Stack>
) : stacks.length === 0 ? (
<Text c="dimmed" ta="center" py="xl" size="sm">
No ground stacks configured in this zone yet. Containers stored here keep zone-level
placement until stacks exist.
</Text>
) : (
<SimpleGrid cols={{ base: 1, xs: 2, sm: 3, lg: 4 }} spacing="sm">
{stacks.map((stack) => (
<StackCard
key={stack.stackId}
stack={stack}
canEdit={canEdit}
canDelete={canDelete}
onSetSlotStatus={setSlotStatus}
onDelete={removeStack}
/>
))}
</SimpleGrid>
)}
</Stack>
</Modal>
);
}
function Stat({ label, value, color }: { label: string; value: number | string; color?: string }) {
return (
<Stack gap={0}>
<Text size="xs" c="dimmed">
{label}
</Text>
<Text fw={700} c={color}>
{value}
</Text>
</Stack>
);
}
export default ZoneLayoutModal;

View File

@@ -25,13 +25,15 @@ function capacityLabel(z: ZoneOccupancy): string {
interface ZoneOccupancyHeatmapProps {
/** Scope to one yard; omit for all zones. */
yardId?: string;
/** Pass to make each tile open that zone; omitted leaves the tiles inert. */
onZoneClick?: (zone: ZoneOccupancy) => void;
}
/**
* Occupancy heatmap: one tile per zone, coloured by how full it is. Occupancy is
* container-count based (unit-consistent); weight is shown as context only.
*/
export function ZoneOccupancyHeatmap({ yardId }: ZoneOccupancyHeatmapProps) {
export function ZoneOccupancyHeatmap({ yardId, onZoneClick }: ZoneOccupancyHeatmapProps) {
const { data: zones = [], isLoading } = useZoneOccupancy(yardId);
if (isLoading) {
@@ -67,7 +69,15 @@ export function ZoneOccupancyHeatmap({ yardId }: ZoneOccupancyHeatmapProps) {
const t = tone(z.occupancyPct);
const pct = z.occupancyPct ?? 0;
return (
<Card key={z.id} withBorder radius="md" padding="sm">
<Card
key={z.id}
withBorder
radius="md"
padding="sm"
onClick={onZoneClick ? () => onZoneClick(z) : undefined}
style={onZoneClick ? { cursor: 'pointer' } : undefined}
title={onZoneClick ? `View what is stored in ${z.name}` : undefined}
>
<Stack gap={6}>
<Group justify="space-between" wrap="nowrap" gap="xs">
<Text fw={600} size="sm" truncate title={z.name}>

View File

@@ -0,0 +1,75 @@
import { describe, expect, it } from "vitest";
import * as XLSX from "xlsx";
import { parseContainerReturnExcel } from "./container-return-excel";
/** Build an in-memory .xlsx and hand it back as a File, like the dropzone would. */
function sheetFile(aoa: unknown[][]): File {
const wb = XLSX.utils.book_new();
XLSX.utils.book_append_sheet(wb, XLSX.utils.aoa_to_sheet(aoa), "Sheet1");
const buf = XLSX.write(wb, { type: "array", bookType: "xlsx" }) as ArrayBuffer;
return new File([buf], "returns.xlsx");
}
const HEADERS = [
"Container Number",
"Container Size",
"Company",
"Returned By",
"Returned Date",
"Facility",
"Yard",
"Zone",
"Condition",
"Handover Note",
];
describe("parseContainerReturnExcel", () => {
it("parses a good sheet, normalizing size and returned-by", async () => {
const result = await parseContainerReturnExcel(
sheetFile([
["Yard tally — August"], // title row above the header is ignored
HEADERS,
["temu1234567", "40ft", "Acme PLC", "EDR last mile", "2026-08-14", "Gelan", "A", "1", "", ""],
["MSCU7654321", "20", "Other Trading", "Self haul", "2026-08-15", "Gelan", "", "", "Dented", "n"],
]),
);
expect(result.errors).toEqual([]);
expect(result.rows).toHaveLength(2);
expect(result.rows[0].containerNumber).toBe("TEMU1234567");
expect(result.rows[0].containerSize).toBe("40");
expect(result.rows[0].returnedBy).toBe("EDR");
expect(result.rows[0].companyName).toBe("Acme PLC");
expect(result.rows[0].returnDate?.startsWith("2026-08-14")).toBe(true);
expect(result.rows[1].containerSize).toBe("20");
expect(result.rows[1].returnedBy).toBe("CUSTOMER");
});
it("rejects the whole file when a container number is invalid", async () => {
const result = await parseContainerReturnExcel(
sheetFile([HEADERS, ["NOTACONTAINER", "40", "Acme", "EDR", "", "", "", "", "", ""]]),
);
expect(result.rows).toEqual([]);
expect(result.errors[0]).toContain("Row 2");
});
it("rejects duplicate container numbers", async () => {
const result = await parseContainerReturnExcel(
sheetFile([
HEADERS,
["TEMU1234567", "40", "Acme", "EDR", "", "", "", "", "", ""],
["temu1234567", "20", "Acme", "EDR", "", "", "", "", "", ""],
]),
);
expect(result.rows).toEqual([]);
expect(result.errors.some((e) => e.includes("appears 2 times"))).toBe(true);
});
it("errors when there is no container-number column", async () => {
const result = await parseContainerReturnExcel(sheetFile([["Company", "Yard"], ["Acme", "A"]]));
expect(result.errors[0]).toContain("Container Number");
});
});

View File

@@ -0,0 +1,231 @@
import * as XLSX from "xlsx";
// Excel import for empties already sitting in an EDR yard that were never
// entered in the system. One spreadsheet row per container. All-or-nothing —
// any bad row rejects the whole file with row-numbered errors, so a partial
// backfill can never silently drop boxes.
// ISO 6346: 4-letter prefix (owner code + category id) + 7 digits.
const ISO_CONTAINER_NUMBER_REGEX = /^[A-Z]{4}\d{7}$/;
export interface ParsedReturnRow {
containerNumber: string;
containerSize: "20" | "40" | null;
companyName: string;
returnedBy: "EDR" | "CUSTOMER" | null;
returnDate: string | null;
facility: string;
yard: string;
zone: string;
condition: string;
handoverNote: string;
}
export interface ContainerReturnExcelResult {
rows: ParsedReturnRow[];
errors: string[];
}
type ColumnKey =
| "containerNumber"
| "containerSize"
| "companyName"
| "returnedBy"
| "returnDate"
| "facility"
| "yard"
| "zone"
| "condition"
| "handoverNote";
/** Match a header cell to a known column, tolerant of casing/spacing/punctuation. */
function headerKey(raw: string): ColumnKey | null {
const h = raw.toLowerCase().replace(/[^a-z]/g, "");
if (!h) return null;
if (h.includes("size") || h.includes("type")) return "containerSize";
if (h.includes("company") || h.includes("customer") || h.includes("consignee")) return "companyName";
if (h.includes("returnedby") || h.includes("haul") || h.includes("truck")) return "returnedBy";
if (h.includes("date")) return "returnDate";
if (h.includes("facility") || h.includes("warehouse") || h.includes("terminal")) return "facility";
if (h.includes("yard")) return "yard";
if (h.includes("zone")) return "zone";
if (h.includes("condition") || h.includes("damage")) return "condition";
if (h.includes("note") || h.includes("remark")) return "handoverNote";
// Least specific last, so "Container Size" is not eaten by "container".
if (h.includes("container") || h.includes("number")) return "containerNumber";
return null;
}
/** "20", "20ft", "40 HC" … → '20' | '40' | null. */
function normalizeSize(raw: string): "20" | "40" | null {
const digits = raw.replace(/[^0-9]/g, "");
if (digits.startsWith("20")) return "20";
if (digits.startsWith("40") || digits.startsWith("45")) return "40";
return null;
}
/** "EDR", "EDR last mile", "customer", "self haul" … */
function normalizeReturnedBy(raw: string): "EDR" | "CUSTOMER" | null {
const v = raw.toLowerCase();
if (!v.trim()) return null;
if (v.includes("edr")) return "EDR";
if (v.includes("customer") || v.includes("self")) return "CUSTOMER";
return null;
}
/**
* Excel dates arrive either as a serial number (raw cells) or as text. Returns
* an ISO instant, or null when the cell is empty/unparseable.
*/
function normalizeDate(raw: string): string | null {
const v = raw.trim();
if (!v) return null;
// Excel serial: days since 1899-12-30.
if (/^\d{1,6}(\.\d+)?$/.test(v)) {
const serial = Number(v);
if (serial > 20000 && serial < 80000) {
return new Date(Math.round((serial - 25569) * 86400000)).toISOString();
}
}
const parsed = new Date(v);
return Number.isNaN(parsed.getTime()) ? null : parsed.toISOString();
}
/**
* Parse an uploaded workbook into one row per empty container. Returns either
* the full row set or the list of row-numbered problems — never both.
*/
export async function parseContainerReturnExcel(file: File): Promise<ContainerReturnExcelResult> {
let sheet: XLSX.WorkSheet | undefined;
try {
const workbook = XLSX.read(await file.arrayBuffer(), { type: "array" });
sheet = workbook.Sheets[workbook.SheetNames[0]];
} catch {
return { rows: [], errors: ["Could not read the file — is it a valid Excel file?"] };
}
if (!sheet) return { rows: [], errors: ["The file has no sheets."] };
const grid = XLSX.utils.sheet_to_json<string[]>(sheet, { header: 1, raw: false, defval: "" });
// First row carrying a container-number column is the header; titles and
// blank rows above it are ignored.
let headerRowIdx = -1;
let columns: Array<ColumnKey | null> = [];
for (let i = 0; i < grid.length; i++) {
const mapped = (grid[i] ?? []).map((c) => headerKey(String(c ?? "")));
if (mapped.includes("containerNumber")) {
headerRowIdx = i;
columns = mapped;
break;
}
}
if (headerRowIdx < 0) {
return {
rows: [],
errors: [
'Could not find a "Container Number" column — download the template to see the expected format.',
],
};
}
const rows: ParsedReturnRow[] = [];
const errors: string[] = [];
const numberCounts = new Map<string, number>();
for (let i = headerRowIdx + 1; i < grid.length; i++) {
const cells = grid[i] ?? [];
if (cells.every((c) => String(c ?? "").trim() === "")) continue;
const rowNo = i + 1; // 1-based, as shown in Excel
const cell = (key: ColumnKey) => {
const idx = columns.indexOf(key);
return idx >= 0 ? String(cells[idx] ?? "").trim() : "";
};
const containerNumber = cell("containerNumber").toUpperCase().replace(/\s/g, "");
if (!ISO_CONTAINER_NUMBER_REGEX.test(containerNumber)) {
errors.push(
`Row ${rowNo}: "${cell("containerNumber") || "—"}" is not a valid ISO container number (e.g. TEMU1234567).`,
);
} else {
numberCounts.set(containerNumber, (numberCounts.get(containerNumber) ?? 0) + 1);
}
const sizeRaw = cell("containerSize");
const containerSize = sizeRaw ? normalizeSize(sizeRaw) : null;
if (sizeRaw && !containerSize) {
errors.push(`Row ${rowNo}: container size "${sizeRaw}" is not 20 or 40.`);
}
const returnedByRaw = cell("returnedBy");
const returnedBy = normalizeReturnedBy(returnedByRaw);
if (returnedByRaw && !returnedBy) {
errors.push(`Row ${rowNo}: returned by "${returnedByRaw}" must be EDR or CUSTOMER.`);
}
const dateRaw = cell("returnDate");
const returnDate = normalizeDate(dateRaw);
if (dateRaw && !returnDate) {
errors.push(`Row ${rowNo}: returned date "${dateRaw}" is not a date.`);
}
rows.push({
containerNumber,
containerSize,
companyName: cell("companyName"),
returnedBy,
returnDate,
facility: cell("facility"),
yard: cell("yard"),
zone: cell("zone"),
condition: cell("condition"),
handoverNote: cell("handoverNote"),
});
}
numberCounts.forEach((count, num) => {
if (count > 1) {
errors.push(`Container number ${num} appears ${count} times — numbers must be unique.`);
}
});
if (rows.length === 0 && errors.length === 0) {
errors.push("The sheet has no container rows below the header.");
}
return errors.length > 0 ? { rows: [], errors } : { rows, errors: [] };
}
/** Download the import template with one filled sample row. */
export function downloadContainerReturnTemplate() {
const headers = [
"Container Number",
"Container Size",
"Company",
"Returned By",
"Returned Date",
"Facility",
"Yard",
"Zone",
"Condition",
"Handover Note",
];
const sample = [
"TEMU1234567",
"40",
"Acme Import PLC",
"CUSTOMER",
new Date().toISOString().split("T")[0],
"Gelan Multipurpose port",
"Yard A",
"Zone 1",
"Sound",
"Backfilled from yard tally sheet",
];
const sheet = XLSX.utils.aoa_to_sheet([headers, sample]);
sheet["!cols"] = headers.map((h) => ({ wch: Math.max(h.length + 2, 18) }));
const workbook = XLSX.utils.book_new();
XLSX.utils.book_append_sheet(workbook, sheet, "Container Returns");
XLSX.writeFile(workbook, "container-return-import-template.xlsx");
}

View File

@@ -0,0 +1,87 @@
import { describe, expect, it } from "vitest";
import * as XLSX from "xlsx";
import { parseFullContainerExcel } from "./full-container-excel";
function sheetFile(aoa: unknown[][]): File {
const wb = XLSX.utils.book_new();
XLSX.utils.book_append_sheet(wb, XLSX.utils.aoa_to_sheet(aoa), "Sheet1");
const buf = XLSX.write(wb, { type: "array", bookType: "xlsx" }) as ArrayBuffer;
return new File([buf], "backlog.xlsx");
}
const HEADERS = [
"Container Number",
"Container Size",
"Company",
"Arrival Date",
"Facility",
"Yard",
"Zone",
"Seal Number",
"Weight (Tons)",
"Notes",
];
const row = (num: string, arrived: string, weight: string | number = 24.5) => [
num,
"40",
"Acme PLC",
arrived,
"Gelan",
"A",
"1",
"SL1",
weight,
"",
];
describe("parseFullContainerExcel", () => {
it("parses a backlog sheet with past arrival dates", async () => {
const result = await parseFullContainerExcel(
sheetFile([HEADERS, row("temu1234567", "2026-03-14"), row("MSCU7654321", "2025-11-02")]),
);
expect(result.errors).toEqual([]);
expect(result.rows).toHaveLength(2);
expect(result.rows[0].containerNumber).toBe("TEMU1234567");
expect(result.rows[0].arrivedAt?.startsWith("2026-03-14")).toBe(true);
expect(result.rows[0].companyName).toBe("Acme PLC");
});
it("rejects a future arrival date — a backlog box arrived in the past", async () => {
const future = new Date();
future.setFullYear(future.getFullYear() + 1);
const result = await parseFullContainerExcel(
sheetFile([HEADERS, row("TEMU1234567", future.toISOString().slice(0, 10))]),
);
expect(result.rows).toEqual([]);
expect(result.errors.some((e) => e.includes("in the future"))).toBe(true);
});
it("accepts an arrival date of today", async () => {
const today = new Date().toISOString().slice(0, 10);
const result = await parseFullContainerExcel(sheetFile([HEADERS, row("TEMU1234567", today)]));
expect(result.errors).toEqual([]);
});
it("rejects an invalid container number and a negative weight", async () => {
const result = await parseFullContainerExcel(
sheetFile([HEADERS, row("NOPE", "2026-03-14"), row("MSCU7654321", "2026-03-14", -3)]),
);
expect(result.rows).toEqual([]);
expect(result.errors.some((e) => e.includes("ISO container number"))).toBe(true);
expect(result.errors.some((e) => e.includes("0 or more"))).toBe(true);
});
it("rejects duplicate container numbers", async () => {
const result = await parseFullContainerExcel(
sheetFile([HEADERS, row("TEMU1234567", "2026-03-14"), row("temu1234567", "2026-03-15")]),
);
expect(result.rows).toEqual([]);
expect(result.errors.some((e) => e.includes("appears 2 times"))).toBe(true);
});
});

View File

@@ -0,0 +1,206 @@
import * as XLSX from "xlsx";
// Excel import for loaded containers already sitting in a yard but never
// entered in the system. One row per container. All-or-nothing — any bad row
// rejects the file with row-numbered errors, so a half-registered yard cannot
// happen.
const ISO_CONTAINER_NUMBER_REGEX = /^[A-Z]{4}\d{7}$/;
export interface ParsedFullContainerRow {
containerNumber: string;
containerSize: string;
companyName: string;
/** ISO instant; null when the cell was empty or unreadable. */
arrivedAt: string | null;
facility: string;
yard: string;
zone: string;
sealNumber: string;
weight: string;
notes: string;
}
export interface FullContainerExcelResult {
rows: ParsedFullContainerRow[];
errors: string[];
}
type ColumnKey =
| "containerNumber"
| "containerSize"
| "companyName"
| "arrivedAt"
| "facility"
| "yard"
| "zone"
| "sealNumber"
| "weight"
| "notes";
/** Match a header cell to a known column, tolerant of casing/spacing/punctuation. */
function headerKey(raw: string): ColumnKey | null {
const h = raw.toLowerCase().replace(/[^a-z]/g, "");
if (!h) return null;
if (h.includes("seal")) return "sealNumber";
if (h.includes("size") || h.includes("type")) return "containerSize";
if (h.includes("company") || h.includes("owner") || h.includes("consignee")) return "companyName";
if (h.includes("arriv") || h.includes("date")) return "arrivedAt";
if (h.includes("facility") || h.includes("warehouse") || h.includes("terminal")) return "facility";
if (h.includes("yard")) return "yard";
if (h.includes("zone")) return "zone";
if (h.includes("weight") || h.includes("vgm")) return "weight";
if (h.includes("note") || h.includes("remark")) return "notes";
if (h.includes("container") || h.includes("number")) return "containerNumber";
return null;
}
/**
* Excel dates arrive either as a serial number (raw cells) or as text.
* Returns an ISO instant, or null when the cell is empty/unparseable.
*/
function normalizeDate(raw: string): string | null {
const v = raw.trim();
if (!v) return null;
if (/^\d{1,6}(\.\d+)?$/.test(v)) {
const serial = Number(v);
if (serial > 20000 && serial < 80000) {
return new Date(Math.round((serial - 25569) * 86400000)).toISOString();
}
}
const parsed = new Date(v);
return Number.isNaN(parsed.getTime()) ? null : parsed.toISOString();
}
/** Parse an uploaded workbook into one row per loaded container. */
export async function parseFullContainerExcel(file: File): Promise<FullContainerExcelResult> {
let sheet: XLSX.WorkSheet | undefined;
try {
const workbook = XLSX.read(await file.arrayBuffer(), { type: "array" });
sheet = workbook.Sheets[workbook.SheetNames[0]];
} catch {
return { rows: [], errors: ["Could not read the file — is it a valid Excel file?"] };
}
if (!sheet) return { rows: [], errors: ["The file has no sheets."] };
const grid = XLSX.utils.sheet_to_json<string[]>(sheet, { header: 1, raw: false, defval: "" });
let headerRowIdx = -1;
let columns: Array<ColumnKey | null> = [];
for (let i = 0; i < grid.length; i++) {
const mapped = (grid[i] ?? []).map((c) => headerKey(String(c ?? "")));
if (mapped.includes("containerNumber")) {
headerRowIdx = i;
columns = mapped;
break;
}
}
if (headerRowIdx < 0) {
return {
rows: [],
errors: [
'Could not find a "Container Number" column — download the template to see the expected format.',
],
};
}
const rows: ParsedFullContainerRow[] = [];
const errors: string[] = [];
const numberCounts = new Map<string, number>();
const startOfTomorrow = new Date();
startOfTomorrow.setHours(24, 0, 0, 0);
for (let i = headerRowIdx + 1; i < grid.length; i++) {
const cells = grid[i] ?? [];
if (cells.every((c) => String(c ?? "").trim() === "")) continue;
const rowNo = i + 1; // 1-based, as shown in Excel
const cell = (key: ColumnKey) => {
const idx = columns.indexOf(key);
return idx >= 0 ? String(cells[idx] ?? "").trim() : "";
};
const containerNumber = cell("containerNumber").toUpperCase().replace(/\s/g, "");
if (!ISO_CONTAINER_NUMBER_REGEX.test(containerNumber)) {
errors.push(
`Row ${rowNo}: "${cell("containerNumber") || "—"}" is not a valid ISO container number (e.g. TEMU1234567).`,
);
} else {
numberCounts.set(containerNumber, (numberCounts.get(containerNumber) ?? 0) + 1);
}
const arrivedRaw = cell("arrivedAt");
const arrivedAt = normalizeDate(arrivedRaw);
if (arrivedRaw && !arrivedAt) {
errors.push(`Row ${rowNo}: arrival date "${arrivedRaw}" is not a date.`);
}
// The whole point of a backlog is that it arrived in the past.
if (arrivedAt && new Date(arrivedAt).getTime() >= startOfTomorrow.getTime()) {
errors.push(`Row ${rowNo}: arrival date "${arrivedRaw}" is in the future.`);
}
const weightRaw = cell("weight");
if (weightRaw && (Number.isNaN(Number(weightRaw)) || Number(weightRaw) < 0)) {
errors.push(`Row ${rowNo}: weight "${weightRaw}" must be a number of 0 or more.`);
}
rows.push({
containerNumber,
containerSize: cell("containerSize"),
companyName: cell("companyName"),
arrivedAt,
facility: cell("facility"),
yard: cell("yard"),
zone: cell("zone"),
sealNumber: cell("sealNumber"),
weight: weightRaw,
notes: cell("notes"),
});
}
numberCounts.forEach((count, num) => {
if (count > 1) {
errors.push(`Container number ${num} appears ${count} times — numbers must be unique.`);
}
});
if (rows.length === 0 && errors.length === 0) {
errors.push("The sheet has no container rows below the header.");
}
return errors.length > 0 ? { rows: [], errors } : { rows, errors: [] };
}
/** Download the import template with one filled sample row. */
export function downloadFullContainerTemplate() {
const headers = [
"Container Number",
"Container Size",
"Company",
"Arrival Date",
"Facility",
"Yard",
"Zone",
"Seal Number",
"Weight (Tons)",
"Notes",
];
const sample = [
"TEMU1234567",
"40",
"Acme Import PLC",
"2026-03-14",
"Gelan Multipurpose port",
"Yard A",
"Zone 1",
"SL482910",
24.5,
"Backlog — registered from yard tally sheet",
];
const sheet = XLSX.utils.aoa_to_sheet([headers, sample]);
sheet["!cols"] = headers.map((h) => ({ wch: Math.max(h.length + 2, 18) }));
const workbook = XLSX.utils.book_new();
XLSX.utils.book_append_sheet(workbook, sheet, "Full Containers");
XLSX.writeFile(workbook, "full-container-backlog-template.xlsx");
}

View File

@@ -9,6 +9,9 @@ export { WarehouseInquiryTable } from './WarehouseInquiryTable';
export { CreateWarehouseModal } from './CreateWarehouseModal';
export { CreateYardModal } from './CreateYardModal';
export { CreateZoneModal } from './CreateZoneModal';
export { ZoneContentsModal, type ZoneRef } from './ZoneContentsModal';
export { ZoneLayoutModal } from './ZoneLayoutModal';
export { SlotPicker } from './SlotPicker';
export { ReceiveInventoryModal, WarehouseFlowWorkbench } from './ReceiveInventoryModal';
export { WarehouseInfoCard } from './WarehouseInfoCard';
export { MoveInventoryModal } from './MoveInventoryModal';

View File

@@ -1,4 +1,5 @@
import {
WAREHOUSE_FREIGHT_TYPES,
WAREHOUSE_TYPES,
WAREHOUSE_YARD_TYPES,
WAREHOUSE_ZONE_TYPES,
@@ -73,6 +74,7 @@ export const yardsForBooking = (
};
export const warehouseTypeOptions = toOptions(WAREHOUSE_TYPES);
export const warehouseFreightTypeOptions = toOptions(WAREHOUSE_FREIGHT_TYPES);
export const yardTypeOptions = toOptions(WAREHOUSE_YARD_TYPES);
export const zoneTypeOptions = toOptions(WAREHOUSE_ZONE_TYPES);
export const statusOptions = toOptions(WAREHOUSE_STATUSES);

View File

@@ -0,0 +1,97 @@
import { describe, expect, it } from 'vitest';
import { fillableLevels } from './SlotPicker';
import type { SlotEffectiveStatus, ZoneLayout } from '@/types/warehouse';
/**
* The picker must offer only positions the API will accept, or the operator
* gets a 400 for a level the form itself suggested.
*/
function layout(
stacks: Array<{
code: string;
height?: number;
isActive?: boolean;
levels: SlotEffectiveStatus[];
}>,
): ZoneLayout {
return {
zoneId: 'zone-1',
zoneCode: 'L1-O-A-ZA',
zoneName: 'Zone A',
summary: {
configuredCapacity: 60,
physicalSlotCount: 60,
occupiedSlotCount: 0,
reservedSlotCount: 0,
blockedSlotCount: 0,
inactiveSlotCount: 0,
availableSlotCount: 60,
inconsistent: false,
},
stacks: stacks.map((stack) => ({
stackId: `id-${stack.code}`,
code: stack.code,
name: null,
maxStackHeight: stack.height ?? 3,
status: stack.isActive === false ? 'INACTIVE' : 'ACTIVE',
isActive: stack.isActive !== false,
// The API returns the highest level first — mirror that here.
slots: stack.levels
.map((effectiveStatus, index) => ({
slotId: `${stack.code}-L${index + 1}`,
level: index + 1,
effectiveStatus,
inventoryId: effectiveStatus === 'OCCUPIED' ? `inv-${stack.code}-${index + 1}` : null,
containerNumber: null,
}))
.reverse(),
})),
};
}
describe('SlotPicker.fillableLevels', () => {
it('offers the ground level of an empty stack', () => {
const options = fillableLevels(layout([{ code: 'ZA-001', levels: ['AVAILABLE', 'AVAILABLE', 'AVAILABLE'] }]));
expect(options).toEqual([{ value: 'ZA-001-L1', label: 'ZA-001 — level 1 (ground)' }]);
});
it('offers only the level directly above the top container', () => {
const options = fillableLevels(layout([{ code: 'ZA-001', levels: ['OCCUPIED', 'AVAILABLE', 'AVAILABLE'] }]));
expect(options).toEqual([{ value: 'ZA-001-L2', label: 'ZA-001 — level 2 (on 1 container)' }]);
});
it('never offers a level that would float over an empty one', () => {
const options = fillableLevels(layout([{ code: 'ZA-001', levels: ['OCCUPIED', 'OCCUPIED', 'AVAILABLE'] }]));
expect(options.map((o) => o.value)).toEqual(['ZA-001-L3']);
});
it('drops a full stack', () => {
expect(fillableLevels(layout([{ code: 'ZA-001', levels: ['OCCUPIED', 'OCCUPIED', 'OCCUPIED'] }]))).toEqual([]);
});
it('drops a stack whose next level is blocked or reserved', () => {
expect(fillableLevels(layout([{ code: 'ZA-001', levels: ['BLOCKED', 'AVAILABLE', 'AVAILABLE'] }]))).toEqual([]);
expect(fillableLevels(layout([{ code: 'ZA-002', levels: ['RESERVED', 'AVAILABLE', 'AVAILABLE'] }]))).toEqual([]);
});
it('drops an inactive stack', () => {
expect(
fillableLevels(layout([{ code: 'ZA-001', isActive: false, levels: ['AVAILABLE', 'AVAILABLE', 'AVAILABLE'] }])),
).toEqual([]);
});
it('lists one position per stack across the zone', () => {
const options = fillableLevels(
layout([
{ code: 'ZA-001', levels: ['OCCUPIED', 'AVAILABLE', 'AVAILABLE'] },
{ code: 'ZA-002', levels: ['AVAILABLE', 'AVAILABLE', 'AVAILABLE'] },
]),
);
expect(options.map((o) => o.value)).toEqual(['ZA-001-L2', 'ZA-002-L1']);
});
it('returns nothing before the layout has loaded', () => {
expect(fillableLevels(undefined)).toEqual([]);
});
});

View File

@@ -0,0 +1,37 @@
import { useQuery } from "@tanstack/react-query";
import { customersService } from "@/services/customers.service";
/**
* Registered customer companies, as Autocomplete options. The picker is an
* Autocomplete rather than a Select on purpose: a company that is not on the
* system yet is typed in, and only the name is kept.
*/
export function useCompanyOptions() {
const { data, isLoading } = useQuery({
queryKey: ["companies-autocomplete"],
queryFn: () => customersService.list({ page: 1, pageSize: 1000 }),
staleTime: 5 * 60 * 1000,
});
const companies = data?.items ?? [];
// Company names are not unique — Mantine throws on duplicate option values,
// so the list is deduped by the trimmed name.
const names = [...new Set(companies.map((c) => c.name.trim()).filter(Boolean))];
return {
loading: isLoading,
names,
/**
* Name → company id, only when exactly one company carries that name. An
* ambiguous name resolves to nothing rather than to an arbitrary company:
* the container keeps the typed name and no wrong customer is attached.
*/
resolveId: (name: string): string | undefined => {
const key = name.trim().toLowerCase();
const matches = companies.filter((c) => c.name.trim().toLowerCase() === key);
return matches.length === 1 ? matches[0].id : undefined;
},
};
}

View File

@@ -80,6 +80,7 @@ export const QUERY_KEYS = {
documents: (id: string) =>
["customers", "detail", id, "documents"] as const,
payments: (id: string) => ["customers", "detail", id, "payments"] as const,
accounts: (id: string) => ["customers", "detail", id, "accounts"] as const,
resetTarget: (id: string) =>
["customers", "detail", id, "reset-target"] as const,
changeRequests: (id: string) =>
@@ -160,6 +161,8 @@ export const QUERY_KEYS = {
["train-scheduling", "schedules", filters ?? {}] as const,
scheduleById: (id: string) => ["train-scheduling", "schedule", id] as const,
track: (id: string) => ["train-scheduling", "track", id] as const,
marshallingStops: (id: string) =>
["train-scheduling", "marshalling-stops", id] as const,
batchBoard: (filters?: unknown) =>
["train-scheduling", "batch-board", "list", filters ?? {}] as const,
batchBoardDetail: (scheduleId: string) =>

View File

@@ -138,6 +138,8 @@ export const URL_CONSTANTS = {
`/backoffice/customers/${companyId}/reset-password`,
RESET_TARGET: (companyId: string) =>
`/backoffice/customers/${companyId}/reset-target`,
ACCOUNTS: (companyId: string) =>
`/backoffice/customers/${companyId}/accounts`,
},
BILLING: {
@@ -536,6 +538,10 @@ export const URL_CONSTANTS = {
`/train-scheduling/schedules/${id}/export/load-list/document`,
INTERCITY_MARSHALLING_DOCUMENT: (id: string) =>
`/train-scheduling/schedules/${id}/intercity/marshalling/document`,
MARSHALLING_STOPS: (id: string) =>
`/train-scheduling/schedules/${id}/marshalling/stops`,
MARSHALLING_DOCUMENT_AT: (id: string, stopIndex: number) =>
`/train-scheduling/schedules/${id}/marshalling/document/${stopIndex}`,
CHECKPOINTS: (id: string) =>
`/train-scheduling/schedules/${id}/checkpoints`,
CHECKPOINT: (id: string, sequenceNo: number) =>
@@ -651,6 +657,16 @@ export const URL_CONSTANTS = {
WAREHOUSE_ZONES: {
BASE: "/warehouse-zones",
BY_ID: (id: string) => `/warehouse-zones/${id}`,
LAYOUT: (id: string) => `/warehouse-zones/${id}/layout`,
SLOT_SUMMARY: (id: string) => `/warehouse-zones/${id}/slot-summary`,
},
WAREHOUSE_ZONE_STACKS: {
BASE: "/warehouse-zone-stacks",
BY_ZONE: (zoneId: string) => `/warehouse-zone-stacks?zoneId=${zoneId}`,
BY_ID: (id: string) => `/warehouse-zone-stacks/${id}`,
OCCUPANCY: (id: string) => `/warehouse-zone-stacks/${id}/occupancy`,
SLOT: (slotId: string) => `/warehouse-zone-stacks/slots/${slotId}`,
},
WAREHOUSE_INVENTORY: {
@@ -665,6 +681,10 @@ export const URL_CONSTANTS = {
LOAD: (id: string) => `/warehouse-inventory/${id}/load`,
DISPATCH: (id: string) => `/warehouse-inventory/${id}/dispatch`,
MOVE: (id: string) => `/warehouse-inventory/${id}/move`,
FIND_SLOT: "/warehouse-inventory/placement/find-slot",
ASSIGN_SLOT: (id: string) => `/warehouse-inventory/${id}/assign-slot`,
RELEASE_SLOT: (id: string) => `/warehouse-inventory/${id}/release-slot`,
ACCESSIBILITY: (id: string) => `/warehouse-inventory/${id}/accessibility`,
RESERVE: "/warehouse-inventory/reserve",
ARRIVAL_QUEUE: "/warehouse-inventory/arrival-queue",
OPS_STATS: "/warehouse-inventory/ops-stats",
@@ -706,6 +726,8 @@ export const URL_CONSTANTS = {
? `/warehouse-inventory/eligible-bookings?direction=${direction}`
: `/warehouse-inventory/eligible-bookings`,
RECEIVE_BULK: "/warehouse-inventory/receive-bulk",
REGISTER_BACKLOG: "/warehouse-inventory/register-backlog",
REGISTER_BACKLOG_BULK: "/warehouse-inventory/register-backlog-bulk",
LOAD_PASSED_EXPORT: "/warehouse-inventory/load-passed-export",
BULK_MARK_INSPECTED: "/warehouse-inventory/bulk-mark-inspected",
RECEIVED_EXPORT: "/warehouse-inventory/received-export",
@@ -778,6 +800,15 @@ export const URL_CONSTANTS = {
CANCEL: (id: string) => `/interchange-documents/${id}/cancel`,
},
EMPTY_RETURN_REQUESTS: {
BASE: "/empty-return-requests",
PLANNED: "/empty-return-requests/planned",
ELIGIBILITY: (bookingId: string) => `/empty-return-requests/eligibility/${bookingId}`,
BY_BOOKING: (bookingId: string) => `/empty-return-requests/by-booking/${bookingId}`,
APPROVE: (id: string) => `/empty-return-requests/${id}/approve`,
REJECT: (id: string) => `/empty-return-requests/${id}/reject`,
},
IMPORT_OPERATIONS: {
DJIBOUTI_INCIDENTS: "/import-operations/djibouti-incidents",
CUSTOMS: (bookingId: string) => `/import-operations/customs/${bookingId}`,
@@ -794,6 +825,8 @@ export const URL_CONSTANTS = {
CUSTOMS_RELEASE_PERMITTED: (bookingId: string) =>
`/import-operations/customs/${bookingId}/release-permitted`,
EMPTY_CONTAINER_RETURNS: "/import-operations/empty-container-returns",
EMPTY_RETURN_BOOKINGS: "/import-operations/empty-return-bookings",
EMPTY_CONTAINER_RETURNS_BULK: "/import-operations/empty-container-returns/bulk",
EMPTY_CONTAINER_RETURN_STATUS: (id: string) =>
`/import-operations/empty-container-returns/${id}/status`,
EMPTY_CONTAINER_RETURNS_LOAD_ON_TRAIN:

View File

@@ -82,6 +82,14 @@ export function useUpdateWarehouse() {
});
}
export function useDeleteWarehouse() {
const qc = useQueryClient();
return useMutation({
mutationFn: (id: string) => warehouseService.remove(id),
onSuccess: () => qc.invalidateQueries({ queryKey: warehouseKeys.all }),
});
}
// ── Yards ────────────────────────────────────────────────────────────────
export function useWarehouseYards(warehouseId?: string) {

View File

@@ -60,3 +60,10 @@ export function formatBytes(bytes: number): string {
const value = bytes / Math.pow(1024, i);
return `${value.toFixed(i === 0 ? 0 : 1)} ${units[i]}`;
}
/** `YYYY-MM-DDTHH:mm` for now, in local time — what `datetime-local` expects. */
export function localNowForInput(): string {
const d = new Date();
d.setMinutes(d.getMinutes() - d.getTimezoneOffset());
return d.toISOString().slice(0, 16);
}

View File

@@ -308,6 +308,7 @@ export const FREIGHT_PERMS = {
view: "edr_freight_app:warehouse_zones:view",
create: "edr_freight_app:warehouse_zones:create",
update: "edr_freight_app:warehouse_zones:update",
delete: "edr_freight_app:warehouse_zones:delete",
},
warehouseAllocationRules: {
view: "edr_freight_app:warehouse_allocation_rules:view",
@@ -357,6 +358,10 @@ export const FREIGHT_PERMS = {
send: "edr_freight_app:additional_charges:send",
cancel: "edr_freight_app:additional_charges:cancel",
},
emptyReturnRequests: {
view: "edr_freight_app:empty_return_requests:view",
review: "edr_freight_app:empty_return_requests:review",
},
/**
* Audit trail. View-only — the API exposes no write routes for audit rows,
* so there is no manage/delete counterpart to grant.

View File

@@ -40,6 +40,7 @@ import {
} from "@mantine/core";
import { PageContainer, PageHeader, KpiStrip } from "@/components/page";
import { extractDownloadErrorMessage } from "@/components/warehouses/options";
import type { KpiItem } from "@/components/page";
import { EntityLink } from "@/components/detail";
import { BookingActionsToolbar } from "@/components/bookings/BookingActionsToolbar";
@@ -80,6 +81,7 @@ import { useFileViewer } from "@/hooks/useFileViewer";
import { bookingsService } from "@/services/bookings.service";
import { useAuth } from "@/auth/useAuth";
import { FREIGHT_PERMS, hasPermission as hasFreightPermission } from "@/lib/permissions";
import { WagonCancellationCreditCard } from "@/components/bookings/wagon-cancellation";
export default function BookingRequestDetailPage() {
const { id } = useParams<{ id: string }>();
@@ -374,11 +376,9 @@ export default function BookingRequestDetailPage() {
a.click();
URL.revokeObjectURL(url);
} catch (error) {
toast.error(
error instanceof Error
? error.message
: "Carriage acceptance sheet is not available yet",
);
// Blob response: the JSON reason is inside the Blob, so
// the sync path would show only "status code 400".
toast.error(await extractDownloadErrorMessage(error));
}
}}
>
@@ -653,6 +653,7 @@ function OverviewPanel({
/>
<BookingCargoCard booking={booking} />
<BookingContainerUnitsCard booking={booking} />
<WagonCancellationCreditCard bookingId={booking.id} onRebooked={onRefetch} />
</Stack>
);
}

View File

@@ -140,6 +140,25 @@ export default function BookingRequestsPage() {
[refData],
);
// Content options mirror the booking wizard's cargo picker: the group itself
// — which the server expands to every commodity beneath it — then each
// commodity, labelled by its full path so a generically-named leaf still
// reads unambiguously. A group with no descendants is emitted by the
// reference-data tree as its own single child; drop that duplicate.
const cargoTypeOptions = useMemo(
() =>
(refData?.cargo_type ?? []).flatMap((group) => [
{ value: group.id, label: group.name },
...(group.children ?? [])
.filter((child) => child.id !== group.id)
.map((child) => ({
value: child.id,
label: `${group.name}${child.name}`,
})),
]),
[refData],
);
// Deep links land here pre-filtered (?statuses=A,B&tradeDirection=IMPORT) —
// the header's document-review alarm opens exactly the undecided requests
// it is counting down for. No sync effect needed any more: controls.values
@@ -147,6 +166,15 @@ export default function BookingRequestsPage() {
// already mounted just works, and every filter — direction included —
// auto-pins its own pill the moment it has a value (FilterBar's `secondary`
// split), so a deep link can never land behind "More filters" unseen.
// Container types, flattened out of the reference data's size groups.
const containerTypeOptions = useMemo(
() =>
(refData?.containers ?? []).flatMap((group) =>
group.types.map((t) => ({ value: t.id, label: t.name || t.code })),
),
[refData],
);
const bookingFilterDefs: FilterDef[] = useMemo(
() => [
{
@@ -183,6 +211,65 @@ export default function BookingRequestsPage() {
multiple: false,
options: FREIGHT_TYPE_OPTIONS,
},
{
// Cargo group or commodity. The group row matches its whole subtree
// server-side, so "Bulk" returns every bulk commodity under it.
key: "cargoTypeId",
label: "Content",
type: "enum",
multiple: false,
options: cargoTypeOptions,
},
{
// Containers carry no customer-written description, so this is also how
// they are reached: it matches container types ("40FT") as well as the
// commodity name and the bulk cargo description.
key: "cargoText",
label: "Content contains",
type: "text",
secondary: true,
placeholder: "Commodity, description or container type",
},
{
key: "containerTypeId",
label: "Container type",
type: "enum",
multiple: false,
options: containerTypeOptions,
secondary: true,
},
{
// Counts boxes. Scoped to the container-type filter when one is set, so
// this one control answers "10 containers" and "10 forty-footers" both.
key: "containers",
label: "Containers",
type: "number",
secondary: true,
operators: ["is", "between"],
toParams: (v) =>
v.op === "between"
? { containersMin: v.v[0], containersMax: v.v[1] }
: { containersMin: v.v[0], containersMax: v.v[0] },
},
{
// Declared on the shipment request, not yet on the booking. Pair it
// with Containers = 0 to find the set awaiting completion.
key: "requestedContainers",
label: "Requested containers",
type: "number",
secondary: true,
operators: ["is", "between"],
toParams: (v) =>
v.op === "between"
? {
requestedContainersMin: v.v[0],
requestedContainersMax: v.v[1],
}
: {
requestedContainersMin: v.v[0],
requestedContainersMax: v.v[0],
},
},
{
key: "serviceTypeId",
label: "Service",
@@ -244,7 +331,13 @@ export default function BookingRequestsPage() {
toParams: dateRangeParams("scheduledFrom", "scheduledTo"),
},
],
[filterOptions, yardOptions, serviceTypeOptions],
[
filterOptions,
yardOptions,
serviceTypeOptions,
cargoTypeOptions,
containerTypeOptions,
],
);
const controls = useFilters(bookingFilterDefs, {

View File

@@ -37,6 +37,7 @@ import {
} from "@/components/bookings/detail";
import { ClearanceReviewSection } from "@/components/bookings/detail/ClearanceReviewSection";
import { AdditionalDocsRequestCard } from "@/components/bookings/detail/AdditionalDocsRequestCard";
import { WagonCancellationCreditCard } from "@/components/bookings/wagon-cancellation";
import { ClearancePhaseStepper } from "@/components/contracts/ClearancePhaseStepper";
import { PhasedClearanceActionPanel } from "@/components/contracts/PhasedClearanceActionPanel";
import { ClearanceMilestoneTimeline } from "@/components/contracts/ClearanceMilestoneTimeline";
@@ -374,6 +375,13 @@ export default function DocumentClearanceDetailPage() {
/>
{booking ? <BookingCompanyCard booking={booking} /> : null}
{booking ? <BookingContractCard booking={booking} /> : null}
{/* Cancelled-wagon credits: GL rebooks them for the customer. */}
{id ? (
<WagonCancellationCreditCard
bookingId={id}
onRebooked={() => void refetch()}
/>
) : null}
{isPhasedGeneral ? (
<PhasedClearanceActionPanel
bookingId={id!}

View File

@@ -33,87 +33,15 @@ import {
type ColumnDef,
} from "@edr/ui-common";
type WagonCancellationStatus =
| "FEE_PENDING"
| "CREDIT_AVAILABLE"
| "REBOOKED"
| "WITHDRAWN"
| "EXPIRED";
interface WagonCancellation {
id: string;
bookingId: string;
rebookedBookingId?: string | null;
wagonsCancelled: number;
weightTons: number;
creditAmount: number;
feeAmount: number;
feeCurrency: string;
feeInvoiceId?: string | null;
feePaidAt?: string | null;
status: WagonCancellationStatus;
reason?: string | null;
rebookedAt?: string | null;
createdAt: string;
booking?: {
id: string;
reference: string;
customsClearingEnabled?: boolean;
company?: { name: string };
};
rebookedBooking?: { id: string; reference: string };
feeInvoice?: { invoiceNumber: string; status: string };
cancelledQuantities?: {
bySize?: Record<string, number>;
units?: Array<{
containerSize: string;
containerNumber: string;
sealNumber?: string | null;
vgmTons: number;
}>;
};
}
interface RebookPartnerCandidate {
id: string;
reference: string;
companyName: string | null;
status: string;
scheduledDate: string | null;
ft20Quantity: number;
}
/** Odd 20ft in the credit ⇒ the rebooked booking shares a wagon and GL must pick the partner. */
const hasOddFt20 = (r: WagonCancellation): boolean =>
Object.entries(r.cancelledQuantities?.bySize ?? {})
.filter(([size]) => parseInt(size, 10) === 20)
.reduce((sum, [, qty]) => sum + Number(qty || 0), 0) %
2 ===
1;
/** Editable rebook unit — prefilled from the cancelled snapshot. */
interface RebookUnitDraft {
containerSize: string;
containerNumber: string;
sealNumber: string;
vgmTons: number | "";
}
interface WagonCancellationListResponse {
items: WagonCancellation[];
total: number;
}
const STATUS_CHIP: Record<
WagonCancellationStatus,
{ label: string; color: string }
> = {
FEE_PENDING: { label: "Fee pending", color: "yellow" },
CREDIT_AVAILABLE: { label: "Credit available", color: "edr-green" },
REBOOKED: { label: "Rebooked", color: "indigo" },
WITHDRAWN: { label: "Withdrawn", color: "gray" },
EXPIRED: { label: "Expired", color: "red" },
};
import {
canRebookWagonCancellations,
isRebookableCredit,
RebookWagonCancellationModal,
WAGON_CANCELLATION_STATUS_CHIP as STATUS_CHIP,
type WagonCancellation,
type WagonCancellationListResponse,
type WagonCancellationStatus,
} from "@/components/bookings/wagon-cancellation";
const STATUS_FILTER_OPTIONS = (
Object.keys(STATUS_CHIP) as WagonCancellationStatus[]
@@ -155,72 +83,11 @@ export default function WagonCancellationsPage() {
const [from, setFrom] = useState<Date | null>(null);
const [to, setTo] = useState<Date | null>(null);
const [voiding, setVoiding] = useState<WagonCancellation | null>(null);
// GL rebook of a customs (Path B) credit: pick the day; container number /
// seal / VGM may be corrected. Non-customs credits are rebooked by the
// customer from the portal.
const canRebook = hasPermission(
user,
FREIGHT_PERMS.bookings.wagonCancellationRebook,
);
// Rebook of a CREDIT_AVAILABLE row — any desk holding the rebook key, or GL
// Ethiopia through its booking-creation key. The modal handles the day pick,
// container corrections and the odd-20ft partner choice.
const canRebook = canRebookWagonCancellations(user);
const [rebooking, setRebooking] = useState<WagonCancellation | null>(null);
const [rebookDate, setRebookDate] = useState<Date | null>(null);
const [rebookPartnerId, setRebookPartnerId] = useState<string | null>(null);
const [rebookDrafts, setRebookDrafts] = useState<RebookUnitDraft[]>([]);
const openRebook = (r: WagonCancellation) => {
setRebooking(r);
setRebookDate(null);
setRebookPartnerId(null);
setRebookDrafts(
(r.cancelledQuantities?.units ?? []).map((u) => ({
containerSize: u.containerSize,
containerNumber: u.containerNumber,
sealNumber: u.sealNumber ?? "",
vgmTons: Number(u.vgmTons) || "",
})),
);
};
const rebookContainersPayload = () => {
const bySize = new Map<string, RebookUnitDraft[]>();
for (const d of rebookDrafts) {
bySize.set(d.containerSize, [...(bySize.get(d.containerSize) ?? []), d]);
}
return [...bySize.entries()].map(([containerSize, units]) => ({
containerSize,
units: units.map((u) => ({
containerNumber: u.containerNumber.trim(),
...(u.sealNumber.trim() ? { sealNumber: u.sealNumber.trim() } : {}),
...(u.vgmTons !== "" ? { vgmTons: Number(u.vgmTons) } : {}),
})),
}));
};
const rebook = useMutation({
mutationFn: () =>
api.post(`/bookings/wagon-cancellations/${rebooking!.id}/rebook`, {
scheduledDate: toDayString(rebookDate!),
...(rebookDrafts.length ? { containers: rebookContainersPayload() } : {}),
...(rebookPartnerId ? { partnerBookingId: rebookPartnerId } : {}),
}),
});
// Odd-20ft credit: the rebooked booking shares a wagon again, so GL must pick
// the odd partner booking riding the chosen day. It ships once that partner pays.
const rebookNeedsPartner = rebooking ? hasOddFt20(rebooking) : false;
const rebookPartners = useQuery({
queryKey: [
"wagon-cancellations",
rebooking?.id,
"rebook-partners",
rebookDate ? toDayString(rebookDate) : null,
],
enabled: Boolean(rebooking && rebookNeedsPartner && rebookDate),
queryFn: async () => {
const res = await api.get<RebookPartnerCandidate[]>(
`/bookings/wagon-cancellations/${rebooking!.id}/rebook-partners`,
{ params: { scheduledDate: toDayString(rebookDate!) } },
);
return res.data;
},
});
const resetPage = () =>
setPagination({ pageIndex: 0, pageSize: pagination.pageSize });
@@ -340,14 +207,12 @@ export default function WagonCancellationsPage() {
cell: ({ row }) => {
const r = row.original;
const showVoid = r.status === "FEE_PENDING" && canVoid;
// Customs credits are GL's to rebook; non-customs ones the customer
// rebooks from the portal — EXCEPT odd-20ft credits: those must be
// re-paired with a partner booking, which only GL can pick.
const showRebook =
r.status === "CREDIT_AVAILABLE" &&
canRebook &&
(Boolean(r.booking?.customsClearingEnabled) || hasOddFt20(r)) &&
Number(r.creditAmount) > 0;
// Every available credit is rebookable from here — customs or not,
// customer- or staff-cancelled, EDR or customer fault. The customer can
// also self-rebook plain non-customs credits from the portal, but GL
// must be able to do it for them (customs bookings and odd-20ft
// credits are never self-booked). The API enforces the fee gate.
const showRebook = isRebookableCredit(r) && canRebook;
if (!showVoid && !showRebook) return null;
return (
<Group justify="flex-end" wrap="nowrap">
@@ -357,7 +222,7 @@ export default function WagonCancellationsPage() {
radius="md"
variant="light"
color="green"
onClick={() => openRebook(r)}
onClick={() => setRebooking(r)}
>
Rebook
</Button>
@@ -518,153 +383,11 @@ export default function WagonCancellationsPage() {
</Stack>
)}
</Modal>
<Modal
opened={!!rebooking}
<RebookWagonCancellationModal
cancellation={rebooking}
onClose={() => setRebooking(null)}
title="Rebook cancelled wagons"
centered
radius="md"
>
{rebooking && (
<Stack gap="sm">
<Text size="sm">
{rebooking.booking?.reference ?? rebooking.bookingId} ·{" "}
{rebooking.wagonsCancelled} wagon(s) · credit{" "}
{formatMoney(rebooking.creditAmount, rebooking.feeCurrency, 2)}
</Text>
<DatePickerInput
label="Shipment day"
placeholder="Pick the day"
value={rebookDate}
onChange={(v) => {
setRebookDate(v ? new Date(v) : null);
setRebookPartnerId(null);
}}
radius="md"
/>
{rebookNeedsPartner && (
<Select
label="Consolidation partner"
description="This credit has an odd 20ft container — pick the odd booking that shares its wagon. The rebooked booking is paid; it ships once the partner pays."
placeholder={
!rebookDate
? "Pick the day first"
: rebookPartners.isLoading
? "Loading…"
: "Pick the partner booking"
}
data={(rebookPartners.data ?? []).map((c) => ({
value: c.id,
label: `${c.reference} · ${c.companyName ?? "—"} · ${c.ft20Quantity}×20ft`,
}))}
value={rebookPartnerId}
onChange={setRebookPartnerId}
disabled={!rebookDate}
searchable
radius="md"
/>
)}
{rebookNeedsPartner &&
rebookDate &&
!rebookPartners.isLoading &&
(rebookPartners.data ?? []).length === 0 && (
<Text size="xs" c="orange">
No odd-20ft booking rides that day pick another day or wait
for a partner booking.
</Text>
)}
{rebookDrafts.length > 0 && (
<Stack gap={6}>
<Text size="xs" c="dimmed">
Correct the container details if they changed sizes and
quantities stay as cancelled.
</Text>
{rebookDrafts.map((d, i) => (
<Group key={i} gap={8} wrap="nowrap" align="flex-end">
<TextInput
label={`${d.containerSize} container`}
value={d.containerNumber}
onChange={(e) => {
const v = e.currentTarget.value;
setRebookDrafts((prev) =>
prev.map((x, idx) =>
idx === i ? { ...x, containerNumber: v } : x,
),
);
}}
size="xs"
radius="md"
style={{ flex: 1.4 }}
/>
<TextInput
label="Seal no."
value={d.sealNumber}
onChange={(e) => {
const v = e.currentTarget.value;
setRebookDrafts((prev) =>
prev.map((x, idx) =>
idx === i ? { ...x, sealNumber: v } : x,
),
);
}}
size="xs"
radius="md"
style={{ flex: 1 }}
/>
<TextInput
label="VGM (t)"
type="number"
value={d.vgmTons === "" ? "" : String(d.vgmTons)}
onChange={(e) => {
const raw = e.currentTarget.value;
setRebookDrafts((prev) =>
prev.map((x, idx) =>
idx === i
? { ...x, vgmTons: raw === "" ? "" : Number(raw) }
: x,
),
);
}}
size="xs"
radius="md"
style={{ width: 90 }}
/>
</Group>
))}
</Stack>
)}
<Group justify="flex-end" gap="sm">
<Button
variant="default"
radius="md"
onClick={() => setRebooking(null)}
>
Close
</Button>
<Button
color="green"
radius="md"
disabled={
!rebookDate || (rebookNeedsPartner && !rebookPartnerId)
}
loading={rebook.isPending}
onClick={async () => {
try {
await rebook.mutateAsync();
toast.success("Credit rebooked as a new paid booking");
setRebooking(null);
void refetch();
} catch {
// interceptor surfaces the reason
}
}}
>
Rebook
</Button>
</Group>
</Stack>
)}
</Modal>
onRebooked={() => void refetch()}
/>
</PageContainer>
);
}

View File

@@ -29,6 +29,7 @@ import {
FileText,
History,
Hourglass,
KeyRound,
IdCard,
LayoutGrid,
Package,
@@ -43,6 +44,7 @@ import { useMemo, useState } from "react";
import { useNavigate, useParams } from "react-router-dom";
import {
AccountCard,
BookingStatusBadge,
ChangeRequestPendingBadge,
ChangeRequestReview,
@@ -157,6 +159,12 @@ export default function CustomerDetailPage() {
enabled: Boolean(id),
}),
);
const accountsQuery = useQuery(
api.customers.accounts.queryOptions({
input: { companyId: id ?? "" },
enabled: Boolean(id),
}),
);
const documentsQuery = useQuery(
api.customers.documents.queryOptions({
input: { id: id ?? "" },
@@ -240,12 +248,61 @@ export default function CustomerDetailPage() {
<div className="space-y-2">
<ProfileTypeBadge type={row.original.type} />
<Text size="sm" fw={600} c="edr-text">
{row.original.reference}
</Text>
{/* The profile reference (EX-A00001). Minted only when a reviewer
approves the role, so an unapproved one has none — say so
rather than rendering an empty line that reads as a bug. */}
{row.original.reference ? (
<Text size="sm" fw={600} c="edr-text">
{row.original.reference}
</Text>
) : (
<Text size="xs" c="dimmed" fs="italic">
Ref. issued on approval
</Text>
)}
</div>
),
},
{
id: "etradeBusiness",
header: "eTrade business",
cell: ({ row }) => {
const business = row.original.etradeBusiness;
// Not attached is a review finding, not a blank: the role names no
// business, so there is nothing to check the uploaded licence
// against. Companies with no eTrade record legitimately show this,
// which is why it reads as a warning rather than an error.
if (!business) {
return (
<Badge size="xs" color="yellow" variant="light">
Not attached
</Badge>
);
}
return (
<Stack gap={2} maw={230}>
<Text size="sm" fw={600} c="edr-text" lineClamp={2}>
{business.tradeName || "(no trade name on this licence)"}
</Text>
{business.activity && (
<Text size="xs" c="dimmed" lineClamp={2}>
{business.activity}
</Text>
)}
{/* The licence number is what the reviewer matches against the
uploaded document — trade names repeat across licences. */}
<Text size="xs" c="dimmed">
{business.licenceNumber}
</Text>
{business.renewedTo && (
<Text size="xs" c="dimmed">
Renewed to {business.renewedTo}
</Text>
)}
</Stack>
);
},
},
{
id: "licenseFiles",
header: "License documents",
@@ -774,6 +831,9 @@ export default function CustomerDetailPage() {
<Tabs.Tab value="invoices" leftSection={<Receipt size={16} />}>
Invoices
</Tabs.Tab>
<Tabs.Tab value="accounts" leftSection={<KeyRound size={16} />}>
Account
</Tabs.Tab>
<Tabs.Tab value="history" leftSection={<History size={16} />}>
History
</Tabs.Tab>
@@ -981,29 +1041,29 @@ export default function CustomerDetailPage() {
</Stack>
</Card>
<Card>
<Stack gap="md">
{/* Padding sits on the header section, not the card, so the
table runs edge to edge. minWidth carries the eTrade
business column; the region scrolls rather than squashing
the other columns. */}
<TableCard
minWidth={980}
header={
<Group justify="space-between">
<Text fw={600} c="edr-text">
Role profiles
</Text>
<ProfileChips profiles={profiles} />
</Group>
{/* Narrower than the old full-width layout — the table
shares the row with the people column now. */}
<Box style={{ overflowX: "auto" }} w="100%">
<Box miw={760}>
<DataTable
columns={profileColumns}
data={profiles}
status="success"
emptyMessage="No profiles registered."
containerClassName="border-0 shadow-none bg-transparent"
/>
</Box>
</Box>
</Stack>
</Card>
}
>
<DataTable
columns={profileColumns}
data={profiles}
status="success"
emptyMessage="No profiles registered."
containerClassName="border-0 shadow-none bg-transparent"
/>
</TableCard>
</Stack>
</Grid.Col>
@@ -1439,6 +1499,51 @@ export default function CustomerDetailPage() {
</Tabs.Panel>
{/* HISTORY */}
{/* ACCOUNT — the IAM logins behind this customer. Distinct from the
contact details on Overview: those are business contact info on the
company row, these are the credentials someone actually signs in
with, and the two drift apart routinely. Cards rather than a table:
it is a handful of rows of mostly-optional detail, which a table
renders as a field of dashes. */}
<Tabs.Panel value="accounts" pt="lg">
{accountsQuery.isLoading ? (
<Center py="xl">
<Loader size="sm" color="edr-green" />
</Center>
) : accountsQuery.isError ? (
<Alert
color="red"
icon={<AlertTriangle size={16} />}
title="Failed to load accounts"
>
<Group justify="space-between" align="center">
<Text size="sm">
We couldn't load this customer's portal logins.
</Text>
<Button
size="xs"
variant="light"
onClick={() => void accountsQuery.refetch()}
>
Retry
</Button>
</Group>
</Alert>
) : (accountsQuery.data?.length ?? 0) === 0 ? (
<Card>
<Text size="sm" c="edr-muted" ta="center" py="md">
This customer has no portal login yet.
</Text>
</Card>
) : (
<SimpleGrid cols={{ base: 1, md: 2 }} spacing="md">
{accountsQuery.data?.map((account) => (
<AccountCard key={account.profileId} account={account} />
))}
</SimpleGrid>
)}
</Tabs.Panel>
<Tabs.Panel value="history" pt="lg">
<CompanyTimeline company={company} />
</Tabs.Panel>

View File

@@ -108,6 +108,24 @@ const CUSTOMER_FILTER_DEFS: FilterDef[] = [
["customer", "freight_forwarder", "dj_freight_forwarder", "transporter"] as const
).map((value) => ({ value, label: humanize(value) })),
},
{
// The operational role, not `type` above: one `customer` company routinely
// holds importer AND exporter, so this asks "who does X?" rather than
// "what kind of company is this?".
key: "profileType",
label: "Role",
type: "enum",
multiple: false,
options: (
[
"importer",
"exporter",
"freight_forwarder",
"dj_freight_forwarder",
"transporter",
] as const
).map((value) => ({ value, label: humanize(value) })),
},
{
key: "kind",
label: "Sector",
@@ -348,7 +366,7 @@ export default function CustomersPage() {
<FilterBar
defs={CUSTOMER_FILTER_DEFS}
controls={controls}
searchPlaceholder="Search by company, TIN, email or profile reference…"
searchPlaceholder="Search by company, trade name, TIN, email, licence no. or profile ref…"
sortOptions={SORT_OPTIONS.map((o) => ({ ...o }))}
viewId="customers"
>

View File

@@ -19,8 +19,10 @@ import { ExportButton } from "@/components/export/ExportButton";
import { useExchangeSettingsQuery } from "@/hooks/useExchangeSettings";
import { api } from "@/services/api";
import {
INVOICE_TYPE_OPTIONS,
PAYMENT_METHOD_OPTIONS,
invoicePaymentMethod,
invoiceTypeLabel,
paymentMethodLabel,
type Invoice,
type InvoiceListFilter,
@@ -55,6 +57,7 @@ const EIMS_STATUS_OPTIONS = [
const INVOICE_FILTER_DEFS: FilterDef[] = [
{ key: "statuses", label: "Status", type: "enum", options: STATUS_OPTIONS },
{ key: "sources", label: "Source", type: "enum", options: SOURCE_OPTIONS },
{ key: "types", label: "Type", type: "enum", options: INVOICE_TYPE_OPTIONS },
{
key: "currency",
label: "Currency",
@@ -261,6 +264,15 @@ export default function InvoicesPanel() {
size: 220,
cell: ({ row }) => <InvoiceSourceCell invoice={row.original} />,
},
{
id: "type",
header: "Type",
cell: ({ row }) => (
<Text size="sm" c="edr-text" truncate maw={180}>
{invoiceTypeLabel(row.original.type)}
</Text>
),
},
{
id: "status",
header: "Status",
@@ -389,7 +401,7 @@ export default function InvoicesPanel() {
</Box>
<Box style={{ overflowX: "auto" }} w="100%">
<Box miw={1040}>
<Box miw={1200}>
<DataTable
columns={columns}
data={rows}

View File

@@ -1,4 +1,4 @@
import type { Freight } from "@edr/types";
import { Freight } from "@edr/types";
import {
ActionIcon,
Badge,
@@ -7,15 +7,19 @@ import {
Card,
Group,
Modal,
SegmentedControl,
Stack,
Text,
TextInput,
Tooltip,
} from "@mantine/core";
import { useDebouncedValue } from "@mantine/hooks";
import { useMutation, useQuery } from "@tanstack/react-query";
import { CheckCircle2, ExternalLink, RefreshCw, Search, X } from "lucide-react";
import {
CheckCircle2,
CircleDollarSign,
ExternalLink,
Receipt,
RefreshCw,
} from "lucide-react";
import { useEffect, useMemo, useState } from "react";
import { useNavigate } from "react-router-dom";
import toast from "react-hot-toast";
@@ -25,18 +29,134 @@ import {
formatMoney,
humanize,
} from "@/components/customers";
import {
FilterBar,
dateRangeParams,
isoToLocalDateStr,
useFilters,
type FilterDef,
} from "@/components/filters";
import { KpiStrip } from "@/components/page";
import { ExportButton } from "@/components/export/ExportButton";
import { PhasedFileDropzone } from "@/components/contracts/PhasedFileDropzone";
import { useAuth } from "@/auth/useAuth";
import { useManualPaymentSettingsQuery } from "@/hooks/useManualPaymentSettings";
import { FREIGHT_PERMS, hasPermission } from "@/lib/permissions";
import { api } from "@/services/api";
import type { OfflineUsdInvoice } from "@/types/invoice";
import {
DataTable,
DataTableFooter,
usePagination,
type ColumnDef,
} from "@edr/ui-common";
INVOICE_TYPE_OPTIONS,
PAYMENT_METHOD_OPTIONS,
invoiceTypeLabel,
type InvoiceListFilter,
type OfflineUsdInvoice,
} from "@/types/invoice";
import { DataTable, DataTableFooter, type ColumnDef } from "@edr/ui-common";
const STATUS_OPTIONS = Object.values(Freight.InvoiceStatus).map((value) => ({
value,
label: humanize(value),
}));
const SOURCE_OPTIONS = Object.values(Freight.InvoiceSource).map((value) => ({
value,
label: humanize(value),
}));
/**
* Mirrors `OPEN_STATUSES` in the API's billing service — the implicit "still
* needs settling" cut this worklist applies when no status pill is set. Only
* the export needs it spelled out (see `exportParams`); the list gets it from
* the server.
*/
const OPEN_STATUSES = [
Freight.InvoiceStatus.Issued,
Freight.InvoiceStatus.Pending,
Freight.InvoiceStatus.PaymentProcessing,
Freight.InvoiceStatus.PartiallyPaid,
Freight.InvoiceStatus.Overdue,
];
/**
* The same filter vocabulary the invoices list uses, minus `currency` — this
* panel is mounted once per currency and pins it from the prop, so offering it
* as a pill could only contradict the tab you are on.
*/
const MANUAL_PAYMENT_FILTER_DEFS: FilterDef[] = [
{ key: "statuses", label: "Status", type: "enum", options: STATUS_OPTIONS },
{
// Computed from the balance and due date rather than read off `status` —
// nothing sweeps PENDING rows into OVERDUE, so the status under-reports.
key: "settlement",
label: "Settlement",
type: "enum",
multiple: false,
options: [
{ value: "outstanding", label: "Outstanding" },
{ value: "overdue", label: "Overdue" },
],
toParams: (v) =>
v.v[0] === "overdue" ? { overdue: "true" } : { hasBalance: "true" },
},
{
key: "issued",
label: "Issued",
type: "date",
operators: ["between", "before", "after"],
toParams: dateRangeParams("issuedFrom", "issuedTo"),
},
{
key: "sources",
label: "Source",
type: "enum",
secondary: true,
options: SOURCE_OPTIONS,
},
{
key: "types",
label: "Type",
type: "enum",
secondary: true,
options: INVOICE_TYPE_OPTIONS,
},
{
key: "paymentMethods",
label: "Payment method",
type: "enum",
secondary: true,
options: PAYMENT_METHOD_OPTIONS,
},
{
key: "due",
label: "Due",
type: "date",
secondary: true,
operators: ["between", "before", "after"],
toParams: dateRangeParams("dueFrom", "dueTo"),
},
{
key: "amount",
label: "Amount",
type: "number",
secondary: true,
operators: ["between", "is"],
toParams: (v) =>
v.op === "between"
? { minAmount: v.v[0], maxAmount: v.v[1] }
: { minAmount: v.v[0], maxAmount: v.v[0] },
},
];
const SORT_OPTIONS = [
{ value: "issuedAt:DESC", label: "Newest issued" },
{ value: "issuedAt:ASC", label: "Oldest issued" },
{ value: "dueAt:ASC", label: "Due soonest" },
{ value: "totalAmount:DESC", label: "Largest amount" },
{ value: "balanceAmount:DESC", label: "Largest balance" },
{ value: "invoiceNumber:ASC", label: "Invoice no. (AZ)" },
];
/** Date params the export's `daterange` coercion expects as calendar days. */
const EXPORT_DAY_KEYS = ["issuedFrom", "issuedTo", "dueFrom", "dueTo"];
/**
* The customer's pay window, counted down live. Finance must confirm the bank
@@ -157,21 +277,19 @@ export default function UsdPaymentsPanel({
currency: "USD" | "ETB";
}) {
const navigate = useNavigate();
const { pagination, setPagination } = usePagination({ pageSize: 10 });
const [query, setQuery] = useState("");
const [debouncedQuery] = useDebouncedValue(query, 300);
const [statusFilter, setStatusFilter] = useState<"" | Freight.InvoiceStatus>(
"",
);
// Namespaced: the ETB and USD tabs share this panel and live on the same URL
// as the Invoices tab, whose filter bar owns the bare `statuses`/`sort` keys.
const controls = useFilters(MANUAL_PAYMENT_FILTER_DEFS, {
defaultSort: "issuedAt:DESC",
pageSize: 10,
ns: "mp",
});
const [confirming, setConfirming] = useState<OfflineUsdInvoice | null>(null);
const [slip, setSlip] = useState<File | null>(null);
const [reference, setReference] = useState("");
const { user } = useAuth();
const canConfirm = hasPermission(
user,
FREIGHT_PERMS.invoices.confirmOffline,
);
const canConfirm = hasPermission(user, FREIGHT_PERMS.invoices.confirmOffline);
// Manual settlement is switched on per currency in Configuration → Manual
// payments. FinanceHubPage hides the tab for a disabled currency; this is
@@ -184,20 +302,8 @@ export default function UsdPaymentsPanel({
: true;
const filter = useMemo(
() => ({
page: pagination.pageIndex + 1,
pageSize: pagination.pageSize,
search: debouncedQuery,
status: statusFilter || undefined,
currency,
}),
[
pagination.pageIndex,
pagination.pageSize,
debouncedQuery,
statusFilter,
currency,
],
() => ({ ...controls.params, currency }) as unknown as InvoiceListFilter,
[controls.params, currency],
);
const { data, isLoading, isError, refetch, isFetching } = useQuery({
@@ -209,7 +315,24 @@ export default function UsdPaymentsPanel({
const rows = data?.items ?? [];
const total = data?.total ?? 0;
const pageCount = Math.max(1, Math.ceil(total / pagination.pageSize));
const outstanding = data?.outstanding?.[currency] ?? 0;
/**
* The export's `daterange` filters are coerced from calendar days while the
* list takes ISO instants, so each bound is handed over as the local day it
* falls on. The worklist's implicit "still open" cut is not a URL param
* either — spelled out here so an exported file covers the rows the screen
* shows rather than every invoice ever raised in this currency.
*/
const exportParams = useMemo(() => {
const out: Record<string, unknown> = { ...controls.params, currency };
for (const key of EXPORT_DAY_KEYS) {
if (typeof out[key] === "string")
out[key] = isoToLocalDateStr(out[key] as string);
}
if (!out.statuses) out.statuses = OPEN_STATUSES.join(",");
return out;
}, [controls.params, currency]);
const closeConfirm = () => {
setConfirming(null);
@@ -310,6 +433,15 @@ export default function UsdPaymentsPanel({
);
},
},
{
id: "type",
header: "Type",
cell: ({ row }) => (
<Text size="sm" c="edr-text" truncate maw={180}>
{invoiceTypeLabel(row.original.type)}
</Text>
),
},
{
id: "status",
header: "Status",
@@ -340,7 +472,9 @@ export default function UsdPaymentsPanel({
header: "Pay window",
meta: { headerClassName: "text-right", cellClassName: "text-right" },
cell: ({ row }) => (
<PayWindowCell deadline={row.original.booking?.paymentDeadline ?? null} />
<PayWindowCell
deadline={row.original.booking?.paymentDeadline ?? null}
/>
),
},
{
@@ -358,47 +492,40 @@ export default function UsdPaymentsPanel({
);
return (
<>
<Stack gap="md">
<KpiStrip
loading={isLoading}
items={[
{
label: `Outstanding in ${currency}`,
hint: "all matching",
value: formatMoney(outstanding, currency),
icon: CircleDollarSign,
color: "edr-green",
},
{
label: "Invoices listed",
value: total,
icon: Receipt,
color: "blue",
},
]}
/>
<Card p={0}>
<Stack gap={0}>
<Box px="md" pt="md" pb="sm" w="100%">
<Group justify="space-between" gap="md" wrap="wrap">
<TextInput
placeholder="Search by invoice number…"
leftSection={<Search size={18} />}
value={query}
onChange={(e) => setQuery(e.target.value)}
rightSection={
query ? (
<ActionIcon
size="sm"
color="gray"
radius="md"
variant="transparent"
onClick={() => setQuery("")}
>
<X size={16} />
</ActionIcon>
) : null
}
style={{ flex: 1, minWidth: "240px" }}
radius="lg"
/>
<SegmentedControl
<FilterBar
defs={MANUAL_PAYMENT_FILTER_DEFS}
controls={controls}
searchPlaceholder="Search invoice, customer, booking ref, PNR, transaction ref, GRN or shipping line…"
sortOptions={SORT_OPTIONS}
viewId={`manual-payments-${currency.toLowerCase()}`}
>
<ExportButton
datasetKey="invoices"
params={exportParams}
size="sm"
radius="md"
value={statusFilter || "open"}
onChange={(v) => {
setStatusFilter(
v === "open" ? "" : (v as Freight.InvoiceStatus),
);
setPagination((prev) => ({ ...prev, pageIndex: 0 }));
}}
data={[
{ label: "Awaiting payment", value: "open" },
{ label: "Paid", value: "PAID" },
{ label: "Overdue", value: "OVERDUE" },
]}
/>
<ActionIcon
variant="default"
@@ -410,11 +537,11 @@ export default function UsdPaymentsPanel({
>
<RefreshCw size={16} />
</ActionIcon>
</Group>
</FilterBar>
</Box>
<Box style={{ overflowX: "auto" }} w="100%">
<Box miw={1160}>
<Box miw={1320}>
<DataTable
columns={columns}
data={rows}
@@ -423,8 +550,8 @@ export default function UsdPaymentsPanel({
emptyMessage={
!currencyEnabled
? `Manual payment is switched off for ${currency} invoices. Enable it in Configuration → Manual payments.`
: debouncedQuery
? "No invoices match your search."
: controls.activeCount > 0
? "No invoices match these filters."
: `No ${currency} invoices awaiting manual payment confirmation.`
}
error={
@@ -435,18 +562,7 @@ export default function UsdPaymentsPanel({
}
: undefined
}
pagination={{
pageIndex: pagination.pageIndex,
pageSize: pagination.pageSize,
pageCount,
totalCount: total,
}}
tableOptions={{
state: { pagination },
onPaginationChange: setPagination,
manualPagination: true,
pageCount,
}}
{...controls.tableProps(total)}
containerClassName="border-0 shadow-none bg-transparent"
footer={DataTableFooter}
/>
@@ -458,9 +574,7 @@ export default function UsdPaymentsPanel({
<Modal
opened={confirming !== null}
onClose={closeConfirm}
title={
<Text fw={700}>Confirm manual payment</Text>
}
title={<Text fw={700}>Confirm manual payment</Text>}
radius="md"
size="md"
>
@@ -510,6 +624,6 @@ export default function UsdPaymentsPanel({
</Stack>
)}
</Modal>
</>
</Stack>
);
}

View File

@@ -15,7 +15,7 @@ import {
Route,
TrainFront,
} from "lucide-react";
import { Box, Button, Group, Loader, Stack, Text } from "@mantine/core";
import { Box, Button, Group, Loader, Menu, Stack, Text } from "@mantine/core";
import { CheckpointTimeModal } from "@/components/trainScheduling/CheckpointTimeModal";
import { CheckpointLogTable } from "@/components/trainScheduling/CheckpointLogTable";
@@ -119,18 +119,34 @@ export default function TrainScheduleTrackPage() {
enabled: Boolean(scheduleId) && trackQuery.data?.status === "DISPATCHED",
}),
);
// Marshalling 2: the current on-board list, reprinted after station work.
// Marshalling: the on-board list, reprinted after station work. Numbered
// per corridor stop that actually coupled/uncoupled something (Marshalling
// 2, 3, 4…) — falls back to the single "current position" doc when nothing
// has happened yet.
const marshallingStopsQuery = useQuery(
api.trainScheduling.marshallingStops.queryOptions({
input: { id: scheduleId ?? "" },
enabled:
Boolean(scheduleId) &&
["DISPATCHED", "ARRIVED"].includes(trackQuery.data?.status ?? ""),
}),
);
const marshallingStops = marshallingStopsQuery.data ?? [];
const intercityMarshalling = useMutation({
mutationFn: () =>
trainSchedulingService.downloadIntercityMarshallingDocument(scheduleId ?? ""),
mutationFn: (stopIndex?: number) =>
stopIndex != null
? trainSchedulingService.downloadMarshallingDocumentAt(scheduleId ?? "", stopIndex)
: trainSchedulingService.downloadIntercityMarshallingDocument(scheduleId ?? ""),
});
const openIntercityMarshalling = async () => {
const openIntercityMarshalling = async (stopIndex?: number) => {
const pdfWindow = window.open("", "_blank");
try {
const blob = await intercityMarshalling.mutateAsync();
const opened = openPdfBlob(blob, `intercity-marshalling-${scheduleId}.pdf`, pdfWindow);
const blob = await intercityMarshalling.mutateAsync(stopIndex);
const filename =
stopIndex != null ? `marshalling-${stopIndex}-${scheduleId}.pdf` : `intercity-marshalling-${scheduleId}.pdf`;
const opened = openPdfBlob(blob, filename, pdfWindow);
toast({
title: "Intercity marshalling ready",
title: stopIndex != null ? `Marshalling ${stopIndex} ready` : "Intercity marshalling ready",
description: opened
? "The PDF opened in a browser tab for printing or saving."
: "The browser blocked the preview tab, so the PDF was downloaded.",
@@ -313,7 +329,7 @@ export default function TrainScheduleTrackPage() {
</Text>
</Group>
<Box style={{ flex: 1 }} />
{inTransit || arrived ? (
{(inTransit || arrived) && marshallingStops.length === 0 ? (
<Button
variant="default"
radius={9}
@@ -325,6 +341,31 @@ export default function TrainScheduleTrackPage() {
Intercity Marshalling
</Button>
) : null}
{(inTransit || arrived) && marshallingStops.length > 0 ? (
<Menu position="bottom-end" withinPortal>
<Menu.Target>
<Button
variant="default"
radius={9}
size="compact-sm"
leftSection={<FileText size={15} color={T.brand} />}
loading={intercityMarshalling.isPending}
>
Marshalling
</Button>
</Menu.Target>
<Menu.Dropdown>
{marshallingStops.map((stop) => (
<Menu.Item
key={stop.stopIndex}
onClick={() => void openIntercityMarshalling(stop.stopIndex)}
>
{`Marshalling ${stop.stopIndex}${stop.yardLabel}`}
</Menu.Item>
))}
</Menu.Dropdown>
</Menu>
) : null}
</Group>
{/* ── Two-column work surface ── */}
@@ -600,6 +641,7 @@ export default function TrainScheduleTrackPage() {
onClose={() => setYardModal(null)}
scheduleId={scheduleId}
station={yardModal?.station ?? null}
stations={track.stations}
isFinal={yardModal?.isFinal ?? false}
alreadyLogged={yardModal?.alreadyLogged ?? false}
/>

View File

@@ -1,6 +1,7 @@
import {
ActionIcon,
Alert,
Anchor,
Badge,
Box,
Button,
@@ -64,6 +65,11 @@ import { LegCapacityPanel } from "@/components/trainScheduling/LegCapacityPanel"
import { ScheduleWagonYardPanel } from "@/components/trainScheduling/ScheduleWagonYardPanel";
import { LegLoadBoardPanel } from "@/components/trainScheduling/LegLoadBoardPanel";
import { LoadEmptyContainersModal } from "@/components/trainScheduling/LoadEmptyContainersModal";
import {
PartiallyLoadedDecisionModal,
parsePartiallyLoaded,
type PartiallyLoadedPayload,
} from "@/components/trainScheduling/PartiallyLoadedDecisionModal";
import MergeScheduleTrainModal from "@/components/trainScheduling/MergeScheduleTrainModal";
import ScheduleHistoryPanel from "@/components/trainScheduling/ScheduleHistoryPanel";
import { StationWorkControls } from "@/components/trainScheduling/StationWorkControls";
@@ -135,10 +141,12 @@ export default function TrainScheduleV2DetailPage() {
// Actual departure — staff often dispatch on paper first and record it later,
// so the time is picked (defaults to now when the dialog opens).
const [dispatchAt, setDispatchAt] = useState<Date | null>(null);
const openDispatchConfirm = () => {
setDispatchAt(new Date());
setDispatchConfirmOpen(true);
};
// Set when dispatch is rejected because a booking is part-loaded; drives the
// EDR-fault / customer-fault decision modal.
const [partialGate, setPartialGate] = useState<PartiallyLoadedPayload | null>(null);
// Log-pass / arrive confirmation for the dispatched leg of the workflow.
const [passConfirmOpen, setPassConfirmOpen] = useState(false);
const [passAt, setPassAt] = useState<Date | null>(null);
const [switchTarget, setSwitchTarget] = useState<EligibleContainerBooking | null>(null);
const [visualization3DOpen, setVisualization3DOpen] = useState(false);
const [loadEmptiesOpen, setLoadEmptiesOpen] = useState(false);
@@ -155,6 +163,23 @@ export default function TrainScheduleV2DetailPage() {
refetchInterval: 300_000,
}),
);
// Journey state for the dispatched leg of the workflow: the corridor stops,
// which one the train has reached, and each yard's loading/unloading windows.
// Only a rolling train has a journey, so it stays idle until then.
const trackQuery = useQuery(
api.trainScheduling.trainTrack.queryOptions({
input: { id: scheduleId ?? "" },
enabled: Boolean(scheduleId),
}),
);
// Which bookings board/alight at each yard — drives the loading gate on the
// log-pass button (a yard with cargo to load must finish its window first).
const yardWorkQuery = useQuery(
api.trainScheduling.yardWork.queryOptions({
input: { scheduleId: scheduleId ?? "" },
enabled: Boolean(scheduleId),
}),
);
// One-row 60s heartbeat: refetch the (expensive) full detail only when the
// schedule row actually changed — same freshness as polling the detail
// itself, at a fraction of the server cost.
@@ -256,14 +281,38 @@ export default function TrainScheduleV2DetailPage() {
const unassign = useMutation(api.trainScheduling.unassignBooking.mutationOptions());
const switchGov = useMutation(api.trainScheduling.switchGovernmentBooking.mutationOptions());
const dispatch = useMutation(api.trainScheduling.dispatchSchedule.mutationOptions());
const recordCheckpoint = useMutation(
api.trainScheduling.recordCheckpoint.mutationOptions(),
);
const downloadMarshalling = useMutation({
mutationFn: ({ id, direction, variant }: { id: string; direction?: string | null; variant?: "INTERCITY" }) =>
variant === "INTERCITY"
? trainSchedulingService.downloadIntercityMarshallingDocument(id)
: direction === "EXPORT"
? trainSchedulingService.downloadExportLoadListDocument(id)
: trainSchedulingService.downloadImportDjiboutiLoadListDocument(id),
mutationFn: ({
id,
direction,
variant,
stopIndex,
}: {
id: string;
direction?: string | null;
variant?: "INTERCITY";
stopIndex?: number;
}) =>
stopIndex != null
? trainSchedulingService.downloadMarshallingDocumentAt(id, stopIndex)
: variant === "INTERCITY"
? trainSchedulingService.downloadIntercityMarshallingDocument(id)
: direction === "EXPORT"
? trainSchedulingService.downloadExportLoadListDocument(id)
: trainSchedulingService.downloadImportDjiboutiLoadListDocument(id),
});
// Numbered marshalling docs (Marshalling 2, 3, 4…) — one per corridor stop
// that actually coupled/uncoupled something. Empty when nothing has yet.
const marshallingStopsQuery = useQuery(
api.trainScheduling.marshallingStops.queryOptions({
input: { id: scheduleId ?? "" },
enabled: Boolean(scheduleId) && ["DISPATCHED", "ARRIVED"].includes(schedule?.status ?? ""),
}),
);
const marshallingStops = marshallingStopsQuery.data ?? [];
useEffect(() => {
const operation = gatepassQuery.data;
@@ -476,12 +525,35 @@ export default function TrainScheduleV2DetailPage() {
b.originYardId === originYardId &&
!b.loadedAt &&
(b.loadingStatus ?? "UNLOADED") !== "LOADED" &&
// Paid is read from the PAYMENT status only, never booking.status.
(b.isGovernment
? b.status === "APPROVED" || b.status === "PAID"
: b.status === "PAID" ||
? b.status === "APPROVED" || b.paymentStatus === "PAID"
: b.paymentStatus === "PAID" ||
// Shipping-line bookings ride from accept on the credit ledger.
(Boolean(b.shippingLineCompanyId) && b.status === "FULLY_EXECUTED")),
);
// A slot can carry several loads of the same booking, so count DISTINCT
// slots per booking — the operator is being told how much steel is freed.
const slotIdsByBookingId = new Map<string, Set<string>>();
for (const slot of schedule.trainSet?.wagons ?? []) {
for (const alloc of slot.allocations ?? []) {
if (!alloc.bookingId) continue;
const slots = slotIdsByBookingId.get(alloc.bookingId) ?? new Set<string>();
slots.add(slot.id);
slotIdsByBookingId.set(alloc.bookingId, slots);
}
}
const wagonsOf = (bookingId: string) => slotIdsByBookingId.get(bookingId)?.size ?? 0;
const openDispatchConfirm = () => {
setDispatchAt(new Date());
setDispatchConfirmOpen(true);
};
// Everything unloaded at the origin comes off the train on dispatch.
// Government bookings can never be shed: the server refuses to unassign them.
const leftBehind = pendingOriginBoarders.filter((b) => !b.isGovernment);
const leftBehindWagons = leftBehind.reduce((n, b) => n + wagonsOf(b.id), 0);
// Origin loading time window: dispatch (which marks the boarders loaded)
// is server-rejected until "Start loading" was clicked for the origin
// yard, so the button mirrors that gate.
@@ -495,6 +567,89 @@ export default function TrainScheduleV2DetailPage() {
// Same gate the server enforces.
const dispatchBlockedByLoading = !originLoadingEnded;
// ── Journey leg: log pass / mark arrived ────────────────────────────────
// Once the train is rolling, the workflow's last step drives the corridor
// instead of dispatch. The stop being logged is the one AFTER the train's
// current position; the last stop on the route is the arrival.
const track = trackQuery.data;
const trackStations = track?.stations ?? [];
const isRolling = schedule.status === "DISPATCHED";
const nextStation = isRolling
? trackStations.find((st) => st.sequenceNo === (track?.currentSequenceNo ?? 0) + 1)
: undefined;
const nextIsFinal =
Boolean(nextStation) &&
nextStation?.sequenceNo === trackStations[trackStations.length - 1]?.sequenceNo;
// Same permission the track page gates its checkpoint actions on.
const canLogPass =
isRolling && hasPermission(authUser, FREIGHT_PERMS.trainScheduling.update);
// Loading gate. Logging a pass means the train LEAVES the yard it is standing
// at, so cargo boarding there must have finished loading first — an open (or
// never-opened) loading window at a yard with boarders blocks the button.
// Unloading never blocks: cargo alighting here can be taken off after the
// pass is recorded, and the final arrival is what opens that window at all.
const currentStation = isRolling
? trackStations.find((st) => st.sequenceNo === (track?.currentSequenceNo ?? 0))
: undefined;
const currentYardWork = currentStation
? yardWorkQuery.data?.yards.find((y) => y.yardId === currentStation.yardId)
: undefined;
const boardersHere = (currentYardWork?.toLoad ?? []).filter((r) => !r.loadedAt);
const currentLoadingLog = currentStation
? track?.stationWorkLogs?.[currentStation.yardId]?.loading
: undefined;
// Only a yard that actually has cargo to load can be blocked by its window.
const passBlockedByLoading =
boardersHere.length > 0 && !currentLoadingLog?.endedAt;
const passBlockReason = !passBlockedByLoading
? null
: currentLoadingLog?.startedAt
? `End the loading window at ${currentStation?.label ?? "this yard"} — the train cannot leave mid-loading.`
: `Start and end the loading window at ${currentStation?.label ?? "this yard"}${boardersHere.length} booking(s) board here.`;
const openPassConfirm = () => {
setPassAt(new Date());
setPassConfirmOpen(true);
};
const runLogPass = async () => {
if (!nextStation) return;
setPassConfirmOpen(false);
try {
await recordCheckpoint.mutateAsync({
id: scheduleId,
payload: {
sequenceNo: nextStation.sequenceNo,
...(passAt ? { occurredAt: passAt.toISOString() } : {}),
},
});
toast({
title: nextIsFinal
? `Train arrived at ${nextStation.label}`
: `Pass logged at ${nextStation.label}`,
description: nextIsFinal
? "Remaining bookings are marked arrived and the assets are freed."
: "The train's position has moved to this yard.",
});
void trackQuery.refetch();
void yardWorkQuery.refetch();
void detailQuery.refetch();
} catch (err) {
// A part-loaded booking blocks the pass until its never-loaded wagons are
// cut — hand over the fault decision rather than a dead-end error.
const gate = parsePartiallyLoaded(err);
if (gate) {
setPartialGate(gate);
return;
}
toast({
title: nextIsFinal ? "Could not mark arrived" : "Could not log pass",
description: parseError(err, "Please try again"),
variant: "destructive",
});
}
};
const finalizeStep = hasContainerStep ? 3 : 2;
const canModifyBookings = canEditBookings && !["DISPATCHED", "ARRIVED"].includes(schedule.status);
const canPrintMarshalling =
@@ -506,6 +661,7 @@ export default function TrainScheduleV2DetailPage() {
successDescription?: string;
errorTitle?: string;
variant?: "INTERCITY";
stopIndex?: number;
}) => {
const pdfWindow = window.open("", "_blank");
try {
@@ -513,13 +669,16 @@ export default function TrainScheduleV2DetailPage() {
id: scheduleId,
direction: schedule.direction,
variant: options?.variant,
stopIndex: options?.stopIndex,
});
const prefix =
options?.variant === "INTERCITY"
? "intercity-marshalling"
: schedule.direction === "EXPORT"
? "export-marshalling"
: "import-marshalling";
options?.stopIndex != null
? `marshalling-${options.stopIndex}`
: options?.variant === "INTERCITY"
? "intercity-marshalling"
: schedule.direction === "EXPORT"
? "export-marshalling"
: "import-marshalling";
const filename = `${prefix}-${schedule.trainNumber ?? scheduleId}.pdf`;
const opened = openPdfBlob(blob, filename, pdfWindow);
toast({
@@ -547,17 +706,34 @@ export default function TrainScheduleV2DetailPage() {
id: scheduleId,
payload: {
...(dispatchAt ? { actualDepartureAt: dispatchAt.toISOString() } : {}),
// No per-booking ticking in the dispatch dialog: every pending origin
// boarder rides — none are left behind at dispatch time.
loadedBookingIds: pendingOriginBoarders.map((b) => b.id),
// Dispatch never loads cargo — loading is recorded in the yard, per
// booking. Anything still unloaded when the train leaves did not make
// it aboard: the server unassigns it (wagons freed, booking back in
// the pool). Government bookings are exempt and ride regardless.
loadedBookingIds: pendingOriginBoarders
.filter((b) => b.isGovernment)
.map((b) => b.id),
},
});
if (leftBehind.length) {
toast({
title: `${leftBehind.length} booking${leftBehind.length === 1 ? "" : "s"} removed from the train`,
description: `Never loaded at the origin — ${leftBehindWagons} wagon${leftBehindWagons === 1 ? "" : "s"} freed. The bookings are back in the pool and can be allocated to another train or cancelled.`,
});
}
await openMarshallingDocument({
title: "Train dispatched",
successDescription: "Marshalling document generated for the dispatched train.",
errorTitle: "Train dispatched, but document could not open",
});
} catch (err) {
// A part-loaded booking blocks dispatch until its never-loaded wagons are
// cut — hand the operator the fault decision instead of a dead error.
const gate = parsePartiallyLoaded(err);
if (gate) {
setPartialGate(gate);
return;
}
toast({
title: "Dispatch failed",
description: parseError(err, "Could not dispatch"),
@@ -680,8 +856,10 @@ export default function TrainScheduleV2DetailPage() {
{
key: "finalize",
icon: CheckCircle2,
title: "Dispatch",
subtitle: "Review the consist & dispatch",
title: isRolling ? "Journey" : "Dispatch",
subtitle: isRolling
? "Log each pass, then mark arrived"
: "Review the consist & dispatch",
complete: finalizeComplete,
},
];
@@ -933,14 +1111,51 @@ export default function TrainScheduleV2DetailPage() {
<CheckCircle2 size={22} />
</ThemeIcon>
<Stack gap={2}>
<Text fw={600}>Ready to depart</Text>
<Text fw={600}>
{isRolling
? nextIsFinal
? "Final leg"
: `In transit — at ${currentStation?.label ?? "the corridor"}`
: "Ready to depart"}
</Text>
<Text size="sm" c="dimmed">
Dispatch begins rail movement and notifies the yard.
{isRolling
? nextIsFinal
? "Marking arrived ends the journey and frees the locomotive and wagons."
: "Logging the pass moves the train to the next yard and settles its cargo there."
: "Dispatch begins rail movement and notifies the yard."}
</Text>
</Stack>
</Group>
</Paper>
{originYardId ? (
{/* Mid-route loading/unloading is recorded on the TRACKING page, per
yard — only the origin's window lives here (below), because dispatch
is the action this page owns. What stays is the read-only reason the
pass button is held, so the blocker is explainable without
duplicating the controls. */}
{isRolling && currentStation && passBlockedByLoading ? (
<Alert
color="orange"
variant="light"
radius="md"
icon={<AlertTriangle size={18} />}
title={`Loading is not finished at ${currentStation.label}`}
>
<Text size="xs">
{boardersHere.length} booking(s) board here, so the train cannot leave until
the loading window is closed. Start and end it on the{" "}
<Anchor
component={Link}
to={`/dashboard/operations/train-scheduling-v2/${scheduleId}/track`}
fw={600}
>
tracking page
</Anchor>
.
</Text>
</Alert>
) : null}
{!isRolling && originYardId ? (
<Paper p="md" radius="lg" withBorder>
<Stack gap={6}>
<Text fw={600} size="sm">
@@ -975,7 +1190,35 @@ export default function TrainScheduleV2DetailPage() {
Dispatch train
</Button>
) : null}
{!canDispatch ? (
{/* The train is rolling: the same slot now drives the corridor. */}
{canLogPass && nextStation ? (
<Tooltip
label={passBlockReason ?? ""}
disabled={!passBlockedByLoading}
withArrow
multiline
w={280}
>
<div>
<Button
color="edr-green"
size="md"
radius="md"
leftSection={
nextIsFinal ? <CheckCircle2 size={18} /> : <Navigation size={18} />
}
loading={recordCheckpoint.isPending}
disabled={passBlockedByLoading}
onClick={openPassConfirm}
>
{nextIsFinal
? `Mark arrived at ${nextStation.label}`
: `Log pass at ${nextStation.label}`}
</Button>
</div>
</Tooltip>
) : null}
{!canDispatch && !(canLogPass && nextStation) ? (
<Text size="sm" c="dimmed">
No actions available for this schedule status.
</Text>
@@ -1108,7 +1351,7 @@ export default function TrainScheduleV2DetailPage() {
Marshalling PDF
</Menu.Item>
) : null}
{["DISPATCHED", "ARRIVED"].includes(schedule.status) ? (
{["DISPATCHED", "ARRIVED"].includes(schedule.status) && marshallingStops.length === 0 ? (
<Menu.Item
leftSection={<FileText size={15} />}
disabled={downloadMarshalling.isPending}
@@ -1122,6 +1365,25 @@ export default function TrainScheduleV2DetailPage() {
Intercity Marshalling
</Menu.Item>
) : null}
{/* One item per corridor stop that actually coupled/uncoupled
something (Marshalling 2, 3, 4…) — replaces the single
"current position" item once anything has happened. */}
{marshallingStops.map((stop) => (
<Menu.Item
key={stop.stopIndex}
leftSection={<FileText size={15} />}
disabled={downloadMarshalling.isPending}
onClick={() =>
void openMarshallingDocument({
title: `Marshalling ${stop.stopIndex} ready`,
successDescription: `${stop.yardLabel} — coupled/uncoupled wagons included.`,
stopIndex: stop.stopIndex,
})
}
>
{`Marshalling ${stop.stopIndex}${stop.yardLabel}`}
</Menu.Item>
))}
{["DISPATCHED", "ARRIVED"].includes(schedule.status) ? (
<Menu.Item
component={Link}
@@ -1592,6 +1854,25 @@ export default function TrainScheduleV2DetailPage() {
radius="md"
/>
{leftBehind.length > 0 ? (
<Alert
color="orange"
variant="light"
radius="md"
icon={<AlertTriangle size={18} />}
title={`${leftBehind.length} booking${leftBehind.length === 1 ? "" : "s"} will be removed from this train`}
>
<Text size="xs">
Never loaded at the origin, so {leftBehind.length === 1 ? "it is" : "they are"}{" "}
not aboard. Dispatch frees {leftBehindWagons} wagon
{leftBehindWagons === 1 ? "" : "s"} and returns{" "}
{leftBehind.length === 1 ? "the booking" : "them"} to the pool, ready to be
allocated to another train or cancelled. Load cargo from the yard workspace
before dispatching if it should ride.
</Text>
</Alert>
) : null}
{hasDispatchWarnings ? (
<Alert
color="orange"
@@ -1673,6 +1954,62 @@ export default function TrainScheduleV2DetailPage() {
</Group>
</Stack>
</Modal>
{/* Log pass / arrival — confirmation only, with the recorded time. */}
<Modal
opened={passConfirmOpen}
onClose={() => setPassConfirmOpen(false)}
centered
radius="lg"
title={
<Group gap={8}>
{nextIsFinal ? <CheckCircle2 size={18} /> : <Navigation size={18} />}
<Text fw={700}>
{nextIsFinal ? "Mark the train arrived?" : "Log the pass?"}
</Text>
</Group>
}
>
<Stack gap="md">
<Text size="sm" c="dimmed">
{nextIsFinal
? `Recording arrival at ${nextStation?.label ?? "the destination"} ends the journey: remaining bookings are marked arrived and the locomotive and wagons are freed.`
: `Recording the pass at ${nextStation?.label ?? "the next yard"} moves the train there. Cargo destined for that yard alights, and cargo boarding there becomes loadable.`}
</Text>
<DateTimePicker
label={nextIsFinal ? "Arrival time" : "Time at station"}
description="Defaults to now — pick an earlier time if you are recording after the fact."
value={passAt}
onChange={(v) => setPassAt(v ? new Date(v) : null)}
maxDate={new Date()}
valueFormat="DD MMM YYYY HH:mm"
clearable={false}
radius="md"
/>
<Group justify="flex-end" gap="sm">
<Button variant="default" radius="md" onClick={() => setPassConfirmOpen(false)}>
Cancel
</Button>
<Button
color="edr-green"
radius="md"
loading={recordCheckpoint.isPending}
onClick={() => void runLogPass()}
>
{nextIsFinal ? "Mark arrived" : "Log pass"}
</Button>
</Group>
</Stack>
</Modal>
{/* Part-loaded gate. Cutting the wagons does NOT dispatch — the operator
confirms dispatch again once the consist is clean. */}
<PartiallyLoadedDecisionModal
payload={partialGate}
onClose={() => setPartialGate(null)}
onResolved={() => void detailQuery.refetch()}
/>
{visualization3DOpen ? (
<Train3DVisualization schedule={schedule} onClose={() => setVisualization3DOpen(false)} />
) : null}

View File

@@ -158,6 +158,11 @@ export default function TrainScheduleV2ListPage() {
const [routeId, setRouteId] = useState("");
const [scheduleDate, setScheduleDate] = useState("");
const [trainId, setTrainId] = useState("");
// Voyage number for this departure — required. Auto-filled from the selected
// train's own voyage number (typed in the Train Builder) when a train is
// picked; legacy trains without one fall back to the direction-matched run
// number. Staff may edit.
const [voyageNumber, setVoyageNumber] = useState("");
const [reverseWagonOrder, setReverseWagonOrder] = useState(false);
// "" = a normal customer train; an id dedicates the departure to that
// shipping line and hides it from every customer-facing view.
@@ -461,6 +466,13 @@ export default function TrainScheduleV2ListPage() {
});
return;
}
if (!voyageNumber.trim()) {
toast({
title: "Voyage number is required",
variant: "destructive",
});
return;
}
// Only build the window override when the toggle is on — off means "inherit
// the global rules", which the API expresses as an absent windowRule.
let windowRule: CreateScheduleWindowRulePayload | undefined;
@@ -483,6 +495,7 @@ export default function TrainScheduleV2ListPage() {
routeId,
scheduleDate: new Date(scheduleDate).toISOString(),
trainId,
voyageNumber: voyageNumber.trim(),
reverseWagonOrder,
...(shippingLineCompanyId ? { shippingLineCompanyId } : {}),
...(windowRule ? { windowRule } : {}),
@@ -490,6 +503,7 @@ export default function TrainScheduleV2ListPage() {
});
toast({ title: "Train schedule created" });
showScheduleWarnings(created.warnings);
setVoyageNumber("");
setReverseWagonOrder(false);
setShippingLineCompanyId("");
setConfigureWindow(false);
@@ -689,7 +703,20 @@ export default function TrainScheduleV2ListPage() {
};
})}
value={trainId || null}
onChange={(v) => setTrainId(v ?? "")}
onChange={(v) => {
setTrainId(v ?? "");
// Default the voyage number to the picked train's own voyage
// number (the Train Builder stores it as `trainName`). The run
// number is a train number, not a voyage — only fall back to it
// for legacy trains that have no voyage number yet; staff can
// still override.
const picked = (trainsQuery.data ?? []).find((t) => t.id === v);
const runNumber =
selectedRoute?.direction === "IMPORT"
? picked?.importTrainNumber
: picked?.exportTrainNumber;
setVoyageNumber(picked?.trainName?.trim() || runNumber || "");
}}
searchable
disabled={!routeId}
nothingFoundMessage={
@@ -698,6 +725,15 @@ export default function TrainScheduleV2ListPage() {
: "Select a route first"
}
/>
<TextInput
label="Voyage number"
description="Sailing/run number for this departure that yards and customs quote. Defaults to the selected train's voyage number — edit if needed."
placeholder={trainId ? "e.g. V-2026-0620" : "Select a train first"}
required
maxLength={20}
value={voyageNumber}
onChange={(e) => setVoyageNumber(e.currentTarget.value)}
/>
<Select
label="Shipping line (optional)"
description="Dedicate this departure to one shipping line. The train is then hidden from customers and shown only in that line's portal."
@@ -930,7 +966,12 @@ function TrainIdentityCell({ schedule }: { schedule: TrainScheduleListItem }) {
let subtitle = "";
if (schedule.train) {
title = schedule.trainNumber ?? schedule.train.code;
subtitle = [schedule.trainNumber ? schedule.train.code : null, schedule.train.trainName]
// Show THIS departure's voyage number (the schedule's own), not the train's
// voyage/name — one train serves many departures, each with its own voyage.
subtitle = [
schedule.trainNumber ? schedule.train.code : null,
schedule.voyageNumber ? `Voyage ${schedule.voyageNumber}` : null,
]
.filter(Boolean)
.join(" · ");
} else if (locos.length) {

View File

@@ -18,15 +18,20 @@ import {
Textarea,
Select,
Checkbox,
Autocomplete,
Input,
} from "@mantine/core";
import { ChevronDown, ChevronRight, FileText, History } from "lucide-react";
import { ChevronDown, ChevronRight, Download, FileText, History, Upload } from "lucide-react";
import { DataTable, type ColumnDef } from "@edr/ui-common";
import { PageContainer, PageHeader } from "@/components/page";
import ListControls from "@/components/common/ListControls";
import RuleEngineListFooter from "@/components/ruleEngine/RuleEngineListFooter";
import { extractDownloadErrorMessage } from "@/components/warehouses/options";
import { extractDownloadErrorMessage, extractErrorMessage } from "@/components/warehouses/options";
import { openPdfBlob } from "@/components/warehouses/pdf";
import BulkContainerReturnModal from "@/components/warehouses/BulkContainerReturnModal";
import { downloadContainerReturnTemplate } from "@/components/warehouses/container-return-excel";
import { useCompanyOptions } from "@/components/warehouses/useCompanyOptions";
import { OverviewHorizontalBarChart } from "@/components/overview/OverviewHorizontalBarChart";
import { OverviewStackedBarChart } from "@/components/overview/OverviewStackedBarChart";
import { useListControls, toDayString } from "@/hooks/useListControls";
@@ -35,13 +40,16 @@ import { useWarehouseYards, useWarehouseZones } from "@/hooks/useWarehouses";
import { api } from "@/services/api";
import { warehouseService } from "@/services/warehouse.service";
import { importOperationsService } from "@/services/importOperations.service";
import { emptyReturnRequestsService } from "@/services/emptyReturnRequests.service";
import type {
EmptyContainerReturn,
EmptyContainerReturnStatus,
EmptyContainerSize,
EmptyReturnBooking,
PlannedEmptyReturn,
} from "@/types/importOperations";
import type { TrainScheduleListItem } from "@/types/trainScheduling";
import { formatDateTime } from "@/lib/format";
import { formatDateTime, localNowForInput } from "@/lib/format";
type ReturnType = "all" | "edr" | "customer";
@@ -72,6 +80,32 @@ const RETURNED_BY_SERIES = [
{ key: "customer", label: "Customer Self-Haul", color: "#b45309" },
];
/**
* A scheduled request seen as the booking shape `BookingEmptyReturnModal`
* takes, so confirming an arrival runs through exactly the same recording
* path as any other empty return.
*/
const plannedAsBooking = (planned: PlannedEmptyReturn): EmptyReturnBooking => ({
bookingId: planned.bookingId,
bookingReference: planned.bookingReference ?? planned.bookingId,
bookingStatus: "SCHEDULED_RETURN",
equipmentReturn: "REQUESTED",
customerId: planned.companyId,
companyName: planned.companyName,
containers: planned.containers.map((container) => ({
key: `${planned.requestId}-${container.containerNumber}`,
unitId: `${planned.requestId}-${container.containerNumber}`,
containerNumber: container.containerNumber,
containerSize: null,
containerType: null,
returnId: container.returnId,
returnStatus: null,
})),
expectedCount: planned.containers.length,
recordedCount: planned.containers.filter((c) => c.returnId).length,
pendingCount: planned.containers.filter((c) => !c.returnId).length,
});
interface ContainerReturnRow {
key: string;
containerNumber: string;
@@ -102,9 +136,13 @@ export default function ContainerReturnsPage() {
const [filterType, setFilterType] = useState<ReturnType>("all");
const [returnModalOpen, setReturnModalOpen] = useState(false);
const [standaloneModalOpen, setStandaloneModalOpen] = useState(false);
const [bulkModalOpen, setBulkModalOpen] = useState(false);
const [activeKey, setActiveKey] = useState<string | null>(null);
const [historyRow, setHistoryRow] = useState<any | null>(null);
const [allocateRow, setAllocateRow] = useState<EmptyContainerReturn | null>(null);
const [emptyReturnBooking, setEmptyReturnBooking] = useState<EmptyReturnBooking | null>(null);
const [expandedBooking, setExpandedBooking] = useState<string | null>(null);
const [arrivingReturn, setArrivingReturn] = useState<PlannedEmptyReturn | null>(null);
const [documentBusyId, setDocumentBusyId] = useState<string | null>(null);
const viewInterchangeDocument = async (ret: EmptyContainerReturn) => {
@@ -140,6 +178,24 @@ export default function ContainerReturnsPage() {
},
});
// Bookings that ship WITH empty-container return and still owe empties. This
// list stands on the booking's own return flags, so it does not wait for the
// box to reach a warehouse or for a last-mile truck to be assigned — the
// queue below still covers that path.
const emptyReturnBookingsQuery = useQuery({
queryKey: ["empty-return-bookings"],
queryFn: () => importOperationsService.listEmptyReturnBookings(),
});
const emptyReturnBookings = emptyReturnBookingsQuery.data ?? [];
// Requests the customer already paid for and booked a truck against — the
// warehouse confirms these on arrival, which is what records the containers.
const plannedReturnsQuery = useQuery({
queryKey: ["planned-empty-returns"],
queryFn: () => emptyReturnRequestsService.planned(),
});
const plannedReturns = plannedReturnsQuery.data ?? [];
const bookingIds = unloadedQueue.map((item) => item.bookingId).filter(Boolean) as string[];
const containerReturnsQuery = useQuery({
queryKey: ["container-returns", bookingIds],
@@ -281,17 +337,24 @@ export default function ContainerReturnsPage() {
searchKeys: ["bookingRef", "companyName"],
});
const bookingReturnControls = useListControls(emptyReturnBookings, {
searchKeys: ["bookingReference", "companyName", "bookingStatus"],
});
const createReturnsMutation = useMutation({
mutationFn: async (payload: {
trucks: Array<{
bookingId: string;
customerId: string | null;
returnType: "EDR" | "CUSTOMER";
companyName?: string;
containers: Array<{
containerNumber: string;
containerSize?: EmptyContainerSize;
returnDate: string;
warehouse: string;
yard?: string;
zone?: string;
condition?: string;
handoverNote?: string;
}>;
@@ -306,7 +369,10 @@ export default function ContainerReturnsPage() {
returnDate: new Date(container.returnDate).toISOString(),
bookingId: truck.bookingId,
customerId: truck.customerId ?? undefined,
companyName: truck.companyName,
facility: container.warehouse,
yard: container.yard,
zone: container.zone,
condition: container.condition,
handoverNote: container.handoverNote,
returnedBy: truck.returnType,
@@ -319,8 +385,14 @@ export default function ContainerReturnsPage() {
onSuccess: () => {
toast({ title: "Container returns recorded" });
qc.invalidateQueries({ queryKey: ["container-returns", bookingIds] });
qc.invalidateQueries({ queryKey: ["empty-container-returns"] });
qc.invalidateQueries({ queryKey: ["empty-return-bookings"] });
qc.invalidateQueries({ queryKey: ["planned-empty-returns"] });
setReturnModalOpen(false);
setStandaloneModalOpen(false);
setActiveKey(null);
setEmptyReturnBooking(null);
setArrivingReturn(null);
},
onError: (error: any) => {
toast({
@@ -372,9 +444,15 @@ export default function ContainerReturnsPage() {
),
},
{
id: "bookingRef",
header: "Booking Ref",
cell: ({ row }) => (row.original.bookingId ? "Associated" : "—"),
id: "company",
header: "Company",
// One identity column: who the box belongs to, and the booking it came
// back on. A standalone return has no booking, so only the name shows.
cell: ({ row }) => {
const { companyName, bookingReference } = row.original;
if (!companyName) return bookingReference || "—";
return bookingReference ? `${companyName} (${bookingReference})` : companyName;
},
},
{
id: "returnedBy",
@@ -390,9 +468,8 @@ export default function ContainerReturnsPage() {
},
{
id: "returnDate",
header: "Returned Date",
cell: ({ row }) =>
row.original.returnDate ? new Date(row.original.returnDate).toLocaleDateString() : "—",
header: "Returned Date & Time",
cell: ({ row }) => formatDateTime(row.original.returnDate),
},
{
id: "facility",
@@ -510,11 +587,259 @@ export default function ContainerReturnsPage() {
{ label: "Customer Self-Haul", value: "customer" },
]}
/>
<Button onClick={() => setStandaloneModalOpen(true)}>
Record Return
</Button>
<Group gap="sm">
<Button
variant="subtle"
leftSection={<Download size={16} />}
onClick={() => downloadContainerReturnTemplate()}
>
Download Template
</Button>
<Button
variant="default"
leftSection={<Upload size={16} />}
onClick={() => setBulkModalOpen(true)}
>
Bulk Upload
</Button>
<Button onClick={() => setStandaloneModalOpen(true)}>Record Return</Button>
</Group>
</Group>
{plannedReturns.length > 0 && (
<Card withBorder radius="lg" p="md" mb="lg">
<Stack gap="md">
<Group justify="space-between" align="flex-start">
<div>
<Text fw={600}>Planned Empty Returns</Text>
<Text size="sm" c="dimmed">
Customers who paid for an empty return and booked a truck. Confirm the arrival
to record the containers.
</Text>
</div>
<Badge variant="light" size="lg" color="orange">
{plannedReturns.length} expected
</Badge>
</Group>
<Table.ScrollContainer minWidth={900}>
<Table highlightOnHover verticalSpacing="xs">
<Table.Thead>
<Table.Tr>
<Table.Th>Booking Ref</Table.Th>
<Table.Th>Company</Table.Th>
<Table.Th>Return Date</Table.Th>
<Table.Th>Truck</Table.Th>
<Table.Th>Containers</Table.Th>
<Table.Th ta="right">Action</Table.Th>
</Table.Tr>
</Table.Thead>
<Table.Tbody>
{plannedReturns.map((planned) => {
const outstanding = planned.containers.filter((c) => !c.returnId);
return (
<Table.Tr key={planned.requestId}>
<Table.Td>
<Text fw={600}>{planned.bookingReference ?? planned.bookingId}</Text>
</Table.Td>
<Table.Td>{planned.companyName ?? "—"}</Table.Td>
<Table.Td>{planned.requestedReturnDate ?? "—"}</Table.Td>
<Table.Td>
<Stack gap={2}>
<Text size="sm">{planned.truckPlateNumber ?? "—"}</Text>
<Text size="xs" c="dimmed">
{planned.truckDriverName ?? "—"}
{planned.truckType ? ` · ${planned.truckType}` : ""}
</Text>
</Stack>
</Table.Td>
<Table.Td>
<Stack gap={2}>
<Badge color={outstanding.length ? "orange" : "edr-green"}>
{outstanding.length} of {planned.containers.length} outstanding
</Badge>
<Text size="xs" c="dimmed" lineClamp={2}>
{planned.containers.map((c) => c.containerNumber).join(", ")}
</Text>
</Stack>
</Table.Td>
<Table.Td ta="right">
<Button
size="xs"
variant="light"
disabled={outstanding.length === 0}
onClick={() => setArrivingReturn(planned)}
>
Confirm Arrival
</Button>
</Table.Td>
</Table.Tr>
);
})}
</Table.Tbody>
</Table>
</Table.ScrollContainer>
</Stack>
</Card>
)}
<Card withBorder radius="lg" p="md" mb="lg">
<Stack gap="md">
<Group justify="space-between" align="flex-start">
<div>
<Text fw={600}>Bookings With Empty Container Return</Text>
<Text size="sm" c="dimmed">
Bookings that ship with equipment return and still owe empties. Open one to pick
the containers coming back, then record the return for that booking.
</Text>
</div>
<Badge variant="light" size="lg">
{emptyReturnBookings.length} booking{emptyReturnBookings.length !== 1 ? "s" : ""}
</Badge>
</Group>
<ListControls
search={bookingReturnControls.search}
onSearchChange={bookingReturnControls.setSearch}
searchPlaceholder="Search booking, company, status…"
dateFrom={bookingReturnControls.dateFrom}
onDateFromChange={bookingReturnControls.setDateFrom}
dateTo={bookingReturnControls.dateTo}
onDateToChange={bookingReturnControls.setDateTo}
showDateRange={false}
hasFilters={bookingReturnControls.hasFilters}
onReset={bookingReturnControls.reset}
/>
{emptyReturnBookingsQuery.isLoading ? (
<Group justify="center" py="md">
<Loader size="sm" />
</Group>
) : emptyReturnBookingsQuery.isError ? (
<Alert color="red">
Could not load bookings with empty container return.{" "}
{extractErrorMessage(emptyReturnBookingsQuery.error)}
</Alert>
) : bookingReturnControls.pagedRows.length === 0 ? (
<Alert color="gray">
No booking is waiting on an empty container return.
</Alert>
) : (
<>
<Table.ScrollContainer minWidth={900}>
<Table highlightOnHover verticalSpacing="xs">
<Table.Thead>
<Table.Tr>
<Table.Th w={40} />
<Table.Th>Booking Ref</Table.Th>
<Table.Th>Company</Table.Th>
<Table.Th>Booking Status</Table.Th>
<Table.Th>Containers To Return</Table.Th>
<Table.Th ta="right">Action</Table.Th>
</Table.Tr>
</Table.Thead>
<Table.Tbody>
{bookingReturnControls.pagedRows.map((booking) => {
const isOpen = expandedBooking === booking.bookingId;
return (
<Fragment key={booking.bookingId}>
<Table.Tr>
<Table.Td>
<ActionIcon
variant="subtle"
color="gray"
onClick={() =>
setExpandedBooking(isOpen ? null : booking.bookingId)
}
title={isOpen ? "Hide containers" : "Show containers"}
>
{isOpen ? <ChevronDown size={14} /> : <ChevronRight size={14} />}
</ActionIcon>
</Table.Td>
<Table.Td>
<Text fw={600}>{booking.bookingReference}</Text>
</Table.Td>
<Table.Td>{booking.companyName ?? "—"}</Table.Td>
<Table.Td>
<Badge size="sm" variant="light">
{booking.bookingStatus.replaceAll("_", " ")}
</Badge>
</Table.Td>
<Table.Td>
<Group gap="xs">
<Badge color="orange">{booking.pendingCount} pending</Badge>
{booking.recordedCount > 0 && (
<Badge color="edr-green" variant="light">
{booking.recordedCount} recorded
</Badge>
)}
</Group>
</Table.Td>
<Table.Td ta="right">
<Button
size="xs"
variant="light"
onClick={() => setEmptyReturnBooking(booking)}
>
Empty Container Return
</Button>
</Table.Td>
</Table.Tr>
{isOpen && (
<Table.Tr>
<Table.Td colSpan={6}>
<Table striped>
<Table.Thead>
<Table.Tr>
<Table.Th>Container</Table.Th>
<Table.Th>Size</Table.Th>
<Table.Th>Type</Table.Th>
<Table.Th>Return Status</Table.Th>
</Table.Tr>
</Table.Thead>
<Table.Tbody>
{booking.containers.map((container) => (
<Table.Tr key={container.key}>
<Table.Td>{container.containerNumber}</Table.Td>
<Table.Td>{container.containerSize ?? "—"}</Table.Td>
<Table.Td>{container.containerType ?? "—"}</Table.Td>
<Table.Td>
{container.returnStatus ? (
<Badge size="sm" color="edr-green" variant="light">
{RETURN_STATUS_LABEL[container.returnStatus] ??
container.returnStatus}
</Badge>
) : (
<Badge size="sm" color="orange" variant="light">
Awaiting return
</Badge>
)}
</Table.Td>
</Table.Tr>
))}
</Table.Tbody>
</Table>
</Table.Td>
</Table.Tr>
)}
</Fragment>
);
})}
</Table.Tbody>
</Table>
</Table.ScrollContainer>
<RuleEngineListFooter
pagination={bookingReturnControls.pagination}
pageCount={bookingReturnControls.pageCount}
totalCount={bookingReturnControls.totalCount}
itemLabel="bookings"
onPaginationChange={bookingReturnControls.setPagination}
/>
</>
)}
</Stack>
</Card>
{returnedContainers.length > 0 && (
<Card withBorder radius="lg" p="md" mb="lg">
<Stack gap="md">
@@ -688,6 +1013,37 @@ export default function ContainerReturnsPage() {
loading={createReturnsMutation.isPending}
/>
<BookingEmptyReturnModal
booking={emptyReturnBooking}
onClose={() => setEmptyReturnBooking(null)}
onSubmit={(payload) => createReturnsMutation.mutate(payload)}
loading={createReturnsMutation.isPending}
/>
{/* A scheduled return arrives on the customer's own truck, so the modal
opens pre-set to self-haul with that truck already noted. */}
<BookingEmptyReturnModal
title="Confirm Empty Return Arrival"
booking={arrivingReturn ? plannedAsBooking(arrivingReturn) : null}
onClose={() => setArrivingReturn(null)}
onSubmit={(payload) => createReturnsMutation.mutate(payload)}
loading={createReturnsMutation.isPending}
defaultReturnedBy="CUSTOMER"
defaultHandoverNote={
arrivingReturn
? `Scheduled empty return · truck ${arrivingReturn.truckPlateNumber ?? "—"}${
arrivingReturn.truckDriverName ? ` · driver ${arrivingReturn.truckDriverName}` : ""
}`
: undefined
}
/>
<BulkContainerReturnModal
opened={bulkModalOpen}
onClose={() => setBulkModalOpen(false)}
onUploaded={() => qc.invalidateQueries({ queryKey: ["empty-container-returns"] })}
/>
<ExportTrainAllocationModal
row={allocateRow}
onClose={() => setAllocateRow(null)}
@@ -848,7 +1204,7 @@ interface ContainerReturnModalProps {
function ContainerReturnModal({ opened, onClose, group, onSubmit, loading }: ContainerReturnModalProps) {
const [selectedContainers, setSelectedContainers] = useState<string[]>([]);
const [returnDate, setReturnDate] = useState<string>(new Date().toISOString().split("T")[0]);
const [returnDate, setReturnDate] = useState<string>(localNowForInput());
const [warehouse, setWarehouse] = useState<string | null>(null);
const [condition, setCondition] = useState<string>("");
const [handoverNote, setHandoverNote] = useState<string>("");
@@ -940,13 +1296,20 @@ function ContainerReturnModal({ opened, onClose, group, onSubmit, loading }: Con
searchable
/>
<input
type="date"
value={returnDate}
onChange={(e) => setReturnDate(e.target.value)}
style={{ padding: "8px", borderRadius: "4px", border: "1px solid #ccc" }}
required
/>
<Input.Wrapper label="Returned Date & Time" required>
<input
type="datetime-local"
value={returnDate}
onChange={(e) => setReturnDate(e.target.value)}
style={{
padding: "8px",
borderRadius: "4px",
border: "1px solid #ced4da",
width: "100%",
}}
required
/>
</Input.Wrapper>
<Textarea
label="Condition"
@@ -982,6 +1345,305 @@ function ContainerReturnModal({ opened, onClose, group, onSubmit, loading }: Con
);
}
interface BookingEmptyReturnModalProps {
booking: EmptyReturnBooking | null;
onClose: () => void;
onSubmit: (payload: any) => void;
loading: boolean;
/** Pre-set for a scheduled return, where the truck type is already known. */
defaultReturnedBy?: "EDR" | "CUSTOMER";
/** Pre-set for a scheduled return — the truck the customer told us about. */
defaultHandoverNote?: string;
title?: string;
}
/**
* Records the empty return for ONE booking: tick the containers coming back,
* say where they landed, and every tick becomes an empty container return on
* that booking. Containers whose return is already recorded stay visible but
* cannot be ticked again. A legacy booking that never captured container
* numbers shows numberless slots — the number is typed here instead.
*/
function BookingEmptyReturnModal({
booking,
onClose,
onSubmit,
loading,
defaultReturnedBy,
defaultHandoverNote,
title = "Empty Container Return",
}: BookingEmptyReturnModalProps) {
const [selected, setSelected] = useState<string[]>([]);
const [returnedBy, setReturnedBy] = useState<"EDR" | "CUSTOMER" | null>(null);
const [returnDate, setReturnDate] = useState<string>(localNowForInput());
const [warehouse, setWarehouse] = useState<string | null>(null);
const [yardId, setYardId] = useState<string | null>(null);
const [zoneId, setZoneId] = useState<string | null>(null);
const [condition, setCondition] = useState<string>("");
const [handoverNote, setHandoverNote] = useState<string>("");
const bookingId = booking?.bookingId ?? null;
// A fresh booking starts from a clean form — never inherit the last one's
// ticks, typed numbers, or placement.
useEffect(() => {
setSelected([]);
setReturnedBy(defaultReturnedBy ?? null);
setReturnDate(localNowForInput());
setWarehouse(null);
setYardId(null);
setZoneId(null);
setCondition("");
setHandoverNote(defaultHandoverNote ?? "");
}, [bookingId, defaultReturnedBy, defaultHandoverNote]);
const { data: warehousesResponse } = useQuery({
queryKey: ["warehouses-list"],
queryFn: async () => {
return await warehouseService.list({});
},
});
const warehouses = (warehousesResponse as any)?.data ?? warehousesResponse ?? [];
const { data: yards } = useWarehouseYards(warehouse ?? undefined);
const { data: zones } = useWarehouseZones(yardId ?? undefined);
useEffect(() => {
setYardId(null);
setZoneId(null);
}, [warehouse]);
useEffect(() => {
setZoneId(null);
}, [yardId]);
const warehouseOptions = Array.isArray(warehouses)
? warehouses.map((wh: any) => ({
value: wh.id,
label: `${wh.name} ${wh.code ? `(${wh.code})` : ""}`,
}))
: [];
const yardOptions = (yards ?? [])
.filter((y) => y.status === "ACTIVE")
.map((y) => ({ value: y.id, label: `${y.name} (${y.code})` }));
const zoneOptions = (zones ?? [])
.filter((z) => z.status === "ACTIVE")
.map((z) => ({ value: z.id, label: `${z.name} (${z.code})` }));
const pending = (booking?.containers ?? []).filter((container) => !container.returnId);
const toggle = (key: string, checked: boolean) =>
setSelected((current) => (checked ? [...current, key] : current.filter((k) => k !== key)));
const handleSubmit = () => {
if (!booking || !selected.length || !warehouse || !returnedBy) return;
const selectedWarehouse = Array.isArray(warehouses)
? warehouses.find((wh: any) => wh.id === warehouse)
: null;
const selectedYard = yards?.find((y) => y.id === yardId);
const selectedZone = zones?.find((z) => z.id === zoneId);
const containers = pending
.filter((container) => selected.includes(container.key))
.map((container) => ({
containerNumber: container.containerNumber,
// The booking records "20ft"/"40ft"; the wagon rule only needs the number.
containerSize: container.containerSize?.includes("40")
? ("40" as const)
: container.containerSize?.includes("20")
? ("20" as const)
: undefined,
returnDate,
warehouse: selectedWarehouse?.name || warehouse,
yard: selectedYard?.name,
zone: selectedZone?.name,
condition: condition || undefined,
handoverNote: handoverNote || undefined,
}));
onSubmit({
trucks: [
{
bookingId: booking.bookingId,
customerId: booking.customerId,
companyName: booking.companyName ?? undefined,
returnType: returnedBy,
containers,
},
],
});
};
return (
<Modal opened={!!booking} onClose={onClose} title={title} size="lg">
{booking && (
<Stack gap="md">
<Group gap="sm">
<Text fw={600}>{booking.bookingReference}</Text>
{booking.companyName && <Text c="dimmed">{booking.companyName}</Text>}
<Badge size="sm" variant="light">
{booking.bookingStatus.replaceAll("_", " ")}
</Badge>
</Group>
<div>
<Group justify="space-between" mb="xs">
<Text size="sm" fw={600}>
Containers to return
</Text>
<Button
size="compact-xs"
variant="subtle"
onClick={() =>
setSelected(
selected.length === pending.length ? [] : pending.map((c) => c.key),
)
}
>
{selected.length === pending.length ? "Clear all" : "Select all"}
</Button>
</Group>
<Table>
<Table.Thead>
<Table.Tr>
<Table.Th w={40} />
<Table.Th>Container</Table.Th>
<Table.Th w={90}>Size</Table.Th>
<Table.Th w={150}>Status</Table.Th>
</Table.Tr>
</Table.Thead>
<Table.Tbody>
{booking.containers.map((container) => {
const recorded = Boolean(container.returnId);
return (
<Table.Tr key={container.key}>
<Table.Td>
<Checkbox
checked={selected.includes(container.key)}
disabled={recorded}
onChange={(e) => toggle(container.key, e.currentTarget.checked)}
/>
</Table.Td>
<Table.Td>
<Text size="sm">{container.containerNumber}</Text>
</Table.Td>
<Table.Td>{container.containerSize ?? "—"}</Table.Td>
<Table.Td>
{recorded ? (
<Badge size="sm" color="edr-green" variant="light">
{(container.returnStatus &&
RETURN_STATUS_LABEL[container.returnStatus]) ??
"Recorded"}
</Badge>
) : (
<Badge size="sm" color="orange" variant="light">
Awaiting return
</Badge>
)}
</Table.Td>
</Table.Tr>
);
})}
</Table.Tbody>
</Table>
</div>
<Select
label="Returned By"
placeholder="Select truck type"
value={returnedBy}
onChange={(val) => setReturnedBy(val as "EDR" | "CUSTOMER" | null)}
data={[
{ value: "EDR", label: "EDR Last Mile" },
{ value: "CUSTOMER", label: "Customer Self-Haul" },
]}
required
/>
<Select
label="Return Warehouse"
placeholder="Select warehouse for container return"
value={warehouse}
onChange={setWarehouse}
data={warehouseOptions}
required
searchable
/>
<Select
label="Yard"
placeholder={warehouse ? "Select yard" : "Select warehouse first"}
value={yardId}
onChange={setYardId}
data={yardOptions}
disabled={!warehouse}
searchable
/>
<Select
label="Zone"
placeholder={yardId ? "Select zone" : "Select yard first"}
value={zoneId}
onChange={setZoneId}
data={zoneOptions}
disabled={!yardId}
searchable
/>
<Input.Wrapper label="Returned Date & Time" required>
<input
type="datetime-local"
value={returnDate}
onChange={(e) => setReturnDate(e.target.value)}
style={{
padding: "8px",
borderRadius: "4px",
border: "1px solid #ced4da",
width: "100%",
}}
required
/>
</Input.Wrapper>
<Textarea
label="Condition"
placeholder="Damage, residue, or cleanliness notes"
value={condition}
onChange={(e) => setCondition(e.currentTarget.value)}
rows={3}
/>
<Textarea
label="Handover Note"
placeholder="Consignee, trucker, or authorization notes"
value={handoverNote}
onChange={(e) => setHandoverNote(e.currentTarget.value)}
rows={3}
/>
<Group justify="flex-end" gap="sm">
<Button variant="default" onClick={onClose} disabled={loading}>
Cancel
</Button>
<Button
onClick={handleSubmit}
disabled={!selected.length || !warehouse || !returnedBy}
loading={loading}
>
Record Empty Return ({selected.length})
</Button>
</Group>
</Stack>
)}
</Modal>
);
}
interface StandaloneReturnModalProps {
opened: boolean;
onClose: () => void;
@@ -991,9 +1653,10 @@ interface StandaloneReturnModalProps {
function StandaloneReturnModal({ opened, onClose, onSubmit, loading }: StandaloneReturnModalProps) {
const [containerNumber, setContainerNumber] = useState<string>("");
const [company, setCompany] = useState<string>("");
const [containerSize, setContainerSize] = useState<EmptyContainerSize | null>(null);
const [returnedBy, setReturnedBy] = useState<"EDR" | "CUSTOMER" | null>(null);
const [returnDate, setReturnDate] = useState<string>(new Date().toISOString().split("T")[0]);
const [returnDate, setReturnDate] = useState<string>(localNowForInput());
const [warehouse, setWarehouse] = useState<string | null>(null);
const [yardId, setYardId] = useState<string | null>(null);
const [zoneId, setZoneId] = useState<string | null>(null);
@@ -1009,6 +1672,7 @@ function StandaloneReturnModal({ opened, onClose, onSubmit, loading }: Standalon
const warehouses = (warehousesResponse as any)?.data ?? warehousesResponse ?? [];
const companies = useCompanyOptions();
const { data: yards } = useWarehouseYards(warehouse ?? undefined);
const { data: zones } = useWarehouseZones(yardId ?? undefined);
@@ -1047,7 +1711,8 @@ function StandaloneReturnModal({ opened, onClose, onSubmit, loading }: Standalon
trucks: [
{
bookingId: null,
customerId: null,
customerId: company ? (companies.resolveId(company) ?? null) : null,
companyName: company || undefined,
returnType: returnedBy,
containers: [
{
@@ -1066,9 +1731,10 @@ function StandaloneReturnModal({ opened, onClose, onSubmit, loading }: Standalon
});
setContainerNumber("");
setCompany("");
setContainerSize(null);
setReturnedBy(null);
setReturnDate(new Date().toISOString().split("T")[0]);
setReturnDate(localNowForInput());
setWarehouse(null);
setYardId(null);
setZoneId(null);
@@ -1092,6 +1758,16 @@ function StandaloneReturnModal({ opened, onClose, onSubmit, loading }: Standalon
required
/>
<Autocomplete
label="Company"
description="Pick a registered customer, or type a company that is not on the system yet"
placeholder={companies.loading ? "Loading companies…" : "Search or type a company"}
data={companies.names}
value={company}
onChange={setCompany}
limit={20}
/>
<Select
label="Returned By"
placeholder="Select truck type"
@@ -1145,13 +1821,20 @@ function StandaloneReturnModal({ opened, onClose, onSubmit, loading }: Standalon
searchable
/>
<input
type="date"
value={returnDate}
onChange={(e) => setReturnDate(e.target.value)}
style={{ padding: "8px", borderRadius: "4px", border: "1px solid #ccc" }}
required
/>
<Input.Wrapper label="Returned Date & Time" required>
<input
type="datetime-local"
value={returnDate}
onChange={(e) => setReturnDate(e.target.value)}
style={{
padding: "8px",
borderRadius: "4px",
border: "1px solid #ced4da",
width: "100%",
}}
required
/>
</Input.Wrapper>
<Textarea
label="Condition"

View File

@@ -0,0 +1,497 @@
import { useEffect, useMemo, useState } from "react";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import {
Alert,
Badge,
Button,
Card,
Divider,
Group,
Loader,
Modal,
NumberInput,
Select,
SimpleGrid,
Stack,
Text,
Textarea,
} from "@mantine/core";
import { DataTable, type ColumnDef } from "@edr/ui-common";
import { useAuth } from "@/auth/useAuth";
import { FREIGHT_PERMS, hasPermission } from "@/lib/permissions";
import { PageContainer, PageHeader } from "@/components/page";
import ListControls from "@/components/common/ListControls";
import { extractErrorMessage } from "@/components/warehouses/options";
import { useListControls } from "@/hooks/useListControls";
import { useToast } from "@/hooks/use-toast";
import { emptyReturnRequestsService } from "@/services/emptyReturnRequests.service";
import type {
EmptyReturnRequest,
EmptyReturnRequestStatus,
} from "@/types/importOperations";
import { formatDateTime } from "@/lib/format";
const STATUS_META: Record<EmptyReturnRequestStatus, { label: string; color: string }> = {
SUBMITTED: { label: "Awaiting review", color: "orange" },
APPROVED: { label: "Awaiting payment", color: "yellow" },
REJECTED: { label: "Rejected", color: "red" },
PAID: { label: "Paid — awaiting date", color: "blue" },
SCHEDULED: { label: "Scheduled", color: "edr-green" },
COMPLETED: { label: "Returned", color: "gray" },
CANCELLED: { label: "Cancelled", color: "gray" },
};
const money = (amount: number | null | undefined, currency: string | null | undefined) =>
amount == null
? "—"
: `${Number(amount).toLocaleString(undefined, { minimumFractionDigits: 2 })} ${currency ?? ""}`.trim();
/**
* The queue for customer-initiated empty container returns: a booking sold
* WITHOUT the return service, whose customer now wants to send the empties
* back. Staff price and approve — which invoices the customer — or reject with
* a reason. Everything after payment (date, truck) happens in the portal, and
* the containers themselves are recorded on Container Returns.
*/
export default function EmptyReturnRequestsPage() {
const { toast } = useToast();
const qc = useQueryClient();
const { user } = useAuth();
// Anyone who runs container returns can watch the queue; pricing and
// approving is its own permission, so show the buttons disabled rather than
// letting them fire into a 403.
const canReview = hasPermission(user, FREIGHT_PERMS.emptyReturnRequests.review);
const [statusFilter, setStatusFilter] = useState<string | null>(null);
const [approving, setApproving] = useState<EmptyReturnRequest | null>(null);
const [rejecting, setRejecting] = useState<EmptyReturnRequest | null>(null);
const requestsQuery = useQuery({
queryKey: ["empty-return-requests"],
queryFn: () => emptyReturnRequestsService.list(),
});
const requests = useMemo(() => {
const rows = requestsQuery.data ?? [];
return statusFilter ? rows.filter((row) => row.status === statusFilter) : rows;
}, [requestsQuery.data, statusFilter]);
const controls = useListControls(requests, {
dateKey: "submittedAt",
searchValue: (row) =>
`${row.bookingReference ?? ""} ${row.companyName ?? ""} ${row.containerNumbers.join(" ")}`,
});
const invalidate = () => {
qc.invalidateQueries({ queryKey: ["empty-return-requests"] });
qc.invalidateQueries({ queryKey: ["planned-empty-returns"] });
};
const approveMutation = useMutation({
mutationFn: ({ id, unitAmount }: { id: string; unitAmount?: number }) =>
emptyReturnRequestsService.approve(id, { unitAmount }),
onSuccess: () => {
toast({ title: "Approved — invoice sent to the customer" });
invalidate();
setApproving(null);
},
onError: (error: unknown) => {
toast({
variant: "destructive",
title: "Could not approve the request",
description: extractErrorMessage(error),
});
},
});
const rejectMutation = useMutation({
mutationFn: ({ id, reason }: { id: string; reason: string }) =>
emptyReturnRequestsService.reject(id, reason),
onSuccess: () => {
toast({ title: "Request rejected" });
invalidate();
setRejecting(null);
},
onError: (error: unknown) => {
toast({
variant: "destructive",
title: "Could not reject the request",
description: extractErrorMessage(error),
});
},
});
const columns: ColumnDef<EmptyReturnRequest>[] = [
{
id: "booking",
header: "Booking",
cell: ({ row }) => (
<Stack gap={2}>
<Text fw={600} size="sm">
{row.original.bookingReference ?? row.original.bookingId}
</Text>
<Text size="xs" c="dimmed">
{row.original.companyName ?? "—"}
</Text>
</Stack>
),
},
{
id: "containers",
header: "Containers",
cell: ({ row }) => (
<Stack gap={2}>
<Badge size="sm">{row.original.containerCount}</Badge>
<Text size="xs" c="dimmed" lineClamp={2}>
{row.original.containerNumbers.join(", ")}
</Text>
</Stack>
),
},
{
id: "submittedAt",
header: "Requested",
cell: ({ row }) => formatDateTime(row.original.submittedAt),
},
{
id: "price",
header: "Price",
cell: ({ row }) =>
row.original.quotedTotalAmount == null ? (
"—"
) : (
<Stack gap={2}>
<Text size="sm" fw={600}>
{money(row.original.quotedTotalAmount, row.original.currency)}
</Text>
<Text size="xs" c="dimmed">
{money(row.original.quotedUnitAmount, row.original.currency)} × {row.original.containerCount}
</Text>
</Stack>
),
},
{
id: "return",
header: "Return",
cell: ({ row }) =>
row.original.requestedReturnDate ? (
<Stack gap={2}>
<Text size="sm">{row.original.requestedReturnDate}</Text>
<Text size="xs" c="dimmed">
{row.original.truckPlateNumber ?? "—"}
{row.original.truckDriverName ? ` · ${row.original.truckDriverName}` : ""}
</Text>
</Stack>
) : (
"—"
),
},
{
id: "status",
header: "Status",
cell: ({ row }) => {
const meta = STATUS_META[row.original.status];
return (
<Stack gap={2}>
<Badge size="sm" color={meta?.color ?? "gray"} variant="light">
{meta?.label ?? row.original.status}
</Badge>
{row.original.rejectionReason && (
<Text size="xs" c="dimmed" lineClamp={2}>
{row.original.rejectionReason}
</Text>
)}
</Stack>
);
},
},
{
id: "action",
header: "Action",
cell: ({ row }) =>
row.original.status === "SUBMITTED" ? (
<Group gap="xs" justify="flex-end" wrap="nowrap">
<Button
size="xs"
variant="subtle"
color="red"
disabled={!canReview}
title={canReview ? undefined : "You do not have permission to review these requests"}
onClick={() => setRejecting(row.original)}
>
Reject
</Button>
<Button
size="xs"
variant="light"
disabled={!canReview}
title={canReview ? undefined : "You do not have permission to review these requests"}
onClick={() => setApproving(row.original)}
>
Approve
</Button>
</Group>
) : (
<Text size="xs" c="dimmed" ta="right">
{row.original.status === "APPROVED" ? "Awaiting customer payment" : "No action"}
</Text>
),
},
];
const pending = (requestsQuery.data ?? []).filter((row) => row.status === "SUBMITTED").length;
return (
<PageContainer>
<PageHeader
title="Empty Return Requests"
subtitle="Customers asking to send empty containers back on bookings sold without equipment return"
/>
<Card withBorder radius="lg" p="md">
<Stack gap="md">
<Group justify="space-between">
<Text fw={600}>
Requests
{pending > 0 && (
<Badge ml="sm" color="orange" variant="light">
{pending} awaiting review
</Badge>
)}
</Text>
</Group>
<ListControls
search={controls.search}
onSearchChange={controls.setSearch}
searchPlaceholder="Search booking, company, container…"
dateFrom={controls.dateFrom}
onDateFromChange={controls.setDateFrom}
dateTo={controls.dateTo}
onDateToChange={controls.setDateTo}
dateLabel="Requested"
hasFilters={controls.hasFilters || Boolean(statusFilter)}
onReset={() => {
controls.reset();
setStatusFilter(null);
}}
>
<Select
placeholder="Status"
value={statusFilter}
onChange={setStatusFilter}
data={Object.entries(STATUS_META).map(([value, meta]) => ({
value,
label: meta.label,
}))}
clearable
w={220}
/>
</ListControls>
{requestsQuery.isLoading ? (
<Group justify="center" py="lg">
<Loader size="sm" />
</Group>
) : requestsQuery.isError ? (
<Alert color="red">
Could not load empty return requests. {extractErrorMessage(requestsQuery.error)}
</Alert>
) : controls.pagedRows.length === 0 ? (
<Alert color="gray">No empty return requests.</Alert>
) : (
<DataTable
columns={columns}
data={controls.pagedRows}
containerClassName="border-0 shadow-none"
{...controls.tableProps}
/>
)}
</Stack>
</Card>
<ApproveModal
request={approving}
onClose={() => setApproving(null)}
onApprove={(unitAmount) =>
approving && approveMutation.mutate({ id: approving.id, unitAmount })
}
loading={approveMutation.isPending}
/>
<Modal
opened={!!rejecting}
onClose={() => setRejecting(null)}
title="Reject empty return request"
size="md"
>
{rejecting && (
<RejectForm
request={rejecting}
loading={rejectMutation.isPending}
onCancel={() => setRejecting(null)}
onReject={(reason) => rejectMutation.mutate({ id: rejecting.id, reason })}
/>
)}
</Modal>
</PageContainer>
);
}
/**
* The pricing step. The per-container price is prefilled from the booking's
* route WITH_RETURN rate; the reviewer can override it before approving, and
* approving is what issues the customer's invoice.
*/
function ApproveModal({
request,
onClose,
onApprove,
loading,
}: {
request: EmptyReturnRequest | null;
onClose: () => void;
onApprove: (unitAmount?: number) => void;
loading: boolean;
}) {
const [unitAmount, setUnitAmount] = useState<number | "">("");
const quoteQuery = useQuery({
queryKey: ["empty-return-quote", request?.bookingId],
queryFn: () => emptyReturnRequestsService.quote(request!.bookingId),
enabled: Boolean(request),
});
// Prefill from the route rate as soon as it lands, and start clean whenever
// a different request is opened.
useEffect(() => {
setUnitAmount(quoteQuery.data?.unitAmount ?? "");
}, [quoteQuery.data?.unitAmount, request?.id]);
const count = request?.containerCount ?? 0;
const total = typeof unitAmount === "number" ? unitAmount * count : null;
const currency = quoteQuery.data?.currency ?? "ETB";
return (
<Modal opened={!!request} onClose={onClose} title="Approve empty return" size="md">
{request && (
<Stack gap="md">
<Group gap="sm">
<Text fw={600}>{request.bookingReference ?? request.bookingId}</Text>
<Text c="dimmed">{request.companyName ?? "—"}</Text>
</Group>
<div>
<Text size="sm" fw={600} mb={4}>
Containers coming back
</Text>
<Text size="sm" c="dimmed">
{request.containerNumbers.join(", ")}
</Text>
</div>
{quoteQuery.isLoading ? (
<Group justify="center" py="sm">
<Loader size="sm" />
</Group>
) : (
<>
{quoteQuery.data?.unavailableReason && (
<Alert color="yellow">{quoteQuery.data.unavailableReason}</Alert>
)}
<NumberInput
label={`Price per container (${currency})`}
description={
quoteQuery.data?.sourceRateUsd
? `Contract route rate: ${quoteQuery.data.sourceRateUsd} USD per container`
: "No route rate found — enter the amount to bill."
}
value={unitAmount}
onChange={(value) =>
setUnitAmount(typeof value === "number" ? value : value === "" ? "" : Number(value))
}
min={0}
decimalScale={2}
thousandSeparator=","
required
/>
<Divider />
<SimpleGrid cols={2}>
<Text size="sm" c="dimmed">
{count} container{count === 1 ? "" : "s"} ×{" "}
{typeof unitAmount === "number" ? unitAmount.toLocaleString() : "—"}
</Text>
<Text size="lg" fw={700} ta="right">
{total == null
? "—"
: `${total.toLocaleString(undefined, { minimumFractionDigits: 2 })} ${currency}`}
</Text>
</SimpleGrid>
<Text size="xs" c="dimmed">
Approving issues this invoice to the customer. They pay it in the portal, then
choose the return date and give the truck details.
</Text>
</>
)}
<Group justify="flex-end" gap="sm">
<Button variant="default" onClick={onClose} disabled={loading}>
Cancel
</Button>
<Button
onClick={() => onApprove(typeof unitAmount === "number" ? unitAmount : undefined)}
disabled={typeof unitAmount !== "number" || unitAmount <= 0}
loading={loading}
>
Approve &amp; invoice
</Button>
</Group>
</Stack>
)}
</Modal>
);
}
function RejectForm({
request,
loading,
onCancel,
onReject,
}: {
request: EmptyReturnRequest;
loading: boolean;
onCancel: () => void;
onReject: (reason: string) => void;
}) {
const [reason, setReason] = useState("");
return (
<Stack gap="md">
<Text size="sm">
{request.bookingReference ?? request.bookingId} {request.containerCount} container
{request.containerCount === 1 ? "" : "s"}
</Text>
<Textarea
label="Reason"
description="Shown to the customer."
placeholder="Why this return cannot be accepted"
value={reason}
onChange={(event) => setReason(event.currentTarget.value)}
rows={3}
required
/>
<Group justify="flex-end" gap="sm">
<Button variant="default" onClick={onCancel} disabled={loading}>
Cancel
</Button>
<Button color="red" onClick={() => onReject(reason.trim())} disabled={reason.trim().length < 3} loading={loading}>
Reject request
</Button>
</Group>
</Stack>
);
}

View File

@@ -205,7 +205,7 @@ function Rows({ rows }: { rows: IntercityRideAlongRow[] }) {
<Table.Td>
<Group gap="xs" justify="flex-end" wrap="nowrap">
{/* Work the cargo right here while the train is at the yard. */}
{r.trainScheduleId && atOrigin(r) && isWaiting(r) && r.status === "PAID" && (
{r.trainScheduleId && atOrigin(r) && isWaiting(r) && r.paymentStatus === "PAID" && (
<Tooltip label={canLoad ? undefined : "You don't have permission to load cargo"} disabled={canLoad}>
<Button
size="compact-xs"

View File

@@ -18,7 +18,9 @@ import { api } from '@/services/api';
import { useToast } from '@/hooks/use-toast';
import type { WarehouseInventoryItem } from '@/types/warehouse';
const isPaid = (item: WarehouseInventoryItem) => item.booking?.status === 'PAID';
// "Paid" is the booking's PAYMENT status only — never booking.status === 'PAID'.
const isPaid = (item: WarehouseInventoryItem) =>
(item.booking?.paymentStatus ?? item.bookingPaymentStatus) === 'PAID';
/**
* Loading Queue — manage inventory through the loading workflow.

View File

@@ -0,0 +1,442 @@
import { useEffect, useMemo, useState } from "react";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import {
Alert,
Autocomplete,
Badge,
Button,
Card,
Group,
Input,
List,
NumberInput,
ScrollArea,
SegmentedControl,
Select,
Stack,
Table,
Text,
TextInput,
Textarea,
} from "@mantine/core";
import { Download, Upload } from "lucide-react";
import { PageContainer, PageHeader } from "@/components/page";
import { useCompanyOptions } from "@/components/warehouses/useCompanyOptions";
import {
downloadFullContainerTemplate,
parseFullContainerExcel,
type ParsedFullContainerRow,
} from "@/components/warehouses/full-container-excel";
import { useToast } from "@/hooks/use-toast";
import { useWarehouseYards, useWarehouseZones } from "@/hooks/useWarehouses";
import { containerTypesService } from "@/services/container-types.service";
import { warehouseService } from "@/services/warehouse.service";
import type { RegisterBacklogContainerPayload } from "@/types/warehouse";
/** `YYYY-MM-DD` for today — the latest arrival a backlog box can claim. */
function todayForInput(): string {
const d = new Date();
d.setMinutes(d.getMinutes() - d.getTimezoneOffset());
return d.toISOString().slice(0, 10);
}
/**
* Loaded containers that have been sitting in a yard since before the system
* knew about them. Registering one records its true arrival date without
* billing storage for the history — the server flags the row so the fee engine
* skips it entirely.
*/
export default function RegisterFullContainersPage() {
const { toast } = useToast();
const qc = useQueryClient();
const companies = useCompanyOptions();
const [mode, setMode] = useState<"single" | "bulk">("single");
// Location + owner, shared by both modes. In bulk they are the defaults that
// fill any blank cell in the sheet.
const [company, setCompany] = useState("");
const [warehouseId, setWarehouseId] = useState<string | null>(null);
const [yardId, setYardId] = useState<string | null>(null);
const [zoneId, setZoneId] = useState<string | null>(null);
const [arrivedAt, setArrivedAt] = useState(todayForInput());
const [containerTypeId, setContainerTypeId] = useState<string | null>(null);
// Single-container fields.
const [containerNumber, setContainerNumber] = useState("");
const [sealNumber, setSealNumber] = useState("");
const [weight, setWeight] = useState<number | string>("");
const [notes, setNotes] = useState("");
// Bulk fields.
const [file, setFile] = useState<File | null>(null);
const [rows, setRows] = useState<ParsedFullContainerRow[]>([]);
const [parseErrors, setParseErrors] = useState<string[]>([]);
const { data: warehousesResponse } = useQuery({
queryKey: ["warehouses-list"],
queryFn: () => warehouseService.list({}),
});
const warehouses = ((warehousesResponse as any)?.data ?? warehousesResponse ?? []) as any[];
const { data: yards } = useWarehouseYards(warehouseId ?? undefined);
const { data: zones } = useWarehouseZones(yardId ?? undefined);
const { data: containerTypes = [] } = useQuery({
queryKey: ["container-types-active"],
queryFn: () => containerTypesService.getContainerTypes(),
staleTime: 5 * 60 * 1000,
});
useEffect(() => {
setYardId(null);
setZoneId(null);
}, [warehouseId]);
useEffect(() => setZoneId(null), [yardId]);
const warehouseOptions = Array.isArray(warehouses)
? warehouses.map((wh) => ({ value: wh.id, label: wh.code ? `${wh.name} (${wh.code})` : wh.name }))
: [];
const yardOptions = (yards ?? [])
.filter((y) => y.status === "ACTIVE")
.map((y) => ({ value: y.id, label: `${y.name} (${y.code})` }));
const zoneOptions = (zones ?? [])
.filter((z) => z.status === "ACTIVE")
.map((z) => ({ value: z.id, label: `${z.name} (${z.code})` }));
const containerTypeOptions = (containerTypes as any[]).map((ct) => ({
value: ct.id,
label: ct.label ? `${ct.label} (${ct.code})` : ct.code,
}));
const locationReady = Boolean(warehouseId && yardId && zoneId);
const basePayload = useMemo(
() => ({
warehouseId: warehouseId ?? "",
yardId: yardId ?? "",
zoneId: zoneId ?? "",
companyId: company ? companies.resolveId(company) : undefined,
companyName: company || undefined,
}),
[warehouseId, yardId, zoneId, company, companies],
);
const onSaved = (count: number) => {
toast({ title: `${count} container${count === 1 ? "" : "s"} registered` });
qc.invalidateQueries({ queryKey: ["warehouse-inventory"] });
};
const singleMutation = useMutation({
mutationFn: () =>
warehouseService.registerBacklogContainer({
...basePayload,
containerNumber: containerNumber.trim().toUpperCase(),
containerTypeId: containerTypeId ?? "",
arrivedAt: new Date(arrivedAt).toISOString(),
sealNumber: sealNumber.trim() || undefined,
weight: weight === "" ? undefined : Number(weight),
notes: notes.trim() || undefined,
}),
onSuccess: () => {
onSaved(1);
setContainerNumber("");
setSealNumber("");
setWeight("");
setNotes("");
},
onError: (error: any) =>
toast({
variant: "destructive",
title: "Could not register container",
description: error?.response?.data?.message || error?.message,
}),
});
// Row cell wins; the fields above the file fill the blanks.
const toPayload = (row: ParsedFullContainerRow): RegisterBacklogContainerPayload => ({
...basePayload,
companyName: row.companyName || basePayload.companyName,
companyId: row.companyName ? companies.resolveId(row.companyName) : basePayload.companyId,
containerNumber: row.containerNumber,
containerTypeId: containerTypeId ?? "",
arrivedAt: row.arrivedAt ?? new Date(arrivedAt).toISOString(),
sealNumber: row.sealNumber || undefined,
weight: row.weight === "" ? undefined : Number(row.weight),
notes: row.notes || undefined,
});
const bulkMutation = useMutation({
mutationFn: () => warehouseService.registerBacklogContainersBulk(rows.map(toPayload)),
onSuccess: (response) => {
onSaved(response.data?.length ?? rows.length);
setFile(null);
setRows([]);
setParseErrors([]);
},
onError: (error: any) =>
toast({
variant: "destructive",
title: "Bulk registration failed",
description: error?.response?.data?.message || error?.message,
}),
});
const handleFile = async (next: File | null) => {
setFile(next);
setRows([]);
setParseErrors([]);
if (!next) return;
const result = await parseFullContainerExcel(next);
setRows(result.rows);
setParseErrors(result.errors);
};
const singleReady =
locationReady && Boolean(containerTypeId) && containerNumber.trim().length > 0 && Boolean(arrivedAt);
const bulkReady = locationReady && Boolean(containerTypeId) && rows.length > 0;
return (
<PageContainer>
<PageHeader
title="Register Full Containers"
subtitle="Loaded containers already in the yard but not yet on the system"
/>
<Group mb="lg" justify="space-between">
<SegmentedControl
value={mode}
onChange={(v) => setMode(v as "single" | "bulk")}
data={[
{ label: "Single Container", value: "single" },
{ label: "Bulk Upload", value: "bulk" },
]}
/>
<Button
variant="subtle"
leftSection={<Download size={16} />}
onClick={() => downloadFullContainerTemplate()}
>
Download Template
</Button>
</Group>
<Alert color="blue" mb="lg" title="Backlog registrations are not billed">
The arrival date you enter is kept as the real record of how long the box has been here,
but no storage or demurrage accrues against it.
</Alert>
<Card withBorder radius="lg" p="md">
<Stack gap="md">
<Group grow align="flex-start">
<Autocomplete
label="Company"
description="Pick a registered customer, or type a company that is not on the system yet"
placeholder={companies.loading ? "Loading companies…" : "Search or type a company"}
data={companies.names}
value={company}
onChange={setCompany}
limit={20}
/>
<Select
label="Container Type"
placeholder="Select container type"
value={containerTypeId}
onChange={setContainerTypeId}
data={containerTypeOptions}
searchable
required
/>
</Group>
<Group grow align="flex-start">
<Select
label="Warehouse"
placeholder="Select warehouse"
value={warehouseId}
onChange={setWarehouseId}
data={warehouseOptions}
searchable
required
/>
<Select
label="Yard"
placeholder={warehouseId ? "Select yard" : "Select warehouse first"}
value={yardId}
onChange={setYardId}
data={yardOptions}
disabled={!warehouseId}
searchable
required
/>
<Select
label="Zone"
placeholder={yardId ? "Select zone" : "Select yard first"}
value={zoneId}
onChange={setZoneId}
data={zoneOptions}
disabled={!yardId}
searchable
required
/>
</Group>
<Input.Wrapper
label="Arrival Date"
description={
mode === "bulk"
? "Used for any row whose sheet cell is blank"
: "When the container actually arrived in the yard"
}
required
>
<input
type="date"
value={arrivedAt}
max={todayForInput()}
onChange={(e) => setArrivedAt(e.target.value)}
style={{
padding: "8px",
borderRadius: "4px",
border: "1px solid #ced4da",
width: "100%",
}}
/>
</Input.Wrapper>
{mode === "single" ? (
<>
<Group grow align="flex-start">
<TextInput
label="Container Number"
placeholder="e.g., TEMU1234567"
value={containerNumber}
onChange={(e) => setContainerNumber(e.currentTarget.value)}
required
/>
<TextInput
label="Seal Number"
placeholder="Optional"
value={sealNumber}
onChange={(e) => setSealNumber(e.currentTarget.value)}
/>
<NumberInput
label="Weight (Tons)"
placeholder="Optional"
value={weight}
onChange={setWeight}
min={0}
decimalScale={3}
/>
</Group>
<Textarea
label="Notes"
placeholder="Where it came from, condition, anything worth recording"
value={notes}
onChange={(e) => setNotes(e.currentTarget.value)}
rows={3}
/>
<Group justify="flex-end">
<Button
onClick={() => singleMutation.mutate()}
disabled={!singleReady}
loading={singleMutation.isPending}
>
Register Container
</Button>
</Group>
</>
) : (
<>
<Input.Wrapper label="Excel file" description="One row per container">
<input
type="file"
accept=".xlsx,.xls"
onChange={(e) => void handleFile(e.target.files?.[0] ?? null)}
style={{ display: "block", padding: "8px 0" }}
/>
</Input.Wrapper>
{parseErrors.length > 0 && (
<Alert color="red" title={`${parseErrors.length} problem(s) — nothing was registered`}>
<ScrollArea.Autosize mah={200}>
<List size="sm">
{parseErrors.map((err) => (
<List.Item key={err}>{err}</List.Item>
))}
</List>
</ScrollArea.Autosize>
</Alert>
)}
{rows.length > 0 && (
<Stack gap="xs">
<Group gap="xs">
<Text fw={600} size="sm">
Preview
</Text>
<Badge size="sm">{rows.length} containers</Badge>
{file && (
<Text size="xs" c="dimmed">
{file.name}
</Text>
)}
</Group>
<ScrollArea.Autosize mah={320}>
<Table striped highlightOnHover withTableBorder>
<Table.Thead>
<Table.Tr>
<Table.Th>Container</Table.Th>
<Table.Th>Company</Table.Th>
<Table.Th>Arrived</Table.Th>
<Table.Th>Seal</Table.Th>
<Table.Th>Weight</Table.Th>
</Table.Tr>
</Table.Thead>
<Table.Tbody>
{rows.map((row) => {
const payload = toPayload(row);
return (
<Table.Tr key={row.containerNumber}>
<Table.Td>{payload.containerNumber}</Table.Td>
<Table.Td>
<Group gap={4} wrap="nowrap">
<Text size="sm">{payload.companyName || "—"}</Text>
{payload.companyName && !payload.companyId && (
<Badge size="xs" color="orange" variant="light">
New
</Badge>
)}
</Group>
</Table.Td>
<Table.Td>
{new Date(payload.arrivedAt).toLocaleDateString()}
</Table.Td>
<Table.Td>{payload.sealNumber ?? "—"}</Table.Td>
<Table.Td>{payload.weight ?? "—"}</Table.Td>
</Table.Tr>
);
})}
</Table.Tbody>
</Table>
</ScrollArea.Autosize>
</Stack>
)}
<Group justify="flex-end">
<Button
leftSection={<Upload size={16} />}
onClick={() => bulkMutation.mutate()}
disabled={!bulkReady}
loading={bulkMutation.isPending}
>
Register {rows.length > 0 ? `${rows.length} containers` : ""}
</Button>
</Group>
</>
)}
</Stack>
</Card>
</PageContainer>
);
}

View File

@@ -1,4 +1,4 @@
import { useMemo, useState } from 'react';
import { useCallback, useMemo, useState } from 'react';
import { useNavigate, useParams } from 'react-router-dom';
import {
ActionIcon,
@@ -12,8 +12,8 @@ import {
Tabs,
Text,
} from '@mantine/core';
import { ArrowLeft, Boxes, LayoutGrid, Package, Pencil, Plus } from 'lucide-react';
import { useQuery } from '@tanstack/react-query';
import { ArrowLeft, Boxes, Layers, LayoutGrid, Package, Pencil, Plus, Trash2 } from 'lucide-react';
import { useMutation, useQuery } from '@tanstack/react-query';
import { DataTable, type ColumnDef } from '@edr/ui-common';
import { KpiStrip, PageContainer, PageHeader } from '@/components/page';
@@ -23,16 +23,27 @@ import {
InventoryWorkbench,
WarehouseStatusBadge,
WarehouseTypeBadge,
ZoneContentsModal,
type ZoneRef,
ZoneLayoutModal,
ZoneOccupancyHeatmap,
formatCapacity,
humanizeEnum,
} from '@/components/warehouses';
import { useAuth } from '@/auth/useAuth';
import { useToast } from '@/hooks/use-toast';
import { extractErrorMessage } from '@/components/warehouses/options';
import { FREIGHT_PERMS, hasPermission } from '@/lib/permissions';
import { api } from '@/services/api';
import type { WarehouseYard, WarehouseZone } from '@/types/warehouse';
export default function WarehouseDetailPage() {
const { id } = useParams<{ id: string }>();
const navigate = useNavigate();
const { toast } = useToast();
const { user } = useAuth();
const canDeleteYard = hasPermission(user, FREIGHT_PERMS.warehouseYards.delete);
const canDeleteZone = hasPermission(user, FREIGHT_PERMS.warehouseZones.delete);
const { data: warehouse, isLoading } = useQuery(
api.warehouses.getById.queryOptions({
@@ -52,6 +63,8 @@ export default function WarehouseDetailPage() {
const [zoneModalOpen, setZoneModalOpen] = useState(false);
const [editingZone, setEditingZone] = useState<WarehouseZone | null>(null);
const [selectedYardId, setSelectedYardId] = useState<string | null>(null);
const [contentsZone, setContentsZone] = useState<ZoneRef | null>(null);
const [layoutZone, setLayoutZone] = useState<ZoneRef | null>(null);
const zonesQuery = useQuery(
api.warehouses.listZones.queryOptions({
@@ -72,6 +85,44 @@ export default function WarehouseDetailPage() {
[yards],
);
const deleteYard = useMutation(api.warehouses.deleteYard.mutationOptions());
const deleteZone = useMutation(api.warehouses.deleteZone.mutationOptions());
// The API refuses a yard that still has zones (and a zone that still holds
// inventory) with a 409 — surface that message rather than a bare failure.
const removeYard = useCallback(
(yard: WarehouseYard) => {
if (!window.confirm(`Delete yard ${yard.code}? Its zones must be removed first.`)) return;
deleteYard.mutate(
{ id: yard.id },
{
onSuccess: () => toast({ title: `Yard ${yard.code} deleted` }),
onError: (error) =>
toast({ variant: 'destructive', title: 'Delete failed', description: extractErrorMessage(error) }),
},
);
},
[deleteYard, toast],
);
const removeZone = useCallback(
(zone: WarehouseZone) => {
if (!window.confirm(`Delete zone ${zone.code}? It must be empty first.`)) return;
deleteZone.mutate(
{ id: zone.id },
{
onSuccess: () => {
toast({ title: `Zone ${zone.code} deleted` });
void zonesQuery.refetch();
},
onError: (error) =>
toast({ variant: 'destructive', title: 'Delete failed', description: extractErrorMessage(error) }),
},
);
},
[deleteZone, toast, zonesQuery],
);
const yardColumns = useMemo<ColumnDef<WarehouseYard>[]>(
() => [
{ id: 'name', header: 'Name', cell: ({ row }) => row.original.name },
@@ -98,20 +149,33 @@ export default function WarehouseDetailPage() {
header: 'Actions',
meta: { headerClassName: 'text-right', cellClassName: 'text-right' },
cell: ({ row }) => (
<ActionIcon
variant="subtle"
color="gray"
onClick={() => {
setEditingYard(row.original);
setYardModalOpen(true);
}}
>
<Pencil size={16} />
</ActionIcon>
<Group gap={4} justify="flex-end" wrap="nowrap">
<ActionIcon
variant="subtle"
color="gray"
title="Edit"
onClick={() => {
setEditingYard(row.original);
setYardModalOpen(true);
}}
>
<Pencil size={16} />
</ActionIcon>
{canDeleteYard ? (
<ActionIcon
variant="subtle"
color="red"
title="Delete"
onClick={() => removeYard(row.original)}
>
<Trash2 size={16} />
</ActionIcon>
) : null}
</Group>
),
},
],
[],
[canDeleteYard, removeYard],
);
const zoneColumns = useMemo<ColumnDef<WarehouseZone>[]>(
@@ -140,20 +204,41 @@ export default function WarehouseDetailPage() {
header: 'Actions',
meta: { headerClassName: 'text-right', cellClassName: 'text-right' },
cell: ({ row }) => (
<ActionIcon
variant="subtle"
color="gray"
onClick={() => {
setEditingZone(row.original);
setZoneModalOpen(true);
}}
>
<Pencil size={16} />
</ActionIcon>
<Group gap={4} justify="flex-end" wrap="nowrap" onClick={(e) => e.stopPropagation()}>
<ActionIcon
variant="subtle"
color="gray"
title="Stack layout"
onClick={() => setLayoutZone(row.original)}
>
<Layers size={16} />
</ActionIcon>
<ActionIcon
variant="subtle"
color="gray"
title="Edit"
onClick={() => {
setEditingZone(row.original);
setZoneModalOpen(true);
}}
>
<Pencil size={16} />
</ActionIcon>
{canDeleteZone ? (
<ActionIcon
variant="subtle"
color="red"
title="Delete"
onClick={() => removeZone(row.original)}
>
<Trash2 size={16} />
</ActionIcon>
) : null}
</Group>
),
},
],
[],
[canDeleteZone, removeZone],
);
if (isLoading) {
@@ -299,7 +384,10 @@ export default function WarehouseDetailPage() {
</Button>
</Group>
<ZoneOccupancyHeatmap yardId={selectedYardId ?? undefined} />
<ZoneOccupancyHeatmap
yardId={selectedYardId ?? undefined}
onZoneClick={(zone) => setContentsZone(zone)}
/>
{!selectedYardId ? (
<Text c="dimmed" ta="center" py="lg">
@@ -309,6 +397,7 @@ export default function WarehouseDetailPage() {
<DataTable
columns={zoneColumns}
data={zonesQuery.data ?? []}
onRowClick={(zone) => setContentsZone(zone)}
status={
zonesQuery.isLoading ? 'loading' : zonesQuery.isError ? 'error' : 'success'
}
@@ -346,6 +435,18 @@ export default function WarehouseDetailPage() {
yard={editingYard}
/>
)}
<ZoneLayoutModal
opened={Boolean(layoutZone)}
onClose={() => setLayoutZone(null)}
zone={layoutZone}
/>
<ZoneContentsModal
opened={Boolean(contentsZone)}
onClose={() => setContentsZone(null)}
zone={contentsZone}
/>
{selectedYardId && (
<CreateZoneModal
opened={zoneModalOpen}

View File

@@ -4,6 +4,7 @@ import { Button, Card, Center, Loader, Stack, Text } from '@mantine/core';
import { useDebouncedValue } from '@mantine/hooks';
import { Plus } from 'lucide-react';
import { useAuth } from '@/auth/useAuth';
import { PageContainer, PageHeader } from '@/components/page';
import {
CreateWarehouseModal,
@@ -17,11 +18,18 @@ import ListControls from '@/components/common/ListControls';
// despite the ruleEngine path.
import RuleEngineListFooter from '@/components/ruleEngine/RuleEngineListFooter';
import { useListControls } from '@/hooks/useListControls';
import { useWarehouses } from '@/hooks/useWarehouses';
import { useToast } from '@/hooks/use-toast';
import { useDeleteWarehouse, useWarehouses } from '@/hooks/useWarehouses';
import { extractErrorMessage } from '@/components/warehouses/options';
import { FREIGHT_PERMS, hasPermission } from '@/lib/permissions';
import type { Warehouse, WarehouseFilter } from '@/types/warehouse';
export default function WarehouseListPage() {
const navigate = useNavigate();
const { toast } = useToast();
const { user } = useAuth();
const remove = useDeleteWarehouse();
const canDelete = hasPermission(user, FREIGHT_PERMS.warehouses.delete);
const [filter, setFilter] = useState<WarehouseFilter>({});
const [view, setView] = useState<WarehouseView>('table');
const [modalOpen, setModalOpen] = useState(false);
@@ -50,6 +58,15 @@ export default function WarehouseListPage() {
setModalOpen(true);
};
const openDetail = (warehouse: Warehouse) => navigate(`/dashboard/warehouses/${warehouse.id}`);
const handleDelete = (warehouse: Warehouse) => {
if (!window.confirm(`Delete warehouse ${warehouse.code}? Yards must be removed first.`)) return;
remove.mutate(warehouse.id, {
onSuccess: () => toast({ title: `Warehouse ${warehouse.code} deleted` }),
onError: (error) =>
toast({ variant: 'destructive', title: 'Delete failed', description: extractErrorMessage(error) }),
});
};
const onDelete = canDelete ? handleDelete : undefined;
return (
<PageContainer>
@@ -91,9 +108,19 @@ export default function WarehouseListPage() {
) : (
<>
{view === 'table' ? (
<WarehouseTable warehouses={controls.pagedRows} onView={openDetail} onEdit={openEdit} />
<WarehouseTable
warehouses={controls.pagedRows}
onView={openDetail}
onEdit={openEdit}
onDelete={onDelete}
/>
) : (
<WarehouseCardView warehouses={controls.pagedRows} onView={openDetail} onEdit={openEdit} />
<WarehouseCardView
warehouses={controls.pagedRows}
onView={openDetail}
onEdit={openEdit}
onDelete={onDelete}
/>
)}
<RuleEngineListFooter
pagination={controls.pagination}

View File

@@ -18,6 +18,10 @@ import {
TooltipTrigger,
} from "@/shared/common/ui/tooltip";
import { PositionName } from "../dto/delegation/delegationDto";
import {
getPositionCookie,
setPositionCookie,
} from "@/shared/utils/positionCookie";
interface BasePosition {
id: string;
employeePositionId: string;
@@ -55,7 +59,7 @@ export const PositionSelect = () => {
? unFilteredUserDetails.employee.flatMap((emp) => emp?.positions ?? [])
: [];
const selectablePositions = allPositions ?? [];
const currentPositionCookie = Cookies.get("current-position-id");
const currentPositionCookie = getPositionCookie();
const delegatedPositionCookie = Cookies.get("delegatedPositionId");
const activePositionId =
selectedPositionId || currentPositionCookie || delegatedPositionCookie;
@@ -85,15 +89,18 @@ export const PositionSelect = () => {
// (useAuthUser already self-heals this cookie for the same reason.)
if (
currentPosition.employeePositionId &&
Cookies.get("current-position-id") !== currentPosition.employeePositionId
getPositionCookie() !== currentPosition.employeePositionId
) {
Cookies.set("current-position-id", currentPosition.employeePositionId);
setPositionCookie(currentPosition.employeePositionId);
}
applyDelegationCookie(currentPosition);
}, [currentPosition, isLoading, selectedPositionId, setSelectedPositionId]);
if (isLoading || selectablePositions.length === 0) return null;
// Below two desks there is nothing to switch between. This now sits in the
// main freight header, so a one-option dropdown would show for every
// single-desk staff member.
if (isLoading || selectablePositions.length < 2) return null;
const handleChange = (value: string) => {
const selected = selectablePositions.find((pos) => pos.id === value);
@@ -102,7 +109,7 @@ export const PositionSelect = () => {
// dropdown's own value is position.id, which the API does not match on.
setSelectedPositionId(selected?.employeePositionId ?? value);
if (selected?.employeePositionId) {
Cookies.set("current-position-id", selected.employeePositionId);
setPositionCookie(selected.employeePositionId);
}
applyDelegationCookie(selected);

View File

@@ -12,9 +12,11 @@ import {
} from "@/record-management/dto/userRecords/teetersAndSignatureDto";
import Cookies from "js-cookie";
import { getPositionCookie } from "@/shared/utils/positionCookie";
export const withHeaders = (passPosId: boolean = false) => {
const unitId = Cookies.get("unit-id");
const positionId = Cookies.get("current-position-id");
const positionId = getPositionCookie();
const projectId = Cookies.get("current-project-id");
const delegatedPositionId = Cookies.get("delegatedPositionId");

View File

@@ -1,9 +1,11 @@
import Cookies from "js-cookie";
import { getPositionCookie } from "@/shared/utils/positionCookie";
export const withHeaders = () => {
const tenantKey = Cookies.get("tenant-key");
const unitId = Cookies.get("unit-id");
const positionId = Cookies.get("current-position-id");
const positionId = getPositionCookie();
const projectId = Cookies.get("current-project-id");
const delegatedPositionId = Cookies.get("delegatedPositionId");
const headers: Record<string, string> = {};

View File

@@ -13,6 +13,7 @@ import type {
CustomerBooking,
CustomerDocument,
CustomerPayment,
CustomerAccount,
CustomerResetTarget,
PaginatedCompanies,
ProfileStatus,
@@ -84,6 +85,7 @@ import type {
UpdateCheckpointPayload,
DispatchSchedulePayload,
StaffBookingWindow,
MarshallingStop,
ScheduleMergePreview,
TrainScheduleDetail,
TrainScheduleFilters,
@@ -151,6 +153,15 @@ import type {
WarehouseLoading,
WarehouseYard,
WarehouseZone,
ZoneContentItem,
ZoneLayout,
ZoneSlotSummary,
WarehouseZoneStack,
WarehouseZoneSlot,
SaveStackPayload,
AvailableSlot,
ContainerAccessibility,
FindSlotPayload,
} from "@/types/warehouse";
import { endpoint } from "@/utils/endpoint";
import {
@@ -261,6 +272,7 @@ import {
type BulkFulfillResult,
type TransferHistory,
type TransferRequestListFilter,
type WagonHistoryParams,
} from "./wagon.service";
import { warehouseService } from "./warehouse.service";
@@ -284,6 +296,11 @@ const INVENTORY_INVALIDATIONS: ReadonlyArray<readonly unknown[]> = [
const TRAIN_SCHEDULING_INVALIDATIONS: ReadonlyArray<readonly unknown[]> = [
QUERY_KEYS.TRAIN_SCHEDULING.ROOT,
QUERY_KEYS.BOOKINGS.ROOT,
// A station's loading window gates the warehouse loading queues too — they
// render the same Start/End controls, so they must refresh on the same click.
["loadable-trains"],
["train-loadable-items"],
["warehouse-inventory"],
];
/**
@@ -594,6 +611,13 @@ export const api = {
({ id }) => QUERY_KEYS.TRAIN_SCHEDULING.track(id),
),
marshallingStops: endpoint<{ id: string }, MarshallingStop[]>(
"train-scheduling",
"marshalling-stops",
({ id }) => trainSchedulingService.getMarshallingStops(id),
({ id }) => QUERY_KEYS.TRAIN_SCHEDULING.marshallingStops(id),
),
unassignedBookings: endpoint<
{ scheduleId: string },
UnassignedBookingsResponse
@@ -1214,6 +1238,14 @@ export const api = {
() => [["warehouses"], ["warehouse-yards"]],
),
deleteYard: endpoint<{ id: string }, unknown>(
"warehouses",
"deleteYard",
({ id }) => warehouseService.removeYard(id).then((r) => r.data),
undefined,
() => [["warehouses"], ["warehouse-yards"]],
),
// ── Zones ──────────────────────────────────────────────────────────────
listZones: endpoint<{ yardId: string }, WarehouseZone[]>(
"warehouses",
@@ -1246,6 +1278,100 @@ export const api = {
() => [["warehouse-yards"]],
),
zoneContents: endpoint<{ zoneId: string }, ZoneContentItem[]>(
"warehouse-zones",
"contents",
({ zoneId }) => warehouseService.zoneContents(zoneId).then((r) => r.data),
({ zoneId }) => ["warehouse-zones", zoneId, "contents"],
),
zoneLayout: endpoint<{ zoneId: string }, ZoneLayout>(
"warehouse-zones",
"layout",
({ zoneId }) => warehouseService.zoneLayout(zoneId).then((r) => r.data),
({ zoneId }) => ["warehouse-zones", zoneId, "layout"],
),
zoneSlotSummary: endpoint<{ zoneId: string }, ZoneSlotSummary>(
"warehouse-zones",
"slot-summary",
({ zoneId }) => warehouseService.zoneSlotSummary(zoneId).then((r) => r.data),
({ zoneId }) => ["warehouse-zones", zoneId, "slot-summary"],
),
// ── Ground stacks and slots ────────────────────────────────────────────
listStacks: endpoint<{ zoneId: string }, WarehouseZoneStack[]>(
"warehouse-zone-stacks",
"list",
({ zoneId }) => warehouseService.listStacks(zoneId).then((r) => r.data),
({ zoneId }) => ["warehouse-zone-stacks", zoneId],
),
createStack: endpoint<
{ zoneId: string; payload: SaveStackPayload },
WarehouseZoneStack
>(
"warehouse-zone-stacks",
"create",
({ zoneId, payload }) =>
warehouseService.createStack(zoneId, payload).then((r) => r.data),
undefined,
({ zoneId }) => [
["warehouse-zone-stacks", zoneId],
["warehouse-zones", zoneId, "layout"],
["warehouse-zones", zoneId, "slot-summary"],
],
),
updateStack: endpoint<
{ id: string; zoneId: string; payload: Partial<SaveStackPayload> },
WarehouseZoneStack
>(
"warehouse-zone-stacks",
"update",
({ id, payload }) => warehouseService.updateStack(id, payload).then((r) => r.data),
undefined,
({ zoneId }) => [
["warehouse-zone-stacks", zoneId],
["warehouse-zones", zoneId, "layout"],
["warehouse-zones", zoneId, "slot-summary"],
],
),
deleteStack: endpoint<{ id: string; zoneId: string }, unknown>(
"warehouse-zone-stacks",
"delete",
({ id }) => warehouseService.removeStack(id).then((r) => r.data),
undefined,
({ zoneId }) => [
["warehouse-zone-stacks", zoneId],
["warehouse-zones", zoneId, "layout"],
["warehouse-zones", zoneId, "slot-summary"],
],
),
updateSlot: endpoint<
{ slotId: string; zoneId: string; payload: { status?: string; isActive?: boolean } },
WarehouseZoneSlot
>(
"warehouse-zone-stacks",
"update-slot",
({ slotId, payload }) => warehouseService.updateSlot(slotId, payload).then((r) => r.data),
undefined,
({ zoneId }) => [
["warehouse-zones", zoneId, "layout"],
["warehouse-zones", zoneId, "slot-summary"],
],
),
deleteZone: endpoint<{ id: string }, unknown>(
"warehouses",
"deleteZone",
({ id }) => warehouseService.removeZone(id).then((r) => r.data),
undefined,
() => [["warehouses"], ["warehouse-yards"]],
),
// ── Inventory (queries) ────────────────────────────────────────────────
listInventory: endpoint<
{ filter?: InventoryFilter },
@@ -1469,7 +1595,7 @@ export const api = {
"store",
({ id, payload }) => warehouseService.store(id, payload).then((r) => r.data),
undefined,
() => INVENTORY_INVALIDATIONS,
() => [...INVENTORY_INVALIDATIONS, ["warehouse-zones"]],
),
reserve: endpoint<ReserveInventoryPayload, WarehouseInventoryItem>(
@@ -1508,6 +1634,36 @@ export const api = {
() => INVENTORY_INVALIDATIONS,
),
findAvailableSlot: endpoint<FindSlotPayload, AvailableSlot | null>(
"warehouse-inventory",
"find-slot",
(payload) => warehouseService.findAvailableSlot(payload).then((r) => r.data),
(payload) => ["warehouse-inventory", "find-slot", payload],
),
containerAccessibility: endpoint<{ id: string }, ContainerAccessibility>(
"warehouse-inventory",
"accessibility",
({ id }) => warehouseService.containerAccessibility(id).then((r) => r.data),
({ id }) => ["warehouse-inventory", id, "accessibility"],
),
assignSlot: endpoint<{ id: string; slotId?: string }, WarehouseInventoryItem>(
"warehouse-inventory",
"assign-slot",
({ id, slotId }) => warehouseService.assignSlot(id, slotId).then((r) => r.data),
undefined,
() => [...INVENTORY_INVALIDATIONS, ["warehouse-zones"]],
),
releaseSlot: endpoint<{ id: string }, WarehouseInventoryItem>(
"warehouse-inventory",
"release-slot",
({ id }) => warehouseService.releaseSlot(id).then((r) => r.data),
undefined,
() => [...INVENTORY_INVALIDATIONS, ["warehouse-zones"]],
),
move: endpoint<
{ id: string; payload: MoveInventoryPayload },
WarehouseInventoryItem
@@ -1517,7 +1673,7 @@ export const api = {
({ id, payload }) =>
warehouseService.move(id, payload).then((r) => r.data),
undefined,
() => INVENTORY_INVALIDATIONS,
() => [...INVENTORY_INVALIDATIONS, ["warehouse-zones"]],
),
markReadyForPickup: endpoint<string, WarehouseInventoryItem>(
@@ -2041,6 +2197,22 @@ export const api = {
({ id }) => wagonService.getStatusHistory(id).then((r) => r.data),
({ id }) => ["wagons", "status-history", id],
),
/** One keyset page of the unified wagon history; page with `cursor`. */
history: endpoint<{ id: string } & WagonHistoryParams, Freight.WagonHistoryPage>(
"wagons",
"history",
({ id, ...params }) => wagonService.getHistory(id, params).then((r) => r.data),
({ id, category, types, cursor, limit }) => [
"wagons",
"history",
id,
category ?? null,
types?.join(",") ?? null,
cursor ?? null,
limit ?? null,
],
),
},
wagonTransferRequests: {
@@ -3250,6 +3422,13 @@ export const api = {
({ id }) => QUERY_KEYS.CUSTOMERS.payments(id),
),
accounts: endpoint<{ companyId: string }, CustomerAccount[]>(
"customers",
"accounts",
({ companyId }) => customersService.accounts(companyId),
({ companyId }) => QUERY_KEYS.CUSTOMERS.accounts(companyId),
),
resetTarget: endpoint<{ companyId: string }, CustomerResetTarget>(
"customers",
"resetTarget",

View File

@@ -73,6 +73,18 @@ export interface BookingListFilter {
freightType?: string;
/** Service type (rule-engine service_types.id). */
serviceTypeId?: string;
/** Cargo type OR cargo group — a group matches every commodity beneath it. */
cargoTypeId?: string;
/** Contains-search over content: cargo description, commodity, container types. */
cargoText?: string;
/** Bookings carrying this container type; also scopes containersMin/Max to it. */
containerTypeId?: string;
/** Container count bounds — of containerTypeId when set, else of all types. */
containersMin?: string;
containersMax?: string;
/** Bounds on containers declared on the shipment request behind the booking. */
requestedContainersMin?: string;
requestedContainersMax?: string;
/** ONE_TIME | GENERAL_CONTRACT — the booking-kind tab filter. */
bookingType?: string;
/** 'true' → customs bookings, 'false' → self-clearance (non-customs). */
@@ -199,6 +211,15 @@ export const bookingsService = {
if (filter.companyId) params.companyId = filter.companyId;
if (filter.freightType) params.freightType = filter.freightType;
if (filter.serviceTypeId) params.serviceTypeId = filter.serviceTypeId;
if (filter.cargoTypeId) params.cargoTypeId = filter.cargoTypeId;
if (filter.cargoText) params.cargoText = filter.cargoText;
if (filter.containerTypeId) params.containerTypeId = filter.containerTypeId;
if (filter.containersMin) params.containersMin = filter.containersMin;
if (filter.containersMax) params.containersMax = filter.containersMax;
if (filter.requestedContainersMin)
params.requestedContainersMin = filter.requestedContainersMin;
if (filter.requestedContainersMax)
params.requestedContainersMax = filter.requestedContainersMax;
if (filter.bookingType) params.bookingType = filter.bookingType;
if (filter.tradeDirection) params.tradeDirection = filter.tradeDirection;
if (filter.paymentCurrency)
@@ -238,6 +259,15 @@ export const bookingsService = {
if (filter.contractId) params.contractId = filter.contractId;
if (filter.freightType) params.freightType = filter.freightType;
if (filter.serviceTypeId) params.serviceTypeId = filter.serviceTypeId;
if (filter.cargoTypeId) params.cargoTypeId = filter.cargoTypeId;
if (filter.cargoText) params.cargoText = filter.cargoText;
if (filter.containerTypeId) params.containerTypeId = filter.containerTypeId;
if (filter.containersMin) params.containersMin = filter.containersMin;
if (filter.containersMax) params.containersMax = filter.containersMax;
if (filter.requestedContainersMin)
params.requestedContainersMin = filter.requestedContainersMin;
if (filter.requestedContainersMax)
params.requestedContainersMax = filter.requestedContainersMax;
if (filter.bookingType) params.bookingType = filter.bookingType;
if (filter.tradeDirection) params.tradeDirection = filter.tradeDirection;
if (filter.paymentCurrency)

View File

@@ -10,6 +10,7 @@ import type {
CustomerBooking,
CustomerDocument,
CustomerPayment,
CustomerAccount,
CustomerResetTarget,
PaginatedCompanies,
ProfileStatus,
@@ -90,6 +91,18 @@ export const customersService = {
.then((r) => r.data);
},
/**
* Every portal login belonging to this customer, primary contact first.
*
* Not filtered to active accounts — a suspended or never-activated login is
* exactly what staff are checking when a customer says they cannot sign in.
*/
accounts(companyId: string): Promise<CustomerAccount[]> {
return apiClient
.get<CustomerAccount[]>(URL_CONSTANTS.COMPANIES.ACCOUNTS(companyId))
.then((r) => r.data);
},
/**
* The IAM account a reset link would go to. Read before offering the action
* so staff see the credentials the link actually reaches, not the company's

View File

@@ -0,0 +1,62 @@
import { api as client } from '../auth/http';
import { URL_CONSTANTS } from '@/constants/URLS';
import { unwrap } from '@/utils/endpoint';
import type {
EmptyReturnQuote,
EmptyReturnRequest,
EmptyReturnRequestStatus,
PlannedEmptyReturn,
} from '@/types/importOperations';
/**
* Empty container return requests — the customer-initiated path for a booking
* that was sold WITHOUT the return service. Staff price and approve them here;
* the customer pays and books the truck from the portal.
*/
export const emptyReturnRequestsService = {
list: async (params: {
status?: EmptyReturnRequestStatus;
bookingId?: string;
} = {}): Promise<EmptyReturnRequest[]> => {
const response = await client.get<EmptyReturnRequest[]>(
URL_CONSTANTS.EMPTY_RETURN_REQUESTS.BASE,
{ params },
);
return unwrap(response.data);
},
/** Scheduled returns the warehouse is expecting, with date and truck. */
planned: async (): Promise<PlannedEmptyReturn[]> => {
const response = await client.get<PlannedEmptyReturn[]>(
URL_CONSTANTS.EMPTY_RETURN_REQUESTS.PLANNED,
);
return unwrap(response.data);
},
/** The route price staff see prefilled at approval. */
quote: async (bookingId: string): Promise<EmptyReturnQuote> => {
const response = await client.get<{ quote: EmptyReturnQuote }>(
URL_CONSTANTS.EMPTY_RETURN_REQUESTS.ELIGIBILITY(bookingId),
);
return unwrap(response.data).quote;
},
approve: async (
id: string,
payload: { unitAmount?: number; currency?: string } = {},
): Promise<EmptyReturnRequest> => {
const response = await client.post<EmptyReturnRequest>(
URL_CONSTANTS.EMPTY_RETURN_REQUESTS.APPROVE(id),
payload,
);
return unwrap(response.data);
},
reject: async (id: string, reason: string): Promise<EmptyReturnRequest> => {
const response = await client.post<EmptyReturnRequest>(
URL_CONSTANTS.EMPTY_RETURN_REQUESTS.REJECT(id),
{ reason },
);
return unwrap(response.data);
},
};

View File

@@ -8,6 +8,7 @@ import type {
LoadEmptyContainersOnTrainPayload,
DjiboutiIncident,
EmptyContainerReturn,
EmptyReturnBooking,
ImportCustomsFinalization,
ImportOperationActionPayload,
RecordDeclarationPayload,
@@ -114,6 +115,14 @@ export const importOperationsService = {
return unwrap(response.data);
},
/** Bookings that ship with empty-container return and still owe empties back. */
listEmptyReturnBookings: async (): Promise<EmptyReturnBooking[]> => {
const response = await client.get<EmptyReturnBooking[]>(
URL_CONSTANTS.IMPORT_OPERATIONS.EMPTY_RETURN_BOOKINGS,
);
return unwrap(response.data);
},
createEmptyReturn: async (
payload: CreateEmptyContainerReturnPayload,
): Promise<EmptyContainerReturn> => {
@@ -124,6 +133,17 @@ export const importOperationsService = {
return unwrap(response.data);
},
/** Backfill empties already in the yard. All-or-nothing on the server. */
bulkCreateEmptyReturns: async (
returns: CreateEmptyContainerReturnPayload[],
): Promise<EmptyContainerReturn[]> => {
const response = await client.post<EmptyContainerReturn[]>(
URL_CONSTANTS.IMPORT_OPERATIONS.EMPTY_CONTAINER_RETURNS_BULK,
{ returns },
);
return unwrap(response.data);
},
loadEmptyContainersOnTrain: async (
payload: LoadEmptyContainersOnTrainPayload,
): Promise<EmptyContainerReturn[]> => {

View File

@@ -33,6 +33,7 @@ import type {
UpdateCheckpointPayload,
DispatchSchedulePayload,
StaffBookingWindow,
MarshallingStop,
ScheduleMergePreview,
TrainScheduleDetail,
UpdateScheduleWindowRulePayload,
@@ -765,6 +766,24 @@ export const trainSchedulingService = {
return response.data;
},
getMarshallingStops: async (scheduleId: string): Promise<MarshallingStop[]> => {
const response = await client.get<MarshallingStop[]>(
URL_CONSTANTS.TRAIN_SCHEDULING.MARSHALLING_STOPS(scheduleId),
);
return unwrap(response.data);
},
downloadMarshallingDocumentAt: async (
scheduleId: string,
stopIndex: number,
): Promise<Blob> => {
const response = await client.get(
URL_CONSTANTS.TRAIN_SCHEDULING.MARSHALLING_DOCUMENT_AT(scheduleId, stopIndex),
{ responseType: "blob" },
);
return response.data;
},
getTrack: async (scheduleId: string): Promise<TrainTrackResponse> => {
const response = await client.get<TrainTrackResponse>(
URL_CONSTANTS.TRAIN_SCHEDULING.CHECKPOINTS(scheduleId),

View File

@@ -154,8 +154,29 @@ export const wagonService = {
/** Status audit trail for one wagon, newest first. */
getStatusHistory: (id: string) =>
apiClient.get<WagonStatusLog[]>(`/wagons/${id}/status-history`),
/**
* Unified history (yard moves, coupling, schedule pins/dispatch, status,
* cargo, lifecycle) — one keyset page, newest first. Pass the previous
* page's `nextCursor` to continue.
*/
getHistory: (id: string, params: WagonHistoryParams = {}) => {
const qs = new URLSearchParams();
if (params.category) qs.set('category', params.category);
if (params.types?.length) qs.set('types', params.types.join(','));
if (params.cursor) qs.set('cursor', params.cursor);
if (params.limit) qs.set('limit', String(params.limit));
const q = qs.toString();
return apiClient.get<Freight.WagonHistoryPage>(`/wagons/${id}/history${q ? `?${q}` : ''}`);
},
};
export interface WagonHistoryParams {
category?: Freight.WagonEventCategory;
types?: Freight.WagonEventType[];
cursor?: string;
limit?: number;
}
/**
* A two-person wagon-transfer request: a requester asks for N wagons of a type
* to move between yards (count only); OCC hand-picks the wagons and fulfils it.

View File

@@ -5,6 +5,14 @@ import { api as apiClient } from '../auth/http';
import { URL_CONSTANTS } from '@/constants/URLS';
import type {
ZoneOccupancy,
ZoneLayout,
ZoneSlotSummary,
WarehouseZoneStack,
WarehouseZoneSlot,
SaveStackPayload,
AvailableSlot,
ContainerAccessibility,
FindSlotPayload,
TruckOnSite,
WarehouseOpsStats,
WarehouseThroughputPoint,
@@ -41,6 +49,7 @@ import type {
MoveInventoryPayload,
StoreInventoryPayload,
ReceiveInventoryPayload,
RegisterBacklogContainerPayload,
ReleaseOrderPayload,
DeliverInventoryPayload,
EligibleBooking,
@@ -71,7 +80,9 @@ import type {
WarehouseLoading,
WarehouseYard,
WarehouseZone,
ZoneContentItem,
} from '@/types/warehouse';
import type { StationWorkLog } from '@/types/trainScheduling';
export type ContainerItemStage = 'PENDING' | 'RECEIVED' | 'GRN' | 'ASSIGNED' | 'LOADED' | 'LEFT' | 'DELIVERED';
@@ -105,6 +116,10 @@ export interface LoadableTrain {
departureTime: string | null;
readyCount: number;
loadedCount: number;
/** freight.yards.id the train departs from. */
originStationId: string | null;
/** The schedule's per-yard loading/unloading windows — same store the train schedule page writes. */
stationWorkLogs: Record<string, StationWorkLog> | null;
}
/** A container/cargo inventory item assigned to a train, with its allocated wagon. */
@@ -122,6 +137,11 @@ export interface TrainLoadableItem {
wagonId: string | null;
wagonNumber: string | null;
sequenceNo: number | null;
/** The booking's boarding yard — the yard whose loading window gates this item. */
originYardId: string | null;
originYardLabel: string | null;
/** True once "Start loading" was clicked for this item's boarding yard on this train. */
loadingWindowStarted: boolean;
loadable: boolean;
}
@@ -186,7 +206,7 @@ export const warehouseService = {
/** A booking's containers with VGM cargo weight (tonnes) for exit weighing. */
getContainerWeights: async (
bookingId: string,
): Promise<Array<{ containerNumber: string; weightTons: number }>> => {
): Promise<Array<{ containerNumber: string; weightTons: number; containerSize: string | null }>> => {
const { data } = await apiClient.get(
`/warehouse-inventory/bookings/${bookingId}/container-weights`,
);
@@ -257,6 +277,7 @@ export const warehouseService = {
apiClient.post<Warehouse>(URL_CONSTANTS.WAREHOUSES.BASE, payload),
update: (id: string, payload: Partial<SaveWarehousePayload>) =>
apiClient.patch<Warehouse>(URL_CONSTANTS.WAREHOUSES.BY_ID(id), payload),
remove: (id: string) => apiClient.delete(URL_CONSTANTS.WAREHOUSES.BY_ID(id)),
// Yards list now returns the standard paginated envelope ({ items, meta }).
listFacilities: () =>
apiClient.get<{ items: WarehouseFacility[] }>(URL_CONSTANTS.RULE_ENGINE.YARDS, {
@@ -272,6 +293,7 @@ export const warehouseService = {
getYard: (id: string) => apiClient.get<WarehouseYard>(URL_CONSTANTS.WAREHOUSE_YARDS.BY_ID(id)),
updateYard: (id: string, payload: Partial<SaveYardPayload>) =>
apiClient.patch<WarehouseYard>(URL_CONSTANTS.WAREHOUSE_YARDS.BY_ID(id), payload),
removeYard: (id: string) => apiClient.delete(URL_CONSTANTS.WAREHOUSE_YARDS.BY_ID(id)),
// ── Zones ──────────────────────────────────────────────────────────────
listZones: (yardId: string) =>
@@ -282,6 +304,40 @@ export const warehouseService = {
getZone: (id: string) => apiClient.get<WarehouseZone>(URL_CONSTANTS.WAREHOUSE_ZONES.BY_ID(id)),
updateZone: (id: string, payload: Partial<SaveZonePayload>) =>
apiClient.patch<WarehouseZone>(URL_CONSTANTS.WAREHOUSE_ZONES.BY_ID(id), payload),
removeZone: (id: string) => apiClient.delete(URL_CONSTANTS.WAREHOUSE_ZONES.BY_ID(id)),
zoneContents: (zoneId: string) =>
apiClient.get<ZoneContentItem[]>(`${URL_CONSTANTS.WAREHOUSE_ZONES.BY_ID(zoneId)}/contents`),
zoneLayout: (zoneId: string) =>
apiClient.get<ZoneLayout>(URL_CONSTANTS.WAREHOUSE_ZONES.LAYOUT(zoneId)),
zoneSlotSummary: (zoneId: string) =>
apiClient.get<ZoneSlotSummary>(URL_CONSTANTS.WAREHOUSE_ZONES.SLOT_SUMMARY(zoneId)),
// ── Ground stacks and slots ────────────────────────────────────────────
listStacks: (zoneId: string) =>
apiClient.get<WarehouseZoneStack[]>(URL_CONSTANTS.WAREHOUSE_ZONE_STACKS.BY_ZONE(zoneId)),
createStack: (zoneId: string, payload: SaveStackPayload) =>
apiClient.post<WarehouseZoneStack>(URL_CONSTANTS.WAREHOUSE_ZONE_STACKS.BASE, {
...payload,
zoneId,
}),
updateStack: (id: string, payload: Partial<SaveStackPayload>) =>
apiClient.patch<WarehouseZoneStack>(URL_CONSTANTS.WAREHOUSE_ZONE_STACKS.BY_ID(id), payload),
removeStack: (id: string) =>
apiClient.delete(URL_CONSTANTS.WAREHOUSE_ZONE_STACKS.BY_ID(id)),
updateSlot: (slotId: string, payload: { status?: string; isActive?: boolean }) =>
apiClient.patch<WarehouseZoneSlot>(URL_CONSTANTS.WAREHOUSE_ZONE_STACKS.SLOT(slotId), payload),
// ── Physical placement ─────────────────────────────────────────────────
findAvailableSlot: (payload: FindSlotPayload) =>
apiClient.post<AvailableSlot | null>(URL_CONSTANTS.WAREHOUSE_INVENTORY.FIND_SLOT, payload),
assignSlot: (id: string, slotId?: string) =>
apiClient.post<WarehouseInventoryItem>(URL_CONSTANTS.WAREHOUSE_INVENTORY.ASSIGN_SLOT(id), {
slotId,
}),
releaseSlot: (id: string) =>
apiClient.post<WarehouseInventoryItem>(URL_CONSTANTS.WAREHOUSE_INVENTORY.RELEASE_SLOT(id), {}),
containerAccessibility: (id: string) =>
apiClient.get<ContainerAccessibility>(URL_CONSTANTS.WAREHOUSE_INVENTORY.ACCESSIBILITY(id)),
// ── Inventory ──────────────────────────────────────────────────────────
listInventory: (filter?: InventoryFilter) =>
@@ -351,6 +407,20 @@ export const warehouseService = {
// ── Receive (Import/Export bulk) ─────────────────────────────────────────
eligibleBookings: (direction?: 'IMPORT' | 'EXPORT') =>
apiClient.get<EligibleBooking[]>(URL_CONSTANTS.WAREHOUSE_INVENTORY.ELIGIBLE_BOOKINGS(direction)),
/** Register one loaded container already in the yard but never entered. */
registerBacklogContainer: (payload: RegisterBacklogContainerPayload) =>
apiClient.post<WarehouseInventoryItem>(
URL_CONSTANTS.WAREHOUSE_INVENTORY.REGISTER_BACKLOG,
payload,
),
/** Bulk backlog registration — all-or-nothing on the server. */
registerBacklogContainersBulk: (containers: RegisterBacklogContainerPayload[]) =>
apiClient.post<WarehouseInventoryItem[]>(
URL_CONSTANTS.WAREHOUSE_INVENTORY.REGISTER_BACKLOG_BULK,
{ containers },
),
receiveBulk: (payload: BulkReceivePayload) =>
apiClient.post<BulkReceiveResult>(URL_CONSTANTS.WAREHOUSE_INVENTORY.RECEIVE_BULK, payload),
bulkMarkInspected: (payload: BulkInspectPayload) =>

View File

@@ -30,6 +30,10 @@ import {
persistRememberMePreference,
setAuthCookies,
} from "@/shared/utils/authPersistence";
import {
getPositionCookie,
setPositionCookie,
} from "@/shared/utils/positionCookie";
import { clearComplaintVerification } from "@/complaints/utils/complaintVerificationStorage";
interface LoginPayload {
@@ -46,7 +50,7 @@ export const useAuthUser = () => {
const { t } = useTranslation();
const { handleError } = useErrorHandler(t);
const delegatedPositionId = Cookies.get("delegatedPositionId");
const currentPositionId = Cookies.get("current-position-id");
const currentPositionId = getPositionCookie();
const {
setUser,
@@ -96,7 +100,7 @@ export const useAuthUser = () => {
userDetails.employee?.[0]?.positions?.[0]?.employeePositionId;
if (firstPositionId) {
setSelectedPositionId(firstPositionId);
Cookies.set("current-position-id", firstPositionId, cookieOptions);
setPositionCookie(firstPositionId, cookieOptions);
}
}
@@ -141,7 +145,7 @@ export const useAuthUser = () => {
if (fallbackId) {
setSelectedPositionId(fallbackId);
Cookies.set("current-position-id", fallbackId);
setPositionCookie(fallbackId);
}
} else if (selectedPositionId) {
// Self-heal stale cookies that were set to position.id instead of
@@ -157,10 +161,7 @@ export const useAuthUser = () => {
if (matchingPosition?.employeePositionId) {
setSelectedPositionId(matchingPosition.employeePositionId);
Cookies.set(
"current-position-id",
matchingPosition.employeePositionId,
);
setPositionCookie(matchingPosition.employeePositionId);
}
}
}

View File

@@ -8,6 +8,7 @@ import {
getAuthCookieOptions,
setAuthCookies,
} from "@/shared/utils/authPersistence";
import { setPositionCookie } from "@/shared/utils/positionCookie";
import type { VerifiedCitizen } from "@/complaints/types/complaint.types";
function unwrapApiData<T>(payload: T | { data?: T }): T {
@@ -138,7 +139,7 @@ export async function persistFaydaRegistrationAuth(
const firstPositionId =
userDetails.employee?.[0]?.positions?.[0]?.employeePositionId;
if (firstPositionId) {
Cookies.set("current-position-id", firstPositionId, cookieOptions);
setPositionCookie(firstPositionId, cookieOptions);
}
return userDetails;

View File

@@ -0,0 +1,39 @@
import Cookies from "js-cookie";
type CookieOptions = NonNullable<Parameters<typeof Cookies.set>[2]>;
/**
* Freight's own active-position cookie.
*
* Smart Office is a separate app on the same IAM and it also writes a cookie
* named `current-position-id` — but it stores `position.id` where freight
* stores `employeePositionId`. The two are not interchangeable, so on a shared
* domain each app's login silently overwrote the other's desk selection and the
* loser fell back to `positions[0]`. Freight keeps its own cookie name so both
* can hold a selection at once.
*/
export const POSITION_COOKIE = "freight-current-position-id";
/**
* The pre-rename, shared-with-Smart-Office name. Still read so a session that
* is live across the deploy keeps its desk, and cleared on every write so the
* colliding cookie does not linger.
*/
const LEGACY_POSITION_COOKIE = "current-position-id";
/** The active `employeePositionId`, or undefined when no desk is selected. */
export const getPositionCookie = (): string | undefined =>
Cookies.get(POSITION_COOKIE) ?? Cookies.get(LEGACY_POSITION_COOKIE);
export const setPositionCookie = (
value: string,
options?: CookieOptions,
): void => {
Cookies.set(POSITION_COOKIE, value, options);
Cookies.remove(LEGACY_POSITION_COOKIE);
};
export const clearPositionCookie = (): void => {
Cookies.remove(POSITION_COOKIE);
Cookies.remove(LEGACY_POSITION_COOKIE);
};

View File

@@ -8,6 +8,8 @@
* API so the data layer can be swapped to live endpoints with no UI changes.
*/
import type { ETradeBusinessOption } from "@edr/types";
/** Mirrors backend `CompanyType`. */
export type CompanyType =
| "customer"
@@ -65,6 +67,15 @@ export interface CompanyProfile {
/** Business-license documents uploaded for this profile. */
licenseFiles?: LicenseFile[];
attributes?: Record<string, unknown> | null;
/**
* Which of the TIN's eTrade business licences this role operates as.
*
* A TIN routinely holds a dozen licences split by activity, so "exporter" and
* "freight forwarder" are usually two different businesses under one company.
* Null when the customer has not attached one, or when the company registered
* without eTrade at all (co-operative / investment licence).
*/
etradeBusiness?: ETradeBusinessOption | null;
/** Reviewer note when the role is rejected. */
reviewNote?: string | null;
createdAt: string;
@@ -166,6 +177,34 @@ export interface ResetPasswordResult {
* Distinct from `Company.email` / `Company.phone`, which are business contact
* details and routinely differ from the credentials the customer logs in with.
*/
/**
* One portal login belonging to a customer: the company-side profile joined to
* the IAM account that actually signs in. Mirrors the API's `CustomerAccount`.
*
* The IAM fields are null when the profile points at a user row that no longer
* exists — surfaced rather than hidden, since that is itself a fault worth
* seeing.
*/
export interface CustomerAccount {
profileId: string;
userId: string;
firstName: string;
lastName: string;
jobTitle: string | null;
isPrimaryContact: boolean;
onboardingStep: string | null;
onboardingCompleted: boolean;
username: string | null;
email: string | null;
phoneNumber: string | null;
phoneVerified: boolean | null;
status: string | null;
isActive: boolean | null;
/** False means the account exists but its owner never set a password. */
hasSetPassword: boolean | null;
createdAt: string;
}
export interface CustomerResetTarget {
userId: string;
name: string;
@@ -314,6 +353,13 @@ export interface CompanyListFilter {
kind?: CompanyKind;
status?: CompanyStatus;
nationality?: CompanyNationality;
/**
* Only companies holding this operational role. Distinct from `type`, which
* is the company's own kind — a `customer` company can hold importer,
* exporter and forwarder roles at once, and its other roles still come back
* on the row.
*/
profileType?: ProfileType;
/** ISO instants — inclusive bounds on the registration date. */
createdFrom?: string;
createdTo?: string;

View File

@@ -92,7 +92,11 @@ export interface EmptyContainerReturn {
id: string;
containerNumber: string;
bookingId: string | null;
/** Reference of the booking this empty came back on; null for standalone returns. */
bookingReference: string | null;
customerId: string | null;
/** Owning company as text — set for backfilled boxes whose company is unregistered. */
companyName: string | null;
returnDate: string;
facility: string | null;
yard: string | null;
@@ -117,11 +121,39 @@ export interface EmptyContainerReturn {
export type EmptyContainerSize = '20' | '40';
/** One container a with-return booking owes back empty. */
export interface EmptyReturnBookingContainer {
/** Stable row key — the booking container unit id. */
key: string;
unitId: string;
containerNumber: string;
containerSize: string | null;
containerType: string | null;
/** Set once this container's empty return has been recorded. */
returnId: string | null;
returnStatus: EmptyContainerReturnStatus | null;
}
/** A booking that ships with empty-container return and still owes empties. */
export interface EmptyReturnBooking {
bookingId: string;
bookingReference: string;
bookingStatus: string;
equipmentReturn: string;
customerId: string | null;
companyName: string | null;
containers: EmptyReturnBookingContainer[];
expectedCount: number;
recordedCount: number;
pendingCount: number;
}
export interface CreateEmptyContainerReturnPayload {
containerNumber: string;
containerSize?: EmptyContainerSize;
bookingId?: string;
customerId?: string;
companyName?: string;
returnDate?: string;
facility?: string;
yard?: string;
@@ -147,3 +179,60 @@ export interface UpdateEmptyContainerReturnStatusPayload extends ImportOperation
wagonAllocationReference?: string;
handoverNote?: string;
}
export type EmptyReturnRequestStatus =
| 'SUBMITTED'
| 'APPROVED'
| 'REJECTED'
| 'PAID'
| 'SCHEDULED'
| 'COMPLETED'
| 'CANCELLED';
/** A customer's request to send empties back on a booking sold without return. */
export interface EmptyReturnRequest {
id: string;
bookingId: string;
bookingReference: string | null;
companyId: string | null;
companyName: string | null;
status: EmptyReturnRequestStatus;
containerNumbers: string[];
containerCount: number;
quotedUnitAmount: number | null;
quotedTotalAmount: number | null;
currency: string | null;
invoiceId: string | null;
paidAt: string | null;
requestedReturnDate: string | null;
truckPlateNumber: string | null;
truckDriverName: string | null;
truckType: string | null;
scheduledAt: string | null;
submittedAt: string;
reviewedAt: string | null;
rejectionReason: string | null;
completedAt: string | null;
}
/** Per-container price for an empty return, off the route's WITH_RETURN rate. */
export interface EmptyReturnQuote {
unitAmount: number | null;
currency: string;
sourceRateUsd: number | null;
unavailableReason: string | null;
}
/** A scheduled empty return the warehouse is waiting on. */
export interface PlannedEmptyReturn {
requestId: string;
bookingId: string;
bookingReference: string | null;
companyName: string | null;
companyId: string | null;
requestedReturnDate: string | null;
truckPlateNumber: string | null;
truckDriverName: string | null;
truckType: string | null;
containers: Array<{ containerNumber: string; returnId: string | null }>;
}

View File

@@ -70,6 +70,8 @@ export interface InvoiceListFilter {
statuses?: string;
/** CSV of `Freight.InvoiceSource` values. */
sources?: string;
/** CSV of invoice types (see `INVOICE_TYPE_OPTIONS`). */
types?: string;
/** CSV of EIMS filing states. */
eimsStatuses?: string;
/** CSV of normalised UPPER_SNAKE payment methods (see `PAYMENT_METHOD_OPTIONS`). */
@@ -118,6 +120,11 @@ export interface OfflineUsdInvoice extends Invoice {
export interface PaginatedOfflineUsdInvoices {
items: OfflineUsdInvoice[];
total: number;
/**
* Outstanding `balanceAmount` across the whole filtered set (not the visible
* page), keyed by normalised currency — feeds the worklist's KPI strip.
*/
outstanding: Record<string, number>;
}
/** Total collected (`paidAmount`) across every filtered invoice, keyed by currency. */
@@ -166,3 +173,28 @@ export function invoicePaymentMethod(invoice: Invoice): string | null {
/** Human label for a method value; unknown values are shown as-is. */
export const paymentMethodLabel = (method: string): string =>
PAYMENT_METHOD_LABELS.get(method) ?? method;
/**
* What an invoice bills for. Every billing source mints its own `type` string,
* so this list is the known vocabulary, not a closed enum — render an unknown
* value rather than treating it as invalid.
*/
export const INVOICE_TYPE_OPTIONS: { value: string; label: string }[] = [
{ value: "PREPAID", label: "Prepaid freight" },
{ value: "WAGON_CANCEL_FEE", label: "Wagon cancellation fee" },
{ value: "GL_FINAL", label: "General contract final" },
{ value: "ADDITIONAL_CHARGE", label: "Additional charge" },
{ value: "PORT_CHARGES", label: "Port charges" },
{ value: "MISCELLANEOUS", label: "Miscellaneous" },
{ value: "DELIVERY_FEE", label: "Delivery fee" },
{ value: "LAST_MILE_ADVANCE", label: "Last-mile advance" },
{ value: "SHIPPING_LINE_CREDIT", label: "Shipping line credit" },
{ value: "STORAGE_FEE", label: "Storage fee" },
{ value: "DEMURRAGE", label: "Demurrage" },
{ value: "MIXED_WAREHOUSE_FEES", label: "Mixed warehouse fees" },
];
/** Label for an invoice `type`, falling back to the humanised raw value. */
export const invoiceTypeLabel = (type: string): string =>
INVOICE_TYPE_OPTIONS.find((o) => o.value === type)?.label ??
type.replace(/_/g, " ");

View File

@@ -192,6 +192,8 @@ export interface TrainScheduleListItem {
createdAt?: string | null;
scheduleDate: string;
trainNumber?: string | null;
/** Voyage (sailing) number for THIS departure — the schedule's own, not the train's. */
voyageNumber?: string | null;
/** Trade direction of this departure (IMPORT / EXPORT), when known. */
direction?: string | null;
routeName?: string | null;
@@ -766,6 +768,8 @@ export interface TrainScheduleDetail {
/** Cargo only (VGM/bulk tons) — the booked weight without wagon tare. */
cargoWeightTons?: number;
status: string | null;
/** Payment status — the only signal that decides whether cargo may load. */
paymentStatus?: string | null;
schedulingStatus?: SchedulingStatus | null;
freightType?: FreightType | string | null;
/** DOMESTIC = intercity ride-along; rides only its own leg below. */
@@ -933,6 +937,19 @@ export interface TrainCheckpoint {
note: string | null;
}
/**
* One corridor stop with a logged consist change (coupled/uncoupled/switched)
* — its own numbered marshalling document exists. `stopIndex` starts at 2;
* origin is always Marshalling 1 (the plain import/export load list, not
* this list). A stop with only a routine checkpoint never appears here.
*/
export interface MarshallingStop {
stopIndex: number;
yardId: string;
yardLabel: string;
firstOccurredAt: string;
}
/** The four station-work stamps, as a payload fragment both endpoints accept. */
export interface CheckpointHandlingTimes {
unloadingStartedAt?: string | null;
@@ -1021,6 +1038,12 @@ export interface ReschedulePlan {
export interface CreateTrainSchedulePayload {
routeId: string;
scheduleDate: string;
/**
* Voyage (sailing) number for this departure — required. The create dialog
* pre-fills it with the selected train's direction-matched run number; staff
* may override before submitting.
*/
voyageNumber: string;
/** Built train (Train Builder) to run this departure — its locomotives are used. */
trainId?: string;
/** Hand-picked locomotives (minimum 2 — front and back). Ignored when trainId is set. */
@@ -1132,6 +1155,8 @@ export interface IntercityBookingRow {
id: string;
reference: string | null;
status: string;
/** Payment status — the only signal that decides whether cargo may load. */
paymentStatus?: string | null;
freightType: FreightType | null;
isGovernment: boolean;
customer: string;
@@ -1252,6 +1277,8 @@ export interface IntercityRideAlongRow {
bookingId: string;
reference: string | null;
status: string;
/** Payment status — the only signal that decides whether cargo may load. */
paymentStatus?: string | null;
freightType: string | null;
weightTons: number | null;
loadedAt: string | null;

View File

@@ -145,6 +145,134 @@ export interface WarehouseYard {
zones?: WarehouseZone[];
}
export const WAREHOUSE_STACK_STATUSES = ['ACTIVE', 'INACTIVE'] as const;
export type WarehouseStackStatus = (typeof WAREHOUSE_STACK_STATUSES)[number];
/** Stored slot intent. OCCUPIED is never stored — it is derived from inventory. */
export const WAREHOUSE_SLOT_STATUSES = ['AVAILABLE', 'BLOCKED', 'RESERVED', 'INACTIVE'] as const;
export type WarehouseSlotStatus = (typeof WAREHOUSE_SLOT_STATUSES)[number];
export type SlotEffectiveStatus = WarehouseSlotStatus | 'OCCUPIED';
/** One vertical level of a ground stack. */
export interface WarehouseZoneSlot {
id: string;
stackId: string;
level: number;
status: WarehouseSlotStatus;
isActive: boolean;
}
/** One ground footprint inside a zone — where containers are stacked vertically. */
export interface WarehouseZoneStack {
id: string;
zoneId: string;
code: string;
name: string | null;
row: string | null;
bay: string | null;
position: string | null;
maxStackHeight: number;
status: WarehouseStackStatus;
isActive: boolean;
slots?: WarehouseZoneSlot[];
}
export interface SaveStackPayload {
code: string;
name?: string;
row?: string;
bay?: string;
position?: string;
maxStackHeight?: number;
status?: WarehouseStackStatus;
}
/** Configured capacity vs slots actually built vs slots actually full. */
export interface ZoneSlotSummary {
configuredCapacity: number | null;
physicalSlotCount: number;
occupiedSlotCount: number;
reservedSlotCount: number;
blockedSlotCount: number;
inactiveSlotCount: number;
availableSlotCount: number;
/** True when more slots are built than the configured capacity allows. */
inconsistent: boolean;
}
export interface ZoneLayoutSlot {
slotId: string;
level: number;
effectiveStatus: SlotEffectiveStatus;
inventoryId: string | null;
containerNumber: string | null;
}
export interface ZoneLayoutStack {
stackId: string;
code: string;
name: string | null;
maxStackHeight: number;
status: WarehouseStackStatus;
isActive: boolean;
/** Highest level first, the way the yard is seen from the side. */
slots: ZoneLayoutSlot[];
}
export interface ZoneLayout {
zoneId: string;
zoneCode: string;
zoneName: string;
stacks: ZoneLayoutStack[];
summary: ZoneSlotSummary;
}
export interface FindSlotPayload {
yardId: string;
zoneId?: string;
direction?: 'IMPORT' | 'EXPORT' | 'BOTH';
cargoTypeId?: string;
}
/** The lowest free stack level the placement engine would use next. */
export interface AvailableSlot {
slotId: string;
stackId: string;
stackCode: string;
level: number;
zoneId: string;
zoneCode: string;
}
export interface BlockingContainer {
inventoryId: string;
containerNumber: string | null;
level: number;
status: string;
}
/** Whether a container can be lifted out, and what is stacked on top of it. */
export interface ContainerAccessibility {
accessible: boolean;
inventoryId: string;
stackCode: string | null;
level: number | null;
blockingContainers: BlockingContainer[];
}
/** One container (or bulk lot) currently sitting in a zone. */
export interface ZoneContentItem {
inventoryId: string;
containerNumber: string | null;
unloadedAt: string | null;
containerType: string | null;
direction: 'IMPORT' | 'EXPORT' | null;
loadState: string | null;
status: string;
bookingReference: string | null;
}
export const FACILITY_TYPES = [
'PORT',
'DRY_PORT',
@@ -168,11 +296,16 @@ export interface Facility {
export type WarehouseFacility = Facility;
/** What a warehouse handles. Null = both. */
export const WAREHOUSE_FREIGHT_TYPES = ['CONTAINER', 'BULK'] as const;
export type WarehouseFreightType = (typeof WAREHOUSE_FREIGHT_TYPES)[number];
export interface Warehouse {
id: string;
name: string;
code: string;
type: WarehouseType;
freightType: WarehouseFreightType | null;
stationId: string | null;
facilityId: string | null;
facility?: Facility | null;
@@ -228,6 +361,8 @@ export interface WarehouseInventoryItem {
/** Flat booking summary fields attached by the inventory list (attachBookingSummaries). */
bookingReference?: string | null;
bookingStatus?: string | null;
/** Payment status of the booking — the only signal that decides "paid". */
bookingPaymentStatus?: string | null;
customerName?: string | null;
}
@@ -365,6 +500,8 @@ export interface MoveInventoryPayload {
warehouseId: string;
yardId: string;
zoneId: string;
/** Exact stack level in the destination zone. Container yards only. */
slotId?: string;
remarks?: string;
}
@@ -440,6 +577,15 @@ export interface EligibleBooking {
customerTruckType: string | null;
customerTruckContainerNumber: string | null;
customerTruckAssignedAt: string | null;
containerUnits: Array<{
containerNumber: string;
containerSize: string | null;
weightTons: number;
received: boolean;
grnNumber: string | null;
}>;
receivedContainerCount: number;
remainingContainerCount: number;
}
export interface BulkReceivePayload {
@@ -448,13 +594,23 @@ export interface BulkReceivePayload {
yardId: string;
zoneId: string;
bookingIds: string[];
containerNumbers?: string[];
truckEntrance?: TruckEntrancePayload;
}
export interface BulkReceiveResult {
receivedCount: number;
skippedCount: number;
results: { bookingId: string; status: string; inventoryId?: string; grnNumber?: string; reason?: string }[];
results: {
bookingId: string;
status: string;
inventoryId?: string;
inventoryIds?: string[];
grnNumber?: string;
receivedContainers?: number;
remainingContainers?: number;
reason?: string;
}[];
}
export interface TruckEntrancePayload {
@@ -640,6 +796,8 @@ export interface StoreInventoryPayload {
warehouseId?: string;
yardId?: string;
zoneId?: string;
/** Exact stack level. Omit to let the placement engine pick the lowest free one. */
slotId?: string;
}
export interface ImportTrainItem {
@@ -1031,6 +1189,7 @@ export interface SaveWarehousePayload {
name: string;
code: string;
type: WarehouseType;
freightType?: WarehouseFreightType | null;
stationId?: string;
facilityId?: string;
locationName?: string;
@@ -1063,6 +1222,26 @@ export interface SaveZonePayload {
status?: WarehouseStatus;
}
/**
* A loaded container already sitting in a yard but never entered in the system.
* `arrivedAt` is the true historical arrival — the row is flagged as a backlog
* registration server-side and accrues no storage or demurrage.
*/
export interface RegisterBacklogContainerPayload {
containerNumber: string;
containerTypeId: string;
warehouseId: string;
yardId: string;
zoneId: string;
arrivedAt: string;
companyId?: string;
companyName?: string;
sealNumber?: string;
weight?: number;
volume?: number;
notes?: string;
}
export interface ReceiveInventoryPayload {
warehouseId: string;
yardId: string;
@@ -1082,6 +1261,8 @@ export interface MoveInventoryPayload {
warehouseId: string;
yardId: string;
zoneId: string;
/** Exact stack level in the destination zone. Container yards only. */
slotId?: string;
remarks?: string;
}