Initial End to End functionality

This commit is contained in:
Mulu Mehari
2026-08-02 22:44:08 +03:00
parent c9d885356c
commit c62ec59655
53 changed files with 6791 additions and 1208 deletions

View File

@@ -0,0 +1,560 @@
import { useEffect, useMemo, useRef, useState } from 'react';
import {
ActionIcon,
Badge,
Button,
Card,
Code,
Container,
Group,
Modal,
NumberInput,
Paper,
ScrollArea,
Select,
Stack,
Switch,
Text,
TextInput,
Textarea,
Title,
Tooltip,
} from '@mantine/core';
import {
IconAlertCircle,
IconDeviceFloppy,
IconEye,
IconPlus,
IconRosetteDiscountCheck,
IconTrash,
} from '@tabler/icons-react';
import { notifications } from '@mantine/notifications';
import { useTranslation } from 'react-i18next';
import {
extractErrorMessage,
useArchiveLicenseTemplateMutation,
useCreateLicenseTemplateMutation,
useDeleteLicenseTemplateMutation,
useGetBuiltInTemplateQuery,
useGetLicenseTemplatesQuery,
useGetLicenseTypesQuery,
useGetTemplateVariablesQuery,
usePublishLicenseTemplateMutation,
useUpdateLicenseValidityMutation,
useUpdateLicenseTemplateMutation,
type LicenseTemplate,
} from '@ema-platform/api';
import { EmptyState, ErrorState, PageHeader } from '@ema-platform/ui';
import { authStorage, usePermissions } from '@ema-platform/auth';
import { PERMISSIONS } from '../../../layouts/nav-config';
/** Same resolution the shared RTK Query baseQuery uses. */
const API_BASE_URL =
(import.meta as { env?: Record<string, string> }).env?.['VITE_BASE_API_URL'] ??
'http://localhost:3000/api';
const STATUS_COLOR: Record<LicenseTemplate['status'], string> = {
DRAFT: 'gray',
PUBLISHED: 'teal',
ARCHIVED: 'dark',
};
/**
* Where the authority designs the certificate its licensees receive.
*
* The layout used to be a Handlebars file inside the deployed image, so any
* change to the authority's own certificate needed a developer and a release.
* Here it is data: staff author a version, preview the real PDF, and publish.
* Publishing archives the incumbent, so exactly one design is live per licence
* type and previously issued certificates keep the design they were made from.
*/
export function CertificateDesignerPage() {
const { t } = useTranslation();
const { can } = usePermissions();
const canEdit = can([PERMISSIONS.UPDATE_TEMPLATE]);
const canPublish = can([PERMISSIONS.PUBLISH_TEMPLATE]);
const { data: licenseTypes } = useGetLicenseTypesQuery();
const [typeId, setTypeId] = useState<string | null>(null);
const {
data: templates = [],
isLoading,
isError,
error,
refetch,
} = useGetLicenseTemplatesQuery(typeId ?? undefined, { skip: !typeId });
const { data: variables = [] } = useGetTemplateVariablesQuery();
const { data: builtIn } = useGetBuiltInTemplateQuery();
const [createTemplate, { isLoading: creating }] = useCreateLicenseTemplateMutation();
const [updateTemplate, { isLoading: saving }] = useUpdateLicenseTemplateMutation();
const [publishTemplate, { isLoading: publishing }] = usePublishLicenseTemplateMutation();
const [archiveTemplate] = useArchiveLicenseTemplateMutation();
const [deleteTemplate] = useDeleteLicenseTemplateMutation();
const [updateValidity, { isLoading: savingValidity }] = useUpdateLicenseValidityMutation();
const [selectedId, setSelectedId] = useState<string | null>(null);
const [source, setSource] = useState('');
const [name, setName] = useState('');
const [landscape, setLandscape] = useState(true);
const [newOpen, setNewOpen] = useState(false);
const [newName, setNewName] = useState('');
const editorRef = useRef<HTMLTextAreaElement>(null);
const selectedType = licenseTypes?.items?.find((type) => type.id === typeId);
const [validityMonths, setValidityMonths] = useState<number>(12);
// Default to the first licence type so the page is never an empty shell.
useEffect(() => {
if (!typeId && licenseTypes?.items?.length) setTypeId(licenseTypes.items[0].id);
}, [licenseTypes, typeId]);
useEffect(() => {
if (selectedType) setValidityMonths(selectedType.validityMonths ?? 12);
}, [selectedType]);
const selected = useMemo(
() => templates.find((tpl) => tpl.id === selectedId) ?? null,
[templates, selectedId],
);
// Pick the live design by default — that is the one staff usually want.
useEffect(() => {
if (!templates.length) {
setSelectedId(null);
return;
}
if (selectedId && templates.some((tpl) => tpl.id === selectedId)) return;
const published = templates.find((tpl) => tpl.status === 'PUBLISHED');
setSelectedId((published ?? templates[0]).id);
}, [templates, selectedId]);
useEffect(() => {
if (!selected) return;
setSource(selected.hbsSource);
setName(selected.name);
setLandscape(selected.pageOptions?.landscape ?? true);
}, [selected]);
const isPublished = selected?.status === 'PUBLISHED';
const dirty =
Boolean(selected) &&
(source !== selected?.hbsSource ||
name !== selected?.name ||
landscape !== (selected?.pageOptions?.landscape ?? true));
async function run(action: () => Promise<unknown>, success: string) {
try {
await action();
notifications.show({ color: 'teal', title: success, message: '' });
} catch (err) {
notifications.show({
color: 'red',
title: t('designer.actionFailed', 'Action failed'),
message: extractErrorMessage(err),
});
}
}
/** Inserts a placeholder where the caret is, rather than at the end. */
function insertVariable(key: string) {
const el = editorRef.current;
const token = `{{${key}}}`;
if (!el) {
setSource((prev) => prev + token);
return;
}
const start = el.selectionStart ?? source.length;
const end = el.selectionEnd ?? start;
setSource(source.slice(0, start) + token + source.slice(end));
requestAnimationFrame(() => {
el.focus();
el.setSelectionRange(start + token.length, start + token.length);
});
}
/**
* Renders the editor's current contents, not the saved row, so unsaved edits
* are what you see. Opened as a blob so it never leaves a file behind.
*/
async function preview() {
try {
// The preview returns a PDF stream, not JSON, so it bypasses RTK Query
// and calls the API directly — which means spelling out the base URL and
// the bearer token that the shared baseQuery would normally attach.
const token = authStorage.getToken();
const response = await fetch(`${API_BASE_URL}/license-templates/preview`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
...(token ? { Authorization: `Bearer ${token}` } : {}),
},
body: JSON.stringify({
hbsSource: source,
licenseTypeId: typeId,
pageOptions: { format: 'A4', landscape, printBackground: true },
}),
});
if (!response.ok) throw new Error(await response.text());
const url = URL.createObjectURL(await response.blob());
window.open(url, '_blank', 'noopener');
// Give the new tab time to read it before revoking.
setTimeout(() => URL.revokeObjectURL(url), 60_000);
} catch (err) {
notifications.show({
color: 'red',
title: t('designer.previewFailed', 'Could not render the preview'),
message: extractErrorMessage(err),
});
}
}
return (
<Container size="xl" py="md">
<PageHeader
title={t('designer.title', 'Certificate designer')}
subtitle={t(
'designer.subtitle',
'Design the certificate issued to licence holders, and set how long it stays valid.',
)}
/>
<Group align="flex-end" mb="md" gap="sm">
<Select
label={t('designer.licenceType', 'Licence type')}
data={(licenseTypes?.items ?? []).map((type) => ({
value: type.id,
label: type.name?.en ?? type.key,
}))}
value={typeId}
onChange={(value) => {
setTypeId(value);
setSelectedId(null);
}}
w={280}
/>
{/* Validity lives beside the design because it is the other half of
what a certificate promises. */}
<NumberInput
label={t('designer.validityYears', 'Valid for (years)')}
description={t('designer.validityHint', 'Applied when a licence is issued')}
value={Number((validityMonths / 12).toFixed(2))}
onChange={(value) => setValidityMonths(Math.round(Number(value || 0) * 12))}
min={0.5}
max={20}
step={0.5}
decimalScale={1}
w={190}
disabled={!canEdit}
/>
<Tooltip
label={
canEdit
? t('designer.saveValidity', 'Save validity')
: t('designer.noPermission', 'You do not have permission')
}
>
<span>
<Button
variant="light"
loading={savingValidity}
disabled={!canEdit || !typeId || validityMonths === selectedType?.validityMonths}
onClick={() =>
run(
() => updateValidity({ id: typeId as string, validityMonths }).unwrap(),
t('designer.validitySaved', 'Validity updated'),
)
}
>
{t('designer.saveValidity', 'Save validity')}
</Button>
</span>
</Tooltip>
<div style={{ flex: 1 }} />
<Button
leftSection={<IconPlus size={16} />}
disabled={!canEdit || !typeId}
onClick={() => {
setNewName(
`${selectedType?.name?.en ?? 'Certificate'} v${(templates[0]?.version ?? 0) + 1}`,
);
setNewOpen(true);
}}
>
{t('designer.newVersion', 'New version')}
</Button>
</Group>
{isError ? (
<ErrorState
title={t('designer.loadFailed', 'Could not load the designs')}
description={extractErrorMessage(error)}
onRetry={() => refetch()}
icon={IconAlertCircle}
/>
) : !isLoading && templates.length === 0 ? (
<EmptyState
title={t('designer.empty', 'No design yet for this licence type')}
description={t(
'designer.emptyBody',
'Certificates currently use the built-in layout. Create a version to take control of it.',
)}
action={
canEdit
? {
label: t('designer.newVersion', 'New version'),
onClick: () => {
setNewName(`${selectedType?.name?.en ?? 'Certificate'} v1`);
setNewOpen(true);
},
}
: undefined
}
/>
) : (
<Group align="flex-start" gap="md" wrap="nowrap">
{/* Versions */}
<Stack gap="xs" w={240} style={{ flexShrink: 0 }}>
<Text fw={600} size="sm">
{t('designer.versions', 'Versions')}
</Text>
{templates.map((tpl) => (
<Card
key={tpl.id}
withBorder
padding="xs"
onClick={() => setSelectedId(tpl.id)}
style={{
cursor: 'pointer',
borderColor:
tpl.id === selectedId ? 'var(--mantine-color-blue-5)' : undefined,
}}
>
<Group justify="space-between" wrap="nowrap">
<div style={{ minWidth: 0 }}>
<Text size="sm" fw={500} truncate>
{tpl.name}
</Text>
<Text size="xs" c="dimmed">
v{tpl.version}
</Text>
</div>
<Badge size="xs" variant="light" color={STATUS_COLOR[tpl.status]}>
{tpl.status}
</Badge>
</Group>
</Card>
))}
</Stack>
{/* Editor */}
<Stack gap="sm" style={{ flex: 1, minWidth: 0 }}>
<Group gap="sm" align="flex-end">
<TextInput
label={t('designer.name', 'Version name')}
value={name}
onChange={(e) => setName(e.currentTarget.value)}
disabled={!canEdit || isPublished}
style={{ flex: 1 }}
/>
<Switch
label={t('designer.landscape', 'Landscape')}
checked={landscape}
onChange={(e) => setLandscape(e.currentTarget.checked)}
disabled={!canEdit || isPublished}
/>
</Group>
{isPublished && (
<Paper withBorder p="xs" bg="var(--mantine-color-teal-light)">
<Text size="xs">
{t(
'designer.publishedLocked',
'This version is live and cannot be edited — certificates have been issued from it. Create a new version to make changes.',
)}
</Text>
</Paper>
)}
<Textarea
ref={editorRef}
label={t('designer.source', 'Template (Handlebars + HTML)')}
value={source}
onChange={(e) => setSource(e.currentTarget.value)}
disabled={!canEdit || isPublished}
autosize
minRows={18}
maxRows={30}
styles={{ input: { fontFamily: 'monospace', fontSize: 12 } }}
/>
<Group>
<Button
variant="light"
leftSection={<IconEye size={16} />}
onClick={preview}
disabled={!source.trim()}
>
{t('designer.preview', 'Preview PDF')}
</Button>
<Button
leftSection={<IconDeviceFloppy size={16} />}
loading={saving}
disabled={!canEdit || isPublished || !dirty}
onClick={() =>
run(
() =>
updateTemplate({
id: selected!.id,
name,
hbsSource: source,
pageOptions: { format: 'A4', landscape, printBackground: true },
}).unwrap(),
t('designer.saved', 'Draft saved'),
)
}
>
{t('designer.save', 'Save draft')}
</Button>
<Tooltip
label={
!canPublish
? t('designer.noPublishPermission', 'You cannot publish designs')
: dirty
? t('designer.saveFirst', 'Save your changes first')
: t('designer.publishHint', 'Makes this the live certificate design')
}
>
<span>
<Button
color="teal"
leftSection={<IconRosetteDiscountCheck size={16} />}
loading={publishing}
disabled={!canPublish || isPublished || dirty || !selected}
onClick={() =>
run(
() => publishTemplate(selected!.id).unwrap(),
t('designer.published', 'Design published'),
)
}
>
{t('designer.publish', 'Publish')}
</Button>
</span>
</Tooltip>
<div style={{ flex: 1 }} />
{selected && isPublished && canPublish && (
<Button
variant="subtle"
color="orange"
onClick={() =>
run(
() => archiveTemplate(selected.id).unwrap(),
t('designer.archived', 'Design withdrawn'),
)
}
>
{t('designer.archive', 'Withdraw')}
</Button>
)}
{selected && !isPublished && canEdit && (
<Tooltip label={t('designer.delete', 'Delete draft')}>
<ActionIcon
variant="subtle"
color="red"
aria-label={t('designer.delete', 'Delete draft')}
onClick={() =>
run(
() => deleteTemplate(selected.id).unwrap(),
t('designer.deleted', 'Draft deleted'),
)
}
>
<IconTrash size={16} />
</ActionIcon>
</Tooltip>
)}
</Group>
</Stack>
{/* Placeholders */}
<Stack gap="xs" w={230} style={{ flexShrink: 0 }}>
<Text fw={600} size="sm">
{t('designer.variables', 'Placeholders')}
</Text>
<Text size="xs" c="dimmed">
{t('designer.variablesHint', 'Click to insert at the cursor.')}
</Text>
<ScrollArea.Autosize mah={480} type="hover">
<Stack gap={4}>
{variables.map((variable) => (
<Tooltip key={variable.key} label={variable.label} position="left">
<Button
size="compact-xs"
variant="default"
justify="flex-start"
disabled={!canEdit || isPublished}
onClick={() => insertVariable(variable.key)}
>
<Code fz={10}>{`{{${variable.key}}}`}</Code>
</Button>
</Tooltip>
))}
</Stack>
</ScrollArea.Autosize>
</Stack>
</Group>
)}
<Modal
opened={newOpen}
onClose={() => setNewOpen(false)}
title={t('designer.newVersion', 'New version')}
>
<Stack>
<TextInput
label={t('designer.name', 'Version name')}
value={newName}
onChange={(e) => setNewName(e.currentTarget.value)}
withAsterisk
/>
<Text size="xs" c="dimmed">
{t(
'designer.newHint',
'Starts from the live design, or the built-in layout if this type has none.',
)}
</Text>
<Group justify="flex-end">
<Button variant="default" onClick={() => setNewOpen(false)}>
{t('common.cancel', 'Cancel')}
</Button>
<Button
loading={creating}
disabled={!newName.trim()}
onClick={() =>
run(async () => {
const created = await createTemplate({
licenseTypeId: typeId as string,
name: newName.trim(),
hbsSource: templates.length ? undefined : builtIn?.hbsSource,
}).unwrap();
setSelectedId(created.id);
setNewOpen(false);
}, t('designer.created', 'Draft created'))
}
>
{t('designer.create', 'Create')}
</Button>
</Group>
</Stack>
</Modal>
</Container>
);
}
export default CertificateDesignerPage;

