Merge pull request #2 from Tria-plc/feature/seafare-registration

Feature/seafare registration, usermanegement and UI configuration
This commit is contained in:
Eyob T.
2026-06-19 12:39:25 +03:00
committed by GitHub
118 changed files with 31802 additions and 637 deletions

8
.gitignore vendored
View File

@@ -1,9 +1,11 @@
# dependencies
node_modules/
.nx
# build output
**/dist/
.next/
.nx/
coverage/
*.tsbuildinfo
**/*.tsbuildinfo
@@ -19,7 +21,13 @@ coverage/
.DS_Store
.idea/
.vscode/
.claude/
.npmrc
branch_structure.json
temp_auto_push.bat
temp_interactive_push.bat
.nx
apps/backoffice/public/_um/
apps/backoffice/public/tinymce/

3
.gitmodules vendored Normal file
View File

@@ -0,0 +1,3 @@
[submodule "user-management"]
path = user-management
url = git@github.com:Tria-plc/iamui.git

View File

@@ -3,6 +3,8 @@
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<link rel="icon" type="image/png" href="/ema-logo.png" />
<link rel="apple-touch-icon" href="/ema-logo.png" />
<title>EMA Backoffice</title>
</head>
<body>

Binary file not shown.

After

Width:  |  Height:  |  Size: 154 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 46 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 390 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 390 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 16 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 451 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 451 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 390 KiB

View File

@@ -1,17 +1,7 @@
import { useEffect } from 'react';
import { configureIam } from '@tria-plc/iamui-common';
import { AppProviders } from './providers/AppProviders';
import { AppRouter } from './router';
const BASE_API_URL =
(import.meta as { env?: Record<string, string> }).env?.['VITE_BASE_API_URL'] ??
'http://localhost:3001/api';
export function App() {
useEffect(() => {
configureIam({ apiUrl: BASE_API_URL });
}, []);
return (
<AppProviders>
<AppRouter />

View File

@@ -1,43 +1,445 @@
import type { ElementType } from 'react';
import { Grid, Paper, Text, Title, Stack, Group } from '@mantine/core';
import { IconBox, IconUsers, IconActivity } from '@tabler/icons-react';
import { useState, type ElementType, type ReactNode } from 'react';
import { useTranslation } from 'react-i18next';
import {
Paper,
Text,
Title,
Stack,
Group,
SimpleGrid,
Grid,
ThemeIcon,
Badge,
Avatar,
SegmentedControl,
Button,
Box,
UnstyledButton,
Anchor,
} from '@mantine/core';
import {
IconUsers,
IconUserCheck,
IconUserPlus,
IconClockHour4,
IconShieldLock,
IconMail,
IconUpload,
IconDownload,
IconClipboardList,
IconArrowUpRight,
IconArrowDownRight,
IconArrowRight,
} from '@tabler/icons-react';
import {
ResponsiveContainer,
BarChart,
Bar,
XAxis,
CartesianGrid,
Tooltip,
Cell,
PieChart,
Pie,
} from 'recharts';
interface StatCardProps {
label: string;
value: string;
icon: ElementType;
color: string;
/* ------------------------------------------------------------------ */
/* sample data — swap for live API results */
/* ------------------------------------------------------------------ */
const REGISTRATIONS = [
{ month: 'Nov', value: 320 },
{ month: 'Dec', value: 410 },
{ month: 'Jan', value: 380 },
{ month: 'Feb', value: 520 },
{ month: 'Mar', value: 470 },
{ month: 'Apr', value: 610 },
{ month: 'May', value: 560 },
{ month: 'Jun', value: 720 },
];
const ROLES = [
{ name: 'Admin', value: 18, color: '#1d4ed8' },
{ name: 'Staff', value: 42, color: '#3b82f6' },
{ name: 'Viewer', value: 30, color: '#60a5fa' },
{ name: 'Guest', value: 10, color: '#93c5fd' },
];
function useKpis(t: (key: string) => string) {
return [
{ label: t('dashboard.kpis.totalUsers'), value: '9,431', icon: IconUsers, color: 'blue', trend: '+12.5%', up: true },
{ label: t('dashboard.kpis.activeUsers'), value: '7,218', icon: IconUserCheck, color: 'indigo', trend: '+8.2%', up: true },
{ label: t('dashboard.kpis.newThisMonth'), value: '642', icon: IconUserPlus, color: 'cyan', trend: '+23.1%', up: true },
{ label: t('dashboard.kpis.pendingApprovals'), value: '37', icon: IconClockHour4, color: 'orange', trend: '-4.0%', up: false },
];
}
function StatCard({ label, value, icon: Icon, color }: StatCardProps) {
function useQuickLinks(t: (key: string) => string) {
return [
{ label: t('dashboard.quickLinks.addUser'), desc: t('dashboard.quickLinks.addUserDesc'), icon: IconUserPlus, color: 'blue' },
{ label: t('dashboard.quickLinks.rolesAndPermissions'), desc: t('dashboard.quickLinks.rolesAndPermissionsDesc'), icon: IconShieldLock, color: 'indigo' },
{ label: t('dashboard.quickLinks.inviteMembers'), desc: t('dashboard.quickLinks.inviteMembersDesc'), icon: IconMail, color: 'cyan' },
{ label: t('dashboard.quickLinks.importUsers'), desc: t('dashboard.quickLinks.importUsersDesc'), icon: IconUpload, color: 'violet' },
{ label: t('dashboard.quickLinks.exportData'), desc: t('dashboard.quickLinks.exportDataDesc'), icon: IconDownload, color: 'blue' },
{ label: t('dashboard.quickLinks.auditLog'), desc: t('dashboard.quickLinks.auditLogDesc'), icon: IconClipboardList, color: 'grape' },
];
}
type UserStatus = 'Active' | 'Pending' | 'Invited';
const STATUS_COLOR: Record<UserStatus, string> = {
Active: 'teal',
Pending: 'orange',
Invited: 'blue',
};
const RECENT_USERS: {
name: string;
email: string;
initials: string;
color: string;
status: UserStatus;
}[] = [
{ name: 'Sara Tesfaye', email: 'sara.t@ema.gov.et', initials: 'ST', color: 'blue', status: 'Active' },
{ name: 'Daniel Bekele', email: 'daniel.b@ema.gov.et', initials: 'DB', color: 'indigo', status: 'Active' },
{ name: 'Hanna Girma', email: 'hanna.g@ema.gov.et', initials: 'HG', color: 'violet', status: 'Pending' },
{ name: 'Yonas Alemu', email: 'yonas.a@ema.gov.et', initials: 'YA', color: 'cyan', status: 'Active' },
{ name: 'Meron Tadesse', email: 'meron.t@ema.gov.et', initials: 'MT', color: 'grape', status: 'Invited' },
];
/* ------------------------------------------------------------------ */
/* small building blocks */
/* ------------------------------------------------------------------ */
function SectionCard({ children }: { children: ReactNode }) {
return (
<Paper p="md" shadow="sm" radius="md" withBorder>
<Group justify="space-between">
<Stack gap={4}>
<Text size="sm" c="dimmed">
{label}
</Text>
<Title order={3}>{value}</Title>
</Stack>
<Icon size={32} color={color} />
</Group>
<Paper p="lg" radius="lg" withBorder h="100%">
{children}
</Paper>
);
}
export function DashboardPage() {
function CardHeading({ title, subtitle }: { title: string; subtitle?: string }) {
return (
<Stack gap="lg">
<Title order={2}>Dashboard</Title>
<Grid>
<Grid.Col span={{ base: 12, sm: 4 }}>
<StatCard label="Total Items" value="—" icon={IconBox} color="#2563eb" />
<Stack gap={2}>
<Text fw={700} fz="lg">
{title}
</Text>
{subtitle && (
<Text size="sm" c="dimmed">
{subtitle}
</Text>
)}
</Stack>
);
}
function StatCard({
label,
value,
icon: Icon,
color,
trend,
up,
}: {
label: string;
value: string;
icon: ElementType;
color: string;
trend: string;
up: boolean;
}) {
return (
<Paper p="lg" radius="lg" withBorder>
<Group justify="space-between" align="flex-start">
<ThemeIcon size={46} radius="md" variant="light" color={color}>
<Icon size={22} />
</ThemeIcon>
<Badge
variant="light"
color={up ? 'teal' : 'red'}
radius="sm"
leftSection={up ? <IconArrowUpRight size={12} /> : <IconArrowDownRight size={12} />}
>
{trend}
</Badge>
</Group>
<Text fz={30} fw={800} mt="md" lh={1.1}>
{value}
</Text>
<Text size="sm" c="dimmed" mt={4}>
{label}
</Text>
</Paper>
);
}
function QuickLinkTile({
label,
desc,
icon: Icon,
color,
}: {
label: string;
desc: string;
icon: ElementType;
color: string;
}) {
return (
<UnstyledButton
style={{
border: '1px solid var(--mantine-color-gray-2)',
borderRadius: 'var(--mantine-radius-md)',
padding: 'var(--mantine-spacing-md)',
transition: 'border-color 120ms ease, box-shadow 120ms ease',
}}
onMouseEnter={(e) => {
e.currentTarget.style.borderColor = 'var(--mantine-color-blue-3)';
e.currentTarget.style.boxShadow = 'var(--mantine-shadow-sm)';
}}
onMouseLeave={(e) => {
e.currentTarget.style.borderColor = 'var(--mantine-color-gray-2)';
e.currentTarget.style.boxShadow = 'none';
}}
>
<ThemeIcon size={42} radius="md" variant="light" color={color}>
<Icon size={20} />
</ThemeIcon>
<Text fw={600} size="sm" mt="sm">
{label}
</Text>
<Text size="xs" c="dimmed" mt={2}>
{desc}
</Text>
</UnstyledButton>
);
}
/* ------------------------------------------------------------------ */
/* page */
/* ------------------------------------------------------------------ */
export function DashboardPage() {
const { t } = useTranslation();
const [range, setRange] = useState('week');
const kpis = useKpis(t);
const quickLinks = useQuickLinks(t);
return (
<Stack gap="xl">
{/* header */}
<Group justify="space-between" align="flex-end" wrap="wrap">
<Stack gap={4}>
<Title order={2}>{t('dashboard.title')}</Title>
<Text c="dimmed" size="sm">
{t('dashboard.subtitle')}
</Text>
</Stack>
<Group gap="sm">
<SegmentedControl
value={range}
onChange={setRange}
radius="md"
data={[
{ label: t('dashboard.timeRange.today'), value: 'today' },
{ label: t('dashboard.timeRange.thisWeek'), value: 'week' },
{ label: t('dashboard.timeRange.thisMonth'), value: 'month' },
]}
/>
<Button leftSection={<IconDownload size={16} />} radius="md">
{t('common.export')}
</Button>
</Group>
</Group>
{/* KPIs */}
<SimpleGrid cols={{ base: 1, sm: 2, lg: 4 }} spacing="lg">
{kpis.map((k) => (
<StatCard key={k.label} {...k} />
))}
</SimpleGrid>
{/* charts */}
<Grid gutter="lg" align="stretch">
<Grid.Col span={{ base: 12, lg: 8 }}>
<SectionCard>
<Group justify="space-between" align="flex-start" mb="lg">
<CardHeading
title={t('dashboard.charts.userRegistrations')}
subtitle={t('dashboard.charts.registrationsSubtitle')}
/>
<Stack gap={0} align="flex-end">
<Text fw={700} c="teal">
+18.2%
</Text>
<Text size="xs" c="dimmed">
{t('dashboard.charts.vsPreviousPeriod')}
</Text>
</Stack>
</Group>
<Box h={260}>
<ResponsiveContainer width="100%" height="100%">
<BarChart data={REGISTRATIONS} margin={{ top: 8, right: 4, left: -22, bottom: 0 }}>
<defs>
<linearGradient id="barBlue" x1="0" y1="0" x2="0" y2="1">
<stop offset="0%" stopColor="#60a5fa" />
<stop offset="100%" stopColor="#2563eb" />
</linearGradient>
</defs>
<CartesianGrid vertical={false} strokeDasharray="3 3" stroke="#eef2f7" />
<XAxis
dataKey="month"
axisLine={false}
tickLine={false}
tick={{ fontSize: 12, fill: '#8d9bb3' }}
/>
<Tooltip
cursor={{ fill: '#f1f5fb' }}
contentStyle={{
borderRadius: 12,
border: '1px solid #e4e9f2',
fontSize: 12,
boxShadow: '0 4px 20px rgba(15,23,42,0.08)',
}}
/>
<Bar dataKey="value" radius={[6, 6, 0, 0]} maxBarSize={34}>
{REGISTRATIONS.map((entry, i) => (
<Cell
key={entry.month}
fill={i === REGISTRATIONS.length - 1 ? '#1d4ed8' : 'url(#barBlue)'}
/>
))}
</Bar>
</BarChart>
</ResponsiveContainer>
</Box>
</SectionCard>
</Grid.Col>
<Grid.Col span={{ base: 12, sm: 4 }}>
<StatCard label="Total Users" value="—" icon={IconUsers} color="#16a34a" />
<Grid.Col span={{ base: 12, lg: 4 }}>
<SectionCard>
<CardHeading title={t('dashboard.charts.usersByRole')} subtitle={t('dashboard.charts.roleDistribution')} />
<Group mt="lg" justify="center" wrap="nowrap" gap="lg">
<Box pos="relative" w={168} h={168} style={{ flexShrink: 0 }}>
<ResponsiveContainer width="100%" height="100%">
<PieChart>
<Pie
data={ROLES}
dataKey="value"
innerRadius={56}
outerRadius={82}
paddingAngle={2}
stroke="none"
startAngle={90}
endAngle={-270}
>
{ROLES.map((r) => (
<Cell key={r.name} fill={r.color} />
))}
</Pie>
<Tooltip
contentStyle={{
borderRadius: 12,
border: '1px solid #e4e9f2',
fontSize: 12,
}}
formatter={(value, name) => [`${value}%`, name]}
/>
</PieChart>
</ResponsiveContainer>
<Stack
gap={0}
align="center"
justify="center"
style={{ position: 'absolute', inset: 0, pointerEvents: 'none' }}
>
<Text fw={800} fz={22}>
9,431
</Text>
<Text size="xs" c="dimmed">
{t('dashboard.charts.totalUsers')}
</Text>
</Stack>
</Box>
<Stack gap="md" style={{ flex: 1 }}>
{ROLES.map((r) => (
<Group key={r.name} justify="space-between" wrap="nowrap">
<Group gap={8} wrap="nowrap">
<Box w={10} h={10} style={{ borderRadius: 999, background: r.color }} />
<Text size="sm" c="dimmed">
{r.name}
</Text>
</Group>
<Text size="sm" fw={700}>
{r.value}%
</Text>
</Group>
))}
</Stack>
</Group>
</SectionCard>
</Grid.Col>
<Grid.Col span={{ base: 12, sm: 4 }}>
<StatCard label="Activity" value="—" icon={IconActivity} color="#d97706" />
</Grid>
{/* quick links + recent users */}
<Grid gutter="lg" align="stretch">
<Grid.Col span={{ base: 12, lg: 8 }}>
<SectionCard>
<CardHeading title={t('dashboard.quickLinks.title')} subtitle={t('dashboard.quickLinks.subtitle')} />
<SimpleGrid cols={{ base: 2, sm: 3 }} spacing="md" mt="lg">
{quickLinks.map((q) => (
<QuickLinkTile key={q.label} {...q} />
))}
</SimpleGrid>
</SectionCard>
</Grid.Col>
<Grid.Col span={{ base: 12, lg: 4 }}>
<SectionCard>
<Group justify="space-between" mb="md">
<Text fw={700} fz="lg">
{t('dashboard.recentUsers.title')}
</Text>
<Anchor size="sm" fw={600}>
<Group gap={4} wrap="nowrap">
{t('common.viewAll')}
<IconArrowRight size={14} />
</Group>
</Anchor>
</Group>
<Stack gap={0}>
{RECENT_USERS.map((u, i) => (
<Group
key={u.email}
justify="space-between"
wrap="nowrap"
py="sm"
style={{
borderBottom:
i < RECENT_USERS.length - 1
? '1px solid var(--mantine-color-gray-2)'
: 'none',
}}
>
<Group gap="sm" wrap="nowrap">
<Avatar color={u.color} radius="xl" size={38}>
{u.initials}
</Avatar>
<Stack gap={0}>
<Text size="sm" fw={600}>
{u.name}
</Text>
<Text size="xs" c="dimmed">
{u.email}
</Text>
</Stack>
</Group>
<Badge variant="light" color={STATUS_COLOR[u.status]} radius="sm">
{u.status}
</Badge>
</Group>
))}
</Stack>
</SectionCard>
</Grid.Col>
</Grid>
</Stack>

View File

@@ -0,0 +1,68 @@
import { baseApi } from '@ema-platform/api';
import type {
Location,
LocationType,
ListResponse,
CreateLocationPayload,
UpdateLocationPayload,
CreateLocationTypePayload,
UpdateLocationTypePayload,
} from '../types/location';
const locationApi = baseApi.injectEndpoints({
endpoints: (builder) => ({
getLocationTypes: builder.query<ListResponse<LocationType>, void>({
query: () => '/location-types',
providesTags: ['Api'],
}),
createLocationType: builder.mutation<LocationType, CreateLocationTypePayload>({
query: (body) => ({ url: '/location-types', method: 'POST', body }),
invalidatesTags: ['Api'],
}),
updateLocationType: builder.mutation<LocationType, UpdateLocationTypePayload>({
query: ({ id, ...body }) => ({
url: `/location-types/${id}`,
method: 'PUT',
body,
}),
invalidatesTags: ['Api'],
}),
deleteLocationType: builder.mutation<void, string>({
query: (id) => ({ url: `/location-types/${id}`, method: 'DELETE' }),
invalidatesTags: ['Api'],
}),
getLocations: builder.query<ListResponse<Location>, { parentId?: string; locationTypeId?: string; take?: number; skip?: number }>({
query: (params) => ({ url: '/locations', params }),
providesTags: ['Api'],
}),
createLocation: builder.mutation<Location, CreateLocationPayload>({
query: (body) => ({ url: '/locations', method: 'POST', body }),
invalidatesTags: ['Api'],
}),
updateLocation: builder.mutation<Location, UpdateLocationPayload>({
query: ({ id, ...body }) => ({
url: `/locations/${id}`,
method: 'PUT',
body,
}),
invalidatesTags: ['Api'],
}),
deleteLocation: builder.mutation<void, string>({
query: (id) => ({ url: `/locations/${id}`, method: 'DELETE' }),
invalidatesTags: ['Api'],
}),
}),
overrideExisting: false,
});
export const {
useGetLocationTypesQuery,
useCreateLocationTypeMutation,
useUpdateLocationTypeMutation,
useDeleteLocationTypeMutation,
useGetLocationsQuery,
useCreateLocationMutation,
useUpdateLocationMutation,
useDeleteLocationMutation,
} = locationApi;

View File

@@ -0,0 +1,129 @@
import {
Paper,
Title,
Text,
Group,
Button,
Stack,
Badge,
Divider,
ActionIcon,
Menu,
rem,
} from '@mantine/core';
import {
IconEdit,
IconTrash,
IconPlus,
IconDots,
} from '@tabler/icons-react';
import { useTranslation } from 'react-i18next';
import type { Location, LocationType } from '../types/location';
interface LocationDetailProps {
location: Location;
locationTypes: LocationType[];
onAddChild: () => void;
onEdit: () => void;
onDelete: () => void;
}
export function LocationDetail({
location,
locationTypes,
onAddChild,
onEdit,
onDelete,
}: LocationDetailProps) {
const { t } = useTranslation();
const typeInfo = locationTypes.find(
(lt) => lt.id === location.locationTypeId,
);
const isLeaf =
!locationTypes.some(
(lt) => lt.level === (typeInfo?.level ?? 0) + 1,
);
return (
<Paper p="lg" radius="md" withBorder>
<Group justify="space-between" mb="xs">
<Title order={4}>{location.names.en}</Title>
<Group gap="xs">
<Button
variant="light"
size="sm"
leftSection={<IconPlus size={16} />}
onClick={onAddChild}
disabled={isLeaf}
>
{t('location.addSubLocation')}
</Button>
<Menu shadow="md" width={180}>
<Menu.Target>
<ActionIcon variant="subtle" color="gray" size="sm">
<IconDots size={16} />
</ActionIcon>
</Menu.Target>
<Menu.Dropdown>
<Menu.Item
leftSection={<IconEdit size={16} />}
onClick={onEdit}
>
{t('location.edit')}
</Menu.Item>
<Menu.Item
leftSection={<IconTrash size={16} />}
color="red"
onClick={onDelete}
>
{t('location.delete')}
</Menu.Item>
</Menu.Dropdown>
</Menu>
</Group>
</Group>
<Group gap="xs" mb="md">
<Badge size="lg" variant="light" color="blue">
{location.code}
</Badge>
{typeInfo && (
<Badge size="lg" variant="light" color="teal">
{typeInfo.names.en}
</Badge>
)}
</Group>
<Divider mb="md" />
<Stack gap="sm">
<div>
<Text size="xs" c="dimmed" tt="uppercase" fw={600}>
{t('location.nameEn')}
</Text>
<Text size="sm">{location.names.en}</Text>
</div>
<div>
<Text size="xs" c="dimmed" tt="uppercase" fw={600}>
{t('location.nameAm')}
</Text>
<Text size="sm">{location.names.am}</Text>
</div>
<div>
<Text size="xs" c="dimmed" tt="uppercase" fw={600}>
{t('location.code')}
</Text>
<Text size="sm">{location.code}</Text>
</div>
{typeInfo && (
<div>
<Text size="xs" c="dimmed" tt="uppercase" fw={600}>
{t('location.type')}
</Text>
<Text size="sm">{typeInfo.names.en} (Level {typeInfo.level})</Text>
</div>
)}
</Stack>
</Paper>
);
}

View File

@@ -0,0 +1,198 @@
import { useEffect, useMemo } from 'react';
import {
TextInput,
Button,
Group,
Stack,
Paper,
Title,
Text,
Badge,
Select,
} from '@mantine/core';
import { useForm } from '@mantine/form';
import { useTranslation } from 'react-i18next';
import { notify } from '@ema-platform/ui';
import type { Location, LocationType } from '../types/location';
interface LocationFormValues {
code: string;
namesEn: string;
namesAm: string;
locationTypeId: string;
}
interface LocationFormProps {
locationTypes: LocationType[];
parentLocation?: Location | null;
editingLocation?: Location | null;
onSubmit: (values: {
code: string;
names: { en: string; am: string };
locationTypeId: string;
parentId?: string | null;
}) => void;
onCancel: () => void;
isSubmitting?: boolean;
}
export function LocationForm({
locationTypes,
parentLocation,
editingLocation,
onSubmit,
onCancel,
isSubmitting,
}: LocationFormProps) {
const { t } = useTranslation();
const isEditing = !!editingLocation;
const { type, allAtLevel, typesAvailable } = useMemo(() => {
if (isEditing) {
const lt = locationTypes.find((t) => t.id === editingLocation.locationTypeId);
return { type: lt ?? null, allAtLevel: [lt].filter(Boolean) as LocationType[], typesAvailable: false };
}
if (parentLocation) {
const parentLevel =
locationTypes.find((lt) => lt.id === parentLocation.locationTypeId)?.level ?? 0;
const nextLevel = locationTypes
.filter((lt) => lt.level === parentLevel + 1)
.sort((a, b) => a.level - b.level);
return { type: nextLevel[0] ?? null, allAtLevel: nextLevel, typesAvailable: nextLevel.length > 1 };
}
const minLevel = Math.min(...locationTypes.map((lt) => lt.level));
const roots = locationTypes
.filter((lt) => lt.level === minLevel)
.sort((a, b) => a.level - b.level);
return { type: roots[0] ?? null, allAtLevel: roots, typesAvailable: roots.length > 1 };
}, [locationTypes, parentLocation, editingLocation, isEditing]);
const form = useForm<LocationFormValues>({
initialValues: {
code: '',
namesEn: '',
namesAm: '',
locationTypeId: typesAvailable ? '' : (type?.id ?? ''),
},
validate: {
code: (v) => (!v ? t('location.validation.codeRequired') : null),
namesEn: (v) => (!v ? t('location.validation.nameEnRequired') : null),
namesAm: (v) => (!v ? t('location.validation.nameAmRequired') : null),
locationTypeId: (v) => {
if (typesAvailable && !v) return t('location.validation.typeRequired');
return null;
},
},
});
useEffect(() => {
if (editingLocation) {
form.setValues({
code: editingLocation.code,
namesEn: editingLocation.names.en,
namesAm: editingLocation.names.am,
locationTypeId: editingLocation.locationTypeId,
});
}
}, [editingLocation]);
const handleSubmit = form.onSubmit((values) => {
const resolvedTypeId = typesAvailable ? values.locationTypeId : type?.id;
if (!resolvedTypeId) {
notify.error(t('location.noTypesAvailable'));
return;
}
onSubmit({
code: values.code,
names: { en: values.namesEn, am: values.namesAm },
locationTypeId: resolvedTypeId,
parentId: editingLocation ? editingLocation.parentId : parentLocation?.id ?? null,
});
});
const title = isEditing
? t('location.editTitle')
: t('location.addTitle');
return (
<Paper p="lg" radius="md" withBorder>
<Title order={5} mb="md">
{title}
</Title>
{parentLocation && !isEditing && (
<TextInput
label={t('location.parent')}
value={parentLocation.names.en}
disabled
mb="sm"
size="sm"
/>
)}
<form onSubmit={handleSubmit}>
<Stack gap="sm">
{typesAvailable ? (
<Select
label={t('location.type')}
placeholder={t('location.selectType')}
data={allAtLevel.map((lt) => ({
value: lt.id,
label: lt.names.en,
}))}
{...form.getInputProps('locationTypeId')}
size="sm"
required
nothingFoundMessage={t('location.noTypesAvailable')}
/>
) : (
<div>
<Text size="xs" c="dimmed" fw={500} mb={4}>
{t('location.type')}
</Text>
{type ? (
<Badge size="lg" variant="light" color="blue">
{type.names.en}
</Badge>
) : (
<Text size="sm" c="red">
{t('location.noTypesAvailable')}
</Text>
)}
</div>
)}
<TextInput
label={t('location.code')}
placeholder="e.g., ETH, AA, KK"
{...form.getInputProps('code')}
size="sm"
required
/>
<TextInput
label={t('location.nameEn')}
placeholder="English name"
{...form.getInputProps('namesEn')}
size="sm"
required
/>
<TextInput
label={t('location.nameAm')}
placeholder="የአማርኛ ስም"
{...form.getInputProps('namesAm')}
size="sm"
required
/>
<Group justify="flex-end" mt="md">
<Button variant="default" onClick={onCancel} size="sm">
{t('location.cancel')}
</Button>
<Button type="submit" loading={isSubmitting} size="sm">
{isEditing ? t('location.update') : t('location.create')}
</Button>
</Group>
</Stack>
</form>
</Paper>
);
}

View File

@@ -0,0 +1,239 @@
import { useState, useCallback, useMemo } from 'react';
import {
Text,
Loader,
Group,
Badge,
TextInput,
UnstyledButton,
Collapse,
Stack,
Box,
rem,
Center,
} from '@mantine/core';
import {
IconChevronRight,
IconChevronDown,
IconSearch,
IconMapPin,
} from '@tabler/icons-react';
import { useGetLocationsQuery } from '../api/location-api';
import type { Location } from '../types/location';
import { useTranslation } from 'react-i18next';
interface LocationTreeProps {
selectedId: string | null;
onSelect: (location: Location) => void;
}
function flatListToTree(
items: Location[],
parentId: string | null,
): Location[] {
return items
.filter((item) => item.parentId === parentId)
.map((item) => ({
...item,
children: flatListToTree(items, item.id),
}));
}
function TreeNode({
location,
selectedId,
onSelect,
depth,
}: {
location: Location;
selectedId: string | null;
onSelect: (location: Location) => void;
depth: number;
}) {
const [opened, setOpened] = useState(depth < 1);
const isSelected = selectedId === location.id;
const hasChildren =
Array.isArray(location.children) && location.children.length > 0;
const toggle = useCallback(() => {
setOpened((prev) => !prev);
}, []);
const select = useCallback(() => {
onSelect(location);
}, [onSelect, location]);
return (
<Box>
<UnstyledButton
onClick={select}
style={{
display: 'flex',
alignItems: 'center',
gap: rem(6),
width: '100%',
padding: `${rem(6)} ${rem(8)}`,
paddingLeft: rem(12 + depth * 20),
borderRadius: rem(8),
backgroundColor: isSelected
? 'var(--mantine-color-blue-light)'
: 'transparent',
color: isSelected
? 'var(--mantine-color-blue-filled)'
: 'var(--mantine-color-text)',
cursor: 'pointer',
border: 'none',
fontSize: rem(14),
transition: 'background-color 100ms ease',
}}
onMouseEnter={(e) => {
if (!isSelected)
e.currentTarget.style.backgroundColor =
'var(--mantine-color-gray-0)';
}}
onMouseLeave={(e) => {
if (!isSelected)
e.currentTarget.style.backgroundColor = 'transparent';
}}
>
{hasChildren && (
<Box
component="button"
onClick={(e: React.MouseEvent) => {
e.stopPropagation();
toggle();
}}
style={{
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
width: rem(18),
height: rem(18),
border: 'none',
background: 'none',
cursor: 'pointer',
padding: 0,
flexShrink: 0,
}}
>
{opened ? (
<IconChevronDown size={14} stroke={1.5} />
) : (
<IconChevronRight size={14} stroke={1.5} />
)}
</Box>
)}
{!hasChildren && <Box w={rem(18)} flexShrink={0} />}
<IconMapPin
size={14}
stroke={1.5}
style={{ flexShrink: 0, opacity: 0.6 }}
/>
<Text size="sm" truncate style={{ flex: 1 }}>
{location.names.en}
</Text>
</UnstyledButton>
{hasChildren && (
<Collapse in={opened}>
<Stack gap={2} mt={2}>
{location.children!.map((child) => (
<TreeNode
key={child.id}
location={child}
selectedId={selectedId}
onSelect={onSelect}
depth={depth + 1}
/>
))}
</Stack>
</Collapse>
)}
</Box>
);
}
export function LocationTree({
selectedId,
onSelect,
}: LocationTreeProps) {
const { t } = useTranslation();
const [search, setSearch] = useState('');
const { data: allLocationsRes, isLoading } = useGetLocationsQuery({ take: 10000 });
const tree = useMemo(() => {
if (!allLocationsRes?.items) return [];
const roots = flatListToTree(allLocationsRes.items, null);
return roots;
}, [allLocationsRes]);
const filteredTree = useMemo(() => {
if (!search) return tree;
const matches = (loc: Location): boolean => {
const nameMatch = loc.names.en
.toLowerCase()
.includes(search.toLowerCase());
const childMatch =
Array.isArray(loc.children) &&
loc.children.some(matches);
return nameMatch || childMatch;
};
const filterNodes = (nodes: Location[]): Location[] =>
nodes
.filter(matches)
.map((node) => ({
...node,
children: Array.isArray(node.children)
? filterNodes(node.children)
: [],
}));
return filterNodes(tree);
}, [tree, search]);
const handleSelect = useCallback(
(loc: Location) => {
onSelect(loc);
},
[onSelect],
);
if (isLoading) {
return (
<Center py="xl">
<Loader />
</Center>
);
}
return (
<>
<TextInput
placeholder={t('location.search')}
leftSection={<IconSearch size={16} />}
value={search}
onChange={(e) => setSearch(e.currentTarget.value)}
mb="sm"
size="sm"
/>
{tree.length === 0 && (
<Text c="dimmed" size="sm" ta="center" py="xl">
{t('location.noLocations')}
</Text>
)}
<Stack gap={2}>
{filteredTree.map((root) => (
<TreeNode
key={root.id}
location={root}
selectedId={selectedId}
onSelect={handleSelect}
depth={0}
/>
))}
</Stack>
</>
);
}

View File

@@ -0,0 +1,231 @@
import { useState, useEffect } from 'react';
import {
Modal,
TextInput,
Button,
Group,
Stack,
NumberInput,
Table,
ActionIcon,
Badge,
Text,
Tooltip,
Divider,
} from '@mantine/core';
import { useForm } from '@mantine/form';
import { IconEdit, IconTrash, IconPlus } from '@tabler/icons-react';
import { useTranslation } from 'react-i18next';
import {
useGetLocationTypesQuery,
useCreateLocationTypeMutation,
useUpdateLocationTypeMutation,
useDeleteLocationTypeMutation,
} from '../api/location-api';
import { notify } from '@ema-platform/ui';
interface LocationTypeFormValues {
code: string;
namesEn: string;
namesAm: string;
level: number;
}
export function LocationTypeModal({ opened, onClose }: { opened: boolean; onClose: () => void }) {
const { t } = useTranslation();
const { data: locationTypes, isLoading } = useGetLocationTypesQuery();
const [createType] = useCreateLocationTypeMutation();
const [updateType] = useUpdateLocationTypeMutation();
const [deleteType] = useDeleteLocationTypeMutation();
const [editingId, setEditingId] = useState<string | null>(null);
const [showForm, setShowForm] = useState(false);
const form = useForm<LocationTypeFormValues>({
initialValues: {
code: '',
namesEn: '',
namesAm: '',
level: 1,
},
validate: {
code: (v) => (!v ? 'Code is required' : null),
namesEn: (v) => (!v ? 'English name is required' : null),
namesAm: (v) => (!v ? 'Amharic name is required' : null),
level: (v) => (v < 1 ? 'Level must be at least 1' : null),
},
});
useEffect(() => {
if (!opened) {
resetForm();
}
}, [opened]);
const resetForm = () => {
form.reset();
setEditingId(null);
setShowForm(false);
};
const handleEdit = (type: { id: string; code: string; names: { en: string; am: string }; level: number }) => {
setEditingId(type.id);
form.setValues({
code: type.code,
namesEn: type.names.en,
namesAm: type.names.am,
level: type.level,
});
setShowForm(true);
};
const handleDelete = async (id: string) => {
try {
await deleteType(id).unwrap();
notify.success(t('location.typeDeleted'));
} catch {
notify.error(t('location.deleteError'));
}
};
const handleSubmit = form.onSubmit(async (values) => {
try {
if (editingId) {
await updateType({ id: editingId, ...values, names: { en: values.namesEn, am: values.namesAm } }).unwrap();
notify.success(t('location.typeUpdated'));
} else {
await createType({ code: values.code, names: { en: values.namesEn, am: values.namesAm }, level: values.level }).unwrap();
notify.success(t('location.typeCreated'));
}
resetForm();
} catch {
notify.error(t('location.typeError'));
}
});
const sortedTypes = locationTypes?.items
? [...locationTypes.items].sort((a, b) => a.level - b.level)
: [];
return (
<Modal
opened={opened}
onClose={onClose}
title={t('location.manageTypes')}
size="lg"
>
{!showForm && (
<Button
variant="light"
leftSection={<IconPlus size={16} />}
onClick={() => setShowForm(true)}
mb="md"
size="sm"
>
{t('location.addType')}
</Button>
)}
{showForm && (
<form onSubmit={handleSubmit}>
<Stack gap="sm" mb="md">
<TextInput
label="Code"
placeholder="e.g., COUNTRY, REGION, CITY"
{...form.getInputProps('code')}
size="sm"
/>
<TextInput
label="Name (English)"
placeholder="English name"
{...form.getInputProps('namesEn')}
size="sm"
/>
<TextInput
label="Name (Amharic)"
placeholder="የአማርኛ ስም"
{...form.getInputProps('namesAm')}
size="sm"
/>
<NumberInput
label="Level"
placeholder="1"
min={1}
max={10}
{...form.getInputProps('level')}
size="sm"
/>
<Group justify="flex-end">
<Button variant="default" onClick={resetForm} size="sm">
{t('location.cancel')}
</Button>
<Button type="submit" size="sm">
{editingId ? t('location.update') : t('location.create')}
</Button>
</Group>
</Stack>
</form>
)}
<Divider mb="md" />
{isLoading && <Text c="dimmed">Loading...</Text>}
{!isLoading && sortedTypes.length === 0 && (
<Text c="dimmed" ta="center" py="xl">
{t('location.noTypes')}
</Text>
)}
{sortedTypes.length > 0 && (
<Table striped highlightOnHover>
<Table.Thead>
<Table.Tr>
<Table.Th>Level</Table.Th>
<Table.Th>Code</Table.Th>
<Table.Th>Name (EN)</Table.Th>
<Table.Th>Name (AM)</Table.Th>
<Table.Th />
</Table.Tr>
</Table.Thead>
<Table.Tbody>
{sortedTypes.map((type) => (
<Table.Tr key={type.id}>
<Table.Td>
<Badge size="sm" variant="light" color="gray">
{type.level}
</Badge>
</Table.Td>
<Table.Td>
<Badge size="sm" variant="light" color="blue">
{type.code}
</Badge>
</Table.Td>
<Table.Td>{type.names.en}</Table.Td>
<Table.Td>{type.names.am}</Table.Td>
<Table.Td>
<Group gap="xs">
<ActionIcon
variant="subtle"
color="blue"
size="sm"
onClick={() => handleEdit(type)}
>
<IconEdit size={14} />
</ActionIcon>
<ActionIcon
variant="subtle"
color="red"
size="sm"
onClick={() => handleDelete(type.id)}
>
<IconTrash size={14} />
</ActionIcon>
</Group>
</Table.Td>
</Table.Tr>
))}
</Table.Tbody>
</Table>
)}
</Modal>
);
}

View File

@@ -0,0 +1,240 @@
import { useState, useCallback } from 'react';
import {
Stack,
Title,
Group,
Button,
Paper,
Text,
Grid,
Modal,
ActionIcon,
Tooltip,
Loader,
Center,
Alert,
} from '@mantine/core';
import { useDisclosure } from '@mantine/hooks';
import { IconSettings, IconMap, IconInfoCircle, IconPlus } from '@tabler/icons-react';
import { useTranslation } from 'react-i18next';
import { notify } from '@ema-platform/ui';
import { LocationTree } from '../components/LocationTree';
import { LocationDetail } from '../components/LocationDetail';
import { LocationForm } from '../components/LocationForm';
import { LocationTypeModal } from '../components/LocationTypeModal';
import {
useGetLocationTypesQuery,
useCreateLocationMutation,
useUpdateLocationMutation,
useDeleteLocationMutation,
} from '../api/location-api';
import type { Location } from '../types/location';
export function LocationPage() {
const { t } = useTranslation();
const { data: locationTypesRes, isLoading: typesLoading } = useGetLocationTypesQuery();
const [createLocation, { isLoading: isCreating }] = useCreateLocationMutation();
const [updateLocation, { isLoading: isUpdating }] = useUpdateLocationMutation();
const [deleteLocation] = useDeleteLocationMutation();
const locationTypes = locationTypesRes?.items ?? [];
const [selectedLocation, setSelectedLocation] = useState<Location | null>(null);
const [typeModalOpened, { open: openTypeModal, close: closeTypeModal }] = useDisclosure(false);
const [formModalOpened, { open: openFormModal, close: closeFormModal }] = useDisclosure(false);
const [deleteModalOpened, { open: openDeleteModal, close: closeDeleteModal }] = useDisclosure(false);
const [editingLocation, setEditingLocation] = useState<Location | null>(null);
const [parentLocation, setParentLocation] = useState<Location | null>(null);
const handleSelect = useCallback((loc: Location) => {
setSelectedLocation(loc);
}, []);
const handleAddChild = useCallback(() => {
setEditingLocation(null);
setParentLocation(selectedLocation);
openFormModal();
}, [selectedLocation, openFormModal]);
const handleEdit = useCallback(() => {
if (!selectedLocation) return;
setEditingLocation(selectedLocation);
setParentLocation(null);
openFormModal();
}, [selectedLocation, openFormModal]);
const handleFormSubmit = useCallback(
async (values: {
code: string;
names: { en: string; am: string };
locationTypeId: string;
parentId?: string | null;
}) => {
try {
if (editingLocation) {
await updateLocation({ id: editingLocation.id, ...values }).unwrap();
notify.success(t('location.updated'));
} else {
await createLocation(values).unwrap();
notify.success(t('location.created'));
}
closeFormModal();
setEditingLocation(null);
setParentLocation(null);
} catch {
notify.error(t('location.error'));
}
},
[editingLocation, createLocation, updateLocation, closeFormModal, t],
);
const handleDeleteConfirm = useCallback(async () => {
if (!selectedLocation) return;
try {
await deleteLocation(selectedLocation.id).unwrap();
notify.success(t('location.deleted'));
setSelectedLocation(null);
closeDeleteModal();
} catch {
notify.error(t('location.deleteError'));
}
}, [selectedLocation, deleteLocation, closeDeleteModal, t]);
if (typesLoading) {
return (
<Center py="xl">
<Loader />
</Center>
);
}
return (
<Stack gap="lg">
<Group justify="space-between">
<Title order={2}>{t('location.title')}</Title>
<Group gap="sm">
{locationTypes.length > 0 && (
<Button
variant="light"
leftSection={<IconPlus size={16} />}
onClick={() => {
setEditingLocation(null);
setParentLocation(null);
openFormModal();
}}
size="sm"
>
{t('location.addRoot')}
</Button>
)}
<Tooltip label={t('location.manageTypes')}>
<ActionIcon
variant="subtle"
color="gray"
size="lg"
onClick={openTypeModal}
>
<IconSettings size={20} />
</ActionIcon>
</Tooltip>
</Group>
</Group>
{locationTypes.length === 0 && (
<Alert
icon={<IconInfoCircle size={16} />}
title={t('location.setupRequired')}
color="blue"
>
<Text size="sm" mb="sm">
{t('location.setupHint')}
</Text>
<Button variant="light" size="sm" onClick={openTypeModal}>
{t('location.configureTypes')}
</Button>
</Alert>
)}
{locationTypes.length > 0 && (
<Grid gutter="md">
<Grid.Col span={{ base: 12, md: 5 }}>
<Paper p="md" radius="md" withBorder style={{ maxHeight: 'calc(100vh - 260px)', overflow: 'auto' }}>
<Text fw={600} size="sm" mb="sm">
{t('location.hierarchy')}
</Text>
<LocationTree
selectedId={selectedLocation?.id ?? null}
onSelect={handleSelect}
/>
</Paper>
</Grid.Col>
<Grid.Col span={{ base: 12, md: 7 }}>
{selectedLocation ? (
<LocationDetail
location={selectedLocation}
locationTypes={locationTypes}
onAddChild={handleAddChild}
onEdit={handleEdit}
onDelete={openDeleteModal}
/>
) : (
<Paper p="xl" radius="md" withBorder>
<Center py="xl">
<Stack align="center" gap="sm">
<IconMap size={48} stroke={1} color="var(--mantine-color-gray-4)" />
<Text c="dimmed" size="sm">
{t('location.selectHint')}
</Text>
</Stack>
</Center>
</Paper>
)}
</Grid.Col>
</Grid>
)}
<Modal
opened={formModalOpened}
onClose={closeFormModal}
title={editingLocation ? t('location.editTitle') : t('location.addTitle')}
size="md"
>
<LocationForm
locationTypes={locationTypes}
parentLocation={parentLocation}
editingLocation={editingLocation}
onSubmit={handleFormSubmit}
onCancel={() => {
closeFormModal();
setEditingLocation(null);
setParentLocation(null);
}}
isSubmitting={isCreating || isUpdating}
/>
</Modal>
<Modal
opened={deleteModalOpened}
onClose={closeDeleteModal}
title={t('location.confirmDelete')}
size="sm"
>
<Text mb="md">
{t('location.deleteConfirmText', {
name: selectedLocation?.names.en ?? '',
})}
</Text>
<Group justify="flex-end">
<Button variant="default" onClick={closeDeleteModal} size="sm">
{t('location.cancel')}
</Button>
<Button color="red" onClick={handleDeleteConfirm} size="sm">
{t('location.delete')}
</Button>
</Group>
</Modal>
<LocationTypeModal opened={typeModalOpened} onClose={closeTypeModal} />
</Stack>
);
}

View File

@@ -0,0 +1,51 @@
export interface NamePair {
en: string;
am: string;
}
export interface LocationType {
id: string;
code: string;
names: NamePair;
level: number;
createdAt: string;
updatedAt: string;
}
export interface Location {
id: string;
code: string;
names: NamePair;
locationTypeId: string;
parentId: string | null;
locationType?: LocationType;
children?: Location[];
createdAt: string;
updatedAt: string;
}
export interface ListResponse<T> {
count: number;
items: T[];
}
export interface CreateLocationTypePayload {
code: string;
names: NamePair;
level: number;
}
export interface UpdateLocationTypePayload extends CreateLocationTypePayload {
id: string;
}
export interface CreateLocationPayload {
code: string;
names: NamePair;
locationTypeId: string;
parentId?: string | null;
}
export interface UpdateLocationPayload extends CreateLocationPayload {
id: string;
}

View File

@@ -0,0 +1,56 @@
/* Segmented "pill" tab bar — light gray track with a white active pill. */
.list {
display: inline-flex;
gap: 6px;
padding: 5px;
background: var(--mantine-color-gray-1);
border-radius: var(--mantine-radius-md);
border: none;
flex-wrap: wrap;
}
.tab {
border: none;
border-radius: 10px;
padding: 9px 18px;
font-weight: 500;
color: var(--mantine-color-gray-7);
background: transparent;
transition:
background-color 120ms ease,
color 120ms ease,
box-shadow 120ms ease;
}
.tab:hover {
background: transparent;
color: var(--mantine-color-gray-9);
}
.tab[data-active],
.tab[data-active]:hover {
background: var(--mantine-color-body);
color: var(--mantine-color-emaPrimary-7);
font-weight: 600;
box-shadow: var(--mantine-shadow-xs);
}
/* Selectable option card (language + appearance). */
.choice {
border: 1px solid var(--mantine-color-gray-3);
border-radius: var(--mantine-radius-md);
background: var(--mantine-color-body);
transition:
border-color 120ms ease,
background-color 120ms ease;
}
.choice:hover {
border-color: var(--mantine-color-gray-4);
}
.choiceActive,
.choiceActive:hover {
border-color: var(--mantine-color-emaPrimary-6);
background: var(--mantine-color-emaPrimary-0);
}

View File

@@ -0,0 +1,605 @@
import { useEffect, useState } from 'react';
import {
Badge,
Box,
Button,
Divider,
Group,
Paper,
PasswordInput,
SimpleGrid,
Stack,
Switch,
Tabs,
Text,
TextInput,
Title,
UnstyledButton,
useMantineColorScheme,
type MantineColorScheme,
} from '@mantine/core';
import {
IconAt,
IconBell,
IconCheck,
IconCircle,
IconCircleCheckFilled,
IconDeviceDesktop,
IconDeviceFloppy,
IconLock,
IconMail,
IconMoon,
IconPhone,
IconSettings,
IconShieldLock,
IconSun,
IconUser,
IconUserCircle,
} from '@tabler/icons-react';
import { useForm } from 'react-hook-form';
import { zodResolver } from '@hookform/resolvers/zod';
import { z } from 'zod';
import { useTranslation } from 'react-i18next';
import { notify, PageHeader } from '@ema-platform/ui';
import { useApiMutation } from '@ema-platform/api';
import { setUser } from '@ema-platform/auth';
import type { AuthUser } from '@ema-platform/auth';
import { SUPPORTED_LANGUAGES, type AppLanguage } from '../../../i18n/config';
import { useAppDispatch, useAppSelector } from '../../../store/hooks';
import classes from './ProfilePage.module.css';
function getInitials(name: string, fallback: string) {
const source = name?.trim() || fallback?.trim() || '';
if (!source) return '?';
const parts = source.split(/\s+/);
const letters = parts.length > 1 ? parts[0][0] + parts[1][0] : source.slice(0, 2);
return letters.toUpperCase();
}
/** 04 rough strength score used by the meter on the security tab. */
function passwordScore(pw: string) {
if (!pw) return 0;
let score = 0;
if (pw.length >= 8) score++;
if (/[a-z]/.test(pw) && /[A-Z]/.test(pw)) score++;
if (/\d/.test(pw)) score++;
if (/[^A-Za-z0-9]/.test(pw)) score++;
return score;
}
export function ProfilePage() {
const { t, i18n } = useTranslation();
const dispatch = useAppDispatch();
const user = useAppSelector((state) => state.auth.user);
const { colorScheme, setColorScheme } = useMantineColorScheme();
const [updateTrigger] = useApiMutation<AuthUser>();
const [meTrigger] = useApiMutation<AuthUser>();
const [passwordTrigger] = useApiMutation<unknown>();
const [isSavingProfile, setIsSavingProfile] = useState(false);
const [isSavingPassword, setIsSavingPassword] = 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).
useEffect(() => {
let active = true;
meTrigger({ url: '/auth/me', method: 'GET' })
.unwrap()
.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.
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
// ---- Profile form ----
const profileSchema = 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') }),
email: z.string().email({ message: t('profile.validation.emailInvalid') }),
phoneNumber: z
.string()
.min(1, { message: t('profile.validation.phoneRequired') }),
});
type ProfileValues = z.infer<typeof profileSchema>;
const {
register: registerProfile,
handleSubmit: handleProfileSubmit,
reset: resetProfile,
formState: { errors: profileErrors },
} = useForm<ProfileValues>({
resolver: zodResolver(profileSchema),
values: {
nameEn: user?.name?.en ?? '',
nameAm: user?.name?.am ?? '',
username: user?.username ?? '',
email: user?.email ?? '',
phoneNumber: user?.phoneNumber ?? '',
},
});
const onSaveProfile = async (values: ProfileValues) => {
setIsSavingProfile(true);
try {
await updateTrigger({
url: '/auth/update-profile',
method: 'PATCH',
body: {
email: values.email,
username: values.username,
phoneNumber: values.phoneNumber,
name: { am: values.nameAm, en: values.nameEn },
},
}).unwrap();
const me = await meTrigger({ url: '/auth/me', method: 'GET' }).unwrap();
dispatch(setUser(me));
notify.success(t('profile.profileUpdated'));
} catch {
notify.error(t('profile.updateFailed'));
} finally {
setIsSavingProfile(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') }),
})
.refine((data) => data.newPassword === data.confirmPassword, {
message: t('profile.validation.passwordMismatch'),
path: ['confirmPassword'],
});
type PasswordValues = z.infer<typeof passwordSchema>;
const {
register: registerPassword,
handleSubmit: handlePasswordSubmit,
reset: resetPassword,
watch: watchPassword,
formState: { errors: passwordErrors },
} = useForm<PasswordValues>({
resolver: zodResolver(passwordSchema),
defaultValues: { oldPassword: '', newPassword: '', confirmPassword: '' },
});
const onChangePassword = async (values: PasswordValues) => {
setIsSavingPassword(true);
try {
await passwordTrigger({
url: '/auth/change-password',
method: 'PATCH',
body: {
oldPassword: values.oldPassword,
newPassword: values.newPassword,
confirmPassword: values.confirmPassword,
},
}).unwrap();
notify.success(t('profile.passwordChanged'));
resetPassword();
} catch {
notify.error(t('profile.passwordFailed'));
} finally {
setIsSavingPassword(false);
}
};
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'),
];
const strengthColors = ['gray', 'red', 'orange', 'emaPrimary', 'emaTeal'];
const flags: Record<AppLanguage, string> = { en: '\uD83C\uDDEC\uD83C\uDDE7', am: '\uD83C\uDDEA\uD83C\uDDF9' };
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 },
];
return (
<Stack gap="lg" maw={900}>
<PageHeader title={t('profile.title')} subtitle={t('profile.subtitle')} />
{/* Profile summary */}
<Paper p="lg" shadow="sm" radius="lg" withBorder>
<Group align="center" wrap="nowrap">
<Box
w={64}
h={64}
style={{
flexShrink: 0,
borderRadius: '50%',
backgroundImage: 'linear-gradient(135deg, #3b6ccc 0%, #1fc29d 100%)',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
}}
>
<Text fw={700} size="xl" c="white">
{getInitials(displayName, user?.email ?? '')}
</Text>
</Box>
<div>
<Group gap="xs" align="center">
<Title order={4}>{displayName || '\u2014'}</Title>
<Badge
variant="light"
color={user?.isPhoneNumberVerified ? 'emaTeal' : 'gray'}
size="sm"
>
{user?.isPhoneNumberVerified
? t('profile.verified')
: t('profile.unverified')}
</Badge>
</Group>
<Group gap={6} mt={2} c="dimmed">
<IconMail size={14} />
<Text size="sm" c="dimmed">
{user?.email}
</Text>
</Group>
</div>
<Box style={{ flex: 1 }} />
{user?.username && (
<Badge
visibleFrom="xs"
variant="default"
size="lg"
radius="xl"
leftSection={<IconAt size={13} />}
>
{user.username}
</Badge>
)}
</Group>
</Paper>
{/* Tabs */}
<Tabs
defaultValue="profile"
variant="pills"
classNames={{ list: classes.list, tab: classes.tab }}
>
<Tabs.List>
<Tabs.Tab value="profile" leftSection={<IconUserCircle size={18} />}>
{t('profile.tabs.profile')}
</Tabs.Tab>
<Tabs.Tab value="security" leftSection={<IconShieldLock size={18} />}>
{t('profile.tabs.security')}
</Tabs.Tab>
<Tabs.Tab value="preferences" leftSection={<IconSettings size={18} />}>
{t('profile.tabs.preferences')}
</Tabs.Tab>
</Tabs.List>
{/* ---- Profile ---- */}
<Tabs.Panel value="profile" pt="md">
<Paper p="xl" shadow="sm" radius="lg" withBorder>
<form onSubmit={handleProfileSubmit(onSaveProfile)}>
<Stack gap="xl">
<div>
<Title order={5}>{t('profile.personal')}</Title>
<Text size="sm" c="dimmed" mb="md">
{t('profile.personalHint')}
</Text>
<SimpleGrid cols={{ base: 1, sm: 2 }} spacing="md">
<TextInput
label={t('profile.fields.fullNameEn')}
leftSection={<IconUser size={18} />}
error={profileErrors.nameEn?.message}
{...registerProfile('nameEn')}
/>
<TextInput
label={t('profile.fields.fullNameAm')}
leftSection={<IconUser size={18} />}
error={profileErrors.nameAm?.message}
{...registerProfile('nameAm')}
/>
<TextInput
label={t('profile.fields.username')}
description={t('profile.fields.usernameHint')}
readOnly
variant="filled"
leftSection={<IconAt size={18} />}
error={profileErrors.username?.message}
{...registerProfile('username')}
/>
</SimpleGrid>
</div>
<Divider />
<div>
<Title order={5} mb="md">
{t('profile.contact')}
</Title>
<SimpleGrid cols={{ base: 1, sm: 2 }} spacing="md">
<TextInput
label={t('profile.fields.email')}
leftSection={<IconMail size={18} />}
error={profileErrors.email?.message}
{...registerProfile('email')}
/>
<TextInput
label={t('profile.fields.phone')}
leftSection={<IconPhone size={18} />}
error={profileErrors.phoneNumber?.message}
{...registerProfile('phoneNumber')}
/>
</SimpleGrid>
</div>
<Group justify="flex-end">
<Button
type="button"
variant="default"
onClick={() => resetProfile()}
>
{t('profile.cancel')}
</Button>
<Button
type="submit"
loading={isSavingProfile}
leftSection={<IconDeviceFloppy size={18} />}
>
{t('profile.updateProfile')}
</Button>
</Group>
</Stack>
</form>
</Paper>
</Tabs.Panel>
{/* ---- Security ---- */}
<Tabs.Panel value="security" pt="md">
<Paper p="xl" shadow="sm" radius="lg" withBorder>
<form onSubmit={handlePasswordSubmit(onChangePassword)}>
<Stack gap="xl">
<div>
<Title order={5}>{t('profile.security')}</Title>
<Text size="sm" c="dimmed" mb="md">
{t('profile.securityHint')}
</Text>
<Stack gap="md">
<PasswordInput
maw={360}
label={t('profile.fields.currentPassword')}
leftSection={<IconLock size={18} />}
error={passwordErrors.oldPassword?.message}
{...registerPassword('oldPassword')}
/>
<SimpleGrid cols={{ base: 1, sm: 2 }} spacing="md">
<PasswordInput
label={t('profile.fields.newPassword')}
leftSection={<IconLock size={18} />}
error={passwordErrors.newPassword?.message}
{...registerPassword('newPassword')}
/>
<PasswordInput
label={t('profile.fields.confirmPassword')}
leftSection={<IconLock size={18} />}
error={passwordErrors.confirmPassword?.message}
{...registerPassword('confirmPassword')}
/>
</SimpleGrid>
{score > 0 && (
<Stack gap={6}>
<Group justify="space-between">
<Text size="xs" c="dimmed" fw={600}>
{t('profile.strength.label')}
</Text>
<Text size="xs" fw={600} c={strengthColors[score]}>
{strengthLabels[score]}
</Text>
</Group>
<Group gap={6} grow>
{[1, 2, 3, 4].map((i) => (
<Box
key={i}
h={6}
style={{
borderRadius: 999,
backgroundColor:
i <= score
? `var(--mantine-color-${strengthColors[score]}-6)`
: 'var(--mantine-color-gray-2)',
}}
/>
))}
</Group>
</Stack>
)}
</Stack>
</div>
<Divider />
<Group align="flex-start" justify="space-between" wrap="nowrap">
<div>
<Text fw={600}>{t('profile.twoStep.title')}</Text>
<Text size="sm" c="dimmed">
{t('profile.twoStep.desc')}
</Text>
</div>
<Switch
checked={twoStepEnabled}
onChange={(e) => setTwoStepEnabled(e.currentTarget.checked)}
/>
</Group>
<Group justify="flex-end">
<Button
type="submit"
loading={isSavingPassword}
leftSection={<IconShieldLock size={18} />}
>
{t('profile.updatePassword')}
</Button>
</Group>
</Stack>
</form>
</Paper>
</Tabs.Panel>
{/* ---- Preferences ---- */}
<Tabs.Panel value="preferences" pt="md">
<Paper p="xl" shadow="sm" radius="lg" withBorder>
<Stack gap="xl">
<div>
<Title order={5}>{t('profile.languageTitle')}</Title>
<Text size="sm" c="dimmed" mb="md">
{t('profile.languageHint')}
</Text>
<SimpleGrid cols={{ base: 1, sm: 2 }} spacing="md">
{SUPPORTED_LANGUAGES.map((lng) => {
const active = i18n.language === lng;
return (
<UnstyledButton
key={lng}
onClick={() => i18n.changeLanguage(lng)}
className={`${classes.choice} ${active ? classes.choiceActive : ''}`}
p="md"
>
<Group wrap="nowrap">
<Text fz={22}>{flags[lng as AppLanguage]}</Text>
<div style={{ flex: 1 }}>
<Text fw={600} size="sm">
{t(`language.${lng}`)}
</Text>
<Text size="xs" c="dimmed">
{lng === 'en' ? 'English (United States)' : 'Amharic'}
</Text>
</div>
{active ? (
<IconCircleCheckFilled
size={20}
color="var(--mantine-color-emaPrimary-6)"
/>
) : (
<IconCircle
size={20}
color="var(--mantine-color-gray-4)"
/>
)}
</Group>
</UnstyledButton>
);
})}
</SimpleGrid>
</div>
<Divider />
<div>
<Title order={5}>{t('profile.appearance.title')}</Title>
<Text size="sm" c="dimmed" mb="md">
{t('profile.appearance.subtitle')}
</Text>
<SimpleGrid cols={{ base: 1, sm: 3 }} spacing="md">
{appearanceOptions.map(({ value, label, icon: Icon }) => {
const active = colorScheme === value;
return (
<UnstyledButton
key={value}
onClick={() => setColorScheme(value)}
className={`${classes.choice} ${active ? classes.choiceActive : ''}`}
p="md"
>
<Group wrap="nowrap">
<Icon
size={20}
color={
active
? 'var(--mantine-color-emaPrimary-6)'
: 'var(--mantine-color-gray-6)'
}
/>
<Text fw={600} size="sm" style={{ flex: 1 }}>
{label}
</Text>
{active && (
<IconCircleCheckFilled
size={18}
color="var(--mantine-color-emaPrimary-6)"
/>
)}
</Group>
</UnstyledButton>
);
})}
</SimpleGrid>
</div>
<Divider />
<Group align="flex-start" justify="space-between" wrap="nowrap">
<Group gap="sm" wrap="nowrap">
<IconBell size={20} color="var(--mantine-color-gray-6)" />
<div>
<Text fw={600}>{t('profile.notifications.title')}</Text>
<Text size="sm" c="dimmed">
{t('profile.notifications.desc')}
</Text>
</div>
</Group>
<Switch
checked={emailNotifications}
onChange={(e) => setEmailNotifications(e.currentTarget.checked)}
/>
</Group>
<Group justify="flex-end">
<Button
leftSection={<IconCheck size={18} />}
onClick={() => notify.success(t('profile.profileUpdated'))}
>
{t('profile.savePreferences')}
</Button>
</Group>
</Stack>
</Paper>
</Tabs.Panel>
</Tabs>
</Stack>
);
}

View File

@@ -0,0 +1,96 @@
import { useEffect, useRef, useState } from 'react';
import { useNavigate, useLocation } from 'react-router-dom';
import { authStorage } from '@ema-platform/auth';
/**
* Same-origin host for the user-management module.
*
* The host app (React 19 / Mantine 8 / Tailwind 3) embeds the module (React 18 /
* Mantine 7 / Tailwind 4) via an iframe so the two never share a React tree,
* router, or CSS — the version mismatch is fully isolated by the document
* boundary. The module is built into apps/backoffice/public/_um and served by
* THIS same server at <origin>/_um/, so there is no second server and no second
* port. Override the mount path with VITE_USER_MANAGEMENT_BASE (default /_um).
*
* SSO: the module and host authenticate against the SAME backend, so the host's
* token is valid in the module. The module posts `UM_REQUEST_AUTH`; we reply with
* our stored token. Route-sync mirrors the module's internal route into the host
* URL (/um/<path>) so a refresh deep-links back to the selected menu.
*/
function readToken(): string | null {
const escaped = 'auth-token'.replace(/([.$?*|{}()[\]\\/+^])/g, '\\$1');
const match = document.cookie.match(new RegExp('(?:^|; )' + escaped + '=([^;]*)'));
return authStorage.getToken() ?? (match ? decodeURIComponent(match[1]) : null);
}
function readRefreshToken(): string | null {
return authStorage.getRefreshToken() ?? null;
}
export default function UserManagementHostPage() {
const navigate = useNavigate();
const location = useLocation();
const iframeRef = useRef<HTMLIFrameElement>(null);
// Same-origin sub-path the module is served from (matches the module's Vite
// `base` + the apps/backoffice/public/_um build). Same origin ⇒ no second port.
const mountBase = (
(import.meta.env.VITE_USER_MANAGEMENT_BASE as string | undefined) ?? '/_um'
).replace(/\/$/, '');
const moduleOrigin = window.location.origin;
// Deep-link: the host route is /um/*, so whatever follows /um is the module's
// own route. Compute src ONCE (frozen) so later parent-URL updates don't reload.
const [iframeSrc] = useState(() => {
const sub = location.pathname.replace(/^\/um(?=\/|$)/, '');
return mountBase + sub + location.search;
});
useEffect(() => {
const onMessage = (event: MessageEvent) => {
if (event.origin !== moduleOrigin) return;
const data = event.data as { type?: string; path?: string } | undefined;
if (!data) return;
if (data.type === 'UM_REQUEST_AUTH') {
const token = readToken();
const refreshToken = readRefreshToken();
const target = iframeRef.current?.contentWindow;
if (token && target) {
target.postMessage({ type: 'UM_AUTH_TOKEN', token, refreshToken }, moduleOrigin);
}
return;
}
if (data.type === 'UM_ROUTE_CHANGED' && typeof data.path === 'string') {
// Allow the module to navigate the host away by using /return/<path>.
// Add a nav item with href: "/return/dashboard" in project.theme.ts
// navItems to send the user back to the host app.
const returnMatch = data.path.match(/^\/return\/(.+)/);
if (returnMatch) {
navigate('/' + returnMatch[1], { replace: true });
return;
}
const target = '/um' + data.path;
if (window.location.pathname + window.location.search !== target) {
navigate(target, { replace: true });
}
}
};
window.addEventListener('message', onMessage);
return () => window.removeEventListener('message', onMessage);
}, [moduleOrigin, navigate]);
return (
<div style={{ position: 'fixed', inset: 0 }}>
<iframe
ref={iframeRef}
title="User Management"
src={iframeSrc}
style={{ width: '100%', height: '100%', border: 0, display: 'block' }}
/>
</div>
);
}

View File

@@ -0,0 +1,47 @@
import i18next from 'i18next';
import { initReactI18next } from 'react-i18next';
import { en } from './locales/en';
import { am } from './locales/am';
export const SUPPORTED_LANGUAGES = ['en', 'am'] as const;
export type AppLanguage = (typeof SUPPORTED_LANGUAGES)[number];
const STORAGE_KEY = 'ema-backoffice-lang';
function getInitialLanguage(): AppLanguage {
const stored =
typeof localStorage !== 'undefined'
? (localStorage.getItem(STORAGE_KEY) as AppLanguage | null)
: null;
if (stored && SUPPORTED_LANGUAGES.includes(stored)) return stored;
return 'en';
}
export const i18n = i18next.createInstance();
i18n.use(initReactI18next).init({
resources: {
en: { translation: en },
am: { translation: am },
},
lng: getInitialLanguage(),
fallbackLng: 'en',
supportedLngs: [...SUPPORTED_LANGUAGES],
interpolation: { escapeValue: false },
returnNull: false,
});
i18n.on('languageChanged', (lng) => {
if (typeof localStorage !== 'undefined') {
localStorage.setItem(STORAGE_KEY, lng);
}
if (typeof document !== 'undefined') {
document.documentElement.lang = lng;
}
});
if (typeof document !== 'undefined') {
document.documentElement.lang = i18n.language;
}
export default i18n;

View File

@@ -0,0 +1,209 @@
import type { Translations } from './en';
export const am: Translations = {
app: {
name: 'ኢማ አስተዳደር',
shortName: 'ኢማ',
authority: 'የኢትዮጵያ ባሕር ጉዳዮች ባለሥልጣን',
tagline: 'የቁጥጥር ማዕከል',
},
language: {
label: 'ቋንቋ',
en: 'English',
am: 'አማርኛ',
},
nav: {
menu: 'ምናሌ',
dashboard: 'ዳሽቦርድ',
userManagement: 'የተጠቃሚ አስተዳደር',
profile: 'መገለጫ',
collapseSidebar: 'ሰብስብ',
expandSidebar: 'ዘርጋ',
},
common: {
logout: 'ውጣ',
profile: 'መገለጫ',
settings: 'ቅንብሮች',
export: 'ላክ',
viewAll: 'ሁሉንም ይመልከቱ',
home: 'መነሻ',
notifications: 'ማሳወቂያዎች',
administrator: 'አስተዳዳሪ',
collapse: 'ሰብስብ',
expand: 'ዘርጋ',
toggleTheme: 'የብርሃን / ጨለማ ሁነታን ይቀይሩ',
},
breadcrumbs: {
dashboard: 'ዳሽቦርድ',
um: 'የተጠቃሚ አስተዳደር',
home: 'መነሻ',
},
dashboard: {
title: 'አጠቃላይ እይታ',
subtitle: 'እንኳን ደህና መጡ — በመድረክዎ ላይ ያለው እንቅስቃሴ አጠቃላይ እይታ።',
kpis: {
totalUsers: 'ጠቅላላ ተጠቃሚዎች',
activeUsers: 'ንቁ ተጠቃሚዎች',
newThisMonth: 'በዚህ ወር አዲስ',
pendingApprovals: 'በመጠባበቅ ላይ ያሉ',
},
charts: {
userRegistrations: 'የተጠቃሚ ምዝገባ',
registrationsSubtitle: 'ባለፉት 8 ወራት አዲስ ምዝገባ',
vsPreviousPeriod: 'ካለፈው ጊዜ ጋር ሲነጻጸር',
usersByRole: 'ተጠቃሚዎች በሚና',
roleDistribution: 'በተደራሽነት ደረጃ ስርጭት',
totalUsers: 'ጠቅላላ ተጠቃሚዎች',
},
quickLinks: {
title: 'ፈጣን አገናኞች',
subtitle: 'የተለመዱ የአስተዳደር አቋራጮች',
addUser: 'ተጠቃሚ ያክሉ',
addUserDesc: 'አዲስ መለያ ይፍጠሩ',
rolesAndPermissions: 'ሚናዎች እና ፈቃዶች',
rolesAndPermissionsDesc: 'የተደራሽነት ደረጃዎችን ያስተዳድሩ',
inviteMembers: 'አባላትን ይጋብዙ',
inviteMembersDesc: 'በኢሜይል ግብዣ ይላኩ',
importUsers: 'ተጠቃሚዎችን ያስመጡ',
importUsersDesc: 'የCSV ጅምላ ማስመጣት',
exportData: 'ውሂብ ያላኩ',
exportDataDesc: 'ሪፖርቶችን ያውርዱ',
auditLog: 'የኦዲት መዝገብ',
auditLogDesc: 'እንቅስቃሴን ይገምግሙ',
},
recentUsers: {
title: 'የቅርብ ጊዜ ተጠቃሚዎች',
},
timeRange: {
today: 'ዛሬ',
thisWeek: 'በዚህ ሳምንት',
thisMonth: 'በዚህ ወር',
},
},
location: {
title: 'አካባቢዎች',
hierarchy: 'የአካባቢ ተዋረድ',
search: 'አካባቢዎችን ይፈልጉ...',
noLocations: 'ምንም አካባቢዎች አልተገኙም። ለመጀመር የስር አካባቢ ያክሉ።',
selectHint: 'ዝርዝሮችን ለማየት ከዛፉ ውስጥ አካባቢ ይምረጡ',
addRoot: 'የስር አካባቢ ያክሉ',
addSubLocation: 'ንዑስ አካባቢ ያክሉ',
addTitle: 'አካባቢ ያክሉ',
editTitle: 'አካባቢ ያስተካክሉ',
code: 'ኮድ',
nameEn: 'ስም (እንግሊዝኛ)',
nameAm: 'ስም (አማርኛ)',
type: 'አይነት',
parent: 'ወላጅ',
selectType: 'አይነት ይምረጡ',
cancel: 'ሰርዝ',
create: 'ፍጠር',
update: 'አዘምን',
edit: 'አስተካክል',
delete: 'ሰርዝ',
created: 'አካባቢ በተሳካ ሁኔታ ተፈጥሯል',
updated: 'አካባቢ በተሳካ ሁኔታ ዘምኗል',
deleted: 'አካባቢ በተሳካ ሁኔታ ተሰርዟል',
error: 'አንድ ስህተት ተፈጥሯል',
deleteError: 'አካባቢውን መሰረዝ አልተቻለም',
confirmDelete: 'መሰረዝን ያረጋግጡ',
deleteConfirmText: 'እርግጠኛ ነዎት {{name}}ን መሰረዝ ይፈልጋሉ?',
manageTypes: 'የአካባቢ አይነቶችን ያስተዳድሩ',
addType: 'የአካባቢ አይነት ያክሉ',
noTypes: 'ገና ምንም የአካባቢ አይነቶች አልተገለጹም',
typeCreated: 'የአካባቢ አይነት ተፈጥሯል',
typeUpdated: 'የአካባቢ አይነት ዘምኗል',
typeDeleted: 'የአካባቢ አይነት ተሰርዟል',
typeError: 'የአካባቢ አይነቱን ማስቀመጥ አልተቻለም',
setupRequired: 'ማዋቀር ያስፈልጋል',
setupHint: 'አካባቢዎችን ከመጨመርዎ በፊት የአካባቢ አይነቶችን ማዋቀር ያስፈልግዎታል።',
configureTypes: 'የአካባቢ አይነቶችን ያዋቅሩ',
noTypesAvailable: 'ለዚህ ደረጃ ምንም የአካባቢ አይነቶች የሉም',
multipleTypesHint: 'በርካታ አይነቶች ተገኝተዋል — አይነቱ ከዝቅተኛው ደረጃ በራስ-ሰር ተመርጧል።',
validation: {
codeRequired: 'ኮድ ያስፈልጋል',
nameEnRequired: 'የእንግሊዝኛ ስም ያስፈልጋል',
nameAmRequired: 'የአማርኛ ስም ያስፈልጋል',
typeRequired: 'የአካባቢ አይነት ያስፈልጋል',
},
},
profile: {
title: 'የእኔ መገለጫ',
subtitle: 'የመለያ ዝርዝሮችዎን እና ምርጫዎችዎን ያስተዳድሩ።',
personal: 'የግል መረጃ',
contact: 'መገኛ',
preferences: 'ምርጫዎች',
security: 'ደህንነት',
securityHint: 'በሌላ ቦታ የማይጠቀሙትን ጠንካራ የይለፍ ቃል ይምረጡ።',
changePassword: 'የይለፍ ቃል ይቀይሩ',
updatePassword: 'የይለፍ ቃል አዘምን',
updateProfile: 'ለውጦችን አስቀምጥ',
savePreferences: 'ምርጫዎችን አስቀምጥ',
cancel: 'ሰርዝ',
verified: 'የተረጋገጠ',
unverified: 'ያልተረጋገጠ',
tabs: {
profile: 'መገለጫ',
security: 'ደህንነት',
preferences: 'ምርጫዎች',
},
personalHint: 'በኦፊሴላዊ ኢማ ሰነዶች ላይ እንደሚታየው ስምዎ።',
languageTitle: 'ቋንቋ',
languageHint: 'በአስተዳደር ፓነል ውስጥ የሚጠቀሙትን ቋንቋ ይምረጡ።',
appearance: {
title: 'መልክ',
subtitle: 'የአስተዳደር ፓነል በመሣሪያዎ ላይ እንዴት እንደሚታይ ይምረጡ።',
light: 'የቀን',
dark: 'ሌሊት',
system: 'ሲስተም',
},
twoStep: {
title: 'ባለሁለት ደረጃ ማረጋገጫ',
desc: 'በየጊዜው ሲገቡ ከስልክዎ የአንድ ጊዜ ኮድ ያስፈልጋል።',
},
notifications: {
title: 'የኢሜይል ማሳወቂያዎች',
desc: 'ስለ መለያ እንቅስቃሴዎ በኢሜይል ዝማኔዎችን ይቀበሉ።',
},
strength: {
label: 'የይለፍ ቃል ጥንካሬ',
weak: 'ደካማ',
fair: 'መካከለኛ',
good: 'ጥሩ',
strong: 'ጠንካራ',
},
profileUpdated: 'መገለጫ በተሳካ ሁኔታ ዘምኗል',
passwordChanged: 'የይለፍ ቃል በተሳካ ሁኔታ ተቀይሯል',
updateFailed: 'መገለጫውን ማዘመን አልተቻለም። እባክዎ እንደገና ይሞክሩ።',
passwordFailed: 'የይለፍ ቃሉን መቀየር አልተቻለም። የአሁኑን የይለፍ ቃል ያረጋግጡ እና እንደገና ይሞክሩ።',
fields: {
fullNameEn: 'ሙሉ ስም (እንግሊዝኛ)',
fullNameAm: 'ሙሉ ስም (አማርኛ)',
username: 'የተጠቃሚ ስም',
usernameHint: 'የተጠቃሚ ስም መቀየር አይቻልም',
organization: 'ድርጅት',
email: 'የኢሜይል አድራሻ',
phone: 'ስልክ ቁጥር',
address: 'አድራሻ',
language: 'የሚመረጥ ቋንቋ',
currentPassword: 'የአሁኑ የይለፍ ቃል',
newPassword: 'አዲስ የይለፍ ቃል',
confirmPassword: 'አዲስ የይለፍ ቃል ያረጋግጡ',
},
validation: {
nameRequired: 'ስም ያስፈልጋል',
emailInvalid: 'የሚሰራ ኢሜይል ያስገቡ',
usernameRequired: 'የተጠቃሚ ስም ያስፈልጋል',
phoneRequired: 'ስልክ ቁጥር ያስፈልጋል',
passwordMin: 'የይለፍ ቃል ቢያንስ 8 ቁምፊዎች መሆን አለበት',
passwordMismatch: 'የይለፍ ቃላት አይዛመዱም',
},
},
};

View File

@@ -0,0 +1,210 @@
export const en = {
app: {
name: 'EMA Admin',
shortName: 'EMA',
authority: 'Ethiopian Maritime Authority',
tagline: 'Control Center',
},
language: {
label: 'Language',
en: 'English',
am: 'አማርኛ',
},
nav: {
menu: 'MENU',
dashboard: 'Dashboard',
userManagement: 'User Management',
profile: 'Profile',
collapseSidebar: 'Collapse',
expandSidebar: 'Expand sidebar',
},
common: {
logout: 'Log out',
profile: 'Profile',
settings: 'Settings',
export: 'Export',
viewAll: 'View all',
home: 'Home',
notifications: 'Notifications',
administrator: 'Administrator',
collapse: 'Collapse',
expand: 'Expand',
toggleTheme: 'Toggle light / dark mode',
},
breadcrumbs: {
dashboard: 'Dashboard',
um: 'User Management',
home: 'Home',
},
dashboard: {
title: 'Overview',
subtitle: 'Welcome back — here\u2019s what\u2019s happening across your platform.',
kpis: {
totalUsers: 'Total Users',
activeUsers: 'Active Users',
newThisMonth: 'New This Month',
pendingApprovals: 'Pending Approvals',
},
charts: {
userRegistrations: 'User Registrations',
registrationsSubtitle: 'New sign-ups over the last 8 months',
vsPreviousPeriod: 'vs previous period',
usersByRole: 'Users by Role',
roleDistribution: 'Distribution across access levels',
totalUsers: 'Total users',
},
quickLinks: {
title: 'Quick Links',
subtitle: 'Common administrative shortcuts',
addUser: 'Add User',
addUserDesc: 'Create a new account',
rolesAndPermissions: 'Roles & Permissions',
rolesAndPermissionsDesc: 'Manage access levels',
inviteMembers: 'Invite Members',
inviteMembersDesc: 'Send email invites',
importUsers: 'Import Users',
importUsersDesc: 'Bulk CSV import',
exportData: 'Export Data',
exportDataDesc: 'Download reports',
auditLog: 'Audit Log',
auditLogDesc: 'Review activity',
},
recentUsers: {
title: 'Recent Users',
},
timeRange: {
today: 'Today',
thisWeek: 'This Week',
thisMonth: 'This Month',
},
},
location: {
title: 'Locations',
hierarchy: 'Location Hierarchy',
search: 'Search locations...',
noLocations: 'No locations found. Add a root location to get started.',
selectHint: 'Select a location from the tree to view details',
addRoot: 'Add Root Location',
addSubLocation: 'Add Sub-Location',
addTitle: 'Add Location',
editTitle: 'Edit Location',
code: 'Code',
nameEn: 'Name (English)',
nameAm: 'Name (Amharic)',
type: 'Type',
parent: 'Parent',
selectType: 'Select type',
cancel: 'Cancel',
create: 'Create',
update: 'Update',
edit: 'Edit',
delete: 'Delete',
created: 'Location created successfully',
updated: 'Location updated successfully',
deleted: 'Location deleted successfully',
error: 'Something went wrong',
deleteError: 'Failed to delete location',
confirmDelete: 'Confirm Delete',
deleteConfirmText: 'Are you sure you want to delete {{name}}?',
manageTypes: 'Manage Location Types',
addType: 'Add Location Type',
noTypes: 'No location types defined yet',
typeCreated: 'Location type created',
typeUpdated: 'Location type updated',
typeDeleted: 'Location type deleted',
typeError: 'Failed to save location type',
setupRequired: 'Setup Required',
setupHint: 'You need to configure location types (e.g., Country, Region, City) before adding locations.',
configureTypes: 'Configure Location Types',
noTypesAvailable: 'No location types available for this level',
multipleTypesHint: 'Multiple types found — type is auto-selected from the lowest available level.',
validation: {
codeRequired: 'Code is required',
nameEnRequired: 'English name is required',
nameAmRequired: 'Amharic name is required',
typeRequired: 'Location type is required',
},
},
profile: {
title: 'My Profile',
subtitle: 'Manage your account details and preferences.',
personal: 'Personal information',
contact: 'Contact',
preferences: 'Preferences',
security: 'Security',
securityHint: 'Choose a strong password you do not use anywhere else.',
changePassword: 'Change password',
updatePassword: 'Update password',
updateProfile: 'Save changes',
savePreferences: 'Save preferences',
cancel: 'Cancel',
verified: 'Verified',
unverified: 'Unverified',
tabs: {
profile: 'Profile',
security: 'Security',
preferences: 'Preferences',
},
personalHint: 'Your name as it appears on official EMA documents.',
languageTitle: 'Language',
languageHint: 'Choose the language used across the admin panel.',
appearance: {
title: 'Appearance',
subtitle: 'Select how the admin panel looks on your device.',
light: 'Light',
dark: 'Dark',
system: 'System',
},
twoStep: {
title: 'Two-step verification',
desc: 'Require a one-time code from your phone each time you sign in.',
},
notifications: {
title: 'Email notifications',
desc: 'Receive updates about your account activity by email.',
},
strength: {
label: 'Password strength',
weak: 'Weak',
fair: 'Fair',
good: 'Good',
strong: 'Strong',
},
profileUpdated: 'Profile updated successfully',
passwordChanged: 'Password changed successfully',
updateFailed: 'Could not update profile. Please try again.',
passwordFailed:
'Could not change password. Check your current password and try again.',
fields: {
fullNameEn: 'Full name (English)',
fullNameAm: 'Full name (Amharic)',
username: 'Username',
usernameHint: 'Username cannot be changed',
organization: 'Organization',
email: 'Email address',
phone: 'Phone number',
address: 'Address',
language: 'Preferred language',
currentPassword: 'Current password',
newPassword: 'New password',
confirmPassword: 'Confirm new password',
},
validation: {
nameRequired: 'Name is required',
emailInvalid: 'Enter a valid email',
usernameRequired: 'Username is required',
phoneRequired: 'Phone number is required',
passwordMin: 'Password must be at least 8 characters',
passwordMismatch: 'Passwords do not match',
},
},
};
export type Translations = typeof en;

View File

@@ -1,41 +0,0 @@
import { type ReactNode } from 'react';
import {
AuthProvider,
PermissionProvider,
BrandingProvider,
BreadcrumbProvider,
UnitProvider,
UserProvider,
} from '@tria-plc/iamui-common';
interface IamProvidersProps {
children: ReactNode;
}
export function IamProviders({ children }: IamProvidersProps) {
return (
<AuthProvider>
<BrandingProvider>
<BreadcrumbProvider>
<PermissionProvider>
<UnitProvider>
<UserProvider>
{children}
</UserProvider>
</UnitProvider>
</PermissionProvider>
</BreadcrumbProvider>
</BrandingProvider>
</AuthProvider>
);
}
export function AuthProviders({ children }: { children: ReactNode }) {
return (
<AuthProvider>
<BrandingProvider>
{children}
</BrandingProvider>
</AuthProvider>
);
}

View File

@@ -1,14 +0,0 @@
import { AuditLogPage } from '@tria-plc/iamui-common';
import { IamProviders } from '../IamProviders';
export function AuditLogPageWrapper() {
return (
<IamProviders>
<AuditLogPage
moduleKey="backoffice"
title="Audit Log"
subtitle="Track all activities and changes in the backoffice"
/>
</IamProviders>
);
}

View File

@@ -1,26 +1,164 @@
import { AppShell } from '@mantine/core';
import { useCallback } from 'react';
import { AppShell, rem } from '@mantine/core';
import { useDisclosure } from '@mantine/hooks';
import { Outlet } from 'react-router-dom';
import { AppHeader } from './components/AppHeader';
import { AppSidebar } from './components/AppSidebar';
import { Outlet, useLocation, useNavigate } from 'react-router-dom';
import { useTranslation } from 'react-i18next';
import { logout } from '@ema-platform/auth';
import { AppHeader } from '@ema-platform/ui';
import {
IconLayoutDashboard,
IconUsers,
IconUser,
IconMap,
} from '@tabler/icons-react';
import { notify } from '@ema-platform/ui';
import { SUPPORTED_LANGUAGES } from '../i18n/config';
import { useAppDispatch, useAppSelector } from '../store/hooks';
import type { Icon } from '@tabler/icons-react';
interface NavItem {
label: string;
icon: Icon;
to?: string;
soon?: boolean;
}
const NAV_ITEMS: NavItem[] = [
{ to: '/dashboard', label: 'Dashboard', icon: IconLayoutDashboard },
{ to: '/um/user-management/dashboard', label: 'User Management', icon: IconUsers },
{ to: '/locations', label: 'Locations', icon: IconMap },
{ to: '/profile', label: 'Profile', icon: IconUser },
];
const HEADER_HEIGHT = 116;
export function BackofficeLayout() {
const [opened, { toggle }] = useDisclosure();
const { t } = useTranslation();
const navigate = useNavigate();
const location = useLocation();
const dispatch = useAppDispatch();
const [opened, { toggle: toggleNav }] = useDisclosure();
const user = useAppSelector((state) => state.auth.user);
const displayName = user?.name?.en || user?.username || '';
const initials = displayName
? displayName.split(/\s+/).map((s) => s[0]).join('').toUpperCase().slice(0, 2)
: '?';
const handleLogout = useCallback(() => {
dispatch(logout());
navigate('/login');
}, [dispatch, navigate]);
const segments = location.pathname.split('/').filter(Boolean);
const crumbs = [
{ label: t('nav.dashboard'), path: '/dashboard' },
...segments
.map((_, i) => '/' + segments.slice(0, i + 1).join('/'))
.filter((path) => path !== '/dashboard')
.filter((path) => !path.startsWith('/um'))
.map((path) => ({ label: t('nav.dashboard'), path })),
];
const go = (item: NavItem) => {
if (item.soon) {
notify.info(`${item.label} — coming soon.`);
return;
}
if (item.to) {
navigate(item.to);
}
};
return (
<AppShell
header={{ height: 60 }}
navbar={{ width: 240, breakpoint: 'sm', collapsed: { mobile: !opened } }}
padding="md"
header={{ height: HEADER_HEIGHT }}
padding="lg"
>
<AppShell.Header>
<AppHeader onToggle={toggle} />
<AppShell.Header
style={{
background: 'var(--mantine-color-body)',
borderBottom: '1px solid var(--mantine-color-gray-2)',
display: 'flex',
flexDirection: 'column',
}}
>
{/* Top bar */}
<div style={{ height: 74, flexShrink: 0, padding: '0 32px' }}>
<AppHeader
onToggleNav={toggleNav}
onToggleSidebar={toggleNav}
navOpened={opened}
breadcrumbs={crumbs}
onNavigate={navigate}
onLogout={handleLogout}
userName={displayName || t('app.name')}
userInitials={initials}
supportedLanguages={SUPPORTED_LANGUAGES}
/>
</div>
{/* Legacy-style tab bar — matching UM AppMenuTabs look */}
<div
style={{
display: 'flex',
alignItems: 'center',
gap: rem(2),
padding: '0 32px',
height: 42,
borderTop: '1px solid var(--mantine-color-gray-1)',
overflowX: 'auto',
flexShrink: 0,
}}
>
{NAV_ITEMS.map((item) => {
const active = !!item.to && (location.pathname === item.to || location.pathname.startsWith(`${item.to}/`));
const ItemIcon = item.icon;
return (
<button
key={item.to}
onClick={() => go(item)}
style={{
display: 'flex',
alignItems: 'center',
gap: rem(6),
padding: '8px 16px',
border: 'none',
borderBottom: '2px solid',
borderBottomColor: active
? 'var(--mantine-color-blue-6)'
: 'transparent',
background: 'transparent',
color: active
? 'var(--mantine-color-blue-6)'
: 'var(--mantine-color-gray-6)',
fontWeight: active ? 600 : 500,
fontSize: rem(14),
whiteSpace: 'nowrap',
cursor: 'pointer',
transition: 'all 150ms ease',
height: '100%',
marginBottom: -1,
fontFamily: 'inherit',
}}
onMouseEnter={(e) => {
if (!active) e.currentTarget.style.color = 'var(--mantine-color-blue-6)';
}}
onMouseLeave={(e) => {
if (!active) e.currentTarget.style.color = 'var(--mantine-color-gray-6)';
}}
>
<ItemIcon size={18} stroke={1.6} />
<span>{item.label}</span>
</button>
);
})}
</div>
</AppShell.Header>
<AppShell.Navbar>
<AppSidebar />
</AppShell.Navbar>
<AppShell.Main>
<Outlet />
<div key={location.pathname} className="ema-page-enter">
<Outlet />
</div>
</AppShell.Main>
</AppShell>
);

View File

@@ -1,32 +0,0 @@
import { Group, Text, ActionIcon, Burger } from '@mantine/core';
import { IconLogout } from '@tabler/icons-react';
import { useNavigate } from 'react-router-dom';
import { useAuthUser } from '@tria-plc/iamui-common';
interface AppHeaderProps {
onToggle: () => void;
}
export function AppHeader({ onToggle }: AppHeaderProps) {
const { logout } = useAuthUser();
const navigate = useNavigate();
const handleLogout = () => {
logout();
navigate('/login');
};
return (
<Group h="100%" px="md" justify="space-between">
<Group>
<Burger onClick={onToggle} size="sm" hiddenFrom="sm" />
<Text fw={700} size="lg">
EMA Backoffice
</Text>
</Group>
<ActionIcon variant="subtle" onClick={handleLogout} title="Logout">
<IconLogout size={18} />
</ActionIcon>
</Group>
);
}

View File

@@ -1,34 +0,0 @@
import { NavLink, Stack } from '@mantine/core';
import {
IconDashboard,
IconBox,
IconUsers,
IconClipboardList,
} from '@tabler/icons-react';
import { useNavigate, useLocation } from 'react-router-dom';
const NAV_ITEMS = [
{ label: 'Dashboard', icon: IconDashboard, path: '/dashboard' },
{ label: 'Items', icon: IconBox, path: '/items' },
{ label: 'User Management', icon: IconUsers, path: '/user-management' },
{ label: 'Audit Log', icon: IconClipboardList, path: '/audit-log' },
];
export function AppSidebar() {
const navigate = useNavigate();
const { pathname } = useLocation();
return (
<Stack p="xs" gap={4}>
{NAV_ITEMS.map(({ label, icon: Icon, path }) => (
<NavLink
key={path}
label={label}
leftSection={<Icon size={18} />}
active={pathname.startsWith(path)}
onClick={() => navigate(path)}
/>
))}
</Stack>
);
}

View File

@@ -1,7 +1,10 @@
import { I18nextProvider } from 'react-i18next';
import { Provider } from 'react-redux';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { AuthConfigProvider } from '@ema-platform/auth';
import type { ReactNode } from 'react';
import { store } from '../store';
import { i18n } from '../i18n/config';
import { MantineThemeProvider } from './MantineThemeProvider';
const queryClient = new QueryClient({
@@ -14,7 +17,19 @@ export function AppProviders({ children }: { children: ReactNode }) {
return (
<Provider store={store}>
<QueryClientProvider client={queryClient}>
<MantineThemeProvider>{children}</MantineThemeProvider>
<AuthConfigProvider
value={{
appName: 'Backoffice',
storagePrefix: 'ema-backoffice',
loginRedirectPath: '/dashboard',
enableSignup: false,
enableForgotPassword: true,
}}
>
<I18nextProvider i18n={i18n}>
<MantineThemeProvider>{children}</MantineThemeProvider>
</I18nextProvider>
</AuthConfigProvider>
</QueryClientProvider>
</Provider>
);

View File

@@ -5,7 +5,7 @@ import type { ReactNode } from 'react';
export function MantineThemeProvider({ children }: { children: ReactNode }) {
return (
<MantineProvider theme={emaTheme}>
<MantineProvider theme={emaTheme} defaultColorScheme="light">
<Notifications position="top-right" />
{children}
</MantineProvider>

View File

@@ -1,13 +1 @@
import { Navigate, Outlet } from 'react-router-dom';
function getTokenFromCookie(): string | undefined {
const match = document.cookie.match(/(?:^|;\s*)auth-token=([^;]*)/);
return match ? decodeURIComponent(match[1]) : undefined;
}
export function ProtectedRoute() {
const token =
localStorage.getItem('ema-backoffice-auth-token') ?? getTokenFromCookie();
if (!token) return <Navigate to="/login" replace />;
return <Outlet />;
}
export { ProtectedRoute } from '@ema-platform/auth';

View File

@@ -1,69 +1,45 @@
import { createBrowserRouter, RouterProvider, Navigate } from 'react-router-dom';
import {
Login,
SetPasswordPage,
UserManagementLayout,
UserManagementPage,
BulkUploadPage,
ArchivedUsersPage,
PositionManagementPage,
CreatePositionPage,
EditPositionPage,
} from '@tria-plc/iamui-common';
import { ProtectedRoute } from './ProtectedRoute';
import { BackofficeLayout } from '../layouts/BackofficeLayout';
createBrowserRouter,
RouterProvider,
Navigate,
} from 'react-router-dom';
import {
LoginPage,
ForgotPasswordPage,
OTPVerificationPage,
} from '@ema-platform/auth';
import { AuthLayout } from '../layouts/AuthLayout';
import { BackofficeLayout } from '../layouts/BackofficeLayout';
import { ProtectedRoute } from './ProtectedRoute';
import { DashboardPage } from '../features/dashboard/pages/DashboardPage';
import { ItemPage } from '../features/item/pages/ItemPage';
import { AuditLogPageWrapper } from '../iam/pages/AuditLogPage';
import { IamProviders, AuthProviders } from '../iam/IamProviders';
import UserManagementHostPage from '../features/user-management-host/UserManagementHostPage';
import { ProfilePage } from '../features/profile/pages/ProfilePage';
import { LocationPage } from '../features/location/pages/LocationPage';
const router = createBrowserRouter([
{
element: (
<AuthProviders>
<ProtectedRoute />
</AuthProviders>
),
element: <AuthLayout />,
children: [
{ path: '/login', element: <LoginPage /> },
{ path: '/forgot-password', element: <ForgotPasswordPage /> },
{ path: '/otp-verify', element: <OTPVerificationPage /> },
],
},
{ path: '/um/*', element: <UserManagementHostPage /> },
{
element: <ProtectedRoute />,
children: [
{
element: <BackofficeLayout />,
children: [
{ path: '/', element: <Navigate to="/dashboard" replace /> },
{ path: '/dashboard', element: <DashboardPage /> },
{ path: '/items', element: <ItemPage /> },
{
path: '/user-management',
element: (
<IamProviders>
<UserManagementLayout />
</IamProviders>
),
children: [
{ index: true, element: <UserManagementPage /> },
{ path: 'bulk-upload', element: <BulkUploadPage /> },
{ path: 'archived-users', element: <ArchivedUsersPage /> },
{ path: 'position-management', element: <PositionManagementPage /> },
{ path: 'position-management/new', element: <CreatePositionPage /> },
{ path: 'position-management/edit/:id', element: <EditPositionPage /> },
],
},
{ path: '/audit-log', element: <AuditLogPageWrapper /> },
{ index: true, element: <Navigate to="/dashboard" replace /> },
{ path: 'dashboard', element: <DashboardPage /> },
{ path: 'profile', element: <ProfilePage /> },
{ path: 'locations', element: <LocationPage /> },
],
},
],
},
{
element: (
<AuthProviders>
<AuthLayout />
</AuthProviders>
),
children: [
{ path: '/login', element: <Login /> },
{ path: '/otp-verify', element: <SetPasswordPage /> },
],
},
{ path: '/404', element: <div>Page not found</div> },
{ path: '*', element: <Navigate to="/404" replace /> },
]);

View File

@@ -1,13 +1,44 @@
import { configureStore } from '@reduxjs/toolkit';
import { baseApi } from '@ema-platform/api';
import { baseApi, configureTokenRefresh } from '@ema-platform/api';
import {
authReducer,
signupReducer,
configureAuthStorage,
authStorage,
refreshAccessToken,
logout,
} from '@ema-platform/auth';
import type { AuthUser } from '@ema-platform/auth';
configureAuthStorage('ema-backoffice');
const preloadedAuth = (() => {
const token = authStorage.getToken();
const user = authStorage.getUser<AuthUser>();
if (token && user) {
return { token, user, isAuthenticated: true };
}
return undefined;
})();
export const store = configureStore({
reducer: {
auth: authReducer,
signup: signupReducer,
[baseApi.reducerPath]: baseApi.reducer,
},
preloadedState: preloadedAuth ? { auth: preloadedAuth } : undefined,
middleware: (getDefaultMiddleware) =>
getDefaultMiddleware().concat(baseApi.middleware),
});
configureTokenRefresh({
onTokenExpired: refreshAccessToken,
onAuthFailure: () => {
store.dispatch(logout());
window.location.href = '/login';
},
});
export type RootState = ReturnType<typeof store.getState>;
export type AppDispatch = typeof store.dispatch;

View File

@@ -3,8 +3,8 @@ import { createRoot } from 'react-dom/client';
import '@mantine/core/styles.css';
import '@mantine/notifications/styles.css';
import '@mantine/dates/styles.css';
import '@tria-plc/iamui-common/styles.css';
import './styles.css';
import './app/i18n/config';
import { App } from './app/app';
window.__USER_MANAGEMENT_BRANDING__ = {

View File

@@ -2,6 +2,20 @@ 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',
@@ -10,7 +24,7 @@ export default defineConfig({
host: 'localhost',
},
preview: { port: 4201, host: 'localhost' },
plugins: [react(), nxViteTsPaths()],
plugins: [react(), nxViteTsPaths(), userManagementSpaFallback()],
resolve: {
dedupe: ['react', 'react-dom', 'react-router-dom', '@tanstack/react-query'],
},

View File

@@ -3,7 +3,16 @@
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<link rel="icon" type="image/png" href="/ema-logo.png" />
<link rel="apple-touch-icon" href="/ema-logo.png" />
<title>EMA Portal</title>
<script>
// Apply the saved Mantine color scheme before paint to avoid a flash.
try {
var s = localStorage.getItem('mantine-color-scheme-value') || 'light';
document.documentElement.setAttribute('data-mantine-color-scheme', s);
} catch (e) {}
</script>
</head>
<body>
<div id="root"></div>

Binary file not shown.

After

Width:  |  Height:  |  Size: 451 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 451 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 390 KiB

View File

@@ -1,18 +1,15 @@
// import { configureIam } from "@tria-plc/iamui-common";
import { BrowserRouter } from 'react-router-dom';
// import "@tria-plc/iamui-common/styles.css";
import { RouterProvider } from 'react-router-dom';
import { configureIam } from '@tria-plc/iamui-common';
import { AppProviders } from './providers/AppProviders';
import { AppRouter } from './router';
import { router } from './router';
// configureIam({ apiUrl: 'http://localhost:3001/api' });
// IAM module configuration (used by the isolated /users admin route).
configureIam({ apiUrl: 'http://localhost:3001/api' });
export function App() {
return (
<AppProviders>
<BrowserRouter>
<AppRouter />
</BrowserRouter>
<RouterProvider router={router} />
</AppProviders>
);
}

View File

@@ -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 (
<Popover
opened={opened}
onChange={close}
position="bottom"
width="auto"
trapFocus
withArrow
>
<Popover.Target>
<TextInput
label={label}
required={required}
value={displayValue}
readOnly
placeholder={placeholder}
onClick={toggle}
leftSection={
<Button
variant="light"
size="compact-xs"
onClick={(e) => {
e.stopPropagation();
setCalendarType((prev) => (prev === 'EN' ? 'AMH' : 'EN'));
}}
aria-label="Switch calendar type"
>
{calendarType}
</Button>
}
leftSectionWidth="calc(4.375rem * var(--mantine-scale))"
rightSection={
<ActionIcon size="md" variant="transparent" onClick={toggle}>
<IconCalendarEvent size={20} />
</ActionIcon>
}
/>
</Popover.Target>
<Popover.Dropdown p="md">
{calendarType === 'AMH' ? (
<EthiopicDayPicker
mode="single"
selected={value ?? undefined}
numerals="latn"
onSelect={(date: Date | undefined) => {
onChange?.(date ?? null);
close();
}}
/>
) : (
<GregorianDayPicker
mode="single"
selected={value ?? undefined}
onSelect={(date: Date | undefined) => {
onChange?.(date ?? null);
close();
}}
/>
)}
</Popover.Dropdown>
</Popover>
);
}

View File

@@ -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<TextInputProps, 'value' | 'onChange' | 'rightSection' | 'rightSectionWidth'> {
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 (
<TextInput
label={label}
required={required}
placeholder={placeholder ?? (lang === 'en' ? 'Enter in English' : 'በአማርኛ ያስገቡ')}
value={value[lang]}
onChange={(e) => onChange({ ...value, [lang]: e.currentTarget.value })}
rightSection={
<UnstyledButton
onClick={toggle}
aria-label={`Switch to ${lang === 'en' ? 'Amharic' : 'English'}`}
style={{
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
width: rem(28),
height: rem(20),
borderRadius: rem(4),
fontSize: rem(10),
fontWeight: 700,
letterSpacing: '0.05em',
background:
lang === 'en'
? 'var(--mantine-color-blue-1)'
: 'var(--mantine-color-teal-1)',
color:
lang === 'en'
? 'var(--mantine-color-blue-7)'
: 'var(--mantine-color-teal-7)',
cursor: 'pointer',
transition: 'background 150ms ease',
}}
>
{lang === 'en' ? 'EN' : 'AM'}
</UnstyledButton>
}
styles={{
input: {
paddingRight: rem(42),
},
}}
{...rest}
/>
);
}

View File

@@ -0,0 +1,67 @@
import { Button, Paper, Stack, Text } from '@mantine/core';
import type { Icon } from '@tabler/icons-react';
import type { ReactNode } from 'react';
interface EmptyStateProps {
/** Kept for API compatibility; the illustration is used as the primary visual. */
icon?: Icon;
title: string;
description?: string;
action?: { label: string; onClick: () => void; icon?: ReactNode };
}
export function EmptyState({ title, description, action }: EmptyStateProps) {
return (
<Paper p="xl" withBorder>
<Stack align="center" gap="sm" py="lg">
<EmptyIllustration />
<Text fw={600} fz="lg">
{title}
</Text>
{description && (
<Text c="dimmed" size="sm" ta="center" maw={440}>
{description}
</Text>
)}
{action && (
<Button mt="xs" leftSection={action.icon} onClick={action.onClick}>
{action.label}
</Button>
)}
</Stack>
</Paper>
);
}
/** Lightweight maritime illustration: a document floating on stylized waves. */
function EmptyIllustration() {
const primary = 'var(--mantine-color-emaPrimary-6)';
const primaryLight = 'var(--mantine-color-emaPrimary-2)';
const teal = 'var(--mantine-color-emaTeal-5)';
const surface = 'var(--mantine-color-body)';
return (
<svg
width="148"
height="120"
viewBox="0 0 148 120"
fill="none"
role="img"
aria-hidden
>
<ellipse cx="74" cy="104" rx="58" ry="9" fill={primaryLight} opacity="0.45" />
{/* document */}
<g>
<rect x="46" y="20" width="56" height="68" rx="8" fill={surface} stroke={primary} strokeWidth="2.5" />
<rect x="56" y="34" width="36" height="4" rx="2" fill={primaryLight} />
<rect x="56" y="46" width="36" height="4" rx="2" fill={primaryLight} />
<rect x="56" y="58" width="24" height="4" rx="2" fill={primaryLight} />
<circle cx="92" cy="74" r="9" fill={teal} opacity="0.9" />
<path d="M88 74l3 3 5-6" stroke={surface} strokeWidth="2.2" strokeLinecap="round" strokeLinejoin="round" />
</g>
{/* waves */}
<path d="M14 96c8 0 8-5 16-5s8 5 16 5 8-5 16-5 8 5 16 5 8-5 16-5 8 5 16 5" stroke={teal} strokeWidth="2.5" strokeLinecap="round" opacity="0.8" />
<path d="M22 106c8 0 8-5 16-5s8 5 16 5 8-5 16-5 8 5 16 5 8-5 16-5" stroke={primary} strokeWidth="2.5" strokeLinecap="round" opacity="0.45" />
</svg>
);
}

View File

@@ -0,0 +1,45 @@
import { Box, type BoxProps } from '@mantine/core';
/**
* App logo variants. Each maps to a file in `public/brand/` that is served at
* the site root by Vite. To swap the placeholder for the real artwork, just
* replace the file (keep the name) — or, if it's a PNG/other format, update the
* path here.
*/
const LOGO = {
color: '/ema-logo.png',
white: '/brand/ema-white.png',
} as const;
export type LogoVariant = keyof typeof LOGO;
interface LogoProps extends BoxProps {
/** Which artwork to render. Defaults to the full-colour mark. */
variant?: LogoVariant;
/** Rendered height (and width — the mark is square) in px. */
size?: number;
alt?: string;
}
/**
* The EMA app logo. A single source of truth for the brand mark so every
* surface (auth pages, portal header, V2 layout) stays in sync.
*/
export function Logo({
variant = 'color',
size = 40,
alt = 'EMA Portal',
...boxProps
}: LogoProps) {
return (
<Box
component="img"
src={LOGO[variant]}
alt={alt}
w={size}
h={size}
style={{ display: 'block', objectFit: 'contain', flexShrink: 0 }}
{...boxProps}
/>
);
}

View File

@@ -0,0 +1 @@
export { ProtectedRoute } from '@ema-platform/auth';

View File

@@ -1,109 +0,0 @@
import { useState } from 'react';
import {
Paper,
TextInput,
PasswordInput,
Button,
Stack,
Title,
Center,
Text,
Anchor,
} from '@mantine/core';
import { useForm } from 'react-hook-form';
import { zodResolver } from '@hookform/resolvers/zod';
import { z } from 'zod';
import { useNavigate, Link } from 'react-router-dom';
import { useAppDispatch } from '../../../store/hooks';
import { loginSuccess, setUser } from '../store/auth.slice';
import type { LoginPayload, AuthUser } from '../types/auth.types';
import { useApiMutation } from '@ema-platform/api';
import { notify } from '@ema-platform/ui';
const schema = z.object({
email: z.string().email({ message: 'Enter a valid email' }),
password: z.string().min(5, { message: 'Password must be at least 6 characters' }),
});
type FormValues = z.infer<typeof schema>;
export function LoginPage() {
const navigate = useNavigate();
const dispatch = useAppDispatch();
const [isLoading, setIsLoading] = useState(false);
const [loginTrigger] = useApiMutation<LoginPayload>();
const [meTrigger] = useApiMutation<AuthUser>();
const {
register,
handleSubmit,
formState: { errors },
} = useForm<FormValues>({
resolver: zodResolver(schema),
});
const onSubmit = async (values: FormValues) => {
setIsLoading(true);
try {
const data = await loginTrigger({
url: '/auth/login',
method: 'POST',
body: values,
}).unwrap();
dispatch(loginSuccess(data));
const me = await meTrigger({
url: '/auth/me',
method: 'GET',
}).unwrap();
dispatch(setUser(me));
if (me.status === 'accepted') {
navigate('/dashboard');
} else {
navigate('/otp-verify', {
state: { email: me.email, phoneNumber: me.phoneNumber },
});
}
} catch {
notify.error('Invalid email or password');
} finally {
setIsLoading(false);
}
};
return (
<Center h="100vh">
<Paper p="xl" shadow="md" radius="md" w={400}>
<Stack gap="md">
<Title order={3}>Sign in to Portal</Title>
<form onSubmit={handleSubmit(onSubmit)}>
<Stack gap="sm">
<TextInput
label="Email"
placeholder="you@example.com"
error={errors.email?.message}
{...register('email')}
/>
<PasswordInput
label="Password"
placeholder="Your password"
error={errors.password?.message}
{...register('password')}
/>
<Button type="submit" loading={isLoading} fullWidth mt="sm">
Sign in
</Button>
</Stack>
</form>
<Text ta="center" size="sm" c="dimmed">
Don't have an account?{' '}
<Anchor component={Link} to="/signup">
Sign up
</Anchor>
</Text>
</Stack>
</Paper>
</Center>
);
}

View File

@@ -1,109 +0,0 @@
import {
Paper,
TextInput,
Button,
Stack,
Title,
Center,
Text,
} from '@mantine/core';
import { useForm } from 'react-hook-form';
import { zodResolver } from '@hookform/resolvers/zod';
import { z } from 'zod';
import { useNavigate, useLocation } from 'react-router-dom';
import { useApiMutation } from '@ema-platform/api';
import { notify } from '@ema-platform/ui';
const schema = z.object({
verificationCode: z.string().min(1, { message: 'Verification code is required' }),
});
type FormValues = z.infer<typeof schema>;
export function OTPVerificationPage() {
const navigate = useNavigate();
const location = useLocation();
const state = location.state as
| { email?: string; phoneNumber?: string }
| null;
const email = state?.email ?? '';
const phoneNumber = state?.phoneNumber ?? '';
const [verifyTrigger, { isLoading: loading }] = useApiMutation();
const [resendTrigger, { isLoading: resending }] = useApiMutation();
const {
register,
handleSubmit,
formState: { errors },
} = useForm<FormValues>({
resolver: zodResolver(schema),
});
const onSubmit = async (values: FormValues) => {
try {
await verifyTrigger({
url: '/auth/verify-phone-number',
method: 'PATCH',
body: { email, phoneNumber, verificationCode: values.verificationCode },
}).unwrap();
notify.success('Phone number verified successfully');
navigate('/dashboard');
} catch (err) {
const msg = err instanceof Error ? err.message : 'Something went wrong';
notify.error(msg);
}
};
const handleResendOtp = async () => {
try {
await resendTrigger({
url: '/auth/resend-otp',
method: 'POST',
body: { email },
}).unwrap();
notify.success('Verification code resent to your email');
} catch (err) {
const msg = err instanceof Error ? err.message : 'Something went wrong';
notify.error(msg);
}
};
return (
<Center h="100vh">
<Paper p="xl" shadow="md" radius="md" w={400}>
<Stack gap="md">
<Title order={3}>Verify your email</Title>
<Text c="dimmed" size="sm">
A verification code has been sent to {email || 'your email'}. Enter it
below to complete your registration.
</Text>
<form onSubmit={handleSubmit(onSubmit)}>
<Stack gap="sm">
<TextInput
label="Verification Code"
placeholder="Enter the code from your email"
error={errors.verificationCode?.message}
{...register('verificationCode')}
/>
<Button type="submit" loading={loading} fullWidth mt="sm">
Verify
</Button>
</Stack>
</form>
<Button
variant="subtle"
size="sm"
loading={resending}
onClick={handleResendOtp}
fullWidth
>
Send OTP again
</Button>
</Stack>
</Paper>
</Center>
);
}

View File

@@ -1,23 +0,0 @@
import type { AuthUser } from '../types/auth.types';
const KEYS = {
token: 'ema-portal-auth-token',
refreshToken: 'ema-portal-refresh-token',
user: 'ema-portal-auth-user',
} as const;
export const authStorage = {
getToken: () => localStorage.getItem(KEYS.token) ?? undefined,
setToken: (token: string) => localStorage.setItem(KEYS.token, token),
getRefreshToken: () => localStorage.getItem(KEYS.refreshToken) ?? undefined,
setRefreshToken: (t: string) => localStorage.setItem(KEYS.refreshToken, t),
getUser: (): AuthUser | null => {
try {
return JSON.parse(localStorage.getItem(KEYS.user) ?? 'null') as AuthUser | null;
} catch {
return null;
}
},
setUser: (u: AuthUser) => localStorage.setItem(KEYS.user, JSON.stringify(u)),
clear: () => Object.values(KEYS).forEach((k) => localStorage.removeItem(k)),
};

View File

@@ -1,14 +1,110 @@
import { Stack, Title, Text, Paper } from '@mantine/core';
import {
Card,
Center,
Group,
Paper,
SimpleGrid,
Stack,
Text,
ThemeIcon,
Title,
UnstyledButton,
useMantineTheme,
} from '@mantine/core';
import {
IconChevronRight,
IconLifebuoy,
IconShip,
IconUserPlus,
} from '@tabler/icons-react';
import type { Icon } from '@tabler/icons-react';
import { useNavigate } from 'react-router-dom';
export function DashboardPage() {
const navigate = useNavigate();
const theme = useMantineTheme();
return (
<Stack gap="lg">
<Title order={2}>My Dashboard</Title>
<Paper p="md" shadow="sm" radius="md" withBorder>
<Text c="dimmed">
Welcome to the EMA Portal. Your content will appear here.
</Text>
{/* ---- Hero banner ------------------------------------------- */}
<Paper
radius="lg"
p="xl"
style={{ background: theme.other.heroGradient as string, overflow: 'hidden' }}
>
<Group justify="space-between" wrap="nowrap" align="center">
<Stack gap="md" maw={560}>
<Stack gap={6}>
<Title order={2} c="white" fz={26}>
Welcome to the EMA Portal
</Title>
<Text style={{ color: 'rgba(255,255,255,0.85)' }} lh={1.55}>
Manage your seafarer profile, submit applications, and track your
maritime credentials all in one place.
</Text>
</Stack>
</Stack>
<Center
visibleFrom="sm"
w={120}
h={120}
style={{ borderRadius: '50%', background: 'rgba(255,255,255,0.15)', flexShrink: 0 }}
>
<IconShip size={62} color="white" stroke={1.4} />
</Center>
</Group>
</Paper>
{/* ---- Quick actions ----------------------------------------- */}
<SimpleGrid cols={{ base: 1, sm: 2 }} spacing="lg">
<Paper withBorder radius="lg" p="lg">
<Title order={4} mb="md">
Quick actions
</Title>
<Stack gap="xs">
<QuickAction
icon={IconUserPlus}
color="emaPrimary"
label="Register a new seafarer"
onClick={() => navigate('/seafarer-registration')}
/>
<QuickAction
icon={IconLifebuoy}
color="orange"
label="Contact support"
onClick={() => navigate('/support')}
/>
</Stack>
</Paper>
</SimpleGrid>
</Stack>
);
}
function QuickAction({
icon: ActionIconCmp,
color,
label,
onClick,
}: {
icon: Icon;
color: string;
label: string;
onClick: () => void;
}) {
return (
<UnstyledButton onClick={onClick}>
<Card padding="xs" radius="md" bg="var(--mantine-color-default-hover)">
<Group gap="sm" wrap="nowrap">
<ThemeIcon variant="light" color={color} size={38} radius="md">
<ActionIconCmp size={19} />
</ThemeIcon>
<Text fz="sm" fw={500} flex={1}>
{label}
</Text>
<IconChevronRight size={16} style={{ opacity: 0.45 }} />
</Group>
</Card>
</UnstyledButton>
);
}

View File

@@ -0,0 +1,19 @@
import { baseApi } from '@ema-platform/api';
import type { Location, LocationType, ListResponse } from '../types/location';
const locationApi = baseApi.injectEndpoints({
endpoints: (builder) => ({
getLocationTypes: builder.query<ListResponse<LocationType>, void>({
query: () => '/location-types',
}),
getLocations: builder.query<
ListResponse<Location>,
{ take?: number }
>({
query: (params) => ({ url: '/locations', params }),
}),
}),
overrideExisting: false,
});
export const { useGetLocationTypesQuery, useGetLocationsQuery } = locationApi;

View File

@@ -0,0 +1,225 @@
import { useState, useMemo, useEffect, useCallback } from 'react';
import { Stack, Select, Group, Text, Loader, Center, Badge } from '@mantine/core';
import { useGetLocationTypesQuery, useGetLocationsQuery } from '../api/location-api';
import type { Location, LocationType } from '../types/location';
import { useTranslation } from 'react-i18next';
interface LocationPickerProps {
value?: string;
onChange: (locationId: string | null) => void;
required?: boolean;
}
export function LocationPicker({ value, onChange, required }: LocationPickerProps) {
const { t } = useTranslation();
const { data: typesRes, isLoading: typesLoading } = useGetLocationTypesQuery();
const { data: locsRes, isLoading: locsLoading } = useGetLocationsQuery({ take: 10000 });
const locationTypes = typesRes?.items ?? [];
const allLocations = locsRes?.items ?? [];
const locMap = useMemo(() => {
const map = new Map<string, Location>();
allLocations.forEach((loc) => map.set(loc.id, loc));
return map;
}, [allLocations]);
const typeMap = useMemo(() => {
const map = new Map<string, LocationType>();
locationTypes.forEach((t) => map.set(t.id, t));
return map;
}, [locationTypes]);
const childrenByParentId = useMemo(() => {
const map = new Map<string, Location[]>();
allLocations.forEach((loc) => {
const key = loc.parentId ?? '__root__';
if (!map.has(key)) map.set(key, []);
map.get(key)!.push(loc);
});
return map;
}, [allLocations]);
const [selectedChain, setSelectedChain] = useState<Location[]>([]);
useEffect(() => {
if (!value || locMap.size === 0) return;
const loc = locMap.get(value);
if (!loc) return;
const chain: Location[] = [];
let current: Location | undefined = loc;
while (current) {
chain.unshift(current);
current = current.parentId ? locMap.get(current.parentId) : undefined;
}
setSelectedChain(chain);
}, [value, locMap]);
const currentLevelChildren = useMemo(() => {
const parentId =
selectedChain.length === 0
? null
: selectedChain[selectedChain.length - 1].id;
const key = parentId ?? '__root__';
return childrenByParentId.get(key) ?? [];
}, [selectedChain, childrenByParentId]);
const levelLabel = useMemo(() => {
if (currentLevelChildren.length === 0) return '';
const typeIds = [...new Set(currentLevelChildren.map((c) => c.locationTypeId))];
const names = typeIds
.map((id) => typeMap.get(id)?.names.en)
.filter(Boolean) as string[];
return names.join(' / ');
}, [currentLevelChildren, typeMap]);
const depth = selectedChain.length;
const allLevelsComplete = useMemo(() => {
return selectedChain.every((loc, i) => {
const key = loc.id;
const children = childrenByParentId.get(key);
return !children || children.length === 0;
});
}, [selectedChain, childrenByParentId]);
const handleSelect = useCallback(
(id: string | null) => {
if (!id) {
const newChain = selectedChain.slice(0, -1);
setSelectedChain(newChain);
onChange(newChain.length > 0 ? newChain[newChain.length - 1].id : null);
return;
}
const loc = locMap.get(id);
if (!loc) return;
const newChain = selectedChain.slice(0, depth);
newChain.push(loc);
setSelectedChain(newChain);
onChange(id);
},
[selectedChain, locMap, onChange, depth],
);
const buildOptions = (levelIdx: number) => {
if (levelIdx === 0) {
const roots = childrenByParentId.get('__root__') ?? [];
return roots
.map((loc) => ({ value: loc.id, label: loc.names.en }))
.sort((a, b) => a.label.localeCompare(b.label));
}
const parent = selectedChain[levelIdx - 1];
if (!parent) return [];
const children = childrenByParentId.get(parent.id) ?? [];
return children
.map((loc) => ({ value: loc.id, label: loc.names.en }))
.sort((a, b) => a.label.localeCompare(b.label));
};
const getLevelLabel = (levelIdx: number) => {
if (levelIdx === 0) {
const roots = childrenByParentId.get('__root__') ?? [];
if (roots.length === 0) return '';
const typeIds = [...new Set(roots.map((r) => r.locationTypeId))];
const names = typeIds
.map((id) => typeMap.get(id)?.names.en)
.filter(Boolean) as string[];
return names.join(' / ');
}
const parent = selectedChain[levelIdx - 1];
if (!parent) return t('location.chooseFirst');
const children = childrenByParentId.get(parent.id) ?? [];
const typeIds = [...new Set(children.map((c) => c.locationTypeId))];
const names = typeIds
.map((id) => typeMap.get(id)?.names.en)
.filter(Boolean) as string[];
return names.join(' / ') || t('location.subLocation');
};
const selectedPath = useMemo(() => {
return selectedChain
.map((loc) => loc.names.en)
.join(' → ');
}, [selectedChain]);
if (typesLoading || locsLoading) {
return (
<Center py="md">
<Loader size="sm" />
</Center>
);
}
const roots = childrenByParentId.get('__root__') ?? [];
if (roots.length === 0) {
return (
<Text size="sm" c="dimmed">
{t('location.noLocationsAvailable')}
</Text>
);
}
const totalRenderedLevels = Math.max(
1,
selectedChain.length + (currentLevelChildren.length > 0 ? 1 : 0),
);
const levels = Array.from({ length: totalRenderedLevels }, (_, i) => i);
return (
<Stack gap="sm">
<Group gap="xs" align="end" wrap="wrap">
{levels.map((levelIdx) => {
const options = buildOptions(levelIdx);
const currentValue = selectedChain[levelIdx]?.id ?? null;
const isDisabled = levelIdx > 0 && !selectedChain[levelIdx - 1];
return (
<Select
key={levelIdx}
label={getLevelLabel(levelIdx) || `Level ${levelIdx + 1}`}
placeholder={t('location.select')}
data={options}
value={currentValue}
onChange={(val) => handleSelect(val)}
disabled={isDisabled}
searchable
clearable
size="sm"
style={{ minWidth: 160, flex: 1 }}
nothingFoundMessage={t('location.noOptions')}
required={required && levelIdx === levels.length - 1}
/>
);
})}
</Group>
{selectedPath && (
<Group gap="xs">
{selectedChain.map((loc) => {
const typeInfo = typeMap.get(loc.locationTypeId);
return (
<Badge
key={loc.id}
size="sm"
variant="light"
color="blue"
style={{ textTransform: 'none' }}
>
{typeInfo ? `${typeInfo.names.en}: ` : ''}
{loc.names.en}
</Badge>
);
})}
</Group>
)}
</Stack>
);
}

View File

@@ -0,0 +1,24 @@
export interface NamePair {
en: string;
am: string;
}
export interface LocationType {
id: string;
code: string;
names: NamePair;
level: number;
}
export interface Location {
id: string;
code: string;
names: NamePair;
locationTypeId: string;
parentId: string | null;
}
export interface ListResponse<T> {
count: number;
items: T[];
}

View File

@@ -0,0 +1,56 @@
/* Segmented "pill" tab bar — light gray track with a white active pill. */
.list {
display: inline-flex;
gap: 6px;
padding: 5px;
background: var(--mantine-color-gray-1);
border-radius: var(--mantine-radius-md);
border: none;
flex-wrap: wrap;
}
.tab {
border: none;
border-radius: 10px;
padding: 9px 18px;
font-weight: 500;
color: var(--mantine-color-gray-7);
background: transparent;
transition:
background-color 120ms ease,
color 120ms ease,
box-shadow 120ms ease;
}
.tab:hover {
background: transparent;
color: var(--mantine-color-gray-9);
}
.tab[data-active],
.tab[data-active]:hover {
background: var(--mantine-color-body);
color: var(--mantine-color-emaPrimary-7);
font-weight: 600;
box-shadow: var(--mantine-shadow-xs);
}
/* Selectable option card (language + appearance). */
.choice {
border: 1px solid var(--mantine-color-gray-3);
border-radius: var(--mantine-radius-md);
background: var(--mantine-color-body);
transition:
border-color 120ms ease,
background-color 120ms ease;
}
.choice:hover {
border-color: var(--mantine-color-gray-4);
}
.choiceActive,
.choiceActive:hover {
border-color: var(--mantine-color-emaPrimary-6);
background: var(--mantine-color-emaPrimary-0);
}

View File

@@ -0,0 +1,607 @@
import { useEffect, useState } from 'react';
import {
Badge,
Box,
Button,
Divider,
Group,
Paper,
PasswordInput,
SimpleGrid,
Stack,
Switch,
Tabs,
Text,
TextInput,
Title,
UnstyledButton,
useMantineColorScheme,
type MantineColorScheme,
} from '@mantine/core';
import {
IconAt,
IconBell,
IconCheck,
IconCircle,
IconCircleCheckFilled,
IconDeviceDesktop,
IconDeviceFloppy,
IconLock,
IconMail,
IconMoon,
IconPhone,
IconSettings,
IconShieldLock,
IconSun,
IconUser,
IconUserCircle,
} from '@tabler/icons-react';
import { useForm } from 'react-hook-form';
import { zodResolver } from '@hookform/resolvers/zod';
import { z } from 'zod';
import { useTranslation } from 'react-i18next';
import { notify, PageHeader } from '@ema-platform/ui';
import { useApiMutation } from '@ema-platform/api';
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 classes from './ProfilePage.module.css';
function getInitials(name: string, fallback: string) {
const source = name?.trim() || fallback?.trim() || '';
if (!source) return '?';
const parts = source.split(/\s+/);
const letters = parts.length > 1 ? parts[0][0] + parts[1][0] : source.slice(0, 2);
return letters.toUpperCase();
}
/** 04 rough strength score used by the meter on the security tab. */
function passwordScore(pw: string) {
if (!pw) return 0;
let score = 0;
if (pw.length >= 8) score++;
if (/[a-z]/.test(pw) && /[A-Z]/.test(pw)) score++;
if (/\d/.test(pw)) score++;
if (/[^A-Za-z0-9]/.test(pw)) score++;
return score;
}
export function ProfilePage() {
const { t, i18n } = useTranslation();
const dispatch = useAppDispatch();
const user = useAppSelector((state) => state.auth.user);
const { colorScheme, setColorScheme } = useMantineColorScheme();
const [updateTrigger] = useApiMutation<AuthUser>();
const [meTrigger] = useApiMutation<AuthUser>();
const [passwordTrigger] = useApiMutation<unknown>();
const [isSavingProfile, setIsSavingProfile] = useState(false);
const [isSavingPassword, setIsSavingPassword] = 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).
useEffect(() => {
let active = true;
meTrigger({ url: '/auth/me', method: 'GET' })
.unwrap()
.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.
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
// ---- Profile form ----
const profileSchema = 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') }),
email: z.string().email({ message: t('profile.validation.emailInvalid') }),
phoneNumber: z
.string()
.min(1, { message: t('profile.validation.phoneRequired') }),
});
type ProfileValues = z.infer<typeof profileSchema>;
const {
register: registerProfile,
handleSubmit: handleProfileSubmit,
reset: resetProfile,
formState: { errors: profileErrors },
} = useForm<ProfileValues>({
resolver: zodResolver(profileSchema),
values: {
nameEn: user?.name?.en ?? '',
nameAm: user?.name?.am ?? '',
username: user?.username ?? '',
email: user?.email ?? '',
phoneNumber: user?.phoneNumber ?? '',
},
});
const onSaveProfile = async (values: ProfileValues) => {
setIsSavingProfile(true);
try {
await updateTrigger({
url: '/auth/update-profile',
method: 'PATCH',
body: {
email: values.email,
username: values.username,
phoneNumber: values.phoneNumber,
name: { am: values.nameAm, en: values.nameEn },
},
}).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));
notify.success(t('profile.profileUpdated'));
} catch {
notify.error(t('profile.updateFailed'));
} finally {
setIsSavingProfile(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') }),
})
.refine((data) => data.newPassword === data.confirmPassword, {
message: t('profile.validation.passwordMismatch'),
path: ['confirmPassword'],
});
type PasswordValues = z.infer<typeof passwordSchema>;
const {
register: registerPassword,
handleSubmit: handlePasswordSubmit,
reset: resetPassword,
watch: watchPassword,
formState: { errors: passwordErrors },
} = useForm<PasswordValues>({
resolver: zodResolver(passwordSchema),
defaultValues: { oldPassword: '', newPassword: '', confirmPassword: '' },
});
const onChangePassword = async (values: PasswordValues) => {
setIsSavingPassword(true);
try {
await passwordTrigger({
url: '/auth/change-password',
method: 'PATCH',
body: {
oldPassword: values.oldPassword,
newPassword: values.newPassword,
confirmPassword: values.confirmPassword,
},
}).unwrap();
notify.success(t('profile.passwordChanged'));
resetPassword();
} catch {
notify.error(t('profile.passwordFailed'));
} finally {
setIsSavingPassword(false);
}
};
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'),
];
const strengthColors = ['gray', 'red', 'orange', 'emaPrimary', 'emaTeal'];
const flags: Record<AppLanguage, string> = { en: '🇬🇧', am: '🇪🇹' };
// Mantine uses 'auto' for the system option.
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 },
];
return (
<Stack gap="lg" maw={900}>
<PageHeader title={t('profile.title')} subtitle={t('profile.subtitle')} />
{/* Profile summary */}
<Paper p="lg" shadow="sm" radius="lg" withBorder>
<Group align="center" wrap="nowrap">
<Box
w={64}
h={64}
style={{
flexShrink: 0,
borderRadius: '50%',
backgroundImage: 'linear-gradient(135deg, #3b6ccc 0%, #1fc29d 100%)',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
}}
>
<Text fw={700} size="xl" c="white">
{getInitials(displayName, user?.email ?? '')}
</Text>
</Box>
<div>
<Group gap="xs" align="center">
<Title order={4}>{displayName || '—'}</Title>
<Badge
variant="light"
color={user?.isPhoneNumberVerified ? 'emaTeal' : 'gray'}
size="sm"
>
{user?.isPhoneNumberVerified
? t('profile.verified')
: t('profile.unverified')}
</Badge>
</Group>
<Group gap={6} mt={2} c="dimmed">
<IconMail size={14} />
<Text size="sm" c="dimmed">
{user?.email}
</Text>
</Group>
</div>
<Box style={{ flex: 1 }} />
{user?.username && (
<Badge
visibleFrom="xs"
variant="default"
size="lg"
radius="xl"
leftSection={<IconAt size={13} />}
>
{user.username}
</Badge>
)}
</Group>
</Paper>
{/* Tabs */}
<Tabs
defaultValue="profile"
variant="pills"
classNames={{ list: classes.list, tab: classes.tab }}
>
<Tabs.List>
<Tabs.Tab value="profile" leftSection={<IconUserCircle size={18} />}>
{t('profile.tabs.profile')}
</Tabs.Tab>
<Tabs.Tab value="security" leftSection={<IconShieldLock size={18} />}>
{t('profile.tabs.security')}
</Tabs.Tab>
<Tabs.Tab value="preferences" leftSection={<IconSettings size={18} />}>
{t('profile.tabs.preferences')}
</Tabs.Tab>
</Tabs.List>
{/* ---- Profile ---- */}
<Tabs.Panel value="profile" pt="md">
<Paper p="xl" shadow="sm" radius="lg" withBorder>
<form onSubmit={handleProfileSubmit(onSaveProfile)}>
<Stack gap="xl">
<div>
<Title order={5}>{t('profile.personal')}</Title>
<Text size="sm" c="dimmed" mb="md">
{t('profile.personalHint')}
</Text>
<SimpleGrid cols={{ base: 1, sm: 2 }} spacing="md">
<TextInput
label={t('profile.fields.fullNameEn')}
leftSection={<IconUser size={18} />}
error={profileErrors.nameEn?.message}
{...registerProfile('nameEn')}
/>
<TextInput
label={t('profile.fields.fullNameAm')}
leftSection={<IconUser size={18} />}
error={profileErrors.nameAm?.message}
{...registerProfile('nameAm')}
/>
<TextInput
label={t('profile.fields.username')}
description={t('profile.fields.usernameHint')}
readOnly
variant="filled"
leftSection={<IconAt size={18} />}
error={profileErrors.username?.message}
{...registerProfile('username')}
/>
</SimpleGrid>
</div>
<Divider />
<div>
<Title order={5} mb="md">
{t('profile.contact')}
</Title>
<SimpleGrid cols={{ base: 1, sm: 2 }} spacing="md">
<TextInput
label={t('profile.fields.email')}
leftSection={<IconMail size={18} />}
error={profileErrors.email?.message}
{...registerProfile('email')}
/>
<TextInput
label={t('profile.fields.phone')}
leftSection={<IconPhone size={18} />}
error={profileErrors.phoneNumber?.message}
{...registerProfile('phoneNumber')}
/>
</SimpleGrid>
</div>
<Group justify="flex-end">
<Button
type="button"
variant="default"
onClick={() => resetProfile()}
>
{t('profile.cancel')}
</Button>
<Button
type="submit"
loading={isSavingProfile}
leftSection={<IconDeviceFloppy size={18} />}
>
{t('profile.updateProfile')}
</Button>
</Group>
</Stack>
</form>
</Paper>
</Tabs.Panel>
{/* ---- Security ---- */}
<Tabs.Panel value="security" pt="md">
<Paper p="xl" shadow="sm" radius="lg" withBorder>
<form onSubmit={handlePasswordSubmit(onChangePassword)}>
<Stack gap="xl">
<div>
<Title order={5}>{t('profile.security')}</Title>
<Text size="sm" c="dimmed" mb="md">
{t('profile.securityHint')}
</Text>
<Stack gap="md">
<PasswordInput
maw={360}
label={t('profile.fields.currentPassword')}
leftSection={<IconLock size={18} />}
error={passwordErrors.oldPassword?.message}
{...registerPassword('oldPassword')}
/>
<SimpleGrid cols={{ base: 1, sm: 2 }} spacing="md">
<PasswordInput
label={t('profile.fields.newPassword')}
leftSection={<IconLock size={18} />}
error={passwordErrors.newPassword?.message}
{...registerPassword('newPassword')}
/>
<PasswordInput
label={t('profile.fields.confirmPassword')}
leftSection={<IconLock size={18} />}
error={passwordErrors.confirmPassword?.message}
{...registerPassword('confirmPassword')}
/>
</SimpleGrid>
{score > 0 && (
<Stack gap={6}>
<Group justify="space-between">
<Text size="xs" c="dimmed" fw={600}>
{t('profile.strength.label')}
</Text>
<Text size="xs" fw={600} c={strengthColors[score]}>
{strengthLabels[score]}
</Text>
</Group>
<Group gap={6} grow>
{[1, 2, 3, 4].map((i) => (
<Box
key={i}
h={6}
style={{
borderRadius: 999,
backgroundColor:
i <= score
? `var(--mantine-color-${strengthColors[score]}-6)`
: 'var(--mantine-color-gray-2)',
}}
/>
))}
</Group>
</Stack>
)}
</Stack>
</div>
<Divider />
<Group align="flex-start" justify="space-between" wrap="nowrap">
<div>
<Text fw={600}>{t('profile.twoStep.title')}</Text>
<Text size="sm" c="dimmed">
{t('profile.twoStep.desc')}
</Text>
</div>
<Switch
checked={twoStepEnabled}
onChange={(e) => setTwoStepEnabled(e.currentTarget.checked)}
/>
</Group>
<Group justify="flex-end">
<Button
type="submit"
loading={isSavingPassword}
leftSection={<IconShieldLock size={18} />}
>
{t('profile.updatePassword')}
</Button>
</Group>
</Stack>
</form>
</Paper>
</Tabs.Panel>
{/* ---- Preferences ---- */}
<Tabs.Panel value="preferences" pt="md">
<Paper p="xl" shadow="sm" radius="lg" withBorder>
<Stack gap="xl">
<div>
<Title order={5}>{t('profile.languageTitle')}</Title>
<Text size="sm" c="dimmed" mb="md">
{t('profile.languageHint')}
</Text>
<SimpleGrid cols={{ base: 1, sm: 2 }} spacing="md">
{SUPPORTED_LANGUAGES.map((lng) => {
const active = i18n.language === lng;
return (
<UnstyledButton
key={lng}
onClick={() => i18n.changeLanguage(lng)}
className={`${classes.choice} ${active ? classes.choiceActive : ''}`}
p="md"
>
<Group wrap="nowrap">
<Text fz={22}>{flags[lng]}</Text>
<div style={{ flex: 1 }}>
<Text fw={600} size="sm">
{t(`language.${lng}`)}
</Text>
<Text size="xs" c="dimmed">
{lng === 'en' ? 'English (United States)' : 'Amharic'}
</Text>
</div>
{active ? (
<IconCircleCheckFilled
size={20}
color="var(--mantine-color-emaPrimary-6)"
/>
) : (
<IconCircle
size={20}
color="var(--mantine-color-gray-4)"
/>
)}
</Group>
</UnstyledButton>
);
})}
</SimpleGrid>
</div>
<Divider />
<div>
<Title order={5}>{t('profile.appearance.title')}</Title>
<Text size="sm" c="dimmed" mb="md">
{t('profile.appearance.subtitle')}
</Text>
<SimpleGrid cols={{ base: 1, sm: 3 }} spacing="md">
{appearanceOptions.map(({ value, label, icon: Icon }) => {
const active = colorScheme === value;
return (
<UnstyledButton
key={value}
onClick={() => setColorScheme(value)}
className={`${classes.choice} ${active ? classes.choiceActive : ''}`}
p="md"
>
<Group wrap="nowrap">
<Icon
size={20}
color={
active
? 'var(--mantine-color-emaPrimary-6)'
: 'var(--mantine-color-gray-6)'
}
/>
<Text fw={600} size="sm" style={{ flex: 1 }}>
{label}
</Text>
{active && (
<IconCircleCheckFilled
size={18}
color="var(--mantine-color-emaPrimary-6)"
/>
)}
</Group>
</UnstyledButton>
);
})}
</SimpleGrid>
</div>
<Divider />
<Group align="flex-start" justify="space-between" wrap="nowrap">
<Group gap="sm" wrap="nowrap">
<IconBell size={20} color="var(--mantine-color-gray-6)" />
<div>
<Text fw={600}>{t('profile.notifications.title')}</Text>
<Text size="sm" c="dimmed">
{t('profile.notifications.desc')}
</Text>
</div>
</Group>
<Switch
checked={emailNotifications}
onChange={(e) => setEmailNotifications(e.currentTarget.checked)}
/>
</Group>
<Group justify="flex-end">
<Button
leftSection={<IconCheck size={18} />}
onClick={() => notify.success(t('profile.profileUpdated'))}
>
{t('profile.savePreferences')}
</Button>
</Group>
</Stack>
</Paper>
</Tabs.Panel>
</Tabs>
</Stack>
);
}

View File

@@ -0,0 +1,701 @@
import { useEffect, useState } from 'react';
import {
ActionIcon,
Alert,
Avatar,
Badge,
Box,
Button,
Card,
Divider,
Group,
Modal,
Paper,
Select,
SimpleGrid,
Skeleton,
Stack,
Table,
Tabs,
Text,
TextInput,
Textarea,
ThemeIcon,
Title,
rem,
} from '@mantine/core';
import { useDisclosure } from '@mantine/hooks';
import {
IconAnchor,
IconArrowLeft,
IconBook,
IconBriefcase,
IconCertificate,
IconCheck,
IconClock,
IconEdit,
IconFileText,
IconHeartbeat,
IconHistory,
IconLayoutDashboard,
IconPlus,
IconPrinter,
IconShip,
IconUser,
IconX,
} from '@tabler/icons-react';
import { useNavigate, useParams } from 'react-router-dom';
import { notify } from '@ema-platform/ui';
import type { Seafarer } from './SeafarerRegistryPage';
// ---------------------------------------------------------------------------
// Extended profile types
// ---------------------------------------------------------------------------
interface TrainingRecord {
id: string;
course: string;
institution: string;
certNo: string;
issueDate: string;
expiry: string;
status: 'Approved' | 'Pending' | 'Expired';
}
interface MedicalRecord {
id: string;
examType: string;
issuedBy: string;
issueDate: string;
expiry: string;
result: 'Fit' | 'Unfit' | 'Conditional';
remarks: string;
}
interface SeaServiceRecord {
id: string;
vesselName: string;
vesselType: string;
rank: string;
flag: string;
from: string;
to: string;
engagementPort: string;
}
interface CertificationRecord {
id: string;
name: string;
certNo: string;
issuedBy: string;
issueDate: string;
expiry: string;
type: string;
status: 'Valid' | 'Expired' | 'Pending';
}
interface HistoryEntry {
id: string;
action: string;
performedBy: string;
date: string;
notes: string;
}
interface SeafarerProfile extends Seafarer {
dob: string;
nationalId: string;
passportNo: string;
bookNumber: string;
permanentAddress: string;
training: TrainingRecord[];
medical: MedicalRecord[];
seaService: SeaServiceRecord[];
certifications: CertificationRecord[];
history: HistoryEntry[];
}
// ---------------------------------------------------------------------------
// Dummy API — replace bodies with real fetch calls
// ---------------------------------------------------------------------------
async function fetchSeafarerProfile(id: string): Promise<SeafarerProfile> {
await new Promise((r) => setTimeout(r, 800));
return {
id,
seafarerId: 'SF-2024-0001',
firstName: 'Abebe',
lastName: 'Girma',
email: 'abebe.g@email.com',
gender: 'Male',
nationality: 'Ethiopian',
mobile: '+251 911 234 567',
region: 'Addis Ababa',
registeredAt: '2024-01-10',
medicalStatus: 'Fit',
bookStatus: 'Active',
status: 'Active',
dob: '1988-03-15',
nationalId: 'ET-1234567',
passportNo: 'EP123456',
bookNumber: 'SB-2024-0001',
permanentAddress: 'Bole Sub-City, Woreda 03, House No. 456, Addis Ababa',
training: [
{ id: '1', course: 'Personal Survival Techniques', institution: 'Ethiopian Maritime Institute', certNo: 'PST-2023-0456', issueDate: '2023-01-10', expiry: '2028-01-14', status: 'Approved' },
{ id: '2', course: 'Fire Prevention and Fire Fighting', institution: 'Djibouti Maritime Academy', certNo: 'FFF-2023-0789', issueDate: '2023-03-05', expiry: '2028-03-07', status: 'Approved' },
{ id: '3', course: 'Elementary First Aid', institution: 'Ethiopian Maritime Institute', certNo: 'EFA-2023-0102', issueDate: '2023-01-10', expiry: '2028-01-10', status: 'Approved' },
],
medical: [
{ id: '1', examType: 'STCW Medical Certificate', issuedBy: 'EMA Medical Center', issueDate: '2023-06-15', expiry: '2025-06-15', result: 'Fit', remarks: 'No medical conditions noted.' },
{ id: '2', examType: 'Pre-Employment Medical', issuedBy: 'Addis Ababa General Hospital', issueDate: '2022-01-10', expiry: '2024-01-10', result: 'Fit', remarks: 'All tests within normal range.' },
],
seaService: [
{ id: '1', vesselName: 'MV Ethiopian Star', vesselType: 'Bulk Carrier', rank: 'Ordinary Seaman', flag: 'Ethiopia', from: '2022-03-01', to: '2023-02-28', engagementPort: 'Djibouti' },
{ id: '2', vesselName: 'MV Red Sea Express', vesselType: 'Container Ship', rank: 'Able Seaman', flag: 'Djibouti', from: '2023-04-01', to: '2024-03-31', engagementPort: 'Berbera' },
],
certifications: [
{ id: '1', name: 'STCW Basic Safety Training', certNo: 'BST-2023-0001', issuedBy: 'Ethiopian Maritime Authority', issueDate: '2023-01-15', expiry: '2028-01-15', type: 'STCW', status: 'Valid' },
{ id: '2', name: 'Certificate of Competency — Deck Rating', certNo: 'COC-2023-0234', issuedBy: 'Ethiopian Maritime Authority', issueDate: '2023-07-01', expiry: '2028-07-01', type: 'COC', status: 'Valid' },
],
history: [
{ id: '1', action: 'Profile Created', performedBy: 'System', date: '2024-01-10', notes: 'Initial registration submitted.' },
{ id: '2', action: 'Status → Active', performedBy: 'Admin Officer', date: '2024-01-15', notes: 'All documents verified and approved.' },
{ id: '3', action: 'Training Record Added', performedBy: 'Abebe Girma', date: '2024-02-20', notes: 'PST certificate uploaded.' },
],
};
}
async function updateSeafarerStatus(_id: string, _status: string): Promise<void> {
await new Promise((r) => setTimeout(r, 600));
}
// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------
const STATUS_COLOR: Record<string, string> = {
Active: 'teal', Pending: 'yellow', Suspended: 'red',
Approved: 'teal', Expired: 'red', Valid: 'teal',
Fit: 'teal', Unfit: 'red', Conditional: 'orange',
};
function Chip({ value }: { value: string }) {
return <Badge color={STATUS_COLOR[value] ?? 'gray'} variant="light" radius="sm" size="sm">{value}</Badge>;
}
function InfoField({ label, value }: { label: string; value: string }) {
return (
<div>
<Text fz={10} fw={700} tt="uppercase" c="dimmed" lh={1.2} mb={3}>{label}</Text>
<Text fz="sm" fw={500}>{value || '—'}</Text>
</div>
);
}
function SectionCard({ title, children, action }: { title: string; children: React.ReactNode; action?: React.ReactNode }) {
return (
<Paper withBorder radius="md" p="md">
<Group justify="space-between" mb="sm">
<Text fw={700} fz="sm">{title}</Text>
{action}
</Group>
<Divider mb="md" />
{children}
</Paper>
);
}
// ---------------------------------------------------------------------------
// Tab: Overview
// ---------------------------------------------------------------------------
function OverviewTab({ profile, onStatusChange }: { profile: SeafarerProfile; onStatusChange: (s: 'Active' | 'Suspended') => void }) {
return (
<SimpleGrid cols={{ base: 1, md: 2 }} spacing="md">
<SectionCard title="Personal Information">
<SimpleGrid cols={3} spacing="md">
<InfoField label="Seafarer ID" value={profile.seafarerId} />
<InfoField label="First Name" value={profile.firstName} />
<InfoField label="Last Name" value={profile.lastName} />
<InfoField label="Gender" value={profile.gender} />
<InfoField label="Date of Birth" value={profile.dob} />
<InfoField label="Nationality" value={profile.nationality} />
<InfoField label="National ID" value={profile.nationalId} />
<InfoField label="Passport No." value={profile.passportNo} />
</SimpleGrid>
</SectionCard>
<SectionCard title="Contact & Status">
<SimpleGrid cols={3} spacing="md" mb="md">
<InfoField label="Mobile" value={profile.mobile} />
<InfoField label="Email" value={profile.email} />
<InfoField label="Region" value={profile.region} />
<div>
<Text fz={10} fw={700} tt="uppercase" c="dimmed" lh={1.2} mb={3}>Reg. Status</Text>
<Chip value={profile.status} />
</div>
<div>
<Text fz={10} fw={700} tt="uppercase" c="dimmed" lh={1.2} mb={3}>Medical Status</Text>
<Chip value={profile.medicalStatus} />
</div>
<InfoField label="Book Number" value={profile.bookNumber} />
<div>
<Text fz={10} fw={700} tt="uppercase" c="dimmed" lh={1.2} mb={3}>Book Status</Text>
<Chip value={profile.bookStatus} />
</div>
</SimpleGrid>
<Divider mb="md" />
<Group gap="xs">
{profile.status !== 'Active' && (
<Button size="xs" color="teal" leftSection={<IconCheck size={13} />} onClick={() => onStatusChange('Active')}>
Approve
</Button>
)}
{profile.status !== 'Suspended' && (
<Button size="xs" color="red" variant="light" leftSection={<IconX size={13} />} onClick={() => onStatusChange('Suspended')}>
Suspend
</Button>
)}
<Button size="xs" variant="default" leftSection={<IconFileText size={13} />} onClick={() => notify.info('Documents — coming soon.')}>
Documents
</Button>
</Group>
</SectionCard>
<Paper withBorder radius="md" p="md">
<Text fw={700} fz="sm" mb="xs">Permanent Address</Text>
<Text fz="sm" c="dimmed">{profile.permanentAddress || '—'}</Text>
</Paper>
</SimpleGrid>
);
}
// ---------------------------------------------------------------------------
// Tab: Training
// ---------------------------------------------------------------------------
function TrainingTab({ records, onAdd }: { records: TrainingRecord[]; onAdd: () => void }) {
return (
<Paper withBorder radius="md">
<Group justify="space-between" p="md">
<Text fw={700} fz="sm">Training Records</Text>
<Button size="xs" leftSection={<IconPlus size={13} />} onClick={onAdd}>+ Add Training</Button>
</Group>
<Divider />
<Table highlightOnHover verticalSpacing="sm" fz="sm">
<Table.Thead bg="var(--mantine-color-default-hover)">
<Table.Tr>
{['Course', 'Institution', 'Cert. No.', 'Issue Date', 'Expiry', 'Status', 'Actions'].map((h) => (
<Table.Th key={h} style={{ fontSize: rem(11), textTransform: 'uppercase', letterSpacing: '0.04em', color: 'var(--mantine-color-dimmed)' }}>{h}</Table.Th>
))}
</Table.Tr>
</Table.Thead>
<Table.Tbody>
{records.map((r) => (
<Table.Tr key={r.id}>
<Table.Td><Text fw={500} fz="sm">{r.course}</Text></Table.Td>
<Table.Td>{r.institution}</Table.Td>
<Table.Td><Text fz="sm" c="dimmed">{r.certNo}</Text></Table.Td>
<Table.Td>{r.issueDate}</Table.Td>
<Table.Td>{r.expiry}</Table.Td>
<Table.Td><Chip value={r.status} /></Table.Td>
<Table.Td>
<Button size="xs" variant="subtle" onClick={() => notify.info('View training — coming soon.')}>View</Button>
</Table.Td>
</Table.Tr>
))}
</Table.Tbody>
</Table>
{records.length === 0 && <Text ta="center" c="dimmed" fz="sm" py="xl">No training records found.</Text>}
</Paper>
);
}
// ---------------------------------------------------------------------------
// Tab: Medical
// ---------------------------------------------------------------------------
function MedicalTab({ records, onAdd }: { records: MedicalRecord[]; onAdd: () => void }) {
return (
<Paper withBorder radius="md">
<Group justify="space-between" p="md">
<Text fw={700} fz="sm">Medical Records</Text>
<Button size="xs" leftSection={<IconPlus size={13} />} onClick={onAdd}>+ Add Record</Button>
</Group>
<Divider />
<Table highlightOnHover verticalSpacing="sm" fz="sm">
<Table.Thead bg="var(--mantine-color-default-hover)">
<Table.Tr>
{['Exam Type', 'Issued By', 'Issue Date', 'Expiry', 'Result', 'Remarks', 'Actions'].map((h) => (
<Table.Th key={h} style={{ fontSize: rem(11), textTransform: 'uppercase', letterSpacing: '0.04em', color: 'var(--mantine-color-dimmed)' }}>{h}</Table.Th>
))}
</Table.Tr>
</Table.Thead>
<Table.Tbody>
{records.map((r) => (
<Table.Tr key={r.id}>
<Table.Td><Text fw={500} fz="sm">{r.examType}</Text></Table.Td>
<Table.Td>{r.issuedBy}</Table.Td>
<Table.Td>{r.issueDate}</Table.Td>
<Table.Td>{r.expiry}</Table.Td>
<Table.Td><Chip value={r.result} /></Table.Td>
<Table.Td><Text fz="xs" c="dimmed" style={{ maxWidth: rem(180) }} lineClamp={1}>{r.remarks}</Text></Table.Td>
<Table.Td>
<Button size="xs" variant="subtle" onClick={() => notify.info('View medical record — coming soon.')}>View</Button>
</Table.Td>
</Table.Tr>
))}
</Table.Tbody>
</Table>
{records.length === 0 && <Text ta="center" c="dimmed" fz="sm" py="xl">No medical records found.</Text>}
</Paper>
);
}
// ---------------------------------------------------------------------------
// Tab: Sea Service
// ---------------------------------------------------------------------------
function SeaServiceTab({ records, onAdd }: { records: SeaServiceRecord[]; onAdd: () => void }) {
return (
<Paper withBorder radius="md">
<Group justify="space-between" p="md">
<Text fw={700} fz="sm">Sea Service Records</Text>
<Button size="xs" leftSection={<IconPlus size={13} />} onClick={onAdd}>+ Add Service</Button>
</Group>
<Divider />
<Table highlightOnHover verticalSpacing="sm" fz="sm">
<Table.Thead bg="var(--mantine-color-default-hover)">
<Table.Tr>
{['Vessel Name', 'Type', 'Rank', 'Flag', 'From', 'To', 'Engagement Port', 'Actions'].map((h) => (
<Table.Th key={h} style={{ fontSize: rem(11), textTransform: 'uppercase', letterSpacing: '0.04em', color: 'var(--mantine-color-dimmed)' }}>{h}</Table.Th>
))}
</Table.Tr>
</Table.Thead>
<Table.Tbody>
{records.map((r) => (
<Table.Tr key={r.id}>
<Table.Td><Text fw={500} fz="sm">{r.vesselName}</Text></Table.Td>
<Table.Td>{r.vesselType}</Table.Td>
<Table.Td>{r.rank}</Table.Td>
<Table.Td>{r.flag}</Table.Td>
<Table.Td>{r.from}</Table.Td>
<Table.Td>{r.to}</Table.Td>
<Table.Td>{r.engagementPort}</Table.Td>
<Table.Td>
<Button size="xs" variant="subtle" onClick={() => notify.info('View sea service — coming soon.')}>View</Button>
</Table.Td>
</Table.Tr>
))}
</Table.Tbody>
</Table>
{records.length === 0 && <Text ta="center" c="dimmed" fz="sm" py="xl">No sea service records found.</Text>}
</Paper>
);
}
// ---------------------------------------------------------------------------
// Tab: Certifications
// ---------------------------------------------------------------------------
function CertificationsTab({ records, onAdd }: { records: CertificationRecord[]; onAdd: () => void }) {
return (
<Paper withBorder radius="md">
<Group justify="space-between" p="md">
<Text fw={700} fz="sm">Certifications</Text>
<Button size="xs" leftSection={<IconPlus size={13} />} onClick={onAdd}>+ Add Certification</Button>
</Group>
<Divider />
<Table highlightOnHover verticalSpacing="sm" fz="sm">
<Table.Thead bg="var(--mantine-color-default-hover)">
<Table.Tr>
{['Certificate', 'Cert. No.', 'Type', 'Issued By', 'Issue Date', 'Expiry', 'Status', 'Actions'].map((h) => (
<Table.Th key={h} style={{ fontSize: rem(11), textTransform: 'uppercase', letterSpacing: '0.04em', color: 'var(--mantine-color-dimmed)' }}>{h}</Table.Th>
))}
</Table.Tr>
</Table.Thead>
<Table.Tbody>
{records.map((r) => (
<Table.Tr key={r.id}>
<Table.Td><Text fw={500} fz="sm">{r.name}</Text></Table.Td>
<Table.Td><Text fz="sm" c="dimmed">{r.certNo}</Text></Table.Td>
<Table.Td><Badge variant="outline" size="xs" radius="sm">{r.type}</Badge></Table.Td>
<Table.Td>{r.issuedBy}</Table.Td>
<Table.Td>{r.issueDate}</Table.Td>
<Table.Td>{r.expiry}</Table.Td>
<Table.Td><Chip value={r.status} /></Table.Td>
<Table.Td>
<Button size="xs" variant="subtle" onClick={() => notify.info('View certificate — coming soon.')}>View</Button>
</Table.Td>
</Table.Tr>
))}
</Table.Tbody>
</Table>
{records.length === 0 && <Text ta="center" c="dimmed" fz="sm" py="xl">No certifications found.</Text>}
</Paper>
);
}
// ---------------------------------------------------------------------------
// Tab: History
// ---------------------------------------------------------------------------
function HistoryTab({ entries }: { entries: HistoryEntry[] }) {
return (
<Paper withBorder radius="md" p="md">
<Text fw={700} fz="sm" mb="md">Activity History</Text>
<Divider mb="md" />
<Stack gap="sm">
{entries.map((e) => (
<Group key={e.id} gap="md" wrap="nowrap" align="flex-start">
<ThemeIcon variant="light" color="blue" size={32} radius="xl" style={{ flexShrink: 0, marginTop: 2 }}>
<IconClock size={15} />
</ThemeIcon>
<div style={{ flex: 1 }}>
<Group gap="xs" align="center">
<Text fz="sm" fw={600}>{e.action}</Text>
<Text fz="xs" c="dimmed">by {e.performedBy}</Text>
</Group>
<Text fz="xs" c="dimmed">{e.date}</Text>
{e.notes && <Text fz="xs" mt={2}>{e.notes}</Text>}
</div>
</Group>
))}
{entries.length === 0 && <Text ta="center" c="dimmed" fz="sm" py="xl">No history found.</Text>}
</Stack>
</Paper>
);
}
// ---------------------------------------------------------------------------
// Add Record Modal (generic)
// ---------------------------------------------------------------------------
function AddTrainingModal({ opened, onClose }: { opened: boolean; onClose: () => void }) {
return (
<Modal opened={opened} onClose={onClose} title="Add Training Record" size="lg">
<Stack gap="sm">
<TextInput label="Course Name" placeholder="e.g. Personal Survival Techniques" required />
<SimpleGrid cols={2} spacing="sm">
<TextInput label="Institution" placeholder="Training institution" />
<TextInput label="Certificate No." placeholder="CERT-0000" />
</SimpleGrid>
<SimpleGrid cols={2} spacing="sm">
<TextInput label="Issue Date" type="date" />
<TextInput label="Expiry Date" type="date" />
</SimpleGrid>
<Select label="Status" data={['Approved', 'Pending', 'Expired']} defaultValue="Pending" />
<Group justify="flex-end" mt="sm">
<Button variant="default" onClick={onClose}>Cancel</Button>
<Button onClick={() => { notify.success('Training record added (demo).'); onClose(); }}>Save</Button>
</Group>
</Stack>
</Modal>
);
}
function AddMedicalModal({ opened, onClose }: { opened: boolean; onClose: () => void }) {
return (
<Modal opened={opened} onClose={onClose} title="Add Medical Record" size="lg">
<Stack gap="sm">
<TextInput label="Exam Type" placeholder="e.g. STCW Medical Certificate" required />
<TextInput label="Issued By" placeholder="Issuing authority" />
<SimpleGrid cols={2} spacing="sm">
<TextInput label="Issue Date" type="date" />
<TextInput label="Expiry Date" type="date" />
</SimpleGrid>
<Select label="Result" data={['Fit', 'Unfit', 'Conditional']} defaultValue="Fit" />
<Textarea label="Remarks" placeholder="Any notes" autosize minRows={2} />
<Group justify="flex-end" mt="sm">
<Button variant="default" onClick={onClose}>Cancel</Button>
<Button onClick={() => { notify.success('Medical record added (demo).'); onClose(); }}>Save</Button>
</Group>
</Stack>
</Modal>
);
}
function AddSeaServiceModal({ opened, onClose }: { opened: boolean; onClose: () => void }) {
return (
<Modal opened={opened} onClose={onClose} title="Add Sea Service Record" size="lg">
<Stack gap="sm">
<SimpleGrid cols={2} spacing="sm">
<TextInput label="Vessel Name" placeholder="MV Name" required />
<TextInput label="Vessel Type" placeholder="e.g. Bulk Carrier" />
</SimpleGrid>
<SimpleGrid cols={2} spacing="sm">
<TextInput label="Rank" placeholder="e.g. Able Seaman" />
<TextInput label="Flag" placeholder="Country" />
</SimpleGrid>
<SimpleGrid cols={2} spacing="sm">
<TextInput label="From" type="date" />
<TextInput label="To" type="date" />
</SimpleGrid>
<TextInput label="Engagement Port" placeholder="Port name" />
<Group justify="flex-end" mt="sm">
<Button variant="default" onClick={onClose}>Cancel</Button>
<Button onClick={() => { notify.success('Sea service record added (demo).'); onClose(); }}>Save</Button>
</Group>
</Stack>
</Modal>
);
}
function AddCertModal({ opened, onClose }: { opened: boolean; onClose: () => void }) {
return (
<Modal opened={opened} onClose={onClose} title="Add Certification" size="lg">
<Stack gap="sm">
<TextInput label="Certificate Name" placeholder="e.g. STCW Basic Safety Training" required />
<SimpleGrid cols={2} spacing="sm">
<TextInput label="Certificate No." placeholder="CERT-0000" />
<Select label="Type" data={['STCW', 'COC', 'COE', 'GMDSS', 'Other']} placeholder="Select type" />
</SimpleGrid>
<TextInput label="Issued By" placeholder="Issuing authority" />
<SimpleGrid cols={2} spacing="sm">
<TextInput label="Issue Date" type="date" />
<TextInput label="Expiry Date" type="date" />
</SimpleGrid>
<Select label="Status" data={['Valid', 'Expired', 'Pending']} defaultValue="Valid" />
<Group justify="flex-end" mt="sm">
<Button variant="default" onClick={onClose}>Cancel</Button>
<Button onClick={() => { notify.success('Certification added (demo).'); onClose(); }}>Save</Button>
</Group>
</Stack>
</Modal>
);
}
// ---------------------------------------------------------------------------
// Main page
// ---------------------------------------------------------------------------
export function SeafarerProfilePage() {
const { id } = useParams<{ id: string }>();
const navigate = useNavigate();
const [profile, setProfile] = useState<SeafarerProfile | null>(null);
const [loading, setLoading] = useState(true);
const [activeTab, setActiveTab] = useState<string | null>('overview');
const [trainingModal, trainingModalHandlers] = useDisclosure(false);
const [medicalModal, medicalModalHandlers] = useDisclosure(false);
const [seaServiceModal, seaServiceModalHandlers] = useDisclosure(false);
const [certModal, certModalHandlers] = useDisclosure(false);
useEffect(() => {
if (!id) return;
fetchSeafarerProfile(id)
.then(setProfile)
.catch(() => notify.error('Failed to load seafarer profile.'))
.finally(() => setLoading(false));
}, [id]);
const handleStatusChange = async (newStatus: 'Active' | 'Suspended') => {
if (!profile) return;
try {
await updateSeafarerStatus(profile.id, newStatus);
setProfile((p) => p ? { ...p, status: newStatus } : p);
notify.success(`Status updated to ${newStatus}.`);
} catch {
notify.error('Failed to update status.');
}
};
const initials = profile ? `${profile.firstName[0]}${profile.lastName[0]}` : '??';
return (
<Stack gap="md">
{/* Breadcrumb */}
<Group gap="xs" align="center">
<ActionIcon variant="subtle" size="sm" onClick={() => navigate('/seafarer-registry')}>
<IconArrowLeft size={16} />
</ActionIcon>
<Text fz="sm" c="dimmed" style={{ cursor: 'pointer' }} onClick={() => navigate('/seafarer-registry')}>
Seafarer Registry
</Text>
<Text fz="sm" c="dimmed">/</Text>
<Text fz="sm" fw={500}>
{loading ? <Skeleton width={100} height={14} /> : `${profile?.firstName} ${profile?.lastName}`}
</Text>
</Group>
{/* Profile header card */}
<Paper withBorder radius="md" p="lg">
{loading ? (
<Group gap="md">
<Skeleton circle height={64} />
<Stack gap={6} style={{ flex: 1 }}>
<Skeleton height={20} width={200} />
<Skeleton height={14} width={300} />
<Skeleton height={14} width={400} />
</Stack>
</Group>
) : profile ? (
<Group justify="space-between" wrap="nowrap" align="flex-start">
<Group gap="lg" wrap="nowrap" align="flex-start">
<Avatar size={64} radius="xl" color="blue" style={{ fontSize: rem(22) }}>
{initials}
</Avatar>
<div>
<Title order={3} lh={1.2}>{profile.firstName} {profile.lastName}</Title>
<Text fz="sm" c="dimmed" mt={2}>
{profile.seafarerId} · Registered {profile.registeredAt}
</Text>
<Group gap="lg" mt={6} wrap="wrap">
<Text fz="sm"><Text span fw={600}>Gender:</Text> {profile.gender}</Text>
<Text fz="sm"><Text span fw={600}>DOB:</Text> {profile.dob}</Text>
<Text fz="sm"><Text span fw={600}>Nationality:</Text> {profile.nationality}</Text>
<Text fz="sm"><Text span fw={600}>Mobile:</Text> {profile.mobile}</Text>
<Text fz="sm"><Text span fw={600}>Email:</Text> {profile.email}</Text>
</Group>
</div>
</Group>
<Stack gap="xs" align="flex-end" style={{ flexShrink: 0 }}>
<Badge color={STATUS_COLOR[profile.status] ?? 'gray'} variant="filled" radius="sm">{profile.status}</Badge>
<Button size="xs" variant="default" leftSection={<IconEdit size={13} />} onClick={() => notify.info('Edit profile — coming soon.')}>
Edit Profile
</Button>
<Button size="xs" variant="default" leftSection={<IconPrinter size={13} />} onClick={() => notify.info('Print — coming soon.')}>
Print Profile
</Button>
</Stack>
</Group>
) : (
<Alert color="red">Profile not found.</Alert>
)}
</Paper>
{/* Tabs */}
{!loading && profile && (
<Tabs value={activeTab} onChange={setActiveTab} variant="outline">
<Tabs.List mb="md">
<Tabs.Tab value="overview" leftSection={<IconLayoutDashboard size={15} />}>Overview</Tabs.Tab>
<Tabs.Tab value="training" leftSection={<IconBook size={15} />}>Training</Tabs.Tab>
<Tabs.Tab value="medical" leftSection={<IconHeartbeat size={15} />}>Medical</Tabs.Tab>
<Tabs.Tab value="sea-service" leftSection={<IconShip size={15} />}>Sea Service</Tabs.Tab>
<Tabs.Tab value="certifications" leftSection={<IconCertificate size={15} />}>Certifications</Tabs.Tab>
<Tabs.Tab value="history" leftSection={<IconHistory size={15} />}>History</Tabs.Tab>
</Tabs.List>
<Tabs.Panel value="overview">
<OverviewTab profile={profile} onStatusChange={handleStatusChange} />
</Tabs.Panel>
<Tabs.Panel value="training">
<TrainingTab records={profile.training} onAdd={trainingModalHandlers.open} />
</Tabs.Panel>
<Tabs.Panel value="medical">
<MedicalTab records={profile.medical} onAdd={medicalModalHandlers.open} />
</Tabs.Panel>
<Tabs.Panel value="sea-service">
<SeaServiceTab records={profile.seaService} onAdd={seaServiceModalHandlers.open} />
</Tabs.Panel>
<Tabs.Panel value="certifications">
<CertificationsTab records={profile.certifications} onAdd={certModalHandlers.open} />
</Tabs.Panel>
<Tabs.Panel value="history">
<HistoryTab entries={profile.history} />
</Tabs.Panel>
</Tabs>
)}
{/* Modals */}
<AddTrainingModal opened={trainingModal} onClose={trainingModalHandlers.close} />
<AddMedicalModal opened={medicalModal} onClose={medicalModalHandlers.close} />
<AddSeaServiceModal opened={seaServiceModal} onClose={seaServiceModalHandlers.close} />
<AddCertModal opened={certModal} onClose={certModalHandlers.close} />
</Stack>
);
}

View File

@@ -0,0 +1,553 @@
import { useRef, useState } from 'react';
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';
// ---------------------------------------------------------------------------
// Dummy API
// ---------------------------------------------------------------------------
async function submitSeafarerRegistration(data: unknown): Promise<{ ok: true; referenceId: string }> {
await new Promise((r) => setTimeout(r, 1200));
console.log('Seafarer registration payload:', data);
return { ok: true, referenceId: `SEA-${Date.now()}` };
}
// ---------------------------------------------------------------------------
// 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();
const [active, setActive] = useState(0);
const [completed, setCompleted] = useState<number[]>([]);
const [submitting, setSubmitting] = useState(false);
// 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 }));
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 () => {
setSubmitting(true);
try {
const result = await submitSeafarerRegistration({
personalInfo: { firstName, middleName, lastName, gender, dob: dob?.toISOString().split('T')[0] ?? '', placeOfBirth, nationality, maritalStatus, nationalIdNumber, passportNumber, passportExpiry },
contactDetails: { mobile, email, locationId, permanentAddress, currentAddress, emergency: { name: emergencyName, relationship: emergencyRel, phone: emergencyPhone } },
documents: Object.fromEntries(Object.entries(files).map(([k, v]) => [k, v?.name ?? null])),
});
notify.success(`Registration submitted! Reference: ${result.referenceId}`);
navigate('/applications');
} catch {
notify.error('Submission failed. Please try again.');
} finally {
setSubmitting(false);
}
};
const stepLabel = STEPS[active]?.label ?? '';
const stepIcons = [IconUser, IconAddressBook, IconFileDescription, IconCircleCheck];
const StepIcon = stepIcons[active];
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 && (
<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>
)}
{/* 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>
);
}

View File

@@ -0,0 +1,379 @@
import { useEffect, useState } from 'react';
import {
ActionIcon,
Badge,
Box,
Button,
Card,
Group,
Loader,
Menu,
Paper,
Select,
SimpleGrid,
Skeleton,
Stack,
Table,
Text,
TextInput,
ThemeIcon,
Title,
rem,
} from '@mantine/core';
import {
IconAnchor,
IconCheck,
IconClock,
IconDotsVertical,
IconEdit,
IconEye,
IconFileExport,
IconSearch,
IconUserCheck,
IconUsers,
IconUserX,
IconX,
} from '@tabler/icons-react';
import { useNavigate } from 'react-router-dom';
import { notify } from '@ema-platform/ui';
// ---------------------------------------------------------------------------
// Types
// ---------------------------------------------------------------------------
export interface Seafarer {
id: string;
seafarerId: string;
firstName: string;
lastName: string;
email: string;
gender: 'Male' | 'Female';
nationality: string;
mobile: string;
region: string;
registeredAt: string;
medicalStatus: 'Fit' | 'Unfit' | 'Pending';
bookStatus: 'Active' | 'Expired' | 'Suspended' | 'Pending';
status: 'Active' | 'Pending' | 'Suspended';
}
// ---------------------------------------------------------------------------
// Dummy API — replace with real fetch later
// ---------------------------------------------------------------------------
async function fetchSeafarers(): Promise<Seafarer[]> {
await new Promise((r) => setTimeout(r, 900));
return [
{
id: '1',
seafarerId: 'SF-2024-0001',
firstName: 'Abebe',
lastName: 'Girma',
email: 'abebe.g@email.com',
gender: 'Male',
nationality: 'Ethiopian',
mobile: '+251 911 234 567',
region: 'Addis Ababa',
registeredAt: '2024-01-10',
medicalStatus: 'Fit',
bookStatus: 'Active',
status: 'Active',
},
{
id: '2',
seafarerId: 'SF-2024-0002',
firstName: 'Sara',
lastName: 'Tadesse',
email: 'sara.t@email.com',
gender: 'Female',
nationality: 'Ethiopian',
mobile: '+251 922 345 678',
region: 'Dire Dawa',
registeredAt: '2024-02-14',
medicalStatus: 'Pending',
bookStatus: 'Pending',
status: 'Pending',
},
{
id: '3',
seafarerId: 'SF-2024-0003',
firstName: 'Dawit',
lastName: 'Bekele',
email: 'dawit.b@email.com',
gender: 'Male',
nationality: 'Ethiopian',
mobile: '+251 933 456 789',
region: 'Oromia',
registeredAt: '2024-03-05',
medicalStatus: 'Fit',
bookStatus: 'Expired',
status: 'Suspended',
},
{
id: '4',
seafarerId: 'SF-2024-0004',
firstName: 'Hana',
lastName: 'Mulugeta',
email: 'hana.m@email.com',
gender: 'Female',
nationality: 'Ethiopian',
mobile: '+251 944 567 890',
region: 'Amhara',
registeredAt: '2024-04-20',
medicalStatus: 'Fit',
bookStatus: 'Active',
status: 'Active',
},
];
}
// ---------------------------------------------------------------------------
// Stat card
// ---------------------------------------------------------------------------
function StatCard({
label,
value,
icon: Icon,
color,
loading,
}: {
label: string;
value: number;
icon: typeof IconUsers;
color: string;
loading: boolean;
}) {
return (
<Card withBorder radius="md" p="md">
<Group justify="space-between" wrap="nowrap">
<div>
{loading ? (
<Skeleton height={28} width={40} mb={6} />
) : (
<Title order={2} lh={1}>{value}</Title>
)}
<Text fz="sm" c="dimmed" mt={4}>{label}</Text>
</div>
<ThemeIcon variant="light" color={color} size={46} radius="md">
<Icon size={22} stroke={1.6} />
</ThemeIcon>
</Group>
</Card>
);
}
// ---------------------------------------------------------------------------
// Status badges
// ---------------------------------------------------------------------------
const STATUS_COLOR: Record<string, string> = {
Active: 'teal',
Pending: 'yellow',
Suspended: 'red',
Expired: 'orange',
Fit: 'teal',
Unfit: 'red',
};
function StatusBadge({ value }: { value: string }) {
return (
<Badge
color={STATUS_COLOR[value] ?? 'gray'}
variant="light"
radius="sm"
size="sm"
>
{value}
</Badge>
);
}
// ---------------------------------------------------------------------------
// Main page
// ---------------------------------------------------------------------------
export function SeafarerRegistryPage() {
const navigate = useNavigate();
const [seafarers, setSeafarers] = useState<Seafarer[]>([]);
const [loading, setLoading] = useState(true);
const [search, setSearch] = useState('');
const [statusFilter, setStatusFilter] = useState<string | null>(null);
useEffect(() => {
fetchSeafarers()
.then(setSeafarers)
.catch(() => notify.error('Failed to load seafarers.'))
.finally(() => setLoading(false));
}, []);
const stats = {
total: seafarers.length,
active: seafarers.filter((s) => s.status === 'Active').length,
pending: seafarers.filter((s) => s.status === 'Pending').length,
suspended: seafarers.filter((s) => s.status === 'Suspended').length,
};
const filtered = seafarers.filter((s) => {
const q = search.toLowerCase();
const matchSearch =
!q ||
s.seafarerId.toLowerCase().includes(q) ||
`${s.firstName} ${s.lastName}`.toLowerCase().includes(q) ||
s.mobile.includes(q) ||
s.email.toLowerCase().includes(q);
const matchStatus = !statusFilter || s.status === statusFilter;
return matchSearch && matchStatus;
});
const rows = filtered.map((s) => (
<Table.Tr key={s.id}>
<Table.Td>
<Text fz="sm" fw={600} c="blue.7" style={{ cursor: 'pointer' }} onClick={() => navigate(`/seafarer-registry/${s.id}`)}>
{s.seafarerId}
</Text>
</Table.Td>
<Table.Td>
<div>
<Text fz="sm" fw={500}>{s.firstName} {s.lastName}</Text>
<Text fz="xs" c="dimmed">{s.email}</Text>
</div>
</Table.Td>
<Table.Td><Text fz="sm">{s.gender}</Text></Table.Td>
<Table.Td><Text fz="sm">{s.nationality}</Text></Table.Td>
<Table.Td><Text fz="sm">{s.mobile}</Text></Table.Td>
<Table.Td><Text fz="sm">{s.region}</Text></Table.Td>
<Table.Td><Text fz="sm">{s.registeredAt}</Text></Table.Td>
<Table.Td><StatusBadge value={s.medicalStatus} /></Table.Td>
<Table.Td><StatusBadge value={s.bookStatus} /></Table.Td>
<Table.Td><StatusBadge value={s.status} /></Table.Td>
<Table.Td>
<Menu position="bottom-end" shadow="sm" width={160} withinPortal>
<Menu.Target>
<ActionIcon variant="subtle" color="gray" size="sm">
<IconDotsVertical size={15} />
</ActionIcon>
</Menu.Target>
<Menu.Dropdown>
<Menu.Item leftSection={<IconEye size={14} />} onClick={() => navigate(`/seafarer-registry/${s.id}`)}>
View
</Menu.Item>
<Menu.Item leftSection={<IconEdit size={14} />} onClick={() => navigate(`/seafarer-registry/${s.id}`)}>
Edit
</Menu.Item>
<Menu.Divider />
<Menu.Item leftSection={<IconX size={14} />} color="red" onClick={() => notify.info('Suspend — coming soon.')}>
Suspend
</Menu.Item>
</Menu.Dropdown>
</Menu>
</Table.Td>
</Table.Tr>
));
return (
<Stack gap="md">
{/* Header */}
<Group justify="space-between" align="flex-start">
<div>
<Title order={3}>Seafarer Registry</Title>
<Text fz="sm" c="dimmed">Manage all registered seafarers</Text>
</div>
<Button
leftSection={<IconAnchor size={16} />}
onClick={() => navigate('/seafarer-registration')}
>
+ New Registration
</Button>
</Group>
{/* Stats */}
<SimpleGrid cols={{ base: 2, sm: 4 }} spacing="md">
<StatCard label="Total Seafarers" value={stats.total} icon={IconUsers} color="blue" loading={loading} />
<StatCard label="Active" value={stats.active} icon={IconUserCheck} color="teal" loading={loading} />
<StatCard label="Pending" value={stats.pending} icon={IconClock} color="yellow" loading={loading} />
<StatCard label="Suspended" value={stats.suspended} icon={IconUserX} color="red" loading={loading} />
</SimpleGrid>
{/* Table card */}
<Paper withBorder radius="md">
{/* Toolbar */}
<Group p="md" justify="space-between" wrap="wrap" gap="sm">
<Text fw={600}>Seafarer List</Text>
<Group gap="sm" wrap="nowrap">
<TextInput
placeholder="Search by name, ID or mobile…"
leftSection={<IconSearch size={15} />}
value={search}
onChange={(e) => setSearch(e.currentTarget.value)}
style={{ minWidth: rem(260) }}
size="sm"
rightSection={
search ? (
<ActionIcon variant="subtle" size="sm" onClick={() => setSearch('')}>
<IconX size={13} />
</ActionIcon>
) : null
}
/>
<Select
placeholder="All Status"
data={['Active', 'Pending', 'Suspended']}
value={statusFilter}
onChange={setStatusFilter}
clearable
size="sm"
style={{ width: rem(140) }}
/>
<ActionIcon
variant="default"
size={34}
title="Export"
onClick={() => notify.info('Export — coming soon.')}
>
<IconFileExport size={16} />
</ActionIcon>
</Group>
</Group>
{/* Table */}
{loading ? (
<Stack gap="xs" p="md">
{[...Array(4)].map((_, i) => <Skeleton key={i} height={44} radius="sm" />)}
</Stack>
) : filtered.length === 0 ? (
<Box py="xl" ta="center">
<ThemeIcon variant="light" color="gray" size={48} radius="xl" mx="auto" mb="sm">
<IconUsers size={22} />
</ThemeIcon>
<Text fz="sm" c="dimmed">No seafarers found</Text>
{(search || statusFilter) && (
<Button size="xs" variant="subtle" mt="xs" onClick={() => { setSearch(''); setStatusFilter(null); }}>
Clear filters
</Button>
)}
</Box>
) : (
<Table highlightOnHover striped withColumnBorders={false} verticalSpacing="sm" fz="sm">
<Table.Thead bg="var(--mantine-color-default-hover)">
<Table.Tr>
{['Seafarer ID', 'Name', 'Gender', 'Nationality', 'Mobile', 'Region', 'Reg. Date', 'Medical', 'Book Status', 'Status', ''].map((h) => (
<Table.Th key={h} style={{ whiteSpace: 'nowrap', fontSize: rem(11), textTransform: 'uppercase', letterSpacing: '0.04em', color: 'var(--mantine-color-dimmed)' }}>
{h}
</Table.Th>
))}
</Table.Tr>
</Table.Thead>
<Table.Tbody>{rows}</Table.Tbody>
</Table>
)}
{/* Footer */}
{!loading && filtered.length > 0 && (
<Group px="md" py="sm" justify="space-between">
<Text fz="xs" c="dimmed">Showing {filtered.length} of {seafarers.length} seafarers</Text>
<Group gap={4}>
<IconCheck size={13} color="var(--mantine-color-teal-6)" />
<Text fz="xs" c="dimmed">Data loaded</Text>
</Group>
</Group>
)}
</Paper>
</Stack>
);
}

