mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-30 07:38:10 +00:00
Merge branch 'dev' into freight/nati-2
This commit is contained in:
@@ -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",
|
||||
|
||||
Reference in New Issue
Block a user