Merge pull request #616 from Tria-plc/freight_feature/usermanagement

Freight feature/usermanagement
This commit is contained in:
marshal
2026-07-11 09:42:56 +03:00
committed by GitHub
57 changed files with 2711 additions and 345 deletions

View File

@@ -145,13 +145,37 @@ function bulkUnitOfMeasure(
}
export default function GlCreateBookingForm() {
const { id } = useParams<{ id: string }>();
// With `bookingId` the form runs in COMPLETION mode: the bare instance
// (auto-initiated by the customer's shipment request) already finished its
// per-booking customs clearance, and this form supplies the deferred cargo
// (container numbers, VGM) + binding shipment day. Same window gate, same
// validation and price confirmation — the submit completes the existing
// booking instead of creating a new one.
const { id, bookingId: completeBookingId } = useParams<{
id: string;
bookingId?: string;
}>();
const [searchParams] = useSearchParams();
const requestId = searchParams.get("requestId");
const requestIdParam = searchParams.get("requestId");
const navigate = useNavigate();
const { data: contract, isLoading } = useContractDetail(id);
const mutations = useContractMutations(id ?? "");
// Completion mode without an explicit ?requestId=: find the shipment request
// that initiated this instance so the quantities still prefill.
const { data: contractRequests } = useQuery({
queryKey: ["shipment-requests-for-contract", id],
queryFn: () => contractsService.listBookingRequests(id!),
enabled: Boolean(id) && Boolean(completeBookingId) && !requestIdParam,
});
const requestId =
requestIdParam ??
(completeBookingId
? (contractRequests?.find(
(r) => r.createdBookingId === completeBookingId,
)?.id ?? null)
: null);
const { data: bookingRequest } = useQuery({
queryKey: ["shipment-request", requestId],
queryFn: () => contractsService.getBookingRequest(requestId!),
@@ -174,18 +198,17 @@ export default function GlCreateBookingForm() {
);
// Next future window across all routes, used for the "next window" notice —
// the train dispatching soonest among those not yet open, matching the
// departure-date ordering of the window cards.
// the next moment booking OPENS (chronological), which may belong to a
// later-departing train. Departure-first ordering here named the soonest
// train's later opening as "next" while another lane opened earlier.
const nextWindow = useMemo(() => {
const now = Date.now();
return (bookingWindows ?? [])
.filter((w) => w.windowOpensAt && new Date(w.windowOpensAt).getTime() > now)
.sort((a, b) => {
const da = a.departureDate ? new Date(a.departureDate).getTime() : Infinity;
const db = b.departureDate ? new Date(b.departureDate).getTime() : Infinity;
if (da !== db) return da - db;
return new Date(a.windowOpensAt!).getTime() - new Date(b.windowOpensAt!).getTime();
})[0];
.sort(
(a, b) =>
new Date(a.windowOpensAt!).getTime() - new Date(b.windowOpensAt!).getTime(),
)[0];
}, [bookingWindows]);
const [scheduledDate, setScheduledDate] = useState("");
@@ -636,6 +659,18 @@ export default function GlCreateBookingForm() {
const payload = buildPayload();
if (!payload) return;
if (completeBookingId) {
// Completion mode: cargo + day land on the already-cleared instance —
// the request was linked and accepted at submission time.
mutations.completeBooking.mutate(
{ bookingId: completeBookingId, payload },
{
onSuccess: () => navigate(`/dashboard/clearance/${completeBookingId}`),
},
);
return;
}
mutations.createBooking.mutate(payload, {
onSuccess: async (booking) => {
if (requestId) {
@@ -685,10 +720,12 @@ export default function GlCreateBookingForm() {
<Group justify="space-between" align="flex-end" wrap="wrap" gap="md" mb="lg">
<Box>
<Text fw={800} fz={26} style={{ letterSpacing: "-0.01em" }}>
New Shipment Booking
{completeBookingId ? "Complete Shipment Booking" : "New Shipment Booking"}
</Text>
<Text size="sm" c="dimmed" mt={4}>
Book a shipment on behalf of the customer for contract {contract.reference}.
{completeBookingId
? `Clearance is finalized — enter the cargo details and shipment day to complete the booking under contract ${contract.reference}.`
: `Book a shipment on behalf of the customer for contract ${contract.reference}.`}
</Text>
</Box>
<Button
@@ -1261,7 +1298,11 @@ export default function GlCreateBookingForm() {
<Modal
opened={priceOpen}
onClose={() => {
if (!mutations.createBooking.isPending) setPriceOpen(false);
if (
!mutations.createBooking.isPending &&
!mutations.completeBooking.isPending
)
setPriceOpen(false);
}}
centered
radius="lg"
@@ -1413,7 +1454,10 @@ export default function GlCreateBookingForm() {
radius="md"
leftSection={<X size={16} />}
onClick={() => setPriceOpen(false)}
disabled={mutations.createBooking.isPending}
disabled={
mutations.createBooking.isPending ||
mutations.completeBooking.isPending
}
>
Reject &amp; edit
</Button>
@@ -1421,7 +1465,10 @@ export default function GlCreateBookingForm() {
color="edr-green"
radius="md"
leftSection={<CheckCircle2 size={16} />}
loading={mutations.createBooking.isPending}
loading={
mutations.createBooking.isPending ||
mutations.completeBooking.isPending
}
disabled={
validateShipmentMutation.isPending ||
pairingErrors.length > 0 ||
@@ -1429,7 +1476,7 @@ export default function GlCreateBookingForm() {
}
onClick={handleSubmit}
>
Confirm &amp; book
{completeBookingId ? "Confirm & complete" : "Confirm & book"}
</Button>
</Group>
</Stack>

View File

@@ -0,0 +1,563 @@
import { Freight } from "@edr/types";
import {
Badge,
Box,
Button,
Card,
Divider,
Grid,
Group,
Loader,
Modal,
NumberInput,
Progress,
Select,
Slider,
Stack,
Switch,
Text,
ThemeIcon,
} from "@mantine/core";
import { useMutation, useQuery } from "@tanstack/react-query";
import { ArrowRight, ArrowRightLeft, CheckCircle2, CircleSlash, Layers, Warehouse } from "lucide-react";
import { useEffect, useMemo, useState } from "react";
import { api } from "@/services/api";
import { useToast } from "@/hooks/use-toast";
import type { Wagon } from "@/services/wagon.service";
export interface WagonYardWorkspaceModalProps {
opened: boolean;
onClose: () => void;
}
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);
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>
);
/**
* 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 } = useQuery(api.wagons.list.queryOptions({ input: {} }));
const { data: yards = [] } = useQuery(api.routes.yards.queryOptions());
const { data: wagonTypes = [] } = useQuery(api.wagonTypes.list.queryOptions());
const [yardId, setYardId] = useState<string | null>(null);
const [typeId, setTypeId] = useState<string | null>(null);
const [transferYardId, setTransferYardId] = useState<string | null>(null);
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 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 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]);
const yardWagons = useMemo(
() => wagons.filter((w): w is Wagon & { currentYardId: string } => Boolean(w.currentYardId)),
[wagons],
);
const yardOptions = useMemo(() => {
const ids = new Set<string>();
for (const w of yardWagons) {
if (typeId && w.wagonTypeId !== typeId) continue;
ids.add(w.currentYardId);
}
return [...ids]
.map((id) => ({ value: id, label: yardName(id) }))
.sort((a, b) => a.label.localeCompare(b.label));
}, [yardWagons, typeId, yardName]);
const typeOptions = useMemo(() => {
const ids = new Set<string>();
for (const w of yardWagons) {
if (yardId && w.currentYardId !== yardId) continue;
ids.add(w.wagonTypeId);
}
return [...ids]
.map((id) => ({ value: id, label: typeInfo.label(id) }))
.sort((a, b) => a.label.localeCompare(b.label));
}, [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 === 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],
);
// 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],
);
const total = matching.length;
const availableCount = availableWagons.length;
const assignedCount = assignedWagons.length;
const otherCount = otherWagons.length;
const destinationYardOptions = useMemo(
() =>
yards
.filter((y) => y.id !== yardId)
.map((y) => ({ value: y.id, label: y.label || y.code || y.id }))
.sort((a, b) => a.label.localeCompare(b.label)),
[yards, yardId],
);
const bothSelected = Boolean(yardId && typeId);
// Reset action inputs when the selection changes.
useEffect(() => {
setTransferYardId(null);
setTransferQty(0);
setFreeAfterMove(false);
setToAssignedQty(0);
setToAvailableQty(0);
}, [yardId, typeId]);
// Reset the whole workspace when closed.
useEffect(() => {
if (!opened) {
setYardId(null);
setTypeId(null);
}
}, [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;
toast({ title: fallback, description: String(message), variant: "destructive" });
};
const handleTransfer = async () => {
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 });
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");
}
};
const handleFlip = async (
pool: Wagon[],
qty: number,
status: Freight.WagonStatus,
label: string,
reset: () => void,
) => {
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 });
toast({ title: `${res.updated} wagon(s) set to ${label}` });
reset();
} catch (err) {
showError(err, "Status update failed");
}
};
const busy = transfer.isPending || setStatus.isPending;
const pct = (n: number) => (total > 0 ? (n / total) * 100 : 0);
return (
<Modal
opened={opened}
onClose={onClose}
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={700}>Wagon Yard Operations</Text>
<Text size="xs" c="dimmed">
Move and re-status wagons in bulk no one-by-one edits
</Text>
</div>
</Group>
}
>
<Stack gap="lg">
{/* ---- 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>
{isLoading ? (
<Group justify="center" p="xl">
<Loader />
</Group>
) : !bothSelected ? (
<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&apos;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>
) : (
<>
{/* ---- 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>
<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>
{/* ---- Actions ---- */}
<Grid gap="lg">
{/* Transfer */}
<Grid.Col span={{ base: 12, md: 6 }}>
<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>
<Text fw={700}>Move to another yard</Text>
</Group>
<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"
data={destinationYardOptions}
value={transferYardId}
onChange={setTransferYardId}
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 || transferQty < 1}
color="edr-green"
>
Move {transferQty > 0 ? `${transferQty} ` : ""}wagon{transferQty === 1 ? "" : "s"}
</Button>
</Stack>
</Card>
</Grid.Col>
{/* Re-status */}
<Grid.Col span={{ base: 12, md: 6 }}>
<Card withBorder radius="md" h="100%" padding="lg">
<Group gap="xs" mb="md">
<ThemeIcon variant="light" color="orange" radius="md" size="md">
<ArrowRightLeft size={16} />
</ThemeIcon>
<Text fw={700}>Change availability</Text>
</Group>
<Stack gap="lg">
<Box>
<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} free
</Badge>
</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={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>
<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>
</Grid.Col>
</Grid>
</>
)}
</Stack>
</Modal>
);
};
export default WagonYardWorkspaceModal;