From eabaae36a000fb783913253982ae38051a7ab8eb Mon Sep 17 00:00:00 2001 From: fitse-yotor Date: Sun, 16 Aug 2026 23:19:08 +0300 Subject: [PATCH] feat(portal): restore Mengestab's client-approved seafarer and vessel UI Copied verbatim from the pre-override branch so the approved screens are recoverable at this exact commit before any wiring changes them. Brings back the richer flows the client signed off: a four-step seafarer registration wizard with bilingual inputs and an Ethiopic date picker, the vessel-owner portal (its own register/login/dashboard), ownership transfer, and the seaman book, certificate, medical and endorsement screens. Six of these pages already call an API; ten are mockups carrying hardcoded data. Both are committed as-is here -- the wiring that follows is a separate commit so the diff shows exactly what changed from what the client approved. Co-Authored-By: Claude Opus 5 --- .../src/app/components/AmharicDatePicker.tsx | 124 ++ .../src/app/components/BilingualInput.tsx | 76 ++ .../pages/BasicSafetyTrainingPage.tsx | 448 ++++++- .../certificates/pages/CertificatesPage.tsx | 272 ++++ .../pages/CertificatesPage/columns.tsx | 64 - .../pages/CertificatesPage/index.tsx | 247 ---- .../certificates/pages/CoCApplicationPage.tsx | 1096 ++++++++++++++++- .../endorsement/pages/EndorsementPage.tsx | 605 +++++---- .../medical/pages/MedicalCertificatePage.tsx | 350 +++++- .../pages/MySeaRecordsPage/actions.tsx | 111 -- .../pages/MySeaRecordsPage/columns.tsx | 117 -- .../seafarer/pages/MySeaRecordsPage/index.tsx | 637 ---------- .../seafarer/pages/SeafarerProfilePage.tsx | 701 +++++++++++ .../pages/SeafarerRegistrationPage.tsx | 781 ++++++++---- .../seafarer/pages/SeafarerRegistryPage.tsx | 379 ++++++ .../pages/SeamanBookApplicationPage.tsx | 597 ++++++++- .../seaman-book/pages/SeamanBookPage.tsx | 316 ++++- .../pages/VesselOwnerDashboardPage.tsx | 114 ++ .../pages/VesselOwnerLoginPage.tsx | 128 ++ .../pages/VesselOwnerRegisterPage.tsx | 199 +++ .../app/features/vessel-registration/mock.ts | 286 ----- .../pages/OwnershipTransferPage.tsx | 440 +++++++ .../VesselRegistrationApplicationPage.tsx | 678 ++++++++++ .../pages/VesselRegistrationPage.tsx | 339 +++++ .../pages/VesselRegistrationPage/columns.tsx | 152 --- .../pages/VesselRegistrationPage/index.tsx | 333 ----- .../pages/VesselRegistrationStatusPage.tsx | 158 --- .../pages/VesselTransferPage.tsx | 233 ---- 28 files changed, 7142 insertions(+), 2839 deletions(-) create mode 100644 apps/portal/src/app/components/AmharicDatePicker.tsx create mode 100644 apps/portal/src/app/components/BilingualInput.tsx create mode 100644 apps/portal/src/app/features/certificates/pages/CertificatesPage.tsx delete mode 100644 apps/portal/src/app/features/certificates/pages/CertificatesPage/columns.tsx delete mode 100644 apps/portal/src/app/features/certificates/pages/CertificatesPage/index.tsx delete mode 100644 apps/portal/src/app/features/seafarer/pages/MySeaRecordsPage/actions.tsx delete mode 100644 apps/portal/src/app/features/seafarer/pages/MySeaRecordsPage/columns.tsx delete mode 100644 apps/portal/src/app/features/seafarer/pages/MySeaRecordsPage/index.tsx create mode 100644 apps/portal/src/app/features/seafarer/pages/SeafarerProfilePage.tsx create mode 100644 apps/portal/src/app/features/seafarer/pages/SeafarerRegistryPage.tsx create mode 100644 apps/portal/src/app/features/vessel-owner/pages/VesselOwnerDashboardPage.tsx create mode 100644 apps/portal/src/app/features/vessel-owner/pages/VesselOwnerLoginPage.tsx create mode 100644 apps/portal/src/app/features/vessel-owner/pages/VesselOwnerRegisterPage.tsx delete mode 100644 apps/portal/src/app/features/vessel-registration/mock.ts create mode 100644 apps/portal/src/app/features/vessel-registration/pages/OwnershipTransferPage.tsx create mode 100644 apps/portal/src/app/features/vessel-registration/pages/VesselRegistrationApplicationPage.tsx create mode 100644 apps/portal/src/app/features/vessel-registration/pages/VesselRegistrationPage.tsx delete mode 100644 apps/portal/src/app/features/vessel-registration/pages/VesselRegistrationPage/columns.tsx delete mode 100644 apps/portal/src/app/features/vessel-registration/pages/VesselRegistrationPage/index.tsx delete mode 100644 apps/portal/src/app/features/vessel-registration/pages/VesselRegistrationStatusPage.tsx delete mode 100644 apps/portal/src/app/features/vessel-registration/pages/VesselTransferPage.tsx diff --git a/apps/portal/src/app/components/AmharicDatePicker.tsx b/apps/portal/src/app/components/AmharicDatePicker.tsx new file mode 100644 index 000000000..3798fcddc --- /dev/null +++ b/apps/portal/src/app/components/AmharicDatePicker.tsx @@ -0,0 +1,124 @@ +import { useState } from 'react'; +import { ActionIcon, Button, Popover, TextInput } from '@mantine/core'; +import { useDisclosure } from '@mantine/hooks'; +import { DayPicker as EthiopicDayPicker } from '@daypicker/ethiopic'; +import { DayPicker as GregorianDayPicker } from '@daypicker/react'; +import { IconCalendarEvent } from '@tabler/icons-react'; +import { EthDateTime } from 'ethiopian-calendar-date-converter'; +import '@daypicker/react/dist/style.css'; + +const EC_MONTHS_AM = [ + 'መስከረም', 'ጥቅምት', 'ኅዳር', 'ታህሳስ', 'ጥር', 'የካቲት', + 'መጋቢት', 'ሚያዝያ', 'ግንቦት', 'ሰኔ', 'ሐምሌ', 'ነሐሴ', 'ጳጉሜ', +]; + +function toAmharicDisplay(date: Date): string { + try { + const eth = EthDateTime.fromEuropeanDate(date); + return `${EC_MONTHS_AM[eth.month - 1]} ${eth.date}/${eth.year}`; + } catch { + return date.toLocaleDateString('en-US'); + } +} + +export function toEthiopicDateLabel(date: Date): string { + try { + const eth = EthDateTime.fromEuropeanDate(date); + return `EC ${eth.year}-${String(eth.month).padStart(2, '0')}-${String(eth.date).padStart(2, '0')}`; + } catch { + return date.toLocaleDateString('en-US'); + } +} + +export interface AmharicDatePickerProps { + label?: string; + value?: Date | null; + onChange?: (date: Date | null) => void; + required?: boolean; + placeholder?: string; +} + +export function AmharicDatePicker({ + label, + value, + onChange, + required, + placeholder, +}: AmharicDatePickerProps) { + const [calendarType, setCalendarType] = useState<'EN' | 'AMH'>('AMH'); + const [opened, { close, toggle }] = useDisclosure(false); + + const displayValue = value + ? calendarType === 'EN' + ? value.toLocaleDateString('en-US', { + year: 'numeric', + month: 'long', + day: 'numeric', + }) + : toAmharicDisplay(value) + : ''; + + return ( + + + { + e.stopPropagation(); + setCalendarType((prev) => (prev === 'EN' ? 'AMH' : 'EN')); + }} + aria-label="Switch calendar type" + > + {calendarType} + + } + leftSectionWidth="calc(4.375rem * var(--mantine-scale))" + rightSection={ + + + + } + /> + + + + {calendarType === 'AMH' ? ( + { + onChange?.(date ?? null); + close(); + }} + /> + ) : ( + { + onChange?.(date ?? null); + close(); + }} + /> + )} + + + ); +} diff --git a/apps/portal/src/app/components/BilingualInput.tsx b/apps/portal/src/app/components/BilingualInput.tsx new file mode 100644 index 000000000..8e55c64eb --- /dev/null +++ b/apps/portal/src/app/components/BilingualInput.tsx @@ -0,0 +1,76 @@ +import { useState } from 'react'; +import { + TextInput, + UnstyledButton, + rem, + type TextInputProps, +} from '@mantine/core'; + +export interface BilingualValue { + en: string; + am: string; +} + +interface BilingualInputProps + extends Omit { + value: BilingualValue; + onChange: (value: BilingualValue) => void; +} + +export function BilingualInput({ + label, + value, + onChange, + required, + placeholder, + ...rest +}: BilingualInputProps) { + const [lang, setLang] = useState<'en' | 'am'>('en'); + + const toggle = () => setLang((l) => (l === 'en' ? 'am' : 'en')); + + return ( + onChange({ ...value, [lang]: e.currentTarget.value })} + rightSection={ + + {lang === 'en' ? 'EN' : 'AM'} + + } + styles={{ + input: { + paddingRight: rem(42), + }, + }} + {...rest} + /> + ); +} 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 index a23be0288..caba3b94d 100644 --- a/apps/portal/src/app/features/basic-safety-training/pages/BasicSafetyTrainingPage.tsx +++ b/apps/portal/src/app/features/basic-safety-training/pages/BasicSafetyTrainingPage.tsx @@ -1,21 +1,437 @@ -import { Container } from '@mantine/core'; -import { FeatureUnavailable } from '@ema-platform/ui'; +import { useRef, useState } from 'react'; +import { + Alert, + Badge, + Box, + Button, + Card, + FileButton, + Group, + List, + Modal, + Paper, + Stack, + Text, + TextInput, + ThemeIcon, + Title, +} from '@mantine/core'; +import { + IconAlertTriangle, + IconBook2, + IconCalendar, + IconCheck, + IconCircleCheck, + IconDownload, + IconInfoCircle, + IconRefresh, + IconShieldCheck, + IconTrash, + IconUpload, +} from '@tabler/icons-react'; +import { notify } from '@ema-platform/ui'; + +// --------------------------------------------------------------------------- +// Types +// --------------------------------------------------------------------------- +interface BSTRecord { + issuer: string; + issueDate: string; + expiryDate: string; + certNumber: string; + fileName: string; + status: 'Valid' | 'Expiring' | 'Expired' | 'Pending Verification'; +} + +const STATUS_COLOR: Record = { + Valid: 'teal', + Expiring: 'orange', + Expired: 'red', + 'Pending Verification': 'yellow', +}; + +const BST_COMPONENTS = [ + { label: 'Personal Survival Techniques', short: 'PST', course: 'IMO 1.19' }, + { label: 'Fire Prevention & Fire Fighting', short: 'FPFF', course: 'IMO 1.20' }, + { label: 'Elementary First Aid', short: 'EFA', course: 'IMO 1.13' }, + { label: 'Personal Safety & Social Responsibility', short: 'PSSR', course: 'IMO 1.21' }, + { label: 'Sexual Harassment Prevention', short: 'SHPT', course: 'EMA National' }, +]; + +function formatDate(dateStr: string) { + return new Date(dateStr).toLocaleDateString('en-GB', { + day: '2-digit', + month: 'short', + year: 'numeric', + }); +} + +function daysUntil(dateStr: string) { + return Math.ceil((new Date(dateStr).getTime() - Date.now()) / (1000 * 60 * 60 * 24)); +} + +// --------------------------------------------------------------------------- +// Upload modal +// --------------------------------------------------------------------------- +function UploadModal({ + opened, + onClose, + onUploaded, +}: { + opened: boolean; + onClose: () => void; + onUploaded: (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 || !expiryDate) { + notify.error('Please fill all required fields and upload the certificate file.'); + return; + } + setSubmitting(true); + await new Promise((r) => setTimeout(r, 1000)); + setSubmitting(false); + onUploaded({ + issuer, + issueDate, + expiryDate, + certNumber, + fileName: file.name, + status: 'Pending Verification', + }); + notify.success('Basic Safety Training certificate submitted for verification.'); + reset(); + onClose(); + }; -/** - * Placeholder until this feature has a backend. - * - * This page previously rendered hardcoded sample records, which were - * indistinguishable from real ones. - */ -export function BasicSafetyTrainingPage() { return ( - - - + { + reset(); + onClose(); + }} + title="Upload Basic Safety Training Certificate" + size="md" + centered + > + + } p="xs"> + + Upload your combined BST certificate issued by an EMA-approved training institution. + The certificate must cover all 5 components (PST, FPFF, EFA, PSSR, SHPT). + + + + setIssuer(e.currentTarget.value)} + size="sm" + /> + setCertNumber(e.currentTarget.value)} + size="sm" + /> + + setIssueDate(e.currentTarget.value)} + size="sm" + /> + setExpiryDate(e.currentTarget.value)} + size="sm" + /> + + +
+ + Certificate File * + + {file ? ( + + + + + {file.name} + + + + + ) : ( + + {(props) => ( + + )} + + )} +
+ + + + + +
+
); } -export default BasicSafetyTrainingPage; +// --------------------------------------------------------------------------- +// Main page +// --------------------------------------------------------------------------- +export function BasicSafetyTrainingPage() { + const [record, setRecord] = useState(null); + const [modalOpen, setModalOpen] = useState(false); + + const days = record?.expiryDate ? daysUntil(record.expiryDate) : null; + const isExpiringSoon = days !== null && days <= 180 && days > 0; + const isExpired = days !== null && days <= 0; + + return ( + + {/* Header */} + +
+ Basic Safety Training Certificate + + STCW Chapter VI/1 — mandatory for all seafarers before joining a vessel. + +
+ {record && ( + } + > + {record.status} + + )} +
+ + {/* Expiry alert */} + {isExpired && ( + }> + + Your BST certificate has expired. Upload a renewed certificate to remain eligible. + + + )} + {isExpiringSoon && ( + }> + + Your BST certificate expires in {days} days. Renew before it lapses. + + + )} + + {/* Certificate card */} + {record ? ( + + + + + + +
+ Basic Safety Training (BST) + Combined certificate — all 5 STCW components +
+
+ + {record.status} + +
+ + + + Certificate Number + {record.certNumber} + + + Issuing Institution + {record.issuer} + + + Issue Date + {formatDate(record.issueDate)} + + + Expiry Date + + {formatDate(record.expiryDate)} + {days !== null && days > 0 && ( + ({days} days remaining) + )} + + + + File + {record.fileName} + + + + + + + +
+ ) : ( + + + + + +
+ No BST Certificate Uploaded + + You must upload a valid Basic Safety Training certificate issued by an + EMA-approved institution before applying for a Seaman Book. + +
+ +
+
+ )} + + {/* Components covered */} + + + + Certificate Components (STCW VI/1) + + + A combined BST certificate from an EMA-approved institution covers all five components: + + + + + } + > + {BST_COMPONENTS.map((c) => ( + + + {c.short} + — {c.label} + {c.course} + + + ))} + + + + {/* Info */} + + + + Validity & Renewal + + + + BST certificates are typically valid for 5 years. PST and FPFF components + require evidence of maintained competence at the 5-year point (STCW Reg. VI/1). + EFA and PSSR do not have a mandatory 5-year revalidation under STCW but your + institution's combined certificate carries a unified expiry date. + Certificates must be from EMA-approved training institutions. + + + + + setModalOpen(false)} + onUploaded={(rec) => setRecord(rec)} + /> +
+ ); +} 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 +
+ +
+ + {/* 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}
+
+ + + + +
+ ))} +
+ )} +
+ + {/* Preview modal */} + setPreviewUrl(null)} + title={{previewTitle}} + size="95vw" + radius="lg" + fullScreen + > +