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

@@ -867,6 +867,18 @@ const App = () => {
</RequirePermission>
}
/>
{/* Completion of an initiated (bare) instance after per-booking
clearance — same form, submits to the complete endpoint. */}
<Route
path="contracts/:id/bookings/:bookingId/complete"
element={
<RequirePermission
permission={FREIGHT_PERMS.contracts.createBooking}
>
<GlCreateBookingForm />
</RequirePermission>
}
/>
<Route
path="bookings/:id/milestones"
element={<BookingMilestonesRedirect />}

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;

View File

@@ -211,6 +211,8 @@ export const URL_CONSTANTS = {
CLEARANCE_HISTORY: "/contracts/clearance/history",
OPS_CLEARANCE_HISTORY: "/contracts/clearance/ops-history",
BOOKINGS: (id: string) => `/contracts/${id}/bookings`,
BOOKINGS_COMPLETE: (id: string, bookingId: string) =>
`/contracts/${id}/bookings/${bookingId}/complete`,
VALIDATE_SHIPMENT: (id: string) => `/contracts/${id}/validate-shipment`,
CAPACITY: (id: string) => `/contracts/${id}/capacity`,
// Shipment requests (GENERAL + customs, Path B): customer → GL queue → booking.

View File

@@ -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>
);
}

View File

@@ -214,6 +214,27 @@ export function useContractMutations(contractId: string) {
onError: () => toast.error("Failed to create booking"),
});
const completeBooking = useMutation({
mutationFn: ({
bookingId,
payload,
}: {
bookingId: string;
payload: Freight.CreateBookingUnderContractDto;
}) =>
contractsService.completeBookingUnderContract(
contractId,
bookingId,
payload,
),
onSuccess: () => {
toast.success("Booking completed");
void invalidateContractDetail(qc, contractId);
},
onError: (e: Error) =>
toast.error(e.message || "Failed to complete booking"),
});
const isPending =
staffAccept.isPending ||
requestChanges.isPending ||
@@ -233,6 +254,7 @@ export function useContractMutations(contractId: string) {
generateContract,
signContract,
createBooking,
completeBooking,
isPending,
};
}

View File

