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

View File

@@ -0,0 +1,110 @@
import { Alert, Loader, Select, Stack, Text } from "@mantine/core";
import { useQuery } from "@tanstack/react-query";
import { AlertCircle } from "lucide-react";
import { useMemo } from "react";
import type { ETradeBusinessOption } from "@edr/types";
import { api } from "@/services/api";
/**
* The eTrade business licences held under the signed-in company's TIN, cached
* for the session. Fetching goes out to eTrade, which is slow and regularly
* down, so this must not refetch on every mount of every role card.
*/
export function useEtradeBusinesses() {
return useQuery({
...api.companies.listEtradeBusinesses.queryOptions(),
staleTime: 5 * 60 * 1000,
retry: 1,
});
}
/** One licence, as it reads in the dropdown: trade name, then what it licenses. */
export function businessLabel(b: ETradeBusinessOption): string {
const name = b.tradeName || "(no trade name on this licence)";
return b.activity ? `${name}${b.activity}` : name;
}
interface EtradeBusinessSelectProps {
/** Currently attached licence number, if any. */
value: string | null;
onChange: (licenceNumber: string) => void;
label?: string;
error?: string;
disabled?: boolean;
}
/**
* Which of the TIN's eTrade businesses a company profile operates as.
*
* A TIN routinely holds a dozen licences split by activity — export of coffee,
* freight forwarding, import of vehicles — so the role a customer signs up for
* corresponds to one specific business, not to the company as a whole. The same
* business may legitimately back several roles, so nothing is filtered out
* because it is already in use elsewhere.
*/
export default function EtradeBusinessSelect({
value,
onChange,
label = "Which business does this profile operate as?",
error,
disabled,
}: EtradeBusinessSelectProps) {
const { data, isLoading, isError } = useEtradeBusinesses();
const options = useMemo(
() =>
(data ?? []).map((b) => ({
value: b.licenceNumber,
label: businessLabel(b),
})),
[data],
);
if (isLoading) {
return (
<Stack gap={4}>
<Text size="sm" c="edr-muted">
{label}
</Text>
<Loader size="sm" color="edr-green" />
</Stack>
);
}
if (isError) {
return (
<Alert color="yellow" icon={<AlertCircle size={16} />}>
We couldn't reach eTrade to list your business licences. Try again in a
moment.
</Alert>
);
}
if (options.length === 0) {
return (
<Text size="xs" c="edr-muted">
eTrade lists no business licence under your TIN, so there is nothing to
attach here.
</Text>
);
}
return (
<Select
label={label}
placeholder="Select a business licence"
data={options}
value={value}
onChange={(v) => v && onChange(v)}
error={error}
disabled={disabled}
searchable={options.length > 8}
nothingFoundMessage="No matching licence"
// The licence number is what identifies the business; the trade name
// repeats across licences, so it alone is not enough to tell them apart.
description={value ?? undefined}
comboboxProps={{ withinPortal: true }}
/>
);
}

View File

@@ -428,6 +428,7 @@ export default function OnboardingWizardDialog({
type: p.type,
reference: p.reference,
existingFiles: p.licenseFiles ?? [],
etradeBusiness: p.etradeBusiness ?? null,
}));
// The active step across the whole journey, driving the header + progress pill.

View File

