mirror of
https://github.com/Tria-plc/emaui.git
synced 2026-09-07 17:45:43 +00:00
Merge branch 'WorkflowChange' into logestic_chnage
This commit is contained in:
@@ -24,6 +24,7 @@ import {
|
||||
TERMINAL_STATUSES,
|
||||
extractErrorMessage,
|
||||
useLocalized,
|
||||
useBypassPaymentMutation,
|
||||
useGetCertificateUrlMutation,
|
||||
useGetMyApplicationsQuery,
|
||||
useGetMyLicensesQuery,
|
||||
@@ -31,9 +32,17 @@ import {
|
||||
import { useCurrentProfile } from '@ema-platform/auth';
|
||||
import { AdvancedTable, PageLoader, notify, useServerTable } from '@ema-platform/ui';
|
||||
import { useDateDisplayer } from '@ema-platform/shared';
|
||||
import { useApplicationPayment } from '../../payments/hooks/useApplicationPayment';
|
||||
import { endorsementColumns } from './columns';
|
||||
|
||||
const ENDORSEMENT_TYPE_KEYS = ['ENDORSEMENT_COC', 'ENDORSEMENT_GOC'];
|
||||
// ENDORSEMENT_SEAFARER covers CoC and GOC together and is the only type new
|
||||
// applications file against; the other two stay listed so an application or
|
||||
// licence filed before the switch keeps showing up here.
|
||||
const ENDORSEMENT_TYPE_KEYS = [
|
||||
'ENDORSEMENT_SEAFARER',
|
||||
'ENDORSEMENT_COC',
|
||||
'ENDORSEMENT_GOC',
|
||||
];
|
||||
|
||||
function EligibilityItem({ ok, label }: { ok: boolean; label: string }) {
|
||||
return (
|
||||
@@ -74,6 +83,8 @@ export function EndorsementPage() {
|
||||
const showDate = useDateDisplayer();
|
||||
const localized = useLocalized();
|
||||
const [getCertificateUrl] = useGetCertificateUrlMutation();
|
||||
const { pay, isPaying } = useApplicationPayment();
|
||||
const [bypassPayment, { isLoading: bypassing }] = useBypassPaymentMutation();
|
||||
const issuedTable = useServerTable();
|
||||
|
||||
const registered =
|
||||
@@ -90,6 +101,22 @@ export function EndorsementPage() {
|
||||
);
|
||||
const issuedPage = issuedTable.paginate(issued);
|
||||
|
||||
async function handleBypass(applicationId: string) {
|
||||
try {
|
||||
const result = await bypassPayment(applicationId).unwrap();
|
||||
notify.success(
|
||||
result.certificateIssued
|
||||
? t('endorsement.bypassIssued', 'Payment bypassed — the endorsement has been issued.')
|
||||
: t('endorsement.bypassOk', {
|
||||
defaultValue: 'Payment bypassed — application is now {{status}}.',
|
||||
status: result.status.replace(/_/g, ' ').toLowerCase(),
|
||||
}),
|
||||
);
|
||||
} catch (err) {
|
||||
notify.error(extractErrorMessage(err, t('endorsement.bypassFailed', 'Bypass failed')));
|
||||
}
|
||||
}
|
||||
|
||||
async function download(licenseId: string) {
|
||||
try {
|
||||
const result = await getCertificateUrl(licenseId).unwrap();
|
||||
@@ -132,21 +159,13 @@ export function EndorsementPage() {
|
||||
/>
|
||||
</List>
|
||||
</div>
|
||||
<Stack gap="xs">
|
||||
<Button
|
||||
rightSection={<IconArrowRight size={16} />}
|
||||
onClick={() => navigate('/licensing/ENDORSEMENT_COC/apply')}
|
||||
>
|
||||
{t('endorsement.endorseCoc', 'Endorse a CoC')}
|
||||
</Button>
|
||||
<Button
|
||||
variant="light"
|
||||
rightSection={<IconArrowRight size={16} />}
|
||||
onClick={() => navigate('/licensing/ENDORSEMENT_GOC/apply')}
|
||||
>
|
||||
{t('endorsement.endorseGoc', 'Endorse a GOC')}
|
||||
</Button>
|
||||
</Stack>
|
||||
<Button
|
||||
disabled={!registered}
|
||||
rightSection={<IconArrowRight size={16} />}
|
||||
onClick={() => navigate('/licensing/ENDORSEMENT_SEAFARER/apply')}
|
||||
>
|
||||
{t('endorsement.apply', 'Apply for an endorsement')}
|
||||
</Button>
|
||||
</Group>
|
||||
{!registered && (
|
||||
<Alert color="orange" mt="md" icon={<IconInfoCircle size={16} />}>
|
||||
@@ -183,6 +202,36 @@ export function EndorsementPage() {
|
||||
<Badge color={STATUS_COLORS[app.status]}>
|
||||
{t(`applications.status.${app.status}`, STATUS_LABELS[app.status])}
|
||||
</Badge>
|
||||
{app.status === 'PAYMENT_PENDING' && (
|
||||
<>
|
||||
<Button
|
||||
size="compact-sm"
|
||||
color="yellow"
|
||||
loading={isPaying}
|
||||
onClick={() => pay(app.id)}
|
||||
>
|
||||
{t('endorsement.pay', {
|
||||
defaultValue: 'Pay {{amount}} {{currency}}',
|
||||
amount: Number(app.feeAmount ?? 0).toLocaleString(),
|
||||
currency: app.feeCurrency,
|
||||
})}
|
||||
</Button>
|
||||
{/* ponytail: shown unconditionally for the testing
|
||||
phase — the server still refuses it unless
|
||||
ALLOW_PAYMENT_BYPASS is set and NODE_ENV is not
|
||||
production. Re-gate on useGetPaymentCapabilitiesQuery
|
||||
(like MyApplicationsPage) before prod. */}
|
||||
<Button
|
||||
size="compact-sm"
|
||||
variant="default"
|
||||
loading={bypassing}
|
||||
onClick={() => handleBypass(app.id)}
|
||||
title="Testing only — marks the fee paid"
|
||||
>
|
||||
{t('endorsement.bypass', 'Bypass payment')}
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
<Button
|
||||
size="compact-sm"
|
||||
variant="light"
|
||||
|
||||
@@ -0,0 +1,50 @@
|
||||
import { Button, Card, Stack, Text, ThemeIcon, Title } from '@mantine/core';
|
||||
import { IconCircleCheck, IconClockPause } from '@tabler/icons-react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import type { AttemptStatus } from '../types/exam-attempt';
|
||||
|
||||
/**
|
||||
* No score, no pass/fail, nothing evaluation-shaped — grading hasn't run.
|
||||
* This only confirms what actually happened: the candidate submitted, or
|
||||
* the deadline closed the attempt out first.
|
||||
*/
|
||||
export function ExamCompletion({
|
||||
status,
|
||||
submittedAt,
|
||||
}: {
|
||||
status: AttemptStatus;
|
||||
submittedAt: string | null;
|
||||
}) {
|
||||
const navigate = useNavigate();
|
||||
const expired = status === 'EXPIRED';
|
||||
|
||||
return (
|
||||
<Stack maw={520} mx="auto" align="center" py="xl">
|
||||
<Card withBorder radius="lg" p="xl" w="100%">
|
||||
<Stack align="center" gap="md">
|
||||
<ThemeIcon size={64} radius="xl" variant="light" color={expired ? 'orange' : 'teal'}>
|
||||
{expired ? <IconClockPause size={32} /> : <IconCircleCheck size={32} />}
|
||||
</ThemeIcon>
|
||||
<Title order={3} ta="center">
|
||||
{expired ? 'Time expired' : 'Exam submitted'}
|
||||
</Title>
|
||||
<Text ta="center" c="dimmed">
|
||||
{expired
|
||||
? 'The scheduled time ran out. Your saved answers were recorded as your final submission.'
|
||||
: 'Your answers have been recorded.'}
|
||||
{' '}Your result will appear on the Examinations page once marking, moderation and
|
||||
approval are complete — it is not available yet.
|
||||
</Text>
|
||||
{submittedAt && (
|
||||
<Text fz="xs" c="dimmed">
|
||||
{expired ? 'Closed' : 'Submitted'} at {new Date(submittedAt).toLocaleString()}
|
||||
</Text>
|
||||
)}
|
||||
<Button variant="light" onClick={() => navigate('/exams')}>
|
||||
Back to Examinations
|
||||
</Button>
|
||||
</Stack>
|
||||
</Card>
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,94 @@
|
||||
import { Alert, Badge, Button, Card, Group, Stack, Text, Title } from '@mantine/core';
|
||||
import { IconAlertCircle, IconClock, IconInfoCircle, IconPlayerPlay } from '@tabler/icons-react';
|
||||
import type { Bilingual } from '@ema-platform/api';
|
||||
import type { EstimatedTime, RegistrationWithExam } from '../types/exam-attempt';
|
||||
|
||||
function formatDuration(time: EstimatedTime | null | undefined): string {
|
||||
if (!time) return 'Not configured';
|
||||
const parts = [
|
||||
time.days ? `${time.days}d` : null,
|
||||
time.hours ? `${time.hours}h` : null,
|
||||
time.minutes ? `${time.minutes}m` : null,
|
||||
].filter(Boolean);
|
||||
return parts.length ? parts.join(' ') : '0m';
|
||||
}
|
||||
|
||||
export function ExamInstructions({
|
||||
registration,
|
||||
localized,
|
||||
showDate,
|
||||
starting,
|
||||
onStart,
|
||||
}: {
|
||||
registration: RegistrationWithExam;
|
||||
localized: (value: Bilingual | undefined) => string;
|
||||
showDate: (value: string | null | undefined) => string;
|
||||
starting: boolean;
|
||||
onStart: () => void;
|
||||
}) {
|
||||
const exam = registration.exam;
|
||||
const canStart = exam?.status === 'ACTIVE';
|
||||
|
||||
return (
|
||||
<Stack maw={720} mx="auto" gap="md">
|
||||
<Title order={2}>{localized(exam?.title) || 'Examination'}</Title>
|
||||
<Text c="dimmed">{localized(exam?.certification?.name)}</Text>
|
||||
|
||||
<Card withBorder radius="md" p="lg">
|
||||
<Stack gap="sm">
|
||||
<Group justify="space-between">
|
||||
<Text fz="sm" c="dimmed">Admission number</Text>
|
||||
<Text fz="sm" fw={600} ff="monospace">{registration.admissionNumber}</Text>
|
||||
</Group>
|
||||
<Group justify="space-between">
|
||||
<Text fz="sm" c="dimmed">Session date</Text>
|
||||
<Text fz="sm">{showDate(exam?.date)}{exam?.venue ? ` · ${exam.venue}` : ''}</Text>
|
||||
</Group>
|
||||
<Group justify="space-between">
|
||||
<Text fz="sm" c="dimmed">Duration</Text>
|
||||
<Badge variant="light" leftSection={<IconClock size={12} />}>
|
||||
{formatDuration(exam?.givenTime)}
|
||||
</Badge>
|
||||
</Group>
|
||||
<Group justify="space-between">
|
||||
<Text fz="sm" c="dimmed">Attempt</Text>
|
||||
<Badge variant="light" color={registration.kind === 'RETAKE' ? 'orange' : 'blue'}>
|
||||
{registration.kind === 'RETAKE'
|
||||
? `Retake · sitting ${registration.attemptNumber}`
|
||||
: 'First sitting'}
|
||||
</Badge>
|
||||
</Group>
|
||||
</Stack>
|
||||
</Card>
|
||||
|
||||
{exam?.direction && localized(exam.direction) && (
|
||||
<Alert icon={<IconInfoCircle size={16} />} color="blue" variant="light" title="Instructions">
|
||||
{localized(exam.direction)}
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
<Alert icon={<IconAlertCircle size={16} />} color="yellow" variant="light">
|
||||
Once started, the timer cannot be paused. Answers are saved automatically as you go, but
|
||||
the exam ends the moment the deadline passes, whether or not you have submitted.
|
||||
</Alert>
|
||||
|
||||
{!canStart && (
|
||||
<Alert color="gray" variant="light">
|
||||
This session is not currently open for candidates to begin.
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
<Group justify="flex-end">
|
||||
<Button
|
||||
size="md"
|
||||
leftSection={<IconPlayerPlay size={16} />}
|
||||
loading={starting}
|
||||
disabled={!canStart}
|
||||
onClick={onStart}
|
||||
>
|
||||
Start exam
|
||||
</Button>
|
||||
</Group>
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,118 @@
|
||||
import { Badge, Button, Group, Paper, Radio, Stack, Text, Textarea } from '@mantine/core';
|
||||
import { IconAlertCircle, IconCheck, IconRefresh } from '@tabler/icons-react';
|
||||
import type { Bilingual } from '@ema-platform/api';
|
||||
import type { CandidateQuestion, SaveState } from '../types/exam-attempt';
|
||||
|
||||
function SaveIndicator({ state, onRetry }: { state: SaveState; onRetry: () => void }) {
|
||||
if (state === 'saving') {
|
||||
return <Text fz="xs" c="dimmed">Saving…</Text>;
|
||||
}
|
||||
if (state === 'saved') {
|
||||
return (
|
||||
<Group gap={4}>
|
||||
<IconCheck size={13} color="var(--mantine-color-teal-6)" />
|
||||
<Text fz="xs" c="teal">Saved</Text>
|
||||
</Group>
|
||||
);
|
||||
}
|
||||
if (state === 'error') {
|
||||
return (
|
||||
<Group gap={6}>
|
||||
<IconAlertCircle size={13} color="var(--mantine-color-red-6)" />
|
||||
<Text fz="xs" c="red">Not saved</Text>
|
||||
<Button
|
||||
size="compact-xs"
|
||||
variant="light"
|
||||
color="red"
|
||||
leftSection={<IconRefresh size={12} />}
|
||||
onClick={onRetry}
|
||||
>
|
||||
Retry
|
||||
</Button>
|
||||
</Group>
|
||||
);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Renders one question — never the answer key, because the API response
|
||||
* this reads from (`CandidateQuestion`/`CandidateOption`) has no such field
|
||||
* to render even by mistake.
|
||||
*/
|
||||
export function ExamQuestionDisplay({
|
||||
question,
|
||||
index,
|
||||
total,
|
||||
localized,
|
||||
selectedOptionId,
|
||||
answerText,
|
||||
saveState,
|
||||
disabled,
|
||||
onSelectOption,
|
||||
onChangeText,
|
||||
onRetry,
|
||||
}: {
|
||||
question: CandidateQuestion;
|
||||
index: number;
|
||||
total: number;
|
||||
localized: (value: Bilingual | undefined) => string;
|
||||
selectedOptionId: string | null | undefined;
|
||||
answerText: string | null | undefined;
|
||||
saveState: SaveState;
|
||||
disabled: boolean;
|
||||
onSelectOption: (optionId: string) => void;
|
||||
onChangeText: (text: string) => void;
|
||||
onRetry: () => void;
|
||||
}) {
|
||||
return (
|
||||
<Paper withBorder radius="md" p="lg">
|
||||
<Group justify="space-between" mb="sm">
|
||||
<Badge variant="light" color="gray">
|
||||
Question {index + 1} of {total} · {question.points} pts
|
||||
</Badge>
|
||||
<SaveIndicator state={saveState} onRetry={onRetry} />
|
||||
</Group>
|
||||
|
||||
<Text fz="md" fw={500} mb="lg">
|
||||
{localized(question.title)}
|
||||
</Text>
|
||||
|
||||
{question.form === 'CHOICE' ? (
|
||||
<Radio.Group
|
||||
value={selectedOptionId ?? ''}
|
||||
onChange={onSelectOption}
|
||||
>
|
||||
<Stack gap="sm">
|
||||
{question.options
|
||||
.slice()
|
||||
.sort((a, b) => a.order - b.order)
|
||||
.map((option) => (
|
||||
<Radio.Card
|
||||
key={option.id}
|
||||
value={option.id}
|
||||
disabled={disabled}
|
||||
p="sm"
|
||||
radius="md"
|
||||
>
|
||||
<Group wrap="nowrap" gap="sm">
|
||||
<Radio.Indicator disabled={disabled} />
|
||||
<Text fz="sm">{localized(option.text)}</Text>
|
||||
</Group>
|
||||
</Radio.Card>
|
||||
))}
|
||||
</Stack>
|
||||
</Radio.Group>
|
||||
) : (
|
||||
<Textarea
|
||||
placeholder="Write your answer"
|
||||
minRows={8}
|
||||
autosize
|
||||
disabled={disabled}
|
||||
value={answerText ?? ''}
|
||||
onChange={(event) => onChangeText(event.currentTarget.value)}
|
||||
/>
|
||||
)}
|
||||
</Paper>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
import { Paper, SimpleGrid, Text, UnstyledButton } from '@mantine/core';
|
||||
import type { CandidateQuestion } from '../types/exam-attempt';
|
||||
|
||||
export function ExamQuestionNav({
|
||||
questions,
|
||||
currentIndex,
|
||||
answeredIds,
|
||||
disabled,
|
||||
onJump,
|
||||
}: {
|
||||
questions: CandidateQuestion[];
|
||||
currentIndex: number;
|
||||
answeredIds: Set<string>;
|
||||
disabled: boolean;
|
||||
onJump: (index: number) => void;
|
||||
}) {
|
||||
return (
|
||||
<Paper withBorder radius="md" p="sm">
|
||||
<Text fz="xs" fw={600} c="dimmed" mb="xs" tt="uppercase">
|
||||
Questions
|
||||
</Text>
|
||||
<SimpleGrid cols={5} spacing={6}>
|
||||
{questions.map((q, index) => {
|
||||
const answered = answeredIds.has(q.id);
|
||||
const current = index === currentIndex;
|
||||
return (
|
||||
<UnstyledButton
|
||||
key={q.id}
|
||||
disabled={disabled}
|
||||
onClick={() => onJump(index)}
|
||||
style={{
|
||||
height: 34,
|
||||
borderRadius: 6,
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
fontWeight: 600,
|
||||
fontSize: 13,
|
||||
border: current ? '2px solid var(--mantine-color-blue-6)' : '1px solid var(--mantine-color-gray-4)',
|
||||
background: answered
|
||||
? 'var(--mantine-color-teal-1)'
|
||||
: 'var(--mantine-color-body)',
|
||||
color: answered ? 'var(--mantine-color-teal-8)' : undefined,
|
||||
opacity: disabled ? 0.5 : 1,
|
||||
}}
|
||||
>
|
||||
{index + 1}
|
||||
</UnstyledButton>
|
||||
);
|
||||
})}
|
||||
</SimpleGrid>
|
||||
<Text fz="xs" c="dimmed" mt="sm">
|
||||
{answeredIds.size} of {questions.length} answered
|
||||
</Text>
|
||||
</Paper>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
import { Badge, Group } from '@mantine/core';
|
||||
import { IconClock } from '@tabler/icons-react';
|
||||
|
||||
function format(totalSeconds: number): string {
|
||||
const s = Math.max(0, totalSeconds);
|
||||
const h = Math.floor(s / 3600);
|
||||
const m = Math.floor((s % 3600) / 60);
|
||||
const sec = s % 60;
|
||||
const pad = (n: number) => String(n).padStart(2, '0');
|
||||
return h > 0 ? `${h}:${pad(m)}:${pad(sec)}` : `${pad(m)}:${pad(sec)}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Display only. `remainingSeconds` is a local countdown seeded once from the
|
||||
* server's own clock (`AttemptSession.remainingSeconds`/`serverTime`) and
|
||||
* ticked down client-side — the deadline it represents is enforced by the
|
||||
* backend on every save/submit regardless of whether this number is right.
|
||||
*/
|
||||
export function ExamTimer({ remainingSeconds }: { remainingSeconds: number }) {
|
||||
const low = remainingSeconds <= 300; // 5 minutes
|
||||
return (
|
||||
<Group gap={6}>
|
||||
<Badge
|
||||
size="lg"
|
||||
variant="light"
|
||||
color={low ? 'red' : 'blue'}
|
||||
leftSection={<IconClock size={14} />}
|
||||
ff="monospace"
|
||||
>
|
||||
{format(remainingSeconds)}
|
||||
</Badge>
|
||||
</Group>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,286 @@
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { useApiMutation, useApiQuery, extractErrorMessage } from '@ema-platform/api';
|
||||
import { notify } from '@ema-platform/ui';
|
||||
import type {
|
||||
AttemptSession,
|
||||
CandidateAnswer,
|
||||
ExamAttempt,
|
||||
RegistrationWithExam,
|
||||
SaveState,
|
||||
} from '../types/exam-attempt';
|
||||
|
||||
const ESSAY_DEBOUNCE_MS = 1500;
|
||||
|
||||
type ViewState = 'loading' | 'not-started' | 'taking' | 'completed' | 'error';
|
||||
|
||||
type LocalAnswer = { selectedOptionId?: string | null; answerText?: string | null };
|
||||
|
||||
/**
|
||||
* All state and API orchestration for taking one exam. Kept out of the page
|
||||
* component so the component tree stays about rendering, not about save
|
||||
* timers and expiry races.
|
||||
*
|
||||
* Nothing here is a security boundary — every write still goes through the
|
||||
* backend's own ownership + `applyExpiry()` checks on every call. This hook
|
||||
* only decides what to show; a client that skipped straight to calling the
|
||||
* API directly would hit exactly the same server-side rules.
|
||||
*/
|
||||
export function useExamAttempt(examId: string | undefined) {
|
||||
const [session, setSession] = useState<AttemptSession | null>(null);
|
||||
const [viewState, setViewState] = useState<ViewState>('loading');
|
||||
const [errorMessage, setErrorMessage] = useState('');
|
||||
const [currentIndex, setCurrentIndex] = useState(0);
|
||||
const [answers, setAnswers] = useState<Record<string, LocalAnswer>>({});
|
||||
const [saveStates, setSaveStates] = useState<Record<string, SaveState>>({});
|
||||
const [remainingSeconds, setRemainingSeconds] = useState(0);
|
||||
|
||||
const answersRef = useRef(answers);
|
||||
answersRef.current = answers;
|
||||
const debounceTimers = useRef<Record<string, ReturnType<typeof setTimeout>>>({});
|
||||
const seeded = useRef(false);
|
||||
|
||||
const {
|
||||
data: registrations,
|
||||
isLoading: loadingRegistrations,
|
||||
} = useApiQuery<RegistrationWithExam[]>({ url: '/exams/registrations/mine' });
|
||||
const registration = registrations?.find((r) => r.exam?.id === examId);
|
||||
|
||||
const {
|
||||
data: mineData,
|
||||
isLoading: loadingMine,
|
||||
isError: mineIsError,
|
||||
error: mineError,
|
||||
refetch: refetchMine,
|
||||
} = useApiQuery<AttemptSession>(
|
||||
{ url: `/exam-attempts/mine/${examId}` },
|
||||
{ skip: !examId },
|
||||
);
|
||||
|
||||
const [startTrigger, { isLoading: starting }] = useApiMutation<AttemptSession>();
|
||||
const [answerTrigger] = useApiMutation<CandidateAnswer>();
|
||||
const [submitTrigger, { isLoading: submitting }] = useApiMutation<ExamAttempt>();
|
||||
|
||||
const seedFrom = useCallback((data: AttemptSession) => {
|
||||
setSession(data);
|
||||
const map: Record<string, LocalAnswer> = {};
|
||||
for (const a of data.answers) {
|
||||
map[a.questionId] = { selectedOptionId: a.selectedOptionId, answerText: a.answerText };
|
||||
}
|
||||
setAnswers(map);
|
||||
setRemainingSeconds(data.remainingSeconds);
|
||||
setViewState(data.attempt.status === 'IN_PROGRESS' ? 'taking' : 'completed');
|
||||
}, []);
|
||||
|
||||
// Seed once from the initial load — after that, local state (ticking
|
||||
// timer, in-flight edits) is the source of truth, not this query.
|
||||
useEffect(() => {
|
||||
if (seeded.current) return;
|
||||
if (loadingRegistrations || loadingMine) return;
|
||||
seeded.current = true;
|
||||
|
||||
if (!registration) {
|
||||
setViewState('error');
|
||||
setErrorMessage('You are not registered for this examination.');
|
||||
return;
|
||||
}
|
||||
if (mineData) {
|
||||
seedFrom(mineData);
|
||||
return;
|
||||
}
|
||||
if (mineIsError) {
|
||||
const key = extractErrorMessage(mineError, '');
|
||||
if (key === 'attempt_not_found') {
|
||||
setViewState('not-started');
|
||||
return;
|
||||
}
|
||||
setViewState('error');
|
||||
setErrorMessage(extractErrorMessage(mineError, 'Could not load the exam.'));
|
||||
}
|
||||
}, [loadingRegistrations, loadingMine, registration, mineData, mineIsError, mineError, seedFrom]);
|
||||
|
||||
/** Authoritative resync — used after any write is refused as expired/submitted. */
|
||||
const syncFromServer = useCallback(async () => {
|
||||
const result = await refetchMine();
|
||||
if (result.data) {
|
||||
seedFrom(result.data as AttemptSession);
|
||||
} else {
|
||||
setViewState('error');
|
||||
setErrorMessage(extractErrorMessage(result.error, 'The exam session ended.'));
|
||||
}
|
||||
}, [refetchMine, seedFrom]);
|
||||
|
||||
const persistAnswer = useCallback(
|
||||
async (questionId: string, payload: LocalAnswer) => {
|
||||
if (!session) return;
|
||||
setSaveStates((s) => ({ ...s, [questionId]: 'saving' }));
|
||||
try {
|
||||
const saved = await answerTrigger({
|
||||
url: `/exam-attempts/${session.attempt.id}/answers`,
|
||||
method: 'POST',
|
||||
body: { questionId, ...payload },
|
||||
}).unwrap();
|
||||
setAnswers((a) => ({
|
||||
...a,
|
||||
[questionId]: { selectedOptionId: saved.selectedOptionId, answerText: saved.answerText },
|
||||
}));
|
||||
setSaveStates((s) => ({ ...s, [questionId]: 'saved' }));
|
||||
} catch (error) {
|
||||
setSaveStates((s) => ({ ...s, [questionId]: 'error' }));
|
||||
const key = extractErrorMessage(error, '');
|
||||
if (key === 'attempt_expired' || key === 'attempt_already_submitted') {
|
||||
notify.error(
|
||||
key === 'attempt_expired'
|
||||
? 'Time is up — this answer was not saved.'
|
||||
: 'This attempt has already been submitted.',
|
||||
);
|
||||
syncFromServer();
|
||||
}
|
||||
}
|
||||
},
|
||||
[session, answerTrigger, syncFromServer],
|
||||
);
|
||||
|
||||
const flush = useCallback(
|
||||
(questionId: string) => {
|
||||
const timer = debounceTimers.current[questionId];
|
||||
if (!timer) return;
|
||||
clearTimeout(timer);
|
||||
delete debounceTimers.current[questionId];
|
||||
const current = answersRef.current[questionId];
|
||||
if (current) persistAnswer(questionId, current);
|
||||
},
|
||||
[persistAnswer],
|
||||
);
|
||||
|
||||
const selectOption = useCallback(
|
||||
(questionId: string, optionId: string) => {
|
||||
setAnswers((a) => ({ ...a, [questionId]: { ...a[questionId], selectedOptionId: optionId } }));
|
||||
persistAnswer(questionId, { selectedOptionId: optionId });
|
||||
},
|
||||
[persistAnswer],
|
||||
);
|
||||
|
||||
const changeText = useCallback(
|
||||
(questionId: string, text: string) => {
|
||||
setAnswers((a) => ({ ...a, [questionId]: { ...a[questionId], answerText: text } }));
|
||||
setSaveStates((s) => ({ ...s, [questionId]: 'idle' }));
|
||||
clearTimeout(debounceTimers.current[questionId]);
|
||||
debounceTimers.current[questionId] = setTimeout(() => {
|
||||
delete debounceTimers.current[questionId];
|
||||
persistAnswer(questionId, { answerText: text });
|
||||
}, ESSAY_DEBOUNCE_MS);
|
||||
},
|
||||
[persistAnswer],
|
||||
);
|
||||
|
||||
const goTo = useCallback(
|
||||
(index: number) => {
|
||||
const current = session?.questions[currentIndex];
|
||||
if (current) flush(current.id);
|
||||
setCurrentIndex(index);
|
||||
},
|
||||
[session, currentIndex, flush],
|
||||
);
|
||||
|
||||
const retry = useCallback(
|
||||
(questionId: string) => {
|
||||
const current = answersRef.current[questionId];
|
||||
if (current) persistAnswer(questionId, current);
|
||||
},
|
||||
[persistAnswer],
|
||||
);
|
||||
|
||||
const start = useCallback(async () => {
|
||||
if (!examId) return;
|
||||
try {
|
||||
const result = await startTrigger({
|
||||
url: '/exam-attempts/start',
|
||||
method: 'POST',
|
||||
body: { examId },
|
||||
}).unwrap();
|
||||
seedFrom(result);
|
||||
} catch (error) {
|
||||
notify.error(extractErrorMessage(error, 'Could not start the exam.'));
|
||||
}
|
||||
}, [examId, startTrigger, seedFrom]);
|
||||
|
||||
const submit = useCallback(async () => {
|
||||
if (!session) return;
|
||||
const current = session.questions[currentIndex];
|
||||
if (current) flush(current.id);
|
||||
try {
|
||||
const attempt = await submitTrigger({
|
||||
url: `/exam-attempts/${session.attempt.id}/submit`,
|
||||
method: 'POST',
|
||||
}).unwrap();
|
||||
setSession((s) => (s ? { ...s, attempt } : s));
|
||||
setViewState('completed');
|
||||
} catch (error) {
|
||||
const key = extractErrorMessage(error, '');
|
||||
if (key === 'attempt_expired' || key === 'attempt_already_submitted') {
|
||||
syncFromServer();
|
||||
} else {
|
||||
notify.error(extractErrorMessage(error, 'Could not submit the exam.'));
|
||||
}
|
||||
}
|
||||
}, [session, currentIndex, flush, submitTrigger, syncFromServer]);
|
||||
|
||||
/** Local countdown only — every write is still checked server-side regardless. */
|
||||
useEffect(() => {
|
||||
if (viewState !== 'taking') return;
|
||||
const id = setInterval(() => {
|
||||
setRemainingSeconds((s) => {
|
||||
if (s <= 1) {
|
||||
clearInterval(id);
|
||||
return 0;
|
||||
}
|
||||
return s - 1;
|
||||
});
|
||||
}, 1000);
|
||||
return () => clearInterval(id);
|
||||
}, [viewState]);
|
||||
|
||||
// Time reaching zero locally: stop taking input, tell the server, then
|
||||
// trust whatever it reports back over anything computed in the browser.
|
||||
const timedOutRef = useRef(false);
|
||||
useEffect(() => {
|
||||
if (viewState !== 'taking' || remainingSeconds > 0 || timedOutRef.current) return;
|
||||
timedOutRef.current = true;
|
||||
notify.error("Time's up.");
|
||||
// submit() itself resyncs from the server if this loses the race against
|
||||
// applyExpiry() — either way the final state comes from the backend, not
|
||||
// from this timer having reached zero.
|
||||
submit();
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [remainingSeconds, viewState]);
|
||||
|
||||
const answeredIds = useMemo(
|
||||
() =>
|
||||
new Set(
|
||||
Object.entries(answers)
|
||||
.filter(([, v]) => v.selectedOptionId || v.answerText?.trim())
|
||||
.map(([id]) => id),
|
||||
),
|
||||
[answers],
|
||||
);
|
||||
|
||||
return {
|
||||
viewState,
|
||||
errorMessage,
|
||||
registration,
|
||||
session,
|
||||
currentIndex,
|
||||
answers,
|
||||
saveStates,
|
||||
answeredIds,
|
||||
remainingSeconds,
|
||||
starting,
|
||||
submitting,
|
||||
start,
|
||||
selectOption,
|
||||
changeText,
|
||||
goTo,
|
||||
retry,
|
||||
submit,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,187 @@
|
||||
import { useState } from 'react';
|
||||
import { useParams } from 'react-router-dom';
|
||||
import { Alert, Button, Center, Group, Loader, Modal, Stack, Text } from '@mantine/core';
|
||||
import { IconAlertCircle, IconSend } from '@tabler/icons-react';
|
||||
import { useLocalized } from '@ema-platform/api';
|
||||
import { useDateDisplayer } from '@ema-platform/shared';
|
||||
import { useExamAttempt } from '../../hooks/useExamAttempt';
|
||||
import { ExamInstructions } from '../../components/ExamInstructions';
|
||||
import { ExamTimer } from '../../components/ExamTimer';
|
||||
import { ExamQuestionNav } from '../../components/ExamQuestionNav';
|
||||
import { ExamQuestionDisplay } from '../../components/ExamQuestionDisplay';
|
||||
import { ExamCompletion } from '../../components/ExamCompletion';
|
||||
|
||||
/**
|
||||
* The candidate exam-taking screen (Phase 4). Route: `/exams/:examId/take`.
|
||||
*
|
||||
* All state/API orchestration lives in `useExamAttempt` — this component is
|
||||
* the view: pick which of loading/not-started/taking/completed/error to
|
||||
* render. Every write it triggers (start, save, submit) is re-checked by the
|
||||
* backend regardless of what this screen currently shows; nothing here is
|
||||
* the actual security boundary.
|
||||
*/
|
||||
export function ExamAttemptPage() {
|
||||
const { examId } = useParams<{ examId: string }>();
|
||||
const localized = useLocalized();
|
||||
const showDate = useDateDisplayer();
|
||||
const [confirmOpened, setConfirmOpened] = useState(false);
|
||||
|
||||
const {
|
||||
viewState,
|
||||
errorMessage,
|
||||
registration,
|
||||
session,
|
||||
currentIndex,
|
||||
answers,
|
||||
saveStates,
|
||||
answeredIds,
|
||||
remainingSeconds,
|
||||
starting,
|
||||
submitting,
|
||||
start,
|
||||
selectOption,
|
||||
changeText,
|
||||
goTo,
|
||||
retry,
|
||||
submit,
|
||||
} = useExamAttempt(examId);
|
||||
|
||||
if (viewState === 'loading') {
|
||||
return (
|
||||
<Center py="xl">
|
||||
<Loader />
|
||||
</Center>
|
||||
);
|
||||
}
|
||||
|
||||
if (viewState === 'error') {
|
||||
return (
|
||||
<Alert icon={<IconAlertCircle size={16} />} color="red" maw={600} mx="auto" mt="xl">
|
||||
{errorMessage}
|
||||
</Alert>
|
||||
);
|
||||
}
|
||||
|
||||
if (viewState === 'not-started') {
|
||||
if (!registration) return null; // guarded by 'error' above, appeases TS
|
||||
return (
|
||||
<ExamInstructions
|
||||
registration={registration}
|
||||
localized={localized}
|
||||
showDate={showDate}
|
||||
starting={starting}
|
||||
onStart={start}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
if (viewState === 'completed' && session) {
|
||||
return (
|
||||
<ExamCompletion status={session.attempt.status} submittedAt={session.attempt.submittedAt} />
|
||||
);
|
||||
}
|
||||
|
||||
if (!session) return null; // 'taking' always has a session by construction
|
||||
|
||||
const question = session.questions[currentIndex];
|
||||
const answer = answers[question.id];
|
||||
|
||||
return (
|
||||
<Stack maw={1000} mx="auto" gap="md">
|
||||
<Group justify="space-between" wrap="nowrap">
|
||||
<Text fw={600}>{localized(registration?.exam?.title) || 'Examination in progress'}</Text>
|
||||
<ExamTimer remainingSeconds={remainingSeconds} />
|
||||
</Group>
|
||||
|
||||
<Group align="flex-start" gap="md" wrap="wrap-reverse">
|
||||
<div style={{ flex: 1, minWidth: 280 }}>
|
||||
<ExamQuestionDisplay
|
||||
question={question}
|
||||
index={currentIndex}
|
||||
total={session.questions.length}
|
||||
localized={localized}
|
||||
selectedOptionId={answer?.selectedOptionId}
|
||||
answerText={answer?.answerText}
|
||||
saveState={saveStates[question.id] ?? 'idle'}
|
||||
disabled={remainingSeconds <= 0}
|
||||
onSelectOption={(optionId) => selectOption(question.id, optionId)}
|
||||
onChangeText={(text) => changeText(question.id, text)}
|
||||
onRetry={() => retry(question.id)}
|
||||
/>
|
||||
|
||||
<Group justify="space-between" mt="md">
|
||||
<Button
|
||||
variant="default"
|
||||
disabled={currentIndex === 0}
|
||||
onClick={() => goTo(currentIndex - 1)}
|
||||
>
|
||||
Previous
|
||||
</Button>
|
||||
{currentIndex < session.questions.length - 1 ? (
|
||||
<Button onClick={() => goTo(currentIndex + 1)}>Next</Button>
|
||||
) : (
|
||||
<Button
|
||||
color="teal"
|
||||
leftSection={<IconSend size={15} />}
|
||||
onClick={() => setConfirmOpened(true)}
|
||||
>
|
||||
Submit exam
|
||||
</Button>
|
||||
)}
|
||||
</Group>
|
||||
</div>
|
||||
|
||||
<div style={{ width: 220, flexShrink: 0 }}>
|
||||
<ExamQuestionNav
|
||||
questions={session.questions}
|
||||
currentIndex={currentIndex}
|
||||
answeredIds={answeredIds}
|
||||
disabled={remainingSeconds <= 0}
|
||||
onJump={goTo}
|
||||
/>
|
||||
<Button
|
||||
fullWidth
|
||||
mt="sm"
|
||||
variant="light"
|
||||
color="teal"
|
||||
leftSection={<IconSend size={15} />}
|
||||
onClick={() => setConfirmOpened(true)}
|
||||
>
|
||||
Submit exam
|
||||
</Button>
|
||||
</div>
|
||||
</Group>
|
||||
|
||||
<Modal
|
||||
opened={confirmOpened}
|
||||
onClose={() => setConfirmOpened(false)}
|
||||
title="Submit this exam?"
|
||||
radius="lg"
|
||||
>
|
||||
<Stack>
|
||||
<Text size="sm">
|
||||
{answeredIds.size} of {session.questions.length} questions answered. Once submitted,
|
||||
answers cannot be changed.
|
||||
</Text>
|
||||
<Group justify="flex-end">
|
||||
<Button variant="default" onClick={() => setConfirmOpened(false)}>
|
||||
Keep working
|
||||
</Button>
|
||||
<Button
|
||||
color="teal"
|
||||
loading={submitting}
|
||||
onClick={async () => {
|
||||
await submit();
|
||||
setConfirmOpened(false);
|
||||
}}
|
||||
>
|
||||
Submit
|
||||
</Button>
|
||||
</Group>
|
||||
</Stack>
|
||||
</Modal>
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
|
||||
export default ExamAttemptPage;
|
||||
@@ -0,0 +1,78 @@
|
||||
import type { Bilingual } from '@ema-platform/api';
|
||||
|
||||
export type AttemptStatus = 'IN_PROGRESS' | 'SUBMITTED' | 'EXPIRED';
|
||||
export type QuestionForm = 'ESSAY' | 'CHOICE';
|
||||
|
||||
export interface CandidateOption {
|
||||
id: string;
|
||||
text: Bilingual;
|
||||
order: number;
|
||||
}
|
||||
|
||||
/** Never carries a correct-answer flag — the API doesn't send one. */
|
||||
export interface CandidateQuestion {
|
||||
id: string;
|
||||
title: Bilingual;
|
||||
form: QuestionForm;
|
||||
points: number;
|
||||
options: CandidateOption[];
|
||||
}
|
||||
|
||||
export interface ExamAttempt {
|
||||
id: string;
|
||||
examId: string;
|
||||
registrationId: string;
|
||||
status: AttemptStatus;
|
||||
startedAt: string;
|
||||
expiresAt: string;
|
||||
submittedAt: string | null;
|
||||
}
|
||||
|
||||
export interface CandidateAnswer {
|
||||
id: string;
|
||||
attemptId: string;
|
||||
questionId: string;
|
||||
selectedOptionId: string | null;
|
||||
answerText: string | null;
|
||||
}
|
||||
|
||||
/** Response shape shared by POST /exam-attempts/start and GET .../mine/:examId. */
|
||||
export interface AttemptSession {
|
||||
attempt: ExamAttempt;
|
||||
questions: CandidateQuestion[];
|
||||
answers: CandidateAnswer[];
|
||||
serverTime: string;
|
||||
remainingSeconds: number;
|
||||
}
|
||||
|
||||
export type SaveState = 'idle' | 'saving' | 'saved' | 'error';
|
||||
|
||||
export interface EstimatedTime {
|
||||
days: number;
|
||||
hours: number;
|
||||
minutes: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* The subset of `GET /exams/registrations/mine`'s response this feature
|
||||
* reads — the endpoint returns the full raw exam/registration, this is
|
||||
* just this feature's own narrow view of it (matches the sibling `exams`
|
||||
* feature's pattern of each screen typing only what it uses).
|
||||
*/
|
||||
export interface RegistrationWithExam {
|
||||
id: string;
|
||||
admissionNumber: string;
|
||||
kind: 'NEW' | 'RETAKE';
|
||||
attemptNumber: number;
|
||||
attendanceStatus: string;
|
||||
exam?: {
|
||||
id: string;
|
||||
title: Bilingual;
|
||||
direction?: Bilingual;
|
||||
date: string;
|
||||
venue: string | null;
|
||||
status: string;
|
||||
givenTime: EstimatedTime | null;
|
||||
certification?: { name?: Bilingual };
|
||||
};
|
||||
}
|
||||
@@ -1,5 +1,5 @@
|
||||
import { Badge, Button, Text } from '@mantine/core';
|
||||
import { IconFileText, IconGavel } from '@tabler/icons-react';
|
||||
import { IconFileText, IconGavel, IconPlayerPlay } from '@tabler/icons-react';
|
||||
import type { TFunction } from 'i18next';
|
||||
import type { AdvancedColumn } from '@ema-platform/ui';
|
||||
import type { Bilingual } from '@ema-platform/api';
|
||||
@@ -20,6 +20,8 @@ const ATTENDANCE_COLOR: Record<AttendanceStatus, string> = {
|
||||
DISQUALIFIED: 'red',
|
||||
};
|
||||
|
||||
const NOT_SITTING: AttendanceStatus[] = ['ABSENT', 'WITHDRAWN', 'DISQUALIFIED'];
|
||||
|
||||
export function registrationColumns(
|
||||
t: TFunction,
|
||||
deps: {
|
||||
@@ -28,6 +30,7 @@ export function registrationColumns(
|
||||
localized: (value: Bilingual | undefined) => string;
|
||||
showDate: (value: string | null | undefined) => string;
|
||||
onDownloadSlip: (registration: MyRegistration) => void;
|
||||
onStartExam: (registration: MyRegistration) => void;
|
||||
},
|
||||
): AdvancedColumn<MyRegistration>[] {
|
||||
return [
|
||||
@@ -91,6 +94,44 @@ export function registrationColumns(
|
||||
</Button>
|
||||
) : null,
|
||||
},
|
||||
{
|
||||
header: t('exams.columns.exam'),
|
||||
cell: ({ row }) => {
|
||||
const exam = row.original.exam;
|
||||
const attemptStatus = row.original.attempt?.status;
|
||||
// Already finished — no restart, no more room for "Take exam" to
|
||||
// invite a click that the backend would just refuse.
|
||||
if (attemptStatus === 'SUBMITTED') {
|
||||
return (
|
||||
<Badge size="sm" variant="light" color="teal">
|
||||
{t('exams.columns.completed')}
|
||||
</Badge>
|
||||
);
|
||||
}
|
||||
if (attemptStatus === 'EXPIRED') {
|
||||
return (
|
||||
<Badge size="sm" variant="light" color="red">
|
||||
{t('exams.columns.timeExpired')}
|
||||
</Badge>
|
||||
);
|
||||
}
|
||||
const eligible =
|
||||
exam?.status === 'ACTIVE' && !NOT_SITTING.includes(row.original.attendanceStatus);
|
||||
if (!eligible || !deps.can([PORTAL_PERMISSIONS.APPLY_EXAM])) return null;
|
||||
return (
|
||||
<Button
|
||||
size="compact-xs"
|
||||
color="teal"
|
||||
leftSection={<IconPlayerPlay size={13} />}
|
||||
onClick={() => deps.onStartExam(row.original)}
|
||||
>
|
||||
{attemptStatus === 'IN_PROGRESS'
|
||||
? t('exams.columns.resumeExam')
|
||||
: t('exams.columns.takeExam')}
|
||||
</Button>
|
||||
);
|
||||
},
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { useState } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import {
|
||||
Badge,
|
||||
Button,
|
||||
@@ -53,6 +54,8 @@ export interface MyRegistration {
|
||||
attemptNumber: number;
|
||||
attendanceStatus: AttendanceStatus;
|
||||
exam?: OpenExam;
|
||||
/** The candidate's online sitting, when one has been started. */
|
||||
attempt?: { status: 'IN_PROGRESS' | 'SUBMITTED' | 'EXPIRED' } | null;
|
||||
}
|
||||
|
||||
export interface MyResult {
|
||||
@@ -80,6 +83,7 @@ export interface MyAppeal {
|
||||
*/
|
||||
export function ExamsPage() {
|
||||
const { t } = useTranslation();
|
||||
const navigate = useNavigate();
|
||||
const showDate = useDateDisplayer();
|
||||
const localized = useLocalized();
|
||||
const [appealFor, setAppealFor] = useState<MyResult | null>(null);
|
||||
@@ -245,6 +249,7 @@ export function ExamsPage() {
|
||||
localized,
|
||||
showDate,
|
||||
onDownloadSlip: downloadSlip,
|
||||
onStartExam: (registration) => navigate(`/exams/${registration.exam?.id}/take`),
|
||||
})}
|
||||
data={pagedRegistrations.rows}
|
||||
itemCount={pagedRegistrations.itemCount}
|
||||
|
||||
@@ -9,9 +9,14 @@ import {
|
||||
} from '@mantine/core';
|
||||
import {
|
||||
conditionHolds,
|
||||
useGetActiveDepartmentsQuery,
|
||||
useGetRanksQuery,
|
||||
useLocalized,
|
||||
type Bilingual,
|
||||
type Department,
|
||||
type FormFieldConfig,
|
||||
type FormSectionConfig,
|
||||
type Rank,
|
||||
type Vessel,
|
||||
} from '@ema-platform/api';
|
||||
import { AmharicDatePicker, CountrySelect, PhoneInput } from '@ema-platform/ui';
|
||||
@@ -82,6 +87,41 @@ export function fillFromVessel(
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Options for a SELECT field.
|
||||
*
|
||||
* A department/rank field's seed `options` are a label cache that goes stale
|
||||
* the moment a backoffice admin adds a department or rank — the field's
|
||||
* value is already resolved server-side (profile department, or
|
||||
* `eligibility.nextRank`), correct either way, but a value missing from a
|
||||
* stale cache renders as a blank Select. Live-fetched departments/ranks take
|
||||
* over the labels for these two `source`s; the seed's own `options` still
|
||||
* cover every other SELECT unchanged.
|
||||
*/
|
||||
function selectOptions(
|
||||
field: FormFieldConfig,
|
||||
currentValue: string | undefined,
|
||||
departments: Department[] | undefined,
|
||||
ranks: Rank[],
|
||||
localized: (v?: Bilingual) => string,
|
||||
): { value: string; label: string }[] {
|
||||
if (field.source === 'profile.seafarerDepartment' && departments) {
|
||||
return departments.map((d) => ({ value: d.code, label: localized(d.name) }));
|
||||
}
|
||||
if (field.source === 'eligibility.nextRank') {
|
||||
const options = ranks.map((r) => ({ value: r.key, label: localized(r.name) }));
|
||||
// The resolved rank might not be on THIS field's ladder (rank/rankEngine,
|
||||
// proficiencyDeck/Engine share one `eligibility.nextRank` source but only
|
||||
// one is ever populated) — still show it rather than a blank Select.
|
||||
if (currentValue && !options.some((o) => o.value === currentValue)) {
|
||||
const known = ranks.find((r) => r.key === currentValue);
|
||||
options.push({ value: currentValue, label: known ? localized(known.name) : currentValue });
|
||||
}
|
||||
return options;
|
||||
}
|
||||
return (field.options ?? []).map((o) => ({ value: o.value, label: localized(o.label) }));
|
||||
}
|
||||
|
||||
/**
|
||||
* Renders one form section from the license type's configuration.
|
||||
*
|
||||
@@ -105,6 +145,21 @@ export function ConfigDrivenSection({
|
||||
(a, b) => (a.sortOrder ?? 0) - (b.sortOrder ?? 0),
|
||||
);
|
||||
|
||||
// A department/rank SELECT ships with a hardcoded `options` label list in
|
||||
// the seed, so a department or rank added later in the backoffice has no
|
||||
// label there and would render as a blank Select even though the field's
|
||||
// value (resolved server-side) is correct. Skipped when the section has
|
||||
// neither kind of field, so most sections never pay for these two queries.
|
||||
const needsDepartmentLabels = fields.some(
|
||||
(f) => f.source === 'profile.seafarerDepartment',
|
||||
);
|
||||
const needsRankLabels = fields.some((f) => f.source === 'eligibility.nextRank');
|
||||
const { data: departments } = useGetActiveDepartmentsQuery(undefined, {
|
||||
skip: !needsDepartmentLabels,
|
||||
});
|
||||
const { data: rankRes } = useGetRanksQuery(undefined, { skip: !needsRankLabels });
|
||||
const ranks = rankRes?.items ?? [];
|
||||
|
||||
return (
|
||||
<Grid>
|
||||
{fields.map((field) => {
|
||||
@@ -183,10 +238,7 @@ export function ConfigDrivenSection({
|
||||
) : field.type === 'SELECT' ? (
|
||||
<Select
|
||||
{...common}
|
||||
data={(field.options ?? []).map((o) => ({
|
||||
value: o.value,
|
||||
label: localized(o.label),
|
||||
}))}
|
||||
data={selectOptions(field, value as string | undefined, departments, ranks, localized)}
|
||||
value={(value as string) ?? null}
|
||||
onChange={(v) => onChange(field.key, v)}
|
||||
clearable={!field.required}
|
||||
|
||||
@@ -322,24 +322,44 @@ export function LicenseApplicationPage() {
|
||||
const isAdjusting = application?.status === "RESUBMIT_REQUIRED";
|
||||
const openRemarks = detail?.openRemarks ?? [];
|
||||
|
||||
// The whole round's remarks, resolved or not — the server's section lock
|
||||
// (`assertSectionUnlocked`) ignores `isResolved`, and this page bulk-resolves
|
||||
// remarks right before resubmitting, so `openRemarks` would re-freeze a
|
||||
// section the moment the applicant ticked it off.
|
||||
const roundRemarks = useMemo(
|
||||
() =>
|
||||
(detail?.remarks ?? []).filter(
|
||||
(r) => r.roundNumber === detail?.application?.adjustmentRound,
|
||||
),
|
||||
[detail?.remarks, detail?.application?.adjustmentRound],
|
||||
);
|
||||
|
||||
const flaggedSections = useMemo(
|
||||
() =>
|
||||
Object.fromEntries(
|
||||
openRemarks
|
||||
roundRemarks
|
||||
.filter((r) => r.targetType === "FORM_SECTION")
|
||||
.map((r) => [r.targetKey, r.remark]),
|
||||
),
|
||||
[openRemarks],
|
||||
[roundRemarks],
|
||||
);
|
||||
const flaggedDocuments = useMemo(
|
||||
() =>
|
||||
Object.fromEntries(
|
||||
openRemarks
|
||||
roundRemarks
|
||||
.filter((r) => r.targetType === "DOCUMENT")
|
||||
.map((r) => [r.targetKey, r.remark]),
|
||||
),
|
||||
[openRemarks],
|
||||
[roundRemarks],
|
||||
);
|
||||
const hasSectionRemarks = Object.keys(flaggedSections).length > 0;
|
||||
const hasDocRemarks = Object.keys(flaggedDocuments).length > 0;
|
||||
|
||||
// A round that flagged no form sections carries no section locks — mirror of
|
||||
// the server's fallback, without which a documents-only correction round
|
||||
// froze every field and the applicant could not edit anything at all.
|
||||
const isSectionLocked = (sectionKey: string) =>
|
||||
isAdjusting && hasSectionRemarks && !flaggedSections[sectionKey];
|
||||
|
||||
// Sections that share a group collapse onto one step, so the stepper stays
|
||||
// short instead of showing a page per section.
|
||||
@@ -356,6 +376,14 @@ export function LicenseApplicationPage() {
|
||||
[steps],
|
||||
);
|
||||
|
||||
// A section-level showWhen can remove a step while the wizard is open
|
||||
// (ENDORSEMENT_SEAFARER's certificate sections follow the chosen scope).
|
||||
// Clamp so `steps[active]` can never go out of bounds if a seed ever lets
|
||||
// a later answer hide an earlier step.
|
||||
useEffect(() => {
|
||||
if (active > steps.length - 1) setActive(Math.max(0, steps.length - 1));
|
||||
}, [active, steps.length]);
|
||||
|
||||
if (loadingConfig || !config || !appId || !application) {
|
||||
return <PageLoader label={t('licenseApplication.loading', 'Loading Application…')} height={400} />;
|
||||
}
|
||||
@@ -375,6 +403,24 @@ export function LicenseApplicationPage() {
|
||||
// to the summary first.
|
||||
const showSummary = application.status !== "DRAFT" && viewingSummary;
|
||||
|
||||
// The applicant reads "Vessel Particulars", not "vesselParticulars" — and a
|
||||
// staff remark is keyed by a uuid, which reads as nothing at all.
|
||||
function remarkLabel(remark: (typeof openRemarks)[number]): string {
|
||||
if (remark.targetType === "FORM_SECTION") {
|
||||
const section = config?.licenseType.formSchema.sections.find(
|
||||
(s) => s.key === remark.targetKey,
|
||||
);
|
||||
return section ? localized(section.title) : remark.targetKey;
|
||||
}
|
||||
if (remark.targetType === "DOCUMENT") {
|
||||
const requirement = config?.documentRequirements.find(
|
||||
(r) => r.key === remark.targetKey,
|
||||
);
|
||||
return requirement ? localized(requirement.name) : remark.targetKey;
|
||||
}
|
||||
return t("licenseApplication.staffMember", "Staff member");
|
||||
}
|
||||
|
||||
// Vessel Information and Current Ownership are separate form sections, so
|
||||
// ConfigDrivenSection (one instance per section) can't fill both itself —
|
||||
// it reports the pick up here and this fans it out across every section.
|
||||
@@ -392,7 +438,7 @@ export function LicenseApplicationPage() {
|
||||
async function saveSection(sectionKey: string) {
|
||||
// During an adjustment round only flagged sections are editable, so don't
|
||||
// even attempt a write the server would reject.
|
||||
if (isAdjusting && !flaggedSections[sectionKey]) return;
|
||||
if (isSectionLocked(sectionKey)) return;
|
||||
const values = { ...(draft[sectionKey] ?? {}) };
|
||||
// The picker works in alpha-2 codes (CountrySelect); the backend, like
|
||||
// the profile Address endpoint, stores the full country name.
|
||||
@@ -671,7 +717,7 @@ export function LicenseApplicationPage() {
|
||||
<Stack gap={4}>
|
||||
{openRemarks.map((remark) => (
|
||||
<Text size="sm" key={remark.id}>
|
||||
<b>{remark.targetKey}</b>: {remark.remark}
|
||||
<b>{remarkLabel(remark)}</b>: {remark.remark}
|
||||
</Text>
|
||||
))}
|
||||
<Text size="xs" c="dimmed" mt={4}>
|
||||
@@ -733,7 +779,7 @@ export function LicenseApplicationPage() {
|
||||
{currentStep?.kind === "sections" && (
|
||||
<Stack gap="lg">
|
||||
{currentStep.sections.map((section, index) => {
|
||||
const locked = isAdjusting && !flaggedSections[section.key];
|
||||
const locked = isSectionLocked(section.key);
|
||||
return (
|
||||
<div key={section.key}>
|
||||
{index > 0 && <Divider mb="lg" />}
|
||||
@@ -891,7 +937,7 @@ export function LicenseApplicationPage() {
|
||||
ownerType="APPLICATION"
|
||||
ownerId={appId}
|
||||
flagged={flaggedDocuments}
|
||||
restrictToFlagged={isAdjusting}
|
||||
restrictToFlagged={isAdjusting && hasDocRemarks}
|
||||
readOnly={readOnly}
|
||||
onUploaded={() => {
|
||||
refetchAttachments();
|
||||
@@ -914,7 +960,10 @@ export function LicenseApplicationPage() {
|
||||
formData={draft}
|
||||
errors={fieldErrors}
|
||||
vessels={vessels}
|
||||
disabled={readOnly}
|
||||
// Same lock as the earlier steps — without it this step
|
||||
// looked editable during an adjustment round while
|
||||
// saveSection silently dropped the changes.
|
||||
disabled={readOnly || isSectionLocked(section.key)}
|
||||
onChange={(key, value) => {
|
||||
setDraft((prev) => ({
|
||||
...prev,
|
||||
|
||||
@@ -55,6 +55,10 @@ const MODE_FREE_TYPE_KEYS = [
|
||||
'VESSEL_OWNERSHIP_TRANSFER',
|
||||
'CERTIFICATE_OF_COMPETENCY',
|
||||
'CERTIFICATE_OF_PROFICIENCY',
|
||||
'ENDORSEMENT_SEAFARER',
|
||||
// Retired by ENDORSEMENT_SEAFARER but kept mode-free: an in-flight
|
||||
// application filed against one of these before the switch must still be
|
||||
// reachable to view, correct or resubmit.
|
||||
'ENDORSEMENT_COC',
|
||||
'ENDORSEMENT_GOC',
|
||||
'PRE_WAIVER',
|
||||
|
||||
@@ -16,10 +16,16 @@ import { OperationsFormContent } from "../../profile/components/OperationsFormCo
|
||||
* Seafarer goes to its own registration page, whose Identity Details step
|
||||
* collects the profile answers itself — no detour via `/profile`. Seafarer
|
||||
* wins when both are ticked; the other form is one nav click away.
|
||||
*
|
||||
* SEAFARER_REGISTRATION is listed before ENDORSEMENT_SEAFARER deliberately:
|
||||
* `nextStepFor` takes the first key that matches, and an applicant who ticked
|
||||
* both belongs in registration first — the endorsement application refuses
|
||||
* submission until that registration is accepted.
|
||||
*/
|
||||
const NEXT_STEP: Record<string, string> = {
|
||||
SEAFARER_REGISTRATION: "/seafarer-registration",
|
||||
VESSEL_REGISTRATION: "/licensing/VESSEL_REGISTRATION/apply",
|
||||
VESSEL_REGISTRATION: "/vessel-registration",
|
||||
ENDORSEMENT_SEAFARER: "/endorsements",
|
||||
};
|
||||
|
||||
function nextStepFor(selectedKeys: string[]): string {
|
||||
|
||||
@@ -25,15 +25,21 @@ import { notify, ModalFooter } from '@ema-platform/ui';
|
||||
import { useDateDisplayer } from '@ema-platform/shared';
|
||||
|
||||
/**
|
||||
* Registrations an applicant makes for themselves rather than for a company.
|
||||
* Registrations and seafarer-only services an applicant declares for
|
||||
* themselves rather than for a company.
|
||||
*
|
||||
* Named explicitly rather than inferred from `requiresOperatorMode: false`,
|
||||
* because that flag is also false for things nobody declares up front — a
|
||||
* waiver is requested per shipment, not adopted as an identity.
|
||||
* ENDORSEMENT_SEAFARER belongs here for the same reason as the two
|
||||
* registrations: a seafarer requesting one is not declaring a logistics mode
|
||||
* of operation, and without this key `accountTypeFor` below would fall the
|
||||
* account through to the company-representative type instead of `SEAFARER`.
|
||||
*/
|
||||
const PERSONAL_REGISTRATION_KEYS = [
|
||||
'SEAFARER_REGISTRATION',
|
||||
'VESSEL_REGISTRATION',
|
||||
'ENDORSEMENT_SEAFARER',
|
||||
];
|
||||
|
||||
/**
|
||||
@@ -56,6 +62,7 @@ function accountTypeFor(keys: string[]): string | null {
|
||||
}
|
||||
if (keys.includes('VESSEL_REGISTRATION')) return 'VESSEL_OWNER';
|
||||
if (keys.includes('SEAFARER_REGISTRATION')) return 'SEAFARER';
|
||||
if (keys.includes('ENDORSEMENT_SEAFARER')) return 'SEAFARER';
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -212,7 +219,7 @@ export function OperationsFormContent({
|
||||
{personalOptions.length > 0 && (
|
||||
<>
|
||||
<Text size="xs" fw={600} c="dimmed" mt="sm" tt="uppercase">
|
||||
Registering as an individual or vessel owner
|
||||
Registering or applying as an individual or vessel owner
|
||||
</Text>
|
||||
{personalOptions.map((type) => (
|
||||
<Checkbox
|
||||
|
||||
@@ -3,6 +3,7 @@ import { Alert, Badge, Button, Card, FileButton, Group, Loader, Stack, Text } fr
|
||||
import { IconAlertTriangle, IconCheck, IconFileUpload } from '@tabler/icons-react';
|
||||
import {
|
||||
SEAFARER_REGISTRATION_DOCUMENTS,
|
||||
isEthiopianNationality,
|
||||
uploadDocument,
|
||||
type Attachment,
|
||||
} from '@ema-platform/api';
|
||||
@@ -10,22 +11,25 @@ import {
|
||||
const MAX_FILE_SIZE_BYTES = 5 * 1024 * 1024;
|
||||
|
||||
/** The document slots a registration asks for. */
|
||||
export function documentSlots(passportDeclared: boolean) {
|
||||
export function documentSlots(passportDeclared: boolean, nationality?: string | null) {
|
||||
const ethiopian = isEthiopianNationality(nationality);
|
||||
return SEAFARER_REGISTRATION_DOCUMENTS.map((d) => ({
|
||||
...d,
|
||||
isRequired: d.required === 'passport' ? passportDeclared : d.required,
|
||||
})).filter((d) => d.required !== 'passport' || passportDeclared);
|
||||
isRequired: d.required === 'passport' ? passportDeclared : d.required === 'ethiopian' ? ethiopian : d.required,
|
||||
})).filter((d) => (d.required !== 'passport' || passportDeclared) && (d.required !== 'ethiopian' || ethiopian));
|
||||
}
|
||||
|
||||
export function RegistrationDocuments({
|
||||
registrationId,
|
||||
passportDeclared,
|
||||
nationality,
|
||||
attachments,
|
||||
readOnly,
|
||||
onUploaded,
|
||||
}: {
|
||||
registrationId: string;
|
||||
passportDeclared: boolean;
|
||||
nationality?: string | null;
|
||||
attachments: Attachment[];
|
||||
readOnly?: boolean;
|
||||
onUploaded: () => void;
|
||||
@@ -62,7 +66,7 @@ export function RegistrationDocuments({
|
||||
{error}
|
||||
</Alert>
|
||||
)}
|
||||
{documentSlots(passportDeclared).map((slot) => {
|
||||
{documentSlots(passportDeclared, nationality).map((slot) => {
|
||||
const existing = attachments.find((a) => a.documentKey === slot.key);
|
||||
const uploaded = Boolean(existing?.files?.length);
|
||||
return (
|
||||
|
||||
@@ -3,6 +3,8 @@ import {
|
||||
SEAFARER_REGISTRATION_FIELD_LABELS,
|
||||
SEAFARER_REGISTRATION_SECTIONS,
|
||||
displaySeafarerAnswer,
|
||||
useGetActiveDepartmentsQuery,
|
||||
useLocalized,
|
||||
type Attachment,
|
||||
type SaveSeafarerRegistration,
|
||||
} from '@ema-platform/api';
|
||||
@@ -16,6 +18,10 @@ export function RegistrationSummary({
|
||||
answers: SaveSeafarerRegistration;
|
||||
attachments?: Attachment[];
|
||||
}) {
|
||||
const localized = useLocalized();
|
||||
const { data: departments } = useGetActiveDepartmentsQuery();
|
||||
const departmentOptions = departments?.map((d) => ({ value: d.code, label: localized(d.name) }));
|
||||
|
||||
return (
|
||||
<Stack gap="md">
|
||||
{SEAFARER_REGISTRATION_SECTIONS.map((section) => (
|
||||
@@ -35,7 +41,9 @@ export function RegistrationSummary({
|
||||
</Text>
|
||||
</Table.Td>
|
||||
<Table.Td>
|
||||
<Text size="sm">{displaySeafarerAnswer(field, answers[field])}</Text>
|
||||
<Text size="sm">
|
||||
{displaySeafarerAnswer(field, answers[field], departmentOptions)}
|
||||
</Text>
|
||||
</Table.Td>
|
||||
</Table.Tr>
|
||||
))}
|
||||
|
||||
@@ -6,6 +6,9 @@ import {
|
||||
GENDER_OPTIONS,
|
||||
HAIR_COLOR_OPTIONS,
|
||||
MARITAL_STATUS_OPTIONS,
|
||||
isEthiopianNationality,
|
||||
useGetActiveDepartmentsQuery,
|
||||
useLocalized,
|
||||
} from '@ema-platform/api';
|
||||
import {
|
||||
DateField,
|
||||
@@ -42,6 +45,7 @@ function SectionTitle({ title, description }: { title: string; description?: str
|
||||
export function IdentityDetailsStep(
|
||||
p: StepProps & { account: { email?: string; phoneNumber?: string } },
|
||||
) {
|
||||
const ethiopian = isEthiopianNationality(p.form.nationality);
|
||||
return (
|
||||
<Stack gap="lg">
|
||||
<SectionTitle title="Contact Details" />
|
||||
@@ -66,26 +70,55 @@ export function IdentityDetailsStep(
|
||||
<DateField {...p} name="dateOfBirth" label="Date of Birth" required />
|
||||
<SelectField {...p} name="maritalStatus" label="Marital Status" required options={MARITAL_STATUS_OPTIONS} />
|
||||
<NationalityField {...p} name="nationality" label="Nationality" required />
|
||||
<TextField {...p} name="nationalIdNumber" label="National ID (Fayda) Number" required maxLength={64} />
|
||||
{ethiopian ? (
|
||||
<TextField {...p} name="nationalIdNumber" label="National ID (Fayda) Number" required maxLength={64} />
|
||||
) : (
|
||||
<TextField
|
||||
{...p}
|
||||
name="passportNumber"
|
||||
label="Passport Number"
|
||||
required
|
||||
maxLength={32}
|
||||
description="Required for non-Ethiopian applicants in place of a National ID."
|
||||
/>
|
||||
)}
|
||||
</Grid>
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
|
||||
/** Step 2 — Identity, Address and Physical Characteristics. */
|
||||
/**
|
||||
* Step 2 — Identity, Address and Physical Characteristics.
|
||||
*
|
||||
* The department list is backoffice-managed (see the Ranks & Departments
|
||||
* configuration tab), so this fetches the live set rather than a fixed
|
||||
* three — falling back to it only until the query resolves, so the field
|
||||
* is never an empty flash.
|
||||
*/
|
||||
export function ApplicantDetailsStep(p: StepProps) {
|
||||
const localized = useLocalized();
|
||||
const { data: departments } = useGetActiveDepartmentsQuery();
|
||||
const departmentOptions =
|
||||
departments?.map((d) => ({ value: d.code, label: localized(d.name) })) ??
|
||||
DEPARTMENT_OPTIONS;
|
||||
// Non-Ethiopians already declare their passport number as their primary ID
|
||||
// on the Identity step — asking again here would just duplicate the field.
|
||||
const ethiopian = isEthiopianNationality(p.form.nationality);
|
||||
|
||||
return (
|
||||
<Stack gap="lg">
|
||||
<SectionTitle title="Identity" />
|
||||
<Grid>
|
||||
<TextField {...p} name="placeOfBirth" label="Place of Birth" required maxLength={128} />
|
||||
<TextField
|
||||
{...p}
|
||||
name="passportNumber"
|
||||
label="Passport Number"
|
||||
maxLength={32}
|
||||
description="Required later for international sea service; optional at registration."
|
||||
/>
|
||||
{ethiopian && (
|
||||
<TextField
|
||||
{...p}
|
||||
name="passportNumber"
|
||||
label="Passport Number"
|
||||
maxLength={32}
|
||||
description="Required later for international sea service; optional at registration."
|
||||
/>
|
||||
)}
|
||||
{p.form.passportNumber && (
|
||||
<DateField {...p} name="passportExpiry" label="Passport Expiry Date" />
|
||||
)}
|
||||
@@ -94,7 +127,7 @@ export function ApplicantDetailsStep(p: StepProps) {
|
||||
name="department"
|
||||
label="Department"
|
||||
required
|
||||
options={DEPARTMENT_OPTIONS}
|
||||
options={departmentOptions}
|
||||
description="The STCW department you serve in. Determines which certificates, examinations and services apply to you."
|
||||
/>
|
||||
</Grid>
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import {
|
||||
Alert,
|
||||
Button,
|
||||
@@ -8,13 +9,14 @@ import {
|
||||
Grid,
|
||||
Group,
|
||||
Loader,
|
||||
Modal,
|
||||
Paper,
|
||||
Stack,
|
||||
Stepper,
|
||||
Text,
|
||||
Title,
|
||||
} from '@mantine/core';
|
||||
import { IconAlertTriangle, IconCheck, IconInfoCircle, IconPencil } from '@tabler/icons-react';
|
||||
import { IconAlertTriangle, IconCheck, IconInfoCircle, IconPencil, IconTrash } from '@tabler/icons-react';
|
||||
import { notifications } from '@mantine/notifications';
|
||||
import {
|
||||
PHYSICAL_BOUNDS,
|
||||
@@ -23,6 +25,8 @@ import {
|
||||
SEAFARER_REGISTRATION_STATUS_LABELS,
|
||||
extractErrorMessage,
|
||||
extractValidationIssues,
|
||||
isEthiopianNationality,
|
||||
useCancelSeafarerRegistrationMutation,
|
||||
useGetAttachmentsQuery,
|
||||
useGetMySeafarerRegistrationQuery,
|
||||
useSaveSeafarerRegistrationMutation,
|
||||
@@ -48,15 +52,26 @@ const STEPS = [
|
||||
{ label: 'Review', description: 'Check & submit' },
|
||||
];
|
||||
|
||||
/** Which answers each step must have before "Continue" — mirrors the API's submission check. */
|
||||
/**
|
||||
* Which answers each step must have before "Continue" — mirrors the API's
|
||||
* submission check. National ID vs Passport Number depends on the declared
|
||||
* nationality, so that slot is added dynamically in `requiredForStep`.
|
||||
*/
|
||||
const REQUIRED_BY_STEP: AnswerKey[][] = [
|
||||
['firstName', 'lastName', 'gender', 'dateOfBirth', 'maritalStatus', 'nationality', 'nationalIdNumber'],
|
||||
['firstName', 'lastName', 'gender', 'dateOfBirth', 'maritalStatus', 'nationality'],
|
||||
['placeOfBirth', 'department', 'locationId', 'hairColor', 'eyeColor', 'heightCm', 'weightKg'],
|
||||
['medicalCertificateNumber', 'medicalIssuerName', 'medicalIssueDate'],
|
||||
[],
|
||||
['declarationAccepted'],
|
||||
];
|
||||
|
||||
/** Ethiopians must give a National ID; everyone else must give a Passport Number instead. */
|
||||
function requiredForStep(index: number, nationality: string | null | undefined): AnswerKey[] {
|
||||
const base = REQUIRED_BY_STEP[index] ?? [];
|
||||
if (index !== 0) return base;
|
||||
return [...base, isEthiopianNationality(nationality) ? 'nationalIdNumber' : 'passportNumber'];
|
||||
}
|
||||
|
||||
const ANSWER_KEYS = Object.keys(SEAFARER_REGISTRATION_FIELD_LABELS) as AnswerKey[];
|
||||
|
||||
function answersOf(registration: SeafarerRegistration): SaveSeafarerRegistration {
|
||||
@@ -102,6 +117,12 @@ function withProfileDefaults(
|
||||
emergencyContactPhone: a?.emergencyContactPhone || null,
|
||||
emergencyContactRelationship: a?.emergencyContactRelation || null,
|
||||
department: profile.seafarerDepartment || null,
|
||||
// Deepest saved level the picker's maxDepth of 3 can show.
|
||||
locationId: a?.subCityId || a?.cityId || a?.regionId || null,
|
||||
hairColor: (profile.hairColor as SaveSeafarerRegistration['hairColor']) || null,
|
||||
eyeColor: (profile.eyeColor as SaveSeafarerRegistration['eyeColor']) || null,
|
||||
bloodType: (profile.bloodType as SaveSeafarerRegistration['bloodType']) || null,
|
||||
heightCm: profile.heightCm ?? null,
|
||||
};
|
||||
const next = { ...answers };
|
||||
for (const [key, value] of Object.entries(defaults) as [AnswerKey, unknown][]) {
|
||||
@@ -119,15 +140,18 @@ function withProfileDefaults(
|
||||
* still missing. A submitted registration opens to a read-only summary.
|
||||
*/
|
||||
export function SeafarerRegistrationPage() {
|
||||
const navigate = useNavigate();
|
||||
const accountUser = useAppSelector((state) => state.auth.user);
|
||||
const { profile } = useCurrentProfile();
|
||||
const { data, isLoading } = useGetMySeafarerRegistrationQuery();
|
||||
const registration = data?.registration ?? null;
|
||||
|
||||
const [start] = useStartSeafarerRegistrationMutation();
|
||||
const [cancelDraft, { isLoading: cancelling }] = useCancelSeafarerRegistrationMutation();
|
||||
const [save, { isLoading: saving }] = useSaveSeafarerRegistrationMutation();
|
||||
const [submit, { isLoading: submitting }] = useSubmitSeafarerRegistrationMutation();
|
||||
const [startError, setStartError] = useState<string | null>(null);
|
||||
const [confirmingCancel, setConfirmingCancel] = useState(false);
|
||||
const started = useRef(false);
|
||||
|
||||
useEffect(() => {
|
||||
@@ -204,7 +228,7 @@ export function SeafarerRegistrationPage() {
|
||||
|
||||
function validateStep(index: number): boolean {
|
||||
const found: Partial<Record<AnswerKey, string>> = {};
|
||||
for (const key of REQUIRED_BY_STEP[index] ?? []) {
|
||||
for (const key of requiredForStep(index, form.nationality)) {
|
||||
if (blank(form[key])) found[key] = `${SEAFARER_REGISTRATION_FIELD_LABELS[key]} is required.`;
|
||||
}
|
||||
if (index === 1) {
|
||||
@@ -232,7 +256,7 @@ export function SeafarerRegistrationPage() {
|
||||
}
|
||||
if (index === 3) {
|
||||
const supplied = new Set(attachments.filter((a) => a.files?.length).map((a) => a.documentKey));
|
||||
const missing = documentSlots(Boolean(form.passportNumber))
|
||||
const missing = documentSlots(Boolean(form.passportNumber), form.nationality)
|
||||
.filter((d) => d.isRequired && !supplied.has(d.key))
|
||||
.map((d) => d.name);
|
||||
if (missing.length) {
|
||||
@@ -302,6 +326,19 @@ export function SeafarerRegistrationPage() {
|
||||
}
|
||||
}
|
||||
|
||||
async function handleCancel() {
|
||||
if (!registration) return;
|
||||
try {
|
||||
await cancelDraft(registration.id).unwrap();
|
||||
notifications.show({ color: 'teal', title: 'Draft discarded', message: 'Nothing was saved.' });
|
||||
navigate('/dashboard');
|
||||
} catch (err) {
|
||||
notifications.show({ color: 'red', title: 'Could not discard the draft', message: extractErrorMessage(err) });
|
||||
} finally {
|
||||
setConfirmingCancel(false);
|
||||
}
|
||||
}
|
||||
|
||||
const stepProps = { form, set, errors, disabled: readOnly };
|
||||
|
||||
return (
|
||||
@@ -319,17 +356,30 @@ export function SeafarerRegistrationPage() {
|
||||
/>
|
||||
</Group>
|
||||
</div>
|
||||
{showSummary && !readOnly && (
|
||||
<Button size="xs" variant="default" leftSection={<IconPencil size={14} />} onClick={() => setViewingSummary(false)}>
|
||||
Edit details
|
||||
</Button>
|
||||
)}
|
||||
<Group gap="xs">
|
||||
{registration.status === 'DRAFT' && (
|
||||
<Button
|
||||
size="xs"
|
||||
variant="subtle"
|
||||
color="red"
|
||||
leftSection={<IconTrash size={14} />}
|
||||
onClick={() => setConfirmingCancel(true)}
|
||||
>
|
||||
Cancel & discard draft
|
||||
</Button>
|
||||
)}
|
||||
{showSummary && !readOnly && (
|
||||
<Button size="xs" variant="default" leftSection={<IconPencil size={14} />} onClick={() => setViewingSummary(false)}>
|
||||
Edit details
|
||||
</Button>
|
||||
)}
|
||||
</Group>
|
||||
</Group>
|
||||
|
||||
{registration.status === 'APPROVED' && (
|
||||
<Alert color="teal" icon={<IconCheck size={16} />} title="Registered" mb="md">
|
||||
You are a registered seafarer. Your seafarer number is <b>{registration.seafarerNumber}</b>.
|
||||
Your Seaman Book and Basic Training Certificate applications have been opened for you.
|
||||
You can now apply for a certificate endorsement from the Endorsement Seafarer page.
|
||||
</Alert>
|
||||
)}
|
||||
{registration.status === 'REJECTED' && (
|
||||
@@ -386,6 +436,7 @@ export function SeafarerRegistrationPage() {
|
||||
<RegistrationDocuments
|
||||
registrationId={registration.id}
|
||||
passportDeclared={Boolean(form.passportNumber)}
|
||||
nationality={form.nationality}
|
||||
attachments={attachments}
|
||||
readOnly={readOnly}
|
||||
onUploaded={refetchAttachments}
|
||||
@@ -426,6 +477,23 @@ export function SeafarerRegistrationPage() {
|
||||
</Group>
|
||||
</Paper>
|
||||
)}
|
||||
|
||||
<Modal opened={confirmingCancel} onClose={() => setConfirmingCancel(false)} title="Discard this draft?" centered>
|
||||
<Stack>
|
||||
<Text size="sm">
|
||||
Everything you have entered will be deleted, including any documents already uploaded. This cannot be
|
||||
undone. You can start a new registration at any time.
|
||||
</Text>
|
||||
<Group justify="flex-end">
|
||||
<Button variant="default" onClick={() => setConfirmingCancel(false)} disabled={cancelling}>
|
||||
Keep draft
|
||||
</Button>
|
||||
<Button color="red" loading={cancelling} onClick={handleCancel}>
|
||||
Discard draft
|
||||
</Button>
|
||||
</Group>
|
||||
</Stack>
|
||||
</Modal>
|
||||
</Container>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -164,6 +164,13 @@ function EvidenceField({
|
||||
|
||||
// ---------------------------------------------------------------- sea service
|
||||
|
||||
/** Today as a `yyyy-mm-dd` key — same shape the pickers emit, so plain
|
||||
* string comparison is a valid date comparison. Taken in the authority's
|
||||
* timezone, matching the server's check, so a seafarer logging in from a
|
||||
* zone ahead of Addis isn't offered a day the server then rejects. */
|
||||
const todayKey = () =>
|
||||
new Date().toLocaleDateString('en-CA', { timeZone: 'Africa/Addis_Ababa' });
|
||||
|
||||
const EMPTY_SEA_SERVICE = {
|
||||
vesselName: '',
|
||||
imoNumber: '',
|
||||
@@ -276,12 +283,43 @@ function SeaServiceTab() {
|
||||
}
|
||||
};
|
||||
|
||||
// Service already served — neither end of an engagement can be in the future.
|
||||
const today = todayKey();
|
||||
const dateError =
|
||||
form.engagementDate > today || form.dischargeDate > today
|
||||
? t('seaRecords.seaService.dateFuture', {
|
||||
defaultValue: 'Engagement and discharge dates cannot be in the future.',
|
||||
})
|
||||
: form.engagementDate &&
|
||||
form.dischargeDate &&
|
||||
form.engagementDate >= form.dischargeDate
|
||||
? t('seaRecords.seaService.dateOrder', {
|
||||
defaultValue: 'Discharge date must be after the engagement date.',
|
||||
})
|
||||
: null;
|
||||
|
||||
// Shown once the field has something in it — a blank required field is left
|
||||
// to the button being disabled, same as the rest of the form; only an
|
||||
// actual too-short value gets called out.
|
||||
const vesselNameError =
|
||||
form.vesselName && form.vesselName.trim().length <= 1
|
||||
? t('seaRecords.seaService.fields.vesselNameTooShort', {
|
||||
defaultValue: 'Must be at least 2 characters.',
|
||||
})
|
||||
: null;
|
||||
const rankError =
|
||||
form.rank && form.rank.trim().length <= 1
|
||||
? t('seaRecords.seaService.fields.rankTooShort', {
|
||||
defaultValue: 'Must be at least 2 characters.',
|
||||
})
|
||||
: null;
|
||||
|
||||
const valid =
|
||||
form.vesselName.trim().length > 1 &&
|
||||
form.rank.trim().length > 1 &&
|
||||
form.engagementDate &&
|
||||
form.dischargeDate &&
|
||||
form.engagementDate < form.dischargeDate;
|
||||
!dateError;
|
||||
|
||||
// Shown under the date pickers as they are filled: the seafarer sees what
|
||||
// the engagement is worth before saving it.
|
||||
@@ -369,6 +407,7 @@ function SeaServiceTab() {
|
||||
required
|
||||
value={form.vesselName}
|
||||
onChange={(e) => setForm({ ...form, vesselName: e.target.value })}
|
||||
error={vesselNameError}
|
||||
/>
|
||||
<TextInput
|
||||
label={t('seaRecords.seaService.fields.imoNumber')}
|
||||
@@ -396,9 +435,13 @@ function SeaServiceTab() {
|
||||
</Group>
|
||||
<TextInput
|
||||
label={t('seaRecords.seaService.fields.rank')}
|
||||
description={t('seaRecords.seaService.fields.rankHint', {
|
||||
defaultValue: 'Must be at least 2 characters, e.g. "AB" or "2nd Officer".',
|
||||
})}
|
||||
required
|
||||
value={form.rank}
|
||||
onChange={(e) => setForm({ ...form, rank: e.target.value })}
|
||||
error={rankError}
|
||||
/>
|
||||
<Group grow>
|
||||
<AmharicDatePicker
|
||||
@@ -408,6 +451,7 @@ function SeaServiceTab() {
|
||||
onChange={(val) =>
|
||||
setForm({ ...form, engagementDate: val })
|
||||
}
|
||||
maxDate={form.dischargeDate || today}
|
||||
dateFormat="date"
|
||||
/>
|
||||
<AmharicDatePicker
|
||||
@@ -417,24 +461,23 @@ function SeaServiceTab() {
|
||||
onChange={(val) =>
|
||||
setForm({ ...form, dischargeDate: val })
|
||||
}
|
||||
minDate={form.engagementDate || undefined}
|
||||
maxDate={today}
|
||||
dateFormat="date"
|
||||
/>
|
||||
</Group>
|
||||
{form.engagementDate && form.dischargeDate && (
|
||||
{(dateError || (form.engagementDate && form.dischargeDate)) && (
|
||||
<Alert
|
||||
variant="light"
|
||||
color={formDays === null ? 'red' : 'teal'}
|
||||
color={dateError ? 'red' : 'teal'}
|
||||
icon={<IconInfoCircle size={16} />}
|
||||
py={6}
|
||||
>
|
||||
{formDays === null
|
||||
? t('seaRecords.seaService.dateOrder', {
|
||||
defaultValue: 'Discharge date must be after the engagement date.',
|
||||
})
|
||||
: t('seaRecords.seaService.daysServed', {
|
||||
days: formDays,
|
||||
defaultValue: 'Days served on this engagement: {{days}} (both days counted)',
|
||||
})}
|
||||
{dateError ??
|
||||
t('seaRecords.seaService.daysServed', {
|
||||
days: formDays,
|
||||
defaultValue: 'Days served on this engagement: {{days}} (both days counted)',
|
||||
})}
|
||||
</Alert>
|
||||
)}
|
||||
<Textarea
|
||||
@@ -581,10 +624,12 @@ function MedicalTab() {
|
||||
}
|
||||
};
|
||||
|
||||
const today = todayKey();
|
||||
const valid =
|
||||
form.issuerName.trim().length > 1 &&
|
||||
form.issueDate &&
|
||||
form.expiryDate &&
|
||||
form.issueDate <= today &&
|
||||
form.issueDate < form.expiryDate;
|
||||
|
||||
const { setPageIndex, pageSize, setPageSize, paginate } = useServerTable({ pageSize: 10 });
|
||||
@@ -664,6 +709,7 @@ function MedicalTab() {
|
||||
required
|
||||
value={form.issueDate}
|
||||
onChange={(val) => setForm({ ...form, issueDate: val })}
|
||||
maxDate={today}
|
||||
dateFormat="date"
|
||||
/>
|
||||
<AmharicDatePicker
|
||||
@@ -671,6 +717,7 @@ function MedicalTab() {
|
||||
required
|
||||
value={form.expiryDate}
|
||||
onChange={(val) => setForm({ ...form, expiryDate: val })}
|
||||
minDate={form.issueDate || undefined}
|
||||
dateFormat="date"
|
||||
/>
|
||||
</Group>
|
||||
|
||||
@@ -12,7 +12,13 @@ import {
|
||||
/** Columns for the applicant's in-flight vessel registration applications. */
|
||||
export function inFlightColumns(
|
||||
t: TFunction,
|
||||
deps: { onOpen: (app: LicenseApplication) => void },
|
||||
deps: {
|
||||
onOpen: (app: LicenseApplication) => void;
|
||||
onPay: (app: LicenseApplication) => void;
|
||||
onBypass: (app: LicenseApplication) => void;
|
||||
isPaying: boolean;
|
||||
bypassing: boolean;
|
||||
},
|
||||
): AdvancedColumn<LicenseApplication>[] {
|
||||
return [
|
||||
{
|
||||
@@ -38,15 +44,50 @@ export function inFlightColumns(
|
||||
},
|
||||
{
|
||||
header: t('applications.table.progress'),
|
||||
size: 140,
|
||||
cell: ({ row }) => (
|
||||
<Progress
|
||||
value={STATUS_PROGRESS[row.original.status]}
|
||||
color={STATUS_COLORS[row.original.status]}
|
||||
size="sm"
|
||||
radius="xl"
|
||||
/>
|
||||
),
|
||||
size: 300,
|
||||
cell: ({ row }) => {
|
||||
const app = row.original;
|
||||
return (
|
||||
<Group gap="xs" wrap="nowrap">
|
||||
<Progress
|
||||
value={STATUS_PROGRESS[app.status]}
|
||||
color={STATUS_COLORS[app.status]}
|
||||
size="sm"
|
||||
radius="xl"
|
||||
style={{ flex: 1, minWidth: 60 }}
|
||||
/>
|
||||
{/* The fee stops the registration dead, so the payment action sits
|
||||
on the bar rather than being hidden behind View. */}
|
||||
{app.status === 'PAYMENT_PENDING' && (
|
||||
<>
|
||||
<Button
|
||||
size="compact-sm"
|
||||
color="yellow"
|
||||
loading={deps.isPaying}
|
||||
onClick={() => deps.onPay(app)}
|
||||
>
|
||||
{t('applications.actions.pay', {
|
||||
amount: Number(app.feeAmount ?? 0).toLocaleString(),
|
||||
currency: app.feeCurrency,
|
||||
})}
|
||||
</Button>
|
||||
{/* ponytail: shown unconditionally — the licensing page hides
|
||||
this behind the API's bypassEnabled capability flag, which
|
||||
is off here. Re-gate on capabilities before prod. */}
|
||||
<Button
|
||||
size="compact-sm"
|
||||
variant="default"
|
||||
loading={deps.bypassing}
|
||||
onClick={() => deps.onBypass(app)}
|
||||
title="Testing only — marks the fee paid"
|
||||
>
|
||||
{t('applications.actions.bypass')}
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
</Group>
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
header: '',
|
||||
|
||||
@@ -30,11 +30,15 @@ import {
|
||||
import { StatusBadge, AdvancedTable } from '@ema-platform/ui';
|
||||
import { inFlightColumns } from '../inFlightColumns';
|
||||
import {
|
||||
extractErrorMessage,
|
||||
TERMINAL_STATUSES,
|
||||
useApiMutation,
|
||||
useBypassPaymentMutation,
|
||||
useGetMyApplicationsQuery,
|
||||
} from '@ema-platform/api';
|
||||
import { notifications } from '@mantine/notifications';
|
||||
import { authStorage } from '@ema-platform/auth';
|
||||
import { useApplicationPayment } from '../../payments/hooks/useApplicationPayment';
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Types
|
||||
@@ -132,6 +136,8 @@ export function VesselRegistrationPage() {
|
||||
const navigate = useNavigate();
|
||||
const { t } = useTranslation();
|
||||
const { data: applications, isFetching, refetch } = useGetMyApplicationsQuery();
|
||||
const { pay, isPaying } = useApplicationPayment();
|
||||
const [bypassPayment, { isLoading: bypassing }] = useBypassPaymentMutation();
|
||||
const [page, setPage] = useState(0);
|
||||
const [registration, setRegistration] = useState<VesselRegistration | null>(null);
|
||||
const [fetchTrigger] = useApiMutation<VesselRegistration>();
|
||||
@@ -155,9 +161,32 @@ export function VesselRegistrationPage() {
|
||||
!TERMINAL_STATUSES.includes(app.status),
|
||||
);
|
||||
|
||||
async function handleBypass(applicationId: string) {
|
||||
try {
|
||||
const result = await bypassPayment(applicationId).unwrap();
|
||||
notifications.show({
|
||||
color: 'teal',
|
||||
title: 'Payment bypassed',
|
||||
message: `Application is now ${result.status.replace(/_/g, ' ').toLowerCase()}.`,
|
||||
});
|
||||
refetch();
|
||||
} catch (err) {
|
||||
notifications.show({
|
||||
color: 'red',
|
||||
title: 'Bypass failed',
|
||||
message: extractErrorMessage(err),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const columns = inFlightColumns(t, {
|
||||
onOpen: (app) =>
|
||||
navigate(`/licensing/${REGISTRATION_TYPE_KEY}/applications/${app.id}`),
|
||||
// Paying leaves the SPA for Telebirr — a provider hand-off, not a route change.
|
||||
onPay: (app) => pay(app.id),
|
||||
onBypass: (app) => handleBypass(app.id),
|
||||
isPaying,
|
||||
bypassing,
|
||||
});
|
||||
|
||||
const certs = registration?.category === 'Sea-going Vessel (International)'
|
||||
|
||||
@@ -63,7 +63,7 @@ export const am: Translations = {
|
||||
certificates: 'የምስክር ወረቀቶች',
|
||||
seamanBook: 'የመርከበኛ መጽሐፍ እና BTC',
|
||||
btc: 'መሠረታዊ ሥልጠና ምስክር ወረቀት',
|
||||
endorsements: 'ማረጋገጫዎች',
|
||||
endorsements: 'የባህረኛ ማስተያየት',
|
||||
vesselRegistrations: 'የመርከብ ምዝገባ',
|
||||
vesselTransfers: 'የመርከብ ባለቤትነት ዝውውር',
|
||||
documents: 'ሰነዶቼ',
|
||||
@@ -242,6 +242,7 @@ export const am: Translations = {
|
||||
RESUBMIT_REQUIRED: 'እንደገና ማስገባት ያስፈልጋል',
|
||||
INSPECTION_PENDING: 'ቁጥጥር በመጠባበቅ ላይ',
|
||||
INSPECTION_COMPLETED: 'ቁጥጥር ተጠናቋል',
|
||||
INSPECTION_FAILED: 'ቁጥጥር አልተሳካም',
|
||||
APPROVED: 'ጸድቋል',
|
||||
REJECTED: 'ውድቅ ተደርጓል',
|
||||
ON_HOLD: 'ላይ ቆሟል',
|
||||
@@ -973,6 +974,11 @@ export const am: Translations = {
|
||||
appeal: 'ይግባኝ',
|
||||
retake: 'ድጋሚ · {{n}}',
|
||||
firstSitting: 'የመጀመሪያ ሙከራ',
|
||||
exam: 'ፈተና',
|
||||
completed: 'ተጠናቋል',
|
||||
timeExpired: 'ጊዜው አልቋል',
|
||||
resumeExam: 'ፈተና ይቀጥሉ',
|
||||
takeExam: 'ፈተና ይውሰዱ',
|
||||
attendanceStatus: {
|
||||
REGISTERED: 'አልተጠራም',
|
||||
PRESENT: 'ተገኝቷል',
|
||||
@@ -1003,8 +1009,7 @@ export const am: Translations = {
|
||||
registered: "የተመዘገበ መርከበኛ ({{number}})",
|
||||
registrationRequired: "ንቁ የመርከበኛ ምዝገባ ያስፈልጋል",
|
||||
},
|
||||
endorseCoc: "CoC ያረጋግጡ",
|
||||
endorseGoc: "GOC ያረጋግጡ",
|
||||
apply: "ለማስተያየት ያመልክቱ",
|
||||
registrationNotice: {
|
||||
prefix: "መጀመሪያ የ",
|
||||
link: "መርከበኛ ምዝገባዎን",
|
||||
|
||||
@@ -63,7 +63,7 @@ export const en = {
|
||||
certificates: 'Certificates',
|
||||
seamanBook: 'SeamanBook and BTC',
|
||||
btc: 'Basic Training Certificate',
|
||||
endorsements: 'Endorsements',
|
||||
endorsements: 'Endorsement Seafarer',
|
||||
vesselRegistrations: 'Vessel Registration',
|
||||
vesselTransfers: 'Vessel Transfers',
|
||||
documents: 'My Documents',
|
||||
@@ -242,6 +242,7 @@ export const en = {
|
||||
RESUBMIT_REQUIRED: 'Resubmit Required',
|
||||
INSPECTION_PENDING: 'Inspection Pending',
|
||||
INSPECTION_COMPLETED: 'Inspection Completed',
|
||||
INSPECTION_FAILED: 'Inspection Failed',
|
||||
APPROVED: 'Approved',
|
||||
REJECTED: 'Rejected',
|
||||
ON_HOLD: 'On Hold',
|
||||
@@ -975,6 +976,11 @@ export const en = {
|
||||
appeal: 'Appeal',
|
||||
retake: 'Retake · {{n}}',
|
||||
firstSitting: 'First sitting',
|
||||
exam: 'Exam',
|
||||
completed: 'Completed',
|
||||
timeExpired: 'Time expired',
|
||||
resumeExam: 'Resume exam',
|
||||
takeExam: 'Take exam',
|
||||
attendanceStatus: {
|
||||
REGISTERED: 'Not called',
|
||||
PRESENT: 'Present',
|
||||
@@ -1005,8 +1011,7 @@ export const en = {
|
||||
registered: 'Registered seafarer ({{number}})',
|
||||
registrationRequired: 'Active seafarer registration required',
|
||||
},
|
||||
endorseCoc: 'Endorse a CoC',
|
||||
endorseGoc: 'Endorse a GOC',
|
||||
apply: 'Apply for an endorsement',
|
||||
registrationNotice: {
|
||||
prefix: 'Complete your',
|
||||
link: 'seafarer registration',
|
||||
|
||||
@@ -140,7 +140,7 @@ const NAV_SECTIONS: { label?: string; items: PortalNavItem[] }[] = [
|
||||
},
|
||||
{
|
||||
to: "/endorsements",
|
||||
label: "Endorsements",
|
||||
label: "Endorsement Seafarer",
|
||||
i18nKey: "nav.endorsements",
|
||||
icon: IconRubberStamp,
|
||||
permissions: [P.VIEW_OWN_CERTIFICATES],
|
||||
|
||||
@@ -30,6 +30,7 @@ import { SupportPage } from "./features/support/pages/SupportPage";
|
||||
import { MedicalRecordsPage, SeaServicePage } from "./features/seafarer/pages/SeaRecords";
|
||||
import { SeafarerRegistrationPage } from "./features/seafarer-registration/pages/SeafarerRegistrationPage";
|
||||
import { ExamsPage } from "./features/exams/pages/ExamsPage";
|
||||
import { ExamAttemptPage } from "./features/exam-attempt/pages/ExamAttemptPage";
|
||||
|
||||
// Phase 1 pages
|
||||
import { DocumentVaultPage } from "./features/documents/pages/DocumentVaultPage";
|
||||
@@ -213,6 +214,14 @@ export const router = createBrowserRouter([
|
||||
</RequirePermission>
|
||||
),
|
||||
},
|
||||
{
|
||||
path: "/exams/:examId/take",
|
||||
element: (
|
||||
<RequirePermission anyOf={[P.APPLY_EXAM, P.VIEW_OWN_EXAM]}>
|
||||
<ExamAttemptPage />
|
||||
</RequirePermission>
|
||||
),
|
||||
},
|
||||
// The public-facing registry was a hardcoded mock and does not belong in
|
||||
// the applicant portal; officers browse seafarers in the backoffice.
|
||||
{
|
||||
|
||||
Reference in New Issue
Block a user