mirror of
https://github.com/Tria-plc/emaui.git
synced 2026-08-26 19:12:50 +00:00
feat(dashboard): enhance dashboard with stat tiles for better data visualization
This commit is contained in:
@@ -1,16 +1,42 @@
|
|||||||
import { useNavigate } from 'react-router-dom';
|
import { useNavigate } from 'react-router-dom';
|
||||||
import {Card, Center, Container, Group, Loader, SimpleGrid, Text} from '@mantine/core';
|
import { Anchor, Grid, Group, Paper, SimpleGrid, Stack, Text } from '@mantine/core';
|
||||||
import { IconChevronRight } from '@tabler/icons-react';
|
import {
|
||||||
import { useGetAssignedToMeQuery, useGetQueueQuery } from '@ema-platform/api';
|
IconAlertTriangle,
|
||||||
import { AdvancedTable, PageHeader, PageLoader, useServerTable } from '@ema-platform/ui';
|
IconCreditCard,
|
||||||
|
IconFileText,
|
||||||
|
IconInbox,
|
||||||
|
IconUserCheck,
|
||||||
|
} from '@tabler/icons-react';
|
||||||
|
import {
|
||||||
|
useGetAssignedToMeQuery,
|
||||||
|
useGetQueueQuery,
|
||||||
|
useListSeafarerDocumentsQuery,
|
||||||
|
useListSeafarerRegistrationsQuery,
|
||||||
|
type LicenseApplication,
|
||||||
|
} from '@ema-platform/api';
|
||||||
|
import {
|
||||||
|
AdvancedTable,
|
||||||
|
PageHeader,
|
||||||
|
PageLoader,
|
||||||
|
StatTile,
|
||||||
|
WaitingFor,
|
||||||
|
useServerTable,
|
||||||
|
} from '@ema-platform/ui';
|
||||||
import { dashboardQueueColumns } from './columns';
|
import { dashboardQueueColumns } from './columns';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Backoffice home.
|
* Backoffice home.
|
||||||
*
|
*
|
||||||
* Shows the licence pipeline, which is the part of the platform that has real
|
* Every figure here is counted from a queue the officer can open, and each
|
||||||
* data behind it. The previous version charted invented registration volumes
|
* tile navigates to the list it counted — a dashboard that cannot be drilled
|
||||||
* and a fictional breakdown of staff roles.
|
* into is a poster. Nothing is charted: the platform exposes queues, not time
|
||||||
|
* series, and an earlier version of this page invented both a registration
|
||||||
|
* trend and a staff-role breakdown rather than admit that.
|
||||||
|
*
|
||||||
|
* The seafarer counts are fetched with `take: 1`, for `total` alone. Both
|
||||||
|
* queues are permission-gated and an officer without them simply gets no
|
||||||
|
* count — never a broken page — so the tiles read `—` rather than `0`, which
|
||||||
|
* would be a lie.
|
||||||
*/
|
*/
|
||||||
export function DashboardPage() {
|
export function DashboardPage() {
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
@@ -18,6 +44,13 @@ export function DashboardPage() {
|
|||||||
const mine = useGetAssignedToMeQuery();
|
const mine = useGetAssignedToMeQuery();
|
||||||
const table = useServerTable();
|
const table = useServerTable();
|
||||||
|
|
||||||
|
const registrations = useListSeafarerRegistrationsQuery({ status: 'SUBMITTED', take: 1 });
|
||||||
|
const seamanBooks = useListSeafarerDocumentsQuery({
|
||||||
|
kind: 'SEAMAN_BOOK',
|
||||||
|
status: 'PAYMENT_PENDING',
|
||||||
|
take: 1,
|
||||||
|
});
|
||||||
|
|
||||||
if (queue.isLoading || mine.isLoading) {
|
if (queue.isLoading || mine.isLoading) {
|
||||||
return <PageLoader label="Loading Backoffice Dashboard…" height={400} />;
|
return <PageLoader label="Loading Backoffice Dashboard…" height={400} />;
|
||||||
}
|
}
|
||||||
@@ -25,58 +58,88 @@ export function DashboardPage() {
|
|||||||
const unclaimed = queue.data?.items ?? [];
|
const unclaimed = queue.data?.items ?? [];
|
||||||
const inProgress = mine.data?.items ?? [];
|
const inProgress = mine.data?.items ?? [];
|
||||||
const all = [...unclaimed, ...inProgress];
|
const all = [...unclaimed, ...inProgress];
|
||||||
const paged = table.paginate(unclaimed.slice(0, 8));
|
|
||||||
|
|
||||||
const stats = [
|
const needsApplicant = all.filter((a) => a.status === 'RESUBMIT_REQUIRED').length;
|
||||||
{ label: 'Awaiting claim', value: unclaimed.length, color: 'blue' },
|
const awaitingPayment = all.filter((a) => a.status === 'PAYMENT_PENDING').length;
|
||||||
{ label: 'Assigned to me', value: inProgress.length, color: 'indigo' },
|
|
||||||
{
|
/** Oldest first: a queue is worked by age, so the dashboard previews it that way. */
|
||||||
label: 'Needs applicant action',
|
const byAge = [...unclaimed].sort((a, b) =>
|
||||||
value: all.filter((a) => a.status === 'RESUBMIT_REQUIRED').length,
|
(a.submittedAt ?? a.createdAt).localeCompare(b.submittedAt ?? b.createdAt),
|
||||||
color: 'orange',
|
);
|
||||||
},
|
const paged = table.paginate(byAge.slice(0, 8));
|
||||||
{
|
|
||||||
label: 'Awaiting payment',
|
/** `undefined` while loading or forbidden — rendered as "—", never as 0. */
|
||||||
value: all.filter((a) => a.status === 'PAYMENT_PENDING').length,
|
const countOf = (q: { data?: { total: number }; isError: boolean }) =>
|
||||||
color: 'yellow',
|
q.isError ? undefined : q.data?.total;
|
||||||
},
|
|
||||||
];
|
const show = (n: number | undefined) => (n === undefined ? '—' : n);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Container size="xl" py="md">
|
<Stack gap="lg">
|
||||||
<PageHeader
|
<PageHeader
|
||||||
title="Dashboard"
|
title="Dashboard"
|
||||||
subtitle="Licence applications currently in the system."
|
subtitle="Work waiting across the Authority's review queues."
|
||||||
|
noMargin
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<SimpleGrid cols={{ base: 1, sm: 2, md: 4 }} mb="xl">
|
<SimpleGrid cols={{ base: 1, sm: 2, lg: 4 }}>
|
||||||
{stats.map((stat) => (
|
<StatTile
|
||||||
<Card withBorder key={stat.label} padding="md" radius="md">
|
label="Awaiting claim"
|
||||||
<Text size="xs" c="dimmed" tt="uppercase" fw={600}>
|
value={unclaimed.length}
|
||||||
{stat.label}
|
hint="Licence applications nobody has picked up"
|
||||||
</Text>
|
icon={IconInbox}
|
||||||
<Text fz={32} fw={700} c={stat.color} lh={1.2}>
|
tone="info"
|
||||||
{stat.value}
|
onClick={() => navigate('/licence-review')}
|
||||||
</Text>
|
/>
|
||||||
</Card>
|
<StatTile
|
||||||
))}
|
label="Assigned to me"
|
||||||
|
value={inProgress.length}
|
||||||
|
hint="Your open licence reviews"
|
||||||
|
icon={IconFileText}
|
||||||
|
tone="neutral"
|
||||||
|
onClick={() => navigate('/licence-review')}
|
||||||
|
/>
|
||||||
|
<StatTile
|
||||||
|
label="Needs applicant action"
|
||||||
|
value={needsApplicant}
|
||||||
|
hint="Returned for corrections"
|
||||||
|
icon={IconAlertTriangle}
|
||||||
|
tone="pending"
|
||||||
|
/>
|
||||||
|
<StatTile
|
||||||
|
label="Awaiting payment"
|
||||||
|
value={awaitingPayment}
|
||||||
|
hint="Approved, fee not yet settled"
|
||||||
|
icon={IconCreditCard}
|
||||||
|
tone="warning"
|
||||||
|
/>
|
||||||
</SimpleGrid>
|
</SimpleGrid>
|
||||||
|
|
||||||
<Card withBorder padding={0} radius="md">
|
{/* Same 4-column track as the row above, so a two-tile row lines up with
|
||||||
<Group justify="space-between" p="md" pb="xs">
|
it instead of stretching each tile to half the page. */}
|
||||||
<Text fw={600} size="sm">
|
<SimpleGrid cols={{ base: 1, sm: 2, lg: 4 }}>
|
||||||
Awaiting claim
|
<StatTile
|
||||||
</Text>
|
label="Seafarer registrations"
|
||||||
<Text
|
value={show(countOf(registrations))}
|
||||||
size="xs"
|
hint="Submitted, awaiting review"
|
||||||
c="blue"
|
icon={IconUserCheck}
|
||||||
style={{ cursor: 'pointer' }}
|
tone="info"
|
||||||
onClick={() => navigate('/licence-review')}
|
onClick={() => navigate('/seafarer-registrations')}
|
||||||
>
|
/>
|
||||||
Open queue <IconChevronRight size={11} style={{ verticalAlign: -1 }} />
|
<StatTile
|
||||||
</Text>
|
label="Seaman books"
|
||||||
</Group>
|
value={show(countOf(seamanBooks))}
|
||||||
<AdvancedTable
|
hint="Released, awaiting payment"
|
||||||
|
icon={IconCreditCard}
|
||||||
|
tone="pending"
|
||||||
|
onClick={() => navigate('/seaman-book-queue')}
|
||||||
|
/>
|
||||||
|
</SimpleGrid>
|
||||||
|
|
||||||
|
<Grid gutter="lg">
|
||||||
|
<Grid.Col span={{ base: 12, lg: 8 }}>
|
||||||
|
<AdvancedTable<LicenseApplication>
|
||||||
|
title="Awaiting claim — oldest first"
|
||||||
tableName="Awaiting claim"
|
tableName="Awaiting claim"
|
||||||
columns={dashboardQueueColumns}
|
columns={dashboardQueueColumns}
|
||||||
data={paged.rows}
|
data={paged.rows}
|
||||||
@@ -84,12 +147,54 @@ export function DashboardPage() {
|
|||||||
pageIndex={paged.pageIndex}
|
pageIndex={paged.pageIndex}
|
||||||
onPageChange={table.setPageIndex}
|
onPageChange={table.setPageIndex}
|
||||||
pageSize={table.pageSize}
|
pageSize={table.pageSize}
|
||||||
onRowClick={() => navigate('/licence-review')}
|
onRowClick={(row) => navigate(`/licence-review/${row.id}`)}
|
||||||
refresh={queue.refetch}
|
refresh={queue.refetch}
|
||||||
|
isLoading={queue.isFetching}
|
||||||
emptyText="Nothing waiting to be claimed."
|
emptyText="Nothing waiting to be claimed."
|
||||||
|
toolbar={
|
||||||
|
<Anchor size="sm" onClick={() => navigate('/licence-review')}>
|
||||||
|
Open queue
|
||||||
|
</Anchor>
|
||||||
|
}
|
||||||
/>
|
/>
|
||||||
</Card>
|
</Grid.Col>
|
||||||
</Container>
|
|
||||||
|
<Grid.Col span={{ base: 12, lg: 4 }}>
|
||||||
|
<Paper withBorder radius="lg" p="lg" h="100%">
|
||||||
|
<Text fw={600} size="sm" mb="xs">
|
||||||
|
Longest waiting
|
||||||
|
</Text>
|
||||||
|
<Text size="xs" c="dimmed" mb="md">
|
||||||
|
Unclaimed applications, by how long they have sat.
|
||||||
|
</Text>
|
||||||
|
<Stack gap="sm">
|
||||||
|
{byAge.slice(0, 5).map((app) => (
|
||||||
|
<Group key={app.id} justify="space-between" wrap="nowrap" gap="sm">
|
||||||
|
<Anchor
|
||||||
|
size="sm"
|
||||||
|
lineClamp={1}
|
||||||
|
onClick={() => navigate(`/licence-review/${app.id}`)}
|
||||||
|
>
|
||||||
|
{app.applicationNumber}
|
||||||
|
</Anchor>
|
||||||
|
<WaitingFor
|
||||||
|
since={app.submittedAt ?? app.createdAt}
|
||||||
|
slaDays={
|
||||||
|
app.licenseType?.slaHours ? app.licenseType.slaHours / 24 : undefined
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</Group>
|
||||||
|
))}
|
||||||
|
{byAge.length === 0 && (
|
||||||
|
<Text size="sm" c="dimmed">
|
||||||
|
Nothing waiting.
|
||||||
|
</Text>
|
||||||
|
)}
|
||||||
|
</Stack>
|
||||||
|
</Paper>
|
||||||
|
</Grid.Col>
|
||||||
|
</Grid>
|
||||||
|
</Stack>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -27,6 +27,7 @@ export * from "./lib/input/PhoneInput";
|
|||||||
export * from "./lib/input/phone";
|
export * from "./lib/input/phone";
|
||||||
export * from "./lib/data/AdvancedTable";
|
export * from "./lib/data/AdvancedTable";
|
||||||
export * from "./lib/data/WaitingFor";
|
export * from "./lib/data/WaitingFor";
|
||||||
|
export * from "./lib/data/StatTile";
|
||||||
export * from "./lib/feedback/use-error-handler";
|
export * from "./lib/feedback/use-error-handler";
|
||||||
export * from "./lib/data/useServerTable";
|
export * from "./lib/data/useServerTable";
|
||||||
export * from "./lib/landing/LandingPage";
|
export * from "./lib/landing/LandingPage";
|
||||||
|
|||||||
83
libs/ui/src/lib/data/StatTile.tsx
Normal file
83
libs/ui/src/lib/data/StatTile.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
16
libs/ui/src/lib/data/stat-tile.css
Normal file
16
libs/ui/src/lib/data/stat-tile.css
Normal 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);
|
||||||
|
}
|
||||||
@@ -1,4 +1,5 @@
|
|||||||
import {
|
import {
|
||||||
|
SimpleGrid,
|
||||||
Alert,
|
Alert,
|
||||||
Anchor,
|
Anchor,
|
||||||
Badge,
|
Badge,
|
||||||
@@ -22,7 +23,20 @@ import {
|
|||||||
Title,
|
Title,
|
||||||
useMantineTheme,
|
useMantineTheme,
|
||||||
} from '@mantine/core';
|
} from '@mantine/core';
|
||||||
|
import { IconAlertTriangle, IconInbox } from '@tabler/icons-react';
|
||||||
import { SkipLink, MAIN_CONTENT_ID } from '../layout/SkipLink';
|
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.
|
* Every primitive the theme controls, on one page.
|
||||||
@@ -264,8 +278,38 @@ export function ThemeGallery() {
|
|||||||
</Tabs>
|
</Tabs>
|
||||||
</Section>
|
</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">
|
<Section title="Table">
|
||||||
<Table striped highlightOnHover withTableBorder>
|
<Table>
|
||||||
<Table.Thead>
|
<Table.Thead>
|
||||||
<Table.Tr>
|
<Table.Tr>
|
||||||
<Table.Th>Seafarer ID</Table.Th>
|
<Table.Th>Seafarer ID</Table.Th>
|
||||||
|
|||||||
Reference in New Issue
Block a user