View File

@@ -0,0 +1,183 @@
import { useMemo } from 'react';
import {
Badge,
Group,
Paper,
ScrollArea,
Text,
Timeline,
Tooltip,
} from '@mantine/core';
import {
IconFileUpload,
IconMessage,
IconArrowRight,
IconUserCheck,
} from '@tabler/icons-react';
import { useTranslation } from 'react-i18next';
import {
STATUS_COLORS,
STATUS_LABELS,
type ApplicationDetail,
} from '@ema-platform/api';
type EntryKind = 'status' | 'remark' | 'upload' | 'assignment';
interface ActivityEntry {
id: string;
kind: EntryKind;
at: string;
actor: string;
title: string;
detail?: string;
color?: string;
}
const ICONS: Record<EntryKind, typeof IconArrowRight> = {
status: IconArrowRight,
remark: IconMessage,
upload: IconFileUpload,
assignment: IconUserCheck,
};
/**
* Chronological record of everything that has happened to an application.
*
* Merged client-side from the three collections the detail endpoint already
* returns — status transitions, officer remarks and document uploads. There is
* no single activity-feed endpoint, so this is assembled rather than fetched;
* the trade-off is that it can only show what the detail payload carries, and
* notifications sent to the applicant are not among them.
*/
export function ActivityRail({ detail }: { detail: ApplicationDetail }) {
const { t, i18n } = useTranslation();
const entries = useMemo<ActivityEntry[]>(() => {
const merged: ActivityEntry[] = [];
for (const history of detail.history ?? []) {
// A transition that does not move the status is a workflow control
// (assignment, escalation), not a decision — labelled as such so the
// trail does not read as "Under Review → Under Review".
const isAssignment = history.fromStatus === history.toStatus;
merged.push({
id: `status-${history.id}`,
kind: isAssignment ? 'assignment' : 'status',
at: history.createdAt,
actor: history.actorName ?? t('review.activity.system', 'System'),
title: isAssignment
? t(`review.events.${history.event}`, {
defaultValue: history.event,
})
: `${history.fromStatus ? STATUS_LABELS[history.fromStatus] : '—'}${
STATUS_LABELS[history.toStatus]
}`,
detail: history.remark ?? undefined,
color: STATUS_COLORS[history.toStatus],
});
}
for (const remark of detail.remarks ?? []) {
merged.push({
id: `remark-${remark.id}`,
kind: 'remark',
at: remark.createdAt,
actor: t('review.activity.officer', 'Officer'),
title: t('review.activity.remarkOn', {
target: remark.targetKey,
defaultValue: 'Correction requested on {{target}}',
}),
detail: remark.remark,
color: remark.resolvedAt ? 'teal' : 'orange',
});
}
for (const attachment of detail.attachments ?? []) {
const file = attachment.files?.[0];
if (!file) continue;
merged.push({
id: `upload-${attachment.id}`,
kind: 'upload',
at: attachment.createdAt ?? detail.application.createdAt,
actor: t('review.activity.applicant', 'Applicant'),
title: t('review.activity.uploaded', {
document: attachment.documentKey,
defaultValue: 'Uploaded {{document}}',
}),
detail: file.originalName,
color: 'blue',
});
}
// Newest first: an officer opening a review wants the latest state, not
// the application's origin story.
return merged.sort(
(a, b) => new Date(b.at).getTime() - new Date(a.at).getTime(),
);
}, [detail, t]);
if (entries.length === 0) {
return (
<Paper withBorder p="md">
<Text size="sm" c="dimmed">
{t('review.activity.empty', 'No activity recorded yet.')}
</Text>
</Paper>
);
}
return (
<Paper withBorder p="md" h="100%">
<Group justify="space-between" mb="sm">
<Text fw={600} size="sm">
{t('review.activity.title', 'Activity & audit trail')}
</Text>
<Badge variant="light" size="sm">
{entries.length}
</Badge>
</Group>
<ScrollArea.Autosize mah={520} type="hover" offsetScrollbars>
<Timeline bulletSize={20} lineWidth={2}>
{entries.map((entry) => {
const EntryIcon = ICONS[entry.kind];
return (
<Timeline.Item
key={entry.id}
bullet={<EntryIcon size={12} />}
color={entry.color}
title={
<Text size="xs" fw={600}>
{entry.title}
</Text>
}
>
<Group gap={4} wrap="nowrap">
<Text size="xs" c="dimmed" truncate>
{entry.actor}
</Text>
<Text size="xs" c="dimmed">
·
</Text>
<Tooltip
label={new Date(entry.at).toLocaleString(i18n.language)}
withArrow
>
<Text size="xs" c="dimmed">
{new Date(entry.at).toLocaleDateString(i18n.language)}
</Text>
</Tooltip>
</Group>
{entry.detail && (
<Text size="xs" mt={2}>
{entry.detail}
</Text>
)}
</Timeline.Item>
);
})}
</Timeline>
</ScrollArea.Autosize>
</Paper>
);
}

View File

@@ -0,0 +1,243 @@
import {
ActionIcon,
Avatar,
Badge,
Button,
Divider,
Group,
Menu,
Paper,
Text,
Tooltip,
rem,
} from '@mantine/core';
import { IconDots, IconAlertTriangle } from '@tabler/icons-react';
import { useTranslation } from 'react-i18next';
import { STATUS_COLORS, STATUS_LABELS, type LicenseStatus } from '@ema-platform/api';
import type { ActionId, ResolvedAction } from '../config/actions';
import type { SlaState } from '../sla';
interface DecisionBarProps {
status: LicenseStatus;
assigneeName?: string | null;
sla?: SlaState;
actions: ResolvedAction[];
busyAction?: ActionId | null;
onAction: (action: ResolvedAction) => void;
}
/**
* The single place decisions are taken.
*
* Sticky to the bottom of the viewport and full workspace width, so it is
* reachable at any scroll depth — the actions used to sit in a right-hand
* column that scrolled away, meaning an officer reading the last document had
* to scroll back up to act on it.
*
* Layout follows the action tiers: workflow controls left, the decision right,
* everything else in the overflow menu. At most one filled button, so where to
* look is never ambiguous.
*/
export function DecisionBar({
status,
assigneeName,
sla,
actions,
busyAction,
onAction,
}: DecisionBarProps) {
const { t } = useTranslation();
const workflow = actions.filter((a) => a.tier === 'workflow');
// Three is the cap: past that the bar stops reading as a decision and starts
// reading as a toolbar. The rest stay reachable in the overflow menu.
const primary = actions.filter((a) => a.tier === 'primary').slice(0, 3);
const overflowPrimary = actions.filter((a) => a.tier === 'primary').slice(3);
const secondary = [
...overflowPrimary,
...actions.filter((a) => a.tier === 'secondary'),
];
const destructive = actions.filter((a) => a.tier === 'destructive');
return (
<Paper
withBorder
shadow="md"
px="lg"
py="sm"
style={{
position: 'sticky',
bottom: 0,
zIndex: 60,
borderRadius: 0,
marginInline: `calc(-1 * var(--mantine-spacing-lg))`,
background: 'var(--mantine-color-body)',
}}
role="region"
aria-label={t('review.decisionBar', 'Decision bar')}
>
<Group justify="space-between" wrap="nowrap" gap="md">
{/* Left: where the application stands, and who has it. */}
<Group gap="sm" wrap="nowrap" style={{ minWidth: 0 }}>
<Badge color={STATUS_COLORS[status]} variant="light" size="lg">
{STATUS_LABELS[status]}
</Badge>
{assigneeName && (
<Group gap={6} wrap="nowrap">
<Avatar size={24} radius="xl" color="blue">
{assigneeName.slice(0, 2).toUpperCase()}
</Avatar>
<Text size="sm" c="dimmed" truncate>
{assigneeName}
</Text>
</Group>
)}
{sla && sla.state !== 'untracked' && (
<Tooltip label={sla.tooltip} withArrow>
<Badge
color={sla.color}
variant="light"
// Never colour alone: the label carries the same meaning for
// anyone who cannot distinguish the hues.
leftSection={
sla.state === 'breached' ? <IconAlertTriangle size={12} /> : undefined
}
>
{sla.label}
</Badge>
</Tooltip>
)}
{workflow.length > 0 && <Divider orientation="vertical" />}
{workflow.map((action) => (
<ActionButton
key={action.id}
action={action}
busy={busyAction === action.id}
onAction={onAction}
size="xs"
/>
))}
</Group>
{/* Right: the decision. */}
<Group gap="xs" wrap="nowrap">
{primary.map((action) => (
<ActionButton
key={action.id}
action={action}
busy={busyAction === action.id}
onAction={onAction}
size="sm"
/>
))}
{(secondary.length > 0 || destructive.length > 0) && (
<Menu position="top-end" withinPortal shadow="md" width={240}>
<Menu.Target>
<ActionIcon
variant="default"
size="lg"
aria-label={t('review.moreActions', 'More actions')}
>
<IconDots size={18} />
</ActionIcon>
</Menu.Target>
<Menu.Dropdown>
{secondary.map((action) => (
<MenuAction key={action.id} action={action} onAction={onAction} />
))}
{destructive.length > 0 && (
<>
<Menu.Divider />
<Menu.Label>
{t('review.irreversible', 'Cannot be undone')}
</Menu.Label>
{destructive.map((action) => (
<MenuAction
key={action.id}
action={action}
onAction={onAction}
color="red"
/>
))}
</>
)}
</Menu.Dropdown>
</Menu>
)}
</Group>
</Group>
</Paper>
);
}
interface ActionButtonProps {
action: ResolvedAction;
busy: boolean;
size: 'xs' | 'sm';
onAction: (action: ResolvedAction) => void;
}
/**
* A disabled button always says why.
*
* Mantine strips pointer events from a disabled button, so the Tooltip has to
* wrap a span — otherwise the one case where the explanation matters is the
* one case it never appears.
*/
function ActionButton({ action, busy, size, onAction }: ActionButtonProps) {
const { t } = useTranslation();
const button = (
<Button
size={size}
variant={action.emphasis === 'filled' ? 'filled' : action.emphasis ?? 'light'}
color={action.color}
loading={busy}
disabled={!action.enabled}
onClick={() => onAction(action)}
>
{t(action.labelKey)}
</Button>
);
if (action.enabled) return button;
return (
<Tooltip label={action.disabledReason} withArrow position="top">
<span style={{ display: 'inline-flex', cursor: 'not-allowed' }}>{button}</span>
</Tooltip>
);
}
function MenuAction({
action,
onAction,
color,
}: {
action: ResolvedAction;
onAction: (action: ResolvedAction) => void;
color?: string;
}) {
const { t } = useTranslation();
const item = (
<Menu.Item
color={color}
disabled={!action.enabled}
onClick={() => onAction(action)}
>
{t(action.labelKey)}
</Menu.Item>
);
if (action.enabled) return item;
return (
<Tooltip label={action.disabledReason} withArrow position="left">
<div>{item}</div>
</Tooltip>
);
}
export const DECISION_BAR_HEIGHT = rem(60);