View File

@@ -0,0 +1,77 @@
import {
Accordion,
Group,
Paper,
SimpleGrid,
Stack,
Text,
ThemeIcon,
Title,
} from '@mantine/core';
import {
IconPhone,
IconMail,
IconMapPin,
IconClock,
} from '@tabler/icons-react';
import type { Icon } from '@tabler/icons-react';
import { useTranslation } from 'react-i18next';
import { PageHeader } from '@ema-platform/ui';
const FAQ_KEYS = ['1', '2', '3', '4'] as const;
export function SupportPage() {
const { t } = useTranslation();
const contacts: { icon: Icon; label: string; value: string }[] = [
{ icon: IconPhone, label: t('support.phone'), value: '+251 11 551 7170' },
{ icon: IconMail, label: t('support.email'), value: 'info@maritime.gov.et' },
{ icon: IconMapPin, label: t('support.office'), value: t('support.officeValue') },
{ icon: IconClock, label: t('support.hours'), value: t('support.hoursValue') },
];
return (
<Stack gap="lg">
<PageHeader title={t('support.title')} subtitle={t('support.subtitle')} />
<Paper p="lg" shadow="sm" radius="md" withBorder>
<Title order={4} mb="md">
{t('support.contact')}
</Title>
<SimpleGrid cols={{ base: 1, sm: 2 }} spacing="lg">
{contacts.map((c) => (
<Group key={c.label} gap="sm" wrap="nowrap">
<ThemeIcon variant="light" color="emaPrimary" size={42} radius="md">
<c.icon size={22} />
</ThemeIcon>
<div>
<Text size="xs" c="dimmed" tt="uppercase" fw={600}>
{c.label}
</Text>
<Text size="sm">{c.value}</Text>
</div>
</Group>
))}
</SimpleGrid>
</Paper>
<Paper p="lg" shadow="sm" radius="md" withBorder>
<Title order={4} mb="md">
{t('support.faq')}
</Title>
<Accordion variant="separated" radius="md">
{FAQ_KEYS.map((k) => (
<Accordion.Item key={k} value={k}>
<Accordion.Control>{t(`support.faqs.q${k}`)}</Accordion.Control>
<Accordion.Panel>
<Text size="sm" c="dimmed">
{t(`support.faqs.a${k}`)}
</Text>
</Accordion.Panel>
</Accordion.Item>
))}
</Accordion>
</Paper>
</Stack>
);
}

