feat: add BookingWagonsPanel component for displaying allocated wagons in booking details

- Implemented BookingWagonsPanel to show allocated wagons, their containers, and export functionality.
- Integrated the new panel into BookingRequestDetailPage and BookingRequestsPage.
- Enhanced wagon cancellation modal to support rebooking of wagon cancellations with partner units.
- Updated API service to include a method for downloading wagons workbook.
- Modified types and constants to accommodate new features related to wagons.
- Adjusted various components and pages to ensure compatibility with the new wagon-related functionality.
This commit is contained in:
marshalyordanos
2026-09-03 15:39:27 +03:00
parent 165146c09d
commit 9c57aa1c0c
23 changed files with 1244 additions and 61 deletions

View File

@@ -0,0 +1,255 @@
import { useMemo, useState } from "react";
import { useQuery } from "@tanstack/react-query";
import {
Badge,
Button,
Center,
Group,
Loader,
SimpleGrid,
Stack,
Table,
Text,
} from "@mantine/core";
import { Container, FileSpreadsheet, Train } from "lucide-react";
import toast from "react-hot-toast";
import { api } from "@/services/api";
import { bookingsService } from "@/services/bookings.service";
import { extractDownloadErrorMessage } from "@/components/warehouses/options";
import { formatDate } from "@/lib/format";
import type { BookingWagonRow } from "@/types/trainScheduling";
import { SectionCard } from "./SectionCard";
import { MetricTile } from "./MetricTile";
/** pg returns numerics as strings; everything here is arithmetic on tons/metres. */
const num = (value: number | string | null | undefined): number => {
const parsed = Number(value ?? 0);
return Number.isFinite(parsed) ? parsed : 0;
};
const tons = (value: number | string | null | undefined): string =>
`${num(value).toLocaleString(undefined, { maximumFractionDigits: 3 })} t`;
/** Allocation status → badge colour. PLANNED is the pre-loading default. */
const STATUS_COLORS: Record<string, string> = {
PLANNED: "blue",
LOADED: "edr-green",
UNLOADED: "gray",
CANCELLED: "red",
};
/**
* The booking detail page's "Wagons" tab: every wagon allocated to this booking,
* with its containers or bulk load, plus an Excel export of the same list.
*
* A booking has no wagons until it is paid and placed on a train, so the empty
* state is the normal case for most of a booking's life — it explains the
* precondition rather than reading as an error.
*/
export function BookingWagonsPanel({
bookingId,
bookingReference,
}: {
bookingId: string;
bookingReference: string;
}) {
const [exporting, setExporting] = useState(false);
const { data, isLoading, isError } = useQuery(
api.trainScheduling.bookingWagons.queryOptions({ input: { bookingId } }),
);
const wagons = useMemo<BookingWagonRow[]>(() => data ?? [], [data]);
const totals = useMemo(() => {
const containerCount = wagons.reduce(
(sum, w) => sum + (w.containers?.length ?? 0),
0,
);
const allocated = wagons.reduce(
(sum, w) => sum + num(w.allocatedWeightTons),
0,
);
const capacity = wagons.reduce((sum, w) => sum + num(w.capacityTons), 0);
return { containerCount, allocated, capacity };
}, [wagons]);
// The train is a property of the allocation, so every wagon on this booking
// carries the same one — read it off the first row rather than per row.
const train = wagons[0];
const handleExport = async () => {
setExporting(true);
try {
const blob = await bookingsService.downloadWagonsWorkbook(bookingId);
const url = URL.createObjectURL(blob);
const a = document.createElement("a");
a.href = url;
a.download = `wagons-${bookingReference}.xlsx`;
a.click();
URL.revokeObjectURL(url);
} catch (error) {
// Blob response: the JSON reason is inside the Blob, so the sync path
// would surface only "Request failed with status code 400".
toast.error(await extractDownloadErrorMessage(error));
} finally {
setExporting(false);
}
};
if (isLoading) {
return (
<Center py="xl">
<Loader size="sm" />
</Center>
);
}
return (
<SectionCard
icon={Train}
title="Allocated wagons"
subtitle={
wagons.length
? `${wagons.length} wagon${wagons.length === 1 ? "" : "s"}${
train?.trainNumber ? ` on train ${train.trainNumber}` : ""
}`
: "No wagons allocated yet"
}
extra={
<Button
size="compact-sm"
variant="light"
color="edr-green"
leftSection={<FileSpreadsheet size={15} />}
loading={exporting}
// The sheet would be headers with no rows — nothing to hand over.
disabled={wagons.length === 0}
onClick={() => void handleExport()}
>
Export Excel
</Button>
}
>
{isError ? (
<Text size="sm" c="red">
Could not load the wagon allocations for this booking.
</Text>
) : wagons.length === 0 ? (
<Text size="sm" c="dimmed">
Wagons appear here once the booking is paid and allocated onto a train.
</Text>
) : (
<Stack gap="lg">
<SimpleGrid cols={{ base: 2, sm: 4 }} spacing="sm">
<MetricTile label="Wagons" value={String(wagons.length)} />
<MetricTile
label="Containers"
value={String(totals.containerCount)}
/>
<MetricTile label="Allocated" value={tons(totals.allocated)} />
<MetricTile label="Capacity" value={tons(totals.capacity)} />
</SimpleGrid>
{train?.departureAt ? (
<Group gap="xs">
<Text size="xs" c="dimmed">
Departs {formatDate(train.departureAt)}
</Text>
{train.originStation && train.destinationStation ? (
<Text size="xs" c="dimmed">
· {train.originStation} {train.destinationStation}
</Text>
) : null}
</Group>
) : null}
<Table.ScrollContainer minWidth={720}>
<Table striped highlightOnHover verticalSpacing="sm">
<Table.Thead>
<Table.Tr>
<Table.Th>Seq</Table.Th>
<Table.Th>Wagon</Table.Th>
<Table.Th>Type</Table.Th>
<Table.Th>Status</Table.Th>
<Table.Th ta="right">Allocated</Table.Th>
<Table.Th ta="right">Capacity</Table.Th>
<Table.Th>Load</Table.Th>
</Table.Tr>
</Table.Thead>
<Table.Tbody>
{wagons.map((w) => (
<Table.Tr key={w.allocationId}>
<Table.Td>
<Text size="sm" c="dimmed">
{w.sequenceNo ?? "—"}
</Text>
</Table.Td>
<Table.Td>
<Text size="sm" fw={600}>
{w.wagonNumber ?? "—"}
</Text>
</Table.Td>
<Table.Td>
<Text size="sm">{w.wagonType ?? "—"}</Text>
</Table.Td>
<Table.Td>
<Badge
size="sm"
variant="light"
color={STATUS_COLORS[w.status] ?? "gray"}
>
{w.status}
</Badge>
</Table.Td>
<Table.Td ta="right">
<Text size="sm">{tons(w.allocatedWeightTons)}</Text>
</Table.Td>
<Table.Td ta="right">
<Text size="sm" c="dimmed">
{tons(w.capacityTons)}
</Text>
</Table.Td>
<Table.Td>
{w.containers?.length ? (
<Stack gap={2}>
{w.containers.map((c, i) => (
<Group
key={`${w.allocationId}-${c.containerNumber ?? i}`}
gap={6}
wrap="nowrap"
>
<Container
size={13}
style={{ opacity: 0.5, flexShrink: 0 }}
/>
<Text size="xs">
{c.containerNumber ?? "—"}
{c.sizeFt ? ` · ${c.sizeFt}ft` : ""}
</Text>
</Group>
))}
</Stack>
) : w.bulkCargoDescription || w.loadType === "BULK" ? (
<Text size="xs">
{w.bulkCargoDescription ?? "Bulk"}
{w.bulkQuantity ? ` · ${num(w.bulkQuantity)}` : ""}
</Text>
) : (
<Text size="xs" c="dimmed">
</Text>
)}
</Table.Td>
</Table.Tr>
))}
</Table.Tbody>
</Table>
</Table.ScrollContainer>
</Stack>
)}
</SectionCard>
);
}

