mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-29 17:38:12 +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[]> => {
|
||||
|
||||
Reference in New Issue
Block a user