Files
edr-platform/apps/edr-freight-web/backoffice/src/components/trainScheduling/PinWagonsForm.tsx
2026-07-16 12:02:11 +00:00

211 lines
6.7 KiB
TypeScript

import { useCallback, useEffect, useMemo, useState } from "react";
import {
Alert,
Badge,
Button,
Group,
Paper,
Progress,
Select,
SimpleGrid,
Stack,
Text,
ThemeIcon,
} from "@mantine/core";
import { AlertTriangle, Link2, Wand2 } from "lucide-react";
import { Freight } from "@edr/types";
import type { PinWagonAssignment, TrainScheduleDetail } from "@/types/trainScheduling";
import type { Wagon } from "@/services/wagon.service";
import { wagonMatchesScheduleOrigin } from "@/utils/wagonAvailability";
import { autoFillWagonAssignments, countFilledSlots } from "./pinWagons.util";
export function PinWagonsForm({
schedule,
availableWagons,
isSubmitting,
onSubmit,
autoFillOnMount = true,
}: {
schedule: TrainScheduleDetail;
availableWagons: Wagon[];
isSubmitting?: boolean;
onSubmit: (assignments: PinWagonAssignment[]) => void;
autoFillOnMount?: boolean;
}) {
const originYardId = schedule.originStation?.id;
// Consist-only rows are the built train's coupled-but-empty wagons — display
// entries with no TrainSetWagon slot behind them, so nothing can be pinned.
const slots = (schedule.trainSet?.wagons ?? []).filter((w) => !w.consistOnly);
const [assignments, setAssignments] = useState<Record<string, string>>({});
const wagonOptionsByType = useMemo(() => {
const map = new Map<string, Array<{ value: string; label: string }>>();
for (const wagon of availableWagons) {
const isPinnedOnSlot = slots.some((s) => s.physicalWagonId === wagon.id);
if (
!wagonMatchesScheduleOrigin(wagon, originYardId, {
allowPinned: isPinnedOnSlot,
})
) {
continue;
}
if (wagon.status !== Freight.WagonStatus.Available && !isPinnedOnSlot) {
continue;
}
const typeId = wagon.wagonTypeId;
const list = map.get(typeId) ?? [];
list.push({ value: wagon.id, label: wagon.wagonNumber });
map.set(typeId, list);
}
return map;
}, [availableWagons, originYardId, slots]);
const runAutoFill = useCallback(
(preserveManual = false) => {
const existing = preserveManual ? assignments : {};
setAssignments(autoFillWagonAssignments(slots, wagonOptionsByType, existing));
},
[assignments, slots, wagonOptionsByType],
);
useEffect(() => {
if (!autoFillOnMount || !slots.length) return;
setAssignments(autoFillWagonAssignments(slots, wagonOptionsByType));
}, [schedule.id, slots, wagonOptionsByType, autoFillOnMount]);
const fillStats = useMemo(
() => countFilledSlots(slots, assignments),
[slots, assignments],
);
const progress =
fillStats.total > 0 ? Math.round((fillStats.filled / fillStats.total) * 100) : 0;
const handleSubmit = () => {
const payload: PinWagonAssignment[] = Object.entries(assignments)
.filter(([, wagonId]) => Boolean(wagonId))
.map(([trainSetWagonId, physicalWagonId]) => ({ trainSetWagonId, physicalWagonId }));
onSubmit(payload);
};
if (!slots.length) {
return (
<Text size="sm" c="dimmed">
Assign bookings first to create wagon slots.
</Text>
);
}
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">
<Link2 size={18} />
<Text fw={600} size="sm">
Pin physical wagons
</Text>
</Group>
<Text size="xs" c="dimmed">
Match each train slot to a fleet wagon. Slots are auto-filled when possible.
</Text>
</Stack>
<Button
variant="light"
size="compact-sm"
leftSection={<Wand2 size={14} />}
onClick={() => runAutoFill(false)}
>
Auto-fill all slots
</Button>
</Group>
<Stack gap={6} mt="md">
<Group justify="space-between">
<Text size="xs" c="dimmed">
{fillStats.filled} of {fillStats.total} slots filled
</Text>
<Badge variant="light" color={progress === 100 ? "teal" : "yellow"}>
{progress}%
</Badge>
</Group>
<Progress value={progress} size="sm" radius="xl" color={progress === 100 ? "teal" : "yellow"} />
</Stack>
</Paper>
{fillStats.unfilledSlotNumbers.length > 0 ? (
<Alert
color="yellow"
variant="light"
radius="lg"
icon={<AlertTriangle size={16} />}
title="Some slots could not be auto-filled"
>
<Text size="sm">
No matching fleet wagon for slot
{fillStats.unfilledSlotNumbers.length === 1 ? "" : "s"} #
{fillStats.unfilledSlotNumbers.join(", #")}. Select manually or add wagons to the fleet.
</Text>
</Alert>
) : null}
<SimpleGrid cols={{ base: 1, md: 2 }} spacing="md">
{slots.map((slot) => {
const typeId = slot.wagonType?.id ?? "";
const options =
wagonOptionsByType.get(typeId) ??
availableWagons.map((w) => ({
value: w.id,
label: w.wagonNumber,
}));
return (
<Paper key={slot.id} p="md" radius="lg" withBorder>
<Group align="flex-end" wrap="nowrap" gap="md">
<Stack gap={2} style={{ minWidth: 90 }}>
<Group gap={6}>
<ThemeIcon size="sm" radius="md" variant="light" color="teal">
<Text size="xs" fw={700}>
{slot.sequenceNo}
</Text>
</ThemeIcon>
<Text size="sm" fw={600}>
Slot #{slot.sequenceNo}
</Text>
</Group>
<Text size="xs" c="dimmed">
{slot.wagonType?.code ?? "—"} · {slot.capacityTons}T
</Text>
</Stack>
<Select
style={{ flex: 1 }}
placeholder="Select physical wagon"
data={options}
value={assignments[slot.id] ?? null}
onChange={(value) =>
setAssignments((current) => ({
...current,
[slot.id]: value ?? "",
}))
}
searchable
/>
</Group>
</Paper>
);
})}
</SimpleGrid>
<Group justify="flex-end">
<Button color="teal" loading={isSubmitting} onClick={handleSubmit}>
Pin wagons
</Button>
</Group>
</Stack>
);
}