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 { useNavigate } from 'react-router-dom';
|
||||
import { useCallback, useMemo, useState } from 'react';
|
||||
import { useNavigate, useParams, useSearchParams } from 'react-router-dom';
|
||||
import {
|
||||
ActionIcon,
|
||||
Badge,
|
||||
Button,
|
||||
Card,
|
||||
Center,
|
||||
Checkbox,
|
||||
Container,
|
||||
Group,
|
||||
Loader,
|
||||
MultiSelect,
|
||||
Pagination,
|
||||
Paper,
|
||||
SegmentedControl,
|
||||
Select,
|
||||
Skeleton,
|
||||
Stack,
|
||||
Kbd,
|
||||
Modal,
|
||||
Table,
|
||||
Tabs,
|
||||
Text,
|
||||
TextInput,
|
||||
Title,
|
||||
Tooltip,
|
||||
} from '@mantine/core';
|
||||
import { IconSearch } from '@tabler/icons-react';
|
||||
import { useDebouncedValue } from '@mantine/hooks';
|
||||
import {
|
||||
IconAlertCircle,
|
||||
IconDownload,
|
||||
IconRefresh,
|
||||
IconSearch,
|
||||
IconSortAscending,
|
||||
IconSortDescending,
|
||||
IconX,
|
||||
} from '@tabler/icons-react';
|
||||
import { notifications } from '@mantine/notifications';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import {
|
||||
STATUS_COLORS,
|
||||
STATUS_LABELS,
|
||||
extractErrorMessage,
|
||||
useClaimApplicationMutation,
|
||||
useGetAllApplicationsQuery,
|
||||
useGetAssignedToMeQuery,
|
||||
useGetLicenseTypesQuery,
|
||||
useGetQueueCountsQuery,
|
||||
useGetQueueQuery,
|
||||
useLazyExportApplicationsQuery,
|
||||
type LicenseApplication,
|
||||
type LicenseStatus,
|
||||
type QueueFilter,
|
||||
} from '@ema-platform/api';
|
||||
import { EmptyState, ErrorState } from '@ema-platform/ui';
|
||||
import { computeSla } from '../sla';
|
||||
import {
|
||||
DEFAULT_VIEW,
|
||||
SAVED_VIEWS,
|
||||
filterFromSearchParams,
|
||||
readLastView,
|
||||
searchParamsFromFilter,
|
||||
writeLastView,
|
||||
type SavedViewId,
|
||||
} from '../queue-views';
|
||||
import { exportApplicationsCsv } from '../export';
|
||||
import { setDensity } from '../../../store/preferences.slice';
|
||||
import { useAppDispatch, useAppSelector } from '../../../store/hooks';
|
||||
import { KEYBOARD_SHORTCUTS, useQueueKeyboard } from '../useQueueKeyboard';
|
||||
|
||||
const PAGE_SIZE = 25;
|
||||
const SEARCH_DEBOUNCE_MS = 300;
|
||||
|
||||
const ALL_STATUSES: LicenseStatus[] = [
|
||||
'SUBMITTED',
|
||||
'UNDER_REVIEW',
|
||||
'UNDER_EVALUATION',
|
||||
'RESUBMIT_REQUIRED',
|
||||
'INSPECTION_PENDING',
|
||||
'INSPECTION_COMPLETED',
|
||||
'ON_HOLD',
|
||||
'APPROVED',
|
||||
'PAYMENT_PENDING',
|
||||
'PAID',
|
||||
'PAYMENT_CONFIRMED',
|
||||
'CERTIFICATE_ISSUED',
|
||||
'COMPLETED',
|
||||
'REJECTED',
|
||||
];
|
||||
|
||||
/**
|
||||
* The officer work pool.
|
||||
*
|
||||
* "Unclaimed" is the shared queue; claiming moves an application into "Mine"
|
||||
* and it stays there through every adjustment round.
|
||||
* Saved views across the top, facets serialised into the URL so a filtered
|
||||
* queue can be shared, and server-side pagination — the previous version
|
||||
* rendered `data.items` unpaged, which was fine at demo volumes and would have
|
||||
* stopped being fine somewhere in the hundreds.
|
||||
*/
|
||||
export function LicenseQueuePage() {
|
||||
const { t, i18n } = useTranslation();
|
||||
const navigate = useNavigate();
|
||||
const [tab, setTab] = useState<'unclaimed' | 'mine'>('unclaimed');
|
||||
const [search, setSearch] = useState('');
|
||||
const { typeCode } = useParams();
|
||||
const [searchParams, setSearchParams] = useSearchParams();
|
||||
const dispatch = useAppDispatch();
|
||||
const density = useAppSelector((state) => state.preferences.density);
|
||||
|
||||
const [view, setView] = useState<SavedViewId>(
|
||||
() => (searchParams.get('view') as SavedViewId) || readLastView(),
|
||||
);
|
||||
const [page, setPage] = useState(() => Number(searchParams.get('page')) || 1);
|
||||
const [selected, setSelected] = useState<string[]>([]);
|
||||
const [searchInput, setSearchInput] = useState(searchParams.get('q') ?? '');
|
||||
const [cursor, setCursor] = useState(0);
|
||||
const [helpOpen, setHelpOpen] = useState(false);
|
||||
const [debouncedSearch] = useDebouncedValue(searchInput, SEARCH_DEBOUNCE_MS);
|
||||
|
||||
const urlFilter = useMemo(
|
||||
() => filterFromSearchParams(searchParams),
|
||||
[searchParams],
|
||||
);
|
||||
const activeView = SAVED_VIEWS.find((v) => v.id === view) ?? SAVED_VIEWS[0];
|
||||
|
||||
const { data: licenseTypes } = useGetLicenseTypesQuery();
|
||||
const { data: counts } = useGetQueueCountsQuery();
|
||||
|
||||
// A `/licence-review/type/:typeCode` deep link pins the type facet.
|
||||
const pinnedTypeId = useMemo(() => {
|
||||
if (!typeCode) return undefined;
|
||||
return licenseTypes?.items?.find((type) => type.key === typeCode)?.id;
|
||||
}, [typeCode, licenseTypes]);
|
||||
|
||||
const filter: QueueFilter = useMemo(
|
||||
() => ({
|
||||
...activeView.filter,
|
||||
...urlFilter,
|
||||
search: debouncedSearch || undefined,
|
||||
licenseTypeId: pinnedTypeId ?? urlFilter.licenseTypeId,
|
||||
take: PAGE_SIZE,
|
||||
skip: (page - 1) * PAGE_SIZE,
|
||||
}),
|
||||
[activeView, urlFilter, debouncedSearch, pinnedTypeId, page],
|
||||
);
|
||||
|
||||
// One query per source; the two inactive ones are skipped, so switching
|
||||
// views costs a single request rather than keeping three in flight.
|
||||
const queueQuery = useGetQueueQuery(filter, { skip: activeView.source !== 'queue' });
|
||||
const mineQuery = useGetAssignedToMeQuery(filter, { skip: activeView.source !== 'mine' });
|
||||
const allQuery = useGetAllApplicationsQuery(filter, { skip: activeView.source !== 'all' });
|
||||
const active =
|
||||
activeView.source === 'queue' ? queueQuery : activeView.source === 'mine' ? mineQuery : allQuery;
|
||||
|
||||
const queue = useGetQueueQuery({ search: search || undefined });
|
||||
const mine = useGetAssignedToMeQuery({ search: search || undefined });
|
||||
const [claim, { isLoading: claiming }] = useClaimApplicationMutation();
|
||||
const [runExport, { isFetching: exporting }] = useLazyExportApplicationsQuery();
|
||||
|
||||
/**
|
||||
* Exports every row the filter matches, not just the page on screen.
|
||||
* The server caps the result set and reports when it did, so a truncated
|
||||
* export says so instead of quietly being wrong.
|
||||
*/
|
||||
async function handleExport() {
|
||||
try {
|
||||
const result = await runExport({ ...filter, take: undefined, skip: undefined }).unwrap();
|
||||
exportApplicationsCsv(result.items, i18n.language);
|
||||
if (result.truncated) {
|
||||
notifications.show({
|
||||
color: 'yellow',
|
||||
title: t('queue.exportTruncated', 'Export truncated'),
|
||||
message: t('queue.exportTruncatedBody', {
|
||||
exported: result.items.length,
|
||||
total: result.total,
|
||||
defaultValue:
|
||||
'Exported the first {{exported}} of {{total}} rows. Narrow the filter for the rest.',
|
||||
}),
|
||||
});
|
||||
}
|
||||
} catch (err) {
|
||||
notifications.show({
|
||||
color: 'red',
|
||||
title: t('queue.exportFailed', 'Export failed'),
|
||||
message: extractErrorMessage(err),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const active = tab === 'unclaimed' ? queue : mine;
|
||||
const items = active.data?.items ?? [];
|
||||
const total = active.data?.total ?? 0;
|
||||
const pageCount = Math.max(1, Math.ceil(total / PAGE_SIZE));
|
||||
|
||||
const updateUrl = useCallback(
|
||||
(next: Partial<QueueFilter>, nextView: SavedViewId, nextPage: number) => {
|
||||
setSearchParams(
|
||||
searchParamsFromFilter({ ...urlFilter, ...next }, nextView, nextPage),
|
||||
{ replace: true },
|
||||
);
|
||||
},
|
||||
[urlFilter, setSearchParams],
|
||||
);
|
||||
|
||||
const changeView = (next: SavedViewId) => {
|
||||
setView(next);
|
||||
writeLastView(next);
|
||||
setPage(1);
|
||||
setSelected([]);
|
||||
updateUrl({}, next, 1);
|
||||
};
|
||||
|
||||
const setFacet = (next: Partial<QueueFilter>) => {
|
||||
setPage(1);
|
||||
updateUrl(next, view, 1);
|
||||
};
|
||||
|
||||
const toggleSort = (field: NonNullable<QueueFilter['sortBy']>) => {
|
||||
const dir =
|
||||
urlFilter.sortBy === field && urlFilter.sortDir !== 'DESC' ? 'DESC' : 'ASC';
|
||||
setFacet({ sortBy: field, sortDir: dir });
|
||||
};
|
||||
|
||||
async function handleClaim(id: string) {
|
||||
try {
|
||||
await claim(id).unwrap();
|
||||
notifications.show({
|
||||
color: 'teal',
|
||||
title: 'Claimed',
|
||||
message: 'The application is now assigned to you.',
|
||||
title: t('queue.claimed', 'Claimed'),
|
||||
message: t('queue.claimedBody', 'The application is now assigned to you.'),
|
||||
});
|
||||
setTab('mine');
|
||||
changeView('mine');
|
||||
} catch (err) {
|
||||
// A 409 means another officer got there first — refresh so the queue
|
||||
// stops showing work that is no longer available.
|
||||
notifications.show({
|
||||
color: 'red',
|
||||
title: 'Could not claim',
|
||||
message: extractErrorMessage(err, 'Another officer already claimed it.'),
|
||||
title: t('queue.claimFailed', 'Could not claim'),
|
||||
message: extractErrorMessage(
|
||||
err,
|
||||
t('queue.claimRace', 'Another officer already claimed it.'),
|
||||
),
|
||||
});
|
||||
queue.refetch();
|
||||
active.refetch();
|
||||
}
|
||||
}
|
||||
|
||||
async function handleBulkClaim() {
|
||||
const results = await Promise.allSettled(
|
||||
selected.map((id) => claim(id).unwrap()),
|
||||
);
|
||||
const claimed = results.filter((r) => r.status === 'fulfilled').length;
|
||||
const lost = results.length - claimed;
|
||||
notifications.show({
|
||||
color: lost ? 'yellow' : 'teal',
|
||||
title: t('queue.bulkClaimed', { count: claimed, defaultValue: '{{count}} claimed' }),
|
||||
// Partial success is the normal case in a shared queue, so it is
|
||||
// reported rather than swallowed or treated as total failure.
|
||||
message: lost
|
||||
? t('queue.bulkClaimPartial', {
|
||||
count: lost,
|
||||
defaultValue: '{{count}} were already taken by another officer.',
|
||||
})
|
||||
: '',
|
||||
});
|
||||
setSelected([]);
|
||||
active.refetch();
|
||||
}
|
||||
|
||||
const cursorRow = items[cursor];
|
||||
useQueueKeyboard({
|
||||
enabled: !helpOpen,
|
||||
onNext: () => setCursor((c) => Math.min(c + 1, Math.max(items.length - 1, 0))),
|
||||
onPrevious: () => setCursor((c) => Math.max(c - 1, 0)),
|
||||
onOpen: () => cursorRow && navigate(`/licence-review/${cursorRow.id}`),
|
||||
onClaim: () => {
|
||||
// Only unclaimed rows can be claimed; pressing c elsewhere is a no-op
|
||||
// rather than an error the officer has to read.
|
||||
if (cursorRow && cursorRow.assignedOfficerId === null) handleClaim(cursorRow.id);
|
||||
},
|
||||
onEscape: () => setSelected([]),
|
||||
onHelp: () => setHelpOpen(true),
|
||||
});
|
||||
|
||||
const allSelected = items.length > 0 && selected.length === items.length;
|
||||
const sortIcon =
|
||||
urlFilter.sortDir === 'DESC' ? <IconSortDescending size={13} /> : <IconSortAscending size={13} />;
|
||||
|
||||
const hasFacets = Boolean(
|
||||
urlFilter.status?.length ||
|
||||
urlFilter.licenseTypeId ||
|
||||
urlFilter.assignee ||
|
||||
urlFilter.submittedFrom ||
|
||||
debouncedSearch,
|
||||
);
|
||||
|
||||
return (
|
||||
<Container size="xl" py="md">
|
||||
<Container size="xl" py="md" pb={selected.length ? 80 : 'md'}>
|
||||
<Group justify="space-between" mb="md">
|
||||
<Title order={3}>Licence applications</Title>
|
||||
<Group>
|
||||
<TextInput
|
||||
placeholder="Company, TIN or number"
|
||||
leftSection={<IconSearch size={14} />}
|
||||
value={search}
|
||||
onChange={(e) => setSearch(e.currentTarget.value)}
|
||||
w={260}
|
||||
/>
|
||||
<div>
|
||||
<Title order={3}>{t('queue.title', 'Licence applications')}</Title>
|
||||
{typeCode && (
|
||||
<Text size="sm" c="dimmed">
|
||||
{t(`nav.type${typeCode}`, { defaultValue: typeCode })}
|
||||
</Text>
|
||||
)}
|
||||
</div>
|
||||
<Group gap="xs">
|
||||
<Tooltip label={t('queue.refresh', 'Refresh')}>
|
||||
<ActionIcon variant="default" size="lg" onClick={() => active.refetch()}>
|
||||
<IconRefresh size={18} />
|
||||
</ActionIcon>
|
||||
</Tooltip>
|
||||
<SegmentedControl
|
||||
value={tab}
|
||||
onChange={(v) => setTab(v as 'unclaimed' | 'mine')}
|
||||
size="xs"
|
||||
value={density}
|
||||
onChange={(v) => dispatch(setDensity(v as 'comfortable' | 'compact'))}
|
||||
data={[
|
||||
{ label: `Unclaimed (${queue.data?.total ?? 0})`, value: 'unclaimed' },
|
||||
{ label: `Mine (${mine.data?.total ?? 0})`, value: 'mine' },
|
||||
{ label: t('queue.comfortable', 'Comfortable'), value: 'comfortable' },
|
||||
{ label: t('queue.compact', 'Compact'), value: 'compact' },
|
||||
]}
|
||||
/>
|
||||
<Button
|
||||
variant="default"
|
||||
leftSection={<IconDownload size={16} />}
|
||||
onClick={handleExport}
|
||||
loading={exporting}
|
||||
disabled={total === 0}
|
||||
>
|
||||
{t('queue.export', 'Export CSV')}
|
||||
</Button>
|
||||
</Group>
|
||||
</Group>
|
||||
|
||||
{/* Saved views, counted. */}
|
||||
<Tabs value={view} onChange={(v) => changeView((v as SavedViewId) ?? DEFAULT_VIEW)} mb="sm">
|
||||
<Tabs.List>
|
||||
{SAVED_VIEWS.map((savedView) => (
|
||||
<Tabs.Tab
|
||||
key={savedView.id}
|
||||
value={savedView.id}
|
||||
rightSection={
|
||||
counts?.[savedView.countKey] ? (
|
||||
<Badge size="xs" variant="light" circle>
|
||||
{counts[savedView.countKey]}
|
||||
</Badge>
|
||||
) : undefined
|
||||
}
|
||||
>
|
||||
{t(savedView.labelKey)}
|
||||
</Tabs.Tab>
|
||||
))}
|
||||
</Tabs.List>
|
||||
</Tabs>
|
||||
|
||||
{/* Facets — every one of these is reflected in the URL. */}
|
||||
<Paper withBorder p="sm" mb="sm">
|
||||
<Group gap="sm" align="flex-end" wrap="wrap">
|
||||
<TextInput
|
||||
label={t('queue.search', 'Search')}
|
||||
placeholder={t('queue.searchPlaceholder', 'Company, TIN or number')}
|
||||
leftSection={<IconSearch size={14} />}
|
||||
value={searchInput}
|
||||
onChange={(e) => setSearchInput(e.currentTarget.value)}
|
||||
w={240}
|
||||
/>
|
||||
<MultiSelect
|
||||
label={t('queue.status', 'Status')}
|
||||
placeholder={t('queue.anyStatus', 'Any')}
|
||||
data={ALL_STATUSES.map((s) => ({ value: s, label: STATUS_LABELS[s] }))}
|
||||
value={urlFilter.status ?? []}
|
||||
onChange={(v) => setFacet({ status: v as LicenseStatus[] })}
|
||||
clearable
|
||||
w={240}
|
||||
/>
|
||||
{!typeCode && (
|
||||
<Select
|
||||
label={t('queue.type', 'Licence type')}
|
||||
placeholder={t('queue.anyType', 'Any')}
|
||||
data={(licenseTypes?.items ?? []).map((type) => ({
|
||||
value: type.id,
|
||||
label: type.name.en ?? type.key,
|
||||
}))}
|
||||
value={urlFilter.licenseTypeId ?? null}
|
||||
onChange={(v) => setFacet({ licenseTypeId: v ?? undefined })}
|
||||
clearable
|
||||
w={220}
|
||||
/>
|
||||
)}
|
||||
<TextInput
|
||||
type="date"
|
||||
label={t('queue.submittedFrom', 'Submitted from')}
|
||||
value={urlFilter.submittedFrom ?? ''}
|
||||
onChange={(e) => setFacet({ submittedFrom: e.currentTarget.value || undefined })}
|
||||
/>
|
||||
<TextInput
|
||||
type="date"
|
||||
label={t('queue.submittedTo', 'Submitted to')}
|
||||
value={urlFilter.submittedTo ?? ''}
|
||||
onChange={(e) => setFacet({ submittedTo: e.currentTarget.value || undefined })}
|
||||
/>
|
||||
{hasFacets && (
|
||||
<Button
|
||||
variant="subtle"
|
||||
leftSection={<IconX size={14} />}
|
||||
onClick={() => {
|
||||
setSearchInput('');
|
||||
setSearchParams(new URLSearchParams(), { replace: true });
|
||||
}}
|
||||
>
|
||||
{t('queue.clearFilters', 'Clear')}
|
||||
</Button>
|
||||
)}
|
||||
</Group>
|
||||
</Paper>
|
||||
|
||||
<Card withBorder padding={0}>
|
||||
{active.isLoading ? (
|
||||
<Center h={200}>
|
||||
<Loader />
|
||||
</Center>
|
||||
// Skeleton rows match the real table, so the layout does not jump
|
||||
// when data lands.
|
||||
<Stack gap={0} p="md">
|
||||
{Array.from({ length: 6 }).map((_, i) => (
|
||||
<Skeleton key={i} height={44} mb="xs" radius="sm" />
|
||||
))}
|
||||
</Stack>
|
||||
) : active.isError ? (
|
||||
<ErrorState
|
||||
title={t('queue.errorTitle', 'Could not load the queue')}
|
||||
description={extractErrorMessage(active.error)}
|
||||
onRetry={() => active.refetch()}
|
||||
icon={IconAlertCircle}
|
||||
/>
|
||||
) : items.length === 0 ? (
|
||||
<Center h={160}>
|
||||
<Text c="dimmed" size="sm">
|
||||
{tab === 'unclaimed'
|
||||
? 'No applications waiting to be claimed.'
|
||||
: 'You have no applications in progress.'}
|
||||
</Text>
|
||||
</Center>
|
||||
<EmptyState
|
||||
title={
|
||||
hasFacets
|
||||
? t('queue.emptyFiltered', 'No applications match these filters')
|
||||
: t('queue.empty', 'Nothing waiting here')
|
||||
}
|
||||
description={
|
||||
hasFacets
|
||||
? t('queue.emptyFilteredBody', 'Try widening or clearing the filters.')
|
||||
: t('queue.emptyBody', 'New applications will appear here as they are submitted.')
|
||||
}
|
||||
action={
|
||||
hasFacets
|
||||
? {
|
||||
label: t('queue.clearFilters', 'Clear'),
|
||||
onClick: () => setSearchParams(new URLSearchParams(), { replace: true }),
|
||||
}
|
||||
: undefined
|
||||
}
|
||||
/>
|
||||
) : (
|
||||
<Table highlightOnHover>
|
||||
<Table.Thead>
|
||||
<Table.Tr>
|
||||
<Table.Th>Number</Table.Th>
|
||||
<Table.Th>Company</Table.Th>
|
||||
<Table.Th>TIN</Table.Th>
|
||||
<Table.Th>Status</Table.Th>
|
||||
<Table.Th>Submitted</Table.Th>
|
||||
<Table.Th />
|
||||
</Table.Tr>
|
||||
</Table.Thead>
|
||||
<Table.Tbody>
|
||||
{items.map((app) => (
|
||||
<Table.Tr key={app.id}>
|
||||
<Table.Td>
|
||||
<Text size="sm" fw={500}>
|
||||
{app.applicationNumber}
|
||||
</Text>
|
||||
</Table.Td>
|
||||
<Table.Td>
|
||||
<Text size="sm">{app.companyName ?? '—'}</Text>
|
||||
</Table.Td>
|
||||
<Table.Td>
|
||||
<Text size="sm" c="dimmed">
|
||||
{app.tinNumber ?? '—'}
|
||||
</Text>
|
||||
</Table.Td>
|
||||
<Table.Td>
|
||||
<Badge color={STATUS_COLORS[app.status]} variant="light">
|
||||
{STATUS_LABELS[app.status]}
|
||||
</Badge>
|
||||
</Table.Td>
|
||||
<Table.Td>
|
||||
<Text size="sm" c="dimmed">
|
||||
{app.submittedAt
|
||||
? new Date(app.submittedAt).toLocaleDateString()
|
||||
: '—'}
|
||||
</Text>
|
||||
</Table.Td>
|
||||
<Table.Td align="right">
|
||||
{tab === 'unclaimed' ? (
|
||||
<Button
|
||||
size="xs"
|
||||
loading={claiming}
|
||||
onClick={() => handleClaim(app.id)}
|
||||
>
|
||||
Claim
|
||||
</Button>
|
||||
) : (
|
||||
<Button
|
||||
size="xs"
|
||||
variant="light"
|
||||
onClick={() => navigate(`/licence-review/${app.id}`)}
|
||||
>
|
||||
Review
|
||||
</Button>
|
||||
)}
|
||||
</Table.Td>
|
||||
</Table.Tr>
|
||||
))}
|
||||
</Table.Tbody>
|
||||
</Table>
|
||||
<>
|
||||
<Table.ScrollContainer minWidth={1100}>
|
||||
<Table highlightOnHover verticalSpacing={density === "compact" ? 4 : "sm"}>
|
||||
<Table.Thead>
|
||||
<Table.Tr>
|
||||
<Table.Th w={40}>
|
||||
<Checkbox
|
||||
aria-label={t('queue.selectAll', 'Select all')}
|
||||
checked={allSelected}
|
||||
indeterminate={selected.length > 0 && !allSelected}
|
||||
onChange={() =>
|
||||
setSelected(allSelected ? [] : items.map((a) => a.id))
|
||||
}
|
||||
/>
|
||||
</Table.Th>
|
||||
<SortableTh
|
||||
label={t('queue.number', 'App #')}
|
||||
field="applicationNumber"
|
||||
current={urlFilter.sortBy}
|
||||
icon={sortIcon}
|
||||
onSort={toggleSort}
|
||||
/>
|
||||
<SortableTh
|
||||
label={t('queue.company', 'Company')}
|
||||
field="companyName"
|
||||
current={urlFilter.sortBy}
|
||||
icon={sortIcon}
|
||||
onSort={toggleSort}
|
||||
/>
|
||||
<Table.Th>{t('queue.tin', 'TIN')}</Table.Th>
|
||||
<Table.Th>{t('queue.typeCol', 'Type')}</Table.Th>
|
||||
<SortableTh
|
||||
label={t('queue.statusCol', 'Status')}
|
||||
field="status"
|
||||
current={urlFilter.sortBy}
|
||||
icon={sortIcon}
|
||||
onSort={toggleSort}
|
||||
/>
|
||||
<SortableTh
|
||||
label={t('queue.submitted', 'Submitted')}
|
||||
field="submittedAt"
|
||||
current={urlFilter.sortBy}
|
||||
icon={sortIcon}
|
||||
onSort={toggleSort}
|
||||
/>
|
||||
<Table.Th>{t('queue.sla', 'Age / SLA')}</Table.Th>
|
||||
<Table.Th />
|
||||
</Table.Tr>
|
||||
</Table.Thead>
|
||||
<Table.Tbody>
|
||||
{items.map((app, index) => (
|
||||
<QueueRow
|
||||
key={app.id}
|
||||
app={app}
|
||||
focused={index === cursor}
|
||||
selected={selected.includes(app.id)}
|
||||
claiming={claiming}
|
||||
locale={i18n.language}
|
||||
onSelect={(checked) =>
|
||||
setSelected((prev) =>
|
||||
checked ? [...prev, app.id] : prev.filter((id) => id !== app.id),
|
||||
)
|
||||
}
|
||||
onClaim={() => handleClaim(app.id)}
|
||||
onOpen={() => navigate(`/licence-review/${app.id}`)}
|
||||
/>
|
||||
))}
|
||||
</Table.Tbody>
|
||||
</Table>
|
||||
</Table.ScrollContainer>
|
||||
|
||||
<Group justify="space-between" p="sm">
|
||||
<Text size="sm" c="dimmed">
|
||||
{t('queue.showing', {
|
||||
from: (page - 1) * PAGE_SIZE + 1,
|
||||
to: Math.min(page * PAGE_SIZE, total),
|
||||
total,
|
||||
defaultValue: 'Showing {{from}}–{{to}} of {{total}}',
|
||||
})}
|
||||
</Text>
|
||||
<Pagination
|
||||
value={page}
|
||||
onChange={(next) => {
|
||||
setPage(next);
|
||||
updateUrl({}, view, next);
|
||||
}}
|
||||
total={pageCount}
|
||||
size="sm"
|
||||
/>
|
||||
</Group>
|
||||
</>
|
||||
)}
|
||||
</Card>
|
||||
|
||||
<Modal
|
||||
opened={helpOpen}
|
||||
onClose={() => setHelpOpen(false)}
|
||||
title={t('shortcuts.title', 'Keyboard shortcuts')}
|
||||
size="sm"
|
||||
>
|
||||
<Stack gap="xs">
|
||||
{KEYBOARD_SHORTCUTS.map((shortcut) => (
|
||||
<Group key={shortcut.keys} justify="space-between">
|
||||
<Text size="sm">{t(shortcut.labelKey)}</Text>
|
||||
<Kbd>{shortcut.keys}</Kbd>
|
||||
</Group>
|
||||
))}
|
||||
</Stack>
|
||||
</Modal>
|
||||
|
||||
{/* Bulk bar. Floating, with the count stated so the scope of the action
|
||||
is never ambiguous. */}
|
||||
{selected.length > 0 && (
|
||||
<Paper
|
||||
withBorder
|
||||
shadow="md"
|
||||
p="sm"
|
||||
style={{ position: 'sticky', bottom: 16, zIndex: 50 }}
|
||||
>
|
||||
<Group justify="space-between">
|
||||
<Text size="sm" fw={500}>
|
||||
{t('queue.selectedCount', {
|
||||
count: selected.length,
|
||||
defaultValue: '{{count}} selected',
|
||||
})}
|
||||
</Text>
|
||||
<Group gap="xs">
|
||||
<Button variant="subtle" onClick={() => setSelected([])}>
|
||||
{t('common.cancel', 'Cancel')}
|
||||
</Button>
|
||||
<Button
|
||||
variant="default"
|
||||
leftSection={<IconDownload size={16} />}
|
||||
onClick={() =>
|
||||
exportApplicationsCsv(
|
||||
items.filter((a) => selected.includes(a.id)),
|
||||
i18n.language,
|
||||
)
|
||||
}
|
||||
>
|
||||
{t('queue.export', 'Export CSV')}
|
||||
</Button>
|
||||
<Button loading={claiming} onClick={handleBulkClaim}>
|
||||
{t('queue.bulkClaim', {
|
||||
count: selected.length,
|
||||
defaultValue: 'Claim {{count}}',
|
||||
})}
|
||||
</Button>
|
||||
</Group>
|
||||
</Group>
|
||||
</Paper>
|
||||
)}
|
||||
</Container>
|
||||
);
|
||||
}
|
||||
|
||||
function SortableTh({
|
||||
label,
|
||||
field,
|
||||
current,
|
||||
icon,
|
||||
onSort,
|
||||
}: {
|
||||
label: string;
|
||||
field: NonNullable<QueueFilter['sortBy']>;
|
||||
current?: QueueFilter['sortBy'];
|
||||
icon: React.ReactNode;
|
||||
onSort: (field: NonNullable<QueueFilter['sortBy']>) => void;
|
||||
}) {
|
||||
return (
|
||||
<Table.Th>
|
||||
<Group
|
||||
gap={4}
|
||||
wrap="nowrap"
|
||||
style={{ cursor: 'pointer' }}
|
||||
onClick={() => onSort(field)}
|
||||
>
|
||||
<span>{label}</span>
|
||||
{current === field && icon}
|
||||
</Group>
|
||||
</Table.Th>
|
||||
);
|
||||
}
|
||||
|
||||
function QueueRow({
|
||||
app,
|
||||
selected,
|
||||
focused,
|
||||
claiming,
|
||||
locale,
|
||||
onSelect,
|
||||
onClaim,
|
||||
onOpen,
|
||||
}: {
|
||||
app: LicenseApplication;
|
||||
selected: boolean;
|
||||
focused: boolean;
|
||||
claiming: boolean;
|
||||
locale: string;
|
||||
onSelect: (checked: boolean) => void;
|
||||
onClaim: () => void;
|
||||
onOpen: () => void;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const sla = computeSla(app);
|
||||
|
||||
return (
|
||||
<Table.Tr
|
||||
// Keyboard cursor. Marked with a left border rather than a background so
|
||||
// it stays distinguishable from row selection and from hover.
|
||||
style={
|
||||
focused
|
||||
? { boxShadow: 'inset 3px 0 0 var(--mantine-color-blue-6)' }
|
||||
: undefined
|
||||
}
|
||||
>
|
||||
<Table.Td>
|
||||
<Checkbox
|
||||
aria-label={t('queue.selectRow', { number: app.applicationNumber, defaultValue: 'Select {{number}}' })}
|
||||
checked={selected}
|
||||
onChange={(e) => onSelect(e.currentTarget.checked)}
|
||||
/>
|
||||
</Table.Td>
|
||||
<Table.Td>
|
||||
<Text size="sm" fw={500}>
|
||||
{app.applicationNumber}
|
||||
</Text>
|
||||
</Table.Td>
|
||||
<Table.Td>
|
||||
<Text size="sm">{app.companyName ?? '—'}</Text>
|
||||
</Table.Td>
|
||||
<Table.Td>
|
||||
<Text size="sm" c="dimmed">
|
||||
{app.tinNumber ?? '—'}
|
||||
</Text>
|
||||
</Table.Td>
|
||||
<Table.Td>
|
||||
<Text size="sm">{app.licenseType?.name?.en ?? '—'}</Text>
|
||||
</Table.Td>
|
||||
<Table.Td>
|
||||
<Badge color={STATUS_COLORS[app.status]} variant="light">
|
||||
{STATUS_LABELS[app.status]}
|
||||
</Badge>
|
||||
</Table.Td>
|
||||
<Table.Td>
|
||||
<Text size="sm" c="dimmed">
|
||||
{app.submittedAt ? new Date(app.submittedAt).toLocaleDateString(locale) : '—'}
|
||||
</Text>
|
||||
</Table.Td>
|
||||
<Table.Td>
|
||||
{/* Colour is never the only signal — the label says the same thing. */}
|
||||
<Tooltip label={sla.tooltip} withArrow>
|
||||
<Badge color={sla.color} variant="light" size="sm">
|
||||
{sla.label}
|
||||
</Badge>
|
||||
</Tooltip>
|
||||
</Table.Td>
|
||||
<Table.Td align="right">
|
||||
<Group gap="xs" justify="flex-end" wrap="nowrap">
|
||||
{app.assignedOfficerId === null && app.status === 'SUBMITTED' ? (
|
||||
<Button size="xs" loading={claiming} onClick={onClaim}>
|
||||
{t('queue.claim', 'Claim')}
|
||||
</Button>
|
||||
) : (
|
||||
<Button size="xs" variant="light" onClick={onOpen}>
|
||||
{t('queue.review', 'Review')}
|
||||
</Button>
|
||||
)}
|
||||
</Group>
|
||||
</Table.Td>
|
||||
</Table.Tr>
|
||||
);
|
||||
}
|
||||
|
||||
export default LicenseQueuePage;
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
152
apps/backoffice/src/app/features/license-review/queue-views.ts
Normal file
152
apps/backoffice/src/app/features/license-review/queue-views.ts
Normal file
@@ -0,0 +1,152 @@
|
||||
import type { LicenseStatus, QueueCounts, QueueFilter } from '@ema-platform/api';
|
||||
|
||||
export type SavedViewId =
|
||||
| 'unassigned'
|
||||
| 'mine'
|
||||
| 'awaitingApplicant'
|
||||
| 'overdue'
|
||||
| 'readyToIssue'
|
||||
| 'all';
|
||||
|
||||
export interface SavedView {
|
||||
id: SavedViewId;
|
||||
labelKey: string;
|
||||
/** Which count from `/counts` labels the tab. */
|
||||
countKey: keyof QueueCounts;
|
||||
/** Filters this view pins. The user's own facets layer on top. */
|
||||
filter: Partial<QueueFilter>;
|
||||
/** Which list endpoint backs it. */
|
||||
source: 'queue' | 'mine' | 'all';
|
||||
}
|
||||
|
||||
const AWAITING_APPLICANT: LicenseStatus[] = ['RESUBMIT_REQUIRED'];
|
||||
const READY_TO_ISSUE: LicenseStatus[] = ['PAYMENT_CONFIRMED'];
|
||||
|
||||
/**
|
||||
* The officer's saved views.
|
||||
*
|
||||
* Replaces a two-option SegmentedControl (Unclaimed / Mine) that could not
|
||||
* express the questions officers actually ask — what is late, what is waiting
|
||||
* on the applicant, what is ready to issue. Each is a filter preset over the
|
||||
* same grid rather than a separate screen.
|
||||
*/
|
||||
export const SAVED_VIEWS: SavedView[] = [
|
||||
{
|
||||
id: 'unassigned',
|
||||
labelKey: 'queue.views.unassigned',
|
||||
countKey: 'unassigned',
|
||||
filter: {},
|
||||
source: 'queue',
|
||||
},
|
||||
{
|
||||
id: 'mine',
|
||||
labelKey: 'queue.views.mine',
|
||||
countKey: 'mine',
|
||||
filter: {},
|
||||
source: 'mine',
|
||||
},
|
||||
{
|
||||
id: 'awaitingApplicant',
|
||||
labelKey: 'queue.views.awaitingApplicant',
|
||||
countKey: 'awaitingApplicant',
|
||||
filter: { status: AWAITING_APPLICANT },
|
||||
source: 'all',
|
||||
},
|
||||
{
|
||||
id: 'overdue',
|
||||
labelKey: 'queue.views.overdue',
|
||||
countKey: 'overdue',
|
||||
filter: { overdue: true },
|
||||
source: 'all',
|
||||
},
|
||||
{
|
||||
id: 'readyToIssue',
|
||||
labelKey: 'queue.views.readyToIssue',
|
||||
countKey: 'readyToIssue',
|
||||
filter: { status: READY_TO_ISSUE },
|
||||
source: 'all',
|
||||
},
|
||||
{
|
||||
id: 'all',
|
||||
labelKey: 'queue.views.all',
|
||||
countKey: 'all',
|
||||
filter: {},
|
||||
source: 'all',
|
||||
},
|
||||
];
|
||||
|
||||
export const DEFAULT_VIEW: SavedViewId = 'unassigned';
|
||||
|
||||
const LAST_VIEW_KEY = 'ema-backoffice-queue-view';
|
||||
|
||||
export function readLastView(): SavedViewId {
|
||||
try {
|
||||
const stored = localStorage.getItem(LAST_VIEW_KEY) as SavedViewId | null;
|
||||
return SAVED_VIEWS.some((v) => v.id === stored) ? (stored as SavedViewId) : DEFAULT_VIEW;
|
||||
} catch {
|
||||
return DEFAULT_VIEW;
|
||||
}
|
||||
}
|
||||
|
||||
export function writeLastView(id: SavedViewId): void {
|
||||
try {
|
||||
localStorage.setItem(LAST_VIEW_KEY, id);
|
||||
} catch {
|
||||
// Not persisting the last view is cosmetic; never break the page for it.
|
||||
}
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------ URL round-trip
|
||||
|
||||
/**
|
||||
* Reads the user's facets out of the query string.
|
||||
*
|
||||
* Filters live in the URL so a filtered queue is a shareable link — "here are
|
||||
* the six overdue MTO applications" should be something an officer can paste
|
||||
* into a message, not a state they describe in prose.
|
||||
*/
|
||||
export function filterFromSearchParams(params: URLSearchParams): QueueFilter {
|
||||
const filter: QueueFilter = {};
|
||||
const search = params.get('q');
|
||||
if (search) filter.search = search;
|
||||
const type = params.get('type');
|
||||
if (type) filter.licenseTypeId = type;
|
||||
const status = params.get('status');
|
||||
if (status) filter.status = status.split(',') as LicenseStatus[];
|
||||
const assignee = params.get('assignee');
|
||||
if (assignee) filter.assignee = assignee;
|
||||
const from = params.get('from');
|
||||
if (from) filter.submittedFrom = from;
|
||||
const to = params.get('to');
|
||||
if (to) filter.submittedTo = to;
|
||||
const sortBy = params.get('sort');
|
||||
if (sortBy) filter.sortBy = sortBy as QueueFilter['sortBy'];
|
||||
const sortDir = params.get('dir');
|
||||
if (sortDir === 'ASC' || sortDir === 'DESC') filter.sortDir = sortDir;
|
||||
const page = params.get('page');
|
||||
if (page) {
|
||||
const parsed = Number(page);
|
||||
if (Number.isFinite(parsed) && parsed > 0) filter.skip = undefined;
|
||||
}
|
||||
return filter;
|
||||
}
|
||||
|
||||
/** Inverse of {@link filterFromSearchParams}. Omits defaults to keep URLs short. */
|
||||
export function searchParamsFromFilter(
|
||||
filter: QueueFilter,
|
||||
view: SavedViewId,
|
||||
page: number,
|
||||
): URLSearchParams {
|
||||
const params = new URLSearchParams();
|
||||
if (view !== DEFAULT_VIEW) params.set('view', view);
|
||||
if (filter.search) params.set('q', filter.search);
|
||||
if (filter.licenseTypeId) params.set('type', filter.licenseTypeId);
|
||||
if (filter.status?.length) params.set('status', filter.status.join(','));
|
||||
if (filter.assignee) params.set('assignee', filter.assignee);
|
||||
if (filter.submittedFrom) params.set('from', filter.submittedFrom);
|
||||
if (filter.submittedTo) params.set('to', filter.submittedTo);
|
||||
if (filter.sortBy) params.set('sort', filter.sortBy);
|
||||
if (filter.sortDir && filter.sortDir !== 'ASC') params.set('dir', filter.sortDir);
|
||||
if (page > 1) params.set('page', String(page));
|
||||
return params;
|
||||
}
|
||||
89
apps/backoffice/src/app/features/license-review/sla.ts
Normal file
89
apps/backoffice/src/app/features/license-review/sla.ts
Normal file
@@ -0,0 +1,89 @@
|
||||
import type { LicenseApplication } from '@ema-platform/api';
|
||||
|
||||
/** Amber once this much of the window has been consumed. */
|
||||
const WARNING_RATIO = 0.7;
|
||||
|
||||
const HOUR_MS = 60 * 60 * 1000;
|
||||
|
||||
export interface SlaState {
|
||||
state: 'ok' | 'warning' | 'breached' | 'untracked' | 'decided';
|
||||
/** Mantine colour. Always paired with `label` — never colour alone. */
|
||||
color: string;
|
||||
/** Short text for the badge, e.g. "2d left" or "Overdue 6h". */
|
||||
label: string;
|
||||
/** The full explanation, including the target, for the tooltip. */
|
||||
tooltip: string;
|
||||
/** Fraction of the window used, clamped to 0..1. */
|
||||
ratio: number;
|
||||
}
|
||||
|
||||
function formatDuration(ms: number): string {
|
||||
const hours = Math.floor(Math.abs(ms) / HOUR_MS);
|
||||
if (hours < 1) return '<1h';
|
||||
if (hours < 48) return `${hours}h`;
|
||||
return `${Math.floor(hours / 24)}d`;
|
||||
}
|
||||
|
||||
/**
|
||||
* How an application is tracking against its licence type's SLA.
|
||||
*
|
||||
* Types with no `slaHours` are untracked rather than instantly overdue — the
|
||||
* authority has not set a target for them, which is not the same as missing
|
||||
* one. Decided applications stop the clock: an approval that took three weeks
|
||||
* is history, not an outstanding breach.
|
||||
*/
|
||||
export function computeSla(
|
||||
application: LicenseApplication,
|
||||
now: number = Date.now(),
|
||||
): SlaState {
|
||||
const slaHours = application.licenseType?.slaHours;
|
||||
const submittedAt = application.submittedAt;
|
||||
|
||||
if (!slaHours || !submittedAt) {
|
||||
return {
|
||||
state: 'untracked',
|
||||
color: 'gray',
|
||||
label: '—',
|
||||
tooltip: 'No turnaround target is set for this licence type.',
|
||||
ratio: 0,
|
||||
};
|
||||
}
|
||||
|
||||
const submitted = new Date(submittedAt).getTime();
|
||||
const target = submitted + slaHours * HOUR_MS;
|
||||
const elapsed = (application.decidedAt ? new Date(application.decidedAt).getTime() : now) - submitted;
|
||||
const window = slaHours * HOUR_MS;
|
||||
const ratio = Math.min(Math.max(elapsed / window, 0), 1);
|
||||
const targetText = `Target ${slaHours}h from submission (${new Date(target).toLocaleString()})`;
|
||||
|
||||
if (application.decidedAt) {
|
||||
const met = elapsed <= window;
|
||||
return {
|
||||
state: 'decided',
|
||||
color: met ? 'teal' : 'gray',
|
||||
label: met ? 'Met' : 'Missed',
|
||||
tooltip: `Decided in ${formatDuration(elapsed)}. ${targetText}`,
|
||||
ratio,
|
||||
};
|
||||
}
|
||||
|
||||
const remaining = target - now;
|
||||
if (remaining < 0) {
|
||||
return {
|
||||
state: 'breached',
|
||||
color: 'red',
|
||||
label: `Overdue ${formatDuration(remaining)}`,
|
||||
tooltip: `Overdue by ${formatDuration(remaining)}. ${targetText}`,
|
||||
ratio: 1,
|
||||
};
|
||||
}
|
||||
|
||||
const used = elapsed / window;
|
||||
return {
|
||||
state: used >= WARNING_RATIO ? 'warning' : 'ok',
|
||||
color: used >= WARNING_RATIO ? 'yellow' : 'teal',
|
||||
label: `${formatDuration(remaining)} left`,
|
||||
tooltip: `${formatDuration(remaining)} remaining. ${targetText}`,
|
||||
ratio,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,100 @@
|
||||
import { useEffect } from 'react';
|
||||
|
||||
/**
|
||||
* True when the user is typing, so a shortcut must not steal the keystroke.
|
||||
*
|
||||
* Without this, typing a company name into the search box would jump rows on
|
||||
* every "j" and try to claim on every "c".
|
||||
*/
|
||||
function isTyping(target: EventTarget | null): boolean {
|
||||
if (!(target instanceof HTMLElement)) return false;
|
||||
const tag = target.tagName;
|
||||
return (
|
||||
tag === 'INPUT' ||
|
||||
tag === 'TEXTAREA' ||
|
||||
tag === 'SELECT' ||
|
||||
target.isContentEditable
|
||||
);
|
||||
}
|
||||
|
||||
interface QueueKeyboardHandlers {
|
||||
onNext: () => void;
|
||||
onPrevious: () => void;
|
||||
onOpen: () => void;
|
||||
onClaim: () => void;
|
||||
onEscape: () => void;
|
||||
onHelp: () => void;
|
||||
/** Disabled while a modal or drawer owns the keyboard. */
|
||||
enabled?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Queue keyboard navigation: j/k to move, Enter to open, c to claim.
|
||||
*
|
||||
* Officers work through a queue one row at a time all day; reaching for the
|
||||
* mouse for each is the slow path. Modifier combinations are ignored so
|
||||
* browser and OS shortcuts keep working.
|
||||
*/
|
||||
export function useQueueKeyboard({
|
||||
onNext,
|
||||
onPrevious,
|
||||
onOpen,
|
||||
onClaim,
|
||||
onEscape,
|
||||
onHelp,
|
||||
enabled = true,
|
||||
}: QueueKeyboardHandlers): void {
|
||||
useEffect(() => {
|
||||
if (!enabled) return;
|
||||
|
||||
const handler = (event: KeyboardEvent) => {
|
||||
if (isTyping(event.target)) {
|
||||
// Esc still works while typing — it is how you get out of the field.
|
||||
if (event.key === 'Escape') onEscape();
|
||||
return;
|
||||
}
|
||||
if (event.metaKey || event.ctrlKey || event.altKey) return;
|
||||
|
||||
switch (event.key) {
|
||||
case 'j':
|
||||
event.preventDefault();
|
||||
onNext();
|
||||
break;
|
||||
case 'k':
|
||||
event.preventDefault();
|
||||
onPrevious();
|
||||
break;
|
||||
case 'Enter':
|
||||
event.preventDefault();
|
||||
onOpen();
|
||||
break;
|
||||
case 'c':
|
||||
event.preventDefault();
|
||||
onClaim();
|
||||
break;
|
||||
case 'Escape':
|
||||
onEscape();
|
||||
break;
|
||||
case '?':
|
||||
event.preventDefault();
|
||||
onHelp();
|
||||
break;
|
||||
default:
|
||||
break;
|
||||
}
|
||||
};
|
||||
|
||||
window.addEventListener('keydown', handler);
|
||||
return () => window.removeEventListener('keydown', handler);
|
||||
}, [enabled, onNext, onPrevious, onOpen, onClaim, onEscape, onHelp]);
|
||||
}
|
||||
|
||||
/** Rows shown in the `?` cheatsheet. */
|
||||
export const KEYBOARD_SHORTCUTS: Array<{ keys: string; labelKey: string }> = [
|
||||
{ keys: '⌘K', labelKey: 'shortcuts.commandPalette' },
|
||||
{ keys: 'j / k', labelKey: 'shortcuts.moveRow' },
|
||||
{ keys: 'Enter', labelKey: 'shortcuts.openRow' },
|
||||
{ keys: 'c', labelKey: 'shortcuts.claimRow' },
|
||||
{ keys: 'Esc', labelKey: 'shortcuts.dismiss' },
|
||||
{ keys: '?', labelKey: 'shortcuts.help' },
|
||||
];
|
||||
@@ -123,7 +123,7 @@ function TreeNode({
|
||||
)}
|
||||
</Box>
|
||||
)}
|
||||
{!hasChildren && <Box w={rem(18)} flexShrink={0} />}
|
||||
{!hasChildren && <Box w={rem(18)} style={{ flexShrink: 0 }} />}
|
||||
<IconMapPin
|
||||
size={14}
|
||||
stroke={1.5}
|
||||
|
||||
@@ -16,6 +16,21 @@ export const am: Translations = {
|
||||
|
||||
nav: {
|
||||
groupLicensing: 'ፈቃድ አሰጣጥ',
|
||||
allApplications: 'ሁሉም ማመልከቻዎች',
|
||||
certificateDesigner: 'የምስክር ወረቀት ንድፍ',
|
||||
byType: 'በዓይነት',
|
||||
typeFreightForwarder: 'የጭነት አስተላላፊ',
|
||||
typeShippingAgent: 'የመርከብ ወኪል',
|
||||
typeCombined: 'ጥምር የመርከብ ወኪል እና ጭነት አስተላላፊ',
|
||||
typeJointInvestment: 'የጋራ ኢንቨስትመንት',
|
||||
typeMto: 'የብዝሃ-ሁነታ ትራንስፖርት አንቀሳቃሽ',
|
||||
primary: 'ዋና',
|
||||
destinations: 'ወደ',
|
||||
noResults: 'ምንም አልተገኘም',
|
||||
commandPlaceholder: 'ማያ ገጾችን፣ ማመልከቻዎችን፣ ኩባንያዎችን፣ ቲን ይፈልጉ…',
|
||||
pending: 'በመጠባበቅ ላይ ያሉ',
|
||||
pendingCount_one: '{{count}} በመጠባበቅ ላይ',
|
||||
pendingCount_other: '{{count}} በመጠባበቅ ላይ',
|
||||
groupSeafarer: 'የመርከበኞች አገልግሎት',
|
||||
groupVessels: 'መርከቦች',
|
||||
groupExaminations: 'ፈተናዎች',
|
||||
@@ -602,4 +617,293 @@ export const am: Translations = {
|
||||
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: {
|
||||
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',
|
||||
groupVessels: 'Vessels',
|
||||
groupExaminations: 'Examinations',
|
||||
@@ -601,6 +616,295 @@ export const en = {
|
||||
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;
|
||||
|
||||
@@ -1,99 +1,28 @@
|
||||
import { useCallback, useState } from 'react';
|
||||
import { AppShell, rem } from '@mantine/core';
|
||||
import { useCallback, useMemo, useState } from 'react';
|
||||
import { AppShell } from '@mantine/core';
|
||||
import { useDisclosure } from '@mantine/hooks';
|
||||
import { Outlet, useLocation, useNavigate } from 'react-router-dom';
|
||||
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 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 { 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 { 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
|
||||
* actually works: `soon` marks screens with no backend behind them yet.
|
||||
* How often the pending-work badges refresh.
|
||||
*
|
||||
* 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[] = [
|
||||
{
|
||||
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 BADGE_POLL_MS = 60_000;
|
||||
|
||||
const HEADER_HEIGHT = 116;
|
||||
|
||||
@@ -106,6 +35,44 @@ export function BackofficeLayout() {
|
||||
const [collapsed, setCollapsed] = useState(false);
|
||||
const user = useAppSelector((state) => state.auth.user);
|
||||
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 initials = displayName
|
||||
@@ -128,7 +95,7 @@ export function BackofficeLayout() {
|
||||
.filter((path) => path !== '/dashboard')
|
||||
.filter((path) => !path.startsWith('/um'))
|
||||
.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 };
|
||||
const segment = path.split('/').pop() ?? '';
|
||||
// Ids get a generic label rather than a raw uuid in the trail.
|
||||
@@ -195,58 +162,19 @@ export function BackofficeLayout() {
|
||||
style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: rem(2),
|
||||
padding: '0 32px',
|
||||
height: 42,
|
||||
borderTop: '1px solid var(--mantine-color-gray-1)',
|
||||
overflowX: 'auto',
|
||||
flexShrink: 0,
|
||||
}}
|
||||
>
|
||||
{NAV_ITEMS.map((item) => {
|
||||
const active = !!item.to && (location.pathname === item.to || location.pathname.startsWith(`${item.to}/`));
|
||||
const ItemIcon = item.icon;
|
||||
return (
|
||||
<button
|
||||
key={item.to}
|
||||
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>
|
||||
);
|
||||
})}
|
||||
{/* Grouped dropdowns. Previously every destination rendered as a
|
||||
sibling button in one horizontally scrolling row. */}
|
||||
<AppTopNav
|
||||
navItems={sections}
|
||||
activePath={location.pathname}
|
||||
onNavigate={go}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</AppShell.Header>
|
||||
@@ -262,13 +190,14 @@ export function BackofficeLayout() {
|
||||
}}
|
||||
>
|
||||
<AppSidebar
|
||||
navItems={NAV_SECTIONS}
|
||||
navItems={sections}
|
||||
collapsed={collapsed}
|
||||
activePath={location.pathname}
|
||||
onToggleCollapse={handleToggleCollapse}
|
||||
onNavigate={go}
|
||||
brandName={t('app.name')}
|
||||
brandSubtitle={t('app.authority')}
|
||||
brandLogo={<BrandMark size={32} />}
|
||||
/>
|
||||
</AppShell.Navbar>
|
||||
)}
|
||||
@@ -278,6 +207,9 @@ export function BackofficeLayout() {
|
||||
<Outlet />
|
||||
</div>
|
||||
</AppShell.Main>
|
||||
|
||||
{/* Registered once for the whole backoffice; opens on ⌘K anywhere. */}
|
||||
<CommandPalette sections={sections} />
|
||||
</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 { WaiverReviewPage } from '../features/waiver/pages/WaiverReviewPage';
|
||||
import { LogisticsHeadDashboardPage } from '../features/logistics-head/pages/LogisticsHeadDashboardPage';
|
||||
import { CertificateDesignerPage } from '../features/certificate-designer/pages/CertificateDesignerPage';
|
||||
|
||||
const router = createBrowserRouter([
|
||||
{
|
||||
@@ -89,7 +90,11 @@ const router = createBrowserRouter([
|
||||
{ path: 'vessel-ownership-transfer', element: <VesselOwnershipTransferQueuePage /> },
|
||||
{ path: 'vessel-ownership-transfer/:id', element: <VesselOwnershipTransferReviewPage /> },
|
||||
// Config-driven review workspace, shared by every licence type.
|
||||
{ path: 'certificate-designer', element: <CertificateDesignerPage /> },
|
||||
{ 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: 'freight-forwarder-license', 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';
|
||||
|
||||
/**
|
||||
* 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 {
|
||||
layoutMode: LayoutMode;
|
||||
density: Density;
|
||||
}
|
||||
|
||||
const PREFERENCES_KEY = 'ema-backoffice-preferences';
|
||||
@@ -11,17 +18,25 @@ const PREFERENCES_KEY = 'ema-backoffice-preferences';
|
||||
const loadPreferences = (): PreferencesState => {
|
||||
try {
|
||||
const stored = localStorage.getItem(PREFERENCES_KEY);
|
||||
if (stored) return JSON.parse(stored);
|
||||
} catch {}
|
||||
if (stored) {
|
||||
// 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
|
||||
// ~20 destinations in one horizontally-scrolling row.
|
||||
return { layoutMode: 'sidebar' };
|
||||
return { layoutMode: 'sidebar', density: 'comfortable' };
|
||||
};
|
||||
|
||||
const savePreferences = (state: PreferencesState) => {
|
||||
try {
|
||||
localStorage.setItem(PREFERENCES_KEY, JSON.stringify(state));
|
||||
} catch {}
|
||||
} catch {
|
||||
// Storage full or unavailable — the preference just will not persist.
|
||||
}
|
||||
};
|
||||
|
||||
const initialState: PreferencesState = loadPreferences();
|
||||
@@ -34,8 +49,12 @@ const preferencesSlice = createSlice({
|
||||
state.layoutMode = action.payload;
|
||||
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;
|
||||
|
||||
@@ -3,10 +3,32 @@ import { createRoot } from 'react-dom/client';
|
||||
import '@mantine/core/styles.css';
|
||||
import '@mantine/notifications/styles.css';
|
||||
import '@mantine/dates/styles.css';
|
||||
import '@mantine/spotlight/styles.css';
|
||||
import './styles.css';
|
||||
import './app/i18n/config';
|
||||
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';
|
||||
|
||||
const _favicon = document.querySelector<HTMLLinkElement>('link[rel="icon"]');
|
||||
|
||||
@@ -4,3 +4,51 @@
|
||||
@tailwind utilities;
|
||||
|
||||
*, *::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,
|
||||
IconShieldCheck,
|
||||
} from '@tabler/icons-react';
|
||||
import { ProfileCompletionNudge } from '../../profile/components/ProfileCompletionNudge';
|
||||
import {
|
||||
APPLICANT_ACTION_STATUSES,
|
||||
STATUS_COLORS,
|
||||
@@ -129,6 +130,9 @@ export function DashboardPage() {
|
||||
licenseCount={activeLicenses.length}
|
||||
/>
|
||||
|
||||
{/* A prompt, not a gate — dismissible and it never blocks the page. */}
|
||||
<ProfileCompletionNudge />
|
||||
|
||||
{needsMe.length > 0 && (
|
||||
<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) {
|
||||
try {
|
||||
const { url } = await getCertificateUrl(licenseId).unwrap();
|
||||
@@ -234,6 +257,19 @@ export function MyApplicationsPage() {
|
||||
Bypass payment
|
||||
</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
|
||||
size="xs"
|
||||
loading={isPaying && app.status === 'PAYMENT_PENDING'}
|
||||
@@ -266,9 +302,8 @@ export function MyApplicationsPage() {
|
||||
? 'Fix & resubmit'
|
||||
: app.status === 'PAYMENT_PENDING'
|
||||
? `Pay ${Number(app.feeAmount ?? 0).toLocaleString()} ${app.feeCurrency}`
|
||||
: app.status === 'PAYMENT_CONFIRMED' ||
|
||||
app.status === 'PAID'
|
||||
? 'View'
|
||||
: app.status === 'CERTIFICATE_ISSUED'
|
||||
? 'Application'
|
||||
: 'View'}
|
||||
</Button>
|
||||
</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 {
|
||||
Badge,
|
||||
Box,
|
||||
@@ -9,6 +9,7 @@ import {
|
||||
Loader,
|
||||
Paper,
|
||||
PasswordInput,
|
||||
RingProgress,
|
||||
SimpleGrid,
|
||||
Stack,
|
||||
Switch,
|
||||
@@ -16,6 +17,7 @@ import {
|
||||
Text,
|
||||
TextInput,
|
||||
Title,
|
||||
Tooltip,
|
||||
UnstyledButton,
|
||||
useMantineColorScheme,
|
||||
type MantineColorScheme,
|
||||
@@ -39,13 +41,14 @@ import {
|
||||
IconUser,
|
||||
IconUserCircle,
|
||||
} from '@tabler/icons-react';
|
||||
import { useLocation } from 'react-router-dom';
|
||||
import { useForm } from 'react-hook-form';
|
||||
import { zodResolver } from '@hookform/resolvers/zod';
|
||||
import { z } from 'zod';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { notify, PageHeader } from '@ema-platform/ui';
|
||||
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 { SUPPORTED_LANGUAGES, type AppLanguage } from '../../../i18n/config';
|
||||
import { useAppDispatch, useAppSelector } from '../../../store/hooks';
|
||||
@@ -62,6 +65,9 @@ import {
|
||||
} from '../components/AddressFormContent';
|
||||
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) {
|
||||
const source = name?.trim() || fallback?.trim() || '';
|
||||
if (!source) return '?';
|
||||
@@ -84,7 +90,7 @@ export function ProfilePage() {
|
||||
const { t, i18n } = useTranslation();
|
||||
const dispatch = useAppDispatch();
|
||||
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 [updateTrigger] = useApiMutation<AuthUser>();
|
||||
@@ -126,8 +132,17 @@ export function ProfilePage() {
|
||||
return map;
|
||||
}, [professions]);
|
||||
|
||||
// ---- Profile data (from stored currentProfile) ----
|
||||
const [fetchProfile] = useApiMutation<{ total: number; items: CurrentProfile[] }>();
|
||||
// ---- Profile data ----
|
||||
// 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 [updateAddress] = useApiMutation<unknown>();
|
||||
|
||||
@@ -136,9 +151,28 @@ export function ProfilePage() {
|
||||
const [profileId, setProfileId] = useState<string | null>(null);
|
||||
const [addressId, setAddressId] = useState<string | null>(null);
|
||||
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(() => {
|
||||
// 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) {
|
||||
setProfileId(currentProfile.id);
|
||||
setLoadedProfile({
|
||||
@@ -177,29 +211,12 @@ export function ProfilePage() {
|
||||
});
|
||||
}
|
||||
setDataLoading(false);
|
||||
} else if (user && !profileFetched.current) {
|
||||
profileFetched.current = true;
|
||||
const profileId = authStorage.getProfileId();
|
||||
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 {
|
||||
} else if (!profileResolving) {
|
||||
// Resolver finished and there is still nothing — render the empty form
|
||||
// rather than an indefinite spinner.
|
||||
setDataLoading(false);
|
||||
}
|
||||
}, [currentProfile, user, fetchProfile, dispatch]);
|
||||
}, [resolvedProfile, storedProfile, profileResolving]);
|
||||
|
||||
// Load the latest user from the server on mount
|
||||
useEffect(() => {
|
||||
@@ -209,7 +226,9 @@ export function ProfilePage() {
|
||||
.then((me) => {
|
||||
if (active) dispatch(setUser(me));
|
||||
})
|
||||
.catch(() => {});
|
||||
.catch(() => {
|
||||
// Best-effort refresh; the store already holds the user from sign-in.
|
||||
});
|
||||
return () => { active = false; };
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, []);
|
||||
@@ -449,12 +468,50 @@ export function ProfilePage() {
|
||||
{user.username}
|
||||
</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>
|
||||
</Paper>
|
||||
|
||||
{/* 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"
|
||||
classNames={{ list: classes.list, tab: classes.tab }}
|
||||
>
|
||||
|
||||
@@ -29,7 +29,6 @@ import {
|
||||
IconTransferIn,
|
||||
} from '@tabler/icons-react';
|
||||
import { useApiMutation } from '@ema-platform/api';
|
||||
import { authStorage } from '@ema-platform/auth';
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Types
|
||||
@@ -124,9 +123,12 @@ export function VesselRegistrationPage() {
|
||||
const [fetchTrigger] = useApiMutation<VesselRegistration>();
|
||||
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(() => {
|
||||
const profileId = authStorage.getProfileId();
|
||||
if (!profileId || fetched.current) return;
|
||||
if (fetched.current) return;
|
||||
fetched.current = true;
|
||||
fetchTrigger({ url: '/vessel-registrations/my', method: 'GET' })
|
||||
.unwrap()
|
||||
|
||||
@@ -89,6 +89,56 @@ export const am: Translations = {
|
||||
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: {
|
||||
title: 'መገለጫዬ',
|
||||
subtitle: 'የመለያ ዝርዝሮችዎንና ምርጫዎችዎን ያስተዳድሩ።',
|
||||
|
||||
@@ -87,6 +87,56 @@ export const en = {
|
||||
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: {
|
||||
title: 'My Profile',
|
||||
subtitle: 'Manage your account details and preferences.',
|
||||
|
||||
@@ -20,7 +20,7 @@ import { useTranslation } from 'react-i18next';
|
||||
import { useDispatch } from 'react-redux';
|
||||
import { notify, AppHeader, AppSidebar } 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 { useAppSelector } from '../store/hooks';
|
||||
|
||||
@@ -179,6 +179,7 @@ export function PortalLayout() {
|
||||
onNavigate={go}
|
||||
brandName={t('app.name')}
|
||||
brandSubtitle={t('app.authority')}
|
||||
brandLogo={<BrandMark size={32} />}
|
||||
/>
|
||||
</AppShell.Navbar>
|
||||
|
||||
|
||||
@@ -6,7 +6,7 @@ import { useTranslation } from 'react-i18next';
|
||||
import { useDispatch } from 'react-redux';
|
||||
import { notify, AppHeader, AppSidebar } 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 { useAppSelector } from '../store/hooks';
|
||||
|
||||
@@ -66,6 +66,7 @@ export function VesselOwnerLayout() {
|
||||
onNavigate={go}
|
||||
brandName="Vessel Owner Portal"
|
||||
brandSubtitle="EMAA"
|
||||
brandLogo={<BrandMark size={32} />}
|
||||
/>
|
||||
</AppShell.Navbar>
|
||||
|
||||
|
||||
@@ -7,9 +7,6 @@ import { ProtectedRoute } from './components/ProtectedRoute';
|
||||
// Auth (standalone pages, no portal chrome)
|
||||
import { LoginPage, SignupPage, OTPVerificationPage, ForgotPasswordPage } from '@ema-platform/auth';
|
||||
|
||||
// Profile setup
|
||||
import { ProfileSetupPage } from './features/profile-setup/pages/ProfileSetupPage';
|
||||
|
||||
// Portal feature pages
|
||||
import { DashboardPage } from './features/dashboard/pages/DashboardPage';
|
||||
import { ProfilePage } from './features/profile/pages/ProfilePage';
|
||||
@@ -59,16 +56,13 @@ export const router = createBrowserRouter([
|
||||
element: <ProtectedRoute><ForgotPasswordPage /></ProtectedRoute>,
|
||||
path: '/forgot-password',
|
||||
},
|
||||
{
|
||||
element: <ProtectedRoute><ProfileSetupPage /></ProtectedRoute>,
|
||||
path: '/profile-setup',
|
||||
},
|
||||
// The two-step setup wizard is gone. Signing up lands on the dashboard, and
|
||||
// profile details are collected where they are actually needed: on /profile,
|
||||
// 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.
|
||||
// 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: (
|
||||
<ProtectedRoute>
|
||||
|
||||
Reference in New Issue
Block a user