fix issue

This commit is contained in:
Marshal
2026-08-22 01:23:50 +00:00
parent b6c1efa043
commit 1ab2cfdb3b
13 changed files with 874 additions and 109 deletions

View File

@@ -54,6 +54,8 @@ export function ScheduleWagonYardPanel({ scheduleId, canEdit }: Props) {
/** wagonId → yardId queued but not yet saved. */
const [pending, setPending] = useState<Record<string, string>>({});
/** wagonId → cut yard queued but not yet saved; null = queued clear (rides to destination). */
const [pendingCut, setPendingCut] = useState<Record<string, string | null>>({});
const [bulkType, setBulkType] = useState<string | null>(null);
const [bulkFrom, setBulkFrom] = useState<string | null>(null);
const [bulkTo, setBulkTo] = useState<string | null>(null);
@@ -70,6 +72,32 @@ export function ScheduleWagonYardPanel({ scheduleId, canEdit }: Props) {
"—";
const effectiveYard = (w: ScheduleWagonYardRow) => pending[w.id] ?? w.plannedYardId;
const effectiveCut = (w: ScheduleWagonYardRow) =>
w.id in pendingCut ? pendingCut[w.id] : w.cutYardId;
const stopIndexOf = (yardId: string | null) =>
yardId == null ? -1 : (data?.stops ?? []).findIndex((s) => s.yardId === yardId);
/** Drop stops a wagon boarding at `boardYardId` can be cut at — strictly after
* boarding, excluding the destination (that's the cleared/default state). */
const cutOptionsFor = (boardYardId: string | null) => {
const stops = data?.stops ?? [];
const boardIdx = Math.max(0, stopIndexOf(boardYardId));
return stops
.slice(boardIdx + 1, stops.length - 1)
.map((s) => ({ value: s.yardId, label: s.label }));
};
/** Board-yard changes can invalidate a cut (server rejects cut ≤ board) — queue a clear. */
const clearInvalidCut = (
next: Record<string, string | null>,
w: ScheduleWagonYardRow,
boardYardId: string | null,
) => {
const cut = w.id in next ? next[w.id] : w.cutYardId;
if (cut != null && stopIndexOf(cut) <= stopIndexOf(boardYardId)) {
if (w.cutYardId == null) delete next[w.id];
else next[w.id] = null;
}
return next;
};
const perStop = useMemo(
() =>
@@ -77,8 +105,11 @@ export function ScheduleWagonYardPanel({ scheduleId, canEdit }: Props) {
...s,
planned: (data?.wagons ?? []).filter((w) => (pending[w.id] ?? w.plannedYardId) === s.yardId)
.length,
cut: (data?.wagons ?? []).filter(
(w) => (w.id in pendingCut ? pendingCut[w.id] : w.cutYardId) === s.yardId,
).length,
})),
[data, pending],
[data, pending, pendingCut],
);
const typeOptions = useMemo(() => {
const seen = new Map<string, string>();
@@ -86,7 +117,7 @@ export function ScheduleWagonYardPanel({ scheduleId, canEdit }: Props) {
return [...seen].map(([value, label]) => ({ value, label }));
}, [data]);
const pendingCount = Object.keys(pending).length;
const pendingCount = new Set([...Object.keys(pending), ...Object.keys(pendingCut)]).size;
const queueBulk = () => {
if (!data || !bulkFrom || !bulkTo || bulkFrom === bulkTo) return;
@@ -111,18 +142,29 @@ export function ScheduleWagonYardPanel({ scheduleId, canEdit }: Props) {
}
return next;
});
setPendingCut((prev) => {
let next = { ...prev };
for (const w of picked) next = clearInvalidCut(next, w, bulkTo);
return next;
});
};
const handleSave = async () => {
if (!pendingCount) return;
try {
const wagonIds = [...new Set([...Object.keys(pending), ...Object.keys(pendingCut)])];
const result = await save.mutateAsync({
scheduleId,
payload: {
moves: Object.entries(pending).map(([wagonId, yardId]) => ({ wagonId, yardId })),
moves: wagonIds.map((wagonId) => ({
wagonId,
...(wagonId in pending ? { yardId: pending[wagonId] } : {}),
...(wagonId in pendingCut ? { cutYardId: pendingCut[wagonId] } : {}),
})),
},
});
setPending({});
setPendingCut({});
toast({
title: `Schedule yards updated — ${pendingCount} wagon(s) re-planned`,
description: result.warnings.length ? result.warnings.join(" ") : undefined,
@@ -153,8 +195,10 @@ export function ScheduleWagonYardPanel({ scheduleId, canEdit }: Props) {
<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.
origin). <b>Physical</b> = where the wagon stands now (train builder). <b>Cut at</b> ={" "}
where this departure detaches the wagon and leaves it blank means it rides to the
destination; booking capacity past the cut shrinks accordingly. Dispatch is blocked until
every wagon stands at its planned yard.
{data.misaligned > 0 ? (
<Text component="span" c="orange" fw={600}>
{" "}
@@ -183,6 +227,16 @@ export function ScheduleWagonYardPanel({ scheduleId, canEdit }: Props) {
<Badge color={s.physical === s.planned ? "gray" : "orange"} variant="light">
Physical {s.physical}
</Badge>
{s.cut > 0 ? (
<Badge color="red" variant="light">
Cut {s.cut}
</Badge>
) : null}
{!s.pickup ? (
<Badge color="blue" variant="light">
Through {data.wagons.length - perStop.reduce((sum, p) => sum + p.cut, 0)}
</Badge>
) : null}
</Group>
</Paper>
))}
@@ -229,13 +283,15 @@ export function ScheduleWagonYardPanel({ scheduleId, canEdit }: Props) {
<Table.Th>Type</Table.Th>
<Table.Th>Physical yard</Table.Th>
<Table.Th>Planned yard (this schedule)</Table.Th>
<Table.Th>Cut at (rides to)</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;
const cut = effectiveCut(w);
const changed = w.id in pending || w.id in pendingCut;
return (
<Table.Tr key={w.id} bg={changed ? "var(--mantine-color-yellow-light)" : undefined}>
<Table.Td>{w.sequenceNumber ?? "—"}</Table.Td>
@@ -252,14 +308,17 @@ export function ScheduleWagonYardPanel({ scheduleId, canEdit }: Props) {
size="xs"
data={yardOptions}
value={planned}
onChange={(v) =>
onChange={(v) => {
setPending((prev) => {
const next = { ...prev };
if (!v || v === w.plannedYardId) delete next[w.id];
else next[w.id] = v;
return next;
})
}
});
setPendingCut((prev) =>
clearInvalidCut({ ...prev }, w, v ?? w.plannedYardId),
);
}}
w={180}
/>
) : (
@@ -273,6 +332,30 @@ export function ScheduleWagonYardPanel({ scheduleId, canEdit }: Props) {
</Group>
)}
</Table.Td>
<Table.Td>
{editable ? (
// Locked wagons stay editable here — the server enforces the
// cargo-destination floor and the toast explains a 409.
<Select
size="xs"
clearable
placeholder="Destination"
data={cutOptionsFor(planned)}
value={cut}
onChange={(v) =>
setPendingCut((prev) => {
const next = { ...prev };
if ((v ?? null) === w.cutYardId) delete next[w.id];
else next[w.id] = v ?? null;
return next;
})
}
w={180}
/>
) : (
<Text size="sm">{cut ? yardLabel(cut) : "Destination"}</Text>
)}
</Table.Td>
<Table.Td>
{planned === w.physicalYardId ? (
<Badge color="teal" variant="light" size="sm">
@@ -295,7 +378,14 @@ export function ScheduleWagonYardPanel({ scheduleId, canEdit }: Props) {
<Text size="sm" c="dimmed">
{pendingCount} pending change(s)
</Text>
<Button variant="default" onClick={() => setPending({})} disabled={!pendingCount}>
<Button
variant="default"
onClick={() => {
setPending({});
setPendingCut({});
}}
disabled={!pendingCount}
>
Discard
</Button>
<Button

View File

@@ -310,6 +310,9 @@ export interface ScheduleWagonYardRow {
physicalYardLabel: string | null;
plannedYardId: string | null;
plannedYardLabel: string | null;
/** Drop stop this departure cuts the wagon at; null = rides to the destination. */
cutYardId: string | null;
cutYardLabel: string | null;
aligned: boolean;
locked: boolean;
lockReason: string | null;
@@ -322,6 +325,8 @@ export interface ScheduleWagonYardStop {
pickup: boolean;
planned: number;
physical: number;
/** Wagons this departure cuts (detaches and leaves) at this stop. */
cut: number;
}
export interface ScheduleWagonYards {
@@ -334,7 +339,8 @@ export interface ScheduleWagonYards {
}
export interface UpdateScheduleWagonYardsPayload {
moves: Array<{ wagonId: string; yardId: string }>;
/** Omit a field to leave it unchanged; cutYardId null clears the cut (rides to destination). */
moves: Array<{ wagonId: string; yardId?: string; cutYardId?: string | null }>;
}
export type UpdateScheduleWagonYardsResult = ScheduleWagonYards & { warnings: string[] };