import { memo, 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. * * Memoized: the detail page passes stable props (memoized bookings array + * useCallback onChanged), so its unrelated re-renders skip this subtree. */ export const BookingsManager = memo(function BookingsManager({ scheduleId, bookings, onChanged, readOnly = false, }: BookingsManagerProps) { const { toast } = useToast(); const [query, setQuery] = useState(""); const [stateFilter, setStateFilter] = useState("ALL"); const [allocFilter, setAllocFilter] = useState("ALL"); const [selected, setSelected] = useState>(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[]>(() => { const cols: ColumnDef[] = []; if (!readOnly) { cols.push({ id: "select", meta: cellMeta, header: () => ( ), cell: ({ row }) => ( toggleOne(row.original.id)} /> ), }); } cols.push( { id: "reference", header: "Reference", meta: cellMeta, cell: ({ row }) => { const b = row.original; return ( {b.reference} {b.isGovernment ? ( Gov ) : null} ); }, }, { id: "customer", header: "Customer", meta: cellMeta, cell: ({ row }) => { const b = row.original; return ( {initials(b.company)} {b.company} ); }, }, { id: "selectedForBatch", header: "Selected for batch", meta: cellMeta, cell: ({ row }) => { const b = row.original; if (!b.selectedForBatchAt) return ( ); return ( <> {fmtDateTime(b.selectedForBatchAt)} EAT {b.paymentDeadline ? ( Pay by {fmtDateTime(b.paymentDeadline)} EAT ) : null} ); }, }, { id: "capacity", header: "Capacity", meta: cellMeta, cell: ({ row }) => { const b = row.original; return ( {b.wagons}w {fmtTons(b.weightTons)} ); }, }, { id: "state", header: "Batch state", meta: cellMeta, cell: ({ row }) => { const m = STATE_META[row.original.state]; return ( {m.label} ); }, }, { id: "allocation", header: "Wagon allocation", meta: cellMeta, cell: ({ row }) => { const b = row.original; const m = ALLOC_META[b.allocationStatus]; const badge = ( {m.label} ); if (!b.allocationIssue) return badge; return ( {badge} ); }, }, ); if (!readOnly) { cols.push({ id: "actions", header: "", meta: cellMeta, cell: ({ row }) => { const b = row.original; const isAssigned = b.allocationStatus === "ASSIGNED"; return ( } disabled={isAssigned || busy} onClick={() => setConfirm({ kind: "reassign", ids: [b.id], label: b.reference, }) } > Re-assign to wagons } disabled={!isAssigned || busy} onClick={() => setConfirm({ kind: "remove", ids: [b.id], label: b.reference, }) } > Remove from train ); }, }); } return cols; // eslint-disable-next-line react-hooks/exhaustive-deps }, [ readOnly, selected, allVisibleSelected, someVisibleSelected, visibleIds, busy, ]); return ( {/* Toolbar: search + filters */} } value={query} onChange={(e) => setQuery(e.currentTarget.value)} rightSection={ query ? ( setQuery("")} aria-label="Clear search" > ) : null } /> setAllocFilter(v ?? "ALL")} aria-label="Filter by allocation" /> {filtered.length} of {bookings.length} {/* Bulk action bar */} {!readOnly && selectedVisible.length > 0 ? ( {selectedVisible.length} selected ) : null} setConfirm(null)} centered radius="md" title={ confirm?.kind === "remove" ? "Remove from train" : "Re-assign to wagons" } > {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?`} ); }); export default BookingsManager;