mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-29 07:10:57 +00:00
658 lines
23 KiB
TypeScript
658 lines
23 KiB
TypeScript
import {
|
||
Alert,
|
||
Badge,
|
||
Button,
|
||
Checkbox,
|
||
Divider,
|
||
Grid,
|
||
Group,
|
||
Modal,
|
||
Progress,
|
||
ScrollArea,
|
||
Select,
|
||
Stack,
|
||
Text,
|
||
Tooltip,
|
||
} from "@mantine/core";
|
||
import { useMutation, useQuery } from "@tanstack/react-query";
|
||
import { isAxiosError } from "axios";
|
||
import { AlertTriangle, ArrowLeftRight, History, MapPin, Minus, Plus } from "lucide-react";
|
||
import { useEffect, useMemo, useState } from "react";
|
||
|
||
import { api } from "@/services/api";
|
||
import type { ConsistWagonRef, ScheduleConsist } 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;
|
||
|
||
type ConsistWagon = ScheduleConsist["wagons"][number];
|
||
|
||
/**
|
||
* 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),
|
||
* couple extra yard wagons while weight/length headroom remains, or SWITCH a
|
||
* wagon for a same-type replacement — the replacement inherits the slot, cargo
|
||
* included, which is the only way a loaded wagon leaves the train. Works
|
||
* before departure and mid-route while the train stands at a checkpointed
|
||
* stop. 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[]>([]);
|
||
// fromWagonId → toWagonId. A switch is same-type, so it never moves the
|
||
// weight/length/slot projections — it only changes which steel rides.
|
||
const [switchMap, setSwitchMap] = useState<Record<string, 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([]);
|
||
setSwitchMap({});
|
||
}
|
||
}, [opened]);
|
||
|
||
const switchCount = Object.keys(switchMap).length;
|
||
const usedReplacementIds = useMemo(
|
||
() => new Set(Object.values(switchMap)),
|
||
[switchMap],
|
||
);
|
||
|
||
// Live projection: gross = cargo + tare of (consist − trims + adds), plus
|
||
// the schedule's wagon-slot picture — the consist IS the booking capacity
|
||
// (weight/length only bind while assembling the consist), so trims/adds
|
||
// move the FULL line in real time. Switches are same-type and cancel out.
|
||
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);
|
||
const wagonCount = data.totals.wagonCount - removeIds.length + addIds.length;
|
||
const cap = data.scheduleCapacity;
|
||
const freeSlots = cap ? wagonCount - cap.allocatedWagons : null;
|
||
return {
|
||
wagonCount,
|
||
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,
|
||
slots:
|
||
cap && freeSlots != null
|
||
? {
|
||
allocated: cap.allocatedWagons,
|
||
free: freeSlots,
|
||
pct:
|
||
wagonCount > 0
|
||
? Math.round((cap.allocatedWagons / wagonCount) * 100)
|
||
: null,
|
||
isFullNow: cap.bookingWindowStatus === "FULL",
|
||
willBeFull: freeSlots <= 0,
|
||
overAllocated: freeSlots < 0,
|
||
willReopen: cap.bookingWindowStatus === "FULL" && freeSlots > 0,
|
||
}
|
||
: null,
|
||
};
|
||
}, [data, removeIds, addIds]);
|
||
|
||
const hasChanges = removeIds.length > 0 || addIds.length > 0 || switchCount > 0;
|
||
|
||
const toggle = (setter: typeof setRemoveIds) => (id: string, checked: boolean) =>
|
||
setter((prev) => (checked ? [...prev, id] : prev.filter((x) => x !== id)));
|
||
|
||
// Same-type replacements standing at the current stop, minus wagons already
|
||
// spoken for by another switch or a couple selection.
|
||
const switchOptionsFor = (wagon: ConsistWagon) =>
|
||
(data?.addableWagons ?? [])
|
||
.filter(
|
||
(candidate) =>
|
||
candidate.wagonType?.id === wagon.wagonType?.id &&
|
||
!addIds.includes(candidate.id) &&
|
||
(!usedReplacementIds.has(candidate.id) ||
|
||
switchMap[wagon.id] === candidate.id),
|
||
)
|
||
.map((candidate) => ({ value: candidate.id, label: candidate.wagonNumber }));
|
||
|
||
const setSwitch = (fromId: string, toId: string | null) =>
|
||
setSwitchMap((prev) => {
|
||
const next = { ...prev };
|
||
if (toId) next[fromId] = toId;
|
||
else delete next[fromId];
|
||
return next;
|
||
});
|
||
|
||
const handleSubmit = async () => {
|
||
if (!hasChanges) return;
|
||
try {
|
||
const result = await adjust.mutateAsync({
|
||
scheduleId,
|
||
payload: {
|
||
...(addIds.length ? { addWagonIds: addIds } : {}),
|
||
...(removeIds.length ? { removeWagonIds: removeIds } : {}),
|
||
...(switchCount
|
||
? {
|
||
switches: Object.entries(switchMap).map(([fromWagonId, toWagonId]) => ({
|
||
fromWagonId,
|
||
toWagonId,
|
||
})),
|
||
}
|
||
: {}),
|
||
},
|
||
});
|
||
toast({
|
||
title: `Consist updated — ${[
|
||
removeIds.length ? `${removeIds.length} trimmed` : "",
|
||
addIds.length ? `${addIds.length} added` : "",
|
||
switchCount ? `${switchCount} switched` : "",
|
||
]
|
||
.filter(Boolean)
|
||
.join(", ")}`,
|
||
});
|
||
// Schedule-impact warnings from the API: window reopened / now FULL /
|
||
// consist trimmed below what bookings already hold.
|
||
for (const warning of result.warnings ?? []) {
|
||
toast({
|
||
title: "Schedule capacity",
|
||
description: warning,
|
||
duration: 8000,
|
||
...(warning.includes("over capacity")
|
||
? { variant: "destructive" as const }
|
||
: {}),
|
||
});
|
||
}
|
||
setRemoveIds([]);
|
||
setAddIds([]);
|
||
setSwitchMap({});
|
||
} 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={920}
|
||
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.currentStop?.isMidRoute ? (
|
||
<Alert color="blue" icon={<MapPin size={16} />}>
|
||
Standing at <strong>{data.currentStop.label}</strong> — mid-route
|
||
consist work is open: couple or switch wagons standing at this
|
||
stop, trim wagons whose cargo was offloaded here. Detached wagons
|
||
stay at {data.currentStop.label}.
|
||
</Alert>
|
||
) : null}
|
||
{!data.editable ? (
|
||
<Alert color="yellow" icon={<AlertTriangle size={16} />}>
|
||
{data.schedule.status === "DISPATCHED"
|
||
? "The train is rolling — consist changes are only possible while it stands at a route stop."
|
||
: "The consist can no longer be adjusted — the run is over."}
|
||
</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>
|
||
{projection?.slots ? (
|
||
<Grid.Col span={12}>
|
||
<LimitGauge
|
||
label="Booking slots — the consist is the schedule's capacity"
|
||
detail={`${projection.slots.allocated} of ${projection.wagonCount} projected wagon slot(s) held by bookings${
|
||
projection.slots.free > 0
|
||
? ` — ${projection.slots.free} free`
|
||
: projection.slots.free === 0
|
||
? " — none free (FULL)"
|
||
: ""
|
||
}`}
|
||
pct={projection.slots.pct}
|
||
over={projection.slots.overAllocated}
|
||
/>
|
||
</Grid.Col>
|
||
) : null}
|
||
</Grid>
|
||
|
||
{projection?.slots?.isFullNow && !hasChanges ? (
|
||
<Alert color="yellow" icon={<AlertTriangle size={16} />}>
|
||
This schedule is FULL — all {projection.wagonCount} wagon slots are
|
||
taken. You can still edit the train: coupling wagons adds capacity
|
||
and reopens booking; trimming free wagons keeps it FULL.
|
||
</Alert>
|
||
) : null}
|
||
{hasChanges && projection?.slots?.overAllocated ? (
|
||
<Alert color="red" icon={<AlertTriangle size={16} />}>
|
||
This change leaves {-projection.slots.free} booked wagon(s) without
|
||
a slot — bookings already hold {projection.slots.allocated} of the{" "}
|
||
{projection.wagonCount} remaining. You can apply it, but couple
|
||
wagons back or free bookings before departure.
|
||
</Alert>
|
||
) : null}
|
||
{hasChanges &&
|
||
projection?.slots &&
|
||
!projection.slots.overAllocated &&
|
||
projection.slots.willBeFull &&
|
||
!projection.slots.isFullNow ? (
|
||
<Alert color="yellow" icon={<AlertTriangle size={16} />}>
|
||
This change takes the last free wagon slot — the schedule becomes
|
||
FULL and stops accepting bookings.
|
||
</Alert>
|
||
) : null}
|
||
{hasChanges && projection?.slots?.willReopen ? (
|
||
<Alert color="blue" icon={<AlertTriangle size={16} />}>
|
||
This schedule is currently FULL — applying frees{" "}
|
||
{projection.slots.free} wagon slot(s) and reopens its booking
|
||
window.
|
||
</Alert>
|
||
) : null}
|
||
|
||
<Grid gap="md">
|
||
<Grid.Col span={{ base: 12, md: 7 }}>
|
||
<Stack gap="xs">
|
||
<Group gap={6}>
|
||
<Minus size={14} />
|
||
<Text size="sm" fw={600}>
|
||
Coupled wagons ({data.totals.wagonCount})
|
||
</Text>
|
||
</Group>
|
||
<Text size="xs" c="dimmed">
|
||
Trim only wagons carrying nothing beyond this stop. A loaded
|
||
wagon can't leave — but it can be <strong>switched</strong>:
|
||
the same-type replacement takes its position and its cargo
|
||
slot. Detaching is permanent.
|
||
</Text>
|
||
<ScrollArea.Autosize mah={280} type="auto">
|
||
<Stack gap={4}>
|
||
{data.wagons.map((wagon) => (
|
||
<CoupledWagonRow
|
||
key={wagon.id}
|
||
wagon={wagon}
|
||
checked={removeIds.includes(wagon.id)}
|
||
editable={data.editable}
|
||
switchValue={switchMap[wagon.id] ?? null}
|
||
switchOptions={switchOptionsFor(wagon)}
|
||
onToggleRemove={toggle(setRemoveIds)}
|
||
onSwitch={setSwitch}
|
||
/>
|
||
))}
|
||
</Stack>
|
||
</ScrollArea.Autosize>
|
||
</Stack>
|
||
</Grid.Col>
|
||
<Grid.Col span={{ base: 12, md: 5 }}>
|
||
<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 at{" "}
|
||
{data.currentStop?.label ?? "the train's yard"}. Blocked when
|
||
they push gross weight or length past the locomotive limits
|
||
incl. tolerance.
|
||
</Text>
|
||
<ScrollArea.Autosize mah={280} type="auto">
|
||
<Stack gap={4}>
|
||
{data.addableWagons.length ? (
|
||
data.addableWagons.map((wagon) => {
|
||
const takenBySwitch = usedReplacementIds.has(wagon.id);
|
||
return (
|
||
<AddableWagonRow
|
||
key={wagon.id}
|
||
wagon={wagon}
|
||
checked={addIds.includes(wagon.id)}
|
||
disabled={!data.editable || takenBySwitch}
|
||
badge={takenBySwitch ? "Switch target" : null}
|
||
onToggle={toggle(setAddIds)}
|
||
/>
|
||
);
|
||
})
|
||
) : (
|
||
<Text size="sm" c="dimmed" py="sm" ta="center">
|
||
No available wagons at this stop
|
||
</Text>
|
||
)}
|
||
</Stack>
|
||
</ScrollArea.Autosize>
|
||
</Stack>
|
||
</Grid.Col>
|
||
</Grid>
|
||
|
||
{switchCount ? (
|
||
<Alert color="blue" icon={<ArrowLeftRight size={16} />} py={8}>
|
||
{Object.entries(switchMap)
|
||
.map(([fromId, toId]) => {
|
||
const from = data.wagons.find((w) => w.id === fromId);
|
||
const to = data.addableWagons.find((w) => w.id === toId);
|
||
return `${from?.wagonNumber ?? "?"} → ${to?.wagonNumber ?? "?"}`;
|
||
})
|
||
.join(" · ")}{" "}
|
||
— cargo allocations move to the replacement wagon(s).
|
||
</Alert>
|
||
) : null}
|
||
|
||
{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"
|
||
: log.action === "SWITCH"
|
||
? "blue"
|
||
: "red"
|
||
}
|
||
>
|
||
{log.action === "ADD"
|
||
? "Added"
|
||
: log.action === "SWITCH"
|
||
? "Switched"
|
||
: "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 ||
|
||
!hasChanges ||
|
||
(addIds.length > 0 && (projection?.overWeight || projection?.overLength))
|
||
}
|
||
onClick={handleSubmit}
|
||
>
|
||
Apply{" "}
|
||
{[
|
||
removeIds.length ? `−${removeIds.length}` : "",
|
||
addIds.length ? `+${addIds.length}` : "",
|
||
switchCount ? `⇄${switchCount}` : "",
|
||
]
|
||
.filter(Boolean)
|
||
.join(" / ")}
|
||
</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>
|
||
);
|
||
}
|
||
|
||
/** Coupled row: trim checkbox (reason-badged when blocked) + switch picker. */
|
||
function CoupledWagonRow({
|
||
wagon,
|
||
checked,
|
||
editable,
|
||
switchValue,
|
||
switchOptions,
|
||
onToggleRemove,
|
||
onSwitch,
|
||
}: {
|
||
wagon: ConsistWagon;
|
||
checked: boolean;
|
||
editable: boolean;
|
||
switchValue: string | null;
|
||
switchOptions: Array<{ value: string; label: string }>;
|
||
onToggleRemove: (id: string, checked: boolean) => void;
|
||
onSwitch: (fromId: string, toId: string | null) => void;
|
||
}) {
|
||
const badge = wagon.loaded ? "Loaded" : !wagon.removable ? "Pinned" : null;
|
||
const checkbox = (
|
||
<Checkbox
|
||
size="sm"
|
||
checked={checked}
|
||
disabled={!editable || !wagon.removable || Boolean(switchValue)}
|
||
onChange={(e) => onToggleRemove(wagon.id, e.currentTarget.checked)}
|
||
aria-label={`Trim wagon ${wagon.wagonNumber}`}
|
||
/>
|
||
);
|
||
return (
|
||
<Group
|
||
gap="sm"
|
||
wrap="nowrap"
|
||
p={6}
|
||
style={{
|
||
border: switchValue
|
||
? "1px solid var(--mantine-color-blue-4)"
|
||
: "1px solid var(--mantine-color-gray-2)",
|
||
borderRadius: "var(--mantine-radius-md)",
|
||
background: switchValue ? "var(--mantine-color-blue-0)" : undefined,
|
||
}}
|
||
>
|
||
{wagon.blockReason ? (
|
||
<Tooltip label={wagon.blockReason} withArrow>
|
||
<span>{checkbox}</span>
|
||
</Tooltip>
|
||
) : (
|
||
checkbox
|
||
)}
|
||
<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}
|
||
{editable && wagon.switchable && switchOptions.length ? (
|
||
<Select
|
||
size="xs"
|
||
w={148}
|
||
placeholder="Switch with…"
|
||
leftSection={<ArrowLeftRight size={12} />}
|
||
data={switchOptions}
|
||
value={switchValue}
|
||
onChange={(toId) => onSwitch(wagon.id, toId)}
|
||
clearable
|
||
searchable
|
||
disabled={checked}
|
||
aria-label={`Switch wagon ${wagon.wagonNumber}`}
|
||
/>
|
||
) : null}
|
||
</Group>
|
||
);
|
||
}
|
||
|
||
function AddableWagonRow({
|
||
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="blue">
|
||
{badge}
|
||
</Badge>
|
||
) : null}
|
||
</Group>
|
||
);
|
||
}
|