import { Freight } from "@edr/types"; import { Badge, Button, Card, Checkbox, Divider, Group, Loader, Modal, ScrollArea, Stack, Switch, Tabs, Text, ThemeIcon, } from "@mantine/core"; import { useMutation, useQuery } from "@tanstack/react-query"; import { ArrowRight, ChevronLeft, History, Inbox, PackageCheck, Warehouse, X, } from "lucide-react"; import { useMemo, useState } from "react"; import { api } from "@/services/api"; import { useAuth } from "@/auth/useAuth"; import { FREIGHT_PERMS, hasPermission } from "@/lib/permissions"; import { useToast } from "@/hooks/use-toast"; import type { WagonMovementRecord, WagonTransferRequest, } from "@/services/wagon.service"; export interface WagonTransferRequestsModalProps { opened: boolean; onClose: () => void; } const PENDING = Freight.WagonTransferRequestStatus.Pending; const AVAILABLE = Freight.WagonStatus.Available; const yardLabel = (y?: { label?: string; code?: string } | null) => y?.label || y?.code || "—"; const typeLabel = (t?: { code?: string; name?: string } | null) => t ? `${t.code ?? ""}${t.name ? ` · ${t.name}` : ""}` : "—"; /** Requester → destination + type + count summary line, reused in list and picker. */ const RequestSummary = ({ r }: { r: WagonTransferRequest }) => ( {yardLabel(r.fromYard)} {yardLabel(r.toYard)} {r.quantity}× {typeLabel(r.wagonType)} ); const STATUS_COLOR: Record = { PENDING: "gray", FULFILLED: "teal", CANCELLED: "red", }; const fmtDateTime = (iso: string) => new Date(iso).toLocaleString("en-GB", { day: "numeric", month: "short", hour: "2-digit", minute: "2-digit", hour12: false, }); /** * Per-user transfer history. A staffer sees their OWN activity — the requests * they filed or fulfilled, and the individual wagons they moved. Holders of * `transfer_history_all` get an "All staff" toggle that widens the view; the * backend enforces the scope regardless of the toggle. */ function HistoryPanel({ opened }: { opened: boolean }) { const { user } = useAuth(); const canSeeAll = hasPermission( user, FREIGHT_PERMS.wagons.transferHistoryAll, ); const myId = (user as { id?: string } | null | undefined)?.id; const [allStaff, setAllStaff] = useState(false); const scopeAll = canSeeAll && allStaff; const mine = useQuery({ ...api.wagonTransferRequests.history.queryOptions(), enabled: opened && !scopeAll, }); const all = useQuery({ ...api.wagonTransferRequests.historyAll.queryOptions({ input: {} }), enabled: opened && scopeAll, }); const source = scopeAll ? all : mine; const requests = source.data?.requests ?? []; const movements: WagonMovementRecord[] = source.data?.movements ?? []; const roleBadge = (r: WagonTransferRequest) => { if (myId && r.fulfilledByUserId === myId) return ( fulfilled ); if (myId && r.requestedByUserId === myId) return ( requested ); return null; }; return ( {canSeeAll ? ( setAllStaff(e.currentTarget.checked)} label="All staff" color="edr-green" /> ) : null} {source.isLoading ? ( ) : ( <>
Requests{scopeAll ? "" : " you touched"} {requests.length === 0 ? ( No requests yet. ) : ( {requests.map((r) => ( {roleBadge(r)} {r.status.toLowerCase()} ))} )}
Wagons moved {movements.length === 0 ? ( No wagon moves yet. ) : ( {movements.map((m) => ( {m.wagon?.wagonNumber ?? "Wagon"} {yardLabel(m.fromYard)} → {yardLabel(m.toYard)} {m.transferRequestId ? ( from request ) : null} {fmtDateTime(m.occurredAt)} ))} )}
)}
); } /** * OCC fulfilment queue for wagon-transfer requests. Lists PENDING requests; open * one to hand-pick exactly the requested number of wagons from the source yard * (of the requested type) and execute the move, or cancel the request. * A second tab shows per-user transfer history. */ const WagonTransferRequestsModal = ({ opened, onClose, }: WagonTransferRequestsModalProps) => { const { toast } = useToast(); const [tab, setTab] = useState("queue"); const [active, setActive] = useState(null); const [picked, setPicked] = useState>(new Set()); const { data: requests = [], isLoading } = useQuery({ ...api.wagonTransferRequests.list.queryOptions({ input: { status: PENDING } }), enabled: opened, }); // Available wagons of the requested type sitting in the request's source yard. const { data: wagons = [], isLoading: wagonsLoading } = useQuery({ ...api.wagons.list.queryOptions({ input: { filters: active ? { currentYardId: active.fromYardId, wagonTypeId: active.wagonTypeId, status: AVAILABLE, } : {}, }, }), enabled: opened && Boolean(active), }); const fulfill = useMutation(api.wagonTransferRequests.fulfill.mutationOptions()); const cancel = useMutation(api.wagonTransferRequests.cancel.mutationOptions()); 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" }); }; const openPicker = (r: WagonTransferRequest) => { setActive(r); setPicked(new Set()); }; const closePicker = () => { setActive(null); setPicked(new Set()); }; const toggle = (id: string) => setPicked((prev) => { const next = new Set(prev); if (next.has(id)) next.delete(id); else if (active && next.size >= active.quantity) return prev; // cap at quantity else next.add(id); return next; }); const need = active?.quantity ?? 0; const shortfall = active ? Math.max(0, need - wagons.length) : 0; const handleFulfill = async () => { if (!active || picked.size !== need) return; try { await fulfill.mutateAsync({ id: active.id, wagonIds: [...picked] }); toast({ title: `Transferred ${need} wagon(s) · ${yardLabel(active.fromYard)} → ${yardLabel( active.toYard, )}`, }); closePicker(); } catch (err) { showError(err, "Transfer failed"); } }; const handleCancel = async (r: WagonTransferRequest) => { try { await cancel.mutateAsync({ id: r.id }); toast({ title: "Request cancelled" }); } catch (err) { showError(err, "Cancel failed"); } }; const sortedWagons = useMemo( () => [...wagons].sort((a, b) => a.wagonNumber.localeCompare(b.wagonNumber)), [wagons], ); return (
Wagon Transfer Requests {active ? "Pick the wagons to move, then transfer" : "OCC queue — pick wagons and complete each move"}
} > }> Queue }> History {!active ? ( // ---- Pending queue ---- isLoading ? ( ) : requests.length === 0 ? ( No pending transfer requests When staff request a yard-to-yard wagon move, it appears here for you to fulfil. ) : ( {requests.map((r) => ( {r.note ? ( “{r.note}” ) : null} ))} ) ) : ( // ---- Wagon picker for the active request ---- Select wagons in {yardLabel(active.fromYard)} {picked.size} / {need} selected {wagonsLoading ? ( ) : sortedWagons.length === 0 ? ( No available wagons of this type in {yardLabel(active.fromYard)}. ) : ( <> {shortfall > 0 ? ( Only {sortedWagons.length} available — {shortfall} short of the{" "} {need} requested. ) : null} {sortedWagons.map((w) => { const checked = picked.has(w.id); const atCap = !checked && picked.size >= need; return ( !atCap && toggle(w.id)} style={{ cursor: atCap ? "not-allowed" : "pointer", borderColor: checked ? "var(--mantine-color-edr-green-4)" : undefined, opacity: atCap ? 0.55 : 1, }} > {/* Visual only — the Card's onClick owns the toggle so a click on the box doesn't fire both and cancel out. */} {w.wagonNumber} ); })} )} )}
); }; export default WagonTransferRequestsModal;