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

@@ -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} />}