backoffice

This commit is contained in:
Fistum
2026-08-21 13:09:40 +00:00
parent 6b5b99f42d
commit 62492e957b
29 changed files with 392 additions and 487 deletions

View File

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

View File

@@ -0,0 +1,83 @@
import { Text, Tooltip } from '@mantine/core';
import { statusToneVars } from '@ema-platform/shared';
export interface WaitingForProps {
/** When the clock started — normally the moment the applicant submitted. */
since: string | null | undefined;
/**
* Days after which the wait reads as overdue. Amber at half of it, red past
* it. Defaults to 14, which is the shortest SLA any configured licence type
* currently uses; pass the real one where a queue knows it.
*/
slaDays?: number;
/** Set once the item is decided — a closed item is not waiting for anyone. */
done?: boolean;
}
/**
* Fraction of the target elapsed before the wait reads as at-risk. Matches the
* licence queue's own `WARNING_RATIO`, so a queue using this component and one
* using `computeSla` turn amber at the same point rather than disagreeing.
*/
const WARNING_RATIO = 0.7;
/** Whole days between `since` and now, floored. Negative clock skew reads as 0. */
function daysSince(since: string): number {
const ms = Date.now() - new Date(since).getTime();
return Math.max(0, Math.floor(ms / 86_400_000));
}
/**
* How long an item has been sitting in a queue.
*
* No review queue showed this. An officer opening a list of thirty
* registrations could see what each one *was*, but not which had been waiting
* three days and which had been waiting three weeks — so the queue was worked
* top-down by whatever the sort happened to be rather than by urgency.
*
* Colour comes from the platform's status tones rather than its own scale, so
* "overdue" here is the same red as "rejected" everywhere else.
*/
export function WaitingFor({ since, slaDays = 14, done }: WaitingForProps) {
if (!since) {
return (
<Text size="sm" c="dimmed">
</Text>
);
}
const days = daysSince(since);
// A decided item keeps its elapsed time visible — useful when reviewing how
// long something took — but never coloured, because nothing is pending.
const tone = done
? 'neutral'
: days >= slaDays
? 'danger'
: days >= slaDays * WARNING_RATIO
? 'pending'
: 'neutral';
const label = days === 0 ? 'today' : `${days}d`;
return (
<Tooltip
label={
done
? `Took ${days} day${days === 1 ? '' : 's'}`
: `Waiting ${days} day${days === 1 ? '' : 's'} · ${slaDays}-day target`
}
withArrow
>
<Text
size="sm"
fw={tone === 'neutral' ? 400 : 600}
c={tone === 'neutral' ? 'dimmed' : undefined}
style={tone === 'neutral' ? undefined : { color: statusToneVars(tone).color }}
>
{label}
</Text>
</Tooltip>
);
}

View File

@@ -2,17 +2,54 @@ import { Group, Stack, Text, Title } from '@mantine/core';
import type { ReactNode } from 'react';
interface PageHeaderProps {
title: string;
subtitle?: string;
/**
* The page's name. A `ReactNode` rather than a string because detail pages
* title themselves with the record they are showing, which is often a
* localised or composed value rather than a literal.
*/
title: ReactNode;
subtitle?: ReactNode;
/**
* Identifiers that belong beside the title rather than under it — a
* reference number, a status badge. Rendered inline, small and dimmed.
*/
meta?: ReactNode;
/** Right-aligned actions (e.g. a primary button). */
action?: ReactNode;
/**
* Set when the header sits directly inside a gapped `<Stack>`, which already
* spaces it from what follows — the built-in margin would double that gap.
*/
noMargin?: boolean;
}
export function PageHeader({ title, subtitle, action }: PageHeaderProps) {
/**
* The heading every backoffice page starts with.
*
* It exists because 25 pages each rolled their own: eight at `order={2}`,
* seventeen at `order={3}`, with `mb="xs"`, `mb={4}`, `mb="lg"` and nothing at
* all between them. Clicking between two screens moved the title, which is the
* single loudest signal that a product was assembled rather than designed.
*
* `order={2}` is deliberate — it is the page's only `h2`, sitting under the
* app-level `h1`, so the heading outline reads correctly for a screen reader.
* Section headings inside cards stay at `order={4}`/`{5}` and are not this
* component's business.
*/
export function PageHeader({ title, subtitle, meta, action, noMargin }: PageHeaderProps) {
return (
<Group justify="space-between" align="flex-end" wrap="wrap" gap="sm">
<Group
justify="space-between"
align="flex-end"
wrap="wrap"
gap="sm"
mb={noMargin ? undefined : 'lg'}
>
<Stack gap={2}>
<Title order={2}>{title}</Title>
<Group gap="sm" align="center" wrap="wrap">
<Title order={2}>{title}</Title>
{meta}
</Group>
{subtitle && (
<Text c="dimmed" size="sm">
{subtitle}