View File

@@ -0,0 +1,311 @@
import { useEffect, useState } from 'react';
import {
Alert,
Button,
Checkbox,
Group,
Modal,
Select,
Stack,
Text,
Textarea,
TextInput,
} from '@mantine/core';
import { IconAlertTriangle } from '@tabler/icons-react';
import { useTranslation } from 'react-i18next';
import type { ResolvedAction } from '../config/actions';
/** Reason codes offered per action. Free text is always available too. */
const REASON_CODES: Record<string, string[]> = {
reject: [
'review.reasons.incompleteDocuments',
'review.reasons.belowCapital',
'review.reasons.failedInspection',
'review.reasons.ineligibleApplicant',
'review.reasons.duplicateApplication',
],
'request-adjustment': [
'review.reasons.illegibleDocument',
'review.reasons.expiredDocument',
'review.reasons.missingDocument',
'review.reasons.inconsistentDetails',
],
hold: [
'review.reasons.awaitingThirdParty',
'review.reasons.legalProceedings',
'review.reasons.applicantRequest',
],
escalate: [
'review.reasons.aboveAuthority',
'review.reasons.policyUnclear',
'review.reasons.conflictOfInterest',
],
};
export interface DecisionSubmission {
reasonCode?: string;
reason: string;
/** Chosen officer, for Assign and Escalate. */
officerId?: string;
/** Documents the applicant must fix. Adjustments only. */
deficiencies: string[];
/** The message that will be sent, after any officer edit. */
notificationBody: string;
}
interface DecisionConfirmModalProps {
action: ResolvedAction | null;
applicantName: string;
applicationNumber: string;
/** Document keys the officer flagged, for the deficiency checklist. */
flaggedDocuments?: string[];
/** Populated for Assign and Escalate, which must name a person. */
officers?: Array<{ id: string; name: string | null }>;
submitting?: boolean;
onClose: () => void;
onConfirm: (submission: DecisionSubmission) => void;
}
/**
* One confirmation anatomy for every decision.
*
* Each decision used to have its own ad-hoc modal — some with a reason, some
* without, none showing what the applicant would actually receive. This gives
* all of them the same five parts: the consequence in plain language naming
* the applicant and application, a reason (required where it matters), the
* document deficiency checklist for adjustments, an editable preview of the
* message that will be sent, and an explicit warning where the step cannot be
* undone.
*/
export function DecisionConfirmModal({
action,
applicantName,
applicationNumber,
flaggedDocuments = [],
officers = [],
submitting,
onClose,
onConfirm,
}: DecisionConfirmModalProps) {
const { t } = useTranslation();
const [reasonCode, setReasonCode] = useState<string | null>(null);
const [reason, setReason] = useState('');
const [deficiencies, setDeficiencies] = useState<string[]>([]);
const [notification, setNotification] = useState('');
const [acknowledged, setAcknowledged] = useState(false);
const [officerId, setOfficerId] = useState<string | null>(null);
const [confirmText, setConfirmText] = useState('');
const codes = action ? (REASON_CODES[action.id] ?? []) : [];
// `flaggedDocuments` is a fresh array on every parent render, so keying the
// reset effect on its identity would wipe the officer's edits continuously.
const flaggedKey = flaggedDocuments.join('|');
// Reset per opening, and seed the message the applicant will receive so the
// officer edits real copy rather than composing from nothing.
useEffect(() => {
if (!action) return;
setReasonCode(null);
setReason('');
setDeficiencies(flaggedDocuments);
setAcknowledged(false);
setOfficerId(null);
setConfirmText('');
setNotification(
t(`review.notifications.${action.id}`, {
applicant: applicantName,
number: applicationNumber,
defaultValue: t('review.notifications.fallback', {
applicant: applicantName,
number: applicationNumber,
action: t(action.labelKey),
defaultValue:
'Dear {{applicant}}, there is an update on application {{number}}: {{action}}.',
}),
}),
);
}, [action, applicantName, applicationNumber, flaggedKey, t]);
if (!action) return null;
const needsOfficer = action.id === 'assign' || action.id === 'escalate';
const reasonMissing = action.requiresReason && !reason.trim() && !reasonCode;
const blocked =
reasonMissing ||
(needsOfficer && !officerId) ||
(action.irreversible && !acknowledged) ||
// A destructive action must have the consequence typed out, not just
// acknowledged with a tick — it is the last stop before something
// irreversible happens to a real operator.
(action.tier === 'destructive' && confirmText.trim() !== applicationNumber);
return (
<Modal
opened
onClose={onClose}
title={t(action.labelKey)}
size="lg"
// Focus returns to the trigger on close, and Esc dismisses.
trapFocus
returnFocus
closeOnEscape
>
<Stack gap="md">
{/* 1. What is about to happen, in plain language. */}
<Text size="sm">
{t(`review.consequences.${action.id}`, {
applicant: applicantName,
number: applicationNumber,
defaultValue: t('review.consequences.fallback', {
applicant: applicantName,
number: applicationNumber,
defaultValue:
'This updates application {{number}} for {{applicant}}.',
}),
})}
</Text>
{/* 1b. Who picks it up. */}
{needsOfficer && (
<Select
label={
action.id === 'escalate'
? t('review.supervisor', 'Supervisor')
: t('review.officer', 'Officer')
}
placeholder={t('review.officerPlaceholder', 'Select who takes this on')}
data={officers.map((officer) => ({
value: officer.id,
label: officer.name ?? officer.id,
}))}
value={officerId}
onChange={setOfficerId}
searchable
withAsterisk
nothingFoundMessage={t('review.noOfficers', 'No officers found')}
/>
)}
{/* 2. Reason — coded plus free text. */}
{action.requiresReason && (
<>
{codes.length > 0 && (
<Select
label={t('review.reasonCode', 'Reason')}
placeholder={t('review.reasonCodePlaceholder', 'Select a reason')}
data={codes.map((code) => ({ value: code, label: t(code) }))}
value={reasonCode}
onChange={setReasonCode}
clearable
withAsterisk
/>
)}
<Textarea
label={t('review.reasonDetail', 'Details for the applicant')}
description={t(
'review.reasonDetailHint',
'This text is sent to the applicant verbatim.',
)}
value={reason}
onChange={(event) => setReason(event.currentTarget.value)}
autosize
minRows={3}
withAsterisk={!reasonCode}
/>
</>
)}
{/* 3. Deficiency checklist — the applicant sees exactly this list. */}
{action.id === 'request-adjustment' && flaggedDocuments.length > 0 && (
<Checkbox.Group
label={t('review.deficiencies', 'Items the applicant must correct')}
description={t(
'review.deficienciesHint',
'Only the ticked items become editable for the applicant.',
)}
value={deficiencies}
onChange={setDeficiencies}
>
<Stack gap={4} mt="xs">
{flaggedDocuments.map((key) => (
<Checkbox key={key} value={key} label={key} />
))}
</Stack>
</Checkbox.Group>
)}
{/* 4. Editable preview of the outbound message. */}
<Textarea
label={t('review.notificationPreview', 'Message to the applicant')}
description={t(
'review.notificationPreviewHint',
'Sent by SMS and email. Edit before confirming if needed.',
)}
value={notification}
onChange={(event) => setNotification(event.currentTarget.value)}
autosize
minRows={3}
/>
{/* 5. Irreversibility, acknowledged explicitly. */}
{action.irreversible && (
<Alert color="red" icon={<IconAlertTriangle size={18} />}>
<Stack gap="xs">
<Text size="sm">
{t(
'review.irreversibleWarning',
'This decision is final and cannot be undone from the backoffice.',
)}
</Text>
<Checkbox
checked={acknowledged}
onChange={(event) => setAcknowledged(event.currentTarget.checked)}
label={t('review.irreversibleAck', 'I understand this is final')}
/>
</Stack>
</Alert>
)}
{/* 5b. Destructive actions require the application number typed out. */}
{action.tier === 'destructive' && (
<TextInput
label={t('review.typeToConfirm', {
number: applicationNumber,
defaultValue: 'Type {{number}} to confirm',
})}
value={confirmText}
onChange={(event) => setConfirmText(event.currentTarget.value)}
placeholder={applicationNumber}
error={
confirmText && confirmText.trim() !== applicationNumber
? t('review.confirmMismatch', 'Does not match')
: undefined
}
/>
)}
<Group justify="flex-end">
<Button variant="default" onClick={onClose}>
{t('common.cancel', 'Cancel')}
</Button>
<Button
color={action.color}
loading={submitting}
disabled={blocked}
onClick={() =>
onConfirm({
reasonCode: reasonCode ?? undefined,
officerId: officerId ?? undefined,
reason: reason.trim() || (reasonCode ? t(reasonCode) : ''),
deficiencies,
notificationBody: notification,
})
}
>
{t(action.labelKey)}
</Button>
</Group>
</Stack>
</Modal>
);
}

View File

