mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-27 20:10:56 +00:00
wagon work space, container validation
This commit is contained in:
@@ -10,15 +10,16 @@ import {
|
||||
Loader,
|
||||
Modal,
|
||||
NumberInput,
|
||||
Progress,
|
||||
Select,
|
||||
SimpleGrid,
|
||||
Slider,
|
||||
Stack,
|
||||
Switch,
|
||||
Text,
|
||||
ThemeIcon,
|
||||
Title,
|
||||
} from "@mantine/core";
|
||||
import { useMutation, useQuery } from "@tanstack/react-query";
|
||||
import { ArrowRightLeft, PackageCheck, Repeat, Warehouse } from "lucide-react";
|
||||
import { ArrowRight, ArrowRightLeft, CheckCircle2, CircleSlash, Layers, Warehouse } from "lucide-react";
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
|
||||
import { api } from "@/services/api";
|
||||
@@ -30,23 +31,93 @@ export interface WagonYardWorkspaceModalProps {
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
const numberOrZero = (v: number | string): number => {
|
||||
const AVAILABLE = Freight.WagonStatus.Available;
|
||||
const ASSIGNED = Freight.WagonStatus.Assigned;
|
||||
|
||||
const clampInt = (v: number | string, max: number): number => {
|
||||
const n = typeof v === "number" ? v : Number(v);
|
||||
return Number.isFinite(n) && n > 0 ? Math.floor(n) : 0;
|
||||
if (!Number.isFinite(n) || n < 0) return 0;
|
||||
return Math.min(Math.floor(n), max);
|
||||
};
|
||||
|
||||
/** NumberInput + Slider + All/Half presets, kept in sync and bounded to `max`. */
|
||||
const QuantityField = ({
|
||||
value,
|
||||
onChange,
|
||||
max,
|
||||
disabled,
|
||||
}: {
|
||||
value: number;
|
||||
onChange: (n: number) => void;
|
||||
max: number;
|
||||
disabled?: boolean;
|
||||
}) => {
|
||||
const set = (v: number | string) => onChange(clampInt(v, max));
|
||||
const off = disabled || max === 0;
|
||||
return (
|
||||
<Stack gap={8}>
|
||||
<Group gap="sm" align="center" wrap="nowrap">
|
||||
<NumberInput
|
||||
value={value}
|
||||
onChange={set}
|
||||
min={0}
|
||||
max={max}
|
||||
allowNegative={false}
|
||||
clampBehavior="strict"
|
||||
disabled={off}
|
||||
radius="md"
|
||||
w={92}
|
||||
/>
|
||||
<Slider
|
||||
style={{ flex: 1 }}
|
||||
value={value}
|
||||
onChange={set}
|
||||
min={0}
|
||||
max={Math.max(max, 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>
|
||||
{value > 0 ? (
|
||||
<Button size="compact-xs" variant="subtle" color="gray" onClick={() => set(0)}>
|
||||
Clear
|
||||
</Button>
|
||||
) : null}
|
||||
</Group>
|
||||
</Stack>
|
||||
);
|
||||
};
|
||||
|
||||
const LegendDot = ({ color, label, value }: { color: string; label: string; value: number }) => (
|
||||
<Group gap={6} wrap="nowrap">
|
||||
<Box w={10} h={10} style={{ borderRadius: 3, background: `var(--mantine-color-${color}-6)` }} />
|
||||
<Text size="sm" c="dimmed">
|
||||
{label}
|
||||
</Text>
|
||||
<Text size="sm" fw={700}>
|
||||
{value}
|
||||
</Text>
|
||||
</Group>
|
||||
);
|
||||
|
||||
/**
|
||||
* Yard workspace: pick a yard + wagon type (the two selects filter each other
|
||||
* to only in-inventory combinations), see how many wagons of that type sit in
|
||||
* that yard and how they split Available / Assigned, then bulk-transfer a
|
||||
* quantity to another yard or flip a quantity between Available and Assigned.
|
||||
* Bulk yard operations. Pick a yard + wagon type (the two selects filter each
|
||||
* other to combinations that actually hold stock), read the live Available /
|
||||
* Assigned split, then move a quantity to another yard or flip a quantity
|
||||
* between Available and Assigned — replacing one-wagon-at-a-time edits.
|
||||
*/
|
||||
const WagonYardWorkspaceModal = ({ opened, onClose }: WagonYardWorkspaceModalProps) => {
|
||||
const { toast } = useToast();
|
||||
|
||||
const { data: wagons = [], isLoading: wagonsLoading } = useQuery(
|
||||
api.wagons.list.queryOptions({ input: {} }),
|
||||
);
|
||||
const { data: wagons = [], isLoading } = useQuery(api.wagons.list.queryOptions({ input: {} }));
|
||||
const { data: yards = [] } = useQuery(api.routes.yards.queryOptions());
|
||||
const { data: wagonTypes = [] } = useQuery(api.wagonTypes.list.queryOptions());
|
||||
|
||||
@@ -54,33 +125,35 @@ const WagonYardWorkspaceModal = ({ opened, onClose }: WagonYardWorkspaceModalPro
|
||||
const [typeId, setTypeId] = useState<string | null>(null);
|
||||
|
||||
const [transferYardId, setTransferYardId] = useState<string | null>(null);
|
||||
const [transferQty, setTransferQty] = useState<number | string>(1);
|
||||
const [toAssignedQty, setToAssignedQty] = useState<number | string>(1);
|
||||
const [toAvailableQty, setToAvailableQty] = useState<number | string>(1);
|
||||
const [transferQty, setTransferQty] = useState(0);
|
||||
const [freeAfterMove, setFreeAfterMove] = useState(false);
|
||||
const [toAssignedQty, setToAssignedQty] = useState(0);
|
||||
const [toAvailableQty, setToAvailableQty] = useState(0);
|
||||
|
||||
const transfer = useMutation(api.wagons.bulkTransfer.mutationOptions());
|
||||
const setStatus = useMutation(api.wagons.bulkSetStatus.mutationOptions());
|
||||
|
||||
const yardLabel = useMemo(() => {
|
||||
const yardName = useMemo(() => {
|
||||
const byId = new Map(yards.map((y) => [y.id, y.label || y.code || y.id]));
|
||||
return (id: string) => byId.get(id) ?? id;
|
||||
}, [yards]);
|
||||
|
||||
const typeLabel = useMemo(() => {
|
||||
const byId = new Map(
|
||||
wagonTypes.map((t) => [t.id, `${t.code}${t.name ? ` - ${t.name}` : ""}`]),
|
||||
);
|
||||
return (id: string) => byId.get(id) ?? id;
|
||||
const typeInfo = useMemo(() => {
|
||||
const byId = new Map(wagonTypes.map((t) => [t.id, t]));
|
||||
return {
|
||||
label: (id: string) => {
|
||||
const t = byId.get(id);
|
||||
return t ? `${t.code}${t.name ? ` - ${t.name}` : ""}` : id;
|
||||
},
|
||||
code: (id: string) => byId.get(id)?.code ?? id,
|
||||
};
|
||||
}, [wagonTypes]);
|
||||
|
||||
// Only wagons that currently sit in a yard participate in the workspace.
|
||||
const yardWagons = useMemo(
|
||||
() => wagons.filter((w): w is Wagon & { currentYardId: string } => Boolean(w.currentYardId)),
|
||||
[wagons],
|
||||
);
|
||||
|
||||
// Each select is constrained by the other's current value so only real
|
||||
// (yard, type) combinations that hold stock can be picked.
|
||||
const yardOptions = useMemo(() => {
|
||||
const ids = new Set<string>();
|
||||
for (const w of yardWagons) {
|
||||
@@ -88,9 +161,9 @@ const WagonYardWorkspaceModal = ({ opened, onClose }: WagonYardWorkspaceModalPro
|
||||
ids.add(w.currentYardId);
|
||||
}
|
||||
return [...ids]
|
||||
.map((id) => ({ value: id, label: yardLabel(id) }))
|
||||
.map((id) => ({ value: id, label: yardName(id) }))
|
||||
.sort((a, b) => a.label.localeCompare(b.label));
|
||||
}, [yardWagons, typeId, yardLabel]);
|
||||
}, [yardWagons, typeId, yardName]);
|
||||
|
||||
const typeOptions = useMemo(() => {
|
||||
const ids = new Set<string>();
|
||||
@@ -99,36 +172,32 @@ const WagonYardWorkspaceModal = ({ opened, onClose }: WagonYardWorkspaceModalPro
|
||||
ids.add(w.wagonTypeId);
|
||||
}
|
||||
return [...ids]
|
||||
.map((id) => ({ value: id, label: typeLabel(id) }))
|
||||
.map((id) => ({ value: id, label: typeInfo.label(id) }))
|
||||
.sort((a, b) => a.label.localeCompare(b.label));
|
||||
}, [yardWagons, yardId, typeLabel]);
|
||||
}, [yardWagons, yardId, typeInfo]);
|
||||
|
||||
const matching = useMemo(() => {
|
||||
if (!yardId || !typeId) return [] as Wagon[];
|
||||
return yardWagons.filter((w) => w.currentYardId === yardId && w.wagonTypeId === typeId);
|
||||
}, [yardWagons, yardId, typeId]);
|
||||
|
||||
const availableWagons = useMemo(
|
||||
() => matching.filter((w) => w.status === Freight.WagonStatus.Available),
|
||||
const availableWagons = useMemo(() => matching.filter((w) => w.status === AVAILABLE), [matching]);
|
||||
const assignedWagons = useMemo(() => matching.filter((w) => w.status === ASSIGNED), [matching]);
|
||||
const otherWagons = useMemo(
|
||||
() => matching.filter((w) => w.status !== AVAILABLE && w.status !== ASSIGNED),
|
||||
[matching],
|
||||
);
|
||||
const assignedWagons = useMemo(
|
||||
() => matching.filter((w) => w.status === Freight.WagonStatus.Assigned),
|
||||
[matching],
|
||||
// Available first, then assigned, then the rest — a partial move relocates
|
||||
// idle wagons before touching assigned ones.
|
||||
const transferPool = useMemo(
|
||||
() => [...availableWagons, ...assignedWagons, ...otherWagons],
|
||||
[availableWagons, assignedWagons, otherWagons],
|
||||
);
|
||||
// Available first so a partial transfer moves idle wagons before assigned ones.
|
||||
const transferPool = useMemo(() => {
|
||||
const rest = matching.filter(
|
||||
(w) =>
|
||||
w.status !== Freight.WagonStatus.Available &&
|
||||
w.status !== Freight.WagonStatus.Assigned,
|
||||
);
|
||||
return [...availableWagons, ...assignedWagons, ...rest];
|
||||
}, [matching, availableWagons, assignedWagons]);
|
||||
|
||||
const total = matching.length;
|
||||
const availableCount = availableWagons.length;
|
||||
const assignedCount = assignedWagons.length;
|
||||
const otherCount = otherWagons.length;
|
||||
|
||||
const destinationYardOptions = useMemo(
|
||||
() =>
|
||||
@@ -141,15 +210,16 @@ const WagonYardWorkspaceModal = ({ opened, onClose }: WagonYardWorkspaceModalPro
|
||||
|
||||
const bothSelected = Boolean(yardId && typeId);
|
||||
|
||||
// Reset the action inputs whenever the yard/type selection changes.
|
||||
// Reset action inputs when the selection changes.
|
||||
useEffect(() => {
|
||||
setTransferYardId(null);
|
||||
setTransferQty(1);
|
||||
setToAssignedQty(1);
|
||||
setToAvailableQty(1);
|
||||
setTransferQty(0);
|
||||
setFreeAfterMove(false);
|
||||
setToAssignedQty(0);
|
||||
setToAvailableQty(0);
|
||||
}, [yardId, typeId]);
|
||||
|
||||
// Reset the whole workspace when it is reopened.
|
||||
// Reset the whole workspace when closed.
|
||||
useEffect(() => {
|
||||
if (!opened) {
|
||||
setYardId(null);
|
||||
@@ -157,6 +227,11 @@ const WagonYardWorkspaceModal = ({ opened, onClose }: WagonYardWorkspaceModalPro
|
||||
}
|
||||
}, [opened]);
|
||||
|
||||
// Keep quantities within bounds as counts shift after each action.
|
||||
useEffect(() => setTransferQty((q) => Math.min(q, total)), [total]);
|
||||
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;
|
||||
@@ -164,15 +239,22 @@ const WagonYardWorkspaceModal = ({ opened, onClose }: WagonYardWorkspaceModalPro
|
||||
};
|
||||
|
||||
const handleTransfer = async () => {
|
||||
const n = numberOrZero(transferQty);
|
||||
if (!transferYardId || n < 1) return;
|
||||
const ids = transferPool.slice(0, n).map((w) => w.id);
|
||||
if (!transferYardId || transferQty < 1) return;
|
||||
const ids = transferPool.slice(0, transferQty).map((w) => w.id);
|
||||
if (!ids.length) return;
|
||||
try {
|
||||
const res = await transfer.mutateAsync({ wagonIds: ids, toYardId: transferYardId });
|
||||
toast({ title: `Transferred ${res.moved} wagon(s) to ${yardLabel(transferYardId)}` });
|
||||
setTransferQty(1);
|
||||
if (freeAfterMove) {
|
||||
await setStatus.mutateAsync({ wagonIds: ids, status: AVAILABLE });
|
||||
}
|
||||
toast({
|
||||
title: `Moved ${res.moved} wagon(s) to ${yardName(transferYardId)}${
|
||||
freeAfterMove ? " · set Available" : ""
|
||||
}`,
|
||||
});
|
||||
setTransferQty(0);
|
||||
setTransferYardId(null);
|
||||
setFreeAfterMove(false);
|
||||
} catch (err) {
|
||||
showError(err, "Transfer failed");
|
||||
}
|
||||
@@ -180,14 +262,13 @@ const WagonYardWorkspaceModal = ({ opened, onClose }: WagonYardWorkspaceModalPro
|
||||
|
||||
const handleFlip = async (
|
||||
pool: Wagon[],
|
||||
qty: number | string,
|
||||
qty: number,
|
||||
status: Freight.WagonStatus,
|
||||
label: string,
|
||||
reset: () => void,
|
||||
) => {
|
||||
const n = numberOrZero(qty);
|
||||
if (n < 1) return;
|
||||
const ids = pool.slice(0, n).map((w) => w.id);
|
||||
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 });
|
||||
@@ -199,100 +280,141 @@ const WagonYardWorkspaceModal = ({ opened, onClose }: WagonYardWorkspaceModalPro
|
||||
};
|
||||
|
||||
const busy = transfer.isPending || setStatus.isPending;
|
||||
const pct = (n: number) => (total > 0 ? (n / total) * 100 : 0);
|
||||
|
||||
return (
|
||||
<Modal
|
||||
opened={opened}
|
||||
onClose={onClose}
|
||||
size="min(1040px, 96vw)"
|
||||
size="min(1080px, 96vw)"
|
||||
radius="lg"
|
||||
centered
|
||||
overlayProps={{ blur: 2 }}
|
||||
title={
|
||||
<Group gap="sm">
|
||||
<ThemeIcon variant="light" color="edr-green" radius="md" size="lg">
|
||||
<Warehouse size={18} />
|
||||
</ThemeIcon>
|
||||
<div>
|
||||
<Text fw={600}>Wagon Yard Workspace</Text>
|
||||
<Text fw={700}>Wagon Yard Operations</Text>
|
||||
<Text size="xs" c="dimmed">
|
||||
Move and re-status wagons by yard and type
|
||||
Move and re-status wagons in bulk — no one-by-one edits
|
||||
</Text>
|
||||
</div>
|
||||
</Group>
|
||||
}
|
||||
>
|
||||
<Stack gap="lg">
|
||||
{/* ---- Selectors ---- */}
|
||||
<Grid gutter="md">
|
||||
<Grid.Col span={{ base: 12, sm: 6 }}>
|
||||
<Select
|
||||
label="Yard"
|
||||
placeholder="Select a yard"
|
||||
data={yardOptions}
|
||||
value={yardId}
|
||||
onChange={setYardId}
|
||||
searchable
|
||||
clearable
|
||||
nothingFoundMessage="No yards with stock"
|
||||
radius="md"
|
||||
/>
|
||||
</Grid.Col>
|
||||
<Grid.Col span={{ base: 12, sm: 6 }}>
|
||||
<Select
|
||||
label="Wagon type"
|
||||
placeholder="Select a wagon type"
|
||||
data={typeOptions}
|
||||
value={typeId}
|
||||
onChange={setTypeId}
|
||||
searchable
|
||||
clearable
|
||||
nothingFoundMessage="No wagon types here"
|
||||
radius="md"
|
||||
/>
|
||||
</Grid.Col>
|
||||
</Grid>
|
||||
{/* ---- Selection ---- */}
|
||||
<Card withBorder radius="md" padding="md" bg="var(--mantine-color-gray-0)">
|
||||
<Grid gap="md" align="flex-end">
|
||||
<Grid.Col span={{ base: 12, sm: 6 }}>
|
||||
<Select
|
||||
label="Yard"
|
||||
placeholder="Select a yard"
|
||||
data={yardOptions}
|
||||
value={yardId}
|
||||
onChange={setYardId}
|
||||
searchable
|
||||
clearable
|
||||
leftSection={<Warehouse size={16} />}
|
||||
nothingFoundMessage="No yards with stock"
|
||||
radius="md"
|
||||
/>
|
||||
</Grid.Col>
|
||||
<Grid.Col span={{ base: 12, sm: 6 }}>
|
||||
<Select
|
||||
label="Wagon type"
|
||||
placeholder="Select a wagon type"
|
||||
data={typeOptions}
|
||||
value={typeId}
|
||||
onChange={setTypeId}
|
||||
searchable
|
||||
clearable
|
||||
leftSection={<Layers size={16} />}
|
||||
nothingFoundMessage="No wagon types here"
|
||||
radius="md"
|
||||
/>
|
||||
</Grid.Col>
|
||||
</Grid>
|
||||
</Card>
|
||||
|
||||
{wagonsLoading ? (
|
||||
{isLoading ? (
|
||||
<Group justify="center" p="xl">
|
||||
<Loader />
|
||||
</Group>
|
||||
) : !bothSelected ? (
|
||||
<Card withBorder radius="md" bg="var(--mantine-color-gray-0)">
|
||||
<Text size="sm" c="dimmed" ta="center" py="lg">
|
||||
Select a yard and a wagon type to see how many wagons are there and act on them.
|
||||
</Text>
|
||||
<Card withBorder radius="md" padding="xl">
|
||||
<Stack align="center" gap={6}>
|
||||
<ThemeIcon variant="light" color="gray" radius="xl" size={48}>
|
||||
<Layers size={22} />
|
||||
</ThemeIcon>
|
||||
<Text fw={600}>Pick a yard and a wagon type</Text>
|
||||
<Text size="sm" c="dimmed" ta="center" maw={420}>
|
||||
You'll see how many wagons of that type sit in that yard, how many are available
|
||||
vs assigned, and can move or re-status them all at once.
|
||||
</Text>
|
||||
</Stack>
|
||||
</Card>
|
||||
) : (
|
||||
<>
|
||||
{/* ---- Counts ---- */}
|
||||
<SimpleGrid cols={{ base: 3 }} spacing="md">
|
||||
<StatCard label="In yard" value={total} color="gray" />
|
||||
<StatCard label="Available" value={availableCount} color="teal" />
|
||||
<StatCard label="Assigned" value={assignedCount} color="blue" />
|
||||
</SimpleGrid>
|
||||
{/* ---- Overview hero ---- */}
|
||||
<Card withBorder radius="md" padding="lg">
|
||||
<Group justify="space-between" align="flex-start" wrap="wrap" gap="md">
|
||||
<Group gap="lg" align="center" wrap="nowrap">
|
||||
<div>
|
||||
<Text size="3rem" fw={800} lh={1}>
|
||||
{total}
|
||||
</Text>
|
||||
</div>
|
||||
<div>
|
||||
<Text fw={700} size="lg">
|
||||
{typeInfo.code(typeId!)} wagons
|
||||
</Text>
|
||||
<Group gap={6} c="dimmed">
|
||||
<Warehouse size={14} />
|
||||
<Text size="sm">{yardName(yardId!)}</Text>
|
||||
</Group>
|
||||
</div>
|
||||
</Group>
|
||||
<Group gap="lg" wrap="wrap">
|
||||
<LegendDot color="teal" label="Available" value={availableCount} />
|
||||
<LegendDot color="blue" label="Assigned" value={assignedCount} />
|
||||
{otherCount > 0 ? <LegendDot color="gray" label="Other" value={otherCount} /> : null}
|
||||
</Group>
|
||||
</Group>
|
||||
|
||||
<Divider />
|
||||
<Progress.Root size={22} radius="md" mt="md">
|
||||
<Progress.Section value={pct(availableCount)} color="teal">
|
||||
{availableCount > 0 ? <Progress.Label>{availableCount}</Progress.Label> : null}
|
||||
</Progress.Section>
|
||||
<Progress.Section value={pct(assignedCount)} color="blue">
|
||||
{assignedCount > 0 ? <Progress.Label>{assignedCount}</Progress.Label> : null}
|
||||
</Progress.Section>
|
||||
<Progress.Section value={pct(otherCount)} color="gray">
|
||||
{otherCount > 0 ? <Progress.Label>{otherCount}</Progress.Label> : null}
|
||||
</Progress.Section>
|
||||
</Progress.Root>
|
||||
</Card>
|
||||
|
||||
<Grid gutter="lg">
|
||||
{/* ---- Transfer ---- */}
|
||||
{/* ---- Actions ---- */}
|
||||
<Grid gap="lg">
|
||||
{/* Transfer */}
|
||||
<Grid.Col span={{ base: 12, md: 6 }}>
|
||||
<Card withBorder radius="md" h="100%">
|
||||
<Group gap="xs" mb="sm">
|
||||
<Card withBorder radius="md" h="100%" padding="lg">
|
||||
<Group gap="xs" mb="md">
|
||||
<ThemeIcon variant="light" color="grape" radius="md" size="md">
|
||||
<ArrowRightLeft size={16} />
|
||||
</ThemeIcon>
|
||||
<Title order={5}>Transfer to another yard</Title>
|
||||
<Text fw={700}>Move to another yard</Text>
|
||||
</Group>
|
||||
<Stack gap="sm">
|
||||
<NumberInput
|
||||
label="How many wagons"
|
||||
min={1}
|
||||
max={total}
|
||||
value={transferQty}
|
||||
onChange={setTransferQty}
|
||||
disabled={total === 0}
|
||||
radius="md"
|
||||
/>
|
||||
<Stack gap="md">
|
||||
<div>
|
||||
<Text size="sm" fw={500} mb={4}>
|
||||
How many wagons
|
||||
</Text>
|
||||
<QuantityField value={transferQty} onChange={setTransferQty} max={total} />
|
||||
</div>
|
||||
<Select
|
||||
label="Destination yard"
|
||||
placeholder="Select destination"
|
||||
@@ -302,111 +424,130 @@ const WagonYardWorkspaceModal = ({ opened, onClose }: WagonYardWorkspaceModalPro
|
||||
searchable
|
||||
radius="md"
|
||||
/>
|
||||
<Switch
|
||||
checked={freeAfterMove}
|
||||
onChange={(e) => setFreeAfterMove(e.currentTarget.checked)}
|
||||
label="Set moved wagons to Available"
|
||||
color="teal"
|
||||
/>
|
||||
{transferYardId && transferQty > 0 ? (
|
||||
<Card bg="var(--mantine-color-gray-0)" radius="md" padding="sm" withBorder>
|
||||
<Group gap={8} wrap="nowrap">
|
||||
<Text size="sm" fw={600}>
|
||||
{yardName(yardId!)} {total}
|
||||
<Text span c="red.6" fw={700}>
|
||||
{" "}
|
||||
−{transferQty}
|
||||
</Text>
|
||||
</Text>
|
||||
<ArrowRight size={16} />
|
||||
<Text size="sm" fw={600}>
|
||||
{yardName(transferYardId)}
|
||||
<Text span c="teal.7" fw={700}>
|
||||
{" "}
|
||||
+{transferQty}
|
||||
</Text>
|
||||
</Text>
|
||||
</Group>
|
||||
</Card>
|
||||
) : null}
|
||||
<Button
|
||||
leftSection={<ArrowRightLeft size={16} />}
|
||||
onClick={handleTransfer}
|
||||
loading={transfer.isPending}
|
||||
disabled={busy || !transferYardId || numberOrZero(transferQty) < 1 || total === 0}
|
||||
disabled={busy || !transferYardId || transferQty < 1}
|
||||
color="edr-green"
|
||||
>
|
||||
Transfer
|
||||
Move {transferQty > 0 ? `${transferQty} ` : ""}wagon{transferQty === 1 ? "" : "s"}
|
||||
</Button>
|
||||
<Text size="xs" c="dimmed">
|
||||
Available wagons move first. Up to {total} can be transferred.
|
||||
</Text>
|
||||
</Stack>
|
||||
</Card>
|
||||
</Grid.Col>
|
||||
|
||||
{/* ---- Re-status ---- */}
|
||||
{/* Re-status */}
|
||||
<Grid.Col span={{ base: 12, md: 6 }}>
|
||||
<Card withBorder radius="md" h="100%">
|
||||
<Group gap="xs" mb="sm">
|
||||
<Card withBorder radius="md" h="100%" padding="lg">
|
||||
<Group gap="xs" mb="md">
|
||||
<ThemeIcon variant="light" color="orange" radius="md" size="md">
|
||||
<Repeat size={16} />
|
||||
<ArrowRightLeft size={16} />
|
||||
</ThemeIcon>
|
||||
<Title order={5}>Change status</Title>
|
||||
<Text fw={700}>Change availability</Text>
|
||||
</Group>
|
||||
<Stack gap="md">
|
||||
<Stack gap="lg">
|
||||
<Box>
|
||||
<Group justify="space-between" mb={4}>
|
||||
<Text size="sm" fw={500}>
|
||||
Available → Assigned
|
||||
</Text>
|
||||
<Group justify="space-between" mb={6}>
|
||||
<Group gap={6}>
|
||||
<ThemeIcon variant="light" color="blue" radius="sm" size="sm">
|
||||
<CircleSlash size={12} />
|
||||
</ThemeIcon>
|
||||
<Text size="sm" fw={600}>
|
||||
Available → Assigned
|
||||
</Text>
|
||||
</Group>
|
||||
<Badge color="teal" variant="light">
|
||||
{availableCount} available
|
||||
{availableCount} free
|
||||
</Badge>
|
||||
</Group>
|
||||
<Group align="flex-end" gap="sm">
|
||||
<NumberInput
|
||||
min={1}
|
||||
max={availableCount}
|
||||
value={toAssignedQty}
|
||||
onChange={setToAssignedQty}
|
||||
disabled={availableCount === 0}
|
||||
radius="md"
|
||||
style={{ flex: 1 }}
|
||||
/>
|
||||
<Button
|
||||
variant="light"
|
||||
color="blue"
|
||||
leftSection={<PackageCheck size={16} />}
|
||||
loading={setStatus.isPending}
|
||||
disabled={busy || availableCount === 0 || numberOrZero(toAssignedQty) < 1}
|
||||
onClick={() =>
|
||||
handleFlip(
|
||||
availableWagons,
|
||||
toAssignedQty,
|
||||
Freight.WagonStatus.Assigned,
|
||||
"Assigned",
|
||||
() => setToAssignedQty(1),
|
||||
)
|
||||
}
|
||||
>
|
||||
Assign
|
||||
</Button>
|
||||
</Group>
|
||||
<QuantityField
|
||||
value={toAssignedQty}
|
||||
onChange={setToAssignedQty}
|
||||
max={availableCount}
|
||||
/>
|
||||
<Button
|
||||
mt="sm"
|
||||
fullWidth
|
||||
variant="light"
|
||||
color="blue"
|
||||
disabled={busy || toAssignedQty < 1}
|
||||
loading={setStatus.isPending}
|
||||
onClick={() =>
|
||||
handleFlip(availableWagons, toAssignedQty, ASSIGNED, "Assigned", () =>
|
||||
setToAssignedQty(0),
|
||||
)
|
||||
}
|
||||
>
|
||||
Assign {toAssignedQty > 0 ? `${toAssignedQty} ` : ""}wagon
|
||||
{toAssignedQty === 1 ? "" : "s"}
|
||||
</Button>
|
||||
</Box>
|
||||
|
||||
<Divider variant="dashed" />
|
||||
|
||||
<Box>
|
||||
<Group justify="space-between" mb={4}>
|
||||
<Text size="sm" fw={500}>
|
||||
Assigned → Available
|
||||
</Text>
|
||||
<Group justify="space-between" mb={6}>
|
||||
<Group gap={6}>
|
||||
<ThemeIcon variant="light" color="teal" radius="sm" size="sm">
|
||||
<CheckCircle2 size={12} />
|
||||
</ThemeIcon>
|
||||
<Text size="sm" fw={600}>
|
||||
Assigned → Available
|
||||
</Text>
|
||||
</Group>
|
||||
<Badge color="blue" variant="light">
|
||||
{assignedCount} assigned
|
||||
</Badge>
|
||||
</Group>
|
||||
<Group align="flex-end" gap="sm">
|
||||
<NumberInput
|
||||
min={1}
|
||||
max={assignedCount}
|
||||
value={toAvailableQty}
|
||||
onChange={setToAvailableQty}
|
||||
disabled={assignedCount === 0}
|
||||
radius="md"
|
||||
style={{ flex: 1 }}
|
||||
/>
|
||||
<Button
|
||||
variant="light"
|
||||
color="teal"
|
||||
leftSection={<PackageCheck size={16} />}
|
||||
loading={setStatus.isPending}
|
||||
disabled={busy || assignedCount === 0 || numberOrZero(toAvailableQty) < 1}
|
||||
onClick={() =>
|
||||
handleFlip(
|
||||
assignedWagons,
|
||||
toAvailableQty,
|
||||
Freight.WagonStatus.Available,
|
||||
"Available",
|
||||
() => setToAvailableQty(1),
|
||||
)
|
||||
}
|
||||
>
|
||||
Free up
|
||||
</Button>
|
||||
</Group>
|
||||
<QuantityField
|
||||
value={toAvailableQty}
|
||||
onChange={setToAvailableQty}
|
||||
max={assignedCount}
|
||||
/>
|
||||
<Button
|
||||
mt="sm"
|
||||
fullWidth
|
||||
variant="light"
|
||||
color="teal"
|
||||
disabled={busy || toAvailableQty < 1}
|
||||
loading={setStatus.isPending}
|
||||
onClick={() =>
|
||||
handleFlip(assignedWagons, toAvailableQty, AVAILABLE, "Available", () =>
|
||||
setToAvailableQty(0),
|
||||
)
|
||||
}
|
||||
>
|
||||
Free up {toAvailableQty > 0 ? `${toAvailableQty} ` : ""}wagon
|
||||
{toAvailableQty === 1 ? "" : "s"}
|
||||
</Button>
|
||||
</Box>
|
||||
</Stack>
|
||||
</Card>
|
||||
@@ -419,23 +560,4 @@ const WagonYardWorkspaceModal = ({ opened, onClose }: WagonYardWorkspaceModalPro
|
||||
);
|
||||
};
|
||||
|
||||
const StatCard = ({
|
||||
label,
|
||||
value,
|
||||
color,
|
||||
}: {
|
||||
label: string;
|
||||
value: number;
|
||||
color: string;
|
||||
}) => (
|
||||
<Card withBorder radius="md" padding="md">
|
||||
<Text size="xs" c="dimmed" tt="uppercase" fw={600}>
|
||||
{label}
|
||||
</Text>
|
||||
<Text size="1.75rem" fw={700} c={`${color}.7`} lh={1.1} mt={4}>
|
||||
{value}
|
||||
</Text>
|
||||
</Card>
|
||||
);
|
||||
|
||||
export default WagonYardWorkspaceModal;
|
||||
|
||||
@@ -0,0 +1,89 @@
|
||||
import { Badge, Group, Text, Tooltip } from "@mantine/core";
|
||||
import { Boxes, Container, Weight } from "lucide-react";
|
||||
import type { Freight } from "@edr/types";
|
||||
|
||||
/**
|
||||
* Compact human summary of a shipment request's requested cargo lines — the
|
||||
* quantities the customer asked for, before GL enters the real booking cargo.
|
||||
* Container contracts read "2 × 20ft, 1 × 40ft"; bulk reads "500 t" or
|
||||
* "300 items" depending on the contract's cargo configuration.
|
||||
*/
|
||||
export function summarizeRequestedCargo(
|
||||
lines?: Freight.RequestedShipmentLines | null,
|
||||
): string {
|
||||
if (!lines) return "—";
|
||||
const containers = (lines.containers ?? []).filter((c) => (c.quantity ?? 0) > 0);
|
||||
if (containers.length) {
|
||||
return containers.map((c) => `${c.quantity} × ${c.containerSize}`).join(", ");
|
||||
}
|
||||
if (lines.bulk) {
|
||||
if (lines.bulk.cargoWeightTons) return `${lines.bulk.cargoWeightTons} t`;
|
||||
if (lines.bulk.itemCount) return `${lines.bulk.itemCount} items`;
|
||||
}
|
||||
return "—";
|
||||
}
|
||||
|
||||
/** Renders the requested cargo as small badges (per container type, or bulk). */
|
||||
export function RequestedCargoChips({
|
||||
lines,
|
||||
size = "sm",
|
||||
}: {
|
||||
lines?: Freight.RequestedShipmentLines | null;
|
||||
size?: "xs" | "sm";
|
||||
}) {
|
||||
const containers = (lines?.containers ?? []).filter((c) => (c.quantity ?? 0) > 0);
|
||||
|
||||
if (containers.length) {
|
||||
return (
|
||||
<Group gap={6} wrap="wrap">
|
||||
{containers.map((c, i) => {
|
||||
const flags: string[] = [];
|
||||
if ((c.hazardousQuantity ?? 0) > 0)
|
||||
flags.push(`${c.hazardousQuantity} hazardous`);
|
||||
if ((c.reeferQuantity ?? 0) > 0)
|
||||
flags.push(`${c.reeferQuantity} reefer`);
|
||||
const chip = (
|
||||
<Badge
|
||||
size={size}
|
||||
variant="light"
|
||||
color="edr-green"
|
||||
radius="sm"
|
||||
leftSection={<Container size={12} />}
|
||||
>
|
||||
{c.quantity} × {c.containerSize}
|
||||
</Badge>
|
||||
);
|
||||
return flags.length ? (
|
||||
<Tooltip key={i} label={flags.join(" · ")} withArrow>
|
||||
{chip}
|
||||
</Tooltip>
|
||||
) : (
|
||||
<span key={i}>{chip}</span>
|
||||
);
|
||||
})}
|
||||
</Group>
|
||||
);
|
||||
}
|
||||
|
||||
if (lines?.bulk && (lines.bulk.cargoWeightTons || lines.bulk.itemCount)) {
|
||||
const isWeight = Boolean(lines.bulk.cargoWeightTons);
|
||||
const value = lines.bulk.cargoWeightTons ?? lines.bulk.itemCount ?? 0;
|
||||
return (
|
||||
<Badge
|
||||
size={size}
|
||||
variant="light"
|
||||
color="edr-green"
|
||||
radius="sm"
|
||||
leftSection={isWeight ? <Weight size={12} /> : <Boxes size={12} />}
|
||||
>
|
||||
{value} {isWeight ? "t" : "items"}
|
||||
</Badge>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Text size="xs" c="dimmed">
|
||||
—
|
||||
</Text>
|
||||
);
|
||||
}
|
||||
@@ -38,10 +38,12 @@ import { ClearanceMilestoneTimeline } from "@/components/contracts/ClearanceMile
|
||||
import { useFileViewer } from "@/hooks/useFileViewer";
|
||||
import { useBookingMilestones } from "@/hooks/contracts/useContracts";
|
||||
import { bookingsService } from "@/services/bookings.service";
|
||||
import { contractsService } from "@/services/contracts.service";
|
||||
import { downloadBookingFile } from "@/services/files.service";
|
||||
import { useBookingDetail } from "@/hooks/bookings/useBookings";
|
||||
import { useAuth } from "@/auth/useAuth";
|
||||
import { FREIGHT_PERMS, hasPermission } from "@/lib/permissions";
|
||||
import { RequestedCargoChips } from "@/features/clearance/requestedCargo";
|
||||
|
||||
export default function DocumentClearanceDetailPage() {
|
||||
const params = useParams<{ id?: string; bookingId?: string }>();
|
||||
@@ -64,6 +66,21 @@ export default function DocumentClearanceDetailPage() {
|
||||
|
||||
const { data: bookingMilestones } = useBookingMilestones(id);
|
||||
|
||||
// The originating shipment request carries the quantities the customer asked
|
||||
// for (per container type, or bulk weight/items). The bare instance itself has
|
||||
// no cargo until GL completes the booking, so surface the request here.
|
||||
const { data: contractRequests } = useQuery({
|
||||
queryKey: ["shipment-requests-for-contract", booking?.contractId],
|
||||
queryFn: () => contractsService.listBookingRequests(booking!.contractId!),
|
||||
enabled: Boolean(booking?.contractId),
|
||||
});
|
||||
const requestedLines = useMemo(
|
||||
() =>
|
||||
(contractRequests ?? []).find((r) => r.createdBookingId === id)
|
||||
?.requestedLines ?? null,
|
||||
[contractRequests, id],
|
||||
);
|
||||
|
||||
const stats = useMemo(() => {
|
||||
const docs = (clearance?.documents ?? []).filter(
|
||||
(d) => d.uploadedBy === "customer",
|
||||
@@ -179,7 +196,12 @@ export default function DocumentClearanceDetailPage() {
|
||||
}
|
||||
/>
|
||||
|
||||
<ClearanceHero booking={booking} clearance={clearance} stats={stats} />
|
||||
<ClearanceHero
|
||||
booking={booking}
|
||||
clearance={clearance}
|
||||
stats={stats}
|
||||
requestedLines={requestedLines}
|
||||
/>
|
||||
|
||||
{isPhasedGeneral ? (
|
||||
<Paper withBorder radius="md" p="lg">
|
||||
@@ -291,10 +313,12 @@ function ClearanceHero({
|
||||
booking,
|
||||
clearance,
|
||||
stats,
|
||||
requestedLines,
|
||||
}: {
|
||||
booking: ReturnType<typeof useBookingDetail>["data"];
|
||||
clearance: Freight.ClearanceView;
|
||||
stats: { pct: number; approved: number; total: number };
|
||||
requestedLines?: Freight.RequestedShipmentLines | null;
|
||||
}) {
|
||||
const direction = booking?.tradeDirection ?? "—";
|
||||
const origin =
|
||||
@@ -360,6 +384,18 @@ function ClearanceHero({
|
||||
<Progress value={stats.pct} color="edr-green" radius="xl" size="md" />
|
||||
</Box>
|
||||
</Group>
|
||||
|
||||
{requestedLines ? (
|
||||
<>
|
||||
<Box my="md" h={1} bg="var(--mantine-color-default-border)" />
|
||||
<Group gap={10} align="center" wrap="wrap">
|
||||
<Text size="xs" fw={700} tt="uppercase" c="dimmed" lts="0.05em">
|
||||
Requested cargo
|
||||
</Text>
|
||||
<RequestedCargoChips lines={requestedLines} size="sm" />
|
||||
</Group>
|
||||
</>
|
||||
) : null}
|
||||
</Paper>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -54,6 +54,12 @@ import { useBookingEtClearanceQueue } from "@/hooks/bookings/useBookings";
|
||||
import type { BookingDetail } from "@/types/booking";
|
||||
import { FREIGHT_PERMS, hasPermission } from "@/lib/permissions";
|
||||
import { GlUpcomingWindowsSection } from "@/components/contracts/GlUpcomingWindowsSection";
|
||||
import {
|
||||
RequestedCargoChips,
|
||||
summarizeRequestedCargo,
|
||||
} from "@/features/clearance/requestedCargo";
|
||||
import { contractsService } from "@/services/contracts.service";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
|
||||
type ViewMode = "table" | "cards";
|
||||
type QueueTab = "all" | "et" | "shipments";
|
||||
@@ -273,6 +279,22 @@ export default function ContractClearanceListPage() {
|
||||
return opts;
|
||||
}, [canReview, canEt]);
|
||||
|
||||
// Shipment requests carry the requested quantities (per container type, or
|
||||
// bulk weight/items). Map them onto the booking rows by createdBookingId so
|
||||
// the queue shows what each shipment was requested for.
|
||||
const { data: requestQueue } = useQuery({
|
||||
queryKey: ["shipment-request-queue"],
|
||||
queryFn: () => contractsService.getBookingRequestQueue(),
|
||||
enabled: queueTab === "shipments",
|
||||
});
|
||||
const requestedByBooking = useMemo(() => {
|
||||
const map = new Map<string, Freight.RequestedShipmentLines>();
|
||||
for (const req of requestQueue ?? []) {
|
||||
if (req.createdBookingId) map.set(req.createdBookingId, req.requestedLines);
|
||||
}
|
||||
return map;
|
||||
}, [requestQueue]);
|
||||
|
||||
// GENERAL-contract shipment bookings in per-booking clearance (ET queue).
|
||||
const bookingRows = useMemo(() => {
|
||||
const rows = (bookingQueue ?? []).map((b: BookingDetail) => ({
|
||||
@@ -284,6 +306,7 @@ export default function ContractClearanceListPage() {
|
||||
tradeDirection: b.tradeDirection ?? "—",
|
||||
freightType: b.freightType ?? "—",
|
||||
status: b.status,
|
||||
requested: requestedByBooking.get(b.id) ?? null,
|
||||
}));
|
||||
const q = query.trim().toLowerCase();
|
||||
if (!q) return rows;
|
||||
@@ -292,9 +315,10 @@ export default function ContractClearanceListPage() {
|
||||
r.reference.toLowerCase().includes(q) ||
|
||||
r.customerLabel.toLowerCase().includes(q) ||
|
||||
r.originLabel.toLowerCase().includes(q) ||
|
||||
r.destinationLabel.toLowerCase().includes(q),
|
||||
r.destinationLabel.toLowerCase().includes(q) ||
|
||||
summarizeRequestedCargo(r.requested).toLowerCase().includes(q),
|
||||
);
|
||||
}, [bookingQueue, query]);
|
||||
}, [bookingQueue, query, requestedByBooking]);
|
||||
|
||||
const allRows = useMemo(
|
||||
() => (data?.items ?? []).map(toClearanceRow),
|
||||
@@ -650,6 +674,8 @@ interface ShipmentBookingRow {
|
||||
tradeDirection: string;
|
||||
freightType: string;
|
||||
status: string;
|
||||
/** Requested quantities from the originating shipment request. */
|
||||
requested: Freight.RequestedShipmentLines | null;
|
||||
}
|
||||
|
||||
const prettyStatus = (s: string) =>
|
||||
@@ -728,6 +754,15 @@ function ShipmentBookingsTable({
|
||||
</Group>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: "requested",
|
||||
header: () => (
|
||||
<span className={bookingTable.headerCell}>Requested cargo</span>
|
||||
),
|
||||
cell: ({ row }) => (
|
||||
<RequestedCargoChips lines={row.original.requested} size="sm" />
|
||||
),
|
||||
},
|
||||
{
|
||||
id: "status",
|
||||
header: () => <span className={bookingTable.headerCell}>Status</span>,
|
||||
|
||||
Reference in New Issue
Block a user