@@ -1,9 +1,13 @@
import { Anchor, Group, Stack, Text } from "@mantine/core";
import { useMutation, useQueryClient } from "@tanstack/react-query";
import { Fragment } from "react";
import { Paperclip } from "lucide-react";
import { SmartFileInput } from "@edr/ui-common";
import type { IFileUploadSetting } from "@edr/types/freight";
import type { ETradeBusinessOption, IFileUploadSetting } from "@edr/types";
import EtradeBusinessSelect from "@/components/onboarding/EtradeBusinessSelect";
import { api } from "@/services/api";
import { fetchViewableFile } from "@/services/files.service";
import type { LicenseFile } from "@/services/companies.service";
@@ -63,6 +67,8 @@ export interface RoleLicenseProfile {
reference: string;
/** License files already uploaded for this profile (rehydration). */
existingFiles: LicenseFile[];
/** The eTrade business already attached to this profile, if any. */
etradeBusiness?: ETradeBusinessOption | null;
}
interface RoleLicenseStepProps {
@@ -73,6 +79,8 @@ interface RoleLicenseStepProps {
onChange: (value: Record<string, File[]>) => void;
/** "Business license is required" style error, keyed by profile id. */
errors?: Record<string, string>;
/** "Choose a business" error, keyed by profile id. */
businessErrors?: Record<string, string>;
}
/**
@@ -86,16 +94,43 @@ export default function RoleLicenseStep({
value,
onChange,
errors,
businessErrors,
}: RoleLicenseStepProps) {
const queryClient = useQueryClient();
const setFiles = (profileId: string, files: File[]) => {
onChange({ ...value, [profileId]: files });
};
// Attaching saves immediately rather than riding along with the step's
// submit: the roles were created on the wizard's first step, so each already
// has a row to attach to, and persisting on pick means a refresh or a resumed
// draft keeps the choice.
const attach = useMutation({
mutationFn: (vars: { profileId: string; licenceNumber: string }) =>
api.companies.attachEtradeBusiness.call(vars),
onSuccess: () => {
// getInfo FIRST: the wizard reads its role list (and each role's attached
// business) from that query, so skipping it leaves the dropdown showing
// blank right after a successful pick.
queryClient.invalidateQueries({
queryKey: api.companies.getInfo.queryKey(),
});
queryClient.invalidateQueries({
queryKey: api.companies.getProfile.queryKey(),
});
queryClient.invalidateQueries({
queryKey: api.companies.onboardingRequirements.queryKey(),
});
},
});
return (
<Stack gap="md">
<Text size="sm" c="edr-muted">
Upload the business license for each of your operational profiles. You
can attach more than one document per profile.
For each operational profile, say which of your eTrade business licences
it operates as, and upload that licence. You can attach more than one
document per profile, and the same business can back more than one role.
</Text>
{profiles.map((profile) => {
@@ -104,7 +139,21 @@ export default function RoleLicenseStep({
const hasExisting = profile.existingFiles.length > 0;
return (
<>
<Fragment key={profile.id}>
<EtradeBusinessSelect
label={`Which business is your ${label} profile?`}
value={profile.etradeBusiness?.licenceNumber ?? null}
error={businessErrors?.[profile.id]}
// Only the row being saved locks; picking the importer's business
// must not freeze the exporter's dropdown next to it.
disabled={
attach.isPending && attach.variables?.profileId === profile.id
}
onChange={(licenceNumber) =>
attach.mutate({ profileId: profile.id, licenceNumber })
}
/>
{hasExisting && (
<Stack gap={4} mb="sm">
{profile.existingFiles.map((f) => (
@@ -142,7 +191,7 @@ export default function RoleLicenseStep({
setFiles(profile.id, files);
}}
/>
</>
</Fragment>
);
})}
</Stack>

View File

@@ -106,6 +106,9 @@ export const URL_CONSTANTS = {
ONBOARDING_REVERT_TO_ETRADE: "/api/companies/onboarding/revert-to-etrade",
DASHBOARD: "/api/companies/dashboard",
FETCH_ETRADE_INFO: "/api/companies/fetch-etrade-info",
ETRADE_BUSINESSES: "/api/companies/etrade-businesses",
PROFILE_ETRADE_BUSINESS: (profileId: string) =>
`/api/companies/company-profiles/${profileId}/etrade-business`,
DOCUMENTS: (id: string) => `/api/companies/${id}/documents`,
PROFILE_LICENSE: (profileId: string) =>
`/api/companies/company-profiles/${profileId}/license`,
@@ -226,6 +229,13 @@ export const URL_CONSTANTS = {
PUBLIC: "/api/support-content",
},
EMPTY_RETURN_REQUESTS: {
BASE: "/api/empty-return-requests",
ELIGIBILITY: (bookingId: string) => `/api/empty-return-requests/eligibility/${bookingId}`,
BY_BOOKING: (bookingId: string) => `/api/empty-return-requests/by-booking/${bookingId}`,
SCHEDULE: (id: string) => `/api/empty-return-requests/${id}/schedule`,
},
LAST_MILE_REQUESTS: {
BY_BOOKING: (bookingId: string) => `/api/last-mile-requests/by-booking/${bookingId}`,
BY_ID: (id: string) => `/api/last-mile-requests/${id}`,

View File

@@ -244,9 +244,17 @@ const useAuth = () => {
const createProfile = async (
type: ProfileTypeValue,
licenseFiles: File[],
/**
* Which of the TIN's eTrade businesses the new role operates as. Required
* by the API for any company that has an eTrade record.
*/
licenceNumber?: string,
): Promise<Result<void>> => {
try {
const created = await api.companies.createCompanyProfile.call({ type });
const created = await api.companies.createCompanyProfile.call({
type,
licenceNumber,
});
if (licenseFiles.length > 0) {
await companiesService.uploadProfileLicense(created.id, licenseFiles);
}

View File

@@ -17,6 +17,7 @@ import { useNavigate, useSearchParams } from "react-router-dom";
import { useFileViewer } from "@/hooks/useFileViewer";
import { bookingsService } from "@/services/bookings.service";
import { lastMileRequestsService } from "@/services/last-mile-requests.service";
import type { Freight } from "@edr/types";
import { ApproveDeliveryButton } from "../delivery/ApproveDeliveryButton";
@@ -41,6 +42,7 @@ import {
} from "./components/Notices";
import { BookingPaymentPanel } from "./components/BookingPaymentPanel";
import { AdditionalChargesPanel } from "./components/AdditionalChargesPanel";
import { EmptyReturnRequestPanel } from "./components/EmptyReturnRequestPanel";
import { HeaderButton, PageHeader } from "./components/PageHeader";
import { PaymentMethodModal } from "./components/PaymentMethodModal";
import { ScheduleCard } from "./components/ScheduleCard";
@@ -181,23 +183,70 @@ export function ReadonlyBookingView({
// backend flag counts only unsigned SELF_HAUL handovers, so no status
// heuristics are needed here.
const canApproveDelivery = Boolean(booking.handoverAwaitingSignature);
// Delivery chosen on the contract only opens a last-mile request; EDR is
// committed to that leg once the request is approved (or a leg record
// exists). Until then the customer may still bring their own truck. Same
// rule as the API's `edrHaulsThisBooking`. Collection (the export leg) has
// no approval step, so the address alone decides it.
const hasLastMileChoice =
booking.tradeDirection !== "EXPORT" && !!booking.lastMileDeliveryAddress;
const lastMileRequests = useQuery({
queryKey: ["booking-last-mile-requests", booking.id],
queryFn: () => lastMileRequestsService.listForBooking(booking.id),
enabled: hasLastMileChoice,
});
const mileSummary = useQuery({
queryKey: ["booking-mile-summary", booking.id],
queryFn: () => bookingsService.mileSummary(booking.id),
enabled: hasLastMileChoice,
});
const lastMileCommitted =
hasLastMileChoice &&
((lastMileRequests.data ?? []).some((r) => r.status === "APPROVED") ||
!!mileSummary.data?.lastMile);
const lastMileCheckPending =
hasLastMileChoice && (lastMileRequests.isPending || mileSummary.isPending);
const usesCustomerTruck =
booking.tradeDirection === "IMPORT"
? !booking.lastMileDeliveryAddress
? !lastMileCommitted
: booking.tradeDirection === "EXPORT"
? !booking.firstMilePickupAddress
: !booking.firstMilePickupAddress && !booking.lastMileDeliveryAddress;
const canAssignCustomerTruck =
booking.paymentStatus === "PAID" &&
usesCustomerTruck &&
(booking.tradeDirection === "IMPORT"
? // Import self-haul: pickup trucks are assigned only after the train has
// arrived at the destination.
status === "ARRIVED"
: // Export / domestic self-haul: delivery trucks are assigned only before
// the cargo is loaded onto the train (PAID / TRUCK_ASSIGNED). Once loaded
// (IN_TRANSIT and beyond) assignment is closed.
["PAID", "TRUCK_ASSIGNED"].includes(status));
: !booking.firstMilePickupAddress && !lastMileCommitted;
// Why truck assignment is closed, or null when it is open. The card renders
// either way — a customer who opens Logistics and finds nothing there cannot
// tell a missing feature from a stage they have not reached yet.
const truckBlockedReason = (() => {
if (lastMileCheckPending) {
return "Checking whether EDR last-mile delivery has been approved for this booking…";
}
if (!usesCustomerTruck) {
return booking.tradeDirection !== "EXPORT" && lastMileCommitted
? "EDR last-mile delivery for this booking has been approved, so no customer truck is needed."
: "EDR is handling first-mile pickup for this booking, so no customer truck is needed.";
}
if (booking.paymentStatus !== "PAID") {
return "Truck assignment opens once payment for this booking is confirmed.";
}
if (booking.tradeDirection === "IMPORT") {
// Import self-haul: pickup trucks are assigned only after the train has
// arrived at the destination.
return status === "ARRIVED" || booking.trainScheduleStatus === "ARRIVED"
? null
: "Pickup trucks can be assigned once the train arrives at the destination.";
}
// Export / domestic self-haul: delivery trucks are assigned only before the
// cargo is loaded onto the train (PAID / TRUCK_ASSIGNED). Once loaded
// (IN_TRANSIT and beyond) assignment is closed.
return ["PAID", "TRUCK_ASSIGNED"].includes(status)
? null
: "The cargo is already loaded onto the train — truck assignment is closed.";
})();
// The customer asked for EDR delivery and can still self-haul instead — say
// so, because assigning a truck here makes that pending request unapprovable.
const truckNotice =
!truckBlockedReason && hasLastMileChoice && !lastMileCommitted
? "You requested EDR last-mile delivery for this booking, and it has not been approved yet. Assigning your own truck here replaces that request — it can no longer be approved once a truck is on the booking."
: null;
const showCountdown = canPay && !!booking.paymentDeadline;
const isExpired = status === "EXPIRED";
// Customs (Path B) bookings are created AND rebooked by Global Logistics, not
@@ -415,6 +464,7 @@ export function ReadonlyBookingView({
showCountdown={showCountdown}
/>
<AdditionalChargesPanel bookingId={booking.id} />
<EmptyReturnRequestPanel bookingId={booking.id} />
<ScheduleCard
booking={booking}
title="Consignment & Schedule"
@@ -462,14 +512,16 @@ export function ReadonlyBookingView({
<BodyGrid
left={
<>
<WarehouseLocationCard bookingId={booking.id} />
{/* Truck assignment leads; where the cargo currently sits is
supporting detail beneath it. */}
<CustomerTruckAssignmentCard
booking={booking}
blockedReason={truckBlockedReason}
notice={truckNotice}
onAssigned={onBookingUpdated ?? (() => {})}
/>
{canAssignCustomerTruck && (
<CustomerTruckAssignmentCard
booking={booking}
onAssigned={onBookingUpdated ?? (() => {})}
/>
)}
<WarehouseLocationCard bookingId={booking.id} />
<MileSummaryCard booking={booking} />
</>

View File

@@ -80,6 +80,8 @@ export type BookingDetail = Freight.IBooking & {
/** The allocated train, present once the booking is placed on a schedule. */
trainSchedule?: {
trainNumber: string | null;
/** The schedule's own voyage (sailing) number for this departure. */
voyageNumber: string | null;
reference: string | null;
scheduledDepartureDate: string | null;
} | null;

View File

@@ -1,131 +1,237 @@
import { useState } from "react";
import { Alert, Button, Group, Modal, Stack, Table, Text, FileInput, Badge } from "@mantine/core";
import { Upload, Download, AlertCircle, CheckCircle, AlertTriangle } from "lucide-react";
import { useMutation } from "@tanstack/react-query";
import { useMemo, useRef, useState } from "react";
import {
Alert,
Badge,
Button,
FileButton,
Group,
List,
Modal,
Stack,
Table,
Text,
} from "@mantine/core";
import { AlertCircle, AlertTriangle, CheckCircle, Download, Upload } from "lucide-react";
import { useMutation, useQueryClient } from "@tanstack/react-query";
import type { Freight } from "@edr/types";
import { client } from "@/utils/api";
import { URL_CONSTANTS } from "@/constants/URLS";
import { generateTruckAssignmentTemplate, parseTruckAssignmentFile } from "@/utils/truck-assignment-template";
import {
downloadTruckAssignmentTemplate,
parseTruckAssignmentFile,
truckTemplateContext,
type ParsedTruckFile,
} from "@/utils/truck-assignment-template";
import type { BookingDetail } from "../booking-detail-types";
interface BulkTruckUploadResponse {
success: number;
failed: number;
errors: Array<{ index: number; row: number; truck: string; reason: string }>;
}
interface BulkTruckUploadModalProps {
opened: boolean;
onClose: () => void;
bookingId: string;
booking: BookingDetail;
trucks: Freight.ICustomerTruck[];
onSuccess?: () => void;
}
/** Long error lists are unreadable in a modal — show the first few and count the rest. */
const MAX_LISTED_ERRORS = 8;
export function BulkTruckUploadModal({
opened,
onClose,
bookingId,
booking,
trucks,
onSuccess,
}: BulkTruckUploadModalProps) {
const [file, setFile] = useState<File | null>(null);
const [parsed, setParsed] = useState<
Array<{
truckPlateNumber: string;
driverName: string;
truckType: string;
containerNumbers?: string[];
}>
>([]);
const [parseError, setParseError] = useState<string | null>(null);
const queryClient = useQueryClient();
// `resetRef` lets the customer re-pick a corrected file with the same name —
// without it the input's onChange never fires the second time.
const resetFile = useRef<() => void>(null);
const [fileName, setFileName] = useState<string | null>(null);
const [parsed, setParsed] = useState<ParsedTruckFile>({ rows: [], errors: [], rowNumbers: [] });
const [result, setResult] = useState<BulkTruckUploadResponse | null>(null);
const ctx = useMemo(() => truckTemplateContext(booking, trucks), [booking, trucks]);
const reset = () => {
resetFile.current?.();
setFileName(null);
setParsed({ rows: [], errors: [], rowNumbers: [] });
setResult(null);
};
const uploadMutation = useMutation({
mutationFn: async () => {
const { data } = await client.post(URL_CONSTANTS.BOOKINGS.CUSTOMER_TRUCKS_BULK(bookingId), {
trucks: parsed,
});
return data;
const { data } = await client.post<BulkTruckUploadResponse | { data: BulkTruckUploadResponse }>(
URL_CONSTANTS.BOOKINGS.CUSTOMER_TRUCKS_BULK(booking.id),
{ trucks: parsed.rows },
);
return ("data" in data ? data.data : data) as BulkTruckUploadResponse;
},
onSuccess: () => {
onSuccess: (response) => {
void queryClient.invalidateQueries({ queryKey: ["customer-trucks", booking.id] });
onSuccess?.();
setFile(null);
setParsed([]);
onClose();
// Only a clean run closes. A partial failure has to be shown, or the
// customer walks away believing all their trucks were created.
if (response.failed === 0) {
reset();
onClose();
return;
}
setResult(response);
},
});
const handleFileSelect = async (selectedFile: File | null) => {
if (!selectedFile) {
setFile(null);
setParsed([]);
setParseError(null);
const handleFile = async (selected: File | null) => {
setResult(null);
if (!selected) {
reset();
return;
}
setFileName(selected.name);
try {
setParseError(null);
const trucks = await parseTruckAssignmentFile(selectedFile);
setFile(selectedFile);
setParsed(trucks);
} catch (err: any) {
setParseError(err.message || "Failed to parse Excel file");
setFile(null);
setParsed([]);
setParsed(await parseTruckAssignmentFile(selected, ctx));
} catch (err) {
setParsed({
rows: [],
errors: [err instanceof Error ? err.message : "Could not read the file."],
rowNumbers: [],
});
}
};
const handleDownloadTemplate = () => {
generateTruckAssignmentTemplate("truck-assignments.xlsx");
};
const { rows, errors, rowNumbers } = parsed;
const listedErrors = errors.slice(0, MAX_LISTED_ERRORS);
const hiddenErrors = errors.length - listedErrors.length;
/** Map a server error back to the spreadsheet row the customer actually sees. */
const excelRowFor = (index: number, fallback: number) => rowNumbers[index] ?? fallback;
const columnLabel =
ctx.shape === "CONTAINER"
? "Containers"
: ctx.shape === "PER_ITEM"
? `Quantity (${ctx.itemNoun})`
: "Planned tons";
return (
<Modal
opened={opened}
onClose={onClose}
title="Bulk Upload Truck Assignments"
onClose={() => {
reset();
onClose();
}}
title="Bulk upload truck assignments"
size="lg"
centered
>
<Stack gap="lg">
<Alert icon={<AlertCircle size={16} />} color="blue">
Download template, fill with truck data, upload Excel file to bulk-create truck assignments.
Download the template for this booking, fill in one row per truck, then upload it.
{ctx.shape === "CONTAINER"
? " It lists this booking's containers and their sizes."
: ctx.remainingTons != null
? ` ${ctx.remainingTons} t are still to be hauled.`
: ""}
</Alert>
<Group>
<Button
leftSection={<Download size={16} />}
variant="light"
onClick={handleDownloadTemplate}
onClick={() => downloadTruckAssignmentTemplate(ctx)}
>
Download Template
Download template
</Button>
<FileButton resetRef={resetFile} accept=".xlsx,.xls" onChange={handleFile}>
{(props) => (
<Button {...props} variant="default" leftSection={<Upload size={16} />}>
{fileName ?? "Choose Excel file"}
</Button>
)}
</FileButton>
</Group>
<FileInput
label="Select Excel File"
placeholder="Choose .xlsx file"
accept=".xlsx,.xls"
value={file}
onChange={handleFileSelect}
leftSection={<Upload size={14} />}
/>
{parseError && (
<Alert icon={<AlertTriangle size={16} />} color="red" title="Parse Error">
{parseError}
{errors.length > 0 && (
<Alert
icon={<AlertTriangle size={16} />}
color="red"
title="Import failed — fix the file and upload it again"
>
<List size="sm" spacing={4}>
{listedErrors.map((message) => (
<List.Item key={message}>{message}</List.Item>
))}
</List>
{hiddenErrors > 0 && (
<Text size="sm" mt={6}>
and {hiddenErrors} more.
</Text>
)}
</Alert>
)}
{parsed.length > 0 && (
{result && result.failed > 0 && (
<Alert
icon={<AlertTriangle size={16} />}
color="orange"
title={`${result.success} truck(s) added, ${result.failed} rejected`}
>
<Table verticalSpacing="xs" mt="xs">
<Table.Thead>
<Table.Tr>
<Table.Th>Row</Table.Th>
<Table.Th>Plate</Table.Th>
<Table.Th>Reason</Table.Th>
</Table.Tr>
</Table.Thead>
<Table.Tbody>
{result.errors.map((e) => (
<Table.Tr key={`${e.index}-${e.truck}`}>
<Table.Td>{excelRowFor(e.index, e.row)}</Table.Td>
<Table.Td>{e.truck}</Table.Td>
<Table.Td>
<Text size="sm">{e.reason}</Text>
</Table.Td>
</Table.Tr>
))}
</Table.Tbody>
</Table>
</Alert>
)}
{rows.length > 0 && (
<>
<div>
<Text fw={600} mb="xs">
Preview ({parsed.length} trucks)
Preview ({rows.length} truck{rows.length !== 1 ? "s" : ""})
</Text>
<Table striped highlightOnHover verticalSpacing="xs">
<Table.Thead>
<Table.Tr>
<Table.Th>Plate Number</Table.Th>
<Table.Th>Driver Name</Table.Th>
<Table.Th>Truck Type</Table.Th>
<Table.Th>Containers</Table.Th>
<Table.Th>Row</Table.Th>
<Table.Th>Plate number</Table.Th>
<Table.Th>Driver</Table.Th>
<Table.Th>Truck type</Table.Th>
<Table.Th>{columnLabel}</Table.Th>
</Table.Tr>
</Table.Thead>
<Table.Tbody>
{parsed.map((truck, idx) => (
<Table.Tr key={idx}>
{rows.map((truck, idx) => (
<Table.Tr key={`${truck.truckPlateNumber}-${idx}`}>
<Table.Td>
<Text size="sm" c="dimmed">
{rowNumbers[idx]}
</Text>
</Table.Td>
<Table.Td>
<Text size="sm">{truck.truckPlateNumber}</Text>
</Table.Td>
@@ -136,18 +242,21 @@ export function BulkTruckUploadModal({
<Text size="sm">{truck.truckType}</Text>
</Table.Td>
<Table.Td>
{truck.containerNumbers?.length ? (
{ctx.shape === "CONTAINER" ? (
<Group gap="xs">
{truck.containerNumbers.map((c) => (
{(truck.containerNumbers ?? []).map((c) => (
<Badge key={c} size="sm">
{c}
</Badge>
))}
</Group>
) : (
<Text size="sm" c="dimmed">
) : ctx.shape === "PER_ITEM" ? (
<Text size="sm">
{truck.plannedQuantity}
{truck.plannedTons ? ` · ${truck.plannedTons} t` : ""}
</Text>
) : (
<Text size="sm">{truck.plannedTons} t</Text>
)}
</Table.Td>
</Table.Tr>
@@ -158,14 +267,14 @@ export function BulkTruckUploadModal({
<Group justify="space-between">
<Text size="sm" c="dimmed">
Ready to upload {parsed.length} truck(s)
Ready to upload {rows.length} truck{rows.length !== 1 ? "s" : ""}
</Text>
<Button
loading={uploadMutation.isPending}
onClick={() => uploadMutation.mutate()}
leftSection={<CheckCircle size={16} />}
>
Upload Trucks
Upload trucks
</Button>
</Group>
</>

View File

@@ -16,9 +16,20 @@ import {
TextInput,
} from "@mantine/core";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import type { Freight } from "@edr/types";
import { CheckCircle2, Clock, Download, Pencil, Plus, Trash2, Truck, Upload } from "lucide-react";
import { useState } from "react";
import { CUSTOMER_TRUCK_TYPES, type Freight } from "@edr/types";
import {
AlertTriangle,
CheckCircle2,
Clock,
Download,
Lock,
Pencil,
Plus,
Trash2,
Truck,
Upload,
} from "lucide-react";
import { useMemo, useState } from "react";
import toast from "react-hot-toast";
import { api } from "@/services/api";
@@ -26,9 +37,14 @@ import { customerTrucksService } from "@/services/customer-trucks.service";
import { CardTitle, SectionCard } from "./layout";
import { BulkTruckUploadModal } from "./BulkTruckUploadModal";
import { generateTruckAssignmentTemplate } from "@/utils/truck-assignment-template";
import type { BookingDetail } from "../booking-detail-types";
import {
downloadTruckAssignmentTemplate,
isTwentyFoot,
truckTemplateContext,
} from "@/utils/truck-assignment-template";
const TRUCK_TYPES = ["Flatbed", "Container Chassis", "Lowboy", "Box Truck", "Tipper"];
const TRUCK_TYPES = [...CUSTOMER_TRUCK_TYPES];
// Waybill-style selectable copies (indexes 1-8 in the API catalog). The 2 gate
// copies (Port Operations, Gate Security & Carrier) are always printed.
@@ -64,9 +80,19 @@ const errorMessage = (error: unknown, fallback: string) => {
export function CustomerTruckAssignmentCard({
booking,
onAssigned,
blockedReason,
notice,
}: {
booking: Freight.IBooking;
booking: BookingDetail;
onAssigned: () => void;
/**
* Why assignment is closed right now, if it is. The card still renders — and
* still lists any trucks already assigned — with this shown in place of the
* form, rather than the whole card vanishing from the Logistics tab.
*/
blockedReason?: string | null;
/** Something the customer should know before assigning — shown above the form when it is open. */
notice?: string | null;
}) {
const queryClient = useQueryClient();
const trucksKey = ["customer-trucks", booking.id];
@@ -86,6 +112,12 @@ export function CustomerTruckAssignmentCard({
const [error, setError] = useState<string | null>(null);
const [bulkModalOpen, setBulkModalOpen] = useState(false);
// What this booking hauls: containers, loose tonnage (PER_TON), or counted
// items (PER_ITEM — machinery, RoRo vehicles). Drives the form, the Excel
// template and its parser from one place.
const ctx = useMemo(() => truckTemplateContext(booking, trucks), [booking, trucks]);
const isPerItem = ctx.shape === "PER_ITEM";
// Container numbers on the booking that aren't already loaded onto a truck.
const assignedNumbers = new Set(
trucks.flatMap((t) => (t.containers ?? []).map((c) => c.containerNumber)),
@@ -94,9 +126,20 @@ export function CustomerTruckAssignmentCard({
const editingOwn = new Set(
(trucks.find((t) => t.id === editingId)?.containers ?? []).map((c) => c.containerNumber),
);
const availableContainers = (booking.containerNumbers ?? []).filter(
const containerSizes = new Map(ctx.containers.map((c) => [c.number, c.size]));
const unassignedContainers = (booking.containerNumbers ?? []).filter(
(n) => !assignedNumbers.has(n) || editingOwn.has(n),
);
// A 40ft fills the truck. Once one is picked, or two 20ft are, nothing more
// may be added — the API rejects it either way, so don't offer the choice.
const pickedHas40ft = containers.some((n) => !isTwentyFoot(containerSizes.get(n) ?? ""));
const availableContainers = unassignedContainers.filter((n) => {
if (containers.includes(n)) return true;
if (pickedHas40ft) return false;
// Something is already picked and it's 20ft — only another 20ft may join it.
if (containers.length > 0) return isTwentyFoot(containerSizes.get(n) ?? "");
return true;
});
// Containers on the booking not yet assigned to any truck (independent of edit).
const pendingAssignmentCount = (booking.containerNumbers ?? []).filter(
(n) => !assignedNumbers.has(n),
@@ -115,16 +158,12 @@ export function CustomerTruckAssignmentCard({
};
const startEdit = (t: Freight.ICustomerTruck) => {
const planned = t as Freight.ICustomerTruck & {
plannedTons?: number | string | null;
plannedQuantity?: number | null;
};
setPlateNumber(t.plateNumber ?? "");
setDriverName(t.driverName ?? "");
setTruckType(t.truckType ?? "");
setContainers((t.containers ?? []).map((c) => c.containerNumber));
setPlannedTons(planned.plannedTons != null ? Number(planned.plannedTons) : "");
setPlannedQty(planned.plannedQuantity != null ? Number(planned.plannedQuantity) : "");
setPlannedTons(t.plannedTons != null ? Number(t.plannedTons) : "");
setPlannedQty(t.plannedQuantity != null ? Number(t.plannedQuantity) : "");
setEditingId(t.id);
setError(null);
};
@@ -187,7 +226,13 @@ export function CustomerTruckAssignmentCard({
setError("Select 1 or 2 container numbers for this truck.");
return;
}
if (isBulk && plannedTons === "") {
// Counted cargo (machinery, RoRo vehicles) is committed by item count —
// tonnage is often unknown until the weighbridge, so it stays optional.
if (isBulk && isPerItem && plannedQty === "") {
setError(`Enter how many ${ctx.itemNoun} this truck will carry.`);
return;
}
if (isBulk && !isPerItem && plannedTons === "") {
setError("Enter the tonnes this truck will haul.");
return;
}
@@ -204,25 +249,27 @@ export function CustomerTruckAssignmentCard({
<CardTitle>External Truck Assignment</CardTitle>
</Group>
<Group gap={12}>
<Group gap="sm">
<Button
size="xs"
variant="default"
leftSection={<Download size={14} />}
onClick={() => generateTruckAssignmentTemplate("truck-assignments.xlsx")}
>
Download Template
</Button>
<Button
size="xs"
variant="light"
leftSection={<Upload size={14} />}
onClick={() => setBulkModalOpen(true)}
>
Bulk Upload
</Button>
</Group>
{pendingAssignmentCount > 0 && (
{!blockedReason && (
<Group gap="sm">
<Button
size="xs"
variant="default"
leftSection={<Download size={14} />}
onClick={() => downloadTruckAssignmentTemplate(ctx)}
>
Download Template
</Button>
<Button
size="xs"
variant="light"
leftSection={<Upload size={14} />}
onClick={() => setBulkModalOpen(true)}
>
Bulk Upload
</Button>
</Group>
)}
{!blockedReason && pendingAssignmentCount > 0 && (
<Text size="sm" fw={600} c="#b45309">
{pendingAssignmentCount} container{pendingAssignmentCount !== 1 ? "s" : ""} pending assignment
</Text>
@@ -306,6 +353,26 @@ export function CustomerTruckAssignmentCard({
</Alert>
)}
{/* Assignment is closed for now — say why, and keep the trucks above
visible rather than hiding the whole card. */}
{blockedReason ? (
<Alert color="gray" variant="light" icon={<Lock size={16} />}>
{blockedReason}
</Alert>
) : (
<>
{notice && (
<Alert color="yellow" variant="light" icon={<AlertTriangle size={16} />}>
{notice}
</Alert>
)}
{!isBulk && (
<Alert color="blue" variant="light">
Truck capacity: assign either <b>1 x 40ft container</b> or up to{' '}
<b>2 x 20ft containers</b>. A 40ft container must travel alone.
</Alert>
)}
{/* Add-truck form. Container bookings assign 12 containers per truck;
bulk bookings just register the truck (no container picker). */}
{isBulk || availableContainers.length > 0 ? (
@@ -337,34 +404,39 @@ export function CustomerTruckAssignmentCard({
description="20ft: up to 2 per truck · 40ft: 1 per truck"
required
placeholder="Select container numbers"
data={availableContainers}
data={availableContainers.map((n) => {
const size = containerSizes.get(n);
return { value: n, label: size ? `${n} · ${size}` : n };
})}
value={containers}
onChange={setContainers}
maxValues={2}
maxValues={pickedHas40ft ? 1 : 2}
searchable
nothingFoundMessage="No unassigned containers"
/>
)}
{isBulk && (
<NumberInput
label="Tonnes to load"
label={isPerItem ? "Tonnes to load (optional)" : "Tonnes to load"}
description={(() => {
const total = Number(booking.cargoTotalWeightVgm) || 0;
const assigned = trucks
.filter((t) => t.id !== editingId)
.reduce((s, t) => {
const x = t as Freight.ICustomerTruck & {
netWeightTons?: number | string | null;
plannedTons?: number | string | null;
};
return s + (Number(x.netWeightTons ?? x.plannedTons) || 0);
}, 0);
.reduce(
(s, t) =>
s +
(Number(
(t as Freight.ICustomerTruck & { netWeightTons?: number | string | null })
.netWeightTons ?? t.plannedTons,
) || 0),
0,
);
const remaining = Math.max(0, Math.round((total - assigned) * 1000) / 1000);
return total > 0
? `${assigned} t of ${total} t already on trucks · ${remaining} t remaining`
: "Tonnage this truck hauls";
})()}
required
required={!isPerItem}
min={0}
value={plannedTons}
onChange={(v) => setPlannedTons(v === "" ? "" : Number(v))}
@@ -372,9 +444,17 @@ export function CustomerTruckAssignmentCard({
)}
{isBulk && (
<NumberInput
label="Items quantity (pcs)"
description="Optional piece count on this truck"
// Counted cargo commits by piece count; loose bulk records it
// only as a note alongside the tonnage that actually bills.
label={isPerItem ? `Number of ${ctx.itemNoun}` : "Items quantity (pcs)"}
description={
isPerItem
? `How many ${ctx.itemNoun} ride this truck`
: "Optional piece count on this truck"
}
required={isPerItem}
min={0}
allowDecimal={false}
value={plannedQty}
onChange={(v) => setPlannedQty(v === "" ? "" : Number(v))}
/>
@@ -403,6 +483,8 @@ export function CustomerTruckAssignmentCard({
</Text>
)
)}
</>
)}
{trucks.length > 0 && (
<Stack gap="xs">
@@ -457,7 +539,8 @@ export function CustomerTruckAssignmentCard({
<BulkTruckUploadModal
opened={bulkModalOpen}
onClose={() => setBulkModalOpen(false)}
bookingId={booking.id}
booking={booking}
trucks={trucks}
onSuccess={() => {
queryClient.invalidateQueries({ queryKey: trucksKey });
onAssigned();

View File

@@ -27,6 +27,25 @@ import { ClearanceUploadedDocumentsPanel } from "@/components/contracts/Clearanc
import toast from "react-hot-toast";
/**
* A `responseType: "blob"` request delivers its JSON error body as a Blob, so
* reading `error.message` gives "Request failed with status code 400" instead
* of the reason. Read the blob back before falling back.
*/
async function downloadErrorMessage(error: unknown, fallback: string): Promise<string> {
const data = (error as { response?: { data?: unknown } })?.response?.data;
if (data instanceof Blob) {
try {
const parsed = JSON.parse(await data.text()) as { message?: unknown };
if (parsed?.message) return String(parsed.message);
} catch {
/* not JSON — fall through */
}
}
return error instanceof Error ? error.message : fallback;
}
import { bookingsService } from "@/services/bookings.service";
import type { EmptyContainerReturn } from "@/services/bookings.service";
import { saveBlob } from "@/utils/download";
@@ -308,6 +327,25 @@ export function DocumentsTab({ booking }: { booking: Freight.IBooking }) {
// One-click warehouse-document bundle: GRN + gate clearance + handover.
const [bundleBusy, setBundleBusy] = useState(false);
// The carriage acceptance sheet is its own document, not warehouse paperwork:
// direct truck-to-train cargo never sees a warehouse, and this sheet IS its
// handover record. Hiding it inside the warehouse bundle made it unfindable.
const [casBusy, setCasBusy] = useState(false);
const downloadCarriageAcceptance = async () => {
setCasBusy(true);
const ref = booking.reference ?? booking.id;
try {
const blob = await bookingsService.downloadCarriageAcceptanceSheet(booking.id);
saveBlob(blob, `carriage-acceptance-${ref}.pdf`);
} catch (error) {
toast.error(
await downloadErrorMessage(error, "Carriage acceptance sheet is not available yet."),
);
} finally {
setCasBusy(false);
}
};
const downloadWarehouseDocuments = async () => {
setBundleBusy(true);
const ref = booking.reference ?? booking.id;
@@ -654,6 +692,24 @@ export function DocumentsTab({ booking }: { booking: Freight.IBooking }) {
</SectionCard>
)}
{/* ── Carriage acceptance sheet (its own document) ────────────────── */}
<SectionCard>
<CardTitle>Carriage acceptance sheet</CardTitle>
<Text fz="12.5px" c="dimmed" mt={4} mb="sm">
The record of the cargo EDR has accepted for carriage, listing each wagon and the
containers on it, and marking which have been loaded.
</Text>
<Button
leftSection={<Download size={16} />}
color="edr-green"
variant="light"
loading={casBusy}
onClick={downloadCarriageAcceptance}
>
Download sheet
</Button>
</SectionCard>
{/* ── Warehouse documents (one-click bundle) ──────────────────────── */}
<SectionCard>
<CardTitle>Warehouse documents</CardTitle>

View File

@@ -0,0 +1,446 @@
import { useState } from "react";
import {
Alert,
Badge,
Box,
Button,
Checkbox,
Group,
Select,
Stack,
Text,
TextInput,
} from "@mantine/core";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { Container as ContainerIcon, CreditCard } from "lucide-react";
import toast from "react-hot-toast";
import { useInvoicePayment } from "@/hooks/useInvoicePayment";
import { ModalSafeWrapper } from "@/components/customer-actions/ModalSafeWrapper";
import {
emptyReturnRequestsService,
type EmptyReturnRequest,
type EmptyReturnRequestStatus,
} from "@/services/empty-return-requests.service";
import { PaymentMethodModal } from "./PaymentMethodModal";
import { CardTitle, SectionCard } from "./layout";
const TRUCK_TYPES = ["Flatbed", "Container Chassis", "Lowboy", "Box Truck", "Tipper"];
const STATUS_META: Record<EmptyReturnRequestStatus, { label: string; color: string }> = {
SUBMITTED: { label: "Awaiting EDR review", color: "#B07D14" },
APPROVED: { label: "Awaiting payment", color: "#B07D14" },
REJECTED: { label: "Rejected", color: "red" },
PAID: { label: "Paid — choose your date", color: "#1F6FEB" },
SCHEDULED: { label: "Scheduled", color: "#0A6F4D" },
COMPLETED: { label: "Returned", color: "#0A6F4D" },
CANCELLED: { label: "Cancelled", color: "#9AA8B5" },
};
const money = (amount: number | null, currency: string | null) =>
amount == null
? "—"
: `${amount.toLocaleString(undefined, { minimumFractionDigits: 2 })} ${currency ?? "ETB"}`;
const errorMessage = (error: unknown, fallback: string) => {
const data = (error as { response?: { data?: { message?: string | string[] } } })?.response?.data;
if (Array.isArray(data?.message)) return data.message.join(", ");
return data?.message ?? fallback;
};
/**
* Returning empties on a booking that did NOT buy the return service up front.
* The customer picks the containers off this booking; EDR prices and approves
* it; the customer pays here and then books the date and the truck that brings
* them in.
*/
export function EmptyReturnRequestPanel({ bookingId }: { bookingId: string }) {
const qc = useQueryClient();
const eligibilityQuery = useQuery({
queryKey: ["empty-return-eligibility", bookingId],
queryFn: () => emptyReturnRequestsService.eligibility(bookingId),
});
const requestsQuery = useQuery({
queryKey: ["empty-return-requests", bookingId],
queryFn: () => emptyReturnRequestsService.listForBooking(bookingId),
});
const requests = requestsQuery.data ?? [];
const live = requests.filter((r) => r.status !== "REJECTED" && r.status !== "CANCELLED");
const eligibility = eligibilityQuery.data;
const refresh = () => {
qc.invalidateQueries({ queryKey: ["empty-return-requests", bookingId] });
qc.invalidateQueries({ queryKey: ["empty-return-eligibility", bookingId] });
};
// Nothing to offer and nothing to show — stay out of the way entirely.
if (!eligibility?.eligible && requests.length === 0) return null;
return (
<SectionCard p={22}>
<CardTitle>Empty container return</CardTitle>
<Stack gap={14} mt={12}>
{live.map((request) => (
<RequestRow key={request.id} request={request} onChanged={refresh} />
))}
{requests
.filter((r) => r.status === "REJECTED")
.map((request) => (
<Box key={request.id} p={12} style={{ borderRadius: 10, border: "1px solid #FDE2E1" }}>
<Group justify="space-between">
<Text fz="13px" fw={600} c="#10202F">
{request.containerCount} container{request.containerCount === 1 ? "" : "s"}
</Text>
<Badge radius="sm" variant="light" color="red">
Rejected
</Badge>
</Group>
{request.rejectionReason && (
<Text fz="12px" c="#9AA8B5" mt={4}>
{request.rejectionReason}
</Text>
)}
</Box>
))}
{eligibility?.eligible ? (
<NewRequestForm
bookingId={bookingId}
availableNumbers={eligibility.availableContainerNumbers}
unitAmount={eligibility.quote.unitAmount}
currency={eligibility.quote.currency}
onCreated={refresh}
/>
) : (
eligibility?.reason &&
live.length === 0 && (
<Text fz="12px" c="#9AA8B5">
{eligibility.reason}
</Text>
)
)}
</Stack>
</SectionCard>
);
}
/** One live request: what it costs, what it is waiting on, and the next step. */
function RequestRow({
request,
onChanged,
}: {
request: EmptyReturnRequest;
onChanged: () => void;
}) {
const meta = STATUS_META[request.status];
return (
<Box p={14} style={{ borderRadius: 10, border: "1px solid #EEF2F6" }}>
<Group justify="space-between" align="flex-start" wrap="nowrap">
<Box style={{ minWidth: 0 }}>
<Group gap={6}>
<ContainerIcon size={13} color="#9AA8B5" />
<Text fz="13.5px" fw={700} c="#10202F">
{request.containerCount} empty container{request.containerCount === 1 ? "" : "s"}
</Text>
</Group>
<Text fz="12px" c="#9AA8B5" mt={2}>
{request.containerNumbers.join(", ")}
</Text>
{request.quotedTotalAmount != null && (
<Text fz="12px" c="#9AA8B5" mt={2}>
{money(request.quotedTotalAmount, request.currency)}
{request.quotedUnitAmount != null &&
` · ${money(request.quotedUnitAmount, request.currency)} per container`}
</Text>
)}
{request.requestedReturnDate && (
<Text fz="12px" c="#9AA8B5" mt={2}>
Returning {request.requestedReturnDate} · truck {request.truckPlateNumber}
</Text>
)}
</Box>
<Badge
radius="sm"
variant="light"
styles={{ root: { backgroundColor: `${meta.color}22`, color: meta.color } }}
>
{meta.label}
</Badge>
</Group>
{request.status === "APPROVED" && request.invoiceId && (
<PayButton
invoiceId={request.invoiceId}
amount={request.quotedTotalAmount ?? 0}
currency={request.currency ?? "ETB"}
/>
)}
{request.status === "PAID" && <ScheduleForm request={request} onScheduled={onChanged} />}
</Box>
);
}
function PayButton({
invoiceId,
amount,
currency,
}: {
invoiceId: string;
amount: number;
currency: string;
}) {
const [modalOpen, setModalOpen] = useState(false);
const flow = useInvoicePayment();
const close = () => {
if (!flow.processing) {
setModalOpen(false);
flow.reset();
}
};
return (
<ModalSafeWrapper>
<Button
mt={10}
size="xs"
radius="md"
fw={700}
color="edr-green"
leftSection={<CreditCard size={14} />}
onClick={(event) => {
event.stopPropagation();
setModalOpen(true);
}}
>
Pay now
</Button>
<PaymentMethodModal
opened={modalOpen}
onClose={close}
amountLabel={`${amount.toLocaleString(undefined, { minimumFractionDigits: 2 })} ${currency}`}
currency={currency}
processing={flow.processing}
error={flow.error}
otp={flow.otp}
bill={flow.bill}
onConfirm={(method, payerAccount) => flow.pay(invoiceId, method, payerAccount)}
/>
</ModalSafeWrapper>
);
}
/** After payment: when the empties come back, and on whose truck. */
function ScheduleForm({
request,
onScheduled,
}: {
request: EmptyReturnRequest;
onScheduled: () => void;
}) {
const [returnDate, setReturnDate] = useState("");
const [plate, setPlate] = useState("");
const [driver, setDriver] = useState("");
const [truckType, setTruckType] = useState<string | null>(null);
const mutation = useMutation({
mutationFn: () =>
emptyReturnRequestsService.schedule(request.id, {
returnDate,
truckPlateNumber: plate.trim(),
truckDriverName: driver.trim(),
truckType: truckType ?? undefined,
}),
onSuccess: () => {
toast.success("Return date and truck saved");
onScheduled();
},
onError: (error: unknown) => {
toast.error(errorMessage(error, "Could not save the return details"));
},
});
const ready = Boolean(returnDate && plate.trim() && driver.trim());
return (
<Stack gap={10} mt={12}>
<Text fz="12px" fw={600} c="#10202F">
Tell us when the containers are coming back
</Text>
<TextInput
size="xs"
type="date"
label="Return date"
value={returnDate}
onChange={(event) => setReturnDate(event.currentTarget.value)}
required
/>
<TextInput
size="xs"
label="Truck plate"
placeholder="3-A12345"
value={plate}
onChange={(event) => setPlate(event.currentTarget.value.toUpperCase())}
required
/>
<TextInput
size="xs"
label="Driver name"
value={driver}
onChange={(event) => setDriver(event.currentTarget.value)}
required
/>
<Select
size="xs"
label="Truck type"
placeholder="Select"
data={TRUCK_TYPES}
value={truckType}
onChange={setTruckType}
clearable
/>
<Button
size="xs"
radius="md"
fw={700}
color="edr-green"
disabled={!ready}
loading={mutation.isPending}
onClick={() => mutation.mutate()}
>
Confirm return details
</Button>
</Stack>
);
}
/**
* The booking's own containers, ticked. Only a container that came in on this
* booking can go back on it, so the customer picks from that list rather than
* typing numbers, and the count follows the ticks.
*/
function NewRequestForm({
bookingId,
availableNumbers,
unitAmount,
currency,
onCreated,
}: {
bookingId: string;
availableNumbers: string[];
unitAmount: number | null;
currency: string;
onCreated: () => void;
}) {
const [open, setOpen] = useState(false);
const [selected, setSelected] = useState<string[]>([]);
const mutation = useMutation({
mutationFn: () => emptyReturnRequestsService.create(bookingId, selected),
onSuccess: () => {
toast.success("Empty return requested — EDR will review and price it");
setOpen(false);
setSelected([]);
onCreated();
},
onError: (error: unknown) => {
toast.error(errorMessage(error, "Could not submit the request"));
},
});
if (!open) {
return (
<Stack gap={6}>
<Button
size="xs"
radius="md"
fw={700}
variant="light"
color="edr-green"
onClick={() => setOpen(true)}
>
Request empty return
</Button>
{unitAmount != null && (
<Text fz="11.5px" c="#9AA8B5">
{money(unitAmount, currency)} per container, payable after EDR approves.
</Text>
)}
</Stack>
);
}
return (
<Stack gap={10}>
<Group justify="space-between" align="center">
<Text fz="12px" fw={600} c="#10202F">
Select the containers you are returning
</Text>
<Button
size="compact-xs"
variant="subtle"
color="edr-green"
onClick={() =>
setSelected(selected.length === availableNumbers.length ? [] : [...availableNumbers])
}
>
{selected.length === availableNumbers.length ? "Clear all" : "Select all"}
</Button>
</Group>
<Stack gap={6}>
{availableNumbers.map((number) => (
<Checkbox
key={number}
size="xs"
label={number}
checked={selected.includes(number)}
onChange={(event) =>
setSelected((current) =>
event.currentTarget.checked
? [...current, number]
: current.filter((value) => value !== number),
)
}
/>
))}
</Stack>
{unitAmount != null && selected.length > 0 && (
<Alert color="gray" p={10}>
<Text fz="12px">
Estimated {money(unitAmount * selected.length, currency)} for {selected.length} container
{selected.length === 1 ? "" : "s"}. EDR confirms the price when it approves your request.
</Text>
</Alert>
)}
<Group gap={8}>
<Button size="xs" variant="default" radius="md" onClick={() => setOpen(false)}>
Cancel
</Button>
<Button
size="xs"
radius="md"
fw={700}
color="edr-green"
disabled={selected.length === 0}
loading={mutation.isPending}
onClick={() => mutation.mutate()}
>
Submit request{selected.length > 0 ? ` (${selected.length})` : ""}
</Button>
</Group>
</Stack>
);
}

View File

@@ -106,12 +106,19 @@ export function ScheduleCard({
value: <StatusPill status={booking.status as string} />,
};
// The schedule's own voyage (sailing) number — shown once the booking has an
// assigned train that carries one.
const voyageRows: Row[] = schedule?.voyageNumber
? [{ label: "Voyage number", value: schedule.voyageNumber }]
: [];
const rows: Row[] = consignment
? [
{ label: "Consignment ID", value: booking.reference },
{ label: "Service", value: service },
{ label: "Equipment return", value: equipmentReturn },
assignedTrain,
...voyageRows,
{ label: "Scheduled", value: fmtDate(booking.scheduledDate) },
]
: [
@@ -120,6 +127,7 @@ export function ScheduleCard({
{ label: "Equipment return", value: equipmentReturn },
{ label: "Proposed date", value: fmtDate(booking.scheduledDate) },
assignedTrain,
...voyageRows,
];
return (

View File

@@ -139,8 +139,17 @@ export function WagonCancellationCard({
// rows this booking opened itself can be paid/withdrawn/rebooked from here.
const rows = data?.items ?? [];
const ownRows = rows.filter((r) => r.bookingId === booking.id);
const openRow = ownRows.find((r) => r.status === "FEE_PENDING");
const creditRow = ownRows.find((r) => r.status === "CREDIT_AVAILABLE");
// A cancellation owes its fee whenever a customer-fault fee is still
// unsettled. FEE_PENDING is the customer-requested flow (cut applies at
// payment); an AT-LOADING cut applies immediately and jumps straight to
// CREDIT_AVAILABLE with its invoice left open — so status alone would hide
// the fee and offer a "no further payment needed" rebook on money still owed.
const owesFee = (r: (typeof ownRows)[number]) =>
r.fault === "CUSTOMER" && Number(r.feeAmount ?? 0) > 0 && !r.feePaidAt;
const openRow = ownRows.find((r) => r.status === "FEE_PENDING" || owesFee(r));
const creditRow = ownRows.find(
(r) => r.status === "CREDIT_AVAILABLE" && !owesFee(r),
);
const feePay = useFeeInvoicePayment(booking.id);
@@ -237,7 +246,14 @@ export function WagonCancellationCard({
toast.error(apiErrorMessage(e, "Could not rebook the wagons. Please try again.")),
});
if (!eligible) return null;
// Showing the card is NOT the same as allowing a new cut. A partial cancel at
// loading leaves the kept wagons to ride, so the booking moves on to
// IN_TRANSIT/ARRIVED/COMPLETED while its cancellation still owes a fee and
// holds a rebookable credit. Gating on PAID/CANCELLED hid exactly that case —
// the customer saw neither the cancelled wagons nor the fee they owe. Any
// booking that HAS cancellation rows keeps the card, whatever its status;
// `canRequest` still limits NEW cuts to a live PAID booking.
if (!eligible && !ownRows.length) return null;
if (!canRequest && !ownRows.length) return null;
return (
@@ -265,8 +281,10 @@ export function WagonCancellationCard({
{fmtMoney(openRow.feeAmount, openRow.feeCurrency)}
</Text>
. The cancelled wagons have left the train. Pay the fee to unlock
the rebooking credit. The request cannot be withdrawn from here
if it was a mistake, contact EDR staff.
the rebooking credit the {Number(openRow.wagonsCancelled)} cancelled
wagon(s) can then be rebooked on a coming train day. The request
cannot be withdrawn from here if it was a mistake, contact EDR
staff.
</Alert>
<Group gap={8}>
<Button
@@ -293,7 +311,7 @@ export function WagonCancellationCard({
{isCustoms || oddFt20Credit ? (
<Text fz={13} c="#475569">
{isCustoms
? "This is a customs-cleared booking — Global Logistics will rebook the credit for you."
? "This is a customs-cleared booking — Global Logistics (EDR staff) will rebook the credit for you on a coming train day. Contact them if you have a preferred date."
: "Your credit includes an odd 20ft container that must share a wagon with another booking — Global Logistics will rebook it for you and pair the wagon. Please contact EDR staff."}
</Text>
) : (

View File

@@ -459,7 +459,15 @@ export default function BookingsListPage() {
const creditByBooking = useMemo(() => {
const m = new Map<string, WagonCancellation>();
for (const r of myCancellations?.items ?? []) {
if (r.status === "CREDIT_AVAILABLE" && !m.has(r.bookingId)) m.set(r.bookingId, r);
// A customer-fault cut invoices a fee. An at-loading cut applies at once
// and opens the credit with that invoice still OPEN, so CREDIT_AVAILABLE
// alone never means the fee was settled — offering "Rebook" here would
// let the customer redeem the wagons without ever paying. Those rows fall
// through to the row's Pay button instead (the fee is on my-payables).
const owesFee =
r.fault === "CUSTOMER" && Number(r.feeAmount ?? 0) > 0 && !r.feePaidAt;
if (r.status === "CREDIT_AVAILABLE" && !owesFee && !m.has(r.bookingId))
m.set(r.bookingId, r);
}
return m;
}, [myCancellations]);

Some files were not shown because too many files have changed in this diff Show More