Merge pull request #1048 from Tria-plc/freight_feature/usermanagement

fix issue
This commit is contained in:
marshal
2026-07-31 16:06:43 +03:00
committed by GitHub
4 changed files with 142 additions and 328 deletions

View File

@@ -94,6 +94,7 @@
"react-intersection-observer": "^9.16.0",
"react-pdf": "^10.4.1",
"react-pdf-html": "^2.1.5",
"react-quill": "^2.0.0",
"react-resizable-panels": "^3.0.6",
"react-router-dom": "^6.27.0",
"react-signature-canvas": "1.1.0-alpha.2",

View File

@@ -17,11 +17,11 @@ import { ChevronDown, ChevronRight, Info, Train, Weight } from "lucide-react";
import type { TrainScheduleDetail } from "@/types/trainScheduling";
/**
* Per-leg capacity workspace tab. A multi-stop corridor (A→B→C→D→E) is
* capacity-checked edge by edge, so this shows, for EVERY adjacent leg, the
* wagons/weight/length the consist actually uses there — and for every
* possible origin→destination pair (A→C, B→E, …) the room left, which is the
* minimum over the legs the pair rides.
* Per-leg capacity workspace tab, computed from the BOOKINGS themselves: every
* adjacent leg (A→B, B→C, …) lists each booking riding it with the booking's
* own wagon count and gross weight, and totals both. A through booking (A→C)
* appears on every leg it rides — so leg totals are what each leg actually
* hauls, independent of how the consist slots were stamped.
*/
interface Stop {
@@ -34,8 +34,6 @@ interface LegBookingUsage {
reference: string;
wagons: number;
grossTons: number;
/** Linked to the schedule but has NO wagon allocation — its weight is on no consist slot. */
unallocated?: boolean;
/** The booking's own origin → destination, so a sub-leg booking reads as such. */
route?: string | null;
}
@@ -46,11 +44,7 @@ interface EdgeUsage {
to: Stop;
wagons: number;
grossTons: number;
lengthMeters: number;
bookingRefs: string[];
bookings: LegBookingUsage[];
/** Gross tons of linked-but-unallocated bookings riding this leg — not yet on any slot. */
pendingTons: number;
}
const round1 = (n: number) => Math.round(n * 10) / 10;
@@ -64,20 +58,6 @@ function utilizationColor(used: number, cap: number | null): string {
return "teal";
}
/** Mirrors the API's slotSpans: unknown/missing yard = the schedule endpoint. */
function spanOf(
boardYardId: string | null | undefined,
alightYardId: string | null | undefined,
indexOf: Map<string, number>,
lastIdx: number,
): { from: number; to: number } {
const fromRaw = boardYardId ? indexOf.get(boardYardId) : 0;
const toRaw = alightYardId ? indexOf.get(alightYardId) : lastIdx;
const from = fromRaw != null && fromRaw >= 0 ? fromRaw : 0;
const to = toRaw != null && toRaw > 0 ? toRaw : lastIdx;
return { from, to };
}
function UsageCell({
used,
cap,
@@ -102,9 +82,7 @@ function UsageCell({
export function LegCapacityPanel({ schedule }: { schedule: TrainScheduleDetail }) {
const stops: Stop[] = schedule.stops ?? [];
const wagons = schedule.trainSet?.wagons ?? [];
const weightCap = schedule.maxGrossWeightTons ?? null;
const lengthCap = schedule.maxLengthMeters ?? null;
const wagonCap = schedule.maxWagons ?? null;
const [expandedEdge, setExpandedEdge] = useState<number | null>(null);
@@ -112,125 +90,35 @@ export function LegCapacityPanel({ schedule }: { schedule: TrainScheduleDetail }
if (stops.length < 2) return [];
const indexOf = new Map(stops.map((s, i) => [s.yardId, i]));
const lastIdx = stops.length - 1;
// Booking legs, for slot-span fallback and for labelling dropdown rows.
const bookingById = new Map((schedule.bookings ?? []).map((b) => [b.id, b]));
const bookingSpan = (bookingId: string) => {
const b = bookingById.get(bookingId);
if (!b) return null;
// A booking rides origin→destination; unknown/off-corridor yards fall back
// to the schedule's own endpoints (through cargo).
const spans = (schedule.bookings ?? []).map((b) => {
const from = (b.originYardId ? indexOf.get(b.originYardId) : undefined) ?? 0;
const toRaw = b.destinationYardId ? indexOf.get(b.destinationYardId) : lastIdx;
const to = toRaw != null && toRaw > from ? toRaw : lastIdx;
return { from, to };
};
// A slot rides its stamped board→alight span. Slots without a stamp (an
// API build predating the fields, or plans written before spans existed)
// fall back to the union of their OWN bookings' legs — an a→b-only wagon
// must not count on the b→c leg. Empty stamped-less wagons ride everything.
const spans = wagons.map((w) => {
if (w.boardYardId || w.alightYardId) {
return spanOf(w.boardYardId, w.alightYardId, indexOf, lastIdx);
}
const legs = (w.allocations ?? [])
.map((a) => bookingSpan(a.bookingId))
.filter((s): s is { from: number; to: number } => s != null);
if (!legs.length) return { from: 0, to: lastIdx };
return {
from: Math.min(...legs.map((s) => s.from)),
to: Math.max(...legs.map((s) => s.to)),
};
return { b, from, to };
});
return stops.slice(0, -1).map((from, edge) => {
const active = wagons.filter(
(_, i) => spans[i].from <= edge && edge < spans[i].to,
);
const refs = new Set<string>();
let grossTons = 0;
let lengthMeters = 0;
// Per booking on this leg: wagon count (distinct wagons carrying at
// least one of its allocations — a shared wagon counts for each
// booking riding it, so per-booking wagon counts can sum to more than
// the leg's total) and its allocated weight share.
const byBooking = new Map<string, LegBookingUsage>();
for (const w of active) {
grossTons += (Number(w.tareWeightTons) || 0) + (Number(w.assignedWeightTons) || 0);
lengthMeters += Number(w.lengthMeters) || 0;
const bookingIdsOnWagon = new Set<string>();
for (const a of w.allocations ?? []) {
if (!a.bookingReference) continue;
refs.add(a.bookingReference);
const linked = bookingById.get(a.bookingId);
const row = byBooking.get(a.bookingId) ?? {
bookingId: a.bookingId,
reference: a.bookingReference,
wagons: 0,
grossTons: 0,
route:
linked?.origin && linked?.destination
? `${linked.origin}${linked.destination}`
: null,
};
row.grossTons += Number(a.allocatedWeightTons) || 0;
byBooking.set(a.bookingId, row);
bookingIdsOnWagon.add(a.bookingId);
}
for (const bookingId of bookingIdsOnWagon) {
const row = byBooking.get(bookingId);
if (row) row.wagons += 1;
}
}
// Linked bookings with NO wagon allocation ride their leg too — without
// this they vanish from the tab entirely (two Dire→DCT bookings hidden
// while a through booking showed alone). Flagged so staff see the gap;
// their tonnage is deliberately NOT in the leg totals, which reflect
// what is physically on consist slots.
for (const b of schedule.bookings ?? []) {
if (byBooking.has(b.id)) continue;
const bFrom = (b.originYardId ? indexOf.get(b.originYardId) : undefined) ?? 0;
const bToRaw = b.destinationYardId ? indexOf.get(b.destinationYardId) : lastIdx;
const bTo = bToRaw != null && bToRaw > bFrom ? bToRaw : lastIdx;
if (!(bFrom <= edge && edge < bTo)) continue;
const reference = b.reference ?? b.id;
refs.add(reference);
byBooking.set(b.id, {
const riding = spans.filter((s) => s.from <= edge && edge < s.to);
const bookings: LegBookingUsage[] = riding
.map(({ b }) => ({
bookingId: b.id,
reference,
reference: b.reference ?? b.id,
wagons: Number(b.wagonsRequired) || 0,
grossTons: Number(b.weightTons) || 0,
unallocated: true,
route:
b.origin && b.destination ? `${b.origin}${b.destination}` : null,
});
}
const bookings = [...byBooking.values()]
.map((b) => ({ ...b, grossTons: round1(b.grossTons) }))
grossTons: round1(Number(b.weightTons) || 0),
route: b.origin && b.destination ? `${b.origin}${b.destination}` : null,
}))
.sort((a, b) => b.grossTons - a.grossTons);
const pendingTons = round1(
bookings.filter((b) => b.unallocated).reduce((sum, b) => sum + b.grossTons, 0),
);
return {
edge,
from,
to: stops[edge + 1],
wagons: active.length,
grossTons: round1(grossTons),
lengthMeters: round1(lengthMeters),
bookingRefs: [...refs],
wagons: bookings.reduce((sum, b) => sum + b.wagons, 0),
grossTons: round1(bookings.reduce((sum, b) => sum + b.grossTons, 0)),
bookings,
pendingTons,
};
});
}, [stops, wagons, schedule.bookings]);
const unallocatedRefs = useMemo(
() => [
...new Set(
edges.flatMap((e) =>
e.bookings.filter((b) => b.unallocated).map((b) => b.reference),
),
),
],
[edges],
);
}, [stops, schedule.bookings]);
if (stops.length < 2) {
return (
@@ -240,21 +128,9 @@ export function LegCapacityPanel({ schedule }: { schedule: TrainScheduleDetail }
);
}
if (!wagons.length) {
return (
<Alert mt="lg" radius="lg" color="gray" icon={<Info size={16} />}>
No wagon plan yet leg utilization appears once bookings are allocated
to wagons. The route strip in the header shows the booking-based
estimate meanwhile.
</Alert>
);
}
const legStatus = (e: EdgeUsage) => {
if (weightCap != null && e.grossTons > weightCap)
return <Badge color="red" variant="filled">Overweight</Badge>;
if (lengthCap != null && e.lengthMeters > lengthCap)
return <Badge color="red" variant="filled">Over length</Badge>;
const wagonsFree = wagonCap != null ? wagonCap - e.wagons : null;
const tonsFree = weightCap != null ? round1(weightCap - e.grossTons) : null;
if ((wagonsFree != null && wagonsFree <= 0) || (tonsFree != null && tonsFree <= 0))
@@ -283,26 +159,19 @@ export function LegCapacityPanel({ schedule }: { schedule: TrainScheduleDetail }
return (
<Stack gap="lg" mt="lg">
{unallocatedRefs.length ? (
<Alert radius="lg" color="orange" icon={<Info size={16} />}>
{unallocatedRefs.join(", ")}{" "}
{unallocatedRefs.length === 1 ? "is" : "are"} linked to this train but
have no wagons allocated their weight is not on any leg yet. Re-run
allocation (or add them from the workspace) to place them.
</Alert>
) : null}
<Paper radius="xl" p="lg" withBorder>
<Stack gap="md">
<Stack gap={2}>
<Text fw={700}>Per-leg utilization</Text>
<Text size="sm" c="dimmed">
Each adjacent leg is checked as its own train wagon tare + cargo
against the locomotive limits{weightCap != null ? ` (${weightCap}T` : ""}
{weightCap != null && lengthCap != null ? `, ${lengthCap}m` : ""}
{weightCap != null ? " incl. tolerance)" : ""}.
Each adjacent leg lists every booking riding it a through
booking counts on all its legs. Totals are checked against the
train limits{weightCap != null ? ` (${weightCap}T incl. tolerance` : ""}
{weightCap != null && wagonCap != null ? `, ${wagonCap} wagons` : ""}
{weightCap != null ? ")" : ""}.
</Text>
</Stack>
<Table.ScrollContainer minWidth={720}>
<Table.ScrollContainer minWidth={640}>
<Table verticalSpacing="sm" highlightOnHover>
<Table.Thead>
<Table.Tr>
@@ -310,7 +179,6 @@ export function LegCapacityPanel({ schedule }: { schedule: TrainScheduleDetail }
<Table.Th>Leg</Table.Th>
<Table.Th>Wagons</Table.Th>
<Table.Th>Gross weight</Table.Th>
<Table.Th>Length</Table.Th>
<Table.Th>Bookings</Table.Th>
<Table.Th>Status</Table.Th>
</Table.Tr>
@@ -348,14 +216,6 @@ export function LegCapacityPanel({ schedule }: { schedule: TrainScheduleDetail }
</Table.Td>
<Table.Td>
<UsageCell used={e.grossTons} cap={weightCap} unit="T" />
{e.pendingTons > 0 ? (
<Text size="xs" c="orange.8" fw={600}>
+{e.pendingTons}T unallocated
</Text>
) : null}
</Table.Td>
<Table.Td>
<UsageCell used={e.lengthMeters} cap={lengthCap} unit="m" />
</Table.Td>
<Table.Td>
{hasBookings ? (
@@ -372,7 +232,7 @@ export function LegCapacityPanel({ schedule }: { schedule: TrainScheduleDetail }
</Table.Tr>
{hasBookings ? (
<Table.Tr key={`${e.edge}-detail`}>
<Table.Td colSpan={7} p={0} style={{ border: 0 }}>
<Table.Td colSpan={6} p={0} style={{ border: 0 }}>
<Collapse expanded={isOpen}>
<Box
p="sm"
@@ -390,22 +250,15 @@ export function LegCapacityPanel({ schedule }: { schedule: TrainScheduleDetail }
{e.bookings.map((b) => (
<Table.Tr key={b.bookingId}>
<Table.Td w="50%">
<Group gap={6} wrap="nowrap">
<Text size="sm" fw={500} style={{ whiteSpace: "nowrap" }}>
{b.reference}
{b.route ? (
<Text span size="xs" c="dimmed">
{" "}
({b.route})
</Text>
) : null}
</Text>
{b.unallocated ? (
<Badge size="xs" color="orange" variant="filled">
no wagons
</Badge>
<Text size="sm" fw={500} style={{ whiteSpace: "nowrap" }}>
{b.reference}
{b.route ? (
<Text span size="xs" c="dimmed">
{" "}
({b.route})
</Text>
) : null}
</Group>
</Text>
</Table.Td>
<Table.Td w="25%">
<Group gap={4} wrap="nowrap">
@@ -413,7 +266,6 @@ export function LegCapacityPanel({ schedule }: { schedule: TrainScheduleDetail }
<Text size="xs" c="dimmed">
{b.wagons > 0 ? b.wagons : "—"} wagon
{b.wagons === 1 ? "" : "s"}
{b.unallocated && b.wagons > 0 ? " needed" : ""}
</Text>
</Group>
</Table.Td>

View File

@@ -4,7 +4,6 @@ import {
Box,
Button,
Card,
Divider,
Grid,
Group,
Loader,
@@ -15,17 +14,20 @@ import {
Slider,
Stack,
Text,
Textarea,
ThemeIcon,
} from "@mantine/core";
import { useMutation, useQuery } from "@tanstack/react-query";
import { ArrowRight, ArrowRightLeft, CheckCircle2, CircleSlash, Layers, Warehouse } from "lucide-react";
import { ArrowRight, ArrowRightLeft, Layers, Warehouse } from "lucide-react";
import { useEffect, useMemo, useState } from "react";
import ReactQuill from "react-quill";
import "react-quill/dist/quill.snow.css";
import { api } from "@/services/api";
import { useToast } from "@/hooks/use-toast";
import type { Wagon } from "@/services/wagon.service";
const stripHtml = (html: string) => html.replace(/<[^>]*>/g, "").trim();
export interface WagonYardWorkspaceModalProps {
opened: boolean;
onClose: () => void;
@@ -139,13 +141,10 @@ const WagonYardWorkspaceModal = ({ opened, onClose }: WagonYardWorkspaceModalPro
const [transferYardId, setTransferYardId] = useState<string | null>(null);
const [transferQty, setTransferQty] = useState(0);
const [transferReason, setTransferReason] = useState("");
const [toAssignedQty, setToAssignedQty] = useState(0);
const [toAvailableQty, setToAvailableQty] = useState(0);
const createRequest = useMutation(
api.wagonTransferRequests.create.mutationOptions(),
);
const setStatus = useMutation(api.wagons.bulkSetStatus.mutationOptions());
const yardName = useMemo(() => {
const byId = new Map(yards.map((y) => [y.id, y.label || y.code || y.id]));
@@ -242,8 +241,6 @@ const WagonYardWorkspaceModal = ({ opened, onClose }: WagonYardWorkspaceModalPro
setTransferYardId(null);
setTransferQty(0);
setTransferReason("");
setToAssignedQty(0);
setToAvailableQty(0);
}, [yardId, typeId]);
// Reset the whole workspace when closed.
@@ -254,13 +251,6 @@ const WagonYardWorkspaceModal = ({ opened, onClose }: WagonYardWorkspaceModalPro
}
}, [opened]);
// Keep quantities within bounds as counts shift after each action. A TRANSFER
// REQUEST is deliberately uncapped: OCC delivers in instalments, so asking for
// 50 where 20 sit today is normal — only the status flips below are bounded by
// what is physically in the yard.
useEffect(() => setToAssignedQty((q) => Math.min(q, availableCount)), [availableCount]);
useEffect(() => setToAvailableQty((q) => Math.min(q, assignedCount)), [assignedCount]);
const showError = (err: unknown, fallback: string) => {
const message =
(err as { response?: { data?: { message?: string } } })?.response?.data?.message ?? fallback;
@@ -275,7 +265,7 @@ const WagonYardWorkspaceModal = ({ opened, onClose }: WagonYardWorkspaceModalPro
!typeId ||
!transferYardId ||
transferQty < 1 ||
!transferReason.trim()
!stripHtml(transferReason)
)
return;
try {
@@ -284,7 +274,7 @@ const WagonYardWorkspaceModal = ({ opened, onClose }: WagonYardWorkspaceModalPro
toYardId: transferYardId,
wagonTypeId: typeId,
quantity: transferQty,
reason: transferReason.trim(),
reason: transferReason,
});
toast({
title: `Requested ${transferQty} ${typeInfo.code(typeId)} wagon(s) · ${yardName(
@@ -300,26 +290,7 @@ const WagonYardWorkspaceModal = ({ opened, onClose }: WagonYardWorkspaceModalPro
}
};
const handleFlip = async (
pool: Wagon[],
qty: number,
status: Freight.WagonStatus,
label: string,
reset: () => void,
) => {
if (qty < 1) return;
const ids = pool.slice(0, qty).map((w) => w.id);
if (!ids.length) return;
try {
const res = await setStatus.mutateAsync({ wagonIds: ids, status });
toast({ title: `${res.updated} wagon(s) set to ${label}` });
reset();
} catch (err) {
showError(err, "Status update failed");
}
};
const busy = createRequest.isPending || setStatus.isPending;
const busy = createRequest.isPending;
const pct = (n: number) => (total > 0 ? (n / total) * 100 : 0);
return (
@@ -443,11 +414,8 @@ const WagonYardWorkspaceModal = ({ opened, onClose }: WagonYardWorkspaceModalPro
</Card>
{/* ---- Actions ---- */}
<Grid gap="lg">
{/* Transfer */}
<Grid.Col span={{ base: 12, md: 6 }}>
<Card withBorder radius="md" h="100%" padding="lg">
<Group gap="xs" mb={4}>
<Card withBorder radius="md" padding="lg">
<Group gap="xs" mb={4}>
<ThemeIcon variant="light" color="grape" radius="md" size="md">
<ArrowRightLeft size={16} />
</ThemeIcon>
@@ -479,16 +447,17 @@ const WagonYardWorkspaceModal = ({ opened, onClose }: WagonYardWorkspaceModalPro
searchable
radius="md"
/>
<Textarea
label="Reason"
placeholder="Why are these wagons needed?"
value={transferReason}
onChange={(e) => setTransferReason(e.currentTarget.value)}
required
autosize
minRows={2}
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>
<Group gap={8} wrap="nowrap">
@@ -518,106 +487,15 @@ const WagonYardWorkspaceModal = ({ opened, onClose }: WagonYardWorkspaceModalPro
busy ||
!transferYardId ||
transferQty < 1 ||
!transferReason.trim()
!stripHtml(transferReason)
}
color="edr-green"
>
Request {transferQty > 0 ? `${transferQty} ` : ""}wagon
{transferQty === 1 ? "" : "s"}
</Button>
</Stack>
</Card>
</Grid.Col>
{/* Re-status */}
<Grid.Col span={{ base: 12, md: 6 }}>
<Card withBorder radius="md" h="100%" padding="lg">
<Group gap="xs" mb="md">
<ThemeIcon variant="light" color="orange" radius="md" size="md">
<ArrowRightLeft size={16} />
</ThemeIcon>
<Text fw={700}>Change availability</Text>
</Group>
<Stack gap="lg">
<Box>
<Group justify="space-between" mb={6}>
<Group gap={6}>
<ThemeIcon variant="light" color="blue" radius="sm" size="sm">
<CircleSlash size={12} />
</ThemeIcon>
<Text size="sm" fw={600}>
Available Assigned
</Text>
</Group>
<Badge color="teal" variant="light">
{availableCount} free
</Badge>
</Group>
<QuantityField
value={toAssignedQty}
onChange={setToAssignedQty}
max={availableCount}
/>
<Button
mt="sm"
fullWidth
variant="light"
color="blue"
disabled={busy || toAssignedQty < 1}
loading={setStatus.isPending}
onClick={() =>
handleFlip(availableWagons, toAssignedQty, ASSIGNED, "Assigned", () =>
setToAssignedQty(0),
)
}
>
Assign {toAssignedQty > 0 ? `${toAssignedQty} ` : ""}wagon
{toAssignedQty === 1 ? "" : "s"}
</Button>
</Box>
<Divider variant="dashed" />
<Box>
<Group justify="space-between" mb={6}>
<Group gap={6}>
<ThemeIcon variant="light" color="teal" radius="sm" size="sm">
<CheckCircle2 size={12} />
</ThemeIcon>
<Text size="sm" fw={600}>
Assigned Available
</Text>
</Group>
<Badge color="blue" variant="light">
{assignedCount} assigned
</Badge>
</Group>
<QuantityField
value={toAvailableQty}
onChange={setToAvailableQty}
max={assignedCount}
/>
<Button
mt="sm"
fullWidth
variant="light"
color="teal"
disabled={busy || toAvailableQty < 1}
loading={setStatus.isPending}
onClick={() =>
handleFlip(assignedWagons, toAvailableQty, AVAILABLE, "Available", () =>
setToAvailableQty(0),
)
}
>
Free up {toAvailableQty > 0 ? `${toAvailableQty} ` : ""}wagon
{toAvailableQty === 1 ? "" : "s"}
</Button>
</Box>
</Stack>
</Card>
</Grid.Col>
</Grid>
</Stack>
</Card>
</>
)}
</Stack>