mirror of
https://github.com/Tria-plc/emaui.git
synced 2026-08-26 19:12:50 +00:00
- 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.
1146 lines
43 KiB
TypeScript
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;
|