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; /** * 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, ) { 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({}); const [capital, setCapital] = useState(); const [pendingAction, setPendingAction] = useState( null, ); const [busyAction, setBusyAction] = useState(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(); const [findings, setFindings] = useState(""); const [checklist, setChecklist] = useState< Record >({}); // 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 = {}; 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 ( ); } if (isError || !data) { return ( refetch()} /> ); } 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, 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 can also open an // unclaimed application directly and claim it from here. await run( () => claimApplication(id).unwrap(), t("review.done.claim", "Application claimed"), ); 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 (
{headerName} {app.applicationNumber} {t(`queue.statusValues.${status}`, STATUS_LABELS[status])} {app.adjustmentRound > 0 && ( {t("review.round", { count: app.adjustmentRound, defaultValue: "round {{count}}", })} )} {linkedBookServices.length > 1 && ( {linkedBookServices.map((related) => ( navigate(`/licence-review/${related.id}`)} > {related.licenseType?.key === "SEAMAN_BOOK" ? "Seaman Book" : "BTC"}{" "} · {related.applicationNumber} ·{" "} {STATUS_LABELS[related.status]} ))} )}
setRailOpen((o) => !o)} > {railOpen ? ( ) : ( )}
{/* Zone 1 — sticky summary rail. */} {/* Who, before what: a person-centric review is about the applicant, and the licence facts below are the context. */} {isPersonal && applicant && } {t("review.summary", "Summary")} {/* A person has no TIN; showing the row blank invited the reviewer to wonder what was missing. */} {!isPersonal && ( )} {/* Eligibility, checked and shown — not applied invisibly. */} {eligibility.length > 0 && ( {t("review.eligibility", "Eligibility")} {eligibility.map((rule) => ( {rule.status === "pass" ? ( ) : rule.status === "fail" ? ( ) : ( )}
{rule.label} {rule.actual}
))}
)} {t("review.statusTimeline", "Progress")} {data.history.slice(-5).map((entry) => ( {t( `queue.statusValues.${entry.toStatus}`, STATUS_LABELS[entry.toStatus] ?? entry.toStatus, )} } > {showDate(entry.createdAt)} ))}
{/* Zone 2 — the application itself. */} {/* Tabs with nothing behind them are not rendered at all. */} {sections.includes("overview") && hasFormAnswers && ( {t("review.tabs.overview", "Overview")} )} {sections.includes("financials") && ( {t("review.tabs.financials", "Financials")} )} {sections.includes("documents") && ( {t("review.tabs.documents", "Documents")} ( {data.attachments.length}) )} {sections.includes("staff") && data.staff.length > 0 && ( {t("review.tabs.staff", "Staff")} ({data.staff.length}) )} {sections.includes("inspection") && app.licenseType?.inspectionRequired && ( {t("review.tabs.inspection", "Inspection")} )} toggleFlag("FORM_SECTION", sectionKey) } onFlagRemark={(sectionKey, remark) => setFlags((p) => ({ ...p, [sectionKey]: { ...p[sectionKey], remark }, })) } resolveLocation={resolveLocation} /> {needsCapital ? ( <> 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 } /> {t("review.declared", "Applicant declared")}{" "} {app.capitalAmountDeclared ? Number(app.capitalAmountDeclared).toLocaleString( i18n.language, ) : "—"} ) : ( {t( "review.capitalLocked", "Capital can no longer be edited at this stage.", )} )} toggleFlag("DOCUMENT", key)} onFlagRemark={(key, remark) => setFlags((p) => ({ ...p, [key]: { ...p[key], remark } })) } /> localized(roleNameByKey.get(member.roleKey)) || member.roleKey, renderEvidence: (member) => ( ), 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} /> {inspections.length === 0 ? ( {t( "review.noInspections", "No inspection has been scheduled yet.", )} ) : ( {inspections.map((inspection) => (
{inspection.scheduledDate ? showDate(inspection.scheduledDate) : t("review.unscheduled", "Not scheduled")} {inspection.findings && ( {inspection.findings} )}
{inspection.result === "PASSED" ? t("review.passed", "Passed") : inspection.result === "FAILED" ? t("review.failed", "Failed") : t( `review.inspectionStatus.${inspection.status}`, inspection.status, )}
))}
)}
{status === "PAYMENT_PENDING" && ( } > {t("review.awaitingPayment", { amount: app.feeAmount, currency: app.feeCurrency, defaultValue: "Waiting for the applicant to pay {{amount}} {{currency}}.", })} )}
{/* Zone 3 — activity and audit trail. */} {railOpen && ( )}
setPendingAction(null)} onConfirm={submitDecision} /> 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), }); } }} /> setExamOutcomeOpen(false)} title={t("review.actions.recordExamOutcome", "Record exam outcome")} > {t( "review.examOutcome.intro", "Record the published result. A pass makes the certificate fee due; a fail leaves the application open for a retake.", )} setExamScore(typeof v === "number" ? v : undefined)} min={0} /> run( async () => { await recordExamOutcome({ id, passed: true, score: examScore, }).unwrap(); setExamOutcomeOpen(false); setExamScore(undefined); }, t("review.done.examPassed", "Exam result recorded — passed"), ) } > run( async () => { await recordExamOutcome({ id, passed: false, score: examScore, }).unwrap(); setExamOutcomeOpen(false); setExamScore(undefined); }, t("review.done.examFailed", "Exam result recorded — not passed"), ) } > setInspectionOpen(false)} title={t("review.actions.scheduleInspection", "Schedule inspection")} >