@@ -0,0 +1,429 @@
import { useState } from 'react';
import {
ActionIcon,
Alert,
Badge,
Button,
Checkbox,
Drawer,
Group,
Paper,
Progress,
Stack,
Text,
TextInput,
Tooltip,
} from '@mantine/core';
import {
IconAlertCircle,
IconCheck,
IconDownload,
IconEye,
IconFileText,
IconRotate,
IconX,
} from '@tabler/icons-react';
import { useTranslation } from 'react-i18next';
import {
useClearDocumentReviewMutation,
useGetDocumentReviewsQuery,
useReviewDocumentMutation,
type Attachment,
type DocumentRequirement,
} from '@ema-platform/api';
import { notifications } from '@mantine/notifications';
interface DocumentsTabProps {
applicationId: string;
attachments: Attachment[];
/** From the licence type config, so completeness is measured against rules. */
requirements: DocumentRequirement[];
/** documentKey -> remark. Owned by the review page. */
flags: Record<string, string>;
onToggleFlag: (documentKey: string) => void;
onFlagRemark: (documentKey: string, remark: string) => void;
}
/**
* The reviewer's document workspace.
*
* Previously a list with View and Download buttons that had no handlers at
* all — the officer could see that a document existed but not what was in it,
* which makes "approve documents" an act of faith. This previews inline,
* measures what is uploaded against what the licence type requires, and lets
* each document be flagged with its own reason.
*/
export function DocumentsTab({
applicationId,
attachments,
requirements,
flags,
onToggleFlag,
onFlagRemark,
}: DocumentsTabProps) {
const { t } = useTranslation();
const [preview, setPreview] = useState<Attachment | null>(null);
const [rejecting, setRejecting] = useState<Record<string, string>>({});
// Verdicts are persisted per document, so an accept survives a reload and
// is visible to whoever picks the application up next.
const { data: reviews = [] } = useGetDocumentReviewsQuery(applicationId, {
skip: !applicationId,
});
const [reviewDocument, { isLoading: saving }] = useReviewDocumentMutation();
const [clearReview] = useClearDocumentReviewMutation();
const verdictFor = (documentKey: string) =>
reviews.find((review) => review.documentKey === documentKey);
async function decide(
documentKey: string,
decision: 'ACCEPTED' | 'REJECTED',
attachmentId?: string,
) {
const reason = rejecting[documentKey]?.trim();
if (decision === 'REJECTED' && !reason) {
// The applicant is shown this verbatim, so refuse to send an empty one.
notifications.show({
color: 'red',
title: t('review.documents.reasonRequired', 'A reason is required'),
message: '',
});
return;
}
try {
await reviewDocument({
id: applicationId,
documentKey,
decision,
reason: decision === 'REJECTED' ? reason : undefined,
attachmentId,
}).unwrap();
setRejecting((prev) => {
const next = { ...prev };
delete next[documentKey];
return next;
});
} catch {
notifications.show({
color: 'red',
title: t('review.documents.saveFailed', 'Could not save the verdict'),
message: '',
});
}
}
const uploadedKeys = new Set(attachments.map((a) => a.documentKey));
const mandatory = requirements.filter((r) => r.mode !== 'OPTIONAL');
const missing = mandatory.filter((r) => !uploadedKeys.has(r.key));
const completeness = mandatory.length
? Math.round(((mandatory.length - missing.length) / mandatory.length) * 100)
: 100;
const previewFile = preview?.files?.[0];
const isImage = previewFile?.mimeType?.startsWith('image/');
const isPdf = previewFile?.mimeType === 'application/pdf';
return (
<Stack gap="md">
{/* Completeness against the licence type's own requirement list. */}
<Paper withBorder p="md">
<Group justify="space-between" mb="xs">
<Text fw={600} size="sm">
{t('review.documents.completeness', 'Required documents')}
</Text>
<Text size="sm" c={missing.length ? 'orange' : 'teal'} fw={600}>
{mandatory.length - missing.length}/{mandatory.length}
</Text>
</Group>
<Progress
value={completeness}
color={missing.length ? 'orange' : 'teal'}
aria-label={t('review.documents.completenessLabel', {
value: completeness,
defaultValue: '{{value}}% of required documents uploaded',
})}
/>
{missing.length > 0 && (
<Alert mt="sm" color="orange" icon={<IconAlertCircle size={16} />} variant="light">
<Text size="sm">
{t('review.documents.missing', 'Not yet uploaded')}:{' '}
{missing.map((r) => r.name.en ?? r.key).join(', ')}
</Text>
</Alert>
)}
</Paper>
{attachments.map((attachment) => {
const file = attachment.files?.[0];
const flagged = attachment.documentKey in flags;
const verdict = verdictFor(attachment.documentKey);
const pendingReject = attachment.documentKey in rejecting;
return (
<Paper withBorder p="md" key={attachment.id}>
<Group justify="space-between" wrap="nowrap" align="flex-start">
<Group gap="sm" wrap="nowrap" style={{ minWidth: 0 }}>
<IconFileText size={20} stroke={1.6} />
<div style={{ minWidth: 0 }}>
<Text size="sm" fw={500}>
{attachment.documentKey}
</Text>
<Text size="xs" c="dimmed" truncate>
{file?.originalName ?? t('review.documents.noFile', 'No file')}
</Text>
</div>
{verdict && (
<Tooltip
label={
verdict.reason ??
t('review.documents.reviewedBy', {
name: verdict.reviewedByName ?? '—',
defaultValue: 'Reviewed by {{name}}',
})
}
>
<Badge
color={verdict.decision === 'ACCEPTED' ? 'teal' : 'red'}
variant="light"
size="sm"
leftSection={
verdict.decision === 'ACCEPTED' ? (
<IconCheck size={11} />
) : (
<IconX size={11} />
)
}
>
{verdict.decision === 'ACCEPTED'
? t('review.documents.accepted', 'Accepted')
: t('review.documents.rejected', 'Rejected')}
</Badge>
</Tooltip>
)}
{flagged && (
<Badge color="orange" variant="light" size="sm">
{t('review.documents.flagged', 'Correction requested')}
</Badge>
)}
</Group>
<Group gap="xs" wrap="nowrap">
<Tooltip
label={
file?.url
? t('review.documents.preview', 'Preview')
: t('review.documents.noFileUploaded', 'Nothing uploaded yet')
}
>
<span>
<Button
size="compact-sm"
variant="light"
leftSection={<IconEye size={14} />}
disabled={!file?.url}
onClick={() => setPreview(attachment)}
>
{t('review.documents.view', 'View')}
</Button>
</span>
</Tooltip>
<Tooltip
label={
file?.url
? t('review.documents.download', 'Download')
: t('review.documents.noFileUploaded', 'Nothing uploaded yet')
}
>
<span>
<ActionIcon
variant="subtle"
disabled={!file?.url}
component="a"
href={file?.url}
download={file?.originalName}
target="_blank"
rel="noreferrer"
aria-label={t('review.documents.download', 'Download')}
>
<IconDownload size={16} />
</ActionIcon>
</span>
</Tooltip>
{/* Accept / Reject are the officer's own record of having
checked the file, persisted independently of any
adjustment round. */}
<Tooltip
label={
file?.url
? t('review.documents.accept', 'Accept')
: t('review.documents.nothingToJudge', 'Nothing uploaded to judge')
}
>
<span>
<ActionIcon
variant={verdict?.decision === 'ACCEPTED' ? 'filled' : 'light'}
color="teal"
loading={saving}
disabled={!file?.url}
aria-label={t('review.documents.accept', 'Accept')}
onClick={() =>
decide(attachment.documentKey, 'ACCEPTED', attachment.id)
}
>
<IconCheck size={16} />
</ActionIcon>
</span>
</Tooltip>
<Tooltip
label={
file?.url
? t('review.documents.reject', 'Reject')
: t('review.documents.nothingToJudge', 'Nothing uploaded to judge')
}
>
<span>
<ActionIcon
variant={verdict?.decision === 'REJECTED' ? 'filled' : 'light'}
color="red"
disabled={!file?.url}
aria-label={t('review.documents.reject', 'Reject')}
onClick={() =>
setRejecting((prev) => ({
...prev,
[attachment.documentKey]: verdict?.reason ?? '',
}))
}
>
<IconX size={16} />
</ActionIcon>
</span>
</Tooltip>
{verdict && (
<Tooltip label={t('review.documents.clear', 'Clear verdict')}>
<ActionIcon
variant="subtle"
color="gray"
aria-label={t('review.documents.clear', 'Clear verdict')}
onClick={() =>
clearReview({
id: applicationId,
documentKey: attachment.documentKey,
})
}
>
<IconRotate size={16} />
</ActionIcon>
</Tooltip>
)}
<Checkbox
size="xs"
checked={flagged}
onChange={() => onToggleFlag(attachment.documentKey)}
label={t('review.documents.includeInAdjustment', 'Send back')}
/>
</Group>
</Group>
{pendingReject && (
<Group mt="sm" gap="xs" align="flex-start" wrap="nowrap">
<TextInput
style={{ flex: 1 }}
size="xs"
autoFocus
placeholder={t(
'review.documents.rejectReason',
'Why must this document be corrected?',
)}
value={rejecting[attachment.documentKey]}
onChange={(e) =>
setRejecting((prev) => ({
...prev,
[attachment.documentKey]: e.currentTarget.value,
}))
}
/>
<Button
size="compact-sm"
color="red"
loading={saving}
disabled={!rejecting[attachment.documentKey]?.trim()}
onClick={() =>
decide(attachment.documentKey, 'REJECTED', attachment.id)
}
>
{t('review.documents.confirmReject', 'Reject')}
</Button>
</Group>
)}
{flagged && (
<TextInput
mt="sm"
size="xs"
placeholder={t(
'review.documents.adjustmentNote',
'What must the applicant correct?',
)}
value={flags[attachment.documentKey]}
onChange={(e) => onFlagRemark(attachment.documentKey, e.currentTarget.value)}
error={
flags[attachment.documentKey].trim()
? undefined
: t('review.documents.reasonRequired', 'A reason is required')
}
/>
)}
</Paper>
);
})}
<Drawer
opened={Boolean(preview)}
onClose={() => setPreview(null)}
position="right"
size="xl"
title={preview?.documentKey}
// Focus is trapped and returned so keyboard users are not dropped at
// the top of the page when the drawer closes.
trapFocus
returnFocus
>
{previewFile?.url ? (
isPdf ? (
<iframe
src={previewFile.url}
title={preview?.documentKey ?? 'document'}
style={{ width: '100%', height: '80vh', border: 'none' }}
/>
) : isImage ? (
<img
src={previewFile.url}
alt={preview?.documentKey ?? 'document'}
style={{ maxWidth: '100%' }}
/>
) : (
// Anything the browser will not render inline still gets a way out.
<Stack align="center" gap="sm" py="xl">
<Text size="sm" c="dimmed">
{t(
'review.documents.noInlinePreview',
'This file type cannot be previewed in the browser.',
)}
</Text>
<Button
component="a"
href={previewFile.url}
target="_blank"
rel="noreferrer"
leftSection={<IconDownload size={16} />}
>
{t('review.documents.downloadShort', 'Download')}
</Button>
</Stack>
)
) : null}
</Drawer>
</Stack>
);
}

View File

