mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-31 23:37:37 +00:00
629 lines
18 KiB
TypeScript
629 lines
18 KiB
TypeScript
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<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;
|