mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-09-07 04:45:03 +00:00
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:
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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";
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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<
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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`,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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} />
|
||||
|
||||
@@ -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>,
|
||||
|
||||
@@ -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} />}
|
||||
|
||||
@@ -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[];
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
}>;
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user