@@ -0,0 +1,312 @@
import type { ApplicationDetail, LicenseStatus } from '@ema-platform/api';
import { PERMISSIONS } from '../../../layouts/nav-config';
/**
* Where an action is rendered. One tier per action, decided here rather than
* by whoever happens to be laying out the page — that is what produced buttons
* scattered down the right-hand column with no ordering principle.
*/
export type ActionTier =
/** Approve / Request Adjustment / Reject. Decision Bar, right. Max three. */
| 'primary'
/** Claim, Assign, Escalate, Hold, Return. Decision Bar, left. */
| 'workflow'
/** Print, Export, Certificate, Audit, Copy Link. Overflow menu. */
| 'secondary'
/** Void, Revoke, Cancel. Overflow menu, separated, red, typed confirm. */
| 'destructive';
export type ActionId =
| 'claim'
| 'assign'
| 'escalate'
| 'hold'
| 'resume'
| 'complete-review'
| 'approve-documents'
| 'schedule-inspection'
| 'record-inspection'
| 'final-approve'
| 'request-adjustment'
| 'reject'
| 'confirm-payment'
| 'print'
| 'copy-link'
| 'download-documents'
| 'generate-certificate'
| 'audit-trail';
export interface ActionDefinition {
id: ActionId;
tier: ActionTier;
labelKey: string;
/** Statuses the action can be fired from. Mirrors the API transition table. */
from?: LicenseStatus[];
/** Any one of these authorises it. Omitted means no permission needed. */
permissions?: string[];
/** Only one primary action is ever filled; everything else is light. */
emphasis?: 'filled' | 'light' | 'subtle';
color?: string;
/** Requires a typed reason before it will submit. */
requiresReason?: boolean;
/** Cannot be undone — the confirmation says so explicitly. */
irreversible?: boolean;
}
/**
* Every action an officer can take, in one place.
*
* Actions absent from this list are absent because the API has no endpoint for
* them. Void, Revoke and Cancel are the notable gaps: `SUSPEND_LICENSE` and
* `CANCEL_LICENSE` permissions exist, but no route does either, so rendering
* them would be a button that cannot work.
*/
export const ACTIONS: ActionDefinition[] = [
// ------------------------------------------------------------- workflow
{
id: 'claim',
tier: 'workflow',
labelKey: 'review.actions.claim',
from: ['SUBMITTED'],
permissions: ['can:claim:license-application'],
emphasis: 'light',
},
{
id: 'assign',
tier: 'workflow',
labelKey: 'review.actions.assign',
from: [
'SUBMITTED',
'UNDER_REVIEW',
'UNDER_EVALUATION',
'INSPECTION_PENDING',
'INSPECTION_COMPLETED',
'ON_HOLD',
],
permissions: ['can:assign:license-application'],
emphasis: 'subtle',
},
{
id: 'escalate',
tier: 'workflow',
labelKey: 'review.actions.escalate',
from: ['UNDER_REVIEW', 'UNDER_EVALUATION', 'INSPECTION_COMPLETED'],
permissions: ['can:escalate:license-application'],
emphasis: 'subtle',
requiresReason: true,
},
{
id: 'hold',
tier: 'workflow',
labelKey: 'review.actions.hold',
from: [
'UNDER_REVIEW',
'UNDER_EVALUATION',
'INSPECTION_PENDING',
'INSPECTION_COMPLETED',
],
permissions: ['can:hold:license-application'],
emphasis: 'subtle',
requiresReason: true,
},
{
id: 'resume',
tier: 'workflow',
labelKey: 'review.actions.resume',
from: ['ON_HOLD'],
permissions: ['can:hold:license-application'],
emphasis: 'light',
},
// -------------------------------------------------------------- primary
{
id: 'complete-review',
tier: 'primary',
labelKey: 'review.actions.completeReview',
from: ['UNDER_REVIEW'],
permissions: [
'can:review:license-application',
'can:evaluate:license-application',
],
emphasis: 'filled',
},
{
id: 'approve-documents',
tier: 'primary',
labelKey: 'review.actions.approveDocuments',
from: ['UNDER_EVALUATION'],
permissions: ['can:evaluate:license-application'],
emphasis: 'filled',
},
{
id: 'schedule-inspection',
tier: 'primary',
labelKey: 'review.actions.scheduleInspection',
from: ['INSPECTION_PENDING'],
permissions: ['can:create:inspection'],
emphasis: 'filled',
},
{
id: 'record-inspection',
tier: 'primary',
labelKey: 'review.actions.recordInspection',
from: ['INSPECTION_PENDING'],
permissions: ['can:update:inspection'],
emphasis: 'filled',
},
{
id: 'final-approve',
tier: 'primary',
labelKey: 'review.actions.finalApprove',
from: ['INSPECTION_COMPLETED', 'UNDER_EVALUATION'],
permissions: ['can:approve:license-application'],
emphasis: 'filled',
color: 'teal',
irreversible: true,
},
{
id: 'request-adjustment',
tier: 'primary',
labelKey: 'review.actions.requestAdjustment',
from: ['UNDER_REVIEW', 'UNDER_EVALUATION', 'INSPECTION_COMPLETED'],
permissions: ['can:request-adjustment:license-application'],
emphasis: 'light',
color: 'orange',
requiresReason: true,
},
{
id: 'reject',
tier: 'primary',
labelKey: 'review.actions.reject',
from: [
'UNDER_REVIEW',
'UNDER_EVALUATION',
'INSPECTION_PENDING',
'INSPECTION_COMPLETED',
],
permissions: ['can:reject:license-application'],
emphasis: 'light',
color: 'red',
requiresReason: true,
irreversible: true,
},
{
id: 'confirm-payment',
tier: 'primary',
labelKey: 'review.actions.confirmPayment',
from: ['PAID'],
permissions: ['can:confirm:license-payment'],
emphasis: 'filled',
color: 'teal',
},
// ------------------------------------------------------------ secondary
{ id: 'print', tier: 'secondary', labelKey: 'review.actions.print' },
{ id: 'copy-link', tier: 'secondary', labelKey: 'review.actions.copyLink' },
{
id: 'download-documents',
tier: 'secondary',
labelKey: 'review.actions.downloadDocuments',
},
{
id: 'generate-certificate',
tier: 'secondary',
labelKey: 'review.actions.generateCertificate',
from: ['CERTIFICATE_ISSUED'],
permissions: [PERMISSIONS.VIEW_APPLICATIONS],
},
{ id: 'audit-trail', tier: 'secondary', labelKey: 'review.actions.auditTrail' },
];
export interface ResolvedAction extends ActionDefinition {
/** False when the officer can see it but cannot fire it right now. */
enabled: boolean;
/**
* Why it is disabled, already translated. Never null when `enabled` is
* false — a greyed-out control with no explanation is the thing this whole
* model exists to prevent.
*/
disabledReason?: string;
}
export interface ResolveContext {
detail: ApplicationDetail;
currentUserId: string;
can: (permissions?: string[]) => boolean;
/** Translated strings for the disabled explanations. */
reasons: {
wrongStatus: string;
notAssigned: string;
noPermission: string;
needsFlags: string;
needsCapital: string;
needsInspection: string;
};
/** Number of sections/documents the officer has flagged for correction. */
flaggedCount: number;
/** True when an inspection is scheduled and awaiting a result. */
hasPendingInspection: boolean;
}
/**
* Which actions to render, and for each, whether it can fire and why not.
*
* Actions the user has no permission for are dropped entirely; actions that
* are merely unavailable right now are kept and disabled with a reason, so the
* officer can see what the next step would be rather than wondering whether
* the screen is broken.
*/
export function resolveActions(ctx: ResolveContext): ResolvedAction[] {
const { detail, currentUserId, can, reasons } = ctx;
const app = detail.application;
return ACTIONS.filter((action) => can(action.permissions)).flatMap<ResolvedAction>(
(action) => {
// Status-scoped actions vanish outside their stage rather than piling up
// as a column of permanently dead buttons.
if (action.from && !action.from.includes(app.status)) return [];
// Scheduling and recording are the same slot at the same status; which
// one applies depends on whether an inspection is already booked.
if (action.id === 'schedule-inspection' && ctx.hasPendingInspection) return [];
if (action.id === 'record-inspection' && !ctx.hasPendingInspection) return [];
const disabled = (reason: string): ResolvedAction => ({
...action,
enabled: false,
disabledReason: reason,
});
// Decisions belong to whoever holds the application.
const needsOwnership =
action.tier === 'primary' && action.id !== 'confirm-payment';
if (
needsOwnership &&
app.assignedOfficerId &&
app.assignedOfficerId !== currentUserId
) {
return disabled(reasons.notAssigned);
}
if (action.id === 'request-adjustment' && ctx.flaggedCount === 0) {
return disabled(reasons.needsFlags);
}
if (action.id === 'final-approve') {
const threshold = app.licenseType?.capitalThreshold;
const needsCapital = threshold != null && Number(threshold) > 0;
if (needsCapital && app.capitalAmountVerified == null) {
return disabled(reasons.needsCapital);
}
if (
app.licenseType?.inspectionRequired &&
app.status !== 'INSPECTION_COMPLETED'
) {
return disabled(reasons.needsInspection);
}
}
return { ...action, enabled: true };
},
);
}

View File

@@ -0,0 +1,177 @@
import {
IconAnchor,
IconFileDescription,
IconShip,
IconTruck,
IconUsers,
type Icon,
} from '@tabler/icons-react';
import type { LicenseApplication, LicenseType } from '@ema-platform/api';
/**
* Presentation-only metadata per licence type.
*
* Deliberately thin. Everything that actually varies between licence types —
* fees, capital threshold, validity, whether an inspection is required,
* whether a certificate is issued, the form schema, the document and staff
* requirements — already lives in the `license_types` table and arrives from
* `GET /license-types`. The API entity states the rule outright: onboarding a
* new type is meant to be a seed or admin change, not a code change
* (BR-MTO-020).
*
* Duplicating any of that here would fork the source of truth and mean a new
* licence type silently rendered with another type's rules. So this file holds
* only what the database has no opinion about: which icon to draw, and the
* order review tabs appear in.
*/
export interface LicenseTypePresentation {
/** Matches `LicenseType.key`. */
key: string;
icon: Icon;
/** Review tabs, in order. Tabs with no data do not render. */
detailSections: DetailSection[];
}
export type DetailSection =
| 'overview'
| 'company'
| 'financials'
| 'documents'
| 'staff'
| 'inspection';
const DEFAULT_SECTIONS: DetailSection[] = [
'overview',
'company',
'financials',
'documents',
'staff',
'inspection',
];
const PRESENTATION: Record<string, LicenseTypePresentation> = {
FREIGHT_FORWARDER: {
key: 'FREIGHT_FORWARDER',
icon: IconTruck,
detailSections: DEFAULT_SECTIONS,
},
SHIPPING_AGENT: {
key: 'SHIPPING_AGENT',
icon: IconShip,
detailSections: DEFAULT_SECTIONS,
},
COMBINED_SA_FF: {
key: 'COMBINED_SA_FF',
icon: IconFileDescription,
detailSections: DEFAULT_SECTIONS,
},
JOINT_INVESTOR: {
key: 'JOINT_INVESTOR',
icon: IconUsers,
// Terminates at COMPLETED with no payment and no certificate, and the
// workflow skips inspection for it.
detailSections: ['overview', 'company', 'financials', 'documents', 'staff'],
},
MULTIMODAL_TRANSPORT_OPERATOR: {
key: 'MULTIMODAL_TRANSPORT_OPERATOR',
icon: IconAnchor,
detailSections: DEFAULT_SECTIONS,
},
};
/** Falls back to a generic presentation so an unseeded type still renders. */
export function presentationFor(key: string | undefined): LicenseTypePresentation {
return (
(key && PRESENTATION[key]) || {
key: key ?? 'UNKNOWN',
icon: IconFileDescription,
detailSections: DEFAULT_SECTIONS,
}
);
}
export const LICENSE_TYPE_KEYS = Object.keys(PRESENTATION);
// ---------------------------------------------------------------- eligibility
export interface EligibilityRule {
id: string;
/** Plain-language statement of the rule, already interpolated. */
label: string;
/** What the application actually declares/verifies, formatted. */
actual: string;
status: 'pass' | 'fail' | 'unknown';
}
/**
* Turns the licence type's configured thresholds into a checked list.
*
* The capital threshold used to be applied invisibly — the officer saw a
* disabled approve button and had to know why. Rendering it as an explicit
* pass/fail line means the rule, the figure it was checked against, and the
* outcome are all on screen.
*/
export function evaluateEligibility(
application: LicenseApplication,
licenseType: LicenseType | undefined,
locale: string,
): EligibilityRule[] {
const rules: EligibilityRule[] = [];
const threshold =
licenseType?.capitalThreshold == null
? undefined
: Number(licenseType.capitalThreshold);
if (threshold !== undefined && !Number.isNaN(threshold)) {
const verified =
application.capitalAmountVerified == null
? undefined
: Number(application.capitalAmountVerified);
const declared =
application.capitalAmountDeclared == null
? undefined
: Number(application.capitalAmountDeclared);
const effective = verified ?? declared;
const currency = licenseType?.feeCurrency ?? 'ETB';
const format = (value: number) =>
`${value.toLocaleString(locale)} ${currency}`;
rules.push({
id: 'capital-threshold',
label: `Paid-up capital ≥ ${format(threshold)}`,
actual:
effective === undefined
? 'Not recorded'
: `${format(effective)}${verified === undefined ? ' (declared)' : ' (verified)'}`,
// An unverified declaration is not evidence, so it reads as unknown
// rather than as a pass the officer never actually made.
status:
effective === undefined || verified === undefined
? 'unknown'
: effective >= threshold
? 'pass'
: 'fail',
});
}
if (licenseType?.inspectionRequired) {
const inspected = [
'INSPECTION_COMPLETED',
'APPROVED',
'PAYMENT_PENDING',
'PAID',
'PAYMENT_CONFIRMED',
'CERTIFICATE_ISSUED',
'COMPLETED',
].includes(application.status);
rules.push({
id: 'inspection',
label: 'Physical inspection completed',
actual: inspected ? 'Recorded' : 'Not yet recorded',
status: inspected ? 'pass' : 'unknown',
});
}
return rules;
}

View File

