mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-29 19:58:11 +00:00
313 lines
10 KiB
TypeScript
313 lines
10 KiB
TypeScript
import {
|
|
Alert,
|
|
Badge,
|
|
Button,
|
|
Group,
|
|
Loader,
|
|
NumberInput,
|
|
Paper,
|
|
Select,
|
|
SimpleGrid,
|
|
Stack,
|
|
Table,
|
|
Text,
|
|
Tooltip,
|
|
} from "@mantine/core";
|
|
import { useMutation, useQuery } from "@tanstack/react-query";
|
|
import { isAxiosError } from "axios";
|
|
import { AlertTriangle, Lock, MapPin } from "lucide-react";
|
|
import { useMemo, useState } from "react";
|
|
|
|
import { useToast } from "@/hooks/use-toast";
|
|
import { api } from "@/services/api";
|
|
import type { ScheduleWagonYardRow } from "@/services/trainBuilder.service";
|
|
|
|
/**
|
|
* Schedule yards tab: where THIS departure plans to board each consist wagon,
|
|
* side by side with where the wagon physically stands (the train builder's
|
|
* truth). Booking capacity per origin reads the plan; dispatch refuses to
|
|
* leave until plan and physical yards agree. Edits are queued locally and
|
|
* saved in one PATCH.
|
|
*/
|
|
|
|
const parseError = (error: unknown, fallback: string) => {
|
|
if (isAxiosError(error)) {
|
|
const message = error.response?.data?.message;
|
|
if (Array.isArray(message)) return message.join(", ");
|
|
if (typeof message === "string") return message;
|
|
}
|
|
return fallback;
|
|
};
|
|
|
|
interface Props {
|
|
scheduleId: string;
|
|
canEdit: boolean;
|
|
}
|
|
|
|
export function ScheduleWagonYardPanel({ scheduleId, canEdit }: Props) {
|
|
const { toast } = useToast();
|
|
const query = useQuery(
|
|
api.trainScheduling.scheduleWagonYards.queryOptions({ input: { scheduleId } }),
|
|
);
|
|
const save = useMutation(api.trainScheduling.updateScheduleWagonYards.mutationOptions());
|
|
const data = query.data;
|
|
|
|
/** wagonId → yardId queued but not yet saved. */
|
|
const [pending, setPending] = useState<Record<string, string>>({});
|
|
const [bulkType, setBulkType] = useState<string | null>(null);
|
|
const [bulkFrom, setBulkFrom] = useState<string | null>(null);
|
|
const [bulkTo, setBulkTo] = useState<string | null>(null);
|
|
const [bulkCount, setBulkCount] = useState<number | string>(1);
|
|
|
|
const editable = Boolean(canEdit && data?.editable);
|
|
const pickupStops = useMemo(() => (data?.stops ?? []).filter((s) => s.pickup), [data]);
|
|
const yardOptions = pickupStops.map((s) => ({ value: s.yardId, label: s.label }));
|
|
const yardLabel = (id: string | null) =>
|
|
(data?.stops ?? []).find((s) => s.yardId === id)?.label ??
|
|
data?.wagons.find((w) => w.plannedYardId === id)?.plannedYardLabel ??
|
|
data?.wagons.find((w) => w.physicalYardId === id)?.physicalYardLabel ??
|
|
id ??
|
|
"—";
|
|
|
|
const effectiveYard = (w: ScheduleWagonYardRow) => pending[w.id] ?? w.plannedYardId;
|
|
|
|
const perStop = useMemo(
|
|
() =>
|
|
(data?.stops ?? []).map((s) => ({
|
|
...s,
|
|
planned: (data?.wagons ?? []).filter((w) => (pending[w.id] ?? w.plannedYardId) === s.yardId)
|
|
.length,
|
|
})),
|
|
[data, pending],
|
|
);
|
|
const typeOptions = useMemo(() => {
|
|
const seen = new Map<string, string>();
|
|
for (const w of data?.wagons ?? []) seen.set(w.wagonType.id, w.wagonType.code);
|
|
return [...seen].map(([value, label]) => ({ value, label }));
|
|
}, [data]);
|
|
|
|
const pendingCount = Object.keys(pending).length;
|
|
|
|
const queueBulk = () => {
|
|
if (!data || !bulkFrom || !bulkTo || bulkFrom === bulkTo) return;
|
|
const n = Number(bulkCount) || 0;
|
|
const picked = data.wagons
|
|
.filter(
|
|
(w) =>
|
|
!w.locked &&
|
|
effectiveYard(w) === bulkFrom &&
|
|
(!bulkType || w.wagonType.id === bulkType),
|
|
)
|
|
.slice(0, n);
|
|
if (!picked.length) {
|
|
toast({ title: "No free wagons match", variant: "destructive" });
|
|
return;
|
|
}
|
|
setPending((prev) => {
|
|
const next = { ...prev };
|
|
for (const w of picked) {
|
|
if (w.plannedYardId === bulkTo) delete next[w.id];
|
|
else next[w.id] = bulkTo;
|
|
}
|
|
return next;
|
|
});
|
|
};
|
|
|
|
const handleSave = async () => {
|
|
if (!pendingCount) return;
|
|
try {
|
|
const result = await save.mutateAsync({
|
|
scheduleId,
|
|
payload: {
|
|
moves: Object.entries(pending).map(([wagonId, yardId]) => ({ wagonId, yardId })),
|
|
},
|
|
});
|
|
setPending({});
|
|
toast({
|
|
title: `Schedule yards updated — ${pendingCount} wagon(s) re-planned`,
|
|
description: result.warnings.length ? result.warnings.join(" ") : undefined,
|
|
variant: result.warnings.length ? "destructive" : undefined,
|
|
});
|
|
} catch (err) {
|
|
toast({
|
|
title: "Update failed",
|
|
description: parseError(err, "Could not update the schedule's wagon yards"),
|
|
variant: "destructive",
|
|
});
|
|
}
|
|
};
|
|
|
|
if (query.isLoading) return <Loader size="sm" />;
|
|
if (query.isError || !data) {
|
|
return (
|
|
<Alert color="red" icon={<AlertTriangle size={16} />}>
|
|
{parseError(
|
|
query.error,
|
|
"This schedule has no wagon yard plan (not created from a built train).",
|
|
)}
|
|
</Alert>
|
|
);
|
|
}
|
|
|
|
return (
|
|
<Stack gap="md">
|
|
<Alert color="blue" icon={<MapPin size={16} />} variant="light">
|
|
<b>Planned</b> = where this departure boards the wagon (what customers can book per
|
|
origin). <b>Physical</b> = where the wagon stands now (train builder). Dispatch is blocked
|
|
until every wagon stands at its planned yard.
|
|
{data.misaligned > 0 ? (
|
|
<Text component="span" c="orange" fw={600}>
|
|
{" "}
|
|
{data.misaligned} wagon(s) currently misaligned.
|
|
</Text>
|
|
) : null}
|
|
</Alert>
|
|
|
|
<SimpleGrid cols={{ base: 2, sm: 3, md: Math.min(5, Math.max(2, perStop.length)) }}>
|
|
{perStop.map((s) => (
|
|
<Paper key={s.yardId} withBorder p="sm" radius="md">
|
|
<Group justify="space-between" mb={4}>
|
|
<Text fw={600} size="sm">
|
|
{s.label}
|
|
</Text>
|
|
{!s.pickup ? (
|
|
<Badge size="xs" color="gray" variant="light">
|
|
destination
|
|
</Badge>
|
|
) : null}
|
|
</Group>
|
|
<Group gap="xs">
|
|
<Badge color="edr-green" variant="filled">
|
|
Planned {s.planned}
|
|
</Badge>
|
|
<Badge color={s.physical === s.planned ? "gray" : "orange"} variant="light">
|
|
Physical {s.physical}
|
|
</Badge>
|
|
</Group>
|
|
</Paper>
|
|
))}
|
|
</SimpleGrid>
|
|
|
|
{editable ? (
|
|
<Paper withBorder p="sm" radius="md">
|
|
<Group align="end" gap="sm" wrap="wrap">
|
|
<NumberInput
|
|
label="Move"
|
|
min={1}
|
|
max={data.wagons.length}
|
|
value={bulkCount}
|
|
onChange={setBulkCount}
|
|
w={90}
|
|
/>
|
|
<Select
|
|
label="Wagon type"
|
|
placeholder="Any"
|
|
clearable
|
|
data={typeOptions}
|
|
value={bulkType}
|
|
onChange={setBulkType}
|
|
w={140}
|
|
/>
|
|
<Select label="From" data={yardOptions} value={bulkFrom} onChange={setBulkFrom} w={170} />
|
|
<Select label="To" data={yardOptions} value={bulkTo} onChange={setBulkTo} w={170} />
|
|
<Button
|
|
variant="light"
|
|
onClick={queueBulk}
|
|
disabled={!bulkFrom || !bulkTo || bulkFrom === bulkTo}
|
|
>
|
|
Queue
|
|
</Button>
|
|
</Group>
|
|
</Paper>
|
|
) : null}
|
|
|
|
<Table striped highlightOnHover withTableBorder>
|
|
<Table.Thead>
|
|
<Table.Tr>
|
|
<Table.Th>#</Table.Th>
|
|
<Table.Th>Wagon</Table.Th>
|
|
<Table.Th>Type</Table.Th>
|
|
<Table.Th>Physical yard</Table.Th>
|
|
<Table.Th>Planned yard (this schedule)</Table.Th>
|
|
<Table.Th>Status</Table.Th>
|
|
</Table.Tr>
|
|
</Table.Thead>
|
|
<Table.Tbody>
|
|
{data.wagons.map((w) => {
|
|
const planned = effectiveYard(w);
|
|
const changed = w.id in pending;
|
|
return (
|
|
<Table.Tr key={w.id} bg={changed ? "var(--mantine-color-yellow-light)" : undefined}>
|
|
<Table.Td>{w.sequenceNumber ?? "—"}</Table.Td>
|
|
<Table.Td>
|
|
<Text fw={600} size="sm">
|
|
{w.wagonNumber}
|
|
</Text>
|
|
</Table.Td>
|
|
<Table.Td>{w.wagonType.code}</Table.Td>
|
|
<Table.Td>{w.physicalYardLabel ?? "No yard"}</Table.Td>
|
|
<Table.Td>
|
|
{editable && !w.locked ? (
|
|
<Select
|
|
size="xs"
|
|
data={yardOptions}
|
|
value={planned}
|
|
onChange={(v) =>
|
|
setPending((prev) => {
|
|
const next = { ...prev };
|
|
if (!v || v === w.plannedYardId) delete next[w.id];
|
|
else next[w.id] = v;
|
|
return next;
|
|
})
|
|
}
|
|
w={180}
|
|
/>
|
|
) : (
|
|
<Group gap={4}>
|
|
<Text size="sm">{yardLabel(planned)}</Text>
|
|
{w.locked ? (
|
|
<Tooltip label={w.lockReason ?? "Locked"}>
|
|
<Lock size={14} />
|
|
</Tooltip>
|
|
) : null}
|
|
</Group>
|
|
)}
|
|
</Table.Td>
|
|
<Table.Td>
|
|
{planned === w.physicalYardId ? (
|
|
<Badge color="teal" variant="light" size="sm">
|
|
Aligned
|
|
</Badge>
|
|
) : (
|
|
<Badge color="orange" variant="light" size="sm">
|
|
Needs move
|
|
</Badge>
|
|
)}
|
|
</Table.Td>
|
|
</Table.Tr>
|
|
);
|
|
})}
|
|
</Table.Tbody>
|
|
</Table>
|
|
|
|
{editable ? (
|
|
<Group justify="flex-end">
|
|
<Text size="sm" c="dimmed">
|
|
{pendingCount} pending change(s)
|
|
</Text>
|
|
<Button variant="default" onClick={() => setPending({})} disabled={!pendingCount}>
|
|
Discard
|
|
</Button>
|
|
<Button
|
|
onClick={() => void handleSave()}
|
|
loading={save.isPending}
|
|
disabled={!pendingCount}
|
|
>
|
|
Save plan
|
|
</Button>
|
|
</Group>
|
|
) : null}
|
|
</Stack>
|
|
);
|
|
}
|