feat(train-scheduling): mid-route consist changes, audit history, safer workspace

- planned couples: loose wagons join the train at a route stop, added
  from the schedule yards tab; capacity credits them per corridor edge
  and coupling validates locomotive weight/length caps per leg
- real-cut toggle: a cut wagon permanently leaves the train build at
  its cut yard (soft cut still sits out one trip only)
- fix heaviest-leg display counting a shared slot's full cargo on
  every spanned edge (phantom pull-weight overload on S-2026-00045)
- confirmation dialogs for workspace add/load/unload/remove actions
- train-builder History and Detached-wagons tabs, backed by paginated
  endpoints; builder detaches now always write adjustment-log rows

Migrations 3660 (planned_wagon_couples, planned_wagon_real_cuts) and
3670 (adjustment log train_schedule_id nullable) — both applied to the
dev DB by hand; watch mode does not run migrations.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Marshal
2026-08-23 03:55:54 +00:00
parent ba89b670c8
commit 8e6fc09aac
34 changed files with 2532 additions and 202 deletions

View File

@@ -231,6 +231,8 @@ import {
type BuildTrainPayload,
type BuiltTrainListFilters,
type BuiltTrainListResponse,
type DetachedWagonRow,
type TrainHistoryEntry,
type ScheduleConsist,
type ScheduleWagonYards,
type UpdateScheduleWagonYardsPayload,
@@ -2103,6 +2105,22 @@ export const api = {
({ id }) => QUERY_KEYS.TRAIN_BUILDER.composition(id),
),
// Keys derive to ["train-builder", "history"|"detachedWagons", input] — the
// shared TRAIN_BUILDER.ROOT invalidation refreshes both after every edit.
history: endpoint<
{ id: string; page: number; pageSize: number },
PaginatedResponse<TrainHistoryEntry>
>("train-builder", "history", ({ id, page, pageSize }) =>
trainBuilderService.getHistory(id, page, pageSize).then((r) => r.data),
),
detachedWagons: endpoint<
{ id: string; page: number; pageSize: number },
PaginatedResponse<DetachedWagonRow>
>("train-builder", "detachedWagons", ({ id, page, pageSize }) =>
trainBuilderService.getDetachedWagons(id, page, pageSize).then((r) => r.data),
),
// Key derives to ["train-builder", "usedTrainNumbers"], so the shared
// TRAIN_BUILDER.ROOT invalidation refreshes it after every build/edit.
usedTrainNumbers: endpoint<void, UsedTrainNumbers>(

View File

@@ -1,3 +1,5 @@
import type { PaginatedResponse } from "@edr/types";
import { api as apiClient } from "../auth/http";
// ---------------------------------------------------------------------------
@@ -300,6 +302,29 @@ export interface ScheduleHistoryEntry {
/** Adjust response: fresh consist + schedule-impact warnings to surface. */
export type AdjustConsistResult = ScheduleConsist & { warnings: string[] };
/** One wagon adjustment on a built train (History tab): builder edits and trip events alike. */
export interface TrainHistoryEntry {
id: string;
action: "ADD" | "REMOVE" | "SWITCH";
subject: string | null;
yardLabel: string | null;
actor: string | null;
/** Set when the change came from a trip (schedule); null = train-builder edit. */
scheduleReference: string | null;
occurredAt: string;
}
/** Wagon last detached from this train and still loose — the re-attach shortlist. */
export interface DetachedWagonRow {
wagonId: string;
wagonNumber: string;
wagonTypeCode: string | null;
currentYardLabel: string | null;
detachedAt: string;
detachedYardLabel: string | null;
detachedBy: string | null;
}
/** One consist wagon in the schedule-yards tab: where this departure plans it vs where it stands. */
export interface ScheduleWagonYardRow {
id: string;
@@ -313,6 +338,11 @@ export interface ScheduleWagonYardRow {
/** Drop stop this departure cuts the wagon at; null = rides to the destination. */
cutYardId: string | null;
cutYardLabel: string | null;
/** true = REAL cut: the built train permanently loses the wagon at the cut yard. */
realCut: boolean;
/** Set on planned-couple rows: the pickup stop this loose wagon joins the train at. */
coupledYardId: string | null;
coupledYardLabel: string | null;
aligned: boolean;
locked: boolean;
lockReason: string | null;
@@ -327,6 +357,8 @@ export interface ScheduleWagonYardStop {
physical: number;
/** Wagons this departure cuts (detaches and leaves) at this stop. */
cut: number;
/** Loose wagons this departure couples onto the train at this stop. */
coupled: number;
}
export interface ScheduleWagonYards {
@@ -340,7 +372,16 @@ export interface ScheduleWagonYards {
export interface UpdateScheduleWagonYardsPayload {
/** Omit a field to leave it unchanged; cutYardId null clears the cut (rides to destination). */
moves: Array<{ wagonId: string; yardId?: string; cutYardId?: string | null }>;
moves?: Array<{
wagonId: string;
yardId?: string;
cutYardId?: string | null;
realCut?: boolean;
}>;
/** Loose wagons to plan-couple at a pickup stop (they must stand at that yard). */
couple?: Array<{ wagonId: string; yardId: string }>;
/** Wagon ids to drop from the couple plan. */
uncouple?: string[];
}
export type UpdateScheduleWagonYardsResult = ScheduleWagonYards & { warnings: string[] };
@@ -349,6 +390,14 @@ export const trainBuilderService = {
list: (filters: BuiltTrainListFilters = {}) =>
apiClient.get<BuiltTrainListResponse>(`${BASE}${toQuery(filters)}`),
getComposition: (id: string) => apiClient.get<TrainComposition>(`${BASE}/${id}`),
getHistory: (id: string, page: number, pageSize: number) =>
apiClient.get<PaginatedResponse<TrainHistoryEntry>>(
`${BASE}/${id}/history?page=${page}&pageSize=${pageSize}`,
),
getDetachedWagons: (id: string, page: number, pageSize: number) =>
apiClient.get<PaginatedResponse<DetachedWagonRow>>(
`${BASE}/${id}/detached-wagons?page=${page}&pageSize=${pageSize}`,
),
/** Import/export run numbers already claimed by existing trains. */
usedTrainNumbers: () => apiClient.get<UsedTrainNumbers>(`${BASE}/used-train-numbers`),
build: (payload: BuildTrainPayload) => apiClient.post<TrainComposition>(BASE, payload),