mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-27 22:30:55 +00:00
203 lines
6.2 KiB
TypeScript
203 lines
6.2 KiB
TypeScript
import { useMemo } from "react";
|
||
import {
|
||
Badge,
|
||
Button,
|
||
Card,
|
||
Group,
|
||
Paper,
|
||
Progress,
|
||
Select,
|
||
SimpleGrid,
|
||
Stack,
|
||
Text,
|
||
TextInput,
|
||
} from "@mantine/core";
|
||
import { CheckCircle2, Container } from "lucide-react";
|
||
|
||
import type { ContainerPlacement, ContainerUnitRow } from "@/types/trainScheduling";
|
||
|
||
import { autoFillPlacements, unitKey, validateLocalPlacements } from "./containerPlacement.util";
|
||
|
||
export function ContainerPlacementGrid({
|
||
units,
|
||
containerSlots,
|
||
placements,
|
||
onChange,
|
||
}: {
|
||
units: ContainerUnitRow[];
|
||
containerSlots: number[];
|
||
placements: ContainerPlacement[];
|
||
onChange: (placements: ContainerPlacement[]) => void;
|
||
}) {
|
||
const placementMap = useMemo(() => {
|
||
const map = new Map<string, ContainerPlacement>();
|
||
for (const placement of placements) {
|
||
map.set(unitKey(placement.bookingContainerId, placement.unitIndex), placement);
|
||
}
|
||
return map;
|
||
}, [placements]);
|
||
|
||
const issues = useMemo(() => validateLocalPlacements(units, placements), [units, placements]);
|
||
|
||
const completedCount = useMemo(
|
||
() =>
|
||
units.filter((unit) => {
|
||
const placement = placementMap.get(unitKey(unit.bookingContainerId, unit.unitIndex));
|
||
return placement?.sequenceNo && placement.containerNumber?.trim();
|
||
}).length,
|
||
[units, placementMap],
|
||
);
|
||
|
||
const slotOptions = containerSlots.map((seq) => ({
|
||
value: String(seq),
|
||
label: `Wagon #${seq}`,
|
||
}));
|
||
|
||
const updatePlacement = (unit: ContainerUnitRow, patch: Partial<ContainerPlacement>) => {
|
||
const key = unitKey(unit.bookingContainerId, unit.unitIndex);
|
||
const existing = placementMap.get(key);
|
||
const next: ContainerPlacement = {
|
||
bookingContainerId: unit.bookingContainerId,
|
||
unitIndex: unit.unitIndex,
|
||
sequenceNo: existing?.sequenceNo ?? containerSlots[0] ?? 1,
|
||
containerNumber: existing?.containerNumber,
|
||
sealNumber: existing?.sealNumber,
|
||
...patch,
|
||
};
|
||
onChange([
|
||
...placements.filter(
|
||
(p) => !(p.bookingContainerId === unit.bookingContainerId && p.unitIndex === unit.unitIndex),
|
||
),
|
||
next,
|
||
]);
|
||
};
|
||
|
||
if (!units.length) {
|
||
return (
|
||
<Text size="sm" c="dimmed">
|
||
No container units in this selection.
|
||
</Text>
|
||
);
|
||
}
|
||
|
||
const progress = units.length ? Math.round((completedCount / units.length) * 100) : 0;
|
||
|
||
return (
|
||
<Stack gap="md">
|
||
<Paper p="md" radius="xl" withBorder>
|
||
<Group justify="space-between" align="flex-start" wrap="wrap" gap="md">
|
||
<Stack gap={4}>
|
||
<Group gap="xs">
|
||
<Container size={18} />
|
||
<Text fw={600} size="sm">
|
||
Container assignment
|
||
</Text>
|
||
</Group>
|
||
<Text size="xs" c="dimmed">
|
||
Map each booking unit to a wagon slot and enter the container number. One wagon fits
|
||
either 1×40ft or 2×20ft containers.
|
||
</Text>
|
||
</Stack>
|
||
<Button
|
||
variant="light"
|
||
size="compact-sm"
|
||
onClick={() => onChange(autoFillPlacements(units, containerSlots))}
|
||
>
|
||
Auto-fill slots
|
||
</Button>
|
||
</Group>
|
||
|
||
<Stack gap={6} mt="md">
|
||
<Group justify="space-between">
|
||
<Text size="xs" c="dimmed">
|
||
{completedCount} of {units.length} units complete
|
||
</Text>
|
||
<Text size="xs" fw={500}>
|
||
{progress}%
|
||
</Text>
|
||
</Group>
|
||
<Progress
|
||
value={progress}
|
||
size="sm"
|
||
radius="xl"
|
||
color={issues.length ? "yellow" : "edr-green"}
|
||
/>
|
||
</Stack>
|
||
</Paper>
|
||
|
||
{issues.length ? (
|
||
<Stack gap={6}>
|
||
{issues.map((issue) => (
|
||
<Badge key={issue} color="red" variant="light" size="sm" w="fit-content">
|
||
{issue}
|
||
</Badge>
|
||
))}
|
||
</Stack>
|
||
) : (
|
||
<Badge
|
||
color="edr-green"
|
||
variant="light"
|
||
size="sm"
|
||
w="fit-content"
|
||
leftSection={<CheckCircle2 size={12} />}
|
||
>
|
||
All units mapped
|
||
</Badge>
|
||
)}
|
||
|
||
<SimpleGrid cols={{ base: 1, lg: 2 }} spacing="md">
|
||
{units.map((unit) => {
|
||
const key = unitKey(unit.bookingContainerId, unit.unitIndex);
|
||
const placement = placementMap.get(key);
|
||
const isComplete = placement?.sequenceNo && placement.containerNumber?.trim();
|
||
|
||
return (
|
||
<Card key={key} radius="xl" padding="md" withBorder>
|
||
<Stack gap="md">
|
||
<Group justify="space-between" align="flex-start">
|
||
<Stack gap={2}>
|
||
<Text size="sm" fw={600}>
|
||
{unit.bookingReference}
|
||
</Text>
|
||
<Text size="xs" c="dimmed">
|
||
{unit.label} · {unit.containerTypeCode} · {unit.grossWeightTons}T
|
||
</Text>
|
||
</Stack>
|
||
<Badge size="sm" variant="light" color={isComplete ? "edr-green" : "gray"}>
|
||
{isComplete ? "Ready" : "Pending"}
|
||
</Badge>
|
||
</Group>
|
||
|
||
<SimpleGrid cols={{ base: 1, sm: 2 }} spacing="sm">
|
||
<Select
|
||
label="Wagon slot"
|
||
size="sm"
|
||
data={slotOptions}
|
||
value={placement?.sequenceNo ? String(placement.sequenceNo) : null}
|
||
onChange={(value) =>
|
||
updatePlacement(unit, { sequenceNo: Number(value ?? containerSlots[0]) })
|
||
}
|
||
placeholder="Select wagon"
|
||
searchable
|
||
/>
|
||
<TextInput
|
||
label="Container number"
|
||
size="sm"
|
||
placeholder="e.g. MSCU1234567"
|
||
value={placement?.containerNumber ?? ""}
|
||
onChange={(e) =>
|
||
updatePlacement(unit, {
|
||
containerNumber: e.currentTarget.value,
|
||
})
|
||
}
|
||
/>
|
||
</SimpleGrid>
|
||
</Stack>
|
||
</Card>
|
||
);
|
||
})}
|
||
</SimpleGrid>
|
||
</Stack>
|
||
);
|
||
}
|