Files
edr-platform/apps/edr-freight-web/backoffice/src/components/trainScheduling/AdjustConsistModal.tsx
2026-07-14 13:49:39 +00:00

397 lines
13 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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>
);
}