Files
edr-platform/apps/edr-freight-web/backoffice/src/components/trainScheduling/EligibleBookingsPanel.tsx
marshalyordanos 5da36eb128 feat: add wagon usage computation and maintenance logging features
- Implemented  utility to calculate wagon usage metrics for train schedules.
- Created  for sending wagons to maintenance with optional notes.
- Added unit tests for train builder maintenance functionalities, including formatting train run labels and building maintenance notes.
- Developed  component for merging train schedules with detailed previews and reasons for merging.
- Introduced  component for selecting wagons with search functionality and selection limits.
- Created  for displaying and filtering audit logs, including detailed views of individual log entries.
- Added  for handling API interactions related to audit logs, including fetching logs and entity types.
2026-08-12 09:36:50 +03:00

287 lines
8.7 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 { Link } from "react-router-dom";
import { ArrowRight, Landmark, Package } from "lucide-react";
import {
Accordion,
Anchor,
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,
onSwitch,
}: {
booking: EligibleContainerBooking;
freightType?: FreightType;
selected: boolean;
onToggle: () => void;
onSwitch?: (booking: EligibleContainerBooking) => 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} />
{/* Opens the booking in a new tab: the row is a selection control in
an allocation flow, so navigating away would lose staff's picks. */}
<Anchor
component={Link}
to={`/dashboard/booking-requests/${booking.id}`}
target="_blank"
fw={600}
size="sm"
c="edr-green.8"
underline="hover"
onClick={(e) => e.stopPropagation()}
>
{booking.reference}
</Anchor>
{resolvedFreightType ? (
<Badge variant="outline" size="xs">
{resolvedFreightType}
</Badge>
) : null}
{booking.schedulingStatus ? (
<Badge variant="light" size="xs">
{booking.schedulingStatus}
</Badge>
) : null}
{booking.isGovernment ? (
<Badge
variant="light"
size="xs"
color="yellow"
leftSection={<Landmark size={10} />}
>
Government
</Badge>
) : null}
{booking.isGovernment && onSwitch ? (
<Button
variant="light"
color="yellow"
size="compact-xs"
onClick={() => onSwitch(booking)}
>
Switch onto train
</Button>
) : 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,
onSwitch,
}: {
items: EligibleContainerBooking[];
isLoading?: boolean;
selectedIds: string[];
onSelectionChange: (ids: string[]) => void;
assignedIds?: string[];
freightType?: FreightType;
onSwitch?: (booking: EligibleContainerBooking) => void;
}) {
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)}
onSwitch={onSwitch}
/>
))}
</Stack>
</Accordion.Panel>
</Accordion.Item>
);
})}
</Accordion>
) : (
<Text size="sm" c="dimmed">
No additional eligible bookings in this corridor.
</Text>
)}
</Stack>
);
}