mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-26 18:42:49 +00:00
Merge branch 'dev' into freight/nati-2
This commit is contained in:
@@ -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={
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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} />
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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} />
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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) ||
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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();
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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[]> => {
|
||||
|
||||
@@ -47,6 +47,7 @@ function applyEvent(
|
||||
windowClosesAt: event.windowClosesAt,
|
||||
docReviewEndsAt: event.docReviewEndsAt,
|
||||
paymentPhaseEndsAt: event.paymentPhaseEndsAt,
|
||||
paymentDrainEndsAt: event.paymentDrainEndsAt,
|
||||
departureDate: event.scheduledDepartureDate ?? row.departureDate,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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're about to cancel booking{" "}
|
||||
<Text span fw={700} c="#10202F">
|
||||
{booking.reference}
|
||||
</Text>
|
||||
. Since you haven'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>
|
||||
);
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
|
||||
/**
|
||||
|
||||
@@ -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 && (
|
||||
|
||||
@@ -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",
|
||||
},
|
||||
}}
|
||||
>
|
||||
|
||||
@@ -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>
|
||||
),
|
||||
}
|
||||
|
||||
@@ -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'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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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."
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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 =
|
||||
|
||||
@@ -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),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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 & 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>
|
||||
)}
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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<
|
||||
|
||||
@@ -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).
|
||||
|
||||
@@ -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;
|
||||
},
|
||||
|
||||
|
||||
Reference in New Issue
Block a user