View File

@@ -0,0 +1,53 @@
import i18next from 'i18next';
import { initReactI18next } from 'react-i18next';
import { en } from './locales/en';
import { am } from './locales/am';
export const SUPPORTED_LANGUAGES = ['en', 'am'] as const;
export type AppLanguage = (typeof SUPPORTED_LANGUAGES)[number];
const STORAGE_KEY = 'ema-portal-lang';
function getInitialLanguage(): AppLanguage {
const stored =
typeof localStorage !== 'undefined'
? (localStorage.getItem(STORAGE_KEY) as AppLanguage | null)
: null;
if (stored && SUPPORTED_LANGUAGES.includes(stored)) return stored;
return 'en';
}
// IMPORTANT: use a DEDICATED instance, not the global i18next singleton.
// `@tria-plc/iamui-common` initializes the global singleton with its own
// resources and `fallbackLng: 'am'`; sharing it would clobber the portal's
// translations (keys would render literally and the language would flip to
// Amharic). An isolated instance + <I18nextProvider> keeps the portal's i18n
// independent of the IAM module.
export const i18n = i18next.createInstance();
i18n.use(initReactI18next).init({
resources: {
en: { translation: en },
am: { translation: am },
},
lng: getInitialLanguage(),
fallbackLng: 'en',
supportedLngs: [...SUPPORTED_LANGUAGES],
interpolation: { escapeValue: false },
returnNull: false,
});
i18n.on('languageChanged', (lng) => {
if (typeof localStorage !== 'undefined') {
localStorage.setItem(STORAGE_KEY, lng);
}
if (typeof document !== 'undefined') {
document.documentElement.lang = lng;
}
});
if (typeof document !== 'undefined') {
document.documentElement.lang = i18n.language;
}
export default i18n;

