Merge branch 'dev' into freight/nati-2

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

View File

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

View File

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

View File

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

View File

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