@@ -1,10 +1,11 @@
import { useMemo } from "react";
import { useQuery } from "@tanstack/react-query";
import { useParams } from "react-router-dom";
import { useNavigate, useParams } from "react-router-dom";
import {
Alert,
Badge,
Box,
Button,
Grid,
Group,
Loader,
@@ -21,6 +22,7 @@ import {
CheckCircle2,
Clock,
PackageCheck,
PackagePlus,
ShieldCheck,
} from "lucide-react";
import type { Freight } from "@edr/types";
@@ -36,12 +38,18 @@ 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, isDjiboutiGl } from "@/lib/permissions";
import { RequestedCargoChips } from "@/features/clearance/requestedCargo";
export default function DocumentClearanceDetailPage() {
const params = useParams<{ id?: string; bookingId?: string }>();
const id = params.id ?? params.bookingId;
const navigate = useNavigate();
const { user } = useAuth();
const { view, viewer } = useFileViewer();
const { data: booking } = useBookingDetail(id);
@@ -58,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",
@@ -76,6 +99,17 @@ export default function DocumentClearanceDetailPage() {
booking?.contractKind === "GENERAL" &&
Boolean(clearance?.phase);
// Bare initiated instance whose clearance is done: GL completes the booking
// (container numbers, VGM, shipment day) via the completion form.
// Creating the booking is a GL Ethiopia action — never available to Djibouti GL.
const canCompleteBooking =
booking?.status === "CLEARANCE_READY" &&
Boolean(booking?.contractId) &&
Boolean(booking?.customsClearingEnabled) &&
!(Number(booking?.totalAmount ?? 0) > 0) &&
hasPermission(user, FREIGHT_PERMS.contracts.createBooking) &&
!isDjiboutiGl(user);
const docsPhaseComplete =
clearance?.milestones?.some(
(m) => m.milestoneCode === "DOCUMENTS_APPROVED" && m.status === "COMPLETED",
@@ -146,9 +180,30 @@ export default function DocumentClearanceDetailPage() {
</Badge>
)
}
action={
canCompleteBooking ? (
<Button
color="edr-green"
radius="md"
leftSection={<PackagePlus size={16} />}
onClick={() =>
navigate(
`/dashboard/contracts/${booking!.contractId}/bookings/${id}/complete`,
)
}
>
Create booking
</Button>
) : undefined
}
/>
<ClearanceHero booking={booking} clearance={clearance} stats={stats} />
<ClearanceHero
booking={booking}
clearance={clearance}
stats={stats}
requestedLines={requestedLines}
/>
{isPhasedGeneral ? (
<Paper withBorder radius="md" p="lg">
@@ -189,6 +244,10 @@ export default function DocumentClearanceDetailPage() {
tradeDirection={booking?.tradeDirection ?? "IMPORT"}
workflowFiles={workflowFiles}
roleMode="ET"
// A bare initiated instance still has no cargo/price — the
// stepper's "Create booking" step must read as NOT-yet-created
// so it never claims the booking is done before GL completes it.
bookingCreated={Number(booking?.totalAmount ?? 0) > 0}
onChanged={() => void refetch()}
onViewFile={view}
onDownloadFile={(f) => void downloadBookingFile(f.id, f.name)}
@@ -256,10 +315,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 =
@@ -325,6 +386,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>
);
}

View File

@@ -25,7 +25,6 @@ import {
AlertTriangle,
ArrowDown,
ArrowUp,
Banknote,
Building2,
CalendarClock,
CalendarDays,
@@ -35,6 +34,7 @@ import {
Hash,
ListOrdered,
ListPlus,
ListTree,
Mail,
MapPin,
Package,
@@ -60,7 +60,7 @@ import {
import type { ContractTemplateArticle } from "@/services/contract-templates.service";
const BODY_HINT =
'One clause per line — clauses are numbered automatically. Prefix a line with "- " to nest it as a bullet under the previous clause. Use the buttons above to drop a placeholder at the cursor — it is filled from the contract when the document is generated.';
'One clause per line. Use "New clause" for the next number (1., 2., …), "Sub-clause" for a nested number (1.1, then 1.1.1), and "Bullet" for a • point — the number or bullet is typed for you, just add the text. Placeholders are filled from the contract when the document is generated.';
interface ArticleDraft {
id?: string;
@@ -105,12 +105,6 @@ const QUICK_PLACEHOLDERS: PlaceholderDef[] = [
icon: CalendarRange,
hint: "Year the contract is signed",
},
{
token: "{{pricing.totalAmount}}",
label: "Total price",
icon: Banknote,
hint: "Total contract price from the pricing schedule",
},
];
const MORE_PLACEHOLDER_GROUPS: { label: string; items: PlaceholderDef[] }[] = [
@@ -243,7 +237,11 @@ const ALL_PLACEHOLDERS: PlaceholderDef[] = [
...MORE_PLACEHOLDER_GROUPS.flatMap((g) => g.items),
];
const KNOWN_TOKENS = new Set<string>(ALL_PLACEHOLDERS.map((p) => p.token));
const KNOWN_TOKENS = new Set<string>([
...ALL_PLACEHOLDERS.map((p) => p.token),
// Still filled by the renderer, just no longer offered as an insert button.
"{{pricing.totalAmount}}",
]);
/** Any {{…}} tokens in the text the renderer does not know how to fill. */
function unknownTokens(text: string): string[] {
@@ -253,6 +251,10 @@ function unknownTokens(text: string): string[] {
interface ParsedClause {
text: string;
/** Computed outline number, e.g. "3" or "2.1.4". */
number: string;
/** Nesting level: 1 = clause, 2 = sub-clause (x.y), 3 = x.y.z, … */
depth: number;
bullets: string[];
}
@@ -262,28 +264,93 @@ interface ParsedBody {
clauses: ParsedClause[];
}
/**
* Leading outline token on a clause line ("1. ", "2.1 ", "1.1.1) ") — its
* segment count sets the depth; the digits themselves are recomputed. Single
* segment requires "."/")" so prose like "10 tons…" is untouched; a token may
* end the line (empty clause still being typed).
*/
const CLAUSE_NUMBER_RE = /^(?:(\d+(?:\.\d+)+)[.)]?|(\d+)[.)])(?:\s+|$)/;
/** Depth of the outline token in a CLAUSE_NUMBER_RE match, else null. */
function matchDepth(match: RegExpExecArray | null): number | null {
if (!match) return null;
const token = match[1] ?? match[2];
return Math.min(token.split(".").length, MAX_CLAUSE_DEPTH);
}
/** Deepest supported sub-clause level. */
const MAX_CLAUSE_DEPTH = 6;
/**
* Mirror of the API renderer's rules (contract-article.util.ts): one clause per
* line, "- " nests a bullet under the previous clause, and a single bullet-less
* clause renders as a plain paragraph instead of a numbered list of one.
* line; a leading outline number ("2. ", "2.1 ") nests the line as a sub-clause
* at that depth and is renumbered sequentially; "- " nests a bullet under the
* previous clause; a single un-numbered bullet-less clause renders as a plain
* paragraph instead of a numbered list of one.
*/
function parseArticleBody(body: string): ParsedBody {
const clauses: ParsedClause[] = [];
const counters: number[] = [];
let sawNumberToken = false;
for (const raw of body.split("\n")) {
const line = raw.trim();
if (!line) continue;
if (line.startsWith("- ") && clauses.length > 0) {
clauses[clauses.length - 1].bullets.push(line.slice(2).trim());
} else {
clauses.push({ text: line.replace(/^- /, ""), bullets: [] });
continue;
}
const cleaned = line.replace(/^- /, "");
const match = CLAUSE_NUMBER_RE.exec(cleaned);
let depth = matchDepth(match) ?? 1;
// A sub-clause can only sit directly under an existing parent.
depth = Math.min(depth, counters.length + 1);
if (match) sawNumberToken = true;
counters.splice(depth);
while (counters.length < depth) counters.push(0);
counters[depth - 1] += 1;
clauses.push({
text: match ? cleaned.slice(match[0].length).trim() : cleaned,
number: counters.slice(0, depth).join("."),
depth,
bullets: [],
});
}
if (clauses.length === 1 && clauses[0].bullets.length === 0) {
if (
clauses.length === 1 &&
clauses[0].bullets.length === 0 &&
!sawNumberToken
) {
return { paragraph: clauses[0].text, clauses: [] };
}
return { clauses };
}
/**
* Rewrite the leading outline tokens in a body so every numbered clause line
* carries its computed sequential number (stale numbers self-heal). Lines
* without a number token and bullet lines pass through untouched.
*/
function renumberBody(body: string): string {
const counters: number[] = [];
return body
.split("\n")
.map((raw) => {
const line = raw.trim();
if (!line || line.startsWith("- ")) return raw;
const match = CLAUSE_NUMBER_RE.exec(line);
let depth = matchDepth(match) ?? 1;
depth = Math.min(depth, counters.length + 1);
counters.splice(depth);
while (counters.length < depth) counters.push(0);
counters[depth - 1] += 1;
if (!match) return raw;
const number = counters.slice(0, depth).join(".");
return `${number}. ${line.slice(match[0].length).trim()}`;
})
.join("\n");
}
/** Render clause text with {{placeholders}} highlighted as green chips. */
function HighlightedText({ text }: { text: string }) {
const parts = text.split(/(\{\{[^{}]+\}\})/g);
@@ -632,13 +699,63 @@ function ArticleEditorModal({
});
};
const insertLinePrefix = (prefix: string) => {
/**
* Insert a structured line (clause / sub-clause / bullet) on a fresh line
* below the one the caret is on. Clause lines get their outline number typed
* in automatically ("3. ", "3.1. ", …) and every numbered line in the body is
* renumbered so the text always matches the preview.
*/
const insertStructuredLine = (kind: "clause" | "sub" | "bullet") => {
const el = bodyRef.current;
const start = el?.selectionStart ?? body.length;
// Start the snippet on its own line unless the caret already is.
const needsNewline = start > 0 && body[start - 1] !== "\n";
lastFocused.current = "body";
insertAtCursor(`${needsNewline ? "\n" : ""}${prefix}`);
const caret = el?.selectionStart ?? body.length;
// Structured lines never split a sentence — insert after the caret's line.
const lineEnd = body.indexOf("\n", caret);
const insertAt = lineEnd === -1 ? body.length : lineEnd;
const before = body.slice(0, insertAt);
const after = body.slice(insertAt); // "" or starts with "\n"
let prefix: string;
if (kind === "bullet") {
prefix = "- ";
} else {
// New clause always starts a fresh top-level number. Sub-clause nests
// one level under a clause (1 → 1.1) but adds a SIBLING when the caret
// is already on a sub-clause (1.1 → 1.2 → 1.3, not ever-deeper) — a
// third level is reached by typing its number (e.g. "1.1.1 ") directly.
const above = parseArticleBody(before);
const lastDepth = above.paragraph
? 1
: (above.clauses[above.clauses.length - 1]?.depth ?? 0);
const depth =
kind === "sub"
? lastDepth <= 1
? Math.min(lastDepth + 1, MAX_CLAUSE_DEPTH)
: lastDepth
: 1;
// Digits are placeholders — renumberBody assigns the real value.
prefix = `${Array.from({ length: depth }, () => "1").join(".")}. `;
}
const beforeLines = before.length > 0 ? before.split("\n") : [];
const afterLines =
after.length > 0 ? after.slice(1).split("\n") : [];
const insertedIdx = beforeLines.length;
const joined = [...beforeLines, prefix, ...afterLines].join("\n");
const next = kind === "bullet" ? joined : renumberBody(joined);
setBody(next);
// Caret lands at the end of the inserted line, ready for typing.
const caretTarget = next
.split("\n")
.slice(0, insertedIdx + 1)
.join("\n").length;
requestAnimationFrame(() => {
const field = bodyRef.current;
if (!field) return;
field.focus();
field.setSelectionRange(caretTarget, caretTarget);
});
};
const parsed = useMemo(() => parseArticleBody(body), [body]);
@@ -724,26 +841,60 @@ function ArticleEditorModal({
))}
</Menu.Dropdown>
</Menu>
<Tooltip label="Start a new numbered clause" withArrow>
</Group>
</Box>
<Box>
<Text size="sm" fw={500} mb={4}>
Add structure
</Text>
<Group gap={6} wrap="wrap">
<Tooltip
label="New line with the next clause number typed for you (1., 2., 3., …)"
withArrow
>
<Button
variant="default"
variant="light"
color="edr-green"
size="compact-sm"
radius="md"
leftSection={<ListOrdered size={13} />}
onMouseDown={(e) => e.preventDefault()}
onClick={() => insertLinePrefix("")}
onClick={() => insertStructuredLine("clause")}
>
New clause
</Button>
</Tooltip>
<Tooltip label="Nest a bullet under the previous clause" withArrow>
<Tooltip
label="Numbered point under the current clause — 1.1, then 1.2, 1.3 on each click. For a deeper level type its number yourself (e.g. 1.1.1 )"
withArrow
>
<Button
variant="default"
variant="light"
color="edr-green"
size="compact-sm"
radius="md"
leftSection={<ListTree size={13} />}
disabled={body.trim().length === 0}
onMouseDown={(e) => e.preventDefault()}
onClick={() => insertStructuredLine("sub")}
>
Sub-clause
</Button>
</Tooltip>
<Tooltip
label="New line with a bullet (•) under the current clause"
withArrow
>
<Button
variant="light"
color="edr-green"
size="compact-sm"
radius="md"
leftSection={<ListPlus size={13} />}
disabled={body.trim().length === 0}
onMouseDown={(e) => e.preventDefault()}
onClick={() => insertLinePrefix("- ")}
onClick={() => insertStructuredLine("bullet")}
>
Bullet
</Button>
@@ -804,10 +955,10 @@ function ArticleEditorModal({
</Text>
)}
{parsed.clauses.map((clause, i) => (
<Box key={i}>
<Box key={i} pl={(clause.depth - 1) * 20}>
<Text size="sm">
<Text component="span" fw={600} c="edr-green.7">
{i + 1}.{" "}
{clause.number}.{" "}
</Text>
<HighlightedText text={clause.text} />
</Text>

View File

@@ -1,4 +1,4 @@
import { useCallback, useMemo, useState, type ReactNode } from "react";
import { useCallback, useEffect, useMemo, useState, type ReactNode } from "react";
import { useNavigate } from "react-router-dom";
import {
ActionIcon,
@@ -7,6 +7,7 @@ import {
Button,
Card,
Group,
Menu,
SegmentedControl,
Stack,
Text,
@@ -16,16 +17,18 @@ import {
} from "@mantine/core";
import {
ArrowRight,
Calendar,
ChevronRight,
ExternalLink,
Eye,
FileText,
Flag,
Inbox,
LayoutGrid,
MoreHorizontal,
PackageCheck,
PackagePlus,
RefreshCw,
Search,
Send,
ShieldCheck,
ShipWheel,
Table as TableIcon,
@@ -46,17 +49,23 @@ import { PageHeader } from "@/components/page/PageHeader";
import { KpiStrip } from "@/components/page/KpiStrip";
import { bookingTable } from "@/components/bookings/booking-ui.styles";
import { useAuth } from "@/auth/useAuth";
import {
useContractClearanceQueue,
useEtClearanceQueue,
} from "@/hooks/contracts/useContracts";
import { useContractClearanceQueue } from "@/hooks/contracts/useContracts";
import { useBookingEtClearanceQueue } from "@/hooks/bookings/useBookings";
import type { BookingDetail } from "@/types/booking";
import { FREIGHT_PERMS, hasPermission } from "@/lib/permissions";
import { FREIGHT_PERMS, hasPermission, isDjiboutiGl } 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";
type QueueTab = "all" | "shipments";
/** Persist the selected queue tab so returning from a detail keeps it. */
const QUEUE_TAB_STORAGE_KEY = "edr.clearance.queueTab";
interface ClearanceRow {
id: string;
@@ -197,21 +206,35 @@ export default function ContractClearanceListPage() {
const { user } = useAuth();
const canReview = hasPermission(user, FREIGHT_PERMS.contracts.clearanceReview);
const canEt = hasPermission(user, FREIGHT_PERMS.contracts.clearanceEtActions);
const canCreateBooking = hasPermission(
user,
FREIGHT_PERMS.contracts.createBooking,
);
// Creating a booking under a cleared contract is a GL Ethiopia action — never
// available to Djibouti GL.
const canCreateBooking =
hasPermission(user, FREIGHT_PERMS.contracts.createBooking) &&
!isDjiboutiGl(user);
const defaultQueue: QueueTab = canReview ? "all" : "et";
const [queueTab, setQueueTab] = useState<QueueTab>(defaultQueue);
const defaultQueue: QueueTab = canReview ? "all" : "shipments";
const [queueTab, setQueueTab] = useState<QueueTab>(() => {
const stored =
typeof window !== "undefined"
? window.localStorage.getItem(QUEUE_TAB_STORAGE_KEY)
: null;
return stored === "all" || stored === "shipments" ? stored : defaultQueue;
});
const [query, setQuery] = useState("");
const [view, setView] = useState<ViewMode>("table");
const { pagination, setPagination } = usePagination({ pageSize: 10 });
const selectQueueTab = useCallback((tab: QueueTab) => {
setQueueTab(tab);
if (typeof window !== "undefined") {
window.localStorage.setItem(QUEUE_TAB_STORAGE_KEY, tab);
}
}, []);
// Contract clearance rows feed both the Contracts tab and the header KPIs, so
// they load regardless of the active tab.
const { data: allData, isLoading: allLoading, isError: allError, isFetching: allFetching, refetch: refetchAll } =
useContractClearanceQueue(queueTab === "all" || queueTab === "shipments");
const { data: etData, isLoading: etLoading, isError: etError, isFetching: etFetching, refetch: refetchEt } =
useEtClearanceQueue(queueTab === "et");
useContractClearanceQueue(true);
const {
data: bookingQueue,
isLoading: bookingsLoading,
@@ -220,18 +243,12 @@ export default function ContractClearanceListPage() {
refetch: refetchBookings,
} = useBookingEtClearanceQueue(queueTab === "shipments");
const data = queueTab === "et" ? etData : allData;
const isLoading = queueTab === "et" ? etLoading : allLoading;
const isError = queueTab === "et" ? etError : allError;
const isFetching =
queueTab === "et"
? etFetching
: queueTab === "shipments"
? bookingsFetching
: allFetching;
const data = allData;
const isLoading = queueTab === "shipments" ? bookingsLoading : allLoading;
const isError = queueTab === "shipments" ? bookingsError : allError;
const isFetching = queueTab === "shipments" ? bookingsFetching : allFetching;
const refetch = () => {
if (queueTab === "et") void refetchEt();
else if (queueTab === "shipments") void refetchBookings();
if (queueTab === "shipments") void refetchBookings();
else void refetchAll();
};
@@ -242,19 +259,8 @@ export default function ContractClearanceListPage() {
value: "all",
label: (
<Group gap={6} wrap="nowrap">
<ShieldCheck size={15} />
<Box visibleFrom="sm">All</Box>
</Group>
),
});
}
if (canEt) {
opts.push({
value: "et",
label: (
<Group gap={6} wrap="nowrap">
<Flag size={15} />
<Box visibleFrom="sm">ET queue</Box>
<FileText size={15} />
<Box visibleFrom="sm">Contracts</Box>
</Group>
),
});
@@ -273,9 +279,36 @@ export default function ContractClearanceListPage() {
return opts;
}, [canReview, canEt]);
// GENERAL-contract shipment bookings in per-booking clearance (ET queue).
// If a persisted/default tab isn't available for this user, fall back to the
// first permitted tab.
useEffect(() => {
if (
queueTabOptions.length > 0 &&
!queueTabOptions.some((o) => o.value === queueTab)
) {
selectQueueTab(queueTabOptions[0].value);
}
}, [queueTabOptions, queueTab, selectQueueTab]);
// 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.
const bookingRows = useMemo(() => {
const rows = (bookingQueue ?? []).map((b: BookingDetail) => ({
const rows: ShipmentBookingRow[] = (bookingQueue ?? []).map((b: BookingDetail) => ({
id: b.id,
reference: b.reference,
customerLabel: b.company?.name ?? b.governmentInstitution ?? "—",
@@ -284,6 +317,15 @@ export default function ContractClearanceListPage() {
tradeDirection: b.tradeDirection ?? "—",
freightType: b.freightType ?? "—",
status: b.status,
requested: requestedByBooking.get(b.id) ?? null,
contractId: b.contractId ?? null,
contractReference: b.contractReference ?? null,
contractKind: b.contractKind ?? null,
customs: b.serviceType?.includesCustoms ?? Boolean(b.customsClearingEnabled),
createdAt: b.createdAt ?? null,
// A bare initiated instance has no cargo/price yet — GL still has to create
// (complete) the booking.
bookingCreated: Number(b.totalAmount ?? 0) > 0,
}));
const q = query.trim().toLowerCase();
if (!q) return rows;
@@ -291,10 +333,12 @@ export default function ContractClearanceListPage() {
(r) =>
r.reference.toLowerCase().includes(q) ||
r.customerLabel.toLowerCase().includes(q) ||
(r.contractReference ?? "").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),
@@ -420,7 +464,7 @@ export default function ContractClearanceListPage() {
id: "go",
size: 150,
cell: ({ row }) =>
row.original.ready ? (
row.original.ready && canCreateBooking ? (
<Group justify="flex-end" pr="xs">
<Button
size="compact-sm"
@@ -444,7 +488,7 @@ export default function ContractClearanceListPage() {
),
},
],
[navigate],
[navigate, canCreateBooking],
);
return (
@@ -464,29 +508,16 @@ export default function ContractClearanceListPage() {
</Badge>
}
action={
<Group gap="sm" wrap="nowrap">
{canCreateBooking ? (
<Button
variant="filled"
color="edr-green"
radius="md"
leftSection={<Send size={15} />}
onClick={() => navigate("/dashboard/shipment-requests")}
>
Shipment requests
</Button>
) : null}
<ActionIcon
variant="default"
size="lg"
radius="md"
onClick={() => refetch()}
loading={isFetching}
aria-label="Refresh"
>
<RefreshCw size={16} />
</ActionIcon>
</Group>
<ActionIcon
variant="default"
size="lg"
radius="md"
onClick={() => refetch()}
loading={isFetching}
aria-label="Refresh"
>
<RefreshCw size={16} />
</ActionIcon>
}
/>
@@ -525,7 +556,7 @@ export default function ContractClearanceListPage() {
radius="md"
value={queueTab}
onChange={(v) => {
setQueueTab(v as QueueTab);
selectQueueTab(v as QueueTab);
setPagination({ pageIndex: 0, pageSize: pagination.pageSize });
}}
data={queueTabOptions}
@@ -600,7 +631,16 @@ export default function ContractClearanceListPage() {
rows={bookingRows}
loading={bookingsLoading}
error={bookingsError}
canCreateBooking={canCreateBooking}
onOpen={(id) => navigate(`/dashboard/clearance/${id}`)}
onCreateBooking={(row) =>
navigate(
`/dashboard/contracts/${row.contractId}/bookings/${row.id}/complete`,
)
}
onViewContract={(contractId) =>
navigate(`/dashboard/contracts/clearance/${contractId}`)
}
/>
) : view === "table" ? (
<Box style={{ overflowX: "auto" }} px="xs" pb="xs">
@@ -650,8 +690,26 @@ interface ShipmentBookingRow {
tradeDirection: string;
freightType: string;
status: string;
/** Requested quantities from the originating shipment request. */
requested: Freight.RequestedShipmentLines | null;
/** Contract this shipment booking was created under. */
contractId: string | null;
contractReference: string | null;
contractKind: "ONE_TIME" | "GENERAL" | null;
customs: boolean;
createdAt: string | null;
/** true once GL has actually created (completed) the booking. */
bookingCreated: boolean;
}
const formatDate = (iso: string | null) => {
if (!iso) return "—";
const d = new Date(iso);
return Number.isNaN(d.getTime())
? "—"
: d.toLocaleDateString(undefined, { day: "2-digit", month: "short", year: "numeric" });
};
const prettyStatus = (s: string) =>
s
.toLowerCase()
@@ -670,13 +728,26 @@ function ShipmentBookingsTable({
rows,
loading,
error,
canCreateBooking,
onOpen,
onCreateBooking,
onViewContract,
}: {
rows: ShipmentBookingRow[];
loading: boolean;
error: boolean;
canCreateBooking: boolean;
onOpen: (id: string) => void;
onCreateBooking: (row: ShipmentBookingRow) => void;
onViewContract: (contractId: string) => void;
}) {
// A bare initiated instance that has cleared but not yet been created by GL.
const isBookable = (r: ShipmentBookingRow) =>
canCreateBooking &&
Boolean(r.contractId) &&
!r.bookingCreated &&
r.status === "CLEARANCE_READY";
const columns = useMemo<ColumnDef<ShipmentBookingRow>[]>(
() => [
{
@@ -699,6 +770,28 @@ function ShipmentBookingsTable({
</div>
),
},
{
id: "contract",
header: () => <span className={bookingTable.headerCell}>Contract</span>,
cell: ({ row }) => {
const r = row.original;
return (
<Stack gap={4} py={2}>
<Group gap={6} wrap="nowrap">
<FileText size={13} className="shrink-0 text-muted-foreground" />
<Text size="sm" fw={500} truncate maw={150}>
{r.contractReference ?? "—"}
</Text>
</Group>
{r.contractKind ? (
<Badge size="xs" variant="default" radius="sm" tt="uppercase">
{r.contractKind === "GENERAL" ? "General" : "One-time"}
</Badge>
) : null}
</Stack>
);
},
},
{
id: "route",
header: () => <span className={bookingTable.headerCell}>Route</span>,
@@ -725,6 +818,28 @@ function ShipmentBookingsTable({
<Badge variant="outline" color="gray" radius="sm">
{prettyStatus(row.original.freightType)}
</Badge>
<CustomsBadge customs={row.original.customs} />
</Group>
),
},
{
id: "requested",
header: () => (
<span className={bookingTable.headerCell}>Requested cargo</span>
),
cell: ({ row }) => (
<RequestedCargoChips lines={row.original.requested} size="sm" />
),
},
{
id: "created",
header: () => <span className={bookingTable.headerCell}>Created</span>,
cell: ({ row }) => (
<Group gap={6} wrap="nowrap">
<Calendar size={13} className="shrink-0 text-muted-foreground" />
<Text size="sm" c="dimmed">
{formatDate(row.original.createdAt)}
</Text>
</Group>
),
},
@@ -732,26 +847,95 @@ function ShipmentBookingsTable({
id: "status",
header: () => <span className={bookingTable.headerCell}>Status</span>,
cell: ({ row }) => (
<Badge
variant="light"
color={shipmentStatusColor(row.original.status)}
radius="sm"
>
{prettyStatus(row.original.status)}
</Badge>
),
},
{
id: "chevron",
header: "",
cell: () => (
<Group justify="flex-end" pr="xs">
<ChevronRight size={16} className="text-muted-foreground" />
<Group gap={6} wrap="nowrap">
<Badge
variant="light"
color={shipmentStatusColor(row.original.status)}
radius="sm"
>
{prettyStatus(row.original.status)}
</Badge>
{row.original.bookingCreated ? (
<Tooltip label="Booking created by GL Ethiopia" withArrow>
<Badge
variant="light"
color="blue"
radius="sm"
leftSection={<PackagePlus size={11} />}
>
Booked
</Badge>
</Tooltip>
) : null}
</Group>
),
},
{
id: "actions",
header: "",
size: 200,
cell: ({ row }) => {
const r = row.original;
const bookable = isBookable(r);
return (
<Group
justify="flex-end"
gap={6}
pr="xs"
wrap="nowrap"
onClick={(e) => e.stopPropagation()}
>
{bookable ? (
<Button
size="compact-sm"
color="edr-green"
radius="md"
leftSection={<PackagePlus size={14} />}
onClick={() => onCreateBooking(r)}
>
Create booking
</Button>
) : null}
<Menu shadow="md" radius="md" position="bottom-end" withinPortal>
<Menu.Target>
<ActionIcon
variant="subtle"
color="gray"
radius="md"
aria-label="Row actions"
>
<MoreHorizontal size={16} />
</ActionIcon>
</Menu.Target>
<Menu.Dropdown>
<Menu.Item leftSection={<Eye size={14} />} onClick={() => onOpen(r.id)}>
Open booking
</Menu.Item>
{bookable ? (
<Menu.Item
leftSection={<PackagePlus size={14} />}
onClick={() => onCreateBooking(r)}
>
Create booking
</Menu.Item>
) : null}
{r.contractId ? (
<Menu.Item
leftSection={<ExternalLink size={14} />}
onClick={() => onViewContract(r.contractId!)}
>
View contract
</Menu.Item>
) : null}
</Menu.Dropdown>
</Menu>
</Group>
);
},
},
],
[],
// eslint-disable-next-line react-hooks/exhaustive-deps
[canCreateBooking, onOpen, onCreateBooking, onViewContract],
);
if (!loading && !error && rows.length === 0) {

View File

@@ -1,6 +1,6 @@
import { useState } from "react";
import { useQuery } from "@tanstack/react-query";
import { useParams } from "react-router-dom";
import { useNavigate, useParams } from "react-router-dom";
import {
Alert,
Badge,
@@ -14,9 +14,19 @@ import {
Text,
ThemeIcon,
} from "@mantine/core";
import { AlertCircle, ClipboardList, FileText, Upload } from "lucide-react";
import {
AlertCircle,
ClipboardList,
FileText,
PackagePlus,
Upload,
} from "lucide-react";
import type { Freight } from "@edr/types";
import { useAuth } from "@/auth/useAuth";
import { FREIGHT_PERMS, hasPermission } from "@/lib/permissions";
import type { BookingDetail } from "@/types/booking";
import { PageContainer } from "@/components/page/PageContainer";
import { PageHeader } from "@/components/page/PageHeader";
import { ClearanceReviewSection } from "@/components/bookings/detail/ClearanceReviewSection";
@@ -46,6 +56,7 @@ type GlClearanceDetail =
reference: string;
tradeDirection: string;
clearance: Freight.ClearanceView;
booking: BookingDetail;
};
async function loadGlClearanceDetail(id: string): Promise<GlClearanceDetail> {
@@ -70,6 +81,7 @@ async function loadGlClearanceDetail(id: string): Promise<GlClearanceDetail> {
reference: booking.reference,
tradeDirection: booking.tradeDirection,
clearance,
booking,
};
}
}
@@ -77,6 +89,8 @@ async function loadGlClearanceDetail(id: string): Promise<GlClearanceDetail> {
/** Djibouti GL clearance detail — RO/DO upload and read-only upstream context. */
export default function GlClearanceDetailPage() {
const { id } = useParams<{ id: string }>();
const navigate = useNavigate();
const { user } = useAuth();
const { view, viewer } = useFileViewer();
const [uploadKind, setUploadKind] = useState<GlClearanceUploadKind | null>(null);
@@ -125,6 +139,20 @@ export default function GlClearanceDetailPage() {
? (data.clearance.vesselDepartureDate ?? null)
: null;
// The shipment booking instance backing this clearance (per-booking GENERAL
// customs). Bare until GL completes it: no cargo, no price.
const shipmentBooking = data.kind === "booking" ? data.booking : null;
const bookingCompleted = Number(shipmentBooking?.totalAmount ?? 0) > 0;
// Import boundary (DO collected) / export boundary (release) reached →
// clearance is ready and GL creates the real booking. Show the create-booking
// CTA here so the GL user who finishes the DJ step isn't left without a next
// action. Permission-gated so only booking creators (GL Ethiopia) see it.
const canCompleteBooking =
shipmentBooking?.status === "CLEARANCE_READY" &&
Boolean(shipmentBooking?.contractId) &&
!bookingCompleted &&
hasPermission(user, FREIGHT_PERMS.contracts.createBooking);
return (
<PageContainer>
<Stack gap="lg">
@@ -144,6 +172,7 @@ export default function GlClearanceDetailPage() {
<Group gap="sm">
{isImport ? (
<Button
variant={canCompleteBooking ? "default" : "filled"}
color="edr-green"
leftSection={<Upload size={16} />}
disabled={!canUploadDo}
@@ -153,6 +182,7 @@ export default function GlClearanceDetailPage() {
</Button>
) : (
<Button
variant={canCompleteBooking ? "default" : "filled"}
color="edr-green"
leftSection={<Upload size={16} />}
onClick={() => setUploadKind("ro")}
@@ -160,6 +190,19 @@ export default function GlClearanceDetailPage() {
{hasRo ? "Replace RO" : "Upload RO"}
</Button>
)}
{canCompleteBooking && shipmentBooking ? (
<Button
color="edr-green"
leftSection={<PackagePlus size={16} />}
onClick={() =>
navigate(
`/dashboard/contracts/${shipmentBooking.contractId}/bookings/${id}/complete`,
)
}
>
Create booking
</Button>
) : null}
</Group>
}
/>
@@ -209,7 +252,15 @@ export default function GlClearanceDetailPage() {
<PhasedClearanceActionPanel
contractId={data.kind === "contract" ? id : undefined}
bookingId={data.kind === "booking" ? id : linkedBookingId}
bookingCreated={data.kind === "booking" || Boolean(linkedBookingId)}
// For a per-booking instance, "created" means COMPLETED (has
// cargo/price), not merely that a booking row exists — a bare
// instance is not yet a real booking. Contract-level clearance
// keeps its linked-booking signal.
bookingCreated={
data.kind === "booking"
? bookingCompleted
: Boolean(linkedBookingId)
}
bookingMilestones={
data.kind === "booking"
? (data.clearance.milestones ?? [])

View File

@@ -402,7 +402,7 @@ export default function ShipmentRequestsPage() {
<Stack gap="lg">
<PageHeader
title="Shipment Requests"
subtitle="Customer requests to ship under general customs contracts. Accept one to create the booking and start its clearance."
subtitle="Customer requests to ship under general customs contracts. Each request starts its booking's clearance immediately — complete the booking from the clearance page once it is ready."
meta={
<Badge
variant="light"

View File

@@ -4,7 +4,7 @@ import { useMutation, useQuery } from "@tanstack/react-query";
import { api } from "@/services/api";
import Breadcrumbs from "@/components/ui/Breadcrumbs";
import { Plus } from "lucide-react";
import { Plus, Warehouse } from "lucide-react";
import { useEffect, useMemo, useState } from "react";
import { Navigate, useLocation } from "react-router-dom";
@@ -14,6 +14,7 @@ import FleetHistoryModal from "@/components/fleet/FleetHistoryModal";
import FleetRecordActions from "@/components/fleet/FleetRecordActions";
import FleetToolbar from "@/components/fleet/FleetToolbar";
import WagonMovementHistoryModal from "@/components/fleet/WagonMovementHistoryModal";
import WagonYardWorkspaceModal from "@/components/wagons/WagonYardWorkspaceModal";
import { formatFleetCell, registerFleetOptionLabels } from "@/components/fleet/fleetFormat";
import { useFleetViewMode } from "@/components/fleet/useFleetViewMode";
import { ruleEngineTable } from "@/components/ruleEngine/ruleEngineStyles";
@@ -46,6 +47,7 @@ const FleetResourcePage = () => {
const [assigningDriver, setAssigningDriver] = useState<FleetRecord | null>(null);
const [historyTarget, setHistoryTarget] = useState<FleetRecord | null>(null);
const [selectedDriver, setSelectedDriver] = useState<string>("");
const [wagonWorkspaceOpen, setWagonWorkspaceOpen] = useState(false);
const { viewMode, setViewMode } = useFleetViewMode(slug);
const serverListFilters = useMemo((): FleetListFilters | undefined => {
@@ -369,12 +371,25 @@ const FleetResourcePage = () => {
{config.subtitle}
</Text>
</div>
<Button leftSection={<Plus size={16} />} styles={{ label: { fontWeight: 500 } }} onClick={() => {
setEditing(null);
setFormOpen(true);
}}>
{config.addLabel}
</Button>
<Group gap="sm">
{slug === "wagons" ? (
<Button
variant="light"
color="edr-green"
leftSection={<Warehouse size={16} />}
styles={{ label: { fontWeight: 500 } }}
onClick={() => setWagonWorkspaceOpen(true)}
>
Yard Workspace
</Button>
) : null}
<Button leftSection={<Plus size={16} />} styles={{ label: { fontWeight: 500 } }} onClick={() => {
setEditing(null);
setFormOpen(true);
}}>
{config.addLabel}
</Button>
</Group>
</Group>
<Card radius="lg" padding={0} withBorder style={{ borderColor: "var(--mantine-color-gray-2)" }}>
@@ -389,31 +404,28 @@ const FleetResourcePage = () => {
onViewModeChange={setViewMode}
filters={
listFilterSelects ? (
<Group gap="xs" wrap="wrap">
<Group gap="sm" wrap="wrap" align="center">
{listFilterSelects.map((filter) => (
<Group key={filter.key} gap={4} wrap="wrap">
<Text size="xs" fw={500} c="dimmed">{filter.label}:</Text>
<Group gap={4} wrap="wrap">
{filter.data.map((option) => (
<Button
key={option.value}
size="xs"
radius="md"
variant={filter.value === option.value ? "filled" : "outline"}
styles={{ label: { fontWeight: 500 } }}
onClick={() => {
setListFilterValues((prev) => ({
...prev,
[filter.key]: option.value,
}));
setPagination((prev) => ({ ...prev, pageIndex: 0 }));
}}
>
{option.label}
</Button>
))}
</Group>
</Group>
<Select
key={filter.key}
aria-label={filter.label}
placeholder={filter.data[0]?.label ?? filter.label}
data={filter.data}
value={filter.value}
onChange={(value) => {
setListFilterValues((prev) => ({
...prev,
[filter.key]: value ?? "ALL",
}));
setPagination((prev) => ({ ...prev, pageIndex: 0 }));
}}
size="sm"
radius="lg"
w={200}
searchable={filter.data.length > 8}
comboboxProps={{ withinPortal: true }}
styles={{ input: { borderColor: "var(--mantine-color-gray-3)" } }}
/>
))}
</Group>
) : hasStatusColumn && statusFilterOptions.length > 1 ? (
@@ -586,6 +598,13 @@ const FleetResourcePage = () => {
</Stack>
</Modal>
{slug === "wagons" ? (
<WagonYardWorkspaceModal
opened={wagonWorkspaceOpen}
onClose={() => setWagonWorkspaceOpen(false)}
/>
) : null}
{slug === "wagons" ? (
<WagonMovementHistoryModal
opened={Boolean(historyTarget)}

View File

@@ -1591,6 +1591,30 @@ export const api = {
undefined,
() => [["wagons"]],
),
bulkTransfer: endpoint<
{ wagonIds: string[]; toYardId: string },
{ moved: number }
>(
"wagons",
"bulkTransfer",
({ wagonIds, toYardId }) =>
wagonService.bulkTransfer(wagonIds, toYardId).then((r) => r.data),
undefined,
() => [["wagons"]],
),
bulkSetStatus: endpoint<
{ wagonIds: string[]; status: Wagon["status"] },
{ updated: number }
>(
"wagons",
"bulkSetStatus",
({ wagonIds, status }) =>
wagonService.bulkSetStatus(wagonIds, status).then((r) => r.data),
undefined,
() => [["wagons"]],
),
},
trains: {

View File

@@ -502,6 +502,31 @@ export const contractsService = {
};
},
/**
* Complete a bare initiated booking instance once its per-booking clearance
* is CLEARANCE_READY — same payload as create; the API persists cargo,
* prices, invoices, checks the booking window and moves the booking to the
* operations queue.
*/
completeBookingUnderContract: async (
id: string,
bookingId: string,
payload: Freight.CreateBookingUnderContractDto,
): Promise<{ id: string; reference: string; warnings?: string[] }> => {
const result = await postContract<{
booking?: { id: string; reference: string };
id?: string;
reference?: string;
warnings?: string[];
}>(C.BOOKINGS_COMPLETE(id, bookingId), payload);
const booking = result.booking ?? result;
return {
id: booking.id ?? "",
reference: booking.reference ?? "",
warnings: result.warnings,
};
},
/**
* Pre-create validation + authoritative price preview: the same
* BookingPricingService pass that prices the booking on create (rail +

View File

@@ -83,4 +83,10 @@ export const wagonService = {
create: (data: Partial<Wagon>) => apiClient.post('/wagons', data),
update: (id: string, data: Partial<Wagon>) => apiClient.patch(`/wagons/${id}`, data),
delete: (id: string) => apiClient.delete(`/wagons/${id}`),
/** Relocate many wagons to one yard in a single call (writes movement ledger). */
bulkTransfer: (wagonIds: string[], toYardId: string) =>
apiClient.post<{ moved: number }>('/wagons/bulk-transfer', { wagonIds, toYardId }),
/** Set the same status on many wagons in a single call. */
bulkSetStatus: (wagonIds: string[], status: Freight.WagonStatus) =>
apiClient.post<{ updated: number }>('/wagons/bulk-status', { wagonIds, status }),
};