mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-09-08 03:05:42 +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:
@@ -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} />}
|
||||
|
||||
Reference in New Issue
Block a user