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

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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