mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-26 18:42:49 +00:00
226 lines
7.2 KiB
TypeScript
226 lines
7.2 KiB
TypeScript
import { useMemo, useState } from "react";
|
|
import { ArrowRight, Building2, Package } from "lucide-react";
|
|
import {
|
|
Accordion,
|
|
Badge,
|
|
Button,
|
|
Checkbox,
|
|
Group,
|
|
Paper,
|
|
Stack,
|
|
Text,
|
|
Title,
|
|
} from "@mantine/core";
|
|
|
|
import { BookingPriorityBadge } from "@/components/bookings/BookingPriorityBadge";
|
|
import { canAllocateBooking } from "@/features/bookings/booking-actions.config";
|
|
import type { BookingListRow } from "@/types/booking";
|
|
import { groupBookingsForOperationsQueue } from "@/utils/groupBookingsForOperationsQueue";
|
|
|
|
function BookingQueueRow({
|
|
booking,
|
|
selected,
|
|
disabled,
|
|
onToggle,
|
|
}: {
|
|
booking: BookingListRow;
|
|
selected: boolean;
|
|
disabled: boolean;
|
|
onToggle: () => void;
|
|
}) {
|
|
return (
|
|
<Group
|
|
align="flex-start"
|
|
wrap="nowrap"
|
|
p="sm"
|
|
style={{
|
|
border: "1px solid var(--mantine-color-gray-3)",
|
|
borderRadius: 8,
|
|
}}
|
|
>
|
|
<Checkbox checked={selected} disabled={disabled} onChange={onToggle} mt={4} />
|
|
<Stack gap={4} style={{ flex: 1 }}>
|
|
<Group gap="xs">
|
|
<Package size={14} />
|
|
<Text fw={600} size="sm">{booking.reference}</Text>
|
|
{booking.isGovernment ? (
|
|
<Badge color="violet" size="xs" leftSection={<Building2 size={10} />}>
|
|
Government
|
|
</Badge>
|
|
) : null}
|
|
<Badge variant="outline" size="xs">{booking.freightType}</Badge>
|
|
{booking.schedulingStatus ? (
|
|
<Badge variant="light" size="xs">{booking.schedulingStatus}</Badge>
|
|
) : null}
|
|
</Group>
|
|
<Text size="xs" c="dimmed">{booking.customerLabel}</Text>
|
|
<Group gap={6}>
|
|
<Text size="xs">{booking.originLabel}</Text>
|
|
<ArrowRight size={12} />
|
|
<Text size="xs">{booking.destinationLabel}</Text>
|
|
</Group>
|
|
<Group gap="sm">
|
|
<BookingPriorityBadge score={booking.priorityScore} />
|
|
{booking.serviceTypeLabel ? (
|
|
<Text size="xs" c="dimmed">
|
|
{booking.serviceTypeLabel}
|
|
{booking.serviceTypeBonus ? ` (+${booking.serviceTypeBonus} bonus)` : ""}
|
|
</Text>
|
|
) : null}
|
|
</Group>
|
|
</Stack>
|
|
</Group>
|
|
);
|
|
}
|
|
|
|
export function OperationsBookingQueue({
|
|
bookings,
|
|
isLoading,
|
|
onAllocate,
|
|
}: {
|
|
bookings: BookingListRow[];
|
|
isLoading?: boolean;
|
|
onAllocate: (bookingIds: string[]) => void;
|
|
}) {
|
|
const { government, commercial } = useMemo(
|
|
() => groupBookingsForOperationsQueue(bookings),
|
|
[bookings],
|
|
);
|
|
const [govSelected, setGovSelected] = useState<string[]>([]);
|
|
const [selectedByBucket, setSelectedByBucket] = useState<Record<string, string[]>>({});
|
|
|
|
const allocatable = (row: BookingListRow) =>
|
|
row.status === "PAID" &&
|
|
canAllocateBooking({ status: row.status, schedulingStatus: row.schedulingStatus });
|
|
|
|
const govSelection = govSelected.length
|
|
? govSelected
|
|
: government.filter(allocatable).map((b) => b.id);
|
|
|
|
const bucketSelection = (bucketKey: string, bucketBookings: BookingListRow[]) => {
|
|
const existing = selectedByBucket[bucketKey];
|
|
if (existing) return existing;
|
|
return bucketBookings.filter(allocatable).map((b) => b.id);
|
|
};
|
|
|
|
const toggleGov = (bookingId: string) => {
|
|
setGovSelected((prev) => {
|
|
const base = prev.length ? prev : government.filter(allocatable).map((b) => b.id);
|
|
return base.includes(bookingId)
|
|
? base.filter((id) => id !== bookingId)
|
|
: [...base, bookingId];
|
|
});
|
|
};
|
|
|
|
const toggleBucket = (bucketKey: string, bookingId: string) => {
|
|
setSelectedByBucket((prev) => {
|
|
const current = prev[bucketKey] ?? [];
|
|
const next = current.includes(bookingId)
|
|
? current.filter((id) => id !== bookingId)
|
|
: [...current, bookingId];
|
|
return { ...prev, [bucketKey]: next };
|
|
});
|
|
};
|
|
|
|
if (isLoading) {
|
|
return <Text size="sm" c="dimmed">Loading operations queue…</Text>;
|
|
}
|
|
|
|
if (!government.length && !commercial.length) {
|
|
return (
|
|
<Text size="sm" c="dimmed">
|
|
No PAID bookings ready to allocate.
|
|
</Text>
|
|
);
|
|
}
|
|
|
|
return (
|
|
<Stack gap="lg">
|
|
{government.length > 0 ? (
|
|
<Paper withBorder p="md" radius="md">
|
|
<Group justify="space-between" mb="md">
|
|
<Stack gap={2}>
|
|
<Title order={5}>Government priority</Title>
|
|
<Text size="xs" c="dimmed">
|
|
Served first — not grouped by 3-hour window
|
|
</Text>
|
|
</Stack>
|
|
<Group gap="xs">
|
|
<Badge variant="light">{govSelection.length} selected</Badge>
|
|
<Button
|
|
size="compact-sm"
|
|
color="violet"
|
|
disabled={!govSelection.length}
|
|
onClick={() => onAllocate(govSelection)}
|
|
>
|
|
Allocate
|
|
</Button>
|
|
</Group>
|
|
</Group>
|
|
<Stack gap="sm">
|
|
{government.map((booking) => (
|
|
<BookingQueueRow
|
|
key={booking.id}
|
|
booking={booking}
|
|
selected={govSelection.includes(booking.id)}
|
|
disabled={!allocatable(booking)}
|
|
onToggle={() => toggleGov(booking.id)}
|
|
/>
|
|
))}
|
|
</Stack>
|
|
</Paper>
|
|
) : null}
|
|
|
|
{commercial.length > 0 ? (
|
|
<Accordion defaultValue={commercial[0]?.key} variant="separated" radius="md">
|
|
{commercial.map((bucket) => {
|
|
const selected = bucketSelection(bucket.key, bucket.bookings);
|
|
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} commercial booking
|
|
{bucket.bookings.length === 1 ? "" : "s"}
|
|
</Text>
|
|
</Stack>
|
|
<Group gap="xs">
|
|
<Badge variant="light">{selected.length} selected</Badge>
|
|
<Button
|
|
size="compact-sm"
|
|
color="edr-green"
|
|
disabled={!selected.length}
|
|
onClick={(e) => {
|
|
e.stopPropagation();
|
|
onAllocate(selected);
|
|
}}
|
|
>
|
|
Allocate
|
|
</Button>
|
|
</Group>
|
|
</Group>
|
|
</Accordion.Control>
|
|
<Accordion.Panel>
|
|
<Stack gap="sm">
|
|
{bucket.bookings.map((booking) => (
|
|
<BookingQueueRow
|
|
key={booking.id}
|
|
booking={booking}
|
|
selected={selected.includes(booking.id)}
|
|
disabled={!allocatable(booking)}
|
|
onToggle={() => toggleBucket(bucket.key, booking.id)}
|
|
/>
|
|
))}
|
|
</Stack>
|
|
</Accordion.Panel>
|
|
</Accordion.Item>
|
|
);
|
|
})}
|
|
</Accordion>
|
|
) : null}
|
|
</Stack>
|
|
);
|
|
}
|