Merge branch 'WorkflowChange' into logestic_chnage

This commit is contained in:
Nati Nigussie
2026-08-27 11:23:14 +03:00
committed by GitHub
92 changed files with 5309 additions and 1041 deletions

View File

@@ -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"

View File

@@ -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>
);
}

View File

@@ -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>
);
}

View File

@@ -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>
);
}

View File

@@ -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>
);
}

View File

@@ -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>
);
}

View File

@@ -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,
};
}

View File

@@ -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;

View File

@@ -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 };
};
}

View File

@@ -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>
);
},
},
];
}

View File

@@ -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}

View File

@@ -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}

View File

@@ -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,

View File

@@ -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',

View File

@@ -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 {

View File

@@ -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

View File

@@ -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 (

View File

@@ -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>
))}

View File

@@ -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>

View File

@@ -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 &amp; 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>
);
}

View File

@@ -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>

View File

@@ -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: '',

View File

@@ -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)'

View File

@@ -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: "መርከበኛ ምዝገባዎን",

View File

@@ -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',

View File

@@ -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],

View File

@@ -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.
{