Files
edr-platform/apps/edr-freight-web/backoffice/src/components/wagons/WagonTransferRequestsModal.tsx
Marshal b5a97d344a train
2026-07-14 13:10:00 +00:00

526 lines
16 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,
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 }) => (
<Group gap={8} wrap="nowrap">
<Text fw={600} size="sm" truncate>
{yardLabel(r.fromYard)}
</Text>
<ArrowRight size={14} style={{ flexShrink: 0 }} />
<Text fw={600} size="sm" truncate>
{yardLabel(r.toYard)}
</Text>
<Badge variant="light" color="grape" radius="sm">
{r.quantity}× {typeLabel(r.wagonType)}
</Badge>
</Group>
);
const STATUS_COLOR: Record<string, string> = {
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 (
<Badge size="xs" variant="light" color="blue">
fulfilled
</Badge>
);
if (myId && r.requestedByUserId === myId)
return (
<Badge size="xs" variant="light" color="grape">
requested
</Badge>
);
return null;
};
return (
<Stack gap="lg">
{canSeeAll ? (
<Group justify="flex-end">
<Switch
checked={allStaff}
onChange={(e) => setAllStaff(e.currentTarget.checked)}
label="All staff"
color="edr-green"
/>
</Group>
) : null}
{source.isLoading ? (
<Group justify="center" p="xl">
<Loader />
</Group>
) : (
<>
<div>
<Text fw={700} size="sm" mb={8}>
Requests{scopeAll ? "" : " you touched"}
</Text>
{requests.length === 0 ? (
<Text size="sm" c="dimmed">
No requests yet.
</Text>
) : (
<Stack gap={6}>
{requests.map((r) => (
<Card key={r.id} withBorder radius="md" padding="xs">
<Group justify="space-between" wrap="nowrap">
<RequestSummary r={r} />
<Group gap={8} wrap="nowrap">
{roleBadge(r)}
<Badge
size="sm"
variant="light"
color={STATUS_COLOR[r.status] ?? "gray"}
>
{r.status.toLowerCase()}
</Badge>
</Group>
</Group>
</Card>
))}
</Stack>
)}
</div>
<Divider />
<div>
<Text fw={700} size="sm" mb={8}>
Wagons moved
</Text>
{movements.length === 0 ? (
<Text size="sm" c="dimmed">
No wagon moves yet.
</Text>
) : (
<ScrollArea.Autosize mah={260}>
<Stack gap={6}>
{movements.map((m) => (
<Card key={m.id} withBorder radius="md" padding="xs">
<Group justify="space-between" wrap="nowrap">
<Group gap={8} wrap="nowrap" style={{ minWidth: 0 }}>
<Text fw={600} size="sm">
{m.wagon?.wagonNumber ?? "Wagon"}
</Text>
<Text size="xs" c="dimmed" truncate>
{yardLabel(m.fromYard)} {yardLabel(m.toYard)}
</Text>
{m.transferRequestId ? (
<Badge size="xs" variant="light" color="teal">
from request
</Badge>
) : null}
</Group>
<Text size="xs" c="dimmed" style={{ flexShrink: 0 }}>
{fmtDateTime(m.occurredAt)}
</Text>
</Group>
</Card>
))}
</Stack>
</ScrollArea.Autosize>
)}
</div>
</>
)}
</Stack>
);
}
/**
* 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<string | null>("queue");
const [active, setActive] = useState<WagonTransferRequest | null>(null);
const [picked, setPicked] = useState<Set<string>>(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 (
<Modal
opened={opened}
onClose={onClose}
size="min(760px, 96vw)"
radius="lg"
centered
overlayProps={{ blur: 2 }}
title={
<Group gap="sm">
<ThemeIcon variant="light" color="edr-green" radius="md" size="lg">
<Inbox size={18} />
</ThemeIcon>
<div>
<Text fw={700}>Wagon Transfer Requests</Text>
<Text size="xs" c="dimmed">
{active
? "Pick the wagons to move, then transfer"
: "OCC queue — pick wagons and complete each move"}
</Text>
</div>
</Group>
}
>
<Tabs value={tab} onChange={setTab} keepMounted={false}>
<Tabs.List mb="md">
<Tabs.Tab value="queue" leftSection={<Inbox size={14} />}>
Queue
</Tabs.Tab>
<Tabs.Tab value="history" leftSection={<History size={14} />}>
History
</Tabs.Tab>
</Tabs.List>
<Tabs.Panel value="queue">
{!active ? (
// ---- Pending queue ----
isLoading ? (
<Group justify="center" p="xl">
<Loader />
</Group>
) : requests.length === 0 ? (
<Card withBorder radius="md" padding="xl">
<Stack align="center" gap={6}>
<ThemeIcon variant="light" color="gray" radius="xl" size={48}>
<Inbox size={22} />
</ThemeIcon>
<Text fw={600}>No pending transfer requests</Text>
<Text size="sm" c="dimmed" ta="center" maw={420}>
When staff request a yard-to-yard wagon move, it appears here for
you to fulfil.
</Text>
</Stack>
</Card>
) : (
<Stack gap="sm">
{requests.map((r) => (
<Card key={r.id} withBorder radius="md" padding="md">
<Group justify="space-between" wrap="nowrap" align="flex-start">
<Stack gap={6} style={{ minWidth: 0 }}>
<RequestSummary r={r} />
{r.note ? (
<Text size="xs" c="dimmed">
{r.note}
</Text>
) : null}
</Stack>
<Group gap={8} wrap="nowrap">
<Button
size="compact-sm"
variant="subtle"
color="gray"
leftSection={<X size={14} />}
loading={cancel.isPending}
onClick={() => handleCancel(r)}
>
Cancel
</Button>
<Button
size="compact-sm"
color="edr-green"
leftSection={<PackageCheck size={14} />}
onClick={() => openPicker(r)}
>
Fulfil
</Button>
</Group>
</Group>
</Card>
))}
</Stack>
)
) : (
// ---- Wagon picker for the active request ----
<Stack gap="md">
<Card withBorder radius="md" padding="sm" bg="var(--mantine-color-gray-0)">
<RequestSummary r={active} />
</Card>
<Group justify="space-between">
<Text size="sm" fw={600}>
Select wagons in {yardLabel(active.fromYard)}
</Text>
<Badge
color={picked.size === need ? "teal" : "gray"}
variant={picked.size === need ? "filled" : "light"}
>
{picked.size} / {need} selected
</Badge>
</Group>
{wagonsLoading ? (
<Group justify="center" p="lg">
<Loader size="sm" />
</Group>
) : sortedWagons.length === 0 ? (
<Card withBorder radius="md" padding="lg">
<Group gap={8} justify="center">
<Warehouse size={16} />
<Text size="sm" c="dimmed">
No available wagons of this type in {yardLabel(active.fromYard)}.
</Text>
</Group>
</Card>
) : (
<>
{shortfall > 0 ? (
<Text size="xs" c="orange.7">
Only {sortedWagons.length} available {shortfall} short of the{" "}
{need} requested.
</Text>
) : null}
<ScrollArea.Autosize mah={320}>
<Stack gap={6}>
{sortedWagons.map((w) => {
const checked = picked.has(w.id);
const atCap = !checked && picked.size >= need;
return (
<Card
key={w.id}
withBorder
radius="md"
padding="xs"
onClick={() => !atCap && toggle(w.id)}
style={{
cursor: atCap ? "not-allowed" : "pointer",
borderColor: checked
? "var(--mantine-color-edr-green-4)"
: undefined,
opacity: atCap ? 0.55 : 1,
}}
>
<Group gap="sm" wrap="nowrap">
{/* Visual only — the Card's onClick owns the toggle so a
click on the box doesn't fire both and cancel out. */}
<Checkbox
checked={checked}
readOnly
disabled={atCap}
color="edr-green"
tabIndex={-1}
aria-hidden
/>
<Text fw={600} size="sm">
{w.wagonNumber}
</Text>
</Group>
</Card>
);
})}
</Stack>
</ScrollArea.Autosize>
</>
)}
<Divider />
<Group justify="space-between">
<Button
variant="subtle"
color="gray"
leftSection={<ChevronLeft size={16} />}
onClick={closePicker}
>
Back to queue
</Button>
<Button
color="edr-green"
leftSection={<PackageCheck size={16} />}
loading={fulfill.isPending}
disabled={picked.size !== need}
onClick={handleFulfill}
>
Transfer {need} wagon{need === 1 ? "" : "s"}
</Button>
</Group>
</Stack>
)}
</Tabs.Panel>
<Tabs.Panel value="history">
<HistoryPanel opened={opened} />
</Tabs.Panel>
</Tabs>
</Modal>
);
};
export default WagonTransferRequestsModal;