Merge origin/WorkflowChange into logestic_chnage

Resolves conflicts:
- LicenseReviewPage: dropped a duplicated schedule-issuance ActionIcon,
  keeping the Tooltip-wrapped one and adding scheduledPeriod (required
  by the scheduleIssuance mutation) to it.
- portal i18n (en.ts/am.ts): both sides added distinct keys under
  licensing.card (reportDamaged/reissueFailed vs status/statusReason) —
  kept both, additive.
- licensing.helpers.ts: kept sectionAppliesToKind (this branch) and
  switched to the centralized BASE_API_URL import from
  base-api/base-query-with-reauth (WorkflowChange), dropping the local
  duplicate constant.
This commit is contained in:
fitse-yotor
2026-08-28 16:27:36 +03:00
58 changed files with 2348 additions and 412 deletions

39
.env.example Normal file
View File

@@ -0,0 +1,39 @@
# emaui environment
#
# Vite reads this from the workspace root, not from the app directory:
# apps/portal/vite.config.mts and apps/backoffice/vite.config.mts both set
# `envDir: '../../'`. So copy this file to ./.env here, at the repo root.
#
# cp .env.example .env
#
# Only VITE_-prefixed values reach the browser, and everything that does is
# public — it ships inside the built bundle. Never put a secret here. The Fayda
# client id, private key and endpoints live in the API's environment only; the
# frontend never speaks to Fayda directly.
# Base URL of the emaapi backend, including the /api prefix.
# 3001, not 3000: the portal itself takes 3000 in development, because that is
# the port in the Fayda redirect URI registered for local testing. Set PORT=3001
# in emaapi's .env to match.
VITE_BASE_API_URL=http://localhost:3001/api
# Serve fixture data instead of calling the API. Any value other than "true"
# uses the real backend.
VITE_USE_MOCKS=false
# --- Docker Compose only -----------------------------------------------------
# Host ports published by docker-compose.yml. It also expects per-app env files
# at apps/portal/.env and apps/backoffice/.env, which can each be a copy of this
# file. Ignored when running the Vite dev servers, which serve the portal on
# 3000 and the backoffice on 4201.
# EMA_PORTAL_PORT=8021
# EMA_BACKOFFICE_PORT=8022
# --- Fayda note --------------------------------------------------------------
# There is nothing to configure here for Fayda. The portal serves the callback
# page at /callback and /signup/fayda/callback, and whichever path is registered
# with Fayda must match the API's FAYDA_REDIRECT_URI exactly.
#
# The value being registered first is http://localhost:3001/callback, so the
# portal's dev server now listens on 3000 and emaapi moves to 3001. Nothing
# extra to run — `nx serve portal` already binds the right port.

2
.gitignore vendored
View File

@@ -13,6 +13,8 @@ coverage/
# env
.env
.env.*
# ...but the checked-in template must survive that rule.
!.env.example
# logs
*.log

View File

