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:
@@ -4,6 +4,12 @@
|
|||||||
<meta charset="UTF-8" />
|
<meta charset="UTF-8" />
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
<title>EDR Freight Backoffice</title>
|
<title>EDR Freight Backoffice</title>
|
||||||
|
<link rel="preconnect" href="https://fonts.googleapis.com" />
|
||||||
|
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
|
||||||
|
<link
|
||||||
|
href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700;800&family=Space+Grotesk:wght@500;600;700&display=swap"
|
||||||
|
rel="stylesheet"
|
||||||
|
/>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<div id="root"></div>
|
<div id="root"></div>
|
||||||
|
|||||||
@@ -53,7 +53,7 @@ export const bookingInput = {
|
|||||||
|
|
||||||
export const bookingTable = {
|
export const bookingTable = {
|
||||||
headerCell:
|
headerCell:
|
||||||
"h-11 text-[11px] font-semibold uppercase tracking-wider text-muted-foreground",
|
"whitespace-nowrap text-[10px] font-semibold uppercase tracking-[0.08em] text-edr-muted",
|
||||||
rowHover:
|
rowHover:
|
||||||
"transition-colors hover:bg-muted/25 data-[state=selected]:bg-muted/30",
|
"transition-colors hover:bg-muted/25 data-[state=selected]:bg-muted/30",
|
||||||
rowIcon: `flex size-10 shrink-0 items-center justify-center rounded-xl ${bookingGlass.iconWellGreen}`,
|
rowIcon: `flex size-10 shrink-0 items-center justify-center rounded-xl ${bookingGlass.iconWellGreen}`,
|
||||||
|
|||||||
@@ -122,7 +122,10 @@ function phaseCountdown(w: WindowRow): {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/** Badge label + Mantine color per UI state — same state the countdown uses. */
|
/** Badge label + Mantine color per UI state — same state the countdown uses. */
|
||||||
const KIND_BADGE: Record<BookingWindowUiKind, { label: string; color: string }> = {
|
const KIND_BADGE: Record<
|
||||||
|
BookingWindowUiKind,
|
||||||
|
{ label: string; color: string }
|
||||||
|
> = {
|
||||||
OPEN: { label: "Open now", color: "edr-green" },
|
OPEN: { label: "Open now", color: "edr-green" },
|
||||||
FULL: { label: "Train full", color: "red" },
|
FULL: { label: "Train full", color: "red" },
|
||||||
PRE_WINDOW: { label: "Opens soon", color: "yellow" },
|
PRE_WINDOW: { label: "Opens soon", color: "yellow" },
|
||||||
@@ -301,8 +304,12 @@ export function GlUpcomingWindowsSection({
|
|||||||
// Order by the train's dispatch (departure) date, nearest first. Open-now
|
// Order by the train's dispatch (departure) date, nearest first. Open-now
|
||||||
// breaks ties on the same departure.
|
// breaks ties on the same departure.
|
||||||
return rows.sort((a, b) => {
|
return rows.sort((a, b) => {
|
||||||
const da = a.departureDate ? new Date(a.departureDate).getTime() : Infinity;
|
const da = a.departureDate
|
||||||
const db = b.departureDate ? new Date(b.departureDate).getTime() : Infinity;
|
? new Date(a.departureDate).getTime()
|
||||||
|
: Infinity;
|
||||||
|
const db = b.departureDate
|
||||||
|
? new Date(b.departureDate).getTime()
|
||||||
|
: Infinity;
|
||||||
if (da !== db) return da - db;
|
if (da !== db) return da - db;
|
||||||
return Number(b.isOpenNow) - Number(a.isOpenNow);
|
return Number(b.isOpenNow) - Number(a.isOpenNow);
|
||||||
});
|
});
|
||||||
@@ -319,14 +326,16 @@ export function GlUpcomingWindowsSection({
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<Card withBorder shadow="sm" radius="lg" p="lg">
|
<Card withBorder shadow="sm" radius="lg" p="lg">
|
||||||
<Group justify="space-between" align="flex-start" mb="md" wrap="nowrap">
|
<Group justify="space-between" align="center" mb="md" wrap="nowrap">
|
||||||
<Group gap={8} wrap="nowrap">
|
<Group gap={10} wrap="nowrap">
|
||||||
<CalendarClock size={18} />
|
<div className="flex size-8 shrink-0 items-center justify-center rounded-[9px] bg-edr-soft text-edr-primary-dark">
|
||||||
|
<CalendarClock size={16} />
|
||||||
|
</div>
|
||||||
<Box>
|
<Box>
|
||||||
<Text fw={700} fz={16}>
|
<Text ff="heading" fw={600} fz={15} lh={1.2}>
|
||||||
Booking windows
|
Booking windows
|
||||||
</Text>
|
</Text>
|
||||||
<Text fz={13} c="dimmed">
|
<Text fz={12} c="edr-muted">
|
||||||
{contractId
|
{contractId
|
||||||
? "Booking windows on this contract's routes (EAT)"
|
? "Booking windows on this contract's routes (EAT)"
|
||||||
: "Import and export booking windows across all lanes (EAT)"}
|
: "Import and export booking windows across all lanes (EAT)"}
|
||||||
@@ -386,7 +395,11 @@ export function GlUpcomingWindowsSection({
|
|||||||
))}
|
))}
|
||||||
</SimpleGrid>
|
</SimpleGrid>
|
||||||
) : (
|
) : (
|
||||||
<SimpleGrid key={safePage} cols={{ base: 1, sm: 2, lg: 3 }} spacing="md">
|
<SimpleGrid
|
||||||
|
key={safePage}
|
||||||
|
cols={{ base: 1, sm: 2, lg: 3 }}
|
||||||
|
spacing="md"
|
||||||
|
>
|
||||||
{visible.map((w) => (
|
{visible.map((w) => (
|
||||||
<WindowCard key={`${w.scheduleId}-${w.bookingCycleNo}`} w={w} />
|
<WindowCard key={`${w.scheduleId}-${w.bookingCycleNo}`} w={w} />
|
||||||
))}
|
))}
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { Card, Skeleton, Text } from "@mantine/core";
|
import { Card, Skeleton, Text } from "@mantine/core";
|
||||||
|
import { ArrowUpRight } from "lucide-react";
|
||||||
import type { LucideIcon } from "lucide-react";
|
import type { LucideIcon } from "lucide-react";
|
||||||
import type { ElementType, ReactNode } from "react";
|
import type { ElementType, ReactNode } from "react";
|
||||||
import { Link } from "react-router-dom";
|
import { Link } from "react-router-dom";
|
||||||
@@ -8,14 +9,14 @@ import { cn } from "@/lib/utils";
|
|||||||
export interface KpiItem {
|
export interface KpiItem {
|
||||||
label: string;
|
label: string;
|
||||||
value: ReactNode;
|
value: ReactNode;
|
||||||
/** Optional leading icon rendered in a tinted chip. */
|
/** Optional leading icon rendered in a tinted chip beside the label. */
|
||||||
icon?: LucideIcon;
|
icon?: LucideIcon;
|
||||||
/** Secondary line under the label (e.g. a unit or comparison). */
|
/** Small tinted pill beside the value (e.g. "+6 today"). */
|
||||||
hint?: string;
|
hint?: string;
|
||||||
/**
|
/**
|
||||||
* Mantine color name for the icon chip (e.g. "edr-green", "red", "yellow").
|
* Mantine color name for the icon chip and sparkline (e.g. "edr-green",
|
||||||
* Defaults to the brand green so a strip reads as uniform unless a page opts
|
* "red", "yellow"). Defaults to the brand green so a strip reads as uniform
|
||||||
* into semantic tints.
|
* unless a page opts into semantic tints.
|
||||||
*/
|
*/
|
||||||
color?: string;
|
color?: string;
|
||||||
/**
|
/**
|
||||||
@@ -28,6 +29,8 @@ export interface KpiItem {
|
|||||||
* becomes clickable (pointer, hover tint); when absent it stays static.
|
* becomes clickable (pointer, hover tint); when absent it stays static.
|
||||||
*/
|
*/
|
||||||
href?: string;
|
href?: string;
|
||||||
|
/** Tiny bar sparkline, oldest → newest, scaled to its own max. */
|
||||||
|
spark?: number[];
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface KpiStripProps {
|
export interface KpiStripProps {
|
||||||
@@ -36,11 +39,52 @@ export interface KpiStripProps {
|
|||||||
loading?: boolean;
|
loading?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function Pill({
|
||||||
|
children,
|
||||||
|
tone,
|
||||||
|
}: {
|
||||||
|
children: ReactNode;
|
||||||
|
tone: "green" | "red";
|
||||||
|
}) {
|
||||||
|
const c = tone === "green" ? "edr-green" : "red";
|
||||||
|
return (
|
||||||
|
<span
|
||||||
|
className="inline-flex shrink-0 items-center gap-1 rounded-full px-[7px] py-[2px] text-[10px] font-medium leading-none"
|
||||||
|
style={{
|
||||||
|
background: `var(--mantine-color-${c}-0)`,
|
||||||
|
color: `var(--mantine-color-${c}-7)`,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function Spark({ values, color }: { values: number[]; color: string }) {
|
||||||
|
const max = Math.max(1, ...values);
|
||||||
|
return (
|
||||||
|
<div className="flex h-[26px] shrink-0 items-end gap-[3px]" aria-hidden>
|
||||||
|
{values.map((v, i) => (
|
||||||
|
<div
|
||||||
|
key={i}
|
||||||
|
className="w-1 rounded-sm"
|
||||||
|
style={{
|
||||||
|
height: Math.max(3, Math.round((v / max) * 26)),
|
||||||
|
background: `var(--mantine-color-${color}-7)`,
|
||||||
|
opacity: i === values.length - 1 ? 0.9 : 0.28,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* A single bordered card divided into up to five KPI cells:
|
* A single bordered card divided into up to five KPI cells:
|
||||||
* `[ kpi | kpi | kpi ]`. Hairline dividers separate cells (vertical on wide
|
* `[ kpi | kpi | kpi ]`. Each cell stacks a tinted icon + label over a large
|
||||||
* screens, horizontal when they wrap). Surface, border and shadow all come from
|
* display-font value, with an optional hint/delta pill and a sparkline on the
|
||||||
* the theme — no per-cell backgrounds, gradients or custom shadows.
|
* right. Hairline dividers separate cells (vertical on wide screens,
|
||||||
|
* horizontal when they wrap).
|
||||||
*/
|
*/
|
||||||
export function KpiStrip({ items, loading = false }: KpiStripProps) {
|
export function KpiStrip({ items, loading = false }: KpiStripProps) {
|
||||||
// The spec caps a strip at five cells; extra items are dropped rather than
|
// The spec caps a strip at five cells; extra items are dropped rather than
|
||||||
@@ -48,7 +92,7 @@ export function KpiStrip({ items, loading = false }: KpiStripProps) {
|
|||||||
const cells = items.slice(0, 5);
|
const cells = items.slice(0, 5);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Card withBorder shadow="sm" p={0} className="overflow-hidden">
|
<Card withBorder shadow="sm" radius="lg" p={0} className="overflow-hidden">
|
||||||
<div className="flex flex-col sm:flex-row">
|
<div className="flex flex-col sm:flex-row">
|
||||||
{cells.map((item, index) => {
|
{cells.map((item, index) => {
|
||||||
const Icon = item.icon;
|
const Icon = item.icon;
|
||||||
@@ -66,66 +110,63 @@ export function KpiStrip({ items, loading = false }: KpiStripProps) {
|
|||||||
className={cn(
|
className={cn(
|
||||||
// min-w-0 lets a crowded strip (five cells, long labels)
|
// min-w-0 lets a crowded strip (five cells, long labels)
|
||||||
// truncate its labels instead of overflowing the card.
|
// truncate its labels instead of overflowing the card.
|
||||||
"flex min-w-0 flex-1 items-center gap-3 px-5 py-4",
|
"flex min-w-0 flex-1 flex-col justify-center gap-2 px-[18px] py-4",
|
||||||
index > 0 &&
|
index > 0 &&
|
||||||
"border-t border-edr-border sm:border-l sm:border-t-0",
|
"border-t border-edr-border sm:border-l sm:border-t-0",
|
||||||
item.href &&
|
item.href &&
|
||||||
"cursor-pointer no-underline transition-colors hover:bg-gray-50 focus-visible:bg-gray-50",
|
"cursor-pointer no-underline transition-colors hover:bg-gray-50 focus-visible:bg-gray-50",
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
{Icon ? (
|
{Icon ? (
|
||||||
<div
|
<div
|
||||||
className="flex size-10 shrink-0 items-center justify-center rounded-lg"
|
className="flex size-7 shrink-0 items-center justify-center rounded-lg"
|
||||||
style={{
|
style={{
|
||||||
background: `var(--mantine-color-${color}-1)`,
|
background: `var(--mantine-color-${color}-0)`,
|
||||||
color: `var(--mantine-color-${color}-7)`,
|
color: `var(--mantine-color-${color}-7)`,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<Icon size={20} strokeWidth={2} />
|
<Icon size={14} strokeWidth={2} />
|
||||||
</div>
|
</div>
|
||||||
) : null}
|
) : null}
|
||||||
|
<Text fz={12} fw={500} c="edr-muted" truncate>
|
||||||
|
{item.label}
|
||||||
|
</Text>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div style={{ minWidth: 0 }}>
|
<div className="flex items-center justify-between gap-3">
|
||||||
|
<div className="flex min-w-0 items-center gap-2">
|
||||||
{loading ? (
|
{loading ? (
|
||||||
<Skeleton height={26} width={72} radius="sm" my={2} />
|
<Skeleton height={28} width={64} radius="sm" />
|
||||||
) : (
|
) : (
|
||||||
<div className="flex items-baseline gap-2">
|
|
||||||
<Text
|
<Text
|
||||||
fw={800}
|
ff="heading"
|
||||||
fz={24}
|
fw={600}
|
||||||
lh={1.05}
|
fz={27}
|
||||||
|
lh={1}
|
||||||
c="edr-text"
|
c="edr-text"
|
||||||
style={{ letterSpacing: "-0.02em" }}
|
style={{ letterSpacing: "-0.03em" }}
|
||||||
truncate
|
truncate
|
||||||
>
|
>
|
||||||
{item.value}
|
{item.value}
|
||||||
</Text>
|
</Text>
|
||||||
{item.delta != null && item.delta !== 0 ? (
|
)}
|
||||||
<Text
|
{!loading && item.hint ? (
|
||||||
component="span"
|
<Pill tone="green">
|
||||||
fz="xs"
|
<ArrowUpRight size={10} />
|
||||||
fw={700}
|
{item.hint}
|
||||||
c={item.delta > 0 ? "edr-green.7" : "red.7"}
|
</Pill>
|
||||||
style={{
|
) : null}
|
||||||
whiteSpace: "nowrap",
|
{!loading && item.delta != null && item.delta !== 0 ? (
|
||||||
background:
|
<Pill tone={item.delta > 0 ? "green" : "red"}>
|
||||||
item.delta > 0
|
|
||||||
? "var(--mantine-color-edr-green-0)"
|
|
||||||
: "var(--mantine-color-red-0)",
|
|
||||||
borderRadius: 999,
|
|
||||||
padding: "1px 7px",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{item.delta > 0 ? "▲" : "▼"}
|
{item.delta > 0 ? "▲" : "▼"}
|
||||||
{Math.abs(item.delta)}%
|
{Math.abs(item.delta)}%
|
||||||
</Text>
|
</Pill>
|
||||||
) : null}
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
)}
|
{item.spark?.length ? (
|
||||||
<Text size="xs" fw={600} c="edr-muted" truncate>
|
<Spark values={item.spark} color={color} />
|
||||||
{item.label}
|
) : null}
|
||||||
{item.hint ? ` · ${item.hint}` : ""}
|
|
||||||
</Text>
|
|
||||||
</div>
|
</div>
|
||||||
</Cell>
|
</Cell>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -52,13 +52,19 @@ export function PageHeader({
|
|||||||
|
|
||||||
<div style={{ minWidth: 0 }}>
|
<div style={{ minWidth: 0 }}>
|
||||||
<Group gap="sm" align="center" wrap="nowrap">
|
<Group gap="sm" align="center" wrap="nowrap">
|
||||||
<Title order={2} className="truncate">
|
<Title
|
||||||
|
order={2}
|
||||||
|
fz={24}
|
||||||
|
fw={600}
|
||||||
|
className="truncate"
|
||||||
|
style={{ letterSpacing: "-0.02em" }}
|
||||||
|
>
|
||||||
{title}
|
{title}
|
||||||
</Title>
|
</Title>
|
||||||
{meta}
|
{meta}
|
||||||
</Group>
|
</Group>
|
||||||
{subtitle ? (
|
{subtitle ? (
|
||||||
<Text c="dimmed" size="sm" mt={4}>
|
<Text c="edr-muted" fz={13} mt={4}>
|
||||||
{subtitle}
|
{subtitle}
|
||||||
</Text>
|
</Text>
|
||||||
) : null}
|
) : null}
|
||||||
|
|||||||
@@ -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,
|
Card,
|
||||||
Group,
|
Group,
|
||||||
Menu,
|
Menu,
|
||||||
|
Select,
|
||||||
Stack,
|
Stack,
|
||||||
Text,
|
Text,
|
||||||
TextInput,
|
TextInput,
|
||||||
ThemeIcon,
|
ThemeIcon,
|
||||||
Tooltip,
|
Tooltip,
|
||||||
|
UnstyledButton,
|
||||||
} from "@mantine/core";
|
} from "@mantine/core";
|
||||||
|
import { useInterval } from "@mantine/hooks";
|
||||||
|
import type { LucideIcon } from "lucide-react";
|
||||||
import {
|
import {
|
||||||
ArrowRight,
|
ArrowRight,
|
||||||
Calendar,
|
Building2,
|
||||||
|
CalendarClock,
|
||||||
ExternalLink,
|
ExternalLink,
|
||||||
Eye,
|
Eye,
|
||||||
FileText,
|
FileText,
|
||||||
@@ -27,26 +32,27 @@ import {
|
|||||||
RefreshCw,
|
RefreshCw,
|
||||||
Search,
|
Search,
|
||||||
ShieldCheck,
|
ShieldCheck,
|
||||||
User,
|
ShipWheel,
|
||||||
|
TriangleAlert,
|
||||||
|
Truck,
|
||||||
X,
|
X,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import {
|
import { DataTable, usePagination, type ColumnDef } from "@edr/ui-common";
|
||||||
DataTable,
|
|
||||||
usePagination,
|
|
||||||
type ColumnDef,
|
|
||||||
} from "@edr/ui-common";
|
|
||||||
import type { Freight } from "@edr/types";
|
import type { Freight } from "@edr/types";
|
||||||
import { useQuery } from "@tanstack/react-query";
|
import { useQuery } from "@tanstack/react-query";
|
||||||
|
|
||||||
import { PageContainer } from "@/components/page/PageContainer";
|
import { PageContainer } from "@/components/page/PageContainer";
|
||||||
import { PageHeader } from "@/components/page/PageHeader";
|
import { PageHeader } from "@/components/page/PageHeader";
|
||||||
import { KpiStrip } from "@/components/page/KpiStrip";
|
import { KpiStrip } from "@/components/page/KpiStrip";
|
||||||
|
import { TablePager } from "@/components/page/TablePager";
|
||||||
import { bookingTable } from "@/components/bookings/booking-ui.styles";
|
import { bookingTable } from "@/components/bookings/booking-ui.styles";
|
||||||
import { useAuth } from "@/auth/useAuth";
|
import { useAuth } from "@/auth/useAuth";
|
||||||
import { useBookingEtClearanceQueue } from "@/hooks/bookings/useBookings";
|
import { useBookingEtClearanceQueue } from "@/hooks/bookings/useBookings";
|
||||||
import type { BookingDetail } from "@/types/booking";
|
import type { BookingDetail } from "@/types/booking";
|
||||||
import { FREIGHT_PERMS, hasPermission, isDjiboutiGl } from "@/lib/permissions";
|
import { FREIGHT_PERMS, hasPermission, isDjiboutiGl } from "@/lib/permissions";
|
||||||
|
import { formatDate } from "@/lib/format";
|
||||||
import { GlUpcomingWindowsSection } from "@/components/contracts/GlUpcomingWindowsSection";
|
import { GlUpcomingWindowsSection } from "@/components/contracts/GlUpcomingWindowsSection";
|
||||||
|
import { CLEARANCE_TABS } from "@/features/clearance/clearance-tabs.config";
|
||||||
import {
|
import {
|
||||||
RequestedCargoChips,
|
RequestedCargoChips,
|
||||||
summarizeRequestedCargo,
|
summarizeRequestedCargo,
|
||||||
@@ -62,53 +68,134 @@ function yardLabel(
|
|||||||
return yard.label ?? yard.name ?? yard.code ?? "—";
|
return yard.label ?? yard.name ?? yard.code ?? "—";
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
const prettyStatus = (s: string) =>
|
||||||
* "Origin → Destination", wrapping past 120px as "Addis Ababa" /
|
s
|
||||||
* "→ Djibouti": the arrow is glued to the destination with an nbsp, and
|
.toLowerCase()
|
||||||
* text wraps normally (the table's cells are otherwise nowrap) so a long
|
.replace(/_/g, " ")
|
||||||
* lane never spills into the next column.
|
.replace(/^\w/, (c) => c.toUpperCase());
|
||||||
*/
|
|
||||||
function RouteLabel({
|
const shipmentStatusColor = (s: string) => {
|
||||||
origin,
|
if (s === "AWAITING_DOCUMENTS") return "yellow";
|
||||||
destination,
|
if (s === "DOCUMENTS_UNDER_REVIEW") return "blue";
|
||||||
}: {
|
if (s === "CLEARANCE_READY") return "edr-green";
|
||||||
origin: string;
|
if (
|
||||||
destination: string;
|
[
|
||||||
}) {
|
"SELECTED_FOR_BATCH",
|
||||||
|
"PNR_GENERATED",
|
||||||
|
"AWAITING_PAYMENT",
|
||||||
|
"PAYMENT_VERIFICATION_IN_PROGRESS",
|
||||||
|
].includes(s)
|
||||||
|
)
|
||||||
|
return "violet";
|
||||||
|
if (s === "EXPIRED") return "orange";
|
||||||
|
if (s === "CANCELLED" || s === "REJECTED") return "red";
|
||||||
|
return "gray";
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Rows created per day over the last `days` days, oldest → newest. */
|
||||||
|
function perDay(rows: { createdAt: string | null }[], days = 8): number[] {
|
||||||
|
const today = new Date().setHours(0, 0, 0, 0);
|
||||||
|
const out = new Array<number>(days).fill(0);
|
||||||
|
for (const r of rows) {
|
||||||
|
if (!r.createdAt) continue;
|
||||||
|
const age = Math.floor(
|
||||||
|
(today - new Date(r.createdAt).setHours(0, 0, 0, 0)) / 86_400_000,
|
||||||
|
);
|
||||||
|
if (age >= 0 && age < days) out[days - 1 - age] += 1;
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Tabs ─────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
type TabKey = "all" | "import" | "export" | "review";
|
||||||
|
|
||||||
|
const TABS: { key: TabKey; label: string; icon: LucideIcon }[] = [
|
||||||
|
...CLEARANCE_TABS,
|
||||||
|
{ key: "review", label: "Needs approval", icon: TriangleAlert },
|
||||||
|
];
|
||||||
|
|
||||||
|
// ── Small pieces ─────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
function LivePill({ updatedAt }: { updatedAt: number }) {
|
||||||
|
// Re-render every 30s so "Xm ago" keeps ticking between refetches.
|
||||||
|
const [, setTick] = useState(0);
|
||||||
|
useInterval(() => setTick((t) => t + 1), 30_000, { autoInvoke: true });
|
||||||
|
const mins = Math.max(0, Math.round((Date.now() - updatedAt) / 60_000));
|
||||||
|
const label = !updatedAt
|
||||||
|
? "Connecting…"
|
||||||
|
: mins < 1
|
||||||
|
? "Live · updated just now"
|
||||||
|
: `Live · updated ${mins}m ago`;
|
||||||
return (
|
return (
|
||||||
<Text
|
<span className="inline-flex shrink-0 items-center gap-1.5 whitespace-nowrap rounded-full bg-edr-soft px-2.5 py-1 text-[11px] font-medium text-edr-primary-dark">
|
||||||
size="sm"
|
<span className="size-1.5 rounded-full bg-edr-primary-dark" />
|
||||||
maw={120}
|
{label}
|
||||||
lh={1.35}
|
</span>
|
||||||
style={{ whiteSpace: "normal", overflowWrap: "anywhere" }}
|
|
||||||
>
|
|
||||||
{origin}{" "}
|
|
||||||
<ArrowRight
|
|
||||||
size={13}
|
|
||||||
className="text-muted-foreground"
|
|
||||||
style={{ display: "inline-block", verticalAlign: "-2px" }}
|
|
||||||
/>
|
|
||||||
{"\u00A0"}
|
|
||||||
{destination}
|
|
||||||
</Text>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function CustomsBadge({ customs }: { customs: boolean }) {
|
function DirectionPill({ direction }: { direction: string }) {
|
||||||
return customs ? (
|
const isImport = direction === "IMPORT";
|
||||||
<Badge
|
const Icon = isImport ? Truck : ShipWheel;
|
||||||
size="xs"
|
const color = isImport ? "blue" : "teal";
|
||||||
variant="light"
|
return (
|
||||||
color="edr-green"
|
<span
|
||||||
radius="sm"
|
className="inline-flex items-center gap-1 rounded-[5px] px-1.5 py-[2px] text-[10px] font-medium leading-none"
|
||||||
leftSection={<ShieldCheck size={11} />}
|
style={{
|
||||||
|
background: `var(--mantine-color-${color}-0)`,
|
||||||
|
color: `var(--mantine-color-${color}-7)`,
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
|
<Icon size={10} />
|
||||||
|
{prettyStatus(direction)}
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function OutlinePill({ children }: { children: React.ReactNode }) {
|
||||||
|
return (
|
||||||
|
<span className="inline-flex items-center rounded-[5px] border border-edr-border px-1.5 py-[2px] text-[10px] leading-none text-edr-muted">
|
||||||
|
{children}
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function RouteCell({
|
||||||
|
origin,
|
||||||
|
destination,
|
||||||
|
direction,
|
||||||
|
freightType,
|
||||||
|
customs,
|
||||||
|
}: {
|
||||||
|
origin: string;
|
||||||
|
destination: string;
|
||||||
|
direction: string;
|
||||||
|
freightType: string;
|
||||||
|
customs: boolean;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<Stack gap={5} py={2}>
|
||||||
|
<Group gap={6} wrap="nowrap">
|
||||||
|
<Text fz={12.5} fw={500} c="edr-text">
|
||||||
|
{origin}
|
||||||
|
</Text>
|
||||||
|
<ArrowRight size={12} className="shrink-0 text-edr-muted" />
|
||||||
|
<Text fz={12.5} fw={500} c="edr-text">
|
||||||
|
{destination}
|
||||||
|
</Text>
|
||||||
|
</Group>
|
||||||
|
<Group gap={6} wrap="nowrap">
|
||||||
|
<DirectionPill direction={direction} />
|
||||||
|
<OutlinePill>{prettyStatus(freightType)}</OutlinePill>
|
||||||
|
{customs ? (
|
||||||
|
<span className="inline-flex items-center gap-1 rounded-[5px] bg-edr-soft px-1.5 py-[2px] text-[10px] font-medium leading-none text-edr-primary-dark">
|
||||||
|
<ShieldCheck size={10} />
|
||||||
Customs
|
Customs
|
||||||
</Badge>
|
</span>
|
||||||
) : (
|
) : null}
|
||||||
<Badge size="xs" variant="light" color="gray" radius="sm">
|
</Group>
|
||||||
No customs
|
</Stack>
|
||||||
</Badge>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -129,13 +216,21 @@ export default function ContractClearanceListPage() {
|
|||||||
!isDjiboutiGl(user);
|
!isDjiboutiGl(user);
|
||||||
|
|
||||||
const [query, setQuery] = useState("");
|
const [query, setQuery] = useState("");
|
||||||
|
const [tab, setTab] = useState<TabKey>("all");
|
||||||
|
const [freight, setFreight] = useState<string | null>(null);
|
||||||
|
const [status, setStatus] = useState<string | null>(null);
|
||||||
const { pagination, setPagination } = usePagination({ pageSize: 10 });
|
const { pagination, setPagination } = usePagination({ pageSize: 10 });
|
||||||
|
const resetPage = useCallback(
|
||||||
|
() => setPagination({ pageIndex: 0, pageSize: pagination.pageSize }),
|
||||||
|
[setPagination, pagination.pageSize],
|
||||||
|
);
|
||||||
|
|
||||||
const {
|
const {
|
||||||
data: bookingQueue,
|
data: bookingQueue,
|
||||||
isLoading,
|
isLoading,
|
||||||
isError,
|
isError,
|
||||||
isFetching,
|
isFetching,
|
||||||
|
dataUpdatedAt,
|
||||||
refetch,
|
refetch,
|
||||||
} = useBookingEtClearanceQueue(true);
|
} = useBookingEtClearanceQueue(true);
|
||||||
|
|
||||||
@@ -149,7 +244,8 @@ export default function ContractClearanceListPage() {
|
|||||||
const requestedByBooking = useMemo(() => {
|
const requestedByBooking = useMemo(() => {
|
||||||
const map = new Map<string, Freight.RequestedShipmentLines>();
|
const map = new Map<string, Freight.RequestedShipmentLines>();
|
||||||
for (const req of requestQueue ?? []) {
|
for (const req of requestQueue ?? []) {
|
||||||
if (req.createdBookingId) map.set(req.createdBookingId, req.requestedLines);
|
if (req.createdBookingId)
|
||||||
|
map.set(req.createdBookingId, req.requestedLines);
|
||||||
}
|
}
|
||||||
return map;
|
return map;
|
||||||
}, [requestQueue]);
|
}, [requestQueue]);
|
||||||
@@ -170,7 +266,8 @@ export default function ContractClearanceListPage() {
|
|||||||
contractId: b.contractId ?? null,
|
contractId: b.contractId ?? null,
|
||||||
contractReference: b.contractReference ?? null,
|
contractReference: b.contractReference ?? null,
|
||||||
contractKind: b.contractKind ?? null,
|
contractKind: b.contractKind ?? null,
|
||||||
customs: b.serviceType?.includesCustoms ?? Boolean(b.customsClearingEnabled),
|
customs:
|
||||||
|
b.serviceType?.includesCustoms ?? Boolean(b.customsClearingEnabled),
|
||||||
createdAt: b.createdAt ?? null,
|
createdAt: b.createdAt ?? null,
|
||||||
// A bare initiated instance has no cargo/price yet — GL still has to
|
// A bare initiated instance has no cargo/price yet — GL still has to
|
||||||
// create (complete) the booking.
|
// create (complete) the booking.
|
||||||
@@ -178,23 +275,9 @@ export default function ContractClearanceListPage() {
|
|||||||
})) as ShipmentBookingRow[];
|
})) as ShipmentBookingRow[];
|
||||||
}, [bookingQueue, requestedByBooking]);
|
}, [bookingQueue, requestedByBooking]);
|
||||||
|
|
||||||
const rows = useMemo(() => {
|
// KPI groups span the whole queue, regardless of tab/filters.
|
||||||
const q = query.trim().toLowerCase();
|
const groups = useMemo(
|
||||||
if (!q) return allRows;
|
|
||||||
return allRows.filter(
|
|
||||||
(r) =>
|
|
||||||
r.reference.toLowerCase().includes(q) ||
|
|
||||||
r.customerLabel.toLowerCase().includes(q) ||
|
|
||||||
(r.contractReference ?? "").toLowerCase().includes(q) ||
|
|
||||||
r.originLabel.toLowerCase().includes(q) ||
|
|
||||||
r.destinationLabel.toLowerCase().includes(q) ||
|
|
||||||
summarizeRequestedCargo(r.requested).toLowerCase().includes(q),
|
|
||||||
);
|
|
||||||
}, [allRows, query]);
|
|
||||||
|
|
||||||
const counts = useMemo(
|
|
||||||
() => ({
|
() => ({
|
||||||
all: allRows.length,
|
|
||||||
// Counts anything actually waiting on GL, including a document added
|
// Counts anything actually waiting on GL, including a document added
|
||||||
// after clearance was finalized (the status stays CLEARANCE_READY).
|
// after clearance was finalized (the status stays CLEARANCE_READY).
|
||||||
review: allRows.filter(
|
review: allRows.filter(
|
||||||
@@ -202,13 +285,72 @@ export default function ContractClearanceListPage() {
|
|||||||
r.status === "AWAITING_DOCUMENTS" ||
|
r.status === "AWAITING_DOCUMENTS" ||
|
||||||
r.status === "DOCUMENTS_UNDER_REVIEW" ||
|
r.status === "DOCUMENTS_UNDER_REVIEW" ||
|
||||||
r.hasDocumentsAwaitingReview,
|
r.hasDocumentsAwaitingReview,
|
||||||
).length,
|
),
|
||||||
ready: allRows.filter((r) => r.status === "CLEARANCE_READY" || r.bookingCreated)
|
approval: allRows.filter((r) => r.hasDocumentsAwaitingReview),
|
||||||
.length,
|
ready: allRows.filter(
|
||||||
|
(r) => r.status === "CLEARANCE_READY" || r.bookingCreated,
|
||||||
|
),
|
||||||
}),
|
}),
|
||||||
[allRows],
|
[allRows],
|
||||||
);
|
);
|
||||||
|
const newToday = perDay(allRows, 1)[0];
|
||||||
|
|
||||||
|
const tabCounts = useMemo<Record<TabKey, number>>(
|
||||||
|
() => ({
|
||||||
|
all: allRows.length,
|
||||||
|
import: allRows.filter((r) => r.tradeDirection === "IMPORT").length,
|
||||||
|
export: allRows.filter((r) => r.tradeDirection === "EXPORT").length,
|
||||||
|
review: groups.approval.length,
|
||||||
|
}),
|
||||||
|
[allRows, groups.approval.length],
|
||||||
|
);
|
||||||
|
|
||||||
|
const statusOptions = useMemo(
|
||||||
|
() =>
|
||||||
|
[...new Set(allRows.map((r) => r.status))].sort().map((s) => ({
|
||||||
|
value: s,
|
||||||
|
label: prettyStatus(s),
|
||||||
|
})),
|
||||||
|
[allRows],
|
||||||
|
);
|
||||||
|
|
||||||
|
const rows = useMemo(() => {
|
||||||
|
const q = query.trim().toLowerCase();
|
||||||
|
return allRows.filter((r) => {
|
||||||
|
if (tab === "review" && !r.hasDocumentsAwaitingReview) return false;
|
||||||
|
if (
|
||||||
|
(tab === "import" || tab === "export") &&
|
||||||
|
r.tradeDirection !== tab.toUpperCase()
|
||||||
|
)
|
||||||
|
return false;
|
||||||
|
if (freight && r.freightType !== freight) return false;
|
||||||
|
if (status && r.status !== status) return false;
|
||||||
|
if (!q) return true;
|
||||||
|
return [
|
||||||
|
r.reference,
|
||||||
|
r.customerLabel,
|
||||||
|
r.contractReference ?? "",
|
||||||
|
r.originLabel,
|
||||||
|
r.destinationLabel,
|
||||||
|
summarizeRequestedCargo(r.requested),
|
||||||
|
].some((v) => v.toLowerCase().includes(q));
|
||||||
|
});
|
||||||
|
}, [allRows, tab, freight, status, query]);
|
||||||
|
|
||||||
|
const total = rows.length;
|
||||||
|
const pageCount = Math.max(1, Math.ceil(total / pagination.pageSize));
|
||||||
|
const pagedRows = useMemo(() => {
|
||||||
|
const start = pagination.pageIndex * pagination.pageSize;
|
||||||
|
return rows.slice(start, start + pagination.pageSize);
|
||||||
|
}, [rows, pagination.pageIndex, pagination.pageSize]);
|
||||||
|
|
||||||
|
const hasFilters = Boolean(query || freight || status);
|
||||||
|
const clearFilters = useCallback(() => {
|
||||||
|
setQuery("");
|
||||||
|
setFreight(null);
|
||||||
|
setStatus(null);
|
||||||
|
resetPage();
|
||||||
|
}, [resetPage]);
|
||||||
|
|
||||||
const openBooking = useCallback(
|
const openBooking = useCallback(
|
||||||
// `from` so the detail page's Back returns to this hub.
|
// `from` so the detail page's Back returns to this hub.
|
||||||
@@ -223,31 +365,20 @@ export default function ContractClearanceListPage() {
|
|||||||
<PageContainer>
|
<PageContainer>
|
||||||
<Stack gap="lg">
|
<Stack gap="lg">
|
||||||
<PageHeader
|
<PageHeader
|
||||||
title="Document Clearance"
|
title="Clearance queue"
|
||||||
subtitle="Every customs shipment in phased clearance — the documents live on the shipment, not on the contract."
|
subtitle="Every customs shipment in phased clearance — the documents live on the shipment, not on the contract."
|
||||||
meta={
|
meta={<LivePill updatedAt={dataUpdatedAt} />}
|
||||||
<Badge
|
|
||||||
variant="light"
|
|
||||||
color="edr-green"
|
|
||||||
radius="sm"
|
|
||||||
leftSection={<ShieldCheck size={13} />}
|
|
||||||
>
|
|
||||||
{counts.all} in clearance
|
|
||||||
</Badge>
|
|
||||||
}
|
|
||||||
action={
|
action={
|
||||||
<Group gap="sm" wrap="nowrap">
|
<Button
|
||||||
<ActionIcon
|
|
||||||
variant="default"
|
variant="default"
|
||||||
size="lg"
|
|
||||||
radius="md"
|
radius="md"
|
||||||
onClick={() => void refetch()}
|
size="sm"
|
||||||
|
leftSection={<RefreshCw size={14} />}
|
||||||
loading={isFetching}
|
loading={isFetching}
|
||||||
aria-label="Refresh"
|
onClick={() => void refetch()}
|
||||||
>
|
>
|
||||||
<RefreshCw size={16} />
|
Refresh
|
||||||
</ActionIcon>
|
</Button>
|
||||||
</Group>
|
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
@@ -256,38 +387,138 @@ export default function ContractClearanceListPage() {
|
|||||||
items={[
|
items={[
|
||||||
{
|
{
|
||||||
label: "In clearance",
|
label: "In clearance",
|
||||||
value: counts.all,
|
value: allRows.length,
|
||||||
icon: Inbox,
|
icon: Inbox,
|
||||||
color: "edr-green",
|
color: "blue",
|
||||||
|
hint: newToday ? `+${newToday} today` : undefined,
|
||||||
|
spark: perDay(allRows),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
label: "Awaiting review",
|
label: "Awaiting review",
|
||||||
value: counts.review,
|
value: groups.review.length,
|
||||||
icon: ShieldCheck,
|
icon: ShieldCheck,
|
||||||
color: "yellow",
|
color: "yellow",
|
||||||
|
spark: perDay(groups.review),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: "Needs approval",
|
||||||
|
value: groups.approval.length,
|
||||||
|
icon: TriangleAlert,
|
||||||
|
color: "red",
|
||||||
|
spark: perDay(groups.approval),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
label: "Ready / booked",
|
label: "Ready / booked",
|
||||||
value: counts.ready,
|
value: groups.ready.length,
|
||||||
icon: PackageCheck,
|
icon: PackageCheck,
|
||||||
color: "edr-green",
|
color: "edr-green",
|
||||||
|
spark: perDay(groups.ready),
|
||||||
},
|
},
|
||||||
]}
|
]}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<GlUpcomingWindowsSection />
|
<GlUpcomingWindowsSection />
|
||||||
|
|
||||||
<Card p={0} withBorder shadow="sm" radius="lg">
|
<Card
|
||||||
|
p={0}
|
||||||
|
withBorder
|
||||||
|
shadow="sm"
|
||||||
|
radius="lg"
|
||||||
|
style={{ overflow: "hidden" }}
|
||||||
|
>
|
||||||
<Stack gap={0}>
|
<Stack gap={0}>
|
||||||
<Box px="md" pt="md" pb="sm">
|
{/* ── Tabs ─────────────────────────────────────────────── */}
|
||||||
<Group justify="space-between" gap="md" wrap="wrap">
|
<Group
|
||||||
|
justify="space-between"
|
||||||
|
align="stretch"
|
||||||
|
px="md"
|
||||||
|
h={46}
|
||||||
|
wrap="nowrap"
|
||||||
|
style={{
|
||||||
|
borderBottom: "1px solid var(--mantine-color-edr-border-6)",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Group gap={2} wrap="nowrap" align="stretch">
|
||||||
|
{TABS.map((t) => {
|
||||||
|
const active = tab === t.key;
|
||||||
|
const Icon = t.icon;
|
||||||
|
return (
|
||||||
|
<UnstyledButton
|
||||||
|
key={t.key}
|
||||||
|
onClick={() => {
|
||||||
|
setTab(t.key);
|
||||||
|
resetPage();
|
||||||
|
}}
|
||||||
|
px={13}
|
||||||
|
className="flex items-center gap-2 transition-colors"
|
||||||
|
style={{
|
||||||
|
borderBottom: `2px solid ${
|
||||||
|
active
|
||||||
|
? "var(--mantine-color-edr-green-6)"
|
||||||
|
: "transparent"
|
||||||
|
}`,
|
||||||
|
marginBottom: -1,
|
||||||
|
}}
|
||||||
|
aria-pressed={active}
|
||||||
|
>
|
||||||
|
<Icon
|
||||||
|
size={14}
|
||||||
|
style={{
|
||||||
|
color: active
|
||||||
|
? "var(--mantine-color-edr-green-6)"
|
||||||
|
: "var(--mantine-color-gray-5)",
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<Text
|
||||||
|
fz={13}
|
||||||
|
fw={active ? 600 : 500}
|
||||||
|
c={active ? "edr-text" : "edr-muted"}
|
||||||
|
>
|
||||||
|
{t.label}
|
||||||
|
</Text>
|
||||||
|
<span
|
||||||
|
className="rounded-full px-1.5 py-px text-[10.5px] font-semibold leading-[1.4]"
|
||||||
|
style={{
|
||||||
|
background: active
|
||||||
|
? "var(--mantine-color-edr-green-0)"
|
||||||
|
: "var(--mantine-color-gray-1)",
|
||||||
|
color: active
|
||||||
|
? "var(--mantine-color-edr-green-7)"
|
||||||
|
: "var(--mantine-color-edr-muted-6)",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{tabCounts[t.key]}
|
||||||
|
</span>
|
||||||
|
</UnstyledButton>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</Group>
|
||||||
|
<Text
|
||||||
|
fz={12}
|
||||||
|
c="edr-muted"
|
||||||
|
className="self-center whitespace-nowrap"
|
||||||
|
>
|
||||||
|
{total} record{total !== 1 ? "s" : ""}
|
||||||
|
</Text>
|
||||||
|
</Group>
|
||||||
|
|
||||||
|
{/* ── Filter bar ───────────────────────────────────────── */}
|
||||||
|
<Group
|
||||||
|
gap={9}
|
||||||
|
px="md"
|
||||||
|
py={12}
|
||||||
|
wrap="wrap"
|
||||||
|
style={{
|
||||||
|
borderBottom: "1px solid var(--mantine-color-edr-divider-6)",
|
||||||
|
}}
|
||||||
|
>
|
||||||
<TextInput
|
<TextInput
|
||||||
placeholder="Search shipment, contract, customer or route…"
|
placeholder="Search reference, customer, contract, or route…"
|
||||||
leftSection={<Search size={18} />}
|
leftSection={<Search size={15} />}
|
||||||
value={query}
|
value={query}
|
||||||
onChange={(e) => {
|
onChange={(e) => {
|
||||||
setQuery(e.currentTarget.value);
|
setQuery(e.currentTarget.value);
|
||||||
setPagination({ pageIndex: 0, pageSize: pagination.pageSize });
|
resetPage();
|
||||||
}}
|
}}
|
||||||
rightSection={
|
rightSection={
|
||||||
query ? (
|
query ? (
|
||||||
@@ -296,25 +527,80 @@ export default function ContractClearanceListPage() {
|
|||||||
color="gray"
|
color="gray"
|
||||||
radius="md"
|
radius="md"
|
||||||
variant="transparent"
|
variant="transparent"
|
||||||
onClick={() => setQuery("")}
|
onClick={() => {
|
||||||
|
setQuery("");
|
||||||
|
resetPage();
|
||||||
|
}}
|
||||||
|
aria-label="Clear search"
|
||||||
>
|
>
|
||||||
<X size={16} />
|
<X size={14} />
|
||||||
</ActionIcon>
|
</ActionIcon>
|
||||||
) : null
|
) : null
|
||||||
}
|
}
|
||||||
radius="lg"
|
radius="md"
|
||||||
|
size="sm"
|
||||||
|
styles={{
|
||||||
|
input: { background: "var(--mantine-color-gray-0)" },
|
||||||
|
}}
|
||||||
style={{ flex: 1, minWidth: 220 }}
|
style={{ flex: 1, minWidth: 220 }}
|
||||||
/>
|
/>
|
||||||
<Text size="sm" c="dimmed">
|
<Select
|
||||||
{rows.length} record{rows.length !== 1 ? "s" : ""}
|
placeholder="Freight"
|
||||||
</Text>
|
data={[
|
||||||
|
{ value: "CONTAINER", label: "Container" },
|
||||||
|
{ value: "BULK", label: "Bulk" },
|
||||||
|
]}
|
||||||
|
value={freight}
|
||||||
|
onChange={(v) => {
|
||||||
|
setFreight(v);
|
||||||
|
resetPage();
|
||||||
|
}}
|
||||||
|
clearable
|
||||||
|
radius="md"
|
||||||
|
size="sm"
|
||||||
|
w={124}
|
||||||
|
comboboxProps={{ withinPortal: true }}
|
||||||
|
aria-label="Filter by freight type"
|
||||||
|
/>
|
||||||
|
<Select
|
||||||
|
placeholder="Status"
|
||||||
|
data={statusOptions}
|
||||||
|
value={status}
|
||||||
|
onChange={(v) => {
|
||||||
|
setStatus(v);
|
||||||
|
resetPage();
|
||||||
|
}}
|
||||||
|
clearable
|
||||||
|
radius="md"
|
||||||
|
size="sm"
|
||||||
|
w={180}
|
||||||
|
comboboxProps={{ withinPortal: true }}
|
||||||
|
aria-label="Filter by status"
|
||||||
|
/>
|
||||||
|
{hasFilters ? (
|
||||||
|
<Button
|
||||||
|
variant="subtle"
|
||||||
|
color="gray"
|
||||||
|
size="compact-sm"
|
||||||
|
radius="md"
|
||||||
|
leftSection={<X size={14} />}
|
||||||
|
onClick={clearFilters}
|
||||||
|
>
|
||||||
|
Clear
|
||||||
|
</Button>
|
||||||
|
) : null}
|
||||||
</Group>
|
</Group>
|
||||||
</Box>
|
|
||||||
|
|
||||||
<ShipmentBookingsTable
|
<ShipmentBookingsTable
|
||||||
rows={rows}
|
rows={pagedRows}
|
||||||
|
total={total}
|
||||||
|
pageCount={pageCount}
|
||||||
|
pagination={pagination}
|
||||||
|
setPagination={setPagination}
|
||||||
loading={isLoading}
|
loading={isLoading}
|
||||||
error={isError}
|
error={isError}
|
||||||
|
hasFilters={hasFilters}
|
||||||
|
onClearFilters={clearFilters}
|
||||||
canCreateBooking={canCreateBooking}
|
canCreateBooking={canCreateBooking}
|
||||||
onOpen={openBooking}
|
onOpen={openBooking}
|
||||||
onCreateBooking={(row) =>
|
onCreateBooking={(row) =>
|
||||||
@@ -337,7 +623,6 @@ export default function ContractClearanceListPage() {
|
|||||||
</Stack>
|
</Stack>
|
||||||
</Card>
|
</Card>
|
||||||
</Stack>
|
</Stack>
|
||||||
|
|
||||||
</PageContainer>
|
</PageContainer>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -367,43 +652,19 @@ interface ShipmentBookingRow {
|
|||||||
bookingCreated: boolean;
|
bookingCreated: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
const formatDate = (iso: string | null) => {
|
type PaginationState = ReturnType<typeof usePagination>["pagination"];
|
||||||
if (!iso) return "—";
|
|
||||||
const d = new Date(iso);
|
|
||||||
return Number.isNaN(d.getTime())
|
|
||||||
? "—"
|
|
||||||
: d.toLocaleDateString(undefined, { day: "2-digit", month: "short", year: "numeric" });
|
|
||||||
};
|
|
||||||
|
|
||||||
const prettyStatus = (s: string) =>
|
|
||||||
s
|
|
||||||
.toLowerCase()
|
|
||||||
.replace(/_/g, " ")
|
|
||||||
.replace(/^\w/, (c) => c.toUpperCase());
|
|
||||||
|
|
||||||
const shipmentStatusColor = (s: string) => {
|
|
||||||
if (s === "AWAITING_DOCUMENTS") return "yellow";
|
|
||||||
if (s === "DOCUMENTS_UNDER_REVIEW") return "blue";
|
|
||||||
if (s === "CLEARANCE_READY") return "edr-green";
|
|
||||||
if (
|
|
||||||
[
|
|
||||||
"SELECTED_FOR_BATCH",
|
|
||||||
"PNR_GENERATED",
|
|
||||||
"AWAITING_PAYMENT",
|
|
||||||
"PAYMENT_VERIFICATION_IN_PROGRESS",
|
|
||||||
].includes(s)
|
|
||||||
)
|
|
||||||
return "violet";
|
|
||||||
if (s === "EXPIRED") return "orange";
|
|
||||||
if (s === "CANCELLED" || s === "REJECTED") return "red";
|
|
||||||
return "gray";
|
|
||||||
};
|
|
||||||
|
|
||||||
/** GENERAL-contract shipment bookings currently in per-booking clearance. */
|
/** GENERAL-contract shipment bookings currently in per-booking clearance. */
|
||||||
function ShipmentBookingsTable({
|
function ShipmentBookingsTable({
|
||||||
rows,
|
rows,
|
||||||
|
total,
|
||||||
|
pageCount,
|
||||||
|
pagination,
|
||||||
|
setPagination,
|
||||||
loading,
|
loading,
|
||||||
error,
|
error,
|
||||||
|
hasFilters,
|
||||||
|
onClearFilters,
|
||||||
canCreateBooking,
|
canCreateBooking,
|
||||||
onOpen,
|
onOpen,
|
||||||
onCreateBooking,
|
onCreateBooking,
|
||||||
@@ -411,8 +672,14 @@ function ShipmentBookingsTable({
|
|||||||
onViewContract,
|
onViewContract,
|
||||||
}: {
|
}: {
|
||||||
rows: ShipmentBookingRow[];
|
rows: ShipmentBookingRow[];
|
||||||
|
total: number;
|
||||||
|
pageCount: number;
|
||||||
|
pagination: PaginationState;
|
||||||
|
setPagination: ReturnType<typeof usePagination>["setPagination"];
|
||||||
loading: boolean;
|
loading: boolean;
|
||||||
error: boolean;
|
error: boolean;
|
||||||
|
hasFilters: boolean;
|
||||||
|
onClearFilters: () => void;
|
||||||
canCreateBooking: boolean;
|
canCreateBooking: boolean;
|
||||||
onOpen: (id: string) => void;
|
onOpen: (id: string) => void;
|
||||||
onCreateBooking: (row: ShipmentBookingRow) => void;
|
onCreateBooking: (row: ShipmentBookingRow) => void;
|
||||||
@@ -440,18 +707,23 @@ function ShipmentBookingsTable({
|
|||||||
id: "booking",
|
id: "booking",
|
||||||
header: () => <span className={bookingTable.headerCell}>Booking</span>,
|
header: () => <span className={bookingTable.headerCell}>Booking</span>,
|
||||||
cell: ({ row }) => (
|
cell: ({ row }) => (
|
||||||
<div className="flex items-center gap-3 py-1.5">
|
<div className="flex items-center gap-2.5 py-1">
|
||||||
<div className={bookingTable.rowIcon}>
|
<div className="flex size-[30px] shrink-0 items-center justify-center rounded-[9px] bg-edr-divider text-edr-muted">
|
||||||
<PackageCheck className="size-4" strokeWidth={1.75} />
|
<PackageCheck size={15} strokeWidth={1.75} />
|
||||||
</div>
|
</div>
|
||||||
<div className="min-w-0">
|
<div className="min-w-0">
|
||||||
<p className="font-medium text-foreground">
|
<Text fz={13} fw={600} c="edr-text">
|
||||||
{row.original.reference}
|
{row.original.reference}
|
||||||
</p>
|
</Text>
|
||||||
<p className="mt-0.5 flex items-center gap-1 text-xs text-muted-foreground">
|
<Group gap={4} wrap="nowrap" align="flex-start">
|
||||||
<User className="size-3 shrink-0 opacity-70" />
|
<Building2
|
||||||
|
size={10}
|
||||||
|
className="mt-[3px] shrink-0 text-edr-muted opacity-70"
|
||||||
|
/>
|
||||||
|
<Text fz={11} c="edr-muted" className="cell-wrap">
|
||||||
{row.original.customerLabel}
|
{row.original.customerLabel}
|
||||||
</p>
|
</Text>
|
||||||
|
</Group>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
),
|
),
|
||||||
@@ -462,17 +734,19 @@ function ShipmentBookingsTable({
|
|||||||
cell: ({ row }) => {
|
cell: ({ row }) => {
|
||||||
const r = row.original;
|
const r = row.original;
|
||||||
return (
|
return (
|
||||||
<Stack gap={4} py={2}>
|
<Stack gap={3} py={2}>
|
||||||
<Group gap={6} wrap="nowrap">
|
<Group gap={5} wrap="nowrap">
|
||||||
<FileText size={13} className="shrink-0 text-muted-foreground" />
|
<FileText size={12} className="shrink-0 text-edr-muted" />
|
||||||
<Text size="sm" fw={500}>
|
<Text fz={12.5} c="edr-text">
|
||||||
{r.contractReference ?? "—"}
|
{r.contractReference ?? "—"}
|
||||||
</Text>
|
</Text>
|
||||||
</Group>
|
</Group>
|
||||||
{r.contractKind ? (
|
{r.contractKind ? (
|
||||||
<Badge size="xs" variant="default" radius="sm" tt="uppercase">
|
<Text fz={10.5} c="edr-muted">
|
||||||
{r.contractKind === "GENERAL" ? "General" : "One-time"}
|
{r.contractKind === "GENERAL"
|
||||||
</Badge>
|
? "General contract"
|
||||||
|
: "One-time"}
|
||||||
|
</Text>
|
||||||
) : null}
|
) : null}
|
||||||
</Stack>
|
</Stack>
|
||||||
);
|
);
|
||||||
@@ -481,44 +755,33 @@ function ShipmentBookingsTable({
|
|||||||
{
|
{
|
||||||
id: "route",
|
id: "route",
|
||||||
header: () => <span className={bookingTable.headerCell}>Route</span>,
|
header: () => <span className={bookingTable.headerCell}>Route</span>,
|
||||||
cell: ({ row }) => (
|
cell: ({ row }) => {
|
||||||
<RouteLabel
|
const r = row.original;
|
||||||
origin={row.original.originLabel}
|
return (
|
||||||
destination={row.original.destinationLabel}
|
<RouteCell
|
||||||
|
origin={r.originLabel}
|
||||||
|
destination={r.destinationLabel}
|
||||||
|
direction={r.tradeDirection}
|
||||||
|
freightType={r.freightType}
|
||||||
|
customs={r.customs}
|
||||||
/>
|
/>
|
||||||
),
|
);
|
||||||
},
|
},
|
||||||
{
|
|
||||||
id: "kind",
|
|
||||||
header: () => <span className={bookingTable.headerCell}>Type</span>,
|
|
||||||
cell: ({ row }) => (
|
|
||||||
<Group gap={6} wrap="nowrap">
|
|
||||||
<Badge variant="light" color="gray" radius="sm">
|
|
||||||
{prettyStatus(row.original.tradeDirection)}
|
|
||||||
</Badge>
|
|
||||||
<Badge variant="outline" color="gray" radius="sm">
|
|
||||||
{prettyStatus(row.original.freightType)}
|
|
||||||
</Badge>
|
|
||||||
<CustomsBadge customs={row.original.customs} />
|
|
||||||
</Group>
|
|
||||||
),
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: "requested",
|
id: "requested",
|
||||||
header: () => (
|
header: () => <span className={bookingTable.headerCell}>Cargo</span>,
|
||||||
<span className={bookingTable.headerCell}>Requested cargo</span>
|
|
||||||
),
|
|
||||||
cell: ({ row }) => (
|
cell: ({ row }) => (
|
||||||
<RequestedCargoChips lines={row.original.requested} size="sm" />
|
<RequestedCargoChips lines={row.original.requested} size="xs" />
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: "created",
|
id: "created",
|
||||||
header: () => <span className={bookingTable.headerCell}>Created</span>,
|
header: () => <span className={bookingTable.headerCell}>Created</span>,
|
||||||
cell: ({ row }) => (
|
cell: ({ row }) => (
|
||||||
<Group gap={6} wrap="nowrap">
|
<Group gap={5} wrap="nowrap">
|
||||||
<Calendar size={13} className="shrink-0 text-muted-foreground" />
|
<CalendarClock size={12} className="shrink-0 text-edr-muted" />
|
||||||
<Text size="sm" c="dimmed">
|
<Text fz={11.5} c="edr-muted">
|
||||||
{formatDate(row.original.createdAt)}
|
{formatDate(row.original.createdAt)}
|
||||||
</Text>
|
</Text>
|
||||||
</Group>
|
</Group>
|
||||||
@@ -533,14 +796,14 @@ function ShipmentBookingsTable({
|
|||||||
a file added after clearance was finalized leaves the status at
|
a file added after clearance was finalized leaves the status at
|
||||||
CLEARANCE_READY, and the row must still call for the review. */}
|
CLEARANCE_READY, and the row must still call for the review. */}
|
||||||
{row.original.hasDocumentsAwaitingReview ? (
|
{row.original.hasDocumentsAwaitingReview ? (
|
||||||
<Badge variant="filled" color="orange" radius="sm">
|
<Badge variant="filled" color="orange" radius="sm" size="sm">
|
||||||
Needs approval
|
Needs approval
|
||||||
</Badge>
|
</Badge>
|
||||||
) : /* All docs approved but not yet finalized: the booking status is
|
) : /* All docs approved but not yet finalized: the booking status is
|
||||||
still DOCUMENTS_UNDER_REVIEW — show the real review state. */
|
still DOCUMENTS_UNDER_REVIEW — show the real review state. */
|
||||||
row.original.status === "DOCUMENTS_UNDER_REVIEW" &&
|
row.original.status === "DOCUMENTS_UNDER_REVIEW" &&
|
||||||
row.original.allDocsApproved ? (
|
row.original.allDocsApproved ? (
|
||||||
<Badge variant="light" color="edr-green" radius="sm">
|
<Badge variant="light" color="edr-green" radius="sm" size="sm">
|
||||||
Documents approved
|
Documents approved
|
||||||
</Badge>
|
</Badge>
|
||||||
) : (
|
) : (
|
||||||
@@ -548,6 +811,7 @@ function ShipmentBookingsTable({
|
|||||||
variant="light"
|
variant="light"
|
||||||
color={shipmentStatusColor(row.original.status)}
|
color={shipmentStatusColor(row.original.status)}
|
||||||
radius="sm"
|
radius="sm"
|
||||||
|
size="sm"
|
||||||
>
|
>
|
||||||
{prettyStatus(row.original.status)}
|
{prettyStatus(row.original.status)}
|
||||||
</Badge>
|
</Badge>
|
||||||
@@ -558,6 +822,7 @@ function ShipmentBookingsTable({
|
|||||||
variant="light"
|
variant="light"
|
||||||
color="blue"
|
color="blue"
|
||||||
radius="sm"
|
radius="sm"
|
||||||
|
size="sm"
|
||||||
leftSection={<PackagePlus size={11} />}
|
leftSection={<PackagePlus size={11} />}
|
||||||
>
|
>
|
||||||
Booked
|
Booked
|
||||||
@@ -617,7 +882,10 @@ function ShipmentBookingsTable({
|
|||||||
</ActionIcon>
|
</ActionIcon>
|
||||||
</Menu.Target>
|
</Menu.Target>
|
||||||
<Menu.Dropdown>
|
<Menu.Dropdown>
|
||||||
<Menu.Item leftSection={<Eye size={14} />} onClick={() => onOpen(r.id)}>
|
<Menu.Item
|
||||||
|
leftSection={<Eye size={14} />}
|
||||||
|
onClick={() => onOpen(r.id)}
|
||||||
|
>
|
||||||
Open booking
|
Open booking
|
||||||
</Menu.Item>
|
</Menu.Item>
|
||||||
{bookable ? (
|
{bookable ? (
|
||||||
@@ -655,25 +923,53 @@ function ShipmentBookingsTable({
|
|||||||
[canCreateBooking, onOpen, onCreateBooking, onViewContract],
|
[canCreateBooking, onOpen, onCreateBooking, onViewContract],
|
||||||
);
|
);
|
||||||
|
|
||||||
if (!loading && !error && rows.length === 0) {
|
if (!loading && !error && total === 0) {
|
||||||
return (
|
return (
|
||||||
<Stack align="center" gap={8} py={48}>
|
<Stack align="center" gap={8} py={48}>
|
||||||
<ThemeIcon variant="light" color="gray" radius="xl" size={48}>
|
<ThemeIcon variant="light" color="gray" radius="xl" size={48}>
|
||||||
<Inbox size={22} />
|
<Inbox size={22} />
|
||||||
</ThemeIcon>
|
</ThemeIcon>
|
||||||
<Text c="dimmed">No shipment bookings in clearance.</Text>
|
<Text c="dimmed">
|
||||||
|
{hasFilters
|
||||||
|
? "No shipments match these filters."
|
||||||
|
: "No shipment bookings in clearance."}
|
||||||
|
</Text>
|
||||||
|
{hasFilters ? (
|
||||||
|
<Button
|
||||||
|
variant="light"
|
||||||
|
color="gray"
|
||||||
|
size="compact-sm"
|
||||||
|
radius="md"
|
||||||
|
onClick={onClearFilters}
|
||||||
|
>
|
||||||
|
Clear filters
|
||||||
|
</Button>
|
||||||
|
) : null}
|
||||||
</Stack>
|
</Stack>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Box w="100%" miw={0} style={{ overflowX: "auto" }}>
|
<Box w="100%" miw={0}>
|
||||||
<DataTable<ShipmentBookingRow, unknown>
|
<DataTable<ShipmentBookingRow, unknown>
|
||||||
columns={columns}
|
columns={columns}
|
||||||
data={rows}
|
data={rows}
|
||||||
status={loading ? "loading" : error ? "error" : "success"}
|
status={loading ? "loading" : error ? "error" : "success"}
|
||||||
onRowClick={(row) => onOpen(row.id)}
|
onRowClick={(row) => onOpen(row.id)}
|
||||||
|
pagination={{
|
||||||
|
pageIndex: pagination.pageIndex,
|
||||||
|
pageSize: pagination.pageSize,
|
||||||
|
pageCount,
|
||||||
|
totalCount: total,
|
||||||
|
}}
|
||||||
|
tableOptions={{
|
||||||
|
state: { pagination },
|
||||||
|
onPaginationChange: setPagination,
|
||||||
|
manualPagination: true,
|
||||||
|
pageCount,
|
||||||
|
}}
|
||||||
containerClassName="edr-clearance-table border-0 shadow-none rounded-none bg-transparent"
|
containerClassName="edr-clearance-table border-0 shadow-none rounded-none bg-transparent"
|
||||||
|
footer={(p) => <TablePager {...p} noun="shipments" />}
|
||||||
/>
|
/>
|
||||||
</Box>
|
</Box>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -15,33 +15,33 @@ import {
|
|||||||
TextInput,
|
TextInput,
|
||||||
ThemeIcon,
|
ThemeIcon,
|
||||||
Tooltip,
|
Tooltip,
|
||||||
|
UnstyledButton,
|
||||||
} from "@mantine/core";
|
} from "@mantine/core";
|
||||||
|
import { useInterval } from "@mantine/hooks";
|
||||||
|
import type { LucideIcon } from "lucide-react";
|
||||||
import {
|
import {
|
||||||
AlertTriangle,
|
AlertTriangle,
|
||||||
ArrowRight,
|
ArrowRight,
|
||||||
|
Building2,
|
||||||
CalendarClock,
|
CalendarClock,
|
||||||
ChevronRight,
|
ChevronRight,
|
||||||
FileText,
|
FileText,
|
||||||
Inbox,
|
Inbox,
|
||||||
|
Layers,
|
||||||
PackageCheck,
|
PackageCheck,
|
||||||
RefreshCw,
|
RefreshCw,
|
||||||
Search,
|
Search,
|
||||||
ShipWheel,
|
ShipWheel,
|
||||||
Truck,
|
Truck,
|
||||||
User,
|
|
||||||
Weight,
|
Weight,
|
||||||
X,
|
X,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import {
|
import { DataTable, usePagination, type ColumnDef } from "@edr/ui-common";
|
||||||
DataTable,
|
|
||||||
DataTableFooter,
|
|
||||||
usePagination,
|
|
||||||
type ColumnDef,
|
|
||||||
} from "@edr/ui-common";
|
|
||||||
|
|
||||||
import { PageContainer } from "@/components/page/PageContainer";
|
import { PageContainer } from "@/components/page/PageContainer";
|
||||||
import { PageHeader } from "@/components/page/PageHeader";
|
import { PageHeader } from "@/components/page/PageHeader";
|
||||||
import { KpiStrip } from "@/components/page/KpiStrip";
|
import { KpiStrip } from "@/components/page/KpiStrip";
|
||||||
|
import { TablePager } from "@/components/page/TablePager";
|
||||||
import { bookingTable } from "@/components/bookings/booking-ui.styles";
|
import { bookingTable } from "@/components/bookings/booking-ui.styles";
|
||||||
import { useBookingDjClearanceQueue } from "@/hooks/bookings/useBookings";
|
import { useBookingDjClearanceQueue } from "@/hooks/bookings/useBookings";
|
||||||
import type { BookingDetail } from "@/types/booking";
|
import type { BookingDetail } from "@/types/booking";
|
||||||
@@ -106,6 +106,20 @@ function statusColor(status: string): string {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Rows created/scheduled per day over the last `days` days, oldest → newest. */
|
||||||
|
function perDay(rows: { scheduledDate: string | null }[], days = 8): number[] {
|
||||||
|
const today = new Date().setHours(0, 0, 0, 0);
|
||||||
|
const out = new Array<number>(days).fill(0);
|
||||||
|
for (const r of rows) {
|
||||||
|
if (!r.scheduledDate) continue;
|
||||||
|
const age = Math.floor(
|
||||||
|
(today - new Date(r.scheduledDate).setHours(0, 0, 0, 0)) / 86_400_000,
|
||||||
|
);
|
||||||
|
if (age >= 0 && age < days) out[days - 1 - age] += 1;
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
// ── DJ next action (shipments) ───────────────────────────────────────────────
|
// ── DJ next action (shipments) ───────────────────────────────────────────────
|
||||||
|
|
||||||
type DjActionKey = "RO_HOLD" | "COLLECT_DO" | "ISSUE_RO" | "LOADING" | "REVIEW";
|
type DjActionKey = "RO_HOLD" | "COLLECT_DO" | "ISSUE_RO" | "LOADING" | "REVIEW";
|
||||||
@@ -180,28 +194,65 @@ function toShipmentRow(b: BookingDetail): ShipmentRow {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── Tabs ─────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
type TabKey = "all" | "import" | "export" | "hold";
|
||||||
|
|
||||||
|
const TABS: { key: TabKey; label: string; icon: LucideIcon }[] = [
|
||||||
|
{ key: "all", label: "All", icon: Layers },
|
||||||
|
{ key: "import", label: "Import", icon: Truck },
|
||||||
|
{ key: "export", label: "Export", icon: ShipWheel },
|
||||||
|
{ key: "hold", label: "On hold", icon: AlertTriangle },
|
||||||
|
];
|
||||||
|
|
||||||
// ── Shared cell pieces ───────────────────────────────────────────────────────
|
// ── Shared cell pieces ───────────────────────────────────────────────────────
|
||||||
|
|
||||||
function DirectionIcon({ direction }: { direction: string }) {
|
function LivePill({ updatedAt }: { updatedAt: number }) {
|
||||||
|
// Re-render every 30s so "Xm ago" keeps ticking between refetches.
|
||||||
|
const [, setTick] = useState(0);
|
||||||
|
useInterval(() => setTick((t) => t + 1), 30_000, { autoInvoke: true });
|
||||||
|
const mins = Math.max(0, Math.round((Date.now() - updatedAt) / 60_000));
|
||||||
|
const label = !updatedAt
|
||||||
|
? "Connecting…"
|
||||||
|
: mins < 1
|
||||||
|
? "Live · updated just now"
|
||||||
|
: `Live · updated ${mins}m ago`;
|
||||||
|
return (
|
||||||
|
<span className="inline-flex shrink-0 items-center gap-1.5 whitespace-nowrap rounded-full bg-edr-soft px-2.5 py-1 text-[11px] font-medium text-edr-primary-dark">
|
||||||
|
<span className="size-1.5 rounded-full bg-edr-primary-dark" />
|
||||||
|
{label}
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function DirectionPill({ direction }: { direction: string }) {
|
||||||
const isImport = direction === "IMPORT";
|
const isImport = direction === "IMPORT";
|
||||||
const Icon = isImport ? Truck : ShipWheel;
|
const Icon = isImport ? Truck : ShipWheel;
|
||||||
const label = directionLabel(direction);
|
const color = isImport ? "blue" : "teal";
|
||||||
return (
|
return (
|
||||||
<Tooltip label={label} withArrow>
|
<Tooltip label={directionLabel(direction)} withArrow>
|
||||||
<ThemeIcon
|
<span
|
||||||
variant="light"
|
className="inline-flex items-center gap-1 rounded-[5px] px-1.5 py-[2px] text-[10px] font-medium leading-none"
|
||||||
color={isImport ? "edr-green" : "gray"}
|
style={{
|
||||||
radius="md"
|
background: `var(--mantine-color-${color}-0)`,
|
||||||
size={26}
|
color: `var(--mantine-color-${color}-7)`,
|
||||||
aria-label={label}
|
}}
|
||||||
>
|
>
|
||||||
<Icon size={14} strokeWidth={1.9} />
|
<Icon size={10} />
|
||||||
</ThemeIcon>
|
{prettyStatus(direction)}
|
||||||
|
</span>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function OutlinePill({ children }: { children: React.ReactNode }) {
|
||||||
|
return (
|
||||||
|
<span className="inline-flex items-center rounded-[5px] border border-edr-border px-1.5 py-[2px] text-[10px] leading-none text-edr-muted">
|
||||||
|
{children}
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
function RouteCell({
|
function RouteCell({
|
||||||
origin,
|
origin,
|
||||||
destination,
|
destination,
|
||||||
@@ -214,30 +265,19 @@ function RouteCell({
|
|||||||
freightType: string;
|
freightType: string;
|
||||||
}) {
|
}) {
|
||||||
return (
|
return (
|
||||||
<Stack gap={4} py={2}>
|
<Stack gap={5} py={2}>
|
||||||
{/* Wraps past 120px as "Addis Ababa" / "→ Djibouti"; text wraps
|
<Group gap={6} wrap="nowrap">
|
||||||
normally (cells are otherwise nowrap) so it never spills over. */}
|
<Text fz={12.5} fw={500} c="edr-text">
|
||||||
<Text
|
{origin}
|
||||||
size="sm"
|
</Text>
|
||||||
fw={500}
|
<ArrowRight size={12} className="shrink-0 text-edr-muted" />
|
||||||
maw={120}
|
<Text fz={12.5} fw={500} c="edr-text">
|
||||||
lh={1.35}
|
|
||||||
style={{ whiteSpace: "normal", overflowWrap: "anywhere" }}
|
|
||||||
>
|
|
||||||
{origin}{" "}
|
|
||||||
<ArrowRight
|
|
||||||
size={13}
|
|
||||||
className="text-muted-foreground"
|
|
||||||
style={{ display: "inline-block", verticalAlign: "-2px" }}
|
|
||||||
/>
|
|
||||||
{"\u00A0"}
|
|
||||||
{destination}
|
{destination}
|
||||||
</Text>
|
</Text>
|
||||||
<Group gap={8} align="center">
|
</Group>
|
||||||
<DirectionIcon direction={direction} />
|
<Group gap={6} wrap="nowrap">
|
||||||
<Badge size="xs" variant="default" radius="sm">
|
<DirectionPill direction={direction} />
|
||||||
{freightType}
|
<OutlinePill>{prettyStatus(freightType)}</OutlinePill>
|
||||||
</Badge>
|
|
||||||
</Group>
|
</Group>
|
||||||
</Stack>
|
</Stack>
|
||||||
);
|
);
|
||||||
@@ -254,7 +294,7 @@ function RouteCell({
|
|||||||
export default function GlDjiboutiClearanceListPage() {
|
export default function GlDjiboutiClearanceListPage() {
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const [query, setQuery] = useState("");
|
const [query, setQuery] = useState("");
|
||||||
const [direction, setDirection] = useState<string | null>(null);
|
const [tab, setTab] = useState<TabKey>("all");
|
||||||
const [freight, setFreight] = useState<string | null>(null);
|
const [freight, setFreight] = useState<string | null>(null);
|
||||||
const [status, setStatus] = useState<string | null>(null);
|
const [status, setStatus] = useState<string | null>(null);
|
||||||
const [action, setAction] = useState<string | null>(null);
|
const [action, setAction] = useState<string | null>(null);
|
||||||
@@ -265,6 +305,7 @@ export default function GlDjiboutiClearanceListPage() {
|
|||||||
isLoading: bookingsLoading,
|
isLoading: bookingsLoading,
|
||||||
isError: bookingsError,
|
isError: bookingsError,
|
||||||
isFetching: bookingsFetching,
|
isFetching: bookingsFetching,
|
||||||
|
dataUpdatedAt,
|
||||||
refetch: refetchBookings,
|
refetch: refetchBookings,
|
||||||
} = useBookingDjClearanceQueue();
|
} = useBookingDjClearanceQueue();
|
||||||
|
|
||||||
@@ -277,18 +318,30 @@ export default function GlDjiboutiClearanceListPage() {
|
|||||||
() => (bookingQueue ?? []).map(toShipmentRow),
|
() => (bookingQueue ?? []).map(toShipmentRow),
|
||||||
[bookingQueue],
|
[bookingQueue],
|
||||||
);
|
);
|
||||||
|
|
||||||
// KPI metrics span the whole queue, regardless of filters.
|
// KPI metrics span the whole queue, regardless of filters.
|
||||||
const metrics = useMemo(
|
const metrics = useMemo(
|
||||||
() => ({
|
() => ({
|
||||||
shipments: allShipmentRows.length,
|
shipments: allShipmentRows,
|
||||||
collectDo: allShipmentRows.filter((r) => r.action.key === "COLLECT_DO")
|
collectDo: allShipmentRows.filter((r) => r.action.key === "COLLECT_DO"),
|
||||||
.length,
|
issueRo: allShipmentRows.filter((r) => r.action.key === "ISSUE_RO"),
|
||||||
issueRo: allShipmentRows.filter((r) => r.action.key === "ISSUE_RO").length,
|
roHolds: allShipmentRows.filter((r) => r.action.key === "RO_HOLD"),
|
||||||
roHolds: allShipmentRows.filter((r) => r.action.key === "RO_HOLD").length,
|
|
||||||
}),
|
}),
|
||||||
[allShipmentRows],
|
[allShipmentRows],
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const tabCounts = useMemo<Record<TabKey, number>>(
|
||||||
|
() => ({
|
||||||
|
all: allShipmentRows.length,
|
||||||
|
import: allShipmentRows.filter((r) => r.tradeDirection === "IMPORT")
|
||||||
|
.length,
|
||||||
|
export: allShipmentRows.filter((r) => r.tradeDirection === "EXPORT")
|
||||||
|
.length,
|
||||||
|
hold: metrics.roHolds.length,
|
||||||
|
}),
|
||||||
|
[allShipmentRows, metrics.roHolds.length],
|
||||||
|
);
|
||||||
|
|
||||||
const statusOptions = useMemo(
|
const statusOptions = useMemo(
|
||||||
() =>
|
() =>
|
||||||
[...new Set(allShipmentRows.map((r) => r.status))].sort().map((s) => ({
|
[...new Set(allShipmentRows.map((r) => r.status))].sort().map((s) => ({
|
||||||
@@ -298,64 +351,45 @@ export default function GlDjiboutiClearanceListPage() {
|
|||||||
[allShipmentRows],
|
[allShipmentRows],
|
||||||
);
|
);
|
||||||
|
|
||||||
const matchesShared = useCallback(
|
const shipmentRows = useMemo(() => {
|
||||||
(
|
const q = query.trim().toLowerCase();
|
||||||
r: {
|
return allShipmentRows.filter((r) => {
|
||||||
reference: string;
|
if (tab === "hold" && r.action.key !== "RO_HOLD") return false;
|
||||||
customerLabel: string;
|
if (
|
||||||
originLabel: string;
|
(tab === "import" || tab === "export") &&
|
||||||
destinationLabel: string;
|
r.tradeDirection !== tab.toUpperCase()
|
||||||
tradeDirection: string;
|
)
|
||||||
freightType: string;
|
return false;
|
||||||
status: string;
|
|
||||||
},
|
|
||||||
extraSearchFields: string[] = [],
|
|
||||||
) => {
|
|
||||||
if (direction && r.tradeDirection !== direction) return false;
|
|
||||||
if (freight && r.freightType !== freight) return false;
|
if (freight && r.freightType !== freight) return false;
|
||||||
if (status && r.status !== status) return false;
|
if (status && r.status !== status) return false;
|
||||||
const q = query.trim().toLowerCase();
|
if (action && r.action.key !== action) return false;
|
||||||
if (!q) return true;
|
if (!q) return true;
|
||||||
return [
|
return [
|
||||||
r.reference,
|
r.reference,
|
||||||
r.customerLabel,
|
r.customerLabel,
|
||||||
|
r.contractReference,
|
||||||
r.originLabel,
|
r.originLabel,
|
||||||
r.destinationLabel,
|
r.destinationLabel,
|
||||||
prettyStatus(r.status),
|
prettyStatus(r.status),
|
||||||
...extraSearchFields,
|
|
||||||
].some((v) => v.toLowerCase().includes(q));
|
].some((v) => v.toLowerCase().includes(q));
|
||||||
},
|
});
|
||||||
[direction, freight, status, query],
|
}, [allShipmentRows, tab, freight, status, action, query]);
|
||||||
);
|
|
||||||
|
|
||||||
const shipmentRows = useMemo(
|
|
||||||
() =>
|
|
||||||
allShipmentRows.filter(
|
|
||||||
(r) =>
|
|
||||||
(!action || r.action.key === action) &&
|
|
||||||
// Shipments also match the parent contract reference in search.
|
|
||||||
matchesShared(r, [r.contractReference]),
|
|
||||||
),
|
|
||||||
[allShipmentRows, action, matchesShared],
|
|
||||||
);
|
|
||||||
|
|
||||||
const isLoading = bookingsLoading;
|
const isLoading = bookingsLoading;
|
||||||
const isError = bookingsError;
|
const isError = bookingsError;
|
||||||
const isFetching = bookingsFetching;
|
const isFetching = bookingsFetching;
|
||||||
const total = shipmentRows.length;
|
const total = shipmentRows.length;
|
||||||
const pageCount = Math.max(1, Math.ceil(total / pagination.pageSize));
|
const pageCount = Math.max(1, Math.ceil(total / pagination.pageSize));
|
||||||
const showEmpty = !isLoading && !isError && total === 0;
|
|
||||||
|
|
||||||
const pagedShipmentRows = useMemo(() => {
|
const pagedShipmentRows = useMemo(() => {
|
||||||
const start = pagination.pageIndex * pagination.pageSize;
|
const start = pagination.pageIndex * pagination.pageSize;
|
||||||
return shipmentRows.slice(start, start + pagination.pageSize);
|
return shipmentRows.slice(start, start + pagination.pageSize);
|
||||||
}, [shipmentRows, pagination.pageIndex, pagination.pageSize]);
|
}, [shipmentRows, pagination.pageIndex, pagination.pageSize]);
|
||||||
|
|
||||||
const hasFilters = Boolean(query || direction || freight || status || action);
|
const hasFilters = Boolean(query || freight || status || action);
|
||||||
|
|
||||||
const clearFilters = useCallback(() => {
|
const clearFilters = useCallback(() => {
|
||||||
setQuery("");
|
setQuery("");
|
||||||
setDirection(null);
|
|
||||||
setFreight(null);
|
setFreight(null);
|
||||||
setStatus(null);
|
setStatus(null);
|
||||||
setAction(null);
|
setAction(null);
|
||||||
@@ -379,18 +413,23 @@ export default function GlDjiboutiClearanceListPage() {
|
|||||||
cell: ({ row }) => {
|
cell: ({ row }) => {
|
||||||
const r = row.original;
|
const r = row.original;
|
||||||
return (
|
return (
|
||||||
<div className="flex items-center gap-3 py-1.5">
|
<div className="flex items-center gap-2.5 py-1">
|
||||||
<div className={bookingTable.rowIcon}>
|
<div className="flex size-[30px] shrink-0 items-center justify-center rounded-[9px] bg-edr-divider text-edr-muted">
|
||||||
<PackageCheck className="size-4" strokeWidth={1.75} />
|
<PackageCheck size={15} strokeWidth={1.75} />
|
||||||
</div>
|
</div>
|
||||||
<div className="min-w-0">
|
<div className="min-w-0">
|
||||||
<p className="font-medium text-foreground">
|
<Text fz={13} fw={600} c="edr-text">
|
||||||
{r.reference}
|
{r.reference}
|
||||||
</p>
|
</Text>
|
||||||
<p className="mt-0.5 flex items-center gap-1 text-xs text-muted-foreground">
|
<Group gap={4} wrap="nowrap" align="flex-start">
|
||||||
<User className="size-3 shrink-0 opacity-70" />
|
<Building2
|
||||||
|
size={10}
|
||||||
|
className="mt-[3px] shrink-0 text-edr-muted opacity-70"
|
||||||
|
/>
|
||||||
|
<Text fz={11} c="edr-muted" className="cell-wrap">
|
||||||
{r.customerLabel}
|
{r.customerLabel}
|
||||||
</p>
|
</Text>
|
||||||
|
</Group>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
@@ -400,9 +439,11 @@ export default function GlDjiboutiClearanceListPage() {
|
|||||||
id: "contract",
|
id: "contract",
|
||||||
header: () => <span className={bookingTable.headerCell}>Contract</span>,
|
header: () => <span className={bookingTable.headerCell}>Contract</span>,
|
||||||
cell: ({ row }) => (
|
cell: ({ row }) => (
|
||||||
<Group gap={6} wrap="nowrap">
|
<Group gap={5} wrap="nowrap">
|
||||||
<FileText size={13} className="shrink-0 text-muted-foreground" />
|
<FileText size={12} className="shrink-0 text-edr-muted" />
|
||||||
<Text size="sm">{row.original.contractReference}</Text>
|
<Text fz={12.5} c="edr-text">
|
||||||
|
{row.original.contractReference}
|
||||||
|
</Text>
|
||||||
</Group>
|
</Group>
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
@@ -428,9 +469,11 @@ export default function GlDjiboutiClearanceListPage() {
|
|||||||
const r = row.original;
|
const r = row.original;
|
||||||
return (
|
return (
|
||||||
<Stack gap={4} py={2}>
|
<Stack gap={4} py={2}>
|
||||||
<Group gap={6} wrap="nowrap">
|
<Group gap={5} wrap="nowrap">
|
||||||
<Weight size={13} className="shrink-0 text-muted-foreground" />
|
<Weight size={12} className="shrink-0 text-edr-muted" />
|
||||||
<Text size="sm">{r.weightTons} t</Text>
|
<Text fz={12} fw={500} c="edr-text">
|
||||||
|
{r.weightTons} t
|
||||||
|
</Text>
|
||||||
</Group>
|
</Group>
|
||||||
{r.isHazardous ? (
|
{r.isHazardous ? (
|
||||||
<Badge
|
<Badge
|
||||||
@@ -449,7 +492,9 @@ export default function GlDjiboutiClearanceListPage() {
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: "action",
|
id: "action",
|
||||||
header: () => <span className={bookingTable.headerCell}>DJ action</span>,
|
header: () => (
|
||||||
|
<span className={bookingTable.headerCell}>DJ action</span>
|
||||||
|
),
|
||||||
cell: ({ row }) => {
|
cell: ({ row }) => {
|
||||||
const r = row.original;
|
const r = row.original;
|
||||||
const badge = (
|
const badge = (
|
||||||
@@ -466,7 +511,7 @@ export default function GlDjiboutiClearanceListPage() {
|
|||||||
) : (
|
) : (
|
||||||
badge
|
badge
|
||||||
)}
|
)}
|
||||||
<Text size="xs" c="dimmed">
|
<Text fz={10.5} c="edr-muted">
|
||||||
{phaseLabel(r.phase)}
|
{phaseLabel(r.phase)}
|
||||||
</Text>
|
</Text>
|
||||||
</Stack>
|
</Stack>
|
||||||
@@ -489,11 +534,13 @@ export default function GlDjiboutiClearanceListPage() {
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: "scheduled",
|
id: "scheduled",
|
||||||
header: () => <span className={bookingTable.headerCell}>Scheduled</span>,
|
header: () => (
|
||||||
|
<span className={bookingTable.headerCell}>Scheduled</span>
|
||||||
|
),
|
||||||
cell: ({ row }) => (
|
cell: ({ row }) => (
|
||||||
<Group gap={6} wrap="nowrap">
|
<Group gap={5} wrap="nowrap">
|
||||||
<CalendarClock size={13} className="shrink-0 text-muted-foreground" />
|
<CalendarClock size={12} className="shrink-0 text-edr-muted" />
|
||||||
<Text size="sm" c="dimmed">
|
<Text fz={11.5} c="edr-muted">
|
||||||
{formatDate(row.original.scheduledDate)}
|
{formatDate(row.original.scheduledDate)}
|
||||||
</Text>
|
</Text>
|
||||||
</Group>
|
</Group>
|
||||||
@@ -505,7 +552,7 @@ export default function GlDjiboutiClearanceListPage() {
|
|||||||
header: "",
|
header: "",
|
||||||
cell: () => (
|
cell: () => (
|
||||||
<Group justify="flex-end" pr="xs">
|
<Group justify="flex-end" pr="xs">
|
||||||
<ChevronRight size={16} className="text-muted-foreground" />
|
<ChevronRight size={16} className="text-edr-muted" />
|
||||||
</Group>
|
</Group>
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
@@ -519,17 +566,18 @@ export default function GlDjiboutiClearanceListPage() {
|
|||||||
<PageHeader
|
<PageHeader
|
||||||
title="GL Djibouti — Clearance"
|
title="GL Djibouti — Clearance"
|
||||||
subtitle="Customs shipments handed off to Djibouti GL — every clearance step lives on the shipment."
|
subtitle="Customs shipments handed off to Djibouti GL — every clearance step lives on the shipment."
|
||||||
|
meta={<LivePill updatedAt={dataUpdatedAt} />}
|
||||||
action={
|
action={
|
||||||
<ActionIcon
|
<Button
|
||||||
variant="default"
|
variant="default"
|
||||||
size="lg"
|
|
||||||
radius="md"
|
radius="md"
|
||||||
|
size="sm"
|
||||||
|
leftSection={<RefreshCw size={14} />}
|
||||||
loading={isFetching}
|
loading={isFetching}
|
||||||
onClick={handleRefresh}
|
onClick={handleRefresh}
|
||||||
aria-label="Refresh"
|
|
||||||
>
|
>
|
||||||
<RefreshCw size={16} />
|
Refresh
|
||||||
</ActionIcon>
|
</Button>
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
@@ -538,38 +586,131 @@ export default function GlDjiboutiClearanceListPage() {
|
|||||||
items={[
|
items={[
|
||||||
{
|
{
|
||||||
label: "Shipments in queue",
|
label: "Shipments in queue",
|
||||||
value: metrics.shipments,
|
value: metrics.shipments.length,
|
||||||
icon: PackageCheck,
|
icon: PackageCheck,
|
||||||
color: "blue",
|
color: "blue",
|
||||||
|
spark: perDay(metrics.shipments),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
label: "Imports — collect DO",
|
label: "Imports — collect DO",
|
||||||
value: metrics.collectDo,
|
value: metrics.collectDo.length,
|
||||||
icon: Truck,
|
icon: Truck,
|
||||||
color: "yellow",
|
color: "yellow",
|
||||||
|
spark: perDay(metrics.collectDo),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
label: "Exports — issue RO",
|
label: "Exports — issue RO",
|
||||||
value: metrics.issueRo,
|
value: metrics.issueRo.length,
|
||||||
icon: ShipWheel,
|
icon: ShipWheel,
|
||||||
color: "blue",
|
color: "blue",
|
||||||
|
spark: perDay(metrics.issueRo),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
label: "RO amendment holds",
|
label: "RO amendment holds",
|
||||||
value: metrics.roHolds,
|
value: metrics.roHolds.length,
|
||||||
icon: AlertTriangle,
|
icon: AlertTriangle,
|
||||||
color: "red",
|
color: "red",
|
||||||
|
spark: perDay(metrics.roHolds),
|
||||||
},
|
},
|
||||||
]}
|
]}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<Card p={0} withBorder shadow="sm" radius="lg">
|
<Card
|
||||||
|
p={0}
|
||||||
|
withBorder
|
||||||
|
shadow="sm"
|
||||||
|
radius="lg"
|
||||||
|
style={{ overflow: "hidden" }}
|
||||||
|
>
|
||||||
<Stack gap={0}>
|
<Stack gap={0}>
|
||||||
<Box px="md" pt="md" pb="sm">
|
{/* ── Tabs ─────────────────────────────────────────────── */}
|
||||||
<Group gap="sm" wrap="wrap">
|
<Group
|
||||||
|
justify="space-between"
|
||||||
|
align="stretch"
|
||||||
|
px="md"
|
||||||
|
h={46}
|
||||||
|
wrap="nowrap"
|
||||||
|
style={{
|
||||||
|
borderBottom: "1px solid var(--mantine-color-edr-border-6)",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Group gap={2} wrap="nowrap" align="stretch">
|
||||||
|
{TABS.map((t) => {
|
||||||
|
const active = tab === t.key;
|
||||||
|
const Icon = t.icon;
|
||||||
|
return (
|
||||||
|
<UnstyledButton
|
||||||
|
key={t.key}
|
||||||
|
onClick={() => {
|
||||||
|
setTab(t.key);
|
||||||
|
resetPage();
|
||||||
|
}}
|
||||||
|
px={13}
|
||||||
|
className="flex items-center gap-2 transition-colors"
|
||||||
|
style={{
|
||||||
|
borderBottom: `2px solid ${
|
||||||
|
active
|
||||||
|
? "var(--mantine-color-edr-green-6)"
|
||||||
|
: "transparent"
|
||||||
|
}`,
|
||||||
|
marginBottom: -1,
|
||||||
|
}}
|
||||||
|
aria-pressed={active}
|
||||||
|
>
|
||||||
|
<Icon
|
||||||
|
size={14}
|
||||||
|
style={{
|
||||||
|
color: active
|
||||||
|
? "var(--mantine-color-edr-green-6)"
|
||||||
|
: "var(--mantine-color-gray-5)",
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<Text
|
||||||
|
fz={13}
|
||||||
|
fw={active ? 600 : 500}
|
||||||
|
c={active ? "edr-text" : "edr-muted"}
|
||||||
|
>
|
||||||
|
{t.label}
|
||||||
|
</Text>
|
||||||
|
<span
|
||||||
|
className="rounded-full px-1.5 py-px text-[10.5px] font-semibold leading-[1.4]"
|
||||||
|
style={{
|
||||||
|
background: active
|
||||||
|
? "var(--mantine-color-edr-green-0)"
|
||||||
|
: "var(--mantine-color-gray-1)",
|
||||||
|
color: active
|
||||||
|
? "var(--mantine-color-edr-green-7)"
|
||||||
|
: "var(--mantine-color-edr-muted-6)",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{tabCounts[t.key]}
|
||||||
|
</span>
|
||||||
|
</UnstyledButton>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</Group>
|
||||||
|
<Text
|
||||||
|
fz={12}
|
||||||
|
c="edr-muted"
|
||||||
|
className="self-center whitespace-nowrap"
|
||||||
|
>
|
||||||
|
{total} record{total !== 1 ? "s" : ""}
|
||||||
|
</Text>
|
||||||
|
</Group>
|
||||||
|
|
||||||
|
{/* ── Filter bar ───────────────────────────────────────── */}
|
||||||
|
<Group
|
||||||
|
gap={9}
|
||||||
|
px="md"
|
||||||
|
py={12}
|
||||||
|
wrap="wrap"
|
||||||
|
style={{
|
||||||
|
borderBottom: "1px solid var(--mantine-color-edr-divider-6)",
|
||||||
|
}}
|
||||||
|
>
|
||||||
<TextInput
|
<TextInput
|
||||||
placeholder="Search reference, customer, route, or status…"
|
placeholder="Search reference, customer, route, or status…"
|
||||||
leftSection={<Search size={18} />}
|
leftSection={<Search size={15} />}
|
||||||
value={query}
|
value={query}
|
||||||
onChange={(e) => {
|
onChange={(e) => {
|
||||||
setQuery(e.currentTarget.value);
|
setQuery(e.currentTarget.value);
|
||||||
@@ -586,28 +727,18 @@ export default function GlDjiboutiClearanceListPage() {
|
|||||||
setQuery("");
|
setQuery("");
|
||||||
resetPage();
|
resetPage();
|
||||||
}}
|
}}
|
||||||
|
aria-label="Clear search"
|
||||||
>
|
>
|
||||||
<X size={16} />
|
<X size={14} />
|
||||||
</ActionIcon>
|
</ActionIcon>
|
||||||
) : null
|
) : null
|
||||||
}
|
}
|
||||||
radius="lg"
|
radius="md"
|
||||||
style={{ flex: 1, minWidth: 220 }}
|
size="sm"
|
||||||
/>
|
styles={{
|
||||||
<Select
|
input: { background: "var(--mantine-color-gray-0)" },
|
||||||
placeholder="Direction"
|
|
||||||
data={[
|
|
||||||
{ value: "IMPORT", label: "Import" },
|
|
||||||
{ value: "EXPORT", label: "Export" },
|
|
||||||
]}
|
|
||||||
value={direction}
|
|
||||||
onChange={(v) => {
|
|
||||||
setDirection(v);
|
|
||||||
resetPage();
|
|
||||||
}}
|
}}
|
||||||
clearable
|
style={{ flex: 1, minWidth: 220 }}
|
||||||
radius="lg"
|
|
||||||
w={130}
|
|
||||||
/>
|
/>
|
||||||
<Select
|
<Select
|
||||||
placeholder="Freight"
|
placeholder="Freight"
|
||||||
@@ -621,8 +752,11 @@ export default function GlDjiboutiClearanceListPage() {
|
|||||||
resetPage();
|
resetPage();
|
||||||
}}
|
}}
|
||||||
clearable
|
clearable
|
||||||
radius="lg"
|
radius="md"
|
||||||
w={130}
|
size="sm"
|
||||||
|
w={124}
|
||||||
|
comboboxProps={{ withinPortal: true }}
|
||||||
|
aria-label="Filter by freight type"
|
||||||
/>
|
/>
|
||||||
<Select
|
<Select
|
||||||
placeholder="Status"
|
placeholder="Status"
|
||||||
@@ -633,8 +767,11 @@ export default function GlDjiboutiClearanceListPage() {
|
|||||||
resetPage();
|
resetPage();
|
||||||
}}
|
}}
|
||||||
clearable
|
clearable
|
||||||
radius="lg"
|
radius="md"
|
||||||
w={190}
|
size="sm"
|
||||||
|
w={180}
|
||||||
|
comboboxProps={{ withinPortal: true }}
|
||||||
|
aria-label="Filter by status"
|
||||||
/>
|
/>
|
||||||
<Select
|
<Select
|
||||||
placeholder="DJ action"
|
placeholder="DJ action"
|
||||||
@@ -645,8 +782,11 @@ export default function GlDjiboutiClearanceListPage() {
|
|||||||
resetPage();
|
resetPage();
|
||||||
}}
|
}}
|
||||||
clearable
|
clearable
|
||||||
radius="lg"
|
radius="md"
|
||||||
w={180}
|
size="sm"
|
||||||
|
w={170}
|
||||||
|
comboboxProps={{ withinPortal: true }}
|
||||||
|
aria-label="Filter by DJ action"
|
||||||
/>
|
/>
|
||||||
{hasFilters ? (
|
{hasFilters ? (
|
||||||
<Button
|
<Button
|
||||||
@@ -661,16 +801,15 @@ export default function GlDjiboutiClearanceListPage() {
|
|||||||
</Button>
|
</Button>
|
||||||
) : null}
|
) : null}
|
||||||
</Group>
|
</Group>
|
||||||
</Box>
|
|
||||||
|
|
||||||
{showEmpty ? (
|
{!isLoading && !isError && total === 0 ? (
|
||||||
<Stack align="center" gap={8} py={48}>
|
<Stack align="center" gap={8} py={48}>
|
||||||
<ThemeIcon variant="light" color="gray" radius="xl" size={48}>
|
<ThemeIcon variant="light" color="gray" radius="xl" size={48}>
|
||||||
<Inbox size={22} />
|
<Inbox size={22} />
|
||||||
</ThemeIcon>
|
</ThemeIcon>
|
||||||
<Text c="dimmed">
|
<Text c="dimmed">
|
||||||
{hasFilters
|
{hasFilters
|
||||||
? "No records match these filters."
|
? "No shipments match these filters."
|
||||||
: "No shipments awaiting a Djibouti action."}
|
: "No shipments awaiting a Djibouti action."}
|
||||||
</Text>
|
</Text>
|
||||||
{hasFilters ? (
|
{hasFilters ? (
|
||||||
@@ -686,7 +825,7 @@ export default function GlDjiboutiClearanceListPage() {
|
|||||||
) : null}
|
) : null}
|
||||||
</Stack>
|
</Stack>
|
||||||
) : (
|
) : (
|
||||||
<Box w="100%" miw={0} style={{ overflowX: "auto" }}>
|
<Box w="100%" miw={0}>
|
||||||
<DataTable<ShipmentRow, unknown>
|
<DataTable<ShipmentRow, unknown>
|
||||||
columns={shipmentColumns}
|
columns={shipmentColumns}
|
||||||
data={pagedShipmentRows}
|
data={pagedShipmentRows}
|
||||||
@@ -705,7 +844,7 @@ export default function GlDjiboutiClearanceListPage() {
|
|||||||
pageCount,
|
pageCount,
|
||||||
}}
|
}}
|
||||||
containerClassName="edr-clearance-table border-0 shadow-none rounded-none bg-transparent"
|
containerClassName="edr-clearance-table border-0 shadow-none rounded-none bg-transparent"
|
||||||
footer={DataTableFooter}
|
footer={(p) => <TablePager {...p} noun="shipments" />}
|
||||||
/>
|
/>
|
||||||
</Box>
|
</Box>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -32,6 +32,22 @@
|
|||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/*
|
||||||
|
* Booking (col 1) and Contract (col 2) carry free-text company/contract names.
|
||||||
|
* Cap those two columns and let their content wrap onto 2+ lines so a very long
|
||||||
|
* name (e.g. "SHAFICI PHARMACEUTICAL MEDICAL SUPPLIES WHOLESALER PARTINERSHIP")
|
||||||
|
* stacks inside its own cell instead of shoving the next column off-screen.
|
||||||
|
* Everything below the header row so the header labels still sit on one line.
|
||||||
|
*/
|
||||||
|
.edr-clearance-table tbody td:not([colspan]):nth-child(1) {
|
||||||
|
max-width: 240px;
|
||||||
|
white-space: normal;
|
||||||
|
}
|
||||||
|
.edr-clearance-table tbody td:not([colspan]):nth-child(2) {
|
||||||
|
max-width: 200px;
|
||||||
|
white-space: normal;
|
||||||
|
}
|
||||||
|
|
||||||
/*
|
/*
|
||||||
* Mantine Badge caps itself at max-width: 100%; inside an auto-layout table
|
* Mantine Badge caps itself at max-width: 100%; inside an auto-layout table
|
||||||
* cell that resolves against min-content and clips the label. Let badges size
|
* cell that resolves against min-content and clips the label. Let badges size
|
||||||
@@ -41,6 +57,22 @@
|
|||||||
max-width: none;
|
max-width: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/*
|
||||||
|
* Opt-out for long free text (company/customer names). The blanket nowrap rule
|
||||||
|
* above keeps every cell on one line so columns size to content; a very long
|
||||||
|
* name would otherwise force the column absurdly wide. Mark such text with
|
||||||
|
* `cell-wrap` to cap it and wrap onto 2+ lines instead of pushing the layout.
|
||||||
|
*/
|
||||||
|
.edr-clearance-table .cell-wrap,
|
||||||
|
.edr-clearance-table .mantine-Group-root > .cell-wrap {
|
||||||
|
white-space: normal;
|
||||||
|
overflow-wrap: anywhere;
|
||||||
|
word-break: break-word;
|
||||||
|
min-width: 0;
|
||||||
|
max-width: 100%;
|
||||||
|
line-height: 1.3;
|
||||||
|
}
|
||||||
|
|
||||||
/*
|
/*
|
||||||
* Mantine Group's preventGrowOverflow caps every child at 100%/N of the cell.
|
* Mantine Group's preventGrowOverflow caps every child at 100%/N of the cell.
|
||||||
* In an auto-width table cell that resolves against min-content and collapses
|
* In an auto-width table cell that resolves against min-content and collapses
|
||||||
@@ -70,7 +102,7 @@
|
|||||||
min-width: 0;
|
min-width: 0;
|
||||||
position: sticky;
|
position: sticky;
|
||||||
right: 0;
|
right: 0;
|
||||||
box-shadow: -12px 0 16px -6px rgba(16, 32, 47, 0.3);
|
box-shadow: -10px 0 14px -8px rgba(16, 32, 47, 0.12);
|
||||||
}
|
}
|
||||||
|
|
||||||
/*
|
/*
|
||||||
@@ -78,17 +110,41 @@
|
|||||||
* background or the columns underneath show through.
|
* background or the columns underneath show through.
|
||||||
*/
|
*/
|
||||||
.edr-clearance-table td:last-child:not([colspan]) {
|
.edr-clearance-table td:last-child:not([colspan]) {
|
||||||
background: #f5f8fb;
|
background: var(--mantine-color-body);
|
||||||
z-index: 2;
|
z-index: 2;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Row hover uses the tailwind `hover:bg-accent` class on the <tr>. */
|
/* Row hover uses the tailwind `hover:bg-accent` class on the <tr>. */
|
||||||
.edr-clearance-table tbody tr:hover td:last-child:not([colspan]) {
|
.edr-clearance-table tbody tr:hover td:last-child:not([colspan]) {
|
||||||
background: var(--accent, #f4fbf8);
|
background: #f7fbf9;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Header cell is sticky on both axes — it must outrank the body's sticky column. */
|
/* Header cell is sticky on both axes — it must outrank the body's sticky column. */
|
||||||
.edr-clearance-table th:last-child {
|
.edr-clearance-table th:last-child {
|
||||||
background: #f4f7fa;
|
background: var(--mantine-color-gray-0);
|
||||||
z-index: 3;
|
z-index: 3;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ── Design pass: flat head band, 64px rows, hairline dividers ─────────── */
|
||||||
|
.edr-clearance-table thead th {
|
||||||
|
height: 38px;
|
||||||
|
padding-top: 0;
|
||||||
|
padding-bottom: 0;
|
||||||
|
background: var(--mantine-color-gray-0);
|
||||||
|
border-bottom: 1px solid var(--mantine-color-edr-divider-6);
|
||||||
|
}
|
||||||
|
|
||||||
|
.edr-clearance-table tbody td:not([colspan]) {
|
||||||
|
height: 64px;
|
||||||
|
padding-top: 8px;
|
||||||
|
padding-bottom: 8px;
|
||||||
|
border-bottom: 1px solid var(--mantine-color-edr-divider-6);
|
||||||
|
}
|
||||||
|
|
||||||
|
.edr-clearance-table tbody tr:last-child td:not([colspan]) {
|
||||||
|
border-bottom: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.edr-clearance-table tbody tr:hover td {
|
||||||
|
background: #f7fbf9;
|
||||||
|
}
|
||||||
|
|||||||
@@ -123,7 +123,7 @@ export const freightMantineTheme = createTheme({
|
|||||||
},
|
},
|
||||||
|
|
||||||
headings: {
|
headings: {
|
||||||
fontFamily: '"Inter", var(--mantine-font-family)',
|
fontFamily: '"Space Grotesk", "Inter", var(--mantine-font-family)',
|
||||||
fontWeight: "700",
|
fontWeight: "700",
|
||||||
sizes: {
|
sizes: {
|
||||||
h1: { fontSize: "36px", lineHeight: "1.1", fontWeight: "800" },
|
h1: { fontSize: "36px", lineHeight: "1.1", fontWeight: "800" },
|
||||||
|
|||||||
Reference in New Issue
Block a user