Files
edr-platform/apps/edr-freight-web/backoffice/src/components/trainScheduling/EligibleBookingsPanel.tsx
2026-06-22 08:11:11 +00:00

249 lines
7.5 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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>
);
}