mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-26 18:42:49 +00:00
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:
@@ -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}`,
|
||||
|
||||
@@ -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} />
|
||||
))}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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 1–10 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;
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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>
|
||||
)}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user