diff --git a/apps/backoffice/src/app/router/index.tsx b/apps/backoffice/src/app/router/index.tsx
index a132b1f64..b017c7ce9 100644
--- a/apps/backoffice/src/app/router/index.tsx
+++ b/apps/backoffice/src/app/router/index.tsx
@@ -12,9 +12,24 @@ import { AuthLayout } from '../layouts/AuthLayout';
import { BackofficeLayout } from '../layouts/BackofficeLayout';
import { ProtectedRoute } from './ProtectedRoute';
import { DashboardPage } from '../features/dashboard/pages/DashboardPage';
-import UserManagementHostPage from '../features/user-management-host/UserManagementHostPage';
+import UserManagementPage from '../features/user-management/UserManagementPage';
import { ProfilePage } from '../features/profile/pages/ProfilePage';
+import { ConfigurationPage } from '../features/configuration/pages/ConfigurationPage';
import { LocationPage } from '../features/location/pages/LocationPage';
+import { AnalyticsPage } from '../features/analytics/pages/AnalyticsPage';
+import { ApplicationReviewPage } from '../features/applications/pages/ApplicationReviewPage';
+import { CoCQueuePage } from '../features/coc-queue/pages/CoCQueuePage';
+import { CoCReviewPage } from '../features/coc-queue/pages/CoCReviewPage';
+import { EndorsementQueuePage } from '../features/endorsement/pages/EndorsementQueuePage';
+import { EndorsementReviewPage } from '../features/endorsement/pages/EndorsementReviewPage';
+import { MedicalVerificationPage } from '../features/medical-verification/pages/MedicalVerificationPage';
+import { PaymentConfigPage } from '../features/payment-config/pages/PaymentConfigPage';
+import { SeafarerRegistryPage } from '../features/seafarer-registry/pages/SeafarerRegistryPage';
+import { SeamanBookQueuePage } from '../features/seaman-book-queue/pages/SeamanBookQueuePage';
+import { QuestionPage } from '../features/question/pages/QuestionPage';
+import { ExamPage } from '../features/exam/pages/ExamPage';
+import { ExamDetailPage } from '../features/exam/pages/ExamDetailPage';
+import { ResultPage } from '../features/result/pages/ResultPage';
const router = createBrowserRouter([
{
@@ -25,7 +40,9 @@ const router = createBrowserRouter([
{ path: '/otp-verify', element:
},
],
},
- { path: '/um/*', element:
},
+ { path: '/um/*', element:
},
+ { path: '/', element:
},
+ { path: '/profile-setup', element:
},
{
element:
,
children: [
@@ -35,7 +52,22 @@ const router = createBrowserRouter([
{ index: true, element:
},
{ path: 'dashboard', element:
},
{ path: 'profile', element:
},
+ { path: 'configuration', element:
},
{ path: 'locations', element:
},
+ { path: 'analytics', element:
},
+ { path: 'applications/:id', element:
},
+ { path: 'coc-queue', element:
},
+ { path: 'coc-queue/:id', element:
},
+ { path: 'endorsement-queue', element:
},
+ { path: 'endorsement-queue/:id', element:
},
+ { path: 'medical-verification', element:
},
+ { path: 'payment-config', element:
},
+ { path: 'seafarer-registry', element:
},
+ { path: 'seaman-book-queue', element:
},
+ { path: 'questions', element:
},
+ { path: 'exams', element:
},
+ { path: 'exams/:id', element:
},
+ { path: 'exam-results', element:
},
],
},
],
diff --git a/apps/backoffice/src/app/store/index.ts b/apps/backoffice/src/app/store/index.ts
index 1b00fdc9f..bb3ca733e 100644
--- a/apps/backoffice/src/app/store/index.ts
+++ b/apps/backoffice/src/app/store/index.ts
@@ -8,15 +8,17 @@ import {
refreshAccessToken,
logout,
} from '@ema-platform/auth';
-import type { AuthUser } from '@ema-platform/auth';
+import type { AuthUser, CurrentProfile } from '@ema-platform/auth';
+import { preferencesReducer } from './preferences.slice';
configureAuthStorage('ema-backoffice');
const preloadedAuth = (() => {
const token = authStorage.getToken();
const user = authStorage.getUser
();
+ const profile = authStorage.getProfile();
if (token && user) {
- return { token, user, isAuthenticated: true };
+ return { token, user, isAuthenticated: true, currentProfile: profile ?? null };
}
return undefined;
})();
@@ -25,6 +27,7 @@ export const store = configureStore({
reducer: {
auth: authReducer,
signup: signupReducer,
+ preferences: preferencesReducer,
[baseApi.reducerPath]: baseApi.reducer,
},
preloadedState: preloadedAuth ? { auth: preloadedAuth } : undefined,
diff --git a/apps/backoffice/src/app/store/preferences.slice.ts b/apps/backoffice/src/app/store/preferences.slice.ts
new file mode 100644
index 000000000..3b25d4b87
--- /dev/null
+++ b/apps/backoffice/src/app/store/preferences.slice.ts
@@ -0,0 +1,39 @@
+import { createSlice, type PayloadAction } from '@reduxjs/toolkit';
+
+export type LayoutMode = 'top' | 'sidebar';
+
+interface PreferencesState {
+ layoutMode: LayoutMode;
+}
+
+const PREFERENCES_KEY = 'ema-backoffice-preferences';
+
+const loadPreferences = (): PreferencesState => {
+ try {
+ const stored = localStorage.getItem(PREFERENCES_KEY);
+ if (stored) return JSON.parse(stored);
+ } catch {}
+ return { layoutMode: 'top' };
+};
+
+const savePreferences = (state: PreferencesState) => {
+ try {
+ localStorage.setItem(PREFERENCES_KEY, JSON.stringify(state));
+ } catch {}
+};
+
+const initialState: PreferencesState = loadPreferences();
+
+const preferencesSlice = createSlice({
+ name: 'preferences',
+ initialState,
+ reducers: {
+ setLayoutMode(state, action: PayloadAction) {
+ state.layoutMode = action.payload;
+ savePreferences(state);
+ },
+ },
+});
+
+export const { setLayoutMode } = preferencesSlice.actions;
+export const preferencesReducer = preferencesSlice.reducer;
diff --git a/apps/backoffice/src/main.tsx b/apps/backoffice/src/main.tsx
index 169d9c9e9..5ee5df572 100644
--- a/apps/backoffice/src/main.tsx
+++ b/apps/backoffice/src/main.tsx
@@ -7,6 +7,11 @@ import './styles.css';
import './app/i18n/config';
import { App } from './app/app';
+document.title = 'EMA Backoffice';
+
+const _favicon = document.querySelector('link[rel="icon"]');
+if (_favicon) _favicon.href = '/ema-logo.png';
+
window.__USER_MANAGEMENT_BRANDING__ = {
appName: 'Ethiopian Maritime Licence',
organizationName: 'Ethiopian Maritime Authority',
diff --git a/apps/backoffice/vite.config.mts b/apps/backoffice/vite.config.mts
index 1203f176b..281c14da1 100644
--- a/apps/backoffice/vite.config.mts
+++ b/apps/backoffice/vite.config.mts
@@ -2,20 +2,6 @@ import { defineConfig } from 'vite';
import react from '@vitejs/plugin-react';
import { nxViteTsPaths } from '@nx/vite/plugins/nx-tsconfig-paths.plugin';
-function userManagementSpaFallback() {
- const rewrite = (req) => {
- const url = req.url || '';
- if (!url.startsWith('/_um/') && url !== '/_um') return;
- if (/\.[a-zA-Z0-9]+$/.test(url.split('?')[0])) return; // real assets pass through
- req.url = '/_um/index.html';
- };
- return {
- name: 'user-management-spa-fallback',
- configureServer(s) { s.middlewares.use((req, _res, next) => { rewrite(req); next(); }); },
- configurePreviewServer(s) { s.middlewares.use((req, _res, next) => { rewrite(req); next(); }); },
- };
-}
-
export default defineConfig({
root: __dirname,
cacheDir: '../../node_modules/.vite/apps/backoffice',
@@ -24,7 +10,7 @@ export default defineConfig({
host: 'localhost',
},
preview: { port: 4201, host: 'localhost' },
- plugins: [react(), nxViteTsPaths(), userManagementSpaFallback()],
+ plugins: [react(), nxViteTsPaths()],
resolve: {
dedupe: ['react', 'react-dom', 'react-router-dom', '@tanstack/react-query'],
},
diff --git a/apps/portal/src/app/app.tsx b/apps/portal/src/app/app.tsx
index 6b88b3ca3..5a29a78fa 100644
--- a/apps/portal/src/app/app.tsx
+++ b/apps/portal/src/app/app.tsx
@@ -1,10 +1,7 @@
import { RouterProvider } from 'react-router-dom';
-import { configureIam } from '@tria-plc/iamui-common';
import { AppProviders } from './providers/AppProviders';
import { router } from './router';
-// IAM module configuration (used by the isolated /users admin route).
-configureIam({ apiUrl: 'http://localhost:3001/api' });
export function App() {
return (
diff --git a/apps/portal/src/app/components/ProfileGuard.tsx b/apps/portal/src/app/components/ProfileGuard.tsx
new file mode 100644
index 000000000..b7f38216a
--- /dev/null
+++ b/apps/portal/src/app/components/ProfileGuard.tsx
@@ -0,0 +1,17 @@
+import { Navigate } from 'react-router-dom';
+import type { ReactNode } from 'react';
+import { authStorage } from '@ema-platform/auth';
+
+interface ProfileGuardProps {
+ children?: ReactNode;
+}
+
+export function ProfileGuard({ children }: ProfileGuardProps) {
+ const profileId = authStorage.getProfileId();
+
+ if (!profileId) {
+ return ;
+ }
+
+ return <>{children}>;
+}
diff --git a/apps/portal/src/app/features/basic-safety-training/pages/BasicSafetyTrainingPage.tsx b/apps/portal/src/app/features/basic-safety-training/pages/BasicSafetyTrainingPage.tsx
new file mode 100644
index 000000000..1b17e5913
--- /dev/null
+++ b/apps/portal/src/app/features/basic-safety-training/pages/BasicSafetyTrainingPage.tsx
@@ -0,0 +1,446 @@
+import { useRef, useState } from 'react';
+import {
+ Alert,
+ Badge,
+ Box,
+ Button,
+ Card,
+ FileButton,
+ Group,
+ Modal,
+ Paper,
+ Progress,
+ SimpleGrid,
+ Stack,
+ Text,
+ TextInput,
+ ThemeIcon,
+ Title,
+ rem,
+} from '@mantine/core';
+import {
+ IconAlertTriangle,
+ IconBook2,
+ IconCalendar,
+ IconCheck,
+ IconCircleCheck,
+ IconDownload,
+ IconFileDescription,
+ IconInfoCircle,
+ IconRefresh,
+ IconShield,
+ IconShieldCheck,
+ IconTrash,
+ IconUpload,
+ IconX,
+} from '@tabler/icons-react';
+import { notify } from '@ema-platform/ui';
+
+// ---------------------------------------------------------------------------
+// Types & constants
+// ---------------------------------------------------------------------------
+interface BSTItem {
+ key: string;
+ label: string;
+ shortLabel: string;
+ description: string;
+ modelCourse: string;
+ refreshYears: number;
+ required: boolean;
+}
+
+interface BSTRecord {
+ key: string;
+ issuer: string;
+ issueDate: string;
+ expiryDate: string;
+ status: 'Valid' | 'Expiring' | 'Expired' | 'Pending Verification';
+ certNumber: string;
+ fileName: string;
+}
+
+const BST_ITEMS: BSTItem[] = [
+ {
+ key: 'pst',
+ label: 'Personal Survival Techniques',
+ shortLabel: 'PST',
+ description: 'Covers lifeboat/life-raft operation, survival at sea, and distress signals.',
+ modelCourse: 'IMO 1.19',
+ refreshYears: 5,
+ required: true,
+ },
+ {
+ key: 'fpff',
+ label: 'Fire Prevention & Fire Fighting',
+ shortLabel: 'FPFF',
+ description: 'Covers fire prevention, detection, and fire-fighting on board vessels.',
+ modelCourse: 'IMO 1.20',
+ refreshYears: 5,
+ required: true,
+ },
+ {
+ key: 'efa',
+ label: 'Elementary First Aid',
+ shortLabel: 'EFA',
+ description: 'Basic first-aid procedures, CPR, and medical emergency response.',
+ modelCourse: 'IMO 1.13',
+ refreshYears: 0,
+ required: true,
+ },
+ {
+ key: 'pssr',
+ label: 'Personal Safety & Social Responsibility',
+ shortLabel: 'PSSR',
+ description: 'Shipboard safety culture, regulations, and working relationships.',
+ modelCourse: 'IMO 1.21',
+ refreshYears: 0,
+ required: true,
+ },
+ {
+ key: 'shp',
+ label: 'Sexual Harassment Prevention Training',
+ shortLabel: 'SHPT',
+ description: 'Awareness and prevention of harassment in the maritime workplace.',
+ modelCourse: 'EMA National',
+ refreshYears: 0,
+ required: true,
+ },
+];
+
+const MOCK_RECORDS: Record = {
+ pst: {
+ key: 'pst',
+ issuer: 'Bahirdar Maritime School',
+ issueDate: '2023-04-10',
+ expiryDate: '2028-04-09',
+ status: 'Valid',
+ certNumber: 'PST-2023-BMS-0421',
+ fileName: 'pst_certificate.pdf',
+ },
+ fpff: {
+ key: 'fpff',
+ issuer: 'Bahirdar Maritime School',
+ issueDate: '2023-04-10',
+ expiryDate: '2028-04-09',
+ status: 'Valid',
+ certNumber: 'FPFF-2023-BMS-0421',
+ fileName: 'fpff_certificate.pdf',
+ },
+};
+
+const STATUS_COLOR: Record = {
+ Valid: 'teal',
+ Expiring: 'orange',
+ Expired: 'red',
+ 'Pending Verification': 'yellow',
+};
+
+function daysUntil(dateStr: string) {
+ return Math.ceil((new Date(dateStr).getTime() - Date.now()) / (1000 * 60 * 60 * 24));
+}
+
+function formatDate(dateStr: string) {
+ return new Date(dateStr).toLocaleDateString('en-GB', { day: '2-digit', month: 'short', year: 'numeric' });
+}
+
+// ---------------------------------------------------------------------------
+// Upload modal
+// ---------------------------------------------------------------------------
+function UploadModal({
+ item,
+ opened,
+ onClose,
+ onUploaded,
+}: {
+ item: BSTItem | null;
+ opened: boolean;
+ onClose: () => void;
+ onUploaded: (key: string, record: BSTRecord) => void;
+}) {
+ const [file, setFile] = useState(null);
+ const [issuer, setIssuer] = useState('');
+ const [certNumber, setCertNumber] = useState('');
+ const [issueDate, setIssueDate] = useState('');
+ const [expiryDate, setExpiryDate] = useState('');
+ const [submitting, setSubmitting] = useState(false);
+ const resetRef = useRef<() => void>(null);
+
+ const reset = () => {
+ setFile(null); setIssuer(''); setCertNumber(''); setIssueDate(''); setExpiryDate('');
+ resetRef.current?.();
+ };
+
+ const handleSubmit = async () => {
+ if (!file || !issuer || !certNumber || !issueDate) {
+ notify.error('Please fill all required fields and upload the certificate.');
+ return;
+ }
+ setSubmitting(true);
+ await new Promise((r) => setTimeout(r, 1000));
+ setSubmitting(false);
+ onUploaded(item!.key, {
+ key: item!.key,
+ issuer,
+ issueDate,
+ expiryDate: expiryDate || '',
+ status: 'Pending Verification',
+ certNumber,
+ fileName: file.name,
+ });
+ notify.success(`${item!.shortLabel} certificate submitted for verification.`);
+ reset();
+ onClose();
+ };
+
+ return (
+ { reset(); onClose(); }} title={`Upload ${item?.label}`} size="md" centered>
+ {item && (
+
+ } p="xs">
+ {item.description} — Model Course: {item.modelCourse}
+
+ setIssuer(e.currentTarget.value)} size="sm" />
+ setCertNumber(e.currentTarget.value)} size="sm" />
+
+ setIssueDate(e.currentTarget.value)} size="sm" />
+ {item.refreshYears > 0 && (
+ setExpiryDate(e.currentTarget.value)} size="sm" />
+ )}
+
+
+ Certificate File *
+ {file ? (
+
+
+
+ {file.name}
+ { setFile(null); resetRef.current?.(); }}>
+
+
+
+
+ ) : (
+
+ {(props) => (
+ } fullWidth {...props}>
+ Choose File (PDF / JPG / PNG)
+
+ )}
+
+ )}
+
+
+ { reset(); onClose(); }}>Cancel
+ }>Submit
+
+
+ )}
+
+ );
+}
+
+// ---------------------------------------------------------------------------
+// Main page
+// ---------------------------------------------------------------------------
+export function BasicSafetyTrainingPage() {
+ const [records, setRecords] = useState>(MOCK_RECORDS);
+ const [modalItem, setModalItem] = useState(null);
+
+ const doneCount = BST_ITEMS.filter((i) => records[i.key]).length;
+ const allDone = doneCount === BST_ITEMS.length;
+
+ const handleUploaded = (key: string, record: BSTRecord) => {
+ setRecords((prev) => ({ ...prev, [key]: record }));
+ };
+
+ return (
+
+ {/* Header */}
+
+
+
Basic Safety Training
+
+ All seafarers must complete 5 mandatory BST certificates before joining a vessel (STCW Chapter VI).
+
+
+ : }
+ >
+ {doneCount} / {BST_ITEMS.length} Complete
+
+
+
+ {/* Overall progress */}
+
+
+ Overall Completion
+ {Math.round((doneCount / BST_ITEMS.length) * 100)}%
+
+
+ {!allDone && (
+ } p="sm">
+
+ You need all 5 certificates to apply for a Seaman Book. Missing: {BST_ITEMS.filter((i) => !records[i.key]).map((i) => i.shortLabel).join(', ')}
+
+
+ )}
+ {allDone && (
+ } p="sm">
+
+ All 5 BST training certificates are complete. You can now apply for your Seaman Book & BTC —
+ the Basic Training Certificate (BTC) is issued by EMA alongside your Seaman Book after your application is approved.
+
+
+ )}
+
+
+ {/* Certificate cards */}
+
+ {BST_ITEMS.map((item) => {
+ const rec = records[item.key];
+ const days = rec?.expiryDate ? daysUntil(rec.expiryDate) : null;
+ const needsRefresh = item.refreshYears > 0;
+
+ return (
+
+
+
+
+ {rec ? : }
+
+
+ {item.shortLabel}
+ {item.modelCourse}
+
+
+ {rec ? (
+ {rec.status}
+ ) : (
+ Missing
+ )}
+
+
+ {item.label}
+
+ {rec ? (
+
+
+ Certificate No.
+ {rec.certNumber}
+
+
+ Issuer
+ {rec.issuer}
+
+
+ Issued
+ {formatDate(rec.issueDate)}
+
+ {needsRefresh && rec.expiryDate && (
+ <>
+
+ Expires
+
+ {formatDate(rec.expiryDate)}
+
+
+ {days !== null && (
+
+ )}
+ >
+ )}
+ {needsRefresh && (
+
+ {item.refreshYears}-year refresh required
+
+ )}
+
+ } flex={1}>
+ Download
+
+ } flex={1} onClick={() => setModalItem(item)}>
+ Update
+
+
+
+ ) : (
+
+ {item.description}
+ {needsRefresh && (
+
+ Requires {item.refreshYears}-year refresh
+
+ )}
+ }
+ onClick={() => setModalItem(item)}
+ fullWidth
+ mt="xs"
+ >
+ Upload Certificate
+
+
+ )}
+
+ );
+ })}
+
+
+ {/* Info */}
+
+
+
+ About Basic Safety Training (STCW VI/1)
+
+
+
+
+ Basic Safety Training is mandatory for all seafarers regardless of department (Deck, Engine, or Catering).
+ PST and FPFF certificates require evidence of maintained competence every 5 years .
+ EFA and PSSR do not have a mandatory 5-year repeat under STCW.
+
+
+
+
+ Certificates must be from EMA-approved training institutions (e.g. Bahirdar Maritime School, Babugaya Maritime School).
+ EMA officers will verify authenticity before approving your Seaman Book application.
+
+
+
+
+
+ {/* Upload modal */}
+ setModalItem(null)}
+ onUploaded={handleUploaded}
+ />
+
+ );
+}
diff --git a/apps/portal/src/app/features/certificates/pages/CertificatesPage.tsx b/apps/portal/src/app/features/certificates/pages/CertificatesPage.tsx
new file mode 100644
index 000000000..b072a9ecd
--- /dev/null
+++ b/apps/portal/src/app/features/certificates/pages/CertificatesPage.tsx
@@ -0,0 +1,272 @@
+import { useState } from 'react';
+import { useNavigate } from 'react-router-dom';
+import {
+ Alert,
+ Badge,
+ Button,
+ Card,
+ Divider,
+ Group,
+ Loader,
+ Modal,
+ Paper,
+ SimpleGrid,
+ Stack,
+ Table,
+ Text,
+ ThemeIcon,
+ Title,
+ rem,
+} from '@mantine/core';
+import { notifications } from '@mantine/notifications';
+import {
+ IconArrowRight,
+ IconBook2,
+ IconCertificate,
+ IconClock,
+ IconDownload,
+ IconEye,
+ IconInfoCircle,
+ IconShieldCheck,
+} from '@tabler/icons-react';
+import { authStorage } from '@ema-platform/auth';
+
+// ---------------------------------------------------------------------------
+// Mock data
+// ---------------------------------------------------------------------------
+const MOCK_COC_APPS = [
+ {
+ id: 'COC-APP-2025-001',
+ type: 'CoC — STCW II/1 Officer in Charge of Navigational Watch',
+ submitted: '2025-03-10',
+ examDate: '2025-04-15',
+ examVenue: 'EMA HQ — Addis Ababa',
+ status: 'Examination Scheduled',
+ statusColor: 'indigo',
+ statusNote: 'TRB inspected and approved by EMA officer. Attend your scheduled examination.',
+ },
+ {
+ id: 'COC-APP-2025-005',
+ type: 'CoC — STCW II/5 Able Seafarer Deck (AB)',
+ submitted: '2025-05-01',
+ examDate: null,
+ examVenue: null,
+ status: 'TRB Inspection',
+ statusColor: 'yellow',
+ statusNote: 'Your TRB is being physically inspected by an EMA officer. You may be contacted to bring the original document.',
+ },
+];
+
+const MOCK_CERTIFICATES = [
+ {
+ id: 'COC-2023-0042',
+ type: 'CoC — STCW II/1',
+ issued: '2023-06-20',
+ expiry: '2028-06-20',
+ status: 'Valid',
+ statusColor: 'teal',
+ },
+];
+
+const API_BASE =
+ (import.meta as { env?: Record }).env?.['VITE_BASE_API_URL'] ??
+ 'http://localhost:3001/api';
+
+async function generateCertificate(profileId: string): Promise {
+ const token = authStorage.getToken();
+ if (!token) throw new Error('No auth token found');
+ const res = await fetch(
+ `${API_BASE}/profiles/generate-seafarer-certificate/${profileId}`,
+ { headers: { Authorization: `Bearer ${token}` } },
+ );
+ if (!res.ok) throw new Error(`Failed to generate 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);
+}
+
+export function CertificatesPage() {
+ const navigate = useNavigate();
+ const profileId = authStorage.getProfileId() ?? '';
+ const [previewUrl, setPreviewUrl] = useState(null);
+ const [previewTitle, setPreviewTitle] = useState('');
+ const [loading, setLoading] = useState(false);
+
+ const openPreview = async (profileId: string, title: string) => {
+ setLoading(true);
+ try {
+ const blob = await generateCertificate(profileId);
+ const url = URL.createObjectURL(blob);
+ setPreviewTitle(title);
+ setPreviewUrl(url);
+ } catch (err) {
+ notifications.show({
+ color: 'red',
+ title: 'Error',
+ message: err instanceof Error ? err.message : 'Could not generate certificate',
+ });
+ } finally {
+ setLoading(false);
+ }
+ };
+
+ const handleDownload = async (profileId: string, title: string) => {
+ try {
+ const blob = await generateCertificate(profileId);
+ downloadBlob(blob, `certificate-${Date.now()}.pdf`);
+ notifications.show({
+ color: 'teal',
+ title: 'Downloaded',
+ message: 'Certificate PDF downloaded successfully',
+ });
+ } catch (err) {
+ notifications.show({
+ color: 'red',
+ title: 'Error',
+ message: err instanceof Error ? err.message : 'Could not download certificate',
+ });
+ }
+ };
+
+ return (
+
+
+
+
Certificates (CoC / CoP)
+ Certificate of Competency and Certificate of Proficiency under STCW
+
+ }
+ rightSection={ }
+ onClick={() => navigate('/certificates/apply')}
+ >
+ Apply for CoC / CoP
+
+
+
+ {/* Info banner */}
+
+
+
+
+ What is a CoC / CoP?
+
+ {[
+ { icon: IconBook2, color: 'blue', title: 'Certificate of Competency (CoC)', desc: 'Authorizes the holder to serve as an officer or master on board a ship under STCW.' },
+ { icon: IconCertificate, color: 'teal', title: 'Certificate of Proficiency (CoP)', desc: 'Certifies completion of specific STCW training for specialized duties on board.' },
+ { icon: IconClock, color: 'orange', title: 'Validity', desc: 'CoC/CoP certificates are valid for 5 years and must be revalidated before expiry.' },
+ ].map(({ icon: Icon, color, title, desc }) => (
+
+
+
+ {title}
+
+ {desc}
+
+ ))}
+
+
+
+
+
+ {/* Active applications */}
+
+ My Applications
+ {MOCK_COC_APPS.length === 0 ? (
+ }>
+ No active CoC/CoP applications. Click "Apply for CoC / CoP" to start.
+
+ ) : (
+
+
+
+ {['App ID', 'Certificate Type', 'Submitted', 'Exam Date / Status Note', 'Status', ''].map((h) => (
+ {h}
+ ))}
+
+
+
+ {MOCK_COC_APPS.map((app) => (
+
+ {app.id}
+ {app.type}
+ {app.submitted}
+
+ {app.examDate
+ ? <>{app.examDate} {app.examVenue} >
+ : {app.statusNote} }
+
+
+ {app.status}
+
+
+ Details
+
+
+ ))}
+
+
+ )}
+
+
+ {/* Issued certificates */}
+
+ My Certificates
+ {MOCK_CERTIFICATES.length === 0 ? (
+ }>
+ No certificates issued yet.
+
+ ) : (
+
+ {MOCK_CERTIFICATES.map((cert) => (
+
+
+
+
+
+ {cert.type}
+ {cert.id}
+
+
+ {cert.status}
+
+
+
+ Issued {cert.issued}
+ Expires {cert.expiry}
+
+
+ : } onClick={() => openPreview(profileId, cert.type)}>View
+ } onClick={() => handleDownload(profileId, cert.type)}>Download
+
+
+ ))}
+
+ )}
+
+
+ {/* Preview modal */}
+ setPreviewUrl(null)}
+ title={{previewTitle} }
+ size="95vw"
+ radius="lg"
+ fullScreen
+ >
+
+
+
+ );
+}
diff --git a/apps/portal/src/app/features/certificates/pages/CoCApplicationPage.tsx b/apps/portal/src/app/features/certificates/pages/CoCApplicationPage.tsx
new file mode 100644
index 000000000..667077cc3
--- /dev/null
+++ b/apps/portal/src/app/features/certificates/pages/CoCApplicationPage.tsx
@@ -0,0 +1,1094 @@
+import { useState } from 'react';
+import { useNavigate } from 'react-router-dom';
+import {
+ Alert,
+ Badge,
+ Box,
+ Button,
+ Card,
+ Collapse,
+ Divider,
+ FileInput,
+ Group,
+ List,
+ Paper,
+ Select,
+ SimpleGrid,
+ Stack,
+ Stepper,
+ Text,
+ TextInput,
+ ThemeIcon,
+ Title,
+} from '@mantine/core';
+import {
+ IconAlertCircle,
+ IconArrowLeft,
+ IconArrowRight,
+ IconAward,
+ IconBook2,
+ IconCheck,
+ IconChevronDown,
+ IconChevronUp,
+ IconCircleCheck,
+ IconClock,
+ IconCreditCard,
+ IconFileDescription,
+ IconInfoCircle,
+ IconShieldCheck,
+ IconUpload,
+} from '@tabler/icons-react';
+import { notify } from '@ema-platform/ui';
+
+// ---------------------------------------------------------------------------
+// STCW certificate catalog — sourced from the STCW Convention & Code
+// ---------------------------------------------------------------------------
+
+export type Department = 'deck' | 'engine' | 'electro' | 'catering';
+
+interface Competency {
+ area: string;
+ items: string[];
+}
+
+interface CertDef {
+ id: string;
+ dept: Department[];
+ type: 'CoC' | 'CoP';
+ stcwRef: string;
+ level: 'Support' | 'Operational' | 'Management';
+ label: string;
+ eligibleRanks: string;
+ prerequisiteId: string | null; // must hold this cert first
+ minAge: number;
+ seaService: string; // human-readable sea-service requirement
+ mandatoryTraining: string[];
+ competencies: Competency[];
+ validityYears: number;
+ revalidationRule: string;
+ notes: string;
+}
+
+const CERT_CATALOG: CertDef[] = [
+ // ── DECK ──────────────────────────────────────────────────────────────────
+ {
+ id: 'rfpnw',
+ dept: ['deck'],
+ type: 'CoP',
+ stcwRef: 'Reg. II/4 — Code A-II/4',
+ level: 'Support',
+ label: 'Rating Forming Part of a Navigational Watch (RFPNW)',
+ eligibleRanks: 'Lookout, Helmsman, Watch Rating',
+ prerequisiteId: null,
+ minAge: 16,
+ seaService: 'At least 6 months approved sea-service/training; OR special training plus 2 months approved sea service with direct supervision.',
+ mandatoryTraining: ['Basic Safety Training (STCW VI/1)', 'Approved national RFPNW programme'],
+ competencies: [
+ { area: 'Navigational Watch', items: ['Keep a safe lookout by sight and hearing', 'Helm orders and steering', 'Handover/relief procedures', 'Vessel communications and signals', 'Anchor watch routines'] },
+ { area: 'Safety', items: ['Personal survival, fire prevention & fighting, elementary first aid, personal safety (VI/1)', 'Pollution prevention awareness'] },
+ ],
+ validityYears: 5,
+ revalidationRule: 'No mandatory STCW periodic revalidation; national practice varies. Maintain Basic Safety evidence every 5 years (PST & FPFF elements).',
+ notes: 'Duties must be directly supervised by a qualified officer.',
+ },
+ {
+ id: 'ab-deck',
+ dept: ['deck'],
+ type: 'CoP',
+ stcwRef: 'Reg. II/5 — Code A-II/5',
+ level: 'Support',
+ label: 'Able Seafarer Deck (AB)',
+ eligibleRanks: 'Able Seafarer, Deck Rating, Bosun candidate',
+ prerequisiteId: 'rfpnw',
+ minAge: 18,
+ seaService: 'Must already hold RFPNW CoP. Then: at least 18 months approved deck sea service while qualified as RFPNW; OR at least 12 months plus approved AB training (IMO MC 7.10).',
+ mandatoryTraining: ['RFPNW CoP (prerequisite)', 'Approved AB Deck training — IMO Model Course 7.10'],
+ competencies: [
+ { area: 'Navigation', items: ['Maintain a safe navigational watch', 'Use of navigational aids including ECDIS', 'Determine compass error by celestial and terrestrial means', 'Contribute to monitoring and controlling vessel position'] },
+ { area: 'Cargo Operations', items: ['Handle, stow and secure cargo', 'Use deck equipment and machinery', 'Rig and operate equipment used in cargo operations'] },
+ { area: 'Ship Operations', items: ['Mooring and anchoring operations', 'Maintenance of the ship and equipment', 'Fuel and ballast transfers awareness'] },
+ { area: 'Safety & Emergency', items: ['Operate survival craft and rescue boats', 'Fight and extinguish fires', 'Apply first aid', 'Contribute to prevention of marine pollution'] },
+ ],
+ validityYears: 5,
+ revalidationRule: 'No mandatory STCW stand-alone periodic revalidation; maintain BST evidence every 5 years.',
+ notes: 'IMO Model Course 7.10 is the implementation guidance for this CoP.',
+ },
+ {
+ id: 'oicnw',
+ dept: ['deck'],
+ type: 'CoC',
+ stcwRef: 'Reg. II/1 — Code A-II/1',
+ level: 'Operational',
+ label: 'Officer in Charge of a Navigational Watch — 500 GT or more (OICNW)',
+ eligibleRanks: 'Third Officer, Second Officer, Watch-keeping Officer',
+ prerequisiteId: null,
+ minAge: 18,
+ seaService: 'Option A: Approved training programme with approved Training Record Book plus at least 12 months approved sea service (including 6 months bridge watchkeeping under supervision). Option B: 36 months approved sea service (including 6 months supervised bridge watchkeeping).',
+ mandatoryTraining: [
+ 'Approved deck officer education programme (or equivalent sea service)',
+ 'GMDSS training applicable under Chapter IV',
+ 'Bridge Resource Management — IMO MC 1.22',
+ 'ECDIS familiarization — IMO MC 1.27',
+ 'Leadership and Teamwork — IMO MC 1.39',
+ ],
+ competencies: [
+ { area: 'Navigation at Operational Level', items: ['Plan and conduct a passage; determine position', 'Maintain a safe watch under COLREGS', 'Use of radar/ARPA, ECDIS and all bridge equipment', 'Respond to navigational emergencies'] },
+ { area: 'Cargo Handling & Stowage', items: ['Cargo handling at operational level', 'Stowage planning and securing awareness', 'Load and stability calculations'] },
+ { area: 'Control of Ship Operations', items: ['Monitor and control compliance with legal requirements', 'Prevent, control and fight fire', 'Operate life-saving appliances'] },
+ { area: 'Marine Engineering', items: ['Basic knowledge of main and auxiliary machinery', 'Knowledge of electrical systems'] },
+ { area: 'Radio Communication', items: ['Transmit and receive voice messages under GMDSS', 'Distress and safety procedures'] },
+ { area: 'Leadership & Teamwork', items: ['Assign and prioritize resources', 'Effective communication with bridge team', 'Apply task and workload management'] },
+ ],
+ validityYears: 5,
+ revalidationRule: 'Revalidation every 5 years under Regulation I/11: demonstrate continued competence and hold valid medical certificate.',
+ notes: 'Near-coastal limitations may restrict the scope of the CoC. Assessment against table A-II/1 methods and criteria.',
+ },
+ {
+ id: 'chief-mate-500',
+ dept: ['deck'],
+ type: 'CoC',
+ stcwRef: 'Reg. II/2 — Code A-II/2',
+ level: 'Management',
+ label: 'Chief Mate — Ships 500–3,000 GT (Management Level)',
+ eligibleRanks: 'Chief Mate on ships 500–3,000 GT',
+ prerequisiteId: 'oicnw',
+ minAge: 18,
+ seaService: 'Must hold OICNW CoC. Then: at least 12 months approved sea service as an officer in charge of a navigational watch.',
+ mandatoryTraining: ['OICNW CoC (prerequisite)', 'Leadership and Managerial Skills — IMO MC 1.40', 'Approved management-level education programme — IMO MC 7.01'],
+ competencies: [
+ { area: 'Navigation at Management Level', items: ['Plan voyages and conduct navigation at management level', 'Determine vessel position using all available means', 'Monitor bridge team performance'] },
+ { area: 'Cargo Handling at Management Level', items: ['Plan and ensure safe loading, stowage, securing, care and unloading of cargo', 'Stability, trim and stress calculations and control'] },
+ { area: 'Control of Ship Operations', items: ['Monitor and control compliance with legislative requirements', 'Ensure the safety of personnel, vessel, cargo and marine environment'] },
+ { area: 'Leadership & Management', items: ['Manage and supervise deck department', 'Initiate and manage emergency procedures', 'Manage crew performance and well-being'] },
+ ],
+ validityYears: 5,
+ revalidationRule: 'Revalidation every 5 years under Regulation I/11.',
+ notes: 'Assessment against table A-II/2.',
+ },
+ {
+ id: 'master-500',
+ dept: ['deck'],
+ type: 'CoC',
+ stcwRef: 'Reg. II/2 — Code A-II/2',
+ level: 'Management',
+ label: 'Master — Ships 500–3,000 GT',
+ eligibleRanks: 'Master on ships 500–3,000 GT',
+ prerequisiteId: 'chief-mate-500',
+ minAge: 18,
+ seaService: 'Must hold Chief Mate CoC. Then: at least 36 months approved sea service in the deck department, reducible to 24 months if 12 months served as chief mate.',
+ mandatoryTraining: ['Chief Mate CoC (prerequisite)', 'Leadership and Managerial Skills — IMO MC 1.40', 'Approved management-level programme — IMO MC 7.01'],
+ competencies: [
+ { area: 'Command & Navigation', items: ['Full command responsibility for the safety of ship, crew, cargo and the environment', 'Management-level passage planning and execution', 'Emergency command during distress, fire, collision, grounding'] },
+ { area: 'Cargo & Stability Management', items: ['Full responsibility for cargo operations and stability', 'Damage stability and emergency procedures'] },
+ { area: 'Legal & Administrative', items: ['Compliance with international and flag-state law', 'Ship documentation, protest, log maintenance', 'Crew management and discipline'] },
+ { area: 'Ship Management', items: ['Operate and manage all shipboard systems at management level', 'Crisis and emergency management'] },
+ ],
+ validityYears: 5,
+ revalidationRule: 'Revalidation every 5 years under Regulation I/11.',
+ notes: 'Highest deck certificate. Assessment against table A-II/2.',
+ },
+ {
+ id: 'master-3000',
+ dept: ['deck'],
+ type: 'CoC',
+ stcwRef: 'Reg. II/2 — Code A-II/2',
+ level: 'Management',
+ label: 'Master / Chief Mate — Ships 3,000 GT or more',
+ eligibleRanks: 'Master, Chief Mate on ships 3,000 GT or more',
+ prerequisiteId: 'master-500',
+ minAge: 20,
+ seaService: 'Must hold Master 500–3,000 GT CoC with adequate service on vessels of 3,000 GT or more, as determined by the Administration.',
+ mandatoryTraining: ['Master 500–3,000 GT CoC (prerequisite)', 'Approved management-level programme — IMO MC 7.01'],
+ competencies: [
+ { area: 'All Master competencies', items: ['Same as Master 500–3,000 GT but for vessels of 3,000 GT or more and all trade areas'] },
+ ],
+ validityYears: 5,
+ revalidationRule: 'Revalidation every 5 years under Regulation I/11.',
+ notes: 'Endorsement may be required for near-coastal limitations.',
+ },
+
+ // ── ENGINE ────────────────────────────────────────────────────────────────
+ {
+ id: 'rfpew',
+ dept: ['engine'],
+ type: 'CoP',
+ stcwRef: 'Reg. III/4 — Code A-III/4',
+ level: 'Support',
+ label: 'Rating Forming Part of an Engineering Watch (RFPEW)',
+ eligibleRanks: 'Engine-room Rating, Watchkeeper',
+ prerequisiteId: null,
+ minAge: 16,
+ seaService: 'At least 6 months approved sea-service/training; OR special training plus 2 months approved sea service.',
+ mandatoryTraining: ['Basic Safety Training (STCW VI/1)', 'Approved RFPEW national programme — IMO MC 7.09'],
+ competencies: [
+ { area: 'Engineering Watch Support', items: ['Understand orders and be understood', 'Use appropriate tools and equipment safely', 'Handover/relief procedures in engine room', 'Operate machinery under supervision', 'Maintain engineering watch routines'] },
+ { area: 'Safety', items: ['Personal survival, fire prevention & fighting, elementary first aid, personal safety (VI/1)', 'Pollution prevention awareness'] },
+ ],
+ validityYears: 5,
+ revalidationRule: 'No mandatory STCW periodic revalidation; maintain BST evidence every 5 years.',
+ notes: 'IMO Model Course 7.09 is the implementation guidance.',
+ },
+ {
+ id: 'ab-engine',
+ dept: ['engine'],
+ type: 'CoP',
+ stcwRef: 'Reg. III/5 — Code A-III/5',
+ level: 'Support',
+ label: 'Able Seafarer Engine (AB Engine)',
+ eligibleRanks: 'AB Engine, Motorman, Senior Rating',
+ prerequisiteId: 'rfpew',
+ minAge: 18,
+ seaService: 'Must hold RFPEW CoP. Then: at least 12 months approved sea service in engine department; OR at least 6 months plus approved training (IMO MC 7.16).',
+ mandatoryTraining: ['RFPEW CoP (prerequisite)', 'Approved AB Engine training — IMO Model Course 7.16'],
+ competencies: [
+ { area: 'Marine Engineering', items: ['Monitor and control propulsion machinery and auxiliaries', 'Maintain and repair mechanical systems', 'Safe use of workshop tools and equipment'] },
+ { area: 'Electrical & Control Systems', items: ['Monitor electrical systems and distribution panels', 'Basic fault-finding in electrical/electronic control circuits'] },
+ { area: 'Ship Operations', items: ['Contribute to fuelling operations', 'Bilge and ballast system operations', 'Safe transfer of liquids'] },
+ { area: 'Safety & Emergency', items: ['Respond to engineering emergencies', 'Fire prevention and firefighting in engine room', 'Pollution prevention from engineering systems'] },
+ ],
+ validityYears: 5,
+ revalidationRule: 'No mandatory STCW stand-alone periodic revalidation; maintain BST evidence every 5 years.',
+ notes: 'IMO Model Course 7.16 is the implementation guidance.',
+ },
+ {
+ id: 'oicew',
+ dept: ['engine'],
+ type: 'CoC',
+ stcwRef: 'Reg. III/1 — Code A-III/1',
+ level: 'Operational',
+ label: 'Officer in Charge of an Engineering Watch (OICEW)',
+ eligibleRanks: 'Junior Engineer, Third/Second Engineer (watchkeeping)',
+ prerequisiteId: null,
+ minAge: 18,
+ seaService: 'Option A: Approved engineer training programme with approved Training Record Book plus at least 12 months sea service (including 6 months supervised engine-room watchkeeping). Option B: 36 months combined workshop/sea service, at least 30 months at sea with 6 months supervised watchkeeping.',
+ mandatoryTraining: [
+ 'Approved engineering education programme (or equivalent)',
+ 'Engine-Room Resource Management — IMO MC 7.17',
+ 'Leadership and Teamwork — IMO MC 1.39',
+ ],
+ competencies: [
+ { area: 'Marine Engineering at Operational Level', items: ['Safe engineering watch; monitor propulsion plant and auxiliaries', 'Operate, monitor and control boiler systems', 'Operate fuel, lubricating oil and bilge/ballast systems'] },
+ { area: 'Electrical, Electronic & Control Engineering', items: ['Operate generators, switchboards and distribution systems', 'Maintain and repair electrical machinery', 'Monitor and operate automated systems'] },
+ { area: 'Maintenance & Repair', items: ['Maintain and repair machinery and equipment', 'Perform planned maintenance', 'Safe use of workshop and maintenance tools'] },
+ { area: 'Controlling Ship Operations', items: ['Apply pollution-prevention procedures', 'Maintain safety of personnel and machinery'] },
+ { area: 'Leadership & Teamwork', items: ['Assign and prioritize resources in engine room', 'Effective communication with engineering team', 'Task and workload management'] },
+ ],
+ validityYears: 5,
+ revalidationRule: 'Revalidation every 5 years under Regulation I/11.',
+ notes: 'Assessment against table A-III/1. Propulsion-type limitations may be endorsed nationally.',
+ },
+ {
+ id: 'second-engineer-750',
+ dept: ['engine'],
+ type: 'CoC',
+ stcwRef: 'Reg. III/3 — Code A-III/3',
+ level: 'Management',
+ label: 'Second Engineer Officer — Ships 750–3,000 kW',
+ eligibleRanks: 'Second Engineer on ships 750–3,000 kW',
+ prerequisiteId: 'oicew',
+ minAge: 18,
+ seaService: 'Must hold OICEW CoC. Then: at least 12 months approved sea service as an assistant or qualified engineer officer.',
+ mandatoryTraining: ['OICEW CoC (prerequisite)', 'Leadership and Managerial Skills — IMO MC 1.40', 'Approved management engineering programme — IMO MC 7.02'],
+ competencies: [
+ { area: 'Management of Engine Room', items: ['Plan and schedule maintenance of propulsion plant and auxiliaries', 'Manage fuel, stores and spare parts'] },
+ { area: 'Electrical Engineering at Management Level', items: ['Manage electrical systems including emergency power', 'High-voltage systems safety awareness'] },
+ { area: 'Leadership & Management', items: ['Supervise and manage engine department personnel', 'Initiate and implement emergency procedures affecting engineering'] },
+ ],
+ validityYears: 5,
+ revalidationRule: 'Revalidation every 5 years under Regulation I/11.',
+ notes: 'Assessment against table A-III/3.',
+ },
+ {
+ id: 'chief-engineer-750',
+ dept: ['engine'],
+ type: 'CoC',
+ stcwRef: 'Reg. III/3 — Code A-III/3',
+ level: 'Management',
+ label: 'Chief Engineer Officer — Ships 750–3,000 kW',
+ eligibleRanks: 'Chief Engineer on ships 750–3,000 kW',
+ prerequisiteId: 'second-engineer-750',
+ minAge: 18,
+ seaService: 'Must hold Second Engineer CoC (750–3,000 kW). Then: at least 24 months approved sea service, of which at least 12 months served as qualified second engineer officer.',
+ mandatoryTraining: ['Second Engineer CoC (prerequisite)', 'Leadership and Managerial Skills — IMO MC 1.40'],
+ competencies: [
+ { area: 'Full Engineering Department Management', items: ['Full responsibility for engineering plant operations, maintenance and repair', 'Fuel, lubricant and consumable management', 'Budget, records and documentation management'] },
+ { area: 'Ship Operation & Safety', items: ['Ensure compliance with all engineering-related legal requirements', 'Emergency equipment maintenance readiness', 'Environmental compliance'] },
+ ],
+ validityYears: 5,
+ revalidationRule: 'Revalidation every 5 years under Regulation I/11.',
+ notes: 'A Second Engineer qualified for 3,000 kW+ may serve as Chief on ships below 3,000 kW if endorsed.',
+ },
+ {
+ id: 'second-engineer-3000',
+ dept: ['engine'],
+ type: 'CoC',
+ stcwRef: 'Reg. III/2 — Code A-III/2',
+ level: 'Management',
+ label: 'Second Engineer Officer — Ships 3,000 kW or more',
+ eligibleRanks: 'Second Engineer on ships 3,000 kW or more',
+ prerequisiteId: 'chief-engineer-750',
+ minAge: 18,
+ seaService: 'Must hold OICEW CoC. Then: at least 12 months approved sea service as a qualified engineer officer.',
+ mandatoryTraining: ['OICEW CoC (prerequisite)', 'Leadership and Managerial Skills — IMO MC 1.40', 'Approved management engineering programme — IMO MC 7.02'],
+ competencies: [
+ { area: 'Management-Level Marine Engineering (≥3,000 kW)', items: ['Plan, operate and maintain large propulsion plants', 'High-voltage management systems', 'Control engineering resource management at management level'] },
+ ],
+ validityYears: 5,
+ revalidationRule: 'Revalidation every 5 years under Regulation I/11.',
+ notes: 'Assessment against table A-III/2.',
+ },
+ {
+ id: 'chief-engineer-3000',
+ dept: ['engine'],
+ type: 'CoC',
+ stcwRef: 'Reg. III/2 — Code A-III/2',
+ level: 'Management',
+ label: 'Chief Engineer Officer — Ships 3,000 kW or more',
+ eligibleRanks: 'Chief Engineer on ships 3,000 kW or more',
+ prerequisiteId: 'second-engineer-3000',
+ minAge: 18,
+ seaService: 'Must hold Second Engineer (3,000 kW) CoC. Then: at least 36 months approved sea service, reducible to 24 months if 12 months served as second engineer.',
+ mandatoryTraining: ['Second Engineer (≥3,000 kW) CoC (prerequisite)', 'Leadership and Managerial Skills — IMO MC 1.40'],
+ competencies: [
+ { area: 'Full Engineering Department — Large Ships', items: ['All competencies of Second Engineer plus full command of engineering department', 'Interface with shore management on technical matters', 'Crew resource management and team leadership'] },
+ ],
+ validityYears: 5,
+ revalidationRule: 'Revalidation every 5 years under Regulation I/11.',
+ notes: 'Highest engine department certificate.',
+ },
+
+ // ── ELECTRO-TECHNICAL ─────────────────────────────────────────────────────
+ {
+ id: 'etr',
+ dept: ['electro'],
+ type: 'CoP',
+ stcwRef: 'Reg. III/7 — Code A-III/7',
+ level: 'Support',
+ label: 'Electro-Technical Rating (ETR)',
+ eligibleRanks: 'ETR, Electrician Rating',
+ prerequisiteId: null,
+ minAge: 18,
+ seaService: 'Either 12 months approved sea-service training/experience; OR approved training with at least 6 months sea service; OR technical qualifications meeting A-III/7 plus at least 3 months approved sea service.',
+ mandatoryTraining: ['Basic Safety Training (STCW VI/1)', 'Approved ETR training — IMO Model Course 7.15'],
+ competencies: [
+ { area: 'Electrical Systems', items: ['Contribute to maintenance and repair of electrical/electronic systems', 'Operate and monitor electrical distribution panels', 'Cable and wiring maintenance'] },
+ { area: 'Electronic & Control', items: ['Assist in maintenance of instrumentation and control systems', 'Basic fault identification in electronic circuits'] },
+ { area: 'Safety & Environment', items: ['Prevent and extinguish fires (especially electrical fires)', 'Basic electrical safety; isolation and lock-out procedures', 'Pollution prevention awareness'] },
+ ],
+ validityYears: 5,
+ revalidationRule: 'No mandatory STCW stand-alone periodic revalidation; maintain BST evidence every 5 years.',
+ notes: 'IMO Model Course 7.15 is the implementation guidance.',
+ },
+ {
+ id: 'eto',
+ dept: ['electro'],
+ type: 'CoC',
+ stcwRef: 'Reg. III/6 — Code A-III/6',
+ level: 'Operational',
+ label: 'Electro-Technical Officer (ETO)',
+ eligibleRanks: 'Electro-Technical Officer',
+ prerequisiteId: 'etr',
+ minAge: 18,
+ seaService: 'Option A: Approved programme with Training Record Book plus at least 12 months sea service (at least 6 months in engine department). Option B: 36 months combined workshop/sea service, at least 30 months at sea in engine department.',
+ mandatoryTraining: [
+ 'ETR CoP (prerequisite)',
+ 'Approved ETO programme — IMO Model Course 7.08',
+ 'Leadership and Teamwork — IMO MC 1.39',
+ ],
+ competencies: [
+ { area: 'Electrical Systems at Operational Level', items: ['Monitor and control main electrical power systems', 'Operate and maintain generators, transformers, switchboards', 'High-voltage system safety and control', 'Emergency power systems'] },
+ { area: 'Electronic Systems', items: ['Maintain and repair navigation and communication electronic systems', 'Instrumentation and measuring systems maintenance', 'Computer systems and ship networks management'] },
+ { area: 'Automation & Control', items: ['Monitor and operate automated control systems', 'Fault diagnosis in electronic/control systems', 'Maintain programmable controllers and automation'] },
+ { area: 'Maintenance & Repair', items: ['Plan and execute planned maintenance schedules', 'Safe use of test equipment and diagnostic tools'] },
+ { area: 'Leadership & Teamwork', items: ['Communicate effectively with electro-technical team', 'Assign tasks and manage workload', 'Supervise ETR and other ratings'] },
+ ],
+ validityYears: 5,
+ revalidationRule: 'Revalidation every 5 years under Regulation I/11.',
+ notes: 'Assessment against table A-III/6. Some administrations may specify high-voltage endorsements.',
+ },
+
+ // ── CATERING ─────────────────────────────────────────────────────────────
+ // STCW does not create department-specific CoC/CoP for catering.
+ // Catering seafarers qualify through basic safety + passenger-ship training.
+ // Ship's Cook is an MLC qualification.
+ {
+ id: 'basic-safety-catering',
+ dept: ['catering'],
+ type: 'CoP',
+ stcwRef: 'Reg. VI/1 — Code A-VI/1',
+ level: 'Support',
+ label: 'Basic Safety Training for Catering Personnel',
+ eligibleRanks: 'All catering crew',
+ prerequisiteId: null,
+ minAge: 16,
+ seaService: 'No sea-service prerequisite; training programme completion required.',
+ mandatoryTraining: [
+ 'Personal Survival Techniques — IMO MC 1.19',
+ 'Fire Prevention and Fire Fighting — IMO MC 1.20',
+ 'Elementary First Aid — IMO MC 1.13',
+ 'Personal Safety and Social Responsibilities — IMO MC 1.21',
+ ],
+ competencies: [
+ { area: 'Personal Survival Techniques', items: ['Don and use a lifejacket', 'Survive in the water', 'Board a liferaft from water', 'Take initial actions upon abandoning ship'] },
+ { area: 'Fire Prevention & Fighting', items: ['Minimize risk of fire', 'Operate a fire extinguisher', 'Apply precautions when working with flammable materials'] },
+ { area: 'Elementary First Aid', items: ['Administer first aid for burns, wounds, fractures', 'Perform CPR', 'Recognize and respond to medical emergencies'] },
+ { area: 'Personal Safety & Social Responsibilities', items: ['Follow safe working practices', 'Report accidents and hazards', 'Environmental protection awareness'] },
+ ],
+ validityYears: 5,
+ revalidationRule: 'PST and FPFF competence evidence required every 5 years (STCW A-VI/1). EFA and PSSR do not carry the same explicit 5-year refresh mandate.',
+ notes: 'Note: STCW does not create a catering-specific CoC or CoP. The Ship\'s Cook qualification is governed by MLC 2006 Standard A3.2 (national law), not STCW.',
+ },
+ {
+ id: 'passenger-direct-service',
+ dept: ['catering'],
+ type: 'CoP',
+ stcwRef: 'Reg. V/2 para. 6 — Code A-V/2 para. 2',
+ level: 'Support',
+ label: 'Passenger Direct-Service Training',
+ eligibleRanks: 'Catering staff serving passengers on passenger ships',
+ prerequisiteId: 'basic-safety-catering',
+ minAge: 16,
+ seaService: 'No specific sea-service requirement; approved training completion required.',
+ mandatoryTraining: ['Basic Safety Training (prerequisite)', 'Passenger direct-service approved training — IMO MC 1.44'],
+ competencies: [
+ { area: 'Passenger Communication & Assistance', items: ['Communicate emergency instructions to passengers', 'Demonstrate use of life-saving appliances', 'Assist passengers during embarkation, disembarkation and muster', 'Provide assistance to passengers with special needs'] },
+ ],
+ validityYears: 5,
+ revalidationRule: 'Refresher/evidence every 5 years.',
+ notes: 'Required for catering personnel directly serving passengers on passenger ships.',
+ },
+];
+
+// ---------------------------------------------------------------------------
+// Mock — what the seafarer currently holds (simulated from profile)
+// ---------------------------------------------------------------------------
+const MOCK_PROFILE = {
+ department: 'deck' as Department,
+ seamanBookNo: 'SB-2024-0001',
+ photoRef: 'Photo on file (Passport Size)',
+ heldCertIds: ['rfpnw', 'ab-deck'], // already issued certificates
+ medicalExpired: false, // true = medical cert expired → user must upload fresh one
+};
+
+// ---------------------------------------------------------------------------
+// Documents: sea service + one upload per competency area + TRB + optional medical
+// ---------------------------------------------------------------------------
+interface DocSlot { key: string; label: string; description: string; required: boolean; isCompetency?: boolean }
+
+const FIRST_CERT_IDS = new Set(['rfpnw', 'rfpew', 'etr', 'basic-safety-catering']);
+
+function buildDocSlots(cert: CertDef | null, medicalExpired: boolean): DocSlot[] {
+ if (!cert) return [];
+
+ const isFirst = FIRST_CERT_IDS.has(cert.id);
+
+ const slots: DocSlot[] = [
+ {
+ key: 'sea-service',
+ label: 'Sea Service Record / Discharge Book',
+ description: 'Certified copy showing total approved sea time satisfying the STCW requirement for this certificate.',
+ required: true,
+ },
+ ];
+
+ // One upload slot per competency area
+ cert.competencies.forEach((comp) => {
+ slots.push({
+ key: `comp-${comp.area.toLowerCase().replace(/[^a-z0-9]/g, '-')}`,
+ label: `Certificate — ${comp.area}`,
+ description: `Upload the certificate(s) or documentary evidence proving competency in: ${comp.items.slice(0, 3).join('; ')}${comp.items.length > 3 ? '; and more.' : '.'}`,
+ required: true,
+ isCompetency: true,
+ });
+ });
+
+ // TRB — mandatory for all certs except the very first entry-level ones
+ slots.push({
+ key: 'trb',
+ label: 'Training Record Book (TRB)',
+ description: isFirst
+ ? 'Your Training Record Book if available. Not mandatory for this entry-level certificate.'
+ : 'Your completed and signed Training Record Book. An EMA officer will physically inspect this document. Mandatory.',
+ required: !isFirst,
+ });
+
+ if (medicalExpired) {
+ slots.push({
+ key: 'medical',
+ label: 'Valid Medical Fitness Certificate',
+ description: 'Your medical certificate on file has expired. Upload a current valid medical fitness certificate.',
+ required: true,
+ });
+ }
+
+ return slots;
+}
+
+// ---------------------------------------------------------------------------
+// Level badge
+// ---------------------------------------------------------------------------
+const LEVEL_COLOR: Record = { Support: 'gray', Operational: 'blue', Management: 'violet' };
+
+// ---------------------------------------------------------------------------
+// Competency accordion
+// ---------------------------------------------------------------------------
+function CompetencyPanel({ cert }: { cert: CertDef }) {
+ const [open, setOpen] = useState(false);
+ return (
+
+ setOpen((o) => !o)}>
+
+
+
+
+ {cert.label}
+ {cert.level}
+ {cert.type}
+
+ {open ? : }
+
+
+
+
+
+
+ STCW Reference
+ {cert.stcwRef}
+
+
+ Eligible Ranks
+ {cert.eligibleRanks}
+
+
+ Minimum Age
+ {cert.minAge} years
+
+
+ Validity
+ {cert.validityYears} years — {cert.revalidationRule}
+
+
+
+
+
+
+ Sea Service Requirement
+ {cert.seaService}
+
+
+
+ Mandatory Training
+
+ {cert.mandatoryTraining.map((t) => {t} )}
+
+
+
+
+
+
+ Competency Areas (STCW Tables)
+
+ {cert.competencies.map((comp) => (
+
+ {comp.area}
+
+ {comp.items.map((item) => {item} )}
+
+
+ ))}
+
+
+
+ {cert.notes && (
+ } p="xs">
+ {cert.notes}
+
+ )}
+
+
+
+ );
+}
+
+// ---------------------------------------------------------------------------
+// Main page
+// ---------------------------------------------------------------------------
+export function CoCApplicationPage() {
+ const navigate = useNavigate();
+ const [step, setStep] = useState(0);
+
+ // Derive available certs for this department
+ const deptCerts = CERT_CATALOG.filter((c) => c.dept.includes(MOCK_PROFILE.department));
+
+ // Which are already held
+ const held = new Set(MOCK_PROFILE.heldCertIds);
+
+ // Eligible: prerequisite must be held (or null)
+ const eligible = deptCerts.filter((c) => {
+ if (held.has(c.id)) return false; // already issued
+ if (c.prerequisiteId && !held.has(c.prerequisiteId)) return false; // prereq not met
+ return true;
+ });
+
+ const selectOptions = eligible.map((c) => ({
+ value: c.id,
+ label: `[${c.type}] ${c.label}`,
+ }));
+
+ // Step 0
+ const [selectedId, setSelectedId] = useState(null);
+ const selectedCert = CERT_CATALOG.find((c) => c.id === selectedId) ?? null;
+
+ // Step 1 — documents (recomputed when selected cert changes)
+ const docSlots = buildDocSlots(selectedCert, MOCK_PROFILE.medicalExpired);
+ const [docs, setDocs] = useState>({});
+ const setDoc = (key: string, f: File | null) => setDocs((p) => ({ ...p, [key]: f }));
+
+ // Step 2 — payment
+ const [paymentMethod, setPaymentMethod] = useState(null);
+ const [paymentRef, setPaymentRef] = useState('');
+ const [paymentDate, setPaymentDate] = useState('');
+ const [paymentFile, setPaymentFile] = useState(null);
+
+ const docsStepOk = docSlots.filter((d) => d.required).every((d) => !!docs[d.key]);
+ const payOk = !!paymentMethod && paymentRef.trim().length > 0 && paymentDate.length > 0 && !!paymentFile;
+
+ const FEES = [
+ { label: 'Application Fee', amount: selectedCert?.type === 'CoC' ? 800 : 300 },
+ { label: 'Examination Fee', amount: selectedCert?.type === 'CoC' ? 400 : 0 },
+ { label: 'Certificate Issuance Fee', amount: 200 },
+ ].filter((f) => f.amount > 0);
+ const TOTAL = FEES.reduce((s, f) => s + f.amount, 0);
+
+ const EXAM_VENUES = [
+ { value: 'addis', label: 'EMA HQ — Addis Ababa' },
+ { value: 'djibouti-link', label: 'EMA Regional Office — Djibouti Liaison' },
+ ];
+
+ const handleSubmit = () => {
+ notify.success(`Application submitted! Reference: ${selectedCert?.type}-APP-2025-${Math.floor(Math.random() * 9000 + 1000)}`);
+ navigate('/certificates');
+ };
+
+ const deptLabel: Record = {
+ deck: 'Deck', engine: 'Engine', electro: 'Electro-Technical', catering: 'Catering',
+ };
+
+ return (
+
+
+ } onClick={() => navigate('/certificates')}>
+ Back to Certificates
+
+
+
+
+
Certificate Application — {deptLabel[MOCK_PROFILE.department]} Department
+ STCW Certificate of Competency / Certificate of Proficiency — Ethiopian Maritime Authority
+
+
+ {/* Profile info pulled from system */}
+
+
+
+
+ Seaman Book:
+ {MOCK_PROFILE.seamanBookNo}
+ from system
+
+
+
+ Photo:
+ {MOCK_PROFILE.photoRef}
+ from system
+
+
+ Your Seaman Book number and passport-size photo are automatically included from your profile. No need to upload them again.
+
+
+ { if (s < step) setStep(s); }} size="sm" color="blue">
+ } />
+ } />
+ } />
+ } />
+
+
+ {/* ── STEP 0 — Select certificate ── */}
+ {step === 0 && (
+
+
+
+ Select the Certificate You Are Applying For
+
+ {eligible.length === 0 ? (
+ }>
+ No certificates available at this time.
+
+ You have either completed all certificates for your department, or you need to obtain a prerequisite certificate first. Contact EMA for guidance.
+
+
+ ) : (
+
+ )}
+
+ {/* Held certificates */}
+ {held.size > 0 && (
+
+ Already Issued to You
+
+ {[...held].map((hid) => {
+ const c = CERT_CATALOG.find((x) => x.id === hid);
+ if (!c) return null;
+ return {c.label} ;
+ })}
+
+
+ )}
+
+ {/* Competency detail for selected */}
+ {selectedCert && (
+ <>
+
+
+ >
+ )}
+
+ {/* All department certificates reference */}
+
+ The full career pathway for the {deptLabel[MOCK_PROFILE.department]} department. You must progress in order — each certificate requires the previous one.
+
+ {deptCerts.map((c) => (
+
+ ))}
+
+
+
+ }
+ disabled={!selectedCert}
+ onClick={() => setStep(1)}
+ >
+ Next: Documents & Exam
+
+
+
+
+
+ )}
+
+ {/* ── STEP 1 — Upload Documents ── */}
+ {step === 1 && selectedCert && (
+
+
+ Upload Documents
+
+ } p="sm">
+
+ Your Seaman Book and passport-size photo are already on file — no need to re-upload them.
+ Upload one certificate file per competency area plus your sea service record and Training Record Book.
+ PDF preferred. Max 5 MB each.
+
+
+
+ {MOCK_PROFILE.medicalExpired && (
+ } p="sm">
+ Your medical fitness certificate on file has expired.
+ Upload a current valid medical fitness certificate to proceed.
+
+ )}
+
+ {/* Sea service */}
+ {(() => {
+ const svc = docSlots.find((d) => d.key === 'sea-service')!;
+ return (
+
+
+ {svc.label}
+ *
+
+ {svc.description}
+
+ } value={docs[svc.key] ?? null} onChange={(f) => setDoc(svc.key, f)} style={{ flex: 1 }} size="sm" clearable />
+ {docs[svc.key] && }
+
+
+ );
+ })()}
+
+ {/* Competency certificates — one per area */}
+
+ Competency Certificates *
+
+ Upload one certificate or documentary evidence file for each competency area listed below.
+ These must directly correspond to the STCW competency items for {selectedCert.label} .
+
+
+ {docSlots.filter((d) => d.isCompetency).map((doc) => (
+
+
+ {doc.label}
+ *
+
+ {doc.description}
+
+ } value={docs[doc.key] ?? null} onChange={(f) => setDoc(doc.key, f)} style={{ flex: 1 }} size="sm" clearable />
+ {docs[doc.key] && }
+
+
+ ))}
+
+
+
+ {/* TRB */}
+ {(() => {
+ const trb = docSlots.find((d) => d.key === 'trb')!;
+ return (
+
+
+ {trb.label}
+ {trb.required ? * : optional for first cert }
+
+ {trb.description}
+ {trb.required && (
+ } p="xs" mb="xs">
+
+ An EMA officer will physically inspect your original TRB at the office before scheduling your examination.
+ Upload a scanned copy here — you will be asked to bring the original when called.
+
+
+ )}
+
+ } value={docs[trb.key] ?? null} onChange={(f) => setDoc(trb.key, f)} style={{ flex: 1 }} size="sm" clearable />
+ {docs[trb.key] && }
+
+
+ );
+ })()}
+
+ {/* Medical if expired */}
+ {MOCK_PROFILE.medicalExpired && (() => {
+ const med = docSlots.find((d) => d.key === 'medical');
+ if (!med) return null;
+ return (
+
+ {med.label} *
+ {med.description}
+
+ } value={docs[med.key] ?? null} onChange={(f) => setDoc(med.key, f)} style={{ flex: 1 }} size="sm" clearable />
+ {docs[med.key] && }
+
+
+ );
+ })()}
+
+ {/* Upload progress summary */}
+
+ Upload Progress
+
+ {docSlots.map((d) => (
+
+ {docs[d.key]
+ ?
+ : }
+
+ {d.label}
+ {!docs[d.key] && d.required && * }
+
+
+ ))}
+
+
+
+ } p="sm">
+
+ After submission, EMA officers will review your documents and TRB. If approved, they will contact you with an examination date and venue.
+ Your original TRB must be brought to the office for physical inspection before the exam is scheduled.
+
+
+
+
+ } onClick={() => setStep(0)}>Back
+ } disabled={!docsStepOk} onClick={() => setStep(2)}>
+ Next: Payment
+
+
+
+
+ )}
+
+ {/* ── STEP 2 — Payment ── */}
+ {step === 2 && selectedCert && (
+
+
+ Fee Payment
+
+
+ Fee Breakdown — {selectedCert.type}: {selectedCert.label}
+
+ {FEES.map((f) => (
+
+ {f.label}
+ ETB {f.amount.toFixed(2)}
+
+ ))}
+
+
+
+ Total
+ ETB {TOTAL.toFixed(2)}
+
+
+
+ Select Payment Method
+
+ {[
+ { key: 'cbe', label: 'CBE Bank Transfer', acct: '1000123456789', name: 'EMA Maritime Authority', hint: 'Use your full name and certificate type as the transfer description.' },
+ { key: 'telebirr', label: 'Telebirr', acct: '+251 11 551 0000', name: 'EMA Maritime Authority', hint: 'Screenshot your confirmation and upload below.' },
+ ].map((m) => (
+ setPaymentMethod(m.key)}
+ >
+
+ {m.label}
+ {paymentMethod === m.key && }
+
+ Account: {m.acct}
+ Name: {m.name}
+ {m.hint}
+
+ ))}
+
+
+ {paymentMethod && (
+
+
+
+ setPaymentRef(e.currentTarget.value)}
+ size="sm"
+ required
+ />
+ setPaymentDate(e.currentTarget.value)}
+ size="sm"
+ required
+ />
+
+ }
+ value={paymentFile}
+ onChange={setPaymentFile}
+ size="sm"
+ required
+ clearable
+ />
+
+ )}
+
+
+ } onClick={() => setStep(1)}>Back
+ } disabled={!payOk} onClick={() => setStep(3)}>
+ Next: Review
+
+
+
+
+ )}
+
+ {/* ── STEP 3 — Review & Submit ── */}
+ {step === 3 && selectedCert && (
+
+
+ Review & Submit
+
+
+
+ Certificate Applying For
+ {selectedCert.type}
+ {selectedCert.label}
+ {selectedCert.stcwRef}
+
+
+
+ Level
+ {selectedCert.level} Level
+
+
+
+ Documents from System
+
+ Seaman Book — {MOCK_PROFILE.seamanBookNo}
+ Passport Photo — on file
+
+
+
+
+ Uploaded Documents
+
+ {docSlots.map((d) => (
+
+ {docs[d.key]
+ ?
+ : }
+ {d.label}
+ {!docs[d.key] && !d.required && not uploaded }
+
+ ))}
+
+
+
+
+ Payment
+ {paymentMethod === 'cbe' ? 'CBE Bank Transfer' : 'Telebirr'}
+ Ref: {paymentRef}
+ ETB {TOTAL.toFixed(2)}
+
+
+
+
+
+ }>
+ Processing time: 10–15 working days after examination
+
+ EMA will notify you at each stage: document verification → payment confirmation → examination invitation → result → certificate issuance.
+
+
+
+
+ } onClick={() => setStep(2)}>Back
+ } color="teal" onClick={handleSubmit}>
+ Submit Application
+
+
+
+
+ )}
+
+ );
+}
diff --git a/apps/portal/src/app/features/dashboard/pages/DashboardPage.tsx b/apps/portal/src/app/features/dashboard/pages/DashboardPage.tsx
index bcfcf2dac..c850542c1 100644
--- a/apps/portal/src/app/features/dashboard/pages/DashboardPage.tsx
+++ b/apps/portal/src/app/features/dashboard/pages/DashboardPage.tsx
@@ -1,8 +1,11 @@
import {
+ Alert,
+ Badge,
Card,
Center,
Group,
Paper,
+ Progress,
SimpleGrid,
Stack,
Text,
@@ -10,23 +13,59 @@ import {
Title,
UnstyledButton,
useMantineTheme,
+ rem,
} from '@mantine/core';
import {
+ IconAlertCircle,
+ IconBook2,
IconChevronRight,
+ IconClipboardList,
+ IconFileCheck,
+ IconHeart,
IconLifebuoy,
IconShip,
+ IconShieldCheck,
IconUserPlus,
+ IconBell,
} from '@tabler/icons-react';
import type { Icon } from '@tabler/icons-react';
import { useNavigate } from 'react-router-dom';
+// ---------------------------------------------------------------------------
+// Mock profile completeness — replace with real store/API data
+// ---------------------------------------------------------------------------
+const PROFILE_STEPS = [
+ { label: 'Personal Information', done: true },
+ { label: 'Document Upload', done: true },
+ { label: 'Medical Certificate', done: false },
+ { label: 'Basic Safety Training', done: false },
+ { label: 'Seaman Book', done: false },
+];
+
+const BST_ITEMS = [
+ { label: 'Personal Survival Techniques (PST)', done: true, expiry: '2028-04-10' },
+ { label: 'Fire Prevention & Fire Fighting (FPFF)', done: true, expiry: '2028-04-10' },
+ { label: 'Elementary First Aid (EFA)', done: false, expiry: null },
+ { label: 'Personal Safety & Social Responsibility (PSSR)', done: false, expiry: null },
+ { label: 'Sexual Harassment Prevention', done: false, expiry: null },
+];
+
+const ALERTS = [
+ { id: 1, type: 'warning', message: 'Medical certificate expires in 45 days. Please renew before it lapses.', route: '/medical-certificate' },
+ { id: 2, type: 'info', message: '3 Basic Safety Training certificates are missing. Complete them to apply for a Seaman Book.', route: '/basic-safety-training' },
+];
+
export function DashboardPage() {
const navigate = useNavigate();
const theme = useMantineTheme();
+ const doneSteps = PROFILE_STEPS.filter((s) => s.done).length;
+ const completeness = Math.round((doneSteps / PROFILE_STEPS.length) * 100);
+ const bstDone = BST_ITEMS.filter((b) => b.done).length;
+
return (
- {/* ---- Hero banner ------------------------------------------- */}
+ {/* Hero */}
- Welcome to the EMA Portal
+ Welcome to the EMA Seafarer Portal
- Manage your seafarer profile, submit applications, and track your
- maritime credentials all in one place.
+ Manage your seafarer profile, track certificates, apply for your Seaman Book
+ and monitor your maritime credentials — all in one place.
@@ -55,54 +94,144 @@ export function DashboardPage() {
- {/* ---- Quick actions ----------------------------------------- */}
+ {/* Alerts */}
+ {ALERTS.map((alert) => (
+ : }
+ style={{ cursor: 'pointer' }}
+ onClick={() => navigate(alert.route)}
+ >
+ {alert.message}
+
+ ))}
+
+ {/* Status cards */}
+
+
+
+
+
+
+
+ {/* Profile completeness */}
-
- Quick actions
-
+
+ Registration Checklist
+
+ {completeness}% complete
+
+
+
- navigate('/seafarer-registration')}
- />
- navigate('/support')}
- />
+ {PROFILE_STEPS.map((step) => (
+
+
+
+
+
+ {step.label}
+
+ {step.done && Done }
+
+ ))}
+
+
+
+ {/* BST tracker */}
+
+
+ Basic Safety Training
+
+ {bstDone} / {BST_ITEMS.length}
+
+
+
+ {BST_ITEMS.map((item) => (
+
+
+
+
+
+ {item.label}
+
+ {item.done && item.expiry && (
+ exp {item.expiry}
+ )}
+ {!item.done && (
+ Missing
+ )}
+
+ ))}
+
+ {/* Quick actions */}
+
+ Quick Actions
+
+ navigate('/seafarer-registration')} />
+ navigate('/seaman-book')} />
+ navigate('/certificates')} />
+ navigate('/documents')} />
+ navigate('/documents')} />
+ navigate('/support')} />
+
+
);
}
+function StatusCard({ label, value, icon: Icon, color }: { label: string; value: string; icon: Icon; color: string }) {
+ return (
+
+
+
+ {value}
+ {label}
+
+
+
+
+
+
+ );
+}
+
function QuickAction({
icon: ActionIconCmp,
color,
label,
+ sub,
onClick,
}: {
icon: Icon;
color: string;
label: string;
+ sub: string;
onClick: () => void;
}) {
return (
-
-
+
+
-
-
+
+
-
- {label}
-
-
+
+ {label}
+ {sub}
+
+
diff --git a/apps/portal/src/app/features/documents/pages/DocumentVaultPage.tsx b/apps/portal/src/app/features/documents/pages/DocumentVaultPage.tsx
new file mode 100644
index 000000000..95fc97804
--- /dev/null
+++ b/apps/portal/src/app/features/documents/pages/DocumentVaultPage.tsx
@@ -0,0 +1,487 @@
+import { useRef, useState } from 'react';
+import {
+ ActionIcon,
+ Badge,
+ Box,
+ Button,
+ Card,
+ Divider,
+ FileButton,
+ Group,
+ Menu,
+ Modal,
+ Paper,
+ SimpleGrid,
+ Stack,
+ Tabs,
+ Text,
+ TextInput,
+ ThemeIcon,
+ Title,
+ rem,
+} from '@mantine/core';
+import {
+ IconBook2,
+ IconCertificate,
+ IconCircleCheck,
+ IconCloudDownload,
+ IconDotsVertical,
+ IconDownload,
+ IconEye,
+ IconFileCheck,
+ IconFileDescription,
+ IconHeart,
+ IconId,
+ IconPhoto,
+ IconSchool,
+ IconSearch,
+ IconShieldCheck,
+ IconTrash,
+ IconUpload,
+ IconX,
+} from '@tabler/icons-react';
+import { notify } from '@ema-platform/ui';
+
+// ---------------------------------------------------------------------------
+// EMA-issued / system-generated documents
+// ---------------------------------------------------------------------------
+interface IssuedDoc {
+ key: string;
+ label: string;
+ category: 'certificate' | 'book';
+ color: string;
+ icon: typeof IconBook2;
+ issuedDate: string;
+ expiryDate: string | null;
+ status: 'issued' | 'pending' | 'expired';
+ description: string;
+}
+
+const ISSUED_DOCS: IssuedDoc[] = [
+ {
+ key: 'seaman-book',
+ label: 'Seaman Book',
+ category: 'book',
+ color: 'blue',
+ icon: IconBook2,
+ issuedDate: '2024-06-01',
+ expiryDate: '2029-06-01',
+ status: 'issued',
+ description: 'Official EMA-issued seafarer identification document. Valid for 5 years.',
+ },
+ {
+ key: 'btc',
+ label: 'Basic Training Certificate (BTC)',
+ category: 'certificate',
+ color: 'teal',
+ icon: IconCertificate,
+ issuedDate: '2024-06-01',
+ expiryDate: '2029-06-01',
+ status: 'issued',
+ description: 'EMA-issued BTC certifying completion of all 5 basic safety training courses.',
+ },
+ {
+ key: 'medical',
+ label: 'Medical Fitness Certificate',
+ category: 'certificate',
+ color: 'pink',
+ icon: IconHeart,
+ issuedDate: '2024-03-20',
+ expiryDate: '2026-03-20',
+ status: 'issued',
+ description: 'Medical fitness certificate from an EMA-approved medical centre.',
+ },
+];
+
+// ---------------------------------------------------------------------------
+// BTC sub-certificates (the 5 training certs that qualify you for BTC)
+// ---------------------------------------------------------------------------
+interface BtcCert {
+ key: string;
+ short: string;
+ label: string;
+ certNumber: string;
+ issuer: string;
+ issueDate: string;
+ expiryDate: string | null;
+}
+
+const BTC_CERTS: BtcCert[] = [
+ { key: 'pst', short: 'PST', label: 'Personal Survival Techniques', certNumber: 'PST-2024-001', issuer: 'Bahirdar Maritime School', issueDate: '2024-01-15', expiryDate: '2029-01-15' },
+ { key: 'fpff', short: 'FPFF', label: 'Fire Prevention & Fire Fighting', certNumber: 'FPFF-2024-002', issuer: 'Bahirdar Maritime School', issueDate: '2024-01-16', expiryDate: '2029-01-16' },
+ { key: 'efa', short: 'EFA', label: 'Elementary First Aid', certNumber: 'EFA-2024-003', issuer: 'EMA Training Centre', issueDate: '2024-02-01', expiryDate: null },
+ { key: 'pssr', short: 'PSSR', label: 'Personal Safety & Social Responsibility', certNumber: 'PSSR-2024-004', issuer: 'EMA Training Centre', issueDate: '2024-02-02', expiryDate: null },
+ { key: 'shpt', short: 'SHPT', label: 'Sexual Harassment Prevention Training', certNumber: 'SHPT-2024-005', issuer: 'EMA Training Centre', issueDate: '2024-02-03', expiryDate: null },
+];
+
+const STATUS_COLOR = { issued: 'teal', pending: 'yellow', expired: 'red' } as const;
+const STATUS_LABEL = { issued: 'Issued', pending: 'Pending', expired: 'Expired' } as const;
+
+// Demo PDF for preview
+const DEMO_PDF =
+ 'data:application/pdf;base64,JVBERi0xLjQKJeLjz9MKMSAwIG9iago8PC9UeXBlL0NhdGFsb2cvUGFnZXMgMiAwIFI+PgplbmRvYmoKMiAwIG9iago8PC9UeXBlL1BhZ2VzL0tpZHNbMyAwIFJdL0NvdW50IDE+PgplbmRvYmoKMyAwIG9iago8PC9UeXBlL1BhZ2UvUGFyZW50IDIgMCBSL01lZGlhQm94WzAgMCA2MTIgNzkyXT4+CmVuZG9iagp4cmVmCjAgNAowMDAwMDAwMDAwIDY1NTM1IGYgCjAwMDAwMDAwMDkgMDAwMDAgbiAKMDAwMDAwMDA1OCAwMDAwMCBuIAowMDAwMDAwMTE1IDAwMDAwIG4gCnRyYWlsZXIKPDwvU2l6ZSA0L1Jvb3QgMSAwIFI+PgpzdGFydHhyZWYKMjIwCiUlRU9G';
+
+// ---------------------------------------------------------------------------
+// Uploaded documents (user-provided supporting docs)
+// ---------------------------------------------------------------------------
+interface DocSlot {
+ key: string;
+ label: string;
+ description: string;
+ required: boolean;
+ icon: typeof IconId;
+ category: 'Identity' | 'Education' | 'Photo';
+ accept: string;
+}
+
+interface UploadedDoc {
+ key: string;
+ file: File;
+ uploadedAt: string;
+ url: string;
+}
+
+const DOC_SLOTS: DocSlot[] = [
+ { key: 'nationalId', label: 'National ID / Fayda', description: 'Front and back of your national identity card', required: true, icon: IconId, category: 'Identity', accept: 'application/pdf,image/jpeg,image/png' },
+ { key: 'passport', label: 'Passport', description: 'Bio-data page of a valid passport', required: false, icon: IconFileDescription, category: 'Identity', accept: 'application/pdf,image/jpeg,image/png' },
+ { key: 'photo', label: 'Passport Size Photo', description: 'Recent photo, white background, 3.5×4.5 cm', required: true, icon: IconPhoto, category: 'Photo', accept: 'image/jpeg,image/png' },
+ { key: 'graduation', label: 'Graduation Certificate', description: 'Highest academic qualification certificate', required: false, icon: IconSchool, category: 'Education', accept: 'application/pdf,image/jpeg,image/png' },
+ { key: 'transcript', label: 'Academic Transcript', description: 'Official academic transcript from institution', required: false, icon: IconSchool, category: 'Education', accept: 'application/pdf,image/jpeg,image/png' },
+];
+
+const UPLOAD_CATEGORIES = ['All', 'Identity', 'Photo', 'Education'] as const;
+type UploadCategory = typeof UPLOAD_CATEGORIES[number];
+
+const CAT_COLOR: Record = { Identity: 'blue', Photo: 'violet', Education: 'teal' };
+
+// ---------------------------------------------------------------------------
+// Main component
+// ---------------------------------------------------------------------------
+export function DocumentVaultPage() {
+ const [docs, setDocs] = useState>(() =>
+ Object.fromEntries(DOC_SLOTS.map((s) => [s.key, null]))
+ );
+ const [search, setSearch] = useState('');
+ const [category, setCategory] = useState('All');
+ const [previewDoc, setPreviewDoc] = useState<{ name: string; url: string; isImage: boolean } | null>(null);
+ const resetRefs = useRef void) | null>>({});
+
+ const handleUpload = (key: string) => (file: File | null) => {
+ if (!file) return;
+ if (file.size > 5 * 1024 * 1024) { notify.error('File exceeds 5MB limit.'); return; }
+ const url = URL.createObjectURL(file);
+ setDocs((prev) => ({ ...prev, [key]: { key, file, uploadedAt: new Date().toLocaleDateString('en-GB'), url } }));
+ notify.success(`${file.name} uploaded.`);
+ };
+
+ const handleRemove = (key: string) => {
+ const doc = docs[key];
+ if (doc) URL.revokeObjectURL(doc.url);
+ setDocs((prev) => ({ ...prev, [key]: null }));
+ resetRefs.current[key]?.();
+ notify.info('Document removed.');
+ };
+
+ const filtered = DOC_SLOTS.filter((slot) => {
+ const matchCat = category === 'All' || slot.category === category;
+ const matchSearch = !search || slot.label.toLowerCase().includes(search.toLowerCase());
+ return matchCat && matchSearch;
+ });
+
+ const uploadedCount = Object.values(docs).filter(Boolean).length;
+
+ return (
+
+
+
My Documents
+ All your EMA-issued certificates and uploaded supporting documents
+
+
+
+
+ }>
+ Certificates & Issued Documents
+
+ }>
+ Uploaded Documents
+ {uploadedCount} / {DOC_SLOTS.length}
+
+
+
+ {/* ── Certificates & Issued Documents ──────────────────────────── */}
+
+
+ {/* EMA-issued documents */}
+
+
EMA Issued Documents
+
+ {ISSUED_DOCS.map((doc) => {
+ const DocIcon = doc.icon;
+ return (
+
+
+
+
+
+
+ {doc.label}
+
+ {STATUS_LABEL[doc.status]}
+
+
+
+ {doc.description}
+
+
+ Issued
+ {doc.issuedDate}
+
+ {doc.expiryDate && (
+
+ Expires
+ {doc.expiryDate}
+
+ )}
+
+
+
+ } style={{ flex: 1 }}
+ onClick={() => setPreviewDoc({ name: doc.label, url: DEMO_PDF, isImage: false })}>
+ View
+
+ }
+ component="a" href={DEMO_PDF} download={`${doc.label}.pdf`}>
+ Download
+
+
+
+ );
+ })}
+
+
+
+ {/* BTC training certs — all 5 listed */}
+
+
+
+
+
+
+ BTC Training Certificates (5/5)
+ Submitted training certificates that qualified you for the BTC
+
+
+
+ {BTC_CERTS.map((cert) => (
+
+
+
+
+
+
+
+ {cert.short}
+
+
+ {cert.label}
+
+
+
+
+ Cert No.
+ {cert.certNumber}
+
+
+ Issuer
+ {cert.issuer}
+
+
+ Issued
+ {cert.issueDate}
+
+ {cert.expiryDate && (
+
+ Expires
+ {cert.expiryDate}
+
+ )}
+
+ } fullWidth
+ onClick={() => setPreviewDoc({ name: `${cert.short} Certificate`, url: DEMO_PDF, isImage: false })}>
+ View Certificate
+
+
+ ))}
+
+
+
+
+
+ {/* ── Uploaded supporting documents ─────────────────────────────── */}
+
+
+ {/* Category summary */}
+
+ {(['Identity', 'Photo', 'Education'] as const).map((cat) => {
+ const slots = DOC_SLOTS.filter((s) => s.category === cat);
+ const done = slots.filter((s) => docs[s.key]).length;
+ return (
+ setCategory(cat)}>
+
+
+
+
+
+ {cat}
+ {done}/{slots.length}
+
+
+
+ );
+ })}
+
+
+ {/* Filters */}
+
+ }
+ value={search}
+ onChange={(e) => setSearch(e.currentTarget.value)}
+ size="sm"
+ style={{ minWidth: rem(220) }}
+ rightSection={search ? (
+ setSearch('')}>
+ ) : null}
+ />
+
+ {UPLOAD_CATEGORIES.map((cat) => (
+ setCategory(cat)}>
+ {cat}
+
+ ))}
+
+
+
+ {/* Document cards */}
+
+ {filtered.map((slot) => {
+ const doc = docs[slot.key];
+ const SlotIcon = slot.icon;
+ const resetRef = { current: null as (() => void) | null };
+ return (
+
+
+
+
+
+
+
+ {slot.label}
+ {slot.required && !doc && * }
+
+ {slot.description}
+
+ {slot.category}
+
+ {doc ? (
+
+
+ {doc.file.name}
+ {doc.uploadedAt}
+
+
+
+
+
+
+
+ }
+ onClick={() => setPreviewDoc({ name: doc.file.name, url: doc.url, isImage: doc.file.type.startsWith('image/') })}>
+ Preview
+
+ } component="a" href={doc.url} download={doc.file.name}>
+ Download
+
+
+ } color="red" onClick={() => handleRemove(slot.key)}>
+ Remove
+
+
+
+
+ ) : (
+
+ {(props) => {
+ resetRefs.current[slot.key] = resetRef.current;
+ return (
+ } fullWidth {...props}>
+ Upload Document
+
+ );
+ }}
+
+ )}
+
+ );
+ })}
+
+
+ {filtered.length === 0 && (
+
+
+
+
+ No documents match your search.
+ { setSearch(''); setCategory('All'); }}>
+ Clear filters
+
+
+ )}
+
+
+
+
+ {/* Preview modal */}
+ setPreviewDoc(null)}
+ title={{previewDoc?.name} }
+ size="xl"
+ centered
+ styles={{ body: { padding: 0, minHeight: rem(500) } }}
+ >
+ {previewDoc && (
+ previewDoc.isImage
+ ?
+ :
+ )}
+
+
+ );
+}
diff --git a/apps/portal/src/app/features/endorsement/pages/EndorsementPage.tsx b/apps/portal/src/app/features/endorsement/pages/EndorsementPage.tsx
new file mode 100644
index 000000000..0a26ebb7e
--- /dev/null
+++ b/apps/portal/src/app/features/endorsement/pages/EndorsementPage.tsx
@@ -0,0 +1,436 @@
+import { useState } from 'react';
+import { useNavigate } from 'react-router-dom';
+import {
+ Alert,
+ Badge,
+ Button,
+ Card,
+ Divider,
+ FileInput,
+ Group,
+ List,
+ Modal,
+ Paper,
+ SimpleGrid,
+ Stack,
+ Stepper,
+ Text,
+ TextInput,
+ ThemeIcon,
+ Title,
+ rem,
+} from '@mantine/core';
+import {
+ IconAlertCircle,
+ IconArrowLeft,
+ IconArrowRight,
+ IconCheck,
+ IconCircleCheck,
+ IconClock,
+ IconDownload,
+ IconEye,
+ IconFileDescription,
+ IconInfoCircle,
+ IconRubberStamp,
+ IconShieldCheck,
+ IconUpload,
+} from '@tabler/icons-react';
+
+// ---------------------------------------------------------------------------
+// Mock data — existing endorsement applications
+// ---------------------------------------------------------------------------
+const MOCK_ENDORSEMENTS = [
+ {
+ id: 'END-APP-2025-001',
+ cocType: 'Officer in Charge of a Navigational Watch (STCW II/1)',
+ foreignCocNo: 'PHL-COC-2022-0045',
+ issuingCountry: 'Philippines',
+ submitted: '2025-04-05',
+ status: 'Document Verification',
+ statusColor: 'blue',
+ statusNote: 'EMA is verifying your documents. You will be notified when verification is complete.',
+ },
+];
+
+const MOCK_ISSUED = [
+ {
+ id: 'EMA-END-2024-012',
+ cocType: 'Chief Mate — STCW II/2',
+ foreignCocNo: 'GRC-COC-2019-0033',
+ issuingCountry: 'Greece',
+ endorsementNo: 'EMA-END-2024-012',
+ issued: '2024-08-10',
+ expiry: '2029-06-15',
+ status: 'Valid',
+ statusColor: 'teal',
+ },
+];
+
+// blank PDF
+const BLANK_PDF = 'data:application/pdf;base64,JVBERi0xLjQKJcfsj6IKMSAwIG9iago8PC9UeXBlL0NhdGFsb2cvUGFnZXMgMiAwIFI+PgplbmRvYmoKMiAwIG9iago8PC9UeXBlL1BhZ2VzL0tpZHNbMyAwIFJdL0NvdW50IDE+PgplbmRvYmoKMyAwIG9iago8PC9UeXBlL1BhZ2UvUGFyZW50IDIgMCBSL01lZGlhQm94WzAgMCA2MTIgNzkyXT4+CmVuZG9iagp4cmVmCjAgNAowMDAwMDAwMDAwIDY1NTM1IGYgCjAwMDAwMDAwMDkgMDAwMDAgbiAKMDAwMDAwMDA1OCAwMDAwMCBuIAowMDAwMDAwMTE1IDAwMDAwIG4gCnRyYWlsZXIKPDwvU2l6ZSA0L1Jvb3QgMSAwIFI+PgpzdGFydHhyZWYKMjE3CiUlRU9G';
+
+// ---------------------------------------------------------------------------
+// Application wizard
+// ---------------------------------------------------------------------------
+interface Docs {
+ foreignCoc: File | null;
+ translation: File | null;
+ medical: File | null;
+ seamanBook: File | null;
+ photo: File | null;
+}
+
+function ApplicationWizard({ onDone }: { onDone: () => void }) {
+ const [step, setStep] = useState(0);
+ const [cocNo, setCocNo] = useState('');
+ const [issuer, setIssuer] = useState('');
+ const [country, setCountry] = useState('');
+ const [cocType, setCocType] = useState('');
+ const [issueDate, setIssueDate] = useState('');
+ const [expiryDate, setExpiryDate] = useState('');
+ const [docs, setDocs] = useState({ foreignCoc: null, translation: null, medical: null, seamanBook: null, photo: null });
+ const [submitted, setSubmitted] = useState(false);
+
+ const step0Ok = !!cocNo && !!issuer && !!country && !!cocType && !!issueDate && !!expiryDate;
+ const step1Ok = !!docs.foreignCoc && !!docs.medical && !!docs.seamanBook && !!docs.photo;
+
+ if (submitted) {
+ return (
+
+
+ Application Submitted
+
+ Your endorsement application has been submitted. EMA officers will verify your documents
+ and notify you of the outcome. Reference: END-APP-2025-NEW
+
+ Back to Endorsements
+
+ );
+ }
+
+ return (
+
+
+
+
+
+
+
+
+ {/* Step 0 — Foreign CoC details */}
+ {step === 0 && (
+
+ } mb="lg">
+
+ STCW Regulation I/10 — EMA will endorse your foreign CoC so it is
+ recognised for service on Ethiopian-flagged vessels. The endorsement is valid
+ for the same period as your foreign CoC.
+
+
+
+ setCocNo(e.currentTarget.value)} required />
+ setCountry(e.currentTarget.value)} required />
+ setIssuer(e.currentTarget.value)} required />
+ setCocType(e.currentTarget.value)} required />
+ setIssueDate(e.currentTarget.value)} required />
+ setExpiryDate(e.currentTarget.value)} required />
+
+
+ )}
+
+ {/* Step 1 — Documents */}
+ {step === 1 && (
+
+ }>
+
+ A certified translation is required if your foreign CoC is not in English.
+ All documents must be clear, legible, and complete.
+
+
+
+
+ Required Documents
+
+ {[
+ { key: 'foreignCoc' as keyof Docs, label: 'Foreign CoC (Original or Certified Copy)', required: true },
+ { key: 'translation' as keyof Docs, label: 'Certified Translation (only if CoC is not in English)', required: false },
+ { key: 'medical' as keyof Docs, label: 'Valid Medical Fitness Certificate (STCW Reg I/2)', required: true },
+ { key: 'seamanBook' as keyof Docs, label: 'Ethiopian Seaman Book', required: true },
+ { key: 'photo' as keyof Docs, label: 'Passport-Size Photo', required: true },
+ ].map((slot) => (
+ {slot.label} {slot.required && Required }}
+ placeholder="Click to upload"
+ leftSection={ }
+ value={docs[slot.key]}
+ onChange={(f) => setDocs((prev) => ({ ...prev, [slot.key]: f }))}
+ accept=".pdf,.jpg,.jpeg,.png"
+ clearable
+ />
+ ))}
+
+
+
+ {/* Upload checklist */}
+
+ Upload Checklist
+
+ {[
+ { label: 'Foreign CoC', done: !!docs.foreignCoc },
+ { label: 'Medical Cert', done: !!docs.medical },
+ { label: 'Seaman Book', done: !!docs.seamanBook },
+ { label: 'Photo', done: !!docs.photo },
+ ].map((item) => (
+
+
+ {item.done ? : }
+
+ {item.label}
+
+ ))}
+
+
+
+ )}
+
+ {/* Step 2 — Payment */}
+ {step === 2 && (
+
+ Endorsement Fee
+
+ {[
+ { label: 'Application Processing Fee', amount: 300 },
+ { label: 'Document Verification Fee', amount: 200 },
+ { label: 'Endorsement Issuance Fee', amount: 500 },
+ ].map(({ label, amount }) => (
+
+ {label}
+ ETB {amount}
+
+ ))}
+
+
+ Total
+ ETB 1,000
+
+
+ }>
+
+ Transfer the fee to CBE Account: 1000-XXXXX-EMA and upload the receipt below.
+
+
+ } mt="md" accept=".pdf,.jpg,.jpeg,.png" />
+
+ )}
+
+ {/* Step 3 — Review */}
+ {step === 3 && (
+
+ Review Your Application
+
+ {[
+ ['CoC Number', cocNo],
+ ['Country', country],
+ ['Issuer', issuer],
+ ['CoC Type', cocType],
+ ['Issue Date', issueDate],
+ ['Expiry Date', expiryDate],
+ ].map(([label, value]) => (
+
+ {label}
+ {value || '—'}
+
+ ))}
+
+
+ Uploaded Documents
+
+ {[
+ { label: 'Foreign CoC', file: docs.foreignCoc },
+ { label: 'Medical Certificate', file: docs.medical },
+ { label: 'Seaman Book', file: docs.seamanBook },
+ { label: 'Photo', file: docs.photo },
+ { label: 'Translation', file: docs.translation },
+ ].map(({ label, file }) => file && (
+ }>
+ {label}: {file.name}
+
+ ))}
+
+ } mt="lg">
+
+ By submitting you confirm that all information is accurate and the documents are genuine.
+ Providing false information is an offence under the Maritime Code.
+
+
+
+ )}
+
+ {/* Navigation */}
+
+ } onClick={() => setStep(s => s - 1)} disabled={step === 0}>
+ Back
+
+ {step < 3 ? (
+ }
+ disabled={(step === 0 && !step0Ok) || (step === 1 && !step1Ok)}
+ onClick={() => setStep(s => s + 1)}
+ >
+ Next
+
+ ) : (
+ } onClick={() => setSubmitted(true)}>
+ Submit Application
+
+ )}
+
+
+ );
+}
+
+// ---------------------------------------------------------------------------
+// Main page
+// ---------------------------------------------------------------------------
+export function EndorsementPage() {
+ const navigate = useNavigate();
+ const [applying, setApplying] = useState(false);
+ const [previewId, setPreviewId] = useState(null);
+
+ if (applying) {
+ return (
+
+
+ } onClick={() => setApplying(false)}>Back
+
+
Apply for Endorsement
+ STCW Reg I/10 — Flag State Endorsement of Foreign CoC
+
+
+ setApplying(false)} />
+
+ );
+ }
+
+ return (
+
+
+
+
Endorsements
+ STCW Reg I/10 — Flag-state endorsement of foreign-issued Certificates of Competency
+
+ } rightSection={ } onClick={() => setApplying(true)}>
+ Apply for Endorsement
+
+
+
+ {/* Info panel */}
+
+
+
+
+ What is an Endorsement?
+
+ {[
+ { icon: IconShieldCheck, color: 'blue', title: 'Flag-State Recognition', desc: 'Under STCW Reg I/10, Ethiopia (flag state) must endorse foreign CoC certificates before a seafarer can serve on Ethiopian-flagged vessels.' },
+ { icon: IconCircleCheck, color: 'teal', title: 'Co-Terminous Validity', desc: 'The endorsement is valid for the same period as your foreign CoC. It must be revalidated whenever the foreign CoC is revalidated.' },
+ { icon: IconClock, color: 'orange', title: 'Processing Time', desc: 'Typical processing time is 10–15 working days after all documents are verified.' },
+ ].map(({ icon: Icon, color, title, desc }) => (
+
+
+
+ {title}
+
+ {desc}
+
+ ))}
+
+
+
+
+
+ {/* Active applications */}
+
+ My Endorsement Applications
+ {MOCK_ENDORSEMENTS.length === 0 ? (
+ }>
+ No active endorsement applications.
+
+ ) : (
+
+ {MOCK_ENDORSEMENTS.map((app) => (
+
+
+
+
+
+ {app.cocType}
+ CoC No: {app.foreignCocNo} · {app.issuingCountry} · Submitted {app.submitted}
+
+
+
+ {app.status}
+ {app.id}
+
+
+ } p="xs" mt="sm">
+ {app.statusNote}
+
+
+ ))}
+
+ )}
+
+
+ {/* Issued endorsements */}
+
+ My Endorsements
+ {MOCK_ISSUED.length === 0 ? (
+ }>
+ No endorsements issued yet.
+
+ ) : (
+
+ {MOCK_ISSUED.map((end) => (
+
+
+
+
+
+ {end.cocType}
+ {end.endorsementNo}
+
+
+ {end.status}
+
+
+
+ Foreign CoC No. {end.foreignCocNo}
+ Issuing Country {end.issuingCountry}
+ Issued {end.issued}
+ Expires {end.expiry}
+
+
+ } onClick={() => setPreviewId(end.id)}>View
+ }>Download
+
+
+ ))}
+
+ )}
+
+
+ {/* Preview modal */}
+ setPreviewId(null)}
+ title={Endorsement Certificate }
+ size="xl"
+ radius="lg"
+ >
+
+
+
+ );
+}
diff --git a/apps/portal/src/app/features/location/components/LocationPicker.tsx b/apps/portal/src/app/features/location/components/LocationPicker.tsx
index 2b1205f67..a68fb839a 100644
--- a/apps/portal/src/app/features/location/components/LocationPicker.tsx
+++ b/apps/portal/src/app/features/location/components/LocationPicker.tsx
@@ -6,11 +6,12 @@ import { useTranslation } from 'react-i18next';
interface LocationPickerProps {
value?: string;
- onChange: (locationId: string | null) => void;
+ onChange?: (locationId: string | null) => void;
+ onChainChange?: (chain: Location[]) => void;
required?: boolean;
}
-export function LocationPicker({ value, onChange, required }: LocationPickerProps) {
+export function LocationPicker({ value, onChange, onChainChange, required }: LocationPickerProps) {
const { t } = useTranslation();
const { data: typesRes, isLoading: typesLoading } = useGetLocationTypesQuery();
const { data: locsRes, isLoading: locsLoading } = useGetLocationsQuery({ take: 10000 });
@@ -54,7 +55,8 @@ export function LocationPicker({ value, onChange, required }: LocationPickerProp
current = current.parentId ? locMap.get(current.parentId) : undefined;
}
setSelectedChain(chain);
- }, [value, locMap]);
+ onChainChange?.(chain);
+ }, [value, locMap, onChainChange]);
const currentLevelChildren = useMemo(() => {
const parentId =
@@ -89,7 +91,8 @@ export function LocationPicker({ value, onChange, required }: LocationPickerProp
if (!id) {
const newChain = selectedChain.slice(0, -1);
setSelectedChain(newChain);
- onChange(newChain.length > 0 ? newChain[newChain.length - 1].id : null);
+ onChange?.(newChain.length > 0 ? newChain[newChain.length - 1].id : null);
+ onChainChange?.(newChain);
return;
}
@@ -100,9 +103,10 @@ export function LocationPicker({ value, onChange, required }: LocationPickerProp
newChain.push(loc);
setSelectedChain(newChain);
- onChange(id);
+ onChange?.(id);
+ onChainChange?.(newChain);
},
- [selectedChain, locMap, onChange, depth],
+ [selectedChain, locMap, onChange, onChainChange, depth],
);
const buildOptions = (levelIdx: number) => {
diff --git a/apps/portal/src/app/features/medical/pages/MedicalCertificatePage.tsx b/apps/portal/src/app/features/medical/pages/MedicalCertificatePage.tsx
new file mode 100644
index 000000000..dbf0c9f31
--- /dev/null
+++ b/apps/portal/src/app/features/medical/pages/MedicalCertificatePage.tsx
@@ -0,0 +1,335 @@
+import { useRef, useState } from 'react';
+import {
+ Alert,
+ Badge,
+ Box,
+ Button,
+ Card,
+ FileButton,
+ Group,
+ Paper,
+ Progress,
+ SimpleGrid,
+ Stack,
+ Text,
+ TextInput,
+ ThemeIcon,
+ Timeline,
+ Title,
+ rem,
+} from '@mantine/core';
+import {
+ IconAlertCircle,
+ IconAlertTriangle,
+ IconCalendar,
+ IconCheck,
+ IconCircleCheck,
+ IconDownload,
+ IconFileDescription,
+ IconHeart,
+ IconInfoCircle,
+ IconTrash,
+ IconUpload,
+} from '@tabler/icons-react';
+import { notify } from '@ema-platform/ui';
+
+// ---------------------------------------------------------------------------
+// Mock current certificate — replace with real API data
+// ---------------------------------------------------------------------------
+const MOCK_CURRENT: MedicalCert | null = {
+ id: 'MC-2024-001',
+ issuedBy: 'EMA Approved Medical Center — Addis Ababa',
+ issuedDate: '2024-03-15',
+ expiryDate: '2026-03-14',
+ status: 'Expiring',
+ restrictions: 'None',
+ fileName: 'medical_cert_2024.pdf',
+};
+
+const MOCK_HISTORY: MedicalCert[] = [
+ { id: 'MC-2022-001', issuedBy: 'EMA Approved Medical Center', issuedDate: '2022-03-10', expiryDate: '2024-03-09', status: 'Expired', restrictions: 'None', fileName: 'medical_cert_2022.pdf' },
+ { id: 'MC-2020-001', issuedBy: 'EMA Approved Medical Center', issuedDate: '2020-02-20', expiryDate: '2022-02-19', status: 'Expired', restrictions: 'None', fileName: 'medical_cert_2020.pdf' },
+];
+
+interface MedicalCert {
+ id: string;
+ issuedBy: string;
+ issuedDate: string;
+ expiryDate: string;
+ status: 'Valid' | 'Expiring' | 'Expired' | 'Pending';
+ restrictions: string;
+ fileName: string;
+}
+
+const STATUS_COLOR: Record = {
+ Valid: 'teal', Expiring: 'orange', Expired: 'red', Pending: 'yellow',
+};
+
+function daysUntil(dateStr: string): number {
+ return Math.ceil((new Date(dateStr).getTime() - Date.now()) / (1000 * 60 * 60 * 24));
+}
+
+function formatDate(dateStr: string): string {
+ return new Date(dateStr).toLocaleDateString('en-GB', { day: '2-digit', month: 'short', year: 'numeric' });
+}
+
+// ---------------------------------------------------------------------------
+// Component
+// ---------------------------------------------------------------------------
+export function MedicalCertificatePage() {
+ const [current] = useState(MOCK_CURRENT);
+ const [uploadedFile, setUploadedFile] = useState(null);
+ const [doctorName, setDoctorName] = useState('');
+ const [issuedDate, setIssuedDate] = useState('');
+ const [expiryDate, setExpiryDate] = useState('');
+ const [submitting, setSubmitting] = useState(false);
+ const resetRef = useRef<() => void>(null);
+
+ const days = current ? daysUntil(current.expiryDate) : 0;
+ const progressVal = current
+ ? Math.max(0, Math.min(100, (days / 730) * 100))
+ : 0;
+
+ const handleSubmit = async () => {
+ if (!uploadedFile || !issuedDate || !expiryDate) {
+ notify.error('Please fill all fields and upload the certificate file.');
+ return;
+ }
+ setSubmitting(true);
+ await new Promise((r) => setTimeout(r, 1200));
+ setSubmitting(false);
+ notify.success('Medical certificate submitted for verification. EMA will review within 2 working days.');
+ setUploadedFile(null);
+ setDoctorName('');
+ setIssuedDate('');
+ setExpiryDate('');
+ resetRef.current?.();
+ };
+
+ return (
+
+ {/* Header */}
+
+
Medical Certificate
+
+ STCW requires a valid medical certificate for all seafarers. Valid for 2 years (1 year if under 18).
+
+
+
+ {/* Validity alert */}
+ {current && days <= 90 && days > 0 && (
+ }>
+ Your medical certificate expires in {days} days ({formatDate(current.expiryDate)}).
+ Please visit an EMA-approved medical centre and upload your renewed certificate below.
+
+ )}
+ {current && days <= 0 && (
+ }>
+ Your medical certificate has expired . You cannot join a vessel until a valid certificate is uploaded and verified.
+
+ )}
+ {!current && (
+ }>
+ No medical certificate on record. Upload your certificate below to complete your profile and apply for a Seaman Book.
+
+ )}
+
+
+ {/* Current certificate */}
+
+
+
+
+
+ Current Certificate
+
+
+ {current ? (
+
+
+ Status
+ {current.status}
+
+
+ Certificate ID
+ {current.id}
+
+
+ Issued By
+ {current.issuedBy}
+
+
+ Issue Date
+ {formatDate(current.issuedDate)}
+
+
+ Expiry Date
+
+ {formatDate(current.expiryDate)}
+
+
+
+ Restrictions
+ {current.restrictions}
+
+
+ {/* Validity bar */}
+
+
+ Validity remaining
+ {Math.max(0, days)} days
+
+
+
+
+ }
+ mt="xs"
+ >
+ Download Certificate
+
+
+ ) : (
+
+
+
+
+ No certificate on record
+
+ )}
+
+
+ {/* Upload new certificate */}
+
+
+
+
+
+ {current ? 'Upload Renewal' : 'Upload Certificate'}
+
+
+
+ setDoctorName(e.currentTarget.value)}
+ size="sm"
+ />
+
+ setIssuedDate(e.currentTarget.value)}
+ size="sm"
+ />
+ setExpiryDate(e.currentTarget.value)}
+ size="sm"
+ />
+
+
+
+ Certificate File *
+ {uploadedFile ? (
+
+
+
+ {uploadedFile.name}
+ { setUploadedFile(null); resetRef.current?.(); }}>
+
+
+
+
+ ) : (
+
+ {(props) => (
+ } fullWidth {...props}>
+ Choose File (PDF / JPG / PNG, max 5MB)
+
+ )}
+
+ )}
+
+
+ } p="xs">
+
+ Your certificate will be reviewed by an EMA Medical Officer within 2 working days .
+ Notifications will be sent by email and SMS.
+
+
+
+ }
+ onClick={handleSubmit}
+ loading={submitting}
+ disabled={!uploadedFile || !issuedDate || !expiryDate}
+ >
+ Submit for Verification
+
+
+
+
+
+ {/* Notification schedule */}
+
+
+
+
+
+ Expiry Notification Schedule
+
+
+ } title="90 Days Before Expiry">
+ First reminder — time to book your medical examination
+
+ } title="60 Days Before Expiry">
+ Second reminder — urgent renewal required
+
+ } title="30 Days Before Expiry">
+ Final reminder — certificate expires very soon
+
+
+
+
+ {/* History */}
+ {MOCK_HISTORY.length > 0 && (
+
+ Certificate History
+
+ {MOCK_HISTORY.map((cert) => (
+
+
+
+
+
+
+
+ {cert.id}
+ {formatDate(cert.issuedDate)} → {formatDate(cert.expiryDate)}
+
+
+
+ {cert.status}
+ }>Download
+
+
+
+ ))}
+
+
+ )}
+
+ );
+}
diff --git a/apps/portal/src/app/features/notifications/pages/NotificationsPage.tsx b/apps/portal/src/app/features/notifications/pages/NotificationsPage.tsx
new file mode 100644
index 000000000..e9407f455
--- /dev/null
+++ b/apps/portal/src/app/features/notifications/pages/NotificationsPage.tsx
@@ -0,0 +1,331 @@
+import { useState } from 'react';
+import {
+ ActionIcon,
+ Badge,
+ Box,
+ Button,
+ Card,
+ Group,
+ Paper,
+ Select,
+ SimpleGrid,
+ Stack,
+ Text,
+ ThemeIcon,
+ Title,
+ rem,
+} from '@mantine/core';
+import {
+ IconAlertCircle,
+ IconAlertTriangle,
+ IconBell,
+ IconBellOff,
+ IconBook2,
+ IconCheck,
+ IconCircleCheck,
+ IconFileDescription,
+ IconHeart,
+ IconInfoCircle,
+ IconShield,
+ IconTrash,
+} from '@tabler/icons-react';
+import { notify } from '@ema-platform/ui';
+
+// ---------------------------------------------------------------------------
+// Types & mock data
+// ---------------------------------------------------------------------------
+type NotifType = 'warning' | 'info' | 'success' | 'error';
+type NotifCategory = 'Medical' | 'Seaman Book' | 'BST' | 'Certificate' | 'Application' | 'System';
+
+interface Notification {
+ id: string;
+ type: NotifType;
+ category: NotifCategory;
+ title: string;
+ message: string;
+ date: string;
+ read: boolean;
+ actionLabel?: string;
+ actionRoute?: string;
+}
+
+const MOCK_NOTIFICATIONS: Notification[] = [
+ {
+ id: '1',
+ type: 'warning',
+ category: 'Medical',
+ title: 'Medical Certificate Expiring Soon',
+ message: 'Your medical certificate expires on 14 March 2026 — 45 days remaining. Visit an EMA-approved medical centre to renew before it lapses.',
+ date: '2026-01-28',
+ read: false,
+ actionLabel: 'View Medical Certificate',
+ actionRoute: '/medical-certificate',
+ },
+ {
+ id: '2',
+ type: 'info',
+ category: 'BST',
+ title: 'Basic Safety Training Incomplete',
+ message: '3 of your 5 Basic Safety Training certificates are missing (EFA, PSSR, SHPT). All 5 are required before you can apply for a Seaman Book.',
+ date: '2026-01-25',
+ read: false,
+ actionLabel: 'Manage BST Certificates',
+ actionRoute: '/basic-safety-training',
+ },
+ {
+ id: '3',
+ type: 'success',
+ category: 'Application',
+ title: 'Seaman Book Application Under Review',
+ message: 'Your Seaman Book application (SB-APP-2024-001) has been received and is currently under review by an EMA Registration Officer.',
+ date: '2024-05-10',
+ read: true,
+ actionLabel: 'Track Application',
+ actionRoute: '/seaman-book',
+ },
+ {
+ id: '4',
+ type: 'success',
+ category: 'BST',
+ title: 'PST Certificate Verified',
+ message: 'Your Personal Survival Techniques (PST) certificate has been verified and approved. Certificate No: PST-2023-BMS-0421.',
+ date: '2023-04-15',
+ read: true,
+ },
+ {
+ id: '5',
+ type: 'success',
+ category: 'BST',
+ title: 'FPFF Certificate Verified',
+ message: 'Your Fire Prevention & Fire Fighting (FPFF) certificate has been verified and approved. Certificate No: FPFF-2023-BMS-0421.',
+ date: '2023-04-15',
+ read: true,
+ },
+ {
+ id: '6',
+ type: 'info',
+ category: 'System',
+ title: 'Profile Setup Incomplete',
+ message: 'Your seafarer profile is 60% complete. Please upload your National ID, passport size photo and remaining documents to complete your registration.',
+ date: '2024-01-05',
+ read: true,
+ actionLabel: 'Go to Document Vault',
+ actionRoute: '/documents',
+ },
+];
+
+const TYPE_CONFIG: Record = {
+ warning: { color: 'orange', icon: IconAlertTriangle },
+ info: { color: 'blue', icon: IconInfoCircle },
+ success: { color: 'teal', icon: IconCircleCheck },
+ error: { color: 'red', icon: IconAlertCircle },
+};
+
+const CATEGORY_ICON: Record = {
+ Medical: IconHeart,
+ 'Seaman Book': IconBook2,
+ BST: IconShield,
+ Certificate: IconFileDescription,
+ Application: IconFileDescription,
+ System: IconBell,
+};
+
+const CATEGORIES = ['All', 'Medical', 'Seaman Book', 'BST', 'Certificate', 'Application', 'System'] as const;
+
+function formatDate(dateStr: string) {
+ return new Date(dateStr).toLocaleDateString('en-GB', { day: '2-digit', month: 'short', year: 'numeric' });
+}
+
+// ---------------------------------------------------------------------------
+// Component
+// ---------------------------------------------------------------------------
+export function NotificationsPage() {
+ const [notifications, setNotifications] = useState(MOCK_NOTIFICATIONS);
+ const [filter, setFilter] = useState('All');
+ const [readFilter, setReadFilter] = useState(null);
+
+ const unreadCount = notifications.filter((n) => !n.read).length;
+
+ const filtered = notifications.filter((n) => {
+ const matchCat = filter === 'All' || n.category === filter;
+ const matchRead =
+ !readFilter ||
+ (readFilter === 'Unread' && !n.read) ||
+ (readFilter === 'Read' && n.read);
+ return matchCat && matchRead;
+ });
+
+ const markRead = (id: string) => {
+ setNotifications((prev) => prev.map((n) => n.id === id ? { ...n, read: true } : n));
+ };
+
+ const markAllRead = () => {
+ setNotifications((prev) => prev.map((n) => ({ ...n, read: true })));
+ notify.success('All notifications marked as read.');
+ };
+
+ const deleteNotif = (id: string) => {
+ setNotifications((prev) => prev.filter((n) => n.id !== id));
+ notify.info('Notification removed.');
+ };
+
+ return (
+
+ {/* Header */}
+
+
+
+ Notifications
+ {unreadCount > 0 && (
+ {unreadCount}
+ )}
+
+ Stay up to date on your certificates, applications and deadlines
+
+ {unreadCount > 0 && (
+ } onClick={markAllRead}>
+ Mark all as read
+
+ )}
+
+
+ {/* Stats */}
+
+ {[
+ { label: 'All', count: notifications.length, color: 'gray', icon: IconBell },
+ { label: 'Unread', count: notifications.filter((n) => !n.read).length, color: 'blue', icon: IconBell },
+ { label: 'Warnings', count: notifications.filter((n) => n.type === 'warning').length, color: 'orange', icon: IconAlertTriangle },
+ { label: 'Actions Needed', count: notifications.filter((n) => !n.read && n.type !== 'success').length, color: 'red', icon: IconAlertCircle },
+ ].map(({ label, count, color, icon: Icon }) => (
+
+
+
+
+
+
+ {count}
+ {label}
+
+
+
+ ))}
+
+
+ {/* Filters */}
+
+
+ {CATEGORIES.map((cat) => (
+ setFilter(cat)}
+ >
+ {cat}
+
+ ))}
+
+
+
+
+ {/* Notification list */}
+ {filtered.length === 0 ? (
+
+
+
+
+ No notifications found
+ { setFilter('All'); setReadFilter(null); }}>
+ Clear filters
+
+
+ ) : (
+
+ {filtered.map((n) => {
+ const { color, icon: TypeIcon } = TYPE_CONFIG[n.type];
+ const CatIcon = CATEGORY_ICON[n.category];
+
+ return (
+ markRead(n.id)}
+ >
+
+
+
+
+
+
+
+ {n.title}
+ {!n.read && New }
+ }>
+ {n.category}
+
+
+ {n.message}
+
+ {formatDate(n.date)}
+ {n.actionLabel && (
+ e.stopPropagation()}
+ >
+ {n.actionLabel}
+
+ )}
+
+
+
+
+ {!n.read && (
+ { e.stopPropagation(); markRead(n.id); }}
+ >
+
+
+ )}
+ { e.stopPropagation(); deleteNotif(n.id); }}
+ >
+
+
+
+
+
+ );
+ })}
+
+ )}
+
+ );
+}
diff --git a/apps/portal/src/app/features/profile-setup/pages/ProfileSetupPage.tsx b/apps/portal/src/app/features/profile-setup/pages/ProfileSetupPage.tsx
new file mode 100644
index 000000000..f89f8a878
--- /dev/null
+++ b/apps/portal/src/app/features/profile-setup/pages/ProfileSetupPage.tsx
@@ -0,0 +1,370 @@
+import { useEffect, useMemo, useRef, useState } from 'react';
+import {
+ Box,
+ Button,
+ Center,
+ Group,
+ Paper,
+ Stack,
+ Text,
+ Title,
+ rem,
+} from '@mantine/core';
+import {
+ IconArrowLeft,
+ IconArrowRight,
+ IconCheck,
+ IconCircleCheck,
+ IconLogout2,
+ IconMapPin,
+ IconUser,
+} from '@tabler/icons-react';
+import { useForm } from 'react-hook-form';
+import { zodResolver } from '@hookform/resolvers/zod';
+import { useNavigate } from 'react-router-dom';
+import { useApiMutation } from '@ema-platform/api';
+import { notify } from '@ema-platform/ui';
+import { authStorage, setUser, logout, type AuthUser } from '@ema-platform/auth';
+import { useAppDispatch, useAppSelector } from '../../../store/hooks';
+import {
+ ProfileFormContent,
+ profileSchema,
+ type ProfileValues,
+} from '../../profile/components/ProfileFormContent';
+import {
+ AddressFormContent,
+ addressSchema,
+ type AddressValues,
+} from '../../profile/components/AddressFormContent';
+
+const STEPS = [
+ { label: 'Profile', icon: IconUser },
+ { label: 'Address', icon: IconMapPin },
+];
+
+function StepIndicator({ active, completed }: { active: number; completed: number[] }) {
+ return (
+
+
+ {STEPS.map((step, i) => {
+ const isDone = completed.includes(i);
+ const isCurrent = active === i;
+ return (
+
+
+
+ {isDone ? (
+
+ ) : (
+
+ {i + 1}
+
+ )}
+
+
+ {step.label}
+
+
+
+ {i < STEPS.length - 1 && (
+
+ )}
+
+ );
+ })}
+
+
+ );
+}
+
+export function ProfileSetupPage() {
+ const navigate = useNavigate();
+ const dispatch = useAppDispatch();
+ const user = useAppSelector((state) => state.auth.user);
+ const [active, setActive] = useState(0);
+ const [completed, setCompleted] = useState([]);
+ const [submitting, setSubmitting] = useState(false);
+ const [professions, setProfessions] = useState>([]);
+ const [professionsLoading, setProfessionsLoading] = useState(true);
+ const [profileTrigger] = useApiMutation<{ id: string }>();
+ const [addressTrigger] = useApiMutation();
+ const [meTrigger] = useApiMutation();
+ const [fetchProfessions] = useApiMutation<{ count: number; items: Array<{ id: string; name: { en: string } }> }>();
+ const fetched = useRef(false);
+
+ useEffect(() => {
+ if (fetched.current) return;
+ fetched.current = true;
+ fetchProfessions({ url: '/professions?take=100', method: 'GET' })
+ .unwrap()
+ .then((data) => setProfessions(data.items ?? []))
+ .catch(() => setProfessions([]))
+ .finally(() => setProfessionsLoading(false));
+ }, [fetchProfessions]);
+
+ const professionOptions = useMemo(
+ () => professions.map((p) => ({ value: p.id, label: p.name.en })),
+ [professions],
+ );
+
+ const professionNameMap = useMemo(() => {
+ const map: Record = {};
+ professions.forEach((p) => {
+ map[p.id] = p.name.en;
+ });
+ return map;
+ }, [professions]);
+
+ const nameParts = useMemo(() => (user?.name?.en || '').trim().split(/\s+/), [user]);
+ const profileDefaults: ProfileValues = useMemo(() => ({
+ professionId: '',
+ firstName: nameParts[0] || '',
+ middleName: nameParts.length > 2 ? nameParts.slice(1, -1).join(' ') : '',
+ lastName: nameParts.length > 1 ? nameParts[nameParts.length - 1] : '',
+ gender: '',
+ dob: '',
+ pob: '',
+ maritalStatus: '',
+ }), [nameParts]);
+
+ const addressDefaults: AddressValues = useMemo(() => ({
+ idType: '',
+ idNumber: '',
+ nationality: '',
+ primaryPhoneNumber: user?.phoneNumber || '',
+ secondaryPhoneNumber: '',
+ email: user?.email || '',
+ regionId: '',
+ cityId: '',
+ subcityId: '',
+ woredaId: '',
+ kebeleId: '',
+ streetAddress: '',
+ postalAddress: '',
+ emergencyContactName: '',
+ emergencyContactPhone: '',
+ emergencyContactRelation: '',
+ }), [user]);
+
+ const {
+ register: profileRegister,
+ handleSubmit: profileHandleSubmit,
+ formState: { errors: profileErrors },
+ setValue: profileSetValue,
+ watch: profileWatch,
+ trigger: profileTriggerValidation,
+ } = useForm({
+ resolver: zodResolver(profileSchema),
+ defaultValues: profileDefaults,
+ });
+
+ const {
+ register: addressRegister,
+ handleSubmit: addressHandleSubmit,
+ formState: { errors: addressErrors },
+ setValue: addressSetValue,
+ watch: addressWatch,
+ trigger: addressTriggerValidation,
+ } = useForm({
+ resolver: zodResolver(addressSchema),
+ defaultValues: addressDefaults,
+ });
+
+ const onNext = async () => {
+ const valid = await profileTriggerValidation();
+ if (!valid) return;
+ setCompleted((prev) => (prev.includes(active) ? prev : [...prev, active]));
+ setActive((c) => c + 1);
+ };
+
+ const onSubmitAddress = async () => {
+ const valid = await addressTriggerValidation();
+ if (!valid) return;
+
+ setSubmitting(true);
+ try {
+ const pv = profileWatch();
+ const av = addressWatch();
+ const selectedProfessionName = professionNameMap[pv.professionId] ?? '';
+
+ const profileResult = await profileTrigger({
+ url: '/profiles',
+ method: 'POST',
+ body: {
+ userId: user?.id,
+ type: 'SEAFARER',
+ professionId: pv.professionId,
+ firstName: pv.firstName,
+ middleName: pv.middleName,
+ lastName: pv.lastName,
+ gender: pv.gender,
+ dob: pv.dob,
+ pob: pv.pob || undefined,
+ maritalStatus: pv.maritalStatus,
+ },
+ }).unwrap();
+ authStorage.setProfileId(profileResult.id);
+
+ await addressTrigger({
+ url: `/addresss/profile/${profileResult.id}`,
+ method: 'POST',
+ body: {
+ idType: av.idType,
+ idNumber: av.idNumber,
+ nationality: av.nationality,
+ primaryPhoneNumber: av.primaryPhoneNumber,
+ secondaryPhoneNumber: av.secondaryPhoneNumber || undefined,
+ email: av.email || undefined,
+ regionId: av.regionId || undefined,
+ cityId: av.cityId || undefined,
+ subcityId: av.subcityId || undefined,
+ woredaId: av.woredaId || undefined,
+ kebeleId: av.kebeleId || undefined,
+ streetAddress: av.streetAddress || undefined,
+ postalAddess: av.postalAddress || undefined,
+ emergencyContactName: av.emergencyContactName || undefined,
+ emergencyContactPhone: av.emergencyContactPhone || undefined,
+ emergencyContactRelation: av.emergencyContactRelation || undefined,
+ },
+ }).unwrap();
+
+ const me = await meTrigger({ url: '/auth/me', method: 'GET' }).unwrap();
+ dispatch(setUser(me));
+
+ notify.success('Profile setup complete!');
+ navigate('/dashboard');
+ } catch {
+ notify.error('Failed to save profile. Please try again.');
+ } finally {
+ setSubmitting(false);
+ }
+ };
+
+ if (!user) {
+ return (
+
+ Please log in first.
+
+ );
+ }
+
+ return (
+
+
+
+
+
Complete Your Profile
+
+ Set up your profile and address to get started
+
+
+
+
+
+ {active === 0 && (
+ <>
+
+ Personal Information
+
+
+ >
+ )}
+
+ {active === 1 && (
+ <>
+
+ Identity & Contact
+
+
+ >
+ )}
+
+
+ }
+ onClick={() => {
+ dispatch(logout());
+ navigate('/login');
+ }}
+ >
+ Sign out
+
+
+ {active > 0 && (
+ }
+ onClick={() => setActive((c) => c - 1)}
+ >
+ Previous
+
+ )}
+ {active < STEPS.length - 1 ? (
+ } onClick={onNext}>
+ Next Step
+
+ ) : (
+ }
+ onClick={onSubmitAddress}
+ loading={submitting}
+ >
+ Complete Setup
+
+ )}
+
+
+
+
+
+ );
+}
diff --git a/apps/portal/src/app/features/profile/components/AddressFormContent.tsx b/apps/portal/src/app/features/profile/components/AddressFormContent.tsx
new file mode 100644
index 000000000..1be246cb6
--- /dev/null
+++ b/apps/portal/src/app/features/profile/components/AddressFormContent.tsx
@@ -0,0 +1,177 @@
+import { useCallback, useMemo } from 'react';
+import { Select, SimpleGrid, Text, TextInput } from '@mantine/core';
+import type { FieldErrors, UseFormRegister, UseFormSetValue, UseFormWatch, UseFormTrigger } from 'react-hook-form';
+import { z } from 'zod';
+import { LocationPicker } from '../../location/components/LocationPicker';
+import { useGetLocationTypesQuery } from '../../location/api/location-api';
+import type { Location } from '../../location/types/location';
+
+export const addressSchema = z.object({
+ idType: z.string().min(1, 'Select ID type'),
+ idNumber: z.string().min(1, 'Enter ID number'),
+ nationality: z.string().min(1, 'Enter nationality'),
+ primaryPhoneNumber: z.string().min(1, 'Enter primary phone number'),
+ secondaryPhoneNumber: z.string().optional(),
+ email: z.string().email('Invalid email').optional().or(z.literal('')),
+ regionId: z.string().optional(),
+ cityId: z.string().optional(),
+ subcityId: z.string().optional(),
+ woredaId: z.string().optional(),
+ kebeleId: z.string().optional(),
+ streetAddress: z.string().optional(),
+ postalAddress: z.string().optional(),
+ emergencyContactName: z.string().optional(),
+ emergencyContactPhone: z.string().optional(),
+ emergencyContactRelation: z.string().optional(),
+});
+
+export type AddressValues = z.infer;
+
+export const ID_TYPES = ['NID', 'VITAL', 'PASSPORT', 'DRIVERS_LICENSE'] as const;
+
+const LEVEL_TO_FIELD: Record = {
+ 1: 'cityId',
+ 2: 'subcityId',
+ 3: 'woredaId',
+ 4: 'kebeleId',
+};
+
+interface AddressFormContentProps {
+ register: UseFormRegister;
+ errors: FieldErrors;
+ setValue: UseFormSetValue;
+ watch: UseFormWatch;
+ trigger: UseFormTrigger;
+}
+
+export function AddressFormContent({
+ register,
+ errors,
+ setValue,
+ watch,
+ trigger,
+}: AddressFormContentProps) {
+ const { data: typesRes } = useGetLocationTypesQuery();
+ const locationTypes = typesRes?.items ?? [];
+
+ const typeLevelMap = useMemo(() => {
+ const map = new Map();
+ locationTypes.forEach((lt) => map.set(lt.id, lt.level));
+ return map;
+ }, [locationTypes]);
+
+ const leafId = watch('kebeleId') || watch('woredaId') || watch('subcityId') || watch('cityId') || undefined;
+
+ const handleChainChange = useCallback(
+ (chain: Location[]) => {
+ if (chain.length > 0 && !typeLevelMap.has(chain[0].locationTypeId)) {
+ return;
+ }
+
+ setValue('cityId', '');
+ setValue('subcityId', '');
+ setValue('woredaId', '');
+ setValue('kebeleId', '');
+
+ chain.forEach((loc) => {
+ const level = typeLevelMap.get(loc.locationTypeId);
+ if (level && LEVEL_TO_FIELD[level]) {
+ setValue(LEVEL_TO_FIELD[level], loc.id);
+ }
+ });
+ },
+ [setValue, typeLevelMap],
+ );
+
+ return (
+ <>
+
+ setValue('idType', val || '', { shouldValidate: true })}
+ onBlur={() => trigger('idType')}
+ name="idType"
+ />
+
+
+
+
+
+
+
+
+ Address
+
+
+
+
+
+
+
+ Emergency Contact
+
+
+
+
+
+
+ >
+ );
+}
diff --git a/apps/portal/src/app/features/profile/components/ProfileFormContent.tsx b/apps/portal/src/app/features/profile/components/ProfileFormContent.tsx
new file mode 100644
index 000000000..5ed92dd72
--- /dev/null
+++ b/apps/portal/src/app/features/profile/components/ProfileFormContent.tsx
@@ -0,0 +1,115 @@
+import { Loader, Select, SimpleGrid, TextInput } from '@mantine/core';
+import type { FieldErrors, UseFormRegister, UseFormSetValue, UseFormWatch, UseFormTrigger } from 'react-hook-form';
+import { z } from 'zod';
+
+export const profileSchema = z.object({
+ professionId: z.string().min(1, 'Select your profession'),
+ firstName: z.string().min(3, 'First name must be at least 3 characters'),
+ middleName: z.string().min(3, 'Middle name must be at least 3 characters'),
+ lastName: z.string().min(3, 'Last name must be at least 3 characters'),
+ gender: z.string().min(1, 'Select your gender'),
+ dob: z.string().min(1, 'Select your date of birth'),
+ pob: z.string().optional(),
+ maritalStatus: z.string().min(1, 'Select your marital status'),
+});
+
+export type ProfileValues = z.infer;
+
+export const GENDERS = ['MALE', 'FEMALE'] as const;
+export const MARITAL_STATUSES = ['SINGLE', 'MARRIED', 'DIVORCED', 'WIDOWED'] as const;
+export const ID_TYPES = ['NID', 'VITAL', 'PASSPORT', 'DRIVERS_LICENSE'] as const;
+
+interface ProfileFormContentProps {
+ register: UseFormRegister;
+ errors: FieldErrors;
+ setValue: UseFormSetValue;
+ watch: UseFormWatch;
+ trigger: UseFormTrigger;
+ professionsLoading: boolean;
+ professionOptions: Array<{ value: string; label: string }>;
+}
+
+export function ProfileFormContent({
+ register,
+ errors,
+ setValue,
+ watch,
+ trigger,
+ professionsLoading,
+ professionOptions,
+}: ProfileFormContentProps) {
+ return (
+
+ setValue('professionId', val || '', { shouldValidate: true })}
+ onBlur={() => trigger('professionId')}
+ name="professionId"
+ searchable
+ disabled={professionsLoading}
+ rightSection={professionsLoading ? : undefined}
+ />
+
+
+
+ setValue('gender', val || '', { shouldValidate: true })}
+ onBlur={() => trigger('gender')}
+ name="gender"
+ />
+
+
+ setValue('maritalStatus', val || '', { shouldValidate: true })}
+ onBlur={() => trigger('maritalStatus')}
+ name="maritalStatus"
+ />
+
+ );
+}
diff --git a/apps/portal/src/app/features/profile/pages/ProfilePage.tsx b/apps/portal/src/app/features/profile/pages/ProfilePage.tsx
index 2c4493fa9..97ba640ce 100644
--- a/apps/portal/src/app/features/profile/pages/ProfilePage.tsx
+++ b/apps/portal/src/app/features/profile/pages/ProfilePage.tsx
@@ -1,10 +1,12 @@
-import { useEffect, useState } from 'react';
+import { useEffect, useMemo, useRef, useState } from 'react';
import {
Badge,
Box,
Button,
+ Center,
Divider,
Group,
+ Loader,
Paper,
PasswordInput,
SimpleGrid,
@@ -28,6 +30,7 @@ import {
IconDeviceFloppy,
IconLock,
IconMail,
+ IconMapPin,
IconMoon,
IconPhone,
IconSettings,
@@ -42,10 +45,21 @@ import { z } from 'zod';
import { useTranslation } from 'react-i18next';
import { notify, PageHeader } from '@ema-platform/ui';
import { useApiMutation } from '@ema-platform/api';
+import { authStorage, setUser, setCurrentProfile } from '@ema-platform/auth';
+import type { CurrentProfile } from '@ema-platform/auth';
import { SUPPORTED_LANGUAGES, type AppLanguage } from '../../../i18n/config';
import { useAppDispatch, useAppSelector } from '../../../store/hooks';
-import { setUser } from '@ema-platform/auth';
import type { AuthUser } from '@ema-platform/auth';
+import {
+ ProfileFormContent,
+ profileSchema,
+ type ProfileValues,
+} from '../components/ProfileFormContent';
+import {
+ AddressFormContent,
+ addressSchema,
+ type AddressValues,
+} from '../components/AddressFormContent';
import classes from './ProfilePage.module.css';
function getInitials(name: string, fallback: string) {
@@ -56,7 +70,6 @@ function getInitials(name: string, fallback: string) {
return letters.toUpperCase();
}
-/** 0–4 rough strength score used by the meter on the security tab. */
function passwordScore(pw: string) {
if (!pw) return 0;
let score = 0;
@@ -71,21 +84,121 @@ export function ProfilePage() {
const { t, i18n } = useTranslation();
const dispatch = useAppDispatch();
const user = useAppSelector((state) => state.auth.user);
+ const currentProfile = useAppSelector((state) => state.auth.currentProfile);
const { colorScheme, setColorScheme } = useMantineColorScheme();
const [updateTrigger] = useApiMutation();
const [meTrigger] = useApiMutation();
const [passwordTrigger] = useApiMutation();
+ const [fetchProfessions] = useApiMutation<{ count: number; items: Array<{ id: string; name: { en: string } }> }>();
const [isSavingProfile, setIsSavingProfile] = useState(false);
const [isSavingPassword, setIsSavingPassword] = useState(false);
+ const [isSavingMaritime, setIsSavingMaritime] = useState(false);
+ const [isSavingAddress, setIsSavingAddress] = useState(false);
- // UI-only preferences (no backend wiring yet).
const [twoStepEnabled, setTwoStepEnabled] = useState(false);
const [emailNotifications, setEmailNotifications] = useState(true);
- // Load the latest profile from the server on mount so the form always
- // reflects the current account information (the cached user may be stale).
+ // ---- Profession list (for Profile tab) ----
+ const [professions, setProfessions] = useState>([]);
+ const [professionsLoading, setProfessionsLoading] = useState(true);
+ const professionsFetched = useRef(false);
+
+ useEffect(() => {
+ if (professionsFetched.current) return;
+ professionsFetched.current = true;
+ fetchProfessions({ url: '/professions?take=100', method: 'GET' })
+ .unwrap()
+ .then((data) => setProfessions(data.items ?? []))
+ .catch(() => setProfessions([]))
+ .finally(() => setProfessionsLoading(false));
+ }, [fetchProfessions]);
+
+ const professionOptions = useMemo(
+ () => professions.map((p) => ({ value: p.id, label: p.name.en })),
+ [professions],
+ );
+
+ const professionNameMap = useMemo(() => {
+ const map: Record = {};
+ professions.forEach((p) => { map[p.id] = p.name.en; });
+ return map;
+ }, [professions]);
+
+ // ---- Profile data (from stored currentProfile) ----
+ const [fetchProfile] = useApiMutation<{ total: number; items: CurrentProfile[] }>();
+ const [updateProfile] = useApiMutation();
+ const [updateAddress] = useApiMutation();
+
+ const [loadedProfile, setLoadedProfile] = useState(null);
+ const [loadedAddress, setLoadedAddress] = useState(null);
+ const [profileId, setProfileId] = useState(null);
+ const [addressId, setAddressId] = useState(null);
+ const [dataLoading, setDataLoading] = useState(true);
+ const profileFetched = useRef(false);
+
+ useEffect(() => {
+ if (currentProfile) {
+ setProfileId(currentProfile.id);
+ setLoadedProfile({
+ professionId: currentProfile.professionId || currentProfile.profession?.id || '',
+ firstName: currentProfile.firstName || '',
+ middleName: currentProfile.middleName || '',
+ lastName: currentProfile.lastName || '',
+ gender: currentProfile.gender || '',
+ dob: currentProfile.dob ? currentProfile.dob.split('T')[0] : '',
+ pob: currentProfile.pob || '',
+ maritalStatus: currentProfile.maritalStatus || '',
+ });
+
+ if (currentProfile.address) {
+ setAddressId(currentProfile.address.id);
+ setLoadedAddress({
+ idType: currentProfile.address.idType || '',
+ idNumber: currentProfile.address.idNumber || '',
+ nationality: currentProfile.address.nationality || '',
+ primaryPhoneNumber: currentProfile.address.primaryPhoneNumber || '',
+ secondaryPhoneNumber: currentProfile.address.secondaryPhoneNumber || '',
+ email: currentProfile.address.email || '',
+ regionId: currentProfile.address.regionId || '',
+ cityId: currentProfile.address.cityId || '',
+ subcityId: currentProfile.address.subCityId || '',
+ woredaId: currentProfile.address.woredaId || '',
+ kebeleId: currentProfile.address.kebeleId || '',
+ streetAddress: currentProfile.address.streetAddress || '',
+ postalAddress: currentProfile.address.postalAddress || '',
+ emergencyContactName: currentProfile.address.emergencyContactName || '',
+ emergencyContactPhone: currentProfile.address.emergencyContactPhone || '',
+ emergencyContactRelation: currentProfile.address.emergencycontactRelation || '',
+ });
+ }
+ setDataLoading(false);
+ } else if (user && !profileFetched.current) {
+ profileFetched.current = true;
+ const profileId = authStorage.getProfileId();
+ if (profileId) {
+ const q = `w=user_id:=:${user.id}&i=user,address,profession`;
+ fetchProfile({ url: `/profiles?q=${encodeURIComponent(q)}`, method: 'GET' })
+ .unwrap()
+ .then((result) => {
+ if (result.total > 0 && result.items.length > 0) {
+ const profile = result.items[0];
+ dispatch(setCurrentProfile(profile));
+ } else {
+ setDataLoading(false);
+ }
+ })
+ .catch(() => setDataLoading(false));
+ } else {
+ setDataLoading(false);
+ }
+ } else {
+ setDataLoading(false);
+ }
+ }, [currentProfile, user, fetchProfile, dispatch]);
+
+ // Load the latest user from the server on mount
useEffect(() => {
let active = true;
meTrigger({ url: '/auth/me', method: 'GET' })
@@ -93,37 +206,28 @@ export function ProfilePage() {
.then((me) => {
if (active) dispatch(setUser(me));
})
- .catch(() => {
- /* fall back to the cached user already in the store */
- });
- return () => {
- active = false;
- };
- // meTrigger/dispatch are stable; run once on mount.
+ .catch(() => {});
+ return () => { active = false; };
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
- // ---- Profile form ----
- const profileSchema = z.object({
+ // ---- Personal form (auth user data) ----
+ const personalSchema = z.object({
nameEn: z.string().min(1, { message: t('profile.validation.nameRequired') }),
nameAm: z.string().min(1, { message: t('profile.validation.nameRequired') }),
- username: z
- .string()
- .min(1, { message: t('profile.validation.usernameRequired') }),
+ username: z.string().min(1, { message: t('profile.validation.usernameRequired') }),
email: z.string().email({ message: t('profile.validation.emailInvalid') }),
- phoneNumber: z
- .string()
- .min(1, { message: t('profile.validation.phoneRequired') }),
+ phoneNumber: z.string().min(1, { message: t('profile.validation.phoneRequired') }),
});
- type ProfileValues = z.infer;
+ type PersonalValues = z.infer;
const {
- register: registerProfile,
- handleSubmit: handleProfileSubmit,
- reset: resetProfile,
- formState: { errors: profileErrors },
- } = useForm({
- resolver: zodResolver(profileSchema),
+ register: registerPersonal,
+ handleSubmit: handlePersonalSubmit,
+ reset: resetPersonal,
+ formState: { errors: personalErrors },
+ } = useForm({
+ resolver: zodResolver(personalSchema),
values: {
nameEn: user?.name?.en ?? '',
nameAm: user?.name?.am ?? '',
@@ -133,7 +237,7 @@ export function ProfilePage() {
},
});
- const onSaveProfile = async (values: ProfileValues) => {
+ const onSavePersonal = async (values: PersonalValues) => {
setIsSavingProfile(true);
try {
await updateTrigger({
@@ -147,7 +251,6 @@ export function ProfilePage() {
},
}).unwrap();
- // Refresh the cached user so the rest of the app stays in sync.
const me = await meTrigger({ url: '/auth/me', method: 'GET' }).unwrap();
dispatch(setUser(me));
@@ -159,18 +262,77 @@ export function ProfilePage() {
}
};
+ // ---- Maritime Profile form ----
+ const {
+ register: registerProfile,
+ handleSubmit: handleProfileSubmit,
+ setValue: profileSetValue,
+ watch: profileWatch,
+ trigger: profileTriggerValidation,
+ formState: { errors: profileErrors },
+ } = useForm({
+ resolver: zodResolver(profileSchema),
+ values: loadedProfile ?? undefined,
+ });
+
+ const onSaveProfile = async (values: ProfileValues) => {
+ if (!profileId) return;
+ setIsSavingMaritime(true);
+ try {
+ await updateProfile({
+ url: `/profiles/${profileId}`,
+ method: 'PUT',
+ body: values,
+ }).unwrap();
+
+ notify.success('Profile updated');
+ } catch {
+ notify.error('Failed to update profile');
+ } finally {
+ setIsSavingMaritime(false);
+ }
+ };
+
+ // ---- Address form ----
+ const {
+ register: registerAddress,
+ handleSubmit: handleAddressSubmit,
+ setValue: addressSetValue,
+ watch: addressWatch,
+ trigger: addressTriggerValidation,
+ formState: { errors: addressErrors },
+ } = useForm({
+ resolver: zodResolver(addressSchema),
+ values: loadedAddress ?? undefined,
+ });
+
+ const onSaveAddress = async (values: AddressValues) => {
+ if (!addressId) return;
+ setIsSavingAddress(true);
+ try {
+ await updateAddress({
+ url: `/addresss/${addressId}`,
+ method: 'PUT',
+ body: {
+ ...values,
+ postalAddess: values.postalAddress,
+ },
+ }).unwrap();
+
+ notify.success('Address updated');
+ } catch {
+ notify.error('Failed to update address');
+ } finally {
+ setIsSavingAddress(false);
+ }
+ };
+
// ---- Password form ----
const passwordSchema = z
.object({
- oldPassword: z
- .string()
- .min(1, { message: t('profile.validation.passwordMin') }),
- newPassword: z
- .string()
- .min(8, { message: t('profile.validation.passwordMin') }),
- confirmPassword: z
- .string()
- .min(8, { message: t('profile.validation.passwordMin') }),
+ oldPassword: z.string().min(1, { message: t('profile.validation.passwordMin') }),
+ newPassword: z.string().min(8, { message: t('profile.validation.passwordMin') }),
+ confirmPassword: z.string().min(8, { message: t('profile.validation.passwordMin') }),
})
.refine((data) => data.newPassword === data.confirmPassword, {
message: t('profile.validation.passwordMismatch'),
@@ -214,21 +376,13 @@ export function ProfilePage() {
const displayName = user?.name?.en || user?.username || '';
const score = passwordScore(watchPassword('newPassword'));
const strengthLabels = [
- '',
- t('profile.strength.weak'),
- t('profile.strength.fair'),
- t('profile.strength.good'),
- t('profile.strength.strong'),
+ '', t('profile.strength.weak'), t('profile.strength.fair'),
+ t('profile.strength.good'), t('profile.strength.strong'),
];
const strengthColors = ['gray', 'red', 'orange', 'emaPrimary', 'emaTeal'];
const flags: Record = { en: '🇬🇧', am: '🇪🇹' };
- // Mantine uses 'auto' for the system option.
- const appearanceOptions: {
- value: MantineColorScheme;
- label: string;
- icon: typeof IconSun;
- }[] = [
+ const appearanceOptions: { value: MantineColorScheme; label: string; icon: typeof IconSun }[] = [
{ value: 'light', label: t('profile.appearance.light'), icon: IconSun },
{ value: 'dark', label: t('profile.appearance.dark'), icon: IconMoon },
{ value: 'auto', label: t('profile.appearance.system'), icon: IconDeviceDesktop },
@@ -297,13 +451,19 @@ export function ProfilePage() {
{/* Tabs */}
- }>
- {t('profile.tabs.profile')}
+ }>
+ Personal
+
+ }>
+ Profile
+
+ }>
+ Address
}>
{t('profile.tabs.security')}
@@ -313,10 +473,10 @@ export function ProfilePage() {
- {/* ---- Profile ---- */}
-
+ {/* ---- Personal (auth user data) ---- */}
+
-
@@ -374,7 +534,7 @@ export function ProfilePage() {