diff --git a/apps/edr-freight-web/backoffice/src/components/bookings/booking-ui.styles.ts b/apps/edr-freight-web/backoffice/src/components/bookings/booking-ui.styles.ts index de357fa05..fa1f3e882 100644 --- a/apps/edr-freight-web/backoffice/src/components/bookings/booking-ui.styles.ts +++ b/apps/edr-freight-web/backoffice/src/components/bookings/booking-ui.styles.ts @@ -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}`, diff --git a/apps/edr-freight-web/backoffice/src/components/contracts/GlUpcomingWindowsSection.tsx b/apps/edr-freight-web/backoffice/src/components/contracts/GlUpcomingWindowsSection.tsx index df1a66330..0f3443633 100644 --- a/apps/edr-freight-web/backoffice/src/components/contracts/GlUpcomingWindowsSection.tsx +++ b/apps/edr-freight-web/backoffice/src/components/contracts/GlUpcomingWindowsSection.tsx @@ -122,7 +122,10 @@ function phaseCountdown(w: WindowRow): { } /** Badge label + Mantine color per UI state — same state the countdown uses. */ -const KIND_BADGE: Record = { +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 ( - - - + + +
+ +
- + Booking windows - + {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({ ))} ) : ( - + {visible.map((w) => ( ))} diff --git a/apps/edr-freight-web/backoffice/src/components/page/KpiStrip.tsx b/apps/edr-freight-web/backoffice/src/components/page/KpiStrip.tsx index 15e2f4425..5aff5afe8 100644 --- a/apps/edr-freight-web/backoffice/src/components/page/KpiStrip.tsx +++ b/apps/edr-freight-web/backoffice/src/components/page/KpiStrip.tsx @@ -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 ( + + {children} + + ); +} + +function Spark({ values, color }: { values: number[]; color: string }) { + const max = Math.max(1, ...values); + return ( +
+ {values.map((v, i) => ( +
+ ))} +
+ ); +} + /** * 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 ( - +
{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 ? ( -
- -
- ) : null} +
+ {Icon ? ( +
+ +
+ ) : null} + + {item.label} + +
-
- {loading ? ( - - ) : ( -
+
+
+ {loading ? ( + + ) : ( {item.value} - {item.delta != null && item.delta !== 0 ? ( - 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)}% - - ) : null} -
- )} - - {item.label} - {item.hint ? ` · ${item.hint}` : ""} - + )} + {!loading && item.hint ? ( + + + {item.hint} + + ) : null} + {!loading && item.delta != null && item.delta !== 0 ? ( + 0 ? "green" : "red"}> + {item.delta > 0 ? "▲" : "▼"} + {Math.abs(item.delta)}% + + ) : null} +
+ {item.spark?.length ? ( + + ) : null}
); diff --git a/apps/edr-freight-web/backoffice/src/components/page/TablePager.tsx b/apps/edr-freight-web/backoffice/src/components/page/TablePager.tsx new file mode 100644 index 000000000..413075b7d --- /dev/null +++ b/apps/edr-freight-web/backoffice/src/components/page/TablePager.tsx @@ -0,0 +1,75 @@ +import { Group, Pagination, Select, Text } from "@mantine/core"; +import type { DataTableFooterProps } from "@edr/ui-common"; + +export interface TablePagerProps extends DataTableFooterProps { + /** 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) => }`. + */ +export function TablePager({ + table, + pagination, + noun = "rows", + pageSizes = [10, 25, 50], +}: TablePagerProps) { + 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 ( + + + Showing {start}–{end} of {total} {noun} + + + + + Rows + + { + setFreight(v); + resetPage(); + }} + clearable + radius="md" + size="sm" + w={124} + comboboxProps={{ withinPortal: true }} + aria-label="Filter by freight type" + /> + { - setDirection(v); - resetPage(); - }} - clearable - radius="lg" - w={130} - /> - { - setStatus(v); - resetPage(); - }} - clearable - radius="lg" - w={190} - /> - { + setFreight(v); + resetPage(); + }} + clearable + radius="md" + size="sm" + w={124} + comboboxProps={{ withinPortal: true }} + aria-label="Filter by freight type" + /> + { + setAction(v); + resetPage(); + }} + clearable + radius="md" + size="sm" + w={170} + comboboxProps={{ withinPortal: true }} + aria-label="Filter by DJ action" + /> + {hasFilters ? ( + + ) : null} + + + {!isLoading && !isError && total === 0 ? ( {hasFilters - ? "No records match these filters." + ? "No shipments match these filters." : "No shipments awaiting a Djibouti action."} {hasFilters ? ( @@ -686,7 +825,7 @@ export default function GlDjiboutiClearanceListPage() { ) : null} ) : ( - + columns={shipmentColumns} data={pagedShipmentRows} @@ -705,7 +844,7 @@ export default function GlDjiboutiClearanceListPage() { pageCount, }} containerClassName="edr-clearance-table border-0 shadow-none rounded-none bg-transparent" - footer={DataTableFooter} + footer={(p) => } /> )} diff --git a/apps/edr-freight-web/backoffice/src/pages/contracts/contract-clearance-table.css b/apps/edr-freight-web/backoffice/src/pages/contracts/contract-clearance-table.css index bdc615fb8..85b25f889 100644 --- a/apps/edr-freight-web/backoffice/src/pages/contracts/contract-clearance-table.css +++ b/apps/edr-freight-web/backoffice/src/pages/contracts/contract-clearance-table.css @@ -32,6 +32,22 @@ white-space: nowrap; } +/* + * Booking (col 1) and Contract (col 2) carry free-text company/contract names. + * Cap those two columns and let their content wrap onto 2+ lines so a very long + * name (e.g. "SHAFICI PHARMACEUTICAL MEDICAL SUPPLIES WHOLESALER PARTINERSHIP") + * stacks inside its own cell instead of shoving the next column off-screen. + * Everything below the header row so the header labels still sit on one line. + */ +.edr-clearance-table tbody td:not([colspan]):nth-child(1) { + max-width: 240px; + white-space: normal; +} +.edr-clearance-table tbody td:not([colspan]):nth-child(2) { + max-width: 200px; + white-space: normal; +} + /* * Mantine Badge caps itself at max-width: 100%; inside an auto-layout table * cell that resolves against min-content and clips the label. Let badges size @@ -41,6 +57,22 @@ max-width: none; } +/* + * Opt-out for long free text (company/customer names). The blanket nowrap rule + * above keeps every cell on one line so columns size to content; a very long + * name would otherwise force the column absurdly wide. Mark such text with + * `cell-wrap` to cap it and wrap onto 2+ lines instead of pushing the layout. + */ +.edr-clearance-table .cell-wrap, +.edr-clearance-table .mantine-Group-root > .cell-wrap { + white-space: normal; + overflow-wrap: anywhere; + word-break: break-word; + min-width: 0; + max-width: 100%; + line-height: 1.3; +} + /* * Mantine Group's preventGrowOverflow caps every child at 100%/N of the cell. * In an auto-width table cell that resolves against min-content and collapses @@ -70,7 +102,7 @@ min-width: 0; position: sticky; right: 0; - box-shadow: -12px 0 16px -6px rgba(16, 32, 47, 0.3); + box-shadow: -10px 0 14px -8px rgba(16, 32, 47, 0.12); } /* @@ -78,17 +110,41 @@ * background or the columns underneath show through. */ .edr-clearance-table td:last-child:not([colspan]) { - background: #f5f8fb; + background: var(--mantine-color-body); z-index: 2; } /* Row hover uses the tailwind `hover:bg-accent` class on the . */ .edr-clearance-table tbody tr:hover td:last-child:not([colspan]) { - background: var(--accent, #f4fbf8); + background: #f7fbf9; } /* Header cell is sticky on both axes — it must outrank the body's sticky column. */ .edr-clearance-table th:last-child { - background: #f4f7fa; + background: var(--mantine-color-gray-0); z-index: 3; } + +/* ── Design pass: flat head band, 64px rows, hairline dividers ─────────── */ +.edr-clearance-table thead th { + height: 38px; + padding-top: 0; + padding-bottom: 0; + background: var(--mantine-color-gray-0); + border-bottom: 1px solid var(--mantine-color-edr-divider-6); +} + +.edr-clearance-table tbody td:not([colspan]) { + height: 64px; + padding-top: 8px; + padding-bottom: 8px; + border-bottom: 1px solid var(--mantine-color-edr-divider-6); +} + +.edr-clearance-table tbody tr:last-child td:not([colspan]) { + border-bottom: 0; +} + +.edr-clearance-table tbody tr:hover td { + background: #f7fbf9; +}