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() { +
+ + + + + + + + +
+ +
+ + + + + + + +
+ +
+ + + + + + +
+
- +
Seafarer ID