View File

@@ -0,0 +1,172 @@
import type { Translations } from './en';
export const am: Translations = {
app: {
name: 'ኢባባ ፖርታል',
authority: 'የኢትዮጵያ ባሕር ጉዳዮች ባለሥልጣን',
tagline: 'የባሕር ፍቃድና የምስክር ወረቀት አገልግሎቶች',
},
language: {
label: 'ቋንቋ',
en: 'English',
am: 'አማርኛ',
},
nav: {
dashboard: 'ዳሽቦርድ',
seafarerRegistry: 'የመርከበኞች ምዝገባ',
profile: 'መገለጫ',
support: 'እገዛና ድጋፍ',
collapseSidebar: 'ሰብስብ',
expandSidebar: 'ዘርጋ',
},
common: {
back: 'ተመለስ',
continue: 'ቀጥል',
submit: 'አስገባ',
cancel: 'ሰርዝ',
save: 'ለውጦችን አስቀምጥ',
saved: 'ለውጦች ተቀምጠዋል',
viewAll: 'ሁሉንም ይመልከቱ',
viewDetails: 'ዝርዝር ይመልከቱ',
search: 'ፈልግ',
download: 'አውርድ',
print: 'አትም',
filter: 'አጣራ',
all: 'ሁሉም',
status: 'ሁኔታ',
date: 'ቀን',
actions: 'ድርጊቶች',
loading: 'በመጫን ላይ…',
none: 'ምንም የለም',
optional: 'አማራጭ',
required: 'ግዴታ',
of: 'ከ',
backToList: 'ወደ ዝርዝሩ ተመለስ',
notFound: 'አልተገኘም',
learnMore: 'ተጨማሪ ይወቁ',
toggleTheme: 'ብርሃን / ጨለማ ገጽታ ቀይር',
welcome: 'እንኳን ደህና መጡ',
},
auth: {
login: 'ግባ',
logout: 'ውጣ',
signup: 'ይመዝገቡ',
account: 'መለያ',
guest: 'እንግዳ ተጠቃሚ',
},
dashboard: {
title: 'ዳሽቦርድ',
quickActions: 'ፈጣን ድርጊቶች',
},
profile: {
title: 'መገለጫዬ',
subtitle: 'የመለያ ዝርዝሮችዎንና ምርጫዎችዎን ያስተዳድሩ።',
personal: 'የግል መረጃ',
contact: 'መገናኛ',
preferences: 'ምርጫዎች',
security: 'ደህንነት',
securityHint: 'በሌላ ቦታ የማይጠቀሙበትን ጠንካራ የይለፍ ቃል ይምረጡ።',
changePassword: 'የይለፍ ቃል ይቀይሩ',
updatePassword: 'የይለፍ ቃል አዘምን',
updateProfile: 'ለውጦችን አስቀምጥ',
savePreferences: 'ምርጫዎችን አስቀምጥ',
cancel: 'ሰርዝ',
verified: 'ተረጋግጧል',
unverified: 'አልተረጋገጠም',
tabs: {
profile: 'መገለጫ',
security: 'ደህንነት',
preferences: 'ምርጫዎች',
},
personalHint: 'ስምዎ በይፋዊ የ EMA ሰነዶች ላይ እንደሚታየው።',
languageTitle: 'ቋንቋ',
languageHint: 'በ EMA ፖርታል ላይ የሚጠቀሙበትን ቋንቋ ይምረጡ።',
appearance: {
title: 'መልክ',
subtitle: 'ፖርታሉ በመሣሪያዎ ላይ እንዴት እንደሚታይ ይምረጡ።',
light: 'ብርሃን',
dark: 'ጨለማ',
system: 'ሲስተም',
},
twoStep: {
title: 'ባለ ሁለት ደረጃ ማረጋገጫ',
desc: 'በሚገቡበት ጊዜ ሁሉ ከስልክዎ የአንድ ጊዜ ኮድ እንዲጠየቅ ያድርጉ።',
},
notifications: {
title: 'የኢሜይል ማሳወቂያዎች',
desc: 'ስለ ፈቃድ ማመልከቻዎችዎና የመለያ እንቅስቃሴ በኢሜይል ዝማኔዎችን ይቀበሉ።',
},
strength: {
label: 'የይለፍ ቃል ጥንካሬ',
weak: 'ደካማ',
fair: 'መካከለኛ',
good: 'ጥሩ',
strong: 'ጠንካራ',
},
profileUpdated: 'መገለጫ በተሳካ ሁኔታ ተዘምኗል',
passwordChanged: 'የይለፍ ቃል በተሳካ ሁኔታ ተቀይሯል',
updateFailed: 'መገለጫን ማዘመን አልተቻለም። እባክዎ እንደገና ይሞክሩ።',
passwordFailed:
'የይለፍ ቃል መቀየር አልተቻለም። የአሁኑን የይለፍ ቃል ያረጋግጡና እንደገና ይሞክሩ።',
fields: {
fullNameEn: 'ሙሉ ስም (እንግሊዝኛ)',
fullNameAm: 'ሙሉ ስም (አማርኛ)',
username: 'የተጠቃሚ ስም',
usernameHint: 'የተጠቃሚ ስም መቀየር አይቻልም',
organization: 'ድርጅት',
email: 'የኢሜይል አድራሻ',
phone: 'ስልክ ቁጥር',
address: 'አድራሻ',
language: 'የሚመረጥ ቋንቋ',
currentPassword: 'የአሁኑ የይለፍ ቃል',
newPassword: 'አዲስ የይለፍ ቃል',
confirmPassword: 'አዲሱን የይለፍ ቃል ያረጋግጡ',
},
validation: {
nameRequired: 'ስም ያስፈልጋል',
emailInvalid: 'ትክክለኛ ኢሜይል ያስገቡ',
usernameRequired: 'የተጠቃሚ ስም ያስፈልጋል',
phoneRequired: 'ስልክ ቁጥር ያስፈልጋል',
passwordMin: 'የይለፍ ቃል ቢያንስ 8 ቁምፊዎች መሆን አለበት',
passwordMismatch: 'የይለፍ ቃላት አይዛመዱም',
},
},
location: {
select: 'ይምረጡ...',
noOptions: 'ምንም አማራጮች አልተገኙም',
noLocationsAvailable: 'ምንም አካባቢዎች የሉም',
chooseFirst: 'መጀመሪያ አካባቢ ይምረጡ',
subLocation: 'ንዑስ አካባቢ',
loading: 'አካባቢዎች በመጫን ላይ...',
},
support: {
title: 'እገዛና ድጋፍ',
subtitle: 'መመሪያ፣ የመገናኛ መንገዶችና ለተደጋጋሚ ጥያቄዎች መልሶች።',
contact: 'ባለሥልጣኑን ያግኙ',
phone: 'ስልክ',
email: 'ኢሜይል',
office: 'ዋና መሥሪያ ቤት',
officeValue: 'አዲስ አበባ፣ ኢትዮጵያ',
hours: 'የሥራ ሰዓታት',
hoursValue: 'ሰኞ–ዓርብ፣ 2:30 11:00 ሰዓት',
faq: 'ተደጋጋሚ ጥያቄዎች',
faqs: {
q1: 'የፍቃድ ማስኬጃ ምን ያህል ጊዜ ይወስዳል?',
a1: 'አብዛኞቹ ማመልከቻዎች እንደ ፍቃዱ ዓይነትና እንደ ሰነዶችዎ ሙሉነት ከ515 የሥራ ቀናት ውስጥ ይገመገማሉ።',
q2: 'ማመልከቻ ካስገባሁ በኋላ ምን ይከሰታል?',
a2: 'ማመልከቻዎ ሰነዶችዎን ለሚያረጋግጥ የፍቃድ መኮንን ይመደባል። እያንዳንዱን የሁኔታ ለውጥ ከ"ማመልከቻዎቼ" ገጽ መከታተል ይችላሉ።',
q3: 'የሚያበቃ ፍቃድ እንዴት አድሳለሁ?',
a3: 'ከ"ፍቃዶቼ" ፍቃዱን ይክፈቱና "አድስ" ይምረጡ፣ ወይም አዲስ ማመልከቻ ይጀምሩና "እድሳት" የጥያቄ ዓይነት ይምረጡ።',
q4: 'ኢባባ ተጨማሪ መረጃ ቢጠይቅስ?',
a4: 'በማመልከቻው ላይ "እርምጃ ያስፈልጋል" የሚል ማስታወሻ ያያሉ። ግምገማውን ለመቀጠል የተጠየቀውን ሰነድ ወይም ዝርዝር ያቅርቡ።',
},
},
};

