mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-09-03 15:33:40 +00:00
feat: implement wagon transfer management modals and page
- Add TransferFulfillModal for fulfilling wagon transfer requests. - Create TransferRequestFormModal for filing new wagon transfer requests. - Introduce TransferCloseShortModal for closing requests that cannot be fully fulfilled. - Develop WagonTransfersPage to manage and display wagon transfer requests. - Implement utility functions for handling wagon transfer request data and UI components. - Enhance UI with Mantine components for better user experience.
This commit is contained in:
@@ -147,7 +147,7 @@ export const BookingDetailModal = ({
|
||||
<Group gap={4} justify="flex-end">
|
||||
{bookingWagons.map((w) => (
|
||||
<Badge key={w.id} size="sm" variant="outline" color="edr-green" radius="sm">
|
||||
#{w.sequenceNo}
|
||||
#{w.position ?? w.sequenceNo}
|
||||
</Badge>
|
||||
))}
|
||||
</Group>
|
||||
|
||||
@@ -281,7 +281,7 @@ function WagonCar({
|
||||
{/* header */}
|
||||
<Group justify="space-between" px={7} pt={3} wrap="nowrap">
|
||||
<Text size="10px" fw={800} c="gray.7">
|
||||
#{wagon.sequenceNo}
|
||||
#{wagon.position ?? wagon.sequenceNo}
|
||||
</Text>
|
||||
{isEmpty ? (
|
||||
<Text size="8px" c="dimmed" fw={700} style={{ letterSpacing: 0.5 }}>
|
||||
@@ -425,7 +425,7 @@ function WagonCar({
|
||||
</Box>
|
||||
<div>
|
||||
<Text size="sm" fw={800}>
|
||||
Wagon #{wagon.sequenceNo}
|
||||
Wagon #{wagon.position ?? wagon.sequenceNo}
|
||||
</Text>
|
||||
<Text size="10px" c="dimmed">
|
||||
{wagon.physicalWagonNumber ?? wagon.wagonType?.code ?? "Unassigned"}
|
||||
|
||||
@@ -45,7 +45,7 @@ export const RemoveBookingModal = ({
|
||||
<strong>Gross weight:</strong> {grossTons.toFixed(2)} T
|
||||
</Text>
|
||||
<Text size="sm">
|
||||
<strong>Wagon Slot:</strong> #{wagon.sequenceNo}
|
||||
<strong>Wagon Slot:</strong> #{wagon.position ?? wagon.sequenceNo}
|
||||
</Text>
|
||||
</Stack>
|
||||
</div>
|
||||
|
||||
@@ -217,7 +217,7 @@ export const TrainConsistView = ({
|
||||
<Box>
|
||||
<Group gap={6} mb={6} wrap="nowrap">
|
||||
<Badge variant="light" color="edr-green" radius="sm">
|
||||
Editing wagon #{selectedWagon.sequenceNo}
|
||||
Editing wagon #{selectedWagon.position ?? selectedWagon.sequenceNo}
|
||||
</Badge>
|
||||
<Text size="xs" c="dimmed">
|
||||
Update container numbers, move containers to another wagon, or remove the booking
|
||||
|
||||
@@ -80,7 +80,7 @@ export const WagonCard = ({
|
||||
<div>
|
||||
<Group gap={4}>
|
||||
<Text size="sm" fw={800}>
|
||||
Wagon #{wagon.sequenceNo}
|
||||
Wagon #{wagon.position ?? wagon.sequenceNo}
|
||||
</Text>
|
||||
<Badge size="xs" variant="light" color="edr-green">
|
||||
{wagonType}
|
||||
@@ -205,7 +205,7 @@ export const WagonCard = ({
|
||||
>
|
||||
<Group gap={6} wrap="nowrap" justify="space-between">
|
||||
<Text size="xs" fw={600} truncate>
|
||||
#{w.sequenceNo} ·{" "}
|
||||
#{w.position ?? w.sequenceNo} ·{" "}
|
||||
{w.physicalWagonNumber ?? w.wagonType?.code ?? "Wagon"}
|
||||
</Text>
|
||||
<Badge
|
||||
|
||||
@@ -1,613 +0,0 @@
|
||||
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>
|
||||
{r.reason ? (
|
||||
<Text size="xs" c="dimmed" mt={4}>
|
||||
Reason: {r.reason}
|
||||
</Text>
|
||||
) : null}
|
||||
</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());
|
||||
// Bulk accept-and-execute: the subset of pending requests OCC ticked.
|
||||
const [selected, setSelected] = 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 bulkFulfill = useMutation(
|
||||
api.wagonTransferRequests.bulkFulfill.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 toggleSelected = (id: string) =>
|
||||
setSelected((prev) => {
|
||||
const next = new Set(prev);
|
||||
if (next.has(id)) next.delete(id);
|
||||
else next.add(id);
|
||||
return next;
|
||||
});
|
||||
|
||||
// Execute the ticked subset; whatever cannot run (not enough available
|
||||
// wagons, already decided) is reported and simply stays PENDING.
|
||||
const handleBulkFulfill = async () => {
|
||||
if (selected.size === 0) return;
|
||||
try {
|
||||
const res = await bulkFulfill.mutateAsync({ requestIds: [...selected] });
|
||||
setSelected(new Set());
|
||||
const skippedNote = res.skipped.length
|
||||
? ` · ${res.skipped.length} left pending (${res.skipped
|
||||
.map((s) => s.reason)
|
||||
.join('; ')})`
|
||||
: "";
|
||||
toast({
|
||||
title: `Executed ${res.fulfilled.length} transfer request(s)`,
|
||||
description: skippedNote || undefined,
|
||||
variant: res.fulfilled.length === 0 ? "destructive" : undefined,
|
||||
});
|
||||
} catch (err) {
|
||||
showError(err, "Bulk execute 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">
|
||||
{/* Bulk accept-and-execute action bar: tick a subset, run it, and
|
||||
everything unticked (or unexecutable) stays PENDING. */}
|
||||
<Group justify="space-between" wrap="nowrap">
|
||||
<Checkbox
|
||||
label={
|
||||
selected.size > 0
|
||||
? `${selected.size} of ${requests.length} selected`
|
||||
: "Select all"
|
||||
}
|
||||
checked={selected.size === requests.length && requests.length > 0}
|
||||
indeterminate={selected.size > 0 && selected.size < requests.length}
|
||||
onChange={() =>
|
||||
setSelected(
|
||||
selected.size === requests.length
|
||||
? new Set()
|
||||
: new Set(requests.map((r) => r.id)),
|
||||
)
|
||||
}
|
||||
color="edr-green"
|
||||
/>
|
||||
<Button
|
||||
size="compact-sm"
|
||||
color="edr-green"
|
||||
leftSection={<PackageCheck size={14} />}
|
||||
loading={bulkFulfill.isPending}
|
||||
disabled={selected.size === 0}
|
||||
onClick={handleBulkFulfill}
|
||||
>
|
||||
Accept & execute {selected.size > 0 ? `(${selected.size})` : ""}
|
||||
</Button>
|
||||
</Group>
|
||||
|
||||
{requests.map((r) => (
|
||||
<Card key={r.id} withBorder radius="md" padding="md">
|
||||
<Group justify="space-between" wrap="nowrap" align="flex-start">
|
||||
<Group gap="sm" wrap="nowrap" align="flex-start" style={{ minWidth: 0 }}>
|
||||
<Checkbox
|
||||
checked={selected.has(r.id)}
|
||||
onChange={() => toggleSelected(r.id)}
|
||||
color="edr-green"
|
||||
mt={2}
|
||||
/>
|
||||
<Stack gap={6} style={{ minWidth: 0 }}>
|
||||
<RequestSummary r={r} />
|
||||
{r.reason ? (
|
||||
<Text size="xs">
|
||||
<Text span fw={600}>
|
||||
Reason:
|
||||
</Text>{" "}
|
||||
{r.reason}
|
||||
</Text>
|
||||
) : null}
|
||||
{r.note ? (
|
||||
<Text size="xs" c="dimmed">
|
||||
“{r.note}”
|
||||
</Text>
|
||||
) : null}
|
||||
</Stack>
|
||||
</Group>
|
||||
<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;
|
||||
@@ -40,7 +40,12 @@ const clampInt = (v: number | string, max: number): number => {
|
||||
return Math.min(Math.floor(n), max);
|
||||
};
|
||||
|
||||
/** NumberInput + Slider + All/Half presets, kept in sync and bounded to `max`. */
|
||||
/**
|
||||
* NumberInput + Slider + All/Half presets, kept in sync. `max` bounds the field
|
||||
* for actions that move real wagons; omit it for a transfer REQUEST, which may
|
||||
* legitimately ask for more than the yard holds today (OCC fulfils it in
|
||||
* instalments) — the slider then just tracks the current value.
|
||||
*/
|
||||
const QuantityField = ({
|
||||
value,
|
||||
onChange,
|
||||
@@ -49,10 +54,11 @@ const QuantityField = ({
|
||||
}: {
|
||||
value: number;
|
||||
onChange: (n: number) => void;
|
||||
max: number;
|
||||
max?: number;
|
||||
disabled?: boolean;
|
||||
}) => {
|
||||
const set = (v: number | string) => onChange(clampInt(v, max));
|
||||
const capped = max ?? Number.MAX_SAFE_INTEGER;
|
||||
const set = (v: number | string) => onChange(clampInt(v, capped));
|
||||
const off = disabled || max === 0;
|
||||
return (
|
||||
<Stack gap={8}>
|
||||
@@ -73,19 +79,25 @@ const QuantityField = ({
|
||||
value={value}
|
||||
onChange={set}
|
||||
min={0}
|
||||
max={Math.max(max, 1)}
|
||||
max={Math.max(max ?? Math.max(value, 10), 1)}
|
||||
disabled={off}
|
||||
label={(v) => `${v}`}
|
||||
color="edr-green"
|
||||
/>
|
||||
</Group>
|
||||
<Group gap={6}>
|
||||
<Button size="compact-xs" variant="light" color="gray" disabled={off} onClick={() => set(Math.ceil(max / 2))}>
|
||||
Half
|
||||
</Button>
|
||||
<Button size="compact-xs" variant="light" color="gray" disabled={off} onClick={() => set(max)}>
|
||||
All ({max})
|
||||
</Button>
|
||||
{/* Presets only make sense against a real ceiling — an uncapped request
|
||||
field (transfer ask) shows the manual input alone. */}
|
||||
{max != null ? (
|
||||
<>
|
||||
<Button size="compact-xs" variant="light" color="gray" disabled={off} onClick={() => set(Math.ceil(max / 2))}>
|
||||
Half
|
||||
</Button>
|
||||
<Button size="compact-xs" variant="light" color="gray" disabled={off} onClick={() => set(max)}>
|
||||
All ({max})
|
||||
</Button>
|
||||
</>
|
||||
) : null}
|
||||
{value > 0 ? (
|
||||
<Button size="compact-xs" variant="subtle" color="gray" onClick={() => set(0)}>
|
||||
Clear
|
||||
@@ -242,9 +254,10 @@ const WagonYardWorkspaceModal = ({ opened, onClose }: WagonYardWorkspaceModalPro
|
||||
}
|
||||
}, [opened]);
|
||||
|
||||
// Keep quantities within bounds as counts shift after each action. Transfers
|
||||
// may only ask for AVAILABLE wagons, so the request cap is availableCount.
|
||||
useEffect(() => setTransferQty((q) => Math.min(q, availableCount)), [availableCount]);
|
||||
// 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]);
|
||||
|
||||
@@ -453,11 +466,9 @@ const WagonYardWorkspaceModal = ({ opened, onClose }: WagonYardWorkspaceModalPro
|
||||
{availableCount} available
|
||||
</Badge>
|
||||
</Group>
|
||||
<QuantityField
|
||||
value={transferQty}
|
||||
onChange={setTransferQty}
|
||||
max={availableCount}
|
||||
/>
|
||||
{/* No max: the request may exceed what the yard holds
|
||||
today — OCC fulfils it in instalments. */}
|
||||
<QuantityField value={transferQty} onChange={setTransferQty} />
|
||||
</div>
|
||||
<Select
|
||||
label="Destination yard"
|
||||
|
||||
Reference in New Issue
Block a user