Files
emaui/apps/backoffice/src/app/features/license-review/pages/LicenseReviewPage/index.tsx
fitse-yotor 0ac75669d4 feat: add ExamStageActions component for exam fee handling
- Implemented ExamStageActions component to manage actions related to exam booking and payment based on application status.
- Added mock-base-query for development, providing a partial mock backend for various API endpoints.
- Introduced mock-data for simulating responses in the mock-base-query, covering profiles, vessels, applications, licenses, exams, and notifications.
2026-08-15 11:51:14 +03:00

1146 lines
43 KiB
TypeScript

import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { useParams } from 'react-router-dom';
import {
ActionIcon,
Alert,
Badge,
Card,
Checkbox,
Container,
Grid,
Group,
Modal,
NumberInput,
Paper,
SegmentedControl,
Skeleton,
Stack,
Table,
Tabs,
Text,
Textarea,
TextInput,
ThemeIcon,
Timeline,
Title,
Tooltip,
} from '@mantine/core';
import {
IconAlertTriangle,
IconCheck,
IconLayoutSidebarRightCollapse,
IconLayoutSidebarRightExpand,
IconQuestionMark,
IconX,
} from '@tabler/icons-react';
import { notifications } from '@mantine/notifications';
import { useTranslation } from 'react-i18next';
import {
STATUS_COLORS,
STATUS_LABELS,
extractErrorMessage,
useLocalized,
useApproveDocumentsMutation,
useAssignApplicationMutation,
useCompleteReviewMutation,
useConfirmPaymentMutation,
useScheduleExamMutation,
useEscalateApplicationMutation,
useFinalApproveMutation,
useGetApplicationForReviewQuery,
useGetAttachmentsQuery,
useGetInspectionsQuery,
useGetAssignableOfficersQuery,
useGetLicenseTypeRequirementsQuery,
useHoldApplicationMutation,
useRecordInspectionResultMutation,
useRejectApplicationMutation,
useRequestAdjustmentMutation,
useResumeApplicationMutation,
useScheduleInspectionMutation,
type RemarkTargetType,
} from '@ema-platform/api';
import {
AdvancedTable,
AmharicDatePicker,
ErrorState,
ModalFooter,
useServerTable,
} from '@ema-platform/ui';
import { useDateDisplayer } from '@ema-platform/shared';
import { usePermissions } from '@ema-platform/auth';
import { useAppSelector } from '../../../../store/hooks';
import { DecisionBar } from '../../components/DecisionBar';
import {
DecisionConfirmModal,
type DecisionSubmission,
} from '../../components/DecisionConfirmModal';
import { ActivityRail } from '../../components/ActivityRail';
import { DocumentsTab } from '../../components/DocumentsTab';
import { ScheduleExamModal } from '../../components/ScheduleExamModal';
import { computeSla } from '../../sla';
import { reviewStaffColumns } from './columns';
import { evaluateEligibility, presentationFor } from '../../config/license-types';
import { resolveActions, type ActionId, type ResolvedAction } from '../../config/actions';
type FlagMap = Record<string, { targetType: RemarkTargetType; remark: string }>;
/**
* Standard site-visit checklist (US-LOG-016 / US-VES-007). The inspection
* entity has carried a checklist column since day one; this is the first UI
* that fills it in.
*/
const INSPECTION_CHECKLIST_ITEMS = [
{ key: 'office_premises', labelKey: 'review.checklist.officePremises', fallback: 'Office premises' },
{ key: 'storage_facilities', labelKey: 'review.checklist.storageFacilities', fallback: 'Warehouse / storage facilities' },
{ key: 'vehicles_equipment', labelKey: 'review.checklist.vehiclesEquipment', fallback: 'Vehicles / equipment' },
{ key: 'safety_compliance', labelKey: 'review.checklist.safetyCompliance', fallback: 'Safety & regulatory compliance' },
] as const;
function buildChecklist(
outcomes: Record<string, 'PASS' | 'FAIL' | 'NEEDS_CORRECTION'>,
) {
return INSPECTION_CHECKLIST_ITEMS.map((item) => ({
key: item.key,
// Persisted as-is (stable English), not the officer's display language —
// this is an audit record, not UI text.
label: item.fallback,
// Untouched rows default to PASS — the segmented control shows exactly
// that, so what the officer saw is what gets recorded.
outcome: outcomes[item.key] ?? 'PASS',
}));
}
/**
* The officer's review workspace.
*
* Three zones: a sticky left rail carrying the summary and eligibility, a
* centre column of tabs driven by the licence type's sections, and a
* collapsible activity trail on the right. Every decision goes through the
* Decision Bar pinned to the bottom, so an officer can act from any scroll
* position instead of scrolling back to a column of buttons.
*/
export function LicenseReviewPage() {
const { t, i18n } = useTranslation();
const showDate = useDateDisplayer();
const localized = useLocalized();
const { id = '' } = useParams();
const { can } = usePermissions();
const currentUserId = useAppSelector((state) => state.auth.user?.id) ?? '';
const { data, isLoading, isError, error, refetch } = useGetApplicationForReviewQuery(id, {
skip: !id,
});
const { data: inspections = [], refetch: refetchInspections } = useGetInspectionsQuery(id, {
skip: !id,
});
const { data: requirements } = useGetLicenseTypeRequirementsQuery(
{ idOrKey: data?.application.licenseTypeId ?? '', kind: data?.application.kind ?? 'NEW' },
{ skip: !data?.application.licenseTypeId },
);
// Staff role names are already bilingual on the config — the wire data only
// carries the role key (e.g. 'CAPTAIN'), so this is what turns it back into
// the label an officer reads.
const roleNameByKey = useMemo(
() => new Map(requirements?.staffRoleRequirements.map((r) => [r.roleKey, r.name]) ?? []),
[requirements],
);
const [completeReview] = useCompleteReviewMutation();
const [requestAdjustment] = useRequestAdjustmentMutation();
const [approveDocuments] = useApproveDocumentsMutation();
const [finalApprove] = useFinalApproveMutation();
const [rejectApplication] = useRejectApplicationMutation();
const [scheduleInspection] = useScheduleInspectionMutation();
const [recordResult] = useRecordInspectionResultMutation();
const [confirmPayment] = useConfirmPaymentMutation();
const [scheduleExam, { isLoading: schedulingExam }] = useScheduleExamMutation();
const [holdApplication] = useHoldApplicationMutation();
const [resumeApplication] = useResumeApplicationMutation();
const [escalateApplication] = useEscalateApplicationMutation();
const [assignApplication] = useAssignApplicationMutation();
// Real officer list, so Assign and Escalate name a person instead of
// silently reassigning to whoever already held the application.
const { data: officers = [] } = useGetAssignableOfficersQuery();
const staffTable = useServerTable();
const [flags, setFlags] = useState<FlagMap>({});
const [capital, setCapital] = useState<number | undefined>();
const [pendingAction, setPendingAction] = useState<ResolvedAction | null>(null);
const [busyAction, setBusyAction] = useState<ActionId | null>(null);
const [railOpen, setRailOpen] = useState(true);
const [inspectionOpen, setInspectionOpen] = useState(false);
const [inspectionDate, setInspectionDate] = useState('');
const [resultOpen, setResultOpen] = useState(false);
const [scheduleExamOpen, setScheduleExamOpen] = useState(false);
const [findings, setFindings] = useState('');
const [checklist, setChecklist] = useState<
Record<string, 'PASS' | 'FAIL' | 'NEEDS_CORRECTION'>
>({});
// Prefill from whatever is recorded, else the declared figure, so the officer
// confirms a number rather than retyping it. Runs before the early return
// below because hooks must be called unconditionally.
const seeded = useRef(false);
const loadedApp = data?.application;
useEffect(() => {
if (seeded.current || !loadedApp) return;
const existing = loadedApp.capitalAmountVerified ?? loadedApp.capitalAmountDeclared;
if (existing != null) setCapital(Number(existing));
seeded.current = true;
}, [loadedApp]);
const toggleFlag = useCallback((targetType: RemarkTargetType, key: string) => {
setFlags((prev) => {
const next = { ...prev };
if (next[key]) delete next[key];
else next[key] = { targetType, remark: '' };
return next;
});
}, []);
const documentFlags = useMemo(() => {
const map: Record<string, string> = {};
for (const [key, flag] of Object.entries(flags)) {
if (flag.targetType === 'DOCUMENT') map[key] = flag.remark;
}
return map;
}, [flags]);
const pendingInspection = inspections.find((i) => i.status === 'SCHEDULED');
const flagged = Object.entries(flags);
/**
* The flagged items as the officer should see them in the confirmation.
*
* Only a document key reads acceptably on its own. A form section is
* camelCase, and a staff flag is keyed by the ApplicationStaff id — showing
* a uuid in the checklist would make the list impossible to tick with
* confidence.
*/
const flaggedItems = useMemo(
() =>
flagged.map(([key, flag]) => {
if (flag.targetType === 'STAFF') {
const member = data?.staff.find((s) => s.id === key);
return {
key,
label: member
? `${localized(roleNameByKey.get(member.roleKey)) || member.roleKey}${member.fullName}`
: t('review.staffMember', 'Staff member'),
};
}
if (flag.targetType === 'FORM_SECTION') {
return { key, label: key.replace(/([A-Z])/g, ' $1').trim() };
}
return { key, label: key };
}),
// eslint-disable-next-line react-hooks/exhaustive-deps
[flags, data?.staff, t, localized, roleNameByKey],
);
const actions = useMemo(() => {
if (!data) return [];
return resolveActions({
detail: data,
currentUserId,
can,
flaggedCount: flagged.length,
hasPendingInspection: Boolean(pendingInspection),
reasons: {
wrongStatus: t('review.disabled.wrongStatus', 'Not available at this stage'),
notAssigned: t('review.disabled.notAssigned', 'Assigned to another officer'),
noPermission: t('review.disabled.noPermission', 'You do not have permission'),
needsFlags: t('review.disabled.needsFlags', 'Flag at least one item to request a correction'),
needsCapital: t('review.disabled.needsCapital', 'Record the verified capital first'),
needsInspection: t('review.disabled.needsInspection', 'Requires an inspection result'),
},
});
}, [data, currentUserId, can, flagged.length, pendingInspection, t]);
if (isLoading) {
// Skeleton mirrors the real three-zone layout so nothing jumps on load.
return (
<Container size="xl" py="md">
<Skeleton height={36} width={320} mb="lg" />
<Grid>
<Grid.Col span={{ base: 12, md: 3 }}>
<Skeleton height={280} radius="md" />
</Grid.Col>
<Grid.Col span={{ base: 12, md: 6 }}>
<Skeleton height={420} radius="md" />
</Grid.Col>
<Grid.Col span={{ base: 12, md: 3 }}>
<Skeleton height={280} radius="md" />
</Grid.Col>
</Grid>
</Container>
);
}
if (isError || !data) {
return (
<Container size="xl" py="md">
<ErrorState
title={t('review.errorTitle', 'Could not load this application')}
description={extractErrorMessage(error)}
onRetry={() => refetch()}
/>
</Container>
);
}
const app = data.application;
const status = app.status;
const staffPaged = staffTable.paginate(data.staff);
const presentation = presentationFor(app.licenseType?.key);
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- i18next's overloaded TFunction type doesn't structurally match a plain callback signature
const sla = computeSla(app, undefined, i18n.language, (key, options) => t(key, options as any) as string);
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- i18next's overloaded TFunction type doesn't structurally match a plain callback signature
const eligibility = evaluateEligibility(app, app.licenseType, i18n.language, (key, options) => t(key, options as any) as string);
const rawThreshold = app.licenseType?.capitalThreshold;
const threshold =
rawThreshold === null || rawThreshold === undefined ? undefined : Number(rawThreshold);
/** Stages where the officer can still record the verified capital. */
const needsCapital =
Boolean(threshold) &&
['UNDER_REVIEW', 'UNDER_EVALUATION', 'INSPECTION_PENDING', 'INSPECTION_COMPLETED'].includes(
status,
);
async function run(action: () => Promise<unknown>, success: string) {
try {
await action();
notifications.show({ color: 'teal', title: success, message: '' });
refetch();
refetchInspections();
} catch (err) {
notifications.show({
color: 'red',
title: t('review.actionFailed', 'Action failed'),
message: extractErrorMessage(err),
});
}
}
/** Actions with their own dedicated form open that; the rest confirm. */
function handleAction(action: ResolvedAction) {
switch (action.id) {
case 'schedule-inspection':
setInspectionOpen(true);
return;
case 'record-inspection':
setResultOpen(true);
return;
// Needs a session picked before anything is sent, so it opens its own
// modal rather than going through the generic confirm step.
case 'schedule-exam':
setScheduleExamOpen(true);
return;
case 'copy-link':
navigator.clipboard.writeText(window.location.href);
notifications.show({
color: 'teal',
title: t('review.linkCopied', 'Link copied'),
message: '',
});
return;
case 'print':
window.print();
return;
case 'audit-trail':
setRailOpen(true);
return;
case 'download-documents':
for (const attachment of data?.attachments ?? []) {
const url = attachment.files?.[0]?.url;
if (url) window.open(url, '_blank', 'noopener');
}
return;
default:
setPendingAction(action);
}
}
async function submitDecision(submission: DecisionSubmission) {
const action = pendingAction;
if (!action) return;
setBusyAction(action.id);
try {
switch (action.id) {
case 'claim':
// Claim is fired from the queue in practice; kept here for the case
// where an officer opens an unclaimed application directly.
break;
case 'complete-review':
await run(
() => completeReview({ id, capitalAmountVerified: capital }).unwrap(),
t('review.done.completeReview', 'Review completed'),
);
break;
case 'approve-documents':
await run(
() => approveDocuments({ id }).unwrap(),
t('review.done.approveDocuments', 'Documents approved'),
);
break;
case 'final-approve':
await run(
() => finalApprove({ id, capitalAmountVerified: capital }).unwrap(),
t('review.done.finalApprove', 'Approved'),
);
break;
case 'request-adjustment': {
const items = flagged
// Only the ticked deficiencies are sent, so the applicant can
// edit exactly the list they were shown.
.filter(([key]) =>
submission.deficiencies.length
? submission.deficiencies.includes(key)
: true,
)
.map(([key, flag]) => ({
targetType: flag.targetType,
targetKey: key,
remark: flag.remark.trim(),
}));
// Each item is an instruction the applicant has to act on, so the
// API requires wording for every one. Catching it here names the
// items that need it; letting it through produced a bare 400 saying
// "items.0.remark should not be empty", which tells an officer
// nothing about which box to go and fill in.
const unexplained = items.filter((item) => !item.remark);
if (items.length === 0 || unexplained.length > 0) {
notifications.show({
color: 'orange',
title:
items.length === 0
? t(
'review.adjustment.nothingFlagged',
'Nothing has been flagged',
)
: t(
'review.adjustment.reasonsMissing',
'Every flagged item needs a reason',
),
message:
items.length === 0
? t(
'review.adjustment.nothingFlaggedHint',
'Tick what the applicant must correct before requesting an adjustment.',
)
: `${t(
'review.adjustment.reasonsMissingHint',
'Say what must be corrected for:',
)} ${unexplained.map((item) => item.targetKey).join(', ')}`,
});
// `finally` closes the modal and clears the busy flag.
return;
}
await run(async () => {
await requestAdjustment({
id,
generalRemark: submission.reason,
notificationBody: submission.notificationBody,
items,
}).unwrap();
setFlags({});
}, t('review.done.requestAdjustment', 'Adjustment requested'));
break;
}
case 'reject':
await run(
() =>
rejectApplication({
id,
reason: submission.reason,
// The preview the officer edited is what actually gets sent.
notificationBody: submission.notificationBody,
}).unwrap(),
t('review.done.reject', 'Application rejected'),
);
break;
case 'confirm-payment':
await run(
() => confirmPayment(id).unwrap(),
t('review.done.confirmPayment', 'Payment confirmed'),
);
break;
case 'hold':
await run(
() => holdApplication({ id, reason: submission.reason }).unwrap(),
t('review.done.hold', 'Application placed on hold'),
);
break;
case 'resume':
await run(
() => resumeApplication({ id, remark: submission.reason }).unwrap(),
t('review.done.resume', 'Application resumed'),
);
break;
case 'escalate':
if (!submission.officerId) return;
await run(
() =>
escalateApplication({
id,
supervisorId: submission.officerId as string,
reason: submission.reason,
}).unwrap(),
t('review.done.escalate', 'Escalated'),
);
break;
case 'assign':
if (!submission.officerId) return;
await run(
() =>
assignApplication({
id,
officerId: submission.officerId as string,
remark: submission.reason,
}).unwrap(),
t('review.done.assign', 'Reassigned'),
);
break;
default:
break;
}
} finally {
setBusyAction(null);
setPendingAction(null);
}
}
const sections = presentation.detailSections;
const formSections = Object.entries(app.formData ?? {});
// The bilingual section/field labels the applicant's wizard renders — this
// page already fetches them (`requirements` above) but used to fall back to
// the raw formData keys, so an officer saw `vesselId` instead of a label in
// either language.
const configSections = requirements?.licenseType.formSchema.sections ?? [];
const sectionsByKey = new Map(configSections.map((s) => [s.key, s]));
return (
<Container size="xl" py="md">
<Group justify="space-between" mb="md">
<div>
<Title order={3}>{app.companyName ?? app.applicationNumber}</Title>
<Group gap="xs">
<Text size="sm" c="dimmed">
{app.applicationNumber}
</Text>
<Badge color={STATUS_COLORS[status]} variant="light">
{t(`queue.statusValues.${status}`, STATUS_LABELS[status])}
</Badge>
{app.adjustmentRound > 0 && (
<Badge color="orange" variant="light" size="sm">
{t('review.round', { count: app.adjustmentRound, defaultValue: 'round {{count}}' })}
</Badge>
)}
</Group>
</div>
<Group gap="xs">
<Tooltip
label={
railOpen
? t('review.hideActivity', 'Hide activity')
: t('review.showActivity', 'Show activity')
}
>
<ActionIcon variant="default" size="lg" onClick={() => setRailOpen((o) => !o)}>
{railOpen ? (
<IconLayoutSidebarRightCollapse size={18} />
) : (
<IconLayoutSidebarRightExpand size={18} />
)}
</ActionIcon>
</Tooltip>
</Group>
</Group>
<Grid>
{/* Zone 1 — sticky summary rail. */}
<Grid.Col span={{ base: 12, md: 3 }}>
<Stack style={{ position: 'sticky', top: 16 }}>
<Paper withBorder p="md">
<Text fw={600} size="sm" mb="sm">
{t('review.summary', 'Summary')}
</Text>
<Stack gap={6}>
<SummaryRow label={t('review.type', 'Type')} value={localized(app.licenseType?.name)} />
<SummaryRow label={t('review.tin', 'TIN')} value={app.tinNumber} />
<SummaryRow label={t('review.kind', 'Kind')} value={t(`review.kindValues.${app.kind}`, app.kind)} />
<SummaryRow
label={t('review.submitted', 'Submitted')}
value={showDate(app.submittedAt)}
/>
<SummaryRow label={t('review.slaLabel', 'SLA')} value={sla.label} />
</Stack>
</Paper>
{/* Eligibility, checked and shown — not applied invisibly. */}
{eligibility.length > 0 && (
<Paper withBorder p="md">
<Text fw={600} size="sm" mb="sm">
{t('review.eligibility', 'Eligibility')}
</Text>
<Stack gap="xs">
{eligibility.map((rule) => (
<Group key={rule.id} gap="xs" wrap="nowrap" align="flex-start">
<ThemeIcon
size={18}
radius="xl"
variant="light"
color={
rule.status === 'pass'
? 'teal'
: rule.status === 'fail'
? 'red'
: 'gray'
}
>
{rule.status === 'pass' ? (
<IconCheck size={11} />
) : rule.status === 'fail' ? (
<IconX size={11} />
) : (
<IconQuestionMark size={11} />
)}
</ThemeIcon>
<div style={{ minWidth: 0 }}>
<Text size="xs" fw={500}>
{rule.label}
</Text>
<Text size="xs" c="dimmed">
{rule.actual}
</Text>
</div>
</Group>
))}
</Stack>
</Paper>
)}
<Paper withBorder p="md">
<Text fw={600} size="sm" mb="sm">
{t('review.statusTimeline', 'Progress')}
</Text>
<Timeline bulletSize={12} lineWidth={2} active={data.history.length}>
{data.history.slice(-5).map((entry) => (
<Timeline.Item
key={entry.id}
title={
<Text size="xs" fw={600}>
{t(`queue.statusValues.${entry.toStatus}`, STATUS_LABELS[entry.toStatus] ?? entry.toStatus)}
</Text>
}
>
<Text size="xs" c="dimmed">
{showDate(entry.createdAt)}
</Text>
</Timeline.Item>
))}
</Timeline>
</Paper>
</Stack>
</Grid.Col>
{/* Zone 2 — the application itself. */}
<Grid.Col span={{ base: 12, md: railOpen ? 6 : 9 }}>
<Tabs defaultValue={sections[0]}>
<Tabs.List mb="md">
{/* Tabs with nothing behind them are not rendered at all. */}
{sections.includes('overview') && formSections.length > 0 && (
<Tabs.Tab value="overview">{t('review.tabs.overview', 'Overview')}</Tabs.Tab>
)}
{sections.includes('financials') && (
<Tabs.Tab value="financials">{t('review.tabs.financials', 'Financials')}</Tabs.Tab>
)}
{sections.includes('documents') && (
<Tabs.Tab value="documents">
{t('review.tabs.documents', 'Documents')} ({data.attachments.length})
</Tabs.Tab>
)}
{sections.includes('staff') && data.staff.length > 0 && (
<Tabs.Tab value="staff">
{t('review.tabs.staff', 'Staff')} ({data.staff.length})
</Tabs.Tab>
)}
{sections.includes('inspection') && app.licenseType?.inspectionRequired && (
<Tabs.Tab value="inspection">{t('review.tabs.inspection', 'Inspection')}</Tabs.Tab>
)}
</Tabs.List>
<Tabs.Panel value="overview">
<Stack>
{formSections.map(([sectionKey, values]) => {
const sectionConfig = sectionsByKey.get(sectionKey);
const fieldsByKey = new Map(
(sectionConfig?.fields ?? []).map((f) => [f.key, f]),
);
return (
<Card withBorder key={sectionKey} padding="md">
<Group justify="space-between" mb="xs">
<Text fw={600} size="sm" tt="capitalize">
{sectionConfig
? localized(sectionConfig.title)
: sectionKey.replace(/([A-Z])/g, ' $1')}
</Text>
<Checkbox
size="xs"
label={t('review.needsCorrection', 'Needs correction')}
checked={Boolean(flags[sectionKey])}
onChange={() => toggleFlag('FORM_SECTION', sectionKey)}
/>
</Group>
<Table withTableBorder>
<Table.Tbody>
{Object.entries(values ?? {}).map(([k, v]) => {
const fieldConfig = fieldsByKey.get(k);
return (
<Table.Tr key={k}>
<Table.Td w="40%">
<Text size="xs" c="dimmed">
{fieldConfig ? localized(fieldConfig.label) : k}
</Text>
</Table.Td>
<Table.Td>
<Text size="sm">{v === null ? '—' : String(v)}</Text>
</Table.Td>
</Table.Tr>
);
})}
</Table.Tbody>
</Table>
{flags[sectionKey] && (
<TextInput
mt="xs"
size="xs"
withAsterisk
placeholder={t(
'review.correctionPlaceholder',
'What must the applicant correct?',
)}
// Flagging without saying why is what the applicant
// would receive: "fix this section", and nothing else.
error={
flags[sectionKey].remark.trim()
? null
: t(
'review.correctionRequired',
'Say what must be corrected',
)
}
value={flags[sectionKey].remark}
onChange={(e) => {
// Read here, not inside the updater: React nulls
// `currentTarget` when the handler returns, and the
// updater runs afterwards during the re-render —
// which crashed the page on the first keystroke.
const remark = e.currentTarget.value;
setFlags((p) => ({
...p,
[sectionKey]: { ...p[sectionKey], remark },
}));
}}
/>
)}
</Card>
);
})}
</Stack>
</Tabs.Panel>
<Tabs.Panel value="financials">
<Paper withBorder p="md">
{needsCapital ? (
<>
<NumberInput
label={t('review.verifiedCapital', 'Verified capital (ETB)')}
description={
threshold
? t('review.capitalHint', {
min: threshold.toLocaleString(i18n.language),
defaultValue:
'Minimum {{min}} — check against the bank letter',
})
: t('review.capitalHintNoMin', 'Checked against the bank letter')
}
value={capital ?? ''}
onChange={(v) => setCapital(Number(v) || undefined)}
thousandSeparator=","
error={
capital !== undefined && threshold && capital < threshold
? t('review.belowMinimum', {
min: threshold.toLocaleString(i18n.language),
defaultValue: 'Below the {{min}} minimum',
})
: undefined
}
/>
<Text size="xs" c="dimmed" mt="xs">
{t('review.declared', 'Applicant declared')}{' '}
{app.capitalAmountDeclared
? Number(app.capitalAmountDeclared).toLocaleString(i18n.language)
: '—'}
</Text>
</>
) : (
<Text size="sm" c="dimmed">
{t('review.capitalLocked', 'Capital can no longer be edited at this stage.')}
</Text>
)}
</Paper>
</Tabs.Panel>
<Tabs.Panel value="documents">
<DocumentsTab
applicationId={id}
attachments={data.attachments}
requirements={requirements?.documentRequirements ?? []}
flags={documentFlags}
onToggleFlag={(key) => toggleFlag('DOCUMENT', key)}
onFlagRemark={(key, remark) =>
setFlags((p) => ({ ...p, [key]: { ...p[key], remark } }))
}
/>
</Tabs.Panel>
<Tabs.Panel value="staff">
<AdvancedTable
tableName={t('review.tabs.staff', 'Staff')}
columns={reviewStaffColumns(t, {
flags,
roleName: (member) =>
localized(roleNameByKey.get(member.roleKey)) || member.roleKey,
renderEvidence: (member) => (
<StaffEvidenceCell staffId={member.id} fallback={member.documents} />
),
onToggleFlag: (member) => toggleFlag('STAFF', member.id),
onRemarkChange: (member, remark) =>
setFlags((p) => ({
...p,
[member.id]: { ...p[member.id], remark },
})),
})}
data={staffPaged.rows}
itemCount={staffPaged.itemCount}
pageIndex={staffPaged.pageIndex}
onPageChange={staffTable.setPageIndex}
pageSize={staffTable.pageSize}
refresh={refetch}
/>
</Tabs.Panel>
<Tabs.Panel value="inspection">
<Paper withBorder p="md">
{inspections.length === 0 ? (
<Text size="sm" c="dimmed">
{t('review.noInspections', 'No inspection has been scheduled yet.')}
</Text>
) : (
<Stack gap="xs">
{inspections.map((inspection) => (
<Group key={inspection.id} justify="space-between">
<div>
<Text size="sm">
{inspection.scheduledDate
? showDate(inspection.scheduledDate)
: t('review.unscheduled', 'Not scheduled')}
</Text>
{inspection.findings && (
<Text size="xs" c="dimmed">
{inspection.findings}
</Text>
)}
</div>
<Badge
variant="light"
color={inspection.result === 'FAILED' ? 'red' : 'teal'}
>
{inspection.result === 'PASSED'
? t('review.passed', 'Passed')
: inspection.result === 'FAILED'
? t('review.failed', 'Failed')
: t(`review.inspectionStatus.${inspection.status}`, inspection.status)}
</Badge>
</Group>
))}
</Stack>
)}
</Paper>
</Tabs.Panel>
</Tabs>
{status === 'PAYMENT_PENDING' && (
<Alert mt="md" color="yellow" icon={<IconAlertTriangle size={16} />}>
{t('review.awaitingPayment', {
amount: app.feeAmount,
currency: app.feeCurrency,
defaultValue: 'Waiting for the applicant to pay {{amount}} {{currency}}.',
})}
</Alert>
)}
</Grid.Col>
{/* Zone 3 — activity and audit trail. */}
{railOpen && (
<Grid.Col span={{ base: 12, md: 3 }}>
<ActivityRail detail={data} />
</Grid.Col>
)}
</Grid>
<DecisionBar
status={status}
assigneeName={app.assignedOfficerId ? t('review.assigned', 'Assigned') : null}
sla={sla}
actions={actions}
busyAction={busyAction}
onAction={handleAction}
/>
<DecisionConfirmModal
action={pendingAction}
applicantName={app.companyName ?? t('review.theApplicant', 'the applicant')}
applicationNumber={app.applicationNumber}
flaggedItems={flaggedItems}
officers={officers}
submitting={Boolean(busyAction)}
onClose={() => setPendingAction(null)}
onConfirm={submitDecision}
/>
<ScheduleExamModal
opened={scheduleExamOpen}
applicantName={app.companyName ?? t('review.theApplicant', 'the applicant')}
loading={schedulingExam}
onClose={() => setScheduleExamOpen(false)}
onConfirm={async (payload) => {
try {
await scheduleExam({ id, ...payload }).unwrap();
notifications.show({
color: 'teal',
title: t('review.done.scheduleExam', 'Exam scheduled'),
message: '',
});
setScheduleExamOpen(false);
} catch (err) {
notifications.show({
color: 'red',
title: t('review.actionFailed', 'Action failed'),
message: extractErrorMessage(err),
});
}
}}
/>
<Modal
opened={inspectionOpen}
onClose={() => setInspectionOpen(false)}
title={t('review.actions.scheduleInspection', 'Schedule inspection')}
>
<Stack>
<AmharicDatePicker
label={t('review.dateTime', 'Date and time')}
value={inspectionDate}
onChange={setInspectionDate}
withTime
/>
<ModalFooter>
<Tooltip
label={t('review.pickDate', 'Pick a date and time first')}
disabled={Boolean(inspectionDate)}
>
<span>
<button
type="button"
hidden
aria-hidden
/>
</span>
</Tooltip>
<ActionIcon
variant="filled"
size="lg"
disabled={!inspectionDate}
aria-label={t('review.schedule', 'Schedule')}
onClick={() =>
run(async () => {
await scheduleInspection({
applicationId: id,
scheduledDate: inspectionDate,
}).unwrap();
setInspectionOpen(false);
}, t('review.done.scheduled', 'Inspection scheduled'))
}
>
<IconCheck size={18} />
</ActionIcon>
</ModalFooter>
</Stack>
</Modal>
<Modal
opened={resultOpen}
onClose={() => setResultOpen(false)}
title={t('review.inspectionResult', 'Inspection result')}
>
<Stack>
{/* Structured per-area outcomes; persisted to the inspection's
checklist column, which was previously never populated. */}
<Stack gap={6}>
{INSPECTION_CHECKLIST_ITEMS.map((item) => (
<Group key={item.key} justify="space-between" wrap="nowrap">
<Text size="sm">{t(item.labelKey, item.fallback)}</Text>
<SegmentedControl
size="xs"
value={checklist[item.key] ?? 'PASS'}
onChange={(value) =>
setChecklist((prev) => ({
...prev,
[item.key]: value as
| 'PASS'
| 'FAIL'
| 'NEEDS_CORRECTION',
}))
}
data={[
{ value: 'PASS', label: t('review.checkPass', 'Pass') },
{
value: 'NEEDS_CORRECTION',
label: t('review.checkFix', 'Fix'),
},
{ value: 'FAIL', label: t('review.checkFail', 'Fail') },
]}
/>
</Group>
))}
</Stack>
<Textarea
label={t('review.findings', 'Findings')}
withAsterisk
value={findings}
onChange={(e) => setFindings(e.currentTarget.value)}
autosize
minRows={3}
/>
<ModalFooter grow>
<ActionIcon
variant="light"
color="teal"
size="lg"
disabled={!findings.trim() || !pendingInspection}
aria-label={t('review.passed', 'Passed')}
onClick={() =>
run(async () => {
// Guarded by the disabled state above; narrowing it here
// keeps that guarantee in the type system too.
if (!pendingInspection) return;
await recordResult({
inspectionId: pendingInspection.id,
applicationId: id,
result: 'PASSED',
findings,
checklist: buildChecklist(checklist),
}).unwrap();
setResultOpen(false);
}, t('review.done.inspectionPassed', 'Inspection passed'))
}
>
<IconCheck size={18} />
</ActionIcon>
<ActionIcon
variant="light"
color="red"
size="lg"
disabled={!findings.trim() || !pendingInspection}
aria-label={t('review.failed', 'Failed')}
onClick={() =>
run(async () => {
// Guarded by the disabled state above; narrowing it here
// keeps that guarantee in the type system too.
if (!pendingInspection) return;
await recordResult({
inspectionId: pendingInspection.id,
applicationId: id,
result: 'FAILED',
findings,
checklist: buildChecklist(checklist),
}).unwrap();
setResultOpen(false);
}, t('review.done.inspectionFailed', 'Inspection failed'))
}
>
<IconX size={18} />
</ActionIcon>
</ModalFooter>
</Stack>
</Modal>
</Container>
);
}
/**
* Evidence badges for one staff member.
*
* `application-for-review` nests a `documents` array per staff member, but it
* doesn't always carry the uploaded file (the portal's own upload widget
* hits the attachments endpoint directly for the same reason). Query
* attachments by owner here too, so the officer gets a working link instead
* of a badge with nowhere to go.
*/
function StaffEvidenceCell({
staffId,
fallback,
}: {
staffId: string;
fallback?: { id: string; documentKey: string; files: { url?: string }[] }[];
}) {
const { data: attachments } = useGetAttachmentsQuery({
ownerType: 'APPLICATION_STAFF',
ownerId: staffId,
});
const docs = attachments?.length ? attachments : (fallback ?? []);
return (
<Group gap={4}>
{docs.map((doc) => {
const url = doc.files?.[0]?.url;
return (
<Badge
key={doc.id}
size="xs"
variant="light"
component={url ? 'a' : undefined}
href={url}
target={url ? '_blank' : undefined}
rel={url ? 'noreferrer' : undefined}
style={url ? { cursor: 'pointer' } : undefined}
>
{doc.documentKey}
</Badge>
);
})}
</Group>
);
}
function SummaryRow({ label, value }: { label: string; value?: string | null }) {
return (
<Group justify="space-between" gap="xs" wrap="nowrap">
<Text size="xs" c="dimmed">
{label}
</Text>
<Text size="xs" fw={500} ta="right" truncate>
{value || '—'}
</Text>
</Group>
);
}
export default LicenseReviewPage;