mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-26 18:42:49 +00:00
249 lines
7.5 KiB
TypeScript
249 lines
7.5 KiB
TypeScript
import { useMemo } from "react";
|
||
import { ArrowRight, Package } from "lucide-react";
|
||
import {
|
||
Accordion,
|
||
Badge,
|
||
Button,
|
||
Checkbox,
|
||
Group,
|
||
Loader,
|
||
Paper,
|
||
Stack,
|
||
Text,
|
||
} from "@mantine/core";
|
||
|
||
import { BookingPriorityBadge } from "@/components/bookings/BookingPriorityBadge";
|
||
import type { EligibleContainerBooking, FreightType } from "@/types/trainScheduling";
|
||
import { groupBookingsByThreeHourWindow } from "@/utils/groupBookingsByThreeHourWindow";
|
||
|
||
function EligibleBookingRow({
|
||
booking,
|
||
freightType,
|
||
selected,
|
||
onToggle,
|
||
}: {
|
||
booking: EligibleContainerBooking;
|
||
freightType?: FreightType;
|
||
selected: boolean;
|
||
onToggle: () => void;
|
||
}) {
|
||
const resolvedFreightType = booking.freightType ?? freightType;
|
||
const isBulk = resolvedFreightType === "BULK";
|
||
|
||
return (
|
||
<Group
|
||
align="flex-start"
|
||
wrap="nowrap"
|
||
p="sm"
|
||
style={{
|
||
border: `1px solid ${
|
||
selected ? "var(--mantine-color-edr-green-3)" : "var(--mantine-color-gray-2)"
|
||
}`,
|
||
borderRadius: 12,
|
||
background: selected ? "var(--mantine-color-edr-green-0)" : "white",
|
||
transition: "border-color 120ms ease, background 120ms ease",
|
||
}}
|
||
>
|
||
<Checkbox checked={selected} onChange={onToggle} mt={4} color="edr-green" />
|
||
<Stack gap={4} style={{ flex: 1 }}>
|
||
<Group gap="xs" wrap="wrap">
|
||
<Package size={14} />
|
||
<Text fw={600} size="sm">
|
||
{booking.reference}
|
||
</Text>
|
||
{resolvedFreightType ? (
|
||
<Badge variant="outline" size="xs">
|
||
{resolvedFreightType}
|
||
</Badge>
|
||
) : null}
|
||
{booking.schedulingStatus ? (
|
||
<Badge variant="light" size="xs">
|
||
{booking.schedulingStatus}
|
||
</Badge>
|
||
) : null}
|
||
</Group>
|
||
<Text size="xs" c="dimmed">
|
||
{booking.customer}
|
||
</Text>
|
||
<Group gap={6}>
|
||
<Text size="xs">{booking.origin}</Text>
|
||
<ArrowRight size={12} />
|
||
<Text size="xs">{booking.destination}</Text>
|
||
</Group>
|
||
<Group gap="sm">
|
||
<BookingPriorityBadge score={booking.priorityScore ?? 0} />
|
||
<Text size="xs" c="dimmed">
|
||
{isBulk
|
||
? `${booking.weightTons}T`
|
||
: `${booking.quantity} × ${booking.containerType}`}
|
||
</Text>
|
||
{booking.preferredDepartureDate ? (
|
||
<Text size="xs" c="dimmed">
|
||
{new Date(booking.preferredDepartureDate).toLocaleString("en-GB", {
|
||
timeZone: "UTC",
|
||
day: "2-digit",
|
||
month: "short",
|
||
hour: "2-digit",
|
||
minute: "2-digit",
|
||
})}{" "}
|
||
UTC
|
||
</Text>
|
||
) : null}
|
||
</Group>
|
||
</Stack>
|
||
</Group>
|
||
);
|
||
}
|
||
|
||
export function EligibleBookingsPanel({
|
||
items,
|
||
isLoading,
|
||
selectedIds,
|
||
onSelectionChange,
|
||
assignedIds = [],
|
||
freightType,
|
||
}: {
|
||
items: EligibleContainerBooking[];
|
||
isLoading?: boolean;
|
||
selectedIds: string[];
|
||
onSelectionChange: (ids: string[]) => void;
|
||
assignedIds?: string[];
|
||
freightType?: FreightType;
|
||
}) {
|
||
const assignedSet = useMemo(() => new Set(assignedIds), [assignedIds]);
|
||
|
||
const availableItems = useMemo(
|
||
() => items.filter((b) => !assignedSet.has(b.id)),
|
||
[items, assignedSet],
|
||
);
|
||
|
||
const buckets = useMemo(
|
||
() => groupBookingsByThreeHourWindow(availableItems),
|
||
[availableItems],
|
||
);
|
||
|
||
const selectableIds = useMemo(() => {
|
||
return [...assignedIds, ...availableItems.map((b) => b.id)];
|
||
}, [availableItems, assignedIds]);
|
||
|
||
const toggle = (id: string) => {
|
||
if (selectedIds.includes(id)) {
|
||
onSelectionChange(selectedIds.filter((x) => x !== id));
|
||
} else {
|
||
onSelectionChange([...selectedIds, id]);
|
||
}
|
||
};
|
||
|
||
const toggleBucket = (bucketIds: string[], select: boolean) => {
|
||
if (select) {
|
||
const merged = new Set([...selectedIds, ...bucketIds]);
|
||
onSelectionChange([...merged]);
|
||
} else {
|
||
onSelectionChange(selectedIds.filter((id) => !bucketIds.includes(id)));
|
||
}
|
||
};
|
||
|
||
if (isLoading) {
|
||
return (
|
||
<Group justify="center" py="md">
|
||
<Loader size="sm" />
|
||
<Text size="sm" c="dimmed">
|
||
Loading eligible bookings…
|
||
</Text>
|
||
</Group>
|
||
);
|
||
}
|
||
|
||
if (!items.length && !assignedIds.length) {
|
||
return (
|
||
<Paper p="lg" radius="lg" withBorder bg="gray.0">
|
||
<Text size="sm" c="dimmed" ta="center">
|
||
No eligible bookings for this corridor
|
||
</Text>
|
||
</Paper>
|
||
);
|
||
}
|
||
|
||
return (
|
||
<Stack gap="md">
|
||
<Group justify="space-between" wrap="wrap">
|
||
<Text size="sm" fw={500}>
|
||
Eligible bookings ({availableItems.length})
|
||
</Text>
|
||
<Group gap="sm">
|
||
<Button
|
||
variant="light"
|
||
size="compact-sm"
|
||
onClick={() => onSelectionChange(selectableIds)}
|
||
>
|
||
Select all
|
||
</Button>
|
||
<Button variant="subtle" size="compact-sm" onClick={() => onSelectionChange(assignedIds)}>
|
||
Clear
|
||
</Button>
|
||
</Group>
|
||
</Group>
|
||
|
||
{buckets.length > 0 ? (
|
||
<Accordion defaultValue={buckets[0]?.key} variant="separated" radius="lg">
|
||
{buckets.map((bucket) => {
|
||
const bucketIds = bucket.bookings.map((b) => b.id);
|
||
const selectedInBucket = bucketIds.filter((id) => selectedIds.includes(id));
|
||
const allSelected = bucketIds.length > 0 && selectedInBucket.length === bucketIds.length;
|
||
|
||
return (
|
||
<Accordion.Item key={bucket.key} value={bucket.key}>
|
||
<Accordion.Control>
|
||
<Group justify="space-between" wrap="nowrap" pr="md">
|
||
<Stack gap={2}>
|
||
<Text fw={600} size="sm">
|
||
{bucket.label}
|
||
</Text>
|
||
<Text size="xs" c="dimmed">
|
||
{bucket.bookings.length} booking
|
||
{bucket.bookings.length === 1 ? "" : "s"} · priority sorted
|
||
</Text>
|
||
</Stack>
|
||
<Group gap="xs" onClick={(e) => e.stopPropagation()}>
|
||
<Badge variant="light" color="edr-green">
|
||
{selectedInBucket.length} selected
|
||
</Badge>
|
||
<Button
|
||
variant="subtle"
|
||
size="compact-xs"
|
||
onClick={(e) => {
|
||
e.stopPropagation();
|
||
toggleBucket(bucketIds, !allSelected);
|
||
}}
|
||
>
|
||
{allSelected ? "Deselect bucket" : "Select bucket"}
|
||
</Button>
|
||
</Group>
|
||
</Group>
|
||
</Accordion.Control>
|
||
<Accordion.Panel>
|
||
<Stack gap="sm">
|
||
{bucket.bookings.map((booking) => (
|
||
<EligibleBookingRow
|
||
key={booking.id}
|
||
booking={booking}
|
||
freightType={freightType}
|
||
selected={selectedIds.includes(booking.id)}
|
||
onToggle={() => toggle(booking.id)}
|
||
/>
|
||
))}
|
||
</Stack>
|
||
</Accordion.Panel>
|
||
</Accordion.Item>
|
||
);
|
||
})}
|
||
</Accordion>
|
||
) : (
|
||
<Text size="sm" c="dimmed">
|
||
No additional eligible bookings in this corridor.
|
||
</Text>
|
||
)}
|
||
</Stack>
|
||
);
|
||
}
|