View File

@@ -0,0 +1,172 @@
export const en = {
app: {
name: 'EMA Portal',
authority: 'Ethiopian Maritime Authority',
tagline: 'Maritime licensing & certification services',
},
language: {
label: 'Language',
en: 'English',
am: 'አማርኛ',
},
nav: {
dashboard: 'Dashboard',
seafarerRegistry: 'Seafarer Registry',
profile: 'Profile',
support: 'Help & Support',
collapseSidebar: 'Collapse',
expandSidebar: 'Expand sidebar',
},
common: {
back: 'Back',
continue: 'Continue',
submit: 'Submit',
cancel: 'Cancel',
save: 'Save changes',
saved: 'Changes saved',
viewAll: 'View all',
viewDetails: 'View details',
search: 'Search',
download: 'Download',
print: 'Print',
filter: 'Filter',
all: 'All',
status: 'Status',
date: 'Date',
actions: 'Actions',
loading: 'Loading…',
none: 'None',
optional: 'optional',
required: 'Required',
of: 'of',
backToList: 'Back to list',
notFound: 'Not found',
learnMore: 'Learn more',
toggleTheme: 'Toggle light / dark mode',
welcome: 'Welcome',
},
auth: {
login: 'Log in',
logout: 'Log out',
signup: 'Sign up',
account: 'Account',
guest: 'Guest user',
},
dashboard: {
title: 'Dashboard',
quickActions: 'Quick actions',
},
profile: {
title: 'My Profile',
subtitle: 'Manage your account details and preferences.',
personal: 'Personal information',
contact: 'Contact',
preferences: 'Preferences',
security: 'Security',
securityHint: 'Choose a strong password you do not use anywhere else.',
changePassword: 'Change password',
updatePassword: 'Update password',
updateProfile: 'Save changes',
savePreferences: 'Save preferences',
cancel: 'Cancel',
verified: 'Verified',
unverified: 'Unverified',
tabs: {
profile: 'Profile',
security: 'Security',
preferences: 'Preferences',
},
personalHint: 'Your name as it appears on official EMA documents.',
languageTitle: 'Language',
languageHint: 'Choose the language used across the EMA portal.',
appearance: {
title: 'Appearance',
subtitle: 'Select how the portal looks on your device.',
light: 'Light',
dark: 'Dark',
system: 'System',
},
twoStep: {
title: 'Two-step verification',
desc: 'Require a one-time code from your phone each time you sign in.',
},
notifications: {
title: 'Email notifications',
desc: 'Receive updates about your license applications and account activity by email.',
},
strength: {
label: 'Password strength',
weak: 'Weak',
fair: 'Fair',
good: 'Good',
strong: 'Strong',
},
profileUpdated: 'Profile updated successfully',
passwordChanged: 'Password changed successfully',
updateFailed: 'Could not update profile. Please try again.',
passwordFailed:
'Could not change password. Check your current password and try again.',
fields: {
fullNameEn: 'Full name (English)',
fullNameAm: 'Full name (Amharic)',
username: 'Username',
usernameHint: 'Username cannot be changed',
organization: 'Organization',
email: 'Email address',
phone: 'Phone number',
address: 'Address',
language: 'Preferred language',
currentPassword: 'Current password',
newPassword: 'New password',
confirmPassword: 'Confirm new password',
},
validation: {
nameRequired: 'Name is required',
emailInvalid: 'Enter a valid email',
usernameRequired: 'Username is required',
phoneRequired: 'Phone number is required',
passwordMin: 'Password must be at least 8 characters',
passwordMismatch: 'Passwords do not match',
},
},
location: {
select: 'Select...',
noOptions: 'No options found',
noLocationsAvailable: 'No locations available',
chooseFirst: 'Choose a location first',
subLocation: 'Sub-location',
loading: 'Loading locations...',
},
support: {
title: 'Help & Support',
subtitle: 'Guidance, contact channels and answers to common questions.',
contact: 'Contact the Authority',
phone: 'Phone',
email: 'Email',
office: 'Head office',
officeValue: 'Addis Ababa, Ethiopia',
hours: 'Working hours',
hoursValue: 'MonFri, 8:30 AM 5:00 PM',
faq: 'Frequently asked questions',
faqs: {
q1: 'How long does licence processing take?',
a1: 'Most applications are reviewed within 515 working days, depending on the licence type and completeness of your documents.',
q2: 'What happens after I submit an application?',
a2: 'Your application is assigned to a licensing officer who verifies your documents. You can track every status change from the My Applications page.',
q3: 'How do I renew an expiring licence?',
a3: 'Open the licence from My Licences and choose Renew, or start a new application and select the Renewal request type.',
q4: 'What if EMA requests more information?',
a4: 'You will see an "Action required" notice on the application. Provide the requested document or detail to resume the review.',
},
},
};
export type Translations = typeof en;