@@ -0,0 +1,72 @@
import { STATUS_LABELS, type LicenseApplication } from '@ema-platform/api';
import { computeSla } from './sla';
/**
* Escapes one CSV field.
*
* Company names routinely contain commas, and remarks contain quotes and
* newlines — unescaped, either one shifts every later column on the row.
*/
function csvCell(value: unknown): string {
if (value === null || value === undefined) return '';
const text = String(value);
return /[",\n\r]/.test(text) ? `"${text.replace(/"/g, '""')}"` : text;
}
const COLUMNS: Array<{
header: string;
value: (app: LicenseApplication, locale: string) => unknown;
}> = [
{ header: 'Application #', value: (a) => a.applicationNumber },
{ header: 'Company', value: (a) => a.companyName },
{ header: 'Trade name', value: (a) => a.tradeName },
{ header: 'TIN', value: (a) => a.tinNumber },
{ header: 'Licence type', value: (a) => a.licenseType?.name?.en ?? a.licenseTypeId },
{ header: 'Status', value: (a) => STATUS_LABELS[a.status] },
{ header: 'Kind', value: (a) => a.kind },
{ header: 'Assigned officer', value: (a) => a.assignedOfficerId },
{
header: 'Submitted',
value: (a, locale) =>
a.submittedAt ? new Date(a.submittedAt).toLocaleString(locale) : '',
},
{
header: 'Decided',
value: (a, locale) =>
a.decidedAt ? new Date(a.decidedAt).toLocaleString(locale) : '',
},
{ header: 'SLA', value: (a) => computeSla(a).label },
{ header: 'Adjustment rounds', value: (a) => a.adjustmentRound },
{ header: 'Declared capital', value: (a) => a.capitalAmountDeclared },
{ header: 'Verified capital', value: (a) => a.capitalAmountVerified },
];
/**
* Exports exactly the rows passed in.
*
* Takes the already-filtered, already-paged list rather than re-querying, so
* what lands in the file is what the officer was looking at. Note this means
* an export covers the current page — exporting a whole filtered result set
* would need a server-side export endpoint, which does not exist.
*/
export function exportApplicationsCsv(
applications: LicenseApplication[],
locale: string,
filename = `licence-applications-${new Date().toISOString().slice(0, 10)}.csv`,
): void {
const header = COLUMNS.map((column) => csvCell(column.header)).join(',');
const rows = applications.map((app) =>
COLUMNS.map((column) => csvCell(column.value(app, locale))).join(','),
);
// BOM so Excel opens Amharic and other non-ASCII content as UTF-8.
const blob = new Blob(['', [header, ...rows].join('\r\n')], {
type: 'text/csv;charset=utf-8;',
});
const url = URL.createObjectURL(blob);
const link = document.createElement('a');
link.href = url;
link.download = filename;
link.click();
URL.revokeObjectURL(url);
}

View File

@@ -1,172 +1,722 @@
import { useState } from 'react';
import { useNavigate } from 'react-router-dom';
import { useCallback, useMemo, useState } from 'react';
import { useNavigate, useParams, useSearchParams } from 'react-router-dom';
import {
ActionIcon,
Badge,
Button,
Card,
Center,
Checkbox,
Container,
Group,
Loader,
MultiSelect,
Pagination,
Paper,
SegmentedControl,
Select,
Skeleton,
Stack,
Kbd,
Modal,
Table,
Tabs,
Text,
TextInput,
Title,
Tooltip,
} from '@mantine/core';
import { IconSearch } from '@tabler/icons-react';
import { useDebouncedValue } from '@mantine/hooks';
import {
IconAlertCircle,
IconDownload,
IconRefresh,
IconSearch,
IconSortAscending,
IconSortDescending,
IconX,
} from '@tabler/icons-react';
import { notifications } from '@mantine/notifications';
import { useTranslation } from 'react-i18next';
import {
STATUS_COLORS,
STATUS_LABELS,
extractErrorMessage,
useClaimApplicationMutation,
useGetAllApplicationsQuery,
useGetAssignedToMeQuery,
useGetLicenseTypesQuery,
useGetQueueCountsQuery,
useGetQueueQuery,
useLazyExportApplicationsQuery,
type LicenseApplication,
type LicenseStatus,
type QueueFilter,
} from '@ema-platform/api';
import { EmptyState, ErrorState } from '@ema-platform/ui';
import { computeSla } from '../sla';
import {
DEFAULT_VIEW,
SAVED_VIEWS,
filterFromSearchParams,
readLastView,
searchParamsFromFilter,
writeLastView,
type SavedViewId,
} from '../queue-views';
import { exportApplicationsCsv } from '../export';
import { setDensity } from '../../../store/preferences.slice';
import { useAppDispatch, useAppSelector } from '../../../store/hooks';
import { KEYBOARD_SHORTCUTS, useQueueKeyboard } from '../useQueueKeyboard';
const PAGE_SIZE = 25;
const SEARCH_DEBOUNCE_MS = 300;
const ALL_STATUSES: LicenseStatus[] = [
'SUBMITTED',
'UNDER_REVIEW',
'UNDER_EVALUATION',
'RESUBMIT_REQUIRED',
'INSPECTION_PENDING',
'INSPECTION_COMPLETED',
'ON_HOLD',
'APPROVED',
'PAYMENT_PENDING',
'PAID',
'PAYMENT_CONFIRMED',
'CERTIFICATE_ISSUED',
'COMPLETED',
'REJECTED',
];
/**
* The officer work pool.
*
* "Unclaimed" is the shared queue; claiming moves an application into "Mine"
* and it stays there through every adjustment round.
* Saved views across the top, facets serialised into the URL so a filtered
* queue can be shared, and server-side pagination — the previous version
* rendered `data.items` unpaged, which was fine at demo volumes and would have
* stopped being fine somewhere in the hundreds.
*/
export function LicenseQueuePage() {
const { t, i18n } = useTranslation();
const navigate = useNavigate();
const [tab, setTab] = useState<'unclaimed' | 'mine'>('unclaimed');
const [search, setSearch] = useState('');
const { typeCode } = useParams();
const [searchParams, setSearchParams] = useSearchParams();
const dispatch = useAppDispatch();
const density = useAppSelector((state) => state.preferences.density);
const [view, setView] = useState<SavedViewId>(
() => (searchParams.get('view') as SavedViewId) || readLastView(),
);
const [page, setPage] = useState(() => Number(searchParams.get('page')) || 1);
const [selected, setSelected] = useState<string[]>([]);
const [searchInput, setSearchInput] = useState(searchParams.get('q') ?? '');
const [cursor, setCursor] = useState(0);
const [helpOpen, setHelpOpen] = useState(false);
const [debouncedSearch] = useDebouncedValue(searchInput, SEARCH_DEBOUNCE_MS);
const urlFilter = useMemo(
() => filterFromSearchParams(searchParams),
[searchParams],
);
const activeView = SAVED_VIEWS.find((v) => v.id === view) ?? SAVED_VIEWS[0];
const { data: licenseTypes } = useGetLicenseTypesQuery();
const { data: counts } = useGetQueueCountsQuery();
// A `/licence-review/type/:typeCode` deep link pins the type facet.
const pinnedTypeId = useMemo(() => {
if (!typeCode) return undefined;
return licenseTypes?.items?.find((type) => type.key === typeCode)?.id;
}, [typeCode, licenseTypes]);
const filter: QueueFilter = useMemo(
() => ({
...activeView.filter,
...urlFilter,
search: debouncedSearch || undefined,
licenseTypeId: pinnedTypeId ?? urlFilter.licenseTypeId,
take: PAGE_SIZE,
skip: (page - 1) * PAGE_SIZE,
}),
[activeView, urlFilter, debouncedSearch, pinnedTypeId, page],
);
// One query per source; the two inactive ones are skipped, so switching
// views costs a single request rather than keeping three in flight.
const queueQuery = useGetQueueQuery(filter, { skip: activeView.source !== 'queue' });
const mineQuery = useGetAssignedToMeQuery(filter, { skip: activeView.source !== 'mine' });
const allQuery = useGetAllApplicationsQuery(filter, { skip: activeView.source !== 'all' });
const active =
activeView.source === 'queue' ? queueQuery : activeView.source === 'mine' ? mineQuery : allQuery;
const queue = useGetQueueQuery({ search: search || undefined });
const mine = useGetAssignedToMeQuery({ search: search || undefined });
const [claim, { isLoading: claiming }] = useClaimApplicationMutation();
const [runExport, { isFetching: exporting }] = useLazyExportApplicationsQuery();
/**
* Exports every row the filter matches, not just the page on screen.
* The server caps the result set and reports when it did, so a truncated
* export says so instead of quietly being wrong.
*/
async function handleExport() {
try {
const result = await runExport({ ...filter, take: undefined, skip: undefined }).unwrap();
exportApplicationsCsv(result.items, i18n.language);
if (result.truncated) {
notifications.show({
color: 'yellow',
title: t('queue.exportTruncated', 'Export truncated'),
message: t('queue.exportTruncatedBody', {
exported: result.items.length,
total: result.total,
defaultValue:
'Exported the first {{exported}} of {{total}} rows. Narrow the filter for the rest.',
}),
});
}
} catch (err) {
notifications.show({
color: 'red',
title: t('queue.exportFailed', 'Export failed'),
message: extractErrorMessage(err),
});
}
}
const active = tab === 'unclaimed' ? queue : mine;
const items = active.data?.items ?? [];
const total = active.data?.total ?? 0;
const pageCount = Math.max(1, Math.ceil(total / PAGE_SIZE));
const updateUrl = useCallback(
(next: Partial<QueueFilter>, nextView: SavedViewId, nextPage: number) => {
setSearchParams(
searchParamsFromFilter({ ...urlFilter, ...next }, nextView, nextPage),
{ replace: true },
);
},
[urlFilter, setSearchParams],
);
const changeView = (next: SavedViewId) => {
setView(next);
writeLastView(next);
setPage(1);
setSelected([]);
updateUrl({}, next, 1);
};
const setFacet = (next: Partial<QueueFilter>) => {
setPage(1);
updateUrl(next, view, 1);
};
const toggleSort = (field: NonNullable<QueueFilter['sortBy']>) => {
const dir =
urlFilter.sortBy === field && urlFilter.sortDir !== 'DESC' ? 'DESC' : 'ASC';
setFacet({ sortBy: field, sortDir: dir });
};
async function handleClaim(id: string) {
try {
await claim(id).unwrap();
notifications.show({
color: 'teal',
title: 'Claimed',
message: 'The application is now assigned to you.',
title: t('queue.claimed', 'Claimed'),
message: t('queue.claimedBody', 'The application is now assigned to you.'),
});
setTab('mine');
changeView('mine');
} catch (err) {
// A 409 means another officer got there first — refresh so the queue
// stops showing work that is no longer available.
notifications.show({
color: 'red',
title: 'Could not claim',
message: extractErrorMessage(err, 'Another officer already claimed it.'),
title: t('queue.claimFailed', 'Could not claim'),
message: extractErrorMessage(
err,
t('queue.claimRace', 'Another officer already claimed it.'),
),
});
queue.refetch();
active.refetch();
}
}
async function handleBulkClaim() {
const results = await Promise.allSettled(
selected.map((id) => claim(id).unwrap()),
);
const claimed = results.filter((r) => r.status === 'fulfilled').length;
const lost = results.length - claimed;
notifications.show({
color: lost ? 'yellow' : 'teal',
title: t('queue.bulkClaimed', { count: claimed, defaultValue: '{{count}} claimed' }),
// Partial success is the normal case in a shared queue, so it is
// reported rather than swallowed or treated as total failure.
message: lost
? t('queue.bulkClaimPartial', {
count: lost,
defaultValue: '{{count}} were already taken by another officer.',
})
: '',
});
setSelected([]);
active.refetch();
}
const cursorRow = items[cursor];
useQueueKeyboard({
enabled: !helpOpen,
onNext: () => setCursor((c) => Math.min(c + 1, Math.max(items.length - 1, 0))),
onPrevious: () => setCursor((c) => Math.max(c - 1, 0)),
onOpen: () => cursorRow && navigate(`/licence-review/${cursorRow.id}`),
onClaim: () => {
// Only unclaimed rows can be claimed; pressing c elsewhere is a no-op
// rather than an error the officer has to read.
if (cursorRow && cursorRow.assignedOfficerId === null) handleClaim(cursorRow.id);
},
onEscape: () => setSelected([]),
onHelp: () => setHelpOpen(true),
});
const allSelected = items.length > 0 && selected.length === items.length;
const sortIcon =
urlFilter.sortDir === 'DESC' ? <IconSortDescending size={13} /> : <IconSortAscending size={13} />;
const hasFacets = Boolean(
urlFilter.status?.length ||
urlFilter.licenseTypeId ||
urlFilter.assignee ||
urlFilter.submittedFrom ||
debouncedSearch,
);
return (
<Container size="xl" py="md">
<Container size="xl" py="md" pb={selected.length ? 80 : 'md'}>
<Group justify="space-between" mb="md">
<Title order={3}>Licence applications</Title>
<Group>
<TextInput
placeholder="Company, TIN or number"
leftSection={<IconSearch size={14} />}
value={search}
onChange={(e) => setSearch(e.currentTarget.value)}
w={260}
/>
<div>
<Title order={3}>{t('queue.title', 'Licence applications')}</Title>
{typeCode && (
<Text size="sm" c="dimmed">
{t(`nav.type${typeCode}`, { defaultValue: typeCode })}
</Text>
)}
</div>
<Group gap="xs">
<Tooltip label={t('queue.refresh', 'Refresh')}>
<ActionIcon variant="default" size="lg" onClick={() => active.refetch()}>
<IconRefresh size={18} />
</ActionIcon>
</Tooltip>
<SegmentedControl
value={tab}
onChange={(v) => setTab(v as 'unclaimed' | 'mine')}
size="xs"
value={density}
onChange={(v) => dispatch(setDensity(v as 'comfortable' | 'compact'))}
data={[
{ label: `Unclaimed (${queue.data?.total ?? 0})`, value: 'unclaimed' },
{ label: `Mine (${mine.data?.total ?? 0})`, value: 'mine' },
{ label: t('queue.comfortable', 'Comfortable'), value: 'comfortable' },
{ label: t('queue.compact', 'Compact'), value: 'compact' },
]}
/>
<Button
variant="default"
leftSection={<IconDownload size={16} />}
onClick={handleExport}
loading={exporting}
disabled={total === 0}
>
{t('queue.export', 'Export CSV')}
</Button>
</Group>
</Group>
{/* Saved views, counted. */}
<Tabs value={view} onChange={(v) => changeView((v as SavedViewId) ?? DEFAULT_VIEW)} mb="sm">
<Tabs.List>
{SAVED_VIEWS.map((savedView) => (
<Tabs.Tab
key={savedView.id}
value={savedView.id}
rightSection={
counts?.[savedView.countKey] ? (
<Badge size="xs" variant="light" circle>
{counts[savedView.countKey]}
</Badge>
) : undefined
}
>
{t(savedView.labelKey)}
</Tabs.Tab>
))}
</Tabs.List>
</Tabs>
{/* Facets — every one of these is reflected in the URL. */}
<Paper withBorder p="sm" mb="sm">
<Group gap="sm" align="flex-end" wrap="wrap">
<TextInput
label={t('queue.search', 'Search')}
placeholder={t('queue.searchPlaceholder', 'Company, TIN or number')}
leftSection={<IconSearch size={14} />}
value={searchInput}
onChange={(e) => setSearchInput(e.currentTarget.value)}
w={240}
/>
<MultiSelect
label={t('queue.status', 'Status')}
placeholder={t('queue.anyStatus', 'Any')}
data={ALL_STATUSES.map((s) => ({ value: s, label: STATUS_LABELS[s] }))}
value={urlFilter.status ?? []}
onChange={(v) => setFacet({ status: v as LicenseStatus[] })}
clearable
w={240}
/>
{!typeCode && (
<Select
label={t('queue.type', 'Licence type')}
placeholder={t('queue.anyType', 'Any')}
data={(licenseTypes?.items ?? []).map((type) => ({
value: type.id,
label: type.name.en ?? type.key,
}))}
value={urlFilter.licenseTypeId ?? null}
onChange={(v) => setFacet({ licenseTypeId: v ?? undefined })}
clearable
w={220}
/>
)}
<TextInput
type="date"
label={t('queue.submittedFrom', 'Submitted from')}
value={urlFilter.submittedFrom ?? ''}
onChange={(e) => setFacet({ submittedFrom: e.currentTarget.value || undefined })}
/>
<TextInput
type="date"
label={t('queue.submittedTo', 'Submitted to')}
value={urlFilter.submittedTo ?? ''}
onChange={(e) => setFacet({ submittedTo: e.currentTarget.value || undefined })}
/>
{hasFacets && (
<Button
variant="subtle"
leftSection={<IconX size={14} />}
onClick={() => {
setSearchInput('');
setSearchParams(new URLSearchParams(), { replace: true });
}}
>
{t('queue.clearFilters', 'Clear')}
</Button>
)}
</Group>
</Paper>
<Card withBorder padding={0}>
{active.isLoading ? (
<Center h={200}>
<Loader />
</Center>
// Skeleton rows match the real table, so the layout does not jump
// when data lands.
<Stack gap={0} p="md">
{Array.from({ length: 6 }).map((_, i) => (
<Skeleton key={i} height={44} mb="xs" radius="sm" />
))}
</Stack>
) : active.isError ? (
<ErrorState
title={t('queue.errorTitle', 'Could not load the queue')}
description={extractErrorMessage(active.error)}
onRetry={() => active.refetch()}
icon={IconAlertCircle}
/>
) : items.length === 0 ? (
<Center h={160}>
<Text c="dimmed" size="sm">
{tab === 'unclaimed'
? 'No applications waiting to be claimed.'
: 'You have no applications in progress.'}
</Text>
</Center>
<EmptyState
title={
hasFacets
? t('queue.emptyFiltered', 'No applications match these filters')
: t('queue.empty', 'Nothing waiting here')
}
description={
hasFacets
? t('queue.emptyFilteredBody', 'Try widening or clearing the filters.')
: t('queue.emptyBody', 'New applications will appear here as they are submitted.')
}
action={
hasFacets
? {
label: t('queue.clearFilters', 'Clear'),
onClick: () => setSearchParams(new URLSearchParams(), { replace: true }),
}
: undefined
}
/>
) : (
<Table highlightOnHover>
<Table.Thead>
<Table.Tr>
<Table.Th>Number</Table.Th>
<Table.Th>Company</Table.Th>
<Table.Th>TIN</Table.Th>
<Table.Th>Status</Table.Th>
<Table.Th>Submitted</Table.Th>
<Table.Th />
</Table.Tr>
</Table.Thead>
<Table.Tbody>
{items.map((app) => (
<Table.Tr key={app.id}>
<Table.Td>
<Text size="sm" fw={500}>
{app.applicationNumber}
</Text>
</Table.Td>
<Table.Td>
<Text size="sm">{app.companyName ?? '—'}</Text>
</Table.Td>
<Table.Td>
<Text size="sm" c="dimmed">
{app.tinNumber ?? '—'}
</Text>
</Table.Td>
<Table.Td>
<Badge color={STATUS_COLORS[app.status]} variant="light">
{STATUS_LABELS[app.status]}
</Badge>
</Table.Td>
<Table.Td>
<Text size="sm" c="dimmed">
{app.submittedAt
? new Date(app.submittedAt).toLocaleDateString()
: '—'}
</Text>
</Table.Td>
<Table.Td align="right">
{tab === 'unclaimed' ? (
<Button
size="xs"
loading={claiming}
onClick={() => handleClaim(app.id)}
>
Claim
</Button>
) : (
<Button
size="xs"
variant="light"
onClick={() => navigate(`/licence-review/${app.id}`)}
>
Review
</Button>
)}
</Table.Td>
</Table.Tr>
))}
</Table.Tbody>
</Table>
<>
<Table.ScrollContainer minWidth={1100}>
<Table highlightOnHover verticalSpacing={density === "compact" ? 4 : "sm"}>
<Table.Thead>
<Table.Tr>
<Table.Th w={40}>
<Checkbox
aria-label={t('queue.selectAll', 'Select all')}
checked={allSelected}
indeterminate={selected.length > 0 && !allSelected}
onChange={() =>
setSelected(allSelected ? [] : items.map((a) => a.id))
}
/>
</Table.Th>
<SortableTh
label={t('queue.number', 'App #')}
field="applicationNumber"
current={urlFilter.sortBy}
icon={sortIcon}
onSort={toggleSort}
/>
<SortableTh
label={t('queue.company', 'Company')}
field="companyName"
current={urlFilter.sortBy}
icon={sortIcon}
onSort={toggleSort}
/>
<Table.Th>{t('queue.tin', 'TIN')}</Table.Th>
<Table.Th>{t('queue.typeCol', 'Type')}</Table.Th>
<SortableTh
label={t('queue.statusCol', 'Status')}
field="status"
current={urlFilter.sortBy}
icon={sortIcon}
onSort={toggleSort}
/>
<SortableTh
label={t('queue.submitted', 'Submitted')}
field="submittedAt"
current={urlFilter.sortBy}
icon={sortIcon}
onSort={toggleSort}
/>
<Table.Th>{t('queue.sla', 'Age / SLA')}</Table.Th>
<Table.Th />
</Table.Tr>
</Table.Thead>
<Table.Tbody>
{items.map((app, index) => (
<QueueRow
key={app.id}
app={app}
focused={index === cursor}
selected={selected.includes(app.id)}
claiming={claiming}
locale={i18n.language}
onSelect={(checked) =>
setSelected((prev) =>
checked ? [...prev, app.id] : prev.filter((id) => id !== app.id),
)
}
onClaim={() => handleClaim(app.id)}
onOpen={() => navigate(`/licence-review/${app.id}`)}
/>
))}
</Table.Tbody>
</Table>
</Table.ScrollContainer>
<Group justify="space-between" p="sm">
<Text size="sm" c="dimmed">
{t('queue.showing', {
from: (page - 1) * PAGE_SIZE + 1,
to: Math.min(page * PAGE_SIZE, total),
total,
defaultValue: 'Showing {{from}}{{to}} of {{total}}',
})}
</Text>
<Pagination
value={page}
onChange={(next) => {
setPage(next);
updateUrl({}, view, next);
}}
total={pageCount}
size="sm"
/>
</Group>
</>
)}
</Card>
<Modal
opened={helpOpen}
onClose={() => setHelpOpen(false)}
title={t('shortcuts.title', 'Keyboard shortcuts')}
size="sm"
>
<Stack gap="xs">
{KEYBOARD_SHORTCUTS.map((shortcut) => (
<Group key={shortcut.keys} justify="space-between">
<Text size="sm">{t(shortcut.labelKey)}</Text>
<Kbd>{shortcut.keys}</Kbd>
</Group>
))}
</Stack>
</Modal>
{/* Bulk bar. Floating, with the count stated so the scope of the action
is never ambiguous. */}
{selected.length > 0 && (
<Paper
withBorder
shadow="md"
p="sm"
style={{ position: 'sticky', bottom: 16, zIndex: 50 }}
>
<Group justify="space-between">
<Text size="sm" fw={500}>
{t('queue.selectedCount', {
count: selected.length,
defaultValue: '{{count}} selected',
})}
</Text>
<Group gap="xs">
<Button variant="subtle" onClick={() => setSelected([])}>
{t('common.cancel', 'Cancel')}
</Button>
<Button
variant="default"
leftSection={<IconDownload size={16} />}
onClick={() =>
exportApplicationsCsv(
items.filter((a) => selected.includes(a.id)),
i18n.language,
)
}
>
{t('queue.export', 'Export CSV')}
</Button>
<Button loading={claiming} onClick={handleBulkClaim}>
{t('queue.bulkClaim', {
count: selected.length,
defaultValue: 'Claim {{count}}',
})}
</Button>
</Group>
</Group>
</Paper>
)}
</Container>
);
}
function SortableTh({
label,
field,
current,
icon,
onSort,
}: {
label: string;
field: NonNullable<QueueFilter['sortBy']>;
current?: QueueFilter['sortBy'];
icon: React.ReactNode;
onSort: (field: NonNullable<QueueFilter['sortBy']>) => void;
}) {
return (
<Table.Th>
<Group
gap={4}
wrap="nowrap"
style={{ cursor: 'pointer' }}
onClick={() => onSort(field)}
>
<span>{label}</span>
{current === field && icon}
</Group>
</Table.Th>
);
}
function QueueRow({
app,
selected,
focused,
claiming,
locale,
onSelect,
onClaim,
onOpen,
}: {
app: LicenseApplication;
selected: boolean;
focused: boolean;
claiming: boolean;
locale: string;
onSelect: (checked: boolean) => void;
onClaim: () => void;
onOpen: () => void;
}) {
const { t } = useTranslation();
const sla = computeSla(app);
return (
<Table.Tr
// Keyboard cursor. Marked with a left border rather than a background so
// it stays distinguishable from row selection and from hover.
style={
focused
? { boxShadow: 'inset 3px 0 0 var(--mantine-color-blue-6)' }
: undefined
}
>
<Table.Td>
<Checkbox
aria-label={t('queue.selectRow', { number: app.applicationNumber, defaultValue: 'Select {{number}}' })}
checked={selected}
onChange={(e) => onSelect(e.currentTarget.checked)}
/>
</Table.Td>
<Table.Td>
<Text size="sm" fw={500}>
{app.applicationNumber}
</Text>
</Table.Td>
<Table.Td>
<Text size="sm">{app.companyName ?? '—'}</Text>
</Table.Td>
<Table.Td>
<Text size="sm" c="dimmed">
{app.tinNumber ?? '—'}
</Text>
</Table.Td>
<Table.Td>
<Text size="sm">{app.licenseType?.name?.en ?? '—'}</Text>
</Table.Td>
<Table.Td>
<Badge color={STATUS_COLORS[app.status]} variant="light">
{STATUS_LABELS[app.status]}
</Badge>
</Table.Td>
<Table.Td>
<Text size="sm" c="dimmed">
{app.submittedAt ? new Date(app.submittedAt).toLocaleDateString(locale) : '—'}
</Text>
</Table.Td>
<Table.Td>
{/* Colour is never the only signal — the label says the same thing. */}
<Tooltip label={sla.tooltip} withArrow>
<Badge color={sla.color} variant="light" size="sm">
{sla.label}
</Badge>
</Tooltip>
</Table.Td>
<Table.Td align="right">
<Group gap="xs" justify="flex-end" wrap="nowrap">
{app.assignedOfficerId === null && app.status === 'SUBMITTED' ? (
<Button size="xs" loading={claiming} onClick={onClaim}>
{t('queue.claim', 'Claim')}
</Button>
) : (
<Button size="xs" variant="light" onClick={onOpen}>
{t('queue.review', 'Review')}
</Button>
)}
</Group>
</Table.Td>
</Table.Tr>
);
}
export default LicenseQueuePage;

View File

@@ -0,0 +1,152 @@
import type { LicenseStatus, QueueCounts, QueueFilter } from '@ema-platform/api';
export type SavedViewId =
| 'unassigned'
| 'mine'
| 'awaitingApplicant'
| 'overdue'
| 'readyToIssue'
| 'all';
export interface SavedView {
id: SavedViewId;
labelKey: string;
/** Which count from `/counts` labels the tab. */
countKey: keyof QueueCounts;
/** Filters this view pins. The user's own facets layer on top. */
filter: Partial<QueueFilter>;
/** Which list endpoint backs it. */
source: 'queue' | 'mine' | 'all';
}
const AWAITING_APPLICANT: LicenseStatus[] = ['RESUBMIT_REQUIRED'];
const READY_TO_ISSUE: LicenseStatus[] = ['PAYMENT_CONFIRMED'];
/**
* The officer's saved views.
*
* Replaces a two-option SegmentedControl (Unclaimed / Mine) that could not
* express the questions officers actually ask — what is late, what is waiting
* on the applicant, what is ready to issue. Each is a filter preset over the
* same grid rather than a separate screen.
*/
export const SAVED_VIEWS: SavedView[] = [
{
id: 'unassigned',
labelKey: 'queue.views.unassigned',
countKey: 'unassigned',
filter: {},
source: 'queue',
},
{
id: 'mine',
labelKey: 'queue.views.mine',
countKey: 'mine',
filter: {},
source: 'mine',
},
{
id: 'awaitingApplicant',
labelKey: 'queue.views.awaitingApplicant',
countKey: 'awaitingApplicant',
filter: { status: AWAITING_APPLICANT },
source: 'all',
},
{
id: 'overdue',
labelKey: 'queue.views.overdue',
countKey: 'overdue',
filter: { overdue: true },
source: 'all',
},
{
id: 'readyToIssue',
labelKey: 'queue.views.readyToIssue',
countKey: 'readyToIssue',
filter: { status: READY_TO_ISSUE },
source: 'all',
},
{
id: 'all',
labelKey: 'queue.views.all',
countKey: 'all',
filter: {},
source: 'all',
},
];
export const DEFAULT_VIEW: SavedViewId = 'unassigned';
const LAST_VIEW_KEY = 'ema-backoffice-queue-view';
export function readLastView(): SavedViewId {
try {
const stored = localStorage.getItem(LAST_VIEW_KEY) as SavedViewId | null;
return SAVED_VIEWS.some((v) => v.id === stored) ? (stored as SavedViewId) : DEFAULT_VIEW;
} catch {
return DEFAULT_VIEW;
}
}
export function writeLastView(id: SavedViewId): void {
try {
localStorage.setItem(LAST_VIEW_KEY, id);
} catch {
// Not persisting the last view is cosmetic; never break the page for it.
}
}
// ------------------------------------------------------------ URL round-trip
/**
* Reads the user's facets out of the query string.
*
* Filters live in the URL so a filtered queue is a shareable link — "here are
* the six overdue MTO applications" should be something an officer can paste
* into a message, not a state they describe in prose.
*/
export function filterFromSearchParams(params: URLSearchParams): QueueFilter {
const filter: QueueFilter = {};
const search = params.get('q');
if (search) filter.search = search;
const type = params.get('type');
if (type) filter.licenseTypeId = type;
const status = params.get('status');
if (status) filter.status = status.split(',') as LicenseStatus[];
const assignee = params.get('assignee');
if (assignee) filter.assignee = assignee;
const from = params.get('from');
if (from) filter.submittedFrom = from;
const to = params.get('to');
if (to) filter.submittedTo = to;
const sortBy = params.get('sort');
if (sortBy) filter.sortBy = sortBy as QueueFilter['sortBy'];
const sortDir = params.get('dir');
if (sortDir === 'ASC' || sortDir === 'DESC') filter.sortDir = sortDir;
const page = params.get('page');
if (page) {
const parsed = Number(page);
if (Number.isFinite(parsed) && parsed > 0) filter.skip = undefined;
}
return filter;
}
/** Inverse of {@link filterFromSearchParams}. Omits defaults to keep URLs short. */
export function searchParamsFromFilter(
filter: QueueFilter,
view: SavedViewId,
page: number,
): URLSearchParams {
const params = new URLSearchParams();
if (view !== DEFAULT_VIEW) params.set('view', view);
if (filter.search) params.set('q', filter.search);
if (filter.licenseTypeId) params.set('type', filter.licenseTypeId);
if (filter.status?.length) params.set('status', filter.status.join(','));
if (filter.assignee) params.set('assignee', filter.assignee);
if (filter.submittedFrom) params.set('from', filter.submittedFrom);
if (filter.submittedTo) params.set('to', filter.submittedTo);
if (filter.sortBy) params.set('sort', filter.sortBy);
if (filter.sortDir && filter.sortDir !== 'ASC') params.set('dir', filter.sortDir);
if (page > 1) params.set('page', String(page));
return params;
}

View File

@@ -0,0 +1,89 @@
import type { LicenseApplication } from '@ema-platform/api';
/** Amber once this much of the window has been consumed. */
const WARNING_RATIO = 0.7;
const HOUR_MS = 60 * 60 * 1000;
export interface SlaState {
state: 'ok' | 'warning' | 'breached' | 'untracked' | 'decided';
/** Mantine colour. Always paired with `label` — never colour alone. */
color: string;
/** Short text for the badge, e.g. "2d left" or "Overdue 6h". */
label: string;
/** The full explanation, including the target, for the tooltip. */
tooltip: string;
/** Fraction of the window used, clamped to 0..1. */
ratio: number;
}
function formatDuration(ms: number): string {
const hours = Math.floor(Math.abs(ms) / HOUR_MS);
if (hours < 1) return '<1h';
if (hours < 48) return `${hours}h`;
return `${Math.floor(hours / 24)}d`;
}
/**
* How an application is tracking against its licence type's SLA.
*
* Types with no `slaHours` are untracked rather than instantly overdue — the
* authority has not set a target for them, which is not the same as missing
* one. Decided applications stop the clock: an approval that took three weeks
* is history, not an outstanding breach.
*/
export function computeSla(
application: LicenseApplication,
now: number = Date.now(),
): SlaState {
const slaHours = application.licenseType?.slaHours;
const submittedAt = application.submittedAt;
if (!slaHours || !submittedAt) {
return {
state: 'untracked',
color: 'gray',
label: '—',
tooltip: 'No turnaround target is set for this licence type.',
ratio: 0,
};
}
const submitted = new Date(submittedAt).getTime();
const target = submitted + slaHours * HOUR_MS;
const elapsed = (application.decidedAt ? new Date(application.decidedAt).getTime() : now) - submitted;
const window = slaHours * HOUR_MS;
const ratio = Math.min(Math.max(elapsed / window, 0), 1);
const targetText = `Target ${slaHours}h from submission (${new Date(target).toLocaleString()})`;
if (application.decidedAt) {
const met = elapsed <= window;
return {
state: 'decided',
color: met ? 'teal' : 'gray',
label: met ? 'Met' : 'Missed',
tooltip: `Decided in ${formatDuration(elapsed)}. ${targetText}`,
ratio,
};
}
const remaining = target - now;
if (remaining < 0) {
return {
state: 'breached',
color: 'red',
label: `Overdue ${formatDuration(remaining)}`,
tooltip: `Overdue by ${formatDuration(remaining)}. ${targetText}`,
ratio: 1,
};
}
const used = elapsed / window;
return {
state: used >= WARNING_RATIO ? 'warning' : 'ok',
color: used >= WARNING_RATIO ? 'yellow' : 'teal',
label: `${formatDuration(remaining)} left`,
tooltip: `${formatDuration(remaining)} remaining. ${targetText}`,
ratio,
};
}

View File

@@ -0,0 +1,100 @@
import { useEffect } from 'react';
/**
* True when the user is typing, so a shortcut must not steal the keystroke.
*
* Without this, typing a company name into the search box would jump rows on
* every "j" and try to claim on every "c".
*/
function isTyping(target: EventTarget | null): boolean {
if (!(target instanceof HTMLElement)) return false;
const tag = target.tagName;
return (
tag === 'INPUT' ||
tag === 'TEXTAREA' ||
tag === 'SELECT' ||
target.isContentEditable
);
}
interface QueueKeyboardHandlers {
onNext: () => void;
onPrevious: () => void;
onOpen: () => void;
onClaim: () => void;
onEscape: () => void;
onHelp: () => void;
/** Disabled while a modal or drawer owns the keyboard. */
enabled?: boolean;
}
/**
* Queue keyboard navigation: j/k to move, Enter to open, c to claim.
*
* Officers work through a queue one row at a time all day; reaching for the
* mouse for each is the slow path. Modifier combinations are ignored so
* browser and OS shortcuts keep working.
*/
export function useQueueKeyboard({
onNext,
onPrevious,
onOpen,
onClaim,
onEscape,
onHelp,
enabled = true,
}: QueueKeyboardHandlers): void {
useEffect(() => {
if (!enabled) return;
const handler = (event: KeyboardEvent) => {
if (isTyping(event.target)) {
// Esc still works while typing — it is how you get out of the field.
if (event.key === 'Escape') onEscape();
return;
}
if (event.metaKey || event.ctrlKey || event.altKey) return;
switch (event.key) {
case 'j':
event.preventDefault();
onNext();
break;
case 'k':
event.preventDefault();
onPrevious();
break;
case 'Enter':
event.preventDefault();
onOpen();
break;
case 'c':
event.preventDefault();
onClaim();
break;
case 'Escape':
onEscape();
break;
case '?':
event.preventDefault();
onHelp();
break;
default:
break;
}
};
window.addEventListener('keydown', handler);
return () => window.removeEventListener('keydown', handler);
}, [enabled, onNext, onPrevious, onOpen, onClaim, onEscape, onHelp]);
}
/** Rows shown in the `?` cheatsheet. */
export const KEYBOARD_SHORTCUTS: Array<{ keys: string; labelKey: string }> = [
{ keys: '⌘K', labelKey: 'shortcuts.commandPalette' },
{ keys: 'j / k', labelKey: 'shortcuts.moveRow' },
{ keys: 'Enter', labelKey: 'shortcuts.openRow' },
{ keys: 'c', labelKey: 'shortcuts.claimRow' },
{ keys: 'Esc', labelKey: 'shortcuts.dismiss' },
{ keys: '?', labelKey: 'shortcuts.help' },
];

View File

@@ -123,7 +123,7 @@ function TreeNode({
)}
</Box>
)}
{!hasChildren && <Box w={rem(18)} flexShrink={0} />}
{!hasChildren && <Box w={rem(18)} style={{ flexShrink: 0 }} />}
<IconMapPin
size={14}
stroke={1.5}