style: inter module integration

This commit is contained in:
Nathnael
2026-08-13 06:54:54 +00:00
parent e0f18f17b3
commit d261d6ea7c
35 changed files with 2104 additions and 2037 deletions

View File

@@ -2,43 +2,56 @@ import { useNavigate, useParams, useSearchParams } from "react-router-dom";
import toast from "react-hot-toast";
import {
ArrowLeft,
Container as ContainerIcon,
FileSignature,
FileText,
Flame,
FolderOpen,
Layers,
LayoutGrid,
Milestone,
MoreHorizontal,
Package,
RefreshCw,
Truck,
Wallet,
Weight,
} from "lucide-react";
import {
Container,
Stack,
Grid,
ActionIcon,
Box,
Button,
Center,
Container,
Grid,
Group,
Loader,
Menu,
Paper,
SegmentedControl,
Stack,
Tabs,
Text,
Paper,
Button,
Box,
SegmentedControl,
} from "@mantine/core";
import { PageContainer } from "@/components/page";
import Breadcrumbs from "@/components/ui/Breadcrumbs";
import { PageContainer, PageHeader, KpiStrip } from "@/components/page";
import type { KpiItem } from "@/components/page";
import { EntityLink } from "@/components/detail";
import { BookingActionsToolbar } from "@/components/bookings/BookingActionsToolbar";
import { BookingPricingSummary } from "@/components/bookings/BookingPricingSummary";
import { BookingWorkflowStepper } from "@/components/bookings/BookingWorkflowStepper";
import { BookingStatusBadge } from "@/components/bookings/BookingStatusBadge";
import { BookingPriorityBadge } from "@/components/bookings/BookingPriorityBadge";
import { NextStepBanner } from "@/components/bookings/NextStepBanner";
import { SchedulingStatusBadge } from "@/components/trainScheduling/ScheduleStatusBadge";
import { ConsolidationWaitingBanner } from "@/components/bookings/detail/ConsolidationWaitingBanner";
import {
detailStyles,
BookingRequestHero,
BookingRouteServiceCard,
BookingMileServicesCard,
BookingCargoCard,
BookingCompanyCard,
BookingContractSummaryCard,
BookingContractCard,
BookingContainerUnitsCard,
BookingSchedulingWindowCard,
BookingDocumentsPanel,
@@ -48,6 +61,7 @@ import {
import { WarehouseInfoCard } from "@/components/warehouses";
import { getStatusMeta } from "@/features/bookings/booking-status.config";
import { toBookingListRow } from "@/features/bookings/mapBookingListRow";
import { cargoTonsAndItems } from "@/utils/cargoWeight";
import type { BookingDetail } from "@/types/booking";
import {
useBookingDetail,
@@ -133,7 +147,6 @@ export default function BookingRequestDetailPage() {
);
}
const row = toBookingListRow(booking);
const statusMeta = getStatusMeta(booking.status);
// Clearance review + finalize now lives solely on the Operations "Clearance
// Documents" hub (/dashboard/contracts/clearance-documents → detail page), so
@@ -159,23 +172,176 @@ export default function BookingRequestDetailPage() {
setSearchParams(next, { replace: true });
};
const company = booking.company;
const customerName = toBookingListRow(booking).customerLabel;
const amount = Number(booking.totalAmount);
const containers = booking.bookingContainers ?? [];
const containerCount = containers.reduce(
(sum, c) => sum + Number(c.quantity ?? 0),
0,
);
const { tons: weight, items: itemCount } = cargoTonsAndItems(booking);
const kpis: KpiItem[] = [
{
label: "Total value",
value: `${booking.paymentCurrency} ${amount.toLocaleString(undefined, { minimumFractionDigits: 2 })}`,
hint: booking.paymentStatus,
icon: Wallet,
color: "edr-green",
},
{
label: "Cargo weight",
value: `${weight} T`,
hint: itemCount != null ? `${itemCount} items` : "VGM total",
icon: Weight,
color: "blue",
},
{
label: "Containers",
value: containerCount || "—",
hint: `${containers.length} line${containers.length === 1 ? "" : "s"}`,
icon: ContainerIcon,
color: "teal",
},
{
label: "Priority score",
value: booking.priorityScore ?? 0,
hint: booking.tradeDirection,
icon: Flame,
color: "orange",
},
];
const hasSignableContract = booking.isGovernment && booking.contractSummary;
return (
<PageContainer>
<Breadcrumbs
items={[
<PageHeader
breadcrumbs={[
{ label: "Booking requests", href: "/dashboard/booking-requests" },
{ label: booking.reference },
]}
backTo="/dashboard/booking-requests"
title={booking.reference}
meta={
<Group gap={6} wrap="wrap">
<BookingStatusBadge status={booking.status} />
<BookingPriorityBadge score={booking.priorityScore} />
{booking.schedulingStatus ? (
<SchedulingStatusBadge status={booking.schedulingStatus} />
) : null}
</Group>
}
subtitle={
<Group gap={6} wrap="wrap">
<EntityLink
to={company?.id ? `/dashboard/customers/${company.id}` : null}
label={customerName ?? "—"}
/>
<Text size="sm" c="dimmed">
· Scheduled {booking.scheduledDate}
</Text>
</Group>
}
action={
<Group gap="sm" wrap="nowrap">
<ActionIcon
variant="light"
color="edr-green"
size="lg"
radius="md"
loading={isFetching}
aria-label="Refresh"
onClick={() => refetch()}
>
<RefreshCw size={16} />
</ActionIcon>
<Menu position="bottom-end" width={260} withinPortal>
<Menu.Target>
<ActionIcon
variant="default"
size="lg"
radius="md"
aria-label="More actions"
>
<MoreHorizontal size={16} />
</ActionIcon>
</Menu.Target>
<Menu.Dropdown>
{hasSignableContract && (
<Menu.Item
leftSection={<FileSignature size={15} />}
onClick={() =>
navigate(`/dashboard/booking-requests/${booking.id}/contract`)
}
>
View / sign contract
</Menu.Item>
)}
<Menu.Item
leftSection={<FileText size={15} />}
onClick={async () => {
try {
const blob =
await bookingsService.downloadCarriageAcceptanceSheet(
booking.id,
);
const url = URL.createObjectURL(blob);
const a = document.createElement("a");
a.href = url;
a.download = `carriage-acceptance-${booking.reference}.pdf`;
a.click();
URL.revokeObjectURL(url);
} catch (error) {
toast.error(
error instanceof Error
? error.message
: "Carriage acceptance sheet is not available yet",
);
}
}}
>
Carriage acceptance sheet
</Menu.Item>
{booking.customsClearingEnabled && (
<Menu.Item
leftSection={<Milestone size={15} />}
onClick={() =>
navigate(`/dashboard/bookings/${booking.id}/clearance`)
}
>
View document clearance
</Menu.Item>
)}
</Menu.Dropdown>
</Menu>
</Group>
}
/>
<Stack gap="lg">
<BookingRequestHero
booking={booking}
customerLabel={row.customerLabel}
onBack={() => navigate("/dashboard/booking-requests")}
onRefresh={() => refetch()}
isFetching={isFetching}
/>
<KpiStrip items={kpis} />
{booking.holdExpiresAt && booking.schedulingStatus === "HOLDING" ? (
<Text size="xs" c="orange.7">
Hold expires {new Date(booking.holdExpiresAt).toLocaleString()}
</Text>
) : null}
{booking.nextStep ? (
<Paper
radius="lg"
p={4}
style={{
background: "var(--mantine-color-gray-0)",
border: "1px solid var(--mantine-color-gray-2)",
}}
>
<NextStepBanner nextStep={booking.nextStep} />
</Paper>
) : null}
<BookingWorkflowStepper
status={booking.status}
@@ -223,7 +389,7 @@ export default function BookingRequestDetailPage() {
</Tabs.List>
<Tabs.Panel value="overview">
<OverviewPanel booking={booking} row={row} />
<OverviewPanel booking={booking} onRefetch={refetch} />
</Tabs.Panel>
{isGeneralContract && (
<Tabs.Panel value="orders">
@@ -247,6 +413,7 @@ export default function BookingRequestDetailPage() {
<Box style={{ position: "sticky", top: 24 }}>
<Stack gap="lg">
<BookingCompanyCard booking={booking} />
<BookingContractCard booking={booking} />
<BookingPricingSummary booking={booking} />
<Box id="warehouse-payments">
<WarehouseInfoCard
@@ -265,97 +432,6 @@ export default function BookingRequestDetailPage() {
booking={booking}
mutations={mutations}
/>
{booking.tradeDirection === "EXPORT" && (
<Paper withBorder radius="md" p="sm">
<Stack gap={6}>
<Text size="sm" fw={600}>
How the cargo reaches the train
</Text>
<SegmentedControl
fullWidth
size="xs"
value={booking.exportHandoverMode ?? "WAREHOUSE"}
data={[
{ value: "WAREHOUSE", label: "Warehouse then train" },
{ value: "DIRECT_TO_TRAIN", label: "Direct truck to train" },
]}
onChange={async (value) => {
try {
await bookingsService.setExportHandoverMode(
booking.id,
value as "DIRECT_TO_TRAIN" | "WAREHOUSE",
);
await refetch();
} catch (error) {
toast.error(
error instanceof Error
? error.message
: "Could not change the handover mode",
);
}
}}
/>
<Text size="xs" c="dimmed">
{booking.exportHandoverMode === "DIRECT_TO_TRAIN"
? "No warehouse receipt and no GRN — the carriage acceptance sheet is the handover document."
: "Cargo is received at the warehouse and issued a GRN before loading."}
</Text>
</Stack>
</Paper>
)}
{booking.isGovernment && booking.contractSummary && (
<Button
fullWidth
variant="default"
leftSection={<FileSignature size={16} />}
onClick={() =>
navigate(
`/dashboard/booking-requests/${booking.id}/contract`,
)
}
>
View / sign contract
</Button>
)}
<Button
fullWidth
variant="default"
leftSection={<FileText size={16} />}
onClick={async () => {
try {
const blob =
await bookingsService.downloadCarriageAcceptanceSheet(
booking.id,
);
const url = URL.createObjectURL(blob);
const a = document.createElement("a");
a.href = url;
a.download = `carriage-acceptance-${booking.reference}.pdf`;
a.click();
URL.revokeObjectURL(url);
} catch (error) {
toast.error(
error instanceof Error
? error.message
: "Carriage acceptance sheet is not available yet",
);
}
}}
>
Carriage acceptance sheet
</Button>
{booking.customsClearingEnabled && (
<Button
fullWidth
variant="default"
leftSection={<Milestone size={16} />}
onClick={() =>
navigate(`/dashboard/bookings/${booking.id}/clearance`)
}
>
View document clearance
</Button>
)}
</Stack>
</Box>
</Grid.Col>
@@ -368,11 +444,13 @@ export default function BookingRequestDetailPage() {
/** The booking's primary detail cards — route, services, cargo, containers. */
function OverviewPanel({
booking,
row,
onRefetch,
}: {
booking: BookingDetail;
row: ReturnType<typeof toBookingListRow>;
onRefetch: () => void;
}) {
const row = toBookingListRow(booking);
return (
<Stack gap="lg">
<BookingRouteServiceCard
@@ -380,12 +458,49 @@ function OverviewPanel({
originLabel={row.originLabel}
destinationLabel={row.destinationLabel}
/>
<BookingMileServicesCard booking={booking} />
<BookingMileServicesCard
booking={booking}
handoverSection={
booking.tradeDirection === "EXPORT" ? (
<Stack gap={6}>
<Text size="sm" fw={600}>
How the cargo reaches the train
</Text>
<SegmentedControl
fullWidth
size="xs"
value={booking.exportHandoverMode ?? "WAREHOUSE"}
data={[
{ value: "WAREHOUSE", label: "Warehouse then train" },
{ value: "DIRECT_TO_TRAIN", label: "Direct truck to train" },
]}
onChange={async (value) => {
try {
await bookingsService.setExportHandoverMode(
booking.id,
value as "DIRECT_TO_TRAIN" | "WAREHOUSE",
);
onRefetch();
} catch (error) {
toast.error(
error instanceof Error
? error.message
: "Could not change the handover mode",
);
}
}}
/>
<Text size="xs" c="dimmed">
{booking.exportHandoverMode === "DIRECT_TO_TRAIN"
? "No warehouse receipt and no GRN — the carriage acceptance sheet is the handover document."
: "Cargo is received at the warehouse and issued a GRN before loading."}
</Text>
</Stack>
) : null
}
/>
<BookingCargoCard booking={booking} />
<BookingContainerUnitsCard booking={booking} />
{booking.contractSummary && (
<BookingContractSummaryCard summary={booking.contractSummary} />
)}
</Stack>
);
}

View File

@@ -10,11 +10,9 @@ import {
Group,
Loader,
Paper,
Progress,
RingProgress,
Stack,
Text,
ThemeIcon,
} from "@mantine/core";
import {
AlertCircle,
@@ -29,9 +27,13 @@ import {
import type { Freight } from "@edr/types";
import { ClearanceOpsTabs } from "@/components/contracts/ClearanceOpsTabs";
import { PageContainer } from "@/components/page/PageContainer";
import { PageHeader } from "@/components/page/PageHeader";
import { SectionCard } from "@/components/bookings/detail";
import { PageContainer, PageHeader, KpiStrip } from "@/components/page";
import type { KpiItem } from "@/components/page";
import {
SectionCard,
BookingCompanyCard,
BookingContractCard,
} from "@/components/bookings/detail";
import { ClearanceReviewSection } from "@/components/bookings/detail/ClearanceReviewSection";
import { ClearancePhaseStepper } from "@/components/contracts/ClearancePhaseStepper";
import { PhasedClearanceActionPanel } from "@/components/contracts/PhasedClearanceActionPanel";
@@ -171,6 +173,18 @@ export default function DocumentClearanceDetailPage() {
);
}
const direction = booking?.tradeDirection ?? "—";
const origin = booking?.originYard?.label ?? booking?.originYard?.code ?? "Origin";
const destination =
booking?.destinationYard?.label ?? booking?.destinationYard?.code ?? "Destination";
const kpis: KpiItem[] = [
{ label: "Approved", value: stats.approved, icon: CheckCircle2, color: "edr-green" },
{ label: "Queried", value: stats.queried, icon: AlertCircle, color: "red" },
{ label: "Pending", value: stats.pending, icon: Clock, color: "gray" },
{ label: "Review progress", value: `${stats.pct}%`, icon: PackageCheck, color: "blue" },
];
return (
<PageContainer>
<Stack gap="lg">
@@ -182,25 +196,46 @@ export default function DocumentClearanceDetailPage() {
{ label: reference },
]}
meta={
clearance.allApproved ? (
<Badge
variant="light"
color="edr-green"
radius="sm"
leftSection={<CheckCircle2 size={13} />}
>
All approved
<Group gap={6} wrap="wrap">
<Badge variant="light" color={direction === "IMPORT" ? "edr-green" : "gray"} radius="sm">
{direction}
</Badge>
) : (
<Badge
variant="light"
color="gray"
radius="sm"
leftSection={<Clock size={13} />}
>
Review pending
</Badge>
)
{clearance.includesCustoms ? (
<Badge variant="light" color="edr-green" radius="sm" leftSection={<ShieldCheck size={12} />}>
Customs
</Badge>
) : null}
{clearance.allApproved ? (
<Badge
variant="light"
color="edr-green"
radius="sm"
leftSection={<CheckCircle2 size={13} />}
>
All approved
</Badge>
) : (
<Badge
variant="light"
color="gray"
radius="sm"
leftSection={<Clock size={13} />}
>
Review pending
</Badge>
)}
</Group>
}
subtitle={
<Group gap={8} wrap="nowrap">
<Text size="sm" c="dimmed" fw={600}>
{origin}
</Text>
<ArrowRight size={14} className="shrink-0 text-muted-foreground" />
<Text size="sm" c="dimmed" fw={600}>
{destination}
</Text>
</Group>
}
action={
canCompleteBooking ? (
@@ -233,12 +268,16 @@ export default function DocumentClearanceDetailPage() {
}
/>
<ClearanceHero
booking={booking}
clearance={clearance}
stats={stats}
requestedLines={requestedLines}
/>
<KpiStrip items={kpis} />
{requestedLines ? (
<Group gap={10} align="center" wrap="wrap">
<Text size="xs" fw={700} tt="uppercase" c="dimmed" lts="0.05em">
Requested cargo
</Text>
<RequestedCargoChips lines={requestedLines} size="sm" />
</Group>
) : null}
{isPhasedGeneral ? (
<Paper withBorder radius="md" p="lg">
@@ -273,67 +312,54 @@ export default function DocumentClearanceDetailPage() {
</Grid.Col>
<Grid.Col span={{ base: 12, lg: isPhasedGeneral ? 5 : 4 }}>
{isPhasedGeneral ? (
<PhasedClearanceActionPanel
bookingId={id!}
clearance={clearance}
tradeDirection={booking?.tradeDirection ?? "IMPORT"}
workflowFiles={workflowFiles}
roleMode="ET"
// A bare initiated instance still has no cargo/price — the
// stepper's "Create booking" step must read as NOT-yet-created
// so it never claims the booking is done before GL completes it.
bookingCreated={Number(booking?.totalAmount ?? 0) > 0}
bookingMilestones={bookingMilestones ?? []}
onChanged={() => void refetch()}
onViewFile={view}
onDownloadFile={(f) => void downloadBookingFile(f.id, f.name)}
/>
) : (
<Box style={{ position: "sticky", top: 24 }}>
<SectionCard
icon={PackageCheck}
title="Review progress"
accent="edr-green"
>
<Stack align="center" gap="sm">
<RingProgress
size={140}
thickness={12}
roundCaps
sections={[{ value: stats.pct, color: "edr-green" }]}
label={
<Stack gap={0} align="center">
<Text fw={800} fz={26} lh={1}>
{stats.pct}%
</Text>
<Text size="xs" c="dimmed">
approved
</Text>
</Stack>
}
/>
<Group gap="lg" justify="center">
<ProgressStat
color="edr-green"
label="Approved"
value={stats.approved}
<Box style={{ position: "sticky", top: 24 }}>
<Stack gap="lg">
{booking ? <BookingCompanyCard booking={booking} /> : null}
{booking ? <BookingContractCard booking={booking} /> : null}
{isPhasedGeneral ? (
<PhasedClearanceActionPanel
bookingId={id!}
clearance={clearance}
tradeDirection={booking?.tradeDirection ?? "IMPORT"}
workflowFiles={workflowFiles}
roleMode="ET"
// A bare initiated instance still has no cargo/price — the
// stepper's "Create booking" step must read as NOT-yet-created
// so it never claims the booking is done before GL completes it.
bookingCreated={Number(booking?.totalAmount ?? 0) > 0}
bookingMilestones={bookingMilestones ?? []}
onChanged={() => void refetch()}
onViewFile={view}
onDownloadFile={(f) => void downloadBookingFile(f.id, f.name)}
/>
) : (
<SectionCard
icon={PackageCheck}
title="Review progress"
accent="edr-green"
>
<Stack align="center" gap="sm">
<RingProgress
size={140}
thickness={12}
roundCaps
sections={[{ value: stats.pct, color: "edr-green" }]}
label={
<Stack gap={0} align="center">
<Text fw={800} fz={26} lh={1}>
{stats.pct}%
</Text>
<Text size="xs" c="dimmed">
approved
</Text>
</Stack>
}
/>
<ProgressStat
color="red"
label="Queried"
value={stats.queried}
/>
<ProgressStat
color="gray"
label="Pending"
value={stats.pending}
/>
</Group>
</Stack>
</SectionCard>
</Box>
)}
</Stack>
</SectionCard>
)}
</Stack>
</Box>
</Grid.Col>
</Grid>
}
@@ -347,125 +373,3 @@ export default function DocumentClearanceDetailPage() {
</PageContainer>
);
}
function ClearanceHero({
booking,
clearance,
stats,
requestedLines,
}: {
booking: ReturnType<typeof useBookingDetail>["data"];
clearance: Freight.ClearanceView;
stats: { pct: number; approved: number; total: number };
requestedLines?: Freight.RequestedShipmentLines | null;
}) {
const direction = booking?.tradeDirection ?? "—";
const origin =
booking?.originYard?.label ?? booking?.originYard?.code ?? "Origin";
const destination =
booking?.destinationYard?.label ??
booking?.destinationYard?.code ??
"Destination";
return (
<Paper withBorder radius="md" p="lg">
<Group justify="space-between" align="flex-start" wrap="wrap" gap="lg">
<Group gap="md" wrap="nowrap" style={{ minWidth: 0 }}>
<ThemeIcon variant="light" color="edr-green" radius="md" size={52}>
<ShieldCheck size={26} />
</ThemeIcon>
<Box style={{ minWidth: 0 }}>
<Group gap={8} wrap="nowrap">
<Text fw={800} fz={20} c="edr-text" truncate>
{booking?.reference ?? "Clearance"}
</Text>
<Badge
size="sm"
variant="light"
color={direction === "IMPORT" ? "edr-green" : "gray"}
radius="sm"
>
{direction}
</Badge>
{clearance.includesCustoms ? (
<Badge
size="sm"
variant="light"
color="edr-green"
radius="sm"
leftSection={<ShieldCheck size={12} />}
>
Customs
</Badge>
) : null}
</Group>
<Group gap={8} mt={6} wrap="nowrap">
<Text size="sm" fw={600} truncate maw={160}>
{origin}
</Text>
<ArrowRight size={15} className="shrink-0 text-muted-foreground" />
<Text size="sm" fw={600} truncate maw={160}>
{destination}
</Text>
</Group>
</Box>
</Group>
<Box style={{ minWidth: 200, flex: 1, maxWidth: 320 }}>
<Group justify="space-between" mb={6}>
<Text size="xs" c="dimmed" fw={600}>
Document review
</Text>
<Text size="xs" c="dimmed">
{stats.approved}/{stats.total}
</Text>
</Group>
<Progress value={stats.pct} color="edr-green" radius="xl" size="md" />
</Box>
</Group>
{requestedLines ? (
<>
<Box my="md" h={1} bg="var(--mantine-color-default-border)" />
<Group gap={10} align="center" wrap="wrap">
<Text size="xs" fw={700} tt="uppercase" c="dimmed" lts="0.05em">
Requested cargo
</Text>
<RequestedCargoChips lines={requestedLines} size="sm" />
</Group>
</>
) : null}
</Paper>
);
}
function ProgressStat({
color,
label,
value,
}: {
color: string;
label: string;
value: number;
}) {
return (
<Stack gap={2} align="center">
<Text fw={700} fz={18} c="edr-text">
{value}
</Text>
<Group gap={4} wrap="nowrap">
<Box
style={{
width: 7,
height: 7,
borderRadius: 999,
background: `var(--mantine-color-${color}-6)`,
}}
/>
<Text fz="11px" c="dimmed">
{label}
</Text>
</Group>
</Stack>
);
}

View File

@@ -10,12 +10,9 @@ import {
Grid,
Group,
Loader,
Paper,
Progress,
RingProgress,
Stack,
Text,
ThemeIcon,
} from "@mantine/core";
import {
AlertCircle,
@@ -37,9 +34,11 @@ import {
import { BookingChangesRequestedAlert } from "@/components/contracts/BookingChangesRequestedAlert";
import { ClearanceOpsTabs } from "@/components/contracts/ClearanceOpsTabs";
import { PageContainer } from "@/components/page/PageContainer";
import { PageHeader } from "@/components/page/PageHeader";
import { PageContainer, PageHeader, KpiStrip } from "@/components/page";
import type { KpiItem } from "@/components/page";
import { EntityLink } from "@/components/detail";
import { SectionCard } from "@/components/bookings/detail/SectionCard";
import { RequestCustomerCard } from "@/components/contracts/detail/RequestDetailCards";
import { ContractClearanceReviewSection } from "@/components/contracts/ContractClearanceReviewSection";
import { GlUpcomingWindowsSection } from "@/components/contracts/GlUpcomingWindowsSection";
import { PhasedClearanceActionPanel } from "@/components/contracts/PhasedClearanceActionPanel";
@@ -196,6 +195,29 @@ export default function ContractClearanceDetailPage() {
const workflowFiles = clearance.workflowFiles ?? [];
const direction = contract?.tradeDirection ?? "—";
const customs =
contract?.serviceType?.includesCustoms ??
contract?.customsClearingEnabled ??
false;
const routes = [...(contract?.routes ?? [])].sort(
(a, b) => a.sortOrder - b.sortOrder,
);
const origin =
routes[0]?.originYard?.label ?? routes[0]?.originYard?.code ?? "Origin";
const lastRoute = routes[routes.length - 1] ?? routes[0];
const destination =
lastRoute?.destinationYard?.label ??
lastRoute?.destinationYard?.code ??
"Destination";
const kpis: KpiItem[] = [
{ label: "Approved", value: stats.approved, icon: CheckCircle2, color: "edr-green" },
{ label: "Queried", value: stats.queried, icon: AlertCircle, color: "red" },
{ label: "Pending", value: stats.pending, icon: Clock, color: "gray" },
{ label: "Review progress", value: `${stats.pct}%`, icon: PackageCheck, color: "blue" },
];
return (
<PageContainer>
<Stack gap="lg">
@@ -206,57 +228,81 @@ export default function ContractClearanceDetailPage() {
{ label: hubLabel, href: hubHref },
{ label: reference },
]}
subtitle={
<Group gap={8} wrap="nowrap">
{id ? (
<EntityLink to={`/dashboard/contract-requests/${id}`} label="Contract details" />
) : null}
<Text size="sm" c="dimmed">
· {origin}
</Text>
<ArrowRight size={14} className="shrink-0 text-muted-foreground" />
<Text size="sm" c="dimmed">
{destination}
</Text>
</Group>
}
meta={
bookingExpired ? (
<Badge
variant="light"
color="orange"
radius="sm"
leftSection={<RefreshCw size={13} />}
>
Payment expired rebook
<Group gap={6} wrap="wrap">
<Badge variant="light" color={direction === "IMPORT" ? "edr-green" : "gray"} radius="sm">
{directionLabel(direction)}
</Badge>
) : bookingAlreadyCreated ? (
<Badge
variant="light"
color="blue"
radius="sm"
leftSection={<PackageCheck size={13} />}
>
Booking created
</Badge>
) : ready ? (
<Badge
variant="light"
color="edr-green"
radius="sm"
leftSection={<PackageCheck size={13} />}
>
Ready create booking
</Badge>
) : clearance.allApproved ? (
<Badge
variant="light"
color="edr-green"
radius="sm"
leftSection={<CheckCircle2 size={13} />}
>
All approved
</Badge>
) : (
<Badge
variant="light"
color="gray"
radius="sm"
leftSection={<Clock size={13} />}
>
Review pending
</Badge>
)
{customs ? (
<Badge variant="light" color="edr-green" radius="sm" leftSection={<ShieldCheck size={12} />}>
Customs
</Badge>
) : null}
{bookingExpired ? (
<Badge
variant="light"
color="orange"
radius="sm"
leftSection={<RefreshCw size={13} />}
>
Payment expired rebook
</Badge>
) : bookingAlreadyCreated ? (
<Badge
variant="light"
color="blue"
radius="sm"
leftSection={<PackageCheck size={13} />}
>
Booking created
</Badge>
) : ready ? (
<Badge
variant="light"
color="edr-green"
radius="sm"
leftSection={<PackageCheck size={13} />}
>
Ready create booking
</Badge>
) : clearance.allApproved ? (
<Badge
variant="light"
color="edr-green"
radius="sm"
leftSection={<CheckCircle2 size={13} />}
>
All approved
</Badge>
) : (
<Badge
variant="light"
color="gray"
radius="sm"
leftSection={<Clock size={13} />}
>
Review pending
</Badge>
)}
</Group>
}
/>
<ClearanceHero contract={contract} stats={stats} />
<KpiStrip items={kpis} />
{/* Windows on this contract's routes/direction only — tells GL ET when
it can actually create the booking without checking the schedule board. */}
@@ -383,6 +429,7 @@ export default function ContractClearanceDetailPage() {
<Grid.Col span={{ base: 12, lg: 5 }}>
<Stack gap="md">
<RequestCustomerCard contract={contract} />
{phasedCustoms ? (
<PhasedClearanceActionPanel
contractId={id!}
@@ -425,23 +472,6 @@ export default function ContractClearanceDetailPage() {
</Stack>
}
/>
<Group gap="lg" justify="center">
<ProgressStat
color="edr-green"
label="Approved"
value={stats.approved}
/>
<ProgressStat
color="red"
label="Queried"
value={stats.queried}
/>
<ProgressStat
color="gray"
label="Pending"
value={stats.pending}
/>
</Group>
</Stack>
</SectionCard>
</Box>
@@ -457,126 +487,3 @@ export default function ContractClearanceDetailPage() {
);
}
function ClearanceHero({
contract,
stats,
}: {
contract: ReturnType<typeof useContractDetail>["data"];
stats: { pct: number; approved: number; total: number };
}) {
const direction = contract?.tradeDirection ?? "—";
const serviceName = contract?.serviceType?.serviceName ?? null;
const customs =
contract?.serviceType?.includesCustoms ??
contract?.customsClearingEnabled ??
false;
const routes = [...(contract?.routes ?? [])].sort(
(a, b) => a.sortOrder - b.sortOrder,
);
const origin =
routes[0]?.originYard?.label ?? routes[0]?.originYard?.code ?? "Origin";
const last = routes[routes.length - 1] ?? routes[0];
const destination =
last?.destinationYard?.label ??
last?.destinationYard?.code ??
"Destination";
return (
<Paper withBorder radius="md" p="lg">
<Group justify="space-between" align="flex-start" wrap="wrap" gap="lg">
<Group gap="md" wrap="nowrap" style={{ minWidth: 0 }}>
<ThemeIcon variant="light" color="edr-green" radius="md" size={52}>
<ShieldCheck size={26} />
</ThemeIcon>
<Box style={{ minWidth: 0 }}>
<Group gap={8} wrap="nowrap">
<Text fw={800} fz={20} c="edr-text" truncate>
{contract?.reference ?? "Clearance"}
</Text>
<Badge
size="sm"
variant="light"
color={direction === "IMPORT" ? "edr-green" : "gray"}
radius="sm"
>
{directionLabel(direction)}
</Badge>
{customs ? (
<Badge
size="sm"
variant="light"
color="edr-green"
radius="sm"
leftSection={<ShieldCheck size={12} />}
>
Customs
</Badge>
) : (
<Badge size="sm" variant="light" color="gray" radius="sm">
No customs
</Badge>
)}
</Group>
{serviceName && (
<Text size="sm" fw={600} c="edr-text" mt={6} truncate maw={280}>
{serviceName}
</Text>
)}
<Group gap={8} mt={6} wrap="nowrap">
<Text size="sm" fw={600} truncate maw={160}>
{origin}
</Text>
<ArrowRight size={15} className="shrink-0 text-muted-foreground" />
<Text size="sm" fw={600} truncate maw={160}>
{destination}
</Text>
</Group>
</Box>
</Group>
<Box style={{ minWidth: 200, flex: 1, maxWidth: 320 }}>
<Group justify="space-between" mb={6}>
<Text size="xs" c="dimmed" fw={600}>
Document review
</Text>
<Text size="xs" c="dimmed">
{stats.approved}/{stats.total}
</Text>
</Group>
<Progress value={stats.pct} color="edr-green" radius="xl" size="md" />
</Box>
</Group>
</Paper>
);
}
function ProgressStat({
color,
label,
value,
}: {
color: string;
label: string;
value: number;
}) {
return (
<Stack gap={2} align="center">
<Text fw={700} fz={18} c="edr-text">
{value}
</Text>
<Group gap={4} wrap="nowrap">
<Box
style={{
width: 7,
height: 7,
borderRadius: 999,
background: `var(--mantine-color-${color}-6)`,
}}
/>
<Text fz="11px" c="dimmed">
{label}
</Text>
</Group>
</Stack>
);
}

View File

@@ -29,9 +29,10 @@ import { useAuth } from "@/auth/useAuth";
import { FREIGHT_PERMS, hasPermission } from "@/lib/permissions";
import type { BookingDetail } from "@/types/booking";
import { PageContainer } from "@/components/page/PageContainer";
import { PageHeader } from "@/components/page/PageHeader";
import { PageContainer, PageHeader } from "@/components/page";
import { EntityLink } from "@/components/detail";
import { ClearanceReviewSection } from "@/components/bookings/detail/ClearanceReviewSection";
import { BookingCompanyCard } from "@/components/bookings/detail/BookingCompanyCard";
import { ContractClearanceReviewSection } from "@/components/contracts/ContractClearanceReviewSection";
import { ClearanceWorkflowFilesPanel } from "@/components/contracts/ClearanceWorkflowFilesPanel";
import { GlExchangePanel } from "@/components/contracts/GlExchangePanel";
@@ -42,6 +43,7 @@ import {
import { PhasedClearanceActionPanel } from "@/components/contracts/PhasedClearanceActionPanel";
import { TransitAssigneePanel } from "@/components/contracts/TransitAssigneePanel";
import { SectionCard } from "@/components/bookings/detail/SectionCard";
import { RequestCustomerCard } from "@/components/contracts/detail/RequestDetailCards";
import { IncidentReportCard } from "@/components/contracts/gl-actions/IncidentReportCard";
import { findWorkflowFile } from "@/components/contracts/PhasedUploadedFileRow";
import { useFileViewer } from "@/hooks/useFileViewer";
@@ -56,6 +58,7 @@ type GlClearanceDetail =
reference: string;
tradeDirection: string;
clearance: Freight.ContractClearanceView;
contract: Freight.IContract;
}
| {
kind: "booking";
@@ -79,6 +82,7 @@ async function loadGlClearanceDetail(id: string): Promise<GlClearanceDetail> {
reference: contract.reference,
tradeDirection: contract.tradeDirection,
clearance,
contract,
};
} catch {
const [clearance, booking] = await Promise.all([
@@ -181,6 +185,16 @@ export default function GlClearanceDetailPage() {
{ label: "GL Djibouti Clearance", href: backTo },
{ label: data.reference },
]}
subtitle={
<EntityLink
to={
data.kind === "contract"
? `/dashboard/contract-requests/${id}`
: `/dashboard/booking-requests/${id}`
}
label={data.kind === "contract" ? "Contract details" : "Booking details"}
/>
}
meta={
<Badge variant="light" color={isImport ? "edr-green" : "gray"} radius="sm">
{directionLabel(data.tradeDirection)}
@@ -278,37 +292,44 @@ export default function GlClearanceDetailPage() {
</Grid.Col>
<Grid.Col span={{ base: 12, lg: 5 }}>
<PhasedClearanceActionPanel
contractId={data.kind === "contract" ? id : undefined}
bookingId={data.kind === "booking" ? id : linkedBookingId}
// For a per-booking instance, "created" means COMPLETED (has
// cargo/price), not merely that a booking row exists — a bare
// instance is not yet a real booking. Contract-level clearance
// keeps its linked-booking signal.
bookingCreated={
data.kind === "booking"
? bookingCompleted
: Boolean(linkedBookingId)
}
bookingMilestones={
data.kind === "booking"
? (data.clearance.milestones ?? [])
: (bookingMilestones ?? [])
}
clearance={data.clearance}
tradeDirection={data.tradeDirection}
workflowFiles={workflowFiles}
roleMode="DJ"
useUploadModals
onUploadDoRequest={() => setUploadKind("do")}
onUploadRoRequest={() => setUploadKind("ro")}
onChanged={() => {
void refetch();
refetchBookingMilestonesIfLinked();
}}
onViewFile={view}
onDownloadFile={(f) => void downloadBookingFile(f.id, f.name)}
/>
<Stack gap="md">
{data.kind === "contract" ? (
<RequestCustomerCard contract={data.contract} />
) : (
<BookingCompanyCard booking={data.booking} />
)}
<PhasedClearanceActionPanel
contractId={data.kind === "contract" ? id : undefined}
bookingId={data.kind === "booking" ? id : linkedBookingId}
// For a per-booking instance, "created" means COMPLETED (has
// cargo/price), not merely that a booking row exists — a bare
// instance is not yet a real booking. Contract-level clearance
// keeps its linked-booking signal.
bookingCreated={
data.kind === "booking"
? bookingCompleted
: Boolean(linkedBookingId)
}
bookingMilestones={
data.kind === "booking"
? (data.clearance.milestones ?? [])
: (bookingMilestones ?? [])
}
clearance={data.clearance}
tradeDirection={data.tradeDirection}
workflowFiles={workflowFiles}
roleMode="DJ"
useUploadModals
onUploadDoRequest={() => setUploadKind("do")}
onUploadRoRequest={() => setUploadKind("ro")}
onChanged={() => {
void refetch();
refetchBookingMilestonesIfLinked();
}}
onViewFile={view}
onDownloadFile={(f) => void downloadBookingFile(f.id, f.name)}
/>
</Stack>
</Grid.Col>
</Grid>
</Tabs.Panel>

View File

@@ -25,6 +25,7 @@ import type { Freight } from "@edr/types";
import { PageContainer } from "@/components/page/PageContainer";
import { PageHeader } from "@/components/page/PageHeader";
import { SectionCard } from "@/components/bookings/detail/SectionCard";
import { EntityLink } from "@/components/detail";
import {
RequestCustomerCard,
RequestContractSummaryCard,
@@ -113,7 +114,17 @@ export default function ShipmentRequestDetailPage() {
<Stack gap="lg">
<PageHeader
title={`Shipment request ${request.reference}`}
subtitle={`On contract ${contractRef}`}
subtitle={
<Group gap={6} wrap="wrap">
<Text size="sm" c="dimmed">
On contract
</Text>
<EntityLink
to={`/dashboard/contract-requests/${request.contractId}`}
label={contractRef}
/>
</Group>
}
backTo="/dashboard/shipment-requests"
breadcrumbs={[
{ label: "Shipment Requests", href: "/dashboard/shipment-requests" },

View File

@@ -24,6 +24,7 @@ import {
Contact,
Download,
Eye,
FileSignature,
FileText,
History,
Hourglass,
@@ -55,7 +56,6 @@ import {
ProfileStatusBadge,
ProfileTypeBadge,
RequestDocumentChangeModal,
ResetPasswordAction,
TableCard,
formatBytes,
formatDate,
@@ -63,6 +63,8 @@ import {
humanize,
} from "@/components/customers";
import { KpiStrip, PageContainer, PageHeader } from "@/components/page";
import { ContractStatusBadge } from "@/components/contracts/ContractStatusBadge";
import { useContractList } from "@/hooks/contracts/useContracts";
import { useAuth } from "@/auth/useAuth";
import { FREIGHT_PERMS, hasPermission } from "@/lib/permissions";
import {
@@ -85,6 +87,7 @@ import {
usePagination,
type ColumnDef,
} from "@edr/ui-common";
import type { Freight } from "@edr/types";
/** Plain-text summary of the company's eTrade-sourced record, downloaded client-side (eTrade returns data, not a document). */
function downloadTinRecord(company: Company) {
@@ -170,6 +173,7 @@ export default function CustomerDetailPage() {
enabled: Boolean(id),
}),
);
const contractsQuery = useContractList({ companyId: id, pageSize: 100 }, Boolean(id));
const { pagination: invoicePagination, setPagination: setInvoicePagination } =
usePagination({
@@ -191,6 +195,7 @@ export default function CustomerDetailPage() {
);
const bookings = Array.isArray(bookingsQuery.data) ? bookingsQuery.data : [];
const contracts = contractsQuery.data?.items ?? [];
const documents = Array.isArray(documentsQuery.data)
? documentsQuery.data
: [];
@@ -402,6 +407,59 @@ export default function CustomerDetailPage() {
[],
);
const contractColumns: ColumnDef<Freight.IContract>[] = useMemo(
() => [
{
id: "reference",
header: "Contract",
cell: ({ row }) => (
<Text size="sm" fw={600} c="edr-text">
{row.original.reference}
</Text>
),
},
{
id: "kind",
header: "Kind",
cell: ({ row }) => (
<Text size="sm" c="dimmed">
{row.original.contractKind === "GENERAL" ? "General" : "One-time"}
</Text>
),
},
{
id: "status",
header: "Status",
cell: ({ row }) => (
<ContractStatusBadge
status={row.original.status}
isRenewal={Boolean(row.original.renewalOfId)}
/>
),
},
{
id: "validUntil",
header: "Valid until",
cell: ({ row }) => (
<Text size="sm" c="dimmed">
{formatDate(row.original.contractValidUntil)}
</Text>
),
},
{
id: "createdAt",
header: "Created",
meta: { headerClassName: "text-right", cellClassName: "text-right" },
cell: ({ row }) => (
<Text size="sm" c="dimmed">
{formatDate(row.original.createdAt)}
</Text>
),
},
],
[],
);
const documentColumns: ColumnDef<CustomerDocument>[] = useMemo(
() => [
{
@@ -709,7 +767,6 @@ export default function CustomerDetailPage() {
<ChangeRequestPendingBadge companyId={company.id} />
</Group>
}
action={<ResetPasswordAction company={company} />}
/>
<Tabs defaultValue="overview">
@@ -720,6 +777,9 @@ export default function CustomerDetailPage() {
<Tabs.Tab value="bookings" leftSection={<Package size={16} />}>
Bookings
</Tabs.Tab>
<Tabs.Tab value="contracts" leftSection={<FileSignature size={16} />}>
Contracts
</Tabs.Tab>
<Tabs.Tab value="documents" leftSection={<FileText size={16} />}>
Documents
</Tabs.Tab>
@@ -1187,6 +1247,7 @@ export default function CustomerDetailPage() {
status={tableStatus(bookingsQuery)}
emptyMessage="No bookings for this customer."
containerClassName="border-0 shadow-none bg-transparent"
onRowClick={(row) => navigate(`/dashboard/booking-requests/${row.id}`)}
error={
bookingsQuery.isError
? {
@@ -1199,6 +1260,28 @@ export default function CustomerDetailPage() {
</TableCard>
</Tabs.Panel>
{/* CONTRACTS */}
<Tabs.Panel value="contracts" pt="lg">
<TableCard minWidth={860}>
<DataTable
columns={contractColumns}
data={contracts}
status={tableStatus(contractsQuery)}
emptyMessage="No contracts for this customer."
containerClassName="border-0 shadow-none bg-transparent"
onRowClick={(row) => navigate(`/dashboard/contract-requests/${row.id}`)}
error={
contractsQuery.isError
? {
message: "Failed to load contracts.",
onRetry: () => void contractsQuery.refetch(),
}
: undefined
}
/>
</TableCard>
</Tabs.Panel>
{/* DOCUMENTS */}
<Tabs.Panel value="documents" pt="lg">
<Stack gap="lg">

View File

@@ -1,9 +1,11 @@
import type { ReactNode } from "react";
import {
ActionIcon,
Button,
Card,
Center,
Container,
Grid,
Group,
Loader,
SimpleGrid,
@@ -12,7 +14,7 @@ import {
Text,
} from "@mantine/core";
import { useQuery } from "@tanstack/react-query";
import { ArrowLeft, Download } from "lucide-react";
import { ArrowLeft, Building2, Download, FileText } from "lucide-react";
import { useAuth } from "@/auth/useAuth";
import { FREIGHT_PERMS, hasPermission } from "@/lib/permissions";
import { EimsFilingCard } from "@/components/invoices/EimsFilingCard";
@@ -26,8 +28,11 @@ import {
humanize,
} from "@/components/customers";
import { PageContainer, PageHeader } from "@/components/page";
import { LinkedEntityCard, type FieldRowProps } from "@/components/detail";
import { useBookingDetail } from "@/hooks/bookings/useBookings";
import { api } from "@/services/api";
import { invoicesService } from "@/services/invoices.service";
import type { Invoice } from "@/types/invoice";
function openPdfBlob(blob: Blob, filename: string) {
const url = URL.createObjectURL(blob);
@@ -43,7 +48,17 @@ function openPdfBlob(blob: Blob, filename: string) {
setTimeout(() => URL.revokeObjectURL(url), 60_000);
}
function InfoField({ label, value }: { label: string; value?: string | null }) {
function InfoField({
label,
value,
}: {
label: string;
value?: ReactNode;
}) {
const isEmpty =
value === undefined ||
value === null ||
(typeof value === "string" && !value.trim());
return (
<Stack gap={2}>
<Text
@@ -56,12 +71,75 @@ function InfoField({ label, value }: { label: string; value?: string | null }) {
{label}
</Text>
<Text size="sm" c="edr-text">
{value && value.trim() ? value : "—"}
{isEmpty ? "—" : value}
</Text>
</Stack>
);
}
/** Billed-to company, with its contact/registration details as quick-info rows. */
function RecipientCard({ invoice }: { invoice: Invoice }) {
const company = invoice.company;
const rows: FieldRowProps[] = [
{ label: "Profile", value: invoice.companyProfile?.reference },
{ label: "TIN", value: company?.tin },
{ label: "VAT No.", value: company?.vatNumber },
{ label: "Phone", value: company?.phone },
{ label: "Email", value: company?.email },
{ label: "Address", value: company?.address },
];
return (
<LinkedEntityCard
icon={Building2}
title="Recipient"
name={company?.name ?? "Unnamed company"}
to={invoice.companyId ? `/dashboard/customers/${invoice.companyId}` : null}
rows={rows}
emptyMessage="No additional recipient details available."
/>
);
}
/** What the invoice was raised for — a booking's route/wagons when the
* source is a booking; otherwise just the source type and its raw id
* (warehouse/demurrage/first-mile/last-mile ids don't link anywhere). */
function SourceCard({ invoice }: { invoice: Invoice }) {
const isBooking = invoice.source === "booking";
const { data: booking } = useBookingDetail(
isBooking ? invoice.sourceId : undefined,
);
if (!isBooking) {
return (
<LinkedEntityCard
icon={FileText}
title="Source"
name={humanize(invoice.source)}
rows={[{ label: "Reference", value: invoice.sourceId }]}
/>
);
}
const route =
booking?.originYard && booking?.destinationYard
? `${booking.originYard.label}${booking.destinationYard.label}`
: undefined;
return (
<LinkedEntityCard
icon={FileText}
title="Source"
name={booking?.reference ?? invoice.sourceId}
to={`/dashboard/booking-requests/${invoice.sourceId}`}
rows={[
{ label: "Type", value: humanize(invoice.type) },
{ label: "Route", value: route },
{ label: "Wagons", value: booking?.wagonsRequired ?? undefined },
]}
/>
);
}
export default function InvoiceDetailPage() {
const { user } = useAuth();
const canExport = hasPermission(user, FREIGHT_PERMS.invoices.export);
@@ -121,7 +199,7 @@ export default function InvoiceDetailPage() {
]}
backTo="/dashboard/invoices"
title={invoice.invoiceNumber}
subtitle={`${humanize(invoice.source)} · ${invoice.sourceId}`}
subtitle={humanize(invoice.source)}
meta={<InvoiceStatusBadge status={invoice.status} />}
action={
<ActionIcon
@@ -138,125 +216,130 @@ export default function InvoiceDetailPage() {
}
/>
<Stack gap="lg">
<Card>
<Grid gap="lg">
<Grid.Col span={{ base: 12, lg: 8 }}>
<Stack gap="lg">
<Text fw={600} c="edr-text">
Summary
</Text>
<SimpleGrid cols={{ base: 1, sm: 2, lg: 4 }} spacing="lg">
<InfoField label="Billed to" value={invoice.company?.name} />
<InfoField
label="Profile"
value={invoice.companyProfile?.reference}
/>
<InfoField label="Type" value={humanize(invoice.type)} />
<InfoField label="Currency" value={invoice.currency} />
<InfoField label="Issued" value={formatDate(invoice.issuedAt)} />
<InfoField label="Due" value={formatDate(invoice.dueAt)} />
<InfoField
label="Total"
value={formatMoney(invoice.totalAmount, invoice.currency)}
/>
<InfoField
label="Balance"
value={formatMoney(invoice.balanceAmount, invoice.currency)}
/>
</SimpleGrid>
<Card>
<Stack gap="lg">
<Text fw={600} c="edr-text">
Amounts
</Text>
<SimpleGrid cols={{ base: 1, sm: 2, lg: 4 }} spacing="lg">
<InfoField label="Currency" value={invoice.currency} />
<InfoField label="Issued" value={formatDate(invoice.issuedAt)} />
<InfoField label="Due" value={formatDate(invoice.dueAt)} />
<InfoField
label="Total"
value={formatMoney(invoice.totalAmount, invoice.currency)}
/>
<InfoField
label="Balance"
value={formatMoney(invoice.balanceAmount, invoice.currency)}
/>
</SimpleGrid>
</Stack>
</Card>
<EimsFilingCard invoiceId={invoice.id} />
<Card>
<Stack gap="md">
<Text fw={600} c="edr-text">
Line items
</Text>
<Table striped withRowBorders={false} verticalSpacing="xs">
<Table.Thead>
<Table.Tr>
<Table.Th>Description</Table.Th>
<Table.Th>Charge type</Table.Th>
<Table.Th ta="right">Quantity</Table.Th>
<Table.Th ta="right">Unit rate</Table.Th>
<Table.Th ta="right">Amount</Table.Th>
</Table.Tr>
</Table.Thead>
<Table.Tbody>
{(invoice.lines ?? []).map((line) => (
<Table.Tr key={line.id}>
<Table.Td>{line.description ?? line.chargeType}</Table.Td>
<Table.Td>
<Text size="sm" c="dimmed">
{humanize(line.chargeType)}
</Text>
</Table.Td>
<Table.Td ta="right">{line.quantity}</Table.Td>
<Table.Td ta="right">
{formatMoney(line.unitRate, line.currency)}
</Table.Td>
<Table.Td ta="right">
{formatMoney(line.amount, line.currency)}
</Table.Td>
</Table.Tr>
))}
{(invoice.lines ?? []).length === 0 && (
<Table.Tr>
<Table.Td colSpan={5}>
<Text size="sm" c="dimmed" ta="center" py="md">
No line items.
</Text>
</Table.Td>
</Table.Tr>
)}
</Table.Tbody>
</Table>
<Group
justify="flex-end"
gap="xl"
pt="sm"
style={{
borderTop: "1px solid var(--mantine-color-edr-border-0)",
}}
>
<Stack gap={2} align="flex-end">
<Text size="xs" c="edr-muted">
Subtotal
</Text>
<Text size="sm">
{formatMoney(invoice.subtotalAmount, invoice.currency)}
</Text>
</Stack>
<Stack gap={2} align="flex-end">
<Text size="xs" c="edr-muted">
Tax
</Text>
<Text size="sm">
{formatMoney(invoice.taxAmount, invoice.currency)}
</Text>
</Stack>
<Stack gap={2} align="flex-end">
<Text size="xs" c="edr-muted">
Paid
</Text>
<Text size="sm">
{formatMoney(invoice.paidAmount, invoice.currency)}
</Text>
</Stack>
<Stack gap={2} align="flex-end">
<Text size="xs" fw={700}>
Total
</Text>
<Text size="sm" fw={700}>
{formatMoney(invoice.totalAmount, invoice.currency)}
</Text>
</Stack>
</Group>
</Stack>
</Card>
</Stack>
</Card>
</Grid.Col>
<EimsFilingCard invoiceId={invoice.id} />
<Card>
<Stack gap="md">
<Text fw={600} c="edr-text">
Line items
</Text>
<Table striped withRowBorders={false} verticalSpacing="xs">
<Table.Thead>
<Table.Tr>
<Table.Th>Description</Table.Th>
<Table.Th>Charge type</Table.Th>
<Table.Th ta="right">Quantity</Table.Th>
<Table.Th ta="right">Unit rate</Table.Th>
<Table.Th ta="right">Amount</Table.Th>
</Table.Tr>
</Table.Thead>
<Table.Tbody>
{(invoice.lines ?? []).map((line) => (
<Table.Tr key={line.id}>
<Table.Td>{line.description ?? line.chargeType}</Table.Td>
<Table.Td>
<Text size="sm" c="dimmed">
{humanize(line.chargeType)}
</Text>
</Table.Td>
<Table.Td ta="right">{line.quantity}</Table.Td>
<Table.Td ta="right">
{formatMoney(line.unitRate, line.currency)}
</Table.Td>
<Table.Td ta="right">
{formatMoney(line.amount, line.currency)}
</Table.Td>
</Table.Tr>
))}
{(invoice.lines ?? []).length === 0 && (
<Table.Tr>
<Table.Td colSpan={5}>
<Text size="sm" c="dimmed" ta="center" py="md">
No line items.
</Text>
</Table.Td>
</Table.Tr>
)}
</Table.Tbody>
</Table>
<Group
justify="flex-end"
gap="xl"
pt="sm"
style={{
borderTop: "1px solid var(--mantine-color-edr-border-0)",
}}
>
<Stack gap={2} align="flex-end">
<Text size="xs" c="edr-muted">
Subtotal
</Text>
<Text size="sm">
{formatMoney(invoice.subtotalAmount, invoice.currency)}
</Text>
</Stack>
<Stack gap={2} align="flex-end">
<Text size="xs" c="edr-muted">
Tax
</Text>
<Text size="sm">
{formatMoney(invoice.taxAmount, invoice.currency)}
</Text>
</Stack>
<Stack gap={2} align="flex-end">
<Text size="xs" c="edr-muted">
Paid
</Text>
<Text size="sm">
{formatMoney(invoice.paidAmount, invoice.currency)}
</Text>
</Stack>
<Stack gap={2} align="flex-end">
<Text size="xs" fw={700}>
Total
</Text>
<Text size="sm" fw={700}>
{formatMoney(invoice.totalAmount, invoice.currency)}
</Text>
</Stack>
</Group>
<Grid.Col span={{ base: 12, lg: 4 }}>
<Stack gap="lg">
<RecipientCard invoice={invoice} />
<SourceCard invoice={invoice} />
</Stack>
</Card>
</Stack>
</Grid.Col>
</Grid>
</PageContainer>
);
}

View File

@@ -1,4 +1,5 @@
import {
ActionIcon,
Alert,
Badge,
Box,
@@ -7,6 +8,7 @@ import {
Group,
List,
Loader,
Menu,
Modal,
Paper,
RingProgress,
@@ -19,7 +21,6 @@ import {
import { isAxiosError } from "axios";
import {
AlertTriangle,
ArrowLeft,
CalendarClock,
CheckCircle2,
Clock,
@@ -29,6 +30,7 @@ import {
FileText,
History as HistoryIcon,
LayoutGrid,
MoreHorizontal,
Navigation,
Package,
PackageCheck,
@@ -42,7 +44,7 @@ import {
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { Link, useParams } from "react-router-dom";
import { KpiStrip, PageContainer } from "@/components/page";
import { KpiStrip, PageContainer, PageHeader } from "@/components/page";
import { useAuth } from "@/auth/useAuth";
import { FREIGHT_PERMS, hasPermission } from "@/lib/permissions";
import {
@@ -886,284 +888,248 @@ export default function TrainScheduleV2DetailPage() {
return (
<PageContainer>
<Button
component={Link}
to="/dashboard/operations/train-scheduling-v2"
variant="subtle"
color="gray"
size="compact-sm"
leftSection={<ArrowLeft size={16} />}
w="fit-content"
>
Back to schedules
</Button>
<Paper
radius="xl"
p="xl"
style={{ position: "relative", overflow: "hidden" }}
>
<Stack gap="lg" style={{ position: "relative" }}>
<Group justify="space-between" align="flex-start" wrap="wrap">
<Group gap="md" align="flex-start" wrap="nowrap">
<ThemeIcon size={56} radius="lg" variant="light" color="#F2A516">
<Train size={28} />
</ThemeIcon>
<Stack gap={6}>
<Group gap="sm" align="center" wrap="wrap">
{schedule.reference ? (
<Badge
variant="filled"
color="edr-green"
radius="sm"
style={{ fontWeight: 700, fontFamily: "monospace" }}
>
{schedule.reference}
</Badge>
) : null}
<Title order={2} fw={700} style={{ color: "#0f172a" }}>
{schedule.route?.name ?? "Train schedule"}
</Title>
{schedule.train?.trainName ? (
<Text fw={700} style={{ color: "#0f172a" }}>
{schedule.train.trainName}
</Text>
) : null}
{schedule.train ? (
<Text size="xs" c="dimmed" ff="monospace">
Train {schedule.train.code}
</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}>
Train No.
</Text>
<Text
ff="monospace"
fw={800}
lh={1.1}
style={{ fontSize: 32, color: "#0f172a" }}
>
{schedule.trainNumber}
</Text>
</Box>
) : null}
{schedule.voyageNumber ? (
<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.voyageNumber}
</Text>
</Box>
) : null}
{/* Merging rewrites the consist, so it is offered only while
the departure can still be edited. */}
{canEditBookings ? (
<Button
variant="light"
size="compact-sm"
leftSection={<Merge size={14} />}
onClick={() => setMergeModalOpen(true)}
>
Merge
</Button>
) : 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",
) ? (
<SegmentOccupancyStrip
stops={schedule.stops ?? []}
bookings={schedule.bookings ?? []}
maxWagons={schedule.maxWagons}
maxGrossTons={schedule.maxGrossWeightTons}
<PageHeader
title={schedule.route?.name ?? "Train schedule"}
backTo="/dashboard/operations/train-scheduling-v2"
breadcrumbs={[
{
label: "Train schedules",
href: "/dashboard/operations/train-scheduling-v2",
},
{ label: schedule.reference ?? "Schedule" },
]}
subtitle={
schedule.train ? (
<Text size="sm" c="dimmed">
{schedule.train.trainName ?? `Train ${schedule.train.code}`}
{schedule.train.trainName ? ` · Train ${schedule.train.code}` : ""}
</Text>
) : undefined
}
meta={
<Group gap={6} wrap="wrap">
{schedule.reference ? (
<Badge
variant="filled"
color="edr-green"
radius="sm"
style={{ fontWeight: 700, fontFamily: "monospace" }}
>
{schedule.reference}
</Badge>
) : null}
<FreightTypeBadge freightType={schedule.freightType} />
<StatusPill status={schedule.status} />
{gatepassApplies && gatepassSecured ? (
<Badge
variant="light"
color="edr-green"
radius="sm"
leftSection={<CheckCircle2 size={12} />}
>
Gate pass secured
</Badge>
) : null}
{previewResult ? (
<Badge
radius="sm"
variant="light"
color={previewResult.valid ? "edr-green" : "red"}
leftSection={
<Box
w={8}
h={8}
style={{
borderRadius: 999,
background: previewResult.valid
? "var(--mantine-color-edr-green-6)"
: "var(--mantine-color-red-6)",
}}
/>
) : (
<Box maw={340}>
<RouteCorridor
origin={
schedule.originStation?.label ?? schedule.originStation?.code
}
destination={
schedule.destinationStation?.label ??
schedule.destinationStation?.code
}
/>
</Box>
)}
<Group gap="sm" align="center">
<FreightTypeBadge freightType={schedule.freightType} />
<StatusPill status={schedule.status} />
</Group>
</Stack>
</Group>
<Group gap="sm">
{(schedule.trainSet?.wagons?.length ?? 0) > 0 ? (
<Button
variant="gradient"
gradient={{ from: "#0f172a", to: "#334155" }}
radius="lg"
size="sm"
leftSection={<Eye size={16} />}
onClick={() => setVisualization3DOpen(true)}
>
3D Visualization
</Button>
) : null}
{canPrintMarshalling ? (
<Button
variant="light"
color="edr-green"
radius="lg"
size="sm"
leftSection={<FileText size={16} />}
loading={downloadMarshalling.isPending}
onClick={() => void openMarshallingDocument()}
>
Marshalling PDF
</Button>
) : null}
{["DISPATCHED", "ARRIVED"].includes(schedule.status) ? (
<Button
variant="light"
color="edr-green"
radius="lg"
size="sm"
leftSection={<FileText size={16} />}
loading={downloadMarshalling.isPending}
onClick={() =>
void openMarshallingDocument({
title: "Intercity marshalling ready",
variant: "INTERCITY",
})
}
>
Intercity Marshalling
</Button>
) : null}
{["DISPATCHED", "ARRIVED"].includes(schedule.status) ? (
<Button
component={Link}
to={`/dashboard/operations/train-scheduling-v2/${scheduleId}/track`}
color="edr-green"
radius="lg"
size="sm"
leftSection={<Navigation size={16} />}
>
Track train
</Button>
) : null}
{schedule.windowPhase === "PRE_WINDOW" ? (
<Button
variant="light"
color="edr-green"
radius="lg"
size="sm"
leftSection={<Clock size={16} />}
onClick={() => setWindowSettingsOpen(true)}
>
Window settings
</Button>
) : null}
{["DRAFT", "SCHEDULED"].includes(schedule.status) ? (
<Button
variant="default"
radius="lg"
size="sm"
onClick={() => setMaintenanceOpen(true)}
>
Reschedule train
</Button>
) : null}
{gatepassApplies ? (
gatepassSecured ? (
<Button
variant="light"
color="edr-green"
radius="lg"
size="sm"
leftSection={<CheckCircle2 size={16} />}
disabled
}
>
Preview {previewResult.valid ? "valid" : "has issues"}
</Badge>
) : null}
</Group>
}
action={
<Group gap="sm" wrap="nowrap">
{/* Merging rewrites the consist, so it is offered only while
the departure can still be edited. */}
{canEditBookings ? (
<Button
variant="light"
size="compact-sm"
leftSection={<Merge size={14} />}
onClick={() => setMergeModalOpen(true)}
>
Merge
</Button>
) : null}
{(schedule.trainSet?.wagons?.length ?? 0) > 0 ? (
<Button
variant="gradient"
gradient={{ from: "#0f172a", to: "#334155" }}
radius="lg"
size="compact-sm"
leftSection={<Eye size={16} />}
onClick={() => setVisualization3DOpen(true)}
>
3D Visualization
</Button>
) : null}
<Menu position="bottom-end" width={240} withinPortal>
<Menu.Target>
<ActionIcon variant="default" size="lg" radius="md" aria-label="More actions">
<MoreHorizontal size={16} />
</ActionIcon>
</Menu.Target>
<Menu.Dropdown>
{canPrintMarshalling ? (
<Menu.Item
leftSection={<FileText size={15} />}
disabled={downloadMarshalling.isPending}
onClick={() => void openMarshallingDocument()}
>
Gate pass secured
</Button>
) : (
<Button
color="edr-green"
radius="lg"
size="sm"
leftSection={<FileText size={16} />}
loading={secureGatepass.isPending}
Marshalling PDF
</Menu.Item>
) : null}
{["DISPATCHED", "ARRIVED"].includes(schedule.status) ? (
<Menu.Item
leftSection={<FileText size={15} />}
disabled={downloadMarshalling.isPending}
onClick={() =>
void openMarshallingDocument({
title: "Intercity marshalling ready",
variant: "INTERCITY",
})
}
>
Intercity Marshalling
</Menu.Item>
) : null}
{["DISPATCHED", "ARRIVED"].includes(schedule.status) ? (
<Menu.Item
component={Link}
to={`/dashboard/operations/train-scheduling-v2/${scheduleId}/track`}
leftSection={<Navigation size={15} />}
>
Track train
</Menu.Item>
) : null}
{schedule.windowPhase === "PRE_WINDOW" ? (
<Menu.Item
leftSection={<Clock size={15} />}
onClick={() => setWindowSettingsOpen(true)}
>
Window settings
</Menu.Item>
) : null}
{["DRAFT", "SCHEDULED"].includes(schedule.status) ? (
<Menu.Item onClick={() => setMaintenanceOpen(true)}>
Reschedule train
</Menu.Item>
) : null}
{gatepassApplies && !gatepassSecured ? (
<Menu.Item
leftSection={<FileText size={15} />}
disabled={secureGatepass.isPending}
onClick={() => secureGatepass.mutate()}
>
Secure gate pass
</Button>
)
) : null}
</Group>
</Menu.Item>
) : null}
</Menu.Dropdown>
</Menu>
</Group>
}
/>
{previewResult ? (
<Badge
size="lg"
radius="sm"
variant="light"
color={previewResult.valid ? "edr-green" : "red"}
leftSection={
<Box
w={8}
h={8}
{/* Ops signage: Train No. / Voyage No. / Direction read at a glance from
across the room, so these stay large rather than folding into the
numeric KpiStrip below. */}
<Paper radius="xl" p="lg">
<Stack gap="md">
<Group gap="lg" align="center" wrap="wrap">
{schedule.trainNumber ? (
<Box>
<Text size="xs" c="dimmed" fw={600} tt="uppercase" lh={1.2}>
Train No.
</Text>
<Text
ff="monospace"
fw={800}
lh={1.1}
style={{ fontSize: 32, color: "#0f172a" }}
>
{schedule.trainNumber}
</Text>
</Box>
) : null}
{schedule.voyageNumber ? (
<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.voyageNumber}
</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={{
borderRadius: 999,
background: previewResult.valid
? "var(--mantine-color-edr-green-6)"
: "var(--mantine-color-red-6)",
fontSize: 32,
letterSpacing: 0.5,
color:
schedule.direction === "IMPORT"
? "#2E5B96"
: schedule.direction === "EXPORT"
? "#0A6F4D"
: "#0f172a",
}}
/>
}
>
Preview {previewResult.valid ? "valid" : "has issues"}
</Badge>
) : null}
>
{schedule.direction}
</Text>
</Box>
) : null}
</Group>
{(schedule.stops?.length ?? 0) >= 3 ||
(schedule.bookings ?? []).some(
(b) => b.tradeDirection === "DOMESTIC",
) ? (
<SegmentOccupancyStrip
stops={schedule.stops ?? []}
bookings={schedule.bookings ?? []}
maxWagons={schedule.maxWagons}
maxGrossTons={schedule.maxGrossWeightTons}
/>
) : (
<Box maw={340}>
<RouteCorridor
origin={
schedule.originStation?.label ?? schedule.originStation?.code
}
destination={
schedule.destinationStation?.label ??
schedule.destinationStation?.code
}
/>
</Box>
)}
</Stack>
</Paper>