mirror of
https://github.com/Tria-plc/emaui.git
synced 2026-08-26 13:02:50 +00:00
Fix
This commit is contained in:
@@ -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 (
|
||||
<Box mb={32}>
|
||||
<Group gap={0} align="center" wrap="nowrap">
|
||||
{STEPS.map((step, i) => {
|
||||
const isDone = completed.includes(i);
|
||||
const isCurrent = active === i;
|
||||
return (
|
||||
<Group key={i} gap={0} align="center" style={{ flex: i < STEPS.length - 1 ? 1 : 'none' }}>
|
||||
<Stack gap={4} align="center" style={{ minWidth: rem(40) }}>
|
||||
<Box
|
||||
style={{
|
||||
width: rem(40),
|
||||
height: rem(40),
|
||||
borderRadius: '50%',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
backgroundColor: isDone
|
||||
? 'var(--mantine-color-blue-8)'
|
||||
: isCurrent
|
||||
? 'var(--mantine-color-blue-7)'
|
||||
: 'var(--mantine-color-gray-1)',
|
||||
border: isCurrent ? '2.5px solid var(--mantine-color-blue-5)' : '2px solid transparent',
|
||||
boxShadow: isCurrent || isDone ? '0 2px 8px rgba(34, 139, 230, 0.2)' : 'none',
|
||||
flexShrink: 0,
|
||||
transition: 'all 0.2s ease',
|
||||
}}
|
||||
>
|
||||
{isDone ? (
|
||||
<IconCheck size={18} color="white" stroke={2.5} />
|
||||
) : (
|
||||
<Text fw={700} fz="sm" c={isCurrent ? 'white' : 'gray.5'}>
|
||||
{i + 1}
|
||||
</Text>
|
||||
)}
|
||||
</Box>
|
||||
<Text
|
||||
fz="xs"
|
||||
fw={isCurrent ? 700 : 400}
|
||||
c={isCurrent ? 'blue.7' : 'dimmed'}
|
||||
style={{ whiteSpace: 'nowrap' }}
|
||||
>
|
||||
{isDone ? `${step.label} ✓` : step.label}
|
||||
</Text>
|
||||
</Stack>
|
||||
|
||||
{i < STEPS.length - 1 && (
|
||||
<Box
|
||||
style={{
|
||||
flex: 1,
|
||||
height: rem(2),
|
||||
backgroundColor: isDone
|
||||
? 'var(--mantine-color-blue-8)'
|
||||
: 'var(--mantine-color-gray-2)',
|
||||
marginBottom: rem(22),
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</Group>
|
||||
);
|
||||
})}
|
||||
</Group>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Section heading
|
||||
// ---------------------------------------------------------------------------
|
||||
function SectionHead({ title }: { title: string }) {
|
||||
return (
|
||||
<>
|
||||
<Divider mt="md" mb="xs" />
|
||||
<Text fw={600} fz="sm" tt="uppercase" c="gray.6">{title}</Text>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Review row
|
||||
// ---------------------------------------------------------------------------
|
||||
function ReviewRow({ label, value }: { label: string; value: string }) {
|
||||
return (
|
||||
<div>
|
||||
<Text fz="xs" c="dimmed" tt="uppercase" fw={600} lh={1.2}>{label}</Text>
|
||||
<Text fz="sm" mt={2}>{value || '—'}</Text>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 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 (
|
||||
<Card
|
||||
withBorder
|
||||
radius="md"
|
||||
p="md"
|
||||
style={{
|
||||
borderStyle: 'dashed',
|
||||
borderColor: file
|
||||
? 'var(--mantine-color-teal-5)'
|
||||
: 'var(--mantine-color-default-border)',
|
||||
}}
|
||||
>
|
||||
<Group gap="sm" mb="sm" wrap="nowrap">
|
||||
<Box
|
||||
style={{
|
||||
width: rem(44),
|
||||
height: rem(44),
|
||||
borderRadius: rem(8),
|
||||
background: 'var(--mantine-color-blue-light)',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
flexShrink: 0,
|
||||
}}
|
||||
>
|
||||
<SlotIcon size={22} color="var(--mantine-color-blue-6)" stroke={1.5} />
|
||||
</Box>
|
||||
<div>
|
||||
<Text fw={600} fz="sm">
|
||||
{slot.label}
|
||||
{slot.required && <Text span c="red" ml={3}>*</Text>}
|
||||
</Text>
|
||||
<Text fz="xs" c="dimmed">{slot.description}</Text>
|
||||
</div>
|
||||
</Group>
|
||||
|
||||
{file ? (
|
||||
<Group gap="xs" align="center">
|
||||
<IconCircleCheck size={16} color="var(--mantine-color-teal-6)" />
|
||||
<Text fz="xs" c="teal.7" style={{ flex: 1 }} truncate>{file.name}</Text>
|
||||
<Button
|
||||
size="xs"
|
||||
variant="subtle"
|
||||
color="red"
|
||||
onClick={() => { onFile(null); resetRef.current?.(); }}
|
||||
>
|
||||
Remove
|
||||
</Button>
|
||||
</Group>
|
||||
) : (
|
||||
<FileButton resetRef={resetRef} onChange={onFile} accept="application/pdf,image/jpeg,image/png">
|
||||
{(props) => (
|
||||
<Button size="xs" variant="default" {...props}>
|
||||
Choose File
|
||||
</Button>
|
||||
)}
|
||||
</FileButton>
|
||||
)}
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 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<number[]>([]);
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
const [updateProfile] = useUpdateMyProfileMutation();
|
||||
const [saveAddress] = useSaveMyAddressMutation();
|
||||
|
||||
// Step 1 — Personal Information
|
||||
const [firstName, setFirstName] = useState<BilingualValue>({ en: '', am: '' });
|
||||
const [middleName, setMiddleName] = useState<BilingualValue>({ en: '', am: '' });
|
||||
const [lastName, setLastName] = useState<BilingualValue>({ en: '', am: '' });
|
||||
const [gender, setGender] = useState<string | null>(null);
|
||||
const [dob, setDob] = useState<Date | null>(null);
|
||||
const [placeOfBirth, setPlaceOfBirth] = useState('');
|
||||
const [nationality, setNationality] = useState<string | null>('Ethiopian');
|
||||
const [maritalStatus, setMaritalStatus] = useState<string | null>(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<string | null>(null);
|
||||
const [permanentAddress, setPermanentAddress] = useState('');
|
||||
const [currentAddress, setCurrentAddress] = useState('');
|
||||
const [emergencyName, setEmergencyName] = useState('');
|
||||
const [emergencyRel, setEmergencyRel] = useState<string | null>(null);
|
||||
const [emergencyPhone, setEmergencyPhone] = useState('');
|
||||
|
||||
// Step 3 — Documents
|
||||
const [files, setFiles] = useState<Record<string, File | null>>({
|
||||
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 = (
|
||||
<Stack gap="md">
|
||||
<Paper withBorder radius="md" p="md">
|
||||
<Text fw={700} fz="sm" mb="sm">Personal Information</Text>
|
||||
<SimpleGrid cols={{ base: 1, sm: 2, lg: 3 }} spacing="md">
|
||||
<ReviewRow label="First Name" value={`${firstName.en}${firstName.am ? ` / ${firstName.am}` : ''}`} />
|
||||
<ReviewRow label="Middle Name" value={`${middleName.en}${middleName.am ? ` / ${middleName.am}` : ''}`} />
|
||||
<ReviewRow label="Last Name" value={`${lastName.en}${lastName.am ? ` / ${lastName.am}` : ''}`} />
|
||||
<ReviewRow label="Gender" value={gender ?? ''} />
|
||||
<ReviewRow label="Date of Birth" value={`${dob?.toLocaleDateString('en-US') ?? ''}${dob ? ` (${toEthiopicDateLabel(dob)})` : ''}`} />
|
||||
<ReviewRow label="Place of Birth" value={placeOfBirth} />
|
||||
<ReviewRow label="Nationality" value={nationality ?? ''} />
|
||||
<ReviewRow label="Marital Status" value={maritalStatus ?? ''} />
|
||||
<ReviewRow label="National ID No." value={nationalIdNumber} />
|
||||
<ReviewRow label="Passport No." value={passportNumber} />
|
||||
<ReviewRow label="Passport Expiry" value={passportExpiry} />
|
||||
</SimpleGrid>
|
||||
</Paper>
|
||||
|
||||
<Paper withBorder radius="md" p="md">
|
||||
<Text fw={700} fz="sm" mb="sm">Contact Details</Text>
|
||||
<SimpleGrid cols={{ base: 1, sm: 2, lg: 3 }} spacing="md">
|
||||
<ReviewRow label="Mobile" value={mobile} />
|
||||
<ReviewRow label="Email" value={email} />
|
||||
<ReviewRow label="Location" value={locationId ?? ''} />
|
||||
<ReviewRow label="Permanent Address" value={permanentAddress} />
|
||||
<ReviewRow label="Current Address" value={currentAddress} />
|
||||
</SimpleGrid>
|
||||
{emergencyName && (
|
||||
<>
|
||||
<Divider mt="md" mb="sm" />
|
||||
<Text fw={600} fz="sm" mb="sm">Emergency Contact</Text>
|
||||
<SimpleGrid cols={{ base: 1, sm: 2, lg: 3 }} spacing="md">
|
||||
<ReviewRow label="Name" value={emergencyName} />
|
||||
<ReviewRow label="Relationship" value={emergencyRel ?? ''} />
|
||||
<ReviewRow label="Phone" value={emergencyPhone} />
|
||||
</SimpleGrid>
|
||||
</>
|
||||
)}
|
||||
</Paper>
|
||||
|
||||
<Paper withBorder radius="md" p="md">
|
||||
<Text fw={700} fz="sm" mb="sm">Documents</Text>
|
||||
<SimpleGrid cols={{ base: 1, sm: 2, lg: 4 }} spacing="md">
|
||||
{DOC_SLOTS.map((slot) => (
|
||||
<Group key={slot.key} gap="xs" align="center">
|
||||
{files[slot.key] ? (
|
||||
<IconCircleCheck size={18} color="var(--mantine-color-teal-6)" />
|
||||
) : (
|
||||
<Box style={{ width: 18, height: 18, borderRadius: '50%', border: '2px solid var(--mantine-color-gray-3)', flexShrink: 0 }} />
|
||||
)}
|
||||
<div>
|
||||
<Text fz="sm" c={files[slot.key] ? undefined : 'dimmed'}>
|
||||
{slot.label}
|
||||
{slot.required && !files[slot.key] && <Text span c="red" ml={3}>*</Text>}
|
||||
</Text>
|
||||
{files[slot.key] && (
|
||||
<Text fz="xs" c="dimmed" truncate maw={160}>{files[slot.key]!.name}</Text>
|
||||
)}
|
||||
</div>
|
||||
</Group>
|
||||
))}
|
||||
</SimpleGrid>
|
||||
</Paper>
|
||||
</Stack>
|
||||
);
|
||||
|
||||
const stepLabel = STEPS[active]?.label ?? '';
|
||||
const stepIcons = [IconUser, IconAddressBook, IconFileDescription, IconCircleCheck];
|
||||
const StepIcon = stepIcons[active];
|
||||
|
||||
if (registered && !editing) {
|
||||
const status = profile.seafarerStatus ?? 'PENDING';
|
||||
return (
|
||||
<Stack gap="md">
|
||||
<Group justify="space-between" align="flex-start">
|
||||
<div>
|
||||
<Title order={3}>Seafarer Registration</Title>
|
||||
<Text fz="sm" c="dimmed">
|
||||
{profile.seafarerNumber
|
||||
? `Seafarer ID ${profile.seafarerNumber}`
|
||||
: 'Submitted — a Seafarer ID is issued once EMA approves your registration.'}
|
||||
</Text>
|
||||
</div>
|
||||
<Group gap="sm">
|
||||
<Badge variant="light" color={status === 'ACTIVE' ? 'teal' : status === 'PENDING' ? 'yellow' : 'red'}>
|
||||
{status}
|
||||
</Badge>
|
||||
<Button variant="default" onClick={() => setEditing(true)}>Edit details</Button>
|
||||
</Group>
|
||||
</Group>
|
||||
{review}
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Stack gap="md">
|
||||
{/* Page header */}
|
||||
<div>
|
||||
<Title order={3}>New Seafarer Registration</Title>
|
||||
<Text fz="sm" c="dimmed">Register a new seafarer profile — Step {active + 1} of {STEPS.length}</Text>
|
||||
</div>
|
||||
|
||||
{/* Step indicator */}
|
||||
<StepIndicator active={active} completed={completed} />
|
||||
|
||||
{/* Card */}
|
||||
<Paper withBorder radius="lg" p="xl">
|
||||
{/* Card header */}
|
||||
<Group justify="space-between" mb="lg">
|
||||
<Group gap="xs">
|
||||
<StepIcon size={20} stroke={1.6} />
|
||||
<Text fw={700} fz="lg">{stepLabel}</Text>
|
||||
</Group>
|
||||
<Badge variant="light" color="blue" radius="md">Step {active + 1} of {STEPS.length}</Badge>
|
||||
</Group>
|
||||
|
||||
{/* ── Step 1: Personal Information ───────────────────────────── */}
|
||||
{active === 0 && (
|
||||
<Stack gap="md">
|
||||
<SectionHead title="Identity Details" />
|
||||
<SimpleGrid cols={{ base: 1, sm: 2, lg: 3 }} spacing="md">
|
||||
<BilingualInput label="First Name" required value={firstName} onChange={setFirstName} />
|
||||
<BilingualInput label="Middle Name" value={middleName} onChange={setMiddleName} />
|
||||
<BilingualInput label="Last Name" required value={lastName} onChange={setLastName} />
|
||||
<Select label="Gender" placeholder="Select" required data={GENDERS} value={gender} onChange={setGender} />
|
||||
<AmharicDatePicker label="Date of Birth" required value={dob} onChange={setDob} />
|
||||
<TextInput label="Place of Birth" placeholder="City, Region" required value={placeOfBirth} onChange={(e) => setPlaceOfBirth(e.currentTarget.value)} />
|
||||
<Select label="Nationality" required data={NATIONALITIES} value={nationality} onChange={setNationality} searchable />
|
||||
<Select label="Marital Status" placeholder="Select" data={MARITAL_STATUSES} value={maritalStatus} onChange={setMaritalStatus} />
|
||||
</SimpleGrid>
|
||||
|
||||
<SectionHead title="Identity Documents" />
|
||||
<SimpleGrid cols={{ base: 1, sm: 2, lg: 3 }} spacing="md">
|
||||
<TextInput label="National ID Number" placeholder="ET-000000" required value={nationalIdNumber} onChange={(e) => setNationalIdNumber(e.currentTarget.value)} />
|
||||
<TextInput label="Passport Number" placeholder="EP000000" value={passportNumber} onChange={(e) => setPassportNumber(e.currentTarget.value)} />
|
||||
<TextInput label="Passport Expiry Date" type="date" value={passportExpiry} onChange={(e) => setPassportExpiry(e.currentTarget.value)} />
|
||||
</SimpleGrid>
|
||||
|
||||
<Alert variant="light" color="blue" icon={<IconInfoCircle size={17} />}>
|
||||
A unique Seafarer ID will be automatically generated upon approval of this registration.
|
||||
</Alert>
|
||||
</Stack>
|
||||
)}
|
||||
|
||||
{/* ── Step 2: Contact Details ─────────────────────────────────── */}
|
||||
{active === 1 && (
|
||||
<Stack gap="md">
|
||||
<SectionHead title="Contact Information" />
|
||||
<SimpleGrid cols={{ base: 1, sm: 2, lg: 3 }} spacing="md">
|
||||
<TextInput label="Mobile Number" placeholder="+251 9XX XXX XXX" required value={mobile} onChange={(e) => setMobile(e.currentTarget.value)} />
|
||||
<TextInput label="Email Address" placeholder="email@example.com" type="email" required value={email} onChange={(e) => setEmail(e.currentTarget.value)} />
|
||||
</SimpleGrid>
|
||||
|
||||
<SectionHead title="Location" />
|
||||
<LocationPicker
|
||||
value={locationId ?? undefined}
|
||||
onChange={setLocationId}
|
||||
required
|
||||
/>
|
||||
|
||||
<SectionHead title="Address" />
|
||||
<Textarea label="Permanent Address" placeholder="Full permanent address" autosize minRows={2} value={permanentAddress} onChange={(e) => setPermanentAddress(e.currentTarget.value)} />
|
||||
<Textarea
|
||||
label={<><Text span fz="sm" fw={500}>Current Address</Text><Text span fz="xs" c="dimmed" ml={6}>(If different from permanent)</Text></>}
|
||||
placeholder="Full current address"
|
||||
autosize
|
||||
minRows={2}
|
||||
value={currentAddress}
|
||||
onChange={(e) => setCurrentAddress(e.currentTarget.value)}
|
||||
/>
|
||||
|
||||
<SectionHead title="Emergency Contact" />
|
||||
<SimpleGrid cols={{ base: 1, sm: 2, lg: 3 }} spacing="md">
|
||||
<TextInput label="Contact Name" placeholder="Full name" value={emergencyName} onChange={(e) => setEmergencyName(e.currentTarget.value)} />
|
||||
<Select label="Relationship" placeholder="Select" data={RELATIONSHIPS} value={emergencyRel} onChange={setEmergencyRel} />
|
||||
<TextInput label="Phone Number" placeholder="+251 9XX XXX XXX" value={emergencyPhone} onChange={(e) => setEmergencyPhone(e.currentTarget.value)} />
|
||||
</SimpleGrid>
|
||||
</Stack>
|
||||
)}
|
||||
|
||||
{/* ── Step 3: Documents Upload ────────────────────────────────── */}
|
||||
{active === 2 && (
|
||||
<Stack gap="md">
|
||||
<Alert variant="light" color="yellow" icon={<IconAlertTriangle size={17} />}>
|
||||
Please upload clear, readable copies of all required documents. Accepted formats: PDF, JPG, PNG (max 5MB each). Items marked with * are mandatory.
|
||||
</Alert>
|
||||
|
||||
<SimpleGrid cols={{ base: 1, sm: 2, lg: 4 }} spacing="md">
|
||||
{DOC_SLOTS.map((slot) => (
|
||||
<DocCard
|
||||
key={slot.key}
|
||||
slot={slot}
|
||||
file={files[slot.key]}
|
||||
onFile={setFile(slot.key)}
|
||||
/>
|
||||
))}
|
||||
</SimpleGrid>
|
||||
|
||||
<Paper withBorder radius="md" p="md" bg="gray.0">
|
||||
<Text fw={600} fz="sm" mb="sm">Upload Progress</Text>
|
||||
<SimpleGrid cols={{ base: 2, sm: 4 }} spacing="sm">
|
||||
{DOC_SLOTS.map((slot) => (
|
||||
<Group key={slot.key} gap={6} align="center">
|
||||
{files[slot.key] ? (
|
||||
<IconCircleCheck size={16} color="var(--mantine-color-teal-6)" />
|
||||
) : (
|
||||
<Box style={{ width: 16, height: 16, borderRadius: '50%', border: '2px solid var(--mantine-color-gray-3)' }} />
|
||||
)}
|
||||
<Text fz="xs" c={files[slot.key] ? 'teal.7' : 'dimmed'} fw={files[slot.key] ? 600 : 400}>
|
||||
{slot.key === 'nationalId' ? 'National ID' : slot.key === 'passport' ? 'Passport' : slot.key === 'graduation' ? 'Certificate' : 'Photo'}
|
||||
</Text>
|
||||
</Group>
|
||||
))}
|
||||
</SimpleGrid>
|
||||
</Paper>
|
||||
</Stack>
|
||||
)}
|
||||
|
||||
{/* ── Step 4: Review & Submit ─────────────────────────────────── */}
|
||||
{active === 3 && review}
|
||||
|
||||
{/* Navigation buttons */}
|
||||
<Group justify="space-between" mt="xl">
|
||||
<Button variant="default" onClick={() => navigate('/applications')}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Group gap="sm">
|
||||
{active > 0 && (
|
||||
<Button variant="default" leftSection={<IconArrowLeft size={16} />} onClick={prev}>
|
||||
Previous
|
||||
</Button>
|
||||
)}
|
||||
{active < STEPS.length - 1 ? (
|
||||
<Button
|
||||
rightSection={<IconArrowRight size={16} />}
|
||||
onClick={next}
|
||||
disabled={!canNext()}
|
||||
>
|
||||
Next Step
|
||||
</Button>
|
||||
) : (
|
||||
<Button
|
||||
color="blue"
|
||||
leftSection={<IconCircleCheck size={16} />}
|
||||
onClick={handleSubmit}
|
||||
loading={submitting}
|
||||
>
|
||||
Submit Registration
|
||||
</Button>
|
||||
)}
|
||||
</Group>
|
||||
</Group>
|
||||
</Paper>
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
@@ -27,6 +27,7 @@ import { RequireSeafarerProfile } from "./features/profile/components/RequireSea
|
||||
import { OperationsOnboardingPage } from "./features/onboarding/pages/OperationsOnboardingPage";
|
||||
import { ProfilePage } from "./features/profile/pages/ProfilePage";
|
||||
import { SupportPage } from "./features/support/pages/SupportPage";
|
||||
import { SeafarerRegistrationPage } from "./features/seafarer/pages/SeafarerRegistrationPage";
|
||||
import { MySeaRecordsPage } from "./features/seafarer/pages/MySeaRecordsPage";
|
||||
import { ExamsPage } from "./features/exams/pages/ExamsPage";
|
||||
|
||||
@@ -152,13 +153,17 @@ export const router = createBrowserRouter([
|
||||
},
|
||||
|
||||
// Seafarer
|
||||
// The standalone wizard is gone — registration is the config-driven
|
||||
// licensing flow like every other licence type, gated by
|
||||
// RequireSeafarerProfile + RequirePermission the same way
|
||||
// /licensing/:typeCode/apply already is.
|
||||
{
|
||||
path: "/seafarer-registration",
|
||||
element: <Navigate to="/licensing/SEAFARER_REGISTRATION/apply" replace />,
|
||||
// No profile gate: this wizard asks for the personal, identity and
|
||||
// contact details itself across its four steps, so sending the
|
||||
// applicant to /profile first made the approved form unreachable —
|
||||
// they were bounced out before ever seeing it.
|
||||
element: (
|
||||
<RequirePermission anyOf={[P.APPLY_SEAFARER_REGISTRATION]}>
|
||||
<SeafarerRegistrationPage />
|
||||
</RequirePermission>
|
||||
),
|
||||
},
|
||||
{
|
||||
path: "/seafarer/records",
|
||||
|
||||
Reference in New Issue
Block a user