diff --git a/apps/edr-freight-web/backoffice/src/components/trainScheduling/ScheduleWorkspacePanel.tsx b/apps/edr-freight-web/backoffice/src/components/trainScheduling/ScheduleWorkspacePanel.tsx new file mode 100644 index 000000000..07222baa1 --- /dev/null +++ b/apps/edr-freight-web/backoffice/src/components/trainScheduling/ScheduleWorkspacePanel.tsx @@ -0,0 +1,546 @@ +import { useMemo, useState } from "react"; +import { + Badge, + Box, + Button, + Group, + Modal, + Paper, + Progress, + ScrollArea, + Select, + Stack, + Text, + ThemeIcon, + Tooltip, +} from "@mantine/core"; +import { useMutation, useQuery } from "@tanstack/react-query"; +import { + AlertTriangle, + ArrowLeftRight, + ArrowRight, + CheckCircle2, + Inbox, + PackageCheck, + Repeat, + Train, + Weight, + X, +} from "lucide-react"; + +import { BookingStatusBadge } from "@/components/bookings/BookingStatusBadge"; +import { api } from "@/services/api"; +import { useToast } from "@/hooks/use-toast"; +import type { + EligibleContainerBooking, + FreightType, + TrainScheduleDetail, +} from "@/types/trainScheduling"; + +interface ScheduleWorkspacePanelProps { + schedule: TrainScheduleDetail; + /** Refetch the schedule detail after a mutation so both panels refresh. */ + onChanged: () => void; +} + +const GREEN = "var(--mantine-color-edr-green-6)"; + +/** Cargo weight already allocated to this train (sum of on-train bookings). */ +function usedWeight(schedule: TrainScheduleDetail): number { + return (schedule.bookings ?? []).reduce( + (sum, b) => sum + (Number(b.weightTons) || 0), + 0, + ); +} + +/** Max pull weight across all locomotives on the set (0 when unknown). */ +function pullCapacity(schedule: TrainScheduleDetail): number { + const set = schedule.trainSet; + if (!set) return 0; + const locos = + set.locomotives && set.locomotives.length > 0 + ? set.locomotives + : set.locomotive + ? [set.locomotive] + : []; + return locos.reduce((sum, l) => sum + (Number(l.maxPullWeightTons) || 0), 0); +} + +export function ScheduleWorkspacePanel({ + schedule, + onChanged, +}: ScheduleWorkspacePanelProps) { + const { toast } = useToast(); + + const freightType: FreightType | undefined = + schedule.freightType === "CONTAINER" || schedule.freightType === "BULK" + ? schedule.freightType + : undefined; + + const locked = ["DISPATCHED", "ARRIVED"].includes(schedule.status); + const canManage = ["DRAFT", "SCHEDULED"].includes(schedule.status); + + // Pool = accepted, ready-to-pay bookings on THIS train's route+day that are not + // yet linked to any schedule (same filter the auto-batch uses). + const poolQuery = useQuery( + api.trainScheduling.eligibleBookings.queryOptions({ + input: { + filters: { + originStationId: schedule.originStation?.id, + destinationStationId: schedule.destinationStation?.id, + trainScheduleId: schedule.id, + }, + freightType, + }, + enabled: Boolean(schedule.originStation?.id && schedule.destinationStation?.id), + }), + ); + + const onTrainIds = useMemo( + () => new Set((schedule.bookings ?? []).map((b) => b.id)), + [schedule.bookings], + ); + + const pool: EligibleContainerBooking[] = useMemo( + () => (poolQuery.data?.items ?? []).filter((b) => !onTrainIds.has(b.id)), + [poolQuery.data, onTrainIds], + ); + + const onTrain = schedule.bookings ?? []; + + // ── Mutations (reuse the existing endpoints) ─────────────────────────────── + const assign = useMutation(api.trainScheduling.assignBookings.mutationOptions()); + const unassign = useMutation(api.trainScheduling.unassignBooking.mutationOptions()); + const moveSchedule = useMutation( + api.trainScheduling.moveBookingSchedule.mutationOptions(), + ); + + const [moveBookingId, setMoveBookingId] = useState(null); + const [moveTarget, setMoveTarget] = useState(null); + + const { data: targets } = useQuery( + api.trainScheduling.bookableSchedules.queryOptions({ + input: { + originYardId: schedule.originStation?.id, + destinationYardId: schedule.destinationStation?.id, + }, + enabled: Boolean( + schedule.originStation?.id && schedule.destinationStation?.id, + ), + }), + ); + const moveOptions = useMemo( + () => + (targets ?? []) + .filter((s) => s.id !== schedule.id) + .map((s) => ({ + value: s.id, + label: `${s.routeName ?? `${s.origin} → ${s.destination}`} · ${new Date( + s.scheduleDate, + ).toLocaleString()} · ${s.remainingWagons}/${s.maxWagons} free`, + })), + [targets, schedule.id], + ); + + // ── Capacity meter (by cargo weight vs locomotive pull) ──────────────────── + const used = usedWeight(schedule); + const capacity = pullCapacity(schedule); + const pct = capacity > 0 ? Math.min(100, Math.round((used / capacity) * 100)) : 0; + const over = capacity > 0 && used > capacity; + + const forceAdd = (bookingId: string, ref: string, weightTons: number) => { + const wouldOverfill = capacity > 0 && used + (weightTons || 0) > capacity; + assign + .mutateAsync({ + id: schedule.id, + freightType, + payload: { + bookingIds: [...onTrainIds, bookingId], + forceAssign: true, + }, + }) + .then(() => { + toast({ + title: `${ref} added to train`, + description: wouldOverfill + ? "Force-added past the pull-weight limit — review capacity." + : "Wagons auto-pinned.", + variant: wouldOverfill ? "destructive" : undefined, + }); + onChanged(); + void poolQuery.refetch(); + }) + .catch(() => + toast({ title: "Could not add booking", variant: "destructive" }), + ); + }; + + const removeFromTrain = (bookingId: string, ref: string) => { + unassign + .mutateAsync({ id: schedule.id, bookingId }) + .then(() => { + toast({ title: `${ref} removed from train` }); + onChanged(); + void poolQuery.refetch(); + }) + .catch(() => + toast({ title: "Could not remove booking", variant: "destructive" }), + ); + }; + + const doMove = () => { + if (!moveBookingId || !moveTarget) return; + moveSchedule + .mutateAsync({ bookingId: moveBookingId, trainScheduleId: moveTarget }) + .then(() => { + toast({ title: "Booking reassigned to another train" }); + setMoveBookingId(null); + onChanged(); + void poolQuery.refetch(); + }) + .catch(() => + toast({ title: "Could not reassign booking", variant: "destructive" }), + ); + }; + + return ( + + + {/* Header + capacity meter */} + + + + + +
+ Allocation workspace + + Manually add ready-to-pay bookings, remove, or reassign them + +
+
+ + + + + + + Load {used.toFixed(1)}T + {capacity > 0 ? ` / ${capacity.toFixed(0)}T pull` : ""} + + + {over ? ( + + Over capacity + + ) : ( + + {capacity > 0 ? `${pct}%` : "—"} + + )} + + 0 ? pct : 0} + color={over ? "red" : pct > 85 ? "orange" : "edr-green"} + radius="xl" + size="md" + /> + +
+ + {over ? ( + + + + This train is loaded beyond its locomotive pull weight. Force-adds are + allowed, but review before dispatch. + + + ) : null} + + {locked ? ( + + This train is {schedule.status.toLowerCase()} — bookings can no longer be + changed. + + ) : null} + + {/* Two-panel board */} + + {/* Pool */} + + {pool.map((b) => ( + + + + ) : null + } + /> + ))} + + + {/* On train */} + + {onTrain.map((b) => ( + + + + + + + + + ) : null + } + /> + ))} + + +
+ + {/* Reassign modal */} + setMoveBookingId(null)} + title={ + + + Reassign booking to another train + + } + centered + radius="lg" + > + +