View File

@@ -3,6 +3,7 @@ export * from "./SectionCard";
export * from "./ClearanceReviewSection";
export * from "./BookingDocumentsPanel";
export * from "./BookingTrucksPanel";
export * from "./BookingWagonsPanel";
export * from "./ContractOrdersPanel";
export * from "./MetricTile";
export * from "./BookingDetailToolbar";

View File

@@ -1,11 +1,11 @@
import { useEffect, useState } from "react";
import { useEffect, useMemo, useState } from "react";
import { Button, Group, Modal, Select, Stack, Text, TextInput } from "@mantine/core";
import { DatePickerInput } from "@mantine/dates";
import { useMutation, useQuery } from "@tanstack/react-query";
import toast from "react-hot-toast";
import { OperationDatePicker } from "@edr/ui-common";
import { api } from "@/auth/http";
import { toDayString } from "@/hooks/useListControls";
import { api as rpc } from "@/services/api";
import { formatMoney } from "@/lib/format";
import {
hasOddFt20,
@@ -13,6 +13,7 @@ import {
type WagonCancellation,
} from "./types";
/** Editable rebook unit — prefilled from the cancelled snapshot. */
interface RebookUnitDraft {
containerSize: string;
@@ -21,6 +22,46 @@ interface RebookUnitDraft {
vgmTons: number | "";
}
/** Editable unit on the consolidation partner — prefilled from its own cargo. */
interface PartnerUnitDraft {
id: string;
containerSize: string;
containerNumber: string;
sealNumber: string;
vgmTons: number | "";
}
const partnerDraftsFrom = (c: RebookPartnerCandidate | undefined) =>
(c?.units ?? []).map((u) => ({
id: u.id,
containerSize: u.containerSize,
containerNumber: u.containerNumber,
sealNumber: u.sealNumber ?? "",
vgmTons: Number(u.vgmTons) || ("" as const),
}));
/** Only the units GL actually changed are sent. */
const partnerUnitsPayload = (
drafts: PartnerUnitDraft[],
original: PartnerUnitDraft[],
) =>
drafts
.filter((d, i) => {
const o = original[i];
return (
!o ||
d.containerNumber !== o.containerNumber ||
d.sealNumber !== o.sealNumber ||
d.vgmTons !== o.vgmTons
);
})
.map((d) => ({
id: d.id,
containerNumber: d.containerNumber.trim(),
sealNumber: d.sealNumber.trim(),
...(d.vgmTons !== "" ? { vgmTons: Number(d.vgmTons) } : {}),
}));
const draftsFrom = (r: WagonCancellation): RebookUnitDraft[] =>
(r.cancelledQuantities?.units ?? []).map((u) => ({
containerSize: u.containerSize,
@@ -62,30 +103,75 @@ export function RebookWagonCancellationModal({
/** Called after a successful rebook with the new booking id (when the API returns it). */
onRebooked?: (result: { bookingId?: string }) => void;
}) {
const [date, setDate] = useState<Date | null>(null);
// Held as the picker's own `yyyy-MM-dd` string, never a Date: converting a
// local-midnight Date back with toISOString() shifts it into the previous day
// in any timezone east of UTC (EAT is +03), which both mis-rendered the
// selection and submitted the wrong shipment day.
const [date, setDate] = useState<string | null>(null);
const [partnerId, setPartnerId] = useState<string | null>(null);
const [partnerDrafts, setPartnerDrafts] = useState<PartnerUnitDraft[]>([]);
const [drafts, setDrafts] = useState<RebookUnitDraft[]>([]);
// Fresh form per row: the modal instance is long-lived on the host page.
useEffect(() => {
setDate(null);
setPartnerId(null);
setPartnerDrafts([]);
setDrafts(cancellation ? draftsFrom(cancellation) : []);
}, [cancellation]);
const needsPartner = cancellation ? hasOddFt20(cancellation) : false;
// The rebook rides the same lane with the same cargo as the cancelled
// shipment, so the shipment day must come from the days that lane actually
// runs — an arbitrary calendar day has no train and no wagon capacity.
const daysQuery = useMemo(() => {
const b = cancellation?.booking;
if (!b?.originYardId || !b?.destinationYardId) return null;
const containers = Object.entries(
cancellation?.cancelledQuantities?.bySize ?? {},
)
.map(([containerSize, quantity]) => ({
containerSize,
quantity: Number(quantity || 0),
}))
.filter((c) => c.quantity >= 1);
if (containers.length > 0) {
return {
originYardId: b.originYardId,
destinationYardId: b.destinationYardId,
freightType: "CONTAINER" as const,
containers,
};
}
const tons = Number(cancellation?.weightTons || 0);
if (tons <= 0) return null;
return {
originYardId: b.originYardId,
destinationYardId: b.destinationYardId,
freightType: "BULK" as const,
totalWeightTons: tons,
};
}, [cancellation]);
const { data: availableDays, isLoading: daysLoading } = useQuery({
...rpc.trainScheduling.availableDaysForCargo.queryOptions({
input: daysQuery ?? { freightType: "BULK" as const },
}),
enabled: Boolean(cancellation) && daysQuery !== null,
});
const partners = useQuery({
queryKey: [
"wagon-cancellations",
cancellation?.id,
"rebook-partners",
date ? toDayString(date) : null,
date,
],
enabled: Boolean(cancellation && needsPartner && date),
queryFn: async () => {
const res = await api.get<RebookPartnerCandidate[]>(
`/bookings/wagon-cancellations/${cancellation!.id}/rebook-partners`,
{ params: { scheduledDate: toDayString(date!) } },
{ params: { scheduledDate: date } },
);
return res.data;
},
@@ -96,15 +182,28 @@ export function RebookWagonCancellationModal({
const res = await api.post<{ bookingId?: string }>(
`/bookings/wagon-cancellations/${cancellation!.id}/rebook`,
{
scheduledDate: toDayString(date!),
scheduledDate: date,
...(drafts.length ? { containers: containersPayload(drafts) } : {}),
...(partnerId ? { partnerBookingId: partnerId } : {}),
...(() => {
if (!partnerId) return {};
const original = partnerDraftsFrom(
(partners.data ?? []).find((c) => c.id === partnerId),
);
const changed = partnerUnitsPayload(partnerDrafts, original);
return changed.length ? { partnerUnits: changed } : {};
})(),
},
);
return res.data ?? {};
},
});
const patchPartnerDraft = (i: number, patch: Partial<PartnerUnitDraft>) =>
setPartnerDrafts((prev) =>
prev.map((d, idx) => (idx === i ? { ...d, ...patch } : d)),
);
const patchDraft = (i: number, patch: Partial<RebookUnitDraft>) =>
setDrafts((prev) => prev.map((x, idx) => (idx === i ? { ...x, ...patch } : x)));
@@ -115,6 +214,9 @@ export function RebookWagonCancellationModal({
title="Rebook cancelled wagons"
centered
radius="md"
// Wide enough for the calendar plus two container-unit editors side by
// side without the number / seal / VGM fields cramping.
size="xl"
>
{cancellation && (
<Stack gap="sm">
@@ -123,21 +225,30 @@ export function RebookWagonCancellationModal({
{cancellation.wagonsCancelled} wagon(s) · credit{" "}
{formatMoney(cancellation.creditAmount, cancellation.feeCurrency, 2)}
</Text>
<DatePickerInput
label="Shipment day"
placeholder="Pick the day"
value={date}
onChange={(v) => {
setDate(v ? new Date(v) : null);
<Text size="sm" fw={600}>
Shipment day
</Text>
<OperationDatePicker
fullWidth
availableDays={daysQuery === null ? [] : (availableDays ?? [])}
isLoading={daysQuery !== null && daysLoading}
emptyMessage={
daysQuery === null
? "This cancellation has no route or cargo on record — the available shipment days cannot be worked out."
: "No train day on this route can take this cargo right now."
}
value={date ?? ""}
onChange={(d) => {
setDate(d || null);
setPartnerId(null);
setPartnerDrafts([]);
}}
minDate={new Date()}
radius="md"
/>
{needsPartner && (
<Select
label="Consolidation partner"
description="This credit has an odd 20ft container — pick the odd booking that shares its wagon. The rebooked booking is paid; it ships once the partner pays."
withAsterisk
placeholder={
!date
? "Pick the day first"
@@ -150,7 +261,14 @@ export function RebookWagonCancellationModal({
label: `${c.reference} · ${c.companyName ?? "—"} · ${c.ft20Quantity}×20ft`,
}))}
value={partnerId}
onChange={setPartnerId}
onChange={(v) => {
setPartnerId(v);
setPartnerDrafts(
partnerDraftsFrom(
(partners.data ?? []).find((c) => c.id === v),
),
);
}}
disabled={!date}
searchable
radius="md"
@@ -161,12 +279,66 @@ export function RebookWagonCancellationModal({
!partners.isLoading &&
(partners.data ?? []).length === 0 && (
<Text size="xs" c="orange">
No odd-20ft booking rides that day pick another day or wait for
a partner booking.
No odd-20ft booking rides that day pick another day, or wait
for a booking that can share this wagon.
</Text>
)}
{partnerId && partnerDrafts.length > 0 && (
<Stack gap={6}>
<Text size="xs" fw={600}>
Partner containers
</Text>
<Text size="xs" c="dimmed">
Correct the partner booking's own container details if they
changed — sizes and quantities stay as booked.
</Text>
{partnerDrafts.map((d, i) => (
<Group key={d.id} gap={8} wrap="nowrap" align="flex-end">
<TextInput
label={`${d.containerSize || "Container"}`}
value={d.containerNumber}
onChange={(e) =>
patchPartnerDraft(i, {
containerNumber: e.currentTarget.value,
})
}
size="xs"
radius="md"
style={{ flex: 1.4 }}
/>
<TextInput
label="Seal no."
value={d.sealNumber}
onChange={(e) =>
patchPartnerDraft(i, { sealNumber: e.currentTarget.value })
}
size="xs"
radius="md"
style={{ flex: 1 }}
/>
<TextInput
label="VGM (t)"
type="number"
value={d.vgmTons === "" ? "" : String(d.vgmTons)}
onChange={(e) => {
const raw = e.currentTarget.value;
patchPartnerDraft(i, {
vgmTons: raw === "" ? "" : Number(raw),
});
}}
size="xs"
radius="md"
style={{ width: 90 }}
/>
</Group>
))}
</Stack>
)}
{drafts.length > 0 && (
<Stack gap={6}>
<Text size="xs" fw={600}>
This booking's containers
</Text>
<Text size="xs" c="dimmed">
Correct the container details if they changed sizes and
quantities stay as cancelled.

View File

@@ -29,6 +29,11 @@ export interface WagonCancellation {
reference: string;
customsClearingEnabled?: boolean;
company?: { name: string };
/** Route of the cancelled shipment — the rebook rides the same lane, so the
* shipment-day picker offers only days that lane actually runs. */
originYardId?: string | null;
destinationYardId?: string | null;
freightType?: string | null;
};
rebookedBooking?: { id: string; reference: string };
feeInvoice?: { invoiceNumber: string; status: string };
@@ -48,6 +53,14 @@ export interface WagonCancellationListResponse {
total: number;
}
export interface RebookPartnerUnit {
id: string;
containerSize: string;
containerNumber: string;
sealNumber: string | null;
vgmTons: number;
}
export interface RebookPartnerCandidate {
id: string;
reference: string;
@@ -55,6 +68,8 @@ export interface RebookPartnerCandidate {
status: string;
scheduledDate: string | null;
ft20Quantity: number;
/** The partner's own container units — editable while pairing. */
units?: RebookPartnerUnit[];
}
export const WAGON_CANCELLATION_STATUS_CHIP: Record<

View File

@@ -18,8 +18,8 @@ import type { ConsolidationCandidate } from "@/services/contracts.service";
/**
* Picker for the booking that shares this booking's wagon. The server has
* already narrowed the list to bookings that can legally pair — same route and
* direction, customs clearing, an odd 20ft count of their own and not already
* linked to someone else — so every row here is a valid choice.
* direction, same booking day, customs clearing, an odd 20ft count of their own
* and not already linked to someone else — so every row here is a valid choice.
*/
interface Props {
opened: boolean;
@@ -55,8 +55,8 @@ export function ConsolidationPartnerPicker({
Pick the parent booking
</Text>
<Text fz="xs" c="dimmed">
Customs bookings on the same route that also carry an odd number of
20ft containers.
Customs bookings on the same route and booking day that also carry
an odd number of 20ft containers.
</Text>
</Box>
</Group>
@@ -84,9 +84,10 @@ export function ConsolidationPartnerPicker({
title="No booking available to share this wagon"
>
<Text fz="sm">
No other customs booking on this route currently carries an odd
number of 20ft containers. Either wait for one, or switch the
shared-wagon option off and book an even number of 20ft containers.
No other customs booking on this route and booking day currently
carries an odd number of 20ft containers. Either wait for one, or
switch the shared-wagon option off and book an even number of 20ft
containers.
</Text>
</Alert>
) : (
@@ -113,9 +114,7 @@ export function ConsolidationPartnerPicker({
? ` · ${candidate.tradeDirection}`
: ""}
{" · "}
{candidate.hasCargo
? `${candidate.ft20Quantity} × 20ft`
: "cargo not entered yet"}
{`${candidate.ft20Quantity} × 20ft`}
</Text>
</Box>
<Button

View File

@@ -226,6 +226,7 @@ export const URL_CONSTANTS = {
CONTRACT_DOWNLOAD: (id: string) => `/bookings/${id}/contract`,
CARRIAGE_ACCEPTANCE_SHEET: (id: string) =>
`/bookings/${id}/carriage-acceptance-sheet`,
WAGONS_EXPORT: (id: string) => `/bookings/${id}/wagons/export`,
EXPORT_HANDOVER_MODE: (id: string) =>
`/bookings/${id}/export-handover-mode`,
SUMMARY: (id: string) => `/bookings/${id}/summary`,

View File

@@ -44,6 +44,18 @@ export function toBookingListRow(booking: BookingDetail): BookingListRow {
booking.serviceType?.name ??
booking.serviceType?.code,
trainScheduleId: booking.trainScheduleId ?? null,
// List rows carry the flat departure date; detail responses carry the fuller
// summary object instead — fall back to it so a row mapped from either shape
// shows the same date.
trainScheduleDepartureDate:
booking.trainScheduleDepartureDate ??
booking.trainScheduleSummary?.scheduledDepartureDate ??
null,
trainScheduleReference:
booking.trainScheduleReference ??
booking.trainScheduleSummary?.reference ??
booking.trainScheduleSummary?.trainNumber ??
null,
isGovernment: booking.isGovernment ?? false,
governmentInstitution: booking.governmentInstitution ?? null,
consolidationPartnerId: booking.consolidationPartnerId ?? null,

View File

@@ -16,6 +16,7 @@ import {
Receipt,
RefreshCw,
Ship,
Train,
Truck,
Wallet,
Weight,
@@ -63,6 +64,7 @@ import {
BookingSchedulingWindowCard,
BookingDocumentsPanel,
BookingTrucksPanel,
BookingWagonsPanel,
ContractOrdersPanel,
} from "@/components/bookings/detail";
import { WarehouseInfoCard } from "@/components/warehouses";
@@ -219,9 +221,11 @@ export default function BookingRequestDetailPage() {
? "documents"
: requestedTab === "trucks"
? "trucks"
: requestedTab === "additional-charges"
? "additional-charges"
: "overview";
: requestedTab === "wagons"
? "wagons"
: requestedTab === "additional-charges"
? "additional-charges"
: "overview";
const setActiveTab = (tab: string | null) => {
const next = new URLSearchParams(searchParams);
if (tab && tab !== "overview") next.set("tab", tab);
@@ -522,6 +526,9 @@ export default function BookingRequestDetailPage() {
<Tabs.Tab value="trucks" leftSection={<Truck size={16} />}>
Trucks
</Tabs.Tab>
<Tabs.Tab value="wagons" leftSection={<Train size={16} />}>
Wagons
</Tabs.Tab>
{canSeeAdditionalCharges && (
<Tabs.Tab
value="additional-charges"
@@ -549,6 +556,12 @@ export default function BookingRequestDetailPage() {
<Tabs.Panel value="trucks">
<BookingTrucksPanel bookingId={booking.id} />
</Tabs.Panel>
<Tabs.Panel value="wagons">
<BookingWagonsPanel
bookingId={booking.id}
bookingReference={booking.reference}
/>
</Tabs.Panel>
{canSeeAdditionalCharges && (
<Tabs.Panel value="additional-charges">
<AdditionalPaymentsTab bookingId={booking.id} onViewFile={view} />

View File

@@ -13,6 +13,7 @@ import {
Plus,
RefreshCw,
Ship,
Train,
User,
} from "lucide-react";
import { useCallback, useMemo, useRef, useState } from "react";
@@ -609,7 +610,7 @@ export default function BookingRequestsPage() {
},
{
id: "scheduled",
header: () => <span className={bookingTable.headerCell}>Scheduled</span>,
header: () => <span className={bookingTable.headerCell}>Requested</span>,
cell: ({ row }) => (
<span className="inline-flex items-center gap-1.5 text-sm text-muted-foreground">
<Calendar className="size-3.5" />
@@ -617,6 +618,50 @@ export default function BookingRequestsPage() {
</span>
),
},
{
// The date of the train the booking is actually allocated to. Empty until
// allocation, which is why it is separate from the requested date above —
// the two differ whenever staff move a booking to another day.
id: "scheduledDate",
header: () => (
<span className={bookingTable.headerCell}>Scheduled date</span>
),
cell: ({ row }) => {
const b = row.original;
if (!b.trainScheduleDepartureDate) {
return (
<span className="text-sm text-muted-foreground">
Not scheduled
</span>
);
}
const movedFromRequest =
b.scheduledDate &&
new Date(b.trainScheduleDepartureDate).toDateString() !==
new Date(b.scheduledDate).toDateString();
return (
<div className="space-y-0.5 py-1">
<span className="inline-flex items-center gap-1.5 text-sm font-medium text-foreground">
<Train className="size-3.5 text-muted-foreground" />
{formatDate(b.trainScheduleDepartureDate)}
</span>
{b.trainScheduleReference ? (
<p className="truncate text-xs text-muted-foreground">
{b.trainScheduleReference}
</p>
) : null}
{movedFromRequest ? (
<Badge
variant="outline"
className="h-4 px-1 text-[9px] font-medium"
>
Date changed
</Badge>
) : null}
</div>
);
},
},
{
id: "priority",
header: () => <span className={bookingTable.headerCell}>Priority</span>,

View File

@@ -58,6 +58,12 @@ import {
summarizeRequestedCargo,
} from "@/features/clearance/requestedCargo";
import { contractsService } from "@/services/contracts.service";
import { api } from "@/auth/http";
import {
RebookWagonCancellationModal,
canRebookWagonCancellations,
type WagonCancellation,
} from "@/components/bookings/wagon-cancellation";
import "./contract-clearance-table.css";
/** Yards carry `label` (API) — older shapes used `name`/`code`. */
@@ -214,6 +220,7 @@ export default function ContractClearanceListPage() {
const canCreateBooking =
hasPermission(user, FREIGHT_PERMS.contracts.createBooking) &&
!isDjiboutiGl(user);
const canRebookCredit = canRebookWagonCancellations(user);
const [query, setQuery] = useState("");
const [tab, setTab] = useState<TabKey>("all");
@@ -234,6 +241,21 @@ export default function ContractClearanceListPage() {
refetch,
} = useBookingEtClearanceQueue(true);
// Credit rebook opens the shared modal, which needs the full cancellation
// row — the queue only carries its id, so fetch it on demand.
const [creditRebook, setCreditRebook] = useState<WagonCancellation | null>(
null,
);
const openCreditRebook = useCallback(async (row: ShipmentBookingRow) => {
const res = await api.get<
{ items?: WagonCancellation[] } | WagonCancellation[]
>(`/bookings/${row.id}/wagon-cancellations`);
const body = res.data;
const list = Array.isArray(body) ? body : (body?.items ?? []);
const match = list.find((c) => c.id === row.rebookableCancellationId);
if (match) setCreditRebook(match);
}, []);
// Shipment requests carry the requested quantities (per container type, or
// bulk weight/items). Map them onto the booking rows by createdBookingId so
// the queue shows what each shipment was requested for.
@@ -272,6 +294,7 @@ export default function ContractClearanceListPage() {
// A bare initiated instance has no cargo/price yet — GL still has to
// create (complete) the booking.
bookingCreated: Number(b.totalAmount ?? 0) > 0,
rebookableCancellationId: b.rebookableCancellationId ?? null,
})) as ShipmentBookingRow[];
}, [bookingQueue, requestedByBooking]);
@@ -602,12 +625,14 @@ export default function ContractClearanceListPage() {
hasFilters={hasFilters}
onClearFilters={clearFilters}
canCreateBooking={canCreateBooking}
canRebookCredit={canRebookCredit}
onOpen={openBooking}
onCreateBooking={(row) =>
navigate(
`/dashboard/contracts/${row.contractId}/bookings/${row.id}/complete`,
)
}
onRebookCredit={openCreditRebook}
onRebook={(row) =>
// Re-complete the SAME expired booking (new day, same finished
// per-booking clearance) — a fresh instance would force the
@@ -623,6 +648,15 @@ export default function ContractClearanceListPage() {
</Stack>
</Card>
</Stack>
<RebookWagonCancellationModal
cancellation={creditRebook}
onClose={() => setCreditRebook(null)}
onRebooked={() => {
setCreditRebook(null);
// The credit is spent and a new booking exists — both change the queue.
void refetch();
}}
/>
</PageContainer>
);
}
@@ -650,6 +684,8 @@ interface ShipmentBookingRow {
createdAt: string | null;
/** true once GL has actually created (completed) the booking. */
bookingCreated: boolean;
/** Unspent wagon-cancellation credit on this booking, if any. */
rebookableCancellationId: string | null;
}
type PaginationState = ReturnType<typeof usePagination>["pagination"];
@@ -666,9 +702,11 @@ function ShipmentBookingsTable({
hasFilters,
onClearFilters,
canCreateBooking,
canRebookCredit,
onOpen,
onCreateBooking,
onRebook,
onRebookCredit,
onViewContract,
}: {
rows: ShipmentBookingRow[];
@@ -681,9 +719,11 @@ function ShipmentBookingsTable({
hasFilters: boolean;
onClearFilters: () => void;
canCreateBooking: boolean;
canRebookCredit: boolean;
onOpen: (id: string) => void;
onCreateBooking: (row: ShipmentBookingRow) => void;
onRebook: (row: ShipmentBookingRow) => void;
onRebookCredit: (row: ShipmentBookingRow) => void;
onViewContract: (contractId: string) => void;
}) {
// A bare initiated instance that has cleared but not yet been created by GL.
@@ -701,6 +741,14 @@ function ShipmentBookingsTable({
r.customs &&
r.status === "EXPIRED";
// A cancelled booking whose wagon-cancellation credit is paid for and unspent.
// Redeeming it is a different action from re-completing an expired booking —
// it opens the credit rebook modal rather than the completion form. Gated on
// the rebook permission (not booking-creation) so the button matches exactly
// who the API lets through.
const hasRebookableCredit = (r: ShipmentBookingRow) =>
canRebookCredit && Boolean(r.rebookableCancellationId);
const columns = useMemo<ColumnDef<ShipmentBookingRow>[]>(
() => [
{
@@ -840,6 +888,7 @@ function ShipmentBookingsTable({
const r = row.original;
const bookable = isBookable(r);
const rebookable = isRebookable(r);
const creditRebookable = hasRebookableCredit(r);
return (
<Group
justify="flex-end"
@@ -870,6 +919,17 @@ function ShipmentBookingsTable({
Rebook
</Button>
) : null}
{creditRebookable ? (
<Button
size="compact-sm"
color="teal"
radius="md"
leftSection={<RefreshCw size={14} />}
onClick={() => onRebookCredit(r)}
>
Rebook credit
</Button>
) : null}
<Menu shadow="md" radius="md" position="bottom-end" withinPortal>
<Menu.Target>
<ActionIcon
@@ -904,6 +964,14 @@ function ShipmentBookingsTable({
Rebook (GL)
</Menu.Item>
) : null}
{creditRebookable ? (
<Menu.Item
leftSection={<RefreshCw size={14} />}
onClick={() => onRebookCredit(r)}
>
Rebook cancellation credit
</Menu.Item>
) : null}
{r.contractId ? (
<Menu.Item
leftSection={<ExternalLink size={14} />}

View File

@@ -810,6 +810,14 @@ export const bookingsService = {
return ensurePdfBlob(response.data as Blob);
},
/** The Wagons tab's Excel export — customer name plus one row per wagon. */
downloadWagonsWorkbook: async (id: string): Promise<Blob> => {
const response = await client.get(B.WAGONS_EXPORT(id), {
responseType: "blob",
});
return response.data as Blob;
},
getDjClearanceQueue: async (): Promise<BookingDetail[]> => {
const response = await client.get(B.CLEARANCE_DJ_QUEUE);
return (unwrap(response.data) ?? []) as BookingDetail[];

View File

@@ -216,6 +216,14 @@ export interface BookingDetail {
wagonsRequired?: number | null;
scheduledAt?: string | null;
trainScheduleId?: string | null;
/**
* The allocated train's departure date, attached by the LIST endpoint (the
* detail endpoint carries the fuller `trainScheduleSummary` instead). This is
* the operational date, as opposed to the customer-requested `scheduledDate`.
*/
trainScheduleDepartureDate?: string | null;
/** The allocated train's reference (S-YYYY-NNNNN) or train number. */
trainScheduleReference?: string | null;
/** Operational status of the allocated train (null until scheduled). */
trainScheduleStatus?: string | null;
/** The allocated train's identity + clock, attached by the detail endpoint. */
@@ -244,6 +252,12 @@ export interface BookingDetail {
allDocsApproved?: boolean;
/** ET clearance queue: a customer document is PENDING or QUERIED. */
hasDocumentsAwaitingReview?: boolean;
/**
* ET clearance queue: id of an unspent wagon-cancellation credit on this
* booking (CREDIT_AVAILABLE, worth > 0, not yet rebooked). Null when there is
* none — GL rebooks the credit straight from the queue row.
*/
rebookableCancellationId?: string | null;
contractKind?: "ONE_TIME" | "GENERAL" | null;
contractId?: string | null;
/** Reference of the contract this booking was created under (list column + search). */
@@ -304,6 +318,10 @@ export interface BookingListRow {
schedulingStatus?: string;
serviceTypeLabel?: string;
trainScheduleId?: string | null;
/** Departure date of the train this booking is allocated to; null until scheduled. */
trainScheduleDepartureDate?: string | null;
/** Reference of the train this booking is allocated to. */
trainScheduleReference?: string | null;
isGovernment?: boolean;
governmentInstitution?: string | null;
consolidationPartnerId?: string | null;

View File

@@ -1258,10 +1258,24 @@ export interface BookingWagonRow {
allocatedWeightTons: number | string | null;
loadType: string | null;
status: string;
/** Numeric columns arrive as strings from pg — parse before arithmetic. */
tareWeightTons?: number | string | null;
capacityTons?: number | string | null;
lengthMeters?: number | string | null;
/** The train this wagon rides on, and where it runs. */
trainNumber?: string | null;
departureAt?: string | null;
originStation?: string | null;
destinationStation?: string | null;
/** Set only when the wagon carries bulk rather than containers. */
bulkCargoDescription?: string | null;
bulkQuantity?: number | string | null;
containers: Array<{
containerNumber: string | null;
sizeFt: number | null;
grossWeightTons: number | string | null;
sealNumber?: string | null;
positionOnWagon?: number | null;
}>;
}