From 66062ac1a2f99c6b04060f961ed858d9aef5762c Mon Sep 17 00:00:00 2001 From: Marshal Date: Fri, 31 Jul 2026 13:05:04 +0000 Subject: [PATCH] fix issue --- apps/edr-freight-web/backoffice/package.json | 1 + .../trainScheduling/LegCapacityPanel.tsx | 216 +++--------------- .../wagons/WagonYardWorkspaceModal.tsx | 170 ++------------ pnpm-lock.yaml | 83 +++++++ 4 files changed, 142 insertions(+), 328 deletions(-) diff --git a/apps/edr-freight-web/backoffice/package.json b/apps/edr-freight-web/backoffice/package.json index 9f7fd27bf..726b80f5e 100644 --- a/apps/edr-freight-web/backoffice/package.json +++ b/apps/edr-freight-web/backoffice/package.json @@ -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", diff --git a/apps/edr-freight-web/backoffice/src/components/trainScheduling/LegCapacityPanel.tsx b/apps/edr-freight-web/backoffice/src/components/trainScheduling/LegCapacityPanel.tsx index cfec294bb..333119257 100644 --- a/apps/edr-freight-web/backoffice/src/components/trainScheduling/LegCapacityPanel.tsx +++ b/apps/edr-freight-web/backoffice/src/components/trainScheduling/LegCapacityPanel.tsx @@ -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, - 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(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(); - 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(); - for (const w of active) { - grossTons += (Number(w.tareWeightTons) || 0) + (Number(w.assignedWeightTons) || 0); - lengthMeters += Number(w.lengthMeters) || 0; - const bookingIdsOnWagon = new Set(); - 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 ( - }> - 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. - - ); - } - const legStatus = (e: EdgeUsage) => { if (weightCap != null && e.grossTons > weightCap) return Overweight; - if (lengthCap != null && e.lengthMeters > lengthCap) - return Over length; 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 ( - {unallocatedRefs.length ? ( - }> - {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. - - ) : null} Per-leg utilization - 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 ? ")" : ""}. - + @@ -310,7 +179,6 @@ export function LegCapacityPanel({ schedule }: { schedule: TrainScheduleDetail } Leg Wagons Gross weight - Length Bookings Status @@ -348,14 +216,6 @@ export function LegCapacityPanel({ schedule }: { schedule: TrainScheduleDetail } - {e.pendingTons > 0 ? ( - - +{e.pendingTons}T unallocated - - ) : null} - - - {hasBookings ? ( @@ -372,7 +232,7 @@ export function LegCapacityPanel({ schedule }: { schedule: TrainScheduleDetail } {hasBookings ? ( - + ( - - - {b.reference} - {b.route ? ( - - {" "} - ({b.route}) - - ) : null} - - {b.unallocated ? ( - - no wagons - + + {b.reference} + {b.route ? ( + + {" "} + ({b.route}) + ) : null} - + @@ -413,7 +266,6 @@ export function LegCapacityPanel({ schedule }: { schedule: TrainScheduleDetail } {b.wagons > 0 ? b.wagons : "—"} wagon {b.wagons === 1 ? "" : "s"} - {b.unallocated && b.wagons > 0 ? " needed" : ""} diff --git a/apps/edr-freight-web/backoffice/src/components/wagons/WagonYardWorkspaceModal.tsx b/apps/edr-freight-web/backoffice/src/components/wagons/WagonYardWorkspaceModal.tsx index 56d5a14de..f07f53b59 100644 --- a/apps/edr-freight-web/backoffice/src/components/wagons/WagonYardWorkspaceModal.tsx +++ b/apps/edr-freight-web/backoffice/src/components/wagons/WagonYardWorkspaceModal.tsx @@ -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(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 {/* ---- Actions ---- */} - - {/* Transfer */} - - - + + @@ -479,16 +447,17 @@ const WagonYardWorkspaceModal = ({ opened, onClose }: WagonYardWorkspaceModalPro searchable radius="md" /> -