Files
emaui/apps/portal/src/app/features/seafarer/pages/SeafarerRegistrationPage.tsx
Nati 6852fc86c6 feat: implement permission-based access control across various pages and components
- Added RequirePermission component to manage access based on user permissions.
- Integrated permission checks in MySeaRecordsPage, SeafarerRegistrationPage, VesselRegistrationPage, WaiverPage, and PortalLayout.
- Updated router to enforce permissions for specific routes.
- Introduced PORTAL_PERMISSIONS and LICENSE_PERMISSIONS constants for consistent permission management.
- Enhanced usePermissions hook to fetch permissions from the server and determine access rights.
- Refactored UI components to conditionally render based on user permissions, improving security and user experience.
2026-08-13 12:58:11 +00:00

269 lines
8.7 KiB
TypeScript

import {
Alert,
Badge,
Button,
Card,
Group,
List,
Loader,
Paper,
Progress,
Stack,
Text,
Title,
} from '@mantine/core';
import {
IconAnchor,
IconArrowRight,
IconCircleCheck,
IconClipboardList,
IconInfoCircle,
} from '@tabler/icons-react';
import { useMemo } from 'react';
import { useNavigate } from 'react-router-dom';
import {
STATUS_COLORS,
STATUS_LABELS,
STATUS_PROGRESS,
TERMINAL_STATUSES,
useGetMyApplicationsQuery,
} from '@ema-platform/api';
import {
PORTAL_PERMISSIONS,
RequirePermission,
useCurrentProfile,
} from '@ema-platform/auth';
const REGISTRATION_TYPE_KEY = 'SEAFARER_REGISTRATION';
const DEPARTMENT_LABELS: Record<string, string> = {
DECK: 'Deck',
ENGINE: 'Engine',
CATERING: 'Catering',
};
const SEAFARER_STATUS_COLORS: Record<string, string> = {
ACTIVE: 'green',
PENDING: 'yellow',
SUSPENDED: 'orange',
INACTIVE: 'gray',
};
/**
* The seafarer's registration home (US-SEA-001…007).
*
* Registration itself runs through the config-driven licensing wizard — this
* page is the state machine around it: start a registration, resume or track
* the one in flight, or show the registered identity once approved.
*/
export function SeafarerRegistrationPage() {
const navigate = useNavigate();
const { profile, isLoading: loadingProfile } = useCurrentProfile();
const { data: applications, isLoading: loadingApplications } =
useGetMyApplicationsQuery();
// The latest registration application, in flight or decided.
const registration = useMemo(() => {
const mine = (applications?.items ?? []).filter(
(app) => app.licenseType?.key === REGISTRATION_TYPE_KEY,
);
return mine.sort(
(a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime(),
)[0];
}, [applications]);
if (loadingProfile || loadingApplications) {
return (
<Group justify="center" py="xl">
<Loader />
</Group>
);
}
// ------------------------------------------------------------- registered
if (profile?.seafarerNumber) {
const status = profile.seafarerStatus ?? 'ACTIVE';
return (
<Stack maw={720} mx="auto">
<Title order={2}>Seafarer Registration</Title>
<Card withBorder radius="md" p="xl">
<Stack>
<Group justify="space-between">
<Group>
<IconCircleCheck size={32} color="var(--mantine-color-green-6)" />
<div>
<Text fw={700} size="lg">
Registered Seafarer
</Text>
<Text c="dimmed" size="sm">
Your official seafarer profile with the Ethiopian Maritime
Authority.
</Text>
</div>
</Group>
<Badge color={SEAFARER_STATUS_COLORS[status] ?? 'gray'} size="lg">
{status}
</Badge>
</Group>
<Group gap="xl" mt="sm">
<div>
<Text size="xs" c="dimmed" tt="uppercase">
Seafarer Number
</Text>
<Text fw={700} ff="monospace" size="lg">
{profile.seafarerNumber}
</Text>
</div>
{profile.seafarerDepartment && (
<div>
<Text size="xs" c="dimmed" tt="uppercase">
Department
</Text>
<Text fw={600}>
{DEPARTMENT_LABELS[profile.seafarerDepartment] ??
profile.seafarerDepartment}
</Text>
</div>
)}
</Group>
{status === 'SUSPENDED' && profile.seafarerStatusReason && (
<Alert color="orange" icon={<IconInfoCircle size={16} />}>
Your profile is suspended: {profile.seafarerStatusReason}
</Alert>
)}
</Stack>
</Card>
<Paper withBorder radius="md" p="lg">
<Group justify="space-between">
<div>
<Text fw={600}>Sea service &amp; medical records</Text>
<Text size="sm" c="dimmed">
Keep your sea-service history and medical certificates up to
date certificate and seaman-book applications draw on them.
</Text>
</div>
<Button
variant="light"
rightSection={<IconArrowRight size={16} />}
onClick={() => navigate('/seafarer/records')}
>
My records
</Button>
</Group>
</Paper>
</Stack>
);
}
// -------------------------------------------------------------- in flight
if (registration && !TERMINAL_STATUSES.includes(registration.status)) {
const isDraft = registration.status === 'DRAFT';
const needsAction = registration.status === 'RESUBMIT_REQUIRED';
return (
<Stack maw={720} mx="auto">
<Title order={2}>Seafarer Registration</Title>
<Card withBorder radius="md" p="xl">
<Stack>
<Group justify="space-between">
<div>
<Text fw={700}>{registration.applicationNumber}</Text>
<Text size="sm" c="dimmed">
Submitted registrations are reviewed by an EMA registration
officer; you will be notified of every decision.
</Text>
</div>
<Badge color={STATUS_COLORS[registration.status]} size="lg">
{STATUS_LABELS[registration.status]}
</Badge>
</Group>
<Progress value={STATUS_PROGRESS[registration.status]} />
{needsAction && (
<Alert color="orange" icon={<IconInfoCircle size={16} />}>
The registration officer asked for corrections. Open the
application to see exactly what needs fixing.
</Alert>
)}
<Group>
<Button
rightSection={<IconArrowRight size={16} />}
onClick={() =>
navigate(
isDraft || needsAction
? `/licensing/${REGISTRATION_TYPE_KEY}/apply`
: `/licensing/${REGISTRATION_TYPE_KEY}/applications/${registration.id}`,
)
}
>
{isDraft
? 'Continue registration'
: needsAction
? 'Fix and resubmit'
: 'View application'}
</Button>
</Group>
</Stack>
</Card>
</Stack>
);
}
// ------------------------------------------------------- not yet started
return (
<Stack maw={720} mx="auto">
<Title order={2}>Seafarer Registration</Title>
{registration?.status === 'REJECTED' && (
<Alert color="red" title="Previous registration rejected">
{registration.rejectionReason ??
'Your previous registration was rejected. You may register again.'}
</Alert>
)}
<Card withBorder radius="md" p="xl">
<Stack>
<Group>
<IconAnchor size={32} color="var(--mantine-color-blue-6)" />
<div>
<Text fw={700} size="lg">
Register as a seafarer
</Text>
<Text c="dimmed" size="sm">
Approval creates your official seafarer profile with a unique
seafarer number the identity every maritime service builds on.
</Text>
</div>
</Group>
<Text fw={600} size="sm" mt="sm">
You will need:
</Text>
<List
size="sm"
spacing={4}
icon={<IconClipboardList size={16} color="var(--mantine-color-blue-5)" />}
>
<List.Item>A passport-size photograph</List.Item>
<List.Item>Your National ID (Fayda) or Kebele ID</List.Item>
<List.Item>Your educational certificate</List.Item>
<List.Item>
A medical fitness certificate and passport, if you already hold
them
</List.Item>
</List>
<Group mt="md">
<RequirePermission
anyOf={[PORTAL_PERMISSIONS.APPLY_SEAFARER_REGISTRATION]}
hideOnly
>
<Button
size="md"
rightSection={<IconArrowRight size={18} />}
onClick={() => navigate(`/licensing/${REGISTRATION_TYPE_KEY}/apply`)}
>
Start registration
</Button>
</RequirePermission>
</Group>
</Stack>
</Card>
</Stack>
);
}