mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-09-06 12:25:02 +00:00
Merge branch 'dev' of github.com:Tria-plc/edr-platform into origin/freight_feature/transit
This commit is contained in:
@@ -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={
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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 && (
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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?.();
|
||||
}}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
export * from "./types";
|
||||
export * from "./RebookWagonCancellationModal";
|
||||
export * from "./WagonCancellationCreditCard";
|
||||
@@ -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;
|
||||
@@ -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>
|
||||
</>
|
||||
)}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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")),
|
||||
|
||||
@@ -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;
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -15,6 +15,7 @@ export {
|
||||
ChangeRequestReview,
|
||||
ChangeRequestPendingBadge,
|
||||
} from "./ChangeRequestReview";
|
||||
export { AccountCard } from "./AccountCard";
|
||||
export { CompanyTimeline } from "./CompanyTimeline";
|
||||
export {
|
||||
RequestDocumentChangeModal,
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -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}>
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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"
|
||||
|
||||
@@ -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 & 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>
|
||||
);
|
||||
}
|
||||
@@ -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}
|
||||
|
||||
@@ -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} />
|
||||
))}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
),
|
||||
},
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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}>
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -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");
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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");
|
||||
}
|
||||
@@ -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';
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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([]);
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -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) =>
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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, {
|
||||
|
||||
@@ -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!}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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"
|
||||
>
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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. (A–Z)" },
|
||||
];
|
||||
|
||||
/** 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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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}
|
||||
/>
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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 & 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>
|
||||
);
|
||||
}
|
||||
@@ -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"
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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}
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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);
|
||||
|
||||
|
||||
@@ -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");
|
||||
|
||||
|
||||
@@ -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> = {};
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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);
|
||||
},
|
||||
};
|
||||
@@ -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[]> => {
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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) =>
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
};
|
||||
@@ -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;
|
||||
|
||||
@@ -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 }>;
|
||||
}
|
||||
|
||||
@@ -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, " ");
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user