Files
emaui/apps/backoffice/src/app/features/license-review/pages/LicenseReviewPage/index.tsx

1576 lines
54 KiB
TypeScript

import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { useNavigate, useParams } from "react-router-dom";
import {
ActionIcon,
Alert,
Badge,
Container,
Grid,
Group,
Modal,
NumberInput,
Paper,
SegmentedControl,
Skeleton,
Stack,
Tabs,
Text,
Textarea,
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,
applicantOrCompanyName,
extractErrorMessage,
useLocalized,
useApproveDocumentsMutation,
useAssignApplicationMutation,
useClaimApplicationMutation,
useCompleteReviewMutation,
useConfirmPaymentMutation,
useScheduleIssuanceMutation,
useIssueCertificateMutation,
useScheduleExamMutation,
useRecordExamOutcomeMutation,
useEscalateApplicationMutation,
useFinalApproveMutation,
useGetApplicationForReviewQuery,
useGetAttachmentsQuery,
useGetDocumentReviewsQuery,
useGetInspectionsQuery,
useGetAssignableOfficersQuery,
useGetLicenseTypeRequirementsQuery,
useHoldApplicationMutation,
useRecordInspectionResultMutation,
useRejectApplicationMutation,
useRequestAdjustmentMutation,
useResumeApplicationMutation,
useScheduleInspectionMutation,
type RemarkTargetType,
type StaffEvidenceRequirement,
} from "@ema-platform/api";
import {
AdvancedTable,
AmharicDatePicker,
ErrorState,
ModalFooter,
PdfPreviewModal,
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 { FormDetailsTab } from "../../components/FormDetailsTab";
import { ApplicantCard } from "../../components/ApplicantCard";
import { useGetLocationsQuery } from "../../../location/api/location-api";
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 navigate = useNavigate();
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],
);
// What each role is *required* to produce (CV, work agreement, ERB
// certificate). Without this the tab can only list what was uploaded, so a
// missing CV looks identical to a role that never needed one.
const evidenceByRole = useMemo(
() =>
new Map(
requirements?.staffRoleRequirements.map((r) => [
r.roleKey,
r.requiredEvidence ?? [],
]) ?? [],
),
[requirements],
);
const [claimApplication] = useClaimApplicationMutation();
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 [scheduleIssuance] = useScheduleIssuanceMutation();
const [issueCertificate] = useIssueCertificateMutation();
const [scheduleExam, { isLoading: schedulingExam }] =
useScheduleExamMutation();
const [recordExamOutcome] = useRecordExamOutcomeMutation();
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();
// Same cached query the Documents tab reads, so the decision bar reacts the
// moment a verdict is saved.
const { data: documentReviews = [] } = useGetDocumentReviewsQuery(id, { skip: !id });
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 [issuanceOpen, setIssuanceOpen] = useState(false);
const [issuanceDate, setIssuanceDate] = useState("");
const [resultOpen, setResultOpen] = useState(false);
const [scheduleExamOpen, setScheduleExamOpen] = useState(false);
const [examOutcomeOpen, setExamOutcomeOpen] = useState(false);
const [examScore, setExamScore] = useState<number | undefined>();
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');
// Approving means every uploaded document was accepted — one unjudged or
// rejected file is enough to keep the decision buttons dead. The counts feed
// the hover explanation, so the officer sees how much is left rather than
// just that the button is dead.
const documentProgress = useMemo(() => {
const attachments = data?.attachments ?? [];
const accepted = new Set(
documentReviews
.filter((review) => review.decision === 'ACCEPTED')
.map((review) => review.documentKey),
);
const acceptedCount = attachments.filter((a) => accepted.has(a.documentKey)).length;
return { acceptedCount, total: attachments.length };
}, [data?.attachments, documentReviews]);
const allDocumentsAccepted =
documentProgress.total > 0 && documentProgress.acceptedCount === documentProgress.total;
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),
allDocumentsAccepted,
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'),
needsDocumentReviews:
documentProgress.total === 0
? t(
'review.disabled.needsDocumentsUploaded',
'No documents uploaded to review yet',
)
: t('review.disabled.needsDocumentReviews', {
accepted: documentProgress.acceptedCount,
total: documentProgress.total,
defaultValue:
'Accept all documents first — {{accepted}} of {{total}} accepted. Open the Documents tab and accept the rest.',
}),
},
});
}, [data, currentUserId, can, flagged.length, pendingInspection, t]);
// Location answers are tree ids. The picker the applicant used resolves them
// client-side from the same list, so the reviewer reads the place rather than
// the uuid. Fetched only when the form actually has a location field.
//
// Above the early returns because it is a hook: React requires the same hook
// order on every render, and the loading and error branches return before the
// application is known.
const needsLocations = (
requirements?.licenseType.formSchema.sections ?? []
).some((section) =>
(section.fields ?? []).some(
(f) =>
f.key === "locationId" ||
(f.label?.en ?? "").trim().toLowerCase() === "location",
),
);
const { data: locationsRes } = useGetLocationsQuery(
{ take: 10000 },
{ skip: !needsLocations },
);
const resolveLocation = useMemo(() => {
const all = locationsRes?.items ?? [];
if (all.length === 0) return undefined;
const byId = new Map(all.map((loc) => [loc.id, loc]));
return (locationId: string) => {
// Walks to the root so the answer reads as a place, not a leaf name:
// "Woreda 03" alone does not say which sub-city it belongs to. Bounded by
// the map size so a cyclic tree cannot spin the render.
const path: string[] = [];
let current = byId.get(locationId);
let hops = 0;
while (current && hops++ <= byId.size) {
path.unshift(localized(current.names) || current.code);
current = current.parentId ? byId.get(current.parentId) : undefined;
}
return path.length ? path.join(" → ") : undefined;
};
}, [locationsRes, localized]);
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 "schedule-issuance":
setIssuanceOpen(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;
// Pass/fail plus an optional score, same reasoning as schedule-exam:
// needs its own inputs before anything is sent.
case "record-exam-outcome":
setExamOutcomeOpen(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":
// Usually fired from the queue, but an officer who opened an
// unclaimed application directly claims it from here.
await run(
() => claimApplication(id).unwrap(),
t("review.done.claim", "Claimed — the application is now yours"),
);
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 "issue-certificate":
await run(
() => issueCertificate(id).unwrap(),
t("review.done.issueCertificate", "Certificate issued"),
);
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 hasFormAnswers = Object.keys(app.formData ?? {}).length > 0;
// 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 ?? [];
// A person-centric service has no company, so the company-shaped facts are
// not merely empty — they are the wrong question. TIN is hidden rather than
// shown blank, and the applicant's own identity card takes its place.
const isPersonal = !app.companyName;
const applicant = data.applicant;
const applicantFullName = [
applicant?.firstName,
applicant?.middleName,
applicant?.lastName,
]
.filter(Boolean)
.join(" ");
// The profile is the reliable name for a personal service — `formData.account`
// holds one only for applications filed after that field was added, and the
// application number identifies the paperwork rather than the person.
const headerName =
app.companyName ||
applicantFullName ||
applicantOrCompanyName(app) ||
app.applicationNumber;
const linkedBookServices = (data.relatedApplications ?? []).filter(
(related) =>
["SEAMAN_BOOK", "BTC_BASIC_TRAINING"].includes(
related.licenseType?.key ?? "",
),
);
return (
<Container size="xl" py="md">
<Group justify="space-between" mb="md">
<div>
<Title order={2}>{headerName}</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>
{linkedBookServices.length > 1 && (
<Group gap="xs" mt="xs">
{linkedBookServices.map((related) => (
<Badge
key={related.id}
variant={related.id === app.id ? "filled" : "outline"}
color={STATUS_COLORS[related.status]}
style={{ cursor: "pointer" }}
onClick={() => navigate(`/licence-review/${related.id}`)}
>
{related.licenseType?.key === "SEAMAN_BOOK"
? "Seaman Book"
: "BTC"}{" "}
· {related.applicationNumber} ·{" "}
{STATUS_LABELS[related.status]}
</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 }}>
{/* Who, before what: a person-centric review is about the applicant,
and the licence facts below are the context. */}
{isPersonal && applicant && <ApplicantCard applicant={applicant} />}
<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)}
/>
{/* A person has no TIN; showing the row blank invited the
reviewer to wonder what was missing. */}
{!isPersonal && (
<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") && hasFormAnswers && (
<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">
<FormDetailsTab
formData={app.formData ?? {}}
configSections={configSections}
currency={app.feeCurrency ?? undefined}
flags={flags}
onToggleFlag={(sectionKey) =>
toggleFlag("FORM_SECTION", sectionKey)
}
onFlagRemark={(sectionKey, remark) =>
setFlags((p) => ({
...p,
[sectionKey]: { ...p[sectionKey], remark },
}))
}
resolveLocation={resolveLocation}
/>
</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 ?? []}
formData={app.formData ?? {}}
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}
required={evidenceByRole.get(member.roleKey) ?? []}
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 ||
applicantFullName ||
t("review.theApplicant", "the applicant")
}
applicationNumber={app.applicationNumber}
flaggedItems={flaggedItems}
officers={officers}
submitting={Boolean(busyAction)}
onClose={() => setPendingAction(null)}
onConfirm={submitDecision}
/>
<ScheduleExamModal
opened={scheduleExamOpen}
applicationId={id}
applicantName={
app.companyName ||
applicantFullName ||
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={examOutcomeOpen}
onClose={() => setExamOutcomeOpen(false)}
title={t("review.actions.recordExamOutcome", "Record exam outcome")}
>
<Stack>
<Text size="sm" c="dimmed">
{t(
"review.examOutcome.intro",
"Record the published result. A pass makes the certificate fee due; a fail leaves the application open for a retake.",
)}
</Text>
<NumberInput
label={t("review.examOutcome.score", "Score (optional)")}
value={examScore}
onChange={(v) => setExamScore(typeof v === "number" ? v : undefined)}
min={0}
/>
<ModalFooter grow>
<ActionIcon
variant="light"
color="teal"
size="lg"
aria-label={t("review.passed", "Passed")}
onClick={() =>
run(
async () => {
await recordExamOutcome({
id,
passed: true,
score: examScore,
}).unwrap();
setExamOutcomeOpen(false);
setExamScore(undefined);
},
t("review.done.examPassed", "Exam result recorded — passed"),
)
}
>
<IconCheck size={18} />
</ActionIcon>
<ActionIcon
variant="light"
color="red"
size="lg"
aria-label={t("review.failed", "Failed")}
onClick={() =>
run(
async () => {
await recordExamOutcome({
id,
passed: false,
score: examScore,
}).unwrap();
setExamOutcomeOpen(false);
setExamScore(undefined);
},
t("review.done.examFailed", "Exam result recorded — not passed"),
)
}
>
<IconX size={18} />
</ActionIcon>
</ModalFooter>
</Stack>
</Modal>
<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={issuanceOpen}
onClose={() => setIssuanceOpen(false)}
title={t("review.actions.scheduleIssuance", "Schedule pickup")}
>
<Stack>
<AmharicDatePicker
label={t("review.pickupDate", "Pickup date")}
value={issuanceDate}
onChange={setIssuanceDate}
/>
<ModalFooter>
<Tooltip
label={t("review.pickDate", "Pick a date and time first")}
disabled={Boolean(issuanceDate)}
>
<span>
<button type="button" hidden aria-hidden />
</span>
</Tooltip>
<ActionIcon
variant="filled"
size="lg"
disabled={!issuanceDate}
aria-label={t("review.schedule", "Schedule")}
onClick={() =>
run(
async () => {
await scheduleIssuance({
id,
scheduledDate: issuanceDate,
}).unwrap();
setIssuanceOpen(false);
},
t("review.done.scheduleIssuance", "Pickup 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,
required,
fallback,
}: {
staffId: string;
/** What this person's role must produce, from the licence-type config. */
required: StaffEvidenceRequirement[];
fallback?: { id: string; documentKey: string; files: { url?: string }[] }[];
}) {
const localized = useLocalized();
const { t } = useTranslation();
const [preview, setPreview] = useState<{ url: string; title: string } | null>(
null,
);
const { data: attachments } = useGetAttachmentsQuery({
ownerType: "APPLICATION_STAFF",
ownerId: staffId,
});
const docs = attachments?.length ? attachments : (fallback ?? []);
const uploadedBy = new Map(docs.map((d) => [d.documentKey, d]));
// Drive the list off the requirements, not off what happens to have been
// uploaded: a mandatory CV that is absent has to be visible as absent, which
// is the whole point of the officer looking at this column. Anything
// uploaded outside the list still gets shown rather than silently dropped.
const extras = docs.filter(
(d) => !required.some((r) => r.docKey === d.documentKey),
);
if (!required.length && !extras.length) {
return (
<Text size="xs" c="dimmed">
</Text>
);
}
const badge = (
key: string,
label: string,
url: string | undefined,
mandatory: boolean,
) => {
const missing = !url;
return (
<Tooltip
key={key}
label={
missing
? mandatory
? t("review.evidenceMissingRequired", "Required — not uploaded")
: t("review.evidenceMissing", "Not uploaded")
: t("licensing.documents.view", "View")
}
withArrow
>
<Badge
size="xs"
variant={missing ? "outline" : "light"}
color={missing ? (mandatory ? "red" : "gray") : "teal"}
style={missing ? undefined : { cursor: "pointer" }}
onClick={
missing ? undefined : () => setPreview({ url: url, title: label })
}
>
{label}
{missing && mandatory ? " *" : ""}
</Badge>
</Tooltip>
);
};
return (
<Group gap={4}>
{required.map((item) =>
badge(
item.docKey,
localized(item.label) || item.docKey,
uploadedBy.get(item.docKey)?.files?.[0]?.url,
item.mandatory,
),
)}
{extras.map((doc) =>
badge(doc.id, doc.documentKey, doc.files?.[0]?.url, false),
)}
<PdfPreviewModal
opened={Boolean(preview)}
onClose={() => setPreview(null)}
url={preview?.url ?? ""}
title={preview?.title}
/>
</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;