add booking request functionality for GENERAL customs contracts

- Create migration for booking_requests table with necessary fields and indexes.
- Implement BookingRequestRepository for database operations related to booking requests.
- Develop BookingRequestService to handle business logic for submitting, accepting, rejecting, and canceling booking requests.
- Create DTOs for creating booking requests and reviewing them.
- Define BookingRequest entity to map to the booking_requests table.
- Add UI components for managing shipment requests, including detail and list pages.
- Implement OperationDatePicker component for selecting available shipment days.
This commit is contained in:
Marshal
2026-06-29 09:30:44 +00:00
parent aeb5e0046e
commit 0f7cac2b68
46 changed files with 2665 additions and 992 deletions

View File

@@ -1,27 +1,16 @@
import { useState } from "react";
import { Banknote, Pencil, Receipt } from "lucide-react";
import {
Button,
Divider,
Group,
NumberInput,
Paper,
Stack,
Text,
Textarea,
} from "@mantine/core";
import { useMutation, useQueryClient } from "@tanstack/react-query";
import toast from "react-hot-toast";
import { Banknote, Receipt } from "lucide-react";
import { Divider, Group, Paper, Stack, Text } from "@mantine/core";
import type { BookingDetail } from "@/types/booking";
import { bookingsService } from "@/services/bookings.service";
import { SectionCard } from "./detail/SectionCard";
import { detailStyles } from "./detail/booking-detail.styles";
export function BookingPricingSummary({ booking }: { booking: BookingDetail }) {
const qc = useQueryClient();
const computed = Number(booking.totalAmount);
// The booking price is computed from the contract and is NOT staff-editable.
// A historical `adjustedTotalAmount` (from before adjustments were removed)
// is still shown read-only so old records render correctly.
const isAdjusted =
booking.adjustedTotalAmount !== null &&
booking.adjustedTotalAmount !== undefined;
@@ -29,21 +18,6 @@ export function BookingPricingSummary({ booking }: { booking: BookingDetail }) {
const lineItems = booking.pricingBreakdown?.lineItems ?? [];
const [editing, setEditing] = useState(false);
const [amount, setAmount] = useState<number | "">(effective);
const [reason, setReason] = useState("");
const adjustMutation = useMutation({
mutationFn: (payload: { amount: number | null; reason?: string }) =>
bookingsService.adjustPrice(booking.id, payload.amount, payload.reason),
onSuccess: () => {
toast.success("Price updated");
setEditing(false);
qc.invalidateQueries({ queryKey: ["bookings"] });
},
onError: () => toast.error("Could not update price"),
});
const fmt = (n: number) =>
`${booking.paymentCurrency} ${n.toLocaleString(undefined, { minimumFractionDigits: 2 })}`;
@@ -51,102 +25,23 @@ export function BookingPricingSummary({ booking }: { booking: BookingDetail }) {
<SectionCard icon={Banknote} title="Pricing & payment">
<Stack gap="md">
<Paper radius="md" withBorder p="md" style={detailStyles.highlightCard}>
<Group justify="space-between" align="flex-start">
<div>
<Text size="xs" c="dimmed" fw={500} tt="uppercase" lts="0.04em">
{isAdjusted ? "Adjusted total" : "Total amount"}
</Text>
<Text
size="xl"
fw={700}
c="edr-green.9"
mt={4}
style={{ fontVariantNumeric: "tabular-nums", letterSpacing: "-0.5px" }}
>
{fmt(effective)}
</Text>
{isAdjusted && (
<Text size="xs" c="dimmed" mt={2}>
Computed: {fmt(computed)}
{booking.adjustmentReason ? ` · ${booking.adjustmentReason}` : ""}
</Text>
)}
</div>
{!editing && (
<Button
size="compact-xs"
variant="light"
leftSection={<Pencil size={13} />}
onClick={() => {
setAmount(effective);
setEditing(true);
}}
>
Adjust
</Button>
)}
</Group>
{editing && (
<Stack gap="xs" mt="md">
<NumberInput
label="New total"
value={amount}
onChange={(v) => setAmount(v === "" ? "" : Number(v))}
min={0}
radius="md"
prefix={`${booking.paymentCurrency} `}
thousandSeparator=","
/>
<Textarea
label="Reason (optional)"
value={reason}
onChange={(e) => setReason(e.currentTarget.value)}
autosize
minRows={2}
radius="md"
/>
<Group justify="space-between" mt={4}>
{isAdjusted ? (
<Button
size="compact-sm"
variant="subtle"
color="red"
loading={adjustMutation.isPending}
onClick={() =>
adjustMutation.mutate({ amount: null })
}
>
Clear adjustment
</Button>
) : (
<span />
)}
<Group gap="xs">
<Button
size="compact-sm"
variant="default"
onClick={() => setEditing(false)}
>
Cancel
</Button>
<Button
size="compact-sm"
color="edr-green"
loading={adjustMutation.isPending}
disabled={amount === ""}
onClick={() =>
adjustMutation.mutate({
amount: Number(amount),
reason: reason.trim() || undefined,
})
}
>
Save
</Button>
</Group>
</Group>
</Stack>
<Text size="xs" c="dimmed" fw={500} tt="uppercase" lts="0.04em">
{isAdjusted ? "Adjusted total" : "Total amount"}
</Text>
<Text
size="xl"
fw={700}
c="edr-green.9"
mt={4}
style={{ fontVariantNumeric: "tabular-nums", letterSpacing: "-0.5px" }}
>
{fmt(effective)}
</Text>
{isAdjusted && (
<Text size="xs" c="dimmed" mt={2}>
Computed: {fmt(computed)}
{booking.adjustmentReason ? ` · ${booking.adjustmentReason}` : ""}
</Text>
)}
</Paper>

View File

@@ -15,13 +15,6 @@ function isValidValidityDays(value: string): boolean {
return Number.isInteger(days) && days >= 1 && days <= 365;
}
/** An adjusted price must be a non-negative number. */
function isValidAmount(value: string): boolean {
if (!value.trim()) return false;
const amount = Number(value.trim());
return Number.isFinite(amount) && amount >= 0;
}
export function useBookingActionDialog(
bookingId: string,
context: BookingActionContext,
@@ -93,15 +86,6 @@ export function useBookingActionDialog(
{ onSuccess },
);
break;
case "operationAdjustPrice": {
const amount = Number(inputValue.trim());
if (!Number.isFinite(amount) || amount < 0) return;
mutations.reviewOperation.mutate(
{ decision: "ADJUST_PRICE", amount },
{ onSuccess },
);
break;
}
case "approve": {
const step = getNextPendingApprovalStep(mergedContext.approvalSteps);
if (!step) return;
@@ -151,8 +135,7 @@ export function useBookingActionDialog(
(pendingAction?.input === "file" && !selectedFile) ||
(pendingAction?.input === "reason" && !inputValue.trim()) ||
(pendingAction?.input === "note" && !inputValue.trim()) ||
(pendingAction?.input === "days" && !isValidValidityDays(inputValue)) ||
(pendingAction?.input === "amount" && !isValidAmount(inputValue));
(pendingAction?.input === "days" && !isValidValidityDays(inputValue));
return {
actions,

View File

@@ -1,9 +1,11 @@
import { useState } from "react";
import { useEffect, useMemo, useState } from "react";
import { useNavigate } from "react-router-dom";
import { useQuery } from "@tanstack/react-query";
import {
Anchor,
Button,
Modal,
NumberInput,
Select,
Stack,
Text,
Textarea,
@@ -19,9 +21,13 @@ import {
} from "lucide-react";
import type { Freight } from "@edr/types";
import { api } from "@/services/api";
import { SectionCard } from "@/components/bookings/detail/SectionCard";
import type { useContractMutations } from "@/hooks/contracts/useContracts";
/** Dropdown-settings code holding the admin-configured contract validity days. */
const CONTRACT_VALIDITY_PERIODS_CODE = "contract_validity_periods";
type Mutations = ReturnType<typeof useContractMutations>;
interface ContractActionsToolbarProps {
@@ -49,12 +55,34 @@ export function ContractActionsToolbar({
const { status } = contract;
const [acceptOpen, setAcceptOpen] = useState(false);
const [validityDays, setValidityDays] = useState<number | string>(365);
const [validityDays, setValidityDays] = useState<string | null>(null);
const [changesOpen, setChangesOpen] = useState(false);
const [changesNote, setChangesNote] = useState("");
const [rejectOpen, setRejectOpen] = useState(false);
const [rejectReason, setRejectReason] = useState("");
// Admin-configured validity durations (days) for the accept dialog. Staff can
// only pick one of these — no free-typing. Read-only setting, fetched once.
const { data: validitySetting, isLoading: validityLoading } = useQuery({
...api.dropdownSettings.getByCode.queryOptions({
input: { code: CONTRACT_VALIDITY_PERIODS_CODE },
}),
retry: false,
});
const validityOptions = useMemo(
() =>
[...(validitySetting?.children ?? [])]
.sort((a, b) => (a.order ?? 0) - (b.order ?? 0))
.map((o) => ({ value: String(o.value), label: o.label })),
[validitySetting],
);
// Default the selection to the first configured option when the dialog opens.
useEffect(() => {
if (acceptOpen && !validityDays && validityOptions.length > 0) {
setValidityDays(validityOptions[0].value);
}
}, [acceptOpen, validityDays, validityOptions]);
if (["REJECTED", "CANCELLED", "EXPIRED", "CONTRACT_CLOSED"].includes(status)) {
return null;
}
@@ -189,22 +217,48 @@ export function ContractActionsToolbar({
>
<Stack gap="md">
<Text size="sm" c="dimmed">
Set the contract validity window, then start the approval chain.
Pick the contract validity window, then start the approval chain.
</Text>
<NumberInput
label="Validity (days)"
min={1}
value={validityDays}
onChange={setValidityDays}
/>
{validityOptions.length > 0 ? (
<Select
label="Validity"
placeholder="Select a validity period"
data={validityOptions}
value={validityDays}
onChange={setValidityDays}
allowDeselect={false}
comboboxProps={{ withinPortal: true }}
/>
) : (
<Text size="sm" c="orange.7">
{validityLoading
? "Loading validity periods…"
: "No validity periods are configured yet. Add them under "}
{!validityLoading && (
<Anchor
href="/dashboard/dropdown-settings"
onClick={(e) => {
e.preventDefault();
navigate("/dashboard/dropdown-settings");
}}
>
Dropdown Settings
</Anchor>
)}
{!validityLoading && "."}
</Text>
)}
<Button
color="edr-green"
loading={mutations.staffAccept.isPending}
onClick={() =>
mutations.staffAccept.mutate(Number(validityDays) || 365, {
disabled={!validityDays}
onClick={() => {
const days = Number(validityDays);
if (!days) return;
mutations.staffAccept.mutate(days, {
onSuccess: () => setAcceptOpen(false),
})
}
});
}}
>
Accept
</Button>

View File

@@ -1,5 +1,10 @@
import { useMemo, useState } from "react";
import { useNavigate, useParams } from "react-router-dom";
import { useEffect, useMemo, useState } from "react";
import {
useNavigate,
useParams,
useSearchParams,
} from "react-router-dom";
import { useQuery } from "@tanstack/react-query";
import {
ActionIcon,
Alert,
@@ -22,6 +27,7 @@ import {
ThemeIcon,
} from "@mantine/core";
import {
AlertCircle,
CheckCircle2,
Container as ContainerIcon,
FileText,
@@ -32,10 +38,13 @@ import {
X,
} from "lucide-react";
import type { Freight } from "@edr/types";
import { OperationDatePicker } from "@edr/ui-common";
import { api } from "@/services/api";
import { PageContainer } from "@/components/page";
import { PageHeader } from "@/components/page/PageHeader";
import { SectionCard } from "@/components/bookings/detail/SectionCard";
import { contractsService } from "@/services/contracts.service";
import {
useContractCapacity,
useContractDetail,
@@ -74,16 +83,61 @@ function emptyUnit(): UnitDraft {
export default function GlCreateBookingForm() {
const { id } = useParams<{ id: string }>();
const [searchParams] = useSearchParams();
// When GL accepts a shipment request, the form opens with ?requestId=… so it
// can prefill the requested quantities/date and mark the request accepted on
// success.
const requestId = searchParams.get("requestId");
const navigate = useNavigate();
const { data: contract, isLoading } = useContractDetail(id);
const { data: capacity = [] } = useContractCapacity(id);
const mutations = useContractMutations(id ?? "");
const { data: bookingRequest } = useQuery({
queryKey: ["shipment-request", requestId],
queryFn: () => contractsService.getBookingRequest(requestId!),
enabled: Boolean(requestId),
});
const [scheduledDate, setScheduledDate] = useState("");
const [contractRouteId, setContractRouteId] = useState<string | null>(null);
const [notes, setNotes] = useState("");
const [containerLines, setContainerLines] = useState<ContainerLineDraft[]>([]);
const [bulkLines, setBulkLines] = useState<BulkLineDraft[]>([]);
const [prefilled, setPrefilled] = useState(false);
// Prefill once from an accepted shipment request: size/qty container lines
// (one blank unit per requested container) + bulk + route + notes. GL still
// enters per-unit container numbers + sets the binding shipment date.
useEffect(() => {
if (!bookingRequest || prefilled) return;
setPrefilled(true);
const lines = bookingRequest.requestedLines ?? {};
if (lines.containers?.length) {
setContainerLines(
lines.containers.map((c) => ({
containerSize: c.containerSize,
hazardousQuantity: c.hazardousQuantity ?? "",
reeferQuantity: c.reeferQuantity ?? "",
units: Array.from({ length: Math.max(1, c.quantity) }, () =>
emptyUnit(),
),
})),
);
} else if (lines.bulk) {
setBulkLines([
{
cargoTypeId: lines.bulk.cargoTypeId ?? "",
cargoWeightTons: lines.bulk.cargoWeightTons ?? "",
itemCount: lines.bulk.itemCount ?? "",
hazardousQuantity: lines.bulk.hazardousQuantity ?? "",
},
]);
}
if (bookingRequest.contractRouteId)
setContractRouteId(bookingRequest.contractRouteId);
if (bookingRequest.notes) setNotes(bookingRequest.notes);
}, [bookingRequest, prefilled]);
// Price-confirm modal — GL reviews the estimate before booking on behalf of
// the customer, mirroring the portal customer flow.
const [priceOpen, setPriceOpen] = useState(false);
@@ -146,6 +200,60 @@ export default function GlCreateBookingForm() {
[contract, quantities],
);
// The route this shipment ships on (for the cargo-aware day list). For a
// single-route contract there's exactly one; for GENERAL multi-route, the
// selected route (defaults to the first).
const selectedRoute = useMemo(
() => routes.find((r) => r.id === contractRouteId) ?? routes[0],
[routes, contractRouteId],
);
// Cargo-aware availability query: only days where a train has remaining
// capacity AND enough matching-type wagons for the entered cargo. Null until
// the cargo is entered (so the Schedule section stays empty first).
const cargoQuery = useMemo<Freight.AvailableDaysForCargoQuery | null>(() => {
if (!selectedRoute?.originYardId || !selectedRoute?.destinationYardId)
return null;
if (isContainer) {
const containers = containerLines
.map((l) => ({
containerSize: l.containerSize,
quantity: l.units.length,
}))
.filter((c) => c.quantity >= 1);
if (containers.length === 0) return null;
return {
originYardId: selectedRoute.originYardId,
destinationYardId: selectedRoute.destinationYardId,
freightType: "CONTAINER",
containers,
};
}
const tons = bulkLines.reduce(
(s, l) => s + Number(l.cargoWeightTons || 0),
0,
);
if (tons <= 0) return null;
return {
originYardId: selectedRoute.originYardId,
destinationYardId: selectedRoute.destinationYardId,
freightType: "BULK",
cargoTypeCode:
contract?.pricingBreakdown?.lineItems?.find((li) => li.cargoTypeCode)
?.cargoTypeCode ?? undefined,
totalWeightTons: tons,
};
}, [selectedRoute, isContainer, containerLines, bulkLines, contract?.pricingBreakdown]);
const { data: availableDays, isLoading: daysLoading } = useQuery({
...api.trainScheduling.availableDaysForCargo.queryOptions({
input: cargoQuery ?? {
freightType: "BULK" as const,
},
}),
enabled: cargoQuery !== null,
});
if (isLoading) {
return (
<PageContainer>
@@ -265,8 +373,20 @@ export default function GlCreateBookingForm() {
}
mutations.createBooking.mutate(payload, {
onSuccess: (booking) =>
navigate(`/dashboard/bookings/${booking.id}/milestones`),
onSuccess: async (booking) => {
if (requestId) {
// GENERAL+customs accept flow: mark the request accepted + link the
// booking, then hand off to the per-booking clearance review.
try {
await contractsService.acceptBookingRequest(requestId, booking.id);
} catch {
// Non-fatal — the booking exists; the request link can be retried.
}
navigate(`/dashboard/clearance/${booking.id}`);
} else {
navigate(`/dashboard/bookings/${booking.id}/milestones`);
}
},
});
};
@@ -312,38 +432,60 @@ export default function GlCreateBookingForm() {
</Group>
</Alert>
)}
<SectionCard icon={FileText} title="Schedule">
<Grid gap="md">
<Grid.Col span={{ base: 12, sm: 6 }}>
<TextInput
label="Scheduled date"
type="date"
description="Binding shipment day"
value={scheduledDate}
onChange={(e) => setScheduledDate(e.currentTarget.value)}
required
/>
</Grid.Col>
{needsRouteSelect && (
<Grid.Col span={{ base: 12, sm: 6 }}>
<Select
label="Route"
placeholder="Select contract route"
value={contractRouteId}
onChange={setContractRouteId}
data={routes.map((r) => ({
value: r.id,
label: `${r.originYard?.label ?? r.originYard?.code ?? "Origin"}${
r.destinationYard?.label ??
r.destinationYard?.code ??
"Destination"
}`,
}))}
required
/>
</Grid.Col>
)}
</Grid>
{bookingRequest ? (
<Alert
color="edr-green"
variant="light"
radius="md"
icon={<FileText size={16} />}
title="From shipment request"
>
Booking on behalf of the customer for request{" "}
<b>{bookingRequest.reference}</b>.
{bookingRequest.scheduledDate ? (
<>
{" "}
Customer requested{" "}
<b>
{new Intl.DateTimeFormat("en-GB", {
day: "2-digit",
month: "short",
year: "numeric",
}).format(new Date(bookingRequest.scheduledDate))}
</b>{" "}
set the binding shipment date below.
</>
) : null}
</Alert>
) : null}
<SectionCard icon={FileText} title="Route">
{needsRouteSelect ? (
<Select
label="Contract route"
placeholder="Select contract route"
value={contractRouteId}
onChange={setContractRouteId}
data={routes.map((r) => ({
value: r.id,
label: `${r.originYard?.label ?? r.originYard?.code ?? "Origin"}${
r.destinationYard?.label ??
r.destinationYard?.code ??
"Destination"
}`,
}))}
required
/>
) : (
<Text size="sm" c="dimmed">
{selectedRoute
? `${selectedRoute.originYard?.label ?? selectedRoute.originYard?.code ?? "Origin"}${
selectedRoute.destinationYard?.label ??
selectedRoute.destinationYard?.code ??
"Destination"
}`
: "This contract's only route."}
</Text>
)}
</SectionCard>
{isContainer ? (
@@ -610,6 +752,40 @@ export default function GlCreateBookingForm() {
</SectionCard>
)}
<SectionCard icon={FileText} title="Schedule">
{cargoQuery === null ? (
<Alert
color="yellow"
variant="light"
radius="md"
icon={<AlertCircle size={16} />}
>
Enter the cargo details first available shipment days depend on
the wagons the cargo needs.
</Alert>
) : (
<>
{bookingRequest?.scheduledDate ? (
<Text size="xs" c="dimmed" mb="xs">
Customer requested{" "}
{new Intl.DateTimeFormat("en-GB", {
day: "2-digit",
month: "short",
year: "numeric",
}).format(new Date(bookingRequest.scheduledDate))}{" "}
pick the binding shipment day below.
</Text>
) : null}
<OperationDatePicker
availableDays={availableDays ?? []}
isLoading={daysLoading}
value={scheduledDate}
onChange={setScheduledDate}
/>
</>
)}
</SectionCard>
<SectionCard icon={FileText} title="Notes">
<Textarea
placeholder="Internal GL notes (optional)"

View File

@@ -154,6 +154,15 @@ export const URL_CONSTANTS = {
OPS_CLEARANCE_HISTORY: "/contracts/clearance/ops-history",
BOOKINGS: (id: string) => `/contracts/${id}/bookings`,
CAPACITY: (id: string) => `/contracts/${id}/capacity`,
// Shipment requests (GENERAL + customs, Path B): customer → GL queue → booking.
BOOKING_REQUEST_QUEUE: "/contracts/booking-requests/queue",
BOOKING_REQUEST_BY_ID: (reqId: string) =>
`/contracts/booking-requests/${reqId}`,
BOOKING_REQUESTS: (id: string) => `/contracts/${id}/booking-requests`,
BOOKING_REQUEST_ACCEPT: (reqId: string) =>
`/contracts/booking-requests/${reqId}/accept`,
BOOKING_REQUEST_REJECT: (reqId: string) =>
`/contracts/booking-requests/${reqId}/reject`,
MILESTONES: (id: string) => `/contracts/${id}/milestones`,
BOOKING_MILESTONES: (bookingId: string) =>
`/contracts/bookings/${bookingId}/milestones`,
@@ -197,6 +206,7 @@ export const URL_CONSTANTS = {
ELIGIBLE_BOOKINGS: "/train-scheduling/eligible-bookings",
BOOKABLE_SCHEDULES: "/train-scheduling/bookable-schedules",
AVAILABLE_DAYS: "/train-scheduling/available-days",
AVAILABLE_DAYS_FOR_CARGO: "/train-scheduling/available-days-for-cargo",
AVAILABLE_LOCOMOTIVES: "/train-scheduling/available-locomotives",
BATCH_BOARD: "/train-scheduling/batch-board",
BATCH_BOARD_DETAIL: (scheduleId: string) =>

View File

@@ -2,7 +2,6 @@ import type { LucideIcon } from "lucide-react";
import {
Ban,
Check,
Coins,
FileSignature,
MessageSquareWarning,
Play,
@@ -36,7 +35,6 @@ export type BookingActionId =
| "complete"
| "operationAccept"
| "operationRequestChanges"
| "operationAdjustPrice"
| "cancel";
export type BookingActionInputKind =
@@ -196,20 +194,6 @@ const OPERATION_REVIEW_ACTIONS: BookingActionDef[] = [
inputLabel: "Message to customer",
inputPlaceholder: "Describe what needs to change…",
},
{
id: "operationAdjustPrice",
label: "Adjust price",
shortLabel: "Price",
description: "Set an adjusted total the customer must confirm",
confirmTitle: "Adjust the order price?",
confirmDescription:
"Enter the new total. The customer must confirm it before the order proceeds.",
variant: "outline",
icon: Coins,
input: "amount",
inputLabel: "Adjusted total",
inputPlaceholder: "0.00",
},
];
const CANCEL_ACTION: BookingActionDef = {
@@ -280,7 +264,6 @@ const ACTION_PERMISSION: Partial<Record<BookingActionId, string>> = {
complete: FREIGHT_PERMS.bookings.operations,
operationAccept: FREIGHT_PERMS.bookings.operations,
operationRequestChanges: FREIGHT_PERMS.bookings.operations,
operationAdjustPrice: FREIGHT_PERMS.bookings.operations,
allocateBooking: FREIGHT_PERMS.trainScheduling.manage,
cancel: FREIGHT_PERMS.bookings.cancel,
};

View File

@@ -63,9 +63,8 @@ export function useBookingMutations(bookingId: string) {
const reviewOperation = useMutation({
mutationFn: (payload: {
decision: "ACCEPT" | "REQUEST_CHANGES" | "ADJUST_PRICE";
decision: "ACCEPT" | "REQUEST_CHANGES";
note?: string;
amount?: number;
}) => api.bookings.reviewOperation.call({ id: bookingId, ...payload }),
onSuccess: (data) => onSuccess(data, "Operation request reviewed"),
onError: () => toast.error("Failed to review operation request"),

View File

@@ -0,0 +1,247 @@
import { useState } from "react";
import { useNavigate, useParams } from "react-router-dom";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import {
Alert,
Badge,
Box,
Button,
Group,
Loader,
Modal,
Stack,
Text,
Textarea,
} from "@mantine/core";
import {
AlertCircle,
CalendarDays,
PackagePlus,
XCircle,
} from "lucide-react";
import type { Freight } from "@edr/types";
import { PageContainer } from "@/components/page/PageContainer";
import { PageHeader } from "@/components/page/PageHeader";
import { SectionCard } from "@/components/bookings/detail/SectionCard";
import { contractsService } from "@/services/contracts.service";
const fmtDate = (iso?: string | null) =>
iso
? new Intl.DateTimeFormat("en-GB", {
weekday: "short",
day: "2-digit",
month: "short",
year: "numeric",
}).format(new Date(iso))
: "—";
function lineRows(lines: Freight.RequestedShipmentLines) {
if (lines.containers?.length) {
return lines.containers.map(
(c) =>
`${c.quantity} × ${c.containerSize}` +
(c.hazardousQuantity ? ` · ${c.hazardousQuantity} hazardous` : "") +
(c.reeferQuantity ? ` · ${c.reeferQuantity} reefer` : ""),
);
}
if (lines.bulk) {
const b = lines.bulk;
const parts: string[] = [];
if (b.cargoWeightTons) parts.push(`${b.cargoWeightTons} tons`);
if (b.itemCount) parts.push(`${b.itemCount} items`);
if (b.hazardousQuantity) parts.push(`${b.hazardousQuantity} hazardous`);
return [parts.join(" · ") || "Bulk cargo"];
}
return ["—"];
}
export default function ShipmentRequestDetailPage() {
const { id: reqId } = useParams<{ id: string }>();
const navigate = useNavigate();
const queryClient = useQueryClient();
const [rejectOpen, setRejectOpen] = useState(false);
const [rejectNote, setRejectNote] = useState("");
const { data: request, isLoading } = useQuery({
queryKey: ["shipment-request", reqId],
queryFn: () => contractsService.getBookingRequest(reqId!),
enabled: Boolean(reqId),
});
const reject = useMutation({
mutationFn: () => contractsService.rejectBookingRequest(reqId!, rejectNote),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ["shipment-request-queue"] });
navigate("/dashboard/shipment-requests");
},
});
if (isLoading) {
return (
<PageContainer>
<Group justify="center" py={80}>
<Loader color="edr-green" />
</Group>
</PageContainer>
);
}
if (!request) {
return (
<PageContainer>
<PageHeader title="Request not found" backTo="/dashboard/shipment-requests" />
<Alert color="red" radius="md" icon={<AlertCircle size={16} />}>
We couldn't load this shipment request.
</Alert>
</PageContainer>
);
}
const isPending = request.status === "PENDING";
const contractRef = request.contract?.reference ?? request.contractId;
return (
<PageContainer>
<Stack gap="lg">
<PageHeader
title={`Shipment request ${request.reference}`}
subtitle={`On contract ${contractRef}`}
backTo="/dashboard/shipment-requests"
breadcrumbs={[
{ label: "Shipment Requests", href: "/dashboard/shipment-requests" },
{ label: request.reference },
]}
meta={
<Badge
variant="light"
radius="sm"
color={
request.status === "PENDING"
? "edr-green"
: request.status === "ACCEPTED"
? "blue"
: "gray"
}
>
{request.status}
</Badge>
}
action={
isPending ? (
<Group gap="sm">
<Button
variant="light"
color="red"
radius="md"
leftSection={<XCircle size={16} />}
onClick={() => setRejectOpen(true)}
>
Reject
</Button>
<Button
color="edr-green"
radius="md"
leftSection={<PackagePlus size={16} />}
onClick={() =>
navigate(
`/dashboard/contracts/${request.contractId}/create-booking?requestId=${request.id}`,
)
}
>
Accept &amp; create booking
</Button>
</Group>
) : request.status === "ACCEPTED" && request.createdBookingId ? (
<Button
variant="light"
color="edr-green"
radius="md"
onClick={() =>
navigate(`/dashboard/clearance/${request.createdBookingId}`)
}
>
View booking clearance
</Button>
) : undefined
}
/>
<SectionCard icon={CalendarDays} title="Requested shipment">
<Stack gap="sm">
<Group justify="space-between">
<Text size="sm" c="dimmed">
Preferred date (informational)
</Text>
<Text size="sm" fw={600}>
{fmtDate(request.scheduledDate)}
</Text>
</Group>
<Box>
<Text size="sm" c="dimmed" mb={6}>
Quantities
</Text>
<Stack gap={4}>
{lineRows(request.requestedLines ?? {}).map((l, i) => (
<Badge
key={i}
variant="light"
color="edr-green"
radius="sm"
size="lg"
>
{l}
</Badge>
))}
</Stack>
</Box>
{request.notes ? (
<Box>
<Text size="sm" c="dimmed" mb={4}>
Customer note
</Text>
<Text size="sm">{request.notes}</Text>
</Box>
) : null}
{request.reviewNote ? (
<Alert color="red" variant="light" radius="md" mt="sm">
Rejected: {request.reviewNote}
</Alert>
) : null}
</Stack>
</SectionCard>
</Stack>
<Modal
opened={rejectOpen}
onClose={() => setRejectOpen(false)}
centered
radius="md"
title="Reject shipment request"
>
<Stack gap="md">
<Textarea
label="Reason"
placeholder="Tell the customer why this request can't proceed"
autosize
minRows={3}
value={rejectNote}
onChange={(e) => setRejectNote(e.currentTarget.value)}
/>
<Group justify="flex-end">
<Button variant="default" onClick={() => setRejectOpen(false)}>
Cancel
</Button>
<Button
color="red"
loading={reject.isPending}
onClick={() => reject.mutate()}
>
Reject request
</Button>
</Group>
</Stack>
</Modal>
</PageContainer>
);
}

View File

@@ -0,0 +1,208 @@
import { useMemo, useState } from "react";
import { useNavigate } from "react-router-dom";
import { useQuery } from "@tanstack/react-query";
import {
ActionIcon,
Badge,
Box,
Group,
Stack,
Text,
TextInput,
} from "@mantine/core";
import { ChevronRight, Inbox, PackageSearch, RefreshCw, Search } from "lucide-react";
import { DataTable, type ColumnDef } from "@edr/ui-common";
import type { Freight } from "@edr/types";
import { PageContainer } from "@/components/page/PageContainer";
import { PageHeader } from "@/components/page/PageHeader";
import { ruleEngineTable } from "@/components/ruleEngine/ruleEngineStyles";
import { contractsService } from "@/services/contracts.service";
const cellMeta = {
headerClassName: ruleEngineTable.headerCell,
cellClassName: ruleEngineTable.bodyCell,
};
const fmtDate = (iso?: string | null) =>
iso
? new Intl.DateTimeFormat("en-GB", {
day: "2-digit",
month: "short",
year: "numeric",
}).format(new Date(iso))
: "—";
/** Summarize requested quantities for the list row. */
function summarizeLines(lines: Freight.RequestedShipmentLines): string {
if (lines.containers?.length) {
return lines.containers
.map((c) => `${c.quantity}× ${c.containerSize}`)
.join(", ");
}
if (lines.bulk) {
const b = lines.bulk;
if (b.cargoWeightTons) return `${b.cargoWeightTons} t bulk`;
if (b.itemCount) return `${b.itemCount} items`;
return "Bulk";
}
return "—";
}
interface RequestRow {
id: string;
reference: string;
contractReference: string;
scheduledDate?: string | null;
summary: string;
}
export default function ShipmentRequestsPage() {
const navigate = useNavigate();
const [query, setQuery] = useState("");
const { data, isLoading, isError, isFetching, refetch } = useQuery({
queryKey: ["shipment-request-queue"],
queryFn: () => contractsService.getBookingRequestQueue(),
refetchInterval: 30_000,
});
const rows = useMemo<RequestRow[]>(() => {
const all = (data ?? []).map((r) => ({
id: r.id,
reference: r.reference || r.id.slice(0, 8),
contractReference: r.contract?.reference ?? r.contractId,
scheduledDate: r.scheduledDate,
summary: summarizeLines(r.requestedLines ?? {}),
}));
const q = query.trim().toLowerCase();
if (!q) return all;
return all.filter(
(r) =>
r.reference.toLowerCase().includes(q) ||
r.contractReference.toLowerCase().includes(q) ||
r.summary.toLowerCase().includes(q),
);
}, [data, query]);
const columns = useMemo<ColumnDef<RequestRow>[]>(
() => [
{
id: "reference",
header: "Request",
meta: cellMeta,
cell: ({ row }) => (
<Text size="sm" fw={700} c="dark.5">
{row.original.reference}
</Text>
),
},
{
id: "contract",
header: "Contract",
meta: cellMeta,
cell: ({ row }) => (
<Text size="sm" c="gray.7">
{row.original.contractReference}
</Text>
),
},
{
id: "summary",
header: "Requested",
meta: cellMeta,
cell: ({ row }) => (
<Badge variant="light" color="edr-green" radius="sm">
{row.original.summary}
</Badge>
),
},
{
id: "date",
header: "Preferred date",
meta: cellMeta,
cell: ({ row }) => (
<Text size="sm">{fmtDate(row.original.scheduledDate)}</Text>
),
},
{
id: "go",
size: 56,
cell: () => (
<Group justify="flex-end" pr="xs">
<ChevronRight size={16} className="text-muted-foreground" />
</Group>
),
},
],
[],
);
return (
<PageContainer>
<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."
meta={
<Badge
variant="light"
color="edr-green"
radius="sm"
leftSection={<PackageSearch size={13} />}
>
{rows.length} pending
</Badge>
}
action={
<ActionIcon
variant="default"
size="lg"
radius="md"
onClick={() => refetch()}
loading={isFetching}
aria-label="Refresh"
>
<RefreshCw size={16} />
</ActionIcon>
}
/>
<TextInput
radius="md"
maw={360}
placeholder="Search request, contract, cargo…"
leftSection={<Search size={15} />}
value={query}
onChange={(e) => setQuery(e.currentTarget.value)}
/>
{rows.length === 0 && !isLoading ? (
<Box
py={56}
style={{
borderRadius: 14,
border: "1px dashed var(--mantine-color-gray-3)",
textAlign: "center",
}}
>
<Inbox size={26} className="text-muted-foreground" />
<Text c="dimmed" mt="sm">
No pending shipment requests.
</Text>
</Box>
) : (
<DataTable
columns={columns}
data={rows}
status={isLoading ? "loading" : isError ? "error" : "success"}
onRowClick={(row) =>
navigate(`/dashboard/shipment-requests/${row.id}`)
}
containerClassName="overflow-x-auto rounded-lg border border-edr-border"
/>
)}
</Stack>
</PageContainer>
);
}

View File

@@ -284,6 +284,32 @@ export const api = {
],
),
availableDaysForCargo: endpoint<
{
originYardId?: string;
destinationYardId?: string;
freightType: "CONTAINER" | "BULK";
cargoTypeCode?: string;
totalWeightTons?: number;
containers?: { containerSize: string; quantity: number }[];
},
string[]
>(
"train-scheduling",
"available-days-for-cargo",
(input) => trainSchedulingService.getAvailableDaysForCargo(input),
(input) => [
...QUERY_KEYS.TRAIN_SCHEDULING.ROOT,
"available-days-for-cargo",
input.originYardId ?? "",
input.destinationYardId ?? "",
input.freightType,
input.cargoTypeCode ?? "",
input.totalWeightTons ?? 0,
JSON.stringify(input.containers ?? []),
],
),
trainTrack: endpoint<{ id: string }, TrainTrackResponse>(
"train-scheduling",
"track",
@@ -1838,13 +1864,12 @@ export const api = {
reviewOperation: endpoint<
{
id: string;
decision: "ACCEPT" | "REQUEST_CHANGES" | "ADJUST_PRICE";
decision: "ACCEPT" | "REQUEST_CHANGES";
note?: string;
amount?: number;
},
BookingDetail
>("bookings", "reviewOperation", ({ id, decision, note, amount }) =>
bookingsService.reviewOperation(id, decision, { note, amount }),
>("bookings", "reviewOperation", ({ id, decision, note }) =>
bookingsService.reviewOperation(id, decision, { note }),
),
approveStep: endpoint<ApproveStepPayload, BookingDetail>(

View File

@@ -212,21 +212,14 @@ export const bookingsService = {
/** Marketing/operations review of a drawdown order's operation request. */
reviewOperation: (
id: string,
decision: "ACCEPT" | "REQUEST_CHANGES" | "ADJUST_PRICE",
options: { note?: string; amount?: number } = {},
decision: "ACCEPT" | "REQUEST_CHANGES",
options: { note?: string } = {},
) =>
postBooking<BookingDetail>(`/bookings/${id}/operation/review`, {
decision,
...options,
}),
/** Adjust a booking's total price (pass null amount to clear the adjustment). */
adjustPrice: (id: string, amount: number | null, reason?: string) =>
postBooking<BookingDetail>(`/bookings/${id}/adjust-price`, {
amount,
reason,
}),
approveStep: ({ id, stepId, requiredRole }: ApproveStepPayload) =>
postBooking<BookingDetail>(B.APPROVE_STEP(id, stepId), { requiredRole }),

View File

@@ -244,6 +244,37 @@ export const contractsService = {
return (unwrap(response.data) ?? []) as Freight.ContractCapacityLine[];
},
// ── Shipment requests (GENERAL + customs) ──
/** GL queue of pending shipment requests across contracts. */
getBookingRequestQueue: async (): Promise<Freight.IBookingRequest[]> => {
const response = await client.get(C.BOOKING_REQUEST_QUEUE);
return (unwrap(response.data) ?? []) as Freight.IBookingRequest[];
},
listBookingRequests: async (
id: string,
): Promise<Freight.IBookingRequest[]> => {
const response = await client.get(C.BOOKING_REQUESTS(id));
return (unwrap(response.data) ?? []) as Freight.IBookingRequest[];
},
getBookingRequest: async (
reqId: string,
): Promise<Freight.IBookingRequest> => {
const response = await client.get(C.BOOKING_REQUEST_BY_ID(reqId));
return unwrap(response.data) as Freight.IBookingRequest;
},
acceptBookingRequest: (reqId: string, bookingId: string) =>
postContract<Freight.IBookingRequest>(C.BOOKING_REQUEST_ACCEPT(reqId), {
bookingId,
}),
rejectBookingRequest: (reqId: string, note?: string) =>
postContract<Freight.IBookingRequest>(C.BOOKING_REQUEST_REJECT(reqId), {
note,
}),
// ── Clearance milestones ──
listMilestonesForContract: async (
id: string,

View File

@@ -1,3 +1,4 @@
import type { Freight } from "@edr/types";
import { api as client } from "../auth/http";
import { unwrap } from "@/utils/endpoint";
import { URL_CONSTANTS } from "@/constants/URLS";
@@ -127,6 +128,24 @@ export const trainSchedulingService = {
return unwrap(response.data).days;
},
// Cargo-aware day pool (matching wagons + open train capacity). `containers`
// is serialized as a JSON string param (the server parses it).
getAvailableDaysForCargo: async (
query: Freight.AvailableDaysForCargoQuery,
): Promise<string[]> => {
const { containers, ...rest } = query;
const response = await client.get<{ days: string[] }>(
URL_CONSTANTS.TRAIN_SCHEDULING.AVAILABLE_DAYS_FOR_CARGO,
{
params: {
...rest,
...(containers ? { containers: JSON.stringify(containers) } : {}),
},
},
);
return unwrap(response.data).days;
},
runBatch: async (scheduleId: string): Promise<BatchBoardScheduleDetail> => {
const response = await client.post<BatchBoardScheduleDetail>(
URL_CONSTANTS.TRAIN_SCHEDULING.RUN_BATCH(scheduleId),