mirror of
https://github.com/Tria-plc/emaui.git
synced 2026-08-26 13:02:50 +00:00
Merge branch 'ui_ux' of https://github.com/Tria-plc/emaui into ui_ux
This commit is contained in:
@@ -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 <PageLoader label="Loading Backoffice Dashboard…" height={400} />;
|
||||
}
|
||||
@@ -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 (
|
||||
<Container size="xl" py="md">
|
||||
<Stack gap="lg">
|
||||
<PageHeader
|
||||
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">
|
||||
{stats.map((stat) => (
|
||||
<Card withBorder key={stat.label} padding="md" radius="md">
|
||||
<Text size="xs" c="dimmed" tt="uppercase" fw={600}>
|
||||
{stat.label}
|
||||
</Text>
|
||||
<Text fz={32} fw={700} c={stat.color} lh={1.2}>
|
||||
{stat.value}
|
||||
</Text>
|
||||
</Card>
|
||||
))}
|
||||
<SimpleGrid cols={{ base: 1, sm: 2, lg: 4 }}>
|
||||
<StatTile
|
||||
label="Awaiting claim"
|
||||
value={unclaimed.length}
|
||||
hint="Licence applications nobody has picked up"
|
||||
icon={IconInbox}
|
||||
tone="info"
|
||||
onClick={() => navigate('/licence-review')}
|
||||
/>
|
||||
<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>
|
||||
|
||||
<Card withBorder padding={0} radius="md">
|
||||
<Group justify="space-between" p="md" pb="xs">
|
||||
<Text fw={600} size="sm">
|
||||
Awaiting claim
|
||||
</Text>
|
||||
<Text
|
||||
size="xs"
|
||||
c="blue"
|
||||
style={{ cursor: 'pointer' }}
|
||||
onClick={() => navigate('/licence-review')}
|
||||
>
|
||||
Open queue <IconChevronRight size={11} style={{ verticalAlign: -1 }} />
|
||||
</Text>
|
||||
</Group>
|
||||
<AdvancedTable
|
||||
tableName="Awaiting claim"
|
||||
columns={dashboardQueueColumns}
|
||||
data={paged.rows}
|
||||
itemCount={paged.itemCount}
|
||||
pageIndex={paged.pageIndex}
|
||||
onPageChange={table.setPageIndex}
|
||||
pageSize={table.pageSize}
|
||||
onRowClick={() => 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. */}
|
||||
<SimpleGrid cols={{ base: 1, sm: 2, lg: 4 }}>
|
||||
<StatTile
|
||||
label="Seafarer registrations"
|
||||
value={show(countOf(registrations))}
|
||||
hint="Submitted, awaiting review"
|
||||
icon={IconUserCheck}
|
||||
tone="info"
|
||||
onClick={() => navigate('/seafarer-registrations')}
|
||||
/>
|
||||
</Card>
|
||||
</Container>
|
||||
<StatTile
|
||||
label="Seaman books"
|
||||
value={show(countOf(seamanBooks))}
|
||||
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"
|
||||
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={
|
||||
<Anchor size="sm" onClick={() => navigate('/licence-review')}>
|
||||
Open queue
|
||||
</Anchor>
|
||||
}
|
||||
/>
|
||||
</Grid.Col>
|
||||
|
||||
<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/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";
|
||||
|
||||
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 {
|
||||
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>
|
||||
|
||||
Reference in New Issue
Block a user