View File

@@ -1,36 +1,126 @@
import { AppShell, Group, Text, Button } from '@mantine/core';
import { Outlet, useNavigate } from 'react-router-dom';
import { useAppDispatch, useAppSelector } from '../store/hooks';
import { logout } from '../features/auth/store/auth.slice';
import { AppShell } from '@mantine/core';
import { useDisclosure } from '@mantine/hooks';
import {
IconLayoutDashboard,
IconLifebuoy,
IconList,
IconUser,
} from '@tabler/icons-react';
import { Outlet, useLocation, useNavigate } from 'react-router-dom';
import { useTranslation } from 'react-i18next';
import { useDispatch } from 'react-redux';
import { notify, AppHeader, AppSidebar } from '@ema-platform/ui';
import type { NavItem } from '@ema-platform/ui';
import { logout } from '@ema-platform/auth';
import { SUPPORTED_LANGUAGES } from '../i18n/config';
import { useAppSelector } from '../store/hooks';
const NAV_ITEMS: (NavItem & { i18nKey: string })[] = [
{ to: '/dashboard', label: 'Dashboard', i18nKey: 'nav.dashboard', icon: IconLayoutDashboard },
{ to: '/seafarer-registry', label: 'Seafarer Registry', i18nKey: 'nav.seafarerRegistry', icon: IconList },
{ to: '/profile', label: 'Profile', i18nKey: 'nav.profile', icon: IconUser },
{ to: '/support', label: 'Help & Support', i18nKey: 'nav.support', icon: IconLifebuoy },
];
const PAGE_META: Record<string, { i18nKey: string }> = {
'/dashboard': { i18nKey: 'nav.dashboard' },
'/seafarer-registry': { i18nKey: 'nav.seafarerRegistry' },
'/profile': { i18nKey: 'nav.profile' },
};
export function PortalLayout() {
const { t } = useTranslation();
const navigate = useNavigate();
const dispatch = useAppDispatch();
const isAuthenticated = useAppSelector((s) => s.auth.isAuthenticated);
const location = useLocation();
const dispatch = useDispatch();
const user = useAppSelector((state) => state.auth.user);
const [navOpened, { toggle: toggleNav, close: closeNav }] = useDisclosure();
const [sidebarCollapsed, { toggle: toggleSidebar }] = useDisclosure(false);
// Breadcrumb trail
const segments = location.pathname.split('/').filter(Boolean);
const crumbs = [
{ label: t('nav.dashboard'), path: '/dashboard' },
...segments
.map((_, i) => '/' + segments.slice(0, i + 1).join('/'))
.filter((path) => PAGE_META[path] && path !== '/dashboard')
.map((path) => ({ label: t(PAGE_META[path].i18nKey), path })),
];
const go = (item: NavItem) => {
if (item.soon) {
notify.info(`${item.label} — coming soon.`);
return;
}
if (item.to) {
navigate(item.to);
closeNav();
}
};
const handleLogout = () => {
dispatch(logout());
navigate('/login');
};
const displayName = user?.name?.en || user?.username || '';
const initials = displayName
? displayName.split(/\s+/).map((s) => s[0]).join('').toUpperCase().slice(0, 2)
: '?';
return (
<AppShell header={{ height: 56 }} padding="md">
<AppShell.Header>
<Group h="100%" px="md" justify="space-between">
<Text fw={700}>EMA Portal</Text>
{isAuthenticated ? (
<Button variant="subtle" size="sm" onClick={handleLogout}>
Logout
</Button>
) : (
<Button variant="subtle" size="sm" onClick={() => navigate('/login')}>
Login
</Button>
)}
</Group>
<AppShell
header={{ height: 74 }}
navbar={{
width: sidebarCollapsed ? 72 : 264,
breakpoint: 'sm',
collapsed: { mobile: !navOpened },
}}
padding="lg"
>
<AppShell.Header
style={{
background: 'var(--mantine-color-body)',
borderBottom: '1px solid var(--mantine-color-gray-2)',
}}
>
<AppHeader
onToggleNav={toggleNav}
onToggleSidebar={toggleSidebar}
navOpened={navOpened}
breadcrumbs={crumbs}
onNavigate={navigate}
onLogout={handleLogout}
userName={displayName || t('app.name')}
userInitials={initials}
supportedLanguages={SUPPORTED_LANGUAGES}
/>
</AppShell.Header>
<AppShell.Navbar
p={0}
style={{
overflow: 'hidden',
transition: 'width 200ms ease',
background: 'var(--mantine-color-body)',
borderRight: '1px solid var(--mantine-color-gray-2)',
}}
>
<AppSidebar
navItems={NAV_ITEMS.map(({ i18nKey, ...rest }) => ({ ...rest, label: t(i18nKey) }))}
collapsed={sidebarCollapsed}
activePath={location.pathname}
onToggleCollapse={toggleSidebar}
onNavigate={go}
brandName={t('app.name')}
brandSubtitle={t('app.authority')}
/>
</AppShell.Navbar>
<AppShell.Main>
<Outlet />
<div key={location.pathname} className="ema-page-enter">
<Outlet />
</div>
</AppShell.Main>
</AppShell>
);

View File

@@ -1,6 +1,7 @@
import { Provider } from 'react-redux';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { AuthProvider } from '@tria-plc/iamui-common';
import { AuthConfigProvider } from '@ema-platform/auth';
import type { ReactNode } from 'react';
import { store } from '../store';
import { MantineThemeProvider } from './MantineThemeProvider';
@@ -16,7 +17,17 @@ export function AppProviders({ children }: { children: ReactNode }) {
<Provider store={store}>
<QueryClientProvider client={queryClient}>
<AuthProvider>
<MantineThemeProvider>{children}</MantineThemeProvider>
<AuthConfigProvider
value={{
appName: 'Portal',
storagePrefix: 'ema-portal',
loginRedirectPath: '/dashboard',
enableSignup: true,
enableForgotPassword: true,
}}
>
<MantineThemeProvider>{children}</MantineThemeProvider>
</AuthConfigProvider>
</AuthProvider>
</QueryClientProvider>
</Provider>

View File

@@ -1,11 +1,11 @@
import { MantineProvider } from '@mantine/core';
import { Notifications } from '@mantine/notifications';
import { emaTheme } from '@ema-platform/shared';
import type { ReactNode } from 'react';
import { portalTheme } from '../theme/portalTheme';
export function MantineThemeProvider({ children }: { children: ReactNode }) {
return (
<MantineProvider theme={emaTheme}>
<MantineProvider theme={portalTheme} defaultColorScheme="light">
<Notifications position="top-right" />
{children}
</MantineProvider>

View File

@@ -1,43 +1,79 @@
import { Navigate, Route, Routes } from 'react-router-dom';
import { createBrowserRouter, Navigate } from 'react-router-dom';
import type { ReactNode } from 'react';
import { I18nextProvider } from 'react-i18next';
import { i18n } from './i18n/config';
import { PortalLayout } from './layouts/PortalLayout';
import { LoginPage } from './features/auth/pages/LoginPage';
import { SignupPage } from './features/auth/pages/SignupPage';
import { OTPVerificationPage } from './features/auth/pages/OTPVerificationPage';
import { ProtectedRoute } from './components/ProtectedRoute';
// Auth (standalone pages, no portal chrome)
import { LoginPage, SignupPage, OTPVerificationPage, ForgotPasswordPage } from '@ema-platform/auth';
// Portal feature pages
import { DashboardPage } from './features/dashboard/pages/DashboardPage';
import { useAppSelector } from './store/hooks';
import { ProfilePage } from './features/profile/pages/ProfilePage';
import { SupportPage } from './features/support/pages/SupportPage';
import { SeafarerRegistrationPage } from './features/seafarer/pages/SeafarerRegistrationPage';
import { SeafarerRegistryPage } from './features/seafarer/pages/SeafarerRegistryPage';
import { SeafarerProfilePage } from './features/seafarer/pages/SeafarerProfilePage';
function ProtectedRoute({ children }: { children: ReactNode }) {
const isAuthenticated = useAppSelector((s) => s.auth.isAuthenticated);
if (!isAuthenticated) return <Navigate to="/login" replace />;
return <>{children}</>;
// IAM (admin user management) — kept reachable but isolated under its own
// provider so it does not depend on the portal's provider tree.
import {
AppProviders as IamProviders,
UserManagementLayout,
UserManagementPage,
} from '@tria-plc/iamui-common';
function IsolatedIam({ children }: { children: ReactNode }) {
return <IamProviders>{children}</IamProviders>;
}
export function AppRouter() {
return (
<Routes>
<Route path="/login" element={<LoginPage />} />
<Route path="/signup" element={<SignupPage />} />
<Route
path="/otp-verify"
element={
<ProtectedRoute>
<OTPVerificationPage />
</ProtectedRoute>
}
/>
<Route element={<PortalLayout />}>
<Route path="/" element={<Navigate to="/dashboard" replace />} />
<Route
path="/dashboard"
element={
<ProtectedRoute>
<DashboardPage />
</ProtectedRoute>
}
/>
</Route>
<Route path="*" element={<Navigate to="/" replace />} />
</Routes>
);
}
export const router = createBrowserRouter([
// Public auth pages
{ path: '/login', element: <LoginPage /> },
{ path: '/signup', element: <SignupPage /> },
// Protected auth pages
{
element: <ProtectedRoute><OTPVerificationPage /></ProtectedRoute>,
path: '/otp-verify',
},
{
element: <ProtectedRoute><ForgotPasswordPage /></ProtectedRoute>,
path: '/forgot-password',
},
// Portal — protected
{
element: (
<ProtectedRoute>
<I18nextProvider i18n={i18n}>
<PortalLayout />
</I18nextProvider>
</ProtectedRoute>
),
children: [
{ path: '/', element: <Navigate to="/dashboard" replace /> },
{ path: '/dashboard', element: <DashboardPage /> },
{ path: '/seafarer-registration', element: <SeafarerRegistrationPage /> },
{ path: '/seafarer-registry', element: <SeafarerRegistryPage /> },
{ path: '/seafarer-registry/:id', element: <SeafarerProfilePage /> },
{ path: '/profile', element: <ProfilePage /> },
{ path: '/support', element: <SupportPage /> },
],
},
// IAM admin user management (isolated providers) — protected
{
element: (
<ProtectedRoute>
<IsolatedIam>
<UserManagementLayout />
</IsolatedIam>
</ProtectedRoute>
),
children: [{ path: '/users', element: <UserManagementPage /> }],
},
{ path: '*', element: <Navigate to="/" replace /> },
]);

View File

@@ -1,11 +1,20 @@
import { configureStore } from '@reduxjs/toolkit';
import { baseApi } from '@ema-platform/api';
import { authReducer } from '../features/auth/store/auth.slice';
import { authStorage } from '../features/auth/utils/auth-storage';
import { baseApi, configureTokenRefresh } from '@ema-platform/api';
import {
authReducer,
signupReducer,
configureAuthStorage,
authStorage,
refreshAccessToken,
logout,
} from '@ema-platform/auth';
import type { AuthUser } from '@ema-platform/auth';
configureAuthStorage('ema-portal');
const preloadedAuth = (() => {
const token = authStorage.getToken();
const user = authStorage.getUser();
const user = authStorage.getUser<AuthUser>();
if (token && user) {
return { token, user, isAuthenticated: true };
}
@@ -15,6 +24,7 @@ const preloadedAuth = (() => {
export const store = configureStore({
reducer: {
auth: authReducer,
signup: signupReducer,
[baseApi.reducerPath]: baseApi.reducer,
},
preloadedState: preloadedAuth ? { auth: preloadedAuth } : undefined,
@@ -22,5 +32,13 @@ export const store = configureStore({
getDefaultMiddleware().concat(baseApi.middleware),
});
configureTokenRefresh({
onTokenExpired: refreshAccessToken,
onAuthFailure: () => {
store.dispatch(logout());
window.location.href = '/login';
},
});
export type RootState = ReturnType<typeof store.getState>;
export type AppDispatch = typeof store.dispatch;

View File

@@ -0,0 +1,60 @@
/* Portal global styles — loaded after Mantine's CSS, no Tailwind preflight so
it never fights Mantine's base styles. */
@import url('https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700;800&display=swap');
:root {
--ema-surface-light: #f5f8fc;
--ema-surface-dark: #0e1521;
}
body {
-webkit-font-smoothing: antialiased;
text-rendering: optimizeLegibility;
}
/* Tinted app background that adapts to the color scheme. */
[data-mantine-color-scheme='light'] body {
background-color: var(--ema-surface-light);
}
[data-mantine-color-scheme='dark'] body {
background-color: var(--ema-surface-dark);
}
[data-mantine-color-scheme] body {
transition: background-color 200ms ease;
}
/* ---- Motion utilities ---------------------------------------------------- */
@keyframes ema-fade-up {
from {
opacity: 0;
transform: translateY(8px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
.ema-page-enter {
animation: ema-fade-up 320ms cubic-bezier(0.22, 1, 0.36, 1) both;
}
/* Subtle lift on interactive cards. */
.ema-hover-lift {
transition:
transform 160ms ease,
box-shadow 160ms ease,
border-color 160ms ease;
}
.ema-hover-lift:hover {
transform: translateY(-3px);
}
@media (prefers-reduced-motion: reduce) {
.ema-page-enter,
.ema-hover-lift {
animation: none;
transition: none;
}
}

View File

@@ -0,0 +1,100 @@
import {
createTheme,
rem,
type MantineColorsTuple,
} from '@mantine/core';
// ---- Coastal Modern palette ----------------------------------------------
// Portal-only theme. Lives here (not in @ema-platform/shared) so the backoffice
// is unaffected.
const emaPrimary: MantineColorsTuple = [
'#eef4ff', '#dce7fb', '#b6cdf4', '#8db0ee', '#6c97e9',
'#5887e6', '#4b7fe5', '#3b6ccc', '#3160b7', '#2453a2',
];
// Teal accent — the "coastal" half of the palette.
const emaTeal: MantineColorsTuple = [
'#e1fbf6', '#cdf3eb', '#9ee6d7', '#6bd9c1', '#46cdaf',
'#30c7a5', '#1fc29d', '#0aab89', '#009879', '#008368',
];
// Cool neutral grays (slightly blue-tinted) for surfaces & text.
const emaGray: MantineColorsTuple = [
'#f6f8fb', '#eceff4', '#dde2eb', '#c8d0dd', '#aab5c7',
'#8d9bb3', '#73839e', '#5c6b85', '#46546b', '#333f52',
];
export const portalTheme = createTheme({
primaryColor: 'emaPrimary',
primaryShade: { light: 6, dark: 5 },
colors: {
emaPrimary,
emaTeal,
gray: emaGray,
},
fontFamily:
'Inter, -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif',
headings: {
fontFamily: 'Inter, sans-serif',
fontWeight: '700',
sizes: {
h1: { fontSize: rem(32), lineHeight: '1.25' },
h2: { fontSize: rem(25), lineHeight: '1.3' },
h3: { fontSize: rem(21), lineHeight: '1.35' },
h4: { fontSize: rem(17), lineHeight: '1.4' },
h5: { fontSize: rem(15), lineHeight: '1.45' },
},
},
defaultRadius: 'md',
radius: {
xs: rem(6),
sm: rem(8),
md: rem(12),
lg: rem(16),
xl: rem(22),
},
shadows: {
xs: '0 1px 2px rgba(15,23,42,0.06)',
sm: '0 2px 8px rgba(15,23,42,0.06), 0 1px 2px rgba(15,23,42,0.04)',
md: '0 8px 24px rgba(15,23,42,0.08)',
lg: '0 16px 40px rgba(15,23,42,0.12)',
xl: '0 24px 64px rgba(15,23,42,0.16)',
},
breakpoints: {
xs: '36em',
sm: '48em',
md: '62em',
lg: '75em',
xl: '88em',
},
cursorType: 'pointer',
components: {
Paper: {
defaultProps: { radius: 'lg' },
},
Card: {
defaultProps: { radius: 'lg' },
},
Button: {
defaultProps: { radius: 'md' },
styles: { root: { fontWeight: 600 } },
},
Badge: {
defaultProps: { radius: 'sm' },
},
ThemeIcon: {
defaultProps: { radius: 'md' },
},
NavLink: {
styles: { root: { borderRadius: rem(10), fontWeight: 500 } },
},
TextInput: { defaultProps: { radius: 'md' } },
Textarea: { defaultProps: { radius: 'md' } },
Select: { defaultProps: { radius: 'md' } },
PasswordInput: { defaultProps: { radius: 'md' } },
},
other: {
heroGradient: 'linear-gradient(135deg, #3160b7 0%, #1fc29d 100%)',
},
});

View File

@@ -2,7 +2,10 @@ import { StrictMode } from 'react';
import { createRoot } from 'react-dom/client';
import '@mantine/core/styles.css';
import '@mantine/notifications/styles.css';
import '@tria-plc/iamui-common/styles.css';
import './app/theme/portal.css';
import './app/i18n/config';
import { App } from './app/app';
const root = document.getElementById('root');

7255
backoffice-v2.pen Normal file

File diff suppressed because it is too large Load Diff

7255
backoffice.pen Normal file

File diff suppressed because it is too large Load Diff

562
ema-portal.pen Normal file
View File

@@ -0,0 +1,562 @@
{
"version": "2.13",
"children": [
{
"type": "frame",
"id": "YKsGt",
"x": 0,
"y": 0,
"name": "Welcome to Pencil",
"clip": true,
"width": 1000,
"height": 1458,
"layout": "none",
"children": [
{
"type": "text",
"id": "Lmqy1",
"x": 38,
"y": 297,
"name": "Start here",
"fill": "#ff8303ff",
"textGrowth": "fixed-width-height",
"width": 923,
"height": 307,
"content": "Ask agent to \ndesign something",
"lineHeight": 1.399999976158142,
"textAlign": "center",
"fontFamily": "Noto Sans",
"fontSize": 100,
"fontWeight": "500",
"letterSpacing": -4
},
{
"type": "path",
"id": "AmD9T",
"x": 267.8010819142764,
"y": 632,
"rotation": -45,
"geometry": "M7 0l0 14m7-7l-7 7-7-7",
"width": 366,
"height": 366,
"stroke": "#ff8302ff",
"strokeWidth": 50,
"strokeLinejoin": "round",
"strokeLinecap": "round"
}
]
}
],
"themes": {
"Mode": [
"Light",
"Dark"
]
},
"variables": {
"--sidebar": {
"type": "color",
"value": [
{
"value": "#E7E8E5"
},
{
"value": "#18181b",
"theme": {
"Mode": "Dark"
}
}
]
},
"--sidebar-foreground": {
"type": "color",
"value": [
{
"value": "#666666"
},
{
"value": "#fafafa",
"theme": {
"Mode": "Dark"
}
}
]
},
"--sidebar-primary": {
"type": "color",
"value": [
{
"value": "#18181b"
},
{
"value": "#18181b",
"theme": {
"Mode": "Dark"
}
}
]
},
"--sidebar-primary-foreground": {
"type": "color",
"value": [
{
"value": "#fafafa"
},
{
"value": "#fafafa",
"theme": {
"Mode": "Dark"
}
}
]
},
"--sidebar-border": {
"type": "color",
"value": [
{
"value": "#CBCCC9"
},
{
"value": "#ffffff1a",
"theme": {
"Mode": "Dark"
}
}
]
},
"--sidebar-accent": {
"type": "color",
"value": [
{
"value": "#CBCCC9"
},
{
"value": "#2a2a30",
"theme": {
"Mode": "Dark"
}
}
]
},
"--sidebar-accent-foreground": {
"type": "color",
"value": [
{
"value": "#18181b"
},
{
"value": "#fafafa",
"theme": {
"Mode": "Dark"
}
}
]
},
"--sidebar-ring": {
"type": "color",
"value": [
{
"value": "#71717a"
},
{
"value": "#71717a",
"theme": {
"Mode": "Dark"
}
}
]
},
"--background": {
"type": "color",
"value": [
{
"value": "#F2F3F0"
},
{
"value": "#111111",
"theme": {
"Mode": "Dark"
}
}
]
},
"--foreground": {
"type": "color",
"value": [
{
"value": "#111111"
},
{
"value": "#FFFFFF",
"theme": {
"Mode": "Dark"
}
}
]
},
"--card": {
"type": "color",
"value": [
{
"value": "#FFFFFF"
},
{
"value": "#1A1A1A",
"theme": {
"Mode": "Dark"
}
}
]
},
"--card-foreground": {
"type": "color",
"value": [
{
"value": "#111111"
},
{
"value": "#FFFFFF",
"theme": {
"Mode": "Dark"
}
}
]
},
"--popover": {
"type": "color",
"value": [
{
"value": "#FFFFFF"
},
{
"value": "#1A1A1A",
"theme": {
"Mode": "Dark"
}
}
]
},
"--popover-foreground": {
"type": "color",
"value": [
{
"value": "#111111"
},
{
"value": "#FFFFFF",
"theme": {
"Mode": "Dark"
}
}
]
},
"--primary": {
"type": "color",
"value": [
{
"value": "#FF8400"
},
{
"value": "#FF8400",
"theme": {
"Mode": "Dark"
}
}
]
},
"--primary-foreground": {
"type": "color",
"value": [
{
"value": "#111111"
},
{
"value": "#111111",
"theme": {
"Mode": "Dark"
}
}
]
},
"--secondary": {
"type": "color",
"value": [
{
"value": "#E7E8E5"
},
{
"value": "#2E2E2E",
"theme": {
"Mode": "Dark"
}
}
]
},
"--secondary-foreground": {
"type": "color",
"value": [
{
"value": "#111111"
},
{
"value": "#FFFFFF",
"theme": {
"Mode": "Dark"
}
}
]
},
"--muted": {
"type": "color",
"value": [
{
"value": "#F2F3F0"
},
{
"value": "#2E2E2E",
"theme": {
"Mode": "Dark"
}
}
]
},
"--muted-foreground": {
"type": "color",
"value": [
{
"value": "#666666"
},
{
"value": "#B8B9B6",
"theme": {
"Mode": "Dark"
}
}
]
},
"--accent": {
"type": "color",
"value": [
{
"value": "#F2F3F0"
},
{
"value": "#111111",
"theme": {
"Mode": "Dark"
}
}
]
},
"--accent-foreground": {
"type": "color",
"value": [
{
"value": "#111111"
},
{
"value": "#F2F3F0",
"theme": {
"Mode": "Dark"
}
}
]
},
"--destructive": {
"type": "color",
"value": [
{
"value": "#D93C15"
},
{
"value": "#FF5C33",
"theme": {
"Mode": "Dark"
}
}
]
},
"--border": {
"type": "color",
"value": [
{
"value": "#CBCCC9"
},
{
"value": "#2E2E2E",
"theme": {
"Mode": "Dark"
}
}
]
},
"--input": {
"type": "color",
"value": [
{
"value": "#CBCCC9"
},
{
"value": "#2E2E2E",
"theme": {
"Mode": "Dark"
}
}
]
},
"--ring": {
"type": "color",
"value": [
{
"value": "#666666"
},
{
"value": "#666666",
"theme": {
"Mode": "Dark"
}
}
]
},
"--white": {
"type": "color",
"value": "#FFFFFF"
},
"--black": {
"type": "color",
"value": "#000000"
},
"--font-primary": {
"type": "string",
"value": "JetBrains Mono"
},
"--font-secondary": {
"type": "string",
"value": "Geist"
},
"--radius-none": {
"type": "number",
"value": 0
},
"--radius-pill": {
"type": "number",
"value": 999
},
"--color-success": {
"type": "color",
"value": [
{
"value": "#DFE6E1"
},
{
"value": "#222924",
"theme": {
"Mode": "Dark"
}
}
]
},
"--color-success-foreground": {
"type": "color",
"value": [
{
"value": "#004D1A"
},
{
"value": "#B6FFCE",
"theme": {
"Mode": "Dark"
}
}
]
},
"--color-warning": {
"type": "color",
"value": [
{
"value": "#E9E3D8"
},
{
"value": "#291C0F",
"theme": {
"Mode": "Dark"
}
}
]
},
"--color-warning-foreground": {
"type": "color",
"value": [
{
"value": "#804200"
},
{
"value": "#FF8400",
"theme": {
"Mode": "Dark"
}
}
]
},
"--color-error": {
"type": "color",
"value": [
{
"value": "#E5DCDA"
},
{
"value": "#24100B",
"theme": {
"Mode": "Dark"
}
}
]
},
"--color-error-foreground": {
"type": "color",
"value": [
{
"value": "#8C1C00"
},
{
"value": "#FF5C33",
"theme": {
"Mode": "Dark"
}
}
]
},
"--color-info": {
"type": "color",
"value": [
{
"value": "#DFDFE6"
},
{
"value": "#222229",
"theme": {
"Mode": "Dark"
}
}
]
},
"--color-info-foreground": {
"type": "color",
"value": [
{
"value": "#000066"
},
{
"value": "#B2B2FF",
"theme": {
"Mode": "Dark"
}
}
]
},
"--radius-m": {
"type": "number",
"value": 16
}
}
}

View File

@@ -1,3 +1,4 @@
export * from './lib/base-api';
export * from './lib/query-and-mutation';
export * from './lib/session';
export { baseQueryWithReauth, configureTokenRefresh } from './lib/base-api/base-query-with-reauth';

View File

@@ -0,0 +1,53 @@
import { fetchBaseQuery, type BaseQueryFn } from '@reduxjs/toolkit/query/react';
import type { FetchArgs, FetchBaseQueryError } from '@reduxjs/toolkit/query';
import { resolveSessionContext } from '../session';
const BASE_API_URL =
(import.meta as { env?: Record<string, string> }).env?.['VITE_BASE_API_URL'] ??
'http://localhost:3001/api';
let _onTokenExpired: (() => Promise<string>) | null = null;
let _onAuthFailure: (() => void) | null = null;
export function configureTokenRefresh(config: {
onTokenExpired: () => Promise<string>;
onAuthFailure: () => void;
}) {
_onTokenExpired = config.onTokenExpired;
_onAuthFailure = config.onAuthFailure;
}
export const baseQueryWithReauth: BaseQueryFn<
string | FetchArgs,
unknown,
FetchBaseQueryError
> = async (args, api, extraOptions) => {
const baseQuery = fetchBaseQuery({
baseUrl: BASE_API_URL,
prepareHeaders: (headers) => {
const { token, sessionHeaders } = resolveSessionContext(
api.getState() as { auth?: { token?: string } },
);
if (token) headers.set('Authorization', `Bearer ${token}`);
Object.entries(sessionHeaders).forEach(([k, v]) => headers.set(k, v));
return headers;
},
});
let result = await baseQuery(args, api, extraOptions);
if (result.error?.status === 401) {
if (_onTokenExpired) {
try {
await _onTokenExpired();
result = await baseQuery(args, api, extraOptions);
} catch {
_onAuthFailure?.();
}
} else {
_onAuthFailure?.();
}
}
return result;
};

View File

@@ -1,23 +1,9 @@
import { createApi, fetchBaseQuery } from '@reduxjs/toolkit/query/react';
import { resolveSessionContext } from '../session';
const BASE_API_URL =
(import.meta as { env?: Record<string, string> }).env?.['VITE_BASE_API_URL'] ??
'http://localhost:3001/api';
import { createApi } from '@reduxjs/toolkit/query/react';
import { baseQueryWithReauth } from './base-query-with-reauth';
export const baseApi = createApi({
reducerPath: 'baseApi',
baseQuery: fetchBaseQuery({
baseUrl: BASE_API_URL,
prepareHeaders: (headers, { getState }) => {
const { token, sessionHeaders } = resolveSessionContext(
getState() as { auth?: { token?: string } },
);
if (token) headers.set('Authorization', `Bearer ${token}`);
Object.entries(sessionHeaders).forEach(([k, v]) => headers.set(k, v));
return headers;
},
}),
baseQuery: baseQueryWithReauth,
tagTypes: ['Api'],
endpoints: () => ({}),
});

7
libs/auth/project.json Normal file
View File

@@ -0,0 +1,7 @@
{
"name": "@ema-platform/auth",
"$schema": "../../node_modules/nx/schemas/project-schema.json",
"sourceRoot": "libs/auth/src",
"projectType": "library",
"tags": []
}

13
libs/auth/src/index.ts Normal file
View File

@@ -0,0 +1,13 @@
export { AuthConfigProvider, useAuthConfig } from './lib/AuthConfig';
export type { AuthConfigValue } from './lib/AuthConfig';
export { AuthShell, BrandMark } from './lib/components/AuthShell';
export { ProtectedRoute } from './lib/components/ProtectedRoute';
export { LoginPage } from './lib/pages/LoginPage';
export { SignupPage } from './lib/pages/SignupPage';
export { ForgotPasswordPage } from './lib/pages/ForgotPasswordPage';
export { OTPVerificationPage } from './lib/pages/OTPVerificationPage';
export { authReducer, loginSuccess, setUser, logout, hydrateAuth } from './lib/store/auth.slice';
export { signupReducer, setSignupData, setSignupStep, resetSignup } from './lib/store/signup.slice';
export { configureAuthStorage, authStorage } from './lib/utils/auth-storage';
export { refreshAccessToken } from './lib/utils/refresh-token';
export type { AuthUser, AuthState, LoginPayload } from './lib/types/auth.types';

View File

@@ -0,0 +1,40 @@
import { createContext, useContext, type ReactNode } from 'react';
export interface AuthConfigValue {
appName: string;
storagePrefix: string;
loginRedirectPath: string;
enableSignup: boolean;
enableForgotPassword: boolean;
logoUrl: string;
}
const defaultConfig: AuthConfigValue = {
appName: 'Portal',
storagePrefix: 'ema-auth',
loginRedirectPath: '/dashboard',
enableSignup: true,
enableForgotPassword: true,
logoUrl: '/brand/ema-white.png',
};
const AuthConfigContext = createContext<AuthConfigValue>(defaultConfig);
export function AuthConfigProvider({
children,
value,
}: {
children: ReactNode;
value: Partial<AuthConfigValue>;
}) {
const merged = { ...defaultConfig, ...value };
return (
<AuthConfigContext.Provider value={merged}>
{children}
</AuthConfigContext.Provider>
);
}
export function useAuthConfig() {
return useContext(AuthConfigContext);
}

View File

@@ -0,0 +1,216 @@
import type { ReactNode } from 'react';
import {
Box,
Center,
Flex,
Group,
Stack,
Text,
Title,
UnstyledButton,
rem,
useComputedColorScheme,
useMantineColorScheme,
useMantineTheme,
type BoxProps,
} from '@mantine/core';
import { IconCheck, IconMoon, IconSun } from '@tabler/icons-react';
import { useAuthConfig } from '../AuthConfig';
export function BrandMark({ size = 44, ...boxProps }: BoxProps & { size?: number }) {
const { logoUrl } = useAuthConfig();
return (
<Box
component="img"
src={logoUrl}
alt="EMA"
w={size}
h={size}
style={{ display: 'block', objectFit: 'contain', flexShrink: 0 }}
{...boxProps}
/>
);
}
function ThemeToggle() {
const { setColorScheme } = useMantineColorScheme();
const computed = useComputedColorScheme('light');
const isDark = computed === 'dark';
return (
<UnstyledButton
onClick={() => setColorScheme(isDark ? 'light' : 'dark')}
aria-label="Toggle theme"
style={{
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
width: rem(36),
height: rem(36),
borderRadius: rem(10),
border: '1px solid var(--mantine-color-default-border)',
background: 'var(--mantine-color-body)',
color: 'var(--mantine-color-dimmed)',
cursor: 'pointer',
transition: 'all 150ms ease',
}}
>
{isDark ? <IconSun size={18} /> : <IconMoon size={18} />}
</UnstyledButton>
);
}
const FEATURES = [
'Submit applications online, 24/7',
'Real-time status tracking & alerts',
'Available in English & አማርኛ',
];
interface AuthShellProps {
children: ReactNode;
brandTitle?: string;
brandSubtitle?: string;
}
export function AuthShell({
children,
brandTitle = 'Maritime licensing, made simple.',
brandSubtitle = 'Apply for vessel and seafarer licenses, upload documents, and track every application in one secure portal.',
}: AuthShellProps) {
const theme = useMantineTheme();
const { logoUrl } = useAuthConfig();
const heroGradient = theme.other.heroGradient as string;
const computed = useComputedColorScheme('light');
const isDark = computed === 'dark';
return (
<Box
mih="100vh"
style={{
background: 'var(--mantine-color-body)',
position: 'relative',
}}
p="md"
>
{/* Theme toggle — top-right corner */}
<Box
style={{
position: 'absolute',
top: rem(16),
right: rem(16),
zIndex: 10,
}}
>
<ThemeToggle />
</Box>
<Flex
mih="100vh"
align="center"
justify="center"
>
<Box
mx="auto"
maw={1280}
w="100%"
p={12}
style={{
borderRadius: rem(16),
background: 'var(--mantine-color-body)',
boxShadow: isDark
? '0 20px 60px rgba(0,0,0,0.5)'
: '0 20px 60px rgba(0,0,0,0.15)',
overflow: 'hidden',
}}
>
<Flex direction={{ base: 'column', lg: 'row' }}>
<Box
w={{ base: '100%', lg: '50%' }}
px={48}
py={48}
style={{ display: 'flex', flexDirection: 'column', justifyContent: 'center' }}
>
{children}
</Box>
<Box
visibleFrom="lg"
w="50%"
p="lg"
pos="relative"
style={{ background: heroGradient, overflow: 'hidden' }}
>
<Box
pos="absolute"
top={-80}
right={-40}
w={240}
h={240}
style={{ borderRadius: '50%', background: 'rgba(255,255,255,0.10)' }}
/>
<Box
pos="absolute"
bottom={-80}
left={-60}
w={220}
h={220}
style={{ borderRadius: '50%', background: 'rgba(255,255,255,0.08)' }}
/>
<Stack
gap="lg"
pos="relative"
style={{ zIndex: 1 }}
h="100%"
justify="center"
>
<Center>
<Box
component="img"
src={logoUrl}
alt="EMA Portal"
style={{ display: 'block', maxWidth: '60%', height: 'auto' }}
/>
</Center>
<Stack gap={6}>
<Title order={2} c="white" fz={rem(28)} lh={1.2} fw={700}>
{brandTitle}
</Title>
<Text fz="sm" lh={1.6} style={{ color: 'rgba(255,255,255,0.85)' }}>
{brandSubtitle}
</Text>
</Stack>
<Stack gap="sm">
{FEATURES.map((feature) => (
<Group key={feature} gap="sm" wrap="nowrap">
<Center
w={22}
h={22}
style={{
borderRadius: '50%',
background: 'rgba(255,255,255,0.2)',
flexShrink: 0,
}}
>
<IconCheck size={12} color="white" stroke={2.4} />
</Center>
<Text c="white" fz="sm" fw={500}>
{feature}
</Text>
</Group>
))}
</Stack>
<Text fz="xs" style={{ color: 'rgba(255,255,255,0.7)' }}>
© 2026 Ethiopian Maritime Authority
</Text>
</Stack>
</Box>
</Flex>
</Box>
</Flex>
</Box>
);
}

View File

@@ -0,0 +1,24 @@
import { Navigate, Outlet, useLocation } from 'react-router-dom';
import type { ReactNode } from 'react';
import { authStorage } from '../utils/auth-storage';
interface ProtectedRouteProps {
children?: ReactNode;
loginPath?: string;
}
function getTokenFromCookie(): string | undefined {
const match = document.cookie.match(/(?:^|;\s*)auth-token=([^;]*)/);
return match ? decodeURIComponent(match[1]) : undefined;
}
export function ProtectedRoute({ children, loginPath = '/login' }: ProtectedRouteProps) {
const location = useLocation();
const token = authStorage.getToken() ?? getTokenFromCookie();
if (!token) {
return <Navigate to={loginPath} state={{ from: location }} replace />;
}
return children ? <>{children}</> : <Outlet />;
}

View File

@@ -0,0 +1,190 @@
import { useState } from 'react';
import {
Anchor,
Button,
Center,
Group,
Stack,
Text,
TextInput,
ThemeIcon,
Title,
} from '@mantine/core';
import {
IconArrowLeft,
IconMail,
IconMailForward,
IconSend,
} from '@tabler/icons-react';
import { useForm } from 'react-hook-form';
import { zodResolver } from '@hookform/resolvers/zod';
import { z } from 'zod';
import { Link } from 'react-router-dom';
import { useApiMutation } from '@ema-platform/api';
import { notify } from '@ema-platform/ui';
import { AuthShell } from '../components/AuthShell';
import { useAuthConfig } from '../AuthConfig';
const schema = z.object({
email: z.string().email({ message: 'Enter a valid email' }),
});
type FormValues = z.infer<typeof schema>;
export function ForgotPasswordPage() {
const { appName } = useAuthConfig();
const [forgotTrigger, { isLoading }] = useApiMutation();
const [sentTo, setSentTo] = useState<string | null>(null);
const {
register,
handleSubmit,
formState: { errors },
} = useForm<FormValues>({
resolver: zodResolver(schema),
});
const sendResetLink = async (email: string) => {
await forgotTrigger({
url: '/auth/forgot-password',
method: 'POST',
body: { email },
}).unwrap();
setSentTo(email);
};
const onSubmit = async (values: FormValues) => {
try {
await sendResetLink(values.email);
} catch (err) {
const msg = err instanceof Error ? err.message : 'Something went wrong';
notify.error(msg);
}
};
const handleResend = async () => {
if (!sentTo || isLoading) return;
try {
await sendResetLink(sentTo);
notify.success('Reset link sent again');
} catch (err) {
const msg = err instanceof Error ? err.message : 'Something went wrong';
notify.error(msg);
}
};
const backToSignIn = (
<Center>
<Anchor
component={Link}
to="/login"
size="sm"
fw={600}
style={{ display: 'inline-flex', alignItems: 'center', gap: 6 }}
>
<IconArrowLeft size={16} />
Back to sign in
</Anchor>
</Center>
);
if (sentTo) {
return (
<AuthShell
brandTitle="Reset your password securely."
brandSubtitle={`We'll email you a secure link to set a new password and get you back into ${appName}.`}
>
<Stack gap="lg">
<ThemeIcon size={56} radius="md" variant="light" color="emaPrimary">
<IconMailForward size={30} />
</ThemeIcon>
<div>
<Title order={2} fz={30}>
Check your email
</Title>
<Text c="dimmed" mt={6}>
We&apos;ve sent a password reset link to{' '}
<Text span fw={600} c="dark">
{sentTo}
</Text>
. Follow the link in that email to choose a new password.
</Text>
</div>
<Button
component="a"
href="https://mail.google.com"
target="_blank"
rel="noopener noreferrer"
fullWidth
size="md"
leftSection={<IconMail size={18} />}
>
Open email app
</Button>
<Group justify="center" gap={6}>
<Text size="sm" c="dimmed">
Didn&apos;t get the email?
</Text>
<Anchor
size="sm"
fw={600}
onClick={handleResend}
style={isLoading ? { pointerEvents: 'none', opacity: 0.6 } : undefined}
>
Resend link
</Anchor>
</Group>
{backToSignIn}
</Stack>
</AuthShell>
);
}
return (
<AuthShell
brandTitle="Reset your password securely."
brandSubtitle={`We'll email you a secure link to set a new password and get you back into ${appName}.`}
>
<Stack gap="lg">
<div>
<Title order={2} fz={30}>
Forgot your password?
</Title>
<Text c="dimmed" mt={6}>
Enter the email linked to your account and we&apos;ll send you a link
to reset your password.
</Text>
</div>
<form onSubmit={handleSubmit(onSubmit)}>
<Stack gap="md">
<TextInput
label="Email address"
placeholder="you@example.com"
size="md"
leftSection={<IconMail size={18} />}
error={errors.email?.message}
{...register('email')}
/>
<Button
type="submit"
loading={isLoading}
fullWidth
size="md"
rightSection={<IconSend size={18} />}
>
Send reset link
</Button>
</Stack>
</form>
{backToSignIn}
</Stack>
</AuthShell>
);
}

View File

@@ -0,0 +1,172 @@
import { useState } from 'react';
import {
Anchor,
Button,
Checkbox,
Divider,
Group,
PasswordInput,
Stack,
Text,
TextInput,
Title,
} from '@mantine/core';
import {
IconArrowRight,
IconDeviceMobile,
IconLock,
IconMail,
} from '@tabler/icons-react';
import { useForm } from 'react-hook-form';
import { zodResolver } from '@hookform/resolvers/zod';
import { z } from 'zod';
import { useNavigate, Link } from 'react-router-dom';
import { useDispatch } from 'react-redux';
import { useApiMutation } from '@ema-platform/api';
import { notify } from '@ema-platform/ui';
import { AuthShell } from '../components/AuthShell';
import { loginSuccess, setUser } from '../store/auth.slice';
import type { LoginPayload, AuthUser } from '../types/auth.types';
import { useAuthConfig } from '../AuthConfig';
const schema = z.object({
email: z.string().email({ message: 'Enter a valid email' }),
password: z.string().min(5, { message: 'Password must be at least 6 characters' }),
});
type FormValues = z.infer<typeof schema>;
export function LoginPage() {
const navigate = useNavigate();
const dispatch = useDispatch();
const { appName, loginRedirectPath, enableSignup, enableForgotPassword } =
useAuthConfig();
const [isLoading, setIsLoading] = useState(false);
const [rememberMe, setRememberMe] = useState(true);
const [loginTrigger] = useApiMutation<LoginPayload>();
const [meTrigger] = useApiMutation<AuthUser>();
const {
register,
handleSubmit,
formState: { errors },
} = useForm<FormValues>({
resolver: zodResolver(schema),
});
const onSubmit = async (values: FormValues) => {
setIsLoading(true);
try {
const data = await loginTrigger({
url: '/auth/login',
method: 'POST',
body: values,
}).unwrap();
dispatch(loginSuccess(data));
const me = await meTrigger({
url: '/auth/me',
method: 'GET',
}).unwrap();
dispatch(setUser(me));
if (me.isPhoneNumberVerified) {
navigate(loginRedirectPath);
} else {
navigate('/otp-verify', {
state: { email: me.email, phoneNumber: me.phoneNumber },
});
}
} catch {
notify.error('Invalid email or password');
} finally {
setIsLoading(false);
}
};
return (
<AuthShell>
<Stack gap="lg">
<div>
<Title order={2} fz={30}>
Welcome to {appName}
</Title>
<Text c="dimmed" mt={6}>
Sign in to access your account.
</Text>
</div>
<form onSubmit={handleSubmit(onSubmit)}>
<Stack gap="md">
<TextInput
label="Email or phone"
placeholder="you@example.com"
size="md"
leftSection={<IconMail size={18} />}
error={errors.email?.message}
{...register('email')}
/>
<PasswordInput
label="Password"
placeholder="Your password"
size="md"
leftSection={<IconLock size={18} />}
error={errors.password?.message}
{...register('password')}
/>
<Group justify="space-between">
<Checkbox
label="Remember me"
size="sm"
checked={rememberMe}
onChange={(e) => setRememberMe(e.currentTarget.checked)}
/>
{enableForgotPassword && (
<Anchor
component={Link}
to="/forgot-password"
size="sm"
fw={600}
>
Forgot password?
</Anchor>
)}
</Group>
<Button
type="submit"
loading={isLoading}
fullWidth
size="md"
rightSection={<IconArrowRight size={18} />}
>
Sign in
</Button>
</Stack>
</form>
<Divider label="or" labelPosition="center" />
<Button
variant="default"
fullWidth
size="md"
leftSection={<IconDeviceMobile size={18} />}
onClick={() => notify.info('Phone sign-in is coming soon.')}
>
Sign in with phone number
</Button>
{enableSignup && (
<Text ta="center" size="sm" c="dimmed">
Don&apos;t have an account?{' '}
<Anchor component={Link} to="/signup" fw={700}>
Create one
</Anchor>
</Text>
)}
</Stack>
</AuthShell>
);
}

View File

@@ -0,0 +1,200 @@
import { useEffect, useState } from 'react';
import {
Anchor,
Button,
Center,
Divider,
Group,
PinInput,
Stack,
Text,
ThemeIcon,
Title,
} from '@mantine/core';
import { IconArrowRight, IconShieldLock } from '@tabler/icons-react';
import { Controller, useForm } from 'react-hook-form';
import { zodResolver } from '@hookform/resolvers/zod';
import { z } from 'zod';
import { useNavigate, useLocation } from 'react-router-dom';
import { useApiMutation } from '@ema-platform/api';
import { notify } from '@ema-platform/ui';
import { AuthShell } from '../components/AuthShell';
import { useAuthConfig } from '../AuthConfig';
const CODE_LENGTH = 6;
const RESEND_SECONDS = 30;
const schema = z.object({
verificationCode: z
.string()
.length(CODE_LENGTH, { message: `Enter the ${CODE_LENGTH}-digit code` }),
});
type FormValues = z.infer<typeof schema>;
export function OTPVerificationPage() {
const navigate = useNavigate();
const location = useLocation();
const { loginRedirectPath } = useAuthConfig();
const state = location.state as
| { email?: string; phoneNumber?: string }
| null;
const email = state?.email ?? '';
const phoneNumber = state?.phoneNumber ?? '';
const [verifyTrigger, { isLoading: loading }] = useApiMutation();
const [resendTrigger, { isLoading: resending }] = useApiMutation();
const [secondsLeft, setSecondsLeft] = useState(RESEND_SECONDS);
const {
control,
handleSubmit,
formState: { errors },
} = useForm<FormValues>({
resolver: zodResolver(schema),
defaultValues: { verificationCode: '' },
});
useEffect(() => {
if (secondsLeft <= 0) return;
const id = setInterval(() => setSecondsLeft((s) => s - 1), 1000);
return () => clearInterval(id);
}, [secondsLeft]);
const onSubmit = async (values: FormValues) => {
try {
await verifyTrigger({
url: '/auth/verify-phone-number',
method: 'PATCH',
body: { email, phoneNumber, verificationCode: values.verificationCode },
}).unwrap();
notify.success('Phone number verified successfully');
navigate(loginRedirectPath);
} catch (err) {
const msg = err instanceof Error ? err.message : 'Something went wrong';
notify.error(msg);
}
};
const handleResendOtp = async () => {
if (secondsLeft > 0 || resending) return;
try {
await resendTrigger({
url: '/auth/resend-otp',
method: 'POST',
body: { email },
}).unwrap();
notify.success('Verification code resent to your email');
setSecondsLeft(RESEND_SECONDS);
} catch (err) {
const msg = err instanceof Error ? err.message : 'Something went wrong';
notify.error(msg);
}
};
return (
<AuthShell
brandTitle="One last step to secure your account."
brandSubtitle="We use a one-time code to confirm it's really you before granting access."
>
<Stack gap="lg">
<ThemeIcon size={56} radius="md" variant="light" color="emaPrimary">
<IconShieldLock size={30} />
</ThemeIcon>
<div>
<Title order={2} fz={30}>
Verify your account
</Title>
<Text c="dimmed" mt={6}>
A verification code has been sent to{' '}
<Text span fw={600} c="dark">
{email || 'your email'}
</Text>
. Enter it below to continue.
</Text>
</div>
<form onSubmit={handleSubmit(onSubmit)}>
<Stack gap="md">
<Controller
control={control}
name="verificationCode"
render={({ field }) => (
<Stack gap={6} align="center">
<PinInput
length={CODE_LENGTH}
type="number"
inputMode="numeric"
oneTimeCode
size="md"
gap="sm"
error={!!errors.verificationCode}
value={field.value}
onChange={field.onChange}
onComplete={() => handleSubmit(onSubmit)()}
/>
{errors.verificationCode?.message && (
<Text c="red" size="sm">
{errors.verificationCode.message}
</Text>
)}
</Stack>
)}
/>
<Button
type="submit"
loading={loading}
fullWidth
size="md"
rightSection={<IconArrowRight size={18} />}
>
Verify
</Button>
</Stack>
</form>
<Divider
label="Having trouble?"
labelPosition="center"
variant="dashed"
/>
<Button
variant="light"
fullWidth
size="md"
onClick={() => navigate(loginRedirectPath)}
>
Skip verification for now
</Button>
<Center>
<Group justify="center" gap={6}>
<Text size="sm" c="dimmed">
Didn&apos;t receive a code?
</Text>
{secondsLeft > 0 ? (
<Text size="sm" c="dimmed" fw={600}>
Resend in {secondsLeft}s
</Text>
) : (
<Anchor
size="sm"
fw={600}
onClick={handleResendOtp}
style={
resending ? { pointerEvents: 'none', opacity: 0.6 } : undefined
}
>
Resend code
</Anchor>
)}
</Group>
</Center>
</Stack>
</AuthShell>
);
}

View File

@@ -1,22 +1,34 @@
import { useState } from 'react';
import {
Paper,
TextInput,
PasswordInput,
Button,
Stack,
Title,
Center,
Text,
Anchor,
Button,
Checkbox,
Group,
PasswordInput,
SimpleGrid,
Stack,
Text,
TextInput,
Title,
} from '@mantine/core';
import {
IconArrowRight,
IconAt,
IconDeviceMobile,
IconLock,
IconMail,
IconUser,
} from '@tabler/icons-react';
import { useForm } from 'react-hook-form';
import { zodResolver } from '@hookform/resolvers/zod';
import { z } from 'zod';
import { useNavigate, Link } from 'react-router-dom';
import { useAppDispatch } from '../../../store/hooks';
import { loginSuccess } from '../store/auth.slice';
import { useDispatch } from 'react-redux';
import { useApiMutation } from '@ema-platform/api';
import { notify } from '@ema-platform/ui';
import { AuthShell } from '../components/AuthShell';
import { loginSuccess } from '../store/auth.slice';
import { useAuthConfig } from '../AuthConfig';
const schema = z
.object({
@@ -51,7 +63,9 @@ interface SignupPayload {
export function SignupPage() {
const navigate = useNavigate();
const dispatch = useAppDispatch();
const dispatch = useDispatch();
const { appName, loginRedirectPath } = useAuthConfig();
const [agreed, setAgreed] = useState(false);
const [signupTrigger, { isLoading: loading }] = useApiMutation<{
token: string;
refreshToken: string;
@@ -93,9 +107,13 @@ export function SignupPage() {
}),
);
navigate('/otp-verify', {
state: { email: values.email, phoneNumber: values.phoneNumber },
});
if (data.isPhoneNumberVerified) {
navigate(loginRedirectPath);
} else {
navigate('/otp-verify', {
state: { email: values.email, phoneNumber: values.phoneNumber },
});
}
} catch (err) {
const msg = err instanceof Error ? err.message : 'Something went wrong';
notify.error(msg);
@@ -103,67 +121,119 @@ export function SignupPage() {
};
return (
<Center h="100vh">
<Paper p="xl" shadow="md" radius="md" w={400}>
<Stack gap="md">
<Title order={3}>Create an account</Title>
<form onSubmit={handleSubmit(onSubmit)}>
<Stack gap="sm">
<AuthShell
brandTitle={`Join ${appName}'s community.`}
brandSubtitle={`Create your account to access ${appName} features.`}
>
<Stack gap="lg">
<div>
<Title order={2} fz={30}>
Create account
</Title>
<Text c="dimmed" mt={6}>
It only takes a minute to get started.
</Text>
</div>
<form onSubmit={handleSubmit(onSubmit)}>
<Stack gap="md">
<SimpleGrid cols={{ base: 1, xs: 2 }} spacing="md">
<TextInput
label="Name (English)"
placeholder="Your name in English"
placeholder="Abebe Bekele"
leftSection={<IconUser size={18} />}
error={errors.nameEn?.message}
{...register('nameEn')}
/>
<TextInput
label="Name (Amharic)"
placeholder="Your name in Amharic"
placeholder="ስም"
leftSection={<IconUser size={18} />}
error={errors.nameAm?.message}
{...register('nameAm')}
/>
</SimpleGrid>
<SimpleGrid cols={{ base: 1, xs: 2 }} spacing="md">
<TextInput
label="Email"
label="Email address"
placeholder="you@example.com"
leftSection={<IconMail size={18} />}
error={errors.email?.message}
{...register('email')}
/>
<TextInput
label="Username"
placeholder="Choose a username"
leftSection={<IconAt size={18} />}
error={errors.username?.message}
{...register('username')}
/>
<TextInput
label="Phone Number"
placeholder="+251 911 234 567"
error={errors.phoneNumber?.message}
{...register('phoneNumber')}
/>
</SimpleGrid>
<TextInput
label="Phone number"
placeholder="+251 911 234 567"
leftSection={<IconDeviceMobile size={18} />}
error={errors.phoneNumber?.message}
{...register('phoneNumber')}
/>
<SimpleGrid cols={{ base: 1, xs: 2 }} spacing="md">
<PasswordInput
label="Password"
placeholder="Enter a password"
placeholder="At least 8 characters"
leftSection={<IconLock size={18} />}
error={errors.password?.message}
{...register('password')}
/>
<PasswordInput
label="Confirm Password"
placeholder="Confirm your password"
label="Confirm password"
placeholder="Re-enter password"
leftSection={<IconLock size={18} />}
error={errors.confirmPassword?.message}
{...register('confirmPassword')}
/>
<Button type="submit" loading={loading} fullWidth mt="sm">
Sign up
</Button>
</Stack>
</form>
<Text ta="center" size="sm" c="dimmed">
Already have an account?{' '}
<Anchor component={Link} to="/login">
Sign in
</Anchor>
</Text>
</Stack>
</Paper>
</Center>
</SimpleGrid>
<Checkbox
size="sm"
checked={agreed}
onChange={(e) => setAgreed(e.currentTarget.checked)}
label={
<Text size="sm">
I agree to the{' '}
<Anchor
size="sm"
fw={600}
onClick={(e) => e.preventDefault()}
>
Terms &amp; Privacy Policy
</Anchor>
</Text>
}
/>
<Button
type="submit"
loading={loading}
disabled={!agreed}
fullWidth
size="md"
rightSection={<IconArrowRight size={18} />}
>
Create account
</Button>
</Stack>
</form>
<Text ta="center" size="sm" c="dimmed">
Already have an account?{' '}
<Anchor component={Link} to="/login" fw={700}>
Sign in
</Anchor>
</Text>
</Stack>
</AuthShell>
);
}

View File

@@ -30,7 +30,7 @@ const authSlice = createSlice({
},
hydrateAuth(state) {
const token = authStorage.getToken();
const user = authStorage.getUser();
const user = authStorage.getUser<AuthUser>();
if (token && user) {
state.token = token;
state.user = user;

View File

@@ -0,0 +1,33 @@
import { createSlice, type PayloadAction } from '@reduxjs/toolkit';
interface SignupState {
email: string;
phoneNumber: string;
step: 'form' | 'otp' | 'complete';
}
const initialState: SignupState = {
email: '',
phoneNumber: '',
step: 'form',
};
const signupSlice = createSlice({
name: 'signup',
initialState,
reducers: {
setSignupData(state, action: PayloadAction<{ email: string; phoneNumber: string }>) {
state.email = action.payload.email;
state.phoneNumber = action.payload.phoneNumber;
},
setSignupStep(state, action: PayloadAction<SignupState['step']>) {
state.step = action.payload;
},
resetSignup() {
return initialState;
},
},
});
export const { setSignupData, setSignupStep, resetSignup } = signupSlice.actions;
export const signupReducer = signupSlice.reducer;

View File

@@ -12,6 +12,7 @@ export interface AuthUser {
hasSetPassword: boolean;
hasFinishedRegistration: boolean;
hasFinishedDMSOnboarding: boolean;
isPhoneNumberVerified: boolean;
}
export interface AuthState {

View File

@@ -0,0 +1,31 @@
let _prefix = 'ema-auth';
export function configureAuthStorage(prefix: string) {
_prefix = prefix;
}
function key(k: string) {
return `${_prefix}-${k}`;
}
export const authStorage = {
getToken: () => localStorage.getItem(key('auth-token')) ?? undefined,
setToken: (token: string) => localStorage.setItem(key('auth-token'), token),
getRefreshToken: () => localStorage.getItem(key('refresh-token')) ?? undefined,
setRefreshToken: (t: string) => localStorage.setItem(key('refresh-token'), t),
getUser: <T = unknown>(): T | null => {
try {
return JSON.parse(localStorage.getItem(key('auth-user')) ?? 'null') as T | null;
} catch {
return null;
}
},
setUser: <T>(u: T) => localStorage.setItem(key('auth-user'), JSON.stringify(u)),
clear: () => {
[key('auth-token'), key('refresh-token'), key('auth-user')].forEach((k) =>
localStorage.removeItem(k),
);
document.cookie =
'auth-token=; path=/; expires=Thu, 01 Jan 1970 00:00:00 UTC; SameSite=Lax';
},
};

View File

@@ -0,0 +1,31 @@
import { authStorage } from './auth-storage';
const BASE_API_URL =
(import.meta as { env?: Record<string, string> }).env?.['VITE_BASE_API_URL'] ??
'http://localhost:3001/api';
interface RefreshResponse {
token: string;
refreshToken: string;
}
export async function refreshAccessToken(): Promise<string> {
const refreshToken = authStorage.getRefreshToken();
if (!refreshToken) throw new Error('No refresh token available');
const response = await fetch(`${BASE_API_URL}/auth/refresh`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ refreshToken }),
});
if (!response.ok) {
authStorage.clear();
throw new Error('Token refresh failed');
}
const data: RefreshResponse = await response.json();
authStorage.setToken(data.token);
if (data.refreshToken) authStorage.setRefreshToken(data.refreshToken);
return data.token;
}

5
libs/auth/tsconfig.json Normal file
View File

@@ -0,0 +1,5 @@
{
"extends": "../../tsconfig.base.json",
"compilerOptions": { "outDir": "../../dist/out-tsc" },
"include": ["src/**/*.ts", "src/**/*.tsx"]
}

View File

@@ -31,4 +31,7 @@ export const emaTheme = createTheme({
md: '0 4px 20px rgba(15,23,42,0.08)',
lg: '0 8px 30px rgba(15,23,42,0.12)',
},
other: {
heroGradient: 'linear-gradient(135deg, #3160b7 0%, #1fc29d 100%)',
},
});

View File

@@ -1,3 +1,9 @@
export * from './lib/feedback/ConfirmModal';
export * from './lib/feedback/ApiErrorAlert';
export * from './lib/feedback/notify';
export * from './lib/layout/AppHeader';
export * from './lib/layout/AppSidebar';
export * from './lib/layout/BrandAvatar';
export * from './lib/layout/ColorSchemeToggle';
export * from './lib/layout/LanguageSwitcher';
export * from './lib/layout/PageHeader';

View File

@@ -0,0 +1,271 @@
import {
Anchor,
Burger,
Group,
Indicator,
Menu,
UnstyledButton,
rem,
} from '@mantine/core';
import {
IconBell,
IconChevronRight,
IconLogout,
IconUserCircle,
} from '@tabler/icons-react';
import { useTranslation } from 'react-i18next';
import { LanguageSwitcher } from './LanguageSwitcher';
import { ColorSchemeToggle } from './ColorSchemeToggle';
import { BrandAvatar } from './BrandAvatar';
export interface Breadcrumb {
label: string;
path: string;
}
interface AppHeaderProps {
onToggleNav: () => void;
onToggleSidebar: () => void;
navOpened: boolean;
breadcrumbs: Breadcrumb[];
onNavigate: (path: string) => void;
onLogout: () => void;
userName?: string;
userInitials?: string;
supportedLanguages: readonly string[];
}
export function AppHeader({
onToggleNav,
onToggleSidebar,
navOpened,
breadcrumbs,
onNavigate,
onLogout,
userName = 'User',
userInitials = '?',
supportedLanguages,
}: AppHeaderProps) {
const isMobile = typeof window !== 'undefined' && window.innerWidth < 768;
return (
<Group h="100%" px="lg" justify="space-between" wrap="nowrap">
<Group gap="md" wrap="nowrap">
{/* Hamburger — styled like user-management Top.tsx */}
<UnstyledButton
onClick={isMobile ? onToggleNav : onToggleSidebar}
style={{
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
width: rem(38),
height: rem(38),
borderRadius: rem(12),
background: 'var(--mantine-color-body)',
color: 'var(--mantine-color-gray-6)',
boxShadow: '0 1px 3px rgba(0,0,0,0.04), 0 0 0 1px rgba(0,0,0,0.06)',
transition: 'all 150ms ease',
cursor: 'pointer',
border: 'none',
}}
onMouseEnter={(e) => {
e.currentTarget.style.background = 'var(--mantine-color-primary-light)';
e.currentTarget.style.color = 'var(--mantine-color-primary-6)';
e.currentTarget.style.boxShadow =
'0 1px 3px rgba(0,0,0,0.04), 0 0 0 1px var(--mantine-color-primary-2)';
}}
onMouseLeave={(e) => {
e.currentTarget.style.background = 'var(--mantine-color-body)';
e.currentTarget.style.color = 'var(--mantine-color-gray-6)';
e.currentTarget.style.boxShadow =
'0 1px 3px rgba(0,0,0,0.04), 0 0 0 1px rgba(0,0,0,0.06)';
}}
>
<Burger
opened={navOpened}
onClick={() => {}}
size="sm"
aria-label="Toggle navigation"
styles={{
root: { border: 'none', background: 'transparent' },
burger: { '--burger-color': 'currentColor' },
}}
/>
</UnstyledButton>
{/* Breadcrumbs — card container with pill-style crumbs */}
<Group
gap={2}
wrap="nowrap"
visibleFrom="xs"
style={{
borderRadius: rem(12),
background: 'var(--mantine-color-body)',
padding: '4px 10px',
boxShadow: '0 1px 3px rgba(0,0,0,0.04), 0 0 0 1px rgba(0,0,0,0.06)',
overflow: 'hidden',
}}
>
{breadcrumbs.map((crumb, i) => {
const isLast = i === breadcrumbs.length - 1;
return (
<div key={crumb.path} style={{ display: 'flex', alignItems: 'center', gap: rem(2) }}>
{i > 0 && (
<IconChevronRight size={14} style={{ color: 'var(--mantine-color-gray-4)', flexShrink: 0 }} />
)}
{isLast ? (
<span
style={{
display: 'inline-flex',
alignItems: 'center',
borderRadius: rem(999),
padding: '2px 12px',
fontSize: rem(12),
fontWeight: 600,
background: 'var(--mantine-color-primary-light)',
color: 'var(--mantine-color-primary-7)',
boxShadow: '0 0 0 1px var(--mantine-color-primary-2)',
whiteSpace: 'nowrap',
lineHeight: '22px',
}}
>
{crumb.label}
</span>
) : (
<Anchor
size="sm"
c="gray.6"
onClick={() => onNavigate(crumb.path)}
style={{
cursor: 'pointer',
borderRadius: rem(999),
padding: '2px 10px',
fontSize: rem(12),
fontWeight: 500,
whiteSpace: 'nowrap',
textDecoration: 'none',
lineHeight: '22px',
transition: 'all 150ms ease',
}}
onMouseEnter={(e) => {
e.currentTarget.style.background = 'var(--mantine-color-primary-light)';
e.currentTarget.style.color = 'var(--mantine-color-primary-7)';
}}
onMouseLeave={(e) => {
e.currentTarget.style.background = 'transparent';
e.currentTarget.style.color = 'var(--mantine-color-gray-6)';
}}
>
{crumb.label}
</Anchor>
)}
</div>
);
})}
</Group>
</Group>
<Group gap="sm" wrap="nowrap">
<LanguageSwitcher supportedLanguages={supportedLanguages} />
<ColorSchemeToggle />
{/* Notification bell */}
<UnstyledButton
style={{
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
width: rem(38),
height: rem(38),
borderRadius: rem(12),
background: 'var(--mantine-color-body)',
color: 'var(--mantine-color-gray-6)',
boxShadow: '0 1px 3px rgba(0,0,0,0.04), 0 0 0 1px rgba(0,0,0,0.06)',
transition: 'all 150ms ease',
cursor: 'pointer',
border: 'none',
position: 'relative',
}}
onMouseEnter={(e) => {
e.currentTarget.style.background = 'var(--mantine-color-primary-light)';
e.currentTarget.style.color = 'var(--mantine-color-primary-6)';
e.currentTarget.style.boxShadow =
'0 1px 3px rgba(0,0,0,0.04), 0 0 0 1px var(--mantine-color-primary-2)';
}}
onMouseLeave={(e) => {
e.currentTarget.style.background = 'var(--mantine-color-body)';
e.currentTarget.style.color = 'var(--mantine-color-gray-6)';
e.currentTarget.style.boxShadow =
'0 1px 3px rgba(0,0,0,0.04), 0 0 0 1px rgba(0,0,0,0.06)';
}}
aria-label="Notifications"
>
<Indicator color="red" size={9} offset={5} withBorder>
<IconBell size={19} />
</Indicator>
</UnstyledButton>
{/* Profile avatar menu */}
<Menu position="bottom-end" width={220} shadow="md" withinPortal>
<Menu.Target>
<UnstyledButton
style={{
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
width: rem(38),
height: rem(38),
borderRadius: rem(12),
transition: 'all 150ms ease',
cursor: 'pointer',
border: 'none',
background: 'transparent',
}}
onMouseEnter={(e) => {
e.currentTarget.style.transform = 'scale(1.05)';
}}
onMouseLeave={(e) => {
e.currentTarget.style.transform = 'scale(1)';
}}
>
<BrandAvatar initials={userInitials} size={38} />
</UnstyledButton>
</Menu.Target>
<Menu.Dropdown
style={{
borderRadius: rem(12),
padding: rem(6),
boxShadow: '0 8px 24px rgba(0,0,0,0.12)',
}}
>
<Menu.Label
style={{
padding: rem(10),
fontWeight: 600,
fontSize: rem(13),
}}
>
{userName}
</Menu.Label>
<Menu.Item
leftSection={<IconUserCircle size={16} />}
onClick={() => onNavigate('/profile')}
style={{ borderRadius: rem(8) }}
>
Profile
</Menu.Item>
<Menu.Divider />
<Menu.Item
color="red"
leftSection={<IconLogout size={16} />}
onClick={onLogout}
style={{ borderRadius: rem(8) }}
>
Logout
</Menu.Item>
</Menu.Dropdown>
</Menu>
</Group>
</Group>
);
}

View File

@@ -0,0 +1,196 @@
import {
AppShell,
NavLink,
ScrollArea,
Stack,
Text,
Tooltip,
UnstyledButton,
rem,
} from '@mantine/core';
import {
IconChevronLeft,
IconChevronRight,
} from '@tabler/icons-react';
import type { Icon } from '@tabler/icons-react';
import { useTranslation } from 'react-i18next';
import { BrandMark } from '@ema-platform/auth';
export interface NavItem {
label: string;
icon: Icon;
to?: string;
soon?: boolean;
}
interface AppSidebarProps {
navItems: NavItem[];
collapsed: boolean;
activePath: string;
onToggleCollapse: () => void;
onNavigate: (item: NavItem) => void;
brandName: string;
brandSubtitle: string;
}
export function AppSidebar({
navItems,
collapsed,
activePath,
onToggleCollapse,
onNavigate,
brandName,
brandSubtitle,
}: AppSidebarProps) {
const { t } = useTranslation();
const activeNavItem = (item: NavItem) =>
!!item.to &&
(activePath === item.to || activePath.startsWith(`${item.to}/`));
return (
<>
{/* Brand header */}
<div
style={{
display: 'flex',
alignItems: 'center',
gap: rem(12),
height: 74,
padding: collapsed ? '0 12px' : '0 20px',
borderBottom: '1px solid var(--mantine-color-gray-2)',
justifyContent: collapsed ? 'center' : 'flex-start',
flexShrink: 0,
}}
>
<BrandMark size={32} />
{!collapsed && (
<div style={{ minWidth: 0 }}>
<Text
fw={800}
size="xs"
style={{
textTransform: 'uppercase',
letterSpacing: '0.08em',
lineHeight: 1.2,
color: 'var(--mantine-color-primary-7)',
}}
>
{brandName}
</Text>
<Text
size="xs"
c="dimmed"
style={{
textTransform: 'uppercase',
letterSpacing: '0.05em',
fontSize: rem(10),
lineHeight: 1.3,
}}
>
{brandSubtitle}
</Text>
</div>
)}
</div>
{/* Navigation items */}
<AppShell.Section grow component={ScrollArea} p="md">
<Stack gap={4}>
{navItems.map((item) => {
const ItemIcon = item.icon;
const active = activeNavItem(item);
if (collapsed) {
return (
<Tooltip key={item.label} label={item.label} position="right" withArrow>
<UnstyledButton
onClick={() => onNavigate(item)}
style={{
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
width: '100%',
height: rem(40),
borderRadius: rem(10),
color: active ? 'var(--mantine-color-blue-6)' : undefined,
backgroundColor: active ? 'var(--mantine-color-blue-light)' : undefined,
}}
>
<ItemIcon size={20} stroke={1.6} />
</UnstyledButton>
</Tooltip>
);
}
return (
<NavLink
key={item.label}
active={active}
label={item.label}
leftSection={<ItemIcon size={19} stroke={1.6} />}
onClick={() => onNavigate(item)}
variant="light"
styles={{ root: { borderRadius: rem(10) }, label: { fontWeight: 500 } }}
/>
);
})}
</Stack>
</AppShell.Section>
{/* Collapse toggle */}
<div
style={{
padding: rem(8),
borderTop: '1px solid var(--mantine-color-gray-2)',
flexShrink: 0,
}}
>
<Tooltip
label={collapsed ? t('nav.expandSidebar', 'Expand sidebar') : t('nav.collapseSidebar', 'Collapse sidebar')}
position="right"
withArrow
disabled={!collapsed}
>
<UnstyledButton
onClick={onToggleCollapse}
visibleFrom="sm"
style={{
display: 'flex',
alignItems: 'center',
justifyContent: collapsed ? 'center' : 'flex-start',
gap: rem(10),
width: '100%',
padding: '10px 12px',
borderRadius: rem(12),
color: 'var(--mantine-color-gray-5)',
transition: 'all 150ms ease',
cursor: 'pointer',
border: 'none',
background: 'transparent',
fontSize: rem(13),
fontWeight: 500,
}}
onMouseEnter={(e) => {
e.currentTarget.style.background = 'var(--mantine-color-gray-0)';
e.currentTarget.style.color = 'var(--mantine-color-gray-7)';
}}
onMouseLeave={(e) => {
e.currentTarget.style.background = 'transparent';
e.currentTarget.style.color = 'var(--mantine-color-gray-5)';
}}
>
{collapsed ? (
<IconChevronRight size={18} stroke={1.6} />
) : (
<>
<IconChevronLeft size={18} stroke={1.6} />
<span>{t('nav.collapseSidebar', 'Collapse')}</span>
</>
)}
</UnstyledButton>
</Tooltip>
</div>
</>
);
}

View File

@@ -0,0 +1,30 @@
import { Box, useMantineTheme } from '@mantine/core';
export function BrandAvatar({
initials = '?',
size = 38,
}: {
initials?: string;
size?: number;
}) {
const theme = useMantineTheme();
return (
<Box
w={size}
h={size}
style={{
borderRadius: '50%',
background: theme.other.heroGradient as string,
color: '#fff',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
fontSize: size * 0.36,
fontWeight: 700,
flexShrink: 0,
}}
>
{initials}
</Box>
);
}

View File

@@ -0,0 +1,45 @@
import { UnstyledButton, useMantineColorScheme, useComputedColorScheme, rem } from '@mantine/core';
import { IconSun, IconMoon } from '@tabler/icons-react';
import { useTranslation } from 'react-i18next';
export function ColorSchemeToggle() {
const { t } = useTranslation();
const { setColorScheme } = useMantineColorScheme();
const computed = useComputedColorScheme('light', { getInitialValueInEffect: true });
const isDark = computed === 'dark';
return (
<UnstyledButton
aria-label={t('common.toggleTheme')}
onClick={() => setColorScheme(isDark ? 'light' : 'dark')}
style={{
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
width: rem(38),
height: rem(38),
borderRadius: rem(12),
background: 'var(--mantine-color-body)',
color: 'var(--mantine-color-gray-6)',
boxShadow: '0 1px 3px rgba(0,0,0,0.04), 0 0 0 1px rgba(0,0,0,0.06)',
transition: 'all 150ms ease',
cursor: 'pointer',
border: 'none',
}}
onMouseEnter={(e) => {
e.currentTarget.style.background = 'var(--mantine-color-primary-light)';
e.currentTarget.style.color = 'var(--mantine-color-primary-6)';
e.currentTarget.style.boxShadow =
'0 1px 3px rgba(0,0,0,0.04), 0 0 0 1px var(--mantine-color-primary-2)';
}}
onMouseLeave={(e) => {
e.currentTarget.style.background = 'var(--mantine-color-body)';
e.currentTarget.style.color = 'var(--mantine-color-gray-6)';
e.currentTarget.style.boxShadow =
'0 1px 3px rgba(0,0,0,0.04), 0 0 0 1px rgba(0,0,0,0.06)';
}}
>
{isDark ? <IconSun size={19} /> : <IconMoon size={19} />}
</UnstyledButton>
);
}

View File

@@ -0,0 +1,83 @@
import { Menu, UnstyledButton, Text, rem } from '@mantine/core';
import { IconWorld, IconCheck, IconChevronDown } from '@tabler/icons-react';
import { useTranslation } from 'react-i18next';
interface LanguageSwitcherProps {
supportedLanguages: readonly string[];
/** `icon` renders a compact globe button; `button` shows the language label. */
variant?: 'icon' | 'button';
}
export function LanguageSwitcher({ supportedLanguages, variant = 'icon' }: LanguageSwitcherProps) {
const { t, i18n } = useTranslation();
const current = i18n.language;
const change = (lng: string) => {
if (lng !== current) i18n.changeLanguage(lng);
};
return (
<Menu shadow="md" width={160} position="bottom-end" withinPortal>
<Menu.Target>
<UnstyledButton
aria-label={t('language.label')}
style={{
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
gap: rem(4),
width: variant === 'icon' ? rem(38) : 'auto',
height: rem(38),
padding: variant === 'icon' ? 0 : '0 12px',
borderRadius: rem(12),
background: 'var(--mantine-color-body)',
color: 'var(--mantine-color-gray-6)',
boxShadow: '0 1px 3px rgba(0,0,0,0.04), 0 0 0 1px rgba(0,0,0,0.06)',
transition: 'all 150ms ease',
cursor: 'pointer',
border: 'none',
}}
onMouseEnter={(e) => {
e.currentTarget.style.background = 'var(--mantine-color-primary-light)';
e.currentTarget.style.color = 'var(--mantine-color-primary-6)';
e.currentTarget.style.boxShadow =
'0 1px 3px rgba(0,0,0,0.04), 0 0 0 1px var(--mantine-color-primary-2)';
}}
onMouseLeave={(e) => {
e.currentTarget.style.background = 'var(--mantine-color-body)';
e.currentTarget.style.color = 'var(--mantine-color-gray-6)';
e.currentTarget.style.boxShadow =
'0 1px 3px rgba(0,0,0,0.04), 0 0 0 1px rgba(0,0,0,0.06)';
}}
>
<IconWorld size={19} />
{variant !== 'icon' && (
<>
<Text size="sm" fw={500}>
{t(`language.${current}`)}
</Text>
<IconChevronDown size={14} />
</>
)}
</UnstyledButton>
</Menu.Target>
<Menu.Dropdown
style={{ borderRadius: rem(12), padding: rem(6) }}
>
<Menu.Label>{t('language.label')}</Menu.Label>
{supportedLanguages.map((lng) => (
<Menu.Item
key={lng}
onClick={() => change(lng)}
rightSection={
current === lng ? <IconCheck size={16} /> : undefined
}
style={{ borderRadius: rem(8) }}
>
{t(`language.${lng}`)}
</Menu.Item>
))}
</Menu.Dropdown>
</Menu>
);
}

Some files were not shown because too many files have changed in this diff Show More