implement BookingsManager component for managing batch bookings with search and filter functionality

This commit is contained in:
Marshal
2026-06-28 22:23:51 +00:00
parent 506b269207
commit d6ae1ac18d
8 changed files with 791 additions and 61 deletions

View File

@@ -55,6 +55,7 @@ import {
WindowStatusPill,
} from "@/components/trainScheduling/batchVisuals";
import { RouteCorridor } from "@/components/trainScheduling/scheduleVisuals";
import { BookingsManager } from "./BookingsManager";
import { useMutation, useQuery } from "@tanstack/react-query";
import { api } from "@/services/api";
import { useToast } from "@/hooks/use-toast";
@@ -482,18 +483,32 @@ export default function BatchScheduleDetailPage() {
}),
);
// Batch bookings by state for the composition side panel (payment / expired lists).
const batchBookings = useMemo(() => {
if (!data) return { awaitingPayment: [], expired: [] };
const all = [
// Every booking on this schedule, flattened across windows + pending-contract,
// de-duplicated (a booking only appears once). Feeds the management table.
const allBookings = useMemo(() => {
if (!data) return [] as BatchBoardBookingDetail[];
const merged = [
...data.windows.flatMap((w) => w.bookings),
...data.pendingContract.bookings,
];
const byId = new Map<string, BatchBoardBookingDetail>();
for (const b of merged) if (!byId.has(b.id)) byId.set(b.id, b);
return [...byId.values()];
}, [data]);
// Batch bookings by state for the composition side panel (payment / expired lists).
const batchBookings = useMemo(() => {
const all = allBookings;
return {
awaitingPayment: all.filter((b) => b.state === "SELECTED_FOR_BATCH"),
expired: all.filter((b) => b.state === "EXPIRED"),
};
}, [data]);
}, [allBookings]);
const bookingsReadOnly = useMemo(
() => ["DISPATCHED", "ARRIVED"].includes(data?.status ?? ""),
[data?.status],
);
// Group the flat window list into per-day sections (one per EAT calendar date).
const dayGroups = useMemo(() => {
@@ -822,6 +837,40 @@ export default function BatchScheduleDetailPage() {
</Alert>
) : null}
{/* Manage bookings — search, filter, remove / re-assign (bulk too) */}
<Paper
radius="lg"
withBorder
p="lg"
mt="lg"
style={{ borderColor: "var(--mantine-color-gray-2)" }}
>
<Group gap="sm" mb="md" wrap="nowrap" align="flex-start">
<ThemeIcon
size={38}
radius="md"
variant="light"
color="#F2A516"
>
<Package size={19} />
</ThemeIcon>
<Box>
<Title order={4}>Manage bookings</Title>
<Text size="sm" c="dimmed">
Search and filter every booking on this train. Remove an
allocated booking to free its wagons, or re-assign one that
is not yet allocated individually or in bulk.
</Text>
</Box>
</Group>
<BookingsManager
scheduleId={scheduleId ?? ""}
bookings={allBookings}
onChanged={() => void refetch()}
readOnly={bookingsReadOnly}
/>
</Paper>
{/* Batch windows */}
<Paper
radius="lg"

View File

@@ -0,0 +1,625 @@
import { useMemo, useState } from "react";
import {
ActionIcon,
Badge,
Box,
Button,
Checkbox,
Group,
Menu,
Modal,
Paper,
Select,
Stack,
Text,
TextInput,
Tooltip,
} from "@mantine/core";
import {
AlertTriangle,
MoreVertical,
PackagePlus,
Search,
Trash2,
X,
} from "lucide-react";
import { useMutation } from "@tanstack/react-query";
import { DataTable, type ColumnDef } from "@edr/ui-common";
import { ruleEngineTable } from "@/components/ruleEngine/ruleEngineStyles";
import { api } from "@/services/api";
import { useToast } from "@/hooks/use-toast";
import type {
BatchBoardBookingDetail,
BatchBoardBookingState,
BookingAllocationStatus,
} from "@/types/trainScheduling";
const cellMeta = {
headerClassName: ruleEngineTable.headerCell,
cellClassName: ruleEngineTable.bodyCell,
};
const fmtTons = (n: number) =>
`${n.toLocaleString(undefined, { maximumFractionDigits: 1 })} t`;
const fmtDateTime = (iso: string | null) =>
iso
? new Intl.DateTimeFormat("en-GB", {
day: "2-digit",
month: "short",
hour: "2-digit",
minute: "2-digit",
hour12: false,
timeZone: "Africa/Addis_Ababa",
}).format(new Date(iso))
: "—";
const initials = (name: string) =>
name
.split(/\s+/)
.filter(Boolean)
.slice(0, 2)
.map((w) => w[0])
.join("")
.toUpperCase() || "?";
const STATE_META: Record<
BatchBoardBookingState,
{ label: string; color: string }
> = {
ALLOCATED: { label: "Allocated", color: "edr-green" },
SELECTED_FOR_BATCH: { label: "Selected for batch", color: "orange" },
READY: { label: "Ready for batch", color: "teal" },
WAITING: { label: "Paid · waiting", color: "blue" },
PENDING_CONTRACT: { label: "Pending contract", color: "gray" },
EXPIRED: { label: "Expired", color: "red" },
};
const ALLOC_META: Record<
BookingAllocationStatus,
{ label: string; color: string }
> = {
ASSIGNED: { label: "Wagons assigned", color: "edr-green" },
NOT_ATTEMPTED: { label: "Not allocated", color: "gray" },
DEFERRED: { label: "Deferred", color: "orange" },
FAILED: { label: "Allocation failed", color: "red" },
};
const STATE_FILTERS = [
{ value: "ALL", label: "All states" },
...Object.entries(STATE_META).map(([value, m]) => ({
value,
label: m.label,
})),
];
const ALLOC_FILTERS = [
{ value: "ALL", label: "All allocations" },
...Object.entries(ALLOC_META).map(([value, m]) => ({
value,
label: m.label,
})),
];
export interface BookingsManagerProps {
scheduleId: string;
bookings: BatchBoardBookingDetail[];
/** Re-pull the batch-board detail after a remove / re-assign mutation. */
onChanged: () => void;
/** Read-only when the schedule can no longer be edited (dispatched / arrived). */
readOnly?: boolean;
}
/**
* Searchable, filterable, bulk-manageable booking table for the batch board.
* Staff can search by reference / customer, filter by batch state and wagon
* allocation status, and remove or re-assign bookings individually or in bulk.
* Wraps the shared DataTable; selection + actions are handled locally so the
* surrounding accordion / tab layout stays untouched.
*/
export function BookingsManager({
scheduleId,
bookings,
onChanged,
readOnly = false,
}: BookingsManagerProps) {
const { toast } = useToast();
const [query, setQuery] = useState("");
const [stateFilter, setStateFilter] = useState<string>("ALL");
const [allocFilter, setAllocFilter] = useState<string>("ALL");
const [selected, setSelected] = useState<Set<string>>(new Set());
const [confirm, setConfirm] = useState<
| { kind: "remove"; ids: string[]; label: string }
| { kind: "reassign"; ids: string[]; label: string }
| null
>(null);
const unassign = useMutation(
api.trainScheduling.unassignBooking.mutationOptions(),
);
const reassign = useMutation(
api.trainScheduling.assignUnassignedBooking.mutationOptions(),
);
const busy = unassign.isPending || reassign.isPending;
const filtered = useMemo(() => {
const q = query.trim().toLowerCase();
return bookings.filter((b) => {
if (stateFilter !== "ALL" && b.state !== stateFilter) return false;
if (allocFilter !== "ALL" && b.allocationStatus !== allocFilter)
return false;
if (!q) return true;
return (
b.reference.toLowerCase().includes(q) ||
b.company.toLowerCase().includes(q)
);
});
}, [bookings, query, stateFilter, allocFilter]);
// Selection is bounded to whatever is currently visible (filtered) to avoid
// acting on rows the user can't see.
const visibleIds = useMemo(() => filtered.map((b) => b.id), [filtered]);
const selectedVisible = useMemo(
() => visibleIds.filter((id) => selected.has(id)),
[visibleIds, selected],
);
const allVisibleSelected =
visibleIds.length > 0 && selectedVisible.length === visibleIds.length;
const someVisibleSelected =
selectedVisible.length > 0 && !allVisibleSelected;
const toggleAll = () =>
setSelected((prev) => {
const next = new Set(prev);
if (allVisibleSelected) {
visibleIds.forEach((id) => next.delete(id));
} else {
visibleIds.forEach((id) => next.add(id));
}
return next;
});
const toggleOne = (id: string) =>
setSelected((prev) => {
const next = new Set(prev);
next.has(id) ? next.delete(id) : next.add(id);
return next;
});
const clearSelection = () => setSelected(new Set());
const runRemove = async (ids: string[]) => {
let ok = 0;
let failed = 0;
// Sequential — each unassign mutates the schedule graph; parallel would race.
for (const id of ids) {
try {
await unassign.mutateAsync({ id: scheduleId, bookingId: id });
ok += 1;
} catch {
failed += 1;
}
}
toast({
title: "Bookings removed",
description: `${ok} removed${failed ? ` · ${failed} failed` : ""}`,
variant: failed ? "destructive" : "default",
});
clearSelection();
onChanged();
};
const runReassign = async (ids: string[]) => {
let ok = 0;
let failed = 0;
for (const id of ids) {
try {
await reassign.mutateAsync({ id: scheduleId, bookingId: id });
ok += 1;
} catch {
failed += 1;
}
}
toast({
title: "Re-assignment run",
description: `${ok} re-assigned${failed ? ` · ${failed} failed` : ""}`,
variant: failed ? "destructive" : "default",
});
clearSelection();
onChanged();
};
const confirmAction = async () => {
if (!confirm) return;
const ids = confirm.ids;
setConfirm(null);
if (confirm.kind === "remove") await runRemove(ids);
else await runReassign(ids);
};
const columns = useMemo<ColumnDef<BatchBoardBookingDetail>[]>(() => {
const cols: ColumnDef<BatchBoardBookingDetail>[] = [];
if (!readOnly) {
cols.push({
id: "select",
meta: cellMeta,
header: () => (
<Checkbox
size="xs"
aria-label="Select all"
checked={allVisibleSelected}
indeterminate={someVisibleSelected}
onChange={toggleAll}
/>
),
cell: ({ row }) => (
<Checkbox
size="xs"
aria-label={`Select ${row.original.reference}`}
checked={selected.has(row.original.id)}
onChange={() => toggleOne(row.original.id)}
/>
),
});
}
cols.push(
{
id: "reference",
header: "Reference",
meta: cellMeta,
cell: ({ row }) => {
const b = row.original;
return (
<Group gap={6} wrap="nowrap">
<Text size="sm" fw={700} c="dark.5">
{b.reference}
</Text>
{b.isGovernment ? (
<Badge size="xs" variant="light" color="grape">
Gov
</Badge>
) : null}
</Group>
);
},
},
{
id: "customer",
header: "Customer",
meta: cellMeta,
cell: ({ row }) => {
const b = row.original;
return (
<Group gap={8} wrap="nowrap">
<Box
style={{
display: "flex",
alignItems: "center",
justifyContent: "center",
width: 26,
height: 26,
borderRadius: 8,
flexShrink: 0,
background: "#FEF1D5",
border: "1px solid #FBD171",
}}
>
<Text size="10px" fw={800} style={{ color: "#B26C09" }}>
{initials(b.company)}
</Text>
</Box>
<Text size="sm" c="gray.7" truncate>
{b.company}
</Text>
</Group>
);
},
},
{
id: "selectedForBatch",
header: "Selected for batch",
meta: cellMeta,
cell: ({ row }) => {
const b = row.original;
if (!b.selectedForBatchAt)
return (
<Text size="sm" c="dimmed">
</Text>
);
return (
<>
<Text size="sm" style={{ whiteSpace: "nowrap" }}>
{fmtDateTime(b.selectedForBatchAt)} EAT
</Text>
{b.paymentDeadline ? (
<Text size="xs" c="orange.7" fw={600}>
Pay by {fmtDateTime(b.paymentDeadline)} EAT
</Text>
) : null}
</>
);
},
},
{
id: "capacity",
header: "Capacity",
meta: cellMeta,
cell: ({ row }) => {
const b = row.original;
return (
<Group gap={4} wrap="nowrap">
<Badge variant="default" radius="sm" size="sm">
{b.wagons}w
</Badge>
<Badge variant="default" radius="sm" size="sm">
{fmtTons(b.weightTons)}
</Badge>
</Group>
);
},
},
{
id: "state",
header: "Batch state",
meta: cellMeta,
cell: ({ row }) => {
const m = STATE_META[row.original.state];
return (
<Badge variant="light" color={m.color} radius="sm">
{m.label}
</Badge>
);
},
},
{
id: "allocation",
header: "Wagon allocation",
meta: cellMeta,
cell: ({ row }) => {
const b = row.original;
const m = ALLOC_META[b.allocationStatus];
const badge = (
<Badge variant="light" color={m.color} radius="sm">
{m.label}
</Badge>
);
if (!b.allocationIssue) return badge;
return (
<Tooltip label={b.allocationIssue} multiline maw={320} withArrow>
<Group gap={4} wrap="nowrap">
{badge}
<AlertTriangle size={14} color="var(--mantine-color-red-6)" />
</Group>
</Tooltip>
);
},
},
);
if (!readOnly) {
cols.push({
id: "actions",
header: "",
meta: cellMeta,
cell: ({ row }) => {
const b = row.original;
const isAssigned = b.allocationStatus === "ASSIGNED";
return (
<Group justify="flex-end" gap={4} wrap="nowrap">
<Menu position="bottom-end" withinPortal shadow="md">
<Menu.Target>
<ActionIcon variant="subtle" color="gray" aria-label="Actions">
<MoreVertical size={16} />
</ActionIcon>
</Menu.Target>
<Menu.Dropdown>
<Menu.Item
leftSection={<PackagePlus size={14} />}
disabled={isAssigned || busy}
onClick={() =>
setConfirm({
kind: "reassign",
ids: [b.id],
label: b.reference,
})
}
>
Re-assign to wagons
</Menu.Item>
<Menu.Item
color="red"
leftSection={<Trash2 size={14} />}
disabled={!isAssigned || busy}
onClick={() =>
setConfirm({
kind: "remove",
ids: [b.id],
label: b.reference,
})
}
>
Remove from train
</Menu.Item>
</Menu.Dropdown>
</Menu>
</Group>
);
},
});
}
return cols;
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [
readOnly,
selected,
allVisibleSelected,
someVisibleSelected,
visibleIds,
busy,
]);
return (
<Stack gap="sm">
{/* Toolbar: search + filters */}
<Group gap="sm" wrap="wrap">
<TextInput
flex={1}
miw={220}
radius="md"
placeholder="Search reference or customer…"
leftSection={<Search size={15} />}
value={query}
onChange={(e) => setQuery(e.currentTarget.value)}
rightSection={
query ? (
<ActionIcon
variant="subtle"
color="gray"
size="sm"
onClick={() => setQuery("")}
aria-label="Clear search"
>
<X size={14} />
</ActionIcon>
) : null
}
/>
<Select
radius="md"
w={170}
data={STATE_FILTERS}
value={stateFilter}
onChange={(v) => setStateFilter(v ?? "ALL")}
aria-label="Filter by batch state"
/>
<Select
radius="md"
w={180}
data={ALLOC_FILTERS}
value={allocFilter}
onChange={(v) => setAllocFilter(v ?? "ALL")}
aria-label="Filter by allocation"
/>
<Text size="xs" c="dimmed">
{filtered.length} of {bookings.length}
</Text>
</Group>
{/* Bulk action bar */}
{!readOnly && selectedVisible.length > 0 ? (
<Paper
withBorder
radius="md"
px="md"
py="xs"
style={{
background: "var(--mantine-color-edr-green-0)",
borderColor: "var(--mantine-color-edr-green-2)",
}}
>
<Group justify="space-between" wrap="wrap" gap="sm">
<Group gap="xs" wrap="nowrap">
<Badge color="edr-green" radius="sm">
{selectedVisible.length} selected
</Badge>
<Button
size="compact-xs"
variant="subtle"
color="gray"
onClick={clearSelection}
>
Clear
</Button>
</Group>
<Group gap="xs" wrap="nowrap">
<Button
size="compact-sm"
variant="light"
color="edr-green"
leftSection={<PackagePlus size={14} />}
loading={reassign.isPending}
disabled={busy}
onClick={() =>
setConfirm({
kind: "reassign",
ids: selectedVisible,
label: `${selectedVisible.length} booking(s)`,
})
}
>
Re-assign
</Button>
<Button
size="compact-sm"
variant="light"
color="red"
leftSection={<Trash2 size={14} />}
loading={unassign.isPending}
disabled={busy}
onClick={() =>
setConfirm({
kind: "remove",
ids: selectedVisible,
label: `${selectedVisible.length} booking(s)`,
})
}
>
Remove
</Button>
</Group>
</Group>
</Paper>
) : null}
<DataTable
columns={columns}
data={filtered}
status="success"
emptyMessage={
bookings.length
? "No bookings match the current search / filters."
: "No bookings in this batch window."
}
containerClassName="overflow-x-auto rounded-lg border border-edr-border"
/>
<Modal
opened={Boolean(confirm)}
onClose={() => setConfirm(null)}
centered
radius="md"
title={
confirm?.kind === "remove"
? "Remove from train"
: "Re-assign to wagons"
}
>
<Text size="sm" mb="lg">
{confirm?.kind === "remove"
? `Remove ${confirm?.label} from this train? Their wagon allocation will be released.`
: `Re-assign ${confirm?.label} to available wagons on this train?`}
</Text>
<Group justify="flex-end" gap="sm">
<Button variant="default" onClick={() => setConfirm(null)}>
Cancel
</Button>
<Button
color={confirm?.kind === "remove" ? "red" : "edr-green"}
loading={busy}
onClick={confirmAction}
>
{confirm?.kind === "remove" ? "Remove" : "Re-assign"}
</Button>
</Group>
</Modal>
</Stack>
);
}
export default BookingsManager;

View File

@@ -52,7 +52,6 @@ import {
PreviewSummary,
ScheduleWarningsAlert,
} from "@/components/trainScheduling/ScheduleWarningsAlert";
import { shouldShowContainerPlacementStep } from "@/components/trainScheduling/schedulingContainerStep.util";
import { TrainCompositionDiagram } from "@/components/trainScheduling/TrainCompositionDiagram";
import { WagonPlanGrid } from "@/components/trainScheduling/WagonPlanGrid";
import { WorkflowRail, WorkflowStep } from "@/components/trainScheduling/WorkflowStep";
@@ -137,26 +136,10 @@ export default function TrainScheduleV2DetailPage() {
const containerUnits = previewResult?.containerUnits ?? [];
const containerSlots = previewResult?.containerSlotSequenceNos ?? [];
const hasContainerStep = useMemo(
() =>
shouldShowContainerPlacementStep({
containerUnitCount: containerUnits.length,
scheduleFreightType: freightType,
bookingFreightTypes: [
...(schedule?.bookings ?? []).map((b) => b.freightType),
...(eligibleQuery.data?.items ?? [])
.filter((item) => allSelectedIds.includes(item.id))
.map((item) => item.freightType),
],
}),
[
allSelectedIds,
containerUnits.length,
eligibleQuery.data?.items,
freightType,
schedule?.bookings,
],
);
// Container-number placement step removed — the customer enters container
// numbers when booking, so scheduling skips straight from the wagon plan to
// finalize. Steps: select bookings → review wagons → finalize.
const hasContainerStep = false;
const displayWagonPlan = useMemo(() => {
const savedWagons = schedule?.trainSet?.wagons ?? [];