Merge branch 'feature/seaman' of https://github.com/Tria-plc/emaui into certficate

This commit is contained in:
fitse-yotor
2026-08-15 12:44:39 +03:00
34 changed files with 2638 additions and 472 deletions

View File

@@ -0,0 +1,82 @@
import { Button } from '@mantine/core';
import type { TFunction } from 'i18next';
import type { LicenseApplication } from '@ema-platform/api';
interface Props {
app: LicenseApplication;
t: TFunction;
requesting: boolean;
paying: boolean;
onRequestExamFee: (app: LicenseApplication) => void;
onPay: (app: LicenseApplication) => void;
}
/**
* What the candidate can do while an examined certificate is in its 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
* more branches into a cell that already reads as a chain of ternaries.
*
* Returns null for every other status, so the caller can render it
* unconditionally.
*/
export function ExamStageActions({
app,
t,
requesting,
paying,
onRequestExamFee,
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';
return (
<Button
size="xs"
variant="filled"
color={retake ? 'orange' : 'teal'}
loading={requesting}
onClick={() => onRequestExamFee(app)}
>
{retake
? t('applications.actions.bookRetake', 'Book a resit')
: t('applications.actions.bookExam', 'Book exam')}
</Button>
);
}
if (app.status === 'EXAM_PAYMENT_PENDING') {
return (
<Button
size="xs"
variant="filled"
color="yellow"
loading={paying}
onClick={() => onPay(app)}
>
{t('applications.actions.payExamFee', {
defaultValue: 'Pay exam fee ({{amount}} {{currency}})',
amount: Number(app.feeAmount ?? 0).toLocaleString(),
currency: app.feeCurrency,
})}
</Button>
);
}
// Paid and scheduled are both waiting states — nothing for the candidate to
// do, so say so rather than offering a button that does nothing.
if (app.status === 'EXAM_PAID' || app.status === 'EXAM_SCHEDULED') {
return (
<Button size="xs" variant="subtle" disabled>
{app.status === 'EXAM_PAID'
? t('applications.actions.awaitingDate', 'Awaiting exam date')
: t('applications.actions.examScheduled', 'Exam scheduled')}
</Button>
);
}
return null;
}

View File

@@ -4,6 +4,16 @@ import type { TFunction } from 'i18next';
import type { AdvancedColumn } from '@ema-platform/ui';
import type { LicenseApplication } from '@ema-platform/api';
import { PORTAL_PERMISSIONS } from '@ema-platform/auth';
import { ExamStageActions } from '../../components/ExamStageActions';
/** Statuses whose primary action is supplied by {@link ExamStageActions}. */
const EXAM_STAGE_STATUSES = [
'ELIGIBILITY_APPROVED',
'EXAM_PAYMENT_PENDING',
'EXAM_PAID',
'EXAM_SCHEDULED',
'EXAM_FAILED',
];
export function applicationActionsColumn(
t: TFunction,
@@ -13,9 +23,12 @@ export function applicationActionsColumn(
bypassEnabled: boolean;
bypassing: boolean;
isPaying: boolean;
/** True while the exam fee is being raised for a booking or a resit. */
requestingExamFee: boolean;
onBypass: (app: LicenseApplication) => void;
onCertificate: (app: LicenseApplication) => void;
onPay: (app: LicenseApplication) => void;
onRequestExamFee: (app: LicenseApplication) => void;
onOpen: (app: LicenseApplication) => void;
},
): AdvancedColumn<LicenseApplication> {
@@ -27,7 +40,20 @@ export function applicationActionsColumn(
const app = row.original;
return (
<Group gap="xs" justify="flex-end" wrap="nowrap">
{deps.bypassEnabled && app.status === 'PAYMENT_PENDING' && (
{/* Renders only during the exam leg; null everywhere else. */}
<ExamStageActions
app={app}
t={t}
requesting={deps.requestingExamFee}
paying={deps.isPaying}
onRequestExamFee={deps.onRequestExamFee}
onPay={deps.onPay}
/>
{/* Both fee stops are bypassable — an examined certificate is
otherwise untestable without a live gateway. */}
{deps.bypassEnabled &&
(app.status === 'PAYMENT_PENDING' ||
app.status === 'EXAM_PAYMENT_PENDING') && (
<Button
size="xs"
variant="default"
@@ -56,8 +82,11 @@ export function applicationActionsColumn(
</Button>
)}
{/* In PAYMENT_PENDING this button initiates payment, so it needs
that grant; every other status it merely opens the wizard. */}
{(app.status !== 'PAYMENT_PENDING' ||
that grant; every other status it merely opens the wizard.
Suppressed during the exam leg, where ExamStageActions already
supplies the action that matters. */}
{!EXAM_STAGE_STATUSES.includes(app.status) &&
(app.status !== 'PAYMENT_PENDING' ||
deps.can([PORTAL_PERMISSIONS.INITIATE_PAYMENT])) && (
<Button
size="xs"

View File

@@ -46,6 +46,7 @@ import {
useGetMyApplicationsQuery,
useGetMyLicensesQuery,
useGetPaymentCapabilitiesQuery,
useRequestExamPaymentMutation,
type LicenseStatus,
} from '@ema-platform/api';
import {
@@ -98,6 +99,8 @@ export function MyApplicationsPage() {
const { data: capabilities } = useGetPaymentCapabilitiesQuery();
const { data: licences, isFetching: isFetchingLicences } = useGetMyLicensesQuery();
const [bypassPayment, { isLoading: bypassing }] = useBypassPaymentMutation();
const [requestExamPayment, { isLoading: requestingExamFee }] =
useRequestExamPaymentMutation();
const [getCertificateUrl] = useGetCertificateUrlMutation();
const { renewLicense, isRenewing } = useRenewLicense();
const [isDownloadingCert, setIsDownloadingCert] = useState(false);
@@ -131,6 +134,33 @@ export function MyApplicationsPage() {
}
}
/**
* Raises the examination fee, for a first sitting or a resit.
*
* Payment is a separate step: this only moves the application 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) {
try {
await requestExamPayment(applicationId).unwrap();
notifications.show({
color: 'teal',
title: t('applications.examFeeRequested', 'Exam fee ready'),
message: t(
'applications.examFeeRequestedBody',
'Pay the examination fee and you will be scheduled for a sitting.',
),
});
} catch (err) {
notifications.show({
color: 'red',
title: t('applications.examFeeFailed', 'Could not book the exam'),
message: extractErrorMessage(err),
});
}
}
/**
* Opens the certificate belonging to an application.
*
@@ -247,9 +277,11 @@ export function MyApplicationsPage() {
bypassEnabled: capabilities?.bypassEnabled ?? false,
bypassing,
isPaying,
requestingExamFee,
onBypass: (app) => handleBypass(app.id),
onCertificate: (app) => openCertificateForApplication(app.id),
onPay: (app) => pay(app.id),
onRequestExamFee: (app) => requestExamFee(app.id),
onOpen: (app) =>
navigate(`/licensing/${app.licenseType?.key ?? 'FREIGHT_FORWARDER'}/applications/${app.id}`),
}),