Files
edr-platform/apps/edr-freight-web/backoffice/src/components/wagons/WagonYardWorkspaceModal.tsx
marshalyordanos 5da36eb128 feat: add wagon usage computation and maintenance logging features
- Implemented  utility to calculate wagon usage metrics for train schedules.
- Created  for sending wagons to maintenance with optional notes.
- Added unit tests for train builder maintenance functionalities, including formatting train run labels and building maintenance notes.
- Developed  component for merging train schedules with detailed previews and reasons for merging.
- Introduced  component for selecting wagons with search functionality and selection limits.
- Created  for displaying and filtering audit logs, including detailed views of individual log entries.
- Added  for handling API interactions related to audit logs, including fetching logs and entity types.
2026-08-12 09:36:50 +03:00

584 lines
21 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 { Freight } from "@edr/types";
import {
Badge,
Box,
Button,
Card,
Grid,
Group,
Loader,
Modal,
NumberInput,
Progress,
Select,
Slider,
Stack,
Text,
ThemeIcon,
} from "@mantine/core";
import { useMutation, useQuery } from "@tanstack/react-query";
import { ArrowRight, ArrowRightLeft, Layers, Warehouse } from "lucide-react";
import { useEffect, useMemo, useState } from "react";
import ReactQuill from "react-quill-new";
import "react-quill-new/dist/quill.snow.css";
import { api } from "@/services/api";
import { useToast } from "@/hooks/use-toast";
import type { Wagon } from "@/services/wagon.service";
import WagonPicker from "./WagonPicker";
const stripHtml = (html: string) => html.replace(/<[^>]*>/g, "").trim();
export interface WagonYardWorkspaceModalProps {
opened: boolean;
onClose: () => void;
}
const AVAILABLE = Freight.WagonStatus.Available;
const ASSIGNED = Freight.WagonStatus.Assigned;
const clampInt = (v: number | string, max: number): number => {
const n = typeof v === "number" ? v : Number(v);
if (!Number.isFinite(n) || n < 0) return 0;
return Math.min(Math.floor(n), max);
};
/**
* NumberInput + Slider + All/Half presets, kept in sync. `max` bounds the field
* to the wagons on hand; omitting it leaves the field unbounded and the slider
* simply tracks the current value.
*/
const QuantityField = ({
value,
onChange,
max,
disabled,
}: {
value: number;
onChange: (n: number) => void;
max?: number;
disabled?: boolean;
}) => {
const capped = max ?? Number.MAX_SAFE_INTEGER;
const set = (v: number | string) => onChange(clampInt(v, capped));
const off = disabled || max === 0;
return (
<Stack gap={8}>
<Group gap="sm" align="center" wrap="nowrap">
<NumberInput
value={value}
onChange={set}
min={0}
max={max}
allowNegative={false}
clampBehavior="strict"
disabled={off}
radius="md"
w={92}
/>
<Slider
style={{ flex: 1 }}
value={value}
onChange={set}
min={0}
max={Math.max(max ?? Math.max(value, 10), 1)}
disabled={off}
label={(v) => `${v}`}
color="edr-green"
/>
</Group>
<Group gap={6}>
{/* Presets only make sense against a real ceiling — an uncapped request
field (transfer ask) shows the manual input alone. */}
{max != null ? (
<>
<Button size="compact-xs" variant="light" color="gray" disabled={off} onClick={() => set(Math.ceil(max / 2))}>
Half
</Button>
<Button size="compact-xs" variant="light" color="gray" disabled={off} onClick={() => set(max)}>
All ({max})
</Button>
</>
) : null}
{value > 0 ? (
<Button size="compact-xs" variant="subtle" color="gray" onClick={() => set(0)}>
Clear
</Button>
) : null}
</Group>
</Stack>
);
};
const LegendDot = ({ color, label, value }: { color: string; label: string; value: number }) => (
<Group gap={6} wrap="nowrap">
<Box w={10} h={10} style={{ borderRadius: 3, background: `var(--mantine-color-${color}-6)` }} />
<Text size="sm" c="dimmed">
{label}
</Text>
<Text size="sm" fw={700}>
{value}
</Text>
</Group>
);
/**
* Bulk yard operations. Pick a yard + wagon type (the two selects filter each
* other to combinations that actually hold stock), read the live Available /
* Assigned split, then move a quantity to another yard or flip a quantity
* between Available and Assigned — replacing one-wagon-at-a-time edits.
*/
const WagonYardWorkspaceModal = ({ opened, onClose }: WagonYardWorkspaceModalProps) => {
const { toast } = useToast();
const { data: wagons = [], isLoading } = useQuery(api.wagons.list.queryOptions({ input: {} }));
const { data: yards = [] } = useQuery(api.routes.yards.queryOptions());
const { data: wagonTypes = [] } = useQuery(api.wagonTypes.list.queryOptions());
const [yardId, setYardId] = useState<string | null>(null);
const [typeId, setTypeId] = useState<string | null>(null);
const [transferYardId, setTransferYardId] = useState<string | null>(null);
const [transferQty, setTransferQty] = useState(0);
const [transferReason, setTransferReason] = useState("");
/** Specific wagons the requester named — optional; empty means "any N". */
const [pickedWagons, setPickedWagons] = useState<Set<string>>(new Set());
const createRequest = useMutation(
api.wagonTransferRequests.create.mutationOptions(),
);
const yardName = useMemo(() => {
const byId = new Map(yards.map((y) => [y.id, y.label || y.code || y.id]));
return (id: string) => byId.get(id) ?? id;
}, [yards]);
const typeInfo = useMemo(() => {
const byId = new Map(wagonTypes.map((t) => [t.id, t]));
return {
label: (id: string) => {
const t = byId.get(id);
return t ? `${t.code}${t.name ? ` - ${t.name}` : ""}` : id;
},
code: (id: string) => byId.get(id)?.code ?? id,
};
}, [wagonTypes]);
const yardWagons = useMemo(
() => wagons.filter((w): w is Wagon & { currentYardId: string } => Boolean(w.currentYardId)),
[wagons],
);
const yardOptions = useMemo(() => {
const ids = new Set<string>();
for (const w of yardWagons) {
if (typeId && w.wagonTypeId !== typeId) continue;
ids.add(w.currentYardId);
}
return [...ids]
.map((id) => ({ value: id, label: yardName(id) }))
.sort((a, b) => a.label.localeCompare(b.label));
}, [yardWagons, typeId, yardName]);
const typeOptions = useMemo(() => {
const ids = new Set<string>();
for (const w of yardWagons) {
if (yardId && w.currentYardId !== yardId) continue;
ids.add(w.wagonTypeId);
}
return [...ids]
.map((id) => ({ value: id, label: typeInfo.label(id) }))
.sort((a, b) => a.label.localeCompare(b.label));
}, [yardWagons, yardId, typeInfo]);
const matching = useMemo(() => {
if (!yardId || !typeId) return [] as Wagon[];
return yardWagons.filter((w) => w.currentYardId === yardId && w.wagonTypeId === typeId);
}, [yardWagons, yardId, typeId]);
// Wagons coupled to a built train (trainId set) are managed through the
// train-builder flow — they can't be bulk-flipped or transferred here, so
// keep them out of the action pools and bucket them under "Other".
const availableWagons = useMemo(
() => matching.filter((w) => w.status === AVAILABLE && !w.trainId),
[matching],
);
const assignedWagons = useMemo(
() => matching.filter((w) => w.status === ASSIGNED && !w.trainId),
[matching],
);
const otherWagons = useMemo(
() =>
matching.filter(
(w) => w.trainId != null || (w.status !== AVAILABLE && w.status !== ASSIGNED),
),
[matching],
);
const total = matching.length;
const availableCount = availableWagons.length;
const assignedCount = assignedWagons.length;
const otherCount = otherWagons.length;
// Split "Other" so a coupled wagon is visible as such. The Available/Assigned
// buckets deliberately count only UNCOUPLED wagons (see above), so a yard
// holding 54 assigned wagons of which 53 are on a train shows "Assigned 1" —
// accurate for shunting, but unreadable unless the other 53 are named.
const onTrainCount = useMemo(
() => matching.filter((w) => w.trainId != null).length,
[matching],
);
const destinationYardOptions = useMemo(
() =>
yards
.filter((y) => y.id !== yardId)
.map((y) => ({ value: y.id, label: y.label || y.code || y.id }))
.sort((a, b) => a.label.localeCompare(b.label)),
[yards, yardId],
);
const bothSelected = Boolean(yardId && typeId);
// Reset action inputs when the selection changes.
useEffect(() => {
setTransferYardId(null);
setTransferQty(0);
setTransferReason("");
setPickedWagons(new Set());
}, [yardId, typeId]);
// Naming wagons IS the ask: the count follows the picks so the two can never
// disagree. Lowering the count by hand (below) trims the selection instead.
const handlePick = (next: Set<string>) => {
setPickedWagons(next);
if (next.size > 0) setTransferQty(next.size);
};
const handleQtyChange = (n: number) => {
setTransferQty(n);
// Asking for fewer than were picked would send a selection the API rejects
// (picks may not exceed quantity) — drop the extras, keeping pick order.
if (pickedWagons.size > n) {
setPickedWagons(new Set([...pickedWagons].slice(0, n)));
}
};
// Reset the whole workspace when closed.
useEffect(() => {
if (!opened) {
setYardId(null);
setTypeId(null);
}
}, [opened]);
const showError = (err: unknown, fallback: string) => {
const message =
(err as { response?: { data?: { message?: string } } })?.response?.data?.message ?? fallback;
toast({ title: fallback, description: String(message), variant: "destructive" });
};
// Request-only: the requester specifies count + destination; OCC later picks
// the physical wagons and executes the move. No wagons are moved here.
const handleRequest = async () => {
if (
!yardId ||
!typeId ||
!transferYardId ||
transferQty < 1 ||
!stripHtml(transferReason)
)
return;
try {
await createRequest.mutateAsync({
fromYardId: yardId,
toYardId: transferYardId,
wagonTypeId: typeId,
quantity: transferQty,
reason: transferReason,
...(pickedWagons.size > 0
? { preferredWagonIds: [...pickedWagons] }
: {}),
});
toast({
title: `Requested ${transferQty} ${typeInfo.code(typeId)} wagon(s) · ${yardName(
yardId,
)}${yardName(transferYardId)}`,
description:
pickedWagons.size > 0
? `OCC will send the ${pickedWagons.size} wagon(s) you named where it can.`
: "OCC will pick the wagons and complete the move.",
});
setTransferQty(0);
setTransferYardId(null);
setTransferReason("");
setPickedWagons(new Set());
} catch (err) {
showError(err, "Request failed");
}
};
const busy = createRequest.isPending;
const pct = (n: number) => (total > 0 ? (n / total) * 100 : 0);
return (
<Modal
opened={opened}
onClose={onClose}
size="min(1080px, 96vw)"
radius="lg"
centered
overlayProps={{ blur: 2 }}
title={
<Group gap="sm">
<ThemeIcon variant="light" color="edr-green" radius="md" size="lg">
<Warehouse size={18} />
</ThemeIcon>
<div>
<Text fw={700}>Wagon Yard Operations</Text>
<Text size="xs" c="dimmed">
Move and re-status wagons in bulk no one-by-one edits
</Text>
</div>
</Group>
}
>
<Stack gap="lg">
{/* ---- Selection ---- */}
<Card withBorder radius="md" padding="md" bg="var(--mantine-color-gray-0)">
<Grid gap="md" align="flex-end">
<Grid.Col span={{ base: 12, sm: 6 }}>
<Select
label="Yard"
placeholder="Select a yard"
data={yardOptions}
value={yardId}
onChange={setYardId}
searchable
clearable
leftSection={<Warehouse size={16} />}
nothingFoundMessage="No yards with stock"
radius="md"
/>
</Grid.Col>
<Grid.Col span={{ base: 12, sm: 6 }}>
<Select
label="Wagon type"
placeholder="Select a wagon type"
data={typeOptions}
value={typeId}
onChange={setTypeId}
searchable
clearable
leftSection={<Layers size={16} />}
nothingFoundMessage="No wagon types here"
radius="md"
/>
</Grid.Col>
</Grid>
</Card>
{isLoading ? (
<Group justify="center" p="xl">
<Loader />
</Group>
) : !bothSelected ? (
<Card withBorder radius="md" padding="xl">
<Stack align="center" gap={6}>
<ThemeIcon variant="light" color="gray" radius="xl" size={48}>
<Layers size={22} />
</ThemeIcon>
<Text fw={600}>Pick a yard and a wagon type</Text>
<Text size="sm" c="dimmed" ta="center" maw={420}>
You&apos;ll see how many wagons of that type sit in that yard, how many are available
vs assigned, and can move or re-status them all at once.
</Text>
</Stack>
</Card>
) : (
<>
{/* ---- Overview hero ---- */}
<Card withBorder radius="md" padding="lg">
<Group justify="space-between" align="flex-start" wrap="wrap" gap="md">
<Group gap="lg" align="center" wrap="nowrap">
<div>
<Text size="3rem" fw={800} lh={1}>
{total}
</Text>
</div>
<div>
<Text fw={700} size="lg">
{typeInfo.code(typeId!)} wagons
</Text>
<Group gap={6} c="dimmed">
<Warehouse size={14} />
<Text size="sm">{yardName(yardId!)}</Text>
</Group>
</div>
</Group>
<Group gap="lg" wrap="wrap">
<LegendDot color="teal" label="Available in yard" value={availableCount} />
<LegendDot color="blue" label="Assigned in yard" value={assignedCount} />
{onTrainCount > 0 ? (
<LegendDot color="gray" label="On train" value={onTrainCount} />
) : null}
{otherCount - onTrainCount > 0 ? (
<LegendDot color="gray" label="Other" value={otherCount - onTrainCount} />
) : null}
</Group>
</Group>
<Progress.Root size={22} radius="md" mt="md">
<Progress.Section value={pct(availableCount)} color="teal">
{availableCount > 0 ? <Progress.Label>{availableCount}</Progress.Label> : null}
</Progress.Section>
<Progress.Section value={pct(assignedCount)} color="blue">
{assignedCount > 0 ? <Progress.Label>{assignedCount}</Progress.Label> : null}
</Progress.Section>
<Progress.Section value={pct(otherCount)} color="gray">
{otherCount > 0 ? <Progress.Label>{otherCount}</Progress.Label> : null}
</Progress.Section>
</Progress.Root>
</Card>
{/* ---- Actions ---- */}
<Card withBorder radius="md" padding="lg">
<Group gap="xs" mb={4}>
<ThemeIcon variant="light" color="grape" radius="md" size="md">
<ArrowRightLeft size={16} />
</ThemeIcon>
<Text fw={700}>Request transfer to another yard</Text>
</Group>
<Text size="xs" c="dimmed" mb="md">
Sends a request to OCC they pick the wagons and complete the move.
</Text>
<Stack gap="md">
<div>
<Group justify="space-between" mb={4}>
<Text size="sm" fw={500}>
How many wagons
</Text>
<Badge color="teal" variant="light">
{availableCount} available
</Badge>
</Group>
{/* Capped at the wagons actually available in this yard
right now (uncoupled + Available) — a request may not
ask for more than the yard can hand over. */}
<QuantityField
value={transferQty}
onChange={handleQtyChange}
max={availableCount}
/>
</div>
{/* Optional: name the exact wagons. Leaving this empty files
a plain count request and OCC picks whatever is free. */}
<div>
<Group justify="space-between" mb={4} wrap="wrap" gap={4}>
<Text size="sm" fw={500}>
Which wagons{" "}
<Text span size="xs" c="dimmed" fw={400}>
(optional)
</Text>
</Text>
<Text size="xs" c="dimmed">
{pickedWagons.size > 0
? `${pickedWagons.size} named — OCC will prioritise these`
: "Leave empty and OCC picks any available"}
</Text>
</Group>
<WagonPicker
wagons={availableWagons}
selected={pickedWagons}
onChange={handlePick}
max={availableCount}
emptyMessage="No available wagons of this type in this yard right now."
/>
</div>
<Select
label="Destination yard"
placeholder="Select destination"
data={destinationYardOptions}
value={transferYardId}
onChange={setTransferYardId}
searchable
radius="md"
/>
<div>
<Text size="sm" fw={500} mb={4}>
Reason <Text span c="red">*</Text>
</Text>
<ReactQuill
theme="snow"
value={transferReason}
onChange={setTransferReason}
placeholder="Why are these wagons needed?"
/>
</div>
{transferYardId && transferQty > 0 ? (
<Card bg="var(--mantine-color-gray-0)" radius="md" padding="sm" withBorder>
<Stack gap={6}>
<Group gap={8} wrap="nowrap">
<Text size="sm" fw={600}>
{yardName(yardId!)} {total}
<Text span c="red.6" fw={700}>
{" "}
{transferQty}
</Text>
</Text>
<ArrowRight size={16} />
<Text size="sm" fw={600}>
{yardName(transferYardId)}
<Text span c="teal.7" fw={700}>
{" "}
+{transferQty}
</Text>
</Text>
</Group>
{pickedWagons.size > 0 ? (
<Group gap={4} wrap="wrap">
{availableWagons
.filter((w) => pickedWagons.has(w.id))
.map((w) => (
<Badge
key={w.id}
size="sm"
variant="light"
color="edr-green"
radius="sm"
>
{w.wagonNumber}
</Badge>
))}
</Group>
) : null}
</Stack>
</Card>
) : null}
<Button
leftSection={<ArrowRightLeft size={16} />}
onClick={handleRequest}
loading={createRequest.isPending}
disabled={
busy ||
!transferYardId ||
transferQty < 1 ||
!stripHtml(transferReason)
}
color="edr-green"
>
Request {transferQty > 0 ? `${transferQty} ` : ""}wagon
{transferQty === 1 ? "" : "s"}
</Button>
</Stack>
</Card>
</>
)}
</Stack>
</Modal>
);
};
export default WagonYardWorkspaceModal;