mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-26 18:42:49 +00:00
Extract the truck column list to TRUCK_COLUMNS and drive both the table head and a per-booking label row from it, so an expanded booking's rows are labelled without scrolling back to the head.
626 lines
22 KiB
TypeScript
626 lines
22 KiB
TypeScript
import { Fragment, useMemo, useState } from "react";
|
|
import { useQueries, useQuery, useQueryClient } from "@tanstack/react-query";
|
|
import {
|
|
ActionIcon,
|
|
Alert,
|
|
Badge,
|
|
Group,
|
|
Loader,
|
|
Menu,
|
|
Table,
|
|
Text,
|
|
Tooltip,
|
|
} from "@mantine/core";
|
|
import {
|
|
ChevronDown,
|
|
ChevronRight,
|
|
ClipboardCheck,
|
|
FileText,
|
|
MoreHorizontal,
|
|
Receipt,
|
|
Truck,
|
|
} from "lucide-react";
|
|
import type { Freight } from "@edr/types";
|
|
|
|
import { PageContainer, PageHeader } from "@/components/page";
|
|
import ListControls from "@/components/common/ListControls";
|
|
import RuleEngineListFooter from "@/components/ruleEngine/RuleEngineListFooter";
|
|
import { InspectionReportModal } from "@/components/warehouses/InspectionReportModal";
|
|
import { TruckDetentionModal } from "@/components/operations/TruckDetentionModal";
|
|
import { WarehouseGateTimesModal } from "@/components/operations/WarehouseGateTimesModal";
|
|
import {
|
|
ReleaseOrderModal,
|
|
type ReleaseOrderTruckPrefill,
|
|
} from "@/components/warehouses/ReleaseOrderModal";
|
|
import {
|
|
extractDownloadErrorMessage,
|
|
formatNumber,
|
|
toReleaseInventoryItem,
|
|
} from "@/components/warehouses/options";
|
|
import { openPdfBlob } from "@/components/warehouses/pdf";
|
|
import { useListControls } from "@/hooks/useListControls";
|
|
import { useToast } from "@/hooks/use-toast";
|
|
import { api } from "@/services/api";
|
|
import { lastMileService } from "@/services/last-mile.service";
|
|
import { warehouseService, type LastMileArrivalTruck } from "@/services/warehouse.service";
|
|
import type { ImportUnloadedItem, WarehouseInventoryItem } from "@/types/warehouse";
|
|
|
|
/**
|
|
* Import trucks — the unloaded queue seen truck-first instead of item-first.
|
|
*
|
|
* The unloaded-queue tab lists inventory rows; the gate and the billing desk
|
|
* ask "which truck takes this booking out, and what does it owe". So bookings
|
|
* are the collapsed row and their trucks are the detail, each carrying its own
|
|
* cargo costs (demurrage/storage, matched by container) and — EDR fleet only —
|
|
* its detention clock. Customer self-haul shows "—" for detention: EDR bills
|
|
* detention on its own trucks only.
|
|
*
|
|
* Per-booking truck/fee/detention queries run only while a booking is expanded;
|
|
* the queue can hold hundreds of bookings and fetching all of them up front
|
|
* would be several hundred requests for rows nobody opened.
|
|
*/
|
|
|
|
const COLS = 11;
|
|
|
|
/** Truck columns — the table head, and repeated inside each expanded booking. */
|
|
const TRUCK_COLUMNS = [
|
|
"Plate",
|
|
"Type",
|
|
"Containers",
|
|
"Truck Arrival",
|
|
"Truck Leaving",
|
|
"Weight",
|
|
"Demurrage",
|
|
"Storage",
|
|
"Detention",
|
|
"Actions",
|
|
] as const;
|
|
|
|
const money = (amount: number, currency: string) =>
|
|
`${Number(amount).toLocaleString(undefined, { maximumFractionDigits: 2 })} ${currency === "ETB" ? "ETB" : currency}`;
|
|
|
|
const formatTime = (iso: string | null | undefined) =>
|
|
iso ? new Date(iso).toLocaleString() : "—";
|
|
|
|
interface BookingGroup {
|
|
bookingId: string;
|
|
bookingReference: string;
|
|
customerName: string | null;
|
|
trainSchedule: string | null;
|
|
status: string;
|
|
arrivalTime: string | null;
|
|
rows: ImportUnloadedItem[];
|
|
}
|
|
|
|
/** One collapsed line per booking; its inventory rows travel with it for the fee lookup. */
|
|
function groupByBooking(items: ImportUnloadedItem[]): BookingGroup[] {
|
|
const groups = new Map<string, BookingGroup>();
|
|
for (const item of items) {
|
|
if (!item.bookingId) continue;
|
|
const existing = groups.get(item.bookingId);
|
|
if (existing) {
|
|
existing.rows.push(item);
|
|
// Mixed statuses across a booking's rows are normal mid-pickup — show the
|
|
// least-advanced one so the row reads as "still has work on it".
|
|
if (existing.status !== item.currentStatus) existing.status = "MIXED";
|
|
continue;
|
|
}
|
|
groups.set(item.bookingId, {
|
|
bookingId: item.bookingId,
|
|
bookingReference: item.bookingReference ?? item.bookingId,
|
|
customerName: item.customerName,
|
|
trainSchedule: item.trainSchedule,
|
|
status: item.currentStatus,
|
|
arrivalTime: item.arrivalTime,
|
|
rows: [item],
|
|
});
|
|
}
|
|
return [...groups.values()];
|
|
}
|
|
|
|
interface TruckRow {
|
|
key: string;
|
|
plate: string;
|
|
driver: string | null;
|
|
truckType: string | null;
|
|
containers: string[];
|
|
/** Inventory rows this truck carries — the ids the documents and fees hang off. */
|
|
inventoryIds: string[];
|
|
weight: number;
|
|
demurrage: number;
|
|
storage: number;
|
|
vehicleId: string | null;
|
|
arrivedAt: string | null;
|
|
departedAt: string | null;
|
|
/** Identity handed to the release modal so it opens on THIS truck. */
|
|
prefill: ReleaseOrderTruckPrefill;
|
|
}
|
|
|
|
/**
|
|
* Haulage mode is decided by which list comes back non-empty — a booking is
|
|
* either EDR last mile or customer self-haul, never both.
|
|
*/
|
|
function TruckRows({ group }: { group: BookingGroup }) {
|
|
const { toast } = useToast();
|
|
const queryClient = useQueryClient();
|
|
const [busy, setBusy] = useState(false);
|
|
const [inspectId, setInspectId] = useState<string | null>(null);
|
|
const [detentionOpen, setDetentionOpen] = useState(false);
|
|
const [gateTimesOpen, setGateTimesOpen] = useState(false);
|
|
// The truck whose arrival/exit weighing is open — kept in state so the prefill
|
|
// object stays referentially stable while the modal is up.
|
|
const [release, setRelease] = useState<{
|
|
item: WarehouseInventoryItem;
|
|
prefill: ReleaseOrderTruckPrefill;
|
|
} | null>(null);
|
|
|
|
const edrQuery = useQuery({
|
|
queryKey: ["booking-edr-trucks", group.bookingId],
|
|
queryFn: () => warehouseService.getLastMileTrucks(group.bookingId),
|
|
});
|
|
const edrTrucks = edrQuery.data ?? [];
|
|
|
|
const customerQuery = useQuery({
|
|
queryKey: ["booking-customer-trucks", group.bookingId],
|
|
queryFn: () => warehouseService.getCustomerTrucks(group.bookingId),
|
|
enabled: edrQuery.isSuccess && edrTrucks.length === 0,
|
|
});
|
|
const customerTrucks = customerQuery.data ?? [];
|
|
const isEdr = edrTrucks.length > 0;
|
|
|
|
const lastMileId = edrTrucks[0]?.lastMileId ?? null;
|
|
|
|
const detentionQuery = useQuery({
|
|
queryKey: ["truck-detention-preview-import-trucks", lastMileId],
|
|
queryFn: () => lastMileService.truckDetentionPreview(lastMileId as string).then((r) => r.data),
|
|
enabled: Boolean(lastMileId),
|
|
});
|
|
const detentionByVehicle = new Map(
|
|
(detentionQuery.data?.groups ?? []).map((g) => [g.vehicleId ?? "", g]),
|
|
);
|
|
|
|
const lastMileRecordQuery = useQuery({
|
|
queryKey: ["last-mile-record-import-trucks", lastMileId],
|
|
queryFn: () => lastMileService.getById(lastMileId as string).then((r) => r.data),
|
|
enabled: detentionOpen && Boolean(lastMileId),
|
|
});
|
|
|
|
// Same per-inventory-row preview the accrual dashboard bills off; the queue
|
|
// rows already ARE this booking's import inventory, so no second list fetch.
|
|
const feeQueries = useQueries({
|
|
queries: group.rows.map((row) =>
|
|
api.warehouses.feePreview.queryOptions({
|
|
input: { inventoryId: row.id, billingCurrency: "USD" },
|
|
}),
|
|
),
|
|
});
|
|
const feesByInventory = new Map(group.rows.map((row, i) => [row.id, feeQueries[i]?.data ?? []]));
|
|
const feeCurrency = feeQueries.flatMap((q) => q.data ?? [])[0]?.currency ?? "USD";
|
|
const rowByContainer = new Map(
|
|
group.rows.filter((r) => r.containerNumber).map((r) => [r.containerNumber as string, r]),
|
|
);
|
|
|
|
/** Fees follow the container onto the truck; a bulk truck carries the whole booking. */
|
|
const costsFor = (containers: string[]) => {
|
|
const matched = containers.map((c) => rowByContainer.get(c)).filter(Boolean) as ImportUnloadedItem[];
|
|
const rows = matched.length > 0 ? matched : group.rows;
|
|
const fees = rows.flatMap((r) => feesByInventory.get(r.id) ?? []);
|
|
const sum = (type: string) =>
|
|
fees.filter((f) => f.ruleType === type).reduce((total, f) => total + Number(f.amount || 0), 0);
|
|
return {
|
|
inventoryIds: rows.map((r) => r.id),
|
|
weight: rows.reduce((total, r) => total + (Number(r.weight) || 0), 0),
|
|
demurrage: sum("DEMURRAGE_FEE"),
|
|
storage: sum("STORAGE_FEE"),
|
|
};
|
|
};
|
|
|
|
const fromEdr = (t: LastMileArrivalTruck): TruckRow => {
|
|
const containers = t.containerNumber ? [t.containerNumber] : [];
|
|
return {
|
|
key: t.vehicleId,
|
|
plate: [t.truckPlateNumber, t.trailerPlateNumber].filter(Boolean).join(" + ") || "—",
|
|
driver: t.driverName,
|
|
truckType: t.truckType,
|
|
containers,
|
|
vehicleId: t.vehicleId,
|
|
arrivedAt: t.arrivedAt,
|
|
departedAt: t.departedAt,
|
|
prefill: {
|
|
truckPlateNumber: t.truckPlateNumber,
|
|
trailerPlateNumber: t.trailerPlateNumber,
|
|
driverName: t.driverName,
|
|
driverLicense: t.driverLicense,
|
|
driverPhone: t.driverPhone,
|
|
truckType: t.truckType,
|
|
containerNumber: t.containerNumber,
|
|
},
|
|
...costsFor(containers),
|
|
};
|
|
};
|
|
const fromCustomer = (t: Freight.ICustomerTruck): TruckRow => {
|
|
const containers = (t.containers ?? []).map((c) => c.containerNumber);
|
|
return {
|
|
key: t.id,
|
|
plate: t.plateNumber,
|
|
driver: t.driverName,
|
|
truckType: t.truckType,
|
|
containers,
|
|
vehicleId: null,
|
|
arrivedAt: t.arrivedAt ?? null,
|
|
departedAt: t.departedAt ?? null,
|
|
prefill: {
|
|
truckPlateNumber: t.plateNumber,
|
|
driverName: t.driverName,
|
|
truckType: t.truckType,
|
|
containerNumber: containers.join(", "),
|
|
},
|
|
...costsFor(containers),
|
|
};
|
|
};
|
|
const trucks: TruckRow[] = isEdr ? edrTrucks.map(fromEdr) : customerTrucks.map(fromCustomer);
|
|
|
|
/**
|
|
* Arrival and leaving are one form per truck: the modal picks the step from
|
|
* that plate's own saved weighing block, so both menu items open it the same
|
|
* way. The booking-level opener (inventory workbench) stays as it was.
|
|
*/
|
|
const openRelease = (t: TruckRow) => {
|
|
const row = group.rows.find((r) => r.id === t.inventoryIds[0]) ?? group.rows[0];
|
|
if (!row) return;
|
|
setRelease({ item: toReleaseInventoryItem(row), prefill: t.prefill });
|
|
};
|
|
|
|
const closeRelease = () => {
|
|
setRelease(null);
|
|
void queryClient.invalidateQueries({ queryKey: ["booking-edr-trucks", group.bookingId] });
|
|
void queryClient.invalidateQueries({ queryKey: ["booking-customer-trucks", group.bookingId] });
|
|
// The saved weighing block lives in the inventory row's notes — refetch the
|
|
// queue or the next open would still show the truck as never arrived.
|
|
void queryClient.invalidateQueries({
|
|
queryKey: api.warehouses.importUnloadedQueue.queryOptions({}).queryKey,
|
|
});
|
|
};
|
|
|
|
const openDocument = async (
|
|
kind: "release" | "handover",
|
|
inventoryId: string,
|
|
label: string,
|
|
) => {
|
|
setBusy(true);
|
|
const pdfWindow = window.open("", "_blank");
|
|
try {
|
|
const response =
|
|
kind === "release"
|
|
? await warehouseService.downloadReleaseDocument(inventoryId)
|
|
: await warehouseService.downloadHandoverDocument(inventoryId);
|
|
openPdfBlob(response.data, `${kind}-${group.bookingReference}.pdf`, pdfWindow);
|
|
} catch (error) {
|
|
pdfWindow?.close();
|
|
toast({
|
|
variant: "destructive",
|
|
title: `${label} failed`,
|
|
description: await extractDownloadErrorMessage(error),
|
|
});
|
|
} finally {
|
|
setBusy(false);
|
|
}
|
|
};
|
|
|
|
if (edrQuery.isLoading || (customerQuery.isFetching && customerTrucks.length === 0)) {
|
|
return (
|
|
<Table.Tr>
|
|
<Table.Td colSpan={COLS}>
|
|
<Group gap="xs" justify="center" py="xs">
|
|
<Loader size="xs" />
|
|
<Text size="xs" c="dimmed">
|
|
Loading trucks…
|
|
</Text>
|
|
</Group>
|
|
</Table.Td>
|
|
</Table.Tr>
|
|
);
|
|
}
|
|
|
|
if (trucks.length === 0) {
|
|
return (
|
|
<Table.Tr>
|
|
<Table.Td colSpan={COLS}>
|
|
<Text size="xs" c="dimmed" ta="center" py="xs">
|
|
No truck assigned to this booking yet.
|
|
</Text>
|
|
</Table.Td>
|
|
</Table.Tr>
|
|
);
|
|
}
|
|
|
|
return (
|
|
<>
|
|
{/* The booking row sits between the head and these trucks, so repeat the
|
|
column labels — otherwise an expanded booking reads as unlabelled. */}
|
|
<Table.Tr bg="var(--mantine-color-gray-1)">
|
|
<Table.Td />
|
|
{TRUCK_COLUMNS.map((label) => (
|
|
<Table.Td key={label} ta={label === "Actions" ? "right" : undefined}>
|
|
<Text size="xs" fw={700} c="dimmed">
|
|
{label}
|
|
</Text>
|
|
</Table.Td>
|
|
))}
|
|
</Table.Tr>
|
|
{trucks.map((t, idx) => {
|
|
const detention = t.vehicleId ? detentionByVehicle.get(t.vehicleId) : undefined;
|
|
const primaryId = t.inventoryIds[0] ?? null;
|
|
return (
|
|
<Table.Tr key={t.key} bg="var(--mantine-color-gray-0)">
|
|
<Table.Td />
|
|
<Table.Td>
|
|
<Text size="sm" fw={600}>
|
|
{t.plate}
|
|
</Text>
|
|
{t.driver && (
|
|
<Text size="xs" c="dimmed">
|
|
{t.driver}
|
|
</Text>
|
|
)}
|
|
</Table.Td>
|
|
<Table.Td>
|
|
<Badge size="sm" radius="sm" variant="light" color={isEdr ? "edr-green" : "blue"}>
|
|
{isEdr ? "EDR" : "Customer"}
|
|
</Badge>
|
|
<Text size="xs" c="dimmed">
|
|
{t.truckType ?? "—"}
|
|
</Text>
|
|
</Table.Td>
|
|
<Table.Td>
|
|
<Text size="sm">{t.containers.length ? t.containers.join(", ") : "Bulk"}</Text>
|
|
</Table.Td>
|
|
<Table.Td>
|
|
<Text size="xs">{formatTime(t.arrivedAt)}</Text>
|
|
</Table.Td>
|
|
<Table.Td>
|
|
<Text size="xs">{formatTime(t.departedAt)}</Text>
|
|
</Table.Td>
|
|
<Table.Td>{formatNumber(t.weight)}</Table.Td>
|
|
<Table.Td>{money(t.demurrage, feeCurrency)}</Table.Td>
|
|
<Table.Td>{money(t.storage, feeCurrency)}</Table.Td>
|
|
<Table.Td>
|
|
{!isEdr || !detention ? (
|
|
<Text size="sm" c="dimmed">
|
|
—
|
|
</Text>
|
|
) : (
|
|
<Group gap={4} wrap="nowrap">
|
|
<Text size="sm">
|
|
{detention.chargeableDays ?? 0}d ·{" "}
|
|
{money(Number(detention.amount ?? 0), detentionQuery.data?.currency ?? "USD")}
|
|
</Text>
|
|
{detention.endIsOpen && (
|
|
<Badge size="xs" color="orange" variant="light">
|
|
open
|
|
</Badge>
|
|
)}
|
|
{!detention.ruleId && (
|
|
<Tooltip label="No detention rule matched this truck" withArrow>
|
|
<Text size="xs" c="red">
|
|
no rule
|
|
</Text>
|
|
</Tooltip>
|
|
)}
|
|
</Group>
|
|
)}
|
|
</Table.Td>
|
|
<Table.Td ta="right">
|
|
<Menu shadow="md" width={220} position="bottom-end" withinPortal>
|
|
<Menu.Target>
|
|
<ActionIcon variant="subtle" color="gray" aria-label="Truck actions" loading={busy}>
|
|
<MoreHorizontal size={16} />
|
|
</ActionIcon>
|
|
</Menu.Target>
|
|
<Menu.Dropdown>
|
|
<Menu.Item
|
|
leftSection={<Truck size={14} />}
|
|
disabled={!primaryId}
|
|
onClick={() => openRelease(t)}
|
|
>
|
|
Truck Arrival
|
|
</Menu.Item>
|
|
<Menu.Item
|
|
leftSection={<Truck size={14} />}
|
|
disabled={!primaryId}
|
|
onClick={() => openRelease(t)}
|
|
>
|
|
Truck Leaving
|
|
</Menu.Item>
|
|
<Menu.Divider />
|
|
<Menu.Item
|
|
leftSection={<FileText size={14} />}
|
|
disabled={!primaryId}
|
|
onClick={() => primaryId && openDocument("release", primaryId, "Exit paper")}
|
|
>
|
|
Exit paper
|
|
</Menu.Item>
|
|
<Menu.Item
|
|
leftSection={<FileText size={14} />}
|
|
disabled={!primaryId}
|
|
onClick={() => primaryId && openDocument("handover", primaryId, "Handover")}
|
|
>
|
|
Handover
|
|
</Menu.Item>
|
|
<Menu.Item
|
|
leftSection={<ClipboardCheck size={14} />}
|
|
disabled={!primaryId}
|
|
onClick={() => setInspectId(primaryId)}
|
|
>
|
|
Inspect / report
|
|
</Menu.Item>
|
|
{isEdr && (
|
|
<>
|
|
<Menu.Divider />
|
|
<Menu.Item
|
|
leftSection={<Receipt size={14} />}
|
|
disabled={!lastMileId}
|
|
onClick={() => setDetentionOpen(true)}
|
|
>
|
|
Detention times…
|
|
</Menu.Item>
|
|
<Menu.Item
|
|
leftSection={<FileText size={14} />}
|
|
disabled={!lastMileId}
|
|
onClick={() => setGateTimesOpen(true)}
|
|
>
|
|
Warehouse gate times…
|
|
</Menu.Item>
|
|
</>
|
|
)}
|
|
</Menu.Dropdown>
|
|
</Menu>
|
|
{/* Modals portal out of the table, so one mount for the whole
|
|
booking hangs off the first truck's cell. */}
|
|
{idx === 0 && (
|
|
<>
|
|
<InspectionReportModal
|
|
opened={Boolean(inspectId)}
|
|
onClose={() => setInspectId(null)}
|
|
inventoryId={inspectId}
|
|
/>
|
|
<ReleaseOrderModal
|
|
opened={Boolean(release)}
|
|
onClose={closeRelease}
|
|
item={release?.item ?? null}
|
|
truckPrefill={release?.prefill ?? null}
|
|
/>
|
|
{isEdr && (
|
|
<>
|
|
<TruckDetentionModal
|
|
opened={detentionOpen}
|
|
onClose={() => setDetentionOpen(false)}
|
|
record={lastMileRecordQuery.data ?? null}
|
|
/>
|
|
<WarehouseGateTimesModal
|
|
opened={gateTimesOpen}
|
|
onClose={() => setGateTimesOpen(false)}
|
|
record={lastMileRecordQuery.data ?? null}
|
|
/>
|
|
</>
|
|
)}
|
|
</>
|
|
)}
|
|
</Table.Td>
|
|
</Table.Tr>
|
|
);
|
|
})}
|
|
</>
|
|
);
|
|
}
|
|
|
|
export default function ImportTrucksPage() {
|
|
const { data: items = [], isLoading } = useQuery(
|
|
api.warehouses.importUnloadedQueue.queryOptions({}),
|
|
);
|
|
const [expanded, setExpanded] = useState<string | null>(null);
|
|
|
|
const groups = useMemo(() => groupByBooking(items), [items]);
|
|
const controls = useListControls(groups, {
|
|
searchKeys: ["bookingReference", "customerName", "trainSchedule"],
|
|
dateKey: "arrivalTime",
|
|
});
|
|
|
|
return (
|
|
<PageContainer>
|
|
<PageHeader
|
|
title="Import trucks"
|
|
subtitle="Unloaded import bookings and the trucks taking them out — cargo costs per truck, detention on EDR fleet."
|
|
/>
|
|
|
|
<ListControls
|
|
search={controls.search}
|
|
onSearchChange={controls.setSearch}
|
|
searchPlaceholder="Booking, customer, train…"
|
|
dateFrom={controls.dateFrom}
|
|
onDateFromChange={controls.setDateFrom}
|
|
dateTo={controls.dateTo}
|
|
onDateToChange={controls.setDateTo}
|
|
dateLabel="Arrived"
|
|
hasFilters={controls.hasFilters}
|
|
onReset={controls.reset}
|
|
/>
|
|
|
|
{isLoading ? (
|
|
<Group justify="center" py="lg">
|
|
<Loader size="sm" />
|
|
</Group>
|
|
) : controls.pagedRows.length === 0 ? (
|
|
<Alert variant="light" color="gray">
|
|
No unloaded import bookings. They appear here after Auto Unload on an arrived train.
|
|
</Alert>
|
|
) : (
|
|
<>
|
|
<Table.ScrollContainer minWidth={1100}>
|
|
<Table highlightOnHover verticalSpacing="xs">
|
|
<Table.Thead>
|
|
<Table.Tr>
|
|
<Table.Th w={40} />
|
|
{TRUCK_COLUMNS.map((label) => (
|
|
<Table.Th key={label} ta={label === "Actions" ? "right" : undefined}>
|
|
{label}
|
|
</Table.Th>
|
|
))}
|
|
</Table.Tr>
|
|
</Table.Thead>
|
|
<Table.Tbody>
|
|
{controls.pagedRows.map((g) => {
|
|
const isOpen = expanded === g.bookingId;
|
|
return (
|
|
<Fragment key={g.bookingId}>
|
|
<Table.Tr>
|
|
<Table.Td>
|
|
<ActionIcon
|
|
variant="subtle"
|
|
color="gray"
|
|
aria-label={isOpen ? "Hide trucks" : "Show trucks"}
|
|
onClick={() => setExpanded(isOpen ? null : g.bookingId)}
|
|
>
|
|
{isOpen ? <ChevronDown size={14} /> : <ChevronRight size={14} />}
|
|
</ActionIcon>
|
|
</Table.Td>
|
|
<Table.Td colSpan={COLS - 1}>
|
|
<Group gap="md" wrap="wrap">
|
|
<Text size="sm" fw={700}>
|
|
{g.bookingReference}
|
|
</Text>
|
|
<Text size="sm" c="dimmed">
|
|
{g.customerName ?? "—"}
|
|
</Text>
|
|
<Text size="sm" c="dimmed">
|
|
Train: {g.trainSchedule ?? "—"}
|
|
</Text>
|
|
<Badge size="sm" radius="sm" variant="light" color="gray">
|
|
{g.status.replace(/_/g, " ")}
|
|
</Badge>
|
|
<Text size="xs" c="dimmed">
|
|
{g.rows.length} item{g.rows.length === 1 ? "" : "s"}
|
|
</Text>
|
|
</Group>
|
|
</Table.Td>
|
|
</Table.Tr>
|
|
{isOpen && <TruckRows group={g} />}
|
|
</Fragment>
|
|
);
|
|
})}
|
|
</Table.Tbody>
|
|
</Table>
|
|
</Table.ScrollContainer>
|
|
<RuleEngineListFooter
|
|
pagination={controls.pagination}
|
|
pageCount={controls.pageCount}
|
|
totalCount={controls.totalCount}
|
|
itemLabel="bookings"
|
|
onPaginationChange={controls.setPagination}
|
|
/>
|
|
</>
|
|
)}
|
|
</PageContainer>
|
|
);
|
|
}
|