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

@@ -72,7 +72,8 @@ const STATUS_COLOR: Record<string, string> = {
RESUBMIT_REQUIRED: 'orange',
INSPECTION_PENDING: 'grape',
INSPECTION_COMPLETED: 'grape',
ELIGIBILITY_APPROVED: 'teal',
ELIGIBILITY_PAYMENT_PENDING: 'orange',
ELIGIBILITY_PAID: 'blue',
EXAM_PAYMENT_PENDING: 'orange',
EXAM_PAID: 'blue',
EXAM_SCHEDULED: 'indigo',
@@ -220,14 +221,25 @@ export function CertificatesPage() {
{/* Tooltip needs a hoverable child even while the button itself is
disabled, so the reason still shows on hover. */}
<span>
<Button
leftSection={<IconShieldCheck size={15} />}
rightSection={<IconArrowRight size={15} />}
onClick={() => navigate('/certificates/apply')}
disabled={!canApply}
>
Apply for CoC / CoP
</Button>
<Group gap="xs">
<Button
leftSection={<IconShieldCheck size={15} />}
rightSection={<IconArrowRight size={15} />}
onClick={() => navigate('/licensing/CERTIFICATE_OF_COMPETENCY/apply')}
disabled={!canApply}
>
Apply for CoC
</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>
</Tooltip>
</Group>
@@ -269,7 +281,7 @@ export function CertificatesPage() {
<Text fw={700} mb="md">My Applications</Text>
{applications.length === 0 ? (
<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>
) : (
<Table highlightOnHover fz="sm" verticalSpacing="sm">

View File

@@ -7,12 +7,13 @@ interface Props {
t: TFunction;
requesting: boolean;
paying: boolean;
onRequestExamFee: (app: LicenseApplication) => void;
onRetakeExam: (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
* occur on types that examine — folding them into that column would put five
@@ -26,24 +27,50 @@ export function ExamStageActions({
t,
requesting,
paying,
onRequestExamFee,
onRetakeExam,
onPay,
}: Props) {
// Eligible but not yet committed to sitting, or sat and not passed: both are
// the same decision — ask for the fee that buys a sitting.
if (app.status === 'ELIGIBILITY_APPROVED' || app.status === 'EXAM_FAILED') {
const retake = app.status === 'EXAM_FAILED';
// The eligibility fee is invoiced the moment the application is submitted —
// there is no separate "request" step, so this is a pay button, exactly
// like EXAM_PAYMENT_PENDING below.
if (app.status === 'ELIGIBILITY_PAYMENT_PENDING') {
return (
<Button
size="xs"
variant="filled"
color={retake ? 'orange' : 'teal'}
loading={requesting}
onClick={() => onRequestExamFee(app)}
color="yellow"
loading={paying}
onClick={() => onPay(app)}
>
{retake
? t('applications.actions.bookRetake', 'Book a resit')
: t('applications.actions.bookExam', 'Book exam')}
{t('applications.actions.payEligibilityFee', {
defaultValue: 'Pay eligibility fee ({{amount}} {{currency}})',
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>
);
}

View File

@@ -8,7 +8,8 @@ import { ExamStageActions } from '../../components/ExamStageActions';
/** Statuses whose primary action is supplied by {@link ExamStageActions}. */
const EXAM_STAGE_STATUSES = [
'ELIGIBILITY_APPROVED',
'ELIGIBILITY_PAYMENT_PENDING',
'ELIGIBILITY_PAID',
'EXAM_PAYMENT_PENDING',
'EXAM_PAID',
'EXAM_SCHEDULED',
@@ -23,12 +24,12 @@ export function applicationActionsColumn(
bypassEnabled: boolean;
bypassing: 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;
onBypass: (app: LicenseApplication) => void;
onCertificate: (app: LicenseApplication) => void;
onPay: (app: LicenseApplication) => void;
onRequestExamFee: (app: LicenseApplication) => void;
onRetakeExam: (app: LicenseApplication) => void;
onOpen: (app: LicenseApplication) => void;
},
): AdvancedColumn<LicenseApplication> {
@@ -46,7 +47,7 @@ export function applicationActionsColumn(
t={t}
requesting={deps.requestingExamFee}
paying={deps.isPaying}
onRequestExamFee={deps.onRequestExamFee}
onRetakeExam={deps.onRetakeExam}
onPay={deps.onPay}
/>
{/* Both fee stops are bypassable — an examined certificate is

View File

@@ -46,7 +46,7 @@ import {
useGetMyApplicationsQuery,
useGetMyLicensesQuery,
useGetPaymentCapabilitiesQuery,
useRequestExamPaymentMutation,
useRetakeExamMutation,
type LicenseStatus,
} from '@ema-platform/api';
import {
@@ -99,8 +99,7 @@ export function MyApplicationsPage() {
const { data: capabilities } = useGetPaymentCapabilitiesQuery();
const { data: licences, isFetching: isFetchingLicences } = useGetMyLicensesQuery();
const [bypassPayment, { isLoading: bypassing }] = useBypassPaymentMutation();
const [requestExamPayment, { isLoading: requestingExamFee }] =
useRequestExamPaymentMutation();
const [retakeExam, { isLoading: requestingExamFee }] = useRetakeExamMutation();
const [getCertificateUrl] = useGetCertificateUrlMutation();
const { renewLicense, isRenewing } = useRenewLicense();
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
* the provider the same way every other fee does.
*/
async function requestExamFee(applicationId: string) {
async function retakeExamFee(applicationId: string) {
try {
await requestExamPayment(applicationId).unwrap();
await retakeExam(applicationId).unwrap();
notifications.show({
color: 'teal',
title: t('applications.examFeeRequested', 'Exam fee ready'),
@@ -281,7 +280,7 @@ export function MyApplicationsPage() {
onBypass: (app) => handleBypass(app.id),
onCertificate: (app) => openCertificateForApplication(app.id),
onPay: (app) => pay(app.id),
onRequestExamFee: (app) => requestExamFee(app.id),
onRetakeExam: (app) => retakeExamFee(app.id),
onOpen: (app) =>
navigate(`/licensing/${app.licenseType?.key ?? 'FREIGHT_FORWARDER'}/applications/${app.id}`),
}),

View File

@@ -38,7 +38,6 @@ import { NotificationsPage } from "./features/notifications/pages/NotificationsP
// Phase 2 — CoC / CoP
import { CertificatesPage } from "./features/certificates/pages/CertificatesPage";
import { CoCApplicationPage } from "./features/certificates/pages/CoCApplicationPage";
// Phase 3 — Endorsement
import { EndorsementPage } from "./features/endorsement/pages/EndorsementPage";
@@ -266,14 +265,9 @@ export const router = createBrowserRouter([
</RequirePermission>
),
},
{
path: "/certificates/apply",
element: (
<RequirePermission anyOf={[P.VIEW_OWN_CERTIFICATES]}>
<CoCApplicationPage />
</RequirePermission>
),
},
// CoC/CoP applications go through the generic license wizard —
// /licensing/CERTIFICATE_OF_COMPETENCY/apply and
// /licensing/CERTIFICATE_OF_PROFICIENCY/apply, wired below.
// Phase 3 — Endorsement
{