diff --git a/apps/backoffice/src/app/features/dashboard/pages/DashboardPage/index.tsx b/apps/backoffice/src/app/features/dashboard/pages/DashboardPage/index.tsx
index 41b363b6c..0d5064448 100644
--- a/apps/backoffice/src/app/features/dashboard/pages/DashboardPage/index.tsx
+++ b/apps/backoffice/src/app/features/dashboard/pages/DashboardPage/index.tsx
@@ -1,16 +1,42 @@
import { useNavigate } from 'react-router-dom';
-import {Card, Center, Container, Group, Loader, SimpleGrid, Text} from '@mantine/core';
-import { IconChevronRight } from '@tabler/icons-react';
-import { useGetAssignedToMeQuery, useGetQueueQuery } from '@ema-platform/api';
-import { AdvancedTable, PageHeader, PageLoader, useServerTable } from '@ema-platform/ui';
+import { Anchor, Grid, Group, Paper, SimpleGrid, Stack, Text } from '@mantine/core';
+import {
+ IconAlertTriangle,
+ 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';
/**
* Backoffice home.
*
- * Shows the licence pipeline, which is the part of the platform that has real
- * data behind it. The previous version charted invented registration volumes
- * and a fictional breakdown of staff roles.
+ * Every figure here is counted from a queue the officer can open, and each
+ * tile navigates to the list it counted — a dashboard that cannot be drilled
+ * 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() {
const navigate = useNavigate();
@@ -18,6 +44,13 @@ export function DashboardPage() {
const mine = useGetAssignedToMeQuery();
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) {
return ;
}
@@ -25,71 +58,143 @@ export function DashboardPage() {
const unclaimed = queue.data?.items ?? [];
const inProgress = mine.data?.items ?? [];
const all = [...unclaimed, ...inProgress];
- const paged = table.paginate(unclaimed.slice(0, 8));
- const stats = [
- { label: 'Awaiting claim', value: unclaimed.length, color: 'blue' },
- { label: 'Assigned to me', value: inProgress.length, color: 'indigo' },
- {
- label: 'Needs applicant action',
- value: all.filter((a) => a.status === 'RESUBMIT_REQUIRED').length,
- color: 'orange',
- },
- {
- label: 'Awaiting payment',
- value: all.filter((a) => a.status === 'PAYMENT_PENDING').length,
- color: 'yellow',
- },
- ];
+ const needsApplicant = all.filter((a) => a.status === 'RESUBMIT_REQUIRED').length;
+ const awaitingPayment = all.filter((a) => a.status === 'PAYMENT_PENDING').length;
+
+ /** Oldest first: a queue is worked by age, so the dashboard previews it that way. */
+ const byAge = [...unclaimed].sort((a, b) =>
+ (a.submittedAt ?? a.createdAt).localeCompare(b.submittedAt ?? b.createdAt),
+ );
+ const paged = table.paginate(byAge.slice(0, 8));
+
+ /** `undefined` while loading or forbidden — rendered as "—", never as 0. */
+ const countOf = (q: { data?: { total: number }; isError: boolean }) =>
+ q.isError ? undefined : q.data?.total;
+
+ const show = (n: number | undefined) => (n === undefined ? '—' : n);
return (
-
+
-
- {stats.map((stat) => (
-
-
- {stat.label}
-
-
- {stat.value}
-
-
- ))}
+
+ navigate('/licence-review')}
+ />
+ navigate('/licence-review')}
+ />
+
+
-
-
-
- Awaiting claim
-
- navigate('/licence-review')}
- >
- Open queue
-
-
- navigate('/licence-review')}
- refresh={queue.refetch}
- emptyText="Nothing waiting to be claimed."
+ {/* Same 4-column track as the row above, so a two-tile row lines up with
+ it instead of stretching each tile to half the page. */}
+
+ navigate('/seafarer-registrations')}
/>
-
-
+ navigate('/seaman-book-queue')}
+ />
+
+
+
+
+
+ title="Awaiting claim — oldest first"
+ tableName="Awaiting claim"
+ columns={dashboardQueueColumns}
+ data={paged.rows}
+ itemCount={paged.itemCount}
+ pageIndex={paged.pageIndex}
+ onPageChange={table.setPageIndex}
+ pageSize={table.pageSize}
+ onRowClick={(row) => navigate(`/licence-review/${row.id}`)}
+ refresh={queue.refetch}
+ isLoading={queue.isFetching}
+ emptyText="Nothing waiting to be claimed."
+ toolbar={
+ navigate('/licence-review')}>
+ Open queue
+
+ }
+ />
+
+
+
+
+
+ Longest waiting
+
+
+ Unclaimed applications, by how long they have sat.
+
+
+ {byAge.slice(0, 5).map((app) => (
+
+ navigate(`/licence-review/${app.id}`)}
+ >
+ {app.applicationNumber}
+
+
+
+ ))}
+ {byAge.length === 0 && (
+
+ Nothing waiting.
+
+ )}
+
+
+
+
+
);
}
diff --git a/libs/ui/src/index.ts b/libs/ui/src/index.ts
index b6edf95d1..61f5825db 100644
--- a/libs/ui/src/index.ts
+++ b/libs/ui/src/index.ts
@@ -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";
diff --git a/libs/ui/src/lib/data/StatTile.tsx b/libs/ui/src/lib/data/StatTile.tsx
new file mode 100644
index 000000000..048876d06
--- /dev/null
+++ b/libs/ui/src/lib/data/StatTile.tsx
@@ -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 `` + two ``,
+ * 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 = (
+ <>
+
+
+ {label}
+
+ {TileIcon && (
+
+
+
+ )}
+
+
+ {value}
+
+ {hint && (
+
+ {hint}
+
+ )}
+ >
+ );
+
+ if (!onClick) {
+ return (
+
+ {body}
+
+ );
+ }
+
+ return (
+
+
+ {body}
+
+
+ );
+}
diff --git a/libs/ui/src/lib/data/stat-tile.css b/libs/ui/src/lib/data/stat-tile.css
new file mode 100644
index 000000000..d20b71c21
--- /dev/null
+++ b/libs/ui/src/lib/data/stat-tile.css
@@ -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);
+}
diff --git a/libs/ui/src/lib/dev/ThemeGallery.tsx b/libs/ui/src/lib/dev/ThemeGallery.tsx
index ef78b9533..cb9386926 100644
--- a/libs/ui/src/lib/dev/ThemeGallery.tsx
+++ b/libs/ui/src/lib/dev/ThemeGallery.tsx
@@ -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() {
+
+
+
+
+
+