feat: enhance GlDjiboutiClearanceListPage with tab navigation and improved filtering

- Added tab navigation for All, Import, Export, and On hold shipments.
- Implemented a new LivePill component to show real-time updates.
- Refactored shipment filtering logic to accommodate new tab functionality.
- Improved UI elements including buttons and text inputs for better user experience.
- Updated styles for table cells to allow text wrapping for long company names.
- Adjusted theme to use Space Grotesk font for headings.
This commit is contained in:
Marshal
2026-08-20 17:44:52 +00:00
parent 0d34b9f5e3
commit 934ed43ccb
10 changed files with 1126 additions and 494 deletions

View File

@@ -4,6 +4,12 @@
<meta charset="UTF-8" /> <meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" /> <meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>EDR Freight Backoffice</title> <title>EDR Freight Backoffice</title>
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
<link
href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700;800&family=Space+Grotesk:wght@500;600;700&display=swap"
rel="stylesheet"
/>
</head> </head>
<body> <body>
<div id="root"></div> <div id="root"></div>

View File

@@ -53,7 +53,7 @@ export const bookingInput = {
export const bookingTable = { export const bookingTable = {
headerCell: headerCell:
"h-11 text-[11px] font-semibold uppercase tracking-wider text-muted-foreground", "whitespace-nowrap text-[10px] font-semibold uppercase tracking-[0.08em] text-edr-muted",
rowHover: rowHover:
"transition-colors hover:bg-muted/25 data-[state=selected]:bg-muted/30", "transition-colors hover:bg-muted/25 data-[state=selected]:bg-muted/30",
rowIcon: `flex size-10 shrink-0 items-center justify-center rounded-xl ${bookingGlass.iconWellGreen}`, rowIcon: `flex size-10 shrink-0 items-center justify-center rounded-xl ${bookingGlass.iconWellGreen}`,

View File

@@ -122,7 +122,10 @@ function phaseCountdown(w: WindowRow): {
} }
/** Badge label + Mantine color per UI state — same state the countdown uses. */ /** Badge label + Mantine color per UI state — same state the countdown uses. */
const KIND_BADGE: Record<BookingWindowUiKind, { label: string; color: string }> = { const KIND_BADGE: Record<
BookingWindowUiKind,
{ label: string; color: string }
> = {
OPEN: { label: "Open now", color: "edr-green" }, OPEN: { label: "Open now", color: "edr-green" },
FULL: { label: "Train full", color: "red" }, FULL: { label: "Train full", color: "red" },
PRE_WINDOW: { label: "Opens soon", color: "yellow" }, PRE_WINDOW: { label: "Opens soon", color: "yellow" },
@@ -301,8 +304,12 @@ export function GlUpcomingWindowsSection({
// Order by the train's dispatch (departure) date, nearest first. Open-now // Order by the train's dispatch (departure) date, nearest first. Open-now
// breaks ties on the same departure. // breaks ties on the same departure.
return rows.sort((a, b) => { return rows.sort((a, b) => {
const da = a.departureDate ? new Date(a.departureDate).getTime() : Infinity; const da = a.departureDate
const db = b.departureDate ? new Date(b.departureDate).getTime() : Infinity; ? new Date(a.departureDate).getTime()
: Infinity;
const db = b.departureDate
? new Date(b.departureDate).getTime()
: Infinity;
if (da !== db) return da - db; if (da !== db) return da - db;
return Number(b.isOpenNow) - Number(a.isOpenNow); return Number(b.isOpenNow) - Number(a.isOpenNow);
}); });
@@ -319,14 +326,16 @@ export function GlUpcomingWindowsSection({
return ( return (
<Card withBorder shadow="sm" radius="lg" p="lg"> <Card withBorder shadow="sm" radius="lg" p="lg">
<Group justify="space-between" align="flex-start" mb="md" wrap="nowrap"> <Group justify="space-between" align="center" mb="md" wrap="nowrap">
<Group gap={8} wrap="nowrap"> <Group gap={10} wrap="nowrap">
<CalendarClock size={18} /> <div className="flex size-8 shrink-0 items-center justify-center rounded-[9px] bg-edr-soft text-edr-primary-dark">
<CalendarClock size={16} />
</div>
<Box> <Box>
<Text fw={700} fz={16}> <Text ff="heading" fw={600} fz={15} lh={1.2}>
Booking windows Booking windows
</Text> </Text>
<Text fz={13} c="dimmed"> <Text fz={12} c="edr-muted">
{contractId {contractId
? "Booking windows on this contract's routes (EAT)" ? "Booking windows on this contract's routes (EAT)"
: "Import and export booking windows across all lanes (EAT)"} : "Import and export booking windows across all lanes (EAT)"}
@@ -386,7 +395,11 @@ export function GlUpcomingWindowsSection({
))} ))}
</SimpleGrid> </SimpleGrid>
) : ( ) : (
<SimpleGrid key={safePage} cols={{ base: 1, sm: 2, lg: 3 }} spacing="md"> <SimpleGrid
key={safePage}
cols={{ base: 1, sm: 2, lg: 3 }}
spacing="md"
>
{visible.map((w) => ( {visible.map((w) => (
<WindowCard key={`${w.scheduleId}-${w.bookingCycleNo}`} w={w} /> <WindowCard key={`${w.scheduleId}-${w.bookingCycleNo}`} w={w} />
))} ))}

View File

@@ -1,4 +1,5 @@
import { Card, Skeleton, Text } from "@mantine/core"; import { Card, Skeleton, Text } from "@mantine/core";
import { ArrowUpRight } from "lucide-react";
import type { LucideIcon } from "lucide-react"; import type { LucideIcon } from "lucide-react";
import type { ElementType, ReactNode } from "react"; import type { ElementType, ReactNode } from "react";
import { Link } from "react-router-dom"; import { Link } from "react-router-dom";
@@ -8,14 +9,14 @@ import { cn } from "@/lib/utils";
export interface KpiItem { export interface KpiItem {
label: string; label: string;
value: ReactNode; value: ReactNode;
/** Optional leading icon rendered in a tinted chip. */ /** Optional leading icon rendered in a tinted chip beside the label. */
icon?: LucideIcon; icon?: LucideIcon;
/** Secondary line under the label (e.g. a unit or comparison). */ /** Small tinted pill beside the value (e.g. "+6 today"). */
hint?: string; hint?: string;
/** /**
* Mantine color name for the icon chip (e.g. "edr-green", "red", "yellow"). * Mantine color name for the icon chip and sparkline (e.g. "edr-green",
* Defaults to the brand green so a strip reads as uniform unless a page opts * "red", "yellow"). Defaults to the brand green so a strip reads as uniform
* into semantic tints. * unless a page opts into semantic tints.
*/ */
color?: string; color?: string;
/** /**
@@ -28,6 +29,8 @@ export interface KpiItem {
* becomes clickable (pointer, hover tint); when absent it stays static. * becomes clickable (pointer, hover tint); when absent it stays static.
*/ */
href?: string; href?: string;
/** Tiny bar sparkline, oldest → newest, scaled to its own max. */
spark?: number[];
} }
export interface KpiStripProps { export interface KpiStripProps {
@@ -36,11 +39,52 @@ export interface KpiStripProps {
loading?: boolean; loading?: boolean;
} }
function Pill({
children,
tone,
}: {
children: ReactNode;
tone: "green" | "red";
}) {
const c = tone === "green" ? "edr-green" : "red";
return (
<span
className="inline-flex shrink-0 items-center gap-1 rounded-full px-[7px] py-[2px] text-[10px] font-medium leading-none"
style={{
background: `var(--mantine-color-${c}-0)`,
color: `var(--mantine-color-${c}-7)`,
}}
>
{children}
</span>
);
}
function Spark({ values, color }: { values: number[]; color: string }) {
const max = Math.max(1, ...values);
return (
<div className="flex h-[26px] shrink-0 items-end gap-[3px]" aria-hidden>
{values.map((v, i) => (
<div
key={i}
className="w-1 rounded-sm"
style={{
height: Math.max(3, Math.round((v / max) * 26)),
background: `var(--mantine-color-${color}-7)`,
opacity: i === values.length - 1 ? 0.9 : 0.28,
}}
/>
))}
</div>
);
}
/** /**
* A single bordered card divided into up to five KPI cells: * A single bordered card divided into up to five KPI cells:
* `[ kpi | kpi | kpi ]`. Hairline dividers separate cells (vertical on wide * `[ kpi | kpi | kpi ]`. Each cell stacks a tinted icon + label over a large
* screens, horizontal when they wrap). Surface, border and shadow all come from * display-font value, with an optional hint/delta pill and a sparkline on the
* the theme — no per-cell backgrounds, gradients or custom shadows. * right. Hairline dividers separate cells (vertical on wide screens,
* horizontal when they wrap).
*/ */
export function KpiStrip({ items, loading = false }: KpiStripProps) { export function KpiStrip({ items, loading = false }: KpiStripProps) {
// The spec caps a strip at five cells; extra items are dropped rather than // The spec caps a strip at five cells; extra items are dropped rather than
@@ -48,7 +92,7 @@ export function KpiStrip({ items, loading = false }: KpiStripProps) {
const cells = items.slice(0, 5); const cells = items.slice(0, 5);
return ( return (
<Card withBorder shadow="sm" p={0} className="overflow-hidden"> <Card withBorder shadow="sm" radius="lg" p={0} className="overflow-hidden">
<div className="flex flex-col sm:flex-row"> <div className="flex flex-col sm:flex-row">
{cells.map((item, index) => { {cells.map((item, index) => {
const Icon = item.icon; const Icon = item.icon;
@@ -66,66 +110,63 @@ export function KpiStrip({ items, loading = false }: KpiStripProps) {
className={cn( className={cn(
// min-w-0 lets a crowded strip (five cells, long labels) // min-w-0 lets a crowded strip (five cells, long labels)
// truncate its labels instead of overflowing the card. // truncate its labels instead of overflowing the card.
"flex min-w-0 flex-1 items-center gap-3 px-5 py-4", "flex min-w-0 flex-1 flex-col justify-center gap-2 px-[18px] py-4",
index > 0 && index > 0 &&
"border-t border-edr-border sm:border-l sm:border-t-0", "border-t border-edr-border sm:border-l sm:border-t-0",
item.href && item.href &&
"cursor-pointer no-underline transition-colors hover:bg-gray-50 focus-visible:bg-gray-50", "cursor-pointer no-underline transition-colors hover:bg-gray-50 focus-visible:bg-gray-50",
)} )}
> >
<div className="flex items-center gap-2">
{Icon ? ( {Icon ? (
<div <div
className="flex size-10 shrink-0 items-center justify-center rounded-lg" className="flex size-7 shrink-0 items-center justify-center rounded-lg"
style={{ style={{
background: `var(--mantine-color-${color}-1)`, background: `var(--mantine-color-${color}-0)`,
color: `var(--mantine-color-${color}-7)`, color: `var(--mantine-color-${color}-7)`,
}} }}
> >
<Icon size={20} strokeWidth={2} /> <Icon size={14} strokeWidth={2} />
</div> </div>
) : null} ) : null}
<Text fz={12} fw={500} c="edr-muted" truncate>
{item.label}
</Text>
</div>
<div style={{ minWidth: 0 }}> <div className="flex items-center justify-between gap-3">
<div className="flex min-w-0 items-center gap-2">
{loading ? ( {loading ? (
<Skeleton height={26} width={72} radius="sm" my={2} /> <Skeleton height={28} width={64} radius="sm" />
) : ( ) : (
<div className="flex items-baseline gap-2">
<Text <Text
fw={800} ff="heading"
fz={24} fw={600}
lh={1.05} fz={27}
lh={1}
c="edr-text" c="edr-text"
style={{ letterSpacing: "-0.02em" }} style={{ letterSpacing: "-0.03em" }}
truncate truncate
> >
{item.value} {item.value}
</Text> </Text>
{item.delta != null && item.delta !== 0 ? ( )}
<Text {!loading && item.hint ? (
component="span" <Pill tone="green">
fz="xs" <ArrowUpRight size={10} />
fw={700} {item.hint}
c={item.delta > 0 ? "edr-green.7" : "red.7"} </Pill>
style={{ ) : null}
whiteSpace: "nowrap", {!loading && item.delta != null && item.delta !== 0 ? (
background: <Pill tone={item.delta > 0 ? "green" : "red"}>
item.delta > 0
? "var(--mantine-color-edr-green-0)"
: "var(--mantine-color-red-0)",
borderRadius: 999,
padding: "1px 7px",
}}
>
{item.delta > 0 ? "▲" : "▼"} {item.delta > 0 ? "▲" : "▼"}
{Math.abs(item.delta)}% {Math.abs(item.delta)}%
</Text> </Pill>
) : null} ) : null}
</div> </div>
)} {item.spark?.length ? (
<Text size="xs" fw={600} c="edr-muted" truncate> <Spark values={item.spark} color={color} />
{item.label} ) : null}
{item.hint ? ` · ${item.hint}` : ""}
</Text>
</div> </div>
</Cell> </Cell>
); );

View File

@@ -52,13 +52,19 @@ export function PageHeader({
<div style={{ minWidth: 0 }}> <div style={{ minWidth: 0 }}>
<Group gap="sm" align="center" wrap="nowrap"> <Group gap="sm" align="center" wrap="nowrap">
<Title order={2} className="truncate"> <Title
order={2}
fz={24}
fw={600}
className="truncate"
style={{ letterSpacing: "-0.02em" }}
>
{title} {title}
</Title> </Title>
{meta} {meta}
</Group> </Group>
{subtitle ? ( {subtitle ? (
<Text c="dimmed" size="sm" mt={4}> <Text c="edr-muted" fz={13} mt={4}>
{subtitle} {subtitle}
</Text> </Text>
) : null} ) : null}

View File

@@ -0,0 +1,75 @@
import { Group, Pagination, Select, Text } from "@mantine/core";
import type { DataTableFooterProps } from "@edr/ui-common";
export interface TablePagerProps<T> extends DataTableFooterProps<T> {
/** Plural noun for the row count — "Showing 110 of 48 shipments". */
noun?: string;
pageSizes?: number[];
}
/**
* DataTable footer: row range on the left, rows-per-page select + numbered
* pager on the right. Pass via `footer={(p) => <TablePager {...p} noun="…" />}`.
*/
export function TablePager<T>({
table,
pagination,
noun = "rows",
pageSizes = [10, 25, 50],
}: TablePagerProps<T>) {
const pageIndex = pagination.pageIndex ?? 0;
const pageSize = pagination.pageSize ?? 10;
const total = pagination.totalCount ?? 0;
const pageCount = Math.max(
1,
pagination.pageCount ?? Math.ceil(total / pageSize),
);
const start = total === 0 ? 0 : pageIndex * pageSize + 1;
const end = Math.min((pageIndex + 1) * pageSize, total);
return (
<Group
justify="space-between"
gap="sm"
wrap="wrap"
px="md"
py={10}
style={{ borderTop: "1px solid var(--mantine-color-edr-divider-6)" }}
>
<Text fz={12} c="edr-muted">
Showing {start}{end} of {total} {noun}
</Text>
<Group gap="sm" wrap="nowrap">
<Group gap={6} wrap="nowrap">
<Text fz={12} c="edr-muted">
Rows
</Text>
<Select
size="xs"
w={70}
radius="md"
value={String(pageSize)}
data={pageSizes.map(String)}
onChange={(v) => v && table.setPageSize(Number(v))}
allowDeselect={false}
comboboxProps={{ withinPortal: true }}
aria-label="Rows per page"
/>
</Group>
<div className="h-5 w-px bg-edr-border" />
<Pagination
size="sm"
radius="md"
color="edr-ink"
total={pageCount}
value={pageIndex + 1}
onChange={(p) => table.setPageIndex(p - 1)}
siblings={1}
boundaries={1}
/>
</Group>
</Group>
);
}
export default TablePager;

View File

@@ -8,15 +8,20 @@ import {
Card, Card,
Group, Group,
Menu, Menu,
Select,
Stack, Stack,
Text, Text,
TextInput, TextInput,
ThemeIcon, ThemeIcon,
Tooltip, Tooltip,
UnstyledButton,
} from "@mantine/core"; } from "@mantine/core";
import { useInterval } from "@mantine/hooks";
import type { LucideIcon } from "lucide-react";
import { import {
ArrowRight, ArrowRight,
Calendar, Building2,
CalendarClock,
ExternalLink, ExternalLink,
Eye, Eye,
FileText, FileText,
@@ -27,26 +32,27 @@ import {
RefreshCw, RefreshCw,
Search, Search,
ShieldCheck, ShieldCheck,
User, ShipWheel,
TriangleAlert,
Truck,
X, X,
} from "lucide-react"; } from "lucide-react";
import { import { DataTable, usePagination, type ColumnDef } from "@edr/ui-common";
DataTable,
usePagination,
type ColumnDef,
} from "@edr/ui-common";
import type { Freight } from "@edr/types"; import type { Freight } from "@edr/types";
import { useQuery } from "@tanstack/react-query"; import { useQuery } from "@tanstack/react-query";
import { PageContainer } from "@/components/page/PageContainer"; import { PageContainer } from "@/components/page/PageContainer";
import { PageHeader } from "@/components/page/PageHeader"; import { PageHeader } from "@/components/page/PageHeader";
import { KpiStrip } from "@/components/page/KpiStrip"; import { KpiStrip } from "@/components/page/KpiStrip";
import { TablePager } from "@/components/page/TablePager";
import { bookingTable } from "@/components/bookings/booking-ui.styles"; import { bookingTable } from "@/components/bookings/booking-ui.styles";
import { useAuth } from "@/auth/useAuth"; import { useAuth } from "@/auth/useAuth";
import { useBookingEtClearanceQueue } from "@/hooks/bookings/useBookings"; import { useBookingEtClearanceQueue } from "@/hooks/bookings/useBookings";
import type { BookingDetail } from "@/types/booking"; import type { BookingDetail } from "@/types/booking";
import { FREIGHT_PERMS, hasPermission, isDjiboutiGl } from "@/lib/permissions"; import { FREIGHT_PERMS, hasPermission, isDjiboutiGl } from "@/lib/permissions";
import { formatDate } from "@/lib/format";
import { GlUpcomingWindowsSection } from "@/components/contracts/GlUpcomingWindowsSection"; import { GlUpcomingWindowsSection } from "@/components/contracts/GlUpcomingWindowsSection";
import { CLEARANCE_TABS } from "@/features/clearance/clearance-tabs.config";
import { import {
RequestedCargoChips, RequestedCargoChips,
summarizeRequestedCargo, summarizeRequestedCargo,
@@ -62,53 +68,134 @@ function yardLabel(
return yard.label ?? yard.name ?? yard.code ?? "—"; return yard.label ?? yard.name ?? yard.code ?? "—";
} }
/** const prettyStatus = (s: string) =>
* "Origin → Destination", wrapping past 120px as "Addis Ababa" / s
* "→ Djibouti": the arrow is glued to the destination with an nbsp, and .toLowerCase()
* text wraps normally (the table's cells are otherwise nowrap) so a long .replace(/_/g, " ")
* lane never spills into the next column. .replace(/^\w/, (c) => c.toUpperCase());
*/
function RouteLabel({ const shipmentStatusColor = (s: string) => {
origin, if (s === "AWAITING_DOCUMENTS") return "yellow";
destination, if (s === "DOCUMENTS_UNDER_REVIEW") return "blue";
}: { if (s === "CLEARANCE_READY") return "edr-green";
origin: string; if (
destination: string; [
}) { "SELECTED_FOR_BATCH",
"PNR_GENERATED",
"AWAITING_PAYMENT",
"PAYMENT_VERIFICATION_IN_PROGRESS",
].includes(s)
)
return "violet";
if (s === "EXPIRED") return "orange";
if (s === "CANCELLED" || s === "REJECTED") return "red";
return "gray";
};
/** Rows created per day over the last `days` days, oldest → newest. */
function perDay(rows: { createdAt: string | null }[], days = 8): number[] {
const today = new Date().setHours(0, 0, 0, 0);
const out = new Array<number>(days).fill(0);
for (const r of rows) {
if (!r.createdAt) continue;
const age = Math.floor(
(today - new Date(r.createdAt).setHours(0, 0, 0, 0)) / 86_400_000,
);
if (age >= 0 && age < days) out[days - 1 - age] += 1;
}
return out;
}
// ── Tabs ─────────────────────────────────────────────────────────────────────
type TabKey = "all" | "import" | "export" | "review";
const TABS: { key: TabKey; label: string; icon: LucideIcon }[] = [
...CLEARANCE_TABS,
{ key: "review", label: "Needs approval", icon: TriangleAlert },
];
// ── Small pieces ─────────────────────────────────────────────────────────────
function LivePill({ updatedAt }: { updatedAt: number }) {
// Re-render every 30s so "Xm ago" keeps ticking between refetches.
const [, setTick] = useState(0);
useInterval(() => setTick((t) => t + 1), 30_000, { autoInvoke: true });
const mins = Math.max(0, Math.round((Date.now() - updatedAt) / 60_000));
const label = !updatedAt
? "Connecting…"
: mins < 1
? "Live · updated just now"
: `Live · updated ${mins}m ago`;
return ( return (
<Text <span className="inline-flex shrink-0 items-center gap-1.5 whitespace-nowrap rounded-full bg-edr-soft px-2.5 py-1 text-[11px] font-medium text-edr-primary-dark">
size="sm" <span className="size-1.5 rounded-full bg-edr-primary-dark" />
maw={120} {label}
lh={1.35} </span>
style={{ whiteSpace: "normal", overflowWrap: "anywhere" }}
>
{origin}{" "}
<ArrowRight
size={13}
className="text-muted-foreground"
style={{ display: "inline-block", verticalAlign: "-2px" }}
/>
{"\u00A0"}
{destination}
</Text>
); );
} }
function CustomsBadge({ customs }: { customs: boolean }) { function DirectionPill({ direction }: { direction: string }) {
return customs ? ( const isImport = direction === "IMPORT";
<Badge const Icon = isImport ? Truck : ShipWheel;
size="xs" const color = isImport ? "blue" : "teal";
variant="light" return (
color="edr-green" <span
radius="sm" className="inline-flex items-center gap-1 rounded-[5px] px-1.5 py-[2px] text-[10px] font-medium leading-none"
leftSection={<ShieldCheck size={11} />} style={{
background: `var(--mantine-color-${color}-0)`,
color: `var(--mantine-color-${color}-7)`,
}}
> >
<Icon size={10} />
{prettyStatus(direction)}
</span>
);
}
function OutlinePill({ children }: { children: React.ReactNode }) {
return (
<span className="inline-flex items-center rounded-[5px] border border-edr-border px-1.5 py-[2px] text-[10px] leading-none text-edr-muted">
{children}
</span>
);
}
function RouteCell({
origin,
destination,
direction,
freightType,
customs,
}: {
origin: string;
destination: string;
direction: string;
freightType: string;
customs: boolean;
}) {
return (
<Stack gap={5} py={2}>
<Group gap={6} wrap="nowrap">
<Text fz={12.5} fw={500} c="edr-text">
{origin}
</Text>
<ArrowRight size={12} className="shrink-0 text-edr-muted" />
<Text fz={12.5} fw={500} c="edr-text">
{destination}
</Text>
</Group>
<Group gap={6} wrap="nowrap">
<DirectionPill direction={direction} />
<OutlinePill>{prettyStatus(freightType)}</OutlinePill>
{customs ? (
<span className="inline-flex items-center gap-1 rounded-[5px] bg-edr-soft px-1.5 py-[2px] text-[10px] font-medium leading-none text-edr-primary-dark">
<ShieldCheck size={10} />
Customs Customs
</Badge> </span>
) : ( ) : null}
<Badge size="xs" variant="light" color="gray" radius="sm"> </Group>
No customs </Stack>
</Badge>
); );
} }
@@ -129,13 +216,21 @@ export default function ContractClearanceListPage() {
!isDjiboutiGl(user); !isDjiboutiGl(user);
const [query, setQuery] = useState(""); const [query, setQuery] = useState("");
const [tab, setTab] = useState<TabKey>("all");
const [freight, setFreight] = useState<string | null>(null);
const [status, setStatus] = useState<string | null>(null);
const { pagination, setPagination } = usePagination({ pageSize: 10 }); const { pagination, setPagination } = usePagination({ pageSize: 10 });
const resetPage = useCallback(
() => setPagination({ pageIndex: 0, pageSize: pagination.pageSize }),
[setPagination, pagination.pageSize],
);
const { const {
data: bookingQueue, data: bookingQueue,
isLoading, isLoading,
isError, isError,
isFetching, isFetching,
dataUpdatedAt,
refetch, refetch,
} = useBookingEtClearanceQueue(true); } = useBookingEtClearanceQueue(true);
@@ -149,7 +244,8 @@ export default function ContractClearanceListPage() {
const requestedByBooking = useMemo(() => { const requestedByBooking = useMemo(() => {
const map = new Map<string, Freight.RequestedShipmentLines>(); const map = new Map<string, Freight.RequestedShipmentLines>();
for (const req of requestQueue ?? []) { for (const req of requestQueue ?? []) {
if (req.createdBookingId) map.set(req.createdBookingId, req.requestedLines); if (req.createdBookingId)
map.set(req.createdBookingId, req.requestedLines);
} }
return map; return map;
}, [requestQueue]); }, [requestQueue]);
@@ -170,7 +266,8 @@ export default function ContractClearanceListPage() {
contractId: b.contractId ?? null, contractId: b.contractId ?? null,
contractReference: b.contractReference ?? null, contractReference: b.contractReference ?? null,
contractKind: b.contractKind ?? null, contractKind: b.contractKind ?? null,
customs: b.serviceType?.includesCustoms ?? Boolean(b.customsClearingEnabled), customs:
b.serviceType?.includesCustoms ?? Boolean(b.customsClearingEnabled),
createdAt: b.createdAt ?? null, createdAt: b.createdAt ?? null,
// A bare initiated instance has no cargo/price yet — GL still has to // A bare initiated instance has no cargo/price yet — GL still has to
// create (complete) the booking. // create (complete) the booking.
@@ -178,23 +275,9 @@ export default function ContractClearanceListPage() {
})) as ShipmentBookingRow[]; })) as ShipmentBookingRow[];
}, [bookingQueue, requestedByBooking]); }, [bookingQueue, requestedByBooking]);
const rows = useMemo(() => { // KPI groups span the whole queue, regardless of tab/filters.
const q = query.trim().toLowerCase(); const groups = useMemo(
if (!q) return allRows;
return allRows.filter(
(r) =>
r.reference.toLowerCase().includes(q) ||
r.customerLabel.toLowerCase().includes(q) ||
(r.contractReference ?? "").toLowerCase().includes(q) ||
r.originLabel.toLowerCase().includes(q) ||
r.destinationLabel.toLowerCase().includes(q) ||
summarizeRequestedCargo(r.requested).toLowerCase().includes(q),
);
}, [allRows, query]);
const counts = useMemo(
() => ({ () => ({
all: allRows.length,
// Counts anything actually waiting on GL, including a document added // Counts anything actually waiting on GL, including a document added
// after clearance was finalized (the status stays CLEARANCE_READY). // after clearance was finalized (the status stays CLEARANCE_READY).
review: allRows.filter( review: allRows.filter(
@@ -202,13 +285,72 @@ export default function ContractClearanceListPage() {
r.status === "AWAITING_DOCUMENTS" || r.status === "AWAITING_DOCUMENTS" ||
r.status === "DOCUMENTS_UNDER_REVIEW" || r.status === "DOCUMENTS_UNDER_REVIEW" ||
r.hasDocumentsAwaitingReview, r.hasDocumentsAwaitingReview,
).length, ),
ready: allRows.filter((r) => r.status === "CLEARANCE_READY" || r.bookingCreated) approval: allRows.filter((r) => r.hasDocumentsAwaitingReview),
.length, ready: allRows.filter(
(r) => r.status === "CLEARANCE_READY" || r.bookingCreated,
),
}), }),
[allRows], [allRows],
); );
const newToday = perDay(allRows, 1)[0];
const tabCounts = useMemo<Record<TabKey, number>>(
() => ({
all: allRows.length,
import: allRows.filter((r) => r.tradeDirection === "IMPORT").length,
export: allRows.filter((r) => r.tradeDirection === "EXPORT").length,
review: groups.approval.length,
}),
[allRows, groups.approval.length],
);
const statusOptions = useMemo(
() =>
[...new Set(allRows.map((r) => r.status))].sort().map((s) => ({
value: s,
label: prettyStatus(s),
})),
[allRows],
);
const rows = useMemo(() => {
const q = query.trim().toLowerCase();
return allRows.filter((r) => {
if (tab === "review" && !r.hasDocumentsAwaitingReview) return false;
if (
(tab === "import" || tab === "export") &&
r.tradeDirection !== tab.toUpperCase()
)
return false;
if (freight && r.freightType !== freight) return false;
if (status && r.status !== status) return false;
if (!q) return true;
return [
r.reference,
r.customerLabel,
r.contractReference ?? "",
r.originLabel,
r.destinationLabel,
summarizeRequestedCargo(r.requested),
].some((v) => v.toLowerCase().includes(q));
});
}, [allRows, tab, freight, status, query]);
const total = rows.length;
const pageCount = Math.max(1, Math.ceil(total / pagination.pageSize));
const pagedRows = useMemo(() => {
const start = pagination.pageIndex * pagination.pageSize;
return rows.slice(start, start + pagination.pageSize);
}, [rows, pagination.pageIndex, pagination.pageSize]);
const hasFilters = Boolean(query || freight || status);
const clearFilters = useCallback(() => {
setQuery("");
setFreight(null);
setStatus(null);
resetPage();
}, [resetPage]);
const openBooking = useCallback( const openBooking = useCallback(
// `from` so the detail page's Back returns to this hub. // `from` so the detail page's Back returns to this hub.
@@ -223,31 +365,20 @@ export default function ContractClearanceListPage() {
<PageContainer> <PageContainer>
<Stack gap="lg"> <Stack gap="lg">
<PageHeader <PageHeader
title="Document Clearance" title="Clearance queue"
subtitle="Every customs shipment in phased clearance — the documents live on the shipment, not on the contract." subtitle="Every customs shipment in phased clearance — the documents live on the shipment, not on the contract."
meta={ meta={<LivePill updatedAt={dataUpdatedAt} />}
<Badge
variant="light"
color="edr-green"
radius="sm"
leftSection={<ShieldCheck size={13} />}
>
{counts.all} in clearance
</Badge>
}
action={ action={
<Group gap="sm" wrap="nowrap"> <Button
<ActionIcon
variant="default" variant="default"
size="lg"
radius="md" radius="md"
onClick={() => void refetch()} size="sm"
leftSection={<RefreshCw size={14} />}
loading={isFetching} loading={isFetching}
aria-label="Refresh" onClick={() => void refetch()}
> >
<RefreshCw size={16} /> Refresh
</ActionIcon> </Button>
</Group>
} }
/> />
@@ -256,38 +387,138 @@ export default function ContractClearanceListPage() {
items={[ items={[
{ {
label: "In clearance", label: "In clearance",
value: counts.all, value: allRows.length,
icon: Inbox, icon: Inbox,
color: "edr-green", color: "blue",
hint: newToday ? `+${newToday} today` : undefined,
spark: perDay(allRows),
}, },
{ {
label: "Awaiting review", label: "Awaiting review",
value: counts.review, value: groups.review.length,
icon: ShieldCheck, icon: ShieldCheck,
color: "yellow", color: "yellow",
spark: perDay(groups.review),
},
{
label: "Needs approval",
value: groups.approval.length,
icon: TriangleAlert,
color: "red",
spark: perDay(groups.approval),
}, },
{ {
label: "Ready / booked", label: "Ready / booked",
value: counts.ready, value: groups.ready.length,
icon: PackageCheck, icon: PackageCheck,
color: "edr-green", color: "edr-green",
spark: perDay(groups.ready),
}, },
]} ]}
/> />
<GlUpcomingWindowsSection /> <GlUpcomingWindowsSection />
<Card p={0} withBorder shadow="sm" radius="lg"> <Card
p={0}
withBorder
shadow="sm"
radius="lg"
style={{ overflow: "hidden" }}
>
<Stack gap={0}> <Stack gap={0}>
<Box px="md" pt="md" pb="sm"> {/* ── Tabs ─────────────────────────────────────────────── */}
<Group justify="space-between" gap="md" wrap="wrap"> <Group
justify="space-between"
align="stretch"
px="md"
h={46}
wrap="nowrap"
style={{
borderBottom: "1px solid var(--mantine-color-edr-border-6)",
}}
>
<Group gap={2} wrap="nowrap" align="stretch">
{TABS.map((t) => {
const active = tab === t.key;
const Icon = t.icon;
return (
<UnstyledButton
key={t.key}
onClick={() => {
setTab(t.key);
resetPage();
}}
px={13}
className="flex items-center gap-2 transition-colors"
style={{
borderBottom: `2px solid ${
active
? "var(--mantine-color-edr-green-6)"
: "transparent"
}`,
marginBottom: -1,
}}
aria-pressed={active}
>
<Icon
size={14}
style={{
color: active
? "var(--mantine-color-edr-green-6)"
: "var(--mantine-color-gray-5)",
}}
/>
<Text
fz={13}
fw={active ? 600 : 500}
c={active ? "edr-text" : "edr-muted"}
>
{t.label}
</Text>
<span
className="rounded-full px-1.5 py-px text-[10.5px] font-semibold leading-[1.4]"
style={{
background: active
? "var(--mantine-color-edr-green-0)"
: "var(--mantine-color-gray-1)",
color: active
? "var(--mantine-color-edr-green-7)"
: "var(--mantine-color-edr-muted-6)",
}}
>
{tabCounts[t.key]}
</span>
</UnstyledButton>
);
})}
</Group>
<Text
fz={12}
c="edr-muted"
className="self-center whitespace-nowrap"
>
{total} record{total !== 1 ? "s" : ""}
</Text>
</Group>
{/* ── Filter bar ───────────────────────────────────────── */}
<Group
gap={9}
px="md"
py={12}
wrap="wrap"
style={{
borderBottom: "1px solid var(--mantine-color-edr-divider-6)",
}}
>
<TextInput <TextInput
placeholder="Search shipment, contract, customer or route…" placeholder="Search reference, customer, contract, or route…"
leftSection={<Search size={18} />} leftSection={<Search size={15} />}
value={query} value={query}
onChange={(e) => { onChange={(e) => {
setQuery(e.currentTarget.value); setQuery(e.currentTarget.value);
setPagination({ pageIndex: 0, pageSize: pagination.pageSize }); resetPage();
}} }}
rightSection={ rightSection={
query ? ( query ? (
@@ -296,25 +527,80 @@ export default function ContractClearanceListPage() {
color="gray" color="gray"
radius="md" radius="md"
variant="transparent" variant="transparent"
onClick={() => setQuery("")} onClick={() => {
setQuery("");
resetPage();
}}
aria-label="Clear search"
> >
<X size={16} /> <X size={14} />
</ActionIcon> </ActionIcon>
) : null ) : null
} }
radius="lg" radius="md"
size="sm"
styles={{
input: { background: "var(--mantine-color-gray-0)" },
}}
style={{ flex: 1, minWidth: 220 }} style={{ flex: 1, minWidth: 220 }}
/> />
<Text size="sm" c="dimmed"> <Select
{rows.length} record{rows.length !== 1 ? "s" : ""} placeholder="Freight"
</Text> data={[
{ value: "CONTAINER", label: "Container" },
{ value: "BULK", label: "Bulk" },
]}
value={freight}
onChange={(v) => {
setFreight(v);
resetPage();
}}
clearable
radius="md"
size="sm"
w={124}
comboboxProps={{ withinPortal: true }}
aria-label="Filter by freight type"
/>
<Select
placeholder="Status"
data={statusOptions}
value={status}
onChange={(v) => {
setStatus(v);
resetPage();
}}
clearable
radius="md"
size="sm"
w={180}
comboboxProps={{ withinPortal: true }}
aria-label="Filter by status"
/>
{hasFilters ? (
<Button
variant="subtle"
color="gray"
size="compact-sm"
radius="md"
leftSection={<X size={14} />}
onClick={clearFilters}
>
Clear
</Button>
) : null}
</Group> </Group>
</Box>
<ShipmentBookingsTable <ShipmentBookingsTable
rows={rows} rows={pagedRows}
total={total}
pageCount={pageCount}
pagination={pagination}
setPagination={setPagination}
loading={isLoading} loading={isLoading}
error={isError} error={isError}
hasFilters={hasFilters}
onClearFilters={clearFilters}
canCreateBooking={canCreateBooking} canCreateBooking={canCreateBooking}
onOpen={openBooking} onOpen={openBooking}
onCreateBooking={(row) => onCreateBooking={(row) =>
@@ -337,7 +623,6 @@ export default function ContractClearanceListPage() {
</Stack> </Stack>
</Card> </Card>
</Stack> </Stack>
</PageContainer> </PageContainer>
); );
} }
@@ -367,43 +652,19 @@ interface ShipmentBookingRow {
bookingCreated: boolean; bookingCreated: boolean;
} }
const formatDate = (iso: string | null) => { type PaginationState = ReturnType<typeof usePagination>["pagination"];
if (!iso) return "—";
const d = new Date(iso);
return Number.isNaN(d.getTime())
? "—"
: d.toLocaleDateString(undefined, { day: "2-digit", month: "short", year: "numeric" });
};
const prettyStatus = (s: string) =>
s
.toLowerCase()
.replace(/_/g, " ")
.replace(/^\w/, (c) => c.toUpperCase());
const shipmentStatusColor = (s: string) => {
if (s === "AWAITING_DOCUMENTS") return "yellow";
if (s === "DOCUMENTS_UNDER_REVIEW") return "blue";
if (s === "CLEARANCE_READY") return "edr-green";
if (
[
"SELECTED_FOR_BATCH",
"PNR_GENERATED",
"AWAITING_PAYMENT",
"PAYMENT_VERIFICATION_IN_PROGRESS",
].includes(s)
)
return "violet";
if (s === "EXPIRED") return "orange";
if (s === "CANCELLED" || s === "REJECTED") return "red";
return "gray";
};
/** GENERAL-contract shipment bookings currently in per-booking clearance. */ /** GENERAL-contract shipment bookings currently in per-booking clearance. */
function ShipmentBookingsTable({ function ShipmentBookingsTable({
rows, rows,
total,
pageCount,
pagination,
setPagination,
loading, loading,
error, error,
hasFilters,
onClearFilters,
canCreateBooking, canCreateBooking,
onOpen, onOpen,
onCreateBooking, onCreateBooking,
@@ -411,8 +672,14 @@ function ShipmentBookingsTable({
onViewContract, onViewContract,
}: { }: {
rows: ShipmentBookingRow[]; rows: ShipmentBookingRow[];
total: number;
pageCount: number;
pagination: PaginationState;
setPagination: ReturnType<typeof usePagination>["setPagination"];
loading: boolean; loading: boolean;
error: boolean; error: boolean;
hasFilters: boolean;
onClearFilters: () => void;
canCreateBooking: boolean; canCreateBooking: boolean;
onOpen: (id: string) => void; onOpen: (id: string) => void;
onCreateBooking: (row: ShipmentBookingRow) => void; onCreateBooking: (row: ShipmentBookingRow) => void;
@@ -440,18 +707,23 @@ function ShipmentBookingsTable({
id: "booking", id: "booking",
header: () => <span className={bookingTable.headerCell}>Booking</span>, header: () => <span className={bookingTable.headerCell}>Booking</span>,
cell: ({ row }) => ( cell: ({ row }) => (
<div className="flex items-center gap-3 py-1.5"> <div className="flex items-center gap-2.5 py-1">
<div className={bookingTable.rowIcon}> <div className="flex size-[30px] shrink-0 items-center justify-center rounded-[9px] bg-edr-divider text-edr-muted">
<PackageCheck className="size-4" strokeWidth={1.75} /> <PackageCheck size={15} strokeWidth={1.75} />
</div> </div>
<div className="min-w-0"> <div className="min-w-0">
<p className="font-medium text-foreground"> <Text fz={13} fw={600} c="edr-text">
{row.original.reference} {row.original.reference}
</p> </Text>
<p className="mt-0.5 flex items-center gap-1 text-xs text-muted-foreground"> <Group gap={4} wrap="nowrap" align="flex-start">
<User className="size-3 shrink-0 opacity-70" /> <Building2
size={10}
className="mt-[3px] shrink-0 text-edr-muted opacity-70"
/>
<Text fz={11} c="edr-muted" className="cell-wrap">
{row.original.customerLabel} {row.original.customerLabel}
</p> </Text>
</Group>
</div> </div>
</div> </div>
), ),
@@ -462,17 +734,19 @@ function ShipmentBookingsTable({
cell: ({ row }) => { cell: ({ row }) => {
const r = row.original; const r = row.original;
return ( return (
<Stack gap={4} py={2}> <Stack gap={3} py={2}>
<Group gap={6} wrap="nowrap"> <Group gap={5} wrap="nowrap">
<FileText size={13} className="shrink-0 text-muted-foreground" /> <FileText size={12} className="shrink-0 text-edr-muted" />
<Text size="sm" fw={500}> <Text fz={12.5} c="edr-text">
{r.contractReference ?? "—"} {r.contractReference ?? "—"}
</Text> </Text>
</Group> </Group>
{r.contractKind ? ( {r.contractKind ? (
<Badge size="xs" variant="default" radius="sm" tt="uppercase"> <Text fz={10.5} c="edr-muted">
{r.contractKind === "GENERAL" ? "General" : "One-time"} {r.contractKind === "GENERAL"
</Badge> ? "General contract"
: "One-time"}
</Text>
) : null} ) : null}
</Stack> </Stack>
); );
@@ -481,44 +755,33 @@ function ShipmentBookingsTable({
{ {
id: "route", id: "route",
header: () => <span className={bookingTable.headerCell}>Route</span>, header: () => <span className={bookingTable.headerCell}>Route</span>,
cell: ({ row }) => ( cell: ({ row }) => {
<RouteLabel const r = row.original;
origin={row.original.originLabel} return (
destination={row.original.destinationLabel} <RouteCell
origin={r.originLabel}
destination={r.destinationLabel}
direction={r.tradeDirection}
freightType={r.freightType}
customs={r.customs}
/> />
), );
}, },
{
id: "kind",
header: () => <span className={bookingTable.headerCell}>Type</span>,
cell: ({ row }) => (
<Group gap={6} wrap="nowrap">
<Badge variant="light" color="gray" radius="sm">
{prettyStatus(row.original.tradeDirection)}
</Badge>
<Badge variant="outline" color="gray" radius="sm">
{prettyStatus(row.original.freightType)}
</Badge>
<CustomsBadge customs={row.original.customs} />
</Group>
),
}, },
{ {
id: "requested", id: "requested",
header: () => ( header: () => <span className={bookingTable.headerCell}>Cargo</span>,
<span className={bookingTable.headerCell}>Requested cargo</span>
),
cell: ({ row }) => ( cell: ({ row }) => (
<RequestedCargoChips lines={row.original.requested} size="sm" /> <RequestedCargoChips lines={row.original.requested} size="xs" />
), ),
}, },
{ {
id: "created", id: "created",
header: () => <span className={bookingTable.headerCell}>Created</span>, header: () => <span className={bookingTable.headerCell}>Created</span>,
cell: ({ row }) => ( cell: ({ row }) => (
<Group gap={6} wrap="nowrap"> <Group gap={5} wrap="nowrap">
<Calendar size={13} className="shrink-0 text-muted-foreground" /> <CalendarClock size={12} className="shrink-0 text-edr-muted" />
<Text size="sm" c="dimmed"> <Text fz={11.5} c="edr-muted">
{formatDate(row.original.createdAt)} {formatDate(row.original.createdAt)}
</Text> </Text>
</Group> </Group>
@@ -533,14 +796,14 @@ function ShipmentBookingsTable({
a file added after clearance was finalized leaves the status at a file added after clearance was finalized leaves the status at
CLEARANCE_READY, and the row must still call for the review. */} CLEARANCE_READY, and the row must still call for the review. */}
{row.original.hasDocumentsAwaitingReview ? ( {row.original.hasDocumentsAwaitingReview ? (
<Badge variant="filled" color="orange" radius="sm"> <Badge variant="filled" color="orange" radius="sm" size="sm">
Needs approval Needs approval
</Badge> </Badge>
) : /* All docs approved but not yet finalized: the booking status is ) : /* All docs approved but not yet finalized: the booking status is
still DOCUMENTS_UNDER_REVIEW — show the real review state. */ still DOCUMENTS_UNDER_REVIEW — show the real review state. */
row.original.status === "DOCUMENTS_UNDER_REVIEW" && row.original.status === "DOCUMENTS_UNDER_REVIEW" &&
row.original.allDocsApproved ? ( row.original.allDocsApproved ? (
<Badge variant="light" color="edr-green" radius="sm"> <Badge variant="light" color="edr-green" radius="sm" size="sm">
Documents approved Documents approved
</Badge> </Badge>
) : ( ) : (
@@ -548,6 +811,7 @@ function ShipmentBookingsTable({
variant="light" variant="light"
color={shipmentStatusColor(row.original.status)} color={shipmentStatusColor(row.original.status)}
radius="sm" radius="sm"
size="sm"
> >
{prettyStatus(row.original.status)} {prettyStatus(row.original.status)}
</Badge> </Badge>
@@ -558,6 +822,7 @@ function ShipmentBookingsTable({
variant="light" variant="light"
color="blue" color="blue"
radius="sm" radius="sm"
size="sm"
leftSection={<PackagePlus size={11} />} leftSection={<PackagePlus size={11} />}
> >
Booked Booked
@@ -617,7 +882,10 @@ function ShipmentBookingsTable({
</ActionIcon> </ActionIcon>
</Menu.Target> </Menu.Target>
<Menu.Dropdown> <Menu.Dropdown>
<Menu.Item leftSection={<Eye size={14} />} onClick={() => onOpen(r.id)}> <Menu.Item
leftSection={<Eye size={14} />}
onClick={() => onOpen(r.id)}
>
Open booking Open booking
</Menu.Item> </Menu.Item>
{bookable ? ( {bookable ? (
@@ -655,25 +923,53 @@ function ShipmentBookingsTable({
[canCreateBooking, onOpen, onCreateBooking, onViewContract], [canCreateBooking, onOpen, onCreateBooking, onViewContract],
); );
if (!loading && !error && rows.length === 0) { if (!loading && !error && total === 0) {
return ( return (
<Stack align="center" gap={8} py={48}> <Stack align="center" gap={8} py={48}>
<ThemeIcon variant="light" color="gray" radius="xl" size={48}> <ThemeIcon variant="light" color="gray" radius="xl" size={48}>
<Inbox size={22} /> <Inbox size={22} />
</ThemeIcon> </ThemeIcon>
<Text c="dimmed">No shipment bookings in clearance.</Text> <Text c="dimmed">
{hasFilters
? "No shipments match these filters."
: "No shipment bookings in clearance."}
</Text>
{hasFilters ? (
<Button
variant="light"
color="gray"
size="compact-sm"
radius="md"
onClick={onClearFilters}
>
Clear filters
</Button>
) : null}
</Stack> </Stack>
); );
} }
return ( return (
<Box w="100%" miw={0} style={{ overflowX: "auto" }}> <Box w="100%" miw={0}>
<DataTable<ShipmentBookingRow, unknown> <DataTable<ShipmentBookingRow, unknown>
columns={columns} columns={columns}
data={rows} data={rows}
status={loading ? "loading" : error ? "error" : "success"} status={loading ? "loading" : error ? "error" : "success"}
onRowClick={(row) => onOpen(row.id)} onRowClick={(row) => onOpen(row.id)}
pagination={{
pageIndex: pagination.pageIndex,
pageSize: pagination.pageSize,
pageCount,
totalCount: total,
}}
tableOptions={{
state: { pagination },
onPaginationChange: setPagination,
manualPagination: true,
pageCount,
}}
containerClassName="edr-clearance-table border-0 shadow-none rounded-none bg-transparent" containerClassName="edr-clearance-table border-0 shadow-none rounded-none bg-transparent"
footer={(p) => <TablePager {...p} noun="shipments" />}
/> />
</Box> </Box>
); );

View File

@@ -15,33 +15,33 @@ import {
TextInput, TextInput,
ThemeIcon, ThemeIcon,
Tooltip, Tooltip,
UnstyledButton,
} from "@mantine/core"; } from "@mantine/core";
import { useInterval } from "@mantine/hooks";
import type { LucideIcon } from "lucide-react";
import { import {
AlertTriangle, AlertTriangle,
ArrowRight, ArrowRight,
Building2,
CalendarClock, CalendarClock,
ChevronRight, ChevronRight,
FileText, FileText,
Inbox, Inbox,
Layers,
PackageCheck, PackageCheck,
RefreshCw, RefreshCw,
Search, Search,
ShipWheel, ShipWheel,
Truck, Truck,
User,
Weight, Weight,
X, X,
} from "lucide-react"; } from "lucide-react";
import { import { DataTable, usePagination, type ColumnDef } from "@edr/ui-common";
DataTable,
DataTableFooter,
usePagination,
type ColumnDef,
} from "@edr/ui-common";
import { PageContainer } from "@/components/page/PageContainer"; import { PageContainer } from "@/components/page/PageContainer";
import { PageHeader } from "@/components/page/PageHeader"; import { PageHeader } from "@/components/page/PageHeader";
import { KpiStrip } from "@/components/page/KpiStrip"; import { KpiStrip } from "@/components/page/KpiStrip";
import { TablePager } from "@/components/page/TablePager";
import { bookingTable } from "@/components/bookings/booking-ui.styles"; import { bookingTable } from "@/components/bookings/booking-ui.styles";
import { useBookingDjClearanceQueue } from "@/hooks/bookings/useBookings"; import { useBookingDjClearanceQueue } from "@/hooks/bookings/useBookings";
import type { BookingDetail } from "@/types/booking"; import type { BookingDetail } from "@/types/booking";
@@ -106,6 +106,20 @@ function statusColor(status: string): string {
} }
} }
/** Rows created/scheduled per day over the last `days` days, oldest → newest. */
function perDay(rows: { scheduledDate: string | null }[], days = 8): number[] {
const today = new Date().setHours(0, 0, 0, 0);
const out = new Array<number>(days).fill(0);
for (const r of rows) {
if (!r.scheduledDate) continue;
const age = Math.floor(
(today - new Date(r.scheduledDate).setHours(0, 0, 0, 0)) / 86_400_000,
);
if (age >= 0 && age < days) out[days - 1 - age] += 1;
}
return out;
}
// ── DJ next action (shipments) ─────────────────────────────────────────────── // ── DJ next action (shipments) ───────────────────────────────────────────────
type DjActionKey = "RO_HOLD" | "COLLECT_DO" | "ISSUE_RO" | "LOADING" | "REVIEW"; type DjActionKey = "RO_HOLD" | "COLLECT_DO" | "ISSUE_RO" | "LOADING" | "REVIEW";
@@ -180,28 +194,65 @@ function toShipmentRow(b: BookingDetail): ShipmentRow {
}; };
} }
// ── Tabs ─────────────────────────────────────────────────────────────────────
type TabKey = "all" | "import" | "export" | "hold";
const TABS: { key: TabKey; label: string; icon: LucideIcon }[] = [
{ key: "all", label: "All", icon: Layers },
{ key: "import", label: "Import", icon: Truck },
{ key: "export", label: "Export", icon: ShipWheel },
{ key: "hold", label: "On hold", icon: AlertTriangle },
];
// ── Shared cell pieces ─────────────────────────────────────────────────────── // ── Shared cell pieces ───────────────────────────────────────────────────────
function DirectionIcon({ direction }: { direction: string }) { function LivePill({ updatedAt }: { updatedAt: number }) {
// Re-render every 30s so "Xm ago" keeps ticking between refetches.
const [, setTick] = useState(0);
useInterval(() => setTick((t) => t + 1), 30_000, { autoInvoke: true });
const mins = Math.max(0, Math.round((Date.now() - updatedAt) / 60_000));
const label = !updatedAt
? "Connecting…"
: mins < 1
? "Live · updated just now"
: `Live · updated ${mins}m ago`;
return (
<span className="inline-flex shrink-0 items-center gap-1.5 whitespace-nowrap rounded-full bg-edr-soft px-2.5 py-1 text-[11px] font-medium text-edr-primary-dark">
<span className="size-1.5 rounded-full bg-edr-primary-dark" />
{label}
</span>
);
}
function DirectionPill({ direction }: { direction: string }) {
const isImport = direction === "IMPORT"; const isImport = direction === "IMPORT";
const Icon = isImport ? Truck : ShipWheel; const Icon = isImport ? Truck : ShipWheel;
const label = directionLabel(direction); const color = isImport ? "blue" : "teal";
return ( return (
<Tooltip label={label} withArrow> <Tooltip label={directionLabel(direction)} withArrow>
<ThemeIcon <span
variant="light" className="inline-flex items-center gap-1 rounded-[5px] px-1.5 py-[2px] text-[10px] font-medium leading-none"
color={isImport ? "edr-green" : "gray"} style={{
radius="md" background: `var(--mantine-color-${color}-0)`,
size={26} color: `var(--mantine-color-${color}-7)`,
aria-label={label} }}
> >
<Icon size={14} strokeWidth={1.9} /> <Icon size={10} />
</ThemeIcon> {prettyStatus(direction)}
</span>
</Tooltip> </Tooltip>
); );
} }
function OutlinePill({ children }: { children: React.ReactNode }) {
return (
<span className="inline-flex items-center rounded-[5px] border border-edr-border px-1.5 py-[2px] text-[10px] leading-none text-edr-muted">
{children}
</span>
);
}
function RouteCell({ function RouteCell({
origin, origin,
destination, destination,
@@ -214,30 +265,19 @@ function RouteCell({
freightType: string; freightType: string;
}) { }) {
return ( return (
<Stack gap={4} py={2}> <Stack gap={5} py={2}>
{/* Wraps past 120px as "Addis Ababa" / "→ Djibouti"; text wraps <Group gap={6} wrap="nowrap">
normally (cells are otherwise nowrap) so it never spills over. */} <Text fz={12.5} fw={500} c="edr-text">
<Text {origin}
size="sm" </Text>
fw={500} <ArrowRight size={12} className="shrink-0 text-edr-muted" />
maw={120} <Text fz={12.5} fw={500} c="edr-text">
lh={1.35}
style={{ whiteSpace: "normal", overflowWrap: "anywhere" }}
>
{origin}{" "}
<ArrowRight
size={13}
className="text-muted-foreground"
style={{ display: "inline-block", verticalAlign: "-2px" }}
/>
{"\u00A0"}
{destination} {destination}
</Text> </Text>
<Group gap={8} align="center"> </Group>
<DirectionIcon direction={direction} /> <Group gap={6} wrap="nowrap">
<Badge size="xs" variant="default" radius="sm"> <DirectionPill direction={direction} />
{freightType} <OutlinePill>{prettyStatus(freightType)}</OutlinePill>
</Badge>
</Group> </Group>
</Stack> </Stack>
); );
@@ -254,7 +294,7 @@ function RouteCell({
export default function GlDjiboutiClearanceListPage() { export default function GlDjiboutiClearanceListPage() {
const navigate = useNavigate(); const navigate = useNavigate();
const [query, setQuery] = useState(""); const [query, setQuery] = useState("");
const [direction, setDirection] = useState<string | null>(null); const [tab, setTab] = useState<TabKey>("all");
const [freight, setFreight] = useState<string | null>(null); const [freight, setFreight] = useState<string | null>(null);
const [status, setStatus] = useState<string | null>(null); const [status, setStatus] = useState<string | null>(null);
const [action, setAction] = useState<string | null>(null); const [action, setAction] = useState<string | null>(null);
@@ -265,6 +305,7 @@ export default function GlDjiboutiClearanceListPage() {
isLoading: bookingsLoading, isLoading: bookingsLoading,
isError: bookingsError, isError: bookingsError,
isFetching: bookingsFetching, isFetching: bookingsFetching,
dataUpdatedAt,
refetch: refetchBookings, refetch: refetchBookings,
} = useBookingDjClearanceQueue(); } = useBookingDjClearanceQueue();
@@ -277,18 +318,30 @@ export default function GlDjiboutiClearanceListPage() {
() => (bookingQueue ?? []).map(toShipmentRow), () => (bookingQueue ?? []).map(toShipmentRow),
[bookingQueue], [bookingQueue],
); );
// KPI metrics span the whole queue, regardless of filters. // KPI metrics span the whole queue, regardless of filters.
const metrics = useMemo( const metrics = useMemo(
() => ({ () => ({
shipments: allShipmentRows.length, shipments: allShipmentRows,
collectDo: allShipmentRows.filter((r) => r.action.key === "COLLECT_DO") collectDo: allShipmentRows.filter((r) => r.action.key === "COLLECT_DO"),
.length, issueRo: allShipmentRows.filter((r) => r.action.key === "ISSUE_RO"),
issueRo: allShipmentRows.filter((r) => r.action.key === "ISSUE_RO").length, roHolds: allShipmentRows.filter((r) => r.action.key === "RO_HOLD"),
roHolds: allShipmentRows.filter((r) => r.action.key === "RO_HOLD").length,
}), }),
[allShipmentRows], [allShipmentRows],
); );
const tabCounts = useMemo<Record<TabKey, number>>(
() => ({
all: allShipmentRows.length,
import: allShipmentRows.filter((r) => r.tradeDirection === "IMPORT")
.length,
export: allShipmentRows.filter((r) => r.tradeDirection === "EXPORT")
.length,
hold: metrics.roHolds.length,
}),
[allShipmentRows, metrics.roHolds.length],
);
const statusOptions = useMemo( const statusOptions = useMemo(
() => () =>
[...new Set(allShipmentRows.map((r) => r.status))].sort().map((s) => ({ [...new Set(allShipmentRows.map((r) => r.status))].sort().map((s) => ({
@@ -298,64 +351,45 @@ export default function GlDjiboutiClearanceListPage() {
[allShipmentRows], [allShipmentRows],
); );
const matchesShared = useCallback( const shipmentRows = useMemo(() => {
( const q = query.trim().toLowerCase();
r: { return allShipmentRows.filter((r) => {
reference: string; if (tab === "hold" && r.action.key !== "RO_HOLD") return false;
customerLabel: string; if (
originLabel: string; (tab === "import" || tab === "export") &&
destinationLabel: string; r.tradeDirection !== tab.toUpperCase()
tradeDirection: string; )
freightType: string; return false;
status: string;
},
extraSearchFields: string[] = [],
) => {
if (direction && r.tradeDirection !== direction) return false;
if (freight && r.freightType !== freight) return false; if (freight && r.freightType !== freight) return false;
if (status && r.status !== status) return false; if (status && r.status !== status) return false;
const q = query.trim().toLowerCase(); if (action && r.action.key !== action) return false;
if (!q) return true; if (!q) return true;
return [ return [
r.reference, r.reference,
r.customerLabel, r.customerLabel,
r.contractReference,
r.originLabel, r.originLabel,
r.destinationLabel, r.destinationLabel,
prettyStatus(r.status), prettyStatus(r.status),
...extraSearchFields,
].some((v) => v.toLowerCase().includes(q)); ].some((v) => v.toLowerCase().includes(q));
}, });
[direction, freight, status, query], }, [allShipmentRows, tab, freight, status, action, query]);
);
const shipmentRows = useMemo(
() =>
allShipmentRows.filter(
(r) =>
(!action || r.action.key === action) &&
// Shipments also match the parent contract reference in search.
matchesShared(r, [r.contractReference]),
),
[allShipmentRows, action, matchesShared],
);
const isLoading = bookingsLoading; const isLoading = bookingsLoading;
const isError = bookingsError; const isError = bookingsError;
const isFetching = bookingsFetching; const isFetching = bookingsFetching;
const total = shipmentRows.length; const total = shipmentRows.length;
const pageCount = Math.max(1, Math.ceil(total / pagination.pageSize)); const pageCount = Math.max(1, Math.ceil(total / pagination.pageSize));
const showEmpty = !isLoading && !isError && total === 0;
const pagedShipmentRows = useMemo(() => { const pagedShipmentRows = useMemo(() => {
const start = pagination.pageIndex * pagination.pageSize; const start = pagination.pageIndex * pagination.pageSize;
return shipmentRows.slice(start, start + pagination.pageSize); return shipmentRows.slice(start, start + pagination.pageSize);
}, [shipmentRows, pagination.pageIndex, pagination.pageSize]); }, [shipmentRows, pagination.pageIndex, pagination.pageSize]);
const hasFilters = Boolean(query || direction || freight || status || action); const hasFilters = Boolean(query || freight || status || action);
const clearFilters = useCallback(() => { const clearFilters = useCallback(() => {
setQuery(""); setQuery("");
setDirection(null);
setFreight(null); setFreight(null);
setStatus(null); setStatus(null);
setAction(null); setAction(null);
@@ -379,18 +413,23 @@ export default function GlDjiboutiClearanceListPage() {
cell: ({ row }) => { cell: ({ row }) => {
const r = row.original; const r = row.original;
return ( return (
<div className="flex items-center gap-3 py-1.5"> <div className="flex items-center gap-2.5 py-1">
<div className={bookingTable.rowIcon}> <div className="flex size-[30px] shrink-0 items-center justify-center rounded-[9px] bg-edr-divider text-edr-muted">
<PackageCheck className="size-4" strokeWidth={1.75} /> <PackageCheck size={15} strokeWidth={1.75} />
</div> </div>
<div className="min-w-0"> <div className="min-w-0">
<p className="font-medium text-foreground"> <Text fz={13} fw={600} c="edr-text">
{r.reference} {r.reference}
</p> </Text>
<p className="mt-0.5 flex items-center gap-1 text-xs text-muted-foreground"> <Group gap={4} wrap="nowrap" align="flex-start">
<User className="size-3 shrink-0 opacity-70" /> <Building2
size={10}
className="mt-[3px] shrink-0 text-edr-muted opacity-70"
/>
<Text fz={11} c="edr-muted" className="cell-wrap">
{r.customerLabel} {r.customerLabel}
</p> </Text>
</Group>
</div> </div>
</div> </div>
); );
@@ -400,9 +439,11 @@ export default function GlDjiboutiClearanceListPage() {
id: "contract", id: "contract",
header: () => <span className={bookingTable.headerCell}>Contract</span>, header: () => <span className={bookingTable.headerCell}>Contract</span>,
cell: ({ row }) => ( cell: ({ row }) => (
<Group gap={6} wrap="nowrap"> <Group gap={5} wrap="nowrap">
<FileText size={13} className="shrink-0 text-muted-foreground" /> <FileText size={12} className="shrink-0 text-edr-muted" />
<Text size="sm">{row.original.contractReference}</Text> <Text fz={12.5} c="edr-text">
{row.original.contractReference}
</Text>
</Group> </Group>
), ),
}, },
@@ -428,9 +469,11 @@ export default function GlDjiboutiClearanceListPage() {
const r = row.original; const r = row.original;
return ( return (
<Stack gap={4} py={2}> <Stack gap={4} py={2}>
<Group gap={6} wrap="nowrap"> <Group gap={5} wrap="nowrap">
<Weight size={13} className="shrink-0 text-muted-foreground" /> <Weight size={12} className="shrink-0 text-edr-muted" />
<Text size="sm">{r.weightTons} t</Text> <Text fz={12} fw={500} c="edr-text">
{r.weightTons} t
</Text>
</Group> </Group>
{r.isHazardous ? ( {r.isHazardous ? (
<Badge <Badge
@@ -449,7 +492,9 @@ export default function GlDjiboutiClearanceListPage() {
}, },
{ {
id: "action", id: "action",
header: () => <span className={bookingTable.headerCell}>DJ action</span>, header: () => (
<span className={bookingTable.headerCell}>DJ action</span>
),
cell: ({ row }) => { cell: ({ row }) => {
const r = row.original; const r = row.original;
const badge = ( const badge = (
@@ -466,7 +511,7 @@ export default function GlDjiboutiClearanceListPage() {
) : ( ) : (
badge badge
)} )}
<Text size="xs" c="dimmed"> <Text fz={10.5} c="edr-muted">
{phaseLabel(r.phase)} {phaseLabel(r.phase)}
</Text> </Text>
</Stack> </Stack>
@@ -489,11 +534,13 @@ export default function GlDjiboutiClearanceListPage() {
}, },
{ {
id: "scheduled", id: "scheduled",
header: () => <span className={bookingTable.headerCell}>Scheduled</span>, header: () => (
<span className={bookingTable.headerCell}>Scheduled</span>
),
cell: ({ row }) => ( cell: ({ row }) => (
<Group gap={6} wrap="nowrap"> <Group gap={5} wrap="nowrap">
<CalendarClock size={13} className="shrink-0 text-muted-foreground" /> <CalendarClock size={12} className="shrink-0 text-edr-muted" />
<Text size="sm" c="dimmed"> <Text fz={11.5} c="edr-muted">
{formatDate(row.original.scheduledDate)} {formatDate(row.original.scheduledDate)}
</Text> </Text>
</Group> </Group>
@@ -505,7 +552,7 @@ export default function GlDjiboutiClearanceListPage() {
header: "", header: "",
cell: () => ( cell: () => (
<Group justify="flex-end" pr="xs"> <Group justify="flex-end" pr="xs">
<ChevronRight size={16} className="text-muted-foreground" /> <ChevronRight size={16} className="text-edr-muted" />
</Group> </Group>
), ),
}, },
@@ -519,17 +566,18 @@ export default function GlDjiboutiClearanceListPage() {
<PageHeader <PageHeader
title="GL Djibouti — Clearance" title="GL Djibouti — Clearance"
subtitle="Customs shipments handed off to Djibouti GL — every clearance step lives on the shipment." subtitle="Customs shipments handed off to Djibouti GL — every clearance step lives on the shipment."
meta={<LivePill updatedAt={dataUpdatedAt} />}
action={ action={
<ActionIcon <Button
variant="default" variant="default"
size="lg"
radius="md" radius="md"
size="sm"
leftSection={<RefreshCw size={14} />}
loading={isFetching} loading={isFetching}
onClick={handleRefresh} onClick={handleRefresh}
aria-label="Refresh"
> >
<RefreshCw size={16} /> Refresh
</ActionIcon> </Button>
} }
/> />
@@ -538,38 +586,131 @@ export default function GlDjiboutiClearanceListPage() {
items={[ items={[
{ {
label: "Shipments in queue", label: "Shipments in queue",
value: metrics.shipments, value: metrics.shipments.length,
icon: PackageCheck, icon: PackageCheck,
color: "blue", color: "blue",
spark: perDay(metrics.shipments),
}, },
{ {
label: "Imports — collect DO", label: "Imports — collect DO",
value: metrics.collectDo, value: metrics.collectDo.length,
icon: Truck, icon: Truck,
color: "yellow", color: "yellow",
spark: perDay(metrics.collectDo),
}, },
{ {
label: "Exports — issue RO", label: "Exports — issue RO",
value: metrics.issueRo, value: metrics.issueRo.length,
icon: ShipWheel, icon: ShipWheel,
color: "blue", color: "blue",
spark: perDay(metrics.issueRo),
}, },
{ {
label: "RO amendment holds", label: "RO amendment holds",
value: metrics.roHolds, value: metrics.roHolds.length,
icon: AlertTriangle, icon: AlertTriangle,
color: "red", color: "red",
spark: perDay(metrics.roHolds),
}, },
]} ]}
/> />
<Card p={0} withBorder shadow="sm" radius="lg"> <Card
p={0}
withBorder
shadow="sm"
radius="lg"
style={{ overflow: "hidden" }}
>
<Stack gap={0}> <Stack gap={0}>
<Box px="md" pt="md" pb="sm"> {/* ── Tabs ─────────────────────────────────────────────── */}
<Group gap="sm" wrap="wrap"> <Group
justify="space-between"
align="stretch"
px="md"
h={46}
wrap="nowrap"
style={{
borderBottom: "1px solid var(--mantine-color-edr-border-6)",
}}
>
<Group gap={2} wrap="nowrap" align="stretch">
{TABS.map((t) => {
const active = tab === t.key;
const Icon = t.icon;
return (
<UnstyledButton
key={t.key}
onClick={() => {
setTab(t.key);
resetPage();
}}
px={13}
className="flex items-center gap-2 transition-colors"
style={{
borderBottom: `2px solid ${
active
? "var(--mantine-color-edr-green-6)"
: "transparent"
}`,
marginBottom: -1,
}}
aria-pressed={active}
>
<Icon
size={14}
style={{
color: active
? "var(--mantine-color-edr-green-6)"
: "var(--mantine-color-gray-5)",
}}
/>
<Text
fz={13}
fw={active ? 600 : 500}
c={active ? "edr-text" : "edr-muted"}
>
{t.label}
</Text>
<span
className="rounded-full px-1.5 py-px text-[10.5px] font-semibold leading-[1.4]"
style={{
background: active
? "var(--mantine-color-edr-green-0)"
: "var(--mantine-color-gray-1)",
color: active
? "var(--mantine-color-edr-green-7)"
: "var(--mantine-color-edr-muted-6)",
}}
>
{tabCounts[t.key]}
</span>
</UnstyledButton>
);
})}
</Group>
<Text
fz={12}
c="edr-muted"
className="self-center whitespace-nowrap"
>
{total} record{total !== 1 ? "s" : ""}
</Text>
</Group>
{/* ── Filter bar ───────────────────────────────────────── */}
<Group
gap={9}
px="md"
py={12}
wrap="wrap"
style={{
borderBottom: "1px solid var(--mantine-color-edr-divider-6)",
}}
>
<TextInput <TextInput
placeholder="Search reference, customer, route, or status…" placeholder="Search reference, customer, route, or status…"
leftSection={<Search size={18} />} leftSection={<Search size={15} />}
value={query} value={query}
onChange={(e) => { onChange={(e) => {
setQuery(e.currentTarget.value); setQuery(e.currentTarget.value);
@@ -586,28 +727,18 @@ export default function GlDjiboutiClearanceListPage() {
setQuery(""); setQuery("");
resetPage(); resetPage();
}} }}
aria-label="Clear search"
> >
<X size={16} /> <X size={14} />
</ActionIcon> </ActionIcon>
) : null ) : null
} }
radius="lg" radius="md"
style={{ flex: 1, minWidth: 220 }} size="sm"
/> styles={{
<Select input: { background: "var(--mantine-color-gray-0)" },
placeholder="Direction"
data={[
{ value: "IMPORT", label: "Import" },
{ value: "EXPORT", label: "Export" },
]}
value={direction}
onChange={(v) => {
setDirection(v);
resetPage();
}} }}
clearable style={{ flex: 1, minWidth: 220 }}
radius="lg"
w={130}
/> />
<Select <Select
placeholder="Freight" placeholder="Freight"
@@ -621,8 +752,11 @@ export default function GlDjiboutiClearanceListPage() {
resetPage(); resetPage();
}} }}
clearable clearable
radius="lg" radius="md"
w={130} size="sm"
w={124}
comboboxProps={{ withinPortal: true }}
aria-label="Filter by freight type"
/> />
<Select <Select
placeholder="Status" placeholder="Status"
@@ -633,8 +767,11 @@ export default function GlDjiboutiClearanceListPage() {
resetPage(); resetPage();
}} }}
clearable clearable
radius="lg" radius="md"
w={190} size="sm"
w={180}
comboboxProps={{ withinPortal: true }}
aria-label="Filter by status"
/> />
<Select <Select
placeholder="DJ action" placeholder="DJ action"
@@ -645,8 +782,11 @@ export default function GlDjiboutiClearanceListPage() {
resetPage(); resetPage();
}} }}
clearable clearable
radius="lg" radius="md"
w={180} size="sm"
w={170}
comboboxProps={{ withinPortal: true }}
aria-label="Filter by DJ action"
/> />
{hasFilters ? ( {hasFilters ? (
<Button <Button
@@ -661,16 +801,15 @@ export default function GlDjiboutiClearanceListPage() {
</Button> </Button>
) : null} ) : null}
</Group> </Group>
</Box>
{showEmpty ? ( {!isLoading && !isError && total === 0 ? (
<Stack align="center" gap={8} py={48}> <Stack align="center" gap={8} py={48}>
<ThemeIcon variant="light" color="gray" radius="xl" size={48}> <ThemeIcon variant="light" color="gray" radius="xl" size={48}>
<Inbox size={22} /> <Inbox size={22} />
</ThemeIcon> </ThemeIcon>
<Text c="dimmed"> <Text c="dimmed">
{hasFilters {hasFilters
? "No records match these filters." ? "No shipments match these filters."
: "No shipments awaiting a Djibouti action."} : "No shipments awaiting a Djibouti action."}
</Text> </Text>
{hasFilters ? ( {hasFilters ? (
@@ -686,7 +825,7 @@ export default function GlDjiboutiClearanceListPage() {
) : null} ) : null}
</Stack> </Stack>
) : ( ) : (
<Box w="100%" miw={0} style={{ overflowX: "auto" }}> <Box w="100%" miw={0}>
<DataTable<ShipmentRow, unknown> <DataTable<ShipmentRow, unknown>
columns={shipmentColumns} columns={shipmentColumns}
data={pagedShipmentRows} data={pagedShipmentRows}
@@ -705,7 +844,7 @@ export default function GlDjiboutiClearanceListPage() {
pageCount, pageCount,
}} }}
containerClassName="edr-clearance-table border-0 shadow-none rounded-none bg-transparent" containerClassName="edr-clearance-table border-0 shadow-none rounded-none bg-transparent"
footer={DataTableFooter} footer={(p) => <TablePager {...p} noun="shipments" />}
/> />
</Box> </Box>
)} )}

View File

@@ -32,6 +32,22 @@
white-space: nowrap; white-space: nowrap;
} }
/*
* Booking (col 1) and Contract (col 2) carry free-text company/contract names.
* Cap those two columns and let their content wrap onto 2+ lines so a very long
* name (e.g. "SHAFICI PHARMACEUTICAL MEDICAL SUPPLIES WHOLESALER PARTINERSHIP")
* stacks inside its own cell instead of shoving the next column off-screen.
* Everything below the header row so the header labels still sit on one line.
*/
.edr-clearance-table tbody td:not([colspan]):nth-child(1) {
max-width: 240px;
white-space: normal;
}
.edr-clearance-table tbody td:not([colspan]):nth-child(2) {
max-width: 200px;
white-space: normal;
}
/* /*
* Mantine Badge caps itself at max-width: 100%; inside an auto-layout table * Mantine Badge caps itself at max-width: 100%; inside an auto-layout table
* cell that resolves against min-content and clips the label. Let badges size * cell that resolves against min-content and clips the label. Let badges size
@@ -41,6 +57,22 @@
max-width: none; max-width: none;
} }
/*
* Opt-out for long free text (company/customer names). The blanket nowrap rule
* above keeps every cell on one line so columns size to content; a very long
* name would otherwise force the column absurdly wide. Mark such text with
* `cell-wrap` to cap it and wrap onto 2+ lines instead of pushing the layout.
*/
.edr-clearance-table .cell-wrap,
.edr-clearance-table .mantine-Group-root > .cell-wrap {
white-space: normal;
overflow-wrap: anywhere;
word-break: break-word;
min-width: 0;
max-width: 100%;
line-height: 1.3;
}
/* /*
* Mantine Group's preventGrowOverflow caps every child at 100%/N of the cell. * Mantine Group's preventGrowOverflow caps every child at 100%/N of the cell.
* In an auto-width table cell that resolves against min-content and collapses * In an auto-width table cell that resolves against min-content and collapses
@@ -70,7 +102,7 @@
min-width: 0; min-width: 0;
position: sticky; position: sticky;
right: 0; right: 0;
box-shadow: -12px 0 16px -6px rgba(16, 32, 47, 0.3); box-shadow: -10px 0 14px -8px rgba(16, 32, 47, 0.12);
} }
/* /*
@@ -78,17 +110,41 @@
* background or the columns underneath show through. * background or the columns underneath show through.
*/ */
.edr-clearance-table td:last-child:not([colspan]) { .edr-clearance-table td:last-child:not([colspan]) {
background: #f5f8fb; background: var(--mantine-color-body);
z-index: 2; z-index: 2;
} }
/* Row hover uses the tailwind `hover:bg-accent` class on the <tr>. */ /* Row hover uses the tailwind `hover:bg-accent` class on the <tr>. */
.edr-clearance-table tbody tr:hover td:last-child:not([colspan]) { .edr-clearance-table tbody tr:hover td:last-child:not([colspan]) {
background: var(--accent, #f4fbf8); background: #f7fbf9;
} }
/* Header cell is sticky on both axes — it must outrank the body's sticky column. */ /* Header cell is sticky on both axes — it must outrank the body's sticky column. */
.edr-clearance-table th:last-child { .edr-clearance-table th:last-child {
background: #f4f7fa; background: var(--mantine-color-gray-0);
z-index: 3; z-index: 3;
} }
/* ── Design pass: flat head band, 64px rows, hairline dividers ─────────── */
.edr-clearance-table thead th {
height: 38px;
padding-top: 0;
padding-bottom: 0;
background: var(--mantine-color-gray-0);
border-bottom: 1px solid var(--mantine-color-edr-divider-6);
}
.edr-clearance-table tbody td:not([colspan]) {
height: 64px;
padding-top: 8px;
padding-bottom: 8px;
border-bottom: 1px solid var(--mantine-color-edr-divider-6);
}
.edr-clearance-table tbody tr:last-child td:not([colspan]) {
border-bottom: 0;
}
.edr-clearance-table tbody tr:hover td {
background: #f7fbf9;
}

View File

@@ -123,7 +123,7 @@ export const freightMantineTheme = createTheme({
}, },
headings: { headings: {
fontFamily: '"Inter", var(--mantine-font-family)', fontFamily: '"Space Grotesk", "Inter", var(--mantine-font-family)',
fontWeight: "700", fontWeight: "700",
sizes: { sizes: {
h1: { fontSize: "36px", lineHeight: "1.1", fontWeight: "800" }, h1: { fontSize: "36px", lineHeight: "1.1", fontWeight: "800" },