mirror of
https://github.com/Tria-plc/emaui.git
synced 2026-08-30 00:38:12 +00:00
Initial End to End functionality
This commit is contained in:
@@ -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;
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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);
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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 };
|
||||
},
|
||||
);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
72
apps/backoffice/src/app/features/license-review/export.ts
Normal file
72
apps/backoffice/src/app/features/license-review/export.ts
Normal 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);
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
152
apps/backoffice/src/app/features/license-review/queue-views.ts
Normal file
152
apps/backoffice/src/app/features/license-review/queue-views.ts
Normal 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;
|
||||
}
|
||||
89
apps/backoffice/src/app/features/license-review/sla.ts
Normal file
89
apps/backoffice/src/app/features/license-review/sla.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
@@ -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' },
|
||||
];
|
||||
@@ -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}
|
||||
|
||||
Reference in New Issue
Block a user