mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-30 07:38:10 +00:00
A GENERAL + customs contract does not let the customer book directly: they
submit a shipment request, and initiateForShipmentRequest opens a BARE booking
from it — "the request itself carries the quantities; the instance carries
none". Between initiation and completeUnderContract the booking legitimately
holds no cargo, so the export reported 0 containers for a customer who had
declared, say, 2 x 20FT. 23 bookings on dev data are in that state.
Adds two columns and one filter reading booking_requests.requested_lines:
- "Requested cargo" — the declared lines as text ("2 x 20FT"), handling the
bulk shape too (tons / item count), not only containers.
- "Requested containers" — the declared box count, with a matching min/max
filter on the list and the export.
Deliberately a separate column rather than a fallback inside the real container
count: a declared 2 x 20FT is a request, not two boxes on a booking, and
merging them would overstate operational totals. The two compose instead —
Containers = 0 AND Requested containers >= 1 is exactly the set awaiting
completion after clearance.
requested_lines is free-form jsonb, so the container array is guarded by
jsonb_typeof before jsonb_array_elements; one malformed row would otherwise
500 the whole list.
822 lines
27 KiB
TypeScript
822 lines
27 KiB
TypeScript
import { useMyTradeAccess } from "@/hooks/useMyTradeAccess";
|
|
import { Box, Button, Card, Group, Modal, Stack, Text } from "@mantine/core";
|
|
import {
|
|
AlertTriangle,
|
|
ArrowRight,
|
|
Calendar,
|
|
CheckCircle2,
|
|
Clock,
|
|
FileText,
|
|
LayoutList,
|
|
Link2,
|
|
Package,
|
|
Plus,
|
|
RefreshCw,
|
|
Ship,
|
|
User,
|
|
} from "lucide-react";
|
|
import { useCallback, useMemo, useRef, useState } from "react";
|
|
import { Link, useNavigate } from "react-router-dom";
|
|
import { useQuery } from "@tanstack/react-query";
|
|
|
|
import { BookingActionsMenu } from "@/components/bookings/BookingActionsMenu";
|
|
import { ExportButton } from "@/components/export/ExportButton";
|
|
import { formatDate, humanize } from "@/lib/format";
|
|
import {
|
|
FilterBar,
|
|
dateRangeParams,
|
|
routeParams,
|
|
useFilters,
|
|
type FilterDef,
|
|
} from "@/components/filters";
|
|
import { BookingPriorityBadge } from "@/components/bookings/BookingPriorityBadge";
|
|
import { BookingStatusBadge } from "@/components/bookings/BookingStatusBadge";
|
|
// BookingStatusTabs / Operations* queues removed — replaced by booking-kind tabs.
|
|
import { BookingTableEmpty } from "@/components/bookings/BookingTableEmpty";
|
|
import { bookingTable } from "@/components/bookings/booking-ui.styles";
|
|
import { KpiStrip, PageContainer, PageHeader } from "@/components/page";
|
|
import { AllocateBookingWizard } from "@/components/trainScheduling/AllocateBookingWizard";
|
|
import { BOOKING_STATUS_STYLES } from "@/features/bookings/booking-status.config";
|
|
import { toBookingListRow } from "@/features/bookings/mapBookingListRow";
|
|
import {
|
|
useBookingDetail,
|
|
useBookingList,
|
|
useBookingListSummary,
|
|
} from "@/hooks/bookings/useBookings";
|
|
import { api } from "@/services/api";
|
|
import type { BookingListFilter } from "@/services/bookings.service";
|
|
import { trainSchedulingService } from "@/services/trainScheduling.service";
|
|
import type { AllocationCandidate } from "@/types/trainScheduling";
|
|
import type { BookingListRow } from "@/types/booking";
|
|
import { useToast } from "@/hooks/use-toast";
|
|
import {
|
|
Badge,
|
|
DataTable,
|
|
DataTableFooter,
|
|
type ColumnDef,
|
|
} from "@edr/ui-common";
|
|
|
|
/** Booking kind: one-time vs general-contract bookings. Now a filter, not a tab. */
|
|
type BookingKind = "ONE_TIME" | "GENERAL_CONTRACT";
|
|
|
|
const BOOKING_KIND_OPTIONS: { value: BookingKind; label: string }[] = [
|
|
{ value: "ONE_TIME", label: "One-time booking" },
|
|
{ value: "GENERAL_CONTRACT", label: "General booking" },
|
|
];
|
|
|
|
/** Who booked: shipping lines own bookings via `shippingLineCompanyId`, not a customer company. */
|
|
const CUSTOMER_KIND_OPTIONS = [
|
|
{ value: "SHIPPING_LINE", label: "Shipping line" },
|
|
{ value: "CUSTOMER", label: "Customer" },
|
|
];
|
|
|
|
/** Status options for the filter select — built from the shared status styles. */
|
|
const STATUS_OPTIONS = Object.entries(BOOKING_STATUS_STYLES).map(
|
|
([value, { label }]) => ({ value, label }),
|
|
);
|
|
|
|
const TRADE_DIRECTION_OPTIONS = [
|
|
{ value: "IMPORT", label: "Import" },
|
|
{ value: "EXPORT", label: "Export" },
|
|
{ value: "DOMESTIC", label: "Domestic" },
|
|
];
|
|
|
|
const FREIGHT_TYPE_OPTIONS = [
|
|
{ value: "CONTAINER", label: "Container" },
|
|
{ value: "BULK", label: "Bulk" },
|
|
];
|
|
|
|
const PAYMENT_STATUS_OPTIONS = [
|
|
{ value: "PENDING", label: "Payment pending" },
|
|
{ value: "PNR_GENERATED", label: "PNR generated" },
|
|
{ value: "VERIFICATION_IN_PROGRESS", label: "Verification in progress" },
|
|
{ value: "PAID", label: "Paid" },
|
|
{ value: "FAILED", label: "Payment failed" },
|
|
];
|
|
|
|
const OWNERSHIP_OPTIONS = [
|
|
{ value: "true", label: "Government" },
|
|
{ value: "false", label: "Private" },
|
|
];
|
|
|
|
export default function BookingRequestsPage() {
|
|
const navigate = useNavigate();
|
|
const { filterOptions } = useMyTradeAccess();
|
|
const [allocateOpen, setAllocateOpen] = useState(false);
|
|
const [allocateIds, setAllocateIds] = useState<string[]>([]);
|
|
const [allocatingId, setAllocatingId] = useState<string | null>(null);
|
|
const [otherDayModal, setOtherDayModal] = useState<{
|
|
booking: BookingListRow;
|
|
candidates: AllocationCandidate[];
|
|
} | null>(null);
|
|
const { toast } = useToast();
|
|
const suppressRowClickRef = useRef(false);
|
|
const suppressRowClick = useCallback(() => {
|
|
suppressRowClickRef.current = true;
|
|
window.setTimeout(() => {
|
|
suppressRowClickRef.current = false;
|
|
}, 400);
|
|
}, []);
|
|
|
|
// Yard options for the origin/destination filters (shared routes reference list).
|
|
const { data: yardRefs } = useQuery(
|
|
api.routes.yards.queryOptions({ staleTime: 5 * 60_000 }),
|
|
);
|
|
const yardOptions = useMemo(
|
|
() =>
|
|
(yardRefs ?? []).map((y) => ({
|
|
value: y.id,
|
|
label: y.label ?? y.code,
|
|
})),
|
|
[yardRefs],
|
|
);
|
|
|
|
// Service-type options — same reference-data payload the booking form uses.
|
|
const { data: refData } = useQuery(
|
|
api.bookings.referenceData.queryOptions({ staleTime: 5 * 60_000 }),
|
|
);
|
|
const serviceTypeOptions = useMemo(
|
|
() => (refData?.service ?? []).map((s) => ({ value: s.id, label: s.name })),
|
|
[refData],
|
|
);
|
|
|
|
// Content options mirror the booking wizard's cargo picker: the group itself
|
|
// — which the server expands to every commodity beneath it — then each
|
|
// commodity, labelled by its full path so a generically-named leaf still
|
|
// reads unambiguously. A group with no descendants is emitted by the
|
|
// reference-data tree as its own single child; drop that duplicate.
|
|
const cargoTypeOptions = useMemo(
|
|
() =>
|
|
(refData?.cargo_type ?? []).flatMap((group) => [
|
|
{ value: group.id, label: group.name },
|
|
...(group.children ?? [])
|
|
.filter((child) => child.id !== group.id)
|
|
.map((child) => ({
|
|
value: child.id,
|
|
label: `${group.name} → ${child.name}`,
|
|
})),
|
|
]),
|
|
[refData],
|
|
);
|
|
|
|
// Deep links land here pre-filtered (?statuses=A,B&tradeDirection=IMPORT) —
|
|
// the header's document-review alarm opens exactly the undecided requests
|
|
// it is counting down for. No sync effect needed any more: controls.values
|
|
// reads live off the URL every render, so a link opened while this page is
|
|
// already mounted just works, and every filter — direction included —
|
|
// auto-pins its own pill the moment it has a value (FilterBar's `secondary`
|
|
// split), so a deep link can never land behind "More filters" unseen.
|
|
// Container types, flattened out of the reference data's size groups.
|
|
const containerTypeOptions = useMemo(
|
|
() =>
|
|
(refData?.containers ?? []).flatMap((group) =>
|
|
group.types.map((t) => ({ value: t.id, label: t.name || t.code })),
|
|
),
|
|
[refData],
|
|
);
|
|
|
|
const bookingFilterDefs: FilterDef[] = useMemo(
|
|
() => [
|
|
{
|
|
key: "customerKind",
|
|
label: "Booked by",
|
|
type: "enum",
|
|
multiple: false,
|
|
options: CUSTOMER_KIND_OPTIONS,
|
|
},
|
|
{
|
|
key: "bookingType",
|
|
label: "Kind",
|
|
type: "enum",
|
|
multiple: false,
|
|
options: BOOKING_KIND_OPTIONS,
|
|
},
|
|
{
|
|
key: "statuses",
|
|
label: "Status",
|
|
type: "enum",
|
|
options: STATUS_OPTIONS,
|
|
},
|
|
{
|
|
key: "tradeDirection",
|
|
label: "Direction",
|
|
type: "enum",
|
|
multiple: false,
|
|
options: filterOptions(TRADE_DIRECTION_OPTIONS),
|
|
},
|
|
{
|
|
key: "freightType",
|
|
label: "Freight",
|
|
type: "enum",
|
|
multiple: false,
|
|
options: FREIGHT_TYPE_OPTIONS,
|
|
},
|
|
{
|
|
// Cargo group or commodity. The group row matches its whole subtree
|
|
// server-side, so "Bulk" returns every bulk commodity under it.
|
|
key: "cargoTypeId",
|
|
label: "Content",
|
|
type: "enum",
|
|
multiple: false,
|
|
options: cargoTypeOptions,
|
|
},
|
|
{
|
|
// Containers carry no customer-written description, so this is also how
|
|
// they are reached: it matches container types ("40FT") as well as the
|
|
// commodity name and the bulk cargo description.
|
|
key: "cargoText",
|
|
label: "Content contains",
|
|
type: "text",
|
|
secondary: true,
|
|
placeholder: "Commodity, description or container type",
|
|
},
|
|
{
|
|
key: "containerTypeId",
|
|
label: "Container type",
|
|
type: "enum",
|
|
multiple: false,
|
|
options: containerTypeOptions,
|
|
secondary: true,
|
|
},
|
|
{
|
|
// Counts boxes. Scoped to the container-type filter when one is set, so
|
|
// this one control answers "10 containers" and "10 forty-footers" both.
|
|
key: "containers",
|
|
label: "Containers",
|
|
type: "number",
|
|
secondary: true,
|
|
operators: ["is", "between"],
|
|
toParams: (v) =>
|
|
v.op === "between"
|
|
? { containersMin: v.v[0], containersMax: v.v[1] }
|
|
: { containersMin: v.v[0], containersMax: v.v[0] },
|
|
},
|
|
{
|
|
// Declared on the shipment request, not yet on the booking. Pair it
|
|
// with Containers = 0 to find the set awaiting completion.
|
|
key: "requestedContainers",
|
|
label: "Requested containers",
|
|
type: "number",
|
|
secondary: true,
|
|
operators: ["is", "between"],
|
|
toParams: (v) =>
|
|
v.op === "between"
|
|
? {
|
|
requestedContainersMin: v.v[0],
|
|
requestedContainersMax: v.v[1],
|
|
}
|
|
: {
|
|
requestedContainersMin: v.v[0],
|
|
requestedContainersMax: v.v[0],
|
|
},
|
|
},
|
|
{
|
|
key: "serviceTypeId",
|
|
label: "Service",
|
|
type: "enum",
|
|
multiple: false,
|
|
options: serviceTypeOptions,
|
|
},
|
|
{
|
|
key: "paymentStatus",
|
|
label: "Payment",
|
|
type: "enum",
|
|
multiple: false,
|
|
options: PAYMENT_STATUS_OPTIONS,
|
|
secondary: true,
|
|
},
|
|
{
|
|
// Wins over the `paymentStatus` filter above — the queue is by
|
|
// definition PAID — because it's later in this array: toApiParams
|
|
// merges defs in order, so a later toParams overwrites an earlier one.
|
|
key: "paidUnallocated",
|
|
label: "Allocation",
|
|
type: "boolean",
|
|
secondary: true,
|
|
trueLabel: "Paid, not allocated",
|
|
toParams: (v) =>
|
|
v.v[0] === "true"
|
|
? { paymentStatus: "PAID", assignedToSchedule: "false" }
|
|
: {},
|
|
},
|
|
{
|
|
key: "isGovernment",
|
|
label: "Ownership",
|
|
type: "enum",
|
|
multiple: false,
|
|
options: OWNERSHIP_OPTIONS,
|
|
secondary: true,
|
|
},
|
|
{
|
|
key: "route",
|
|
label: "Route",
|
|
type: "route",
|
|
options: yardOptions,
|
|
toParams: routeParams("originYardId", "destinationYardId"),
|
|
},
|
|
{
|
|
key: "created",
|
|
label: "Created",
|
|
type: "date",
|
|
secondary: true,
|
|
operators: ["between", "before", "after"],
|
|
toParams: dateRangeParams("createdFrom", "createdTo"),
|
|
},
|
|
{
|
|
key: "scheduled",
|
|
label: "Scheduled",
|
|
type: "date",
|
|
secondary: true,
|
|
operators: ["between", "before", "after"],
|
|
toParams: dateRangeParams("scheduledFrom", "scheduledTo"),
|
|
},
|
|
],
|
|
[
|
|
filterOptions,
|
|
yardOptions,
|
|
serviceTypeOptions,
|
|
cargoTypeOptions,
|
|
containerTypeOptions,
|
|
],
|
|
);
|
|
|
|
const controls = useFilters(bookingFilterDefs, {
|
|
defaultSort: "createdAt:DESC",
|
|
pageSize: 10,
|
|
});
|
|
|
|
const filter: BookingListFilter = useMemo(
|
|
() => ({
|
|
...(controls.params as unknown as BookingListFilter),
|
|
// React Query cache key per kind selection ("ALL" when unfiltered) —
|
|
// kept as a param the API ignores, matching the pre-migration cache key.
|
|
tab:
|
|
(controls.values.bookingType?.v[0] as BookingKind | undefined) ?? "ALL",
|
|
}),
|
|
[controls.params, controls.values.bookingType],
|
|
);
|
|
|
|
const { data, isLoading, isError, refetch, isFetching } =
|
|
useBookingList(filter);
|
|
const primaryAllocateId = allocateIds[0];
|
|
const { data: allocateBooking } = useBookingDetail(
|
|
allocateOpen ? primaryAllocateId : undefined,
|
|
);
|
|
const {
|
|
data: summary,
|
|
isLoading: summaryLoading,
|
|
refetch: refetchSummary,
|
|
} = useBookingListSummary(filter);
|
|
|
|
// Search is applied server-side (via the `search` filter param) — no
|
|
// client-side filtering here.
|
|
const rows = useMemo(() => {
|
|
const mapped = (data?.items ?? []).map(toBookingListRow);
|
|
// Consolidated pairs share one wagon and are decided together, so they show
|
|
// as ONE row. Keep the half that appears first in the current sort and hang
|
|
// the other on it as `pairedWith`; the row renders both bookings' details
|
|
// and opens the detail page, where each half gets its own tab.
|
|
const byId = new Map(mapped.map((row) => [row.id, row]));
|
|
const absorbed = new Set<string>();
|
|
const merged: BookingListRow[] = [];
|
|
for (const row of mapped) {
|
|
if (absorbed.has(row.id)) continue;
|
|
const partnerId = row.consolidationPartnerId;
|
|
const partner = partnerId ? byId.get(partnerId) : undefined;
|
|
if (partner && !absorbed.has(partner.id)) {
|
|
absorbed.add(partner.id);
|
|
merged.push({ ...row, pairedWith: partner });
|
|
continue;
|
|
}
|
|
merged.push(row);
|
|
}
|
|
return merged;
|
|
}, [data?.items]);
|
|
|
|
const total = data?.total ?? 0;
|
|
const hasSearch = controls.searchText.trim().length > 0;
|
|
const showEmpty = !isLoading && !isError && rows.length === 0;
|
|
|
|
const metrics = summary?.metrics;
|
|
const tabCounts = summary?.tabs;
|
|
|
|
const handleRefresh = useCallback(() => {
|
|
void refetch();
|
|
void refetchSummary();
|
|
}, [refetch, refetchSummary]);
|
|
|
|
const handleRowClick = useCallback(
|
|
(row: BookingListRow) => {
|
|
if (suppressRowClickRef.current) return;
|
|
navigate(`/dashboard/booking-requests/${row.id}`);
|
|
},
|
|
[navigate],
|
|
);
|
|
|
|
// One click: same-day fit → allocate straight away. No same-day fit but a
|
|
// train on another date fits → let staff pick it (customer is notified of
|
|
// the date change by the API). Nothing fits → say so.
|
|
const handleAllocatePaid = useCallback(
|
|
async (row: BookingListRow) => {
|
|
setAllocatingId(row.id);
|
|
try {
|
|
const candidates = await trainSchedulingService.getAllocationCandidates(
|
|
row.id,
|
|
);
|
|
if (candidates.sameDay.length > 0) {
|
|
const target = candidates.sameDay[0];
|
|
await trainSchedulingService.allocatePaidBooking(row.id, target.id);
|
|
toast({
|
|
title: `Allocated ${row.reference}`,
|
|
description: `Placed on ${target.reference ?? "train"} departing ${formatDate(target.scheduledDepartureDate)}.`,
|
|
});
|
|
void refetch();
|
|
} else if (candidates.otherDays.length > 0) {
|
|
setOtherDayModal({ booking: row, candidates: candidates.otherDays });
|
|
} else {
|
|
toast({
|
|
title: "No fitting train",
|
|
description:
|
|
"No open schedule covers this booking's route with enough capacity.",
|
|
variant: "destructive",
|
|
});
|
|
}
|
|
} catch {
|
|
toast({ title: "Allocation failed", variant: "destructive" });
|
|
} finally {
|
|
setAllocatingId(null);
|
|
}
|
|
},
|
|
[refetch, toast],
|
|
);
|
|
|
|
const handleAllocateOtherDay = useCallback(
|
|
async (candidate: AllocationCandidate) => {
|
|
if (!otherDayModal) return;
|
|
const { booking } = otherDayModal;
|
|
setAllocatingId(booking.id);
|
|
try {
|
|
await trainSchedulingService.allocatePaidBooking(
|
|
booking.id,
|
|
candidate.id,
|
|
);
|
|
toast({
|
|
title: `Allocated ${booking.reference}`,
|
|
description: `Placed on ${candidate.reference ?? "train"} departing ${formatDate(candidate.scheduledDepartureDate)}. Customer notified of the date change.`,
|
|
});
|
|
setOtherDayModal(null);
|
|
void refetch();
|
|
} catch {
|
|
toast({ title: "Allocation failed", variant: "destructive" });
|
|
} finally {
|
|
setAllocatingId(null);
|
|
}
|
|
},
|
|
[otherDayModal, refetch, toast],
|
|
);
|
|
|
|
const columns: ColumnDef<BookingListRow>[] = [
|
|
{
|
|
id: "booking",
|
|
header: () => <span className={bookingTable.headerCell}>Booking</span>,
|
|
cell: ({ row }) => {
|
|
const b = row.original;
|
|
const isGeneral = b.bookingKind === "GENERAL_CONTRACT";
|
|
return (
|
|
<div className="flex items-center gap-3 py-1.5">
|
|
<div className={bookingTable.rowIcon}>
|
|
<Package className="size-4" strokeWidth={1.75} />
|
|
</div>
|
|
<div className="min-w-0 max-w-[220px]">
|
|
<div className="flex items-center gap-1.5">
|
|
<p className="truncate font-medium text-foreground">
|
|
{b.reference}
|
|
</p>
|
|
<Badge
|
|
variant={isGeneral ? "secondary" : "outline"}
|
|
className="h-5 shrink-0 px-1.5 text-[10px] font-medium"
|
|
>
|
|
{isGeneral ? "General" : "One-time"}
|
|
</Badge>
|
|
</div>
|
|
{b.contractReference ? (
|
|
<p className="mt-0.5 flex items-center gap-1 truncate text-xs">
|
|
<FileText className="size-3 shrink-0 text-muted-foreground opacity-70" />
|
|
{b.contractId ? (
|
|
<Link
|
|
to={`/dashboard/contract-requests/${b.contractId}/view`}
|
|
// The row itself opens the booking — without this the
|
|
// contract link would never win the click.
|
|
onClick={(e) => e.stopPropagation()}
|
|
className="truncate text-blue-600 hover:underline"
|
|
>
|
|
{b.contractReference}
|
|
</Link>
|
|
) : (
|
|
<span className="truncate text-muted-foreground">
|
|
{b.contractReference}
|
|
</span>
|
|
)}
|
|
</p>
|
|
) : null}
|
|
<p className="mt-0.5 flex items-center gap-1 truncate text-xs text-muted-foreground">
|
|
{b.isShippingLine ? (
|
|
<Ship className="size-3 shrink-0 opacity-70" />
|
|
) : (
|
|
<User className="size-3 shrink-0 opacity-70" />
|
|
)}
|
|
{b.customerLabel}
|
|
{b.isShippingLine ? (
|
|
<Badge
|
|
variant="secondary"
|
|
className="h-4 shrink-0 px-1 text-[9px] font-medium"
|
|
>
|
|
Shipping line
|
|
</Badge>
|
|
) : null}
|
|
</p>
|
|
{/* Shared wagon: the second booking rides in the same row, so the
|
|
operator sees both customers before opening the pair. */}
|
|
{b.pairedWith ? (
|
|
<div className="mt-1.5 border-l-2 border-muted pl-2">
|
|
<div className="flex items-center gap-1.5">
|
|
<Link2 className="size-3 shrink-0 opacity-70" />
|
|
<p className="truncate text-xs font-medium text-foreground">
|
|
{b.pairedWith.reference}
|
|
</p>
|
|
</div>
|
|
<p className="mt-0.5 flex items-center gap-1 truncate text-xs text-muted-foreground">
|
|
<User className="size-3 shrink-0 opacity-70" />
|
|
{b.pairedWith.customerLabel}
|
|
</p>
|
|
</div>
|
|
) : null}
|
|
</div>
|
|
</div>
|
|
);
|
|
},
|
|
},
|
|
{
|
|
id: "route",
|
|
header: () => <span className={bookingTable.headerCell}>Route</span>,
|
|
cell: ({ row }) => {
|
|
const b = row.original;
|
|
return (
|
|
<div className="space-y-1 py-1">
|
|
<div className="flex items-center gap-1.5 text-sm font-medium text-foreground">
|
|
<span className="max-w-[8rem] truncate">{b.originLabel}</span>
|
|
<ArrowRight className="size-3.5 shrink-0 text-muted-foreground" />
|
|
<span className="max-w-[8rem] truncate">
|
|
{b.destinationLabel}
|
|
</span>
|
|
</div>
|
|
<div className="flex gap-1.5">
|
|
<Badge
|
|
variant="outline"
|
|
className="h-5 border-border/50 bg-background/50 px-1.5 text-[10px] font-medium backdrop-blur-sm"
|
|
>
|
|
{humanize(b.tradeDirection)}
|
|
</Badge>
|
|
<Badge
|
|
variant="secondary"
|
|
className="h-5 bg-muted/40 px-1.5 text-[10px] font-medium"
|
|
>
|
|
{humanize(b.freightType)}
|
|
</Badge>
|
|
</div>
|
|
</div>
|
|
);
|
|
},
|
|
},
|
|
{
|
|
id: "status",
|
|
size: 200,
|
|
minSize: 180,
|
|
header: () => <span className={bookingTable.headerCell}>Status</span>,
|
|
cell: ({ row }) => (
|
|
<div className="py-1">
|
|
<BookingStatusBadge
|
|
status={row.original.status}
|
|
consolidated={Boolean(row.original.consolidationPartnerId)}
|
|
partnerReference={row.original.consolidationPartnerReference}
|
|
/>
|
|
</div>
|
|
),
|
|
meta: {
|
|
headerClassName: "min-w-[11rem]",
|
|
cellClassName: "min-w-[11rem]",
|
|
},
|
|
},
|
|
{
|
|
id: "scheduled",
|
|
header: () => <span className={bookingTable.headerCell}>Scheduled</span>,
|
|
cell: ({ row }) => (
|
|
<span className="inline-flex items-center gap-1.5 text-sm text-muted-foreground">
|
|
<Calendar className="size-3.5" />
|
|
{formatDate(row.original.scheduledDate)}
|
|
</span>
|
|
),
|
|
},
|
|
{
|
|
id: "priority",
|
|
header: () => <span className={bookingTable.headerCell}>Priority</span>,
|
|
cell: ({ row }) => (
|
|
<BookingPriorityBadge score={row.original.priorityScore} />
|
|
),
|
|
},
|
|
{
|
|
id: "actions",
|
|
size: 140,
|
|
cell: ({ row }) => {
|
|
const b = row.original;
|
|
const needsAllocation =
|
|
b.paymentStatus === "PAID" && !b.trainScheduleId;
|
|
return (
|
|
<Group gap="xs" wrap="nowrap">
|
|
{needsAllocation ? (
|
|
<Button
|
|
size="compact-xs"
|
|
color="edr-green"
|
|
loading={allocatingId === b.id}
|
|
onClick={(e) => {
|
|
e.stopPropagation();
|
|
suppressRowClick();
|
|
void handleAllocatePaid(b);
|
|
}}
|
|
>
|
|
Allocate
|
|
</Button>
|
|
) : null}
|
|
<BookingActionsMenu
|
|
row={b}
|
|
variant="table"
|
|
onSuppressRowClick={suppressRowClick}
|
|
/>
|
|
</Group>
|
|
);
|
|
},
|
|
},
|
|
];
|
|
|
|
return (
|
|
<PageContainer>
|
|
<Stack gap="lg">
|
|
<PageHeader
|
|
title="Booking requests"
|
|
subtitle="Review, approve, and schedule freight booking requests."
|
|
action={
|
|
<>
|
|
<Button
|
|
color="edr-green"
|
|
leftSection={<Plus size={18} />}
|
|
onClick={() => navigate("/dashboard/booking-requests/new")}
|
|
>
|
|
Create booking
|
|
</Button>
|
|
<Button
|
|
variant="default"
|
|
leftSection={<RefreshCw size={16} />}
|
|
loading={isFetching}
|
|
onClick={handleRefresh}
|
|
>
|
|
Refresh
|
|
</Button>
|
|
</>
|
|
}
|
|
/>
|
|
|
|
<KpiStrip
|
|
loading={summaryLoading}
|
|
items={[
|
|
{
|
|
label: "In queue",
|
|
value: metrics?.inQueue ?? 0,
|
|
icon: LayoutList,
|
|
color: "edr-green",
|
|
},
|
|
{
|
|
label: "Needs action",
|
|
value: metrics?.needsAction ?? 0,
|
|
icon: Clock,
|
|
color: "yellow",
|
|
},
|
|
{
|
|
label: "Urgent",
|
|
value: metrics?.urgent ?? 0,
|
|
icon: AlertTriangle,
|
|
color: "red",
|
|
},
|
|
{
|
|
label: "Completed",
|
|
value: tabCounts?.completed ?? 0,
|
|
icon: CheckCircle2,
|
|
color: "edr-green",
|
|
},
|
|
]}
|
|
/>
|
|
|
|
{/* Status tabs replaced by booking-kind tabs (one-time / general). The
|
|
old BookingStatusTabs is commented out — status is now a filter select.
|
|
<BookingStatusTabs
|
|
active={activeTab}
|
|
onChange={(tab) => {
|
|
setActiveTab(tab);
|
|
setPagination({ pageIndex: 0, pageSize: pagination.pageSize });
|
|
}}
|
|
counts={tabCounts}
|
|
/>
|
|
*/}
|
|
|
|
<Card p={0}>
|
|
<Stack gap={0}>
|
|
<Box px="md" pt="sm" pb="xs" w="100%">
|
|
<FilterBar
|
|
defs={bookingFilterDefs}
|
|
controls={controls}
|
|
searchPlaceholder="Search booking, contract, customer or shipping line…"
|
|
viewId="booking-requests"
|
|
>
|
|
<ExportButton datasetKey="bookings" params={controls.params} />
|
|
</FilterBar>
|
|
</Box>
|
|
|
|
{showEmpty ? (
|
|
<Box px="md" pb="md">
|
|
<BookingTableEmpty
|
|
isError={isError}
|
|
hasSearch={hasSearch}
|
|
onRetry={handleRefresh}
|
|
/>
|
|
</Box>
|
|
) : (
|
|
<Box style={{ overflowX: "auto" }} w="100%">
|
|
<DataTable
|
|
columns={columns}
|
|
data={rows}
|
|
status={isLoading ? "loading" : isError ? "error" : "success"}
|
|
onRowClick={handleRowClick}
|
|
{...controls.tableProps(total)}
|
|
containerClassName="border-0 shadow-none bg-transparent"
|
|
footer={DataTableFooter}
|
|
/>
|
|
</Box>
|
|
)}
|
|
</Stack>
|
|
</Card>
|
|
</Stack>
|
|
|
|
<Modal
|
|
opened={otherDayModal !== null}
|
|
onClose={() => setOtherDayModal(null)}
|
|
title="Allocate to another date"
|
|
centered
|
|
>
|
|
<Stack gap="sm">
|
|
<Text size="sm" c="dimmed">
|
|
No train on{" "}
|
|
{otherDayModal
|
|
? formatDate(otherDayModal.booking.scheduledDate)
|
|
: "the booking's day"}{" "}
|
|
fits booking {otherDayModal?.booking.reference}. These trains on
|
|
other dates do — the customer will be notified of the date change.
|
|
</Text>
|
|
{otherDayModal?.candidates.map((c) => (
|
|
<Group key={c.id} justify="space-between" wrap="nowrap">
|
|
<div>
|
|
<Text size="sm" fw={500}>
|
|
{c.reference ?? "Train"}
|
|
</Text>
|
|
<Text size="xs" c="dimmed">
|
|
Departs {formatDate(c.scheduledDepartureDate)}
|
|
{c.direction ? ` · ${c.direction}` : ""}
|
|
</Text>
|
|
</div>
|
|
<Button
|
|
size="compact-sm"
|
|
color="edr-green"
|
|
loading={allocatingId === otherDayModal.booking.id}
|
|
onClick={() => void handleAllocateOtherDay(c)}
|
|
>
|
|
Allocate
|
|
</Button>
|
|
</Group>
|
|
))}
|
|
</Stack>
|
|
</Modal>
|
|
|
|
{allocateBooking ? (
|
|
<AllocateBookingWizard
|
|
booking={allocateBooking}
|
|
opened={allocateOpen}
|
|
onClose={() => {
|
|
setAllocateOpen(false);
|
|
setAllocateIds([]);
|
|
void refetch();
|
|
}}
|
|
initialBookingIds={allocateIds}
|
|
/>
|
|
) : null}
|
|
</PageContainer>
|
|
);
|
|
}
|