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}`),
}),

View File

@@ -1,5 +1,9 @@
import { configureStore } from "@reduxjs/toolkit";
import { baseApi, configureTokenRefresh } from "@ema-platform/api";
import {
baseApi,
configureSessionScope,
configureTokenRefresh,
} from "@ema-platform/api";
import {
authReducer,
signupReducer,
@@ -12,6 +16,35 @@ import {
import type { AuthUser, CurrentProfile } from "@ema-platform/auth";
configureAuthStorage("ema-portal", true);
// See the backoffice store: cookies ignore the port, so the API layer is told
// which app it is rather than guessing from a shared jar.
configureSessionScope("ema-portal");
// Dev-only preview mode (VITE_USE_MOCKS=true): seed a fake session so
// ProtectedRoute (which only checks that a token exists) treats the user as
// logged in without a real backend to authenticate against. Only runs when
// no real session is already present, so a genuine login is never clobbered.
if (
(import.meta as { env?: Record<string, string> }).env?.["VITE_USE_MOCKS"] === "true" &&
!authStorage.getToken()
) {
authStorage.setToken("mock-dev-token");
authStorage.setRefreshToken("mock-dev-refresh-token");
authStorage.setUser<AuthUser>({
id: "user-mock-001",
email: "abebe.tesfaye@example.et",
username: "abebe.tesfaye",
phoneNumber: "+251911223344",
name: { am: "አበበ ተስፋዬ", en: "Abebe Tesfaye" },
status: "ACTIVE",
sharepointId: null,
hasSetPassword: true,
hasFinishedRegistration: true,
hasFinishedDMSOnboarding: true,
isPhoneNumberVerified: true,
userType: "PORTAL",
});
}
const preloadedAuth = (() => {
const token = authStorage.getToken();