mirror of
https://github.com/Tria-plc/emaui.git
synced 2026-08-29 16:28:13 +00:00
Merge branch 'feature/seaman' of https://github.com/Tria-plc/emaui into certficate
This commit is contained in:
@@ -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;
|
||||
}
|
||||
@@ -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"
|
||||
|
||||
@@ -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}`),
|
||||
}),
|
||||
|
||||
@@ -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();
|
||||
|
||||
Reference in New Issue
Block a user