@@ -6,20 +6,20 @@ A fully scaffolded Nx monorepo housing two Vite + React 19 SPAs (Backoffice and
## Tech Stack
| Tool | Version |
|------|---------|
| React | 19 |
| Nx | 22 |
| Vite | 7 |
| TypeScript | 5.9 |
| Redux Toolkit | 2.11 |
| Mantine | 8.3 |
| React Router | 7 |
| TanStack Query | 5 |
| React Hook Form | 7 |
| Zod | 4 |
| Tailwind CSS | 3.4 |
| Vitest | 4 |
| Tool | Version |
| --------------- | ------- |
| React | 19 |
| Nx | 22 |
| Vite | 7 |
| TypeScript | 5.9 |
| Redux Toolkit | 2.11 |
| Mantine | 8.3 |
| React Router | 7 |
| TanStack Query | 5 |
| React Hook Form | 7 |
| Zod | 4 |
| Tailwind CSS | 3.4 |
| Vitest | 4 |
---
@@ -37,16 +37,19 @@ emaui/
```
### libs/api
- `base-api/` — RTK Query `createApi` instance with `prepareHeaders` that injects the Bearer token from Redux state or storage.
- `session/``resolveTokenFromStorage()` reads the `auth-token` cookie first, falling back to `localStorage` for legacy pre-migration sessions. `resolveSessionContext()` merges Redux state token with storage fallback.
- `query-and-mutation/` — Generic `useApiQuery` / `useApiMutation` wrappers for one-off API calls without defining a dedicated endpoint file.
### libs/ui
- `ConfirmModal` — Reusable Mantine modal for destructive-action confirmation.
- `ApiErrorAlert` — Extracts a human-readable message from RTK Query error shapes or Error objects.
- `notify` — Thin wrapper around `@mantine/notifications` with `.success`, `.error`, `.info`, `.warning` helpers.
### libs/shared
- `ema-theme` — Mantine v8 `createTheme()` with `emaPrimary` (blue) and `emaSecondary` (warm) color tuples, Inter font, and custom shadow scale.
---
@@ -86,14 +89,14 @@ npm run dev:all
## Environment Variables
| Variable | Required | Default | Description |
|---|---|---|---|
| `VITE_BASE_API_URL` | Yes | `http://localhost:3000` | Base URL for all API requests |
| `VITE_ENABLE_DEVELOPER_TOOLS` | No | `true` | Toggle Redux DevTools |
| `VITE_PAYMENT_API_URL` | Yes (portal) | — | Base URL for the payment service (portal `payment` feature). Can be a same-origin path if a backend proxy is later placed in front of it |
| `VITE_PAYMENT_SERVICE_TOKEN` | Yes (portal) | — | Sent as `x-service-token` on every payment request. Note: bundled `VITE_*` values are public in the built app, not secret |
| `PORTAL_PORT` | No | `4200` | Docker host port for portal |
| `BACKOFFICE_PORT` | No | `4201` | Docker host port for backoffice |
| Variable | Required | Default | Description |
| ----------------------------- | ------------ | ----------------------- | ---------------------------------------------------------------------------------------------------------------------------------------- |
| `VITE_BASE_API_URL` | Yes | `http://localhost:3001` | Base URL for all API requests |
| `VITE_ENABLE_DEVELOPER_TOOLS` | No | `true` | Toggle Redux DevTools |
| `VITE_PAYMENT_API_URL` | Yes (portal) | — | Base URL for the payment service (portal `payment` feature). Can be a same-origin path if a backend proxy is later placed in front of it |
| `VITE_PAYMENT_SERVICE_TOKEN` | Yes (portal) | — | Sent as `x-service-token` on every payment request. Note: bundled `VITE_*` values are public in the built app, not secret |
| `PORTAL_PORT` | No | `4200` | Docker host port for portal |
| `BACKOFFICE_PORT` | No | `4201` | Docker host port for backoffice |
---
@@ -112,6 +115,7 @@ The `Dockerfile` uses multi-stage builds with named targets (`portal` / `backoff
## Adding a New Feature
1. Create the feature folder under the relevant app:
```
apps/backoffice/src/app/features/<feature-name>/
├── types/ # TypeScript interfaces

View File

@@ -0,0 +1,322 @@
import { useMemo, useState } from 'react';
import {
Alert,
Badge,
Button,
Card,
Container,
Group,
Loader,
Select,
Stack,
Table,
Text,
TextInput,
ThemeIcon,
} from '@mantine/core';
import { IconAlertTriangle, IconFingerprint, IconPrinter, IconScan, IconSearch, IconX } from '@tabler/icons-react';
import { useDebouncedValue } from '@mantine/hooks';
import {
extractErrorMessage,
openAuthedDocument,
useEnrollBiometricMutation,
useGenerateBsidMutation,
useGetBiometricEnrollmentsQuery,
useGetBiometricSimulateCapabilitiesQuery,
useListSeafarerRegistrationsQuery,
useRevokeBiometricEnrollmentMutation,
type BiometricModality,
type SeafarerRegistration,
} from '@ema-platform/api';
import { notify, PageHeader, StatusBadge } from '@ema-platform/ui';
import { useDateDisplayer } from '@ema-platform/shared';
const MODALITIES: { value: BiometricModality; label: string }[] = [
{ value: 'FINGERPRINT', label: 'Fingerprint' },
{ value: 'FACE', label: 'Face' },
];
function applicantName(r: Pick<SeafarerRegistration, 'firstName' | 'middleName' | 'lastName'>): string {
return [r.firstName, r.middleName, r.lastName].filter(Boolean).join(' ') || '—';
}
/**
* No scanner is wired yet (US-BIO placeholder): "Simulate Scan" stands in for
* the real vendor SDK capture, producing a random template so the rest of the
* pipeline — encrypt, store, print — is exercisable end to end. Swap the
* simulated bytes for the SDK's real template once a vendor is chosen; the
* API call shape (base64 template + format tag) does not change.
*/
function fakeTemplate(): string {
const bytes = crypto.getRandomValues(new Uint8Array(64));
return btoa(String.fromCharCode(...bytes));
}
/** Pick a registered seafarer to enroll — approved registrations carry a profileId. */
function ProfilePicker({ onPick }: { onPick: (r: SeafarerRegistration) => void }) {
const [search, setSearch] = useState('');
const [debounced] = useDebouncedValue(search, 300);
const { data, isFetching } = useListSeafarerRegistrationsQuery({
status: 'APPROVED',
search: debounced || undefined,
take: 10,
});
return (
<Card withBorder radius="md" p="md">
<TextInput
placeholder="Search seafarer by name, ID or registration number…"
leftSection={<IconSearch size={14} />}
value={search}
onChange={(e) => setSearch(e.currentTarget.value)}
mb="sm"
/>
{isFetching && <Loader size="sm" />}
<Table highlightOnHover fz="sm">
<Table.Tbody>
{(data?.items ?? []).map((r) => (
<Table.Tr key={r.id} onClick={() => onPick(r)} style={{ cursor: 'pointer' }}>
<Table.Td>
<Text fz="sm" fw={600}>{applicantName(r)}</Text>
<Text fz="xs" c="dimmed" ff="monospace">{r.seafarerNumber}</Text>
</Table.Td>
</Table.Tr>
))}
{!isFetching && (data?.items ?? []).length === 0 && (
<Table.Tr>
<Table.Td>
<Text fz="sm" c="dimmed">No registered seafarer matches.</Text>
</Table.Td>
</Table.Tr>
)}
</Table.Tbody>
</Table>
</Card>
);
}
/** Backoffice counter screen: enroll a scanner capture against a profile, view what's on file, print the slip. */
export function BiometricEnrollmentPage() {
const showDate = useDateDisplayer();
const [selected, setSelected] = useState<SeafarerRegistration | null>(null);
const [modality, setModality] = useState<BiometricModality>('FINGERPRINT');
const [deviceId, setDeviceId] = useState('');
const profileId = selected?.profileId ?? '';
const { data: enrollments, isLoading } = useGetBiometricEnrollmentsQuery(profileId, { skip: !profileId });
// No vendor SDK integrated yet — "Simulate Scan" fakes a capture so the
// rest of the flow is exercisable. Reports false in production unless
// ALLOW_BIOMETRIC_SIMULATION=true, same shortcut the payment bypass uses.
const { data: capabilities } = useGetBiometricSimulateCapabilitiesQuery();
const simulateEnabled = capabilities?.simulateEnabled ?? false;
const [generateBsid, { isLoading: generatingBsid }] = useGenerateBsidMutation();
// Seeded from the seafarer registration list (which does not carry BSID
// yet) and updated locally once generated — this screen's only source of
// truth for it until the registry surfaces the profile's BSID directly.
const [bsid, setBsid] = useState<string | null>(null);
const [enroll, { isLoading: enrolling }] = useEnrollBiometricMutation();
const [revoke, { isLoading: revoking }] = useRevokeBiometricEnrollmentMutation();
const [printing, setPrinting] = useState(false);
const hasActive = useMemo(
() => (m: BiometricModality) => (enrollments ?? []).some((e) => e.modality === m),
[enrollments],
);
async function handleEnroll() {
if (!profileId) return;
try {
await enroll({
profileId,
modality,
template: fakeTemplate(),
templateFormat: 'SIMULATED',
deviceId: deviceId || undefined,
consentAt: new Date().toISOString(),
}).unwrap();
notify.success(`${modality === 'FINGERPRINT' ? 'Fingerprint' : 'Face'} enrolled.`);
} catch (err) {
notify.error(extractErrorMessage(err, 'Enrollment failed.'));
}
}
async function handleGenerateBsid() {
if (!profileId) return;
try {
const result = await generateBsid(profileId).unwrap();
setBsid(result.bsid);
notify.success(`BSID ${result.bsid} generated.`);
} catch (err) {
notify.error(extractErrorMessage(err, 'Could not generate BSID.'));
}
}
async function handleRevoke(id: string) {
if (!profileId) return;
try {
await revoke({ id, profileId, reason: 'Withdrawn at counter' }).unwrap();
notify.success('Enrollment revoked.');
} catch (err) {
notify.error(extractErrorMessage(err, 'Could not revoke.'));
}
}
async function handlePrint() {
if (!profileId) return;
setPrinting(true);
try {
await openAuthedDocument(
`/biometric-enrollments/profile/${profileId}/certificate`,
`biometric-enrollment-${profileId}.pdf`,
);
} catch (err) {
notify.error(extractErrorMessage(err, 'Could not open the certificate.'));
} finally {
setPrinting(false);
}
}
return (
<Container size="md" py="md">
<PageHeader
title="Biometric Enrollment"
subtitle="Capture a fingerprint or face template for a registered seafarer, and print the enrollment slip."
/>
{!selected ? (
<ProfilePicker
onPick={(r) => {
setSelected(r);
setBsid(null);
}}
/>
) : (
<Stack gap="md">
<Card withBorder radius="md" p="md">
<Group justify="space-between">
<div>
<Text fw={600}>{applicantName(selected)}</Text>
<Text fz="xs" c="dimmed" ff="monospace">{selected.seafarerNumber}</Text>
</div>
<Button
variant="subtle"
size="xs"
leftSection={<IconX size={14} />}
onClick={() => {
setSelected(null);
setBsid(null);
}}
>
Change seafarer
</Button>
</Group>
</Card>
<Card withBorder radius="md" p="md">
<Text fz="sm" fw={600} mb="sm">Capture</Text>
{simulateEnabled ? (
<>
<Alert color="yellow" icon={<IconAlertTriangle size={16} />} mb="sm" variant="light">
No scanner is wired yet this simulates a capture so the rest of the flow can be tested.
</Alert>
<Group align="flex-end">
<Select label="Modality" data={MODALITIES} value={modality} onChange={(v) => setModality((v as BiometricModality) ?? 'FINGERPRINT')} w={160} />
<TextInput label="Device (optional)" placeholder="scanner-01" value={deviceId} onChange={(e) => setDeviceId(e.currentTarget.value)} w={180} />
<Button leftSection={<IconScan size={16} />} onClick={handleEnroll} loading={enrolling}>
Simulate Scan &amp; Enroll
</Button>
</Group>
</>
) : (
<Alert color="gray" icon={<IconAlertTriangle size={16} />} variant="light">
No scanner is wired yet, and capture simulation is off in this environment.
</Alert>
)}
</Card>
<Card withBorder radius="md" p="md">
<Text fz="sm" fw={600} mb="sm">Biometric Subject ID (BSID)</Text>
<Text fz="xs" c="dimmed" mb="sm">
Required before this registration can be approved. Generating it is final
confirm the capture is good first.
</Text>
<Group justify="space-between">
{bsid ? (
<StatusBadge tone="success" label={`BSID ${bsid}`} />
) : (
<Badge color="gray" variant="light">Not generated</Badge>
)}
{!bsid && (
<Button
size="xs"
onClick={handleGenerateBsid}
loading={generatingBsid}
disabled={!hasActive('FINGERPRINT') && !hasActive('FACE')}
>
Generate BSID
</Button>
)}
</Group>
</Card>
<Card withBorder radius="md" p="md">
<Group justify="space-between" mb="sm">
<Text fz="sm" fw={600}>On file</Text>
<Button
variant="light"
size="xs"
leftSection={<IconPrinter size={14} />}
onClick={handlePrint}
loading={printing}
>
Print certificate
</Button>
</Group>
{isLoading ? (
<Loader size="sm" />
) : (
<Stack gap="xs">
{MODALITIES.map((m) => (
<Group key={m.value} justify="space-between" p="xs" style={{ borderRadius: 8, background: 'var(--mantine-color-default-hover)' }}>
<Group gap="xs">
<ThemeIcon variant="light" color={hasActive(m.value) ? 'teal' : 'gray'} size={30} radius="md">
<IconFingerprint size={15} />
</ThemeIcon>
<Text fz="sm">{m.label}</Text>
</Group>
{hasActive(m.value) ? (
<Group gap="xs">
<StatusBadge tone="success" label="Enrolled" />
<Button
size="xs"
color="red"
variant="subtle"
loading={revoking}
onClick={() => {
const row = (enrollments ?? []).find((e) => e.modality === m.value);
if (row) handleRevoke(row.id);
}}
>
Revoke
</Button>
</Group>
) : (
<Badge color="gray" variant="light">Not enrolled</Badge>
)}
</Group>
))}
{(enrollments ?? []).map((e) => (
<Text key={e.id} fz="xs" c="dimmed">
{e.modality} captured {showDate(e.enrolledAt)}{e.deviceId ? ` · ${e.deviceId}` : ''}
</Text>
))}
</Stack>
)}
</Card>
</Stack>
)}
</Container>
);
}
export default BiometricEnrollmentPage;

View File

@@ -1,9 +1,9 @@
import type { Bilingual, LicenseCategory, LicenseTemplate, LicenseType } from '@ema-platform/api';
import { BASE_API_URL } from '@ema-platform/api';
/** Same resolution — and same fallback — the shared RTK Query baseQuery uses. */
export const API_BASE_URL =
(import.meta as { env?: Record<string, string> }).env?.['VITE_BASE_API_URL'] ??
'http://localhost:3000/api';
export const API_BASE_URL = BASE_API_URL;
export const STATUS_COLOR: Record<LicenseTemplate['status'], string> = {
DRAFT: 'gray',

View File

@@ -198,7 +198,7 @@ function ConditionArmFields({
fz="xs"
px={6}
py={2}
bg="var(--mantine-color-gray-1)"
bg="var(--mantine-color-default-hover)"
style={{ borderRadius: 4, cursor: 'pointer' }}
onClick={() => setInValues((value.in ?? []).filter((_, idx) => idx !== i).map(String))}
title={t('certReq.condition.removeValue', 'Click to remove')}

View File

@@ -127,7 +127,7 @@ export function DocumentRequirementsTab({ licenseType }: { licenseType: LicenseT
) : (
<Stack gap="xs">
{rows.map((req) => (
<Card key={req.id} withBorder radius="sm" p="xs" bg="var(--mantine-color-gray-0)">
<Card key={req.id} withBorder radius="sm" p="xs" bg="var(--mantine-color-default-hover)">
<Group justify="space-between" wrap="nowrap">
<div style={{ minWidth: 0 }}>
<Group gap={6}>

View File

@@ -209,7 +209,7 @@ export function FormSchemaTab({ licenseType }: { licenseType: LicenseType }) {
<Card key={section.key} withBorder radius="md" p="md">
<Group justify="space-between" align="flex-start" mb="sm">
<Group gap="xs" wrap="nowrap">
<IconGripVertical size={16} color="var(--mantine-color-gray-5)" />
<IconGripVertical size={16} color="var(--mantine-color-dimmed)" />
<div>
<Group gap="xs">
<Text fw={700}>{localized(section.title) || section.key}</Text>
@@ -237,7 +237,7 @@ export function FormSchemaTab({ licenseType }: { licenseType: LicenseType }) {
<Stack gap="xs">
{section.fields.map((field, fIndex) => (
<Card key={field.key} withBorder radius="sm" p="xs" bg="var(--mantine-color-gray-0)">
<Card key={field.key} withBorder radius="sm" p="xs" bg="var(--mantine-color-default-hover)">
<Group justify="space-between" wrap="nowrap">
<Group gap="xs" wrap="nowrap" style={{ minWidth: 0 }}>
<div style={{ minWidth: 0 }}>

View File

@@ -12,6 +12,7 @@ import type {
CreateIncidentPayload,
ResolveIncidentPayload,
RegradeOutcome,
GradingSheet,
} from '../types/exam';
const examApi = baseApi.injectEndpoints({
@@ -101,6 +102,15 @@ const examApi = baseApi.injectEndpoints({
}),
invalidatesTags: ['Api'],
}),
/** The candidate's answers plus auto-computable CHOICE scores, for RecordResultModal. */
getGradingSheet: builder.query<
GradingSheet,
{ examId: string; profileId: string }
>({
query: ({ examId, profileId }) =>
`/exam-attempts/exam/${examId}/candidate/${profileId}/grading-sheet`,
providesTags: ['Api'],
}),
}),
overrideExisting: false,
});
@@ -119,4 +129,5 @@ export const {
useRecordIncidentMutation,
useResolveIncidentMutation,
useRegradeAttemptMutation,
useGetGradingSheetQuery,
} = examApi;

View File

@@ -22,6 +22,7 @@ import {
TextInput,
ThemeIcon,
Box,
Tooltip,
rem,
} from "@mantine/core";
import { useDisclosure } from "@mantine/hooks";
@@ -48,6 +49,7 @@ import {
useUpdateExamMutation,
useAssignQuestionsMutation,
useSelectRandomQuestionsMutation,
useGetExamRegistrationsQuery,
} from '../api/exam-api';
import { useGetQuestionsQuery } from '../../question/api/question-api';
import { useGetCertificationsQuery } from '../../certification/api/certification-api';
@@ -114,6 +116,12 @@ export function ExamDetailPage() {
const [selectRandom, { isLoading: isDrawing }] = useSelectRandomQuestionsMutation();
const { data: exam, isLoading, isError } = useGetExamQuery(id ?? '', { skip: !id });
// The backend locks the paper the moment the first candidate registers
// (ExamService.assertPaperEditable) — every candidate must sit the same
// paper. Same query ExamCandidatesPanel already runs, so RTK Query serves
// it from cache rather than issuing a second request.
const { data: registrations } = useGetExamRegistrationsQuery(id ?? '', { skip: !id });
const paperLocked = (registrations?.length ?? 0) > 0;
const { data: qRes } = useGetQuestionsQuery();
const { data: certRes } = useGetCertificationsQuery();
const allQuestions = qRes?.items ?? [];
@@ -122,10 +130,14 @@ export function ExamDetailPage() {
// Only approved bank items may go on a paper (US-EXAM-003), so the picker
// must not offer drafts or retired questions either.
//
// BOTH describes a mixed paper — a question itself is never "BOTH" (see
// Filters on exam.form alone, not administrationMethod: the backend no
// longer restricts ONLINE to CHOICE (ExamService no longer has an
// assertOnlineIsChoiceOnly gate), so exam.form is now the sole source of
// truth for what belongs on the paper, ONLINE or OFFLINE alike. BOTH
// describes a mixed paper — a question itself is never "BOTH" (see
// QuestionForm), so an equality check against it would match nothing and
// silently offer zero questions. Same skip-condition as the backend's own
// random draw (ExamService.selectRandomQuestions).
// silently offer zero questions; skipped the same way the backend's own
// random draw does (ExamService.selectRandomQuestions).
const eligibleQuestions = useMemo(() => {
if (!exam) return [];
return allQuestions
@@ -179,7 +191,9 @@ export function ExamDetailPage() {
} catch (error) {
const key = extractErrorMessage(error, t('exam.randomError'));
notify.error(
key.startsWith('insufficient_approved_questions')
key === 'paper_locked_after_registration'
? t('exam.paperLockedHint', { count: registrations?.length ?? 0 })
: key.startsWith('insufficient_approved_questions')
? `${t('exam.notEnoughApproved')} (${key.split(':')[1] ?? ''})`
: key.startsWith('paper_cannot_reach_cutting_point')
? t('exam.cannotReachCuttingPoint', {
@@ -200,7 +214,9 @@ export function ExamDetailPage() {
} catch (error) {
const key = extractErrorMessage(error, 'Failed to assign questions');
notify.error(
key.startsWith('question_not_approved')
key === 'paper_locked_after_registration'
? t('exam.paperLockedHint', { count: registrations?.length ?? 0 })
: key.startsWith('question_not_approved')
? t('question.qc.onlyApprovedUsable')
: key.startsWith('paper_cannot_reach_cutting_point')
? t('exam.cannotReachCuttingPoint', {
@@ -452,19 +468,45 @@ export function ExamDetailPage() {
{t("exam.detail.questionsSection", { pts: totalPoints })}
</Title>
<RequirePermission anyOf={[LICENSE_PERMISSIONS.MANAGE_EXAMS]} hideOnly>
<Button
variant="light"
size="xs"
leftSection={<IconPlus size={14} />}
onClick={openAssignModal}
>
{t("exam.manageQuestions")}
</Button>
<Group gap="xs">
{paperLocked && (
<Badge size="sm" variant="light" color="gray">
{t("exam.paperLocked")}
</Badge>
)}
<Tooltip
label={t("exam.paperLockedHint", {
count: registrations?.length ?? 0,
})}
disabled={!paperLocked}
multiline
w={280}
>
{/* Wrapped: a disabled Mantine Button fires no pointer events,
so the tooltip needs an enabled element to hang off. */}
<Box>
<Button
variant="light"
size="xs"
leftSection={<IconPlus size={14} />}
onClick={openAssignModal}
disabled={paperLocked}
>
{t("exam.manageQuestions")}
</Button>
</Box>
</Tooltip>
</Group>
</RequirePermission>
</Group>
{(exam.questions ?? []).length === 0 ? (
<Alert color="gray" icon={<IconInfoCircle size={16} />}>
{t("exam.noQuestionsAssigned")}
<Alert
color={paperLocked ? "red" : "gray"}
icon={<IconInfoCircle size={16} />}
>
{paperLocked
? t("exam.paperLockedHint", { count: registrations?.length ?? 0 })
: t("exam.noQuestionsAssigned")}
</Alert>
) : (
<Stack gap="md">

View File

@@ -79,21 +79,44 @@ function ExamForm({
const [status, setStatus] = useState<string | null>(editing?.status ?? null);
const [activeTab, setActiveTab] = useState<string | null>("basic");
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault();
/**
* Split per tab so "Next" can check just the tab in front of the user.
* Submitting from Basic Info used to complain about Settings fields the
* user had not been shown yet — the error was correct and unactionable at
* the same time. Each returns the message key for what is missing, or null.
*/
const validateBasic = (): string | null => {
if (!certificationId || !titleEn || !titleAm || !date || !venue) {
setActiveTab("basic");
notify.error(t("exam.form.fillRequiredBasic"));
return;
return "exam.form.fillRequiredBasic";
}
if ((directionEn || directionAm) && !(directionEn && directionAm)) {
setActiveTab("basic");
notify.error(t("exam.form.directionBothLanguages"));
return "exam.form.directionBothLanguages";
}
return null;
};
const validateSettings = (): string | null =>
!type || !form || !adminMethod || !evalMethod || !cuttingPoint
? "exam.form.fillRequiredSettings"
: null;
const goNext = () => {
const error = validateBasic();
if (error) {
notify.error(t(error));
return;
}
if (!type || !form || !adminMethod || !evalMethod || !cuttingPoint) {
setActiveTab("settings");
notify.error(t("exam.form.fillRequiredSettings"));
setActiveTab("settings");
};
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault();
// Still checks both: the tabs are clickable, so a user can reach Settings
// without going through Next.
const error = validateBasic() ?? validateSettings();
if (error) {
setActiveTab(error === "exam.form.fillRequiredSettings" ? "settings" : "basic");
notify.error(t(error));
return;
}
onSubmit(
@@ -255,12 +278,6 @@ function ExamForm({
onChange={setForm}
size="sm"
required
disabled={adminMethod === "ONLINE"}
description={
adminMethod === "ONLINE"
? t("exam.form.onlineChoiceOnlyHint")
: undefined
}
/>
<Select
label={t("exam.detail.administration")}
@@ -270,14 +287,7 @@ function ExamForm({
{ value: "ONLINE", label: t("exam.form.online") },
]}
value={adminMethod}
onChange={(value) => {
setAdminMethod(value);
// Online exams are graded automatically, and that only
// has an answer model for CHOICE — matches the backend
// rule (online_exam_requires_choice_form), not just a
// UI nicety.
if (value === "ONLINE") setForm("CHOICE");
}}
onChange={setAdminMethod}
size="sm"
required
/>
@@ -350,9 +360,26 @@ function ExamForm({
<Button variant="default" onClick={onCancel} size="sm">
{t("exam.cancel")}
</Button>
<Button type="submit" size="sm" loading={isSubmitting}>
{editing ? t("exam.update") : t("exam.create")}
</Button>
{activeTab === "basic" ? (
/* Not type="submit": Basic Info is not the last step, so the
primary action advances rather than saves. */
<Button size="sm" onClick={goNext}>
{t("exam.form.next")}
</Button>
) : (
<>
<Button
variant="default"
size="sm"
onClick={() => setActiveTab("basic")}
>
{t("exam.form.back")}
</Button>
<Button type="submit" size="sm" loading={isSubmitting}>
{editing ? t("exam.update") : t("exam.create")}
</Button>
</>
)}
</ModalFooter>
</form>
</Modal>

View File

@@ -133,6 +133,24 @@ export interface ExamRegistration {
export type RegradeOutcome =
{ graded: true; resultId: string } | { graded: false; reason: string };
/** One question's row on the staff grading sheet — the candidate's own
* answer plus the auto-computable score, where one exists. */
export interface GradingSheetQuestion {
questionId: string;
form: QuestionForm;
points: number;
answerText: string | null;
selectedOptionId: string | null;
selectedOptionText: { en?: string; am?: string } | null;
/** null means "no auto-score" — examiner enters one by hand. */
autoScore: number | null;
}
export interface GradingSheet {
attemptStatus: 'IN_PROGRESS' | 'SUBMITTED' | 'EXPIRED';
questions: GradingSheetQuestion[];
}
export interface RecordAttendancePayload {
registrationId: string;
status: AttendanceStatus;

View File

@@ -22,6 +22,9 @@ interface Props {
* of a per-candidate appointment. Scoped to sittings whose certification
* matches this application's rank, so a Chief Mate candidate cannot be seated
* into an OOW Deck sitting by accident.
*
* Scheduling makes the sitting available; it does not register the candidate.
* That is their own act, from the portal's Register button.
*/
export function ScheduleExamModal({
opened,
@@ -61,7 +64,7 @@ export function ScheduleExamModal({
<Text size="sm" c="dimmed">
{t('review.scheduleExam.intro', {
defaultValue:
'Assign {{applicant}} to a scheduled sitting. They will be notified with the date and their admission number.',
'Make a sitting available to {{applicant}}. They register for it themselves from the portal.',
applicant: applicantName,
})}
</Text>
@@ -89,7 +92,7 @@ export function ScheduleExamModal({
<Text size="xs" c="dimmed">
{t(
'review.scheduleExam.admissionHint',
'An admission number is issued automatically when the candidate is seated.',
'Scheduling does not seat the candidate. They must register for the sitting from the portal, and the admission number is issued then.',
)}
</Text>

View File

@@ -28,6 +28,7 @@ export type ActionId =
| 'complete-review'
| 'approve-documents'
| 'schedule-inspection'
| 'reschedule-inspection'
| 'record-inspection'
| 'final-approve'
| 'request-adjustment'
@@ -200,6 +201,17 @@ export const ACTIONS: ActionDefinition[] = [
permissions: ['can:create:inspection'],
emphasis: 'filled',
},
{
id: 'reschedule-inspection',
tier: 'primary',
labelKey: 'review.actions.rescheduleInspection',
from: ['INSPECTION_PENDING', 'INSPECTION_FAILED'],
// Either permission: the team leader who booked the visit holds CREATE,
// the inspector who has to attend holds UPDATE, and both have a reason to
// move it. The server guards the same pair.
permissions: ['can:create:inspection', 'can:update:inspection'],
emphasis: 'light',
},
{
id: 'record-inspection',
tier: 'primary',
@@ -212,12 +224,15 @@ export const ACTIONS: ActionDefinition[] = [
id: 'final-approve',
tier: 'primary',
labelKey: 'review.actions.finalApprove',
from: ['INSPECTION_COMPLETED',
'REVIEW_REPORTED',
'INSPECTION_REPORTED', 'UNDER_EVALUATION'],
// ELIGIBILITY_PAID: an examined certificate (CoC/CoP) is decided straight
// off the eligibility queue — no assignment step.
from: ['INSPECTION_COMPLETED', 'UNDER_EVALUATION', 'ELIGIBILITY_PAID'],
from: [
'INSPECTION_COMPLETED',
'REVIEW_REPORTED',
'INSPECTION_REPORTED',
'UNDER_EVALUATION',
'ELIGIBILITY_PAID',
],
permissions: ['can:approve:license-application'],
emphasis: 'filled',
color: 'teal',
@@ -227,13 +242,12 @@ export const ACTIONS: ActionDefinition[] = [
id: 'request-adjustment',
tier: 'primary',
labelKey: 'review.actions.requestAdjustment',
from: ['UNDER_REVIEW', 'UNDER_EVALUATION', 'INSPECTION_COMPLETED',
'REVIEW_REPORTED',
'INSPECTION_REPORTED'],
from: [
'UNDER_REVIEW',
'UNDER_EVALUATION',
'INSPECTION_COMPLETED',
'REVIEW_REPORTED',
'INSPECTION_REPORTED',
'INSPECTION_FAILED',
'ELIGIBILITY_PAID',
],
@@ -439,6 +453,12 @@ export function resolveActions(ctx: ResolveContext): ResolvedAction[] {
// one applies depends on whether an inspection is already booked.
if (action.id === 'schedule-inspection' && ctx.hasPendingInspection) return [];
if (action.id === 'record-inspection' && !ctx.hasPendingInspection) return [];
// The mirror of the scheduling gate: there is nothing to move until a
// visit is booked, and once one is, moving it is the officer's only
// option until the day arrives.
if (action.id === 'reschedule-inspection' && !ctx.hasPendingInspection) {
return [];
}
// The transition table doesn't know which types need an inspection, so
// `availableEvents` lists approve-documents at UNDER_EVALUATION even for

View File

@@ -32,6 +32,7 @@ import {
IconLayoutSidebarRightCollapse,
IconLayoutSidebarRightExpand,
IconPaperclip,
IconPencil,
IconQuestionMark,
IconX,
} from "@tabler/icons-react";
@@ -70,6 +71,7 @@ import {
useRequestAdjustmentMutation,
useResumeApplicationMutation,
useScheduleInspectionMutation,
useRescheduleInspectionMutation,
useGetCertificateUrlForOfficerMutation,
uploadDocument,
type RemarkTargetType,
@@ -218,6 +220,7 @@ export function LicenseReviewPage() {
const [finalApprove] = useFinalApproveMutation();
const [rejectApplication] = useRejectApplicationMutation();
const [scheduleInspection] = useScheduleInspectionMutation();
const [rescheduleInspection] = useRescheduleInspectionMutation();
const [recordResult] = useRecordInspectionResultMutation();
const [confirmPayment] = useConfirmPaymentMutation();
const [scheduleIssuance] = useScheduleIssuanceMutation();
@@ -261,6 +264,9 @@ export function LicenseReviewPage() {
const [inspectionTimeSlot, setInspectionTimeSlot] = useState<"MORNING" | "AFTERNOON">(
"MORNING",
);
/** The booking modal moves an existing visit rather than creating one. */
const [rescheduling, setRescheduling] = useState(false);
const [rescheduleReason, setRescheduleReason] = useState("");
const [issuanceOpen, setIssuanceOpen] = useState(false);
const [issuanceDate, setIssuanceDate] = useState("");
const [issuancePeriod, setIssuancePeriod] = useState<"MORNING" | "AFTERNOON">(
@@ -314,10 +320,14 @@ export function LicenseReviewPage() {
const pendingInspection = inspections.find((i) => i.status === 'SCHEDULED');
// A visit cannot have an outcome before it happens — mirror of the server's
// inspection_not_yet_due guard, compared instant-to-instant.
// inspection_not_yet_due guard. The column holds a calendar day, so the
// comparison is between day strings in the authority's timezone: parsing
// "2026-08-28" as a Date would read it as UTC midnight, i.e. 03:00 in Addis.
const inspectionNotYetDue = Boolean(
pendingInspection?.scheduledDate &&
new Date(pendingInspection.scheduledDate) > new Date(),
new Intl.DateTimeFormat("en-CA", { timeZone: "Africa/Addis_Ababa" }).format(
new Date(),
) < pendingInspection.scheduledDate.slice(0, 10),
);
const { data: findingsEvidence = [], refetch: refetchFindingsEvidence } =
@@ -561,12 +571,29 @@ export function LicenseReviewPage() {
}
}
/** Opens the booking modal seeded with the visit already on the books. */
function openReschedule() {
if (!pendingInspection) return;
setRescheduling(true);
setInspectionDate(pendingInspection.scheduledDate ?? "");
setInspectionTimeSlot(pendingInspection.timeSlot ?? "MORNING");
setRescheduleReason("");
setInspectionOpen(true);
}
/** Actions with their own dedicated form open that; the rest confirm. */
function handleAction(action: ResolvedAction) {
switch (action.id) {
case "schedule-inspection":
setRescheduling(false);
setInspectionDate("");
setInspectionTimeSlot("MORNING");
setRescheduleReason("");
setInspectionOpen(true);
return;
case "reschedule-inspection":
openReschedule();
return;
case "schedule-issuance":
setIssuanceOpen(true);
return;
@@ -1222,18 +1249,6 @@ export function LicenseReviewPage() {
</Tabs.Panel>
<Tabs.Panel value="inspection">
{status === "INSPECTION_FAILED" && (
<Alert
mb="md"
color="red"
icon={<IconAlertTriangle size={16} />}
>
{t(
"review.inspectionFailedBlocked",
"Approval is unavailable because the inspection failed. Schedule a re-inspection, request corrections, or reject the application.",
)}
</Alert>
)}
<Paper withBorder p="md">
{inspections.length === 0 ? (
<Text size="sm" c="dimmed">
@@ -1266,21 +1281,48 @@ export function LicenseReviewPage() {
</Text>
)}
</div>
<Badge
variant="light"
color={
inspection.result === "FAILED" ? "red" : "teal"
}
>
{inspection.result === "PASSED"
? t("review.passed", "Passed")
: inspection.result === "FAILED"
? t("review.failed", "Failed")
: t(
`review.inspectionStatus.${inspection.status}`,
inspection.status,
<Group gap="xs">
{inspection.status === "SCHEDULED" &&
inspection.id === pendingInspection?.id &&
can([
"can:create:inspection",
"can:update:inspection",
]) && (
<Tooltip
label={t(
"review.actions.rescheduleInspection",
"Reschedule inspection",
)}
</Badge>
>
<ActionIcon
variant="subtle"
size="sm"
aria-label={t(
"review.actions.rescheduleInspection",
"Reschedule inspection",
)}
onClick={openReschedule}
>
<IconPencil size={16} />
</ActionIcon>
</Tooltip>
)}
<Badge
variant="light"
color={
inspection.result === "FAILED" ? "red" : "teal"
}
>
{inspection.result === "PASSED"
? t("review.passed", "Passed")
: inspection.result === "FAILED"
? t("review.failed", "Failed")
: t(
`review.inspectionStatus.${inspection.status}`,
inspection.status,
)}
</Badge>
</Group>
</Group>
))}
</Stack>
@@ -1289,6 +1331,18 @@ export function LicenseReviewPage() {
</Tabs.Panel>
</Tabs>
{/* Page-level, not inside the inspection tab: a license type
configured without an inspection detail section must still show
why approval is blocked if it ever lands here. */}
{status === "INSPECTION_FAILED" && (
<Alert mt="md" color="red" icon={<IconAlertTriangle size={16} />}>
{t(
"review.inspectionFailedBlocked",
"Approval is unavailable because the inspection failed. Schedule a re-inspection, request corrections, or reject the application.",
)}
</Alert>
)}
{status === "PAYMENT_PENDING" && (
<Alert
mt="md"
@@ -1438,7 +1492,11 @@ export function LicenseReviewPage() {
<Modal
opened={inspectionOpen}
onClose={() => setInspectionOpen(false)}
title={t("review.actions.scheduleInspection", "Schedule inspection")}
title={
rescheduling
? t("review.actions.rescheduleInspection", "Reschedule inspection")
: t("review.actions.scheduleInspection", "Schedule inspection")
}
>
<Stack>
<AmharicDatePicker
@@ -1454,36 +1512,72 @@ export function LicenseReviewPage() {
{ value: "AFTERNOON", label: t("review.afternoon", "Afternoon") },
]}
/>
{rescheduling && (
<Textarea
label={t("review.rescheduleReason", "Why is it moving?")}
description={t(
"review.rescheduleReasonHint",
"Kept in the audit trail and sent to the applicant.",
)}
value={rescheduleReason}
onChange={(e) => setRescheduleReason(e.currentTarget.value)}
autosize
minRows={2}
/>
)}
<ModalFooter>
{/* Mantine strips pointer events from a disabled control, so the
tooltip wraps a span — same trick as DecisionBar's ActionButton;
a disabled button must still say why. */}
<Tooltip
label={t("review.pickDate", "Pick a date first")}
disabled={Boolean(inspectionDate)}
>
<span>
<button type="button" hidden aria-hidden />
</span>
</Tooltip>
<span style={{ display: "inline-flex" }}>
<ActionIcon
variant="filled"
size="lg"
disabled={!inspectionDate}
aria-label={t("review.schedule", "Schedule")}
aria-label={
rescheduling
? t("review.reschedule", "Reschedule")
: t("review.schedule", "Schedule")
}
onClick={() =>
run(
async () => {
await scheduleInspection({
applicationId: id,
scheduledDate: inspectionDate,
timeSlot: inspectionTimeSlot,
}).unwrap();
if (rescheduling) {
// Guarded by the action's own gate, which only offers
// rescheduling while a booking exists.
if (!pendingInspection) return;
await rescheduleInspection({
inspectionId: pendingInspection.id,
applicationId: id,
scheduledDate: inspectionDate,
timeSlot: inspectionTimeSlot,
...(rescheduleReason.trim()
? { reason: rescheduleReason.trim() }
: {}),
}).unwrap();
} else {
await scheduleInspection({
applicationId: id,
scheduledDate: inspectionDate,
timeSlot: inspectionTimeSlot,
}).unwrap();
}
setInspectionOpen(false);
},
t("review.done.scheduled", "Inspection scheduled"),
rescheduling
? t("review.done.rescheduled", "Inspection rescheduled")
: t("review.done.scheduled", "Inspection scheduled"),
)
}
>
<IconCheck size={18} />
</ActionIcon>
</span>
</Tooltip>
</ModalFooter>
</Stack>
</Modal>
@@ -1508,35 +1602,37 @@ export function LicenseReviewPage() {
]}
/>
<ModalFooter>
{/* Span-wrapped like DecisionBar's ActionButton — Mantine strips
pointer events from a disabled control, and a disabled button
must still say why. */}
<Tooltip
label={t("review.pickDate", "Pick a date first")}
disabled={Boolean(issuanceDate)}
>
<span>
<button type="button" hidden aria-hidden />
<span style={{ display: "inline-flex" }}>
<ActionIcon
variant="filled"
size="lg"
disabled={!issuanceDate}
aria-label={t("review.schedule", "Schedule")}
onClick={() =>
run(
async () => {
await scheduleIssuance({
id,
scheduledDate: issuanceDate,
scheduledPeriod: issuancePeriod,
}).unwrap();
setIssuanceOpen(false);
},
t("review.done.scheduleIssuance", "Pickup scheduled"),
)
}
>
<IconCheck size={18} />
</ActionIcon>
</span>
</Tooltip>
<ActionIcon
variant="filled"
size="lg"
disabled={!issuanceDate}
aria-label={t("review.schedule", "Schedule")}
onClick={() =>
run(
async () => {
await scheduleIssuance({
id,
scheduledDate: issuanceDate,
scheduledPeriod: issuancePeriod,
}).unwrap();
setIssuanceOpen(false);
},
t("review.done.scheduleIssuance", "Pickup scheduled"),
)
}
>
<IconCheck size={18} />
</ActionIcon>
</ModalFooter>
</Stack>
</Modal>

View File

@@ -1,7 +1,7 @@
import { NumberInput, Text, TextInput } from '@mantine/core';
import { Badge, Group, NumberInput, Text, TextInput } from '@mantine/core';
import type { TFunction } from 'i18next';
import type { AdvancedColumn } from '@ema-platform/ui';
import type { QuestionBrief } from '../../../exam/types/exam';
import type { GradingSheetQuestion, QuestionBrief } from '../../../exam/types/exam';
export function recordResultColumns(
t: TFunction,
@@ -11,6 +11,9 @@ export function recordResultColumns(
questionRemarks: Record<string, string>;
onScoreChange: (questionId: string, value: number) => void;
onRemarkChange: (questionId: string, value: string) => void;
/** The candidate's own answer + auto-score, when available (empty for
* an OFFLINE candidate or one who hasn't sat an online attempt). */
answersByQuestion: Map<string, GradingSheetQuestion>;
},
): AdvancedColumn<QuestionBrief>[] {
return [
@@ -22,6 +25,20 @@ export function recordResultColumns(
</Text>
),
},
{
header: t('result.recordModal.candidateAnswer'),
cell: ({ row }) => {
const answer = handlers.answersByQuestion.get(row.original.id);
if (!answer || (!answer.answerText && !answer.selectedOptionText)) {
return <Text fz="xs" c="dimmed">{t('result.recordModal.noAnswer')}</Text>;
}
return (
<Text fz="sm" maw={220} lineClamp={3}>
{answer.selectedOptionText?.[locale] ?? answer.answerText}
</Text>
);
},
},
{
header: t('result.recordModal.maxPoints'),
cell: ({ row }) => (
@@ -32,16 +49,26 @@ export function recordResultColumns(
},
{
header: t('result.recordModal.score'),
cell: ({ row }) => (
<NumberInput
value={handlers.scores[row.original.id] ?? 0}
onChange={(v) => handlers.onScoreChange(row.original.id, Number(v))}
min={0}
max={row.original.points}
size="xs"
style={{ width: 80 }}
/>
),
cell: ({ row }) => {
const autoGraded = handlers.answersByQuestion.get(row.original.id)?.autoScore != null;
return (
<Group gap={4} wrap="nowrap">
<NumberInput
value={handlers.scores[row.original.id] ?? 0}
onChange={(v) => handlers.onScoreChange(row.original.id, Number(v))}
min={0}
max={row.original.points}
size="xs"
style={{ width: 80 }}
/>
{autoGraded && (
<Badge size="xs" variant="light" color="teal">
{t('result.recordModal.autoGraded')}
</Badge>
)}
</Group>
);
},
},
{
header: t('result.recordModal.remark'),

View File

@@ -1,4 +1,4 @@
import { useState } from 'react';
import { useEffect, useState } from 'react';
import { useTranslation } from 'react-i18next';
import {
Modal,
@@ -20,7 +20,7 @@ import { extractErrorMessage } from '@ema-platform/api';
import { LICENSE_PERMISSIONS, RequirePermission } from '@ema-platform/auth';
import { recordResultColumns } from './columns';
import { useCreateResultMutation } from '../../api/result-api';
import { useGetExamRegistrationsQuery } from '../../../exam/api/exam-api';
import { useGetExamRegistrationsQuery, useGetGradingSheetQuery } from '../../../exam/api/exam-api';
import type { Exam } from '../../../exam/types/exam';
function InfoRow({ label, value }: { label: string; value: string }) {
@@ -55,12 +55,39 @@ export function RecordResultModal({
const { data: registrations } = useGetExamRegistrationsQuery(exam.id, {
skip: !opened,
});
// The candidate's own answers plus whatever score auto-grading could
// already compute for the CHOICE portion — degrades to "no data" for an
// OFFLINE candidate or one who never sat an online attempt, same as
// before this existed.
const { data: gradingSheet } = useGetGradingSheetQuery(
{ examId: exam.id, profileId: selectedSeafarerId ?? '' },
{ skip: !opened || !selectedSeafarerId },
);
const answersByQuestion = new Map(
(gradingSheet?.questions ?? []).map((q) => [q.questionId, q]),
);
const [createResult, { isLoading: isSaving }] = useCreateResultMutation();
const table = useServerTable();
const questions = exam.questions ?? [];
const pagedQuestions = table.paginate(questions);
// Prefill (never override) the CHOICE questions auto-grading already
// scored — the examiner only has to key in the ESSAY marks. A fresh
// seafarer selection always starts from an empty scores map, so this
// only ever fills in blanks, never stomps a manual edit already made.
useEffect(() => {
if (!gradingSheet) return;
const autoScores: Record<string, number> = {};
for (const q of gradingSheet.questions) {
if (q.autoScore !== null) autoScores[q.questionId] = q.autoScore;
}
if (Object.keys(autoScores).length) {
setScores((prev) => ({ ...autoScores, ...prev }));
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [gradingSheet]);
const seafarerOptions = (registrations ?? [])
.filter((registration) =>
['PRESENT', 'LATE'].includes(registration.attendanceStatus),
@@ -175,6 +202,7 @@ export function RecordResultModal({
questionRemarks,
onScoreChange: handleScoreChange,
onRemarkChange: handleQuestionRemarkChange,
answersByQuestion,
})}
data={pagedQuestions.rows}
itemCount={pagedQuestions.itemCount}

View File

@@ -26,7 +26,7 @@ export function applicantName(r: Pick<SeafarerRegistration, 'firstName' | 'middl
return [r.firstName, r.middleName, r.lastName].filter(Boolean).join(' ') || '—';
}
/** Submitted seafarer registrations, oldest first — click a row to review it. */
/** Submitted seafarer registrations, newest first — click a row to review it. */
export function SeafarerRegistrationQueuePage() {
const navigate = useNavigate();
const showDate = useDateDisplayer();
@@ -43,6 +43,8 @@ export function SeafarerRegistrationQueuePage() {
const { data, isLoading, isFetching, refetch } = useListSeafarerRegistrationsQuery({
status: status ?? undefined,
search: debouncedSearch || undefined,
sortBy: 'submittedAt',
sortDir: 'DESC',
take: pageSize,
skip: page * pageSize,
});

View File

@@ -184,7 +184,7 @@ export function SeafarerRegistryPage() {
</SimpleGrid>
{/* Search + filters */}
<Paper withBorder radius="lg" p="xl">
<Paper withBorder radius="lg" p={{ base: 'md', sm: 'xl' }}>
<Group mb="sm" gap="sm" justify="space-between">
<TextInput
placeholder="Search by name, seafarer ID, or seaman book…"
@@ -215,6 +215,7 @@ export function SeafarerRegistryPage() {
<Text fz="xs" c="dimmed" mb="sm">{filtered.length} seafarer(s) found</Text>
<Table.ScrollContainer minWidth={1100}>
<Table highlightOnHover fz="sm" verticalSpacing="sm">
<Table.Thead bg="var(--mantine-color-default-hover)">
<Table.Tr>
@@ -278,6 +279,7 @@ export function SeafarerRegistryPage() {
)}
</Table.Tbody>
</Table>
</Table.ScrollContainer>
</Paper>
<DetailModal sf={selected} opened={!!selected} onClose={() => setSelected(null)} />

View File

@@ -1,10 +1,14 @@
import Cookies from 'js-cookie';
import { useCallback, useEffect, useRef } from 'react';
import { createRoot, type Root } from 'react-dom/client';
import { useNavigate } from 'react-router-dom';
import { UserManagementApp } from '@tria-plc/iamui';
import type { DesignConfig, UserManagementSessionOptions } from '@tria-plc/iamui';
import '@tria-plc/iamui/style.css';
import Cookies from "js-cookie";
import { useCallback, useEffect, useRef } from "react";
import { createRoot, type Root } from "react-dom/client";
import { useNavigate } from "react-router-dom";
import { UserManagementApp } from "@tria-plc/iamui";
import { BASE_API_URL } from "@ema-platform/api";
import type {
DesignConfig,
UserManagementSessionOptions,
} from "@tria-plc/iamui";
import "@tria-plc/iamui/style.css";
const UM_OVERRIDES = `
.um-theme-light {
@@ -58,73 +62,73 @@ const UM_OVERRIDES = `
const UM_CONFIG: DesignConfig = {
brand: {
appName: 'Ethiopian Maritime Licence',
logoUrl: '/assets/emaLogo.jpg',
appName: "Ethiopian Maritime Licence",
logoUrl: "/assets/emaLogo.jpg",
},
colors: {
primary: '#2563eb',
sidebar: '#ffffff',
background: '#f8fafc',
foreground: '#1e293b',
border: '#e2e8f0',
mutedForeground: '#94a3b8',
card: '#ffffff',
primary: "#2563eb",
sidebar: "#ffffff",
background: "#f8fafc",
foreground: "#1e293b",
border: "#e2e8f0",
mutedForeground: "#94a3b8",
card: "#ffffff",
},
typography: {
fontFamily: 'Inter, ui-sans-serif, system-ui, sans-serif',
fontFamily: "Inter, ui-sans-serif, system-ui, sans-serif",
},
layout: {
userManagementView: 'classic',
sidebarBrandLabel: 'Ethiopian Maritime Authority',
sidebarBrandSublabel: 'User Management',
sidebarBackground: '#ffffff',
sidebarColor: '#1e293b',
sidebarMutedColor: '#94a3b8',
sidebarActiveBackground: '#eff6ff',
sidebarActiveColor: '#2563eb',
sidebarHoverBackground: '#f8fafc',
sidebarBorder: '#e2e8f0',
sidebarWidth: '280px',
sidebarCollapsedWidth: '80px',
modalAccentColor: '#2563eb',
modalHeaderBackground: '#f8fafc',
modalHeaderEditBackground: '#eff6ff',
modalIconBackground: '#eff6ff',
modalIconColor: '#2563eb',
modalTitleColor: '#1e293b',
modalFocusColor: '#2563eb',
modalSurface: '#ffffff',
userManagementView: "classic",
sidebarBrandLabel: "Ethiopian Maritime Authority",
sidebarBrandSublabel: "User Management",
sidebarBackground: "#ffffff",
sidebarColor: "#1e293b",
sidebarMutedColor: "#94a3b8",
sidebarActiveBackground: "#eff6ff",
sidebarActiveColor: "#2563eb",
sidebarHoverBackground: "#f8fafc",
sidebarBorder: "#e2e8f0",
sidebarWidth: "280px",
sidebarCollapsedWidth: "80px",
modalAccentColor: "#2563eb",
modalHeaderBackground: "#f8fafc",
modalHeaderEditBackground: "#eff6ff",
modalIconBackground: "#eff6ff",
modalIconColor: "#2563eb",
modalTitleColor: "#1e293b",
modalFocusColor: "#2563eb",
modalSurface: "#ffffff",
},
};
const UM_RUNTIME = {
basename: '/um',
basename: "/um",
// Keep the embedded IAM module on the same API as the backoffice client.
// When VITE_BASE_API_URL is absent locally, passing undefined makes iamui
// fall back to its remote development server, where the local JWT is
// rejected and the module redirects to its login page.
apiUrl: import.meta.env.VITE_BASE_API_URL ?? 'http://localhost:3000/api',
apiUrl: import.meta.env.VITE_BASE_API_URL ?? "http://localhost:3000/api",
};
const buttonStyle: React.CSSProperties = {
position: 'fixed',
position: "fixed",
top: 12,
left: 12,
zIndex: 9999,
display: 'flex',
alignItems: 'center',
display: "flex",
alignItems: "center",
gap: 6,
padding: '8px 16px',
border: '1px solid #e2e8f0',
padding: "8px 16px",
border: "1px solid #e2e8f0",
borderRadius: 8,
background: '#ffffff',
color: '#2563eb',
background: "#ffffff",
color: "#2563eb",
fontSize: 14,
fontWeight: 600,
cursor: 'pointer',
fontFamily: 'Inter, ui-sans-serif, system-ui, sans-serif',
boxShadow: '0 1px 3px rgba(0,0,0,0.08)',
transition: 'all 150ms ease',
cursor: "pointer",
fontFamily: "Inter, ui-sans-serif, system-ui, sans-serif",
boxShadow: "0 1px 3px rgba(0,0,0,0.08)",
transition: "all 150ms ease",
};
export default function UserManagementPage() {
@@ -133,29 +137,31 @@ export default function UserManagementPage() {
const navigate = useNavigate();
const handleReturn = useCallback(() => {
navigate('/dashboard');
navigate("/dashboard");
}, [navigate]);
useEffect(() => {
if (!containerRef.current) return;
const style = document.createElement('style');
const style = document.createElement("style");
style.textContent = UM_OVERRIDES;
document.head.appendChild(style);
const token = Cookies.get('ema-backoffice-auth-token') ?? '';
const refreshToken = Cookies.get('ema-backoffice-refresh-token');
const token = Cookies.get("ema-backoffice-auth-token") ?? "";
const refreshToken = Cookies.get("ema-backoffice-refresh-token");
const session: UserManagementSessionOptions = {
initialSession: token
? { token, refreshToken, rememberMe: true }
: null,
initialSession: token ? { token, refreshToken, rememberMe: true } : null,
enableEmbeddedAuthBridge: false,
};
rootRef.current = createRoot(containerRef.current);
rootRef.current.render(
<UserManagementApp config={UM_CONFIG} runtime={UM_RUNTIME} session={session} />,
<UserManagementApp
config={UM_CONFIG}
runtime={UM_RUNTIME}
session={session}
/>,
);
return () => {
@@ -173,21 +179,28 @@ export default function UserManagementPage() {
onClick={handleReturn}
style={buttonStyle}
onMouseEnter={(e) => {
e.currentTarget.style.background = '#f8fafc';
e.currentTarget.style.boxShadow = '0 1px 6px rgba(0,0,0,0.12)';
e.currentTarget.style.background = "#f8fafc";
e.currentTarget.style.boxShadow = "0 1px 6px rgba(0,0,0,0.12)";
}}
onMouseLeave={(e) => {
e.currentTarget.style.background = '#ffffff';
e.currentTarget.style.boxShadow = '0 1px 3px rgba(0,0,0,0.08)';
}}
>
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
e.currentTarget.style.background = "#ffffff";
e.currentTarget.style.boxShadow = "0 1px 3px rgba(0,0,0,0.08)";
}}>
<svg
width="16"
height="16"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round">
<path d="m12 19-7-7 7-7" />
<path d="M19 12H5" />
</svg>
Return to EMA
</button>
<div ref={containerRef} style={{ position: 'fixed', inset: 0 }} />
<div ref={containerRef} style={{ position: "fixed", inset: 0 }} />
</>
);
}

View File

@@ -88,11 +88,14 @@ export const am: Translations = {
btcQueue: "የBTC ወረፋ",
cocQueue: "የCoC ወረፋ",
copQueue: "የCoP ወረፋ",
endorsementCocQueue: "የCoC እውቅና ወረፋ",
endorsementGocQueue: "የGOC እውቅና ወረፋ",
endorsementQueue: "የማስተያየት ወረፋ",
vesselRegistrations: "የመርከብ ምዝገባ",
// vesselRegistrationReport: 'የምዝገባ ሪፖርት',
vesselTransfers: "የመርከብ ባለቤትነት ዝውውር",
seafarerRegistry: "የመርከበኞች መዝገብ",
biometricEnrollment: "ባዮሜትሪክ ምዝገባ",
seafarerRegistrationQueue: "የመርከበኞች ምዝገባ ወረፋ",
applications: "ማመልከቻዎች",
paymentConfig: "የክፍያ ውቅረት",
@@ -262,7 +265,6 @@ export const am: Translations = {
both: "ሁለቱም",
offline: "ከመስመር ውጪ",
online: "በመስመር",
onlineChoiceOnlyHint: "የመስመር ላይ ፈተናዎች በራስ-ሰር ይመዘገባሉ፣ ይህም ለምርጫ ጥያቄዎች ብቻ ይሰራል።",
sum: "ድምር",
average: "አማካይ",
percentage: "መቶኛ",
@@ -274,6 +276,8 @@ export const am: Translations = {
cuttingPointPercentageHint: "የመቶኛ ግምገማ — ከ100 አይበልጥም።",
fillRequiredBasic: "በመሠረታዊ መረጃ ውስጥ ያሉ አስፈላጊ መስኮችን ይሙሉ።",
fillRequiredSettings: "በቅንብሮች ውስጥ ያሉ አስፈላጊ መስኮችን ይሙሉ — ዓይነት፣ ቅጽ፣ የአስተዳደር ዘዴ፣ የግምገማ ዘዴ እና የማለፊያ ነጥብ።",
next: "ቀጣይ",
back: "ተመለስ",
directionBothLanguages: "መመሪያ በሁለቱም እንግሊዝኛ እና አማርኛ ጽሑፍ ያስፈልገዋል፣ ወይም ሁለቱንም ባዶ ይተዉ።",
status: "ሁኔታ",
statusPlaceholder: "የፈተና ሁኔታ",
@@ -390,6 +394,9 @@ export const am: Translations = {
notEnoughApproved: "ለዚህ ትምህርት በቂ የጸደቁ ጥያቄዎች የሉም።",
cannotReachCuttingPoint:
"ይህ ወረቀት የማለፊያ ነጥቡን ሊደርስ አይችልም (ከፍተኛ {{max}}፣ የማለፊያ ነጥብ {{cuttingPoint}})። ተጨማሪ ወይም ከፍ ያለ ነጥብ ያላቸው ጥያቄዎችን ጨምር፣ ወይም የማለፊያ ነጥቡን ቀንስ።",
paperLocked: "ወረቀቱ ተቆልፏል",
paperLockedHint:
"ለዚህ ፈተና {{count}} ተፈታኝ(ዎች) ተመዝግበዋል። ሁሉም ተፈታኞች አንድ ዓይነት ወረቀት መፈተን ስላለባቸው ጥያቄዎቹ ከዚህ በኋላ አይቀየሩም።",
},
country: {
@@ -660,6 +667,9 @@ export const am: Translations = {
seafarerPlaceholder: "መርከበኛ ይፈልጉ እና ይምረጡ",
scorePerQuestion: "በጥያቄ ውጤት",
question: "ጥያቄ",
candidateAnswer: "የተፈታኙ መልስ",
noAnswer: "የተመዘገበ መልስ የለም",
autoGraded: "በራስ-ሰር የተመዘነ",
maxPoints: "ከፍተኛ ውጤት",
score: "ውጤት",
remark: "ማስታወሻ",
@@ -875,6 +885,7 @@ export const am: Translations = {
DRAFT: "ረቂቅ",
SUBMITTED: "ቀርቧል",
UNDER_REVIEW: "በግምገማ ላይ",
AWAITING_BIOMETRICS: "ባዮሜትሪክ በመጠባበቅ ላይ",
UNDER_EVALUATION: "በምዘና ላይ",
RESUBMIT_REQUIRED: "እንደገና ማቅረብ ያስፈልጋል",
INSPECTION_PENDING: "ምርመራ በመጠባበቅ ላይ",
@@ -995,6 +1006,9 @@ export const am: Translations = {
morning: "ጠዋት",
afternoon: "ከሰዓት በኋላ",
schedule: "ያዝ",
reschedule: "አዛውር",
rescheduleReason: "ለምን ይዛወራል?",
rescheduleReasonHint: "በኦዲት መዝገብ ውስጥ ተይዞ ለአመልካቹ ይላካል።",
pickDate: "መጀመሪያ ቀን ይምረጡ",
passed: "አልፏል",
failed: "ወድቋል",
@@ -1021,7 +1035,6 @@ export const am: Translations = {
actions: {
claim: "ውሰድ",
assign: "መድብ",
assignReviewer: "ግምገማ መድብ",
reportReview: "ለቡድን መሪ አሳውቅ",
assignInspector: "ምርመራ መድብ",
reportInspection: "የምርመራ ውጤት አሳውቅ",
@@ -1032,6 +1045,7 @@ export const am: Translations = {
completeReview: "ግምገማ አጠናቅቅ",
approveDocuments: "ሰነዶችን አጽድቅ",
scheduleInspection: "ምርመራ ያዝ",
rescheduleInspection: "ምርመራ አዛውር",
recordInspection: "የምርመራ ውጤት መዝግብ",
finalApprove: "አጽድቅ እና ስጥ",
requestAdjustment: "ማስተካከያ ጠይቅ",
@@ -1185,6 +1199,7 @@ export const am: Translations = {
assign: "እንደገና ተመድቧል",
assignReviewer: "ግምገማ ተመድቧል",
scheduled: "ምርመራ ተይዟል",
rescheduled: "ምርመራ ተዛውሯል",
inspectionPassed: "ምርመራ አልፏል",
inspectionFailed: "ምርመራ ወድቋል",
},

View File

@@ -88,10 +88,13 @@ export const en = {
postWaiverQueue: 'Post-Waiver Queue',
cocQueue: 'CoC Queue',
copQueue: 'CoP Queue',
endorsementCocQueue: 'CoC Endorsement Queue',
endorsementGocQueue: 'GOC Endorsement Queue',
endorsementQueue: 'Endorsement Queue',
vesselRegistrations: 'Vessel Registration',
vesselTransfers: 'Vessel Ownership Transfer',
seafarerRegistry: 'Seafarer Registry',
biometricEnrollment: 'Biometric Enrollment',
seafarerRegistrationQueue: 'Seafarer Registration Queue',
applications: 'Applications',
paymentConfig: 'Payment Config',
@@ -261,7 +264,6 @@ export const en = {
both: 'Both',
offline: 'Offline',
online: 'Online',
onlineChoiceOnlyHint: 'Online exams are graded automatically, which only works for multiple choice.',
sum: 'Sum',
average: 'Average',
percentage: 'Percentage',
@@ -274,6 +276,8 @@ export const en = {
fillRequiredBasic: 'Please fill all required fields in Basic Info.',
fillRequiredSettings: 'Please fill all required fields in Settings — type, form, administration method, evaluation method, and cutting point.',
directionBothLanguages: 'Direction needs text in both English and Amharic, or leave both empty.',
next: 'Next',
back: 'Back',
status: 'Status',
statusPlaceholder: 'Exam status',
pending: 'Pending',
@@ -389,6 +393,9 @@ export const en = {
'Not enough approved questions in the bank for this subject.',
cannotReachCuttingPoint:
'This paper cannot reach the passing mark (max {{max}}, pass mark {{cuttingPoint}}). Add more/higher-point questions, or lower the cutting point.',
paperLocked: 'Paper locked',
paperLockedHint:
'{{count}} candidate(s) have registered for this session. Every candidate must sit the same paper, so questions can no longer be changed.',
},
country: {
@@ -660,6 +667,9 @@ export const en = {
seafarerPlaceholder: 'Search and select a seafarer',
scorePerQuestion: 'Score per Question',
question: 'Question',
candidateAnswer: "Candidate's Answer",
noAnswer: 'No answer on file',
autoGraded: 'Auto-graded',
maxPoints: 'Max Points',
score: 'Score',
remark: 'Remark',
@@ -882,6 +892,7 @@ export const en = {
DRAFT: 'Draft',
SUBMITTED: 'Submitted',
UNDER_REVIEW: 'Under Review',
AWAITING_BIOMETRICS: 'Awaiting Biometrics',
UNDER_EVALUATION: 'Under Evaluation',
RESUBMIT_REQUIRED: 'Resubmit Required',
INSPECTION_PENDING: 'Inspection Pending',
@@ -1004,6 +1015,9 @@ export const en = {
morning: 'Morning',
afternoon: 'Afternoon',
schedule: 'Schedule',
reschedule: 'Reschedule',
rescheduleReason: 'Why is it moving?',
rescheduleReasonHint: 'Kept in the audit trail and sent to the applicant.',
pickDate: 'Pick a date first',
passed: 'Passed',
failed: 'Failed',
@@ -1030,7 +1044,6 @@ export const en = {
actions: {
claim: 'Claim',
assign: 'Assign',
assignReviewer: 'Assign review',
reportReview: 'Report to team leader',
assignInspector: 'Assign inspection',
reportInspection: 'Report inspection result',
@@ -1041,6 +1054,7 @@ export const en = {
completeReview: 'Complete review',
approveDocuments: 'Approve documents',
scheduleInspection: 'Schedule inspection',
rescheduleInspection: 'Reschedule inspection',
recordInspection: 'Record inspection result',
finalApprove: 'Approve & issue',
requestAdjustment: 'Request adjustment',
@@ -1191,6 +1205,7 @@ export const en = {
assign: 'Reassigned',
assignReviewer: 'Review assigned',
scheduled: 'Inspection scheduled',
rescheduled: 'Inspection rescheduled',
inspectionPassed: 'Inspection passed',
inspectionFailed: 'Inspection failed',
},

View File

@@ -10,6 +10,7 @@ import {
IconCreditCard,
IconFileDescription,
IconFilePlus,
IconFingerprint,
IconGauge,
IconGavel,
IconHeart,
@@ -27,9 +28,9 @@ import {
IconTruck,
IconUsers,
IconUserShield,
} from '@tabler/icons-react';
import type { NavSection } from '@ema-platform/ui';
import { LICENSE_PERMISSIONS as P } from '@ema-platform/auth';
} from "@tabler/icons-react";
import type { NavSection } from "@ema-platform/ui";
import { LICENSE_PERMISSIONS as P } from "@ema-platform/auth";
/**
* Every licence-type queue and its review workspace share one gate: the
@@ -37,6 +38,18 @@ import { LICENSE_PERMISSIONS as P } from '@ema-platform/auth';
*/
const APPLICATION_QUEUE = [P.VIEW_APPLICATION_QUEUE, P.VIEW_APPLICATIONS];
/**
* Seafarer queues are reachable by registry staff and application reviewers
* alike, so both permission families gate them as any-of.
*/
const SEAFARER_QUEUE = [P.VIEW_SEAFARER_REGISTRY, ...APPLICATION_QUEUE];
const BIOMETRIC_ENROLLMENT = [
P.ENROLL_BIOMETRICS,
P.VIEW_BIOMETRICS,
P.VIEW_SEAFARER_REGISTRY,
];
/**
* The backoffice information architecture.
*
@@ -52,116 +65,255 @@ const APPLICATION_QUEUE = [P.VIEW_APPLICATION_QUEUE, P.VIEW_APPLICATIONS];
*/
export const NAV_SECTIONS: NavSection[] = [
{
items: [{ to: '/dashboard', label: 'nav.dashboard', icon: IconLayoutDashboard }],
items: [
{ to: "/dashboard", label: "nav.dashboard", icon: IconLayoutDashboard },
],
},
{
label: 'nav.groupLicensing',
label: "nav.groupLicensing",
items: [
{
to: '/licence-review',
label: 'nav.allApplications',
to: "/licence-review",
label: "nav.allApplications",
icon: IconListCheck,
permissions: APPLICATION_QUEUE,
},
{
// A disclosure, not a destination — each child deep-links the grid to
// one type, which is a facet of the same workspace.
label: 'nav.byType',
label: "nav.byType",
icon: IconTruck,
permissions: APPLICATION_QUEUE,
children: [
{ to: '/licence-review/type/FREIGHT_FORWARDER', label: 'nav.typeFreightForwarder', icon: IconTruck, permissions: APPLICATION_QUEUE },
{ to: '/licence-review/type/SHIPPING_AGENT', label: 'nav.typeShippingAgent', icon: IconShip, permissions: APPLICATION_QUEUE },
{ to: '/licence-review/type/COMBINED_SA_FF', label: 'nav.typeCombined', icon: IconFileDescription, permissions: APPLICATION_QUEUE },
{ to: '/licence-review/type/JOINT_INVESTOR', label: 'nav.typeJointInvestment', icon: IconUsers, permissions: APPLICATION_QUEUE },
{ to: '/licence-review/type/MULTIMODAL_TRANSPORT_OPERATOR', label: 'nav.typeMto', icon: IconAnchor, permissions: APPLICATION_QUEUE },
{
to: "/licence-review/type/FREIGHT_FORWARDER",
label: "nav.typeFreightForwarder",
icon: IconTruck,
permissions: APPLICATION_QUEUE,
},
{
to: "/licence-review/type/SHIPPING_AGENT",
label: "nav.typeShippingAgent",
icon: IconShip,
permissions: APPLICATION_QUEUE,
},
{
to: "/licence-review/type/COMBINED_SA_FF",
label: "nav.typeCombined",
icon: IconFileDescription,
permissions: APPLICATION_QUEUE,
},
{
to: "/licence-review/type/JOINT_INVESTOR",
label: "nav.typeJointInvestment",
icon: IconUsers,
permissions: APPLICATION_QUEUE,
},
{
to: "/licence-review/type/MULTIMODAL_TRANSPORT_OPERATOR",
label: "nav.typeMto",
icon: IconAnchor,
permissions: APPLICATION_QUEUE,
},
],
},
{
to: '/licence-register',
label: 'nav.licenceRegister',
to: "/licence-register",
label: "nav.licenceRegister",
icon: IconListCheck,
permissions: [P.VIEW_LICENSES],
},
{ to: '/licence-review/type/PRE_WAIVER', label: 'nav.preWaiverQueue', icon: IconShieldOff, permissions: APPLICATION_QUEUE },
{ to: '/licence-review/type/POST_WAIVER', label: 'nav.postWaiverQueue', icon: IconShieldOff, permissions: APPLICATION_QUEUE },
{
to: "/licence-review/type/PRE_WAIVER",
label: "nav.preWaiverQueue",
icon: IconShieldOff,
permissions: APPLICATION_QUEUE,
},
{
to: "/licence-review/type/POST_WAIVER",
label: "nav.postWaiverQueue",
icon: IconShieldOff,
permissions: APPLICATION_QUEUE,
},
{
// Every figure on it is derived from the licence application queue.
to: '/logistics-head-dashboard',
label: 'nav.logisticsHeadDashboard',
to: "/logistics-head-dashboard",
label: "nav.logisticsHeadDashboard",
icon: IconGauge,
permissions: APPLICATION_QUEUE,
},
],
},
{
label: 'nav.groupSeafarer',
label: "nav.groupSeafarer",
items: [
{ to: '/seafarer-registry', label: 'nav.seafarerRegistry', icon: IconUsers, permissions: [P.VIEW_SEAFARER_REGISTRY] },
{ to: '/seafarer-registrations', label: 'nav.seafarerRegistrationQueue', icon: IconId, permissions: [P.VIEW_SEAFARER_REGISTRY] },
{ to: '/licence-review/type/CERTIFICATE_OF_COMPETENCY', label: 'nav.cocQueue', icon: IconShieldCheck, permissions: [P.VIEW_SEAFARER_REGISTRY] },
{ to: '/licence-review/type/CERTIFICATE_OF_PROFICIENCY', label: 'nav.copQueue', icon: IconShieldCheck, permissions: [P.VIEW_SEAFARER_REGISTRY] },
{ to: '/seaman-book-queue', label: 'nav.seamanBookQueue', icon: IconBook2, permissions: [P.VIEW_SEAFARER_REGISTRY] },
{ to: '/btc-queue', label: 'nav.btcQueue', icon: IconShieldCheck, permissions: [P.VIEW_SEAFARER_REGISTRY] },
{ to: '/licence-review/type/ENDORSEMENT_COC', label: 'nav.endorsementCocQueue', icon: IconRubberStamp, permissions: [P.VIEW_SEAFARER_REGISTRY] },
{ to: '/licence-review/type/ENDORSEMENT_GOC', label: 'nav.endorsementGocQueue', icon: IconRubberStamp, permissions: [P.VIEW_SEAFARER_REGISTRY] },
{ to: '/seafarer-registrations', label: 'nav.seafarerRegistrationQueue', icon: IconId, permissions: APPLICATION_QUEUE },
{ to: '/licence-review/type/CERTIFICATE_OF_COMPETENCY', label: 'nav.cocQueue', icon: IconShieldCheck, permissions: APPLICATION_QUEUE },
{ to: '/licence-review/type/CERTIFICATE_OF_PROFICIENCY', label: 'nav.copQueue', icon: IconShieldCheck, permissions: APPLICATION_QUEUE },
{ to: '/seaman-book-queue', label: 'nav.seamanBookQueue', icon: IconBook2, permissions: APPLICATION_QUEUE },
{ to: '/btc-queue', label: 'nav.btcQueue', icon: IconShieldCheck, permissions: APPLICATION_QUEUE },
{ to: '/licence-review/type/ENDORSEMENT_SEAFARER', label: 'nav.endorsementQueue', icon: IconRubberStamp, permissions: APPLICATION_QUEUE },
{ to: '/sea-service-verification', label: 'nav.seaServiceVerification', icon: IconAnchor, permissions: [P.VERIFY_SEAFARER_RECORDS] },
{ to: '/medical-verification', label: 'nav.medicalVerification', icon: IconHeart, permissions: [P.VERIFY_SEAFARER_RECORDS] },
],
},
{
label: 'nav.groupVessels',
items: [
{ to: '/vessel-registration-report', label: 'nav.vesselRegistrationReport', icon: IconChartBar, permissions: [P.VIEW_VESSEL_REGISTRY] },
{ to: '/vessel-registration-queue', label: 'nav.vesselRegistrationQueue', icon: IconAnchor, permissions: [P.VIEW_VESSEL_REGISTRY] },
{ to: '/licence-review/type/VESSEL_REGISTRATION', label: 'nav.vesselRegistrationApplicationQueue', icon: IconAnchor, permissions: [P.VIEW_VESSEL_REGISTRY] },
{ to: '/licence-review/type/VESSEL_OWNERSHIP_TRANSFER', label: 'nav.ownershipTransferQueue', icon: IconArrowsExchange, permissions: [P.VIEW_VESSEL_REGISTRY] },
{ to: '/vessel-registration-queue/new', label: 'nav.vesselFormBuilder', icon: IconFilePlus, soon: true, permissions: [P.VIEW_VESSEL_REGISTRY] },
],
},
{
label: 'nav.groupExaminations',
items: [
{ to: '/questions', label: 'nav.questions', icon: IconQuestionMark, permissions: [P.APPROVE_QUESTION, P.AUTHOR_QUESTION] },
{
to: '/exams',
label: 'nav.exams',
to: "/seafarer-registry",
label: "nav.seafarerRegistry",
icon: IconUsers,
permissions: [P.VIEW_SEAFARER_REGISTRY],
},
{
to: "/biometric-enrollment",
label: "nav.biometricEnrollment",
icon: IconFingerprint,
permissions: BIOMETRIC_ENROLLMENT,
},
{
to: "/seafarer-registrations",
label: "nav.seafarerRegistrationQueue",
icon: IconId,
permissions: SEAFARER_QUEUE,
},
{
to: "/licence-review/type/CERTIFICATE_OF_COMPETENCY",
label: "nav.cocQueue",
icon: IconShieldCheck,
permissions: SEAFARER_QUEUE,
},
{
to: "/licence-review/type/CERTIFICATE_OF_PROFICIENCY",
label: "nav.copQueue",
icon: IconShieldCheck,
permissions: SEAFARER_QUEUE,
},
{
to: "/seaman-book-queue",
label: "nav.seamanBookQueue",
icon: IconBook2,
permissions: SEAFARER_QUEUE,
},
{
to: "/btc-queue",
label: "nav.btcQueue",
icon: IconShieldCheck,
permissions: SEAFARER_QUEUE,
},
{
to: "/licence-review/type/ENDORSEMENT_COC",
label: "nav.endorsementCocQueue",
icon: IconRubberStamp,
permissions: [P.VIEW_SEAFARER_REGISTRY],
},
{
to: "/licence-review/type/ENDORSEMENT_GOC",
label: "nav.endorsementGocQueue",
icon: IconRubberStamp,
permissions: [P.VIEW_SEAFARER_REGISTRY],
},
{
to: "/licence-review/type/ENDORSEMENT_SEAFARER",
label: "nav.endorsementQueue",
icon: IconRubberStamp,
permissions: APPLICATION_QUEUE,
},
{
to: "/sea-service-verification",
label: "nav.seaServiceVerification",
icon: IconAnchor,
permissions: [P.VERIFY_SEAFARER_RECORDS],
},
{
to: "/medical-verification",
label: "nav.medicalVerification",
icon: IconHeart,
permissions: [P.VERIFY_SEAFARER_RECORDS],
},
],
},
{
label: "nav.groupVessels",
items: [
{
to: "/vessel-registration-report",
label: "nav.vesselRegistrationReport",
icon: IconChartBar,
permissions: [P.VIEW_VESSEL_REGISTRY],
},
{
to: "/vessel-registration-queue",
label: "nav.vesselRegistrationQueue",
icon: IconAnchor,
permissions: [P.VIEW_VESSEL_REGISTRY],
},
{
to: "/licence-review/type/VESSEL_REGISTRATION",
label: "nav.vesselRegistrationApplicationQueue",
icon: IconAnchor,
permissions: [P.VIEW_VESSEL_REGISTRY],
},
{
to: "/licence-review/type/VESSEL_OWNERSHIP_TRANSFER",
label: "nav.ownershipTransferQueue",
icon: IconArrowsExchange,
permissions: [P.VIEW_VESSEL_REGISTRY],
},
{
to: "/vessel-registration-queue/new",
label: "nav.vesselFormBuilder",
icon: IconFilePlus,
soon: true,
permissions: [P.VIEW_VESSEL_REGISTRY],
},
],
},
{
label: "nav.groupExaminations",
items: [
{
to: "/questions",
label: "nav.questions",
icon: IconQuestionMark,
permissions: [P.APPROVE_QUESTION, P.AUTHOR_QUESTION],
},
{
to: "/exams",
label: "nav.exams",
icon: IconClipboardList,
permissions: [P.MANAGE_EXAMS, P.RECORD_EXAM_ATTENDANCE, P.MANAGE_EXAM_INCIDENTS, P.PUBLISH_EXAM_RESULT],
permissions: [
P.MANAGE_EXAMS,
P.RECORD_EXAM_ATTENDANCE,
P.MANAGE_EXAM_INCIDENTS,
P.PUBLISH_EXAM_RESULT,
],
},
{
to: '/exam-results',
label: 'nav.examResults',
to: "/exam-results",
label: "nav.examResults",
icon: IconReport,
permissions: [P.RECORD_EXAM_RESULT, P.MODERATE_EXAM_RESULT, P.APPROVE_EXAM_RESULT, P.PUBLISH_EXAM_RESULT],
permissions: [
P.RECORD_EXAM_RESULT,
P.MODERATE_EXAM_RESULT,
P.APPROVE_EXAM_RESULT,
P.PUBLISH_EXAM_RESULT,
],
},
{
to: "/exam-appeals",
label: "nav.examAppeals",
icon: IconGavel,
permissions: [P.DECIDE_EXAM_APPEAL],
},
{ to: '/exam-appeals', label: 'nav.examAppeals', icon: IconGavel, permissions: [P.DECIDE_EXAM_APPEAL] },
],
},
{
label: 'nav.groupShared',
label: "nav.groupShared",
items: [
{
to: '/certificate-designer',
label: 'nav.certificateDesigner',
to: "/certificate-designer",
label: "nav.certificateDesigner",
icon: IconRosetteDiscountCheck,
permissions: [P.VIEW_TEMPLATES],
},
{
to: '/certificate-requirements',
label: 'nav.certificateRequirements',
to: "/certificate-requirements",
label: "nav.certificateRequirements",
icon: IconClipboardText,
permissions: [P.VIEW_LICENSE_TYPES],
},
{
to: '/payment-config',
label: 'nav.paymentConfig',
to: "/payment-config",
label: "nav.paymentConfig",
icon: IconCreditCard,
permissions: [P.VIEW_PAYMENTS],
},
@@ -180,18 +332,27 @@ export const NAV_SECTIONS: NavSection[] = [
],
},
{
label: 'nav.groupAdministration',
label: "nav.groupAdministration",
items: [
{ to: '/um/user-management/dashboard', label: 'nav.userManagement', icon: IconUserShield },
{
to: "/um/user-management/dashboard",
label: "nav.userManagement",
icon: IconUserShield,
},
{
// Professions, locations and certifications have no dedicated keys;
// the config-view keys are the closest published contract.
to: '/configuration',
label: 'nav.configuration',
to: "/configuration",
label: "nav.configuration",
icon: IconSettings,
permissions: [P.VIEW_LICENSE_TYPES, P.VIEW_TEMPLATES],
},
{ to: '/analytics', label: 'nav.analytics', icon: IconChartBar, soon: true },
{
to: "/analytics",
label: "nav.analytics",
icon: IconChartBar,
soon: true,
},
],
},
// `/profile` deliberately absent: it is a property of the signed-in user,

View File

@@ -50,10 +50,17 @@ import { LicenseReviewPage } from '../features/license-review/pages/LicenseRevie
import { LogisticsHeadDashboardPage } from '../features/logistics-head/pages/LogisticsHeadDashboardPage';
import { CertificateDesignerPage } from '../features/certificate-designer/pages/CertificateDesignerPage';
import { CertificateRequirementsPage } from '../features/certificate-requirements/pages/CertificateRequirementsPage';
import { BiometricEnrollmentPage } from '../features/biometric-enrollment/pages/BiometricEnrollmentPage';
/** Any-of gate shared by every licence-type queue and its review workspace. */
const APPLICATION_QUEUE = [P.VIEW_APPLICATION_QUEUE, P.VIEW_APPLICATIONS];
const BIOMETRIC_ENROLLMENT = [
P.ENROLL_BIOMETRICS,
P.VIEW_BIOMETRICS,
P.VIEW_SEAFARER_REGISTRY,
];
/** Route gate: same keys as the route's nav item in nav-config.ts. */
const guard = (anyOf: string[], element: ReactNode) => (
<RequirePermission anyOf={anyOf}>{element}</RequirePermission>
@@ -104,6 +111,7 @@ const router = createBrowserRouter([
{ path: 'pickup-desk', element: guard([P.MANAGE_PICKUP_DESK], <PickupDeskPage />) },
{ path: 'pickup-offices', element: guard([P.CONFIGURE_PICKUP_OFFICES], <PickupOfficesPage />) },
{ path: 'seafarer-registry', element: guard([P.VIEW_SEAFARER_REGISTRY], <SeafarerRegistryPage />) },
{ path: 'biometric-enrollment', element: guard(BIOMETRIC_ENROLLMENT, <BiometricEnrollmentPage />) },
// Seafarer registration is not a licence: own queue, own review.
{ path: 'seafarer-registrations', element: guard(APPLICATION_QUEUE, <SeafarerRegistrationQueuePage />) },
{ path: 'seafarer-registrations/:id', element: guard(APPLICATION_QUEUE, <SeafarerRegistrationReviewPage />) },

View File

@@ -17,7 +17,7 @@ export default defineConfig({
// port: 4201,
// proxy: {
// '/api': {
// target: 'http://localhost:3000', // change from 'https://ema-api-dev.triaplc.com'
// target: 'http://localhost:3001', // change from 'https://ema-api-dev.triaplc.com'
// changeOrigin: true,
// },
// },
@@ -42,14 +42,14 @@ export default defineConfig({
emptyOutDir: true,
reportCompressedSize: true,
},
// Unit tests for the pure helpers behind a screen (formatters, URL state).
// Component tests are deliberately not set up: nothing here renders React,
// so no jsdom environment or setup file is needed.
// test: {
// watch: false,
// globals: true,
// environment: 'node',
// include: ['src/**/*.spec.ts'],
// reporters: ['default'],
// },
// Unit tests for the pure helpers behind a screen (formatters, URL state,
// queue views). Component tests are deliberately not set up: nothing here
// renders React, so no jsdom environment or setup file is needed.
test: {
watch: false,
globals: true,
environment: 'node',
include: ['src/**/*.spec.ts', 'src/**/*.test.ts'],
reporters: ['default'],
},
});

View File

@@ -22,7 +22,7 @@ own ports, against its own database:
| Backoffice | 4303 | same |
| Database | — | `ema_e2e` |
This is deliberate. A developer's stack is usually already up on 3000/4200/4201,
This is deliberate. A developer's stack is usually already up on 3000/3001/4201,
and `dev/start.sh` **rewrites** `emaapi/apps/server/emaapi/.env` and the apps'
`.env.local` on every run — a suite that read those files would point at
whichever stack was started last. The API is launched with `DATABASE_NAME`,

View File

@@ -120,9 +120,7 @@ function formatDate(value: string | null | undefined): string {
});
}
const API_BASE =
(import.meta as { env?: Record<string, string> }).env?.['VITE_BASE_API_URL'] ??
'http://localhost:3000/api';
import { BASE_API_URL as API_BASE } from '@ema-platform/api';
async function generateCertificate(profileId: string): Promise<Blob> {
const token = authStorage.getToken();

View File

@@ -9,6 +9,9 @@ import type {
SaveState,
} from '../types/exam-attempt';
/** Mirrors the server's allow-list (ExamAttemptService.MAY_SIT). */
const MAY_SIT = ['PRESENT', 'LATE'];
const ESSAY_DEBOUNCE_MS = 1500;
type ViewState = 'loading' | 'not-started' | 'taking' | 'completed' | 'error';
@@ -83,6 +86,19 @@ export function useExamAttempt(examId: string | undefined) {
setErrorMessage('You are not registered for this examination.');
return;
}
// Attendance gates the sitting, and the exam list already hides "Take
// exam" for these — this is the direct-link path. The server refuses
// either way (ExamAttemptService.MAY_SIT); this only makes the refusal
// legible instead of a raw error key on a Start button that never works.
if (!MAY_SIT.includes(registration.attendanceStatus)) {
setViewState('error');
setErrorMessage(
registration.attendanceStatus === 'REGISTERED'
? 'An invigilator must confirm you are present before this exam opens.'
: 'Your attendance record does not permit sitting this examination.',
);
return;
}
if (mineData) {
seedFrom(mineData);
return;
@@ -200,7 +216,14 @@ export function useExamAttempt(examId: string | undefined) {
}).unwrap();
seedFrom(result);
} catch (error) {
notify.error(extractErrorMessage(error, 'Could not start the exam.'));
const key = extractErrorMessage(error, 'Could not start the exam.');
notify.error(
key === 'attendance_not_confirmed'
? 'An invigilator must confirm you are present before this exam opens.'
: key === 'candidate_not_present'
? 'Your attendance record does not permit sitting this examination.'
: key,
);
}
}, [examId, startTrigger, seedFrom]);

View File

@@ -1,4 +1,4 @@
import { Badge, Button, Text } from '@mantine/core';
import { Badge, Button, Text, Tooltip } from '@mantine/core';
import { IconFileText, IconGavel, IconPlayerPlay } from '@tabler/icons-react';
import type { TFunction } from 'i18next';
import type { AdvancedColumn } from '@ema-platform/ui';
@@ -20,7 +20,14 @@ const ATTENDANCE_COLOR: Record<AttendanceStatus, string> = {
DISQUALIFIED: 'red',
};
const NOT_SITTING: AttendanceStatus[] = ['ABSENT', 'WITHDRAWN', 'DISQUALIFIED'];
/**
* Attendance rulings that permit sitting the paper — an allow-list mirroring
* the server's (ExamAttemptService.MAY_SIT). The deny-list this replaced named
* only ABSENT/WITHDRAWN/DISQUALIFIED, so the default REGISTERED fell through
* and "Take exam" appeared before any invigilator had confirmed the candidate
* was there. LATE counts: a late arrival is present, just not on time.
*/
const MAY_SIT: AttendanceStatus[] = ['PRESENT', 'LATE'];
export function registrationColumns(
t: TFunction,
@@ -115,9 +122,26 @@ export function registrationColumns(
</Badge>
);
}
const eligible =
exam?.status === 'ACTIVE' && !NOT_SITTING.includes(row.original.attendanceStatus);
if (!eligible || !deps.can([PORTAL_PERMISSIONS.APPLY_EXAM])) return null;
if (!deps.can([PORTAL_PERMISSIONS.APPLY_EXAM])) return null;
// Say why the exam is shut rather than rendering an empty cell: the
// candidate is waiting on an invigilator, and silence reads as a bug.
if (row.original.attendanceStatus === 'REGISTERED') {
return (
<Tooltip label={t('exams.columns.awaitingAttendanceHint')} multiline w={240}>
<Badge size="sm" variant="light" color="gray">
{t('exams.columns.awaitingAttendance')}
</Badge>
</Tooltip>
);
}
if (!MAY_SIT.includes(row.original.attendanceStatus)) {
return (
<Badge size="sm" variant="light" color="orange">
{t('exams.columns.notSitting')}
</Badge>
);
}
if (exam?.status !== 'ACTIVE') return null;
return (
<Button
size="compact-xs"

View File

@@ -37,6 +37,12 @@ interface Props {
flagged?: Record<string, string>;
/** When set, only flagged slots accept a new upload. */
restrictToFlagged?: boolean;
/**
* Requirement keys opened because a flagged section drives their condition —
* a category correction can make documents newly required, and those have to
* be uploadable even though the officer flagged no document.
*/
alsoUnlocked?: string[];
onUploaded: () => void;
readOnly?: boolean;
}
@@ -55,6 +61,7 @@ export function DocumentSlots({
ownerId,
flagged = {},
restrictToFlagged = false,
alsoUnlocked = [],
onUploaded,
readOnly,
}: Props) {
@@ -106,7 +113,11 @@ export function DocumentSlots({
const uploaded = Boolean(existing?.files?.length);
const fileUrl = existing?.files?.[0]?.url;
const flagRemark = flagged[requirement.key];
const locked = readOnly || (restrictToFlagged && !flagRemark);
const locked =
readOnly ||
(restrictToFlagged &&
!flagRemark &&
!alsoUnlocked.includes(requirement.key));
return (
<Card

View File

@@ -115,6 +115,11 @@ export function LicenseCard({
// only for a cached response from before those fields existed.
const days = license.daysUntilExpiry ?? daysUntil(license.expiryDate);
const expired = license.status === 'EXPIRED' || days < 0;
// Suspended, cancelled and superseded are none of them "valid until" their
// expiry date — the card used to say exactly that, because only EXPIRED was
// treated as not-current. A suspended licence read as a live one with a grey
// badge.
const current = license.status === 'ACTIVE' && !expired;
const renewable = license.renewable ?? false;
const reissuable = license.reissuable ?? false;
const showDate = useDateDisplayer();
@@ -135,9 +140,15 @@ export function LicenseCard({
<Badge
size="sm"
variant="light"
color={expired ? 'red' : license.status === 'ACTIVE' ? 'teal' : 'gray'}
color={expired ? 'red' : current ? 'teal' : 'gray'}
>
{expired ? t('licensing.card.expired') : license.status}
{/* The raw enum was rendered here, so an Amharic page showed
"SUSPENDED" among otherwise translated text. */}
{expired
? t('licensing.card.expired')
: t(`licensing.card.status.${license.status}`, {
defaultValue: license.status,
})}
</Badge>
</Group>
@@ -148,8 +159,19 @@ export function LicenseCard({
<Text size="sm" fw={500}>
{expired
? t('licensing.card.expiredOn', { date: showDate(license.expiryDate) })
: t('licensing.card.validUntil', { date: showDate(license.expiryDate) })}
: current
? t('licensing.card.validUntil', { date: showDate(license.expiryDate) })
: t(`licensing.card.status.${license.status}`, {
defaultValue: license.status,
})}
</Text>
{/* Why it stopped being current. The API returns it; the card threw
it away, leaving the holder to guess. */}
{!current && license.statusReason && (
<Text size="xs" c="dimmed" mt={2}>
{t('licensing.card.statusReason', { reason: license.statusReason })}
</Text>
)}
</Box>
<RequirePermission
anyOf={[

View File

@@ -1,6 +1,7 @@
import { useMemo, useState } from 'react';
import { useNavigate } from 'react-router-dom';
import {
Alert,
Anchor,
Badge,
Box,
@@ -20,6 +21,7 @@ import {
IconBuildingWarehouse,
IconChevronRight,
IconFileText,
IconLock,
IconShieldOff,
IconShip,
IconTrendingUp,
@@ -45,9 +47,9 @@ const CATEGORY_ICONS: Record<LicenseCategory, typeof IconShip> = {
CARGO_FREIGHT: IconBuildingWarehouse,
SHIPPING_AGENCY: IconShip,
INVESTMENT: IconTrendingUp,
// The three below are filtered out of this catalogue today
// (requiresOperatorMode is false for all of them), and are listed only so
// the record stays total if that ever changes.
// The three below appear only when the applicant has declared a licence
// type in them (see the family filter below); listed here so the record
// stays total either way.
MARITIME_PERSONNEL: IconShip,
VESSEL_SERVICES: IconAnchor,
WAIVER_SERVICES: IconShieldOff,
@@ -81,15 +83,19 @@ export function LicenseCatalogue() {
const { groups, orphans } = useMemo(() => {
const active = (types?.items ?? [])
.filter((t) => t.isActive)
// Logistics licences only: this is the operator catalogue, not the
// seafarer certificate or vessel/seafarer document catalogue — those
// have their own entry points. `familyKind` is the real data-model
// classification (set on the type at seed time); `requiresOperatorMode`
// was the proxy this used before that column existed and happened to
// agree for every type seeded so far, but a type can only be trusted to
// stay in sync with the catalogue it belongs in if the catalogue reads
// its actual family instead of a flag with a different purpose.
.filter((t) => t.familyKind === 'LOGISTICS_LICENSE')
// The logistics family, plus whatever this applicant actually declared.
//
// `familyKind` is the real data-model classification and is what keeps
// browse-all to the operator catalogue rather than every certificate and
// document type in the system. But it is not what decides eligibility:
// the Operations tab also offers the personal registrations (seafarer,
// vessel) and the seafarer endorsement, which are DOCUMENT/CERTIFICATE
// family, so an applicant who declared one of those was shown an empty
// catalogue — allowed to file, and offered nothing to file. Each of
// those keys already has an entry point at `/licensing/<key>/apply`
// (a router redirect for SEAFARER_REGISTRATION and SEAMAN_BOOK, the
// generic wizard for the rest), so the card leads somewhere real.
.filter((t) => t.familyKind === 'LOGISTICS_LICENSE' || declared.has(t.id))
// Only what the applicant operates as. The server enforces the same rule
// on create; this is what stops them starting an application they will
// be refused at the end of.
@@ -273,8 +279,8 @@ function LicenseTypeCard({
withBorder
radius="md"
padding="md"
style={{ cursor: 'pointer', height: '100%' }}
onClick={() => onSelect(type)}
style={{ cursor: canApply ? 'pointer' : 'default', height: '100%' }}
onClick={canApply ? () => onSelect(type) : undefined}
>
<Stack gap="xs" justify="space-between" h="100%">
<Box>
@@ -282,11 +288,19 @@ function LicenseTypeCard({
<Text fw={600} size="sm" lh={1.35}>
{localized(type.name)}
</Text>
<IconChevronRight
size={16}
color="var(--mantine-color-dimmed)"
style={{ flexShrink: 0, marginTop: 2 }}
/>
{canApply ? (
<IconChevronRight
size={16}
color="var(--mantine-color-dimmed)"
style={{ flexShrink: 0, marginTop: 2 }}
/>
) : (
<IconLock
size={16}
color="var(--mantine-color-dimmed)"
style={{ flexShrink: 0, marginTop: 2 }}
/>
)}
</Group>
{type.description && (
<Text size="xs" c="dimmed" mt={6} lineClamp={3}>
@@ -325,13 +339,45 @@ function LicenseTypeCard({
mt="sm"
size="xs"
variant="light"
color={canApply ? undefined : 'gray'}
disabled={!canApply}
rightSection={<IconArrowRight size={14} />}
>
{canApply
? t('licensing.catalogue.startApplication')
: t('licensing.catalogue.addToOperations')}
{t('licensing.catalogue.startApplication')}
</Button>
{/* A disabled button on its own only says "no". This says why, and
where to go about it — the licence is offered against a declared
mode of operation, and the server refuses a create for one the
applicant has not declared. Called out rather than set in dimmed
small print: it is the only thing on a locked card the applicant
can act on. */}
{!canApply && (
<Alert
variant="light"
color="orange"
radius="md"
mt="sm"
p="xs"
>
<Text size="xs" lh={1.4}>
{t('licensing.catalogue.lockedHint')}
</Text>
<Anchor
size="xs"
fw={600}
component="button"
type="button"
mt={4}
onClick={(event) => {
// The card is inert while locked, but the anchor inside it
// must not re-trigger anything if that ever changes.
event.stopPropagation();
onSelect(type);
}}
>
{t('licensing.catalogue.addToOperations')}
</Anchor>
</Alert>
)}
</RequirePermission>
</Box>
</Stack>

View File

@@ -33,6 +33,8 @@ import { useTranslation } from "react-i18next";
import {
buildWizardSteps,
conditionHolds,
conditionSections,
sectionsDependingOn,
extractErrorMessage,
extractValidationIssues,
useLocalized,
@@ -360,14 +362,44 @@ export function LicenseApplicationPage() {
),
[roundRemarks],
);
const hasSectionRemarks = Object.keys(flaggedSections).length > 0;
const hasDocRemarks = Object.keys(flaggedDocuments).length > 0;
const hasStaffRemarks = roundRemarks.some((r) => r.targetType === "STAFF");
// Nothing at all came back for this round (a detail response that predates
// the remarks, say) — lock nothing rather than freeze the whole application
// with no way forward. Any remark present means the round is itemised, so
// only what the officer flagged opens: a documents-only round leaves every
// form section frozen, and a sections-only round leaves every document as
// filed.
const roundIsItemised = isAdjusting && roundRemarks.length > 0;
// An answer the officer flagged can decide which fields *other* sections
// require — the vessel category is the live example. Freeze those and the
// applicant is shown newly-required fields they cannot fill, and cannot
// resubmit; the server unlocks them the same way.
const cascadeUnlocked = useMemo(
() =>
sectionsDependingOn(
config?.licenseType?.formSchema?.sections ?? [],
new Set(Object.keys(flaggedSections)),
),
[config, flaggedSections],
);
const unlockedDocuments = useMemo(
() =>
(config?.documentRequirements ?? [])
.filter((requirement) =>
conditionSections(requirement.conditionExpression).some(
(sectionKey) => sectionKey in flaggedSections,
),
)
.map((requirement) => requirement.key),
[config, flaggedSections],
);
// 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];
roundIsItemised &&
!flaggedSections[sectionKey] &&
!cascadeUnlocked.has(sectionKey);
const staffLocked = roundIsItemised && !hasStaffRemarks;
// Sections that share a group collapse onto one step, so the stepper stays
// short instead of showing a page per section. A Damaged/Reissue
@@ -892,7 +924,7 @@ export function LicenseApplicationPage() {
complete
</Badge>
)}
{!readOnly && (
{!readOnly && !staffLocked && (
<Button
size="xs"
variant="light"
@@ -928,7 +960,7 @@ export function LicenseApplicationPage() {
: ""}
</Text>
</div>
{!readOnly && (
{!readOnly && !staffLocked && (
<ActionIcon
variant="subtle"
color="red"
@@ -947,7 +979,7 @@ export function LicenseApplicationPage() {
<StaffEvidence
staffId={member.id}
evidence={role.requiredEvidence}
readOnly={readOnly}
readOnly={readOnly || staffLocked}
onUploaded={refetch}
/>
</Card>
@@ -967,7 +999,8 @@ export function LicenseApplicationPage() {
ownerType="APPLICATION"
ownerId={appId}
flagged={flaggedDocuments}
restrictToFlagged={isAdjusting && hasDocRemarks}
restrictToFlagged={roundIsItemised}
alsoUnlocked={unlockedDocuments}
readOnly={readOnly}
onUploaded={() => {
refetchAttachments();

View File

@@ -0,0 +1,180 @@
import { useState } from 'react';
import {
Alert,
Badge,
Button,
Card,
Group,
Loader,
Paper,
SimpleGrid,
Stack,
Text,
ThemeIcon,
Title,
} from '@mantine/core';
import { notifications } from '@mantine/notifications';
import {
IconDownload,
IconFingerprint,
IconInfoCircle,
IconScan,
} from '@tabler/icons-react';
import { useTranslation } from 'react-i18next';
import { authStorage } from '@ema-platform/auth';
import { useGetMyBiometricEnrollmentsQuery } from '@ema-platform/api';
import { PdfPreviewModal } from '@ema-platform/ui';
const API_BASE =
(import.meta as { env?: Record<string, string> }).env?.['VITE_BASE_API_URL'] ??
'http://localhost:3000/api';
async function fetchCertificate(): Promise<Blob> {
const token = authStorage.getToken();
if (!token) throw new Error('No auth token found');
const res = await fetch(`${API_BASE}/biometric-enrollments/mine/certificate`, {
headers: { Authorization: `Bearer ${token}` },
});
if (!res.ok) throw new Error(`Failed to fetch certificate (${res.status})`);
return res.blob();
}
function downloadBlob(blob: Blob, filename: string) {
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = filename;
a.click();
URL.revokeObjectURL(url);
}
const MODALITY_LABEL: Record<string, string> = {
FINGERPRINT: 'Fingerprint',
FACE: 'Face',
};
/**
* View-only: what's enrolled, plus a printable slip. Capture stays
* counter-side with a scanner — there is no self-enrollment flow here.
*/
export function BiometricsPage() {
const { t } = useTranslation();
const { data: enrollments, isLoading } = useGetMyBiometricEnrollmentsQuery();
const [previewUrl, setPreviewUrl] = useState<string | null>(null);
const [busy, setBusy] = useState(false);
const openPreview = async () => {
setBusy(true);
try {
setPreviewUrl(URL.createObjectURL(await fetchCertificate()));
} catch (err) {
notifications.show({
color: 'red',
title: 'Error',
message: err instanceof Error ? err.message : 'Could not load certificate',
});
} finally {
setBusy(false);
}
};
const handleDownload = async () => {
setBusy(true);
try {
downloadBlob(await fetchCertificate(), 'biometric-enrollment-certificate.pdf');
} catch (err) {
notifications.show({
color: 'red',
title: 'Error',
message: err instanceof Error ? err.message : 'Could not download certificate',
});
} finally {
setBusy(false);
}
};
const rows = enrollments ?? [];
return (
<Stack>
<Group gap="xs">
<IconFingerprint size={22} />
<Title order={2}>{t('biometrics.title', 'Biometrics')}</Title>
</Group>
<Alert variant="light" color="blue" icon={<IconInfoCircle size={16} />}>
{t(
'biometrics.pageIntro',
'Fingerprint and face enrollment happen in person at an EMA counter. This page shows what is on file for you.',
)}
</Alert>
<Paper withBorder radius="lg" p="xl">
{isLoading ? (
<Group justify="center" py="lg">
<Loader size="sm" />
</Group>
) : rows.length === 0 ? (
<Alert variant="light" color="gray" icon={<IconInfoCircle size={15} />}>
{t('biometrics.empty', 'No biometric enrollment on file yet.')}
</Alert>
) : (
<Stack>
<SimpleGrid cols={{ base: 1, sm: 2 }} spacing="md">
{rows.map((e) => (
<Card key={e.id} withBorder radius="md" p="md">
<Group justify="space-between">
<Group gap="sm">
<ThemeIcon size={36} radius="md" color="blue" variant="light">
<IconScan size={18} />
</ThemeIcon>
<div>
<Text fw={700} fz="sm">
{MODALITY_LABEL[e.modality] ?? e.modality}
</Text>
<Text fz="xs" c="dimmed">
Enrolled {new Date(e.enrolledAt).toLocaleDateString('en-GB', {
day: '2-digit',
month: 'short',
year: 'numeric',
})}
</Text>
</div>
</Group>
<Badge color="teal" variant="light">
{e.status}
</Badge>
</Group>
</Card>
))}
</SimpleGrid>
<Group>
<Button
variant="light"
leftSection={busy ? <Loader size={12} /> : <IconInfoCircle size={12} />}
onClick={openPreview}
disabled={busy}
>
{t('biometrics.view', 'View certificate')}
</Button>
<Button
variant="default"
leftSection={<IconDownload size={12} />}
onClick={handleDownload}
disabled={busy}
>
{t('biometrics.download', 'Download')}
</Button>
</Group>
</Stack>
)}
</Paper>
<PdfPreviewModal
opened={!!previewUrl}
onClose={() => setPreviewUrl(null)}
url={previewUrl ?? ''}
title={t('biometrics.title', 'Biometrics')}
/>
</Stack>
);
}

View File

@@ -59,6 +59,7 @@ export const am: Translations = {
seaRecords: 'የባህር መዝገቦቼ',
seaService: 'የባህር አገልግሎት',
medical: 'የሕክምና የምስክር ወረቀት',
biometrics: 'ባዮሜትሪክ',
myApplication: 'ማመልከቻዬ',
certificates: 'የምስክር ወረቀቶች',
seamanBook: 'የመርከበኛ መጽሐፍ እና BTC',
@@ -607,6 +608,28 @@ export const am: Translations = {
createOne: "አንድ ይፍጠሩ",
},
fayda: {
continueWith: "በፋይዳ ይቀጥሉ",
orFillManually: "ወይም መረጃዎን ራስዎ ይሙሉ",
verifiedTitle: "በፋይዳ ተረጋግጧል",
verifiedBody: "ፋይዳ ያረጋገጣቸውን መረጃዎች ሞልተናል። እባክዎ የቀሩትን መስኮች ያሟሉ።",
discard: "እነዚህን መረጃዎች አጥፍቼ ቅጹን ራሴ እሞላለሁ",
fieldVerified: "ከፋይዳ",
fieldConflict: "በሌላ መለያ ተይዟል",
conflictBody:
"አንዳንድ የተረጋገጡ መረጃዎች አስቀድሞ የሌላ መለያ ናቸው። የተመለከቱትን መስኮች ይቀይሩ ወይም ይግቡ።",
brandTitle: "በፋይዳ በማረጋገጥ ላይ",
brandSubtitle: "ማንነትዎን እስክናረጋግጥ ድረስ አንድ አፍታ።",
verifying: "የፋይዳ ማንነትዎን በማረጋገጥ ላይ…",
failedTitle: "ማረጋገጡ አልተጠናቀቀም",
backToSignup: "ወደ ምዝገባ ተመለስ",
cancelled: "የፋይዳ ማረጋገጫው ተሰርዟል። አሁንም በእጅ መመዝገብ ይችላሉ።",
rejected: "ፋይዳ ማንነትዎን ማረጋገጥ አልቻለም። እባክዎ እንደገና ይሞክሩ።",
invalidCallback: "ይህ የማረጋገጫ ሊንክ አልተሟላም። እባክዎ እንደገና ይጀምሩ።",
sessionLost: "የማረጋገጫ ክፍለ ጊዜዎ አልፏል። እባክዎ እንደገና ይጀምሩ።",
stateMismatch: "ይህ ማረጋገጫ ሊታመን አልቻለም። እባክዎ እንደገና ይጀምሩ።",
},
signup: {
usernameMinLength: "የተጠቃሚ ስም ቢያንስ 3 ቁምፊዎች ሊኖረው ይገባል",
nameEnRequired: "ስም (እንግሊዝኛ) ያስፈልጋል",
@@ -809,6 +832,14 @@ export const am: Translations = {
renewFailed: 'ዕድሳት መጀመር አልተቻለም',
reportDamaged: 'ጉዳት ሪፖርት ያድርጉ / ምትክ ይጠይቁ',
reissueFailed: 'የምትክ ጥያቄ መጀመር አልተቻለም',
status: {
ACTIVE: 'የፀና',
EXPIRED: 'ጊዜው ያለፈበት',
SUSPENDED: 'የታገደ',
CANCELLED: 'የተሰረዘ',
SUPERSEDED: 'በአዲስ የምስክር ወረቀት የተተካ',
},
statusReason: 'ምክንያት፦ {{reason}}',
},
catalogue: {
emptyTitle: 'የሚሰሩበትን የስራ ዘርፍ ይንገሩን',
@@ -830,6 +861,8 @@ export const am: Translations = {
evaluationOnly: 'ግምገማ ብቻ',
startApplication: 'ማመልከቻ ጀምር',
addToOperations: 'ወደ ስራ ዘርፎቼ ጨምር',
lockedHint:
'ከተመዘገቡ የስራ ዘርፎችዎ ውስጥ ስላልሆነ እስካሁን ማመልከት አይችሉም።',
},
},
@@ -995,6 +1028,10 @@ export const am: Translations = {
timeExpired: 'ጊዜው አልቋል',
resumeExam: 'ፈተና ይቀጥሉ',
takeExam: 'ፈተና ይውሰዱ',
awaitingAttendance: 'መገኘት በመጠባበቅ ላይ',
awaitingAttendanceHint:
'ፈተናው ከመከፈቱ በፊት ተቆጣጣሪ መገኘትዎን ማረጋገጥ አለበት።',
notSitting: 'አይፈተኑም',
attendanceStatus: {
REGISTERED: 'አልተጠራም',
PRESENT: 'ተገኝቷል',
@@ -1294,4 +1331,49 @@ export const am: Translations = {
},
},
},
documents: {
title: 'ሰነዶቼ',
subtitle: 'EMA ያወጣልዎት እያንዳንዱ ሰነድ፣ እንዲሁም ከመዝገቦችዎ ጋር የተያያዙ ፋይሎች።',
tabs: {
license: 'ፈቃዶች',
medical: 'ሕክምና',
seaService: 'የባህር አገልግሎት',
personal: 'የግል መረጃ',
},
issuedTitle: 'በ EMA የተሰጡ ሰነዶች',
licensesTitle: 'የምስክር ወረቀቶች እና ፈቃዶች',
kind: {
SEAMAN_BOOK: 'የመርከበኛ መጽሐፍ',
BTC_BASIC_TRAINING: 'የመሠረታዊ ስልጠና የምስክር ወረቀት (BTC)',
},
documentStatus: {
AWAITING_REGISTRATION: 'ምዝገባ በመጠበቅ ላይ',
PAYMENT_PENDING: 'ክፍያ በመጠበቅ ላይ',
PAID: 'ተከፍሏል',
PAYMENT_CONFIRMED: 'ሰነድ በመዘጋጀት ላይ',
SCHEDULED: 'የመውሰጃ ቀጠሮ ተይዟል',
ISSUED: 'ተሰጥቷል',
REJECTED: 'ተቀባይነት አላገኘም',
CANCELLED: 'ተሰርዟል',
},
view: 'ይመልከቱ',
notIssued: 'እስካሁን አልተሰጠም',
openFailed: 'ሰነዱን መክፈት አልተቻለም',
files: {
none: 'ምንም የተያያዘ ፋይል የለም።',
},
empty: {
licenses: 'እስካሁን የተሰጠዎት የምስክር ወረቀት ወይም ፈቃድ የለም።',
medical: 'እስካሁን በመዝገብ ላይ የሕክምና የምስክር ወረቀት የለም።',
seaService: 'እስካሁን በመዝገብ ላይ የባህር አገልግሎት መዝገብ የለም።',
},
personal: {
description: 'ከመርከበኛ ምዝገባዎ ጋር ያስገቡት ሰነዶች።',
noRegistration: 'እስካሁን የመርከበኛ ምዝገባ ስለሌለዎት በመዝገብ ላይ የግል ሰነዶች የሉም።',
startRegistration: 'ወደ መርከበኛ ምዝገባ ይሂዱ',
uploaded: 'ተሰቅሏል',
missing: 'አልተሰቀለም',
},
},
};

View File

@@ -49,6 +49,7 @@ export const en = {
seaRecords: 'My Sea Records',
seaService: 'Sea Service',
medical: 'Medical Certificate',
biometrics: 'Biometrics',
myApplication: 'My Application',
vesselRegistration: 'Vessel Registration',
vesselRegistrationDashboard: 'Vessel Registration Dashboard',
@@ -607,6 +608,28 @@ export const en = {
createOne: 'Create one',
},
fayda: {
continueWith: 'Continue with Fayda',
orFillManually: 'or fill in your details',
verifiedTitle: 'Verified with Fayda',
verifiedBody: 'We filled in the details Fayda confirmed. Please complete the remaining fields.',
discard: 'Clear these details and fill the form myself',
fieldVerified: 'From Fayda',
fieldConflict: 'Already used by another account',
conflictBody:
'Some verified details already belong to another account. Change the highlighted fields, or sign in instead.',
brandTitle: 'Verifying with Fayda',
brandSubtitle: 'One moment while we confirm your identity.',
verifying: 'Verifying your Fayda identity\u2026',
failedTitle: 'Verification incomplete',
backToSignup: 'Back to sign up',
cancelled: 'Fayda verification was cancelled. You can still sign up manually.',
rejected: 'Fayda could not verify your identity. Please try again.',
invalidCallback: 'This verification link is incomplete. Please start again.',
sessionLost: 'Your verification session has expired. Please start again.',
stateMismatch: 'This verification could not be trusted. Please start again.',
},
signup: {
usernameMinLength: 'Username must be at least 3 characters',
nameEnRequired: 'Name (English) is required',
@@ -809,6 +832,14 @@ export const en = {
renewFailed: 'Could not start the renewal',
reportDamaged: 'Report damaged / request replacement',
reissueFailed: 'Could not start the replacement request',
status: {
ACTIVE: 'Active',
EXPIRED: 'Expired',
SUSPENDED: 'Suspended',
CANCELLED: 'Cancelled',
SUPERSEDED: 'Replaced by a newer certificate',
},
statusReason: 'Reason: {{reason}}',
},
catalogue: {
emptyTitle: 'Tell us what you operate as',
@@ -830,6 +861,8 @@ export const en = {
evaluationOnly: 'Evaluation only',
startApplication: 'Start application',
addToOperations: 'Add to my operations',
lockedHint:
'Not one of your declared operations, so it cannot be applied for yet.',
},
},
@@ -997,6 +1030,10 @@ export const en = {
timeExpired: 'Time expired',
resumeExam: 'Resume exam',
takeExam: 'Take exam',
awaitingAttendance: 'Awaiting attendance',
awaitingAttendanceHint:
'An invigilator must confirm you are present before the exam opens.',
notSitting: 'Not sitting',
attendanceStatus: {
REGISTERED: 'Not called',
PRESENT: 'Present',
@@ -1296,6 +1333,53 @@ export const en = {
},
},
},
documents: {
title: 'My documents',
subtitle:
'Every document EMA has issued you, and the files attached to your records.',
tabs: {
license: 'Licences',
medical: 'Medical',
seaService: 'Sea Service',
personal: 'Personal Data',
},
issuedTitle: 'EMA-issued documents',
licensesTitle: 'Certificates and licences',
kind: {
SEAMAN_BOOK: 'Seaman Book',
BTC_BASIC_TRAINING: 'Basic Training Certificate (BTC)',
},
documentStatus: {
AWAITING_REGISTRATION: 'Awaiting registration',
PAYMENT_PENDING: 'Payment pending',
PAID: 'Paid',
PAYMENT_CONFIRMED: 'Preparing document',
SCHEDULED: 'Pickup scheduled',
ISSUED: 'Issued',
REJECTED: 'Rejected',
CANCELLED: 'Cancelled',
},
view: 'View',
notIssued: 'Not issued yet',
openFailed: 'Could not open the document',
files: {
none: 'No files attached.',
},
empty: {
licenses: 'No certificates or licences have been issued to you yet.',
medical: 'No medical certificates on file yet.',
seaService: 'No sea-service records on file yet.',
},
personal: {
description: 'The documents you submitted with your seafarer registration.',
noRegistration:
'You have no seafarer registration yet, so there are no personal documents on file.',
startRegistration: 'Go to seafarer registration',
uploaded: 'Uploaded',
missing: 'Not uploaded',
},
},
};
export type Translations = typeof en;

View File

@@ -5,6 +5,7 @@ import {
IconArrowsExchange,
IconBell,
IconBook2,
IconFingerprint,
IconFolderOpen,
IconHeadset,
IconHome2,
@@ -131,6 +132,13 @@ const NAV_SECTIONS: { label?: string; items: PortalNavItem[] }[] = [
icon: IconShieldCheck,
permissions: [P.VIEW_OWN_CERTIFICATES],
},
{
to: "/seafarer/biometrics",
label: "Biometrics",
i18nKey: "nav.biometrics",
icon: IconFingerprint,
permissions: [P.VIEW_OWN_BIOMETRICS],
},
{
to: "/exams",
label: "Examinations",
@@ -206,6 +214,7 @@ const PAGE_META: Record<string, { i18nKey: string }> = {
"/seaman-book": { i18nKey: "nav.seamanBook" },
"/basic-training-certificate": { i18nKey: "nav.btc" },
"/certificates": { i18nKey: "nav.certificates" },
"/seafarer/biometrics": { i18nKey: "nav.biometrics" },
"/exams": { i18nKey: "nav.exams" },
"/endorsements": { i18nKey: "nav.endorsements" },
"/documents": { i18nKey: "nav.documents" },

View File

@@ -8,6 +8,7 @@ import { LandingRoute } from "./components/LandingRoute";
import {
LoginPage,
SignupPage,
FaydaCallbackPage,
OTPVerificationPage,
ForgotPasswordPage,
SetPasswordPage,
@@ -28,6 +29,7 @@ import { OperationsOnboardingPage } from "./features/onboarding/pages/Operations
import { ProfilePage } from "./features/profile/pages/ProfilePage";
import { SupportPage } from "./features/support/pages/SupportPage";
import { MedicalRecordsPage, SeaServicePage } from "./features/seafarer/pages/SeaRecords";
import { BiometricsPage } from "./features/seafarer/pages/Biometrics";
import { SeafarerRegistrationPage } from "./features/seafarer-registration/pages/SeafarerRegistrationPage";
import { ExamsPage } from "./features/exams/pages/ExamsPage";
import { ExamAttemptPage } from "./features/exam-attempt/pages/ExamAttemptPage";
@@ -67,6 +69,16 @@ export const router = createBrowserRouter([
{ path: "/login", element: <LoginPage /> },
{ path: "/signup", element: <SignupPage /> },
// Where Fayda returns the applicant. Public by necessity — they have no
// account yet. It redeems the code and hands control back to /signup.
//
// Two paths for one page: whichever is registered with Fayda has to match the
// API's FAYDA_REDIRECT_URI exactly, and the value being registered first is a
// bare /callback. The descriptive path is kept so the route still reads as
// part of signup once that can be changed.
{ path: "/signup/fayda/callback", element: <FaydaCallbackPage /> },
{ path: "/callback", element: <FaydaCallbackPage /> },
// Completes the forgot-password flow; the reset message links here. The
// IAM package generates `/reset-password` links, `/set-password` is the
// first-time-credential variant — one page serves both.
@@ -205,6 +217,14 @@ export const router = createBrowserRouter([
</RequirePermission>
),
},
{
path: "/seafarer/biometrics",
element: (
<RequirePermission anyOf={[P.VIEW_OWN_BIOMETRICS]}>
<BiometricsPage />
</RequirePermission>
),
},
{ path: "/seafarer/records", element: <Navigate to="/seafarer/sea-service" replace /> },
{
path: "/exams",

View File

@@ -1,31 +1,34 @@
import { defineConfig } from 'vite';
import react from '@vitejs/plugin-react';
import { nxViteTsPaths } from '@nx/vite/plugins/nx-tsconfig-paths.plugin';
import { defineConfig } from "vite";
import react from "@vitejs/plugin-react";
import { nxViteTsPaths } from "@nx/vite/plugins/nx-tsconfig-paths.plugin";
export default defineConfig({
root: __dirname,
// Env lives at the workspace root, shared with the backoffice — without this
// Vite looks in apps/portal and VITE_BASE_API_URL silently falls back to its
// built-in default.
envDir: '../../',
cacheDir: '../../node_modules/.vite/apps/portal',
server: { port: 4200, host: 'localhost' },
// server: {
// port: 4200,
// proxy: {
// '/api': {
// target: 'http://localhost:3000', // change from 'https://ema-api-dev.triaplc.com'
// changeOrigin: true,
// },
// },
// },
preview: { port: 4200, host: 'localhost' },
envDir: "../../",
cacheDir: "../../node_modules/.vite/apps/portal",
// 3000, not the usual 4200: the Fayda redirect URI registered for local
// testing is http://localhost:3001/callback, and the provider matches it
// exactly. The API moves to 3001 to make room.
server: { port: 3000, host: "localhost" },
// server: {
// port: 4200,
// proxy: {
// '/api': {
// target: 'http://localhost:3001', // change from 'https://ema-api-dev.triaplc.com'
// changeOrigin: true,
// },
// },
// },
preview: { port: 3000, host: "localhost" },
plugins: [react(), nxViteTsPaths()],
resolve: {
dedupe: ['react', 'react-dom', 'react-router-dom', '@tanstack/react-query'],
dedupe: ["react", "react-dom", "react-router-dom", "@tanstack/react-query"],
},
build: {
outDir: '../../dist/apps/portal',
outDir: "../../dist/apps/portal",
emptyOutDir: true,
reportCompressedSize: true,
},

View File

@@ -6,6 +6,7 @@ export * from './lib/features/location';
export * from './lib/features/seafarer';
export * from './lib/features/seafarer-registration';
export * from './lib/features/seafarer-document';
export * from './lib/features/biometric-enrollment';
export * from './lib/features/vessel';
export { baseQueryWithReauth, configureTokenRefresh } from './lib/base-api/base-query-with-reauth';
export { BASE_API_URL, baseQueryWithReauth, configureTokenRefresh } from './lib/base-api/base-query-with-reauth';
export { openAuthedDocument, downloadAuthedFile } from './lib/base-api/download';

View File

@@ -2,10 +2,15 @@ import { fetchBaseQuery, type BaseQueryFn } from "@reduxjs/toolkit/query/react";
import type { FetchArgs, FetchBaseQueryError } from "@reduxjs/toolkit/query";
import { resolveSessionContext } from "../session";
/**
* The one place the backend URL is resolved: VITE_BASE_API_URL from the env,
* falling back to the local dev API (3001 — the portal itself owns 3000 for
* the Fayda redirect). Import this; do not re-derive it.
*/
export const BASE_API_URL =
(import.meta as { env?: Record<string, string> }).env?.[
"VITE_BASE_API_URL"
] ?? "http://localhost:3000/api";
]?.trim() || "http://localhost:3001/api";
let _onTokenExpired: (() => Promise<string>) | null = null;
let _onAuthFailure: (() => void) | null = null;

View File

@@ -0,0 +1,67 @@
import { baseApi } from '../../base-api';
import type { BiometricEnrollment, EnrollBiometric } from './biometric-enrollment.types';
const TAG = 'BiometricEnrollment' as const;
const forProfile = (profileId: string) => ({ type: TAG, id: profileId }) as const;
/**
* Scanner capture (fingerprint, face) stored per profile. No applicant-facing
* endpoint — enrollment happens at a counter with a scanner.
*/
export const biometricEnrollmentApi = baseApi
.enhanceEndpoints({ addTagTypes: [TAG] })
.injectEndpoints({
endpoints: (builder) => ({
enrollBiometric: builder.mutation<BiometricEnrollment, EnrollBiometric>({
query: (body) => ({ url: '/biometric-enrollments', method: 'POST', body }),
invalidatesTags: (r, error) => (error || !r ? [] : [forProfile(r.profileId)]),
}),
getBiometricEnrollments: builder.query<BiometricEnrollment[], string>({
query: (profileId) => ({ url: `/biometric-enrollments/profile/${profileId}` }),
providesTags: (_r, _e, profileId) => [forProfile(profileId)],
}),
/** Self-service, view-only: the caller's own live enrollments. */
getMyBiometricEnrollments: builder.query<BiometricEnrollment[], void>({
query: () => ({ url: '/biometric-enrollments/mine' }),
providesTags: [{ type: TAG, id: 'MINE' }],
}),
/** Dev/test only — the API reports false in production. */
getBiometricSimulateCapabilities: builder.query<{ simulateEnabled: boolean }, void>({
query: () => ({ url: '/biometric-enrollments/simulate/capabilities' }),
}),
revokeBiometricEnrollment: builder.mutation<
BiometricEnrollment,
{ id: string; profileId: string; reason: string }
>({
query: ({ id, reason }) => ({
url: `/biometric-enrollments/${id}/revoke`,
method: 'POST',
body: { reason },
}),
invalidatesTags: (_r, error, { profileId }) => (error ? [] : [forProfile(profileId)]),
}),
/** Stamps the profile's BSID once enrollment is confirmed. Requires an active enrollment. */
generateBsid: builder.mutation<{ id: string; bsid: string | null }, string>({
query: (profileId) => ({
url: `/biometric-enrollments/profile/${profileId}/generate-bsid`,
method: 'POST',
}),
invalidatesTags: (_r, error, profileId) => (error ? [] : [forProfile(profileId)]),
}),
}),
overrideExisting: false,
});
export const {
useEnrollBiometricMutation,
useGetBiometricEnrollmentsQuery,
useGetMyBiometricEnrollmentsQuery,
useGetBiometricSimulateCapabilitiesQuery,
useRevokeBiometricEnrollmentMutation,
useGenerateBsidMutation,
} = biometricEnrollmentApi;

View File

@@ -0,0 +1,32 @@
export type BiometricModality = 'FINGERPRINT' | 'FACE';
export type BiometricEnrollmentStatus = 'ACTIVE' | 'REVOKED';
export interface BiometricEnrollment {
id: string;
profileId: string;
modality: BiometricModality;
templateFormat: string;
qualityScore: number | null;
deviceId: string | null;
status: BiometricEnrollmentStatus;
enrolledById: string;
enrolledAt: string;
consentAt: string;
revokedReason: string | null;
revokedById: string | null;
revokedAt: string | null;
createdAt: string;
updatedAt: string;
}
export interface EnrollBiometric {
profileId: string;
modality: BiometricModality;
/** Vendor SDK template, base64. Never the raw scan image. */
template: string;
templateFormat: string;
qualityScore?: number;
deviceId?: string;
/** ISO 8601 — when the subject consented to capture. */
consentAt: string;
}

View File

@@ -0,0 +1,2 @@
export * from './biometric-enrollment.types';
export * from './biometric-enrollment-api';

View File

@@ -866,19 +866,6 @@ export const licensingApi = baseApi
* flight; these two *start* a stage, because under the push model
* assignment is how work begins — nothing is claimed from a queue.
*/
assignReviewer: builder.mutation<
LicenseApplication,
{ id: string; officerId: string; remark?: string }
>({
query: ({ id, ...body }) => ({
url: `/license-application-review/${id}/assign-reviewer`,
method: 'POST',
body,
}),
invalidatesTags: (_r, error, { id }) =>
error ? [] : [itemTag('LicenseApplication', id), listTag('ApplicationQueue')],
}),
assignInspector: builder.mutation<
LicenseApplication,
{ id: string; inspectorId: string; remark?: string }
@@ -1132,6 +1119,30 @@ export const licensingApi = baseApi
error ? [] : [itemTag('LicenseApplication', applicationId), listTag('Inspection')],
}),
/** Moves a booked visit: another day, slot, inspector or place. */
rescheduleInspection: builder.mutation<
Inspection,
{
inspectionId: string;
applicationId: string;
scheduledDate: string;
timeSlot: 'MORNING' | 'AFTERNOON';
inspectorId?: string;
location?: string;
reason?: string;
}
>({
query: ({ inspectionId, scheduledDate, timeSlot, inspectorId, location, reason }) => ({
url: `/inspections/${inspectionId}/schedule`,
method: 'PATCH',
// applicationId is for cache invalidation only; the visit knows its
// own application.
body: { scheduledDate, timeSlot, inspectorId, location, reason },
}),
invalidatesTags: (_r, error, { applicationId }) =>
error ? [] : [itemTag('LicenseApplication', applicationId), listTag('Inspection')],
}),
getInspections: builder.query<Inspection[], string>({
query: (applicationId) => ({ url: `/inspections/application/${applicationId}` }),
providesTags: () => [listTag('Inspection')],
@@ -1280,6 +1291,7 @@ export const {
useCreatePickupOfficeMutation,
useUpdatePickupOfficeMutation,
useScheduleInspectionMutation,
useRescheduleInspectionMutation,
useGetInspectionsQuery,
useRecordInspectionResultMutation,
useGetNotificationsQuery,

View File

@@ -1,5 +1,6 @@
import { isValidPhoneNumber } from 'libphonenumber-js';
import { resolveTokenFromStorage } from '../../session';
import { BASE_API_URL } from '../../base-api/base-query-with-reauth';
import type {
ApplicationKind,
Bilingual,
@@ -17,10 +18,6 @@ function sectionAppliesToKind(section: FormSectionConfig, kind: ApplicationKind)
return !section.applicationKinds?.length || section.applicationKinds.includes(kind);
}
const BASE_API_URL =
(import.meta as { env?: Record<string, string> }).env?.['VITE_BASE_API_URL'] ??
'http://localhost:3000/api';
/**
* Uploads a document straight to the API.
*
@@ -69,6 +66,7 @@ export const STATUS_LABELS: Record<LicenseStatus, string> = {
DRAFT: 'Draft',
SUBMITTED: 'Submitted',
UNDER_REVIEW: 'Under Review',
AWAITING_BIOMETRICS: 'Awaiting Biometrics',
UNDER_EVALUATION: 'Under Evaluation',
RESUBMIT_REQUIRED: 'Resubmit Required',
INSPECTION_PENDING: 'Inspection Pending',
@@ -99,6 +97,7 @@ export const STATUS_COLORS: Record<LicenseStatus, string> = {
DRAFT: 'gray',
SUBMITTED: 'blue',
UNDER_REVIEW: 'indigo',
AWAITING_BIOMETRICS: 'indigo',
UNDER_EVALUATION: 'indigo',
RESUBMIT_REQUIRED: 'orange',
INSPECTION_PENDING: 'cyan',
@@ -138,6 +137,7 @@ export const STATUS_PROGRESS: Record<LicenseStatus, number> = {
DRAFT: 5,
SUBMITTED: 15,
UNDER_REVIEW: 30,
AWAITING_BIOMETRICS: 30,
UNDER_EVALUATION: 45,
RESUBMIT_REQUIRED: 30,
INSPECTION_PENDING: 55,
@@ -619,6 +619,49 @@ interface ConditionLike {
anyOf?: ConditionLike[];
}
/**
* Section keys a condition reads, recursing `anyOf`.
*
* `FieldCondition.field` is a `sectionKey.fieldKey` path, so the prefix names
* the section whose answer decides the condition.
*/
export function conditionSections(
condition: FieldCondition | undefined | null,
): string[] {
if (!condition) return [];
if (condition.anyOf) return condition.anyOf.flatMap(conditionSections);
if (!condition.field) return [];
const [sectionKey] = condition.field.split('.');
return sectionKey ? [sectionKey] : [];
}
/**
* Sections whose visibility hangs on an answer in one of `flagged`.
*
* Mirrors the server's `sectionsDependingOn`: an officer flagging the section
* that holds the vessel category is asking for an answer that decides which
* fields in other sections are required, so those sections have to open too —
* otherwise the applicant sees newly-required fields they cannot edit and
* cannot resubmit.
*/
export function sectionsDependingOn(
sections: FormSectionConfig[],
flagged: Set<string>,
): Set<string> {
const dependent = new Set<string>();
if (flagged.size === 0) return dependent;
for (const section of sections) {
if (flagged.has(section.key)) continue;
const reads = [
section.showWhen,
...(section.fields ?? []).map((field) => field.showWhen),
].flatMap(conditionSections);
if (reads.some((key) => flagged.has(key))) dependent.add(section.key);
}
return dependent;
}
export function conditionHolds(
condition: FieldCondition | undefined | null,
formData: Record<string, Record<string, unknown>>,

View File

@@ -25,6 +25,9 @@ export type LicenseStatus =
| "DRAFT"
| "SUBMITTED"
| "UNDER_REVIEW"
// Seafarer registration only: same slot UNDER_REVIEW occupies elsewhere,
// but approval is blocked until the applicant's profile has a BSID.
| "AWAITING_BIOMETRICS"
| "UNDER_EVALUATION"
// Employee filed their review; parked with the team leader for a decision.
| "REVIEW_REPORTED"

View File

@@ -10,9 +10,17 @@ const TAG = 'SeafarerRegistration' as const;
const LIST = { type: TAG, id: 'LIST' } as const;
const item = (id: string) => ({ type: TAG, id }) as const;
export type SeafarerRegistrationSortField =
| 'submittedAt'
| 'registrationNumber'
| 'lastName'
| 'status';
export interface SeafarerRegistrationListFilter {
status?: SeafarerRegistrationStatus;
search?: string;
sortBy?: SeafarerRegistrationSortField;
sortDir?: 'ASC' | 'DESC';
take?: number;
skip?: number;
}

View File

@@ -1,17 +1,17 @@
import Cookies from 'js-cookie';
import Cookies from "js-cookie";
export const SESSION_HEADER_KEYS = {
tenantId: 'x-tenant-id',
organizationUnitId: 'x-organization-unit-id',
currentPositionId: 'x-current-position-id',
currentProjectId: 'x-current-project-id',
tenantId: "x-tenant-id",
organizationUnitId: "x-organization-unit-id",
currentPositionId: "x-current-position-id",
currentProjectId: "x-current-project-id",
} as const;
/**
* Which app this bundle is, so it reads its own session and no one else's.
*
* Set by each app's store via `configureSessionScope`. Cookies ignore the
* port, so `localhost:4200` and `localhost:4201` share one jar: without a
* port, so `localhost:3001` and `localhost:4201` share one jar: without a
* scope the backoffice would happily authenticate as whoever last signed into
* the portal, and render a staff console with an applicant's permissions.
*/
@@ -22,7 +22,7 @@ export function configureSessionScope(prefix: string): void {
}
/** Legacy, pre-prefix sessions. Read only when the scoped key is empty. */
const LEGACY_TOKEN_KEY = 'auth-token';
const LEGACY_TOKEN_KEY = "auth-token";
export function resolveTokenFromStorage(): string | undefined {
// Only this app's key, then the legacy unprefixed one. Never another app's:

View File

@@ -6,6 +6,7 @@ export { AuthBootstrap } from "./lib/components/AuthBootstrap";
export { useIdleTimer } from "./lib/hooks/useIdleTimer";
export { LoginPage } from "./lib/pages/LoginPage";
export { SignupPage } from "./lib/pages/SignupPage";
export { FaydaCallbackPage } from "./lib/pages/FaydaCallbackPage";
export { ForgotPasswordPage } from "./lib/pages/ForgotPasswordPage";
export { SetPasswordPage } from "./lib/pages/SetPasswordPage";
export { OTPVerificationPage } from "./lib/pages/OTPVerificationPage";

View File

@@ -6,9 +6,7 @@ import { hydrateAuth, logout, setToken, setUser } from '../store/auth.slice';
import { refreshAccessToken } from '../utils/refresh-token';
import type { AuthUser } from '../types/auth.types';
const BASE_API_URL =
(import.meta as { env?: Record<string, string> }).env?.['VITE_BASE_API_URL'] ??
'http://localhost:3000/api';
import { BASE_API_URL } from '@ema-platform/api';
/**
* Restores the signed-in session before the router renders.

View File

@@ -0,0 +1,121 @@
import { useEffect, useRef, useState } from 'react';
import { Alert, Button, Group, Loader, Stack, Text, Title } from '@mantine/core';
import { IconAlertTriangle, IconArrowLeft } from '@tabler/icons-react';
import { useNavigate, useSearchParams } from 'react-router-dom';
import { useTranslation } from 'react-i18next';
import { useApiMutation } from '@ema-platform/api';
import { useErrorHandler } from '@ema-platform/ui';
import { AuthShell } from '../components/AuthShell';
import { faydaSession, type FaydaResult } from '../utils/fayda-session';
/**
* Where Fayda returns the applicant.
*
* It creates no account and holds no credentials — it hands the authorization
* code to the API, stashes the normalised result, and sends the applicant back
* to the signup form they started on.
*/
export function FaydaCallbackPage() {
const navigate = useNavigate();
const { t } = useTranslation();
const [params] = useSearchParams();
const { handleError } = useErrorHandler();
const [error, setError] = useState<string | null>(null);
const [callbackTrigger] = useApiMutation<FaydaResult>();
// React 18 mounts effects twice in development, and the authorization code is
// single-use — the second redemption would fail and show a spurious error.
const redeemed = useRef(false);
useEffect(() => {
if (redeemed.current) return;
redeemed.current = true;
const code = params.get('code');
const state = params.get('state');
const providerError = params.get('error');
const request = faydaSession.takeRequest();
if (providerError) {
setError(
providerError === 'access_denied'
? t('fayda.cancelled', 'Fayda verification was cancelled. You can still sign up manually.')
: t('fayda.rejected', 'Fayda could not verify your identity. Please try again.'),
);
return;
}
if (!code || !state) {
setError(t('fayda.invalidCallback', 'This verification link is incomplete. Please start again.'));
return;
}
if (!request) {
setError(
t('fayda.sessionLost', 'Your verification session has expired. Please start again.'),
);
return;
}
if (request.state !== state) {
setError(t('fayda.stateMismatch', 'This verification could not be trusted. Please start again.'));
return;
}
callbackTrigger({
url: '/auth/register-with-fayda',
method: 'POST',
// `verify` returns the identity without creating an account — the
// existing signup endpoint still does that.
body: { action: 'verify', code, state, transactionToken: request.transactionToken },
})
.unwrap()
.then((result) => {
faydaSession.saveResult(result);
// replace: the callback URL carries a spent code, so it must not come
// back on Back.
navigate('/signup', { replace: true });
})
.catch((err: unknown) => setError(handleError(err)));
// Runs once on mount; the guard above makes that explicit.
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
return (
<AuthShell
brandTitle={t('fayda.brandTitle', 'Verifying with Fayda')}
brandSubtitle={t('fayda.brandSubtitle', 'One moment while we confirm your identity.')}
>
<Stack gap="lg">
{error ? (
<>
<Title order={2} fz={26}>
{t('fayda.failedTitle', 'Verification incomplete')}
</Title>
<Alert
variant="light"
color="orange"
icon={<IconAlertTriangle size={18} />}
>
{error}
</Alert>
<Group>
<Button
variant="light"
leftSection={<IconArrowLeft size={18} />}
onClick={() => navigate('/signup', { replace: true })}
>
{t('fayda.backToSignup', 'Back to sign up')}
</Button>
</Group>
</>
) : (
<Group gap="sm">
<Loader size="sm" />
<Text c="dimmed">{t('fayda.verifying', 'Verifying your Fayda identity…')}</Text>
</Group>
)}
</Stack>
</AuthShell>
);
}

View File

@@ -1,10 +1,11 @@
import { useState } from 'react';
import { useEffect, useState } from 'react';
import {
Alert,
Anchor,
Badge,
Button,
Checkbox,
Group,
Divider,
PasswordInput,
SimpleGrid,
Stack,
@@ -14,11 +15,14 @@ import {
UnstyledButton,
} from '@mantine/core';
import {
IconAlertTriangle,
IconArrowLeft,
IconArrowRight,
IconAt,
IconId,
IconLock,
IconMail,
IconRosetteDiscountCheck,
IconUser,
} from '@tabler/icons-react';
import { useForm } from 'react-hook-form';
@@ -33,6 +37,7 @@ import { AuthShell } from '../components/AuthShell';
import { loginSuccess, setUser } from '../store/auth.slice';
import type { AuthUser } from '../types/auth.types';
import { useAuthConfig } from '../AuthConfig';
import { faydaSession, type FaydaResult } from '../utils/fayda-session';
interface SignupPayload {
email: string;
@@ -62,6 +67,56 @@ export function SignupPage() {
}>();
const [meTrigger] = useApiMutation<AuthUser>();
// Fayda is optional: the form below works exactly as before without it.
const [fayda, setFayda] = useState<FaydaResult | null>(() => faydaSession.peekResult());
const [faydaStarting, setFaydaStarting] = useState(false);
const [startTrigger] = useApiMutation<{
authorizationUrl: string;
state: string;
transactionToken: string;
expiresIn: number;
}>();
const [linkTrigger] = useApiMutation<{ phoneNumberVerified?: boolean }>();
const verified = (field: string) => fayda?.verifiedFields.includes(field) ?? false;
const conflicted = (field: string) => fayda?.conflicts.includes(field) ?? false;
/**
* Per-field provenance, so it is obvious which values came from Fayda and
* which are still the applicant's to supply. Verified fields stay editable —
* a conflicting email has to be changeable for the form to be completable at
* all.
*/
const faydaMark = (field: string): { description?: React.ReactNode } => {
if (conflicted(field)) {
return {
// component="span" on these badges: the description slot renders
// inside a <p>, where Badge's default <div> is invalid HTML.
description: (
<Badge component="span" size="xs" variant="light" color="orange">
{t('fayda.fieldConflict', 'Already used by another account')}
</Badge>
),
};
}
if (verified(field)) {
return {
description: (
<Badge
component="span"
size="xs"
variant="light"
color="teal"
leftSection={<IconRosetteDiscountCheck size={11} />}
>
{t('fayda.fieldVerified', 'From Fayda')}
</Badge>
),
};
}
return {};
};
const handleBack = () => {
if (window.history.length > 1) {
navigate(-1);
@@ -118,6 +173,42 @@ export function SignupPage() {
defaultValues: { userType: 'individual' },
});
// Fills what Fayda vouched for and leaves the rest — username and password
// are always the applicant's to choose, and Fayda supplies neither.
useEffect(() => {
if (!fayda) return;
const { email, phoneNumber: phone, nameEn, nameAm } = fayda.identity;
if (email) setValue('email', email);
if (phone) setValue('phoneNumber', phone);
if (nameEn) setValue('nameEn', nameEn);
if (nameAm) setValue('nameAm', nameAm);
}, [fayda, setValue]);
const startFayda = async () => {
setServerError(null);
setFaydaStarting(true);
try {
// Same endpoint the registration itself uses; `start` only opens the
// attempt and hands back where to send the user.
const { authorizationUrl, transactionToken, state } = await startTrigger({
url: '/auth/register-with-fayda',
method: 'POST',
body: { action: 'start' },
}).unwrap();
faydaSession.saveRequest({ transactionToken, state });
window.location.assign(authorizationUrl);
} catch (err: unknown) {
setFaydaStarting(false);
setServerError(handleError(err));
}
};
const clearFayda = () => {
faydaSession.clearResult();
setFayda(null);
};
const onSubmit = async (values: FormValues) => {
try {
const payload: SignupPayload = {
@@ -147,7 +238,28 @@ export function SignupPage() {
const me = await meTrigger({ url: '/auth/me', method: 'GET' }).unwrap();
dispatch(setUser(me));
if (data.isPhoneNumberVerified) {
// Records the Fayda-verified identity on the new account: marks the
// phone verified when it is the one Fayda vouched for, and fills the
// still-empty profile fields. Best-effort — the account already works,
// and the token can be presented again on a retry.
let faydaPhoneVerified = false;
if (fayda?.verificationToken) {
try {
const applied = await linkTrigger({
url: '/profiles/me/fayda',
method: 'POST',
body: { verificationToken: fayda.verificationToken },
}).unwrap();
faydaPhoneVerified = Boolean(applied?.phoneNumberVerified);
} catch {
/* deliberately ignored — signup already succeeded */
}
}
faydaSession.clearResult();
// Fayda already verified this exact number via its own OTP; asking for
// a second OTP on the same number is theatre.
if (data.isPhoneNumberVerified || faydaPhoneVerified) {
navigate(loginRedirectPath);
} else {
navigate('/otp-verify', {
@@ -212,6 +324,53 @@ export function SignupPage() {
</Alert>
)}
{fayda ? (
<Alert
variant="light"
color="teal"
icon={<IconRosetteDiscountCheck size={18} />}
title={t('fayda.verifiedTitle', 'Verified with Fayda')}
>
<Stack gap="xs">
<Text size="sm">
{t(
'fayda.verifiedBody',
'We filled in the details Fayda confirmed. Please complete the remaining fields.',
)}
</Text>
<Anchor size="sm" component="button" type="button" onClick={clearFayda}>
{t('fayda.discard', 'Clear these details and fill the form myself')}
</Anchor>
</Stack>
</Alert>
) : (
<>
<Button
variant="default"
size="md"
fullWidth
loading={faydaStarting}
leftSection={<IconId size={18} />}
onClick={startFayda}
>
{t('fayda.continueWith', 'Continue with Fayda')}
</Button>
<Divider
label={t('fayda.orFillManually', 'or fill in your details')}
labelPosition="center"
/>
</>
)}
{fayda && fayda.conflicts.length > 0 && (
<Alert variant="light" color="orange" icon={<IconAlertTriangle size={18} />}>
{t(
'fayda.conflictBody',
'Some verified details already belong to another account. Change the highlighted fields, or sign in instead.',
)}
</Alert>
)}
<form onSubmit={handleSubmit(onSubmit)}>
<Stack gap="md">
<SimpleGrid cols={{ base: 1, xs: 2 }} spacing="md">
@@ -220,6 +379,7 @@ export function SignupPage() {
placeholder={t('signup.nameEnPlaceholder', 'Abebe Bekele')}
leftSection={<IconUser size={18} />}
error={errors.nameEn?.message}
{...faydaMark('nameEn')}
{...register('nameEn')}
/>
<TextInput
@@ -227,6 +387,7 @@ export function SignupPage() {
placeholder={t('signup.nameAmPlaceholder', 'ስም')}
leftSection={<IconUser size={18} />}
error={errors.nameAm?.message}
{...faydaMark('nameAm')}
{...register('nameAm')}
/>
</SimpleGrid>
@@ -237,6 +398,7 @@ export function SignupPage() {
placeholder={t('signup.emailPlaceholder', 'you@example.com')}
leftSection={<IconMail size={18} />}
error={errors.email?.message}
{...faydaMark('email')}
{...register('email')}
/>
<TextInput
@@ -255,6 +417,7 @@ export function SignupPage() {
onChange={(val) => setValue('phoneNumber', val, { shouldValidate: !!errors.phoneNumber })}
onBlur={() => trigger('phoneNumber')}
error={errors.phoneNumber?.message}
{...faydaMark('phoneNumber')}
/>
<SimpleGrid cols={{ base: 1, xs: 2 }} spacing="md">

View File

@@ -47,6 +47,8 @@ export const LICENSE_PERMISSIONS = {
MANAGE_SEAFARER_STATUS: "can:manage:seafarer-status",
VIEW_SEAFARER_REGISTRY: "can:View:seafarer-registry",
VERIFY_SEAFARER_RECORDS: "can:verify:seafarer-records",
ENROLL_BIOMETRICS: "can:enroll:biometrics",
VIEW_BIOMETRICS: "can:View:biometrics",
VIEW_VESSEL_REGISTRY: "can:View:vessel-registry",
MANAGE_VESSEL_STATUS: "can:manage:vessel-status",
APPROVE_QUESTION: "can:approve:exam-question",
@@ -81,6 +83,7 @@ export const PORTAL_PERMISSIONS = {
APPLY_EXAM: "can:apply:exam",
VIEW_OWN_EXAM: "can:View:own-exam",
VIEW_OWN_CERTIFICATES: "can:View:own-certificates",
VIEW_OWN_BIOMETRICS: "can:View:own-biometrics",
APPLY_VESSEL_REGISTRATION: "can:apply:vessel-registration",
VIEW_OWN_VESSELS: "can:View:own-vessels",
REPORT_VESSEL_INCIDENT: "can:report:own-vessel-incident",

View File

@@ -84,6 +84,8 @@ export interface CurrentProfile {
* registration is approved, null before that.
*/
seafarerNumber?: string | null;
/** Biometric Subject ID — stamped by staff once biometric enrollment is confirmed. */
bsid?: string | null;
seafarerStatus?: 'ACTIVE' | 'INACTIVE' | 'PENDING' | 'SUSPENDED' | null;
seafarerDepartment?: 'DECK' | 'ENGINE' | 'CATERING' | null;
seafarerStatusReason?: string | null;

View File

@@ -0,0 +1,87 @@
/**
* The Fayda round trip leaves the app entirely, so the little state that has to
* survive it lives in sessionStorage: same tab, same origin, gone when the tab
* closes.
*
* Nothing secret is kept here. The `transactionToken` is signed by the API and
* useless without it — the PKCE verifier, the nonce and the client key never
* leave the backend.
*/
const REQUEST_KEY = 'fayda:request';
const RESULT_KEY = 'fayda:result';
export interface FaydaRequest {
transactionToken: string;
state: string;
}
export interface FaydaPrefill {
email?: string;
phoneNumber?: string;
nameEn?: string;
nameAm?: string;
/** Shown for context only — the signup form has no field for these. */
gender?: string;
address?: string;
birthdate?: string;
nationality?: string;
faydaNumber?: string;
}
/** Shape of `POST /auth/register-with-fayda` with `action: "verify"`. */
export interface FaydaResult {
identity: FaydaPrefill;
faydaVerified: boolean;
/** Signup fields Fayda vouched for. */
verifiedFields: string[];
/** Prefilled fields already taken by another account. */
conflicts: string[];
/**
* Encrypted proof of the verification, presented to POST /profiles/me/fayda
* after signup so the account and profile record what Fayda vouched for.
*/
verificationToken: string;
}
// Private browsing and locked-down browsers can throw on access, and a failure
// here should degrade to "no Fayda prefill", never break the signup page.
function read<T>(key: string): T | null {
try {
const raw = sessionStorage.getItem(key);
return raw ? (JSON.parse(raw) as T) : null;
} catch {
return null;
}
}
function write(key: string, value: unknown): void {
try {
sessionStorage.setItem(key, JSON.stringify(value));
} catch {
/* nothing to do — the flow reports a generic failure instead */
}
}
function clear(key: string): void {
try {
sessionStorage.removeItem(key);
} catch {
/* ignore */
}
}
export const faydaSession = {
saveRequest: (request: FaydaRequest) => write(REQUEST_KEY, request),
takeRequest: (): FaydaRequest | null => {
const request = read<FaydaRequest>(REQUEST_KEY);
// Single use: a stale token would otherwise be replayed against a fresh
// callback and fail with a confusing "session expired".
clear(REQUEST_KEY);
return request;
},
saveResult: (result: FaydaResult) => write(RESULT_KEY, result),
peekResult: (): FaydaResult | null => read<FaydaResult>(RESULT_KEY),
clearResult: () => clear(RESULT_KEY),
};

View File

@@ -1,9 +1,6 @@
import { authStorage } from "./auth-storage";
const BASE_API_URL =
(import.meta as { env?: Record<string, string> }).env?.[
"VITE_BASE_API_URL"
] ?? "http://localhost:3000/api";
import { BASE_API_URL } from "@ema-platform/api";
interface RefreshResponse {
token: string;

View File

@@ -6,6 +6,7 @@
"backoffice": "nx serve @ema-platform/backoffice",
"portal": "nx serve @ema-platform/portal",
"dev:all": "nx run-many -t serve -p @ema-platform/portal @ema-platform/backoffice --parallel=2",
"build": "nx run-many -t build -p @ema-platform/portal @ema-platform/backoffice",
"build:backoffice": "nx build @ema-platform/backoffice",
"build:portal": "nx build @ema-platform/portal",
"lint": "nx run-many -t lint",