mirror of
https://github.com/Tria-plc/emaui.git
synced 2026-08-26 19:12:50 +00:00
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:
@@ -29,6 +29,7 @@ export type ActionId =
|
|||||||
| 'request-adjustment'
|
| 'request-adjustment'
|
||||||
| 'reject'
|
| 'reject'
|
||||||
| 'schedule-exam'
|
| 'schedule-exam'
|
||||||
|
| 'record-exam-outcome'
|
||||||
| 'confirm-payment'
|
| 'confirm-payment'
|
||||||
| 'schedule-issuance'
|
| 'schedule-issuance'
|
||||||
| 'issue-certificate'
|
| 'issue-certificate'
|
||||||
@@ -68,7 +69,9 @@ export const ACTIONS: ActionDefinition[] = [
|
|||||||
id: 'claim',
|
id: 'claim',
|
||||||
tier: 'workflow',
|
tier: 'workflow',
|
||||||
labelKey: 'review.actions.claim',
|
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'],
|
permissions: ['can:claim:license-application'],
|
||||||
emphasis: 'light',
|
emphasis: 'light',
|
||||||
},
|
},
|
||||||
@@ -198,7 +201,21 @@ export const ACTIONS: ActionDefinition[] = [
|
|||||||
// Only after the examination fee clears — scheduling an unpaid candidate
|
// Only after the examination fee clears — scheduling an unpaid candidate
|
||||||
// is what the EXAM_PAID gate exists to prevent.
|
// is what the EXAM_PAID gate exists to prevent.
|
||||||
from: ['EXAM_PAID'],
|
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',
|
emphasis: 'filled',
|
||||||
color: 'cyan',
|
color: 'cyan',
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -22,7 +22,11 @@ export function licenseQueueActionsColumn(
|
|||||||
cell: ({ row }) =>
|
cell: ({ row }) =>
|
||||||
handlers.claimable !== false &&
|
handlers.claimable !== false &&
|
||||||
row.original.assignedOfficerId === null &&
|
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
|
<RequirePermission
|
||||||
anyOf={[LICENSE_PERMISSIONS.CLAIM_APPLICATION]}
|
anyOf={[LICENSE_PERMISSIONS.CLAIM_APPLICATION]}
|
||||||
hideOnly
|
hideOnly
|
||||||
|
|||||||
@@ -87,6 +87,17 @@ const STANDARD_ONLY_STATUSES: LicenseStatus[] = [
|
|||||||
"INSPECTION_COMPLETED",
|
"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[] = [
|
const ALL_STATUSES: LicenseStatus[] = [
|
||||||
"SUBMITTED",
|
"SUBMITTED",
|
||||||
"UNDER_REVIEW",
|
"UNDER_REVIEW",
|
||||||
@@ -96,9 +107,11 @@ const ALL_STATUSES: LicenseStatus[] = [
|
|||||||
"INSPECTION_COMPLETED",
|
"INSPECTION_COMPLETED",
|
||||||
"ON_HOLD",
|
"ON_HOLD",
|
||||||
"APPROVED",
|
"APPROVED",
|
||||||
|
...EXAM_ONLY_STATUSES,
|
||||||
"PAYMENT_PENDING",
|
"PAYMENT_PENDING",
|
||||||
"PAID",
|
"PAID",
|
||||||
"PAYMENT_CONFIRMED",
|
"PAYMENT_CONFIRMED",
|
||||||
|
"SCHEDULED",
|
||||||
"CERTIFICATE_ISSUED",
|
"CERTIFICATE_ISSUED",
|
||||||
"COMPLETED",
|
"COMPLETED",
|
||||||
"REJECTED",
|
"REJECTED",
|
||||||
@@ -117,6 +130,7 @@ function statusesFor(type: LicenseType | undefined): LicenseStatus[] {
|
|||||||
if (status === "INSPECTION_PENDING" || status === "INSPECTION_COMPLETED") {
|
if (status === "INSPECTION_PENDING" || status === "INSPECTION_COMPLETED") {
|
||||||
return type.inspectionRequired;
|
return type.inspectionRequired;
|
||||||
}
|
}
|
||||||
|
if (EXAM_ONLY_STATUSES.includes(status)) return Boolean(type.requiresExamination);
|
||||||
if (status === "CERTIFICATE_ISSUED") return type.issuesCertificate;
|
if (status === "CERTIFICATE_ISSUED") return type.issuesCertificate;
|
||||||
return true;
|
return true;
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -44,6 +44,7 @@ import {
|
|||||||
useScheduleIssuanceMutation,
|
useScheduleIssuanceMutation,
|
||||||
useIssueCertificateMutation,
|
useIssueCertificateMutation,
|
||||||
useScheduleExamMutation,
|
useScheduleExamMutation,
|
||||||
|
useRecordExamOutcomeMutation,
|
||||||
useEscalateApplicationMutation,
|
useEscalateApplicationMutation,
|
||||||
useFinalApproveMutation,
|
useFinalApproveMutation,
|
||||||
useGetApplicationForReviewQuery,
|
useGetApplicationForReviewQuery,
|
||||||
@@ -209,6 +210,7 @@ export function LicenseReviewPage() {
|
|||||||
const [issueCertificate] = useIssueCertificateMutation();
|
const [issueCertificate] = useIssueCertificateMutation();
|
||||||
const [scheduleExam, { isLoading: schedulingExam }] =
|
const [scheduleExam, { isLoading: schedulingExam }] =
|
||||||
useScheduleExamMutation();
|
useScheduleExamMutation();
|
||||||
|
const [recordExamOutcome] = useRecordExamOutcomeMutation();
|
||||||
const [holdApplication] = useHoldApplicationMutation();
|
const [holdApplication] = useHoldApplicationMutation();
|
||||||
const [resumeApplication] = useResumeApplicationMutation();
|
const [resumeApplication] = useResumeApplicationMutation();
|
||||||
const [escalateApplication] = useEscalateApplicationMutation();
|
const [escalateApplication] = useEscalateApplicationMutation();
|
||||||
@@ -234,6 +236,8 @@ export function LicenseReviewPage() {
|
|||||||
const [issuanceDate, setIssuanceDate] = useState("");
|
const [issuanceDate, setIssuanceDate] = useState("");
|
||||||
const [resultOpen, setResultOpen] = useState(false);
|
const [resultOpen, setResultOpen] = useState(false);
|
||||||
const [scheduleExamOpen, setScheduleExamOpen] = useState(false);
|
const [scheduleExamOpen, setScheduleExamOpen] = useState(false);
|
||||||
|
const [examOutcomeOpen, setExamOutcomeOpen] = useState(false);
|
||||||
|
const [examScore, setExamScore] = useState<number | undefined>();
|
||||||
const [findings, setFindings] = useState("");
|
const [findings, setFindings] = useState("");
|
||||||
const [checklist, setChecklist] = useState<
|
const [checklist, setChecklist] = useState<
|
||||||
Record<string, "PASS" | "FAIL" | "NEEDS_CORRECTION">
|
Record<string, "PASS" | "FAIL" | "NEEDS_CORRECTION">
|
||||||
@@ -490,6 +494,11 @@ export function LicenseReviewPage() {
|
|||||||
case "schedule-exam":
|
case "schedule-exam":
|
||||||
setScheduleExamOpen(true);
|
setScheduleExamOpen(true);
|
||||||
return;
|
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":
|
case "copy-link":
|
||||||
navigator.clipboard.writeText(window.location.href);
|
navigator.clipboard.writeText(window.location.href);
|
||||||
notifications.show({
|
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
|
<Modal
|
||||||
opened={inspectionOpen}
|
opened={inspectionOpen}
|
||||||
onClose={() => setInspectionOpen(false)}
|
onClose={() => setInspectionOpen(false)}
|
||||||
|
|||||||
@@ -72,7 +72,8 @@ const STATUS_COLOR: Record<string, string> = {
|
|||||||
RESUBMIT_REQUIRED: 'orange',
|
RESUBMIT_REQUIRED: 'orange',
|
||||||
INSPECTION_PENDING: 'grape',
|
INSPECTION_PENDING: 'grape',
|
||||||
INSPECTION_COMPLETED: 'grape',
|
INSPECTION_COMPLETED: 'grape',
|
||||||
ELIGIBILITY_APPROVED: 'teal',
|
ELIGIBILITY_PAYMENT_PENDING: 'orange',
|
||||||
|
ELIGIBILITY_PAID: 'blue',
|
||||||
EXAM_PAYMENT_PENDING: 'orange',
|
EXAM_PAYMENT_PENDING: 'orange',
|
||||||
EXAM_PAID: 'blue',
|
EXAM_PAID: 'blue',
|
||||||
EXAM_SCHEDULED: 'indigo',
|
EXAM_SCHEDULED: 'indigo',
|
||||||
@@ -220,14 +221,25 @@ export function CertificatesPage() {
|
|||||||
{/* Tooltip needs a hoverable child even while the button itself is
|
{/* Tooltip needs a hoverable child even while the button itself is
|
||||||
disabled, so the reason still shows on hover. */}
|
disabled, so the reason still shows on hover. */}
|
||||||
<span>
|
<span>
|
||||||
|
<Group gap="xs">
|
||||||
<Button
|
<Button
|
||||||
leftSection={<IconShieldCheck size={15} />}
|
leftSection={<IconShieldCheck size={15} />}
|
||||||
rightSection={<IconArrowRight size={15} />}
|
rightSection={<IconArrowRight size={15} />}
|
||||||
onClick={() => navigate('/certificates/apply')}
|
onClick={() => navigate('/licensing/CERTIFICATE_OF_COMPETENCY/apply')}
|
||||||
disabled={!canApply}
|
disabled={!canApply}
|
||||||
>
|
>
|
||||||
Apply for CoC / CoP
|
Apply for CoC
|
||||||
</Button>
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="light"
|
||||||
|
leftSection={<IconShieldCheck size={15} />}
|
||||||
|
rightSection={<IconArrowRight size={15} />}
|
||||||
|
onClick={() => navigate('/licensing/CERTIFICATE_OF_PROFICIENCY/apply')}
|
||||||
|
disabled={!canApply}
|
||||||
|
>
|
||||||
|
Apply for CoP
|
||||||
|
</Button>
|
||||||
|
</Group>
|
||||||
</span>
|
</span>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
</Group>
|
</Group>
|
||||||
@@ -269,7 +281,7 @@ export function CertificatesPage() {
|
|||||||
<Text fw={700} mb="md">My Applications</Text>
|
<Text fw={700} mb="md">My Applications</Text>
|
||||||
{applications.length === 0 ? (
|
{applications.length === 0 ? (
|
||||||
<Alert variant="light" color="gray" icon={<IconInfoCircle size={15} />}>
|
<Alert variant="light" color="gray" icon={<IconInfoCircle size={15} />}>
|
||||||
No active CoC/CoP applications. Click "Apply for CoC / CoP" to start.
|
No active CoC/CoP applications. Click "Apply for CoC" or "Apply for CoP" to start.
|
||||||
</Alert>
|
</Alert>
|
||||||
) : (
|
) : (
|
||||||
<Table highlightOnHover fz="sm" verticalSpacing="sm">
|
<Table highlightOnHover fz="sm" verticalSpacing="sm">
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -7,12 +7,13 @@ interface Props {
|
|||||||
t: TFunction;
|
t: TFunction;
|
||||||
requesting: boolean;
|
requesting: boolean;
|
||||||
paying: boolean;
|
paying: boolean;
|
||||||
onRequestExamFee: (app: LicenseApplication) => void;
|
onRetakeExam: (app: LicenseApplication) => void;
|
||||||
onPay: (app: LicenseApplication) => void;
|
onPay: (app: LicenseApplication) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* What the candidate can do while an examined certificate is in its exam leg.
|
* What the candidate can do while an examined certificate is in its
|
||||||
|
* eligibility or exam leg.
|
||||||
*
|
*
|
||||||
* Kept apart from the general actions column because these statuses only ever
|
* Kept apart from the general actions column because these statuses only ever
|
||||||
* occur on types that examine — folding them into that column would put five
|
* occur on types that examine — folding them into that column would put five
|
||||||
@@ -26,24 +27,50 @@ export function ExamStageActions({
|
|||||||
t,
|
t,
|
||||||
requesting,
|
requesting,
|
||||||
paying,
|
paying,
|
||||||
onRequestExamFee,
|
onRetakeExam,
|
||||||
onPay,
|
onPay,
|
||||||
}: Props) {
|
}: Props) {
|
||||||
// Eligible but not yet committed to sitting, or sat and not passed: both are
|
// The eligibility fee is invoiced the moment the application is submitted —
|
||||||
// the same decision — ask for the fee that buys a sitting.
|
// there is no separate "request" step, so this is a pay button, exactly
|
||||||
if (app.status === 'ELIGIBILITY_APPROVED' || app.status === 'EXAM_FAILED') {
|
// like EXAM_PAYMENT_PENDING below.
|
||||||
const retake = app.status === 'EXAM_FAILED';
|
if (app.status === 'ELIGIBILITY_PAYMENT_PENDING') {
|
||||||
return (
|
return (
|
||||||
<Button
|
<Button
|
||||||
size="xs"
|
size="xs"
|
||||||
variant="filled"
|
variant="filled"
|
||||||
color={retake ? 'orange' : 'teal'}
|
color="yellow"
|
||||||
loading={requesting}
|
loading={paying}
|
||||||
onClick={() => onRequestExamFee(app)}
|
onClick={() => onPay(app)}
|
||||||
>
|
>
|
||||||
{retake
|
{t('applications.actions.payEligibilityFee', {
|
||||||
? t('applications.actions.bookRetake', 'Book a resit')
|
defaultValue: 'Pay eligibility fee ({{amount}} {{currency}})',
|
||||||
: t('applications.actions.bookExam', 'Book exam')}
|
amount: Number(app.feeAmount ?? 0).toLocaleString(),
|
||||||
|
currency: app.feeCurrency,
|
||||||
|
})}
|
||||||
|
</Button>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Paid — queued for backoffice review. Nothing for the candidate to do.
|
||||||
|
if (app.status === 'ELIGIBILITY_PAID') {
|
||||||
|
return (
|
||||||
|
<Button size="xs" variant="subtle" disabled>
|
||||||
|
{t('applications.actions.eligibilityUnderReview', 'Under review')}
|
||||||
|
</Button>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Failed a sitting: the only decision left is whether to pay for another.
|
||||||
|
if (app.status === 'EXAM_FAILED') {
|
||||||
|
return (
|
||||||
|
<Button
|
||||||
|
size="xs"
|
||||||
|
variant="filled"
|
||||||
|
color="orange"
|
||||||
|
loading={requesting}
|
||||||
|
onClick={() => onRetakeExam(app)}
|
||||||
|
>
|
||||||
|
{t('applications.actions.bookRetake', 'Book a resit')}
|
||||||
</Button>
|
</Button>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -8,7 +8,8 @@ import { ExamStageActions } from '../../components/ExamStageActions';
|
|||||||
|
|
||||||
/** Statuses whose primary action is supplied by {@link ExamStageActions}. */
|
/** Statuses whose primary action is supplied by {@link ExamStageActions}. */
|
||||||
const EXAM_STAGE_STATUSES = [
|
const EXAM_STAGE_STATUSES = [
|
||||||
'ELIGIBILITY_APPROVED',
|
'ELIGIBILITY_PAYMENT_PENDING',
|
||||||
|
'ELIGIBILITY_PAID',
|
||||||
'EXAM_PAYMENT_PENDING',
|
'EXAM_PAYMENT_PENDING',
|
||||||
'EXAM_PAID',
|
'EXAM_PAID',
|
||||||
'EXAM_SCHEDULED',
|
'EXAM_SCHEDULED',
|
||||||
@@ -23,12 +24,12 @@ export function applicationActionsColumn(
|
|||||||
bypassEnabled: boolean;
|
bypassEnabled: boolean;
|
||||||
bypassing: boolean;
|
bypassing: boolean;
|
||||||
isPaying: boolean;
|
isPaying: boolean;
|
||||||
/** True while the exam fee is being raised for a booking or a resit. */
|
/** True while a resit is being requested. */
|
||||||
requestingExamFee: boolean;
|
requestingExamFee: boolean;
|
||||||
onBypass: (app: LicenseApplication) => void;
|
onBypass: (app: LicenseApplication) => void;
|
||||||
onCertificate: (app: LicenseApplication) => void;
|
onCertificate: (app: LicenseApplication) => void;
|
||||||
onPay: (app: LicenseApplication) => void;
|
onPay: (app: LicenseApplication) => void;
|
||||||
onRequestExamFee: (app: LicenseApplication) => void;
|
onRetakeExam: (app: LicenseApplication) => void;
|
||||||
onOpen: (app: LicenseApplication) => void;
|
onOpen: (app: LicenseApplication) => void;
|
||||||
},
|
},
|
||||||
): AdvancedColumn<LicenseApplication> {
|
): AdvancedColumn<LicenseApplication> {
|
||||||
@@ -46,7 +47,7 @@ export function applicationActionsColumn(
|
|||||||
t={t}
|
t={t}
|
||||||
requesting={deps.requestingExamFee}
|
requesting={deps.requestingExamFee}
|
||||||
paying={deps.isPaying}
|
paying={deps.isPaying}
|
||||||
onRequestExamFee={deps.onRequestExamFee}
|
onRetakeExam={deps.onRetakeExam}
|
||||||
onPay={deps.onPay}
|
onPay={deps.onPay}
|
||||||
/>
|
/>
|
||||||
{/* Both fee stops are bypassable — an examined certificate is
|
{/* Both fee stops are bypassable — an examined certificate is
|
||||||
|
|||||||
@@ -46,7 +46,7 @@ import {
|
|||||||
useGetMyApplicationsQuery,
|
useGetMyApplicationsQuery,
|
||||||
useGetMyLicensesQuery,
|
useGetMyLicensesQuery,
|
||||||
useGetPaymentCapabilitiesQuery,
|
useGetPaymentCapabilitiesQuery,
|
||||||
useRequestExamPaymentMutation,
|
useRetakeExamMutation,
|
||||||
type LicenseStatus,
|
type LicenseStatus,
|
||||||
} from '@ema-platform/api';
|
} from '@ema-platform/api';
|
||||||
import {
|
import {
|
||||||
@@ -99,8 +99,7 @@ export function MyApplicationsPage() {
|
|||||||
const { data: capabilities } = useGetPaymentCapabilitiesQuery();
|
const { data: capabilities } = useGetPaymentCapabilitiesQuery();
|
||||||
const { data: licences, isFetching: isFetchingLicences } = useGetMyLicensesQuery();
|
const { data: licences, isFetching: isFetchingLicences } = useGetMyLicensesQuery();
|
||||||
const [bypassPayment, { isLoading: bypassing }] = useBypassPaymentMutation();
|
const [bypassPayment, { isLoading: bypassing }] = useBypassPaymentMutation();
|
||||||
const [requestExamPayment, { isLoading: requestingExamFee }] =
|
const [retakeExam, { isLoading: requestingExamFee }] = useRetakeExamMutation();
|
||||||
useRequestExamPaymentMutation();
|
|
||||||
const [getCertificateUrl] = useGetCertificateUrlMutation();
|
const [getCertificateUrl] = useGetCertificateUrlMutation();
|
||||||
const { renewLicense, isRenewing } = useRenewLicense();
|
const { renewLicense, isRenewing } = useRenewLicense();
|
||||||
const [isDownloadingCert, setIsDownloadingCert] = useState(false);
|
const [isDownloadingCert, setIsDownloadingCert] = useState(false);
|
||||||
@@ -135,15 +134,15 @@ export function MyApplicationsPage() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Raises the examination fee, for a first sitting or a resit.
|
* Re-opens the examination fee for a failed candidate.
|
||||||
*
|
*
|
||||||
* Payment is a separate step: this only moves the application to
|
* Payment is a separate step: this only moves the application back to
|
||||||
* EXAM_PAYMENT_PENDING, and the Pay button that then appears hands off to
|
* EXAM_PAYMENT_PENDING, and the Pay button that then appears hands off to
|
||||||
* the provider the same way every other fee does.
|
* the provider the same way every other fee does.
|
||||||
*/
|
*/
|
||||||
async function requestExamFee(applicationId: string) {
|
async function retakeExamFee(applicationId: string) {
|
||||||
try {
|
try {
|
||||||
await requestExamPayment(applicationId).unwrap();
|
await retakeExam(applicationId).unwrap();
|
||||||
notifications.show({
|
notifications.show({
|
||||||
color: 'teal',
|
color: 'teal',
|
||||||
title: t('applications.examFeeRequested', 'Exam fee ready'),
|
title: t('applications.examFeeRequested', 'Exam fee ready'),
|
||||||
@@ -281,7 +280,7 @@ export function MyApplicationsPage() {
|
|||||||
onBypass: (app) => handleBypass(app.id),
|
onBypass: (app) => handleBypass(app.id),
|
||||||
onCertificate: (app) => openCertificateForApplication(app.id),
|
onCertificate: (app) => openCertificateForApplication(app.id),
|
||||||
onPay: (app) => pay(app.id),
|
onPay: (app) => pay(app.id),
|
||||||
onRequestExamFee: (app) => requestExamFee(app.id),
|
onRetakeExam: (app) => retakeExamFee(app.id),
|
||||||
onOpen: (app) =>
|
onOpen: (app) =>
|
||||||
navigate(`/licensing/${app.licenseType?.key ?? 'FREIGHT_FORWARDER'}/applications/${app.id}`),
|
navigate(`/licensing/${app.licenseType?.key ?? 'FREIGHT_FORWARDER'}/applications/${app.id}`),
|
||||||
}),
|
}),
|
||||||
|
|||||||
@@ -38,7 +38,6 @@ import { NotificationsPage } from "./features/notifications/pages/NotificationsP
|
|||||||
|
|
||||||
// Phase 2 — CoC / CoP
|
// Phase 2 — CoC / CoP
|
||||||
import { CertificatesPage } from "./features/certificates/pages/CertificatesPage";
|
import { CertificatesPage } from "./features/certificates/pages/CertificatesPage";
|
||||||
import { CoCApplicationPage } from "./features/certificates/pages/CoCApplicationPage";
|
|
||||||
|
|
||||||
// Phase 3 — Endorsement
|
// Phase 3 — Endorsement
|
||||||
import { EndorsementPage } from "./features/endorsement/pages/EndorsementPage";
|
import { EndorsementPage } from "./features/endorsement/pages/EndorsementPage";
|
||||||
@@ -266,14 +265,9 @@ export const router = createBrowserRouter([
|
|||||||
</RequirePermission>
|
</RequirePermission>
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
{
|
// CoC/CoP applications go through the generic license wizard —
|
||||||
path: "/certificates/apply",
|
// /licensing/CERTIFICATE_OF_COMPETENCY/apply and
|
||||||
element: (
|
// /licensing/CERTIFICATE_OF_PROFICIENCY/apply, wired below.
|
||||||
<RequirePermission anyOf={[P.VIEW_OWN_CERTIFICATES]}>
|
|
||||||
<CoCApplicationPage />
|
|
||||||
</RequirePermission>
|
|
||||||
),
|
|
||||||
},
|
|
||||||
|
|
||||||
// Phase 3 — Endorsement
|
// Phase 3 — Endorsement
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -337,7 +337,7 @@ export const mockApplications: Record<string, any> = {
|
|||||||
licenseType: { id: 'license-type-coc', key: 'CERTIFICATE_OF_COMPETENCY', name: { en: 'Certificate of Competency' } },
|
licenseType: { id: 'license-type-coc', key: 'CERTIFICATE_OF_COMPETENCY', name: { en: 'Certificate of Competency' } },
|
||||||
applicantUserId: 'user-mock-001',
|
applicantUserId: 'user-mock-001',
|
||||||
kind: 'RENEWAL',
|
kind: 'RENEWAL',
|
||||||
status: 'ELIGIBILITY_APPROVED',
|
status: 'ELIGIBILITY_PAID',
|
||||||
assignedOfficerId: 'officer-mock-002',
|
assignedOfficerId: 'officer-mock-002',
|
||||||
claimedAt: '2026-08-01T10:00:00.000Z',
|
claimedAt: '2026-08-01T10:00:00.000Z',
|
||||||
formData: { account: { applicantName: 'Abebe Tesfaye' } },
|
formData: { account: { applicantName: 'Abebe Tesfaye' } },
|
||||||
|
|||||||
@@ -494,9 +494,26 @@ export const licensingApi = baseApi
|
|||||||
scheduleExam: builder.mutation<
|
scheduleExam: builder.mutation<
|
||||||
LicenseApplication,
|
LicenseApplication,
|
||||||
{ id: string; examId: string; admissionNumber?: string; examDate?: string }
|
{ id: string; examId: string; admissionNumber?: string; examDate?: string }
|
||||||
|
>({
|
||||||
|
query: ({ id, examDate: _examDate, ...body }) => ({
|
||||||
|
// Matches the controller's `:id/exam-scheduled` route — `examDate`
|
||||||
|
// is UI-only context for the confirmation toast, not part of
|
||||||
|
// `MarkExamScheduledDto`, so it never goes on the wire.
|
||||||
|
url: `/license-application-review/${id}/exam-scheduled`,
|
||||||
|
method: 'POST',
|
||||||
|
body,
|
||||||
|
}),
|
||||||
|
invalidatesTags: (_r, error, { id }) =>
|
||||||
|
error ? [] : [itemTag('LicenseApplication', id), listTag('ApplicationQueue')],
|
||||||
|
}),
|
||||||
|
|
||||||
|
/** Records a published examination result (pass or fail). */
|
||||||
|
recordExamOutcome: builder.mutation<
|
||||||
|
LicenseApplication,
|
||||||
|
{ id: string; passed: boolean; score?: number }
|
||||||
>({
|
>({
|
||||||
query: ({ id, ...body }) => ({
|
query: ({ id, ...body }) => ({
|
||||||
url: `/license-application-review/${id}/schedule-exam`,
|
url: `/license-application-review/${id}/exam-outcome`,
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
body,
|
body,
|
||||||
}),
|
}),
|
||||||
@@ -505,12 +522,13 @@ export const licensingApi = baseApi
|
|||||||
}),
|
}),
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Raises the examination fee — after eligibility approval, or again when
|
* A failed candidate asks for another sitting. Re-opens the examination
|
||||||
* a failed candidate elects to resit.
|
* fee (EXAM_FAILED -> EXAM_PAYMENT_PENDING); eligibility was already
|
||||||
|
* assessed and paid for on the first attempt.
|
||||||
*/
|
*/
|
||||||
requestExamPayment: builder.mutation<LicenseApplication, string>({
|
retakeExam: builder.mutation<LicenseApplication, string>({
|
||||||
query: (id) => ({
|
query: (id) => ({
|
||||||
url: `/license-applications/${id}/request-exam-payment`,
|
url: `/license-applications/${id}/retake`,
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
}),
|
}),
|
||||||
invalidatesTags: (_r, error, id) =>
|
invalidatesTags: (_r, error, id) =>
|
||||||
@@ -864,7 +882,8 @@ export const {
|
|||||||
useFinalApproveMutation,
|
useFinalApproveMutation,
|
||||||
useRejectApplicationMutation,
|
useRejectApplicationMutation,
|
||||||
useScheduleExamMutation,
|
useScheduleExamMutation,
|
||||||
useRequestExamPaymentMutation,
|
useRecordExamOutcomeMutation,
|
||||||
|
useRetakeExamMutation,
|
||||||
useConfirmPaymentMutation,
|
useConfirmPaymentMutation,
|
||||||
useScheduleIssuanceMutation,
|
useScheduleIssuanceMutation,
|
||||||
useIssueCertificateMutation,
|
useIssueCertificateMutation,
|
||||||
|
|||||||
@@ -74,7 +74,8 @@ export const STATUS_LABELS: Record<LicenseStatus, string> = {
|
|||||||
SCHEDULED: 'Pickup Scheduled',
|
SCHEDULED: 'Pickup Scheduled',
|
||||||
CERTIFICATE_ISSUED: 'Certificate Issued',
|
CERTIFICATE_ISSUED: 'Certificate Issued',
|
||||||
COMPLETED: 'Completed',
|
COMPLETED: 'Completed',
|
||||||
ELIGIBILITY_APPROVED: 'Eligible to Sit',
|
ELIGIBILITY_PAYMENT_PENDING: 'Eligibility Fee Due',
|
||||||
|
ELIGIBILITY_PAID: 'Eligibility Under Review',
|
||||||
EXAM_PAYMENT_PENDING: 'Exam Fee Due',
|
EXAM_PAYMENT_PENDING: 'Exam Fee Due',
|
||||||
EXAM_PAID: 'Awaiting Exam Date',
|
EXAM_PAID: 'Awaiting Exam Date',
|
||||||
EXAM_SCHEDULED: 'Exam Scheduled',
|
EXAM_SCHEDULED: 'Exam Scheduled',
|
||||||
@@ -100,7 +101,8 @@ export const STATUS_COLORS: Record<LicenseStatus, string> = {
|
|||||||
SCHEDULED: 'cyan',
|
SCHEDULED: 'cyan',
|
||||||
CERTIFICATE_ISSUED: 'green',
|
CERTIFICATE_ISSUED: 'green',
|
||||||
COMPLETED: 'green',
|
COMPLETED: 'green',
|
||||||
ELIGIBILITY_APPROVED: 'teal',
|
ELIGIBILITY_PAYMENT_PENDING: 'yellow',
|
||||||
|
ELIGIBILITY_PAID: 'lime',
|
||||||
EXAM_PAYMENT_PENDING: 'yellow',
|
EXAM_PAYMENT_PENDING: 'yellow',
|
||||||
EXAM_PAID: 'lime',
|
EXAM_PAID: 'lime',
|
||||||
EXAM_SCHEDULED: 'cyan',
|
EXAM_SCHEDULED: 'cyan',
|
||||||
@@ -135,7 +137,8 @@ export const STATUS_PROGRESS: Record<LicenseStatus, number> = {
|
|||||||
REJECTED: 100,
|
REJECTED: 100,
|
||||||
// The exam leg sits between approval and the certificate fee, so these
|
// The exam leg sits between approval and the certificate fee, so these
|
||||||
// interleave with PAYMENT_PENDING (80) rather than running past it.
|
// interleave with PAYMENT_PENDING (80) rather than running past it.
|
||||||
ELIGIBILITY_APPROVED: 60,
|
ELIGIBILITY_PAYMENT_PENDING: 52,
|
||||||
|
ELIGIBILITY_PAID: 56,
|
||||||
EXAM_PAYMENT_PENDING: 64,
|
EXAM_PAYMENT_PENDING: 64,
|
||||||
EXAM_PAID: 68,
|
EXAM_PAID: 68,
|
||||||
EXAM_SCHEDULED: 72,
|
EXAM_SCHEDULED: 72,
|
||||||
@@ -149,6 +152,9 @@ export const APPLICANT_ACTION_STATUSES: LicenseStatus[] = [
|
|||||||
'DRAFT',
|
'DRAFT',
|
||||||
'RESUBMIT_REQUIRED',
|
'RESUBMIT_REQUIRED',
|
||||||
'PAYMENT_PENDING',
|
'PAYMENT_PENDING',
|
||||||
|
// Due the moment an examined application is submitted, before any officer
|
||||||
|
// looks at it.
|
||||||
|
'ELIGIBILITY_PAYMENT_PENDING',
|
||||||
// Both wait on the candidate: one to pay for a sitting, one to decide to
|
// Both wait on the candidate: one to pay for a sitting, one to decide to
|
||||||
// sit again after a failure.
|
// sit again after a failure.
|
||||||
'EXAM_PAYMENT_PENDING',
|
'EXAM_PAYMENT_PENDING',
|
||||||
|
|||||||
@@ -41,9 +41,11 @@ export type LicenseStatus =
|
|||||||
| "SCHEDULED"
|
| "SCHEDULED"
|
||||||
| "CERTIFICATE_ISSUED"
|
| "CERTIFICATE_ISSUED"
|
||||||
| "COMPLETED"
|
| "COMPLETED"
|
||||||
// Examined certificates (CoC, some CoP): approval establishes eligibility,
|
// Examined certificates (CoC, some CoP): the eligibility assessment fee is
|
||||||
// the candidate pays to sit, and the certificate fee falls due on a pass.
|
// due before review starts, then the candidate pays to sit, and the
|
||||||
| "ELIGIBILITY_APPROVED"
|
// certificate fee falls due on a pass.
|
||||||
|
| "ELIGIBILITY_PAYMENT_PENDING"
|
||||||
|
| "ELIGIBILITY_PAID"
|
||||||
| "EXAM_PAYMENT_PENDING"
|
| "EXAM_PAYMENT_PENDING"
|
||||||
| "EXAM_PAID"
|
| "EXAM_PAID"
|
||||||
| "EXAM_SCHEDULED"
|
| "EXAM_SCHEDULED"
|
||||||
|
|||||||
Reference in New Issue
Block a user