mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-29 18:48:11 +00:00
feat: enhance GlDjiboutiClearanceListPage with tab navigation and improved filtering
- Added tab navigation for All, Import, Export, and On hold shipments. - Implemented a new LivePill component to show real-time updates. - Refactored shipment filtering logic to accommodate new tab functionality. - Improved UI elements including buttons and text inputs for better user experience. - Updated styles for table cells to allow text wrapping for long company names. - Adjusted theme to use Space Grotesk font for headings.
This commit is contained in:
@@ -53,7 +53,7 @@ export const bookingInput = {
|
||||
|
||||
export const bookingTable = {
|
||||
headerCell:
|
||||
"h-11 text-[11px] font-semibold uppercase tracking-wider text-muted-foreground",
|
||||
"whitespace-nowrap text-[10px] font-semibold uppercase tracking-[0.08em] text-edr-muted",
|
||||
rowHover:
|
||||
"transition-colors hover:bg-muted/25 data-[state=selected]:bg-muted/30",
|
||||
rowIcon: `flex size-10 shrink-0 items-center justify-center rounded-xl ${bookingGlass.iconWellGreen}`,
|
||||
|
||||
@@ -122,7 +122,10 @@ function phaseCountdown(w: WindowRow): {
|
||||
}
|
||||
|
||||
/** Badge label + Mantine color per UI state — same state the countdown uses. */
|
||||
const KIND_BADGE: Record<BookingWindowUiKind, { label: string; color: string }> = {
|
||||
const KIND_BADGE: Record<
|
||||
BookingWindowUiKind,
|
||||
{ label: string; color: string }
|
||||
> = {
|
||||
OPEN: { label: "Open now", color: "edr-green" },
|
||||
FULL: { label: "Train full", color: "red" },
|
||||
PRE_WINDOW: { label: "Opens soon", color: "yellow" },
|
||||
@@ -301,8 +304,12 @@ export function GlUpcomingWindowsSection({
|
||||
// Order by the train's dispatch (departure) date, nearest first. Open-now
|
||||
// breaks ties on the same departure.
|
||||
return rows.sort((a, b) => {
|
||||
const da = a.departureDate ? new Date(a.departureDate).getTime() : Infinity;
|
||||
const db = b.departureDate ? new Date(b.departureDate).getTime() : Infinity;
|
||||
const da = a.departureDate
|
||||
? new Date(a.departureDate).getTime()
|
||||
: Infinity;
|
||||
const db = b.departureDate
|
||||
? new Date(b.departureDate).getTime()
|
||||
: Infinity;
|
||||
if (da !== db) return da - db;
|
||||
return Number(b.isOpenNow) - Number(a.isOpenNow);
|
||||
});
|
||||
@@ -319,14 +326,16 @@ export function GlUpcomingWindowsSection({
|
||||
|
||||
return (
|
||||
<Card withBorder shadow="sm" radius="lg" p="lg">
|
||||
<Group justify="space-between" align="flex-start" mb="md" wrap="nowrap">
|
||||
<Group gap={8} wrap="nowrap">
|
||||
<CalendarClock size={18} />
|
||||
<Group justify="space-between" align="center" mb="md" wrap="nowrap">
|
||||
<Group gap={10} wrap="nowrap">
|
||||
<div className="flex size-8 shrink-0 items-center justify-center rounded-[9px] bg-edr-soft text-edr-primary-dark">
|
||||
<CalendarClock size={16} />
|
||||
</div>
|
||||
<Box>
|
||||
<Text fw={700} fz={16}>
|
||||
<Text ff="heading" fw={600} fz={15} lh={1.2}>
|
||||
Booking windows
|
||||
</Text>
|
||||
<Text fz={13} c="dimmed">
|
||||
<Text fz={12} c="edr-muted">
|
||||
{contractId
|
||||
? "Booking windows on this contract's routes (EAT)"
|
||||
: "Import and export booking windows across all lanes (EAT)"}
|
||||
@@ -386,7 +395,11 @@ export function GlUpcomingWindowsSection({
|
||||
))}
|
||||
</SimpleGrid>
|
||||
) : (
|
||||
<SimpleGrid key={safePage} cols={{ base: 1, sm: 2, lg: 3 }} spacing="md">
|
||||
<SimpleGrid
|
||||
key={safePage}
|
||||
cols={{ base: 1, sm: 2, lg: 3 }}
|
||||
spacing="md"
|
||||
>
|
||||
{visible.map((w) => (
|
||||
<WindowCard key={`${w.scheduleId}-${w.bookingCycleNo}`} w={w} />
|
||||
))}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { Card, Skeleton, Text } from "@mantine/core";
|
||||
import { ArrowUpRight } from "lucide-react";
|
||||
import type { LucideIcon } from "lucide-react";
|
||||
import type { ElementType, ReactNode } from "react";
|
||||
import { Link } from "react-router-dom";
|
||||
@@ -8,14 +9,14 @@ import { cn } from "@/lib/utils";
|
||||
export interface KpiItem {
|
||||
label: string;
|
||||
value: ReactNode;
|
||||
/** Optional leading icon rendered in a tinted chip. */
|
||||
/** Optional leading icon rendered in a tinted chip beside the label. */
|
||||
icon?: LucideIcon;
|
||||
/** Secondary line under the label (e.g. a unit or comparison). */
|
||||
/** Small tinted pill beside the value (e.g. "+6 today"). */
|
||||
hint?: string;
|
||||
/**
|
||||
* Mantine color name for the icon chip (e.g. "edr-green", "red", "yellow").
|
||||
* Defaults to the brand green so a strip reads as uniform unless a page opts
|
||||
* into semantic tints.
|
||||
* Mantine color name for the icon chip and sparkline (e.g. "edr-green",
|
||||
* "red", "yellow"). Defaults to the brand green so a strip reads as uniform
|
||||
* unless a page opts into semantic tints.
|
||||
*/
|
||||
color?: string;
|
||||
/**
|
||||
@@ -28,6 +29,8 @@ export interface KpiItem {
|
||||
* becomes clickable (pointer, hover tint); when absent it stays static.
|
||||
*/
|
||||
href?: string;
|
||||
/** Tiny bar sparkline, oldest → newest, scaled to its own max. */
|
||||
spark?: number[];
|
||||
}
|
||||
|
||||
export interface KpiStripProps {
|
||||
@@ -36,11 +39,52 @@ export interface KpiStripProps {
|
||||
loading?: boolean;
|
||||
}
|
||||
|
||||
function Pill({
|
||||
children,
|
||||
tone,
|
||||
}: {
|
||||
children: ReactNode;
|
||||
tone: "green" | "red";
|
||||
}) {
|
||||
const c = tone === "green" ? "edr-green" : "red";
|
||||
return (
|
||||
<span
|
||||
className="inline-flex shrink-0 items-center gap-1 rounded-full px-[7px] py-[2px] text-[10px] font-medium leading-none"
|
||||
style={{
|
||||
background: `var(--mantine-color-${c}-0)`,
|
||||
color: `var(--mantine-color-${c}-7)`,
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
function Spark({ values, color }: { values: number[]; color: string }) {
|
||||
const max = Math.max(1, ...values);
|
||||
return (
|
||||
<div className="flex h-[26px] shrink-0 items-end gap-[3px]" aria-hidden>
|
||||
{values.map((v, i) => (
|
||||
<div
|
||||
key={i}
|
||||
className="w-1 rounded-sm"
|
||||
style={{
|
||||
height: Math.max(3, Math.round((v / max) * 26)),
|
||||
background: `var(--mantine-color-${color}-7)`,
|
||||
opacity: i === values.length - 1 ? 0.9 : 0.28,
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* A single bordered card divided into up to five KPI cells:
|
||||
* `[ kpi | kpi | kpi ]`. Hairline dividers separate cells (vertical on wide
|
||||
* screens, horizontal when they wrap). Surface, border and shadow all come from
|
||||
* the theme — no per-cell backgrounds, gradients or custom shadows.
|
||||
* `[ kpi | kpi | kpi ]`. Each cell stacks a tinted icon + label over a large
|
||||
* display-font value, with an optional hint/delta pill and a sparkline on the
|
||||
* right. Hairline dividers separate cells (vertical on wide screens,
|
||||
* horizontal when they wrap).
|
||||
*/
|
||||
export function KpiStrip({ items, loading = false }: KpiStripProps) {
|
||||
// The spec caps a strip at five cells; extra items are dropped rather than
|
||||
@@ -48,7 +92,7 @@ export function KpiStrip({ items, loading = false }: KpiStripProps) {
|
||||
const cells = items.slice(0, 5);
|
||||
|
||||
return (
|
||||
<Card withBorder shadow="sm" p={0} className="overflow-hidden">
|
||||
<Card withBorder shadow="sm" radius="lg" p={0} className="overflow-hidden">
|
||||
<div className="flex flex-col sm:flex-row">
|
||||
{cells.map((item, index) => {
|
||||
const Icon = item.icon;
|
||||
@@ -66,66 +110,63 @@ export function KpiStrip({ items, loading = false }: KpiStripProps) {
|
||||
className={cn(
|
||||
// min-w-0 lets a crowded strip (five cells, long labels)
|
||||
// truncate its labels instead of overflowing the card.
|
||||
"flex min-w-0 flex-1 items-center gap-3 px-5 py-4",
|
||||
"flex min-w-0 flex-1 flex-col justify-center gap-2 px-[18px] py-4",
|
||||
index > 0 &&
|
||||
"border-t border-edr-border sm:border-l sm:border-t-0",
|
||||
item.href &&
|
||||
"cursor-pointer no-underline transition-colors hover:bg-gray-50 focus-visible:bg-gray-50",
|
||||
)}
|
||||
>
|
||||
{Icon ? (
|
||||
<div
|
||||
className="flex size-10 shrink-0 items-center justify-center rounded-lg"
|
||||
style={{
|
||||
background: `var(--mantine-color-${color}-1)`,
|
||||
color: `var(--mantine-color-${color}-7)`,
|
||||
}}
|
||||
>
|
||||
<Icon size={20} strokeWidth={2} />
|
||||
</div>
|
||||
) : null}
|
||||
<div className="flex items-center gap-2">
|
||||
{Icon ? (
|
||||
<div
|
||||
className="flex size-7 shrink-0 items-center justify-center rounded-lg"
|
||||
style={{
|
||||
background: `var(--mantine-color-${color}-0)`,
|
||||
color: `var(--mantine-color-${color}-7)`,
|
||||
}}
|
||||
>
|
||||
<Icon size={14} strokeWidth={2} />
|
||||
</div>
|
||||
) : null}
|
||||
<Text fz={12} fw={500} c="edr-muted" truncate>
|
||||
{item.label}
|
||||
</Text>
|
||||
</div>
|
||||
|
||||
<div style={{ minWidth: 0 }}>
|
||||
{loading ? (
|
||||
<Skeleton height={26} width={72} radius="sm" my={2} />
|
||||
) : (
|
||||
<div className="flex items-baseline gap-2">
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<div className="flex min-w-0 items-center gap-2">
|
||||
{loading ? (
|
||||
<Skeleton height={28} width={64} radius="sm" />
|
||||
) : (
|
||||
<Text
|
||||
fw={800}
|
||||
fz={24}
|
||||
lh={1.05}
|
||||
ff="heading"
|
||||
fw={600}
|
||||
fz={27}
|
||||
lh={1}
|
||||
c="edr-text"
|
||||
style={{ letterSpacing: "-0.02em" }}
|
||||
style={{ letterSpacing: "-0.03em" }}
|
||||
truncate
|
||||
>
|
||||
{item.value}
|
||||
</Text>
|
||||
{item.delta != null && item.delta !== 0 ? (
|
||||
<Text
|
||||
component="span"
|
||||
fz="xs"
|
||||
fw={700}
|
||||
c={item.delta > 0 ? "edr-green.7" : "red.7"}
|
||||
style={{
|
||||
whiteSpace: "nowrap",
|
||||
background:
|
||||
item.delta > 0
|
||||
? "var(--mantine-color-edr-green-0)"
|
||||
: "var(--mantine-color-red-0)",
|
||||
borderRadius: 999,
|
||||
padding: "1px 7px",
|
||||
}}
|
||||
>
|
||||
{item.delta > 0 ? "▲" : "▼"}
|
||||
{Math.abs(item.delta)}%
|
||||
</Text>
|
||||
) : null}
|
||||
</div>
|
||||
)}
|
||||
<Text size="xs" fw={600} c="edr-muted" truncate>
|
||||
{item.label}
|
||||
{item.hint ? ` · ${item.hint}` : ""}
|
||||
</Text>
|
||||
)}
|
||||
{!loading && item.hint ? (
|
||||
<Pill tone="green">
|
||||
<ArrowUpRight size={10} />
|
||||
{item.hint}
|
||||
</Pill>
|
||||
) : null}
|
||||
{!loading && item.delta != null && item.delta !== 0 ? (
|
||||
<Pill tone={item.delta > 0 ? "green" : "red"}>
|
||||
{item.delta > 0 ? "▲" : "▼"}
|
||||
{Math.abs(item.delta)}%
|
||||
</Pill>
|
||||
) : null}
|
||||
</div>
|
||||
{item.spark?.length ? (
|
||||
<Spark values={item.spark} color={color} />
|
||||
) : null}
|
||||
</div>
|
||||
</Cell>
|
||||
);
|
||||
|
||||
@@ -0,0 +1,75 @@
|
||||
import { Group, Pagination, Select, Text } from "@mantine/core";
|
||||
import type { DataTableFooterProps } from "@edr/ui-common";
|
||||
|
||||
export interface TablePagerProps<T> extends DataTableFooterProps<T> {
|
||||
/** Plural noun for the row count — "Showing 1–10 of 48 shipments". */
|
||||
noun?: string;
|
||||
pageSizes?: number[];
|
||||
}
|
||||
|
||||
/**
|
||||
* DataTable footer: row range on the left, rows-per-page select + numbered
|
||||
* pager on the right. Pass via `footer={(p) => <TablePager {...p} noun="…" />}`.
|
||||
*/
|
||||
export function TablePager<T>({
|
||||
table,
|
||||
pagination,
|
||||
noun = "rows",
|
||||
pageSizes = [10, 25, 50],
|
||||
}: TablePagerProps<T>) {
|
||||
const pageIndex = pagination.pageIndex ?? 0;
|
||||
const pageSize = pagination.pageSize ?? 10;
|
||||
const total = pagination.totalCount ?? 0;
|
||||
const pageCount = Math.max(
|
||||
1,
|
||||
pagination.pageCount ?? Math.ceil(total / pageSize),
|
||||
);
|
||||
const start = total === 0 ? 0 : pageIndex * pageSize + 1;
|
||||
const end = Math.min((pageIndex + 1) * pageSize, total);
|
||||
|
||||
return (
|
||||
<Group
|
||||
justify="space-between"
|
||||
gap="sm"
|
||||
wrap="wrap"
|
||||
px="md"
|
||||
py={10}
|
||||
style={{ borderTop: "1px solid var(--mantine-color-edr-divider-6)" }}
|
||||
>
|
||||
<Text fz={12} c="edr-muted">
|
||||
Showing {start}–{end} of {total} {noun}
|
||||
</Text>
|
||||
<Group gap="sm" wrap="nowrap">
|
||||
<Group gap={6} wrap="nowrap">
|
||||
<Text fz={12} c="edr-muted">
|
||||
Rows
|
||||
</Text>
|
||||
<Select
|
||||
size="xs"
|
||||
w={70}
|
||||
radius="md"
|
||||
value={String(pageSize)}
|
||||
data={pageSizes.map(String)}
|
||||
onChange={(v) => v && table.setPageSize(Number(v))}
|
||||
allowDeselect={false}
|
||||
comboboxProps={{ withinPortal: true }}
|
||||
aria-label="Rows per page"
|
||||
/>
|
||||
</Group>
|
||||
<div className="h-5 w-px bg-edr-border" />
|
||||
<Pagination
|
||||
size="sm"
|
||||
radius="md"
|
||||
color="edr-ink"
|
||||
total={pageCount}
|
||||
value={pageIndex + 1}
|
||||
onChange={(p) => table.setPageIndex(p - 1)}
|
||||
siblings={1}
|
||||
boundaries={1}
|
||||
/>
|
||||
</Group>
|
||||
</Group>
|
||||
);
|
||||
}
|
||||
|
||||
export default TablePager;
|
||||
Reference in New Issue
Block a user