Files
edr-platform/apps/edr-freight-web/backoffice/src/components/warehouses/LoadToTrainPanel.tsx

308 lines
10 KiB
TypeScript

import {
Alert,
Badge,
Box,
Button,
Checkbox,
Group,
Loader,
Paper,
Stack,
Table,
Text,
} from '@mantine/core';
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { ChevronDown, ChevronRight, TrainFront } from 'lucide-react';
import { useMemo, useState } from 'react';
import { useToast } from '@/hooks/use-toast';
import {
warehouseService,
type LoadableTrain,
type TrainLoadableItem,
} from '@/services/warehouse.service';
import { extractErrorMessage } from './options';
const STAGE_COLOR: Record<string, string> = {
RECEIVED: 'blue',
STORED: 'gray',
RESERVED: 'grape',
READY_FOR_LOADING: 'teal',
LOADED: 'green',
};
const weight = (w: number | null) => (w == null ? '—' : `${Number(w).toLocaleString()} t`);
interface BookingGroup {
bookingId: string | null;
bookingReference: string | null;
customerName: string | null;
items: TrainLoadableItem[];
}
function groupByBooking(items: TrainLoadableItem[]): BookingGroup[] {
const map = new Map<string, BookingGroup>();
for (const i of items) {
const key = i.bookingId ?? i.bookingReference ?? 'unknown';
let g = map.get(key);
if (!g) {
g = { bookingId: i.bookingId, bookingReference: i.bookingReference, customerName: i.customerName, items: [] };
map.set(key, g);
}
g.items.push(i);
}
return [...map.values()];
}
/**
* Load to Train — a datatable of allocated EXPORT trains. Expand a train to see
* the bookings allocated to it; expand a booking to see its containers/cargoes
* and load the ready ones onto their wagons. Only READY_FOR_LOADING items with an
* allocated wagon are selectable.
*/
export function LoadToTrainPanel() {
const { data: trains = [], isLoading } = useQuery({
queryKey: ['loadable-trains'],
queryFn: () => warehouseService.getLoadableTrains(),
});
const [expanded, setExpanded] = useState<Set<string>>(new Set());
const toggle = (id: string) =>
setExpanded((s) => {
const next = new Set(s);
next.has(id) ? next.delete(id) : next.add(id);
return next;
});
if (isLoading) {
return (
<Group justify="center" py="lg">
<Loader />
</Group>
);
}
if (trains.length === 0) {
return (
<Alert color="gray" variant="light">
No allocated EXPORT trains awaiting loading. Trains appear here after train and wagon allocation.
</Alert>
);
}
return (
<Table.ScrollContainer minWidth={720}>
<Table verticalSpacing="sm">
<Table.Thead>
<Table.Tr>
<Table.Th w={40} />
<Table.Th>Train</Table.Th>
<Table.Th>Route</Table.Th>
<Table.Th ta="center">Ready</Table.Th>
<Table.Th ta="center">Loaded</Table.Th>
</Table.Tr>
</Table.Thead>
<Table.Tbody>
{trains.map((t) => (
<TrainRow
key={t.scheduleId}
train={t}
expanded={expanded.has(t.scheduleId)}
onToggle={() => toggle(t.scheduleId)}
/>
))}
</Table.Tbody>
</Table>
</Table.ScrollContainer>
);
}
function TrainRow({ train, expanded, onToggle }: { train: LoadableTrain; expanded: boolean; onToggle: () => void }) {
const { data: items = [], isLoading } = useQuery({
queryKey: ['train-loadable-items', train.scheduleId],
queryFn: () => warehouseService.getTrainLoadableItems(train.scheduleId),
enabled: expanded,
});
const bookings = useMemo(() => groupByBooking(items), [items]);
const route =
train.origin || train.destination ? `${train.origin ?? '?'}${train.destination ?? '?'}` : '—';
return (
<>
<Table.Tr style={{ cursor: 'pointer' }} onClick={onToggle}>
<Table.Td>{expanded ? <ChevronDown size={16} /> : <ChevronRight size={16} />}</Table.Td>
<Table.Td>
<Group gap="xs" wrap="nowrap">
<TrainFront size={16} />
<Text fw={600}>{train.trainNumber ?? train.scheduleId.slice(0, 8)}</Text>
</Group>
</Table.Td>
<Table.Td>{route}</Table.Td>
<Table.Td ta="center">
<Badge color="blue" variant="light">
{train.readyCount}
</Badge>
</Table.Td>
<Table.Td ta="center">
<Badge color="green" variant="light">
{train.loadedCount}
</Badge>
</Table.Td>
</Table.Tr>
{expanded && (
<Table.Tr>
<Table.Td colSpan={5} p={0}>
<Box p="sm" bg="var(--mantine-color-gray-0)">
{isLoading ? (
<Group justify="center" py="md">
<Loader size="sm" />
</Group>
) : bookings.length === 0 ? (
<Alert color="gray" variant="light">
No arrived containers/cargoes allocated to this train yet.
</Alert>
) : (
<Stack gap="xs">
{bookings.map((b) => (
<BookingBlock key={b.bookingId ?? b.bookingReference} scheduleId={train.scheduleId} booking={b} />
))}
</Stack>
)}
</Box>
</Table.Td>
</Table.Tr>
)}
</>
);
}
function BookingBlock({ scheduleId, booking }: { scheduleId: string; booking: BookingGroup }) {
const { toast } = useToast();
const queryClient = useQueryClient();
const [open, setOpen] = useState(false);
const [selected, setSelected] = useState<string[]>([]);
const loadedCount = booking.items.filter((i) => i.status === 'LOADED').length;
const selectable = booking.items.filter((i) => i.loadable);
const allSelected = selectable.length > 0 && selectable.every((i) => selected.includes(i.id));
const toggleItem = (id: string) =>
setSelected((s) => (s.includes(id) ? s.filter((x) => x !== id) : [...s, id]));
const toggleAll = () =>
setSelected((s) =>
allSelected ? s.filter((id) => !selectable.some((i) => i.id === id)) : selectable.map((i) => i.id),
);
const loadMutation = useMutation({
mutationFn: () => warehouseService.loadItemsOntoTrain(scheduleId, selected),
onSuccess: (r) => {
queryClient.invalidateQueries({ queryKey: ['train-loadable-items', scheduleId] });
queryClient.invalidateQueries({ queryKey: ['loadable-trains'] });
setSelected([]);
toast({ title: 'Loaded onto train', description: `Loaded ${r.loadedCount}; skipped ${r.skippedCount}.` });
},
onError: (e) =>
toast({ variant: 'destructive', title: 'Load failed', description: extractErrorMessage(e) }),
});
return (
<Paper withBorder radius="sm" p="xs">
<Group justify="space-between" style={{ cursor: 'pointer' }} onClick={() => setOpen((o) => !o)} wrap="nowrap">
<Group gap="xs" wrap="nowrap">
{open ? <ChevronDown size={15} /> : <ChevronRight size={15} />}
<Text fw={600}>{booking.bookingReference ?? booking.bookingId?.slice(0, 8) ?? '—'}</Text>
<Text size="sm" c="dimmed">
{booking.customerName ?? '—'}
</Text>
</Group>
<Group gap="xs" wrap="nowrap">
<Badge variant="light" color="blue">
{booking.items.length} item(s)
</Badge>
{loadedCount > 0 && (
<Badge variant="light" color="green">
{loadedCount} loaded
</Badge>
)}
</Group>
</Group>
{open && (
<>
<Table striped highlightOnHover verticalSpacing="xs" mt="xs">
<Table.Thead>
<Table.Tr>
<Table.Th w={36}>
<Checkbox
checked={allSelected}
indeterminate={!allSelected && selected.length > 0}
onChange={toggleAll}
disabled={selectable.length === 0}
/>
</Table.Th>
<Table.Th>Container / Cargo</Table.Th>
<Table.Th>Goods</Table.Th>
<Table.Th>Weight</Table.Th>
<Table.Th>Stage</Table.Th>
<Table.Th>Wagon</Table.Th>
<Table.Th>Inspection</Table.Th>
</Table.Tr>
</Table.Thead>
<Table.Tbody>
{booking.items.map((i) => (
<Table.Tr key={i.id}>
<Table.Td>
<Checkbox checked={selected.includes(i.id)} onChange={() => toggleItem(i.id)} disabled={!i.loadable} />
</Table.Td>
<Table.Td>
<Text fw={600}>{i.containerNumber ?? i.cargoType ?? '—'}</Text>
</Table.Td>
<Table.Td>{i.cargoType ?? '—'}</Table.Td>
<Table.Td>{weight(i.weight)}</Table.Td>
<Table.Td>
<Badge color={STAGE_COLOR[i.status] ?? 'gray'} variant="light">
{i.status.replace(/_/g, ' ')}
</Badge>
</Table.Td>
<Table.Td>
{i.wagonNumber ? (
<Badge variant="outline" color="indigo">
{i.wagonNumber}
</Badge>
) : (
<Text size="xs" c="red">
Not allocated
</Text>
)}
</Table.Td>
<Table.Td>
{i.inspectionStatus ? (
<Badge size="xs" variant="light" color={i.inspectionStatus === 'PASSED' ? 'green' : 'orange'}>
{i.inspectionStatus}
</Badge>
) : (
'—'
)}
</Table.Td>
</Table.Tr>
))}
</Table.Tbody>
</Table>
<Group justify="space-between" align="center" mt="xs">
<Text size="xs" c="dimmed">
{selected.length} selected · only READY_FOR_LOADING items with a wagon can be loaded
</Text>
<Button
size="compact-sm"
color="edr-green"
leftSection={<TrainFront size={14} />}
disabled={selected.length === 0}
loading={loadMutation.isPending}
onClick={() => loadMutation.mutate()}
>
Load {selected.length || ''} onto train
</Button>
</Group>
</>
)}
</Paper>
);
}