mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-29 18:48:11 +00:00
fix train builder and consolidation
This commit is contained in:
@@ -0,0 +1,396 @@
|
||||
import {
|
||||
Alert,
|
||||
Badge,
|
||||
Button,
|
||||
Checkbox,
|
||||
Divider,
|
||||
Grid,
|
||||
Group,
|
||||
Modal,
|
||||
Progress,
|
||||
ScrollArea,
|
||||
Stack,
|
||||
Text,
|
||||
} from "@mantine/core";
|
||||
import { useMutation, useQuery } from "@tanstack/react-query";
|
||||
import { isAxiosError } from "axios";
|
||||
import { AlertTriangle, History, Minus, Plus } from "lucide-react";
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
|
||||
import { api } from "@/services/api";
|
||||
import type { ConsistWagonRef } from "@/services/trainBuilder.service";
|
||||
import { useToast } from "@/hooks/use-toast";
|
||||
|
||||
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;
|
||||
};
|
||||
|
||||
const tareOf = (w: ConsistWagonRef) => w.wagonType?.tareWeightTons ?? 0;
|
||||
const lengthOf = (w: ConsistWagonRef) => w.wagonType?.lengthMeters ?? 0;
|
||||
const round2 = (v: number) => Math.round(v * 100) / 100;
|
||||
|
||||
/**
|
||||
* Adjust the built train's consist from a schedule: trim free wagons (their
|
||||
* tare no longer rides — the fix when gross weight beats the pull limit) or
|
||||
* couple extra yard wagons while weight/length headroom remains. Changes are
|
||||
* permanent on the train and logged on the schedule.
|
||||
*/
|
||||
export default function AdjustConsistModal({
|
||||
scheduleId,
|
||||
opened,
|
||||
onClose,
|
||||
}: AdjustConsistModalProps) {
|
||||
const { toast } = useToast();
|
||||
const [removeIds, setRemoveIds] = useState<string[]>([]);
|
||||
const [addIds, setAddIds] = useState<string[]>([]);
|
||||
|
||||
const consistQuery = useQuery(
|
||||
api.trainScheduling.scheduleConsist.queryOptions({
|
||||
input: { scheduleId },
|
||||
enabled: opened && Boolean(scheduleId),
|
||||
}),
|
||||
);
|
||||
const adjust = useMutation(api.trainScheduling.adjustConsist.mutationOptions());
|
||||
const data = consistQuery.data;
|
||||
|
||||
useEffect(() => {
|
||||
if (opened) {
|
||||
setRemoveIds([]);
|
||||
setAddIds([]);
|
||||
}
|
||||
}, [opened]);
|
||||
|
||||
// Live projection: gross = cargo + tare of (consist − trims + adds).
|
||||
const projection = useMemo(() => {
|
||||
if (!data) return null;
|
||||
const removed = new Set(removeIds);
|
||||
const keptTare = data.wagons
|
||||
.filter((w) => !removed.has(w.id))
|
||||
.reduce((s, w) => s + tareOf(w), 0);
|
||||
const keptLength = data.wagons
|
||||
.filter((w) => !removed.has(w.id))
|
||||
.reduce((s, w) => s + lengthOf(w), 0);
|
||||
const addedWagons = data.addableWagons.filter((w) => addIds.includes(w.id));
|
||||
const tare = keptTare + addedWagons.reduce((s, w) => s + tareOf(w), 0);
|
||||
const length = keptLength + addedWagons.reduce((s, w) => s + lengthOf(w), 0);
|
||||
const gross = round2(data.totals.cargoTons + tare);
|
||||
return {
|
||||
wagonCount: data.totals.wagonCount - removeIds.length + addIds.length,
|
||||
tare: round2(tare),
|
||||
gross,
|
||||
length: round2(length),
|
||||
grossPct: data.limits.pullCapTons
|
||||
? Math.round((gross / data.limits.pullCapTons) * 100)
|
||||
: null,
|
||||
lengthPct: data.limits.lengthCapMeters
|
||||
? Math.round((length / data.limits.lengthCapMeters) * 100)
|
||||
: null,
|
||||
overWeight: data.limits.pullCapTons > 0 && gross > data.limits.pullCapTons,
|
||||
overLength:
|
||||
data.limits.lengthCapMeters > 0 && length > data.limits.lengthCapMeters,
|
||||
};
|
||||
}, [data, removeIds, addIds]);
|
||||
|
||||
const toggle = (setter: typeof setRemoveIds) => (id: string, checked: boolean) =>
|
||||
setter((prev) => (checked ? [...prev, id] : prev.filter((x) => x !== id)));
|
||||
|
||||
const handleSubmit = async () => {
|
||||
if (!removeIds.length && !addIds.length) return;
|
||||
try {
|
||||
await adjust.mutateAsync({
|
||||
scheduleId,
|
||||
payload: {
|
||||
...(addIds.length ? { addWagonIds: addIds } : {}),
|
||||
...(removeIds.length ? { removeWagonIds: removeIds } : {}),
|
||||
},
|
||||
});
|
||||
toast({
|
||||
title: `Consist updated — ${removeIds.length ? `${removeIds.length} trimmed` : ""}${
|
||||
removeIds.length && addIds.length ? ", " : ""
|
||||
}${addIds.length ? `${addIds.length} added` : ""}`,
|
||||
});
|
||||
setRemoveIds([]);
|
||||
setAddIds([]);
|
||||
} catch (err) {
|
||||
toast({
|
||||
title: "Adjustment failed",
|
||||
description: parseError(err, "Could not adjust the consist"),
|
||||
variant: "destructive",
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Modal
|
||||
opened={opened}
|
||||
onClose={onClose}
|
||||
title={
|
||||
<Text fw={600}>
|
||||
Adjust consist{data ? ` — train ${data.train.code}` : ""}
|
||||
</Text>
|
||||
}
|
||||
radius="lg"
|
||||
size={860}
|
||||
centered
|
||||
>
|
||||
{consistQuery.isLoading || !data ? (
|
||||
<Text py="lg" ta="center" c="dimmed" size="sm">
|
||||
{consistQuery.isError
|
||||
? "This schedule has no built train to adjust."
|
||||
: "Loading consist…"}
|
||||
</Text>
|
||||
) : (
|
||||
<Stack gap="md">
|
||||
{!data.editable ? (
|
||||
<Alert color="yellow" icon={<AlertTriangle size={16} />}>
|
||||
The consist is frozen once the train is dispatched.
|
||||
</Alert>
|
||||
) : null}
|
||||
|
||||
<Grid gap="md">
|
||||
<Grid.Col span={{ base: 12, sm: 6 }}>
|
||||
<LimitGauge
|
||||
label="Gross weight"
|
||||
detail={`${data.totals.cargoTons}T cargo + ${projection?.tare}T tare = ${projection?.gross}T of ${data.limits.pullCapTons}T (limit ${data.limits.maxPullWeightTons}T + ${data.limits.overageToleranceTons}T tolerance)`}
|
||||
pct={projection?.grossPct ?? null}
|
||||
over={projection?.overWeight ?? false}
|
||||
/>
|
||||
</Grid.Col>
|
||||
<Grid.Col span={{ base: 12, sm: 6 }}>
|
||||
<LimitGauge
|
||||
label="Consist length"
|
||||
detail={`${projection?.length}m of ${data.limits.lengthCapMeters}m (limit ${data.limits.maxTrainLengthMeters}m + ${data.limits.overageToleranceMeters}m tolerance)`}
|
||||
pct={projection?.lengthPct ?? null}
|
||||
over={projection?.overLength ?? false}
|
||||
/>
|
||||
</Grid.Col>
|
||||
</Grid>
|
||||
|
||||
<Grid gap="md">
|
||||
<Grid.Col span={{ base: 12, md: 6 }}>
|
||||
<Stack gap="xs">
|
||||
<Group gap={6}>
|
||||
<Minus size={14} />
|
||||
<Text size="sm" fw={600}>
|
||||
Trim coupled wagons ({data.totals.wagonCount})
|
||||
</Text>
|
||||
</Group>
|
||||
<Text size="xs" c="dimmed">
|
||||
Only free (unloaded, unpinned) wagons can be detached. Detaching is
|
||||
permanent — the wagon returns to the yard as available.
|
||||
</Text>
|
||||
<ScrollArea.Autosize mah={260} type="auto">
|
||||
<Stack gap={4}>
|
||||
{data.wagons.map((wagon) => (
|
||||
<WagonRow
|
||||
key={wagon.id}
|
||||
wagon={wagon}
|
||||
checked={removeIds.includes(wagon.id)}
|
||||
disabled={!data.editable || !wagon.removable}
|
||||
badge={
|
||||
wagon.loaded ? "Loaded" : !wagon.removable ? "Pinned" : null
|
||||
}
|
||||
onToggle={toggle(setRemoveIds)}
|
||||
/>
|
||||
))}
|
||||
</Stack>
|
||||
</ScrollArea.Autosize>
|
||||
</Stack>
|
||||
</Grid.Col>
|
||||
<Grid.Col span={{ base: 12, md: 6 }}>
|
||||
<Stack gap="xs">
|
||||
<Group gap={6}>
|
||||
<Plus size={14} />
|
||||
<Text size="sm" fw={600}>
|
||||
Couple yard wagons ({data.addableWagons.length} available)
|
||||
</Text>
|
||||
</Group>
|
||||
<Text size="xs" c="dimmed">
|
||||
AVAILABLE wagons standing in the train's yard. Blocked when they push
|
||||
gross weight or length past the locomotive limits incl. tolerance.
|
||||
</Text>
|
||||
<ScrollArea.Autosize mah={260} type="auto">
|
||||
<Stack gap={4}>
|
||||
{data.addableWagons.length ? (
|
||||
data.addableWagons.map((wagon) => (
|
||||
<WagonRow
|
||||
key={wagon.id}
|
||||
wagon={wagon}
|
||||
checked={addIds.includes(wagon.id)}
|
||||
disabled={!data.editable}
|
||||
badge={null}
|
||||
onToggle={toggle(setAddIds)}
|
||||
/>
|
||||
))
|
||||
) : (
|
||||
<Text size="sm" c="dimmed" py="sm" ta="center">
|
||||
No available wagons in this yard
|
||||
</Text>
|
||||
)}
|
||||
</Stack>
|
||||
</ScrollArea.Autosize>
|
||||
</Stack>
|
||||
</Grid.Col>
|
||||
</Grid>
|
||||
|
||||
{data.adjustments.length ? (
|
||||
<>
|
||||
<Divider />
|
||||
<Stack gap={4}>
|
||||
<Group gap={6}>
|
||||
<History size={14} />
|
||||
<Text size="sm" fw={600}>
|
||||
Adjustment history
|
||||
</Text>
|
||||
</Group>
|
||||
<ScrollArea.Autosize mah={120} type="auto">
|
||||
<Stack gap={2}>
|
||||
{data.adjustments.map((log) => (
|
||||
<Group key={log.id} gap="xs">
|
||||
<Badge
|
||||
size="xs"
|
||||
variant="light"
|
||||
color={log.action === "ADD" ? "edr-green" : "red"}
|
||||
>
|
||||
{log.action === "ADD" ? "Added" : "Trimmed"}
|
||||
</Badge>
|
||||
<Text size="xs" ff="monospace">
|
||||
{log.wagonNumber}
|
||||
</Text>
|
||||
<Text size="xs" c="dimmed">
|
||||
{new Date(log.occurredAt).toLocaleString()}
|
||||
</Text>
|
||||
</Group>
|
||||
))}
|
||||
</Stack>
|
||||
</ScrollArea.Autosize>
|
||||
</Stack>
|
||||
</>
|
||||
) : null}
|
||||
|
||||
<Group justify="space-between">
|
||||
<Text size="xs" c="dimmed">
|
||||
Projected consist: {projection?.wagonCount} wagons
|
||||
</Text>
|
||||
<Group>
|
||||
<Button variant="default" onClick={onClose}>
|
||||
Close
|
||||
</Button>
|
||||
<Button
|
||||
loading={adjust.isPending}
|
||||
disabled={
|
||||
!data.editable ||
|
||||
(!removeIds.length && !addIds.length) ||
|
||||
(addIds.length > 0 && (projection?.overWeight || projection?.overLength))
|
||||
}
|
||||
onClick={handleSubmit}
|
||||
>
|
||||
Apply{" "}
|
||||
{removeIds.length ? `−${removeIds.length}` : ""}
|
||||
{removeIds.length && addIds.length ? " / " : ""}
|
||||
{addIds.length ? `+${addIds.length}` : ""}
|
||||
</Button>
|
||||
</Group>
|
||||
</Group>
|
||||
</Stack>
|
||||
)}
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
|
||||
export interface AdjustConsistModalProps {
|
||||
scheduleId: string;
|
||||
opened: boolean;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
function LimitGauge({
|
||||
label,
|
||||
detail,
|
||||
pct,
|
||||
over,
|
||||
}: {
|
||||
label: string;
|
||||
detail: string;
|
||||
pct: number | null;
|
||||
over: boolean;
|
||||
}) {
|
||||
return (
|
||||
<Stack gap={4}>
|
||||
<Group justify="space-between">
|
||||
<Text size="xs" fw={600}>
|
||||
{label}
|
||||
</Text>
|
||||
<Text size="xs" fw={700} c={over ? "red.7" : "edr-green.7"}>
|
||||
{pct != null ? `${pct}%` : "—"}
|
||||
</Text>
|
||||
</Group>
|
||||
<Progress
|
||||
value={Math.min(pct ?? 0, 100)}
|
||||
size="md"
|
||||
radius="xl"
|
||||
color={over ? "red" : (pct ?? 0) > 85 ? "yellow" : "edr-green"}
|
||||
striped={over}
|
||||
animated={over}
|
||||
/>
|
||||
<Text size="xs" c="dimmed">
|
||||
{detail}
|
||||
</Text>
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
|
||||
function WagonRow({
|
||||
wagon,
|
||||
checked,
|
||||
disabled,
|
||||
badge,
|
||||
onToggle,
|
||||
}: {
|
||||
wagon: ConsistWagonRef;
|
||||
checked: boolean;
|
||||
disabled: boolean;
|
||||
badge: string | null;
|
||||
onToggle: (id: string, checked: boolean) => void;
|
||||
}) {
|
||||
return (
|
||||
<Group
|
||||
gap="sm"
|
||||
wrap="nowrap"
|
||||
p={6}
|
||||
style={{
|
||||
border: "1px solid var(--mantine-color-gray-2)",
|
||||
borderRadius: "var(--mantine-radius-md)",
|
||||
opacity: disabled && !badge ? 0.7 : 1,
|
||||
}}
|
||||
>
|
||||
<Checkbox
|
||||
size="sm"
|
||||
checked={checked}
|
||||
disabled={disabled}
|
||||
onChange={(e) => onToggle(wagon.id, e.currentTarget.checked)}
|
||||
aria-label={`Select wagon ${wagon.wagonNumber}`}
|
||||
/>
|
||||
<Stack gap={0} style={{ flex: 1, minWidth: 0 }}>
|
||||
<Text size="sm" fw={600} ff="monospace" truncate>
|
||||
{wagon.wagonNumber}
|
||||
</Text>
|
||||
<Text size="xs" c="dimmed" truncate>
|
||||
{wagon.wagonType
|
||||
? `${wagon.wagonType.code} · ${wagon.wagonType.tareWeightTons}T tare · ${wagon.wagonType.lengthMeters}m`
|
||||
: "Unknown type"}
|
||||
</Text>
|
||||
</Stack>
|
||||
{badge ? (
|
||||
<Badge size="xs" variant="light" color={badge === "Loaded" ? "orange" : "gray"}>
|
||||
{badge}
|
||||
</Badge>
|
||||
) : null}
|
||||
</Group>
|
||||
);
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
import {
|
||||
ActionIcon,
|
||||
Alert,
|
||||
Badge,
|
||||
Box,
|
||||
Button,
|
||||
@@ -324,6 +325,21 @@ export default function NewBookingPage() {
|
||||
const containerWeight = lines.reduce((s, l) => s + (l.quantity || 0) * (l.vgmPerUnitTons || 0), 0);
|
||||
const cargoTotalWeightVgm = freightType === "CONTAINER" ? containerWeight : bulkWeight;
|
||||
|
||||
// 20ft containers ride two per wagon, so a booking must hold an even number
|
||||
// of them — odd counts would leave half a wagon waiting on a co-loader
|
||||
// (cross-booking consolidation is disabled for now).
|
||||
const twentyFtCount = useMemo(() => {
|
||||
const sizeById = new Map<string, string>();
|
||||
for (const group of refData?.containers ?? []) {
|
||||
for (const type of group.types) sizeById.set(type.id, group.size);
|
||||
}
|
||||
return lines.reduce((sum, l) => {
|
||||
const size = l.containerTypeId ? (sizeById.get(l.containerTypeId) ?? "") : "";
|
||||
return String(size).includes("20") ? sum + (l.quantity || 0) : sum;
|
||||
}, 0);
|
||||
}, [refData?.containers, lines]);
|
||||
const hasOdd20ft = freightType === "CONTAINER" && twentyFtCount % 2 === 1;
|
||||
|
||||
// ---- validation ----
|
||||
const lineValid = (l: ContainerLine) =>
|
||||
Boolean(l.containerTypeId) && l.quantity >= 1 && l.vgmPerUnitTons > 0;
|
||||
@@ -344,7 +360,7 @@ export default function NewBookingPage() {
|
||||
(isGovernment ? Boolean(govCompanyId && govProfileId) : Boolean(companyId)) &&
|
||||
(freightType === "BULK"
|
||||
? Boolean(cargoTypeId) && bulkWeight > 0
|
||||
: allLinesValid);
|
||||
: allLinesValid && !hasOdd20ft);
|
||||
|
||||
const updateLine = (key: string, patch: Partial<ContainerLine>) =>
|
||||
setLines((prev) => prev.map((l) => (l.key === key ? { ...l, ...patch } : l)));
|
||||
@@ -842,6 +858,20 @@ export default function NewBookingPage() {
|
||||
</Group>
|
||||
) : null}
|
||||
|
||||
{hasOdd20ft ? (
|
||||
<Alert color="red" icon={<AlertTriangle size={16} />} radius="md" mt="md">
|
||||
<Text size="sm" fw={600}>
|
||||
Odd number of 20ft containers ({twentyFtCount})
|
||||
</Text>
|
||||
<Text size="xs" mt={4}>
|
||||
20ft containers travel two per wagon, so they must be booked in
|
||||
even numbers. Add one more 20ft container or remove one — e.g.
|
||||
book {twentyFtCount + 1} or {twentyFtCount - 1} instead of{" "}
|
||||
{twentyFtCount}.
|
||||
</Text>
|
||||
</Alert>
|
||||
) : null}
|
||||
|
||||
<Stack gap="sm" mt="lg">
|
||||
<Button
|
||||
size="md"
|
||||
|
||||
@@ -172,8 +172,8 @@ export default function TrainBuilderDetailPage() {
|
||||
{ label: "Locomotives", value: composition.locomotives.length, icon: TrainFront },
|
||||
{ label: "Wagons", value: totals.wagonCount, icon: TrainIcon },
|
||||
{
|
||||
label: "Payload available",
|
||||
value: `${totals.payloadCapacityTons}T of ${totals.maxPullWeightTons}T`,
|
||||
label: "Tare weight / haul limit",
|
||||
value: `${totals.totalTareTons}T of ${totals.maxPullWeightTons}T`,
|
||||
icon: Weight,
|
||||
},
|
||||
{
|
||||
|
||||
@@ -43,6 +43,7 @@ import { CountdownTimer, DataTable, type ColumnDef } from "@edr/ui-common";
|
||||
import { KpiStrip, PageContainer } from "@/components/page";
|
||||
import { ruleEngineTable } from "@/components/ruleEngine/ruleEngineStyles";
|
||||
import Breadcrumbs from "@/components/ui/Breadcrumbs";
|
||||
import AdjustConsistModal from "@/components/trainScheduling/AdjustConsistModal";
|
||||
import { TrainCompositionDiagram } from "@/components/trainScheduling/TrainCompositionDiagram";
|
||||
import {
|
||||
TrainConsistView,
|
||||
@@ -684,6 +685,7 @@ export default function BatchScheduleDetailPage() {
|
||||
);
|
||||
|
||||
const [activeTab, setActiveTab] = useState<string | null>("overview");
|
||||
const [adjustConsistOpen, setAdjustConsistOpen] = useState(false);
|
||||
const [selectedBookingId, setSelectedBookingId] = useState<string | null>(
|
||||
null,
|
||||
);
|
||||
@@ -870,6 +872,17 @@ export default function BatchScheduleDetailPage() {
|
||||
>
|
||||
Refresh
|
||||
</Button>
|
||||
{data.train && ["DRAFT", "SCHEDULED"].includes(data.status) ? (
|
||||
<Button
|
||||
variant="light"
|
||||
color="edr-green"
|
||||
radius="md"
|
||||
leftSection={<TrainFront size={16} />}
|
||||
onClick={() => setAdjustConsistOpen(true)}
|
||||
>
|
||||
Adjust consist
|
||||
</Button>
|
||||
) : null}
|
||||
{data.windowPhase === "DOC_REVIEW" ? (
|
||||
<Button
|
||||
color="yellow"
|
||||
@@ -1181,6 +1194,12 @@ export default function BatchScheduleDetailPage() {
|
||||
)}
|
||||
</Tabs.Panel>
|
||||
</Tabs>
|
||||
|
||||
<AdjustConsistModal
|
||||
scheduleId={data.scheduleId}
|
||||
opened={adjustConsistOpen}
|
||||
onClose={() => setAdjustConsistOpen(false)}
|
||||
/>
|
||||
</PageContainer>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -52,6 +52,7 @@ import { IntercityRideAlongPanel } from "@/components/trainScheduling/IntercityR
|
||||
import { YardWorkPanel } from "@/components/trainScheduling/YardWorkPanel";
|
||||
// import { ImportLoadingConfirmationPanel } from "@/components/trainScheduling/ImportLoadingConfirmationPanel";
|
||||
import { RescheduleTrainDialog } from "@/components/trainScheduling/RescheduleTrainDialog";
|
||||
import AdjustConsistModal from "@/components/trainScheduling/AdjustConsistModal";
|
||||
import BookingWindowSettingsModal from "@/components/trainScheduling/BookingWindowSettingsModal";
|
||||
// import { ScheduleBatchPanel } from "@/components/trainScheduling/ScheduleBatchPanel";
|
||||
import { ScheduleBookingsStep } from "@/components/trainScheduling/ScheduleBookingsStep";
|
||||
@@ -101,6 +102,7 @@ export default function TrainScheduleV2DetailPage() {
|
||||
const [previewResult, setPreviewResult] = useState<TrainSchedulePreviewResponse | null>(null);
|
||||
const [containerPlacements, setContainerPlacements] = useState<ContainerPlacement[]>([]);
|
||||
const [maintenanceOpen, setMaintenanceOpen] = useState(false);
|
||||
const [adjustConsistOpen, setAdjustConsistOpen] = useState(false);
|
||||
const [windowSettingsOpen, setWindowSettingsOpen] = useState(false);
|
||||
const [gatepassSecuredAt, setGatepassSecuredAt] = useState("");
|
||||
const [gatepassReference, setGatepassReference] = useState("");
|
||||
@@ -949,6 +951,18 @@ export default function TrainScheduleV2DetailPage() {
|
||||
Reschedule train
|
||||
</Button>
|
||||
) : null}
|
||||
{schedule.train && ["DRAFT", "SCHEDULED"].includes(schedule.status) ? (
|
||||
<Button
|
||||
variant="light"
|
||||
color="edr-green"
|
||||
radius="lg"
|
||||
size="sm"
|
||||
leftSection={<Train size={16} />}
|
||||
onClick={() => setAdjustConsistOpen(true)}
|
||||
>
|
||||
Adjust consist
|
||||
</Button>
|
||||
) : null}
|
||||
{gatepassApplies ? (
|
||||
gatepassSecured ? (
|
||||
<Button
|
||||
@@ -1173,6 +1187,12 @@ export default function TrainScheduleV2DetailPage() {
|
||||
/>
|
||||
) : null}
|
||||
|
||||
<AdjustConsistModal
|
||||
scheduleId={schedule.id}
|
||||
opened={adjustConsistOpen}
|
||||
onClose={() => setAdjustConsistOpen(false)}
|
||||
/>
|
||||
|
||||
<BookingWindowSettingsModal
|
||||
scheduleId={scheduleId ?? null}
|
||||
opened={windowSettingsOpen}
|
||||
|
||||
@@ -184,10 +184,12 @@ import {
|
||||
import { trainService, type Train } from "./trains.service";
|
||||
import {
|
||||
trainBuilderService,
|
||||
type AdjustConsistPayload,
|
||||
type AvailableTrain,
|
||||
type BuildTrainPayload,
|
||||
type BuiltTrainListFilters,
|
||||
type BuiltTrainListResponse,
|
||||
type ScheduleConsist,
|
||||
type TrainComposition,
|
||||
} from "./trainBuilder.service";
|
||||
import { trainSchedulingService } from "./trainScheduling.service";
|
||||
@@ -320,6 +322,30 @@ export const api = {
|
||||
({ routeId }) => QUERY_KEYS.TRAIN_SCHEDULING.availableTrains(routeId),
|
||||
),
|
||||
|
||||
scheduleConsist: endpoint<{ scheduleId: string }, ScheduleConsist>(
|
||||
"train-scheduling",
|
||||
"schedule-consist",
|
||||
({ scheduleId }) =>
|
||||
trainBuilderService.scheduleConsist(scheduleId).then((r) => r.data),
|
||||
({ scheduleId }) => [
|
||||
...QUERY_KEYS.TRAIN_SCHEDULING.ROOT,
|
||||
"consist",
|
||||
scheduleId,
|
||||
],
|
||||
),
|
||||
|
||||
adjustConsist: endpoint<
|
||||
{ scheduleId: string; payload: AdjustConsistPayload },
|
||||
ScheduleConsist
|
||||
>(
|
||||
"train-scheduling",
|
||||
"adjust-consist",
|
||||
({ scheduleId, payload }) =>
|
||||
trainBuilderService.adjustConsist(scheduleId, payload).then((r) => r.data),
|
||||
undefined,
|
||||
() => TRAIN_BUILDER_INVALIDATIONS,
|
||||
),
|
||||
|
||||
bookableSchedules: endpoint<
|
||||
{ originYardId?: string | null; destinationYardId?: string | null },
|
||||
BookableSchedule[]
|
||||
|
||||
@@ -153,6 +153,64 @@ const toQuery = (filters: BuiltTrainListFilters = {}) => {
|
||||
return qs ? `?${qs}` : "";
|
||||
};
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Schedule consist adjustment (train-bound schedules)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export interface ConsistWagonRef {
|
||||
id: string;
|
||||
wagonNumber: string;
|
||||
sequenceNumber: number | null;
|
||||
wagonType: {
|
||||
id: string;
|
||||
code: string;
|
||||
tareWeightTons: number;
|
||||
capacityTons: number;
|
||||
lengthMeters: number;
|
||||
} | null;
|
||||
}
|
||||
|
||||
export interface ScheduleConsist {
|
||||
schedule: { id: string; reference: string | null; status: string };
|
||||
train: {
|
||||
id: string;
|
||||
code: string;
|
||||
trainName: string | null;
|
||||
currentYardId: string | null;
|
||||
};
|
||||
limits: {
|
||||
maxPullWeightTons: number;
|
||||
overageToleranceTons: number;
|
||||
pullCapTons: number;
|
||||
maxTrainLengthMeters: number;
|
||||
overageToleranceMeters: number;
|
||||
lengthCapMeters: number;
|
||||
};
|
||||
totals: {
|
||||
wagonCount: number;
|
||||
cargoTons: number;
|
||||
consistTareTons: number;
|
||||
grossTons: number;
|
||||
consistLengthMeters: number;
|
||||
};
|
||||
wagons: Array<ConsistWagonRef & { loaded: boolean; removable: boolean }>;
|
||||
addableWagons: ConsistWagonRef[];
|
||||
adjustments: Array<{
|
||||
id: string;
|
||||
action: "ADD" | "REMOVE";
|
||||
wagonId: string;
|
||||
wagonNumber: string;
|
||||
adjustedByUserId: string | null;
|
||||
occurredAt: string;
|
||||
}>;
|
||||
editable: boolean;
|
||||
}
|
||||
|
||||
export interface AdjustConsistPayload {
|
||||
addWagonIds?: string[];
|
||||
removeWagonIds?: string[];
|
||||
}
|
||||
|
||||
export const trainBuilderService = {
|
||||
list: (filters: BuiltTrainListFilters = {}) =>
|
||||
apiClient.get<BuiltTrainListResponse>(`${BASE}${toQuery(filters)}`),
|
||||
@@ -175,4 +233,13 @@ export const trainBuilderService = {
|
||||
apiClient.get<AvailableTrain[]>(`/train-scheduling/available-trains`, {
|
||||
params: { routeId },
|
||||
}),
|
||||
/** Consist snapshot for a train-bound schedule (adjust-consist UI). */
|
||||
scheduleConsist: (scheduleId: string) =>
|
||||
apiClient.get<ScheduleConsist>(`/train-scheduling/schedules/${scheduleId}/consist`),
|
||||
/** Permanently trim/add wagons on the schedule's built train. */
|
||||
adjustConsist: (scheduleId: string, payload: AdjustConsistPayload) =>
|
||||
apiClient.post<ScheduleConsist>(
|
||||
`/train-scheduling/schedules/${scheduleId}/adjust-consist`,
|
||||
payload,
|
||||
),
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user