diff --git a/apps/portal/src/app/features/seafarer/pages/SeafarerRegistrationPage.tsx b/apps/portal/src/app/features/seafarer/pages/SeafarerRegistrationPage.tsx new file mode 100644 index 000000000..cd0e7bee4 --- /dev/null +++ b/apps/portal/src/app/features/seafarer/pages/SeafarerRegistrationPage.tsx @@ -0,0 +1,642 @@ +import { useEffect, useRef, useState } from 'react'; +import { useCurrentProfile, useUpdateMyProfileMutation } from '@ema-platform/auth'; +import { useAppSelector } from '../../../store/hooks'; +import { useSaveMyAddressMutation } from '../../profile/api/address-api'; +import { + Alert, + Badge, + Box, + Button, + Card, + Divider, + FileButton, + Group, + Paper, + Select, + SimpleGrid, + Stack, + Text, + Textarea, + TextInput, + Title, + rem, +} from '@mantine/core'; +import { + IconAddressBook, + IconAlertTriangle, + IconArrowLeft, + IconArrowRight, + IconCamera, + IconCheck, + IconCircleCheck, + IconFileDescription, + IconId, + IconInfoCircle, + IconSchool, + IconUser, +} from '@tabler/icons-react'; +import { useNavigate } from 'react-router-dom'; +import { notify } from '@ema-platform/ui'; +import { BilingualInput } from '../../../components/BilingualInput'; +import type { BilingualValue } from '../../../components/BilingualInput'; +import { AmharicDatePicker, toEthiopicDateLabel } from '../../../components/AmharicDatePicker'; +import { LocationPicker } from '../../location/components/LocationPicker'; + + +// --------------------------------------------------------------------------- +// Constants +// --------------------------------------------------------------------------- +const NATIONALITIES = [ + 'Ethiopian', 'Eritrean', 'Djiboutian', 'Kenyan', 'Somali', 'Sudanese', 'Other', +]; +const MARITAL_STATUSES = ['Single', 'Married', 'Divorced', 'Widowed']; +const GENDERS = ['Male', 'Female']; +const RELATIONSHIPS = ['Spouse', 'Parent', 'Sibling', 'Child', 'Friend', 'Other']; + +const STEPS = [ + { label: 'Personal Information' }, + { label: 'Contact Details' }, + { label: 'Documents Upload' }, + { label: 'Review & Submit' }, +]; + +interface DocSlot { + key: string; + label: string; + description: string; + required: boolean; + icon: typeof IconId; +} + +const DOC_SLOTS: DocSlot[] = [ + { key: 'nationalId', label: 'National ID (Front & Back)', description: 'Both sides of your national identity card', required: true, icon: IconId }, + { key: 'passport', label: 'Passport Copy', description: 'Bio-data page of valid passport', required: false, icon: IconFileDescription }, + { key: 'graduation', label: 'Graduation Certificate', description: 'Highest academic qualification', required: false, icon: IconSchool }, + { key: 'photo', label: 'Passport Size Photo', description: 'Recent photo, white background, 3.5×4.5cm', required: true, icon: IconCamera }, +]; + +// --------------------------------------------------------------------------- +// Step indicator +// --------------------------------------------------------------------------- +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} + + )} + + + {isDone ? `${step.label} ✓` : step.label} + + + + {i < STEPS.length - 1 && ( + + )} + + ); + })} + + + ); +} + +// --------------------------------------------------------------------------- +// Section heading +// --------------------------------------------------------------------------- +function SectionHead({ title }: { title: string }) { + return ( + <> + + {title} + + ); +} + +// --------------------------------------------------------------------------- +// Review row +// --------------------------------------------------------------------------- +function ReviewRow({ label, value }: { label: string; value: string }) { + return ( +
+ {label} + {value || '—'} +
+ ); +} + +// --------------------------------------------------------------------------- +// Document upload card +// --------------------------------------------------------------------------- +function DocCard({ + slot, + file, + onFile, +}: { + slot: DocSlot; + file: File | null; + onFile: (f: File | null) => void; +}) { + const resetRef = useRef<() => void>(null); + const SlotIcon = slot.icon; + return ( + + + + + +
+ + {slot.label} + {slot.required && *} + + {slot.description} +
+
+ + {file ? ( + + + {file.name} + + + ) : ( + + {(props) => ( + + )} + + )} +
+ ); +} + +// --------------------------------------------------------------------------- +// Main page +// --------------------------------------------------------------------------- +export function SeafarerRegistrationPage() { + const navigate = useNavigate(); + // Every signed-in user already has a profile (`/profiles/me` provisions + // one), so registering fills that row in rather than creating a second — + // POST /profiles trips the unique user_id constraint. + const { profileId, profile } = useCurrentProfile(); + const user = useAppSelector((state) => state.auth.user); + const [active, setActive] = useState(0); + const [completed, setCompleted] = useState([]); + const [submitting, setSubmitting] = useState(false); + const [updateProfile] = useUpdateMyProfileMutation(); + const [saveAddress] = useSaveMyAddressMutation(); + + // Step 1 — Personal Information + const [firstName, setFirstName] = useState({ en: '', am: '' }); + const [middleName, setMiddleName] = useState({ en: '', am: '' }); + const [lastName, setLastName] = useState({ en: '', am: '' }); + const [gender, setGender] = useState(null); + const [dob, setDob] = useState(null); + const [placeOfBirth, setPlaceOfBirth] = useState(''); + const [nationality, setNationality] = useState('Ethiopian'); + const [maritalStatus, setMaritalStatus] = useState(null); + const [nationalIdNumber, setNationalIdNumber] = useState(''); + const [passportNumber, setPassportNumber] = useState(''); + const [passportExpiry, setPassportExpiry] = useState(''); + + // Step 2 — Contact Details + const [mobile, setMobile] = useState(''); + const [email, setEmail] = useState(''); + const [locationId, setLocationId] = useState(null); + const [permanentAddress, setPermanentAddress] = useState(''); + const [currentAddress, setCurrentAddress] = useState(''); + const [emergencyName, setEmergencyName] = useState(''); + const [emergencyRel, setEmergencyRel] = useState(null); + const [emergencyPhone, setEmergencyPhone] = useState(''); + + // Step 3 — Documents + const [files, setFiles] = useState>({ + nationalId: null, passport: null, graduation: null, photo: null, + }); + + const setFile = (key: string) => (f: File | null) => + setFiles((prev) => ({ ...prev, [key]: f })); + + // Signup already asked for name, email and phone — start from those (and + // whatever is on the profile) instead of making the applicant retype them. + // Only blank fields are filled, so nothing typed here is overwritten. + useEffect(() => { + const [enFirst = '', ...enRest] = (user?.name.en ?? '').trim().split(/\s+/); + const [amFirst = '', ...amRest] = (user?.name.am ?? '').trim().split(/\s+/); + const orEmpty = (v?: string | null) => v ?? ''; + // Profile stores MALE / SINGLE; the selects here list Male / Single. + const title = (v: string) => v.charAt(0) + v.slice(1).toLowerCase(); + setFirstName((c) => c.en ? c : { en: profile?.firstName || enFirst, am: amFirst }); + setMiddleName((c) => c.en ? c : { en: profile?.middleName || enRest.slice(0, -1).join(' '), am: amRest.slice(0, -1).join(' ') }); + setLastName((c) => c.en ? c : { en: profile?.lastName || orEmpty(enRest.at(-1)), am: orEmpty(amRest.at(-1)) }); + if (profile?.gender) setGender((c) => c ?? title(profile.gender)); + if (profile?.dob) setDob((c) => c ?? new Date(profile.dob)); + if (profile?.pob) setPlaceOfBirth((c) => c || profile.pob); + if (profile?.maritalStatus) setMaritalStatus((c) => c ?? title(profile.maritalStatus)); + setEmail((c) => c || profile?.address?.email || user?.email || ''); + setMobile((c) => c || profile?.address?.primaryPhoneNumber || user?.phoneNumber || ''); + if (profile?.address?.idNumber) setNationalIdNumber((c) => c || profile.address.idNumber); + if (profile?.address?.nationality) setNationality((c) => c ?? profile.address.nationality); + if (profile?.address?.streetAddress) setPermanentAddress((c) => c || orEmpty(profile.address.streetAddress)); + if (profile?.address?.emergencyContactName) setEmergencyName((c) => c || orEmpty(profile.address.emergencyContactName)); + if (profile?.address?.emergencyContactPhone) setEmergencyPhone((c) => c || orEmpty(profile.address.emergencyContactPhone)); + if (profile?.address?.emergencyContactRelation) setEmergencyRel((c) => c ?? profile.address.emergencyContactRelation); + }, [user, profile]); + + // Registration writes the profile as SEAFARER with personal details, so a + // profile in that state is a submitted registration: show it read-only + // instead of an empty wizard. "Edit" reopens the wizard on the same data. + const registered = profile?.type === 'SEAFARER' && !!profile.firstName && !!profile.dob; + const [editing, setEditing] = useState(false); + + const canNext = () => { + if (active === 0) return !!firstName.en.trim() && !!lastName.en.trim() && !!gender && !!dob && !!placeOfBirth && !!nationality && !!nationalIdNumber.trim(); + if (active === 1) return !!mobile.trim() && !!email.trim() && !!locationId; + if (active === 2) return !!files.nationalId && !!files.photo; + return true; + }; + + const next = () => { + setCompleted((prev) => prev.includes(active) ? prev : [...prev, active]); + setActive((c) => c + 1); + }; + const prev = () => setActive((c) => c - 1); + + const handleSubmit = async () => { + if (!profileId) return; + setSubmitting(true); + try { + await updateProfile({ + id: profileId, + body: { + type: 'SEAFARER', + firstName: firstName.en, + middleName: middleName.en || undefined, + lastName: lastName.en, + gender: gender?.toUpperCase() ?? 'MALE', + dob: dob?.toISOString().split('T')[0] ?? '', + pob: placeOfBirth || undefined, + maritalStatus: maritalStatus?.toUpperCase() ?? 'SINGLE', + }, + }).unwrap(); + + await saveAddress({ + profileId, + body: { + idType: 'NID', + idNumber: nationalIdNumber, + nationality: nationality ?? 'Ethiopian', + primaryPhoneNumber: mobile, + email: email || undefined, + website: null, + streetAddress: permanentAddress || undefined, + emergencyContactName: emergencyName || undefined, + emergencyContactPhone: emergencyPhone || undefined, + emergencyContactRelation: emergencyRel || undefined, + }, + }).unwrap(); + + notify.success(`Registration submitted! Profile ID: ${profileId.slice(0, 8).toUpperCase()}`); + setEditing(false); + setActive(0); + setCompleted([]); + } catch { + notify.error('Submission failed. Please try again.'); + } finally { + setSubmitting(false); + } + }; + + const review = ( + + + Personal Information + + + + + + + + + + + + + + + + + Contact Details + + + + + + + + {emergencyName && ( + <> + + Emergency Contact + + + + + + + )} + + + + Documents + + {DOC_SLOTS.map((slot) => ( + + {files[slot.key] ? ( + + ) : ( + + )} +
+ + {slot.label} + {slot.required && !files[slot.key] && *} + + {files[slot.key] && ( + {files[slot.key]!.name} + )} +
+
+ ))} +
+
+
+ ); + + const stepLabel = STEPS[active]?.label ?? ''; + const stepIcons = [IconUser, IconAddressBook, IconFileDescription, IconCircleCheck]; + const StepIcon = stepIcons[active]; + + if (registered && !editing) { + const status = profile.seafarerStatus ?? 'PENDING'; + return ( + + +
+ Seafarer Registration + + {profile.seafarerNumber + ? `Seafarer ID ${profile.seafarerNumber}` + : 'Submitted — a Seafarer ID is issued once EMA approves your registration.'} + +
+ + + {status} + + + +
+ {review} +
+ ); + } + + return ( + + {/* Page header */} +
+ New Seafarer Registration + Register a new seafarer profile — Step {active + 1} of {STEPS.length} +
+ + {/* Step indicator */} + + + {/* Card */} + + {/* Card header */} + + + + {stepLabel} + + Step {active + 1} of {STEPS.length} + + + {/* ── Step 1: Personal Information ───────────────────────────── */} + {active === 0 && ( + + + + + + + +