feat: enhance GlDjiboutiClearanceListPage with tab navigation and improved filtering

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

View File

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

View File

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

View File

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

View File

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