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

203 lines
6.2 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 {
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>
);
}