feat(dashboard): enhance dashboard with stat tiles for better data visualization

This commit is contained in:
Fistum
2026-08-21 13:33:07 +00:00
parent 62492e957b
commit 81d3ebdcd9
5 changed files with 312 additions and 63 deletions

View File

@@ -27,6 +27,7 @@ export * from "./lib/input/PhoneInput";
export * from "./lib/input/phone";
export * from "./lib/data/AdvancedTable";
export * from "./lib/data/WaitingFor";
export * from "./lib/data/StatTile";
export * from "./lib/feedback/use-error-handler";
export * from "./lib/data/useServerTable";
export * from "./lib/landing/LandingPage";

View File

@@ -0,0 +1,83 @@
import type { ReactNode } from 'react';
import { Group, Paper, Text, ThemeIcon, UnstyledButton } from '@mantine/core';
import type { Icon } from '@tabler/icons-react';
import { STATUS_TONE_COLOR, type StatusTone } from '@ema-platform/shared';
import './stat-tile.css';
export interface StatTileProps {
label: string;
/** The number itself. A string so callers can pass "—" while loading. */
value: ReactNode;
/** One line under the value: what the number means, or how it is trending. */
hint?: ReactNode;
icon?: Icon;
/**
* Which tone the icon carries. Tone rather than colour so a tile counting
* overdue work is the same red as an overdue badge.
*/
tone?: StatusTone;
/** Makes the whole tile a button — use when the number has somewhere to go. */
onClick?: () => void;
}
/**
* One figure on a dashboard.
*
* The four stat cards on the backoffice home were `<Card>` + two `<Text>`,
* re-declared inline on every dashboard that wanted them — so the logistics
* overview and the backoffice home showed the same kind of number at different
* sizes. The number leads, the label sits above it small and quiet, and the
* icon is decoration that carries the tone.
*
* A tile with `onClick` becomes a real button: dashboards exist to be a
* jumping-off point, and a count you cannot click is a dead end.
*/
export function StatTile({ label, value, hint, icon: TileIcon, tone, onClick }: StatTileProps) {
const body = (
<>
<Group justify="space-between" align="flex-start" wrap="nowrap">
<Text size="xs" c="dimmed" tt="uppercase" fw={700} lh={1.4}>
{label}
</Text>
{TileIcon && (
<ThemeIcon
size={38}
radius="md"
variant="light"
color={tone ? STATUS_TONE_COLOR[tone] : undefined}
>
<TileIcon size={20} stroke={1.7} />
</ThemeIcon>
)}
</Group>
<Text fz={30} fw={800} lh={1.15} mt="xs">
{value}
</Text>
{hint && (
<Text size="xs" c="dimmed" mt={2}>
{hint}
</Text>
)}
</>
);
if (!onClick) {
return (
<Paper withBorder radius="lg" p="lg">
{body}
</Paper>
);
}
return (
<UnstyledButton
onClick={onClick}
className="ema-stat-tile-clickable"
style={{ display: 'block', width: '100%', height: '100%' }}
>
<Paper withBorder radius="lg" p="lg" h="100%">
{body}
</Paper>
</UnstyledButton>
);
}

View File

@@ -0,0 +1,16 @@
/*
* A clickable StatTile. The hover cue lives here rather than in inline style
* handlers so it can use a real `:hover` — a JS mouseenter/leave pair misses
* keyboard focus, and `:focus-visible` needs the app's focus ring anyway.
*/
.ema-stat-tile-clickable > * {
transition: border-color 120ms ease, box-shadow 120ms ease;
}
.ema-stat-tile-clickable:hover > * {
border-color: var(--mantine-primary-color-filled);
}
.ema-stat-tile-clickable:focus-visible > * {
border-color: var(--mantine-primary-color-filled);
}

View File

@@ -1,4 +1,5 @@
import {
SimpleGrid,
Alert,
Anchor,
Badge,
@@ -22,7 +23,20 @@ import {
Title,
useMantineTheme,
} from '@mantine/core';
import { IconAlertTriangle, IconInbox } from '@tabler/icons-react';
import { SkipLink, MAIN_CONTENT_ID } from '../layout/SkipLink';
import { StatusBadge } from '../feedback/StatusBadge';
import { WaitingFor } from '../data/WaitingFor';
import { StatTile } from '../data/StatTile';
/**
* Fixed clock. The gallery is screenshotted by the visual suite, so a tile
* reading "3d" must read "3d" tomorrow too — `Date.now()` would rewrite the
* baseline every day.
*/
const GALLERY_NOW = '2026-08-21T00:00:00.000Z';
const daysAgo = (n: number) =>
new Date(Date.parse(GALLERY_NOW) - n * 86_400_000).toISOString();
/**
* Every primitive the theme controls, on one page.
@@ -264,8 +278,38 @@ export function ThemeGallery() {
</Tabs>
</Section>
<Section title="Status badges">
<Group>
<StatusBadge tone="success" label="Approved" />
<StatusBadge tone="warning" label="Expiring" />
<StatusBadge tone="danger" label="Rejected" />
<StatusBadge tone="info" label="Submitted" />
<StatusBadge tone="pending" label="Corrections requested" />
<StatusBadge tone="neutral" label="Draft" />
</Group>
</Section>
<Section title="Waiting for">
<Group>
<WaitingFor since={GALLERY_NOW} />
<WaitingFor since={daysAgo(9)} />
<WaitingFor since={daysAgo(16)} />
<WaitingFor since={daysAgo(30)} done />
<WaitingFor since={null} />
</Group>
</Section>
<Section title="Stat tiles">
<SimpleGrid cols={{ base: 1, sm: 2, lg: 4 }}>
<StatTile label="Awaiting claim" value={12} hint="Nobody has picked these up" icon={IconInbox} tone="info" />
<StatTile label="Needs applicant action" value={3} hint="Returned for corrections" icon={IconAlertTriangle} tone="pending" />
<StatTile label="Overdue" value={2} hint="Past the turnaround target" icon={IconAlertTriangle} tone="danger" />
<StatTile label="Not permitted" value="—" hint="No access to this queue" icon={IconInbox} tone="neutral" />
</SimpleGrid>
</Section>
<Section title="Table">
<Table striped highlightOnHover withTableBorder>
<Table>
<Table.Thead>
<Table.Tr>
<Table.Th>Seafarer ID</Table.Th>