feat: update exam stage actions and payment flow

- Refactor ExamStageActions component to handle eligibility payment and retake exam actions.
- Update MyApplicationsPage to use new retakeExam mutation instead of requestExamPayment.
- Modify licensing API to include retakeExam mutation for handling exam fee requests for failed candidates.
- Adjust mock data to reflect new application statuses and ensure consistency in eligibility and exam payment states.
- Update licensing helpers to include new status labels and colors for eligibility payment states.
- Revise licensing types to replace ELIGIBILITY_APPROVED with ELIGIBILITY_PAYMENT_PENDING and ELIGIBILITY_PAID for clarity in application status flow.
This commit is contained in:
Nati
2026-08-20 12:41:46 +00:00
parent 558cfe20a1
commit 6901e985fc
14 changed files with 231 additions and 1154 deletions

View File

@@ -29,6 +29,7 @@ export type ActionId =
| 'request-adjustment'
| 'reject'
| 'schedule-exam'
| 'record-exam-outcome'
| 'confirm-payment'
| 'schedule-issuance'
| 'issue-certificate'
@@ -68,7 +69,9 @@ export const ACTIONS: ActionDefinition[] = [
id: 'claim',
tier: 'workflow',
labelKey: 'review.actions.claim',
from: ['SUBMITTED'],
// Mirrors the CLAIM transition's `from` list: an examined cert (CoC/CoP)
// sits in ELIGIBILITY_PAID once its eligibility fee clears, not SUBMITTED.
from: ['SUBMITTED', 'ELIGIBILITY_PAID'],
permissions: ['can:claim:license-application'],
emphasis: 'light',
},
@@ -198,7 +201,21 @@ export const ACTIONS: ActionDefinition[] = [
// Only after the examination fee clears — scheduling an unpaid candidate
// is what the EXAM_PAID gate exists to prevent.
from: ['EXAM_PAID'],
permissions: ['can:schedule:exam-candidate'],
// Matches the controller's guard on `:id/exam-scheduled`
// (`LICENSE_PERMISSIONS.MANAGE_EXAMS`) — the previous string didn't
// correspond to any real permission constant, so this button could never
// actually be granted to anyone.
permissions: ['can:manage:exams'],
emphasis: 'filled',
color: 'cyan',
},
{
id: 'record-exam-outcome',
tier: 'primary',
labelKey: 'review.actions.recordExamOutcome',
// Only once the candidate has actually sat the exam.
from: ['EXAM_SCHEDULED'],
permissions: ['can:publish:exam-result'],
emphasis: 'filled',
color: 'cyan',
},

View File

@@ -22,7 +22,11 @@ export function licenseQueueActionsColumn(
cell: ({ row }) =>
handlers.claimable !== false &&
row.original.assignedOfficerId === null &&
row.original.status === "SUBMITTED" ? (
// Mirrors the CLAIM transition's `from` list: an examined cert
// (CoC/CoP) sits in ELIGIBILITY_PAID once its eligibility fee clears,
// not SUBMITTED.
(row.original.status === "SUBMITTED" ||
row.original.status === "ELIGIBILITY_PAID") ? (
<RequirePermission
anyOf={[LICENSE_PERMISSIONS.CLAIM_APPLICATION]}
hideOnly

View File

@@ -87,6 +87,17 @@ const STANDARD_ONLY_STATUSES: LicenseStatus[] = [
"INSPECTION_COMPLETED",
];
/** Statuses only an examined cert (CoC, or a CoP with requiresExamination) reaches. */
const EXAM_ONLY_STATUSES: LicenseStatus[] = [
"ELIGIBILITY_PAYMENT_PENDING",
"ELIGIBILITY_PAID",
"EXAM_PAYMENT_PENDING",
"EXAM_PAID",
"EXAM_SCHEDULED",
"EXAM_PASSED",
"EXAM_FAILED",
];
const ALL_STATUSES: LicenseStatus[] = [
"SUBMITTED",
"UNDER_REVIEW",
@@ -96,9 +107,11 @@ const ALL_STATUSES: LicenseStatus[] = [
"INSPECTION_COMPLETED",
"ON_HOLD",
"APPROVED",
...EXAM_ONLY_STATUSES,
"PAYMENT_PENDING",
"PAID",
"PAYMENT_CONFIRMED",
"SCHEDULED",
"CERTIFICATE_ISSUED",
"COMPLETED",
"REJECTED",
@@ -117,6 +130,7 @@ function statusesFor(type: LicenseType | undefined): LicenseStatus[] {
if (status === "INSPECTION_PENDING" || status === "INSPECTION_COMPLETED") {
return type.inspectionRequired;
}
if (EXAM_ONLY_STATUSES.includes(status)) return Boolean(type.requiresExamination);
if (status === "CERTIFICATE_ISSUED") return type.issuesCertificate;
return true;
});

View File

@@ -44,6 +44,7 @@ import {
useScheduleIssuanceMutation,
useIssueCertificateMutation,
useScheduleExamMutation,
useRecordExamOutcomeMutation,
useEscalateApplicationMutation,
useFinalApproveMutation,
useGetApplicationForReviewQuery,
@@ -209,6 +210,7 @@ export function LicenseReviewPage() {
const [issueCertificate] = useIssueCertificateMutation();
const [scheduleExam, { isLoading: schedulingExam }] =
useScheduleExamMutation();
const [recordExamOutcome] = useRecordExamOutcomeMutation();
const [holdApplication] = useHoldApplicationMutation();
const [resumeApplication] = useResumeApplicationMutation();
const [escalateApplication] = useEscalateApplicationMutation();
@@ -234,6 +236,8 @@ export function LicenseReviewPage() {
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">
@@ -490,6 +494,11 @@ export function LicenseReviewPage() {
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({
@@ -1173,6 +1182,73 @@ export function LicenseReviewPage() {
}}
/>
<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)}