Merge branch 'dev' into freight/nati-2

This commit is contained in:
Nathnael
2026-08-07 08:09:34 +00:00
79 changed files with 7273 additions and 802 deletions

View File

@@ -29,6 +29,7 @@ import {
Wallet,
LifeBuoy,
TrainFront,
XCircle,
} from "lucide-react";
import { useEffect } from "react";
import {
@@ -54,6 +55,7 @@ import BookingContractPage from "./pages/bookings/BookingContractPage";
import BookingRequestDetailPage from "./pages/bookings/BookingRequestDetailPage";
import BookingRequestsPage from "./pages/bookings/BookingRequestsPage";
import NewBookingPage from "./pages/bookings/NewBookingPage";
import WagonCancellationsPage from "./pages/bookings/WagonCancellationsPage";
import ContractRequestsPage from "./pages/contracts/ContractRequestsPage";
import ContractRequestDetailPage from "./pages/contracts/ContractRequestDetailPage";
import ContractViewPage from "./pages/contracts/ContractViewPage";
@@ -184,6 +186,12 @@ const buildSidebarSections = (demoItems: SidebarItem[]): SidebarSection[] => [
icon: <FileText />,
permission: FREIGHT_PERMS.bookings.view,
},
{
label: "Wagon cancellations",
href: "/dashboard/wagon-cancellations",
icon: <XCircle />,
permission: FREIGHT_PERMS.bookings.wagonCancellationView,
},
// Operations hub: per-shipment clearance-document review for services
// WITHOUT customs clearing (self-clearance) — bookings only.
{
@@ -901,6 +909,16 @@ const App = () => {
}
/>
<Route path="booking-requests/new" element={<RequirePermission permission={FREIGHT_PERMS.bookings.view}><NewBookingPage /></RequirePermission>} />
<Route
path="wagon-cancellations"
element={
<RequirePermission
permission={FREIGHT_PERMS.bookings.wagonCancellationView}
>
<WagonCancellationsPage />
</RequirePermission>
}
/>
<Route
path="booking-requests/:id"
element={

View File

@@ -23,7 +23,13 @@ interface AuthEmployeePosition {
permissions?: AuthPermission[];
/** Some IAM payloads nest the position record instead of flattening its key. */
position?: { id?: string; key?: string; name?: LocaleText };
positionType?: { id?: string; key?: string; name?: LocaleText } | null;
positionType?: {
id?: string;
key?: string;
name?: LocaleText;
/** Grants held by the TYPE — where admin-created positions keep theirs. */
permissions?: AuthPermission[];
} | null;
}
interface AuthEmployeeRecord {

View File

@@ -1,6 +1,6 @@
import type { LucideIcon } from "lucide-react";
import type { ReactNode } from "react";
import { Hash, Package, Ship, Weight, Clock } from "lucide-react";
import { Hash, Package, Ship, Weight, Clock, TrainFront } from "lucide-react";
import { Group, Stack, Text, Divider } from "@mantine/core";
import { cargoTonsAndItems } from "@/utils/cargoWeight";
@@ -50,6 +50,21 @@ export function BookingFactsCard({ booking }: BookingFactsCardProps) {
},
{ icon: Clock, label: "Last Updated", value: formatDate(booking.updatedAt) },
];
// Allocated train facts — only once the booking rides a schedule.
if (booking.trainSchedule?.trainNumber || booking.trainSchedule?.reference) {
facts.splice(1, 0, {
icon: TrainFront,
label: "Allocated Train",
value: [
booking.trainSchedule.trainNumber
? `Train ${booking.trainSchedule.trainNumber}`
: null,
booking.trainSchedule.reference ?? null,
]
.filter(Boolean)
.join(" · "),
});
}
return (
<SectionCard icon={Hash} title="Booking Details" accent="cyan">

View File

@@ -144,4 +144,10 @@ export interface BookingDetailView {
bookingContainers?: BookingContainerView[];
reviewNotes?: BookingReviewNoteView[];
files?: BookingFileView[];
/** The allocated train, present once the booking is placed on a schedule. */
trainSchedule?: {
trainNumber: string | null;
reference: string | null;
scheduledDepartureDate: string | null;
} | null;
}

View File

@@ -1,6 +1,6 @@
import { Alert, Button, Group, Paper, Stack, Text } from "@mantine/core";
import { DateInput } from "@mantine/dates";
import { AlertTriangle, Send } from "lucide-react";
import { AlertTriangle, Pencil, Send } from "lucide-react";
import { useState } from "react";
import { Link } from "react-router-dom";
import toast from "react-hot-toast";
@@ -16,6 +16,9 @@ export interface BookingChangesRequestedAlertProps {
scheduledDate?: string | null;
/** GL Ethiopia owns customs bookings, so only they get the resubmit control. */
canResubmit: boolean;
/** Completion-form route for editing the cargo before resubmitting —
* rendered only for resubmit-capable users when provided. */
editHref?: string;
onResubmitted?: () => void;
}
@@ -33,6 +36,7 @@ export function BookingChangesRequestedAlert({
note,
scheduledDate,
canResubmit,
editHref,
onResubmitted,
}: BookingChangesRequestedAlertProps) {
const [day, setDay] = useState<Date | null>(
@@ -122,6 +126,18 @@ export function BookingChangesRequestedAlert({
>
Resubmit to Operations
</Button>
{editHref ? (
<Button
component={Link}
to={editHref}
variant="default"
radius="md"
size="sm"
leftSection={<Pencil size={15} />}
>
Edit cargo & resubmit
</Button>
) : null}
</Group>
) : null}
</Stack>

View File

@@ -225,7 +225,13 @@ export function ExportClearanceStepper({
<Stepper.Step
label="Request transit assignee"
description="Ask GL Djibouti to name the officer handling this shipment"
// Description is the only part of a passed step that stays visible,
// so it carries the assigned officer's name for GL Ethiopia.
description={
clearance.transitAssignee?.name
? `Transit assignee: ${clearance.transitAssignee.name}`
: "Ask GL Djibouti to name the officer handling this shipment"
}
icon={
clearance.transitAssignee?.name ? (
<CheckCircle2 size={14} />

View File

@@ -961,7 +961,7 @@ export default function GlCreateBookingForm() {
// modal falls back to the contract unit-rate estimate while it loads.
const validateShipmentMutation = useMutation({
mutationFn: (dto: Freight.CreateBookingUnderContractDto) =>
contractsService.validateShipment(id ?? "", dto),
contractsService.validateShipment(id ?? "", dto, completeBookingId),
});
const validation = validateShipmentMutation.data ?? null;

View File

@@ -39,6 +39,8 @@ interface WindowRow {
windowClosesAt: string | null;
docReviewEndsAt: string | null;
paymentPhaseEndsAt: string | null;
/** End of the payment drain tail — pending payments may settle until then. */
paymentDrainEndsAt?: string | null;
bookingWindowStatus: string;
bookingCycleNo: number;
departureDate: string;
@@ -94,16 +96,29 @@ const COUNTDOWN_TEXT: Partial<
PRE_WINDOW: { label: "Opens in", expiredText: "Opening now…" },
OPEN: { label: "Closes in", expiredText: "Review starting…" },
DOC_REVIEW: { label: "Doc review ends in", expiredText: "Payment starting…" },
PAYMENT: { label: "Payment ends in", expiredText: "Closing…" },
PAYMENT: { label: "Payment ends in", expiredText: "Finalizing…" },
};
function phaseCountdown(
w: WindowRow,
): { label: string; deadline: string; expiredText: string } | null {
function phaseCountdown(w: WindowRow): {
label: string;
deadline: string;
expiredText: string;
graceDeadline?: string | null;
graceLabel?: string;
} | null {
const state = bookingWindowUiState(w);
const text = COUNTDOWN_TEXT[state.kind];
if (!state.countdownTo || !text) return null;
return { ...text, deadline: state.countdownTo };
// Once the pay deadline lapses, pending payments still settle during the
// drain tail — count it down as "processing" instead of a stale "closing".
const grace =
state.kind === "PAYMENT" && w.paymentDrainEndsAt
? {
graceDeadline: w.paymentDrainEndsAt,
graceLabel: "Processing payments — closes in",
}
: undefined;
return { ...text, deadline: state.countdownTo, ...grace };
}
/** Badge label + Mantine color per UI state — same state the countdown uses. */
@@ -225,6 +240,8 @@ function WindowCard({ w }: { w: WindowRow }) {
deadline={cd.deadline}
label={cd.label}
expiredText={cd.expiredText}
graceDeadline={cd.graceDeadline}
graceLabel={cd.graceLabel}
size="xs"
/>
</Box>

View File

@@ -357,7 +357,14 @@ export function PhasedClearanceActionPanel({
<Stepper.Step
label="Request transit assignee"
description="Ask GL Djibouti to name the officer handling this shipment"
// Once the flow moves past this step its content collapses — the
// description is the only slot that stays visible, so it carries
// the assigned officer's name for GL Ethiopia.
description={
clearance.transitAssignee?.name
? `Transit assignee: ${clearance.transitAssignee.name}`
: "Ask GL Djibouti to name the officer handling this shipment"
}
icon={
clearance.transitAssignee?.name ? (
<CheckCircle2 size={14} />

View File

@@ -30,6 +30,7 @@ interface WindowRow {
windowClosesAt: string | null;
docReviewEndsAt: string | null;
paymentPhaseEndsAt: string | null;
paymentDrainEndsAt?: string | null;
bookingWindowStatus: string;
bookingCycleNo: number;
departureDate: string;
@@ -55,6 +56,7 @@ function applyEvent<T extends WindowRow>(row: T, event: BookingWindowPhaseEvent)
windowClosesAt: event.windowClosesAt,
docReviewEndsAt: event.docReviewEndsAt,
paymentPhaseEndsAt: event.paymentPhaseEndsAt,
paymentDrainEndsAt: event.paymentDrainEndsAt,
departureDate: event.scheduledDepartureDate ?? row.departureDate,
};
}

View File

@@ -53,6 +53,10 @@ export const FREIGHT_PERMS = {
finalizeClearance: "edr_freight_app:bookings:finalize_clearance",
docReviewAlert: "edr_freight_app:bookings:doc_review_alert",
governmentExpedite: "edr_freight_app:bookings:government_expedite",
wagonCancellationView: "edr_freight_app:bookings:wagon_cancellation_view",
wagonCancellationVoid: "edr_freight_app:bookings:wagon_cancellation_void",
wagonCancellationRebook:
"edr_freight_app:bookings:wagon_cancellation_rebook",
},
contracts: {
view: "edr_freight_app:contracts:view",
@@ -382,6 +386,14 @@ export function getPermissionKeys(user: AuthUser | null | undefined): string[] {
for (const p of pos.permissions ?? []) {
if (p.key) keys.add(p.key);
}
// Positions created through the admin UI keep their grants on the
// position TYPE, not the position — miss these and such staff resolve to
// zero permissions and every gated route rejects them. `/api/me` folds
// them into the position's permission list, but older payloads may still
// carry them separately.
for (const p of pos.positionType?.permissions ?? []) {
if (p.key) keys.add(p.key);
}
}
}
return [...keys];
@@ -597,7 +609,9 @@ export function canViewScheduling(user: AuthUser | null | undefined): boolean {
}
/** Any train-scheduling write action (create / update / cancel / reschedule). */
export function canManageScheduling(user: AuthUser | null | undefined): boolean {
export function canManageScheduling(
user: AuthUser | null | undefined,
): boolean {
return (
hasPermission(user, FREIGHT_PERMS.trainScheduling.create) ||
hasPermission(user, FREIGHT_PERMS.trainScheduling.update) ||

View File

@@ -0,0 +1,420 @@
import {
Anchor,
Badge,
Box,
Button,
Card,
Group,
Modal,
Select,
Stack,
Text,
TextInput,
} from "@mantine/core";
import { DateInput } from "@mantine/dates";
import { useDebouncedValue } from "@mantine/hooks";
import { useMutation, useQuery } from "@tanstack/react-query";
import { Search, XCircle } from "lucide-react";
import { useMemo, useState } from "react";
import toast from "react-hot-toast";
import { Link } from "react-router-dom";
import { api } from "@/auth/http";
import { useAuth } from "@/auth/useAuth";
import { PageContainer, PageHeader } from "@/components/page";
import { toDayString } from "@/hooks/useListControls";
import { FREIGHT_PERMS, hasPermission } from "@/lib/permissions";
import {
DataTable,
DataTableFooter,
usePagination,
type ColumnDef,
} from "@edr/ui-common";
type WagonCancellationStatus =
| "FEE_PENDING"
| "CREDIT_AVAILABLE"
| "REBOOKED"
| "WITHDRAWN"
| "EXPIRED";
interface WagonCancellation {
id: string;
bookingId: string;
rebookedBookingId?: string | null;
wagonsCancelled: number;
weightTons: number;
creditAmount: number;
feeAmount: number;
feeCurrency: string;
feeInvoiceId?: string | null;
feePaidAt?: string | null;
status: WagonCancellationStatus;
reason?: string | null;
rebookedAt?: string | null;
createdAt: string;
booking?: { id: string; reference: string; company?: { name: string } };
rebookedBooking?: { id: string; reference: string };
feeInvoice?: { invoiceNumber: string; status: string };
}
interface WagonCancellationListResponse {
items: WagonCancellation[];
total: number;
}
const STATUS_CHIP: Record<
WagonCancellationStatus,
{ label: string; color: string }
> = {
FEE_PENDING: { label: "Fee pending", color: "yellow" },
CREDIT_AVAILABLE: { label: "Credit available", color: "edr-green" },
REBOOKED: { label: "Rebooked", color: "indigo" },
WITHDRAWN: { label: "Withdrawn", color: "gray" },
EXPIRED: { label: "Expired", color: "red" },
};
const STATUS_FILTER_OPTIONS = (
Object.keys(STATUS_CHIP) as WagonCancellationStatus[]
).map((s) => ({ value: s, label: STATUS_CHIP[s].label }));
function StatusChip({ status }: { status: WagonCancellationStatus }) {
const chip = STATUS_CHIP[status] ?? { label: status, color: "gray" };
return (
<Badge
color={chip.color}
variant="light"
size="sm"
radius="md"
tt="uppercase"
fw={600}
style={{ fontSize: "0.7rem", letterSpacing: "0.05em" }}
>
{chip.label}
</Badge>
);
}
function formatDate(iso: string | null | undefined): string {
if (!iso) return "—";
const d = new Date(iso);
return Number.isNaN(d.getTime())
? "—"
: d.toLocaleDateString(undefined, {
year: "numeric",
month: "short",
day: "numeric",
});
}
function formatAmount(amount: number, currency: string): string {
return `${currency} ${Number(amount).toLocaleString(undefined, {
minimumFractionDigits: 2,
})}`;
}
/**
* Staff view of partial wagon cancellations: every slice of capacity a
* customer gave back, its cancellation fee, and where the credit went
* (rebooked, still available, expired, or the request was voided).
*/
export default function WagonCancellationsPage() {
const { user } = useAuth();
const canVoid = hasPermission(
user,
FREIGHT_PERMS.bookings.wagonCancellationVoid,
);
const { pagination, setPagination } = usePagination({ pageSize: 10 });
const [status, setStatus] = useState<string | null>(null);
const [search, setSearch] = useState("");
const [debouncedSearch] = useDebouncedValue(search, 300);
const [from, setFrom] = useState<Date | null>(null);
const [to, setTo] = useState<Date | null>(null);
const [voiding, setVoiding] = useState<WagonCancellation | null>(null);
const resetPage = () =>
setPagination({ pageIndex: 0, pageSize: pagination.pageSize });
const filter = useMemo(
() => ({
page: pagination.pageIndex + 1,
pageSize: pagination.pageSize,
...(status ? { statuses: status } : {}),
...(debouncedSearch.trim() ? { search: debouncedSearch.trim() } : {}),
...(from ? { from: toDayString(from) } : {}),
...(to ? { to: toDayString(to) } : {}),
}),
[pagination.pageIndex, pagination.pageSize, status, debouncedSearch, from, to],
);
const { data, isLoading, isError, refetch } = useQuery({
queryKey: ["bookings", "wagon-cancellations", filter],
queryFn: async () => {
const res = await api.get<WagonCancellationListResponse>(
"/bookings/wagon-cancellations/history",
{ params: filter },
);
return res.data;
},
});
const rows = data?.items ?? [];
const total = data?.total ?? 0;
const pageCount = Math.max(1, Math.ceil(total / pagination.pageSize));
const withdraw = useMutation({
mutationFn: (id: string) =>
api.post(`/bookings/wagon-cancellations/${id}/withdraw`),
});
const columns: ColumnDef<WagonCancellation>[] = [
{
id: "requested",
header: () => <span>Requested</span>,
cell: ({ row }) => (
<Text size="xs" c="dimmed">
{formatDate(row.original.createdAt)}
</Text>
),
},
{
id: "booking",
header: () => <span>Booking</span>,
cell: ({ row }) => (
<Anchor
component={Link}
to={`/dashboard/booking-requests/${row.original.bookingId}`}
size="sm"
fw={600}
>
{row.original.booking?.reference ?? row.original.bookingId}
</Anchor>
),
},
{
id: "company",
header: () => <span>Company</span>,
cell: ({ row }) => (
<Text size="sm">{row.original.booking?.company?.name ?? "—"}</Text>
),
},
{
id: "wagons",
header: () => <span>Wagons</span>,
cell: ({ row }) => <Text size="sm">{row.original.wagonsCancelled}</Text>,
},
{
id: "fee",
header: () => <span>Fee</span>,
cell: ({ row }) => (
<Text size="sm" style={{ fontVariantNumeric: "tabular-nums" }}>
{formatAmount(row.original.feeAmount, row.original.feeCurrency)}
</Text>
),
},
{
id: "credit",
header: () => <span>Credit</span>,
cell: ({ row }) => (
<Text size="sm" style={{ fontVariantNumeric: "tabular-nums" }}>
{formatAmount(row.original.creditAmount, row.original.feeCurrency)}
</Text>
),
},
{
id: "status",
header: () => <span>Status</span>,
cell: ({ row }) => <StatusChip status={row.original.status} />,
},
{
id: "rebookedAs",
header: () => <span>Rebooked as</span>,
cell: ({ row }) => {
const r = row.original;
if (!r.rebookedBookingId) return <Text size="sm"></Text>;
return (
<Anchor
component={Link}
to={`/dashboard/booking-requests/${r.rebookedBookingId}`}
size="sm"
>
{r.rebookedBooking?.reference ?? r.rebookedBookingId}
</Anchor>
);
},
},
{
id: "actions",
header: () => <span />,
cell: ({ row }) => {
const r = row.original;
if (r.status !== "FEE_PENDING" || !canVoid) return null;
return (
<Group justify="flex-end" wrap="nowrap">
<Button
size="xs"
radius="md"
variant="subtle"
color="red"
onClick={() => setVoiding(r)}
>
Void
</Button>
</Group>
);
},
},
];
return (
<PageContainer>
<Stack gap="lg">
<PageHeader
title="Wagon cancellations"
subtitle="Partial wagon cancellations — fees charged, credits held, and where each credit was rebooked"
breadcrumbs={[
{ label: "Bookings", href: "/dashboard/booking-requests" },
{ label: "Wagon cancellations" },
]}
/>
<Card withBorder radius="md" p="md">
<Stack gap="md">
<Group gap="sm" wrap="wrap">
<TextInput
placeholder="Search booking ref or company…"
leftSection={<Search size={15} />}
value={search}
onChange={(e) => {
setSearch(e.currentTarget.value);
resetPage();
}}
w={260}
radius="md"
/>
<Select
placeholder="Status"
data={STATUS_FILTER_OPTIONS}
value={status}
onChange={(v) => {
setStatus(v);
resetPage();
}}
clearable
w={190}
radius="md"
/>
<DateInput
placeholder="From"
value={from}
onChange={(v) => {
setFrom(v ? new Date(v) : null);
resetPage();
}}
maxDate={to ?? undefined}
clearable
radius="md"
style={{ minWidth: 140 }}
/>
<DateInput
placeholder="To"
value={to}
onChange={(v) => {
setTo(v ? new Date(v) : null);
resetPage();
}}
minDate={from ?? undefined}
clearable
radius="md"
style={{ minWidth: 140 }}
/>
<Button
variant="subtle"
radius="md"
onClick={() => {
setStatus(null);
setSearch("");
setFrom(null);
setTo(null);
resetPage();
}}
>
Clear
</Button>
</Group>
<Box style={{ overflowX: "auto" }} w="100%">
<DataTable
columns={columns}
data={rows}
status={isLoading ? "loading" : isError ? "error" : "success"}
pagination={{
pageIndex: pagination.pageIndex,
pageSize: pagination.pageSize,
pageCount,
totalCount: total,
}}
tableOptions={{
state: { pagination },
onPaginationChange: setPagination,
manualPagination: true,
pageCount,
}}
containerClassName="border-0 shadow-none bg-transparent"
footer={DataTableFooter}
/>
</Box>
</Stack>
</Card>
</Stack>
<Modal
opened={Boolean(voiding)}
onClose={() => setVoiding(null)}
radius="md"
title="Void this cancellation?"
>
{!voiding ? null : (
<Stack gap="sm">
<Text size="sm">
{voiding.booking?.reference ?? voiding.bookingId} ·{" "}
{voiding.wagonsCancelled} wagon(s) · fee{" "}
{formatAmount(voiding.feeAmount, voiding.feeCurrency)}
</Text>
<Text size="sm" c="dimmed">
The pending fee is dropped and the wagons stay on the booking.
Voiding can't be undone.
</Text>
<Group justify="flex-end" gap="sm">
<Button
variant="default"
radius="md"
onClick={() => setVoiding(null)}
>
Keep it
</Button>
<Button
color="red"
radius="md"
leftSection={<XCircle size={15} />}
loading={withdraw.isPending}
onClick={async () => {
try {
await withdraw.mutateAsync(voiding.id);
toast.success("Cancellation voided");
setVoiding(null);
void refetch();
} catch {
// interceptor surfaces the reason
}
}}
>
Void
</Button>
</Group>
</Stack>
)}
</Modal>
</PageContainer>
);
}

View File

@@ -311,6 +311,7 @@ export default function ContractClearanceDetailPage() {
note={clearance.linkedBookingReviewNote}
scheduledDate={clearance.linkedBookingScheduledDate}
canResubmit={canResubmitBooking}
editHref={`/dashboard/contracts/${id}/bookings/${linkedBookingId}/complete?copyFrom=${linkedBookingId}`}
onResubmitted={() => {
void refetch();
void refetchContract();

View File

@@ -450,6 +450,35 @@ export default function ContractRequestDetailPage() {
description={statusMeta.description}
/>
{/* A contract resting in APPROVED means the automatic PDF generation on
final approval failed — on success it moves straight to
CONTRACT_READY. Offer the manual retry. */}
{contract.status === "APPROVED" ? (
<Alert
color="orange"
radius="md"
icon={<AlertTriangle size={18} />}
title="Contract document was not generated"
>
<Stack gap="sm" align="flex-start">
<Text size="sm">
All approvals are complete, but generating the contract PDF
failed. Retry the generation below.
</Text>
<Button
color="edr-green"
size="compact-sm"
radius="lg"
leftSection={<RefreshCw size={15} />}
loading={mutations.generateContract.isPending}
onClick={() => mutations.generateContract.mutate()}
>
Regenerate contract
</Button>
</Stack>
</Alert>
) : null}
{contract.status === "REJECTED" && contract.latestRejectionNote ? (
<Alert
color="red"

View File

@@ -921,10 +921,10 @@ export default function TrainScheduleV2DetailPage() {
<Title order={2} fw={700} style={{ color: "#0f172a" }}>
{schedule.route?.name ?? "Train schedule"}
</Title>
{schedule.trainNumber ? (
<Badge variant="light" color="#F2A516" radius="sm" style={{ fontWeight: 600 }}>
{schedule.trainNumber}
</Badge>
{schedule.train?.trainName ? (
<Text fw={700} style={{ color: "#0f172a" }}>
{schedule.train.trainName}
</Text>
) : null}
{schedule.train ? (
<Text size="xs" c="dimmed" ff="monospace">
@@ -932,6 +932,51 @@ export default function TrainScheduleV2DetailPage() {
</Text>
) : null}
</Group>
{/* Voyage (train) number and trade direction — the two things
operations identify a run by, so they read at a glance
rather than as small badges among the rest. */}
<Group gap="lg" align="center" wrap="wrap">
{schedule.trainNumber ? (
<Box>
<Text size="xs" c="dimmed" fw={600} tt="uppercase" lh={1.2}>
Voyage No.
</Text>
<Text
ff="monospace"
fw={800}
lh={1.1}
style={{ fontSize: 32, color: "#0f172a" }}
>
{schedule.trainNumber}
</Text>
</Box>
) : null}
{schedule.direction ? (
<Box>
<Text size="xs" c="dimmed" fw={600} tt="uppercase" lh={1.2}>
Direction
</Text>
<Text
fw={800}
lh={1.1}
tt="uppercase"
style={{
fontSize: 32,
letterSpacing: 0.5,
color:
schedule.direction === "IMPORT"
? "#2E5B96"
: schedule.direction === "EXPORT"
? "#0A6F4D"
: "#0f172a",
}}
>
{schedule.direction}
</Text>
</Box>
) : null}
</Group>
{(schedule.stops?.length ?? 0) >= 3 ||
(schedule.bookings ?? []).some(
(b) => b.tradeDirection === "DOMESTIC",

View File

@@ -673,8 +673,16 @@ export const contractsService = {
validateShipment: (
id: string,
payload: Freight.CreateBookingUnderContractDto,
// Completion/resubmit preview: exclude this booking's own containers from
// the same-train clash check.
excludeBookingId?: string,
) =>
postContract<ShipmentValidation>(C.VALIDATE_SHIPMENT(id), payload),
postContract<ShipmentValidation>(
excludeBookingId
? `${C.VALIDATE_SHIPMENT(id)}?bookingId=${excludeBookingId}`
: C.VALIDATE_SHIPMENT(id),
payload,
),
/** Remaining bookable quantity per cargo line (GENERAL draw-down cap). */
getCapacity: async (id: string): Promise<Freight.ContractCapacityLine[]> => {

View File

@@ -47,6 +47,7 @@ function applyEvent(
windowClosesAt: event.windowClosesAt,
docReviewEndsAt: event.docReviewEndsAt,
paymentPhaseEndsAt: event.paymentPhaseEndsAt,
paymentDrainEndsAt: event.paymentDrainEndsAt,
departureDate: event.scheduledDepartureDate ?? row.departureDate,
};
}

View File

@@ -68,16 +68,29 @@ const COUNTDOWN_TEXT: Partial<
PRE_WINDOW: { label: "Booking opens in", expiredText: "Booking opening now…" },
OPEN: { label: "Window closes in", expiredText: "Document review starting…" },
DOC_REVIEW: { label: "Document review ends in", expiredText: "Payment starting…" },
PAYMENT: { label: "Payment due in", expiredText: "Payment window closing…" },
PAYMENT: { label: "Payment due in", expiredText: "Finalizing payments…" },
};
function phaseCountdown(
w: MyBookingWindow,
): { label: string; deadline: string; expiredText: string } | null {
function phaseCountdown(w: MyBookingWindow): {
label: string;
deadline: string;
expiredText: string;
graceDeadline?: string | null;
graceLabel?: string;
} | null {
const state = bookingWindowUiState(w);
const text = COUNTDOWN_TEXT[state.kind];
if (!state.countdownTo || !text) return null;
return { ...text, deadline: state.countdownTo };
// Once the pay deadline lapses, pending payments still settle during the
// drain tail — count it down as "processing" instead of a stale "closing".
const grace =
state.kind === "PAYMENT" && w.paymentDrainEndsAt
? {
graceDeadline: w.paymentDrainEndsAt,
graceLabel: "Payment processing — closes in",
}
: undefined;
return { ...text, deadline: state.countdownTo, ...grace };
}
function Pill({
@@ -336,6 +349,8 @@ export const UpcomingWindowsSection = memo(function UpcomingWindowsSection({
deadline={cd.deadline}
label={cd.label}
expiredText={cd.expiredText}
graceDeadline={cd.graceDeadline}
graceLabel={cd.graceLabel}
size="xs"
/>
</Box>

View File

@@ -1,18 +1,30 @@
import {
Alert,
Box,
Button,
Group,
Modal,
Stack,
Text,
TextInput,
UnstyledButton,
} from "@mantine/core";
import { useMutation, useQuery } from "@tanstack/react-query";
import { AlertCircle, Send, XCircle } from "lucide-react";
import {
AlertCircle,
CalendarDays,
ChevronRight,
Package,
Pencil,
Send,
XCircle,
} from "lucide-react";
import type { ReactNode } from "react";
import { useState } from "react";
import { useNavigate } from "react-router-dom";
import { api } from "@/services/api";
import { bookingsService } from "@/services/bookings.service";
import type { Freight } from "@edr/types";
import { PriceChangeModal } from "@/pages/bookings/resubmit/PriceChangeModal";
@@ -25,13 +37,55 @@ import { CompanyInfoCard } from "./components/CompanyInfoCard";
import { ContainersCard } from "./components/ContainersCard";
import { ContractInfoCard } from "./components/ContractInfoCard";
import { ActionRequiredBanner, MutationErrors } from "./components/Notices";
import { PageHeader } from "./components/PageHeader";
import { HeaderButton, PageHeader } from "./components/PageHeader";
import { EstimateCard } from "./components/pricing";
import { ScheduleCard } from "./components/ScheduleCard";
import { ShipmentDetailsCard } from "./components/ShipmentDetailsCard";
import { StatusHero } from "./components/StatusHero";
import { SupportCard } from "./components/SupportCard";
/** Row linking straight to one section of the edit-booking form. */
function EditLink({
icon,
title,
description,
onClick,
}: {
icon: ReactNode;
title: string;
description: string;
onClick: () => void;
}) {
return (
<UnstyledButton
onClick={onClick}
p="sm"
style={{
border: "1px solid #E6ECF2",
borderRadius: 12,
width: "100%",
}}
>
<Group gap="sm" wrap="nowrap" align="flex-start">
<Box mt={2} c="#0A6F4D">
{icon}
</Box>
<Box style={{ flex: 1, minWidth: 0 }}>
<Text fz={13.5} fw={700} c="#10202F">
{title}
</Text>
<Text fz={12} c="#6B7C8E" mt={2}>
{description}
</Text>
</Box>
<Box mt={2} c="#9AA8B5">
<ChevronRight size={16} />
</Box>
</Group>
</UnstyledButton>
);
}
/**
* Detail-page view for a booking staff returned with CHANGES_REQUESTED.
*
@@ -64,8 +118,9 @@ export function ChangesRequestedView({
null) as Freight.PricingBreakdown | null;
const cancelMutation = useMutation({
// Customer-facing cancel endpoint — the plain /cancel route is staff-only.
mutationFn: (reason: string) =>
api.bookings.cancel.call({ id: booking.id, reason }),
bookingsService.customerCancel(booking.id, reason),
onSuccess: () => {
setCancelDialogOpen(false);
onBookingUpdated();
@@ -76,17 +131,21 @@ export function ChangesRequestedView({
<PageShell>
<PageHeader
booking={booking}
menuActions={{
onCancel: () => setCancelDialogOpen(true),
onSupport: () => navigate("/support"),
}}
actions={
<HeaderButton
red
icon={<XCircle size={16} />}
label="Cancel booking"
onClick={() => setCancelDialogOpen(true)}
/>
}
/>
<MutationErrors mutations={[...flow.mutations, cancelMutation]} />
<StatusHero booking={booking}>
{booking.latestChangeRequestNote ? (
<ActionRequiredBanner title="Review the requested changes, then resubmit.">
<ActionRequiredBanner title="Review the requested changes, update the booking if needed, then resubmit.">
{booking.latestChangeRequestNote}
</ActionRequiredBanner>
) : undefined}
@@ -101,6 +160,41 @@ export function ChangesRequestedView({
<ContractInfoCard booking={booking} />
<SectionCard>
<CardTitle>Fix your booking</CardTitle>
<Text fz="12.5px" c="#6B7C8E" mt={4} mb="md">
Staff asked for changes on this booking. Update whatever needs
fixing below, then resubmit for review the booking stays in
place, no need to start over.
</Text>
<Stack gap={8}>
<EditLink
icon={<Package size={16} />}
title="Cargo & containers"
description="Add or remove containers, change container type, quantity or VGM — or for bulk cargo, change the commodity and tonnage."
onClick={() =>
navigate(`/bookings/${booking.id}/edit?section=cargo`)
}
/>
<EditLink
icon={<CalendarDays size={16} />}
title="Schedule date"
description="Pick a different departure day — only days with an open schedule on your route can be selected."
onClick={() =>
navigate(`/bookings/${booking.id}/edit?section=schedule`)
}
/>
<EditLink
icon={<Pencil size={16} />}
title="Route, service & other details"
description="Change the origin or destination yard, service type, trucking options or notes."
onClick={() =>
navigate(`/bookings/${booking.id}/edit?section=service`)
}
/>
</Stack>
</SectionCard>
<SectionCard>
<Group justify="space-between" align="center" mb="md">
<CardTitle>Your documents</CardTitle>

View File

@@ -24,7 +24,10 @@ import { useNavigate } from "react-router-dom";
import { api } from "@/services/api";
import { downloadStoredFile } from "@/services/files.service";
import type { SubmitBookingResponse } from "@/services/bookings.service";
import {
bookingsService,
type SubmitBookingResponse,
} from "@/services/bookings.service";
import type { Freight } from "@edr/types";
import { REQUIRED_DOC_FIELDS } from "./constants";
@@ -116,8 +119,9 @@ export function DraftBookingView({
});
const cancelMutation = useMutation({
// Customer-facing cancel endpoint — the plain /cancel route is staff-only.
mutationFn: (reason: string) =>
api.bookings.cancel.call({ id: booking.id, reason }),
bookingsService.customerCancel(booking.id, reason),
onSuccess: () => {
setCancelDialogOpen(false);
onBookingUpdated();
@@ -157,17 +161,21 @@ export function DraftBookingView({
<PageHeader
booking={booking}
actions={
<HeaderButton
dark
icon={<Pencil size={16} />}
label="Continue editing"
onClick={() => navigate(`/bookings/${booking.id}/edit`)}
/>
<Group gap={8} wrap="nowrap">
<HeaderButton
dark
icon={<Pencil size={16} />}
label="Continue editing"
onClick={() => navigate(`/bookings/${booking.id}/edit`)}
/>
<HeaderButton
red
icon={<XCircle size={16} />}
label="Cancel"
onClick={() => setCancelDialogOpen(true)}
/>
</Group>
}
menuActions={{
onCancel: () => setCancelDialogOpen(true),
onSupport: () => navigate("/support"),
}}
/>
<MutationErrors

View File

@@ -1,15 +1,21 @@
import { Group, Tabs } from "@mantine/core";
import { Button, Group, Modal, Stack, Tabs, Text } from "@mantine/core";
import { useMutation } from "@tanstack/react-query";
import {
Clock,
CreditCard,
FileText,
LayoutGrid,
Package,
TrainFront,
Truck,
XCircle,
} from "lucide-react";
import { useState } from "react";
import toast from "react-hot-toast";
import { useNavigate } from "react-router-dom";
import { useFileViewer } from "@/hooks/useFileViewer";
import { bookingsService } from "@/services/bookings.service";
import type { Freight } from "@edr/types";
import { ApproveDeliveryButton } from "../delivery/ApproveDeliveryButton";
@@ -28,6 +34,7 @@ import { MileSummaryCard } from "./components/MileSummaryCard";
import { BodyGrid, PageShell } from "./components/layout";
import {
CancelledBanner,
ActionRequiredBanner,
ConsolidationPairedNotice,
ConsolidationWaitingBanner,
} from "./components/Notices";
@@ -41,10 +48,33 @@ import { ShipmentDetailsCard } from "./components/ShipmentDetailsCard";
import { ShipmentTrackingCard } from "./components/ShipmentTrackingCard";
import { StatusHero } from "./components/StatusHero";
import { SupportCard } from "./components/SupportCard";
import { WagonCancellationCard } from "./components/WagonCancellationCard";
import { WagonsTab } from "./components/WagonsTab";
import { fmtDate, isNegative, priceTotal } from "./utils";
import { useScrollToHash } from "@/hooks/useScrollToHash";
import { useBookingPayment } from "@/pages/bookings/payments/useBookingPayment";
// Pre-payment statuses the customer may self-cancel from this view (free of
// charge). DRAFT / CHANGES_REQUESTED render their own views and drafts can
// simply be deleted; anything at or past payment must go through support.
const CUSTOMER_CANCELLABLE_STATUSES = [
"SUBMITTED",
"PRICE_CHANGED_PENDING_CONFIRM",
"PENDING_APPROVAL",
"CONTRACT_READY",
"OPERATION_REQUEST_PENDING",
"SELECTED_FOR_BATCH",
];
const cancelErrorMessage = (error: unknown) => {
const data = (
error as { response?: { data?: { message?: string | string[] } } }
)?.response?.data;
if (Array.isArray(data?.message)) return data.message.join(", ");
if (data?.message) return data.message;
return "Could not cancel the booking. Please try again.";
};
export function ReadonlyBookingView({
booking,
onBookingUpdated,
@@ -71,6 +101,23 @@ export function ReadonlyBookingView({
// and handles redirect vs CAC Bank OTP.
const pay = useBookingPayment(booking.id);
const [cancelOpen, setCancelOpen] = useState(false);
const cancelMutation = useMutation({
mutationFn: () => bookingsService.customerCancel(booking.id),
onSuccess: () => {
setCancelOpen(false);
toast.success(
"Your booking has been cancelled — no cancellation fee was charged.",
{ duration: 6000 },
);
onBookingUpdated?.();
},
onError: (e) => toast.error(cancelErrorMessage(e)),
});
const canCancel =
booking.paymentStatus !== "PAID" &&
CUSTOMER_CANCELLABLE_STATUSES.includes(status);
const pricing = booking.pricingBreakdown;
// A general contract is paid once it's FULLY_EXECUTED (signed) — it never
// enters batch selection. A one-time booking can only pay once it's been
@@ -117,6 +164,9 @@ export function ReadonlyBookingView({
"DOCUMENTS_UNDER_REVIEW",
"CLEARANCE_READY",
"OPERATION_REQUESTED",
// Operations returned the order — same card hosts the pick-a-new-day +
// resubmit flow.
"OPERATION_CHANGES_REQUESTED",
].includes(status);
// Paired: a consolidation partner was found and the booking resumed the normal
// flow. Surface the "partner found" reassurance only in the early stages,
@@ -124,13 +174,16 @@ export function ReadonlyBookingView({
const showPairedNotice =
!!booking.consolidationPartnerId &&
["SUBMITTED", "PENDING_APPROVAL", "CHANGES_REQUESTED"].includes(status);
// Wagons exist only after payment puts the booking on a train; before that
// the tab would always be an empty state, so it stays hidden.
const showWagonsTab = booking.paymentStatus === "PAID" && !isNegative(status);
return (
<PageShell>
<PageHeader
booking={booking}
actions={
(canApproveDelivery || (canPay && !showCountdown)) && (
(canApproveDelivery || (canPay && !showCountdown) || canCancel) && (
<Group gap={8} wrap="nowrap">
{canApproveDelivery && (
<ApproveDeliveryButton bookingId={booking.id} />
@@ -143,13 +196,17 @@ export function ReadonlyBookingView({
onClick={pay.open}
/>
)}
{canCancel && (
<HeaderButton
red
icon={<XCircle size={16} />}
label="Cancel booking"
onClick={() => setCancelOpen(true)}
/>
)}
</Group>
)
}
menuActions={{
onRebook: canSelfRebook ? onRebook : undefined,
onSupport: () => navigate("/support"),
}}
/>
{isNegative(status) ? (
@@ -182,7 +239,14 @@ export function ReadonlyBookingView({
priceLabel={pricing ? priceTotal(pricing) : undefined}
/>
) : (
<StatusHero booking={booking} />
<StatusHero booking={booking}>
{status === "OPERATION_CHANGES_REQUESTED" &&
booking.latestChangeRequestNote ? (
<ActionRequiredBanner title="Operations requested changes — pick a new shipment day and resubmit.">
{booking.latestChangeRequestNote}
</ActionRequiredBanner>
) : undefined}
</StatusHero>
)}
{showPairedNotice && <ConsolidationPairedNotice />}
@@ -210,6 +274,11 @@ export function ReadonlyBookingView({
<Tabs.Tab value="cargo" leftSection={<Package size={15} />}>
Cargo
</Tabs.Tab>
{showWagonsTab && (
<Tabs.Tab value="wagons" leftSection={<TrainFront size={15} />}>
Wagons
</Tabs.Tab>
)}
<Tabs.Tab value="logistics" leftSection={<Truck size={15} />}>
Logistics
</Tabs.Tab>
@@ -257,6 +326,11 @@ export function ReadonlyBookingView({
title="Consignment & Schedule"
consignment
/>
{/* Renders only on PAID + paid + contract-backed bookings. */}
<WagonCancellationCard
booking={booking}
onBookingUpdated={onBookingUpdated}
/>
<CompanyInfoCard booking={booking} />
<SupportCard />
</>
@@ -269,6 +343,20 @@ export function ReadonlyBookingView({
<CargoTab booking={booking} />
</Tabs.Panel>
{showWagonsTab && (
<Tabs.Panel value="wagons">
<WagonsTab
bookingId={booking.id}
cancellable={
booking.status === "PAID" &&
booking.paymentStatus === "PAID" &&
Boolean(booking.contractId)
}
onCancellationRequested={onBookingUpdated}
/>
</Tabs.Panel>
)}
<Tabs.Panel value="logistics">
<div className="flex flex-col gap-6">
<BodyGrid
@@ -313,6 +401,52 @@ export function ReadonlyBookingView({
bill={pay.bill}
onConfirm={pay.confirm}
/>
<Modal
opened={cancelOpen}
onClose={() => setCancelOpen(false)}
title={
<Text fw={800} fz={18} c="#10202F">
Cancel this booking?
</Text>
}
centered
radius={16}
>
<Stack gap="md">
<Text size="sm" c="#475569">
You&apos;re about to cancel booking{" "}
<Text span fw={700} c="#10202F">
{booking.reference}
</Text>
. Since you haven&apos;t paid yet,{" "}
<Text span fw={700}>
no cancellation fee
</Text>{" "}
will be charged
{status === "SELECTED_FOR_BATCH"
? ", and your reserved wagon space will be released immediately"
: ""}
. This cannot be undone.
</Text>
<Group justify="flex-end" gap={8}>
<Button
variant="default"
radius={10}
onClick={() => setCancelOpen(false)}
>
Keep booking
</Button>
<Button
color="red"
radius={10}
loading={cancelMutation.isPending}
onClick={() => cancelMutation.mutate()}
>
Cancel booking
</Button>
</Group>
</Stack>
</Modal>
{viewer}
</PageShell>
);

View File

@@ -35,6 +35,8 @@ export interface BookingContainerLineDetail {
isOverweight?: boolean;
overweightExcessTons?: number | string | null;
containerNumber?: string | null;
/** Size (ft) as stored on the line ("20"/"40") — the wagon-cancellation key. */
containerSize?: string | null;
containerType?: {
code: string;
label?: string | null;
@@ -75,6 +77,12 @@ export type BookingDetail = Freight.IBooking & {
reference: string;
status: string;
} | null;
/** The allocated train, present once the booking is placed on a schedule. */
trainSchedule?: {
trainNumber: string | null;
reference: string | null;
scheduledDepartureDate: string | null;
} | null;
};
/**

View File

@@ -51,7 +51,12 @@ export function ClearanceCard({ booking }: { booking: Freight.IBooking }) {
}
const summary =
status === "CLEARANCE_READY" ? (
status === "OPERATION_CHANGES_REQUESTED" ? (
<Alert color="yellow" radius="md" icon={<Clock size={18} />}>
Operations returned this order for changes. Update the booking details,
pick a new shipment day and resubmit.
</Alert>
) : status === "CLEARANCE_READY" ? (
<Alert color="teal" radius="md" icon={<CheckCircle2 size={18} />}>
{`${
booking.customsClearingEnabled
@@ -103,9 +108,11 @@ export function ClearanceCard({ booking }: { booking: Freight.IBooking }) {
{summary}
<Text fz="12.5px" c="dimmed" mt="sm">
{isBookAction
? "Use “Book” to enter the cargo details and schedule your shipment."
: `Use “${action?.label ?? "the action button"}” to manage your ${docNoun}.`}
{status === "OPERATION_CHANGES_REQUESTED"
? "Use “Change booking” to update the details and pick a new shipment day."
: isBookAction
? "Use “Book” to enter the cargo details and schedule your shipment."
: `Use “${action?.label ?? "the action button"}” to manage your ${docNoun}.`}
</Text>
{!isBookAction && (

View File

@@ -1,14 +1,5 @@
import { ActionIcon, Button, Group, Menu, Stack, Text } from "@mantine/core";
import {
ArrowDownLeft,
ArrowUpRight,
Edit2,
FileText,
HelpCircle,
MoreHorizontal,
RefreshCw,
XCircle,
} from "lucide-react";
import { Button, Group, Stack, Text } from "@mantine/core";
import { ArrowDownLeft, ArrowUpRight } from "lucide-react";
import type { ReactNode } from "react";
import type { Freight } from "@edr/types";
@@ -20,22 +11,12 @@ import {
import { bookingSubtitle, isDraftLike, isNegative } from "../utils";
export interface PageHeaderMenuActions {
onViewContract?: () => void;
onCancel?: () => void;
onEdit?: () => void;
onSupport?: () => void;
onRebook?: () => void;
}
export function PageHeader({
booking,
actions,
menuActions,
}: {
booking: Freight.IBooking;
actions?: ReactNode;
menuActions?: PageHeaderMenuActions;
}) {
const status = booking.status as string;
const negative = isNegative(status);
@@ -47,8 +28,6 @@ export function PageHeader({
const pillText = negative ? "#A93226" : draft ? "#475569" : "#0A6F4D";
const isExport = booking.tradeDirection === "EXPORT";
const hasMenu = menuActions && Object.values(menuActions).some(Boolean);
return (
<Group justify="space-between" align="flex-start" wrap="wrap" gap="md">
<Stack gap={8} miw={0}>
@@ -83,66 +62,6 @@ export function PageHeader({
<Group gap={8} wrap="nowrap" align="center">
{actions}
{hasMenu && (
<Menu shadow="md" radius={12} position="bottom-end">
<Menu.Target>
<ActionIcon
variant="default"
size={42}
radius={10}
aria-label="More options"
>
<MoreHorizontal size={18} />
</ActionIcon>
</Menu.Target>
<Menu.Dropdown miw={210}>
{menuActions!.onViewContract && (
<Menu.Item
leftSection={<FileText size={15} />}
onClick={menuActions!.onViewContract}
>
View contract
</Menu.Item>
)}
{menuActions!.onEdit && (
<Menu.Item
leftSection={<Edit2 size={15} />}
onClick={menuActions!.onEdit}
>
Edit
</Menu.Item>
)}
{menuActions!.onSupport && (
<Menu.Item
leftSection={<HelpCircle size={15} />}
onClick={menuActions!.onSupport}
>
Contact customer support
</Menu.Item>
)}
{menuActions!.onRebook && (
<Menu.Item
leftSection={<RefreshCw size={15} />}
onClick={menuActions!.onRebook}
>
Rebook similar schedule
</Menu.Item>
)}
{menuActions!.onCancel && (
<>
<Menu.Divider />
<Menu.Item
color="red"
leftSection={<XCircle size={15} />}
onClick={menuActions!.onCancel}
>
Cancel booking
</Menu.Item>
</>
)}
</Menu.Dropdown>
</Menu>
)}
</Group>
</Group>
);
@@ -154,6 +73,7 @@ export function HeaderButton({
onClick,
dark,
green,
red,
disabled,
}: {
label: string;
@@ -161,6 +81,7 @@ export function HeaderButton({
onClick?: () => void;
dark?: boolean;
green?: boolean;
red?: boolean;
disabled?: boolean;
}) {
return (
@@ -169,14 +90,14 @@ export function HeaderButton({
disabled={disabled}
leftSection={icon}
radius={10}
variant={green || dark ? "filled" : "default"}
color={green ? "edr-green" : dark ? "#0C1A2B" : undefined}
variant={green || dark ? "filled" : red ? "outline" : "default"}
color={green ? "edr-green" : dark ? "#0C1A2B" : red ? "red" : undefined}
styles={{
root: { height: 42, paddingInline: 16 },
label: {
fontSize: 13,
fontWeight: 700,
color: green || dark ? "#fff" : "#10202F",
color: green || dark ? "#fff" : red ? undefined : "#10202F",
},
}}
>

View File

@@ -65,6 +65,12 @@ export function ScheduleCard({
const service = serviceTypeLabel(booking);
const equipmentReturn =
booking.equipmentReturn === "WITH_RETURN" ? "With return" : "Without return";
const schedule = booking.trainSchedule;
const trainLabel = schedule?.trainNumber
? `Train ${schedule.trainNumber}${schedule.reference ? ` · ${schedule.reference}` : ""}`
: schedule?.reference
? `Schedule ${schedule.reference}`
: "Track shipment";
const assignedTrain: Row = booking.trainScheduleId
? {
label: "Assigned train",
@@ -85,7 +91,7 @@ export function ScheduleCard({
fontSize: 13,
}}
>
<MapPin size={13} /> Track shipment
<MapPin size={13} /> {trainLabel}
</button>
),
}

View File

@@ -0,0 +1,496 @@
import {
Alert,
Box,
Button,
Group,
Modal,
NumberInput,
Stack,
Table,
Text,
Textarea,
} from "@mantine/core";
import { useMutation, useQuery } from "@tanstack/react-query";
import { CheckCircle2, Clock, CreditCard, TrainTrack } from "lucide-react";
import { useMemo, useState } from "react";
import toast from "react-hot-toast";
import { Link, useNavigate } from "react-router-dom";
import type { Freight } from "@edr/types";
import { api } from "@/services/api";
import {
bookingsService,
type RequestWagonCancellationPayload,
type WagonCancellation,
type WagonCancellationPreview,
} from "@/services/bookings.service";
import { OperationDatePicker } from "@/pages/bookings/clearance";
import { useFeeInvoicePayment } from "@/pages/bookings/payments/useBookingPayment";
import type { BookingDetail } from "../booking-detail-types";
import { fmtDate } from "../utils";
import { CardTitle, SectionCard } from "./layout";
import { PaymentMethodModal } from "./PaymentMethodModal";
// Same pill treatment as WagonsTab's STATUS_TONES so the page reads as one.
const STATUS_TONES: Record<
WagonCancellation["status"],
{ bg: string; color: string; label: string }
> = {
FEE_PENDING: { bg: "#FFFBEB", color: "#92400E", label: "Fee pending" },
CREDIT_AVAILABLE: { bg: "#EAF1FE", color: "#1E40AF", label: "Credit available" },
REBOOKED: { bg: "#E8F5EF", color: "#0A6F4D", label: "Rebooked" },
WITHDRAWN: { bg: "#F1F4F7", color: "#475569", label: "Withdrawn" },
EXPIRED: { bg: "#FEF2F2", color: "#B91C1C", label: "Expired" },
};
function StatusPill({ status }: { status: WagonCancellation["status"] }) {
const tone = STATUS_TONES[status] ?? STATUS_TONES.WITHDRAWN;
return (
<Text
component="span"
fz={11}
fw={700}
px={9}
py={3}
style={{ borderRadius: 999, backgroundColor: tone.bg, color: tone.color }}
>
{tone.label}
</Text>
);
}
const fmtMoney = (amount: number | string, currency: string) =>
`${Number(amount).toLocaleString()} ${currency}`;
const apiErrorMessage = (error: unknown, fallback: string) => {
const data = (
error as { response?: { data?: { message?: string | string[] } } }
)?.response?.data;
if (Array.isArray(data?.message)) return data.message.join(", ");
if (data?.message) return data.message;
return fallback;
};
const th = { color: "#9AA8B5", fontSize: 11 } as const;
/**
* Partial wagon cancellation on a PAID contract booking: request a cut (fee
* previewed first), pay the cancellation fee, then rebook the freed credit
* onto another shipment day — plus the booking's cancellation history.
* Wagons leave the schedule at request time; the fee settles the credit.
*/
export function WagonCancellationCard({
booking,
onBookingUpdated,
}: {
booking: Freight.IBooking;
onBookingUpdated?: () => void;
}) {
const navigate = useNavigate();
const status = booking.status as string;
const eligible =
status === "PAID" &&
(booking.paymentStatus as string) === "PAID" &&
!!booking.contractId;
const isBulk = booking.freightType === "BULK";
const detail = booking as BookingDetail;
// The entity field the API serializes on the detail read; not on the DTO type.
const wagonsRequired = Number(
(booking as { wagonsRequired?: number | string | null }).wagonsRequired ?? 0,
);
// Live units per container size ("20"/"40"), summed across lines.
const containerLines = useMemo(() => {
const bySize = new Map<string, number>();
for (const line of detail.bookingContainers ?? []) {
const size =
line.containerSize ??
(line.containerType?.sizeFt != null
? String(line.containerType.sizeFt)
: null);
if (!size) continue;
bySize.set(size, (bySize.get(size) ?? 0) + Number(line.quantity ?? 0));
}
return [...bySize.entries()].map(([containerSize, quantity]) => ({
containerSize,
quantity,
}));
}, [detail.bookingContainers]);
const { data, refetch } = useQuery({
...api.bookings.listWagonCancellations.queryOptions({
input: { bookingId: booking.id },
}),
enabled: eligible,
});
// History includes rows where this booking is the rebooked TARGET — only
// rows this booking opened itself can be paid/withdrawn/rebooked from here.
const rows = data?.items ?? [];
const ownRows = rows.filter((r) => r.bookingId === booking.id);
const openRow = ownRows.find((r) => r.status === "FEE_PENDING");
const creditRow = ownRows.find((r) => r.status === "CREDIT_AVAILABLE");
const feePay = useFeeInvoicePayment(booking.id);
// ── Request modal state ──
const [modalOpen, setModalOpen] = useState(false);
const [wagons, setWagons] = useState<number | string>(1);
const [cancelBySize, setCancelBySize] = useState<Record<string, number>>({});
const [reason, setReason] = useState("");
const [preview, setPreview] = useState<WagonCancellationPreview | null>(null);
const closeModal = () => {
setModalOpen(false);
setWagons(1);
setCancelBySize({});
setReason("");
setPreview(null);
};
const requestPayload = (): RequestWagonCancellationPayload | null => {
const trimmedReason = reason.trim();
if (isBulk) {
const n = Number(wagons);
if (!n || n <= 0) return null;
return { wagons: n, ...(trimmedReason ? { reason: trimmedReason } : {}) };
}
const containers = containerLines
.map((l) => ({
containerSize: l.containerSize,
quantity: cancelBySize[l.containerSize] ?? 0,
}))
.filter((c) => c.quantity > 0);
if (!containers.length) return null;
return { containers, ...(trimmedReason ? { reason: trimmedReason } : {}) };
};
const payload = requestPayload();
const previewMutation = useMutation({
mutationFn: (body: RequestWagonCancellationPayload) =>
bookingsService.previewWagonCancellation(booking.id, body),
onSuccess: setPreview,
onError: (e) => {
setPreview(null);
toast.error(apiErrorMessage(e, "Could not calculate the fee. Please try again."));
},
});
const requestMutation = useMutation({
mutationFn: (body: RequestWagonCancellationPayload) =>
bookingsService.requestWagonCancellation(booking.id, body),
onSuccess: () => {
closeModal();
toast.success(
"Cancellation requested — pay the fee to release the wagons.",
{ duration: 6000 },
);
void refetch();
onBookingUpdated?.();
},
onError: (e) =>
toast.error(
apiErrorMessage(e, "Could not request the cancellation. Please try again."),
),
});
const withdrawMutation = useMutation({
mutationFn: () => bookingsService.withdrawWagonCancellation(openRow!.id),
onSuccess: () => {
toast.success("Cancellation withdrawn — the fee invoice was voided.");
void refetch();
onBookingUpdated?.();
},
onError: (e) =>
toast.error(
apiErrorMessage(e, "Could not withdraw the cancellation. Please try again."),
),
});
const [rebookDate, setRebookDate] = useState("");
const rebookMutation = useMutation({
mutationFn: () =>
bookingsService.rebookWagonCancellation(creditRow!.id, {
scheduledDate: rebookDate,
}),
onSuccess: ({ bookingId }) => {
toast.success("Wagons rebooked — taking you to the new booking.", {
duration: 6000,
});
navigate(`/bookings/${bookingId}`);
},
onError: (e) =>
toast.error(apiErrorMessage(e, "Could not rebook the wagons. Please try again.")),
});
if (!eligible) return null;
return (
<SectionCard>
<Group justify="space-between" align="center" mb="sm">
<CardTitle>Wagon Cancellation</CardTitle>
{!openRow && !creditRow && (
<Button
variant="default"
radius="md"
leftSection={<TrainTrack size={16} />}
onClick={() => setModalOpen(true)}
>
Cancel wagons
</Button>
)}
</Group>
{openRow ? (
<Stack gap="sm">
<Alert color="yellow" radius="md" icon={<Clock size={18} />}>
A cancellation of {Number(openRow.wagonsCancelled)} wagon(s) is
awaiting its fee of{" "}
<Text span fw={700}>
{fmtMoney(openRow.feeAmount, openRow.feeCurrency)}
</Text>
. The cancelled wagons have left the train. Pay the fee to unlock
the rebooking credit, or withdraw the request to get the wagons
back withdrawing works only while the train still has free space
for them.
</Alert>
<Group gap={8}>
<Button
color="edr-green"
radius="md"
leftSection={<CreditCard size={16} />}
onClick={feePay.open}
>
Pay cancellation fee
</Button>
<Button
variant="default"
radius="md"
loading={withdrawMutation.isPending}
onClick={() => withdrawMutation.mutate()}
>
Withdraw request
</Button>
</Group>
</Stack>
) : creditRow ? (
<Stack gap="sm">
<Alert color="teal" radius="md" icon={<CheckCircle2 size={18} />}>
{Number(creditRow.wagonsCancelled)} wagon(s) were released a
credit of{" "}
<Text span fw={700}>
{fmtMoney(creditRow.creditAmount, booking.paymentCurrency)}
</Text>{" "}
is available. Pick a shipment day to rebook them as a new paid
booking (no further payment needed).
</Alert>
<OperationDatePicker
bookingId={booking.id}
value={rebookDate}
onChange={setRebookDate}
/>
<Group justify="flex-end">
<Button
color="edr-green"
radius="md"
disabled={!rebookDate}
loading={rebookMutation.isPending}
onClick={() => rebookMutation.mutate()}
>
Rebook wagons
</Button>
</Group>
</Stack>
) : (
<Text fz={13} c="#475569">
Need fewer wagons than you paid for? Cancel part of this booking for
a per-wagon fee the freed freight amount becomes a credit you can
rebook onto another shipment day.
</Text>
)}
{rows.length > 0 && (
<Box style={{ overflowX: "auto" }} mt="md">
<Table verticalSpacing={6} horizontalSpacing="sm">
<Table.Thead>
<Table.Tr>
<Table.Th style={th}>Date</Table.Th>
<Table.Th style={th}>Wagons</Table.Th>
<Table.Th style={th}>Fee</Table.Th>
<Table.Th style={th}>Status</Table.Th>
<Table.Th style={th}>Rebooked as</Table.Th>
</Table.Tr>
</Table.Thead>
<Table.Tbody>
{rows.map((r) => (
<Table.Tr key={r.id}>
<Table.Td>
<Text fz={12.5} c="#475569">
{fmtDate(r.createdAt)}
</Text>
</Table.Td>
<Table.Td>
<Text fz={12.5} fw={700} c="#10202F">
{Number(r.wagonsCancelled)}
</Text>
</Table.Td>
<Table.Td>
<Text fz={12.5} c="#475569">
{fmtMoney(r.feeAmount, r.feeCurrency)}
</Text>
</Table.Td>
<Table.Td>
<StatusPill status={r.status} />
</Table.Td>
<Table.Td>
{r.rebookedBookingId ? (
<Text
component={Link}
to={`/bookings/${r.rebookedBookingId}`}
fz={12.5}
fw={700}
c="#0A6F4D"
style={{ textDecoration: "underline" }}
>
{r.rebookedBooking?.reference ?? "View booking"}
</Text>
) : (
<Text fz={12.5} c="#9AA8B5">
</Text>
)}
</Table.Td>
</Table.Tr>
))}
</Table.Tbody>
</Table>
</Box>
)}
<PaymentMethodModal
opened={feePay.modalOpen}
onClose={feePay.close}
amountLabel={
openRow ? fmtMoney(openRow.feeAmount, openRow.feeCurrency) : undefined
}
currency={openRow?.feeCurrency}
processing={feePay.processing}
error={feePay.error}
otp={feePay.otp}
bill={feePay.bill}
onConfirm={feePay.confirm}
/>
<Modal
opened={modalOpen}
onClose={closeModal}
title={
<Text fw={800} fz={18} c="#10202F">
Cancel wagons
</Text>
}
centered
radius={16}
>
<Stack gap="md">
<Text size="sm" c="#475569">
Choose how much of booking{" "}
<Text span fw={700} c="#10202F">
{booking.reference}
</Text>{" "}
to cancel. A per-wagon fee applies; once it&apos;s paid the wagons
are released and the freed amount becomes a rebooking credit. At
least one wagon must remain to cancel everything, cancel the
whole booking instead.
</Text>
{isBulk ? (
<NumberInput
label="Wagons to cancel"
min={1}
max={wagonsRequired > 1 ? wagonsRequired - 1 : undefined}
allowDecimal={false}
value={wagons}
onChange={(v) => {
setWagons(v);
setPreview(null);
}}
/>
) : (
containerLines.map((line) => (
<NumberInput
key={line.containerSize}
label={`${line.containerSize}ft containers to cancel`}
description={`${line.quantity} on this booking`}
min={0}
max={line.quantity}
allowDecimal={false}
value={cancelBySize[line.containerSize] ?? 0}
onChange={(v) => {
setCancelBySize((prev) => ({
...prev,
[line.containerSize]: Number(v) || 0,
}));
setPreview(null);
}}
/>
))
)}
<Textarea
label="Reason (optional)"
placeholder="Why are these wagons no longer needed?"
autosize
minRows={2}
value={reason}
onChange={(e) => setReason(e.currentTarget.value)}
/>
{preview && (
<Alert color="blue" radius="md">
<Text fz={13}>
Cancelling{" "}
<Text span fw={700}>
{preview.wagons} wagon(s)
</Text>{" "}
(~{preview.weightTons} t) costs a fee of{" "}
<Text span fw={700}>
{fmtMoney(preview.feeAmount, preview.feeCurrency)}
</Text>{" "}
({fmtMoney(preview.feePerWagon, preview.feeCurrency)} per
wagon) and frees a rebooking credit of{" "}
<Text span fw={700}>
{fmtMoney(preview.creditAmount, booking.paymentCurrency)}
</Text>
.
</Text>
</Alert>
)}
<Group justify="flex-end" gap={8}>
<Button
variant="default"
radius={10}
disabled={!payload}
loading={previewMutation.isPending}
onClick={() => payload && previewMutation.mutate(payload)}
>
Calculate fee
</Button>
<Button
color="red"
radius={10}
disabled={!payload}
loading={requestMutation.isPending}
onClick={() => payload && requestMutation.mutate(payload)}
>
Request cancellation
</Button>
</Group>
</Stack>
</Modal>
</SectionCard>
);
}

View File

@@ -0,0 +1,792 @@
import {
Alert,
Box,
Button,
Checkbox,
Group,
Modal,
SimpleGrid,
Skeleton,
Stack,
Table,
Text,
Textarea,
Tooltip,
} from "@mantine/core";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import {
Container,
Gauge,
MapPin,
Package,
Route,
Scale,
TrainFront,
TrainTrack,
} from "lucide-react";
import { useState, type ReactNode } from "react";
import toast from "react-hot-toast";
import {
bookingsService,
type BookingWagonAllocation,
type WagonCancellation,
type WagonCancellationPreview,
} from "@/services/bookings.service";
import { api } from "@/services/api";
import { fmtDate, fmtWeight } from "../utils";
import { CardTitle, SectionCard } from "./layout";
const CANCEL_TONES: Record<
string,
{ bg: string; color: string; border: string; label: string; hint: string }
> = {
FEE_PENDING: {
bg: "#FFFBEB",
color: "#92400E",
border: "#FDE68A",
label: "Cancelled — fee unpaid",
hint: "These wagons left the train. Pay the cancellation fee to turn them into a rebooking credit, or withdraw to get them back (needs free space).",
},
CREDIT_AVAILABLE: {
bg: "#E6F7F2",
color: "#0A6F4D",
border: "#B7E6D6",
label: "Cancelled — credit ready",
hint: "Fee paid. Pick a new shipment day in the wagon cancellation card to rebook these — no new freight charge.",
},
REBOOKED: {
bg: "#E8F5EF",
color: "#0A6F4D",
border: "#B7E6D6",
label: "Rebooked",
hint: "These wagons ride again on the rebooked shipment.",
},
};
/** Cancelled wagons + their cargo, categorized by cancellation state. */
function CancelledWagonsSection({ rows }: { rows: WagonCancellation[] }) {
const visible = rows.filter((r) => CANCEL_TONES[r.status]);
if (!visible.length) return null;
return (
<SectionCard>
<CardTitle>Cancelled wagons</CardTitle>
<Stack gap="sm" mt="sm">
{visible.map((r) => {
const tone = CANCEL_TONES[r.status];
const units = r.cancelledQuantities.units ?? [];
return (
<Box
key={r.id}
p={12}
style={{
borderRadius: 10,
backgroundColor: tone.bg,
border: `1px solid ${tone.border}`,
}}
>
<Group justify="space-between" align="center" wrap="wrap" gap={6}>
<Text fz={13.5} fw={800} style={{ color: tone.color }}>
{Number(r.wagonsCancelled)} wagon(s) {tone.label}
</Text>
<Text fz={12} c="#6B7C8E">
{fmtDate(r.createdAt)}
{r.rebookedBooking
? ` · new booking ${r.rebookedBooking.reference}`
: ""}
</Text>
</Group>
{units.length > 0 && (
<Group gap={6} mt={6} wrap="wrap">
{units.map((u) => (
<Text
key={u.containerNumber}
component="span"
fz={11.5}
fw={700}
px={8}
py={2}
style={{
borderRadius: 6,
backgroundColor: "white",
border: `1px solid ${tone.border}`,
color: tone.color,
fontFamily: "monospace",
}}
>
{u.containerNumber} · {u.containerSize}ft
</Text>
))}
</Group>
)}
{!units.length && r.cancelledQuantities.bulkTons != null && (
<Text fz={12.5} mt={4} style={{ color: tone.color }}>
{Number(r.cancelledQuantities.bulkTons).toLocaleString()} tons of
bulk cargo
</Text>
)}
<Text fz={12} c="#6B7C8E" mt={6}>
{tone.hint}
</Text>
</Box>
);
})}
</Stack>
</SectionCard>
);
}
const apiErrorMessage = (error: unknown, fallback: string) => {
const data = (
error as { response?: { data?: { message?: string | string[] } } }
)?.response?.data;
if (Array.isArray(data?.message)) return data.message.join(", ");
if (data?.message) return data.message;
return fallback;
};
// Mirrors CargoTab's local Flag/StatTile look so the two tabs read as one page.
const STATUS_TONES: Record<
BookingWagonAllocation["status"],
{ bg: string; color: string; label: string }
> = {
PLANNED: { bg: "#F1F4F7", color: "#475569", label: "Planned" },
RESERVED: { bg: "#FFFBEB", color: "#92400E", label: "Reserved" },
LOADED: { bg: "#E8F5EF", color: "#0A6F4D", label: "Loaded" },
DEPARTED: { bg: "#EAF1FE", color: "#1E40AF", label: "Departed" },
};
function StatusPill({ status }: { status: BookingWagonAllocation["status"] }) {
const tone = STATUS_TONES[status] ?? STATUS_TONES.PLANNED;
return (
<Text
component="span"
fz={11}
fw={700}
px={9}
py={3}
style={{ borderRadius: 999, backgroundColor: tone.bg, color: tone.color }}
>
{tone.label}
</Text>
);
}
function StatTile({
icon,
label,
value,
sub,
}: {
icon: ReactNode;
label: string;
value: string;
sub?: string;
}) {
return (
<Box
p={14}
style={{
borderRadius: 12,
border: "1px solid #E6ECF2",
backgroundColor: "#FAFCFE",
}}
>
<Group gap={6} align="center" mb={6} c="#6B7C8E">
{icon}
<Text fz="11px" fw={700} tt="uppercase" style={{ letterSpacing: "0.05em" }}>
{label}
</Text>
</Group>
<Text fz={18} fw={800} c="#10202F" truncate>
{value}
</Text>
{sub && (
<Text fz={12} c="#9AA8B5" mt={2}>
{sub}
</Text>
)}
</Box>
);
}
/** Little consist strip: locomotive + one box per wagon, in marshalling order. */
function ConsistStrip({ wagons }: { wagons: BookingWagonAllocation[] }) {
return (
<Box style={{ overflowX: "auto" }} pb={4}>
<Group gap={5} wrap="nowrap" align="flex-end">
<Box
px={10}
py={8}
style={{
borderRadius: "10px 4px 4px 10px",
backgroundColor: "#10202F",
color: "white",
display: "flex",
alignItems: "center",
gap: 6,
flexShrink: 0,
}}
>
<TrainFront size={16} />
<Text fz={11} fw={800}>
LOCO
</Text>
</Box>
{wagons.map((w) => (
<Tooltip
key={w.sequenceNo}
label={`${w.wagonNumber ?? "Unassigned"} · ${w.wagonType ?? "—"} · ${
STATUS_TONES[w.status]?.label ?? w.status
}`}
withArrow
>
<Box
px={10}
py={8}
ta="center"
style={{
borderRadius: 6,
border: "1.5px solid #C9D6E2",
backgroundColor: STATUS_TONES[w.status]?.bg ?? "#F1F4F7",
flexShrink: 0,
minWidth: 64,
cursor: "default",
}}
>
<Text fz={10} fw={700} c="#6B7C8E">
W{w.sequenceNo}
</Text>
<Text fz={11.5} fw={800} c="#10202F" style={{ fontFamily: "monospace" }}>
{w.wagonNumber ?? "—"}
</Text>
</Box>
</Tooltip>
))}
</Group>
</Box>
);
}
function LoadBar({ allocated, capacity }: { allocated: number; capacity: number }) {
const pct = capacity > 0 ? Math.min(100, Math.round((allocated / capacity) * 100)) : 0;
return (
<Box>
<Group justify="space-between" mb={4}>
<Text fz={11.5} fw={700} c="#6B7C8E">
Load
</Text>
<Text fz={11.5} fw={800} c="#10202F">
{fmtWeight(allocated)}
{capacity > 0 ? ` / ${fmtWeight(capacity)} · ${pct}%` : ""}
</Text>
</Group>
<Box style={{ height: 6, borderRadius: 999, backgroundColor: "#EDF2F7" }}>
<Box
style={{
height: 6,
width: `${pct}%`,
borderRadius: 999,
backgroundColor: pct >= 95 ? "#B45309" : "#0A6F4D",
transition: "width 300ms ease",
}}
/>
</Box>
</Box>
);
}
const th = { color: "#9AA8B5", fontSize: 11 } as const;
function WagonCard({
wagon,
selectable,
selected,
onToggle,
}: {
wagon: BookingWagonAllocation;
selectable?: boolean;
selected?: boolean;
onToggle?: () => void;
}) {
const allocated = Number(wagon.allocatedWeightTons || 0);
const capacity = Number(wagon.capacityTons || 0);
const containers = wagon.containers ?? [];
return (
<SectionCard
style={
selected
? { outline: "2px solid #B45309", outlineOffset: -2, borderRadius: 16 }
: undefined
}
>
<Group justify="space-between" align="flex-start" wrap="nowrap" mb="sm">
<Group gap={10} align="center" wrap="nowrap">
{selectable && (
<Checkbox
checked={!!selected}
onChange={onToggle}
color="orange"
aria-label={`Select wagon ${wagon.sequenceNo} for cancellation`}
/>
)}
<Box
style={{
width: 40,
height: 40,
display: "flex",
flexDirection: "column",
alignItems: "center",
justifyContent: "center",
borderRadius: 10,
backgroundColor: "#10202F",
color: "white",
flexShrink: 0,
}}
>
<Text fz={9} fw={700} c="#9AA8B5" lh={1}>
WAGON
</Text>
<Text fz={15} fw={800} lh={1.2}>
{wagon.sequenceNo}
</Text>
</Box>
<Box>
<Text fz={16} fw={800} c="#10202F" style={{ fontFamily: "monospace" }}>
{wagon.wagonNumber ?? "Not yet assigned"}
</Text>
<Text fz={12} c="#9AA8B5">
{wagon.wagonType ?? "Wagon type pending"}
{wagon.wagonTypeCode && wagon.wagonType !== wagon.wagonTypeCode
? ` · ${wagon.wagonTypeCode}`
: ""}
</Text>
</Box>
</Group>
<StatusPill status={wagon.status} />
</Group>
<LoadBar allocated={allocated} capacity={capacity} />
<Group gap={16} mt="sm" mb={containers.length || wagon.loadType === "BULK" ? "sm" : 0}>
{Number(wagon.tareWeightTons) > 0 && (
<Group gap={5}>
<Scale size={12} color="#9AA8B5" />
<Text fz={12} c="#475569">
Tare {fmtWeight(Number(wagon.tareWeightTons))}
</Text>
</Group>
)}
{Number(wagon.lengthMeters) > 0 && (
<Group gap={5}>
<Route size={12} color="#9AA8B5" />
<Text fz={12} c="#475569">
{Number(wagon.lengthMeters)} m
</Text>
</Group>
)}
<Group gap={5}>
{wagon.loadType === "BULK" ? (
<Package size={12} color="#9AA8B5" />
) : (
<Container size={12} color="#9AA8B5" />
)}
<Text fz={12} c="#475569">
{wagon.loadType === "BULK" ? "Bulk load" : "Container load"}
</Text>
</Group>
</Group>
{wagon.loadType === "BULK" && (wagon.bulkCargoDescription || wagon.bulkQuantity) && (
<Box
p={10}
style={{ borderRadius: 10, backgroundColor: "#FAFCFE", border: "1px solid #EDF2F7" }}
>
<Text fz={12.5} fw={700} c="#10202F">
{wagon.bulkCargoDescription ?? "Bulk cargo"}
</Text>
{Number(wagon.bulkQuantity) > 0 && (
<Text fz={12} c="#9AA8B5">
Quantity: {Number(wagon.bulkQuantity).toLocaleString()}
</Text>
)}
</Box>
)}
{containers.length > 0 && (
<Box style={{ overflowX: "auto" }}>
<Table verticalSpacing={6} horizontalSpacing="sm">
<Table.Thead>
<Table.Tr>
<Table.Th style={th}>Container no.</Table.Th>
<Table.Th style={th}>Seal no.</Table.Th>
<Table.Th style={th}>Gross wt.</Table.Th>
</Table.Tr>
</Table.Thead>
<Table.Tbody>
{containers.map((c, i) => (
<Table.Tr key={c.containerNumber ?? i}>
<Table.Td>
<Text fz={12.5} fw={700} c="#10202F" style={{ fontFamily: "monospace" }}>
{c.containerNumber ?? "—"}
</Text>
</Table.Td>
<Table.Td>
<Text fz={12.5} c="#475569">
{c.sealNumber ?? "—"}
</Text>
</Table.Td>
<Table.Td>
<Text fz={12.5} c="#475569">
{Number(c.grossWeightTons) > 0
? fmtWeight(Number(c.grossWeightTons))
: "—"}
</Text>
</Table.Td>
</Table.Tr>
))}
</Table.Tbody>
</Table>
</Box>
)}
</SectionCard>
);
}
/**
* "Wagons" tab: the customer's view of their allocated wagons once the paid
* booking has been placed on a train — consist strip in marshalling order,
* per-wagon load/containers, and the train's route summary.
*/
export function WagonsTab({
bookingId,
cancellable,
onCancellationRequested,
}: {
bookingId: string;
/** PAID contract booking — specific wagons may be selected for cancellation. */
cancellable?: boolean;
onCancellationRequested?: () => void;
}) {
const queryClient = useQueryClient();
const { data: wagons, isLoading } = useQuery({
queryKey: ["booking-wagons", bookingId],
queryFn: () => bookingsService.getWagons(bookingId),
enabled: !!bookingId,
});
// Cancellation history: feeds the "Cancelled wagons" section and blocks a
// second request while one is awaiting its fee.
const { data: history } = useQuery({
...api.bookings.listWagonCancellations.queryOptions({ input: { bookingId } }),
enabled: !!bookingId,
});
const ownCancellations = (history?.items ?? []).filter(
(r) => r.bookingId === bookingId,
);
const hasOpenCancellation = ownCancellations.some(
(r) => r.status === "FEE_PENDING",
);
const [selected, setSelected] = useState<Set<string>>(new Set());
const [confirmOpen, setConfirmOpen] = useState(false);
const [reason, setReason] = useState("");
const [preview, setPreview] = useState<WagonCancellationPreview | null>(null);
const toggle = (allocationId: string) =>
setSelected((prev) => {
const next = new Set(prev);
if (next.has(allocationId)) next.delete(allocationId);
else next.add(allocationId);
return next;
});
const previewMutation = useMutation({
mutationFn: () =>
bookingsService.previewWagonCancellation(bookingId, {
wagonAllocationIds: [...selected],
}),
onSuccess: setPreview,
onError: (e) => {
setPreview(null);
toast.error(apiErrorMessage(e, "Could not calculate the fee. Please try again."));
},
});
const requestMutation = useMutation({
mutationFn: () =>
bookingsService.requestWagonCancellation(bookingId, {
wagonAllocationIds: [...selected],
...(reason.trim() ? { reason: reason.trim() } : {}),
}),
onSuccess: () => {
setConfirmOpen(false);
setSelected(new Set());
setReason("");
setPreview(null);
toast.success(
"Cancellation requested — pay the fee in the wagon cancellation card to release these wagons.",
{ duration: 7000 },
);
void queryClient.invalidateQueries({ queryKey: ["booking-wagons", bookingId] });
void queryClient.invalidateQueries({
queryKey: api.bookings.listWagonCancellations.queryKey({ bookingId }),
});
onCancellationRequested?.();
},
onError: (e) =>
toast.error(apiErrorMessage(e, "Could not request the cancellation. Please try again.")),
});
const openConfirm = () => {
setPreview(null);
setConfirmOpen(true);
previewMutation.mutate();
};
if (isLoading) {
return (
<div className="flex flex-col gap-6" style={{ maxWidth: 980 }}>
<Skeleton height={140} radius={16} />
<SimpleGrid cols={{ base: 1, md: 2 }} spacing={24}>
<Skeleton height={220} radius={16} />
<Skeleton height={220} radius={16} />
</SimpleGrid>
</div>
);
}
if (!wagons?.length) {
return (
<div className="flex flex-col gap-6" style={{ maxWidth: 980 }}>
<CancelledWagonsSection rows={ownCancellations} />
<SectionCard>
<Group gap={12} align="center">
<Box
style={{
width: 44,
height: 44,
display: "flex",
alignItems: "center",
justifyContent: "center",
borderRadius: 12,
backgroundColor: "#F1F4F7",
color: "#6B7C8E",
}}
>
<TrainTrack size={22} />
</Box>
<Box>
<Text fz={15} fw={800} c="#10202F">
No wagons allocated yet
</Text>
<Text fz={13} c="#9AA8B5">
Your wagons will appear here once the shipment is placed on a
train after payment.
</Text>
</Box>
</Group>
</SectionCard>
</div>
);
}
const canSelect = !!cancellable && !hasOpenCancellation;
const first = wagons[0];
const totalAllocated = wagons.reduce(
(s, w) => s + Number(w.allocatedWeightTons || 0),
0,
);
const totalCapacity = wagons.reduce((s, w) => s + Number(w.capacityTons || 0), 0);
const containerCount = wagons.reduce((s, w) => s + (w.containers?.length ?? 0), 0);
const utilization =
totalCapacity > 0 ? Math.round((totalAllocated / totalCapacity) * 100) : null;
return (
<div className="flex flex-col gap-6" style={{ maxWidth: 980 }}>
<SectionCard>
<Group justify="space-between" align="flex-start" mb="md" wrap="wrap">
<Group gap={10} align="center">
<Box
style={{
width: 36,
height: 36,
display: "flex",
alignItems: "center",
justifyContent: "center",
borderRadius: 10,
backgroundColor: "#E8F5EF",
color: "#0A6F4D",
}}
>
<TrainFront size={18} />
</Box>
<Box>
<Text fz={15} fw={800} c="#10202F">
{first.trainNumber ? `Train ${first.trainNumber}` : "Your train"}
</Text>
<Group gap={5} align="center">
<MapPin size={11} color="#9AA8B5" />
<Text fz={12} c="#9AA8B5">
{first.originStation ?? "—"} {first.destinationStation ?? "—"}
{first.departureAt ? ` · departs ${fmtDate(first.departureAt)}` : ""}
</Text>
</Group>
</Box>
</Group>
<CardTitle>Your wagons on this train</CardTitle>
</Group>
<ConsistStrip wagons={wagons} />
<SimpleGrid cols={{ base: 2, sm: 4 }} spacing={10} mt="md">
<StatTile
icon={<TrainTrack size={13} />}
label="Wagons"
value={`${wagons.length}`}
sub="allocated to you"
/>
<StatTile
icon={<Scale size={13} />}
label="Allocated weight"
value={fmtWeight(totalAllocated)}
/>
<StatTile
icon={<Container size={13} />}
label="Containers"
value={containerCount ? `${containerCount}` : "—"}
sub={containerCount ? "loaded on wagons" : undefined}
/>
<StatTile
icon={<Gauge size={13} />}
label="Utilization"
value={utilization != null ? `${utilization}%` : "—"}
sub="of wagon capacity"
/>
</SimpleGrid>
</SectionCard>
{canSelect && (
<SectionCard>
<Group justify="space-between" align="center" wrap="wrap" gap="sm">
<Box>
<Text fz={14} fw={800} c="#10202F">
Cancel specific wagons
</Text>
<Text fz={12.5} c="#9AA8B5">
Tick the wagons you want to cancel. They leave this train
immediately; after you pay the per-wagon cancellation fee, the
freight you paid for them becomes a credit you can rebook on
another day.
</Text>
</Box>
<Button
color="orange"
disabled={selected.size === 0 || selected.size >= wagons.length}
onClick={openConfirm}
>
Cancel selected ({selected.size})
</Button>
</Group>
{selected.size >= wagons.length && selected.size > 0 && (
<Text fz={12} c="#B3362C" mt={6}>
You cannot cancel every wagon here to cancel the whole booking,
use the booking cancellation instead.
</Text>
)}
</SectionCard>
)}
{cancellable && hasOpenCancellation && (
<Alert color="yellow" variant="light">
A wagon cancellation is already awaiting its fee pay or withdraw it
in the wagon cancellation card before requesting another.
</Alert>
)}
<CancelledWagonsSection rows={ownCancellations} />
<SimpleGrid cols={{ base: 1, md: 2 }} spacing={24}>
{wagons.map((w) => (
<WagonCard
key={w.allocationId ?? w.sequenceNo}
wagon={w}
selectable={
canSelect &&
!!w.allocationId &&
(w.status === "PLANNED" || w.status === "RESERVED")
}
selected={!!w.allocationId && selected.has(w.allocationId)}
onToggle={() => w.allocationId && toggle(w.allocationId)}
/>
))}
</SimpleGrid>
<Modal
opened={confirmOpen}
onClose={() => setConfirmOpen(false)}
title="Cancel selected wagons"
centered
>
<Stack gap="sm">
<Text fz={13.5} c="#475569">
You are cancelling <b>{selected.size}</b> wagon(s). They stay
allocated to you until the cancellation fee is paid; after that the
paid freight for them becomes a credit you can rebook on another
day while your contract is valid.
</Text>
{previewMutation.isPending && <Skeleton height={64} radius={10} />}
{preview && (
<Box
p={12}
style={{
borderRadius: 10,
backgroundColor: "#FFFBEB",
border: "1px solid #FDE68A",
}}
>
<Group justify="space-between">
<Text fz={13} c="#92400E">
Cancellation fee ({preview.wagons} × {Number(preview.feePerWagon).toLocaleString()})
</Text>
<Text fz={14} fw={800} c="#92400E">
{Number(preview.feeAmount).toLocaleString()} {preview.feeCurrency}
</Text>
</Group>
<Group justify="space-between" mt={4}>
<Text fz={13} c="#0A6F4D">
Rebooking credit kept
</Text>
<Text fz={14} fw={800} c="#0A6F4D">
{Number(preview.creditAmount).toLocaleString()}
</Text>
</Group>
</Box>
)}
<Textarea
label="Reason (optional)"
placeholder="Why are you cancelling these wagons?"
value={reason}
onChange={(e) => setReason(e.currentTarget.value)}
autosize
minRows={2}
/>
<Group justify="flex-end" gap="sm">
<Button variant="default" onClick={() => setConfirmOpen(false)}>
Keep wagons
</Button>
<Button
color="orange"
loading={requestMutation.isPending}
disabled={!preview}
onClick={() => requestMutation.mutate()}
>
Request cancellation
</Button>
</Group>
</Stack>
</Modal>
</div>
);
}

View File

@@ -85,7 +85,12 @@ export function ClearanceFlow({ booking, flow, footer }: ClearanceFlowProps) {
return (
<Stack gap={0}>
{isReady ? (
{status === "OPERATION_CHANGES_REQUESTED" ? (
<Alert color="yellow" radius="md" icon={<Clock size={18} />} mb="md">
Operations returned this order for changes. Review their note, pick a
new shipment day below and resubmit.
</Alert>
) : isReady ? (
<Alert color="teal" radius="md" icon={<CheckCircle2 size={18} />} mb="md">
{needsCompletion
? "Clearance is finalized. Complete your booking now — enter the cargo details and pick a shipment day inside an open booking window."

View File

@@ -89,6 +89,22 @@ function actionByStatus(booking: ActionBooking): BookingNextAction | null {
label: "Schedule & proceed",
title: "Schedule your shipment",
};
case "OPERATION_CHANGES_REQUESTED":
// Contract bookings reopen the full completion form (cargo + shipment
// day, prefilled from the booking) — same page as the initial booking.
// Contract-less bookings keep the in-place day-picker modal.
return booking.contractId
? {
kind: "BOOK",
label: "Change booking",
title: "Change your booking",
to: `/contracts/${booking.contractId}/bookings/${booking.id}/complete`,
}
: {
kind: "SCHEDULE_OPERATION",
label: "Choose day & resubmit",
title: "Resubmit your shipment",
};
default:
return null;
}

View File

@@ -98,7 +98,10 @@ export function useClearanceFlow(booking: Freight.IBooking) {
[clearance],
);
const isReady = status === "CLEARANCE_READY";
// OPERATION_CHANGES_REQUESTED re-opens the same pick-a-day flow: the
// customer resubmits via the same clearance/proceed endpoint.
const isReady =
status === "CLEARANCE_READY" || status === "OPERATION_CHANGES_REQUESTED";
// Bare initiated instance: created with no cargo and no price; completion
// (cargo + shipment day + window check) happens on the full booking form.
const isBareInstance =

View File

@@ -3,18 +3,22 @@ import { useState } from "react";
import { useInvoicePayment } from "@/hooks/useInvoicePayment";
import { type PaymentMethod } from "@/services/payments.service";
import { invoicesService } from "@/services/invoices.service";
import { invoicesService, type PortalInvoice } from "@/services/invoices.service";
import { isPayable } from "@/pages/billing/invoice-ui";
/** Fee invoice opened by a partial wagon cancellation (see WagonCancellationCard). */
export const WAGON_CANCEL_FEE_INVOICE_TYPE = "WAGON_CANCEL_FEE";
/**
* Shared payment flow for a single booking: opens the method modal, fires
* POST /billing/my-invoices/:id/pay for the booking's currently payable
* invoice, and redirects the browser to the provider (or, for CAC Bank, an
* OTP debit with no redirect, collects the SMS'd code in the modal). Reused by
* the booking detail page, the booking list, and the home page so "Pay now"
* behaves identically everywhere.
* Core of the booking payment flows: resolves the booking's invoices (shared
* query/key with BookingPaymentPanel, so they share that cache), picks the one
* matching `match`, and charges it through the ownership-checked portal route —
* redirect vs CAC Bank OTP handled by `useInvoicePayment`.
*/
export function useBookingPayment(bookingId: string) {
function useBookingInvoicePayment(
bookingId: string,
match: (invoice: PortalInvoice) => boolean,
) {
const [modalOpen, setModalOpen] = useState(false);
const [noInvoice, setNoInvoice] = useState(false);
@@ -22,7 +26,7 @@ export function useBookingPayment(bookingId: string) {
queryKey: ["booking-invoices", bookingId],
queryFn: () => invoicesService.listForSource("booking", bookingId),
});
const payableInvoiceId = invoices.find((inv) => isPayable(inv.status))?.id;
const payableInvoiceId = invoices.find(match)?.id;
const flow = useInvoicePayment();
@@ -56,3 +60,28 @@ export function useBookingPayment(bookingId: string) {
},
};
}
/**
* Shared payment flow for a single booking: opens the method modal, fires
* POST /billing/my-invoices/:id/pay for the booking's currently payable
* invoice, and redirects the browser to the provider (or, for CAC Bank, an
* OTP debit with no redirect, collects the SMS'd code in the modal). Reused by
* the booking detail page, the booking list, and the home page so "Pay now"
* behaves identically everywhere.
*/
export function useBookingPayment(bookingId: string) {
return useBookingInvoicePayment(bookingId, (inv) => isPayable(inv.status));
}
/**
* Same flow, but targets the booking's payable wagon-cancellation FEE invoice
* (type WAGON_CANCEL_FEE) — the freight invoice is already paid on these
* bookings, so the generic "first payable" pick would work today, but pinning
* the type keeps the two buttons from ever racing over the same invoice.
*/
export function useFeeInvoicePayment(bookingId: string) {
return useBookingInvoicePayment(
bookingId,
(inv) => inv.type === WAGON_CANCEL_FEE_INVOICE_TYPE && isPayable(inv.status),
);
}

View File

@@ -104,16 +104,29 @@ const COUNTDOWN_TEXT: Partial<
PRE_WINDOW: { label: "Booking opens in", expiredText: "Booking opening now…" },
OPEN: { label: "Window closes in", expiredText: "Document review starting…" },
DOC_REVIEW: { label: "Document review ends in", expiredText: "Payment starting…" },
PAYMENT: { label: "Payment due in", expiredText: "Payment window closing…" },
PAYMENT: { label: "Payment due in", expiredText: "Finalizing payments…" },
};
function phaseCountdown(
w: MyBookingWindow,
): { label: string; deadline: string; expiredText: string } | null {
function phaseCountdown(w: MyBookingWindow): {
label: string;
deadline: string;
expiredText: string;
graceDeadline?: string | null;
graceLabel?: string;
} | null {
const state = bookingWindowUiState(w);
const text = COUNTDOWN_TEXT[state.kind];
if (!state.countdownTo || !text) return null;
return { ...text, deadline: state.countdownTo };
// Once the pay deadline lapses, pending payments still settle during the
// drain tail — count it down as "processing" instead of a stale "closing".
const grace =
state.kind === "PAYMENT" && w.paymentDrainEndsAt
? {
graceDeadline: w.paymentDrainEndsAt,
graceLabel: "Payment processing — closes in",
}
: undefined;
return { ...text, deadline: state.countdownTo, ...grace };
}
/**
@@ -226,6 +239,8 @@ function WindowCard({ w }: { w: MyBookingWindow }) {
deadline={cd.deadline}
label={cd.label}
expiredText={cd.expiredText}
graceDeadline={cd.graceDeadline}
graceLabel={cd.graceLabel}
size="xs"
/>
</Box>

View File

@@ -38,6 +38,7 @@ import {
CheckCircle2,
ChevronLeft,
FileDown,
FileText,
FileUp,
Flame,
MapPin,
@@ -59,6 +60,7 @@ import {
contractsService,
type ShipmentValidation,
} from "@/services/contracts.service";
import { downloadStoredFile } from "@/services/files.service";
import {
SelectField,
StepCard,
@@ -245,6 +247,127 @@ function bulkUnitOfMeasure(
return hasPerItem ? "PER_ITEM" : "PER_TON";
}
/**
* Prefill for a changes-requested resubmit: the booking's persisted cargo,
* currency and route become the form's starting values so the customer edits
* what exists instead of retyping it. The shipment day is deliberately left
* empty — a new day must be picked.
*/
function mapBookingToShipmentValues(
booking: Freight.IBooking,
contract: Freight.IContract,
): Partial<ShipmentFormInputValues> {
const b = booking as unknown as {
cargoFreeText?: string | null;
contractRouteId?: string | null;
cargoTotalWeightVgm?: number | string | null;
bulkTotalWeightTons?: number | string | null;
bulkHazardousQuantity?: number | string | null;
bulkReeferQuantity?: number | string | null;
bookingContainers?: Array<{
quantity?: number;
hazardousQuantity?: number | string | null;
reeferQuantity?: number | string | null;
returnQuantity?: number | string | null;
containerType?: { sizeFt?: number | null } | null;
units?: Array<{
containerNumber?: string;
sealNumber?: string | null;
vgmTons?: number | string;
isHazardous?: boolean;
isReefer?: boolean;
isReturn?: boolean;
}>;
}>;
};
const values: Partial<ShipmentFormInputValues> = {
paymentCurrency: booking.paymentCurrency === "ETB" ? "ETB" : "USD",
withReturn: booking.equipmentReturn === "WITH_RETURN",
cargoDescription: b.cargoFreeText ?? "",
...(b.contractRouteId ? { contractRouteId: b.contractRouteId } : {}),
};
if (contract.freightType === "CONTAINER") {
const rows = b.bookingContainers ?? [];
const lineFor = (size: "20ft" | "40ft") => {
const bc = rows.find(
(r) => (r.containerType?.sizeFt === 40 ? "40ft" : "20ft") === size,
);
return {
containerSize: size,
quantity: String(bc?.quantity ?? 0),
hazardousQuantity: String(Number(bc?.hazardousQuantity ?? 0)),
reeferQuantity: String(Number(bc?.reeferQuantity ?? 0)),
returnQuantity: String(Number(bc?.returnQuantity ?? 0)),
units: (bc?.units ?? []).map((u) => ({
containerNumber: u.containerNumber ?? "",
sealNumber: u.sealNumber ?? "",
vgmTons: String(Number(u.vgmTons ?? 0)),
isHazardous: Boolean(u.isHazardous),
isReefer: Boolean(u.isReefer),
isReturn: Boolean(u.isReturn),
})),
};
};
const sizes = (contract.cargoScope ?? [])
.map((s) => s.containerSize)
.filter((s): s is "20ft" | "40ft" => s === "20ft" || s === "40ft");
values.containers = (sizes.length ? sizes : (["20ft", "40ft"] as const)).map(lineFor);
} else {
const perItem = bulkUnitOfMeasure(contract) === "PER_ITEM";
const amount = Number(b.cargoTotalWeightVgm ?? 0);
if (perItem) {
values.itemCount = amount ? String(amount) : "";
values.cargoWeightTons =
b.bulkTotalWeightTons != null
? String(Number(b.bulkTotalWeightTons))
: "";
} else {
values.cargoWeightTons = amount ? String(amount) : "";
}
values.bulkHazardousQuantity = String(Number(b.bulkHazardousQuantity ?? 0));
values.bulkReeferQuantity = String(Number(b.bulkReeferQuantity ?? 0));
}
return values;
}
/** Read-only list of the booking's already-uploaded documents (resubmit view). */
function UploadedDocumentsCard({ booking }: { booking: Freight.IBooking }) {
const files =
(booking as unknown as { files?: Array<{ id: string; name: string }> })
.files ?? [];
if (!files.length) return null;
return (
<Paper withBorder radius="lg" p="lg">
<Group gap={8} mb={4}>
<FileText size={16} />
<Text fw={700} fz="sm">
Your uploaded documents
</Text>
</Group>
<Text fz={12.5} c="dimmed" mb="sm">
These stay attached to the booking no need to upload them again.
</Text>
<Stack gap={6}>
{files.map((f) => (
<Group key={f.id} justify="space-between" wrap="nowrap">
<Text fz={13} truncate>
{f.name}
</Text>
<ActionIcon
variant="default"
radius="md"
onClick={() => void downloadStoredFile(f.id, f.name)}
aria-label={`Download ${f.name}`}
>
<FileDown size={15} />
</ActionIcon>
</Group>
))}
</Stack>
</Paper>
);
}
function NewShipmentBookingForm({
contract,
contractId,
@@ -284,6 +407,10 @@ function NewShipmentBookingForm({
unitOfMeasure: bulkUnitOfMeasure(contract),
// Intercity rides a passing train staff pick later — no date to choose.
requiresDate: contract.tradeDirection !== "DOMESTIC",
// Export completion locks onto a specific train — the pick is required
// (mirrors the ScheduleStep picker's visibility).
requiresTrain:
contract.tradeDirection === "EXPORT" && Boolean(completeBookingId),
}),
),
mode: "onChange",
@@ -302,6 +429,34 @@ function NewShipmentBookingForm({
: 0;
const hasOdd20ft = ft20Total % 2 === 1;
// COMPLETION mode: fetch the booking — a changes-requested resubmit prefills
// the form from it and shows the operations note + uploaded documents.
const { data: completeBooking } = useQuery(
api.bookings.get.queryOptions({
input: { id: completeBookingId! },
enabled: Boolean(completeBookingId),
}),
);
const isResubmit = Boolean(
completeBooking &&
["OPERATION_CHANGES_REQUESTED", "EXPIRED"].includes(
completeBooking.status as string,
) &&
(((completeBooking as unknown as { bookingContainers?: unknown[] })
.bookingContainers?.length ?? 0) > 0 ||
Number(completeBooking.cargoTotalWeightVgm ?? 0) > 0),
);
const prefilledRef = useRef(false);
useEffect(() => {
if (!isResubmit || prefilledRef.current || !completeBooking) return;
prefilledRef.current = true;
form.reset({
...form.getValues(),
...mapBookingToShipmentValues(completeBooking, contract),
});
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [isResubmit]);
const submitMutation = useMutation({
mutationFn: (dto: Freight.CreateBookingUnderContractDto) =>
completeBookingId
@@ -328,7 +483,12 @@ function NewShipmentBookingForm({
// price modal opens so re-reviewing after an edit re-checks.
const validateMutation = useMutation({
mutationFn: (dto: Freight.CreateBookingUnderContractDto) =>
api.contracts.validateShipment.call({ id: contractId, dto }),
api.contracts.validateShipment.call({
id: contractId,
dto,
// Resubmit preview must not clash with this booking's own containers.
excludeBookingId: completeBookingId,
}),
});
function buildDto(
@@ -484,12 +644,16 @@ function NewShipmentBookingForm({
style={{ letterSpacing: "-0.01em" }}
>
{completeBookingId
? "Complete Your Booking"
? isResubmit
? "Change Your Booking"
: "Complete Your Booking"
: "New Shipment Booking"}
</Title>
<Text size="sm" c="edr-muted" mt={4}>
{completeBookingId
? `Clearance is finalized — enter the cargo details and shipment day to complete your booking under contract ${contract.reference}.`
? isResubmit
? `Update the details below and pick a new shipment day, then resubmit your booking under contract ${contract.reference}.`
: `Clearance is finalized — enter the cargo details and shipment day to complete your booking under contract ${contract.reference}.`
: `Book a shipment against contract ${contract.reference}.`}
</Text>
</Box>
@@ -531,6 +695,16 @@ function NewShipmentBookingForm({
{/* Single-step form — all sections on one page. */}
<Stack gap="lg" className="mx-auto max-w-4xl">
{isResubmit && completeBooking?.latestChangeRequestNote && (
<Alert
color="yellow"
icon={<AlertCircle size={16} />}
radius="md"
title="Operations requested changes"
>
<Text size="sm">{completeBooking.latestChangeRequestNote}</Text>
</Alert>
)}
<RouteStep form={form} contract={contract} routes={routes} />
<CargoStep form={form} contract={contract} />
{/* Legacy contracts only — WITH_RETURN contracts capture per-line
@@ -543,6 +717,9 @@ function NewShipmentBookingForm({
routes={routes}
completeBookingId={completeBookingId ?? null}
/>
{isResubmit && completeBooking && (
<UploadedDocumentsCard booking={completeBooking} />
)}
{/* Notes are captured when the booking is initiated — completing
a bare booking does not re-ask for them. */}
{!completeBookingId && <NotesSection form={form} />}
@@ -590,7 +767,7 @@ function NewShipmentBookingForm({
onClick={handleReview}
disabled={hasOdd20ft}
>
Review price &amp; book
{isResubmit ? "Change booking" : "Review price & book"}
</Button>
</Box>
</Tooltip>
@@ -1179,12 +1356,23 @@ function ScheduleStep({
</Text>
)}
{isExportPick && scheduledDate ? (
<ExportTrainPicker
options={exportTrainsQuery.data ?? []}
loading={exportTrainsQuery.isLoading}
value={selectedTrainId ?? ""}
onChange={(id) => form.setValue("trainScheduleId", id)}
/>
<>
<ExportTrainPicker
options={exportTrainsQuery.data ?? []}
loading={exportTrainsQuery.isLoading}
value={selectedTrainId ?? ""}
onChange={(id) =>
form.setValue("trainScheduleId", id, {
shouldValidate: true,
})
}
/>
{form.formState.errors.trainScheduleId?.message && (
<Text fz="xs" c="red" mt={6}>
{String(form.formState.errors.trainScheduleId.message)}
</Text>
)}
</>
) : null}
</Box>
)}

View File

@@ -30,6 +30,11 @@ export interface ShipmentValidationContext {
* staff pick later, so no shipment day is chosen. Defaults to true.
*/
requiresDate?: boolean;
/**
* EXPORT rail completion: the shipment must ride a specific train the
* customer picks for the chosen day. Defaults to false.
*/
requiresTrain?: boolean;
}
// ISO 6346: 3-letter owner code + category id (U/J/Z) + 6-digit serial + check digit.
@@ -102,6 +107,20 @@ export function createShipmentFormSchema(ctx: ShipmentValidationContext) {
});
}
// Train is only pickable once a day is chosen — the day error covers the
// no-date case, so don't stack a second error on an invisible field.
if (
ctx.requiresTrain &&
data.scheduledDate.trim() &&
!data.trainScheduleId.trim()
) {
refineCtx.addIssue({
code: "custom",
path: ["trainScheduleId"],
message: "Select a train for your shipment day.",
});
}
// No default currency — the customer must pick one before submitting.
if (!data.paymentCurrency) {
refineCtx.addIssue({

View File

@@ -0,0 +1,52 @@
import { describe, expect, it } from "vitest";
import { createShipmentFormSchema, initialShipmentFormValues } from "./schema";
const schema = createShipmentFormSchema({
isContainer: false,
isHazardous: false,
isReefer: false,
requiresTrain: true,
});
const values = (over: Record<string, unknown> = {}) => ({
...initialShipmentFormValues,
cargoWeightTons: "10",
paymentCurrency: "USD",
scheduledDate: "2026-08-10",
...over,
});
const trainIssue = (input: Record<string, unknown>) => {
const result = schema.safeParse(input);
return result.success
? undefined
: result.error.issues.find((i) => i.path[0] === "trainScheduleId");
};
describe("requiresTrain", () => {
it("rejects a dated export completion without a train pick", () => {
expect(trainIssue(values())?.message).toMatch(/select a train/i);
});
it("passes once a train is picked", () => {
expect(trainIssue(values({ trainScheduleId: "sched-1" }))).toBeUndefined();
});
it("stays silent while no date is chosen (day error covers it)", () => {
expect(trainIssue(values({ scheduledDate: "" }))).toBeUndefined();
});
it("is off by default (non-completion flows)", () => {
const plain = createShipmentFormSchema({
isContainer: false,
isHazardous: false,
isReefer: false,
});
const result = plain.safeParse(values());
expect(
result.success ||
result.error.issues.every((i) => i.path[0] !== "trainScheduleId"),
).toBe(true);
});
});

View File

@@ -15,6 +15,8 @@ import {
GeneratePriceResponse,
type MyBookingWindow,
SubmitBookingResponse,
type WagonCancellationListFilter,
type WagonCancellationListResponse,
} from "./bookings.service";
import {
contractsService,
@@ -483,6 +485,22 @@ export const api = {
bookingsService.getExportTrains(bookingId, date, cargo),
),
listWagonCancellations: endpoint<
{ bookingId: string },
WagonCancellationListResponse
>("bookings", "listWagonCancellations", ({ bookingId }) =>
bookingsService.listWagonCancellations(bookingId),
),
listMyWagonCancellations: endpoint<
WagonCancellationListFilter | void,
WagonCancellationListResponse
>(
"bookings",
"listMyWagonCancellations",
bookingsService.listMyWagonCancellations,
),
getMyBookingWindows: endpoint<void, MyBookingWindow[]>(
"train-scheduling",
"myBookingWindows",
@@ -610,10 +628,14 @@ export const api = {
),
validateShipment: endpoint<
{ id: string; dto: Freight.CreateBookingUnderContractDto },
{
id: string;
dto: Freight.CreateBookingUnderContractDto;
excludeBookingId?: string;
},
ShipmentValidation
>("contracts", "validateShipment", ({ id, dto }) =>
contractsService.validateShipment(id, dto),
>("contracts", "validateShipment", ({ id, dto, excludeBookingId }) =>
contractsService.validateShipment(id, dto, excludeBookingId),
),
getContractMilestones: endpoint<

View File

@@ -91,6 +91,8 @@ export interface MyBookingWindow {
windowClosesAt: string | null;
docReviewEndsAt: string | null;
paymentPhaseEndsAt: string | null;
/** End of the payment drain tail — pending payments may settle until then. */
paymentDrainEndsAt: string | null;
bookingWindowStatus: string;
bookingCycleNo: number;
departureDate: string;
@@ -193,6 +195,116 @@ export interface BookingListFilter {
sortOrder?: "ASC" | "DESC";
}
export interface BookingWagonContainer {
containerNumber: string | null;
sealNumber: string | null;
positionOnWagon: number | null;
grossWeightTons: string | null;
}
/** One allocated wagon of a booking, as returned by GET /bookings/:id/wagons. */
export interface BookingWagonAllocation {
/** wagon_booking_allocations id — the handle for cancelling this specific wagon. */
allocationId: string;
sequenceNo: number;
wagonNumber: string | null;
wagonType: string | null;
wagonTypeCode: string | null;
tareWeightTons: string | null;
capacityTons: string | null;
lengthMeters: string | null;
allocatedWeightTons: string | null;
loadType: "CONTAINER" | "BULK";
status: "PLANNED" | "RESERVED" | "LOADED" | "DEPARTED";
trainNumber: string | null;
departureAt: string | null;
originStation: string | null;
destinationStation: string | null;
bulkCargoDescription: string | null;
bulkQuantity: string | null;
containers: BookingWagonContainer[];
}
// ── Partial wagon cancellation (paid bookings) ──────────────────────────────
export type WagonCancellationStatus =
| "FEE_PENDING"
| "CREDIT_AVAILABLE"
| "REBOOKED"
| "WITHDRAWN"
| "EXPIRED";
/** One partial-cancellation ledger row of a paid booking. */
export interface WagonCancellation {
id: string;
bookingId: string;
rebookedBookingId?: string | null;
wagonsCancelled: number;
weightTons: number;
/** What was cut: bulk tons, or container units per size (ft). */
cancelledQuantities: {
bulkTons?: number;
bySize?: Record<string, number>;
/** Exact physical containers leaving with the cancelled wagons. */
units?: Array<{
containerSize: string;
containerNumber: string;
sealNumber?: string | null;
vgmTons: number;
isHazardous: boolean;
isReefer: boolean;
}>;
/** Wagons already left the schedule when the request was made. */
releasedAtRequest?: boolean;
};
/** Rebooking credit — the cancelled share of the original freight price. */
creditAmount: number;
feeAmount: number;
feeCurrency: string;
feeInvoiceId?: string | null;
feePaidAt?: string | null;
status: WagonCancellationStatus;
reason?: string | null;
rebookedAt?: string | null;
createdAt: string;
booking?: { id: string; reference: string } | null;
rebookedBooking?: { id: string; reference: string } | null;
}
/** Fee/credit preview of a partial wagon cancellation (no writes). */
export interface WagonCancellationPreview {
wagons: number;
weightTons: number;
feePerWagon: number;
feeAmount: number;
feeCurrency: string;
creditAmount: number;
}
export interface RequestWagonCancellationPayload {
/** Cancel SPECIFIC wagons: allocationIds from getWagons. Overrides the fields below. */
wagonAllocationIds?: string[];
/** BULK bookings: number of wagons to cancel (tons derived proportionally). */
wagons?: number;
/** CONTAINER bookings: units to cancel per size ("20"/"40", as stored on the line). */
containers?: Array<{ containerSize: string; quantity: number }>;
reason?: string;
}
export interface WagonCancellationListFilter {
statuses?: string[];
search?: string;
from?: string;
to?: string;
page?: number;
pageSize?: number;
}
export interface WagonCancellationListResponse {
items: WagonCancellation[];
total: number;
}
export const bookingsService = {
list: async (
filter: BookingListFilter | void = {},
@@ -315,6 +427,16 @@ export const bookingsService = {
return data.data;
},
customerCancel: async (
id: string,
reason?: string,
): Promise<Freight.IBooking> => {
const { data } = await client.post(`/api/bookings/${id}/customer-cancel`, {
reason,
});
return data.data;
},
reject: async (id: string, reason?: string): Promise<Freight.IBooking> => {
const { data } = await client.post(`/api/bookings/${id}/reject`, { reason });
return data.data;
@@ -548,6 +670,82 @@ export const bookingsService = {
return data.data as Freight.DayAvailabilityResponse;
},
/**
* Allocated wagons for a paid booking (empty until placed on a train).
* One row per wagon with its containers / bulk load.
*/
getWagons: async (bookingId: string): Promise<BookingWagonAllocation[]> => {
const { data } = await client.get(`/api/bookings/${bookingId}/wagons`);
return (data.data ?? data) as BookingWagonAllocation[];
},
// ── Partial wagon cancellation ──
/** Fee/credit preview for the confirm dialog — same math as the request, no writes. */
previewWagonCancellation: async (
id: string,
payload: RequestWagonCancellationPayload,
): Promise<WagonCancellationPreview> => {
const { data } = await client.post(
`/api/bookings/${id}/wagon-cancellations/preview`,
payload,
);
return data.data ?? data;
},
/** Open a cancellation: issues the fee invoice; wagons release once the fee settles. */
requestWagonCancellation: async (
id: string,
payload: RequestWagonCancellationPayload,
): Promise<WagonCancellation> => {
const { data } = await client.post(
`/api/bookings/${id}/wagon-cancellations`,
payload,
);
return data.data ?? data;
},
/** Cancellation history of one booking (as source and as rebooked target). */
listWagonCancellations: async (
bookingId: string,
): Promise<WagonCancellationListResponse> => {
const { data } = await client.get(
`/api/bookings/${bookingId}/wagon-cancellations`,
);
return data.data ?? data;
},
/** The signed-in customer's wagon cancellations (paginated, filterable). */
listMyWagonCancellations: async (
filter: WagonCancellationListFilter | void = {},
): Promise<WagonCancellationListResponse> => {
const { data } = await client.get("/api/bookings/wagon-cancellations/my", {
params: filter,
});
return data.data ?? data;
},
/** Void a FEE_PENDING request — the fee invoice is cancelled, nothing was released. */
withdrawWagonCancellation: async (
cancellationId: string,
): Promise<WagonCancellation> => {
const { data } = await client.post(
`/api/bookings/wagon-cancellations/${cancellationId}/withdraw`,
);
return data.data ?? data;
},
/** Rebook a CREDIT_AVAILABLE cancellation onto a shipment day → new PAID booking. */
rebookWagonCancellation: async (
cancellationId: string,
payload: { scheduledDate: string },
): Promise<{ cancellation: WagonCancellation; bookingId: string }> => {
const { data } = await client.post(
`/api/bookings/wagon-cancellations/${cancellationId}/rebook`,
payload,
);
return data.data ?? data;
},
/**
* Upcoming/open booking windows on the signed-in customer's active-contract
* lanes (import booking-day windows + export 24h pre-departure windows).

View File

@@ -400,8 +400,13 @@ export const contractsService = {
validateShipment: async (
id: string,
dto: Freight.CreateBookingUnderContractDto,
// Completion/resubmit: exclude this booking's own containers from the
// same-train clash check.
excludeBookingId?: string,
): Promise<ShipmentValidation> => {
const { data } = await client.post(C.VALIDATE_SHIPMENT(id), dto);
const { data } = await client.post(C.VALIDATE_SHIPMENT(id), dto, {
params: excludeBookingId ? { bookingId: excludeBookingId } : undefined,
});
return data.data ?? data;
},