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 name="viewport" content="width=device-width, initial-scale=1.0" />
<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>
<body>
<div id="root"></div>

View File

@@ -53,7 +53,7 @@ export const bookingInput = {
export const bookingTable = {
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:
"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}`,

View File

@@ -122,7 +122,10 @@ function phaseCountdown(w: WindowRow): {
}
/** 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" },
FULL: { label: "Train full", color: "red" },
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
// breaks ties on the same departure.
return rows.sort((a, b) => {
const da = a.departureDate ? new Date(a.departureDate).getTime() : Infinity;
const db = b.departureDate ? new Date(b.departureDate).getTime() : Infinity;
const da = a.departureDate
? new Date(a.departureDate).getTime()
: Infinity;
const db = b.departureDate
? new Date(b.departureDate).getTime()
: Infinity;
if (da !== db) return da - db;
return Number(b.isOpenNow) - Number(a.isOpenNow);
});
@@ -319,14 +326,16 @@ export function GlUpcomingWindowsSection({
return (
<Card withBorder shadow="sm" radius="lg" p="lg">
<Group justify="space-between" align="flex-start" mb="md" wrap="nowrap">
<Group gap={8} wrap="nowrap">
<CalendarClock size={18} />
<Group justify="space-between" align="center" mb="md" wrap="nowrap">
<Group gap={10} wrap="nowrap">
<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>
<Text fw={700} fz={16}>
<Text ff="heading" fw={600} fz={15} lh={1.2}>
Booking windows
</Text>
<Text fz={13} c="dimmed">
<Text fz={12} c="edr-muted">
{contractId
? "Booking windows on this contract's routes (EAT)"
: "Import and export booking windows across all lanes (EAT)"}
@@ -386,7 +395,11 @@ export function GlUpcomingWindowsSection({
))}
</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) => (
<WindowCard key={`${w.scheduleId}-${w.bookingCycleNo}`} w={w} />
))}

View File

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

View File

@@ -52,13 +52,19 @@ export function PageHeader({
<div style={{ minWidth: 0 }}>
<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>
{meta}
</Group>
{subtitle ? (
<Text c="dimmed" size="sm" mt={4}>
<Text c="edr-muted" fz={13} mt={4}>
{subtitle}
</Text>
) : 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,
Group,
Menu,
Select,
Stack,
Text,
TextInput,
ThemeIcon,
Tooltip,
UnstyledButton,
} from "@mantine/core";
import { useInterval } from "@mantine/hooks";
import type { LucideIcon } from "lucide-react";
import {
ArrowRight,
Calendar,
Building2,
CalendarClock,
ExternalLink,
Eye,
FileText,
@@ -27,26 +32,27 @@ import {
RefreshCw,
Search,
ShieldCheck,
User,
ShipWheel,
TriangleAlert,
Truck,
X,
} from "lucide-react";
import {
DataTable,
usePagination,
type ColumnDef,
} from "@edr/ui-common";
import { DataTable, usePagination, type ColumnDef } from "@edr/ui-common";
import type { Freight } from "@edr/types";
import { useQuery } from "@tanstack/react-query";
import { PageContainer } from "@/components/page/PageContainer";
import { PageHeader } from "@/components/page/PageHeader";
import { KpiStrip } from "@/components/page/KpiStrip";
import { TablePager } from "@/components/page/TablePager";
import { bookingTable } from "@/components/bookings/booking-ui.styles";
import { useAuth } from "@/auth/useAuth";
import { useBookingEtClearanceQueue } from "@/hooks/bookings/useBookings";
import type { BookingDetail } from "@/types/booking";
import { FREIGHT_PERMS, hasPermission, isDjiboutiGl } from "@/lib/permissions";
import { formatDate } from "@/lib/format";
import { GlUpcomingWindowsSection } from "@/components/contracts/GlUpcomingWindowsSection";
import { CLEARANCE_TABS } from "@/features/clearance/clearance-tabs.config";
import {
RequestedCargoChips,
summarizeRequestedCargo,
@@ -62,53 +68,134 @@ function yardLabel(
return yard.label ?? yard.name ?? yard.code ?? "—";
}
/**
* "Origin → Destination", wrapping past 120px as "Addis Ababa" /
* "→ Djibouti": the arrow is glued to the destination with an nbsp, and
* text wraps normally (the table's cells are otherwise nowrap) so a long
* lane never spills into the next column.
*/
function RouteLabel({
origin,
destination,
}: {
origin: string;
destination: string;
}) {
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";
};
/** 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 (
<Text
size="sm"
maw={120}
lh={1.35}
style={{ whiteSpace: "normal", overflowWrap: "anywhere" }}
>
{origin}{" "}
<ArrowRight
size={13}
className="text-muted-foreground"
style={{ display: "inline-block", verticalAlign: "-2px" }}
/>
{"\u00A0"}
{destination}
</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">
<span className="size-1.5 rounded-full bg-edr-primary-dark" />
{label}
</span>
);
}
function CustomsBadge({ customs }: { customs: boolean }) {
return customs ? (
<Badge
size="xs"
variant="light"
color="edr-green"
radius="sm"
leftSection={<ShieldCheck size={11} />}
function DirectionPill({ direction }: { direction: string }) {
const isImport = direction === "IMPORT";
const Icon = isImport ? Truck : ShipWheel;
const color = isImport ? "blue" : "teal";
return (
<span
className="inline-flex items-center gap-1 rounded-[5px] px-1.5 py-[2px] text-[10px] font-medium leading-none"
style={{
background: `var(--mantine-color-${color}-0)`,
color: `var(--mantine-color-${color}-7)`,
}}
>
Customs
</Badge>
) : (
<Badge size="xs" variant="light" color="gray" radius="sm">
No customs
</Badge>
<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
</span>
) : null}
</Group>
</Stack>
);
}
@@ -129,13 +216,21 @@ export default function ContractClearanceListPage() {
!isDjiboutiGl(user);
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 resetPage = useCallback(
() => setPagination({ pageIndex: 0, pageSize: pagination.pageSize }),
[setPagination, pagination.pageSize],
);
const {
data: bookingQueue,
isLoading,
isError,
isFetching,
dataUpdatedAt,
refetch,
} = useBookingEtClearanceQueue(true);
@@ -149,7 +244,8 @@ export default function ContractClearanceListPage() {
const requestedByBooking = useMemo(() => {
const map = new Map<string, Freight.RequestedShipmentLines>();
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;
}, [requestQueue]);
@@ -170,7 +266,8 @@ export default function ContractClearanceListPage() {
contractId: b.contractId ?? null,
contractReference: b.contractReference ?? null,
contractKind: b.contractKind ?? null,
customs: b.serviceType?.includesCustoms ?? Boolean(b.customsClearingEnabled),
customs:
b.serviceType?.includesCustoms ?? Boolean(b.customsClearingEnabled),
createdAt: b.createdAt ?? null,
// A bare initiated instance has no cargo/price yet — GL still has to
// create (complete) the booking.
@@ -178,23 +275,9 @@ export default function ContractClearanceListPage() {
})) as ShipmentBookingRow[];
}, [bookingQueue, requestedByBooking]);
const rows = useMemo(() => {
const q = query.trim().toLowerCase();
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(
// KPI groups span the whole queue, regardless of tab/filters.
const groups = useMemo(
() => ({
all: allRows.length,
// Counts anything actually waiting on GL, including a document added
// after clearance was finalized (the status stays CLEARANCE_READY).
review: allRows.filter(
@@ -202,13 +285,72 @@ export default function ContractClearanceListPage() {
r.status === "AWAITING_DOCUMENTS" ||
r.status === "DOCUMENTS_UNDER_REVIEW" ||
r.hasDocumentsAwaitingReview,
).length,
ready: allRows.filter((r) => r.status === "CLEARANCE_READY" || r.bookingCreated)
.length,
),
approval: allRows.filter((r) => r.hasDocumentsAwaitingReview),
ready: allRows.filter(
(r) => r.status === "CLEARANCE_READY" || r.bookingCreated,
),
}),
[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(
// `from` so the detail page's Back returns to this hub.
@@ -223,31 +365,20 @@ export default function ContractClearanceListPage() {
<PageContainer>
<Stack gap="lg">
<PageHeader
title="Document Clearance"
title="Clearance queue"
subtitle="Every customs shipment in phased clearance — the documents live on the shipment, not on the contract."
meta={
<Badge
variant="light"
color="edr-green"
radius="sm"
leftSection={<ShieldCheck size={13} />}
>
{counts.all} in clearance
</Badge>
}
meta={<LivePill updatedAt={dataUpdatedAt} />}
action={
<Group gap="sm" wrap="nowrap">
<ActionIcon
variant="default"
size="lg"
radius="md"
onClick={() => void refetch()}
loading={isFetching}
aria-label="Refresh"
>
<RefreshCw size={16} />
</ActionIcon>
</Group>
<Button
variant="default"
radius="md"
size="sm"
leftSection={<RefreshCw size={14} />}
loading={isFetching}
onClick={() => void refetch()}
>
Refresh
</Button>
}
/>
@@ -256,65 +387,220 @@ export default function ContractClearanceListPage() {
items={[
{
label: "In clearance",
value: counts.all,
value: allRows.length,
icon: Inbox,
color: "edr-green",
color: "blue",
hint: newToday ? `+${newToday} today` : undefined,
spark: perDay(allRows),
},
{
label: "Awaiting review",
value: counts.review,
value: groups.review.length,
icon: ShieldCheck,
color: "yellow",
spark: perDay(groups.review),
},
{
label: "Needs approval",
value: groups.approval.length,
icon: TriangleAlert,
color: "red",
spark: perDay(groups.approval),
},
{
label: "Ready / booked",
value: counts.ready,
value: groups.ready.length,
icon: PackageCheck,
color: "edr-green",
spark: perDay(groups.ready),
},
]}
/>
<GlUpcomingWindowsSection />
<Card p={0} withBorder shadow="sm" radius="lg">
<Card
p={0}
withBorder
shadow="sm"
radius="lg"
style={{ overflow: "hidden" }}
>
<Stack gap={0}>
<Box px="md" pt="md" pb="sm">
<Group justify="space-between" gap="md" wrap="wrap">
<TextInput
placeholder="Search shipment, contract, customer or route…"
leftSection={<Search size={18} />}
value={query}
onChange={(e) => {
setQuery(e.currentTarget.value);
setPagination({ pageIndex: 0, pageSize: pagination.pageSize });
}}
rightSection={
query ? (
<ActionIcon
size="sm"
color="gray"
radius="md"
variant="transparent"
onClick={() => setQuery("")}
{/* ── Tabs ─────────────────────────────────────────────── */}
<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"}
>
<X size={16} />
</ActionIcon>
) : null
}
radius="lg"
style={{ flex: 1, minWidth: 220 }}
/>
<Text size="sm" c="dimmed">
{rows.length} record{rows.length !== 1 ? "s" : ""}
</Text>
{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>
</Box>
<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
placeholder="Search reference, customer, contract, or route…"
leftSection={<Search size={15} />}
value={query}
onChange={(e) => {
setQuery(e.currentTarget.value);
resetPage();
}}
rightSection={
query ? (
<ActionIcon
size="sm"
color="gray"
radius="md"
variant="transparent"
onClick={() => {
setQuery("");
resetPage();
}}
aria-label="Clear search"
>
<X size={14} />
</ActionIcon>
) : null
}
radius="md"
size="sm"
styles={{
input: { background: "var(--mantine-color-gray-0)" },
}}
style={{ flex: 1, minWidth: 220 }}
/>
<Select
placeholder="Freight"
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>
<ShipmentBookingsTable
rows={rows}
rows={pagedRows}
total={total}
pageCount={pageCount}
pagination={pagination}
setPagination={setPagination}
loading={isLoading}
error={isError}
hasFilters={hasFilters}
onClearFilters={clearFilters}
canCreateBooking={canCreateBooking}
onOpen={openBooking}
onCreateBooking={(row) =>
@@ -337,7 +623,6 @@ export default function ContractClearanceListPage() {
</Stack>
</Card>
</Stack>
</PageContainer>
);
}
@@ -367,43 +652,19 @@ interface ShipmentBookingRow {
bookingCreated: boolean;
}
const formatDate = (iso: string | null) => {
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";
};
type PaginationState = ReturnType<typeof usePagination>["pagination"];
/** GENERAL-contract shipment bookings currently in per-booking clearance. */
function ShipmentBookingsTable({
rows,
total,
pageCount,
pagination,
setPagination,
loading,
error,
hasFilters,
onClearFilters,
canCreateBooking,
onOpen,
onCreateBooking,
@@ -411,8 +672,14 @@ function ShipmentBookingsTable({
onViewContract,
}: {
rows: ShipmentBookingRow[];
total: number;
pageCount: number;
pagination: PaginationState;
setPagination: ReturnType<typeof usePagination>["setPagination"];
loading: boolean;
error: boolean;
hasFilters: boolean;
onClearFilters: () => void;
canCreateBooking: boolean;
onOpen: (id: string) => void;
onCreateBooking: (row: ShipmentBookingRow) => void;
@@ -440,18 +707,23 @@ function ShipmentBookingsTable({
id: "booking",
header: () => <span className={bookingTable.headerCell}>Booking</span>,
cell: ({ row }) => (
<div className="flex items-center gap-3 py-1.5">
<div className={bookingTable.rowIcon}>
<PackageCheck className="size-4" strokeWidth={1.75} />
<div className="flex items-center gap-2.5 py-1">
<div className="flex size-[30px] shrink-0 items-center justify-center rounded-[9px] bg-edr-divider text-edr-muted">
<PackageCheck size={15} strokeWidth={1.75} />
</div>
<div className="min-w-0">
<p className="font-medium text-foreground">
<Text fz={13} fw={600} c="edr-text">
{row.original.reference}
</p>
<p className="mt-0.5 flex items-center gap-1 text-xs text-muted-foreground">
<User className="size-3 shrink-0 opacity-70" />
{row.original.customerLabel}
</p>
</Text>
<Group gap={4} wrap="nowrap" align="flex-start">
<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}
</Text>
</Group>
</div>
</div>
),
@@ -462,17 +734,19 @@ function ShipmentBookingsTable({
cell: ({ row }) => {
const r = row.original;
return (
<Stack gap={4} py={2}>
<Group gap={6} wrap="nowrap">
<FileText size={13} className="shrink-0 text-muted-foreground" />
<Text size="sm" fw={500}>
<Stack gap={3} py={2}>
<Group gap={5} wrap="nowrap">
<FileText size={12} className="shrink-0 text-edr-muted" />
<Text fz={12.5} c="edr-text">
{r.contractReference ?? "—"}
</Text>
</Group>
{r.contractKind ? (
<Badge size="xs" variant="default" radius="sm" tt="uppercase">
{r.contractKind === "GENERAL" ? "General" : "One-time"}
</Badge>
<Text fz={10.5} c="edr-muted">
{r.contractKind === "GENERAL"
? "General contract"
: "One-time"}
</Text>
) : null}
</Stack>
);
@@ -481,44 +755,33 @@ function ShipmentBookingsTable({
{
id: "route",
header: () => <span className={bookingTable.headerCell}>Route</span>,
cell: ({ row }) => (
<RouteLabel
origin={row.original.originLabel}
destination={row.original.destinationLabel}
/>
),
},
{
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>
),
cell: ({ row }) => {
const r = row.original;
return (
<RouteCell
origin={r.originLabel}
destination={r.destinationLabel}
direction={r.tradeDirection}
freightType={r.freightType}
customs={r.customs}
/>
);
},
},
{
id: "requested",
header: () => (
<span className={bookingTable.headerCell}>Requested cargo</span>
),
header: () => <span className={bookingTable.headerCell}>Cargo</span>,
cell: ({ row }) => (
<RequestedCargoChips lines={row.original.requested} size="sm" />
<RequestedCargoChips lines={row.original.requested} size="xs" />
),
},
{
id: "created",
header: () => <span className={bookingTable.headerCell}>Created</span>,
cell: ({ row }) => (
<Group gap={6} wrap="nowrap">
<Calendar size={13} className="shrink-0 text-muted-foreground" />
<Text size="sm" c="dimmed">
<Group gap={5} wrap="nowrap">
<CalendarClock size={12} className="shrink-0 text-edr-muted" />
<Text fz={11.5} c="edr-muted">
{formatDate(row.original.createdAt)}
</Text>
</Group>
@@ -533,14 +796,14 @@ function ShipmentBookingsTable({
a file added after clearance was finalized leaves the status at
CLEARANCE_READY, and the row must still call for the review. */}
{row.original.hasDocumentsAwaitingReview ? (
<Badge variant="filled" color="orange" radius="sm">
<Badge variant="filled" color="orange" radius="sm" size="sm">
Needs approval
</Badge>
) : /* All docs approved but not yet finalized: the booking status is
still DOCUMENTS_UNDER_REVIEW — show the real review state. */
row.original.status === "DOCUMENTS_UNDER_REVIEW" &&
row.original.allDocsApproved ? (
<Badge variant="light" color="edr-green" radius="sm">
<Badge variant="light" color="edr-green" radius="sm" size="sm">
Documents approved
</Badge>
) : (
@@ -548,6 +811,7 @@ function ShipmentBookingsTable({
variant="light"
color={shipmentStatusColor(row.original.status)}
radius="sm"
size="sm"
>
{prettyStatus(row.original.status)}
</Badge>
@@ -558,6 +822,7 @@ function ShipmentBookingsTable({
variant="light"
color="blue"
radius="sm"
size="sm"
leftSection={<PackagePlus size={11} />}
>
Booked
@@ -617,7 +882,10 @@ function ShipmentBookingsTable({
</ActionIcon>
</Menu.Target>
<Menu.Dropdown>
<Menu.Item leftSection={<Eye size={14} />} onClick={() => onOpen(r.id)}>
<Menu.Item
leftSection={<Eye size={14} />}
onClick={() => onOpen(r.id)}
>
Open booking
</Menu.Item>
{bookable ? (
@@ -655,25 +923,53 @@ function ShipmentBookingsTable({
[canCreateBooking, onOpen, onCreateBooking, onViewContract],
);
if (!loading && !error && rows.length === 0) {
if (!loading && !error && total === 0) {
return (
<Stack align="center" gap={8} py={48}>
<ThemeIcon variant="light" color="gray" radius="xl" size={48}>
<Inbox size={22} />
</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>
);
}
return (
<Box w="100%" miw={0} style={{ overflowX: "auto" }}>
<Box w="100%" miw={0}>
<DataTable<ShipmentBookingRow, unknown>
columns={columns}
data={rows}
status={loading ? "loading" : error ? "error" : "success"}
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"
footer={(p) => <TablePager {...p} noun="shipments" />}
/>
</Box>
);

View File

@@ -15,33 +15,33 @@ import {
TextInput,
ThemeIcon,
Tooltip,
UnstyledButton,
} from "@mantine/core";
import { useInterval } from "@mantine/hooks";
import type { LucideIcon } from "lucide-react";
import {
AlertTriangle,
ArrowRight,
Building2,
CalendarClock,
ChevronRight,
FileText,
Inbox,
Layers,
PackageCheck,
RefreshCw,
Search,
ShipWheel,
Truck,
User,
Weight,
X,
} from "lucide-react";
import {
DataTable,
DataTableFooter,
usePagination,
type ColumnDef,
} from "@edr/ui-common";
import { DataTable, usePagination, type ColumnDef } from "@edr/ui-common";
import { PageContainer } from "@/components/page/PageContainer";
import { PageHeader } from "@/components/page/PageHeader";
import { KpiStrip } from "@/components/page/KpiStrip";
import { TablePager } from "@/components/page/TablePager";
import { bookingTable } from "@/components/bookings/booking-ui.styles";
import { useBookingDjClearanceQueue } from "@/hooks/bookings/useBookings";
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) ───────────────────────────────────────────────
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 ───────────────────────────────────────────────────────
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 Icon = isImport ? Truck : ShipWheel;
const label = directionLabel(direction);
const color = isImport ? "blue" : "teal";
return (
<Tooltip label={label} withArrow>
<ThemeIcon
variant="light"
color={isImport ? "edr-green" : "gray"}
radius="md"
size={26}
aria-label={label}
<Tooltip label={directionLabel(direction)} withArrow>
<span
className="inline-flex items-center gap-1 rounded-[5px] px-1.5 py-[2px] text-[10px] font-medium leading-none"
style={{
background: `var(--mantine-color-${color}-0)`,
color: `var(--mantine-color-${color}-7)`,
}}
>
<Icon size={14} strokeWidth={1.9} />
</ThemeIcon>
<Icon size={10} />
{prettyStatus(direction)}
</span>
</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({
origin,
destination,
@@ -214,30 +265,19 @@ function RouteCell({
freightType: string;
}) {
return (
<Stack gap={4} py={2}>
{/* Wraps past 120px as "Addis Ababa" / "→ Djibouti"; text wraps
normally (cells are otherwise nowrap) so it never spills over. */}
<Text
size="sm"
fw={500}
maw={120}
lh={1.35}
style={{ whiteSpace: "normal", overflowWrap: "anywhere" }}
>
{origin}{" "}
<ArrowRight
size={13}
className="text-muted-foreground"
style={{ display: "inline-block", verticalAlign: "-2px" }}
/>
{"\u00A0"}
{destination}
</Text>
<Group gap={8} align="center">
<DirectionIcon direction={direction} />
<Badge size="xs" variant="default" radius="sm">
{freightType}
</Badge>
<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>
</Group>
</Stack>
);
@@ -254,7 +294,7 @@ function RouteCell({
export default function GlDjiboutiClearanceListPage() {
const navigate = useNavigate();
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 [status, setStatus] = useState<string | null>(null);
const [action, setAction] = useState<string | null>(null);
@@ -265,6 +305,7 @@ export default function GlDjiboutiClearanceListPage() {
isLoading: bookingsLoading,
isError: bookingsError,
isFetching: bookingsFetching,
dataUpdatedAt,
refetch: refetchBookings,
} = useBookingDjClearanceQueue();
@@ -277,18 +318,30 @@ export default function GlDjiboutiClearanceListPage() {
() => (bookingQueue ?? []).map(toShipmentRow),
[bookingQueue],
);
// KPI metrics span the whole queue, regardless of filters.
const metrics = useMemo(
() => ({
shipments: allShipmentRows.length,
collectDo: allShipmentRows.filter((r) => r.action.key === "COLLECT_DO")
.length,
issueRo: allShipmentRows.filter((r) => r.action.key === "ISSUE_RO").length,
roHolds: allShipmentRows.filter((r) => r.action.key === "RO_HOLD").length,
shipments: allShipmentRows,
collectDo: allShipmentRows.filter((r) => r.action.key === "COLLECT_DO"),
issueRo: allShipmentRows.filter((r) => r.action.key === "ISSUE_RO"),
roHolds: allShipmentRows.filter((r) => r.action.key === "RO_HOLD"),
}),
[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(
() =>
[...new Set(allShipmentRows.map((r) => r.status))].sort().map((s) => ({
@@ -298,64 +351,45 @@ export default function GlDjiboutiClearanceListPage() {
[allShipmentRows],
);
const matchesShared = useCallback(
(
r: {
reference: string;
customerLabel: string;
originLabel: string;
destinationLabel: string;
tradeDirection: string;
freightType: string;
status: string;
},
extraSearchFields: string[] = [],
) => {
if (direction && r.tradeDirection !== direction) return false;
const shipmentRows = useMemo(() => {
const q = query.trim().toLowerCase();
return allShipmentRows.filter((r) => {
if (tab === "hold" && r.action.key !== "RO_HOLD") 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;
const q = query.trim().toLowerCase();
if (action && r.action.key !== action) return false;
if (!q) return true;
return [
r.reference,
r.customerLabel,
r.contractReference,
r.originLabel,
r.destinationLabel,
prettyStatus(r.status),
...extraSearchFields,
].some((v) => v.toLowerCase().includes(q));
},
[direction, freight, status, 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],
);
});
}, [allShipmentRows, tab, freight, status, action, query]);
const isLoading = bookingsLoading;
const isError = bookingsError;
const isFetching = bookingsFetching;
const total = shipmentRows.length;
const pageCount = Math.max(1, Math.ceil(total / pagination.pageSize));
const showEmpty = !isLoading && !isError && total === 0;
const pagedShipmentRows = useMemo(() => {
const start = pagination.pageIndex * pagination.pageSize;
return shipmentRows.slice(start, start + 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(() => {
setQuery("");
setDirection(null);
setFreight(null);
setStatus(null);
setAction(null);
@@ -379,18 +413,23 @@ export default function GlDjiboutiClearanceListPage() {
cell: ({ row }) => {
const r = row.original;
return (
<div className="flex items-center gap-3 py-1.5">
<div className={bookingTable.rowIcon}>
<PackageCheck className="size-4" strokeWidth={1.75} />
<div className="flex items-center gap-2.5 py-1">
<div className="flex size-[30px] shrink-0 items-center justify-center rounded-[9px] bg-edr-divider text-edr-muted">
<PackageCheck size={15} strokeWidth={1.75} />
</div>
<div className="min-w-0">
<p className="font-medium text-foreground">
<Text fz={13} fw={600} c="edr-text">
{r.reference}
</p>
<p className="mt-0.5 flex items-center gap-1 text-xs text-muted-foreground">
<User className="size-3 shrink-0 opacity-70" />
{r.customerLabel}
</p>
</Text>
<Group gap={4} wrap="nowrap" align="flex-start">
<Building2
size={10}
className="mt-[3px] shrink-0 text-edr-muted opacity-70"
/>
<Text fz={11} c="edr-muted" className="cell-wrap">
{r.customerLabel}
</Text>
</Group>
</div>
</div>
);
@@ -400,9 +439,11 @@ export default function GlDjiboutiClearanceListPage() {
id: "contract",
header: () => <span className={bookingTable.headerCell}>Contract</span>,
cell: ({ row }) => (
<Group gap={6} wrap="nowrap">
<FileText size={13} className="shrink-0 text-muted-foreground" />
<Text size="sm">{row.original.contractReference}</Text>
<Group gap={5} wrap="nowrap">
<FileText size={12} className="shrink-0 text-edr-muted" />
<Text fz={12.5} c="edr-text">
{row.original.contractReference}
</Text>
</Group>
),
},
@@ -428,9 +469,11 @@ export default function GlDjiboutiClearanceListPage() {
const r = row.original;
return (
<Stack gap={4} py={2}>
<Group gap={6} wrap="nowrap">
<Weight size={13} className="shrink-0 text-muted-foreground" />
<Text size="sm">{r.weightTons} t</Text>
<Group gap={5} wrap="nowrap">
<Weight size={12} className="shrink-0 text-edr-muted" />
<Text fz={12} fw={500} c="edr-text">
{r.weightTons} t
</Text>
</Group>
{r.isHazardous ? (
<Badge
@@ -449,7 +492,9 @@ export default function GlDjiboutiClearanceListPage() {
},
{
id: "action",
header: () => <span className={bookingTable.headerCell}>DJ action</span>,
header: () => (
<span className={bookingTable.headerCell}>DJ action</span>
),
cell: ({ row }) => {
const r = row.original;
const badge = (
@@ -466,7 +511,7 @@ export default function GlDjiboutiClearanceListPage() {
) : (
badge
)}
<Text size="xs" c="dimmed">
<Text fz={10.5} c="edr-muted">
{phaseLabel(r.phase)}
</Text>
</Stack>
@@ -489,11 +534,13 @@ export default function GlDjiboutiClearanceListPage() {
},
{
id: "scheduled",
header: () => <span className={bookingTable.headerCell}>Scheduled</span>,
header: () => (
<span className={bookingTable.headerCell}>Scheduled</span>
),
cell: ({ row }) => (
<Group gap={6} wrap="nowrap">
<CalendarClock size={13} className="shrink-0 text-muted-foreground" />
<Text size="sm" c="dimmed">
<Group gap={5} wrap="nowrap">
<CalendarClock size={12} className="shrink-0 text-edr-muted" />
<Text fz={11.5} c="edr-muted">
{formatDate(row.original.scheduledDate)}
</Text>
</Group>
@@ -505,7 +552,7 @@ export default function GlDjiboutiClearanceListPage() {
header: "",
cell: () => (
<Group justify="flex-end" pr="xs">
<ChevronRight size={16} className="text-muted-foreground" />
<ChevronRight size={16} className="text-edr-muted" />
</Group>
),
},
@@ -519,17 +566,18 @@ export default function GlDjiboutiClearanceListPage() {
<PageHeader
title="GL Djibouti — Clearance"
subtitle="Customs shipments handed off to Djibouti GL — every clearance step lives on the shipment."
meta={<LivePill updatedAt={dataUpdatedAt} />}
action={
<ActionIcon
<Button
variant="default"
size="lg"
radius="md"
size="sm"
leftSection={<RefreshCw size={14} />}
loading={isFetching}
onClick={handleRefresh}
aria-label="Refresh"
>
<RefreshCw size={16} />
</ActionIcon>
Refresh
</Button>
}
/>
@@ -538,139 +586,230 @@ export default function GlDjiboutiClearanceListPage() {
items={[
{
label: "Shipments in queue",
value: metrics.shipments,
value: metrics.shipments.length,
icon: PackageCheck,
color: "blue",
spark: perDay(metrics.shipments),
},
{
label: "Imports — collect DO",
value: metrics.collectDo,
value: metrics.collectDo.length,
icon: Truck,
color: "yellow",
spark: perDay(metrics.collectDo),
},
{
label: "Exports — issue RO",
value: metrics.issueRo,
value: metrics.issueRo.length,
icon: ShipWheel,
color: "blue",
spark: perDay(metrics.issueRo),
},
{
label: "RO amendment holds",
value: metrics.roHolds,
value: metrics.roHolds.length,
icon: AlertTriangle,
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}>
<Box px="md" pt="md" pb="sm">
<Group gap="sm" wrap="wrap">
<TextInput
placeholder="Search reference, customer, route, or status…"
leftSection={<Search size={18} />}
value={query}
onChange={(e) => {
setQuery(e.currentTarget.value);
resetPage();
}}
rightSection={
query ? (
<ActionIcon
size="sm"
color="gray"
radius="md"
variant="transparent"
onClick={() => {
setQuery("");
resetPage();
{/* ── Tabs ─────────────────────────────────────────────── */}
<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)",
}}
>
<X size={16} />
</ActionIcon>
) : null
}
radius="lg"
style={{ flex: 1, minWidth: 220 }}
/>
<Select
placeholder="Direction"
data={[
{ value: "IMPORT", label: "Import" },
{ value: "EXPORT", label: "Export" },
]}
value={direction}
onChange={(v) => {
setDirection(v);
resetPage();
}}
clearable
radius="lg"
w={130}
/>
<Select
placeholder="Freight"
data={[
{ value: "CONTAINER", label: "Container" },
{ value: "BULK", label: "Bulk" },
]}
value={freight}
onChange={(v) => {
setFreight(v);
resetPage();
}}
clearable
radius="lg"
w={130}
/>
<Select
placeholder="Status"
data={statusOptions}
value={status}
onChange={(v) => {
setStatus(v);
resetPage();
}}
clearable
radius="lg"
w={190}
/>
<Select
placeholder="DJ action"
data={DJ_ACTION_OPTIONS}
value={action}
onChange={(v) => {
setAction(v);
resetPage();
}}
clearable
radius="lg"
w={180}
/>
{hasFilters ? (
<Button
variant="subtle"
color="gray"
size="compact-sm"
radius="md"
leftSection={<X size={14} />}
onClick={clearFilters}
>
Clear
</Button>
) : null}
{tabCounts[t.key]}
</span>
</UnstyledButton>
);
})}
</Group>
</Box>
<Text
fz={12}
c="edr-muted"
className="self-center whitespace-nowrap"
>
{total} record{total !== 1 ? "s" : ""}
</Text>
</Group>
{showEmpty ? (
{/* ── Filter bar ───────────────────────────────────────── */}
<Group
gap={9}
px="md"
py={12}
wrap="wrap"
style={{
borderBottom: "1px solid var(--mantine-color-edr-divider-6)",
}}
>
<TextInput
placeholder="Search reference, customer, route, or status…"
leftSection={<Search size={15} />}
value={query}
onChange={(e) => {
setQuery(e.currentTarget.value);
resetPage();
}}
rightSection={
query ? (
<ActionIcon
size="sm"
color="gray"
radius="md"
variant="transparent"
onClick={() => {
setQuery("");
resetPage();
}}
aria-label="Clear search"
>
<X size={14} />
</ActionIcon>
) : null
}
radius="md"
size="sm"
styles={{
input: { background: "var(--mantine-color-gray-0)" },
}}
style={{ flex: 1, minWidth: 220 }}
/>
<Select
placeholder="Freight"
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"
/>
<Select
placeholder="DJ action"
data={DJ_ACTION_OPTIONS}
value={action}
onChange={(v) => {
setAction(v);
resetPage();
}}
clearable
radius="md"
size="sm"
w={170}
comboboxProps={{ withinPortal: true }}
aria-label="Filter by DJ action"
/>
{hasFilters ? (
<Button
variant="subtle"
color="gray"
size="compact-sm"
radius="md"
leftSection={<X size={14} />}
onClick={clearFilters}
>
Clear
</Button>
) : null}
</Group>
{!isLoading && !isError && total === 0 ? (
<Stack align="center" gap={8} py={48}>
<ThemeIcon variant="light" color="gray" radius="xl" size={48}>
<Inbox size={22} />
</ThemeIcon>
<Text c="dimmed">
{hasFilters
? "No records match these filters."
? "No shipments match these filters."
: "No shipments awaiting a Djibouti action."}
</Text>
{hasFilters ? (
@@ -686,7 +825,7 @@ export default function GlDjiboutiClearanceListPage() {
) : null}
</Stack>
) : (
<Box w="100%" miw={0} style={{ overflowX: "auto" }}>
<Box w="100%" miw={0}>
<DataTable<ShipmentRow, unknown>
columns={shipmentColumns}
data={pagedShipmentRows}
@@ -705,7 +844,7 @@ export default function GlDjiboutiClearanceListPage() {
pageCount,
}}
containerClassName="edr-clearance-table border-0 shadow-none rounded-none bg-transparent"
footer={DataTableFooter}
footer={(p) => <TablePager {...p} noun="shipments" />}
/>
</Box>
)}

View File

@@ -32,6 +32,22 @@
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
* cell that resolves against min-content and clips the label. Let badges size
@@ -41,6 +57,22 @@
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.
* In an auto-width table cell that resolves against min-content and collapses
@@ -70,7 +102,7 @@
min-width: 0;
position: sticky;
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.
*/
.edr-clearance-table td:last-child:not([colspan]) {
background: #f5f8fb;
background: var(--mantine-color-body);
z-index: 2;
}
/* Row hover uses the tailwind `hover:bg-accent` class on the <tr>. */
.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. */
.edr-clearance-table th:last-child {
background: #f4f7fa;
background: var(--mantine-color-gray-0);
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: {
fontFamily: '"Inter", var(--mantine-font-family)',
fontFamily: '"Space Grotesk", "Inter", var(--mantine-font-family)',
fontWeight: "700",
sizes: {
h1: { fontSize: "36px", lineHeight: "1.1", fontWeight: "800" },