mirror of
https://github.com/Tria-plc/emaui.git
synced 2026-08-26 19:12:50 +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 { useCallback, useMemo, useState } from 'react';
|
||||||
import { useNavigate } from 'react-router-dom';
|
import { useNavigate, useParams, useSearchParams } from 'react-router-dom';
|
||||||
import {
|
import {
|
||||||
|
ActionIcon,
|
||||||
Badge,
|
Badge,
|
||||||
Button,
|
Button,
|
||||||
Card,
|
Card,
|
||||||
Center,
|
Checkbox,
|
||||||
Container,
|
Container,
|
||||||
Group,
|
Group,
|
||||||
Loader,
|
MultiSelect,
|
||||||
|
Pagination,
|
||||||
|
Paper,
|
||||||
SegmentedControl,
|
SegmentedControl,
|
||||||
|
Select,
|
||||||
|
Skeleton,
|
||||||
|
Stack,
|
||||||
|
Kbd,
|
||||||
|
Modal,
|
||||||
Table,
|
Table,
|
||||||
|
Tabs,
|
||||||
Text,
|
Text,
|
||||||
TextInput,
|
TextInput,
|
||||||
Title,
|
Title,
|
||||||
|
Tooltip,
|
||||||
} from '@mantine/core';
|
} 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 { notifications } from '@mantine/notifications';
|
||||||
|
import { useTranslation } from 'react-i18next';
|
||||||
import {
|
import {
|
||||||
STATUS_COLORS,
|
STATUS_COLORS,
|
||||||
STATUS_LABELS,
|
STATUS_LABELS,
|
||||||
extractErrorMessage,
|
extractErrorMessage,
|
||||||
useClaimApplicationMutation,
|
useClaimApplicationMutation,
|
||||||
|
useGetAllApplicationsQuery,
|
||||||
useGetAssignedToMeQuery,
|
useGetAssignedToMeQuery,
|
||||||
|
useGetLicenseTypesQuery,
|
||||||
|
useGetQueueCountsQuery,
|
||||||
useGetQueueQuery,
|
useGetQueueQuery,
|
||||||
|
useLazyExportApplicationsQuery,
|
||||||
|
type LicenseApplication,
|
||||||
|
type LicenseStatus,
|
||||||
|
type QueueFilter,
|
||||||
} from '@ema-platform/api';
|
} 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.
|
* The officer work pool.
|
||||||
*
|
*
|
||||||
* "Unclaimed" is the shared queue; claiming moves an application into "Mine"
|
* Saved views across the top, facets serialised into the URL so a filtered
|
||||||
* and it stays there through every adjustment round.
|
* 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() {
|
export function LicenseQueuePage() {
|
||||||
|
const { t, i18n } = useTranslation();
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const [tab, setTab] = useState<'unclaimed' | 'mine'>('unclaimed');
|
const { typeCode } = useParams();
|
||||||
const [search, setSearch] = useState('');
|
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 [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 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) {
|
async function handleClaim(id: string) {
|
||||||
try {
|
try {
|
||||||
await claim(id).unwrap();
|
await claim(id).unwrap();
|
||||||
notifications.show({
|
notifications.show({
|
||||||
color: 'teal',
|
color: 'teal',
|
||||||
title: 'Claimed',
|
title: t('queue.claimed', 'Claimed'),
|
||||||
message: 'The application is now assigned to you.',
|
message: t('queue.claimedBody', 'The application is now assigned to you.'),
|
||||||
});
|
});
|
||||||
setTab('mine');
|
changeView('mine');
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
// A 409 means another officer got there first — refresh so the queue
|
// A 409 means another officer got there first — refresh so the queue
|
||||||
// stops showing work that is no longer available.
|
// stops showing work that is no longer available.
|
||||||
notifications.show({
|
notifications.show({
|
||||||
color: 'red',
|
color: 'red',
|
||||||
title: 'Could not claim',
|
title: t('queue.claimFailed', 'Could not claim'),
|
||||||
message: extractErrorMessage(err, 'Another officer already claimed it.'),
|
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 (
|
return (
|
||||||
<Container size="xl" py="md">
|
<Container size="xl" py="md" pb={selected.length ? 80 : 'md'}>
|
||||||
<Group justify="space-between" mb="md">
|
<Group justify="space-between" mb="md">
|
||||||
<Title order={3}>Licence applications</Title>
|
<div>
|
||||||
<Group>
|
<Title order={3}>{t('queue.title', 'Licence applications')}</Title>
|
||||||
<TextInput
|
{typeCode && (
|
||||||
placeholder="Company, TIN or number"
|
<Text size="sm" c="dimmed">
|
||||||
leftSection={<IconSearch size={14} />}
|
{t(`nav.type${typeCode}`, { defaultValue: typeCode })}
|
||||||
value={search}
|
</Text>
|
||||||
onChange={(e) => setSearch(e.currentTarget.value)}
|
)}
|
||||||
w={260}
|
</div>
|
||||||
/>
|
<Group gap="xs">
|
||||||
|
<Tooltip label={t('queue.refresh', 'Refresh')}>
|
||||||
|
<ActionIcon variant="default" size="lg" onClick={() => active.refetch()}>
|
||||||
|
<IconRefresh size={18} />
|
||||||
|
</ActionIcon>
|
||||||
|
</Tooltip>
|
||||||
<SegmentedControl
|
<SegmentedControl
|
||||||
value={tab}
|
size="xs"
|
||||||
onChange={(v) => setTab(v as 'unclaimed' | 'mine')}
|
value={density}
|
||||||
|
onChange={(v) => dispatch(setDensity(v as 'comfortable' | 'compact'))}
|
||||||
data={[
|
data={[
|
||||||
{ label: `Unclaimed (${queue.data?.total ?? 0})`, value: 'unclaimed' },
|
{ label: t('queue.comfortable', 'Comfortable'), value: 'comfortable' },
|
||||||
{ label: `Mine (${mine.data?.total ?? 0})`, value: 'mine' },
|
{ 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>
|
||||||
</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}>
|
<Card withBorder padding={0}>
|
||||||
{active.isLoading ? (
|
{active.isLoading ? (
|
||||||
<Center h={200}>
|
// Skeleton rows match the real table, so the layout does not jump
|
||||||
<Loader />
|
// when data lands.
|
||||||
</Center>
|
<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 ? (
|
) : items.length === 0 ? (
|
||||||
<Center h={160}>
|
<EmptyState
|
||||||
<Text c="dimmed" size="sm">
|
title={
|
||||||
{tab === 'unclaimed'
|
hasFacets
|
||||||
? 'No applications waiting to be claimed.'
|
? t('queue.emptyFiltered', 'No applications match these filters')
|
||||||
: 'You have no applications in progress.'}
|
: t('queue.empty', 'Nothing waiting here')
|
||||||
</Text>
|
}
|
||||||
</Center>
|
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.ScrollContainer minWidth={1100}>
|
||||||
<Table.Tr>
|
<Table highlightOnHover verticalSpacing={density === "compact" ? 4 : "sm"}>
|
||||||
<Table.Th>Number</Table.Th>
|
<Table.Thead>
|
||||||
<Table.Th>Company</Table.Th>
|
<Table.Tr>
|
||||||
<Table.Th>TIN</Table.Th>
|
<Table.Th w={40}>
|
||||||
<Table.Th>Status</Table.Th>
|
<Checkbox
|
||||||
<Table.Th>Submitted</Table.Th>
|
aria-label={t('queue.selectAll', 'Select all')}
|
||||||
<Table.Th />
|
checked={allSelected}
|
||||||
</Table.Tr>
|
indeterminate={selected.length > 0 && !allSelected}
|
||||||
</Table.Thead>
|
onChange={() =>
|
||||||
<Table.Tbody>
|
setSelected(allSelected ? [] : items.map((a) => a.id))
|
||||||
{items.map((app) => (
|
}
|
||||||
<Table.Tr key={app.id}>
|
/>
|
||||||
<Table.Td>
|
</Table.Th>
|
||||||
<Text size="sm" fw={500}>
|
<SortableTh
|
||||||
{app.applicationNumber}
|
label={t('queue.number', 'App #')}
|
||||||
</Text>
|
field="applicationNumber"
|
||||||
</Table.Td>
|
current={urlFilter.sortBy}
|
||||||
<Table.Td>
|
icon={sortIcon}
|
||||||
<Text size="sm">{app.companyName ?? '—'}</Text>
|
onSort={toggleSort}
|
||||||
</Table.Td>
|
/>
|
||||||
<Table.Td>
|
<SortableTh
|
||||||
<Text size="sm" c="dimmed">
|
label={t('queue.company', 'Company')}
|
||||||
{app.tinNumber ?? '—'}
|
field="companyName"
|
||||||
</Text>
|
current={urlFilter.sortBy}
|
||||||
</Table.Td>
|
icon={sortIcon}
|
||||||
<Table.Td>
|
onSort={toggleSort}
|
||||||
<Badge color={STATUS_COLORS[app.status]} variant="light">
|
/>
|
||||||
{STATUS_LABELS[app.status]}
|
<Table.Th>{t('queue.tin', 'TIN')}</Table.Th>
|
||||||
</Badge>
|
<Table.Th>{t('queue.typeCol', 'Type')}</Table.Th>
|
||||||
</Table.Td>
|
<SortableTh
|
||||||
<Table.Td>
|
label={t('queue.statusCol', 'Status')}
|
||||||
<Text size="sm" c="dimmed">
|
field="status"
|
||||||
{app.submittedAt
|
current={urlFilter.sortBy}
|
||||||
? new Date(app.submittedAt).toLocaleDateString()
|
icon={sortIcon}
|
||||||
: '—'}
|
onSort={toggleSort}
|
||||||
</Text>
|
/>
|
||||||
</Table.Td>
|
<SortableTh
|
||||||
<Table.Td align="right">
|
label={t('queue.submitted', 'Submitted')}
|
||||||
{tab === 'unclaimed' ? (
|
field="submittedAt"
|
||||||
<Button
|
current={urlFilter.sortBy}
|
||||||
size="xs"
|
icon={sortIcon}
|
||||||
loading={claiming}
|
onSort={toggleSort}
|
||||||
onClick={() => handleClaim(app.id)}
|
/>
|
||||||
>
|
<Table.Th>{t('queue.sla', 'Age / SLA')}</Table.Th>
|
||||||
Claim
|
<Table.Th />
|
||||||
</Button>
|
</Table.Tr>
|
||||||
) : (
|
</Table.Thead>
|
||||||
<Button
|
<Table.Tbody>
|
||||||
size="xs"
|
{items.map((app, index) => (
|
||||||
variant="light"
|
<QueueRow
|
||||||
onClick={() => navigate(`/licence-review/${app.id}`)}
|
key={app.id}
|
||||||
>
|
app={app}
|
||||||
Review
|
focused={index === cursor}
|
||||||
</Button>
|
selected={selected.includes(app.id)}
|
||||||
)}
|
claiming={claiming}
|
||||||
</Table.Td>
|
locale={i18n.language}
|
||||||
</Table.Tr>
|
onSelect={(checked) =>
|
||||||
))}
|
setSelected((prev) =>
|
||||||
</Table.Tbody>
|
checked ? [...prev, app.id] : prev.filter((id) => id !== app.id),
|
||||||
</Table>
|
)
|
||||||
|
}
|
||||||
|
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>
|
</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>
|
</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;
|
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>
|
</Box>
|
||||||
)}
|
)}
|
||||||
{!hasChildren && <Box w={rem(18)} flexShrink={0} />}
|
{!hasChildren && <Box w={rem(18)} style={{ flexShrink: 0 }} />}
|
||||||
<IconMapPin
|
<IconMapPin
|
||||||
size={14}
|
size={14}
|
||||||
stroke={1.5}
|
stroke={1.5}
|
||||||
|
|||||||
@@ -16,6 +16,21 @@ export const am: Translations = {
|
|||||||
|
|
||||||
nav: {
|
nav: {
|
||||||
groupLicensing: 'ፈቃድ አሰጣጥ',
|
groupLicensing: 'ፈቃድ አሰጣጥ',
|
||||||
|
allApplications: 'ሁሉም ማመልከቻዎች',
|
||||||
|
certificateDesigner: 'የምስክር ወረቀት ንድፍ',
|
||||||
|
byType: 'በዓይነት',
|
||||||
|
typeFreightForwarder: 'የጭነት አስተላላፊ',
|
||||||
|
typeShippingAgent: 'የመርከብ ወኪል',
|
||||||
|
typeCombined: 'ጥምር የመርከብ ወኪል እና ጭነት አስተላላፊ',
|
||||||
|
typeJointInvestment: 'የጋራ ኢንቨስትመንት',
|
||||||
|
typeMto: 'የብዝሃ-ሁነታ ትራንስፖርት አንቀሳቃሽ',
|
||||||
|
primary: 'ዋና',
|
||||||
|
destinations: 'ወደ',
|
||||||
|
noResults: 'ምንም አልተገኘም',
|
||||||
|
commandPlaceholder: 'ማያ ገጾችን፣ ማመልከቻዎችን፣ ኩባንያዎችን፣ ቲን ይፈልጉ…',
|
||||||
|
pending: 'በመጠባበቅ ላይ ያሉ',
|
||||||
|
pendingCount_one: '{{count}} በመጠባበቅ ላይ',
|
||||||
|
pendingCount_other: '{{count}} በመጠባበቅ ላይ',
|
||||||
groupSeafarer: 'የመርከበኞች አገልግሎት',
|
groupSeafarer: 'የመርከበኞች አገልግሎት',
|
||||||
groupVessels: 'መርከቦች',
|
groupVessels: 'መርከቦች',
|
||||||
groupExaminations: 'ፈተናዎች',
|
groupExaminations: 'ፈተናዎች',
|
||||||
@@ -602,4 +617,293 @@ export const am: Translations = {
|
|||||||
departmentRequired: 'ክፍል ያስፈልጋል',
|
departmentRequired: 'ክፍል ያስፈልጋል',
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|
||||||
|
queue: {
|
||||||
|
title: 'የፈቃድ ማመልከቻዎች',
|
||||||
|
search: 'ፍለጋ',
|
||||||
|
searchPlaceholder: 'ኩባንያ፣ ቲን ወይም ቁጥር',
|
||||||
|
status: 'ሁኔታ',
|
||||||
|
anyStatus: 'ማንኛውም',
|
||||||
|
type: 'የፈቃድ ዓይነት',
|
||||||
|
anyType: 'ማንኛውም',
|
||||||
|
typeCol: 'ዓይነት',
|
||||||
|
statusCol: 'ሁኔታ',
|
||||||
|
submittedFrom: 'ከቀን ጀምሮ የቀረበ',
|
||||||
|
submittedTo: 'እስከ ቀን የቀረበ',
|
||||||
|
clearFilters: 'አጽዳ',
|
||||||
|
refresh: 'አድስ',
|
||||||
|
export: 'ወደ CSV ላክ',
|
||||||
|
exportTruncated: 'ወደ ውጭ መላክ ተቆርጧል',
|
||||||
|
exportTruncatedBody: 'ከ{{total}} ረድፎች ውስጥ የመጀመሪያዎቹ {{exported}} ተልከዋል። ለቀሪው ማጣሪያውን ያጥቡ።',
|
||||||
|
exportFailed: 'ወደ ውጭ መላክ አልተሳካም',
|
||||||
|
comfortable: 'ሰፊ',
|
||||||
|
compact: 'ጥብቅ',
|
||||||
|
number: 'ማመልከቻ ቁ.',
|
||||||
|
company: 'ኩባንያ',
|
||||||
|
tin: 'ቲን',
|
||||||
|
submitted: 'የቀረበበት',
|
||||||
|
sla: 'ዕድሜ / የጊዜ ገደብ',
|
||||||
|
claim: 'ውሰድ',
|
||||||
|
review: 'ገምግም',
|
||||||
|
claimed: 'ተወስዷል',
|
||||||
|
claimedBody: 'ማመልከቻው አሁን ለእርስዎ ተመድቧል።',
|
||||||
|
claimFailed: 'መውሰድ አልተቻለም',
|
||||||
|
claimRace: 'ሌላ ሹም አስቀድሞ ወስዶታል።',
|
||||||
|
bulkClaim_one: '{{count}} ውሰድ',
|
||||||
|
bulkClaim_other: '{{count}} ውሰድ',
|
||||||
|
bulkClaimed_one: '{{count}} ተወስዷል',
|
||||||
|
bulkClaimed_other: '{{count}} ተወስደዋል',
|
||||||
|
bulkClaimPartial_one: '{{count}} አስቀድሞ በሌላ ሹም ተወስዷል።',
|
||||||
|
bulkClaimPartial_other: '{{count}} አስቀድሞ በሌሎች ሹማምንት ተወስደዋል።',
|
||||||
|
selectAll: 'ሁሉንም ምረጥ',
|
||||||
|
selectRow: '{{number}} ምረጥ',
|
||||||
|
selectedCount_one: '{{count}} ተመርጧል',
|
||||||
|
selectedCount_other: '{{count}} ተመርጠዋል',
|
||||||
|
showing: 'ከ{{total}} ውስጥ {{from}}–{{to}} በማሳየት ላይ',
|
||||||
|
empty: 'እዚህ የሚጠብቅ ነገር የለም',
|
||||||
|
emptyBody: 'አዲስ ማመልከቻዎች ሲቀርቡ እዚህ ይታያሉ።',
|
||||||
|
emptyFiltered: 'በእነዚህ ማጣሪያዎች የሚዛመድ ማመልከቻ የለም',
|
||||||
|
emptyFilteredBody: 'ማጣሪያዎቹን ለማስፋት ወይም ለማጽዳት ይሞክሩ።',
|
||||||
|
errorTitle: 'ወረፋውን መጫን አልተቻለም',
|
||||||
|
views: {
|
||||||
|
unassigned: 'ያልተመደበ',
|
||||||
|
mine: 'የእኔ ወረፋ',
|
||||||
|
awaitingApplicant: 'አመልካችን በመጠባበቅ',
|
||||||
|
overdue: 'ጊዜው ያለፈበት',
|
||||||
|
readyToIssue: 'ለመስጠት ዝግጁ',
|
||||||
|
all: 'ሁሉም',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
|
||||||
|
review: {
|
||||||
|
summary: 'ማጠቃለያ',
|
||||||
|
officer: 'ሹም',
|
||||||
|
supervisor: 'የበላይ ኃላፊ',
|
||||||
|
officerPlaceholder: 'ማን እንደሚረከበው ይምረጡ',
|
||||||
|
noOfficers: 'ምንም ሹም አልተገኘም',
|
||||||
|
typeToConfirm: 'ለማረጋገጥ {{number}} ይተይቡ',
|
||||||
|
confirmMismatch: 'አይዛመድም',
|
||||||
|
type: 'ዓይነት',
|
||||||
|
tin: 'ቲን',
|
||||||
|
kind: 'ዓይነት',
|
||||||
|
submitted: 'የቀረበበት',
|
||||||
|
slaLabel: 'የጊዜ ገደብ',
|
||||||
|
eligibility: 'ብቁነት',
|
||||||
|
statusTimeline: 'ሂደት',
|
||||||
|
assigned: 'ተመድቧል',
|
||||||
|
decisionBar: 'የውሳኔ አሞሌ',
|
||||||
|
moreActions: 'ተጨማሪ ተግባራት',
|
||||||
|
irreversible: 'መመለስ አይቻልም',
|
||||||
|
irreversibleWarning: 'ይህ ውሳኔ የመጨረሻ ሲሆን ከጀርባ ቢሮ መመለስ አይቻልም።',
|
||||||
|
irreversibleAck: 'ይህ የመጨረሻ መሆኑን ተረድቻለሁ',
|
||||||
|
reasonCode: 'ምክንያት',
|
||||||
|
reasonCodePlaceholder: 'ምክንያት ይምረጡ',
|
||||||
|
reasonDetail: 'ለአመልካቹ ዝርዝር',
|
||||||
|
reasonDetailHint: 'ይህ ጽሑፍ ለአመልካቹ እንዳለ ይላካል።',
|
||||||
|
deficiencies: 'አመልካቹ ማስተካከል ያለበት ነገሮች',
|
||||||
|
deficienciesHint: 'የተመረጡት ብቻ ለአመልካቹ ሊስተካከሉ ይችላሉ።',
|
||||||
|
notificationPreview: 'ለአመልካቹ የሚላክ መልእክት',
|
||||||
|
notificationPreviewHint: 'በኤስኤምኤስ እና ኢሜይል ይላካል። ከማረጋገጥዎ በፊት ያስተካክሉ።',
|
||||||
|
needsCorrection: 'ማስተካከያ ያስፈልገዋል',
|
||||||
|
correctionPlaceholder: 'አመልካቹ ምን ማስተካከል አለበት?',
|
||||||
|
verifiedCapital: 'የተረጋገጠ ካፒታል (ብር)',
|
||||||
|
capitalHint: 'ዝቅተኛ {{min}} — ከባንክ ደብዳቤ ጋር ያረጋግጡ',
|
||||||
|
capitalHintNoMin: 'ከባንክ ደብዳቤ ጋር ተረጋግጧል',
|
||||||
|
capitalLocked: 'በዚህ ደረጃ ካፒታል ማስተካከል አይቻልም።',
|
||||||
|
belowMinimum: 'ከ{{min}} ዝቅተኛ በታች',
|
||||||
|
declared: 'አመልካቹ ያሳወቀው',
|
||||||
|
role: 'ሚና',
|
||||||
|
name: 'ስም',
|
||||||
|
evidence: 'ማስረጃ',
|
||||||
|
noInspections: 'እስካሁን ምርመራ አልተያዘም።',
|
||||||
|
unscheduled: 'አልተያዘም',
|
||||||
|
inspectionResult: 'የምርመራ ውጤት',
|
||||||
|
findings: 'ግኝቶች',
|
||||||
|
dateTime: 'ቀን እና ሰዓት',
|
||||||
|
schedule: 'ያዝ',
|
||||||
|
pickDate: 'መጀመሪያ ቀን እና ሰዓት ይምረጡ',
|
||||||
|
passed: 'አልፏል',
|
||||||
|
failed: 'ወድቋል',
|
||||||
|
round_one: 'ዙር {{count}}',
|
||||||
|
round_other: 'ዙር {{count}}',
|
||||||
|
theApplicant: 'አመልካቹ',
|
||||||
|
linkCopied: 'አገናኝ ተቀድቷል',
|
||||||
|
actionFailed: 'ተግባሩ አልተሳካም',
|
||||||
|
errorTitle: 'ይህን ማመልከቻ መጫን አልተቻለም',
|
||||||
|
hideActivity: 'እንቅስቃሴ ደብቅ',
|
||||||
|
showActivity: 'እንቅስቃሴ አሳይ',
|
||||||
|
awaitingPayment: 'አመልካቹ {{amount}} {{currency}} እንዲከፍል በመጠባበቅ ላይ።',
|
||||||
|
tabs: {
|
||||||
|
overview: 'አጠቃላይ እይታ',
|
||||||
|
financials: 'የገንዘብ መረጃ',
|
||||||
|
documents: 'ሰነዶች',
|
||||||
|
staff: 'ሠራተኞች',
|
||||||
|
inspection: 'ምርመራ',
|
||||||
|
},
|
||||||
|
actions: {
|
||||||
|
claim: 'ውሰድ',
|
||||||
|
assign: 'መድብ',
|
||||||
|
escalate: 'ወደ ላይ አሳድግ',
|
||||||
|
hold: 'አግድ',
|
||||||
|
resume: 'ቀጥል',
|
||||||
|
completeReview: 'ግምገማ አጠናቅቅ',
|
||||||
|
approveDocuments: 'ሰነዶችን አጽድቅ',
|
||||||
|
scheduleInspection: 'ምርመራ ያዝ',
|
||||||
|
recordInspection: 'የምርመራ ውጤት መዝግብ',
|
||||||
|
finalApprove: 'አጽድቅ እና ስጥ',
|
||||||
|
requestAdjustment: 'ማስተካከያ ጠይቅ',
|
||||||
|
reject: 'አትቀበል',
|
||||||
|
confirmPayment: 'ክፍያ አረጋግጥ',
|
||||||
|
print: 'ሰነድ አትም',
|
||||||
|
copyLink: 'አገናኝ ቅዳ',
|
||||||
|
downloadDocuments: 'ሁሉንም ሰነዶች አውርድ',
|
||||||
|
generateCertificate: 'ሰርተፍኬት አዘጋጅ',
|
||||||
|
auditTrail: 'የኦዲት መዝገብ አሳይ',
|
||||||
|
},
|
||||||
|
disabled: {
|
||||||
|
wrongStatus: 'በዚህ ደረጃ አይገኝም',
|
||||||
|
notAssigned: 'ለሌላ ሹም ተመድቧል',
|
||||||
|
noPermission: 'ፈቃድ የለዎትም',
|
||||||
|
needsFlags: 'ማስተካከያ ለመጠየቅ ቢያንስ አንድ ነገር ምልክት ያድርጉ',
|
||||||
|
needsCapital: 'መጀመሪያ የተረጋገጠውን ካፒታል ይመዝግቡ',
|
||||||
|
needsInspection: 'የምርመራ ውጤት ያስፈልጋል',
|
||||||
|
},
|
||||||
|
reasons: {
|
||||||
|
incompleteDocuments: 'ያልተሟሉ ሰነዶች',
|
||||||
|
belowCapital: 'ካፒታል ከሚያስፈልገው በታች',
|
||||||
|
failedInspection: 'ምርመራ ወድቋል',
|
||||||
|
ineligibleApplicant: 'አመልካቹ ብቁ አይደለም',
|
||||||
|
duplicateApplication: 'ተደጋጋሚ ማመልከቻ',
|
||||||
|
illegibleDocument: 'ሰነዱ አይነበብም',
|
||||||
|
expiredDocument: 'ሰነዱ ጊዜው አልፎበታል',
|
||||||
|
missingDocument: 'ሰነዱ ጠፍቷል',
|
||||||
|
inconsistentDetails: 'ዝርዝሮቹ ከሰነዶቹ ጋር አይዛመዱም',
|
||||||
|
awaitingThirdParty: 'የሶስተኛ ወገን ማረጋገጫ በመጠባበቅ',
|
||||||
|
legalProceedings: 'በሕግ ሂደት ላይ',
|
||||||
|
applicantRequest: 'በአመልካቹ ጥያቄ',
|
||||||
|
aboveAuthority: 'ከእኔ የማጽደቅ ሥልጣን በላይ',
|
||||||
|
policyUnclear: 'የፖሊሲ መመሪያ ያስፈልጋል',
|
||||||
|
conflictOfInterest: 'የጥቅም ግጭት',
|
||||||
|
},
|
||||||
|
consequences: {
|
||||||
|
fallback: 'ይህ ለ{{applicant}} ማመልከቻ {{number}} ያዘምናል።',
|
||||||
|
'final-approve': 'ለ{{applicant}} ማመልከቻ {{number}} ያጸድቃል እና የሰርተፍኬት አሰጣጥ ይጀምራል።',
|
||||||
|
reject: 'ለ{{applicant}} ማመልከቻ {{number}} አይቀበልም። ይህ ማመልከቻውን ያጠናቅቃል።',
|
||||||
|
'request-adjustment': 'ማመልከቻ {{number}} ለማስተካከያ ወደ {{applicant}} ይመልሳል።',
|
||||||
|
hold: 'ለ{{applicant}} ማመልከቻ {{number}} ያግዳል። ለእርስዎ ተመድቦ ይቆያል።',
|
||||||
|
resume: 'ማመልከቻ {{number}} ወደ ታገደበት ደረጃ ይመልሳል።',
|
||||||
|
escalate: 'ማመልከቻ {{number}} ለውሳኔ ወደ የበላይ ኃላፊ ያሳድጋል።',
|
||||||
|
'confirm-payment': 'ለማመልከቻ {{number}} ክፍያ ያረጋግጣል።',
|
||||||
|
},
|
||||||
|
notifications: {
|
||||||
|
fallback: 'ውድ {{applicant}}፣ በማመልከቻ {{number}} ላይ ዝማኔ አለ፦ {{action}}።',
|
||||||
|
'final-approve': 'ውድ {{applicant}}፣ ማመልከቻ {{number}} ጸድቋል። ሰርተፍኬትዎ በዝግጅት ላይ ነው።',
|
||||||
|
reject: 'ውድ {{applicant}}፣ ማመልከቻ {{number}} አልጸደቀም። እባክዎ ከታች ያለውን ምክንያት ይመልከቱ።',
|
||||||
|
'request-adjustment': 'ውድ {{applicant}}፣ ማመልከቻ {{number}} ከመቀጠሉ በፊት ማስተካከያ ያስፈልገዋል።',
|
||||||
|
},
|
||||||
|
activity: {
|
||||||
|
title: 'እንቅስቃሴ እና የኦዲት መዝገብ',
|
||||||
|
empty: 'እስካሁን የተመዘገበ እንቅስቃሴ የለም።',
|
||||||
|
system: 'ሲስተም',
|
||||||
|
officer: 'ሹም',
|
||||||
|
applicant: 'አመልካች',
|
||||||
|
remarkOn: 'በ{{target}} ላይ ማስተካከያ ተጠይቋል',
|
||||||
|
uploaded: '{{document}} ተጭኗል',
|
||||||
|
},
|
||||||
|
documents: {
|
||||||
|
completeness: 'የሚያስፈልጉ ሰነዶች',
|
||||||
|
accepted: 'ተቀባይነት አግኝቷል',
|
||||||
|
rejected: 'ተቀባይነት አላገኘም',
|
||||||
|
accept: 'ተቀበል',
|
||||||
|
clear: 'ውሳኔ አጽዳ',
|
||||||
|
confirmReject: 'አትቀበል',
|
||||||
|
includeInAdjustment: 'መልስ',
|
||||||
|
adjustmentNote: 'አመልካቹ ምን ማስተካከል አለበት?',
|
||||||
|
nothingToJudge: 'የሚገመገም ምንም አልተጫነም',
|
||||||
|
reviewedBy: 'በ{{name}} ተገምግሟል',
|
||||||
|
saveFailed: 'ውሳኔውን ማስቀመጥ አልተቻለም',
|
||||||
|
completenessLabel: '{{value}}% የሚያስፈልጉ ሰነዶች ተጭነዋል',
|
||||||
|
missing: 'እስካሁን አልተጫነም',
|
||||||
|
flagged: 'ማስተካከያ ተጠይቋል',
|
||||||
|
view: 'እይ',
|
||||||
|
preview: 'ቅድመ እይታ',
|
||||||
|
download: 'አውርድ',
|
||||||
|
downloadShort: 'አውርድ',
|
||||||
|
reject: 'አትቀበል',
|
||||||
|
rejectReason: 'ይህ ሰነድ ለምን መስተካከል አለበት?',
|
||||||
|
reasonRequired: 'ምክንያት ያስፈልጋል',
|
||||||
|
noFile: 'ፋይል የለም',
|
||||||
|
noFileUploaded: 'እስካሁን ምንም አልተጫነም',
|
||||||
|
noInlinePreview: 'ይህ የፋይል ዓይነት በአሳሹ ውስጥ ቅድመ እይታ አይደረግም።',
|
||||||
|
},
|
||||||
|
done: {
|
||||||
|
completeReview: 'ግምገማ ተጠናቋል',
|
||||||
|
approveDocuments: 'ሰነዶች ጸድቀዋል',
|
||||||
|
finalApprove: 'ጸድቋል',
|
||||||
|
requestAdjustment: 'ማስተካከያ ተጠይቋል',
|
||||||
|
reject: 'ማመልከቻው አልተቀበለም',
|
||||||
|
confirmPayment: 'ክፍያ ተረጋግጧል',
|
||||||
|
hold: 'ማመልከቻው ታግዷል',
|
||||||
|
resume: 'ማመልከቻው ቀጥሏል',
|
||||||
|
escalate: 'ወደ ላይ አድጓል',
|
||||||
|
assign: 'እንደገና ተመድቧል',
|
||||||
|
scheduled: 'ምርመራ ተይዟል',
|
||||||
|
inspectionPassed: 'ምርመራ አልፏል',
|
||||||
|
inspectionFailed: 'ምርመራ ወድቋል',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
|
||||||
|
error: {
|
||||||
|
reference: 'ማጣቀሻ',
|
||||||
|
retry: 'እንደገና ሞክር',
|
||||||
|
},
|
||||||
|
|
||||||
|
shortcuts: {
|
||||||
|
title: 'የቁልፍ ሰሌዳ አቋራጮች',
|
||||||
|
commandPalette: 'ሁሉንም ፈልግ',
|
||||||
|
moveRow: 'በረድፎች መካከል ተንቀሳቀስ',
|
||||||
|
openRow: 'የተመረጠውን ረድፍ ክፈት',
|
||||||
|
claimRow: 'የተመረጠውን ረድፍ ውሰድ',
|
||||||
|
dismiss: 'ምርጫ አጽዳ / ዝጋ',
|
||||||
|
help: 'ይህን ዝርዝር አሳይ',
|
||||||
|
},
|
||||||
|
|
||||||
|
designer: {
|
||||||
|
title: 'የምስክር ወረቀት ንድፍ',
|
||||||
|
subtitle: 'ለፈቃድ ባለቤቶች የሚሰጠውን የምስክር ወረቀት ይንደፉ፣ የሚቆይበትንም ጊዜ ያዘጋጁ።',
|
||||||
|
licenceType: 'የፈቃድ ዓይነት',
|
||||||
|
validityYears: 'የሚቆይበት (ዓመታት)',
|
||||||
|
validityHint: 'ፈቃድ ሲሰጥ ተግባራዊ ይሆናል',
|
||||||
|
saveValidity: 'የሚቆይበትን ጊዜ አስቀምጥ',
|
||||||
|
validitySaved: 'የሚቆይበት ጊዜ ተዘምኗል',
|
||||||
|
newVersion: 'አዲስ ስሪት',
|
||||||
|
versions: 'ስሪቶች',
|
||||||
|
name: 'የስሪት ስም',
|
||||||
|
landscape: 'አግድም',
|
||||||
|
source: 'ቅንብር (Handlebars + HTML)',
|
||||||
|
variables: 'ቦታ ያዢዎች',
|
||||||
|
variablesHint: 'በጠቋሚው ቦታ ለማስገባት ይጫኑ።',
|
||||||
|
preview: 'PDF ቅድመ እይታ',
|
||||||
|
previewFailed: 'ቅድመ እይታውን ማዘጋጀት አልተቻለም',
|
||||||
|
save: 'ረቂቅ አስቀምጥ',
|
||||||
|
saved: 'ረቂቅ ተቀምጧል',
|
||||||
|
saveFirst: 'መጀመሪያ ለውጦችዎን ያስቀምጡ',
|
||||||
|
publish: 'አትም',
|
||||||
|
published: 'ንድፉ ታትሟል',
|
||||||
|
publishHint: 'ይህንን ቀጥታ የምስክር ወረቀት ንድፍ ያደርገዋል',
|
||||||
|
publishedLocked: 'ይህ ስሪት ቀጥታ ላይ ስለሆነ ማስተካከል አይቻልም — ከእሱ የምስክር ወረቀቶች ተሰጥተዋል። ለውጥ ለማድረግ አዲስ ስሪት ይፍጠሩ።',
|
||||||
|
archive: 'አንሳ',
|
||||||
|
archived: 'ንድፉ ተነስቷል',
|
||||||
|
delete: 'ረቂቅ ሰርዝ',
|
||||||
|
deleted: 'ረቂቅ ተሰርዟል',
|
||||||
|
create: 'ፍጠር',
|
||||||
|
created: 'ረቂቅ ተፈጥሯል',
|
||||||
|
newHint: 'ከቀጥታ ንድፉ ወይም ይህ ዓይነት ከሌለው ከውስጠ-ግንብ ቅንብር ይጀምራል።',
|
||||||
|
empty: 'ለዚህ የፈቃድ ዓይነት እስካሁን ንድፍ የለም',
|
||||||
|
emptyBody: 'የምስክር ወረቀቶች አሁን ውስጠ-ግንብ ቅንብር ይጠቀማሉ። ለመቆጣጠር ስሪት ይፍጠሩ።',
|
||||||
|
loadFailed: 'ንድፎቹን መጫን አልተቻለም',
|
||||||
|
actionFailed: 'ተግባሩ አልተሳካም',
|
||||||
|
noPermission: 'ፈቃድ የለዎትም',
|
||||||
|
noPublishPermission: 'ንድፎችን ማተም አይችሉም',
|
||||||
|
},
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -14,6 +14,21 @@ export const en = {
|
|||||||
|
|
||||||
nav: {
|
nav: {
|
||||||
groupLicensing: 'Licensing',
|
groupLicensing: 'Licensing',
|
||||||
|
allApplications: 'All Applications',
|
||||||
|
certificateDesigner: 'Certificate Designer',
|
||||||
|
byType: 'By Type',
|
||||||
|
typeFreightForwarder: 'Freight Forwarder',
|
||||||
|
typeShippingAgent: 'Shipping Agent',
|
||||||
|
typeCombined: 'Combined SA + FF',
|
||||||
|
typeJointInvestment: 'Joint Investment',
|
||||||
|
typeMto: 'Multimodal Transport Operator',
|
||||||
|
primary: 'Primary',
|
||||||
|
destinations: 'Go to',
|
||||||
|
noResults: 'Nothing found',
|
||||||
|
commandPlaceholder: 'Search screens, applications, companies, TIN…',
|
||||||
|
pending: 'Items pending',
|
||||||
|
pendingCount_one: '{{count}} pending',
|
||||||
|
pendingCount_other: '{{count}} pending',
|
||||||
groupSeafarer: 'Seafarer Services',
|
groupSeafarer: 'Seafarer Services',
|
||||||
groupVessels: 'Vessels',
|
groupVessels: 'Vessels',
|
||||||
groupExaminations: 'Examinations',
|
groupExaminations: 'Examinations',
|
||||||
@@ -601,6 +616,295 @@ export const en = {
|
|||||||
departmentRequired: 'Department is required',
|
departmentRequired: 'Department is required',
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|
||||||
|
queue: {
|
||||||
|
title: 'Licence applications',
|
||||||
|
search: 'Search',
|
||||||
|
searchPlaceholder: 'Company, TIN or number',
|
||||||
|
status: 'Status',
|
||||||
|
anyStatus: 'Any',
|
||||||
|
type: 'Licence type',
|
||||||
|
anyType: 'Any',
|
||||||
|
typeCol: 'Type',
|
||||||
|
statusCol: 'Status',
|
||||||
|
submittedFrom: 'Submitted from',
|
||||||
|
submittedTo: 'Submitted to',
|
||||||
|
clearFilters: 'Clear',
|
||||||
|
refresh: 'Refresh',
|
||||||
|
export: 'Export CSV',
|
||||||
|
exportTruncated: 'Export truncated',
|
||||||
|
exportTruncatedBody: 'Exported the first {{exported}} of {{total}} rows. Narrow the filter for the rest.',
|
||||||
|
exportFailed: 'Export failed',
|
||||||
|
comfortable: 'Comfortable',
|
||||||
|
compact: 'Compact',
|
||||||
|
number: 'App #',
|
||||||
|
company: 'Company',
|
||||||
|
tin: 'TIN',
|
||||||
|
submitted: 'Submitted',
|
||||||
|
sla: 'Age / SLA',
|
||||||
|
claim: 'Claim',
|
||||||
|
review: 'Review',
|
||||||
|
claimed: 'Claimed',
|
||||||
|
claimedBody: 'The application is now assigned to you.',
|
||||||
|
claimFailed: 'Could not claim',
|
||||||
|
claimRace: 'Another officer already claimed it.',
|
||||||
|
bulkClaim_one: 'Claim {{count}}',
|
||||||
|
bulkClaim_other: 'Claim {{count}}',
|
||||||
|
bulkClaimed_one: '{{count}} claimed',
|
||||||
|
bulkClaimed_other: '{{count}} claimed',
|
||||||
|
bulkClaimPartial_one: '{{count}} was already taken by another officer.',
|
||||||
|
bulkClaimPartial_other: '{{count}} were already taken by another officer.',
|
||||||
|
selectAll: 'Select all',
|
||||||
|
selectRow: 'Select {{number}}',
|
||||||
|
selectedCount_one: '{{count}} selected',
|
||||||
|
selectedCount_other: '{{count}} selected',
|
||||||
|
showing: 'Showing {{from}}–{{to}} of {{total}}',
|
||||||
|
empty: 'Nothing waiting here',
|
||||||
|
emptyBody: 'New applications will appear here as they are submitted.',
|
||||||
|
emptyFiltered: 'No applications match these filters',
|
||||||
|
emptyFilteredBody: 'Try widening or clearing the filters.',
|
||||||
|
errorTitle: 'Could not load the queue',
|
||||||
|
views: {
|
||||||
|
unassigned: 'Unassigned',
|
||||||
|
mine: 'My Queue',
|
||||||
|
awaitingApplicant: 'Awaiting Applicant',
|
||||||
|
overdue: 'Overdue',
|
||||||
|
readyToIssue: 'Ready to Issue',
|
||||||
|
all: 'All',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
|
||||||
|
review: {
|
||||||
|
summary: 'Summary',
|
||||||
|
officer: 'Officer',
|
||||||
|
supervisor: 'Supervisor',
|
||||||
|
officerPlaceholder: 'Select who takes this on',
|
||||||
|
noOfficers: 'No officers found',
|
||||||
|
typeToConfirm: 'Type {{number}} to confirm',
|
||||||
|
confirmMismatch: 'Does not match',
|
||||||
|
type: 'Type',
|
||||||
|
tin: 'TIN',
|
||||||
|
kind: 'Kind',
|
||||||
|
submitted: 'Submitted',
|
||||||
|
slaLabel: 'SLA',
|
||||||
|
eligibility: 'Eligibility',
|
||||||
|
statusTimeline: 'Progress',
|
||||||
|
assigned: 'Assigned',
|
||||||
|
decisionBar: 'Decision bar',
|
||||||
|
moreActions: 'More actions',
|
||||||
|
irreversible: 'Cannot be undone',
|
||||||
|
irreversibleWarning: 'This decision is final and cannot be undone from the backoffice.',
|
||||||
|
irreversibleAck: 'I understand this is final',
|
||||||
|
reasonCode: 'Reason',
|
||||||
|
reasonCodePlaceholder: 'Select a reason',
|
||||||
|
reasonDetail: 'Details for the applicant',
|
||||||
|
reasonDetailHint: 'This text is sent to the applicant verbatim.',
|
||||||
|
deficiencies: 'Items the applicant must correct',
|
||||||
|
deficienciesHint: 'Only the ticked items become editable for the applicant.',
|
||||||
|
notificationPreview: 'Message to the applicant',
|
||||||
|
notificationPreviewHint: 'Sent by SMS and email. Edit before confirming if needed.',
|
||||||
|
needsCorrection: 'Needs correction',
|
||||||
|
correctionPlaceholder: 'What must the applicant correct?',
|
||||||
|
verifiedCapital: 'Verified capital (ETB)',
|
||||||
|
capitalHint: 'Minimum {{min}} — check against the bank letter',
|
||||||
|
capitalHintNoMin: 'Checked against the bank letter',
|
||||||
|
capitalLocked: 'Capital can no longer be edited at this stage.',
|
||||||
|
belowMinimum: 'Below the {{min}} minimum',
|
||||||
|
declared: 'Applicant declared',
|
||||||
|
role: 'Role',
|
||||||
|
name: 'Name',
|
||||||
|
evidence: 'Evidence',
|
||||||
|
noInspections: 'No inspection has been scheduled yet.',
|
||||||
|
unscheduled: 'Not scheduled',
|
||||||
|
inspectionResult: 'Inspection result',
|
||||||
|
findings: 'Findings',
|
||||||
|
dateTime: 'Date and time',
|
||||||
|
schedule: 'Schedule',
|
||||||
|
pickDate: 'Pick a date and time first',
|
||||||
|
passed: 'Passed',
|
||||||
|
failed: 'Failed',
|
||||||
|
round_one: 'round {{count}}',
|
||||||
|
round_other: 'round {{count}}',
|
||||||
|
theApplicant: 'the applicant',
|
||||||
|
linkCopied: 'Link copied',
|
||||||
|
actionFailed: 'Action failed',
|
||||||
|
errorTitle: 'Could not load this application',
|
||||||
|
hideActivity: 'Hide activity',
|
||||||
|
showActivity: 'Show activity',
|
||||||
|
awaitingPayment: 'Waiting for the applicant to pay {{amount}} {{currency}}.',
|
||||||
|
tabs: {
|
||||||
|
overview: 'Overview',
|
||||||
|
financials: 'Financials',
|
||||||
|
documents: 'Documents',
|
||||||
|
staff: 'Staff',
|
||||||
|
inspection: 'Inspection',
|
||||||
|
},
|
||||||
|
actions: {
|
||||||
|
claim: 'Claim',
|
||||||
|
assign: 'Assign',
|
||||||
|
escalate: 'Escalate',
|
||||||
|
hold: 'Put on hold',
|
||||||
|
resume: 'Resume',
|
||||||
|
completeReview: 'Complete review',
|
||||||
|
approveDocuments: 'Approve documents',
|
||||||
|
scheduleInspection: 'Schedule inspection',
|
||||||
|
recordInspection: 'Record inspection result',
|
||||||
|
finalApprove: 'Approve & issue',
|
||||||
|
requestAdjustment: 'Request adjustment',
|
||||||
|
reject: 'Reject',
|
||||||
|
confirmPayment: 'Confirm payment',
|
||||||
|
print: 'Print dossier',
|
||||||
|
copyLink: 'Copy link',
|
||||||
|
downloadDocuments: 'Download all documents',
|
||||||
|
generateCertificate: 'Generate certificate',
|
||||||
|
auditTrail: 'Show audit trail',
|
||||||
|
},
|
||||||
|
disabled: {
|
||||||
|
wrongStatus: 'Not available at this stage',
|
||||||
|
notAssigned: 'Assigned to another officer',
|
||||||
|
noPermission: 'You do not have permission',
|
||||||
|
needsFlags: 'Flag at least one item to request a correction',
|
||||||
|
needsCapital: 'Record the verified capital first',
|
||||||
|
needsInspection: 'Requires an inspection result',
|
||||||
|
},
|
||||||
|
reasons: {
|
||||||
|
incompleteDocuments: 'Incomplete documents',
|
||||||
|
belowCapital: 'Capital below the required minimum',
|
||||||
|
failedInspection: 'Failed inspection',
|
||||||
|
ineligibleApplicant: 'Applicant not eligible',
|
||||||
|
duplicateApplication: 'Duplicate application',
|
||||||
|
illegibleDocument: 'Document is illegible',
|
||||||
|
expiredDocument: 'Document has expired',
|
||||||
|
missingDocument: 'Document is missing',
|
||||||
|
inconsistentDetails: 'Details do not match the documents',
|
||||||
|
awaitingThirdParty: 'Awaiting third-party confirmation',
|
||||||
|
legalProceedings: 'Subject to legal proceedings',
|
||||||
|
applicantRequest: 'Requested by the applicant',
|
||||||
|
aboveAuthority: 'Above my approval authority',
|
||||||
|
policyUnclear: 'Policy guidance needed',
|
||||||
|
conflictOfInterest: 'Conflict of interest',
|
||||||
|
},
|
||||||
|
consequences: {
|
||||||
|
fallback: 'This updates application {{number}} for {{applicant}}.',
|
||||||
|
'final-approve': 'Approves application {{number}} for {{applicant}} and starts certificate issuance.',
|
||||||
|
reject: 'Rejects application {{number}} for {{applicant}}. This ends the application.',
|
||||||
|
'request-adjustment': 'Returns application {{number}} to {{applicant}} for correction.',
|
||||||
|
hold: 'Parks application {{number}} for {{applicant}}. It stays assigned to you.',
|
||||||
|
resume: 'Returns application {{number}} to the stage it was held from.',
|
||||||
|
escalate: 'Raises application {{number}} to a supervisor for a decision.',
|
||||||
|
'confirm-payment': 'Confirms settlement for application {{number}}.',
|
||||||
|
},
|
||||||
|
notifications: {
|
||||||
|
fallback: 'Dear {{applicant}}, there is an update on application {{number}}: {{action}}.',
|
||||||
|
'final-approve': 'Dear {{applicant}}, application {{number}} has been approved. Your certificate is being prepared.',
|
||||||
|
reject: 'Dear {{applicant}}, application {{number}} has not been approved. Please see the reason below.',
|
||||||
|
'request-adjustment': 'Dear {{applicant}}, application {{number}} needs corrections before it can proceed.',
|
||||||
|
},
|
||||||
|
activity: {
|
||||||
|
title: 'Activity & audit trail',
|
||||||
|
empty: 'No activity recorded yet.',
|
||||||
|
system: 'System',
|
||||||
|
officer: 'Officer',
|
||||||
|
applicant: 'Applicant',
|
||||||
|
remarkOn: 'Correction requested on {{target}}',
|
||||||
|
uploaded: 'Uploaded {{document}}',
|
||||||
|
},
|
||||||
|
documents: {
|
||||||
|
completeness: 'Required documents',
|
||||||
|
accepted: 'Accepted',
|
||||||
|
rejected: 'Rejected',
|
||||||
|
accept: 'Accept',
|
||||||
|
clear: 'Clear verdict',
|
||||||
|
confirmReject: 'Reject',
|
||||||
|
includeInAdjustment: 'Send back',
|
||||||
|
adjustmentNote: 'What must the applicant correct?',
|
||||||
|
nothingToJudge: 'Nothing uploaded to judge',
|
||||||
|
reviewedBy: 'Reviewed by {{name}}',
|
||||||
|
saveFailed: 'Could not save the verdict',
|
||||||
|
completenessLabel: '{{value}}% of required documents uploaded',
|
||||||
|
missing: 'Not yet uploaded',
|
||||||
|
flagged: 'Correction requested',
|
||||||
|
view: 'View',
|
||||||
|
preview: 'Preview',
|
||||||
|
download: 'Download',
|
||||||
|
downloadShort: 'Download',
|
||||||
|
reject: 'Reject',
|
||||||
|
rejectReason: 'Why must this document be corrected?',
|
||||||
|
reasonRequired: 'A reason is required',
|
||||||
|
noFile: 'No file',
|
||||||
|
noFileUploaded: 'Nothing uploaded yet',
|
||||||
|
noInlinePreview: 'This file type cannot be previewed in the browser.',
|
||||||
|
},
|
||||||
|
done: {
|
||||||
|
completeReview: 'Review completed',
|
||||||
|
approveDocuments: 'Documents approved',
|
||||||
|
finalApprove: 'Approved',
|
||||||
|
requestAdjustment: 'Adjustment requested',
|
||||||
|
reject: 'Application rejected',
|
||||||
|
confirmPayment: 'Payment confirmed',
|
||||||
|
hold: 'Application placed on hold',
|
||||||
|
resume: 'Application resumed',
|
||||||
|
escalate: 'Escalated',
|
||||||
|
assign: 'Reassigned',
|
||||||
|
scheduled: 'Inspection scheduled',
|
||||||
|
inspectionPassed: 'Inspection passed',
|
||||||
|
inspectionFailed: 'Inspection failed',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
|
||||||
|
error: {
|
||||||
|
reference: 'Reference',
|
||||||
|
retry: 'Try again',
|
||||||
|
},
|
||||||
|
|
||||||
|
shortcuts: {
|
||||||
|
title: 'Keyboard shortcuts',
|
||||||
|
commandPalette: 'Search everything',
|
||||||
|
moveRow: 'Move between rows',
|
||||||
|
openRow: 'Open the selected row',
|
||||||
|
claimRow: 'Claim the selected row',
|
||||||
|
dismiss: 'Clear selection / close',
|
||||||
|
help: 'Show this list',
|
||||||
|
},
|
||||||
|
|
||||||
|
designer: {
|
||||||
|
title: 'Certificate designer',
|
||||||
|
subtitle: 'Design the certificate issued to licence holders, and set how long it stays valid.',
|
||||||
|
licenceType: 'Licence type',
|
||||||
|
validityYears: 'Valid for (years)',
|
||||||
|
validityHint: 'Applied when a licence is issued',
|
||||||
|
saveValidity: 'Save validity',
|
||||||
|
validitySaved: 'Validity updated',
|
||||||
|
newVersion: 'New version',
|
||||||
|
versions: 'Versions',
|
||||||
|
name: 'Version name',
|
||||||
|
landscape: 'Landscape',
|
||||||
|
source: 'Template (Handlebars + HTML)',
|
||||||
|
variables: 'Placeholders',
|
||||||
|
variablesHint: 'Click to insert at the cursor.',
|
||||||
|
preview: 'Preview PDF',
|
||||||
|
previewFailed: 'Could not render the preview',
|
||||||
|
save: 'Save draft',
|
||||||
|
saved: 'Draft saved',
|
||||||
|
saveFirst: 'Save your changes first',
|
||||||
|
publish: 'Publish',
|
||||||
|
published: 'Design published',
|
||||||
|
publishHint: 'Makes this the live certificate design',
|
||||||
|
publishedLocked: 'This version is live and cannot be edited — certificates have been issued from it. Create a new version to make changes.',
|
||||||
|
archive: 'Withdraw',
|
||||||
|
archived: 'Design withdrawn',
|
||||||
|
delete: 'Delete draft',
|
||||||
|
deleted: 'Draft deleted',
|
||||||
|
create: 'Create',
|
||||||
|
created: 'Draft created',
|
||||||
|
newHint: 'Starts from the live design, or the built-in layout if this type has none.',
|
||||||
|
empty: 'No design yet for this licence type',
|
||||||
|
emptyBody: 'Certificates currently use the built-in layout. Create a version to take control of it.',
|
||||||
|
loadFailed: 'Could not load the designs',
|
||||||
|
actionFailed: 'Action failed',
|
||||||
|
noPermission: 'You do not have permission',
|
||||||
|
noPublishPermission: 'You cannot publish designs',
|
||||||
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
export type Translations = typeof en;
|
export type Translations = typeof en;
|
||||||
|
|||||||
@@ -1,99 +1,28 @@
|
|||||||
import { useCallback, useState } from 'react';
|
import { useCallback, useMemo, useState } from 'react';
|
||||||
import { AppShell, rem } from '@mantine/core';
|
import { AppShell } from '@mantine/core';
|
||||||
import { useDisclosure } from '@mantine/hooks';
|
import { useDisclosure } from '@mantine/hooks';
|
||||||
import { Outlet, useLocation, useNavigate } from 'react-router-dom';
|
import { Outlet, useLocation, useNavigate } from 'react-router-dom';
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
import { logout } from '@ema-platform/auth';
|
import { BrandMark, logout } from '@ema-platform/auth';
|
||||||
import { AppHeader, AppSidebar } from '@ema-platform/ui';
|
import { AppHeader, AppSidebar } from '@ema-platform/ui';
|
||||||
import type { NavItem, NavSection } from '@ema-platform/ui';
|
import type { NavItem, NavSection } from '@ema-platform/ui';
|
||||||
import {
|
|
||||||
IconBook2,
|
|
||||||
IconChartBar,
|
|
||||||
IconCreditCard,
|
|
||||||
IconFileDescription,
|
|
||||||
IconHeart,
|
|
||||||
IconLayoutDashboard,
|
|
||||||
IconShieldCheck,
|
|
||||||
IconRubberStamp,
|
|
||||||
IconSettings,
|
|
||||||
IconUser,
|
|
||||||
IconUsers,
|
|
||||||
IconUserShield,
|
|
||||||
IconQuestionMark,
|
|
||||||
IconClipboardList,
|
|
||||||
IconReport,
|
|
||||||
IconAnchor,
|
|
||||||
IconFilePlus,
|
|
||||||
IconGauge,
|
|
||||||
IconShieldOff,
|
|
||||||
IconShip,
|
|
||||||
IconMapPin,
|
|
||||||
IconStack2,
|
|
||||||
IconTruck,
|
|
||||||
} from '@tabler/icons-react';
|
|
||||||
import { notify } from '@ema-platform/ui';
|
import { notify } from '@ema-platform/ui';
|
||||||
|
import { AppTopNav, filterByPermissions } from '@ema-platform/ui';
|
||||||
|
import { useGetQueueCountsQuery } from '@ema-platform/api';
|
||||||
|
import { usePermissions } from '@ema-platform/auth';
|
||||||
import { SUPPORTED_LANGUAGES } from '../i18n/config';
|
import { SUPPORTED_LANGUAGES } from '../i18n/config';
|
||||||
import { useAppDispatch, useAppSelector } from '../store/hooks';
|
import { useAppDispatch, useAppSelector } from '../store/hooks';
|
||||||
|
import { NAV_SECTIONS } from './nav-config';
|
||||||
|
import { CommandPalette } from './CommandPalette';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Grouped so a reviewer can find things, and flagged so they can tell what
|
* How often the pending-work badges refresh.
|
||||||
* actually works: `soon` marks screens with no backend behind them yet.
|
*
|
||||||
|
* Polled on a timer rather than refetched per navigation: the counts sit in
|
||||||
|
* the chrome and are visible on every screen, so tying them to route changes
|
||||||
|
* would fire a request each time an officer clicked anything.
|
||||||
*/
|
*/
|
||||||
const NAV_SECTIONS: NavSection[] = [
|
const BADGE_POLL_MS = 60_000;
|
||||||
{
|
|
||||||
items: [{ to: '/dashboard', label: 'nav.dashboard', icon: IconLayoutDashboard }],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
label: 'nav.groupLicensing',
|
|
||||||
items: [
|
|
||||||
{ to: '/licence-review', label: 'nav.licenceReview', icon: IconTruck },
|
|
||||||
{ to: '/logistics-head-dashboard', label: 'nav.logisticsHeadDashboard', icon: IconGauge },
|
|
||||||
{ to: '/payment-config', label: 'nav.paymentConfig', icon: IconCreditCard },
|
|
||||||
{ to: '/waiver', label: 'nav.waiver', icon: IconShieldOff, soon: true },
|
|
||||||
],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
label: 'nav.groupSeafarer',
|
|
||||||
items: [
|
|
||||||
{ to: '/seafarer-registry', label: 'nav.seafarerRegistry', icon: IconUsers },
|
|
||||||
{ to: '/coc-queue', label: 'nav.cocQueue', icon: IconShieldCheck, soon: true },
|
|
||||||
{ to: '/seaman-book-queue', label: 'nav.seamanBookQueue', icon: IconBook2, soon: true },
|
|
||||||
{ to: '/endorsement-queue', label: 'nav.endorsementQueue', icon: IconRubberStamp, soon: true },
|
|
||||||
{ to: '/medical-verification', label: 'nav.medicalVerification', icon: IconHeart, soon: true },
|
|
||||||
],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
label: 'nav.groupExaminations',
|
|
||||||
items: [
|
|
||||||
{ to: '/questions', label: 'nav.questions', icon: IconQuestionMark },
|
|
||||||
{ to: '/exams', label: 'nav.exams', icon: IconClipboardList },
|
|
||||||
{ to: '/exam-results', label: 'nav.examResults', icon: IconReport },
|
|
||||||
],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
label: 'nav.groupVessels',
|
|
||||||
items: [
|
|
||||||
{ to: '/vessel-registration-queue', label: 'nav.vesselRegistrationQueue', icon: IconAnchor, soon: true },
|
|
||||||
{ to: '/vessel-ownership-transfer', label: 'nav.ownershipTransferQueue', icon: IconFileDescription, soon: true },
|
|
||||||
{ to: '/vessel-registration-queue/new', label: 'nav.vesselFormBuilder', icon: IconFilePlus, soon: true },
|
|
||||||
{ to: '/vessel-registration-report', label: 'nav.vesselRegistrationReport', icon: IconChartBar, soon: true },
|
|
||||||
{ to: '/vessel-registration-head-dashboard', label: 'nav.vesselRegistrationHeadDashboard', icon: IconGauge, soon: true },
|
|
||||||
],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
label: 'nav.groupAdministration',
|
|
||||||
items: [
|
|
||||||
{ to: '/um/user-management/dashboard', label: 'nav.userManagement', icon: IconUserShield },
|
|
||||||
{ to: '/configuration', label: 'nav.configuration', icon: IconSettings },
|
|
||||||
{ to: '/locations', label: 'nav.locations', icon: IconMapPin },
|
|
||||||
{ to: '/analytics', label: 'nav.analytics', icon: IconChartBar, soon: true },
|
|
||||||
{ to: '/profile', label: 'nav.profile', icon: IconUser },
|
|
||||||
],
|
|
||||||
},
|
|
||||||
];
|
|
||||||
|
|
||||||
/** Flat list used for breadcrumbs and active-route lookup. */
|
|
||||||
const NAV_ITEMS: NavItem[] = NAV_SECTIONS.flatMap((section) => section.items);
|
|
||||||
|
|
||||||
const HEADER_HEIGHT = 116;
|
const HEADER_HEIGHT = 116;
|
||||||
|
|
||||||
@@ -106,6 +35,44 @@ export function BackofficeLayout() {
|
|||||||
const [collapsed, setCollapsed] = useState(false);
|
const [collapsed, setCollapsed] = useState(false);
|
||||||
const user = useAppSelector((state) => state.auth.user);
|
const user = useAppSelector((state) => state.auth.user);
|
||||||
const layoutMode = useAppSelector((state) => state.preferences.layoutMode);
|
const layoutMode = useAppSelector((state) => state.preferences.layoutMode);
|
||||||
|
const { can } = usePermissions();
|
||||||
|
|
||||||
|
// Badges reflect real pending work. One grouped request on a timer, shared
|
||||||
|
// by the sidebar and the top bar via the RTK cache.
|
||||||
|
const { data: counts } = useGetQueueCountsQuery(undefined, {
|
||||||
|
pollingInterval: BADGE_POLL_MS,
|
||||||
|
refetchOnMountOrArgChange: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
const sections = useMemo<NavSection[]>(() => {
|
||||||
|
const withBadges = NAV_SECTIONS.map((section) => ({
|
||||||
|
...section,
|
||||||
|
items: section.items.map((item) =>
|
||||||
|
item.to === '/licence-review' && counts?.unassigned
|
||||||
|
? { ...item, badge: counts.unassigned }
|
||||||
|
: item,
|
||||||
|
),
|
||||||
|
}));
|
||||||
|
return filterByPermissions(
|
||||||
|
withBadges,
|
||||||
|
// `can` already fails open when the token carries no permission claim,
|
||||||
|
// so this only ever removes items we are sure the user cannot use.
|
||||||
|
withBadges
|
||||||
|
.flatMap((section) => section.items)
|
||||||
|
.flatMap((item) => [item, ...(item.children ?? [])])
|
||||||
|
.flatMap((item) => item.permissions ?? [])
|
||||||
|
.filter((permission) => can([permission])),
|
||||||
|
);
|
||||||
|
}, [counts?.unassigned, can]);
|
||||||
|
|
||||||
|
/** Flat list used for breadcrumbs and active-route lookup. */
|
||||||
|
const navItems = useMemo<NavItem[]>(
|
||||||
|
() =>
|
||||||
|
sections.flatMap((section) =>
|
||||||
|
section.items.flatMap((item) => [item, ...(item.children ?? [])]),
|
||||||
|
),
|
||||||
|
[sections],
|
||||||
|
);
|
||||||
|
|
||||||
const displayName = user?.name?.en || user?.username || '';
|
const displayName = user?.name?.en || user?.username || '';
|
||||||
const initials = displayName
|
const initials = displayName
|
||||||
@@ -128,7 +95,7 @@ export function BackofficeLayout() {
|
|||||||
.filter((path) => path !== '/dashboard')
|
.filter((path) => path !== '/dashboard')
|
||||||
.filter((path) => !path.startsWith('/um'))
|
.filter((path) => !path.startsWith('/um'))
|
||||||
.map((path) => {
|
.map((path) => {
|
||||||
const match = NAV_ITEMS.find((item) => item.to === path);
|
const match = navItems.find((item) => item.to === path);
|
||||||
if (match) return { label: t(match.label), path };
|
if (match) return { label: t(match.label), path };
|
||||||
const segment = path.split('/').pop() ?? '';
|
const segment = path.split('/').pop() ?? '';
|
||||||
// Ids get a generic label rather than a raw uuid in the trail.
|
// Ids get a generic label rather than a raw uuid in the trail.
|
||||||
@@ -195,58 +162,19 @@ export function BackofficeLayout() {
|
|||||||
style={{
|
style={{
|
||||||
display: 'flex',
|
display: 'flex',
|
||||||
alignItems: 'center',
|
alignItems: 'center',
|
||||||
gap: rem(2),
|
|
||||||
padding: '0 32px',
|
padding: '0 32px',
|
||||||
height: 42,
|
height: 42,
|
||||||
borderTop: '1px solid var(--mantine-color-gray-1)',
|
borderTop: '1px solid var(--mantine-color-gray-1)',
|
||||||
overflowX: 'auto',
|
|
||||||
flexShrink: 0,
|
flexShrink: 0,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{NAV_ITEMS.map((item) => {
|
{/* Grouped dropdowns. Previously every destination rendered as a
|
||||||
const active = !!item.to && (location.pathname === item.to || location.pathname.startsWith(`${item.to}/`));
|
sibling button in one horizontally scrolling row. */}
|
||||||
const ItemIcon = item.icon;
|
<AppTopNav
|
||||||
return (
|
navItems={sections}
|
||||||
<button
|
activePath={location.pathname}
|
||||||
key={item.to}
|
onNavigate={go}
|
||||||
onClick={() => go(item)}
|
/>
|
||||||
title={item.soon ? `${t(item.label)} — ${t('nav.soon', 'Soon')}` : undefined}
|
|
||||||
style={{
|
|
||||||
opacity: item.soon ? 0.55 : 1,
|
|
||||||
display: 'flex',
|
|
||||||
alignItems: 'center',
|
|
||||||
gap: rem(6),
|
|
||||||
padding: '8px 16px',
|
|
||||||
border: 'none',
|
|
||||||
borderBottom: '2px solid',
|
|
||||||
borderBottomColor: active
|
|
||||||
? 'var(--mantine-color-blue-6)'
|
|
||||||
: 'transparent',
|
|
||||||
background: 'transparent',
|
|
||||||
color: active
|
|
||||||
? 'var(--mantine-color-blue-6)'
|
|
||||||
: 'var(--mantine-color-gray-6)',
|
|
||||||
fontWeight: active ? 600 : 500,
|
|
||||||
fontSize: rem(14),
|
|
||||||
whiteSpace: 'nowrap',
|
|
||||||
cursor: 'pointer',
|
|
||||||
transition: 'all 150ms ease',
|
|
||||||
height: '100%',
|
|
||||||
marginBottom: -1,
|
|
||||||
fontFamily: 'inherit',
|
|
||||||
}}
|
|
||||||
onMouseEnter={(e) => {
|
|
||||||
if (!active) e.currentTarget.style.color = 'var(--mantine-color-blue-6)';
|
|
||||||
}}
|
|
||||||
onMouseLeave={(e) => {
|
|
||||||
if (!active) e.currentTarget.style.color = 'var(--mantine-color-gray-6)';
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<ItemIcon size={18} stroke={1.6} />
|
|
||||||
<span>{t(item.label)}</span>
|
|
||||||
</button>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</AppShell.Header>
|
</AppShell.Header>
|
||||||
@@ -262,13 +190,14 @@ export function BackofficeLayout() {
|
|||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<AppSidebar
|
<AppSidebar
|
||||||
navItems={NAV_SECTIONS}
|
navItems={sections}
|
||||||
collapsed={collapsed}
|
collapsed={collapsed}
|
||||||
activePath={location.pathname}
|
activePath={location.pathname}
|
||||||
onToggleCollapse={handleToggleCollapse}
|
onToggleCollapse={handleToggleCollapse}
|
||||||
onNavigate={go}
|
onNavigate={go}
|
||||||
brandName={t('app.name')}
|
brandName={t('app.name')}
|
||||||
brandSubtitle={t('app.authority')}
|
brandSubtitle={t('app.authority')}
|
||||||
|
brandLogo={<BrandMark size={32} />}
|
||||||
/>
|
/>
|
||||||
</AppShell.Navbar>
|
</AppShell.Navbar>
|
||||||
)}
|
)}
|
||||||
@@ -278,6 +207,9 @@ export function BackofficeLayout() {
|
|||||||
<Outlet />
|
<Outlet />
|
||||||
</div>
|
</div>
|
||||||
</AppShell.Main>
|
</AppShell.Main>
|
||||||
|
|
||||||
|
{/* Registered once for the whole backoffice; opens on ⌘K anywhere. */}
|
||||||
|
<CommandPalette sections={sections} />
|
||||||
</AppShell>
|
</AppShell>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
96
apps/backoffice/src/app/layouts/CommandPalette.tsx
Normal file
96
apps/backoffice/src/app/layouts/CommandPalette.tsx
Normal file
@@ -0,0 +1,96 @@
|
|||||||
|
import { useMemo, useState } from 'react';
|
||||||
|
import { Spotlight, type SpotlightActionData } from '@mantine/spotlight';
|
||||||
|
import { useDebouncedValue } from '@mantine/hooks';
|
||||||
|
import { IconFileText, IconSearch } from '@tabler/icons-react';
|
||||||
|
import { useTranslation } from 'react-i18next';
|
||||||
|
import { useNavigate } from 'react-router-dom';
|
||||||
|
import { flattenNav, type NavSection } from '@ema-platform/ui';
|
||||||
|
import { useGetAllApplicationsQuery } from '@ema-platform/api';
|
||||||
|
|
||||||
|
/** Long enough that typing a company name does not fire a request per keystroke. */
|
||||||
|
const SEARCH_DEBOUNCE_MS = 250;
|
||||||
|
|
||||||
|
/** Below this, a server search matches too much to be useful. */
|
||||||
|
const MIN_SEARCH_LENGTH = 2;
|
||||||
|
|
||||||
|
interface CommandPaletteProps {
|
||||||
|
/** Already permission-filtered, so the palette cannot reach a hidden route. */
|
||||||
|
sections: NavSection[];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* ⌘K search over every destination and recent application.
|
||||||
|
*
|
||||||
|
* With twenty-plus destinations plus five licence types, hunting through
|
||||||
|
* nested menus is the slow path. This makes nesting cheap: anything reachable
|
||||||
|
* by clicking is reachable by typing, including applications by number,
|
||||||
|
* company or TIN.
|
||||||
|
*/
|
||||||
|
export function CommandPalette({ sections }: CommandPaletteProps) {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const [query, setQuery] = useState('');
|
||||||
|
const [debounced] = useDebouncedValue(query, SEARCH_DEBOUNCE_MS);
|
||||||
|
|
||||||
|
const term = debounced.trim();
|
||||||
|
// Only hits the API once the palette is open and the query is meaningful.
|
||||||
|
const { data: applications } = useGetAllApplicationsQuery(
|
||||||
|
{ search: term, take: 8 },
|
||||||
|
{ skip: term.length < MIN_SEARCH_LENGTH },
|
||||||
|
);
|
||||||
|
|
||||||
|
const destinationActions = useMemo<SpotlightActionData[]>(
|
||||||
|
() =>
|
||||||
|
flattenNav(sections)
|
||||||
|
.filter((item) => item.to && !item.soon)
|
||||||
|
.map((item) => ({
|
||||||
|
id: item.to as string,
|
||||||
|
label: t(item.label),
|
||||||
|
description: item.to,
|
||||||
|
leftSection: <item.icon size={18} stroke={1.6} />,
|
||||||
|
onClick: () => navigate(item.to as string),
|
||||||
|
})),
|
||||||
|
[sections, navigate, t],
|
||||||
|
);
|
||||||
|
|
||||||
|
const applicationActions = useMemo<SpotlightActionData[]>(
|
||||||
|
() =>
|
||||||
|
(applications?.items ?? []).map((app) => ({
|
||||||
|
id: `application-${app.id}`,
|
||||||
|
label: app.companyName ?? app.applicationNumber,
|
||||||
|
description: [app.applicationNumber, app.tinNumber]
|
||||||
|
.filter(Boolean)
|
||||||
|
.join(' · '),
|
||||||
|
leftSection: <IconFileText size={18} stroke={1.6} />,
|
||||||
|
onClick: () => navigate(`/licence-review/${app.id}`),
|
||||||
|
})),
|
||||||
|
[applications, navigate],
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Spotlight
|
||||||
|
query={query}
|
||||||
|
onQueryChange={setQuery}
|
||||||
|
actions={[
|
||||||
|
{
|
||||||
|
group: t('nav.destinations', 'Go to'),
|
||||||
|
actions: destinationActions,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
group: t('nav.applications', 'Applications'),
|
||||||
|
actions: applicationActions,
|
||||||
|
},
|
||||||
|
]}
|
||||||
|
shortcut={['mod + K']}
|
||||||
|
nothingFound={t('nav.noResults', 'Nothing found')}
|
||||||
|
highlightQuery
|
||||||
|
searchProps={{
|
||||||
|
leftSection: <IconSearch size={18} stroke={1.6} />,
|
||||||
|
placeholder: t(
|
||||||
|
'nav.commandPlaceholder',
|
||||||
|
'Search screens, applications, companies, TIN…',
|
||||||
|
),
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
144
apps/backoffice/src/app/layouts/nav-config.ts
Normal file
144
apps/backoffice/src/app/layouts/nav-config.ts
Normal file
@@ -0,0 +1,144 @@
|
|||||||
|
import {
|
||||||
|
IconAnchor,
|
||||||
|
IconBook2,
|
||||||
|
IconChartBar,
|
||||||
|
IconClipboardList,
|
||||||
|
IconCreditCard,
|
||||||
|
IconFileDescription,
|
||||||
|
IconFilePlus,
|
||||||
|
IconGauge,
|
||||||
|
IconHeart,
|
||||||
|
IconLayoutDashboard,
|
||||||
|
IconListCheck,
|
||||||
|
IconMapPin,
|
||||||
|
IconQuestionMark,
|
||||||
|
IconReport,
|
||||||
|
IconRosetteDiscountCheck,
|
||||||
|
IconRubberStamp,
|
||||||
|
IconSettings,
|
||||||
|
IconShieldCheck,
|
||||||
|
IconShieldOff,
|
||||||
|
IconShip,
|
||||||
|
IconTruck,
|
||||||
|
IconUsers,
|
||||||
|
IconUserShield,
|
||||||
|
} from '@tabler/icons-react';
|
||||||
|
import type { NavSection } from '@ema-platform/ui';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Permission keys mirrored from the API's `LICENSE_PERMISSIONS`.
|
||||||
|
*
|
||||||
|
* Kept as literals rather than imported: the backoffice bundle must not pull
|
||||||
|
* in server code, and these strings are a published contract — the IAM seed
|
||||||
|
* and every `PermissionGuard([...])` already read from the same list.
|
||||||
|
*/
|
||||||
|
export const PERMISSIONS = {
|
||||||
|
VIEW_APPLICATION_QUEUE: 'can:View:license-application-queue',
|
||||||
|
VIEW_APPLICATIONS: 'can:View:license-applications',
|
||||||
|
VIEW_LICENSE_TYPES: 'can:View:license-types',
|
||||||
|
VIEW_PAYMENTS: 'can:View:license-payments',
|
||||||
|
VIEW_TEMPLATES: 'can:View:license-templates',
|
||||||
|
UPDATE_TEMPLATE: 'can:update:license-template',
|
||||||
|
PUBLISH_TEMPLATE: 'can:publish:license-template',
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The backoffice information architecture.
|
||||||
|
*
|
||||||
|
* Six top-level groups, none deeper than one level of nesting. `soon` marks
|
||||||
|
* screens with no backend behind them, so a reviewer can tell at a glance what
|
||||||
|
* actually works.
|
||||||
|
*/
|
||||||
|
export const NAV_SECTIONS: NavSection[] = [
|
||||||
|
{
|
||||||
|
items: [{ to: '/dashboard', label: 'nav.dashboard', icon: IconLayoutDashboard }],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: 'nav.groupLicensing',
|
||||||
|
items: [
|
||||||
|
{
|
||||||
|
to: '/licence-review',
|
||||||
|
label: 'nav.allApplications',
|
||||||
|
icon: IconListCheck,
|
||||||
|
permissions: [PERMISSIONS.VIEW_APPLICATION_QUEUE],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
// A disclosure, not a destination — each child deep-links the grid to
|
||||||
|
// one type, which is a facet of the same workspace.
|
||||||
|
label: 'nav.byType',
|
||||||
|
icon: IconTruck,
|
||||||
|
permissions: [PERMISSIONS.VIEW_APPLICATIONS],
|
||||||
|
children: [
|
||||||
|
{ to: '/licence-review/type/FREIGHT_FORWARDER', label: 'nav.typeFreightForwarder', icon: IconTruck },
|
||||||
|
{ to: '/licence-review/type/SHIPPING_AGENT', label: 'nav.typeShippingAgent', icon: IconShip },
|
||||||
|
{ to: '/licence-review/type/COMBINED_SA_FF', label: 'nav.typeCombined', icon: IconFileDescription },
|
||||||
|
{ to: '/licence-review/type/JOINT_INVESTOR', label: 'nav.typeJointInvestment', icon: IconUsers },
|
||||||
|
{ to: '/licence-review/type/MULTIMODAL_TRANSPORT_OPERATOR', label: 'nav.typeMto', icon: IconAnchor },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
to: '/certificate-designer',
|
||||||
|
label: 'nav.certificateDesigner',
|
||||||
|
icon: IconRosetteDiscountCheck,
|
||||||
|
permissions: [PERMISSIONS.VIEW_TEMPLATES],
|
||||||
|
},
|
||||||
|
{ to: '/waiver', label: 'nav.waiver', icon: IconShieldOff, soon: true },
|
||||||
|
{
|
||||||
|
to: '/logistics-head-dashboard',
|
||||||
|
label: 'nav.logisticsHeadDashboard',
|
||||||
|
icon: IconGauge,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
to: '/payment-config',
|
||||||
|
label: 'nav.paymentConfig',
|
||||||
|
icon: IconCreditCard,
|
||||||
|
permissions: [PERMISSIONS.VIEW_PAYMENTS],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: 'nav.groupSeafarer',
|
||||||
|
items: [
|
||||||
|
{ to: '/seafarer-registry', label: 'nav.seafarerRegistry', icon: IconUsers },
|
||||||
|
{ to: '/coc-queue', label: 'nav.cocQueue', icon: IconShieldCheck, soon: true },
|
||||||
|
{ to: '/seaman-book-queue', label: 'nav.seamanBookQueue', icon: IconBook2, soon: true },
|
||||||
|
{ to: '/endorsement-queue', label: 'nav.endorsementQueue', icon: IconRubberStamp, soon: true },
|
||||||
|
{ to: '/medical-verification', label: 'nav.medicalVerification', icon: IconHeart, soon: true },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: 'nav.groupVessels',
|
||||||
|
items: [
|
||||||
|
{ to: '/vessel-registration-queue', label: 'nav.vesselRegistrationQueue', icon: IconAnchor, soon: true },
|
||||||
|
{ to: '/vessel-ownership-transfer', label: 'nav.ownershipTransferQueue', icon: IconFileDescription, soon: true },
|
||||||
|
{ to: '/vessel-registration-queue/new', label: 'nav.vesselFormBuilder', icon: IconFilePlus, soon: true },
|
||||||
|
{ to: '/vessel-registration-report', label: 'nav.vesselRegistrationReport', icon: IconChartBar, soon: true },
|
||||||
|
{ to: '/vessel-registration-head-dashboard', label: 'nav.vesselRegistrationHeadDashboard', icon: IconGauge, soon: true },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: 'nav.groupExaminations',
|
||||||
|
items: [
|
||||||
|
{ to: '/questions', label: 'nav.questions', icon: IconQuestionMark },
|
||||||
|
{ to: '/exams', label: 'nav.exams', icon: IconClipboardList },
|
||||||
|
{ to: '/exam-results', label: 'nav.examResults', icon: IconReport },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: 'nav.groupAdministration',
|
||||||
|
items: [
|
||||||
|
{ to: '/um/user-management/dashboard', label: 'nav.userManagement', icon: IconUserShield },
|
||||||
|
{ to: '/locations', label: 'nav.locations', icon: IconMapPin },
|
||||||
|
{
|
||||||
|
to: '/configuration',
|
||||||
|
label: 'nav.configuration',
|
||||||
|
icon: IconSettings,
|
||||||
|
permissions: [PERMISSIONS.VIEW_LICENSE_TYPES],
|
||||||
|
},
|
||||||
|
{ to: '/analytics', label: 'nav.analytics', icon: IconChartBar, soon: true },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
// `/profile` deliberately absent: it is a property of the signed-in user,
|
||||||
|
// not a destination in the authority's workload, and now lives in the
|
||||||
|
// AppHeader user menu alongside sign-out.
|
||||||
|
];
|
||||||
@@ -42,6 +42,7 @@ import { LicenseReviewPage } from '../features/license-review/pages/LicenseRevie
|
|||||||
import { WaiverQueuePage } from '../features/waiver/pages/WaiverQueuePage';
|
import { WaiverQueuePage } from '../features/waiver/pages/WaiverQueuePage';
|
||||||
import { WaiverReviewPage } from '../features/waiver/pages/WaiverReviewPage';
|
import { WaiverReviewPage } from '../features/waiver/pages/WaiverReviewPage';
|
||||||
import { LogisticsHeadDashboardPage } from '../features/logistics-head/pages/LogisticsHeadDashboardPage';
|
import { LogisticsHeadDashboardPage } from '../features/logistics-head/pages/LogisticsHeadDashboardPage';
|
||||||
|
import { CertificateDesignerPage } from '../features/certificate-designer/pages/CertificateDesignerPage';
|
||||||
|
|
||||||
const router = createBrowserRouter([
|
const router = createBrowserRouter([
|
||||||
{
|
{
|
||||||
@@ -89,7 +90,11 @@ const router = createBrowserRouter([
|
|||||||
{ path: 'vessel-ownership-transfer', element: <VesselOwnershipTransferQueuePage /> },
|
{ path: 'vessel-ownership-transfer', element: <VesselOwnershipTransferQueuePage /> },
|
||||||
{ path: 'vessel-ownership-transfer/:id', element: <VesselOwnershipTransferReviewPage /> },
|
{ path: 'vessel-ownership-transfer/:id', element: <VesselOwnershipTransferReviewPage /> },
|
||||||
// Config-driven review workspace, shared by every licence type.
|
// Config-driven review workspace, shared by every licence type.
|
||||||
|
{ path: 'certificate-designer', element: <CertificateDesignerPage /> },
|
||||||
{ path: 'licence-review', element: <LicenseQueuePage /> },
|
{ path: 'licence-review', element: <LicenseQueuePage /> },
|
||||||
|
// Deep link into the grid with the type facet pinned, so "Freight
|
||||||
|
// Forwarder" in the nav is a filtered view rather than a page.
|
||||||
|
{ path: 'licence-review/type/:typeCode', element: <LicenseQueuePage /> },
|
||||||
{ path: 'licence-review/:id', element: <LicenseReviewPage /> },
|
{ path: 'licence-review/:id', element: <LicenseReviewPage /> },
|
||||||
{ path: 'freight-forwarder-license', element: <Navigate to="/licence-review" replace /> },
|
{ path: 'freight-forwarder-license', element: <Navigate to="/licence-review" replace /> },
|
||||||
{ path: 'freight-forwarder-license/:id', element: <Navigate to="/licence-review" replace /> },
|
{ path: 'freight-forwarder-license/:id', element: <Navigate to="/licence-review" replace /> },
|
||||||
|
|||||||
@@ -2,8 +2,15 @@ import { createSlice, type PayloadAction } from '@reduxjs/toolkit';
|
|||||||
|
|
||||||
export type LayoutMode = 'top' | 'sidebar';
|
export type LayoutMode = 'top' | 'sidebar';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Row height in tables. Officers who work a queue all day want more rows per
|
||||||
|
* screen; occasional users want the breathing room.
|
||||||
|
*/
|
||||||
|
export type Density = 'comfortable' | 'compact';
|
||||||
|
|
||||||
interface PreferencesState {
|
interface PreferencesState {
|
||||||
layoutMode: LayoutMode;
|
layoutMode: LayoutMode;
|
||||||
|
density: Density;
|
||||||
}
|
}
|
||||||
|
|
||||||
const PREFERENCES_KEY = 'ema-backoffice-preferences';
|
const PREFERENCES_KEY = 'ema-backoffice-preferences';
|
||||||
@@ -11,17 +18,25 @@ const PREFERENCES_KEY = 'ema-backoffice-preferences';
|
|||||||
const loadPreferences = (): PreferencesState => {
|
const loadPreferences = (): PreferencesState => {
|
||||||
try {
|
try {
|
||||||
const stored = localStorage.getItem(PREFERENCES_KEY);
|
const stored = localStorage.getItem(PREFERENCES_KEY);
|
||||||
if (stored) return JSON.parse(stored);
|
if (stored) {
|
||||||
} catch {}
|
// Merge over the defaults so a preferences blob written before a new
|
||||||
|
// key existed does not come back with that key undefined.
|
||||||
|
return { layoutMode: 'sidebar', density: 'comfortable', ...JSON.parse(stored) };
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// Corrupt or unavailable storage (private mode) — fall through to the default.
|
||||||
|
}
|
||||||
// The sidebar is the grouped, scannable layout; the top strip puts all
|
// The sidebar is the grouped, scannable layout; the top strip puts all
|
||||||
// ~20 destinations in one horizontally-scrolling row.
|
// ~20 destinations in one horizontally-scrolling row.
|
||||||
return { layoutMode: 'sidebar' };
|
return { layoutMode: 'sidebar', density: 'comfortable' };
|
||||||
};
|
};
|
||||||
|
|
||||||
const savePreferences = (state: PreferencesState) => {
|
const savePreferences = (state: PreferencesState) => {
|
||||||
try {
|
try {
|
||||||
localStorage.setItem(PREFERENCES_KEY, JSON.stringify(state));
|
localStorage.setItem(PREFERENCES_KEY, JSON.stringify(state));
|
||||||
} catch {}
|
} catch {
|
||||||
|
// Storage full or unavailable — the preference just will not persist.
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const initialState: PreferencesState = loadPreferences();
|
const initialState: PreferencesState = loadPreferences();
|
||||||
@@ -34,8 +49,12 @@ const preferencesSlice = createSlice({
|
|||||||
state.layoutMode = action.payload;
|
state.layoutMode = action.payload;
|
||||||
savePreferences(state);
|
savePreferences(state);
|
||||||
},
|
},
|
||||||
|
setDensity(state, action: PayloadAction<Density>) {
|
||||||
|
state.density = action.payload;
|
||||||
|
savePreferences(state);
|
||||||
|
},
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
export const { setLayoutMode } = preferencesSlice.actions;
|
export const { setLayoutMode, setDensity } = preferencesSlice.actions;
|
||||||
export const preferencesReducer = preferencesSlice.reducer;
|
export const preferencesReducer = preferencesSlice.reducer;
|
||||||
|
|||||||
@@ -3,10 +3,32 @@ import { createRoot } from 'react-dom/client';
|
|||||||
import '@mantine/core/styles.css';
|
import '@mantine/core/styles.css';
|
||||||
import '@mantine/notifications/styles.css';
|
import '@mantine/notifications/styles.css';
|
||||||
import '@mantine/dates/styles.css';
|
import '@mantine/dates/styles.css';
|
||||||
|
import '@mantine/spotlight/styles.css';
|
||||||
import './styles.css';
|
import './styles.css';
|
||||||
import './app/i18n/config';
|
import './app/i18n/config';
|
||||||
import { App } from './app/app';
|
import { App } from './app/app';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Branding handed to the vendored `@tria-plc/iamui` user-management module,
|
||||||
|
* which reads it off `window` at import time. Declared here because that
|
||||||
|
* package ships no ambient type for it.
|
||||||
|
*/
|
||||||
|
declare global {
|
||||||
|
interface Window {
|
||||||
|
__USER_MANAGEMENT_BRANDING__: {
|
||||||
|
appName: string;
|
||||||
|
organizationName: string;
|
||||||
|
logoSrc: string;
|
||||||
|
logoAlt: string;
|
||||||
|
homePath: string;
|
||||||
|
moduleBasePath: string;
|
||||||
|
backToAppPath: string;
|
||||||
|
backToAppLabel: string;
|
||||||
|
cssVariables: Record<string, string>;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
document.title = 'EMA Backoffice';
|
document.title = 'EMA Backoffice';
|
||||||
|
|
||||||
const _favicon = document.querySelector<HTMLLinkElement>('link[rel="icon"]');
|
const _favicon = document.querySelector<HTMLLinkElement>('link[rel="icon"]');
|
||||||
|
|||||||
@@ -4,3 +4,51 @@
|
|||||||
@tailwind utilities;
|
@tailwind utilities;
|
||||||
|
|
||||||
*, *::before, *::after { box-sizing: border-box; }
|
*, *::before, *::after { box-sizing: border-box; }
|
||||||
|
|
||||||
|
/* ---------------------------------------------------------------------------
|
||||||
|
Print — the review dossier.
|
||||||
|
|
||||||
|
An officer printing a review wants the application, not the application
|
||||||
|
plus the chrome around it. Navigation, the Decision Bar and the collapsible
|
||||||
|
rails are all interactive surfaces with no meaning on paper, so they are
|
||||||
|
dropped and the centre column is given the full width.
|
||||||
|
--------------------------------------------------------------------------- */
|
||||||
|
@media print {
|
||||||
|
.mantine-AppShell-navbar,
|
||||||
|
.mantine-AppShell-header,
|
||||||
|
[role='region'][aria-label='Decision bar'],
|
||||||
|
.mantine-Drawer-root,
|
||||||
|
.mantine-Modal-root {
|
||||||
|
display: none !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mantine-AppShell-main {
|
||||||
|
padding: 0 !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Tabs print flattened: a printed dossier that shows one tab's worth of a
|
||||||
|
six-tab application is missing five sixths of the record. */
|
||||||
|
.mantine-Tabs-panel {
|
||||||
|
display: block !important;
|
||||||
|
}
|
||||||
|
.mantine-Tabs-list {
|
||||||
|
display: none !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Sticky positioning collapses badly across page breaks. */
|
||||||
|
[style*='position: sticky'],
|
||||||
|
[style*='position:sticky'] {
|
||||||
|
position: static !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Keep a record entry from being split across two sheets. */
|
||||||
|
.mantine-Paper-root,
|
||||||
|
.mantine-Card-root,
|
||||||
|
.mantine-Table-tr {
|
||||||
|
break-inside: avoid;
|
||||||
|
}
|
||||||
|
|
||||||
|
body {
|
||||||
|
background: #fff !important;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,17 +0,0 @@
|
|||||||
import { Navigate } from 'react-router-dom';
|
|
||||||
import type { ReactNode } from 'react';
|
|
||||||
import { authStorage } from '@ema-platform/auth';
|
|
||||||
|
|
||||||
interface ProfileGuardProps {
|
|
||||||
children?: ReactNode;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function ProfileGuard({ children }: ProfileGuardProps) {
|
|
||||||
const profileId = authStorage.getProfileId();
|
|
||||||
|
|
||||||
if (!profileId) {
|
|
||||||
return <Navigate to="/profile-setup" replace />;
|
|
||||||
}
|
|
||||||
|
|
||||||
return <>{children}</>;
|
|
||||||
}
|
|
||||||
@@ -34,6 +34,7 @@ import {
|
|||||||
IconFileText,
|
IconFileText,
|
||||||
IconShieldCheck,
|
IconShieldCheck,
|
||||||
} from '@tabler/icons-react';
|
} from '@tabler/icons-react';
|
||||||
|
import { ProfileCompletionNudge } from '../../profile/components/ProfileCompletionNudge';
|
||||||
import {
|
import {
|
||||||
APPLICANT_ACTION_STATUSES,
|
APPLICANT_ACTION_STATUSES,
|
||||||
STATUS_COLORS,
|
STATUS_COLORS,
|
||||||
@@ -129,6 +130,9 @@ export function DashboardPage() {
|
|||||||
licenseCount={activeLicenses.length}
|
licenseCount={activeLicenses.length}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
{/* A prompt, not a gate — dismissible and it never blocks the page. */}
|
||||||
|
<ProfileCompletionNudge />
|
||||||
|
|
||||||
{needsMe.length > 0 && (
|
{needsMe.length > 0 && (
|
||||||
<ActionRequired applications={needsMe} navigate={navigate} />
|
<ActionRequired applications={needsMe} navigate={navigate} />
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -66,6 +66,29 @@ export function MyApplicationsPage() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Opens the certificate belonging to an application.
|
||||||
|
*
|
||||||
|
* The applicant knows their application number, not the licence id, so the
|
||||||
|
* licence is looked up from the list already loaded rather than making them
|
||||||
|
* find it in a separate table.
|
||||||
|
*/
|
||||||
|
async function openCertificateForApplication(applicationId: string) {
|
||||||
|
const licence = (licences?.items ?? []).find(
|
||||||
|
(l) => l.applicationId === applicationId,
|
||||||
|
);
|
||||||
|
if (!licence) {
|
||||||
|
notifications.show({
|
||||||
|
color: 'yellow',
|
||||||
|
title: 'Certificate not ready',
|
||||||
|
message:
|
||||||
|
'The licence for this application has not been issued yet. It will appear under My licences.',
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
await downloadCertificate(licence.id);
|
||||||
|
}
|
||||||
|
|
||||||
async function downloadCertificate(licenseId: string) {
|
async function downloadCertificate(licenseId: string) {
|
||||||
try {
|
try {
|
||||||
const { url } = await getCertificateUrl(licenseId).unwrap();
|
const { url } = await getCertificateUrl(licenseId).unwrap();
|
||||||
@@ -234,6 +257,19 @@ export function MyApplicationsPage() {
|
|||||||
Bypass payment
|
Bypass payment
|
||||||
</Button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
|
{/* An issued application's primary action is the
|
||||||
|
certificate. It used to be "View", which opened the
|
||||||
|
application wizard — so the one thing the applicant
|
||||||
|
came back for was the one thing the button did not do. */}
|
||||||
|
{app.status === 'CERTIFICATE_ISSUED' && (
|
||||||
|
<Button
|
||||||
|
size="xs"
|
||||||
|
leftSection={<IconDownload size={14} />}
|
||||||
|
onClick={() => openCertificateForApplication(app.id)}
|
||||||
|
>
|
||||||
|
Certificate
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
<Button
|
<Button
|
||||||
size="xs"
|
size="xs"
|
||||||
loading={isPaying && app.status === 'PAYMENT_PENDING'}
|
loading={isPaying && app.status === 'PAYMENT_PENDING'}
|
||||||
@@ -266,9 +302,8 @@ export function MyApplicationsPage() {
|
|||||||
? 'Fix & resubmit'
|
? 'Fix & resubmit'
|
||||||
: app.status === 'PAYMENT_PENDING'
|
: app.status === 'PAYMENT_PENDING'
|
||||||
? `Pay ${Number(app.feeAmount ?? 0).toLocaleString()} ${app.feeCurrency}`
|
? `Pay ${Number(app.feeAmount ?? 0).toLocaleString()} ${app.feeCurrency}`
|
||||||
: app.status === 'PAYMENT_CONFIRMED' ||
|
: app.status === 'CERTIFICATE_ISSUED'
|
||||||
app.status === 'PAID'
|
? 'Application'
|
||||||
? 'View'
|
|
||||||
: 'View'}
|
: 'View'}
|
||||||
</Button>
|
</Button>
|
||||||
</Group>
|
</Group>
|
||||||
|
|||||||
@@ -1,378 +0,0 @@
|
|||||||
import { useEffect, useMemo, useRef, useState } from 'react';
|
|
||||||
import {
|
|
||||||
Box,
|
|
||||||
Button,
|
|
||||||
Center,
|
|
||||||
Group,
|
|
||||||
Paper,
|
|
||||||
Stack,
|
|
||||||
Text,
|
|
||||||
Title,
|
|
||||||
rem,
|
|
||||||
} from '@mantine/core';
|
|
||||||
import {
|
|
||||||
IconArrowLeft,
|
|
||||||
IconArrowRight,
|
|
||||||
IconCheck,
|
|
||||||
IconCircleCheck,
|
|
||||||
IconLogout2,
|
|
||||||
IconMapPin,
|
|
||||||
IconUser,
|
|
||||||
} from '@tabler/icons-react';
|
|
||||||
import { useForm } from 'react-hook-form';
|
|
||||||
import { zodResolver } from '@hookform/resolvers/zod';
|
|
||||||
import { useNavigate } from 'react-router-dom';
|
|
||||||
import { useApiMutation } from '@ema-platform/api';
|
|
||||||
import { notify } from '@ema-platform/ui';
|
|
||||||
import { authStorage, setUser, logout, type AuthUser } from '@ema-platform/auth';
|
|
||||||
import { useAppDispatch, useAppSelector } from '../../../store/hooks';
|
|
||||||
import {
|
|
||||||
ProfileFormContent,
|
|
||||||
profileSchema,
|
|
||||||
type ProfileValues,
|
|
||||||
} from '../../profile/components/ProfileFormContent';
|
|
||||||
import {
|
|
||||||
AddressFormContent,
|
|
||||||
addressSchema,
|
|
||||||
type AddressValues,
|
|
||||||
} from '../../profile/components/AddressFormContent';
|
|
||||||
|
|
||||||
const STEPS = [
|
|
||||||
{ label: 'Profile', icon: IconUser },
|
|
||||||
{ label: 'Address', icon: IconMapPin },
|
|
||||||
];
|
|
||||||
|
|
||||||
function StepIndicator({ active, completed }: { active: number; completed: number[] }) {
|
|
||||||
return (
|
|
||||||
<Box mb={32}>
|
|
||||||
<Group gap={0} align="center" wrap="nowrap">
|
|
||||||
{STEPS.map((step, i) => {
|
|
||||||
const isDone = completed.includes(i);
|
|
||||||
const isCurrent = active === i;
|
|
||||||
return (
|
|
||||||
<Group key={i} gap={0} align="center" style={{ flex: i < STEPS.length - 1 ? 1 : 'none' }}>
|
|
||||||
<Stack gap={4} align="center" style={{ minWidth: rem(40) }}>
|
|
||||||
<Box
|
|
||||||
style={{
|
|
||||||
width: rem(40),
|
|
||||||
height: rem(40),
|
|
||||||
borderRadius: '50%',
|
|
||||||
display: 'flex',
|
|
||||||
alignItems: 'center',
|
|
||||||
justifyContent: 'center',
|
|
||||||
backgroundColor: isDone
|
|
||||||
? 'var(--mantine-color-blue-8)'
|
|
||||||
: isCurrent
|
|
||||||
? 'var(--mantine-color-blue-7)'
|
|
||||||
: 'var(--mantine-color-gray-1)',
|
|
||||||
border: isCurrent ? '2.5px solid var(--mantine-color-blue-5)' : '2px solid transparent',
|
|
||||||
boxShadow: isCurrent || isDone ? '0 2px 8px rgba(34, 139, 230, 0.2)' : 'none',
|
|
||||||
flexShrink: 0,
|
|
||||||
transition: 'all 0.2s ease',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{isDone ? (
|
|
||||||
<IconCheck size={18} color="white" stroke={2.5} />
|
|
||||||
) : (
|
|
||||||
<Text fw={700} fz="sm" c={isCurrent ? 'white' : 'gray.5'}>
|
|
||||||
{i + 1}
|
|
||||||
</Text>
|
|
||||||
)}
|
|
||||||
</Box>
|
|
||||||
<Text
|
|
||||||
fz="xs"
|
|
||||||
fw={isCurrent ? 700 : 400}
|
|
||||||
c={isCurrent ? 'blue.7' : 'dimmed'}
|
|
||||||
style={{ whiteSpace: 'nowrap' }}
|
|
||||||
>
|
|
||||||
{step.label}
|
|
||||||
</Text>
|
|
||||||
</Stack>
|
|
||||||
|
|
||||||
{i < STEPS.length - 1 && (
|
|
||||||
<Box
|
|
||||||
style={{
|
|
||||||
flex: 1,
|
|
||||||
height: rem(2),
|
|
||||||
backgroundColor: isDone
|
|
||||||
? 'var(--mantine-color-blue-8)'
|
|
||||||
: 'var(--mantine-color-gray-2)',
|
|
||||||
marginBottom: rem(22),
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</Group>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</Group>
|
|
||||||
</Box>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export function ProfileSetupPage() {
|
|
||||||
const navigate = useNavigate();
|
|
||||||
const dispatch = useAppDispatch();
|
|
||||||
const user = useAppSelector((state) => state.auth.user);
|
|
||||||
const [active, setActive] = useState(0);
|
|
||||||
const [completed, setCompleted] = useState<number[]>([]);
|
|
||||||
const [submitting, setSubmitting] = useState(false);
|
|
||||||
const [professions, setProfessions] = useState<Array<{ id: string; name: { en: string } }>>([]);
|
|
||||||
const [professionsLoading, setProfessionsLoading] = useState(true);
|
|
||||||
const [profileTrigger] = useApiMutation<{ id: string }>();
|
|
||||||
const [addressTrigger] = useApiMutation<unknown>();
|
|
||||||
const [meTrigger] = useApiMutation<AuthUser>();
|
|
||||||
const [fetchProfessions] = useApiMutation<{ count: number; items: Array<{ id: string; name: { en: string } }> }>();
|
|
||||||
const fetched = useRef(false);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (fetched.current) return;
|
|
||||||
fetched.current = true;
|
|
||||||
fetchProfessions({ url: '/professions?take=100', method: 'GET' })
|
|
||||||
.unwrap()
|
|
||||||
.then((data) => setProfessions(data.items ?? []))
|
|
||||||
.catch(() => setProfessions([]))
|
|
||||||
.finally(() => setProfessionsLoading(false));
|
|
||||||
}, [fetchProfessions]);
|
|
||||||
|
|
||||||
const professionOptions = useMemo(
|
|
||||||
() => professions.map((p) => ({ value: p.id, label: p.name.en })),
|
|
||||||
[professions],
|
|
||||||
);
|
|
||||||
|
|
||||||
const professionNameMap = useMemo(() => {
|
|
||||||
const map: Record<string, string> = {};
|
|
||||||
professions.forEach((p) => {
|
|
||||||
map[p.id] = p.name.en;
|
|
||||||
});
|
|
||||||
return map;
|
|
||||||
}, [professions]);
|
|
||||||
|
|
||||||
const nameParts = useMemo(() => (user?.name?.en || '').trim().split(/\s+/), [user]);
|
|
||||||
const profileDefaults: ProfileValues = useMemo(() => ({
|
|
||||||
professionId: '',
|
|
||||||
firstName: nameParts[0] || '',
|
|
||||||
middleName: nameParts.length > 2 ? nameParts.slice(1, -1).join(' ') : '',
|
|
||||||
lastName: nameParts.length > 1 ? nameParts[nameParts.length - 1] : '',
|
|
||||||
gender: '',
|
|
||||||
dob: '',
|
|
||||||
pob: '',
|
|
||||||
maritalStatus: '',
|
|
||||||
}), [nameParts]);
|
|
||||||
|
|
||||||
const addressDefaults: AddressValues = useMemo(() => ({
|
|
||||||
idType: '',
|
|
||||||
idNumber: '',
|
|
||||||
nationality: '',
|
|
||||||
primaryPhoneNumber: user?.phoneNumber || '',
|
|
||||||
secondaryPhoneNumber: '',
|
|
||||||
email: user?.email || '',
|
|
||||||
regionId: '',
|
|
||||||
cityId: '',
|
|
||||||
subcityId: '',
|
|
||||||
woredaId: '',
|
|
||||||
kebeleId: '',
|
|
||||||
streetAddress: '',
|
|
||||||
postalAddress: '',
|
|
||||||
emergencyContactName: '',
|
|
||||||
emergencyContactPhone: '',
|
|
||||||
emergencyContactRelation: '',
|
|
||||||
}), [user]);
|
|
||||||
|
|
||||||
const {
|
|
||||||
register: profileRegister,
|
|
||||||
handleSubmit: profileHandleSubmit,
|
|
||||||
formState: { errors: profileErrors },
|
|
||||||
setValue: profileSetValue,
|
|
||||||
watch: profileWatch,
|
|
||||||
trigger: profileTriggerValidation,
|
|
||||||
} = useForm<ProfileValues>({
|
|
||||||
resolver: zodResolver(profileSchema),
|
|
||||||
defaultValues: profileDefaults,
|
|
||||||
});
|
|
||||||
|
|
||||||
const {
|
|
||||||
register: addressRegister,
|
|
||||||
handleSubmit: addressHandleSubmit,
|
|
||||||
formState: { errors: addressErrors },
|
|
||||||
setValue: addressSetValue,
|
|
||||||
watch: addressWatch,
|
|
||||||
trigger: addressTriggerValidation,
|
|
||||||
} = useForm<AddressValues>({
|
|
||||||
resolver: zodResolver(addressSchema),
|
|
||||||
defaultValues: addressDefaults,
|
|
||||||
});
|
|
||||||
|
|
||||||
const onNext = async () => {
|
|
||||||
const valid = await profileTriggerValidation();
|
|
||||||
if (!valid) {
|
|
||||||
// Without this the button silently does nothing, which reads as broken
|
|
||||||
// when the offending field is off-screen.
|
|
||||||
notify.error('Please complete the highlighted fields before continuing.');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
setCompleted((prev) => (prev.includes(active) ? prev : [...prev, active]));
|
|
||||||
setActive((c) => c + 1);
|
|
||||||
};
|
|
||||||
|
|
||||||
const onSubmitAddress = async () => {
|
|
||||||
const valid = await addressTriggerValidation();
|
|
||||||
if (!valid) {
|
|
||||||
notify.error('Please complete the highlighted fields before continuing.');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
setSubmitting(true);
|
|
||||||
try {
|
|
||||||
const pv = profileWatch();
|
|
||||||
const av = addressWatch();
|
|
||||||
const selectedProfessionName = professionNameMap[pv.professionId] ?? '';
|
|
||||||
|
|
||||||
const profileResult = await profileTrigger({
|
|
||||||
url: '/profiles',
|
|
||||||
method: 'POST',
|
|
||||||
body: {
|
|
||||||
userId: user?.id,
|
|
||||||
type: 'SEAFARER',
|
|
||||||
professionId: pv.professionId,
|
|
||||||
firstName: pv.firstName,
|
|
||||||
middleName: pv.middleName,
|
|
||||||
lastName: pv.lastName,
|
|
||||||
gender: pv.gender,
|
|
||||||
dob: pv.dob,
|
|
||||||
pob: pv.pob || undefined,
|
|
||||||
maritalStatus: pv.maritalStatus,
|
|
||||||
},
|
|
||||||
}).unwrap();
|
|
||||||
authStorage.setProfileId(profileResult.id);
|
|
||||||
|
|
||||||
await addressTrigger({
|
|
||||||
url: `/addresss/profile/${profileResult.id}`,
|
|
||||||
method: 'POST',
|
|
||||||
body: {
|
|
||||||
idType: av.idType,
|
|
||||||
idNumber: av.idNumber,
|
|
||||||
nationality: av.nationality,
|
|
||||||
primaryPhoneNumber: av.primaryPhoneNumber,
|
|
||||||
secondaryPhoneNumber: av.secondaryPhoneNumber || undefined,
|
|
||||||
email: av.email || undefined,
|
|
||||||
regionId: av.regionId || undefined,
|
|
||||||
cityId: av.cityId || undefined,
|
|
||||||
subcityId: av.subcityId || undefined,
|
|
||||||
woredaId: av.woredaId || undefined,
|
|
||||||
kebeleId: av.kebeleId || undefined,
|
|
||||||
streetAddress: av.streetAddress || undefined,
|
|
||||||
postalAddress: av.postalAddress || undefined,
|
|
||||||
emergencyContactName: av.emergencyContactName || undefined,
|
|
||||||
emergencyContactPhone: av.emergencyContactPhone || undefined,
|
|
||||||
emergencyContactRelation: av.emergencyContactRelation || undefined,
|
|
||||||
},
|
|
||||||
}).unwrap();
|
|
||||||
|
|
||||||
const me = await meTrigger({ url: '/auth/me', method: 'GET' }).unwrap();
|
|
||||||
dispatch(setUser(me));
|
|
||||||
|
|
||||||
notify.success('Profile setup complete!');
|
|
||||||
navigate('/dashboard');
|
|
||||||
} catch {
|
|
||||||
notify.error('Failed to save profile. Please try again.');
|
|
||||||
} finally {
|
|
||||||
setSubmitting(false);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
if (!user) {
|
|
||||||
return (
|
|
||||||
<Center mih="100vh">
|
|
||||||
<Text c="dimmed">Please log in first.</Text>
|
|
||||||
</Center>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Center mih="100vh" bg="gray.0">
|
|
||||||
<Paper withBorder radius="lg" p="xl" maw={900} w="100%" mx="md">
|
|
||||||
<Stack gap="md">
|
|
||||||
<div>
|
|
||||||
<Title order={3}>Complete Your Profile</Title>
|
|
||||||
<Text fz="sm" c="dimmed">
|
|
||||||
Set up your profile and address to get started
|
|
||||||
</Text>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<StepIndicator active={active} completed={completed} />
|
|
||||||
|
|
||||||
{active === 0 && (
|
|
||||||
<>
|
|
||||||
<Text fw={600} fz="sm" tt="uppercase" c="gray.6" mb="sm">
|
|
||||||
Personal Information
|
|
||||||
</Text>
|
|
||||||
<ProfileFormContent
|
|
||||||
register={profileRegister}
|
|
||||||
errors={profileErrors}
|
|
||||||
setValue={profileSetValue}
|
|
||||||
watch={profileWatch}
|
|
||||||
trigger={profileTriggerValidation}
|
|
||||||
professionsLoading={professionsLoading}
|
|
||||||
professionOptions={professionOptions}
|
|
||||||
/>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{active === 1 && (
|
|
||||||
<>
|
|
||||||
<Text fw={600} fz="sm" tt="uppercase" c="gray.6" mb="sm">
|
|
||||||
Identity & Contact
|
|
||||||
</Text>
|
|
||||||
<AddressFormContent
|
|
||||||
register={addressRegister}
|
|
||||||
errors={addressErrors}
|
|
||||||
setValue={addressSetValue}
|
|
||||||
watch={addressWatch}
|
|
||||||
trigger={addressTriggerValidation}
|
|
||||||
/>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<Group justify="space-between" mt="xl">
|
|
||||||
<Button
|
|
||||||
variant="default"
|
|
||||||
color="gray"
|
|
||||||
leftSection={<IconLogout2 size={16} />}
|
|
||||||
onClick={() => {
|
|
||||||
dispatch(logout());
|
|
||||||
navigate('/login');
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
Sign out
|
|
||||||
</Button>
|
|
||||||
<Group gap="sm">
|
|
||||||
{active > 0 && (
|
|
||||||
<Button
|
|
||||||
variant="default"
|
|
||||||
leftSection={<IconArrowLeft size={16} />}
|
|
||||||
onClick={() => setActive((c) => c - 1)}
|
|
||||||
>
|
|
||||||
Previous
|
|
||||||
</Button>
|
|
||||||
)}
|
|
||||||
{active < STEPS.length - 1 ? (
|
|
||||||
<Button rightSection={<IconArrowRight size={16} />} onClick={onNext}>
|
|
||||||
Next Step
|
|
||||||
</Button>
|
|
||||||
) : (
|
|
||||||
<Button
|
|
||||||
color="blue"
|
|
||||||
leftSection={<IconCircleCheck size={16} />}
|
|
||||||
onClick={onSubmitAddress}
|
|
||||||
loading={submitting}
|
|
||||||
>
|
|
||||||
Complete Setup
|
|
||||||
</Button>
|
|
||||||
)}
|
|
||||||
</Group>
|
|
||||||
</Group>
|
|
||||||
</Stack>
|
|
||||||
</Paper>
|
|
||||||
</Center>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,119 @@
|
|||||||
|
import { useCallback, useState } from 'react';
|
||||||
|
import {
|
||||||
|
ActionIcon,
|
||||||
|
Button,
|
||||||
|
Group,
|
||||||
|
Paper,
|
||||||
|
RingProgress,
|
||||||
|
Stack,
|
||||||
|
Text,
|
||||||
|
Tooltip,
|
||||||
|
} from '@mantine/core';
|
||||||
|
import { IconX } from '@tabler/icons-react';
|
||||||
|
import { useTranslation } from 'react-i18next';
|
||||||
|
import { Link } from 'react-router-dom';
|
||||||
|
import { PROFILE_FIELD_SECTION, useCurrentProfile } from '@ema-platform/auth';
|
||||||
|
|
||||||
|
const DISMISS_KEY = 'ema-portal-profile-nudge-dismissed';
|
||||||
|
|
||||||
|
/** Nothing below this is worth interrupting anyone about. */
|
||||||
|
const NUDGE_THRESHOLD = 100;
|
||||||
|
|
||||||
|
/** How many gaps to name before falling back to a count. */
|
||||||
|
const MAX_LISTED_GAPS = 3;
|
||||||
|
|
||||||
|
function readDismissed(): boolean {
|
||||||
|
try {
|
||||||
|
return localStorage.getItem(DISMISS_KEY) === 'true';
|
||||||
|
} catch {
|
||||||
|
// Private mode — treat as not dismissed rather than hiding the nudge.
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A prompt to finish the profile. Explicitly not a gate.
|
||||||
|
*
|
||||||
|
* The applicant can dismiss it, and dismissing it persists. It never prevents
|
||||||
|
* navigation and never appears on top of anything — replacing the wizard with
|
||||||
|
* a modal would just be the same wall in a smaller box.
|
||||||
|
*/
|
||||||
|
export function ProfileCompletionNudge() {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const { completeness, missing, isLoading } = useCurrentProfile();
|
||||||
|
const [dismissed, setDismissed] = useState(readDismissed);
|
||||||
|
|
||||||
|
const dismiss = useCallback(() => {
|
||||||
|
setDismissed(true);
|
||||||
|
try {
|
||||||
|
localStorage.setItem(DISMISS_KEY, 'true');
|
||||||
|
} catch {
|
||||||
|
// Not persisting a dismissal is a smaller problem than crashing here.
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
if (isLoading || dismissed || completeness >= NUDGE_THRESHOLD) return null;
|
||||||
|
|
||||||
|
const topGaps = missing.slice(0, MAX_LISTED_GAPS);
|
||||||
|
const remaining = missing.length - topGaps.length;
|
||||||
|
const firstSection = topGaps.length ? PROFILE_FIELD_SECTION[topGaps[0]] : 'personal';
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Paper withBorder radius="md" p="md">
|
||||||
|
<Group justify="space-between" wrap="nowrap" align="flex-start">
|
||||||
|
<Group wrap="nowrap" gap="md" align="center">
|
||||||
|
<RingProgress
|
||||||
|
size={64}
|
||||||
|
thickness={6}
|
||||||
|
roundCaps
|
||||||
|
sections={[{ value: completeness, color: 'emaPrimary' }]}
|
||||||
|
label={
|
||||||
|
<Text ta="center" fw={700} size="xs">
|
||||||
|
{completeness}%
|
||||||
|
</Text>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<Stack gap={2}>
|
||||||
|
<Text fw={600} size="sm">
|
||||||
|
{t('profileNudge.title', 'Finish setting up your profile')}
|
||||||
|
</Text>
|
||||||
|
<Text size="xs" c="dimmed">
|
||||||
|
{t('profileNudge.body', {
|
||||||
|
fields: topGaps
|
||||||
|
.map((field) => t(`profileFields.${field}`, { defaultValue: field }))
|
||||||
|
.join(', '),
|
||||||
|
defaultValue: 'Still needed: {{fields}}',
|
||||||
|
})}
|
||||||
|
{remaining > 0 &&
|
||||||
|
` ${t('profileNudge.andMore', {
|
||||||
|
count: remaining,
|
||||||
|
defaultValue: 'and {{count}} more',
|
||||||
|
})}`}
|
||||||
|
</Text>
|
||||||
|
</Stack>
|
||||||
|
</Group>
|
||||||
|
|
||||||
|
<Group gap="xs" wrap="nowrap">
|
||||||
|
<Button
|
||||||
|
component={Link}
|
||||||
|
to={`/profile#${firstSection}`}
|
||||||
|
size="xs"
|
||||||
|
variant="light"
|
||||||
|
>
|
||||||
|
{t('profileNudge.action', 'Complete profile')}
|
||||||
|
</Button>
|
||||||
|
<Tooltip label={t('profileNudge.dismiss', 'Dismiss')}>
|
||||||
|
<ActionIcon
|
||||||
|
variant="subtle"
|
||||||
|
color="gray"
|
||||||
|
onClick={dismiss}
|
||||||
|
aria-label={t('profileNudge.dismiss', 'Dismiss')}
|
||||||
|
>
|
||||||
|
<IconX size={16} />
|
||||||
|
</ActionIcon>
|
||||||
|
</Tooltip>
|
||||||
|
</Group>
|
||||||
|
</Group>
|
||||||
|
</Paper>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,91 @@
|
|||||||
|
import { Alert, Anchor, Button, Group, List, Stack, Text } from '@mantine/core';
|
||||||
|
import { IconInfoCircle } from '@tabler/icons-react';
|
||||||
|
import { useTranslation } from 'react-i18next';
|
||||||
|
import { Link } from 'react-router-dom';
|
||||||
|
import {
|
||||||
|
PROFILE_FIELD_SECTION,
|
||||||
|
useCurrentProfile,
|
||||||
|
type ProfileRequirement,
|
||||||
|
} from '@ema-platform/auth';
|
||||||
|
import type { ReactNode } from 'react';
|
||||||
|
|
||||||
|
interface ProfileRequirementGateProps {
|
||||||
|
requirement: ProfileRequirement;
|
||||||
|
/** Rendered once the requirement is satisfied. */
|
||||||
|
children: ReactNode;
|
||||||
|
/**
|
||||||
|
* When true the children still render alongside the notice. Use for flows
|
||||||
|
* the applicant can keep working through while a detail is outstanding.
|
||||||
|
*/
|
||||||
|
advisory?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Asks for missing profile details in place.
|
||||||
|
*
|
||||||
|
* Deliberately not a redirect. Sending someone to /profile mid-application
|
||||||
|
* loses their work and their place, which is what the old setup wizard did at
|
||||||
|
* a larger scale. This renders an inline card naming exactly which fields are
|
||||||
|
* outstanding and links to the tab that collects them, so the applicant can
|
||||||
|
* fill them in a second tab and come back.
|
||||||
|
*/
|
||||||
|
export function ProfileRequirementGate({
|
||||||
|
requirement,
|
||||||
|
children,
|
||||||
|
advisory = false,
|
||||||
|
}: ProfileRequirementGateProps) {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const { gapsFor, isLoading } = useCurrentProfile();
|
||||||
|
|
||||||
|
// Never block on the resolver: showing the flow and letting submission fail
|
||||||
|
// is better than a spinner over content that is probably fine.
|
||||||
|
if (isLoading) return <>{children}</>;
|
||||||
|
|
||||||
|
const gaps = gapsFor(requirement);
|
||||||
|
if (gaps.length === 0) return <>{children}</>;
|
||||||
|
|
||||||
|
const sections = [...new Set(gaps.map((field) => PROFILE_FIELD_SECTION[field]))];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Stack gap="md">
|
||||||
|
<Alert
|
||||||
|
variant="light"
|
||||||
|
color="blue"
|
||||||
|
icon={<IconInfoCircle size={18} />}
|
||||||
|
title={t('profileGate.title', {
|
||||||
|
count: gaps.length,
|
||||||
|
defaultValue: 'We need {{count}} more detail before you continue',
|
||||||
|
defaultValue_other: 'We need {{count}} more details before you continue',
|
||||||
|
})}
|
||||||
|
>
|
||||||
|
<Stack gap="xs">
|
||||||
|
<Text size="sm">{requirement.reason}</Text>
|
||||||
|
<List size="sm" spacing={2}>
|
||||||
|
{gaps.map((field) => (
|
||||||
|
<List.Item key={field}>
|
||||||
|
{t(`profileFields.${field}`, { defaultValue: field })}
|
||||||
|
</List.Item>
|
||||||
|
))}
|
||||||
|
</List>
|
||||||
|
<Group gap="xs">
|
||||||
|
{sections.map((section) => (
|
||||||
|
<Button
|
||||||
|
key={section}
|
||||||
|
component={Link}
|
||||||
|
to={`/profile#${section}`}
|
||||||
|
size="xs"
|
||||||
|
variant="light"
|
||||||
|
>
|
||||||
|
{t('profileGate.addDetails', 'Add these details')}
|
||||||
|
</Button>
|
||||||
|
))}
|
||||||
|
<Anchor component={Link} to="/profile" size="xs" c="dimmed">
|
||||||
|
{t('profileGate.viewProfile', 'View full profile')}
|
||||||
|
</Anchor>
|
||||||
|
</Group>
|
||||||
|
</Stack>
|
||||||
|
</Alert>
|
||||||
|
{advisory && children}
|
||||||
|
</Stack>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
import { useEffect, useMemo, useRef, useState } from 'react';
|
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||||
import {
|
import {
|
||||||
Badge,
|
Badge,
|
||||||
Box,
|
Box,
|
||||||
@@ -9,6 +9,7 @@ import {
|
|||||||
Loader,
|
Loader,
|
||||||
Paper,
|
Paper,
|
||||||
PasswordInput,
|
PasswordInput,
|
||||||
|
RingProgress,
|
||||||
SimpleGrid,
|
SimpleGrid,
|
||||||
Stack,
|
Stack,
|
||||||
Switch,
|
Switch,
|
||||||
@@ -16,6 +17,7 @@ import {
|
|||||||
Text,
|
Text,
|
||||||
TextInput,
|
TextInput,
|
||||||
Title,
|
Title,
|
||||||
|
Tooltip,
|
||||||
UnstyledButton,
|
UnstyledButton,
|
||||||
useMantineColorScheme,
|
useMantineColorScheme,
|
||||||
type MantineColorScheme,
|
type MantineColorScheme,
|
||||||
@@ -39,13 +41,14 @@ import {
|
|||||||
IconUser,
|
IconUser,
|
||||||
IconUserCircle,
|
IconUserCircle,
|
||||||
} from '@tabler/icons-react';
|
} from '@tabler/icons-react';
|
||||||
|
import { useLocation } from 'react-router-dom';
|
||||||
import { useForm } from 'react-hook-form';
|
import { useForm } from 'react-hook-form';
|
||||||
import { zodResolver } from '@hookform/resolvers/zod';
|
import { zodResolver } from '@hookform/resolvers/zod';
|
||||||
import { z } from 'zod';
|
import { z } from 'zod';
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
import { notify, PageHeader } from '@ema-platform/ui';
|
import { notify, PageHeader } from '@ema-platform/ui';
|
||||||
import { useApiMutation } from '@ema-platform/api';
|
import { useApiMutation } from '@ema-platform/api';
|
||||||
import { authStorage, setUser, setCurrentProfile } from '@ema-platform/auth';
|
import { setUser, useCurrentProfile } from '@ema-platform/auth';
|
||||||
import type { CurrentProfile } from '@ema-platform/auth';
|
import type { CurrentProfile } from '@ema-platform/auth';
|
||||||
import { SUPPORTED_LANGUAGES, type AppLanguage } from '../../../i18n/config';
|
import { SUPPORTED_LANGUAGES, type AppLanguage } from '../../../i18n/config';
|
||||||
import { useAppDispatch, useAppSelector } from '../../../store/hooks';
|
import { useAppDispatch, useAppSelector } from '../../../store/hooks';
|
||||||
@@ -62,6 +65,9 @@ import {
|
|||||||
} from '../components/AddressFormContent';
|
} from '../components/AddressFormContent';
|
||||||
import classes from './ProfilePage.module.css';
|
import classes from './ProfilePage.module.css';
|
||||||
|
|
||||||
|
/** Tab keys addressable via the URL hash. */
|
||||||
|
const VALID_TABS = ['personal', 'profile', 'address', 'security', 'preferences'];
|
||||||
|
|
||||||
function getInitials(name: string, fallback: string) {
|
function getInitials(name: string, fallback: string) {
|
||||||
const source = name?.trim() || fallback?.trim() || '';
|
const source = name?.trim() || fallback?.trim() || '';
|
||||||
if (!source) return '?';
|
if (!source) return '?';
|
||||||
@@ -84,7 +90,7 @@ export function ProfilePage() {
|
|||||||
const { t, i18n } = useTranslation();
|
const { t, i18n } = useTranslation();
|
||||||
const dispatch = useAppDispatch();
|
const dispatch = useAppDispatch();
|
||||||
const user = useAppSelector((state) => state.auth.user);
|
const user = useAppSelector((state) => state.auth.user);
|
||||||
const currentProfile = useAppSelector((state) => state.auth.currentProfile);
|
const storedProfile = useAppSelector((state) => state.auth.currentProfile);
|
||||||
const { colorScheme, setColorScheme } = useMantineColorScheme();
|
const { colorScheme, setColorScheme } = useMantineColorScheme();
|
||||||
|
|
||||||
const [updateTrigger] = useApiMutation<AuthUser>();
|
const [updateTrigger] = useApiMutation<AuthUser>();
|
||||||
@@ -126,8 +132,17 @@ export function ProfilePage() {
|
|||||||
return map;
|
return map;
|
||||||
}, [professions]);
|
}, [professions]);
|
||||||
|
|
||||||
// ---- Profile data (from stored currentProfile) ----
|
// ---- Profile data ----
|
||||||
const [fetchProfile] = useApiMutation<{ total: number; items: CurrentProfile[] }>();
|
// Resolved through `useCurrentProfile`, which provisions a profile if the
|
||||||
|
// user has none. The page used to read an id out of local storage that only
|
||||||
|
// the deleted setup wizard ever wrote, so it rendered an empty form forever
|
||||||
|
// for anyone who signed up after the wizard was removed.
|
||||||
|
const {
|
||||||
|
profile: resolvedProfile,
|
||||||
|
isLoading: profileResolving,
|
||||||
|
completeness,
|
||||||
|
missing,
|
||||||
|
} = useCurrentProfile();
|
||||||
const [updateProfile] = useApiMutation<unknown>();
|
const [updateProfile] = useApiMutation<unknown>();
|
||||||
const [updateAddress] = useApiMutation<unknown>();
|
const [updateAddress] = useApiMutation<unknown>();
|
||||||
|
|
||||||
@@ -136,9 +151,28 @@ export function ProfilePage() {
|
|||||||
const [profileId, setProfileId] = useState<string | null>(null);
|
const [profileId, setProfileId] = useState<string | null>(null);
|
||||||
const [addressId, setAddressId] = useState<string | null>(null);
|
const [addressId, setAddressId] = useState<string | null>(null);
|
||||||
const [dataLoading, setDataLoading] = useState(true);
|
const [dataLoading, setDataLoading] = useState(true);
|
||||||
const profileFetched = useRef(false);
|
|
||||||
|
// Deep links. `useCurrentProfile` reports gaps by section, and the nudge and
|
||||||
|
// requirement gates link straight at them (/profile#address), so the hash
|
||||||
|
// has to select a tab rather than being ignored. Emergency-contact fields
|
||||||
|
// live inside the address form, so both anchors land on that tab.
|
||||||
|
const tabFromHash = useCallback((hash: string) => {
|
||||||
|
const key = hash.replace('#', '');
|
||||||
|
if (key === 'emergency') return 'address';
|
||||||
|
return VALID_TABS.includes(key) ? key : 'personal';
|
||||||
|
}, []);
|
||||||
|
const [activeTab, setActiveTab] = useState(() =>
|
||||||
|
tabFromHash(typeof window === 'undefined' ? '' : window.location.hash),
|
||||||
|
);
|
||||||
|
const { hash } = useLocation();
|
||||||
|
useEffect(() => {
|
||||||
|
setActiveTab(tabFromHash(hash));
|
||||||
|
}, [hash, tabFromHash]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
// Prefer the freshly resolved profile; fall back to whatever the store
|
||||||
|
// already holds so the form does not flash empty on a refetch.
|
||||||
|
const currentProfile = resolvedProfile ?? storedProfile;
|
||||||
if (currentProfile) {
|
if (currentProfile) {
|
||||||
setProfileId(currentProfile.id);
|
setProfileId(currentProfile.id);
|
||||||
setLoadedProfile({
|
setLoadedProfile({
|
||||||
@@ -177,29 +211,12 @@ export function ProfilePage() {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
setDataLoading(false);
|
setDataLoading(false);
|
||||||
} else if (user && !profileFetched.current) {
|
} else if (!profileResolving) {
|
||||||
profileFetched.current = true;
|
// Resolver finished and there is still nothing — render the empty form
|
||||||
const profileId = authStorage.getProfileId();
|
// rather than an indefinite spinner.
|
||||||
if (profileId) {
|
|
||||||
const q = `w=user_id:=:${user.id}&i=user,address,profession`;
|
|
||||||
fetchProfile({ url: `/profiles?q=${encodeURIComponent(q)}`, method: 'GET' })
|
|
||||||
.unwrap()
|
|
||||||
.then((result) => {
|
|
||||||
if (result.total > 0 && result.items.length > 0) {
|
|
||||||
const profile = result.items[0];
|
|
||||||
dispatch(setCurrentProfile(profile));
|
|
||||||
} else {
|
|
||||||
setDataLoading(false);
|
|
||||||
}
|
|
||||||
})
|
|
||||||
.catch(() => setDataLoading(false));
|
|
||||||
} else {
|
|
||||||
setDataLoading(false);
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
setDataLoading(false);
|
setDataLoading(false);
|
||||||
}
|
}
|
||||||
}, [currentProfile, user, fetchProfile, dispatch]);
|
}, [resolvedProfile, storedProfile, profileResolving]);
|
||||||
|
|
||||||
// Load the latest user from the server on mount
|
// Load the latest user from the server on mount
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -209,7 +226,9 @@ export function ProfilePage() {
|
|||||||
.then((me) => {
|
.then((me) => {
|
||||||
if (active) dispatch(setUser(me));
|
if (active) dispatch(setUser(me));
|
||||||
})
|
})
|
||||||
.catch(() => {});
|
.catch(() => {
|
||||||
|
// Best-effort refresh; the store already holds the user from sign-in.
|
||||||
|
});
|
||||||
return () => { active = false; };
|
return () => { active = false; };
|
||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
}, []);
|
}, []);
|
||||||
@@ -449,12 +468,50 @@ export function ProfilePage() {
|
|||||||
{user.username}
|
{user.username}
|
||||||
</Badge>
|
</Badge>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{/* Completeness. Informational only — nothing here blocks the user,
|
||||||
|
it just makes visible what the nudge and the in-flow gates are
|
||||||
|
reacting to. Computed by the API so all three agree. */}
|
||||||
|
<Tooltip
|
||||||
|
label={
|
||||||
|
missing.length
|
||||||
|
? missing
|
||||||
|
.map((field) => t(`profileFields.${field}`, { defaultValue: field }))
|
||||||
|
.join(', ')
|
||||||
|
: t('profileSections.sectionSaved', 'Saved')
|
||||||
|
}
|
||||||
|
multiline
|
||||||
|
w={260}
|
||||||
|
withArrow
|
||||||
|
>
|
||||||
|
<RingProgress
|
||||||
|
size={64}
|
||||||
|
thickness={6}
|
||||||
|
roundCaps
|
||||||
|
sections={[{ value: completeness, color: 'emaPrimary' }]}
|
||||||
|
aria-label={t('profileSections.completeness', {
|
||||||
|
value: completeness,
|
||||||
|
defaultValue: '{{value}}% complete',
|
||||||
|
})}
|
||||||
|
label={
|
||||||
|
<Text ta="center" fw={700} size="xs">
|
||||||
|
{completeness}%
|
||||||
|
</Text>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</Tooltip>
|
||||||
</Group>
|
</Group>
|
||||||
</Paper>
|
</Paper>
|
||||||
|
|
||||||
{/* Tabs */}
|
{/* Tabs */}
|
||||||
<Tabs
|
<Tabs
|
||||||
defaultValue="personal"
|
value={activeTab}
|
||||||
|
onChange={(value) => {
|
||||||
|
const next = value ?? 'personal';
|
||||||
|
setActiveTab(next);
|
||||||
|
// Keep the URL shareable without pushing a history entry per tab.
|
||||||
|
window.history.replaceState(null, '', `#${next}`);
|
||||||
|
}}
|
||||||
variant="pills"
|
variant="pills"
|
||||||
classNames={{ list: classes.list, tab: classes.tab }}
|
classNames={{ list: classes.list, tab: classes.tab }}
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -29,7 +29,6 @@ import {
|
|||||||
IconTransferIn,
|
IconTransferIn,
|
||||||
} from '@tabler/icons-react';
|
} from '@tabler/icons-react';
|
||||||
import { useApiMutation } from '@ema-platform/api';
|
import { useApiMutation } from '@ema-platform/api';
|
||||||
import { authStorage } from '@ema-platform/auth';
|
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// Types
|
// Types
|
||||||
@@ -124,9 +123,12 @@ export function VesselRegistrationPage() {
|
|||||||
const [fetchTrigger] = useApiMutation<VesselRegistration>();
|
const [fetchTrigger] = useApiMutation<VesselRegistration>();
|
||||||
const fetched = useRef(false);
|
const fetched = useRef(false);
|
||||||
|
|
||||||
|
// `/vessel-registrations/my` resolves the owner from the token, so it never
|
||||||
|
// needed a profile id. Gating on one meant anyone who signed up after the
|
||||||
|
// setup wizard was removed — and so had nothing in local storage — silently
|
||||||
|
// never loaded their registration.
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const profileId = authStorage.getProfileId();
|
if (fetched.current) return;
|
||||||
if (!profileId || fetched.current) return;
|
|
||||||
fetched.current = true;
|
fetched.current = true;
|
||||||
fetchTrigger({ url: '/vessel-registrations/my', method: 'GET' })
|
fetchTrigger({ url: '/vessel-registrations/my', method: 'GET' })
|
||||||
.unwrap()
|
.unwrap()
|
||||||
|
|||||||
@@ -89,6 +89,56 @@ export const am: Translations = {
|
|||||||
quickActions: 'ፈጣን ድርጊቶች',
|
quickActions: 'ፈጣን ድርጊቶች',
|
||||||
},
|
},
|
||||||
|
|
||||||
|
/** Field labels shared by the completeness nudge and the requirement gates. */
|
||||||
|
profileFields: {
|
||||||
|
firstName: 'የመጀመሪያ ስም',
|
||||||
|
middleName: 'የአባት ስም',
|
||||||
|
lastName: 'የአያት ስም',
|
||||||
|
gender: 'ጾታ',
|
||||||
|
dob: 'የትውልድ ቀን',
|
||||||
|
pob: 'የትውልድ ቦታ',
|
||||||
|
maritalStatus: 'የጋብቻ ሁኔታ',
|
||||||
|
professionId: 'ሙያ',
|
||||||
|
idType: 'የመታወቂያ ዓይነት',
|
||||||
|
idNumber: 'የመታወቂያ ቁጥር',
|
||||||
|
nationality: 'ዜግነት',
|
||||||
|
primaryPhoneNumber: 'ስልክ ቁጥር',
|
||||||
|
email: 'ኢሜይል አድራሻ',
|
||||||
|
regionId: 'ክልል',
|
||||||
|
cityId: 'ከተማ',
|
||||||
|
subCityId: 'ክፍለ ከተማ',
|
||||||
|
woredaId: 'ወረዳ',
|
||||||
|
streetAddress: 'የመንገድ አድራሻ',
|
||||||
|
emergencyContactName: 'የአደጋ ጊዜ ተጠሪ ስም',
|
||||||
|
emergencyContactPhone: 'የአደጋ ጊዜ ተጠሪ ስልክ',
|
||||||
|
emergencyContactRelation: 'የአደጋ ጊዜ ተጠሪ ዝምድና',
|
||||||
|
},
|
||||||
|
|
||||||
|
profileNudge: {
|
||||||
|
title: 'መገለጫዎን ማጠናቀቅ',
|
||||||
|
body: 'የሚያስፈልጉ፡ {{fields}}',
|
||||||
|
andMore: 'እና ሌሎች {{count}}',
|
||||||
|
action: 'መገለጫ አጠናቅቅ',
|
||||||
|
dismiss: 'አሰናብት',
|
||||||
|
},
|
||||||
|
|
||||||
|
profileGate: {
|
||||||
|
title_one: 'ከመቀጠልዎ በፊት {{count}} ተጨማሪ መረጃ እንፈልጋለን',
|
||||||
|
title_other: 'ከመቀጠልዎ በፊት {{count}} ተጨማሪ መረጃዎች እንፈልጋለን',
|
||||||
|
addDetails: 'እነዚህን መረጃዎች ጨምር',
|
||||||
|
viewProfile: 'ሙሉ መገለጫ ይመልከቱ',
|
||||||
|
},
|
||||||
|
|
||||||
|
profileSections: {
|
||||||
|
personal: 'የግል መረጃ',
|
||||||
|
address: 'መታወቂያ እና አድራሻ',
|
||||||
|
emergency: 'የአደጋ ጊዜ ተጠሪ',
|
||||||
|
documents: 'ሰነዶች',
|
||||||
|
completeness: '{{value}}% ተጠናቋል',
|
||||||
|
saveSection: 'ይህን ክፍል አስቀምጥ',
|
||||||
|
sectionSaved: 'ተቀምጧል',
|
||||||
|
},
|
||||||
|
|
||||||
profile: {
|
profile: {
|
||||||
title: 'መገለጫዬ',
|
title: 'መገለጫዬ',
|
||||||
subtitle: 'የመለያ ዝርዝሮችዎንና ምርጫዎችዎን ያስተዳድሩ።',
|
subtitle: 'የመለያ ዝርዝሮችዎንና ምርጫዎችዎን ያስተዳድሩ።',
|
||||||
|
|||||||
@@ -87,6 +87,56 @@ export const en = {
|
|||||||
quickActions: 'Quick actions',
|
quickActions: 'Quick actions',
|
||||||
},
|
},
|
||||||
|
|
||||||
|
/** Field labels shared by the completeness nudge and the requirement gates. */
|
||||||
|
profileFields: {
|
||||||
|
firstName: 'First name',
|
||||||
|
middleName: 'Middle name',
|
||||||
|
lastName: 'Last name',
|
||||||
|
gender: 'Gender',
|
||||||
|
dob: 'Date of birth',
|
||||||
|
pob: 'Place of birth',
|
||||||
|
maritalStatus: 'Marital status',
|
||||||
|
professionId: 'Profession',
|
||||||
|
idType: 'ID type',
|
||||||
|
idNumber: 'ID number',
|
||||||
|
nationality: 'Nationality',
|
||||||
|
primaryPhoneNumber: 'Phone number',
|
||||||
|
email: 'Email address',
|
||||||
|
regionId: 'Region',
|
||||||
|
cityId: 'City',
|
||||||
|
subCityId: 'Sub-city',
|
||||||
|
woredaId: 'Woreda',
|
||||||
|
streetAddress: 'Street address',
|
||||||
|
emergencyContactName: 'Emergency contact name',
|
||||||
|
emergencyContactPhone: 'Emergency contact phone',
|
||||||
|
emergencyContactRelation: 'Emergency contact relationship',
|
||||||
|
},
|
||||||
|
|
||||||
|
profileNudge: {
|
||||||
|
title: 'Finish setting up your profile',
|
||||||
|
body: 'Still needed: {{fields}}',
|
||||||
|
andMore: 'and {{count}} more',
|
||||||
|
action: 'Complete profile',
|
||||||
|
dismiss: 'Dismiss',
|
||||||
|
},
|
||||||
|
|
||||||
|
profileGate: {
|
||||||
|
title_one: 'We need {{count}} more detail before you continue',
|
||||||
|
title_other: 'We need {{count}} more details before you continue',
|
||||||
|
addDetails: 'Add these details',
|
||||||
|
viewProfile: 'View full profile',
|
||||||
|
},
|
||||||
|
|
||||||
|
profileSections: {
|
||||||
|
personal: 'Personal',
|
||||||
|
address: 'Identity & Address',
|
||||||
|
emergency: 'Emergency Contact',
|
||||||
|
documents: 'Documents',
|
||||||
|
completeness: '{{value}}% complete',
|
||||||
|
saveSection: 'Save this section',
|
||||||
|
sectionSaved: 'Saved',
|
||||||
|
},
|
||||||
|
|
||||||
profile: {
|
profile: {
|
||||||
title: 'My Profile',
|
title: 'My Profile',
|
||||||
subtitle: 'Manage your account details and preferences.',
|
subtitle: 'Manage your account details and preferences.',
|
||||||
|
|||||||
@@ -20,7 +20,7 @@ import { useTranslation } from 'react-i18next';
|
|||||||
import { useDispatch } from 'react-redux';
|
import { useDispatch } from 'react-redux';
|
||||||
import { notify, AppHeader, AppSidebar } from '@ema-platform/ui';
|
import { notify, AppHeader, AppSidebar } from '@ema-platform/ui';
|
||||||
import type { NavItem } from '@ema-platform/ui';
|
import type { NavItem } from '@ema-platform/ui';
|
||||||
import { logout } from '@ema-platform/auth';
|
import { BrandMark, logout } from '@ema-platform/auth';
|
||||||
import { SUPPORTED_LANGUAGES } from '../i18n/config';
|
import { SUPPORTED_LANGUAGES } from '../i18n/config';
|
||||||
import { useAppSelector } from '../store/hooks';
|
import { useAppSelector } from '../store/hooks';
|
||||||
|
|
||||||
@@ -179,6 +179,7 @@ export function PortalLayout() {
|
|||||||
onNavigate={go}
|
onNavigate={go}
|
||||||
brandName={t('app.name')}
|
brandName={t('app.name')}
|
||||||
brandSubtitle={t('app.authority')}
|
brandSubtitle={t('app.authority')}
|
||||||
|
brandLogo={<BrandMark size={32} />}
|
||||||
/>
|
/>
|
||||||
</AppShell.Navbar>
|
</AppShell.Navbar>
|
||||||
|
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ import { useTranslation } from 'react-i18next';
|
|||||||
import { useDispatch } from 'react-redux';
|
import { useDispatch } from 'react-redux';
|
||||||
import { notify, AppHeader, AppSidebar } from '@ema-platform/ui';
|
import { notify, AppHeader, AppSidebar } from '@ema-platform/ui';
|
||||||
import type { NavItem } from '@ema-platform/ui';
|
import type { NavItem } from '@ema-platform/ui';
|
||||||
import { logout } from '@ema-platform/auth';
|
import { BrandMark, logout } from '@ema-platform/auth';
|
||||||
import { SUPPORTED_LANGUAGES } from '../i18n/config';
|
import { SUPPORTED_LANGUAGES } from '../i18n/config';
|
||||||
import { useAppSelector } from '../store/hooks';
|
import { useAppSelector } from '../store/hooks';
|
||||||
|
|
||||||
@@ -66,6 +66,7 @@ export function VesselOwnerLayout() {
|
|||||||
onNavigate={go}
|
onNavigate={go}
|
||||||
brandName="Vessel Owner Portal"
|
brandName="Vessel Owner Portal"
|
||||||
brandSubtitle="EMAA"
|
brandSubtitle="EMAA"
|
||||||
|
brandLogo={<BrandMark size={32} />}
|
||||||
/>
|
/>
|
||||||
</AppShell.Navbar>
|
</AppShell.Navbar>
|
||||||
|
|
||||||
|
|||||||
@@ -7,9 +7,6 @@ import { ProtectedRoute } from './components/ProtectedRoute';
|
|||||||
// Auth (standalone pages, no portal chrome)
|
// Auth (standalone pages, no portal chrome)
|
||||||
import { LoginPage, SignupPage, OTPVerificationPage, ForgotPasswordPage } from '@ema-platform/auth';
|
import { LoginPage, SignupPage, OTPVerificationPage, ForgotPasswordPage } from '@ema-platform/auth';
|
||||||
|
|
||||||
// Profile setup
|
|
||||||
import { ProfileSetupPage } from './features/profile-setup/pages/ProfileSetupPage';
|
|
||||||
|
|
||||||
// Portal feature pages
|
// Portal feature pages
|
||||||
import { DashboardPage } from './features/dashboard/pages/DashboardPage';
|
import { DashboardPage } from './features/dashboard/pages/DashboardPage';
|
||||||
import { ProfilePage } from './features/profile/pages/ProfilePage';
|
import { ProfilePage } from './features/profile/pages/ProfilePage';
|
||||||
@@ -59,16 +56,13 @@ export const router = createBrowserRouter([
|
|||||||
element: <ProtectedRoute><ForgotPasswordPage /></ProtectedRoute>,
|
element: <ProtectedRoute><ForgotPasswordPage /></ProtectedRoute>,
|
||||||
path: '/forgot-password',
|
path: '/forgot-password',
|
||||||
},
|
},
|
||||||
{
|
// The two-step setup wizard is gone. Signing up lands on the dashboard, and
|
||||||
element: <ProtectedRoute><ProfileSetupPage /></ProtectedRoute>,
|
// profile details are collected where they are actually needed: on /profile,
|
||||||
path: '/profile-setup',
|
// via the dashboard nudge, or inline in an application flow. The path stays
|
||||||
},
|
// as a redirect so existing bookmarks and emailed links do not 404.
|
||||||
|
{ path: '/profile-setup', element: <Navigate to="/profile" replace /> },
|
||||||
|
|
||||||
// Portal — protected.
|
// Portal — protected.
|
||||||
// Profile setup is deliberately not enforced: applicants go straight to the
|
|
||||||
// portal after signing up and supply whatever a given licence type asks for
|
|
||||||
// as part of that application. `/profile-setup` stays reachable for the
|
|
||||||
// seafarer features, which do need a profile record.
|
|
||||||
{
|
{
|
||||||
element: (
|
element: (
|
||||||
<ProtectedRoute>
|
<ProtectedRoute>
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { FlatCompat } from '@eslint/eslintrc';
|
import { FlatCompat } from '@eslint/eslintrc';
|
||||||
import nxEslintPlugin from '@nx/eslint-plugin';
|
import nxEslintPlugin from '@nx/eslint-plugin';
|
||||||
|
import reactHooks from 'eslint-plugin-react-hooks';
|
||||||
import js from '@eslint/js';
|
import js from '@eslint/js';
|
||||||
|
|
||||||
const compat = new FlatCompat({
|
const compat = new FlatCompat({
|
||||||
@@ -24,6 +25,18 @@ export default [
|
|||||||
],
|
],
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
// Source files carry `eslint-disable-next-line react-hooks/exhaustive-deps`
|
||||||
|
// comments, but the plugin was never registered — so every one of them was
|
||||||
|
// an error ("Definition for rule ... was not found") rather than a
|
||||||
|
// suppression, and no hook was ever actually checked.
|
||||||
|
{
|
||||||
|
files: ['**/*.tsx', '**/*.jsx'],
|
||||||
|
plugins: { 'react-hooks': reactHooks },
|
||||||
|
rules: {
|
||||||
|
'react-hooks/rules-of-hooks': 'error',
|
||||||
|
'react-hooks/exhaustive-deps': 'warn',
|
||||||
|
},
|
||||||
|
},
|
||||||
...compat.config({
|
...compat.config({
|
||||||
extends: ['plugin:@nx/typescript'],
|
extends: ['plugin:@nx/typescript'],
|
||||||
}).map((c) => ({ ...c, files: ['**/*.ts', '**/*.tsx'] })),
|
}).map((c) => ({ ...c, files: ['**/*.ts', '**/*.tsx'] })),
|
||||||
|
|||||||
@@ -14,10 +14,44 @@ import type {
|
|||||||
LicenseStatus,
|
LicenseStatus,
|
||||||
LicenseType,
|
LicenseType,
|
||||||
LicenseTypeRequirements,
|
LicenseTypeRequirements,
|
||||||
|
AssignableOfficer,
|
||||||
|
DocumentDecision,
|
||||||
|
DocumentReview,
|
||||||
|
ExportResult,
|
||||||
|
LicenseTemplate,
|
||||||
Paginated,
|
Paginated,
|
||||||
|
QueueCounts,
|
||||||
|
QueueFilter,
|
||||||
RemarkTargetType,
|
RemarkTargetType,
|
||||||
|
SavedQueueView,
|
||||||
|
TemplatePageOptions,
|
||||||
|
TemplateVariable,
|
||||||
} from './licensing.types';
|
} from './licensing.types';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Drops empty filters and flattens the status array.
|
||||||
|
*
|
||||||
|
* The grid keeps every facet in one object and serialises it to the URL, so
|
||||||
|
* cleared facets arrive as undefined/[]; sending those verbatim would put
|
||||||
|
* `status=` and `assignee=` on the wire and fail DTO validation.
|
||||||
|
*/
|
||||||
|
function serialiseQueueFilter(
|
||||||
|
filter: QueueFilter | void,
|
||||||
|
): Record<string, unknown> {
|
||||||
|
if (!filter) return {};
|
||||||
|
const params: Record<string, unknown> = {};
|
||||||
|
for (const [key, value] of Object.entries(filter)) {
|
||||||
|
if (value === undefined || value === null || value === '') continue;
|
||||||
|
if (Array.isArray(value)) {
|
||||||
|
if (value.length === 0) continue;
|
||||||
|
params[key] = value.join(',');
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
params[key] = value;
|
||||||
|
}
|
||||||
|
return params;
|
||||||
|
}
|
||||||
|
|
||||||
const TAGS = [
|
const TAGS = [
|
||||||
'LicenseType',
|
'LicenseType',
|
||||||
'LicenseApplication',
|
'LicenseApplication',
|
||||||
@@ -26,6 +60,8 @@ const TAGS = [
|
|||||||
'Notification',
|
'Notification',
|
||||||
'Inspection',
|
'Inspection',
|
||||||
'License',
|
'License',
|
||||||
|
'SavedView',
|
||||||
|
'LicenseTemplate',
|
||||||
] as const;
|
] as const;
|
||||||
|
|
||||||
const listTag = (type: (typeof TAGS)[number]) => ({ type, id: 'LIST' }) as const;
|
const listTag = (type: (typeof TAGS)[number]) => ({ type, id: 'LIST' }) as const;
|
||||||
@@ -86,6 +122,20 @@ export const licensingApi = baseApi
|
|||||||
error ? [] : [listTag('LicenseType'), itemTag('LicenseType', id)],
|
error ? [] : [listTag('LicenseType'), itemTag('LicenseType', id)],
|
||||||
}),
|
}),
|
||||||
|
|
||||||
|
/** Validity is edited beside the certificate design, not with the fees. */
|
||||||
|
updateLicenseValidity: builder.mutation<
|
||||||
|
LicenseType,
|
||||||
|
{ id: string; validityMonths: number }
|
||||||
|
>({
|
||||||
|
query: ({ id, validityMonths }) => ({
|
||||||
|
url: `/license-types/${id}/validity`,
|
||||||
|
method: 'PATCH',
|
||||||
|
body: { validityMonths },
|
||||||
|
}),
|
||||||
|
invalidatesTags: (_r, error, { id }) =>
|
||||||
|
error ? [] : [itemTag('LicenseType', id), listTag('LicenseType')],
|
||||||
|
}),
|
||||||
|
|
||||||
getLicenseTypeRequirements: builder.query<
|
getLicenseTypeRequirements: builder.query<
|
||||||
LicenseTypeRequirements,
|
LicenseTypeRequirements,
|
||||||
{ idOrKey: string; kind?: ApplicationKind }
|
{ idOrKey: string; kind?: ApplicationKind }
|
||||||
@@ -234,30 +284,82 @@ export const licensingApi = baseApi
|
|||||||
providesTags: () => [listTag('License')],
|
providesTags: () => [listTag('License')],
|
||||||
}),
|
}),
|
||||||
|
|
||||||
|
suspendLicense: builder.mutation<IssuedLicense, { id: string; reason: string }>({
|
||||||
|
query: ({ id, reason }) => ({
|
||||||
|
url: `/licenses/${id}/suspend`,
|
||||||
|
method: 'POST',
|
||||||
|
body: { reason },
|
||||||
|
}),
|
||||||
|
invalidatesTags: (_r, error, { id }) =>
|
||||||
|
error ? [] : [itemTag('License', id), listTag('License')],
|
||||||
|
}),
|
||||||
|
|
||||||
|
revokeLicense: builder.mutation<IssuedLicense, { id: string; reason: string }>({
|
||||||
|
query: ({ id, reason }) => ({
|
||||||
|
url: `/licenses/${id}/revoke`,
|
||||||
|
method: 'POST',
|
||||||
|
body: { reason },
|
||||||
|
}),
|
||||||
|
invalidatesTags: (_r, error, { id }) =>
|
||||||
|
error ? [] : [itemTag('License', id), listTag('License')],
|
||||||
|
}),
|
||||||
|
|
||||||
|
reinstateLicense: builder.mutation<IssuedLicense, { id: string; reason: string }>({
|
||||||
|
query: ({ id, reason }) => ({
|
||||||
|
url: `/licenses/${id}/reinstate`,
|
||||||
|
method: 'POST',
|
||||||
|
body: { reason },
|
||||||
|
}),
|
||||||
|
invalidatesTags: (_r, error, { id }) =>
|
||||||
|
error ? [] : [itemTag('License', id), listTag('License')],
|
||||||
|
}),
|
||||||
|
|
||||||
getCertificateUrl: builder.mutation<{ url: string }, string>({
|
getCertificateUrl: builder.mutation<{ url: string }, string>({
|
||||||
query: (id) => ({ url: `/licenses/${id}/certificate` }),
|
query: (id) => ({ url: `/licenses/${id}/certificate` }),
|
||||||
}),
|
}),
|
||||||
|
|
||||||
// ------------------------------------------------------------- review
|
// ------------------------------------------------------------- review
|
||||||
getQueue: builder.query<
|
getQueue: builder.query<Paginated<LicenseApplication>, QueueFilter | void>({
|
||||||
Paginated<LicenseApplication>,
|
query: (params) => ({
|
||||||
{ licenseTypeId?: string; search?: string } | void
|
url: '/license-application-review/queue',
|
||||||
>({
|
params: serialiseQueueFilter(params),
|
||||||
query: (params) => ({ url: '/license-application-review/queue', params: params ?? {} }),
|
}),
|
||||||
providesTags: () => [listTag('ApplicationQueue')],
|
providesTags: () => [listTag('ApplicationQueue')],
|
||||||
}),
|
}),
|
||||||
|
|
||||||
getAssignedToMe: builder.query<
|
getAssignedToMe: builder.query<
|
||||||
Paginated<LicenseApplication>,
|
Paginated<LicenseApplication>,
|
||||||
{ licenseTypeId?: string; search?: string } | void
|
QueueFilter | void
|
||||||
>({
|
>({
|
||||||
query: (params) => ({
|
query: (params) => ({
|
||||||
url: '/license-application-review/assigned-to-me',
|
url: '/license-application-review/assigned-to-me',
|
||||||
params: params ?? {},
|
params: serialiseQueueFilter(params),
|
||||||
}),
|
}),
|
||||||
providesTags: () => [listTag('ApplicationQueue')],
|
providesTags: () => [listTag('ApplicationQueue')],
|
||||||
}),
|
}),
|
||||||
|
|
||||||
|
/** Every non-draft application — backs the grid's "All" view. */
|
||||||
|
getAllApplications: builder.query<
|
||||||
|
Paginated<LicenseApplication>,
|
||||||
|
QueueFilter | void
|
||||||
|
>({
|
||||||
|
query: (params) => ({
|
||||||
|
url: '/license-application-review/all',
|
||||||
|
params: serialiseQueueFilter(params),
|
||||||
|
}),
|
||||||
|
providesTags: () => [listTag('ApplicationQueue')],
|
||||||
|
}),
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Counts for the saved-view tabs. Shares the ApplicationQueue tag, so a
|
||||||
|
* claim or a decision refreshes the badges along with the list and the
|
||||||
|
* numbers can never drift from what the grid is showing.
|
||||||
|
*/
|
||||||
|
getQueueCounts: builder.query<QueueCounts, void>({
|
||||||
|
query: () => ({ url: '/license-application-review/counts' }),
|
||||||
|
providesTags: () => [listTag('ApplicationQueue')],
|
||||||
|
}),
|
||||||
|
|
||||||
getApplicationForReview: builder.query<ApplicationDetail, string>({
|
getApplicationForReview: builder.query<ApplicationDetail, string>({
|
||||||
query: (id) => ({ url: `/license-application-review/${id}` }),
|
query: (id) => ({ url: `/license-application-review/${id}` }),
|
||||||
providesTags: (_r, _e, id) => [itemTag('LicenseApplication', id)],
|
providesTags: (_r, _e, id) => [itemTag('LicenseApplication', id)],
|
||||||
@@ -288,6 +390,8 @@ export const licensingApi = baseApi
|
|||||||
id: string;
|
id: string;
|
||||||
generalRemark?: string;
|
generalRemark?: string;
|
||||||
items: { targetType: RemarkTargetType; targetKey: string; remark: string }[];
|
items: { targetType: RemarkTargetType; targetKey: string; remark: string }[];
|
||||||
|
/** Officer-edited wording for the applicant notification. */
|
||||||
|
notificationBody?: string;
|
||||||
}
|
}
|
||||||
>({
|
>({
|
||||||
query: ({ id, ...body }) => ({
|
query: ({ id, ...body }) => ({
|
||||||
@@ -327,16 +431,213 @@ export const licensingApi = baseApi
|
|||||||
error ? [] : [itemTag('LicenseApplication', id), listTag('ApplicationQueue')],
|
error ? [] : [itemTag('LicenseApplication', id), listTag('ApplicationQueue')],
|
||||||
}),
|
}),
|
||||||
|
|
||||||
rejectApplication: builder.mutation<LicenseApplication, { id: string; reason: string }>({
|
rejectApplication: builder.mutation<
|
||||||
query: ({ id, reason }) => ({
|
LicenseApplication,
|
||||||
|
{ id: string; reason: string; notificationBody?: string }
|
||||||
|
>({
|
||||||
|
query: ({ id, ...body }) => ({
|
||||||
url: `/license-application-review/${id}/reject`,
|
url: `/license-application-review/${id}/reject`,
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
|
body,
|
||||||
|
}),
|
||||||
|
invalidatesTags: (_r, error, { id }) =>
|
||||||
|
error ? [] : [itemTag('LicenseApplication', id), listTag('ApplicationQueue')],
|
||||||
|
}),
|
||||||
|
|
||||||
|
// ------------------------------------------------- certificate designs
|
||||||
|
getLicenseTemplates: builder.query<LicenseTemplate[], string | void>({
|
||||||
|
query: (licenseTypeId) => ({
|
||||||
|
url: '/license-templates',
|
||||||
|
params: licenseTypeId ? { licenseTypeId } : {},
|
||||||
|
}),
|
||||||
|
providesTags: () => [listTag('LicenseTemplate')],
|
||||||
|
}),
|
||||||
|
|
||||||
|
getTemplateVariables: builder.query<TemplateVariable[], void>({
|
||||||
|
query: () => ({ url: '/license-templates/variables' }),
|
||||||
|
}),
|
||||||
|
|
||||||
|
getBuiltInTemplate: builder.query<{ hbsSource: string }, void>({
|
||||||
|
query: () => ({ url: '/license-templates/built-in' }),
|
||||||
|
}),
|
||||||
|
|
||||||
|
createLicenseTemplate: builder.mutation<
|
||||||
|
LicenseTemplate,
|
||||||
|
{
|
||||||
|
licenseTypeId: string;
|
||||||
|
name: string;
|
||||||
|
hbsSource?: string;
|
||||||
|
pageOptions?: TemplatePageOptions;
|
||||||
|
}
|
||||||
|
>({
|
||||||
|
query: (body) => ({ url: '/license-templates', method: 'POST', body }),
|
||||||
|
invalidatesTags: (_r, error) => (error ? [] : [listTag('LicenseTemplate')]),
|
||||||
|
}),
|
||||||
|
|
||||||
|
updateLicenseTemplate: builder.mutation<
|
||||||
|
LicenseTemplate,
|
||||||
|
{
|
||||||
|
id: string;
|
||||||
|
name?: string;
|
||||||
|
hbsSource?: string;
|
||||||
|
pageOptions?: TemplatePageOptions;
|
||||||
|
}
|
||||||
|
>({
|
||||||
|
query: ({ id, ...body }) => ({
|
||||||
|
url: `/license-templates/${id}`,
|
||||||
|
method: 'PATCH',
|
||||||
|
body,
|
||||||
|
}),
|
||||||
|
invalidatesTags: (_r, error) => (error ? [] : [listTag('LicenseTemplate')]),
|
||||||
|
}),
|
||||||
|
|
||||||
|
publishLicenseTemplate: builder.mutation<LicenseTemplate, string>({
|
||||||
|
query: (id) => ({ url: `/license-templates/${id}/publish`, method: 'POST' }),
|
||||||
|
// The previously published design is archived by the same call, so the
|
||||||
|
// whole list is refetched rather than patching two rows by hand.
|
||||||
|
invalidatesTags: (_r, error) => (error ? [] : [listTag('LicenseTemplate')]),
|
||||||
|
}),
|
||||||
|
|
||||||
|
archiveLicenseTemplate: builder.mutation<LicenseTemplate, string>({
|
||||||
|
query: (id) => ({ url: `/license-templates/${id}/archive`, method: 'POST' }),
|
||||||
|
invalidatesTags: (_r, error) => (error ? [] : [listTag('LicenseTemplate')]),
|
||||||
|
}),
|
||||||
|
|
||||||
|
deleteLicenseTemplate: builder.mutation<unknown, string>({
|
||||||
|
query: (id) => ({ url: `/license-templates/${id}`, method: 'DELETE' }),
|
||||||
|
invalidatesTags: (_r, error) => (error ? [] : [listTag('LicenseTemplate')]),
|
||||||
|
}),
|
||||||
|
|
||||||
|
// ------------------------------------------------------ document review
|
||||||
|
getDocumentReviews: builder.query<DocumentReview[], string>({
|
||||||
|
query: (id) => ({ url: `/license-application-review/${id}/document-reviews` }),
|
||||||
|
providesTags: (_r, _e, id) => [itemTag('LicenseApplication', id)],
|
||||||
|
}),
|
||||||
|
|
||||||
|
reviewDocument: builder.mutation<
|
||||||
|
DocumentReview,
|
||||||
|
{
|
||||||
|
id: string;
|
||||||
|
documentKey: string;
|
||||||
|
decision: DocumentDecision;
|
||||||
|
reason?: string;
|
||||||
|
attachmentId?: string;
|
||||||
|
}
|
||||||
|
>({
|
||||||
|
query: ({ id, documentKey, ...body }) => ({
|
||||||
|
url: `/license-application-review/${id}/documents/${encodeURIComponent(documentKey)}/review`,
|
||||||
|
method: 'POST',
|
||||||
|
body,
|
||||||
|
}),
|
||||||
|
invalidatesTags: (_r, error, { id }) =>
|
||||||
|
error ? [] : [itemTag('LicenseApplication', id)],
|
||||||
|
}),
|
||||||
|
|
||||||
|
clearDocumentReview: builder.mutation<unknown, { id: string; documentKey: string }>({
|
||||||
|
query: ({ id, documentKey }) => ({
|
||||||
|
url: `/license-application-review/${id}/documents/${encodeURIComponent(documentKey)}/review`,
|
||||||
|
method: 'DELETE',
|
||||||
|
}),
|
||||||
|
invalidatesTags: (_r, error, { id }) =>
|
||||||
|
error ? [] : [itemTag('LicenseApplication', id)],
|
||||||
|
}),
|
||||||
|
|
||||||
|
// ----------------------------------------------------------- saved views
|
||||||
|
getSavedViews: builder.query<SavedQueueView[], void>({
|
||||||
|
query: () => ({ url: '/license-application-review/saved-views' }),
|
||||||
|
providesTags: () => [listTag('SavedView')],
|
||||||
|
}),
|
||||||
|
|
||||||
|
createSavedView: builder.mutation<
|
||||||
|
SavedQueueView,
|
||||||
|
{ name: string; queryString: string; isShared?: boolean; isDefault?: boolean }
|
||||||
|
>({
|
||||||
|
query: (body) => ({
|
||||||
|
url: '/license-application-review/saved-views',
|
||||||
|
method: 'POST',
|
||||||
|
body,
|
||||||
|
}),
|
||||||
|
invalidatesTags: (_r, error) => (error ? [] : [listTag('SavedView')]),
|
||||||
|
}),
|
||||||
|
|
||||||
|
deleteSavedView: builder.mutation<unknown, string>({
|
||||||
|
query: (viewId) => ({
|
||||||
|
url: `/license-application-review/saved-views/${viewId}`,
|
||||||
|
method: 'DELETE',
|
||||||
|
}),
|
||||||
|
invalidatesTags: (_r, error) => (error ? [] : [listTag('SavedView')]),
|
||||||
|
}),
|
||||||
|
|
||||||
|
// ------------------------------------------------------------- export
|
||||||
|
/**
|
||||||
|
* The whole filtered result set, not the current page. Lazy so it only
|
||||||
|
* runs when the officer actually asks for a file.
|
||||||
|
*/
|
||||||
|
exportApplications: builder.query<ExportResult, QueueFilter | void>({
|
||||||
|
query: (params) => ({
|
||||||
|
url: '/license-application-review/export',
|
||||||
|
params: serialiseQueueFilter(params),
|
||||||
|
}),
|
||||||
|
}),
|
||||||
|
|
||||||
|
/** Officers the Assign and Escalate dialogs can offer. */
|
||||||
|
getAssignableOfficers: builder.query<AssignableOfficer[], void>({
|
||||||
|
query: () => ({ url: '/license-application-review/officers' }),
|
||||||
|
}),
|
||||||
|
|
||||||
|
// ----------------------------------------------------- workflow controls
|
||||||
|
assignApplication: builder.mutation<
|
||||||
|
LicenseApplication,
|
||||||
|
{ id: string; officerId: string; remark?: string }
|
||||||
|
>({
|
||||||
|
query: ({ id, ...body }) => ({
|
||||||
|
url: `/license-application-review/${id}/assign`,
|
||||||
|
method: 'POST',
|
||||||
|
body,
|
||||||
|
}),
|
||||||
|
invalidatesTags: (_r, error, { id }) =>
|
||||||
|
error ? [] : [itemTag('LicenseApplication', id), listTag('ApplicationQueue')],
|
||||||
|
}),
|
||||||
|
|
||||||
|
holdApplication: builder.mutation<
|
||||||
|
LicenseApplication,
|
||||||
|
{ id: string; reason: string }
|
||||||
|
>({
|
||||||
|
query: ({ id, reason }) => ({
|
||||||
|
url: `/license-application-review/${id}/hold`,
|
||||||
|
method: 'POST',
|
||||||
body: { reason },
|
body: { reason },
|
||||||
}),
|
}),
|
||||||
invalidatesTags: (_r, error, { id }) =>
|
invalidatesTags: (_r, error, { id }) =>
|
||||||
error ? [] : [itemTag('LicenseApplication', id), listTag('ApplicationQueue')],
|
error ? [] : [itemTag('LicenseApplication', id), listTag('ApplicationQueue')],
|
||||||
}),
|
}),
|
||||||
|
|
||||||
|
resumeApplication: builder.mutation<
|
||||||
|
LicenseApplication,
|
||||||
|
{ id: string; remark?: string }
|
||||||
|
>({
|
||||||
|
query: ({ id, remark }) => ({
|
||||||
|
url: `/license-application-review/${id}/resume`,
|
||||||
|
method: 'POST',
|
||||||
|
body: { remark },
|
||||||
|
}),
|
||||||
|
invalidatesTags: (_r, error, { id }) =>
|
||||||
|
error ? [] : [itemTag('LicenseApplication', id), listTag('ApplicationQueue')],
|
||||||
|
}),
|
||||||
|
|
||||||
|
escalateApplication: builder.mutation<
|
||||||
|
LicenseApplication,
|
||||||
|
{ id: string; supervisorId: string; reason: string }
|
||||||
|
>({
|
||||||
|
query: ({ id, ...body }) => ({
|
||||||
|
url: `/license-application-review/${id}/escalate`,
|
||||||
|
method: 'POST',
|
||||||
|
body,
|
||||||
|
}),
|
||||||
|
invalidatesTags: (_r, error, { id }) =>
|
||||||
|
error ? [] : [itemTag('LicenseApplication', id), listTag('ApplicationQueue')],
|
||||||
|
}),
|
||||||
|
|
||||||
confirmPayment: builder.mutation<LicenseApplication, string>({
|
confirmPayment: builder.mutation<LicenseApplication, string>({
|
||||||
query: (id) => ({
|
query: (id) => ({
|
||||||
url: `/license-application-review/${id}/confirm-payment`,
|
url: `/license-application-review/${id}/confirm-payment`,
|
||||||
@@ -397,6 +698,7 @@ export const {
|
|||||||
useGetLicenseTypesQuery,
|
useGetLicenseTypesQuery,
|
||||||
useGetLicenseCategoriesQuery,
|
useGetLicenseCategoriesQuery,
|
||||||
useUpdateLicenseFeesMutation,
|
useUpdateLicenseFeesMutation,
|
||||||
|
useUpdateLicenseValidityMutation,
|
||||||
useGetLicenseTypeRequirementsQuery,
|
useGetLicenseTypeRequirementsQuery,
|
||||||
useCreateApplicationMutation,
|
useCreateApplicationMutation,
|
||||||
useGetMyApplicationsQuery,
|
useGetMyApplicationsQuery,
|
||||||
@@ -417,6 +719,31 @@ export const {
|
|||||||
useDeleteAttachmentMutation,
|
useDeleteAttachmentMutation,
|
||||||
useGetQueueQuery,
|
useGetQueueQuery,
|
||||||
useGetAssignedToMeQuery,
|
useGetAssignedToMeQuery,
|
||||||
|
useGetAllApplicationsQuery,
|
||||||
|
useGetQueueCountsQuery,
|
||||||
|
useGetLicenseTemplatesQuery,
|
||||||
|
useGetTemplateVariablesQuery,
|
||||||
|
useGetBuiltInTemplateQuery,
|
||||||
|
useCreateLicenseTemplateMutation,
|
||||||
|
useUpdateLicenseTemplateMutation,
|
||||||
|
usePublishLicenseTemplateMutation,
|
||||||
|
useArchiveLicenseTemplateMutation,
|
||||||
|
useDeleteLicenseTemplateMutation,
|
||||||
|
useGetDocumentReviewsQuery,
|
||||||
|
useReviewDocumentMutation,
|
||||||
|
useClearDocumentReviewMutation,
|
||||||
|
useGetSavedViewsQuery,
|
||||||
|
useCreateSavedViewMutation,
|
||||||
|
useDeleteSavedViewMutation,
|
||||||
|
useLazyExportApplicationsQuery,
|
||||||
|
useGetAssignableOfficersQuery,
|
||||||
|
useSuspendLicenseMutation,
|
||||||
|
useRevokeLicenseMutation,
|
||||||
|
useReinstateLicenseMutation,
|
||||||
|
useAssignApplicationMutation,
|
||||||
|
useHoldApplicationMutation,
|
||||||
|
useResumeApplicationMutation,
|
||||||
|
useEscalateApplicationMutation,
|
||||||
useGetApplicationForReviewQuery,
|
useGetApplicationForReviewQuery,
|
||||||
useClaimApplicationMutation,
|
useClaimApplicationMutation,
|
||||||
useCompleteReviewMutation,
|
useCompleteReviewMutation,
|
||||||
|
|||||||
@@ -57,6 +57,7 @@ export const STATUS_LABELS: Record<LicenseStatus, string> = {
|
|||||||
INSPECTION_COMPLETED: 'Inspection Completed',
|
INSPECTION_COMPLETED: 'Inspection Completed',
|
||||||
APPROVED: 'Approved',
|
APPROVED: 'Approved',
|
||||||
REJECTED: 'Rejected',
|
REJECTED: 'Rejected',
|
||||||
|
ON_HOLD: 'On Hold',
|
||||||
PAYMENT_PENDING: 'Payment Pending',
|
PAYMENT_PENDING: 'Payment Pending',
|
||||||
PAID: 'Paid',
|
PAID: 'Paid',
|
||||||
PAYMENT_CONFIRMED: 'Preparing Certificate',
|
PAYMENT_CONFIRMED: 'Preparing Certificate',
|
||||||
@@ -75,6 +76,7 @@ export const STATUS_COLORS: Record<LicenseStatus, string> = {
|
|||||||
INSPECTION_COMPLETED: 'cyan',
|
INSPECTION_COMPLETED: 'cyan',
|
||||||
APPROVED: 'teal',
|
APPROVED: 'teal',
|
||||||
REJECTED: 'red',
|
REJECTED: 'red',
|
||||||
|
ON_HOLD: 'gray',
|
||||||
PAYMENT_PENDING: 'yellow',
|
PAYMENT_PENDING: 'yellow',
|
||||||
PAID: 'lime',
|
PAID: 'lime',
|
||||||
PAYMENT_CONFIRMED: 'teal',
|
PAYMENT_CONFIRMED: 'teal',
|
||||||
@@ -97,6 +99,8 @@ export const STATUS_PROGRESS: Record<LicenseStatus, number> = {
|
|||||||
INSPECTION_PENDING: 55,
|
INSPECTION_PENDING: 55,
|
||||||
INSPECTION_COMPLETED: 65,
|
INSPECTION_COMPLETED: 65,
|
||||||
APPROVED: 75,
|
APPROVED: 75,
|
||||||
|
// Parked, so it keeps the progress of wherever it was held from.
|
||||||
|
ON_HOLD: 45,
|
||||||
PAYMENT_PENDING: 80,
|
PAYMENT_PENDING: 80,
|
||||||
PAID: 88,
|
PAID: 88,
|
||||||
PAYMENT_CONFIRMED: 94,
|
PAYMENT_CONFIRMED: 94,
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ export type LicenseStatus =
|
|||||||
| 'INSPECTION_COMPLETED'
|
| 'INSPECTION_COMPLETED'
|
||||||
| 'APPROVED'
|
| 'APPROVED'
|
||||||
| 'REJECTED'
|
| 'REJECTED'
|
||||||
|
| 'ON_HOLD'
|
||||||
| 'PAYMENT_PENDING'
|
| 'PAYMENT_PENDING'
|
||||||
| 'PAID'
|
| 'PAID'
|
||||||
| 'PAYMENT_CONFIRMED'
|
| 'PAYMENT_CONFIRMED'
|
||||||
@@ -97,6 +98,11 @@ export interface LicenseType {
|
|||||||
feeCurrency: string;
|
feeCurrency: string;
|
||||||
capitalThreshold: string | number | null;
|
capitalThreshold: string | number | null;
|
||||||
validityMonths: number;
|
validityMonths: number;
|
||||||
|
/**
|
||||||
|
* Target turnaround in hours. Null means this type is not tracked against
|
||||||
|
* an SLA, which the grid renders as "—" rather than as instantly overdue.
|
||||||
|
*/
|
||||||
|
slaHours: number | null;
|
||||||
inspectionRequired: boolean;
|
inspectionRequired: boolean;
|
||||||
issuesCertificate: boolean;
|
issuesCertificate: boolean;
|
||||||
renewalEnabled: boolean;
|
renewalEnabled: boolean;
|
||||||
@@ -202,6 +208,8 @@ export interface Attachment {
|
|||||||
validFrom: string | null;
|
validFrom: string | null;
|
||||||
validTo: string | null;
|
validTo: string | null;
|
||||||
files: AttachmentFile[];
|
files: AttachmentFile[];
|
||||||
|
/** From BaseEntity. Used to place the upload on the activity trail. */
|
||||||
|
createdAt?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface StatusHistoryEntry {
|
export interface StatusHistoryEntry {
|
||||||
@@ -264,6 +272,105 @@ export interface AppNotification {
|
|||||||
createdAt: string;
|
createdAt: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Server-side queue filters. Mirrors ApplicationQueueFilterDto in the API. */
|
||||||
|
export interface QueueFilter {
|
||||||
|
licenseTypeId?: string;
|
||||||
|
search?: string;
|
||||||
|
status?: LicenseStatus[];
|
||||||
|
/** Officer uuid, or the literal 'unassigned'. */
|
||||||
|
assignee?: string;
|
||||||
|
submittedFrom?: string;
|
||||||
|
submittedTo?: string;
|
||||||
|
overdue?: boolean;
|
||||||
|
sortBy?: QueueSortField;
|
||||||
|
sortDir?: 'ASC' | 'DESC';
|
||||||
|
take?: number;
|
||||||
|
skip?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type QueueSortField =
|
||||||
|
| 'submittedAt'
|
||||||
|
| 'applicationNumber'
|
||||||
|
| 'companyName'
|
||||||
|
| 'status'
|
||||||
|
| 'dueAt';
|
||||||
|
|
||||||
|
/** Row counts behind the queue's saved-view tabs. */
|
||||||
|
export interface QueueCounts {
|
||||||
|
unassigned: number;
|
||||||
|
mine: number;
|
||||||
|
awaitingApplicant: number;
|
||||||
|
overdue: number;
|
||||||
|
readyToIssue: number;
|
||||||
|
all: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type DocumentDecision = 'ACCEPTED' | 'REJECTED';
|
||||||
|
|
||||||
|
/** An officer's verdict on one uploaded document. */
|
||||||
|
export interface DocumentReview {
|
||||||
|
id: string;
|
||||||
|
applicationId: string;
|
||||||
|
documentKey: string;
|
||||||
|
attachmentId: string | null;
|
||||||
|
decision: DocumentDecision;
|
||||||
|
reason: string | null;
|
||||||
|
reviewedById: string | null;
|
||||||
|
reviewedByName: string | null;
|
||||||
|
updatedAt: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** An officer-defined queue filter, persisted server-side. */
|
||||||
|
export interface SavedQueueView {
|
||||||
|
id: string;
|
||||||
|
ownerUserId: string;
|
||||||
|
name: string;
|
||||||
|
queryString: string;
|
||||||
|
isShared: boolean;
|
||||||
|
isDefault: boolean;
|
||||||
|
sortOrder: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AssignableOfficer {
|
||||||
|
id: string;
|
||||||
|
name: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Full filtered result set for export. `truncated` when the cap was hit. */
|
||||||
|
export interface ExportResult {
|
||||||
|
items: LicenseApplication[];
|
||||||
|
total: number;
|
||||||
|
truncated: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type TemplateStatus = 'DRAFT' | 'PUBLISHED' | 'ARCHIVED';
|
||||||
|
|
||||||
|
export interface TemplatePageOptions {
|
||||||
|
format?: 'A4' | 'A5' | 'Letter' | 'Legal';
|
||||||
|
landscape?: boolean;
|
||||||
|
printBackground?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A certificate design authored in the backoffice. */
|
||||||
|
export interface LicenseTemplate {
|
||||||
|
id: string;
|
||||||
|
licenseTypeId: string;
|
||||||
|
name: string;
|
||||||
|
version: number;
|
||||||
|
hbsSource: string;
|
||||||
|
pageOptions: TemplatePageOptions | null;
|
||||||
|
status: TemplateStatus;
|
||||||
|
publishedAt: string | null;
|
||||||
|
createdAt: string;
|
||||||
|
updatedAt: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A placeholder a certificate design may use. */
|
||||||
|
export interface TemplateVariable {
|
||||||
|
key: string;
|
||||||
|
label: string;
|
||||||
|
}
|
||||||
|
|
||||||
export interface Paginated<T> {
|
export interface Paginated<T> {
|
||||||
total: number;
|
total: number;
|
||||||
items: T[];
|
items: T[];
|
||||||
@@ -321,6 +428,10 @@ export interface ApplicationPayment {
|
|||||||
|
|
||||||
/** A licence certificate issued to the applicant. */
|
/** A licence certificate issued to the applicant. */
|
||||||
export interface IssuedLicense {
|
export interface IssuedLicense {
|
||||||
|
/** Why the licence was suspended, revoked or cancelled. */
|
||||||
|
statusReason?: string | null;
|
||||||
|
statusChangedAt?: string | null;
|
||||||
|
statusChangedByName?: string | null;
|
||||||
id: string;
|
id: string;
|
||||||
certificateNumber: string;
|
certificateNumber: string;
|
||||||
licenseTypeId: string;
|
licenseTypeId: string;
|
||||||
|
|||||||
@@ -9,6 +9,17 @@ export { ForgotPasswordPage } from './lib/pages/ForgotPasswordPage';
|
|||||||
export { OTPVerificationPage } from './lib/pages/OTPVerificationPage';
|
export { OTPVerificationPage } from './lib/pages/OTPVerificationPage';
|
||||||
export { authReducer, loginSuccess, setUser, setCurrentProfile, clearCurrentProfile, logout, hydrateAuth } from './lib/store/auth.slice';
|
export { authReducer, loginSuccess, setUser, setCurrentProfile, clearCurrentProfile, logout, hydrateAuth } from './lib/store/auth.slice';
|
||||||
export { signupReducer, setSignupData, setSignupStep, resetSignup } from './lib/store/signup.slice';
|
export { signupReducer, setSignupData, setSignupStep, resetSignup } from './lib/store/signup.slice';
|
||||||
|
export { usePermissions } from './lib/hooks/usePermissions';
|
||||||
|
export type { PermissionSet } from './lib/hooks/usePermissions';
|
||||||
|
export {
|
||||||
|
useCurrentProfile,
|
||||||
|
useGetMyProfileQuery,
|
||||||
|
useUpdateMyProfileMutation,
|
||||||
|
useUpdateMyAddressMutation,
|
||||||
|
PROFILE_FIELDS,
|
||||||
|
PROFILE_FIELD_SECTION,
|
||||||
|
} from './lib/hooks/useCurrentProfile';
|
||||||
|
export type { ProfileField, ProfileRequirement, ProfileMeResponse } from './lib/hooks/useCurrentProfile';
|
||||||
export { configureAuthStorage, authStorage } from './lib/utils/auth-storage';
|
export { configureAuthStorage, authStorage } from './lib/utils/auth-storage';
|
||||||
export { refreshAccessToken } from './lib/utils/refresh-token';
|
export { refreshAccessToken } from './lib/utils/refresh-token';
|
||||||
export type { AuthUser, AuthState, LoginPayload, CurrentProfile, CurrentProfileAddress, CurrentProfileProfession } from './lib/types/auth.types';
|
export type { AuthUser, AuthState, LoginPayload, CurrentProfile, CurrentProfileAddress, CurrentProfileProfession } from './lib/types/auth.types';
|
||||||
|
|||||||
164
libs/auth/src/lib/hooks/useCurrentProfile.ts
Normal file
164
libs/auth/src/lib/hooks/useCurrentProfile.ts
Normal file
@@ -0,0 +1,164 @@
|
|||||||
|
import { useEffect, useMemo } from 'react';
|
||||||
|
import { useDispatch } from 'react-redux';
|
||||||
|
import { baseApi } from '@ema-platform/api';
|
||||||
|
import { authStorage } from '../utils/auth-storage';
|
||||||
|
import { setCurrentProfile } from '../store/auth.slice';
|
||||||
|
import type { CurrentProfile } from '../types/auth.types';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Profile fields the portal knows how to collect.
|
||||||
|
*
|
||||||
|
* Mirrors `PROFILE_FIELDS` in the API (`module/profile/profile-completeness.ts`).
|
||||||
|
* Screens name the fields they need from this list rather than checking
|
||||||
|
* properties ad hoc, so "what is missing" has one definition.
|
||||||
|
*/
|
||||||
|
export const PROFILE_FIELDS = [
|
||||||
|
'firstName',
|
||||||
|
'middleName',
|
||||||
|
'lastName',
|
||||||
|
'gender',
|
||||||
|
'dob',
|
||||||
|
'pob',
|
||||||
|
'maritalStatus',
|
||||||
|
'professionId',
|
||||||
|
'idType',
|
||||||
|
'idNumber',
|
||||||
|
'nationality',
|
||||||
|
'primaryPhoneNumber',
|
||||||
|
'email',
|
||||||
|
'regionId',
|
||||||
|
'cityId',
|
||||||
|
'subCityId',
|
||||||
|
'woredaId',
|
||||||
|
'streetAddress',
|
||||||
|
'emergencyContactName',
|
||||||
|
'emergencyContactPhone',
|
||||||
|
'emergencyContactRelation',
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
export type ProfileField = (typeof PROFILE_FIELDS)[number];
|
||||||
|
|
||||||
|
/** Which `/profile` tab collects a field — used to build deep links. */
|
||||||
|
export const PROFILE_FIELD_SECTION: Record<ProfileField, 'personal' | 'address' | 'emergency'> = {
|
||||||
|
firstName: 'personal',
|
||||||
|
middleName: 'personal',
|
||||||
|
lastName: 'personal',
|
||||||
|
gender: 'personal',
|
||||||
|
dob: 'personal',
|
||||||
|
pob: 'personal',
|
||||||
|
maritalStatus: 'personal',
|
||||||
|
professionId: 'personal',
|
||||||
|
idType: 'address',
|
||||||
|
idNumber: 'address',
|
||||||
|
nationality: 'address',
|
||||||
|
primaryPhoneNumber: 'address',
|
||||||
|
email: 'address',
|
||||||
|
regionId: 'address',
|
||||||
|
cityId: 'address',
|
||||||
|
subCityId: 'address',
|
||||||
|
woredaId: 'address',
|
||||||
|
streetAddress: 'address',
|
||||||
|
emergencyContactName: 'emergency',
|
||||||
|
emergencyContactPhone: 'emergency',
|
||||||
|
emergencyContactRelation: 'emergency',
|
||||||
|
};
|
||||||
|
|
||||||
|
/** What a screen needs before it can do its job. */
|
||||||
|
export interface ProfileRequirement {
|
||||||
|
fields: ProfileField[];
|
||||||
|
/** Shown to the applicant — why this is being asked for, in plain language. */
|
||||||
|
reason: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ProfileMeResponse {
|
||||||
|
profile: CurrentProfile;
|
||||||
|
completeness: number;
|
||||||
|
missing: ProfileField[];
|
||||||
|
}
|
||||||
|
|
||||||
|
const profileApi = baseApi
|
||||||
|
.enhanceEndpoints({ addTagTypes: ['CurrentProfile'] as const })
|
||||||
|
.injectEndpoints({
|
||||||
|
endpoints: (builder) => ({
|
||||||
|
getMyProfile: builder.query<ProfileMeResponse, void>({
|
||||||
|
query: () => ({ url: '/profiles/me' }),
|
||||||
|
providesTags: ['CurrentProfile'],
|
||||||
|
}),
|
||||||
|
/**
|
||||||
|
* Saves one tab of `/profile`. Invalidates the profile so the
|
||||||
|
* completeness meter and every requirement gate recompute at once.
|
||||||
|
*/
|
||||||
|
updateMyProfile: builder.mutation<
|
||||||
|
unknown,
|
||||||
|
{ id: string; body: Record<string, unknown> }
|
||||||
|
>({
|
||||||
|
query: ({ id, body }) => ({ url: `/profiles/${id}`, method: 'PATCH', body }),
|
||||||
|
invalidatesTags: ['CurrentProfile'],
|
||||||
|
}),
|
||||||
|
updateMyAddress: builder.mutation<
|
||||||
|
unknown,
|
||||||
|
{ id: string; body: Record<string, unknown> }
|
||||||
|
>({
|
||||||
|
query: ({ id, body }) => ({ url: `/addresses/${id}`, method: 'PATCH', body }),
|
||||||
|
invalidatesTags: ['CurrentProfile'],
|
||||||
|
}),
|
||||||
|
}),
|
||||||
|
overrideExisting: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
export const {
|
||||||
|
useGetMyProfileQuery,
|
||||||
|
useUpdateMyProfileMutation,
|
||||||
|
useUpdateMyAddressMutation,
|
||||||
|
} = profileApi;
|
||||||
|
export const currentProfileApi = profileApi;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The one way to get at the signed-in user's profile.
|
||||||
|
*
|
||||||
|
* Replaces `authStorage.getProfileId()`, which only ever held a value if the
|
||||||
|
* user had been through the (now deleted) setup wizard. Pages that read it
|
||||||
|
* directly did `if (!profileId) return;` and rendered blank forever for anyone
|
||||||
|
* who signed up afterwards.
|
||||||
|
*
|
||||||
|
* Resolution order: RTK Query cache → `authStorage` (so the id is available
|
||||||
|
* synchronously on the very first render) → `GET /profiles/me`, which
|
||||||
|
* provisions a profile if the user has none. The resolved id is written back
|
||||||
|
* to storage. Never blocks render: `profileId` may be undefined for a tick,
|
||||||
|
* and callers should show a loading or empty state rather than bail out.
|
||||||
|
*/
|
||||||
|
export function useCurrentProfile() {
|
||||||
|
const dispatch = useDispatch();
|
||||||
|
const { data, isLoading, isFetching, error, refetch } = useGetMyProfileQuery();
|
||||||
|
|
||||||
|
const profileId = data?.profile?.id ?? authStorage.getProfileId();
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!data?.profile) return;
|
||||||
|
authStorage.setProfileId(data.profile.id);
|
||||||
|
dispatch(setCurrentProfile(data.profile));
|
||||||
|
}, [data?.profile, dispatch]);
|
||||||
|
|
||||||
|
const missing = useMemo(() => data?.missing ?? [], [data?.missing]);
|
||||||
|
|
||||||
|
return {
|
||||||
|
profileId,
|
||||||
|
profile: data?.profile,
|
||||||
|
isLoading,
|
||||||
|
isFetching,
|
||||||
|
error,
|
||||||
|
refetch,
|
||||||
|
completeness: data?.completeness ?? 0,
|
||||||
|
missing,
|
||||||
|
/**
|
||||||
|
* True when nothing the requirement asks for is still blank. Unknown
|
||||||
|
* profile (still loading) reads as not-ready, so a caller never submits
|
||||||
|
* against data it has not seen.
|
||||||
|
*/
|
||||||
|
isReadyFor: (requirement: ProfileRequirement) =>
|
||||||
|
Boolean(data) && requirement.fields.every((field) => !missing.includes(field)),
|
||||||
|
/** The subset of a requirement that is still outstanding. */
|
||||||
|
gapsFor: (requirement: ProfileRequirement) =>
|
||||||
|
requirement.fields.filter((field) => missing.includes(field)),
|
||||||
|
};
|
||||||
|
}
|
||||||
80
libs/auth/src/lib/hooks/usePermissions.ts
Normal file
80
libs/auth/src/lib/hooks/usePermissions.ts
Normal file
@@ -0,0 +1,80 @@
|
|||||||
|
import { useMemo } from 'react';
|
||||||
|
import { useSelector } from 'react-redux';
|
||||||
|
import { authStorage } from '../utils/auth-storage';
|
||||||
|
|
||||||
|
interface TokenClaims {
|
||||||
|
permissions?: string[];
|
||||||
|
roles?: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Reads the claims out of the access token without verifying it.
|
||||||
|
*
|
||||||
|
* Verification is the API's job — this is only used to decide what to *show*.
|
||||||
|
* Every guarded route is enforced server-side by `PermissionGuard`, so the
|
||||||
|
* worst a wrong answer here can do is offer a menu item that then 403s.
|
||||||
|
*/
|
||||||
|
function decodeClaims(token: string | undefined): TokenClaims | null {
|
||||||
|
if (!token) return null;
|
||||||
|
const payload = token.split('.')[1];
|
||||||
|
if (!payload) return null;
|
||||||
|
try {
|
||||||
|
const json = atob(payload.replace(/-/g, '+').replace(/_/g, '/'));
|
||||||
|
// The claim set is UTF-8; atob yields latin-1, so non-ASCII names would
|
||||||
|
// otherwise come back mangled.
|
||||||
|
const decoded = decodeURIComponent(
|
||||||
|
json
|
||||||
|
.split('')
|
||||||
|
.map((c) => `%${c.charCodeAt(0).toString(16).padStart(2, '0')}`)
|
||||||
|
.join(''),
|
||||||
|
);
|
||||||
|
return JSON.parse(decoded) as TokenClaims;
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PermissionSet {
|
||||||
|
permissions: string[];
|
||||||
|
/** True if the user holds any one of `required`. Empty `required` = allowed. */
|
||||||
|
can: (required?: string[]) => boolean;
|
||||||
|
/**
|
||||||
|
* Whether permissions could be read at all. When false, callers should show
|
||||||
|
* everything rather than hide the whole application from someone whose token
|
||||||
|
* simply does not carry the claim.
|
||||||
|
*/
|
||||||
|
known: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* What the signed-in user is allowed to do.
|
||||||
|
*
|
||||||
|
* Deliberately fails open: if the token carries no `permissions` claim we
|
||||||
|
* report `known: false` and `can()` returns true. Hiding navigation on a
|
||||||
|
* claim-shape mismatch would leave a legitimate officer staring at an empty
|
||||||
|
* sidebar with no way to tell why, whereas failing open costs at most a 403
|
||||||
|
* on a link they should not have seen.
|
||||||
|
*/
|
||||||
|
export function usePermissions(): PermissionSet {
|
||||||
|
// Re-read whenever the session changes rather than only on mount.
|
||||||
|
const token = useSelector(
|
||||||
|
(state: { auth?: { token?: string | null } }) => state.auth?.token,
|
||||||
|
);
|
||||||
|
|
||||||
|
return useMemo(() => {
|
||||||
|
const claims = decodeClaims(token ?? authStorage.getToken());
|
||||||
|
const permissions = claims?.permissions ?? [];
|
||||||
|
const known = Array.isArray(claims?.permissions);
|
||||||
|
const granted = new Set(permissions);
|
||||||
|
|
||||||
|
return {
|
||||||
|
permissions,
|
||||||
|
known,
|
||||||
|
can: (required?: string[]) => {
|
||||||
|
if (!required?.length) return true;
|
||||||
|
if (!known) return true;
|
||||||
|
return required.some((permission) => granted.has(permission));
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}, [token]);
|
||||||
|
}
|
||||||
@@ -48,7 +48,7 @@ export function LoginPage() {
|
|||||||
const [serverError, setServerError] = useState<string | null>(null);
|
const [serverError, setServerError] = useState<string | null>(null);
|
||||||
const [loginTrigger] = useApiMutation<LoginPayload>();
|
const [loginTrigger] = useApiMutation<LoginPayload>();
|
||||||
const [meTrigger] = useApiMutation<AuthUser>();
|
const [meTrigger] = useApiMutation<AuthUser>();
|
||||||
const [profileCheckTrigger] = useApiMutation<{ total: number; items: CurrentProfile[] }>();
|
const [profileTrigger] = useApiMutation<{ profile: CurrentProfile | null }>();
|
||||||
|
|
||||||
const {
|
const {
|
||||||
register,
|
register,
|
||||||
@@ -74,22 +74,22 @@ export function LoginPage() {
|
|||||||
}).unwrap();
|
}).unwrap();
|
||||||
dispatch(setUser(me));
|
dispatch(setUser(me));
|
||||||
|
|
||||||
// Load the seafarer profile if one exists, so those screens have it —
|
// Warm the profile so screens that need an id have one on first paint.
|
||||||
// but never gate sign-in on it.
|
// `/profiles/me` provisions an empty profile when the user has none, so
|
||||||
|
// unlike the old filtered lookup this cannot come back empty-handed.
|
||||||
|
// Sign-in is still never gated on it — a failure here is ignored and
|
||||||
|
// `useCurrentProfile` resolves it again on demand.
|
||||||
try {
|
try {
|
||||||
const q = `w=user_id:=:${me.id}&i=user,address,profession`;
|
const { profile } = await profileTrigger({
|
||||||
const result = await profileCheckTrigger({
|
url: '/profiles/me',
|
||||||
url: `/profiles?q=${encodeURIComponent(q)}`,
|
|
||||||
method: 'GET',
|
method: 'GET',
|
||||||
}).unwrap();
|
}).unwrap();
|
||||||
if (result.total > 0 && result.items.length > 0) {
|
if (profile) {
|
||||||
const profile = result.items[0];
|
|
||||||
authStorage.setProfileId(profile.id);
|
authStorage.setProfileId(profile.id);
|
||||||
dispatch(setCurrentProfile(profile));
|
dispatch(setCurrentProfile(profile));
|
||||||
}
|
}
|
||||||
} catch {
|
} catch {
|
||||||
// No profile yet. That is fine — a profile is only needed by the
|
// Offline or a 5xx — the portal still works; the resolver retries.
|
||||||
// seafarer features, not to apply for a licence.
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!me.isPhoneNumberVerified) {
|
if (!me.isPhoneNumberVerified) {
|
||||||
|
|||||||
@@ -3,8 +3,12 @@ export * from './lib/feedback/ConfirmModal';
|
|||||||
export * from './lib/feedback/ApiErrorAlert';
|
export * from './lib/feedback/ApiErrorAlert';
|
||||||
export * from './lib/feedback/notify';
|
export * from './lib/feedback/notify';
|
||||||
export * from './lib/feedback/FeatureUnavailable';
|
export * from './lib/feedback/FeatureUnavailable';
|
||||||
|
export * from './lib/feedback/EmptyState';
|
||||||
|
export * from './lib/feedback/ErrorState';
|
||||||
export * from './lib/layout/AppHeader';
|
export * from './lib/layout/AppHeader';
|
||||||
export * from './lib/layout/AppSidebar';
|
export * from './lib/layout/AppSidebar';
|
||||||
|
export * from './lib/layout/AppTopNav';
|
||||||
|
export * from './lib/layout/nav-utils';
|
||||||
export * from './lib/layout/BrandAvatar';
|
export * from './lib/layout/BrandAvatar';
|
||||||
export * from './lib/layout/ColorSchemeToggle';
|
export * from './lib/layout/ColorSchemeToggle';
|
||||||
export * from './lib/layout/LanguageSwitcher';
|
export * from './lib/layout/LanguageSwitcher';
|
||||||
|
|||||||
47
libs/ui/src/lib/feedback/EmptyState.tsx
Normal file
47
libs/ui/src/lib/feedback/EmptyState.tsx
Normal file
@@ -0,0 +1,47 @@
|
|||||||
|
import { Button, Paper, Stack, Text, ThemeIcon } from '@mantine/core';
|
||||||
|
import { IconInbox, type Icon } from '@tabler/icons-react';
|
||||||
|
import type { ReactNode } from 'react';
|
||||||
|
|
||||||
|
interface EmptyStateProps {
|
||||||
|
title: string;
|
||||||
|
description?: string;
|
||||||
|
icon?: Icon;
|
||||||
|
action?: { label: string; onClick: () => void; icon?: ReactNode };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The "nothing here" state.
|
||||||
|
*
|
||||||
|
* Distinct from an error: an empty queue is a normal, often good, outcome. It
|
||||||
|
* says so plainly and offers the next useful action rather than leaving a bare
|
||||||
|
* grey panel that reads as a failure.
|
||||||
|
*/
|
||||||
|
export function EmptyState({
|
||||||
|
title,
|
||||||
|
description,
|
||||||
|
icon: StateIcon = IconInbox,
|
||||||
|
action,
|
||||||
|
}: EmptyStateProps) {
|
||||||
|
return (
|
||||||
|
<Paper p="xl" withBorder>
|
||||||
|
<Stack align="center" gap="sm" py="xl">
|
||||||
|
<ThemeIcon size={52} radius="xl" variant="light" color="gray">
|
||||||
|
<StateIcon size={28} stroke={1.5} />
|
||||||
|
</ThemeIcon>
|
||||||
|
<Text fw={600} fz="lg" ta="center">
|
||||||
|
{title}
|
||||||
|
</Text>
|
||||||
|
{description && (
|
||||||
|
<Text c="dimmed" size="sm" ta="center" maw={440}>
|
||||||
|
{description}
|
||||||
|
</Text>
|
||||||
|
)}
|
||||||
|
{action && (
|
||||||
|
<Button mt="xs" variant="light" leftSection={action.icon} onClick={action.onClick}>
|
||||||
|
{action.label}
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</Stack>
|
||||||
|
</Paper>
|
||||||
|
);
|
||||||
|
}
|
||||||
60
libs/ui/src/lib/feedback/ErrorState.tsx
Normal file
60
libs/ui/src/lib/feedback/ErrorState.tsx
Normal file
@@ -0,0 +1,60 @@
|
|||||||
|
import { Button, Code, Paper, Stack, Text, ThemeIcon } from '@mantine/core';
|
||||||
|
import { IconAlertTriangle, IconRefresh, type Icon } from '@tabler/icons-react';
|
||||||
|
import { useTranslation } from 'react-i18next';
|
||||||
|
|
||||||
|
interface ErrorStateProps {
|
||||||
|
title: string;
|
||||||
|
/** What actually failed, in the API's words where available. */
|
||||||
|
description?: string;
|
||||||
|
/**
|
||||||
|
* Request/trace id. Shown verbatim and selectable so a user can quote it in
|
||||||
|
* a support ticket — "it didn't work" is not something anyone can act on.
|
||||||
|
*/
|
||||||
|
correlationId?: string;
|
||||||
|
onRetry?: () => void;
|
||||||
|
icon?: Icon;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The error state every screen shows: what broke, how to retry, what to quote. */
|
||||||
|
export function ErrorState({
|
||||||
|
title,
|
||||||
|
description,
|
||||||
|
correlationId,
|
||||||
|
onRetry,
|
||||||
|
icon: CustomIcon = IconAlertTriangle,
|
||||||
|
}: ErrorStateProps) {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Paper p="xl" withBorder role="alert">
|
||||||
|
<Stack align="center" gap="sm" py="lg">
|
||||||
|
<ThemeIcon size={48} radius="xl" color="red" variant="light">
|
||||||
|
<CustomIcon size={26} />
|
||||||
|
</ThemeIcon>
|
||||||
|
<Text fw={600} fz="lg" ta="center">
|
||||||
|
{title}
|
||||||
|
</Text>
|
||||||
|
{description && (
|
||||||
|
<Text c="dimmed" size="sm" ta="center" maw={460}>
|
||||||
|
{description}
|
||||||
|
</Text>
|
||||||
|
)}
|
||||||
|
{correlationId && (
|
||||||
|
<Text size="xs" c="dimmed">
|
||||||
|
{t('error.reference', 'Reference')}: <Code>{correlationId}</Code>
|
||||||
|
</Text>
|
||||||
|
)}
|
||||||
|
{onRetry && (
|
||||||
|
<Button
|
||||||
|
mt="xs"
|
||||||
|
variant="light"
|
||||||
|
leftSection={<IconRefresh size={16} />}
|
||||||
|
onClick={onRetry}
|
||||||
|
>
|
||||||
|
{t('error.retry', 'Try again')}
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</Stack>
|
||||||
|
</Paper>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -53,8 +53,10 @@ export function AppHeader({
|
|||||||
<Group h="100%" px="lg" justify="space-between" wrap="nowrap">
|
<Group h="100%" px="lg" justify="space-between" wrap="nowrap">
|
||||||
<Group gap="md" wrap="nowrap">
|
<Group gap="md" wrap="nowrap">
|
||||||
{/* Hamburger — styled like user-management Top.tsx */}
|
{/* Hamburger — styled like user-management Top.tsx */}
|
||||||
|
{/* The Burger itself owns the click so the control is a real, keyboard
|
||||||
|
reachable <button>; the Box is chrome only. It previously wrapped a
|
||||||
|
no-op button, which no keyboard user could operate. */}
|
||||||
<Box
|
<Box
|
||||||
onClick={isMobile ? onToggleNav : onToggleSidebar}
|
|
||||||
style={{
|
style={{
|
||||||
display: 'flex',
|
display: 'flex',
|
||||||
alignItems: 'center',
|
alignItems: 'center',
|
||||||
@@ -84,7 +86,7 @@ export function AppHeader({
|
|||||||
>
|
>
|
||||||
<Burger
|
<Burger
|
||||||
opened={navOpened}
|
opened={navOpened}
|
||||||
onClick={() => {}}
|
onClick={isMobile ? onToggleNav : onToggleSidebar}
|
||||||
size="sm"
|
size="sm"
|
||||||
aria-label="Toggle navigation"
|
aria-label="Toggle navigation"
|
||||||
styles={{
|
styles={{
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import {
|
|||||||
AppShell,
|
AppShell,
|
||||||
Badge,
|
Badge,
|
||||||
NavLink,
|
NavLink,
|
||||||
|
Popover,
|
||||||
ScrollArea,
|
ScrollArea,
|
||||||
Stack,
|
Stack,
|
||||||
Text,
|
Text,
|
||||||
@@ -14,13 +15,32 @@ import {
|
|||||||
IconChevronRight,
|
IconChevronRight,
|
||||||
} from '@tabler/icons-react';
|
} from '@tabler/icons-react';
|
||||||
import type { Icon } from '@tabler/icons-react';
|
import type { Icon } from '@tabler/icons-react';
|
||||||
|
import type { ReactNode } from 'react';
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
import { BrandMark } from '@ema-platform/auth';
|
import {
|
||||||
|
badgeLabel,
|
||||||
|
isBranchActive,
|
||||||
|
isItemActive,
|
||||||
|
toSections,
|
||||||
|
type NavEntries,
|
||||||
|
} from './nav-utils';
|
||||||
|
|
||||||
export interface NavItem {
|
export interface NavItem {
|
||||||
label: string;
|
label: string;
|
||||||
icon: Icon;
|
icon: Icon;
|
||||||
to?: string;
|
to?: string;
|
||||||
|
/**
|
||||||
|
* One level only. A parent with children is a disclosure, not a destination,
|
||||||
|
* so give it either `to` or `children` — not both.
|
||||||
|
*/
|
||||||
|
children?: NavItem[];
|
||||||
|
/**
|
||||||
|
* Pending count, or `'dot'` for "something is waiting" without a number.
|
||||||
|
* Zero renders nothing: a badge reading 0 is worse than no badge.
|
||||||
|
*/
|
||||||
|
badge?: number | 'dot';
|
||||||
|
/** Hidden unless the user holds at least one of these. */
|
||||||
|
permissions?: string[];
|
||||||
/** Not yet connected to real data — surfaced as a "Soon" badge. */
|
/** Not yet connected to real data — surfaced as a "Soon" badge. */
|
||||||
soon?: boolean;
|
soon?: boolean;
|
||||||
}
|
}
|
||||||
@@ -32,15 +52,174 @@ export interface NavSection {
|
|||||||
items: NavItem[];
|
items: NavItem[];
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Both shapes are accepted so callers can migrate to sections gradually. */
|
export type { NavEntries } from './nav-utils';
|
||||||
export type NavEntries = NavItem[] | NavSection[];
|
|
||||||
|
|
||||||
function toSections(entries: NavEntries): NavSection[] {
|
/** Shown when an item is disabled, so no control is ever dead without a reason. */
|
||||||
if (entries.length === 0) return [];
|
function itemTooltip(item: NavItem, soonLabel: string): string | undefined {
|
||||||
const isSectioned = (entries as NavSection[])[0]?.items !== undefined;
|
return item.soon ? soonLabel : undefined;
|
||||||
return isSectioned
|
}
|
||||||
? (entries as NavSection[])
|
|
||||||
: [{ items: entries as NavItem[] }];
|
interface SidebarItemProps {
|
||||||
|
item: NavItem;
|
||||||
|
collapsed: boolean;
|
||||||
|
activePath: string;
|
||||||
|
onNavigate: (item: NavItem) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* One nav entry, at either level.
|
||||||
|
*
|
||||||
|
* A parent with children renders as a Mantine NavLink disclosure that starts
|
||||||
|
* open when the current route is inside it, so the user can always see where
|
||||||
|
* they are without hunting. Collapsed to the icon rail there is no room to
|
||||||
|
* nest, so a parent becomes a hover flyout instead of silently losing its
|
||||||
|
* children.
|
||||||
|
*/
|
||||||
|
function SidebarItem({ item, collapsed, activePath, onNavigate }: SidebarItemProps) {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const ItemIcon = item.icon;
|
||||||
|
const hasChildren = Boolean(item.children?.length);
|
||||||
|
const active = isItemActive(item, activePath);
|
||||||
|
const branchActive = isBranchActive(item, activePath);
|
||||||
|
const soonLabel = `${t(item.label)} — ${t('nav.soon', 'Soon')}`;
|
||||||
|
const badge = badgeLabel(item.badge);
|
||||||
|
|
||||||
|
const badgeNode = badge !== null && (
|
||||||
|
<Badge
|
||||||
|
size="xs"
|
||||||
|
circle={item.badge === 'dot'}
|
||||||
|
variant="filled"
|
||||||
|
color="red"
|
||||||
|
radius="sm"
|
||||||
|
aria-label={
|
||||||
|
item.badge === 'dot'
|
||||||
|
? t('nav.pending', 'Items pending')
|
||||||
|
: t('nav.pendingCount', { count: Number(item.badge), defaultValue: '{{count}} pending' })
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{badge}
|
||||||
|
</Badge>
|
||||||
|
);
|
||||||
|
|
||||||
|
const rightSection = item.soon ? (
|
||||||
|
<Badge size="xs" variant="light" color="gray" radius="sm">
|
||||||
|
{t('nav.soon', 'Soon')}
|
||||||
|
</Badge>
|
||||||
|
) : (
|
||||||
|
badgeNode || undefined
|
||||||
|
);
|
||||||
|
|
||||||
|
if (collapsed) {
|
||||||
|
const trigger = (
|
||||||
|
<UnstyledButton
|
||||||
|
onClick={() => !hasChildren && onNavigate(item)}
|
||||||
|
aria-label={t(item.label)}
|
||||||
|
aria-current={active ? 'page' : undefined}
|
||||||
|
style={{
|
||||||
|
position: 'relative',
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
width: '100%',
|
||||||
|
height: rem(40),
|
||||||
|
borderRadius: rem(10),
|
||||||
|
opacity: item.soon ? 0.55 : 1,
|
||||||
|
color: branchActive ? 'var(--mantine-color-blue-6)' : undefined,
|
||||||
|
backgroundColor: branchActive ? 'var(--mantine-color-blue-light)' : undefined,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<ItemIcon size={20} stroke={1.6} />
|
||||||
|
{badge !== null && (
|
||||||
|
<Badge
|
||||||
|
size="xs"
|
||||||
|
circle
|
||||||
|
variant="filled"
|
||||||
|
color="red"
|
||||||
|
style={{ position: 'absolute', top: rem(6), right: rem(10) }}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</UnstyledButton>
|
||||||
|
);
|
||||||
|
|
||||||
|
// Children would be unreachable behind an icon, so open them in a flyout.
|
||||||
|
if (hasChildren) {
|
||||||
|
return (
|
||||||
|
<Popover position="right-start" withArrow shadow="md" trapFocus>
|
||||||
|
<Popover.Target>{trigger}</Popover.Target>
|
||||||
|
<Popover.Dropdown p={4}>
|
||||||
|
<Text size="xs" fw={700} c="dimmed" px="xs" py={4}>
|
||||||
|
{t(item.label)}
|
||||||
|
</Text>
|
||||||
|
{item.children?.map((child) => (
|
||||||
|
<NavLink
|
||||||
|
key={child.label}
|
||||||
|
active={isItemActive(child, activePath)}
|
||||||
|
label={t(child.label)}
|
||||||
|
leftSection={<child.icon size={17} stroke={1.6} />}
|
||||||
|
onClick={() => onNavigate(child)}
|
||||||
|
variant="light"
|
||||||
|
styles={{ root: { borderRadius: rem(8) } }}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</Popover.Dropdown>
|
||||||
|
</Popover>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Tooltip
|
||||||
|
label={itemTooltip(item, soonLabel) ?? t(item.label)}
|
||||||
|
position="right"
|
||||||
|
withArrow
|
||||||
|
>
|
||||||
|
{trigger}
|
||||||
|
</Tooltip>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<NavLink
|
||||||
|
active={hasChildren ? branchActive && !active : active}
|
||||||
|
label={t(item.label)}
|
||||||
|
leftSection={<ItemIcon size={19} stroke={1.6} />}
|
||||||
|
rightSection={rightSection}
|
||||||
|
// Auto-expands the group the user is currently inside.
|
||||||
|
defaultOpened={hasChildren ? branchActive : undefined}
|
||||||
|
onClick={() => !hasChildren && onNavigate(item)}
|
||||||
|
variant="light"
|
||||||
|
styles={{
|
||||||
|
root: { borderRadius: rem(10), opacity: item.soon ? 0.7 : 1 },
|
||||||
|
label: { fontWeight: 500 },
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{hasChildren
|
||||||
|
? item.children?.map((child) => (
|
||||||
|
<NavLink
|
||||||
|
key={child.label}
|
||||||
|
active={isItemActive(child, activePath)}
|
||||||
|
label={t(child.label)}
|
||||||
|
leftSection={<child.icon size={17} stroke={1.6} />}
|
||||||
|
rightSection={
|
||||||
|
child.soon ? (
|
||||||
|
<Badge size="xs" variant="light" color="gray" radius="sm">
|
||||||
|
{t('nav.soon', 'Soon')}
|
||||||
|
</Badge>
|
||||||
|
) : (
|
||||||
|
badgeLabel(child.badge) !== null && (
|
||||||
|
<Badge size="xs" variant="filled" color="red" radius="sm">
|
||||||
|
{badgeLabel(child.badge)}
|
||||||
|
</Badge>
|
||||||
|
)
|
||||||
|
) || undefined
|
||||||
|
}
|
||||||
|
onClick={() => onNavigate(child)}
|
||||||
|
variant="light"
|
||||||
|
styles={{ root: { borderRadius: rem(8) }, label: { fontWeight: 500 } }}
|
||||||
|
/>
|
||||||
|
))
|
||||||
|
: null}
|
||||||
|
</NavLink>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
interface AppSidebarProps {
|
interface AppSidebarProps {
|
||||||
@@ -51,6 +230,12 @@ interface AppSidebarProps {
|
|||||||
onNavigate: (item: NavItem) => void;
|
onNavigate: (item: NavItem) => void;
|
||||||
brandName: string;
|
brandName: string;
|
||||||
brandSubtitle: string;
|
brandSubtitle: string;
|
||||||
|
/**
|
||||||
|
* Rendered in the brand header. Passed in rather than imported so this
|
||||||
|
* library stays free of a dependency on `@ema-platform/auth`, which depends
|
||||||
|
* on it — the two formed an import cycle.
|
||||||
|
*/
|
||||||
|
brandLogo?: ReactNode;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function AppSidebar({
|
export function AppSidebar({
|
||||||
@@ -61,13 +246,10 @@ export function AppSidebar({
|
|||||||
onNavigate,
|
onNavigate,
|
||||||
brandName,
|
brandName,
|
||||||
brandSubtitle,
|
brandSubtitle,
|
||||||
|
brandLogo,
|
||||||
}: AppSidebarProps) {
|
}: AppSidebarProps) {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
|
|
||||||
const activeNavItem = (item: NavItem) =>
|
|
||||||
!!item.to &&
|
|
||||||
(activePath === item.to || activePath.startsWith(`${item.to}/`));
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
{/* Brand header */}
|
{/* Brand header */}
|
||||||
@@ -83,7 +265,7 @@ export function AppSidebar({
|
|||||||
flexShrink: 0,
|
flexShrink: 0,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<BrandMark size={32} />
|
{brandLogo}
|
||||||
{!collapsed && (
|
{!collapsed && (
|
||||||
<div style={{ minWidth: 0 }}>
|
<div style={{ minWidth: 0 }}>
|
||||||
<Text
|
<Text
|
||||||
@@ -141,61 +323,15 @@ export function AppSidebar({
|
|||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
{section.items.map((item) => {
|
{section.items.map((item) => (
|
||||||
const ItemIcon = item.icon;
|
<SidebarItem
|
||||||
const active = activeNavItem(item);
|
|
||||||
|
|
||||||
if (collapsed) {
|
|
||||||
return (
|
|
||||||
<Tooltip
|
|
||||||
key={item.label}
|
key={item.label}
|
||||||
label={item.soon ? `${t(item.label)} — ${t('nav.soon', 'Soon')}` : t(item.label)}
|
item={item}
|
||||||
position="right"
|
collapsed={collapsed}
|
||||||
withArrow
|
activePath={activePath}
|
||||||
>
|
onNavigate={onNavigate}
|
||||||
<UnstyledButton
|
/>
|
||||||
onClick={() => onNavigate(item)}
|
))}
|
||||||
style={{
|
|
||||||
display: 'flex',
|
|
||||||
alignItems: 'center',
|
|
||||||
justifyContent: 'center',
|
|
||||||
width: '100%',
|
|
||||||
height: rem(40),
|
|
||||||
borderRadius: rem(10),
|
|
||||||
opacity: item.soon ? 0.55 : 1,
|
|
||||||
color: active ? 'var(--mantine-color-blue-6)' : undefined,
|
|
||||||
backgroundColor: active ? 'var(--mantine-color-blue-light)' : undefined,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<ItemIcon size={20} stroke={1.6} />
|
|
||||||
</UnstyledButton>
|
|
||||||
</Tooltip>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<NavLink
|
|
||||||
key={item.label}
|
|
||||||
active={active}
|
|
||||||
label={t(item.label)}
|
|
||||||
leftSection={<ItemIcon size={19} stroke={1.6} />}
|
|
||||||
// Tells a reviewer at a glance which screens are wired up.
|
|
||||||
rightSection={
|
|
||||||
item.soon ? (
|
|
||||||
<Badge size="xs" variant="light" color="gray" radius="sm">
|
|
||||||
{t('nav.soon', 'Soon')}
|
|
||||||
</Badge>
|
|
||||||
) : undefined
|
|
||||||
}
|
|
||||||
onClick={() => onNavigate(item)}
|
|
||||||
variant="light"
|
|
||||||
styles={{
|
|
||||||
root: { borderRadius: rem(10), opacity: item.soon ? 0.7 : 1 },
|
|
||||||
label: { fontWeight: 500 },
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</Stack>
|
</Stack>
|
||||||
))}
|
))}
|
||||||
</Stack>
|
</Stack>
|
||||||
|
|||||||
187
libs/ui/src/lib/layout/AppTopNav.tsx
Normal file
187
libs/ui/src/lib/layout/AppTopNav.tsx
Normal file
@@ -0,0 +1,187 @@
|
|||||||
|
import { Badge, Group, Menu, UnstyledButton, rem } from '@mantine/core';
|
||||||
|
import { IconChevronDown } from '@tabler/icons-react';
|
||||||
|
import { useTranslation } from 'react-i18next';
|
||||||
|
import type { NavItem } from './AppSidebar';
|
||||||
|
import {
|
||||||
|
badgeLabel,
|
||||||
|
isBranchActive,
|
||||||
|
isItemActive,
|
||||||
|
toSections,
|
||||||
|
type NavEntries,
|
||||||
|
} from './nav-utils';
|
||||||
|
|
||||||
|
interface AppTopNavProps {
|
||||||
|
navItems: NavEntries;
|
||||||
|
activePath: string;
|
||||||
|
onNavigate: (item: NavItem) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Horizontal navigation for the top-bar layout.
|
||||||
|
*
|
||||||
|
* Every destination used to render as a sibling button in one horizontally
|
||||||
|
* scrolling strip, so with twenty-odd of them most were off-screen and the
|
||||||
|
* grouping that the sidebar already had was thrown away. Here each section
|
||||||
|
* collapses to a single labelled dropdown, which fits and keeps the same
|
||||||
|
* information architecture as the sidebar.
|
||||||
|
*/
|
||||||
|
export function AppTopNav({ navItems, activePath, onNavigate }: AppTopNavProps) {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const sections = toSections(navItems);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Group
|
||||||
|
gap={rem(2)}
|
||||||
|
h="100%"
|
||||||
|
wrap="nowrap"
|
||||||
|
role="navigation"
|
||||||
|
aria-label={t('nav.primary', 'Primary')}
|
||||||
|
>
|
||||||
|
{sections.map((section, index) => {
|
||||||
|
// An unlabelled leading block (Dashboard) is a plain link, not a menu.
|
||||||
|
if (!section.label) {
|
||||||
|
return section.items.map((item) => (
|
||||||
|
<TopNavButton
|
||||||
|
key={item.label}
|
||||||
|
label={t(item.label)}
|
||||||
|
active={isBranchActive(item, activePath)}
|
||||||
|
badge={badgeLabel(item.badge)}
|
||||||
|
soon={item.soon}
|
||||||
|
onClick={() => onNavigate(item)}
|
||||||
|
/>
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
const sectionActive = section.items.some((item) =>
|
||||||
|
isBranchActive(item, activePath),
|
||||||
|
);
|
||||||
|
const pending = section.items.reduce((sum, item) => {
|
||||||
|
const own = typeof item.badge === 'number' ? item.badge : 0;
|
||||||
|
const nested = (item.children ?? []).reduce(
|
||||||
|
(n, child) => n + (typeof child.badge === 'number' ? child.badge : 0),
|
||||||
|
0,
|
||||||
|
);
|
||||||
|
return sum + own + nested;
|
||||||
|
}, 0);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Menu
|
||||||
|
key={section.label ?? `section-${index}`}
|
||||||
|
trigger="click-hover"
|
||||||
|
openDelay={80}
|
||||||
|
closeDelay={140}
|
||||||
|
position="bottom-start"
|
||||||
|
withinPortal
|
||||||
|
shadow="md"
|
||||||
|
>
|
||||||
|
<Menu.Target>
|
||||||
|
<TopNavButton
|
||||||
|
label={t(section.label)}
|
||||||
|
active={sectionActive}
|
||||||
|
badge={badgeLabel(pending)}
|
||||||
|
withChevron
|
||||||
|
/>
|
||||||
|
</Menu.Target>
|
||||||
|
<Menu.Dropdown>
|
||||||
|
{section.items.map((item) =>
|
||||||
|
item.children?.length ? (
|
||||||
|
<Menu.Sub key={item.label} position="right-start">
|
||||||
|
<Menu.Sub.Target>
|
||||||
|
<Menu.Sub.Item leftSection={<item.icon size={16} stroke={1.6} />}>
|
||||||
|
{t(item.label)}
|
||||||
|
</Menu.Sub.Item>
|
||||||
|
</Menu.Sub.Target>
|
||||||
|
<Menu.Sub.Dropdown>
|
||||||
|
{item.children.map((child) => (
|
||||||
|
<Menu.Item
|
||||||
|
key={child.label}
|
||||||
|
leftSection={<child.icon size={16} stroke={1.6} />}
|
||||||
|
onClick={() => onNavigate(child)}
|
||||||
|
disabled={child.soon}
|
||||||
|
>
|
||||||
|
{t(child.label)}
|
||||||
|
</Menu.Item>
|
||||||
|
))}
|
||||||
|
</Menu.Sub.Dropdown>
|
||||||
|
</Menu.Sub>
|
||||||
|
) : (
|
||||||
|
<Menu.Item
|
||||||
|
key={item.label}
|
||||||
|
leftSection={<item.icon size={16} stroke={1.6} />}
|
||||||
|
rightSection={
|
||||||
|
item.soon ? (
|
||||||
|
<Badge size="xs" variant="light" color="gray" radius="sm">
|
||||||
|
{t('nav.soon', 'Soon')}
|
||||||
|
</Badge>
|
||||||
|
) : (
|
||||||
|
badgeLabel(item.badge) !== null && (
|
||||||
|
<Badge size="xs" variant="filled" color="red" radius="sm">
|
||||||
|
{badgeLabel(item.badge)}
|
||||||
|
</Badge>
|
||||||
|
)
|
||||||
|
) || undefined
|
||||||
|
}
|
||||||
|
onClick={() => onNavigate(item)}
|
||||||
|
// `soon` screens have no backend; the Menu.Item's own
|
||||||
|
// disabled styling plus the badge explains why.
|
||||||
|
disabled={item.soon}
|
||||||
|
aria-current={isItemActive(item, activePath) ? 'page' : undefined}
|
||||||
|
>
|
||||||
|
{t(item.label)}
|
||||||
|
</Menu.Item>
|
||||||
|
),
|
||||||
|
)}
|
||||||
|
</Menu.Dropdown>
|
||||||
|
</Menu>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</Group>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
interface TopNavButtonProps {
|
||||||
|
label: string;
|
||||||
|
active: boolean;
|
||||||
|
badge?: string | null;
|
||||||
|
soon?: boolean;
|
||||||
|
withChevron?: boolean;
|
||||||
|
onClick?: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
function TopNavButton({
|
||||||
|
label,
|
||||||
|
active,
|
||||||
|
badge,
|
||||||
|
soon,
|
||||||
|
withChevron,
|
||||||
|
onClick,
|
||||||
|
}: TopNavButtonProps) {
|
||||||
|
return (
|
||||||
|
<UnstyledButton
|
||||||
|
onClick={onClick}
|
||||||
|
style={{
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
gap: rem(6),
|
||||||
|
padding: `0 ${rem(14)}`,
|
||||||
|
height: '100%',
|
||||||
|
borderBottom: '2px solid',
|
||||||
|
borderBottomColor: active ? 'var(--mantine-color-blue-6)' : 'transparent',
|
||||||
|
color: active ? 'var(--mantine-color-blue-6)' : 'var(--mantine-color-gray-6)',
|
||||||
|
fontWeight: active ? 600 : 500,
|
||||||
|
fontSize: rem(14),
|
||||||
|
whiteSpace: 'nowrap',
|
||||||
|
opacity: soon ? 0.55 : 1,
|
||||||
|
marginBottom: -1,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<span>{label}</span>
|
||||||
|
{badge !== null && badge !== undefined && (
|
||||||
|
<Badge size="xs" variant="filled" color="red" radius="sm">
|
||||||
|
{badge}
|
||||||
|
</Badge>
|
||||||
|
)}
|
||||||
|
{withChevron && <IconChevronDown size={14} stroke={2} />}
|
||||||
|
</UnstyledButton>
|
||||||
|
);
|
||||||
|
}
|
||||||
79
libs/ui/src/lib/layout/nav-utils.ts
Normal file
79
libs/ui/src/lib/layout/nav-utils.ts
Normal file
@@ -0,0 +1,79 @@
|
|||||||
|
import type { NavItem, NavSection } from './AppSidebar';
|
||||||
|
|
||||||
|
/** Both shapes are accepted so callers can migrate to sections gradually. */
|
||||||
|
export type NavEntries = NavItem[] | NavSection[];
|
||||||
|
|
||||||
|
export function toSections(entries: NavEntries): NavSection[] {
|
||||||
|
if (entries.length === 0) return [];
|
||||||
|
const isSectioned = (entries as NavSection[])[0]?.items !== undefined;
|
||||||
|
return isSectioned
|
||||||
|
? (entries as NavSection[])
|
||||||
|
: [{ items: entries as NavItem[] }];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Whether a nav entry points at the current route.
|
||||||
|
*
|
||||||
|
* Prefix-matches on a path boundary so `/licensing` lights up for
|
||||||
|
* `/licensing/FREIGHT_FORWARDER` but not for an unrelated `/licensing-report`.
|
||||||
|
*/
|
||||||
|
export function isItemActive(item: NavItem, activePath: string): boolean {
|
||||||
|
if (!item.to) return false;
|
||||||
|
return activePath === item.to || activePath.startsWith(`${item.to}/`);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** True when the item, or anything nested under it, matches the route. */
|
||||||
|
export function isBranchActive(item: NavItem, activePath: string): boolean {
|
||||||
|
if (isItemActive(item, activePath)) return true;
|
||||||
|
return (item.children ?? []).some((child) => isItemActive(child, activePath));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Removes entries the user may not see.
|
||||||
|
*
|
||||||
|
* An item with no `permissions` is visible to everyone. A parent survives if
|
||||||
|
* it is itself permitted and at least one child is — a disclosure that opens
|
||||||
|
* onto nothing is worse than no disclosure at all. Sections left empty are
|
||||||
|
* dropped so their heading does not hang over a gap.
|
||||||
|
*/
|
||||||
|
export function filterByPermissions(
|
||||||
|
sections: NavSection[],
|
||||||
|
granted: readonly string[],
|
||||||
|
): NavSection[] {
|
||||||
|
const permitted = new Set(granted);
|
||||||
|
const allows = (item: NavItem) =>
|
||||||
|
!item.permissions?.length ||
|
||||||
|
item.permissions.some((permission) => permitted.has(permission));
|
||||||
|
|
||||||
|
return sections
|
||||||
|
.map((section) => ({
|
||||||
|
...section,
|
||||||
|
items: section.items
|
||||||
|
.filter(allows)
|
||||||
|
.map((item) =>
|
||||||
|
item.children
|
||||||
|
? { ...item, children: item.children.filter(allows) }
|
||||||
|
: item,
|
||||||
|
)
|
||||||
|
.filter((item) => !item.children || item.children.length > 0),
|
||||||
|
}))
|
||||||
|
.filter((section) => section.items.length > 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Flattens parents and children into one list, for search and breadcrumbs. */
|
||||||
|
export function flattenNav(sections: NavSection[]): NavItem[] {
|
||||||
|
return sections.flatMap((section) =>
|
||||||
|
section.items.flatMap((item) => [item, ...(item.children ?? [])]),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Badge text, or null when there is nothing worth showing. */
|
||||||
|
export function badgeLabel(badge: NavItem['badge']): string | null {
|
||||||
|
if (badge === 'dot') return '';
|
||||||
|
if (typeof badge === 'number' && badge > 0) {
|
||||||
|
// Three digits of pending work is already "a lot"; the exact number
|
||||||
|
// stops being actionable and starts breaking the layout.
|
||||||
|
return badge > 99 ? '99+' : String(badge);
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
87
package-lock.json
generated
87
package-lock.json
generated
@@ -17,6 +17,7 @@
|
|||||||
"@mantine/form": "^8.3.16",
|
"@mantine/form": "^8.3.16",
|
||||||
"@mantine/hooks": "^8.3.17",
|
"@mantine/hooks": "^8.3.17",
|
||||||
"@mantine/notifications": "^8.3.16",
|
"@mantine/notifications": "^8.3.16",
|
||||||
|
"@mantine/spotlight": "^8.3.18",
|
||||||
"@reduxjs/toolkit": "^2.11.2",
|
"@reduxjs/toolkit": "^2.11.2",
|
||||||
"@tabler/icons-react": "^3.40.0",
|
"@tabler/icons-react": "^3.40.0",
|
||||||
"@tanstack/react-query": "^5.99.0",
|
"@tanstack/react-query": "^5.99.0",
|
||||||
@@ -50,6 +51,7 @@
|
|||||||
"@vitejs/plugin-react": "^4.3.4",
|
"@vitejs/plugin-react": "^4.3.4",
|
||||||
"autoprefixer": "^10.4.20",
|
"autoprefixer": "^10.4.20",
|
||||||
"eslint": "^9.8.0",
|
"eslint": "^9.8.0",
|
||||||
|
"eslint-plugin-react-hooks": "^5.2.0",
|
||||||
"nx": "^22.5.4",
|
"nx": "^22.5.4",
|
||||||
"postcss": "^8.5.1",
|
"postcss": "^8.5.1",
|
||||||
"prettier": "^3.6.2",
|
"prettier": "^3.6.2",
|
||||||
@@ -3178,6 +3180,21 @@
|
|||||||
"react-dom": "^18.x || ^19.x"
|
"react-dom": "^18.x || ^19.x"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@mantine/spotlight": {
|
||||||
|
"version": "8.3.18",
|
||||||
|
"resolved": "https://registry.npmjs.org/@mantine/spotlight/-/spotlight-8.3.18.tgz",
|
||||||
|
"integrity": "sha512-yFoEYG0wKduxbnv6+1CUOXc91lmQ5DN4QvEShYO2ftDm0kXhxeOvJFtGOBYK80tpmSCsaT253p9E3J3DcaOt2w==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@mantine/store": "8.3.18"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"@mantine/core": "8.3.18",
|
||||||
|
"@mantine/hooks": "8.3.18",
|
||||||
|
"react": "^18.x || ^19.x",
|
||||||
|
"react-dom": "^18.x || ^19.x"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/@mantine/store": {
|
"node_modules/@mantine/store": {
|
||||||
"version": "8.3.18",
|
"version": "8.3.18",
|
||||||
"resolved": "https://registry.npmjs.org/@mantine/store/-/store-8.3.18.tgz",
|
"resolved": "https://registry.npmjs.org/@mantine/store/-/store-8.3.18.tgz",
|
||||||
@@ -3867,6 +3884,18 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@mui/x-date-pickers/node_modules/@types/react": {
|
||||||
|
"version": "18.3.31",
|
||||||
|
"resolved": "https://registry.npmjs.org/@types/react/-/react-18.3.31.tgz",
|
||||||
|
"integrity": "sha512-vfEqpXTvwT91yhmwdfouStN2hSKwTvyRs8qpLfADyrq/kxDw0hZM7Wk9Ug1FELj8hIby+S/+kQCSRFF32nv2Qw==",
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"peer": true,
|
||||||
|
"dependencies": {
|
||||||
|
"@types/prop-types": "*",
|
||||||
|
"csstype": "^3.2.2"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/@mui/x-date-pickers/node_modules/react-is": {
|
"node_modules/@mui/x-date-pickers/node_modules/react-is": {
|
||||||
"version": "19.2.7",
|
"version": "19.2.7",
|
||||||
"resolved": "https://registry.npmjs.org/react-is/-/react-is-19.2.7.tgz",
|
"resolved": "https://registry.npmjs.org/react-is/-/react-is-19.2.7.tgz",
|
||||||
@@ -3991,9 +4020,6 @@
|
|||||||
"cpu": [
|
"cpu": [
|
||||||
"arm64"
|
"arm64"
|
||||||
],
|
],
|
||||||
"libc": [
|
|
||||||
"glibc"
|
|
||||||
],
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -4014,9 +4040,6 @@
|
|||||||
"cpu": [
|
"cpu": [
|
||||||
"arm64"
|
"arm64"
|
||||||
],
|
],
|
||||||
"libc": [
|
|
||||||
"musl"
|
|
||||||
],
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -4037,9 +4060,6 @@
|
|||||||
"cpu": [
|
"cpu": [
|
||||||
"riscv64"
|
"riscv64"
|
||||||
],
|
],
|
||||||
"libc": [
|
|
||||||
"glibc"
|
|
||||||
],
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -4060,9 +4080,6 @@
|
|||||||
"cpu": [
|
"cpu": [
|
||||||
"x64"
|
"x64"
|
||||||
],
|
],
|
||||||
"libc": [
|
|
||||||
"glibc"
|
|
||||||
],
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -4083,9 +4100,6 @@
|
|||||||
"cpu": [
|
"cpu": [
|
||||||
"x64"
|
"x64"
|
||||||
],
|
],
|
||||||
"libc": [
|
|
||||||
"musl"
|
|
||||||
],
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -8327,9 +8341,6 @@
|
|||||||
"cpu": [
|
"cpu": [
|
||||||
"arm64"
|
"arm64"
|
||||||
],
|
],
|
||||||
"libc": [
|
|
||||||
"glibc"
|
|
||||||
],
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -8346,9 +8357,6 @@
|
|||||||
"cpu": [
|
"cpu": [
|
||||||
"arm64"
|
"arm64"
|
||||||
],
|
],
|
||||||
"libc": [
|
|
||||||
"musl"
|
|
||||||
],
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -8365,9 +8373,6 @@
|
|||||||
"cpu": [
|
"cpu": [
|
||||||
"x64"
|
"x64"
|
||||||
],
|
],
|
||||||
"libc": [
|
|
||||||
"glibc"
|
|
||||||
],
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -8384,9 +8389,6 @@
|
|||||||
"cpu": [
|
"cpu": [
|
||||||
"x64"
|
"x64"
|
||||||
],
|
],
|
||||||
"libc": [
|
|
||||||
"musl"
|
|
||||||
],
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -11949,7 +11951,7 @@
|
|||||||
"version": "0.1.13",
|
"version": "0.1.13",
|
||||||
"resolved": "https://registry.npmjs.org/encoding/-/encoding-0.1.13.tgz",
|
"resolved": "https://registry.npmjs.org/encoding/-/encoding-0.1.13.tgz",
|
||||||
"integrity": "sha512-ETBauow1T35Y/WZMkio9jiM0Z5xjHHmJ4XmjZOq1l/dXz3lr2sRn87nJy20RupqSh1F2m3HHPSp8ShIPQJrJ3A==",
|
"integrity": "sha512-ETBauow1T35Y/WZMkio9jiM0Z5xjHHmJ4XmjZOq1l/dXz3lr2sRn87nJy20RupqSh1F2m3HHPSp8ShIPQJrJ3A==",
|
||||||
"devOptional": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"iconv-lite": "^0.6.2"
|
"iconv-lite": "^0.6.2"
|
||||||
@@ -12261,6 +12263,19 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/eslint-plugin-react-hooks": {
|
||||||
|
"version": "5.2.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/eslint-plugin-react-hooks/-/eslint-plugin-react-hooks-5.2.0.tgz",
|
||||||
|
"integrity": "sha512-+f15FfK64YQwZdJNELETdn5ibXEUQmW1DZL6KXhNnc2heoy/sg9VJJeT7n8TlMWouzWqSWavFkIhHyIbIAEapg==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=10"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"eslint": "^3.0.0 || ^4.0.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0-0 || ^9.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/eslint-scope": {
|
"node_modules/eslint-scope": {
|
||||||
"version": "8.4.0",
|
"version": "8.4.0",
|
||||||
"resolved": "https://registry.npmjs.org/eslint-scope/-/eslint-scope-8.4.0.tgz",
|
"resolved": "https://registry.npmjs.org/eslint-scope/-/eslint-scope-8.4.0.tgz",
|
||||||
@@ -13740,7 +13755,7 @@
|
|||||||
"version": "0.6.3",
|
"version": "0.6.3",
|
||||||
"resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.6.3.tgz",
|
"resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.6.3.tgz",
|
||||||
"integrity": "sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==",
|
"integrity": "sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==",
|
||||||
"devOptional": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"safer-buffer": ">= 2.1.2 < 3.0.0"
|
"safer-buffer": ">= 2.1.2 < 3.0.0"
|
||||||
@@ -14558,9 +14573,6 @@
|
|||||||
"cpu": [
|
"cpu": [
|
||||||
"arm64"
|
"arm64"
|
||||||
],
|
],
|
||||||
"libc": [
|
|
||||||
"glibc"
|
|
||||||
],
|
|
||||||
"license": "MPL-2.0",
|
"license": "MPL-2.0",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -14581,9 +14593,6 @@
|
|||||||
"cpu": [
|
"cpu": [
|
||||||
"arm64"
|
"arm64"
|
||||||
],
|
],
|
||||||
"libc": [
|
|
||||||
"musl"
|
|
||||||
],
|
|
||||||
"license": "MPL-2.0",
|
"license": "MPL-2.0",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -14604,9 +14613,6 @@
|
|||||||
"cpu": [
|
"cpu": [
|
||||||
"x64"
|
"x64"
|
||||||
],
|
],
|
||||||
"libc": [
|
|
||||||
"glibc"
|
|
||||||
],
|
|
||||||
"license": "MPL-2.0",
|
"license": "MPL-2.0",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -14627,9 +14633,6 @@
|
|||||||
"cpu": [
|
"cpu": [
|
||||||
"x64"
|
"x64"
|
||||||
],
|
],
|
||||||
"libc": [
|
|
||||||
"musl"
|
|
||||||
],
|
|
||||||
"license": "MPL-2.0",
|
"license": "MPL-2.0",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -17919,7 +17922,7 @@
|
|||||||
"version": "2.1.2",
|
"version": "2.1.2",
|
||||||
"resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz",
|
"resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz",
|
||||||
"integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==",
|
"integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==",
|
||||||
"devOptional": true,
|
"dev": true,
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
"node_modules/sax": {
|
"node_modules/sax": {
|
||||||
|
|||||||
@@ -22,6 +22,7 @@
|
|||||||
"@mantine/form": "^8.3.16",
|
"@mantine/form": "^8.3.16",
|
||||||
"@mantine/hooks": "^8.3.17",
|
"@mantine/hooks": "^8.3.17",
|
||||||
"@mantine/notifications": "^8.3.16",
|
"@mantine/notifications": "^8.3.16",
|
||||||
|
"@mantine/spotlight": "^8.3.18",
|
||||||
"@reduxjs/toolkit": "^2.11.2",
|
"@reduxjs/toolkit": "^2.11.2",
|
||||||
"@tabler/icons-react": "^3.40.0",
|
"@tabler/icons-react": "^3.40.0",
|
||||||
"@tanstack/react-query": "^5.99.0",
|
"@tanstack/react-query": "^5.99.0",
|
||||||
@@ -55,6 +56,7 @@
|
|||||||
"@vitejs/plugin-react": "^4.3.4",
|
"@vitejs/plugin-react": "^4.3.4",
|
||||||
"autoprefixer": "^10.4.20",
|
"autoprefixer": "^10.4.20",
|
||||||
"eslint": "^9.8.0",
|
"eslint": "^9.8.0",
|
||||||
|
"eslint-plugin-react-hooks": "^5.2.0",
|
||||||
"nx": "^22.5.4",
|
"nx": "^22.5.4",
|
||||||
"postcss": "^8.5.1",
|
"postcss": "^8.5.1",
|
||||||
"prettier": "^3.6.2",
|
"prettier": "^3.6.2",
|
||||||
|
|||||||
Reference in New Issue
Block a user