Merge pull request #2 from Tria-plc/feature/seafare-registration
Feature/seafare registration, usermanegement and UI configuration
8
.gitignore
vendored
@@ -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
@@ -0,0 +1,3 @@
|
||||
[submodule "user-management"]
|
||||
path = user-management
|
||||
url = git@github.com:Tria-plc/iamui.git
|
||||
@@ -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>
|
||||
|
||||
BIN
apps/backoffice/public/assets/MainDashboard.png
Normal file
|
After Width: | Height: | Size: 154 KiB |
BIN
apps/backoffice/public/assets/StatsCard.png
Normal file
|
After Width: | Height: | Size: 46 KiB |
BIN
apps/backoffice/public/assets/eiarLogo.jpg
Normal file
|
After Width: | Height: | Size: 390 KiB |
BIN
apps/backoffice/public/assets/ema-logo.png
Normal file
|
After Width: | Height: | Size: 390 KiB |
11
apps/backoffice/public/assets/smart-office-logo.svg
Normal file
|
After Width: | Height: | Size: 16 KiB |
BIN
apps/backoffice/public/brand/ema-black.png
Normal file
|
After Width: | Height: | Size: 451 KiB |
BIN
apps/backoffice/public/brand/ema-white.png
Normal file
|
After Width: | Height: | Size: 451 KiB |
BIN
apps/backoffice/public/ema-logo.png
Normal file
|
After Width: | Height: | Size: 390 KiB |
@@ -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 />
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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;
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
240
apps/backoffice/src/app/features/location/pages/LocationPage.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
51
apps/backoffice/src/app/features/location/types/location.ts
Normal 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;
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
605
apps/backoffice/src/app/features/profile/pages/ProfilePage.tsx
Normal 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();
|
||||
}
|
||||
|
||||
/** 0–4 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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
47
apps/backoffice/src/app/i18n/config.ts
Normal 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;
|
||||
209
apps/backoffice/src/app/i18n/locales/am.ts
Normal 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: 'የይለፍ ቃላት አይዛመዱም',
|
||||
},
|
||||
},
|
||||
};
|
||||
210
apps/backoffice/src/app/i18n/locales/en.ts
Normal 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;
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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 /> },
|
||||
]);
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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__ = {
|
||||
|
||||
@@ -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'],
|
||||
},
|
||||
|
||||
@@ -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>
|
||||
|
||||
BIN
apps/portal/public/brand/ema-black.png
Normal file
|
After Width: | Height: | Size: 451 KiB |
BIN
apps/portal/public/brand/ema-white.png
Normal file
|
After Width: | Height: | Size: 451 KiB |
BIN
apps/portal/public/ema-logo.png
Normal file
|
After Width: | Height: | Size: 390 KiB |
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
124
apps/portal/src/app/components/AmharicDatePicker.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
76
apps/portal/src/app/components/BilingualInput.tsx
Normal 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}
|
||||
/>
|
||||
);
|
||||
}
|
||||
67
apps/portal/src/app/components/EmptyState.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
45
apps/portal/src/app/components/Logo.tsx
Normal 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}
|
||||
/>
|
||||
);
|
||||
}
|
||||
1
apps/portal/src/app/components/ProtectedRoute.tsx
Normal file
@@ -0,0 +1 @@
|
||||
export { ProtectedRoute } from '@ema-platform/auth';
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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)),
|
||||
};
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
19
apps/portal/src/app/features/location/api/location-api.ts
Normal 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;
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
24
apps/portal/src/app/features/location/types/location.ts
Normal 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[];
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
607
apps/portal/src/app/features/profile/pages/ProfilePage.tsx
Normal 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();
|
||||
}
|
||||
|
||||
/** 0–4 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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
77
apps/portal/src/app/features/support/pages/SupportPage.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
53
apps/portal/src/app/i18n/config.ts
Normal 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;
|
||||
172
apps/portal/src/app/i18n/locales/am.ts
Normal 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: 'አብዛኞቹ ማመልከቻዎች እንደ ፍቃዱ ዓይነትና እንደ ሰነዶችዎ ሙሉነት ከ5–15 የሥራ ቀናት ውስጥ ይገመገማሉ።',
|
||||
q2: 'ማመልከቻ ካስገባሁ በኋላ ምን ይከሰታል?',
|
||||
a2: 'ማመልከቻዎ ሰነዶችዎን ለሚያረጋግጥ የፍቃድ መኮንን ይመደባል። እያንዳንዱን የሁኔታ ለውጥ ከ"ማመልከቻዎቼ" ገጽ መከታተል ይችላሉ።',
|
||||
q3: 'የሚያበቃ ፍቃድ እንዴት አድሳለሁ?',
|
||||
a3: 'ከ"ፍቃዶቼ" ፍቃዱን ይክፈቱና "አድስ" ይምረጡ፣ ወይም አዲስ ማመልከቻ ይጀምሩና "እድሳት" የጥያቄ ዓይነት ይምረጡ።',
|
||||
q4: 'ኢባባ ተጨማሪ መረጃ ቢጠይቅስ?',
|
||||
a4: 'በማመልከቻው ላይ "እርምጃ ያስፈልጋል" የሚል ማስታወሻ ያያሉ። ግምገማውን ለመቀጠል የተጠየቀውን ሰነድ ወይም ዝርዝር ያቅርቡ።',
|
||||
},
|
||||
},
|
||||
};
|
||||
172
apps/portal/src/app/i18n/locales/en.ts
Normal 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: 'Mon–Fri, 8:30 AM – 5:00 PM',
|
||||
faq: 'Frequently asked questions',
|
||||
faqs: {
|
||||
q1: 'How long does licence processing take?',
|
||||
a1: 'Most applications are reviewed within 5–15 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;
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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 /> },
|
||||
]);
|
||||
|
||||
@@ -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;
|
||||
|
||||
60
apps/portal/src/app/theme/portal.css
Normal 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;
|
||||
}
|
||||
}
|
||||
100
apps/portal/src/app/theme/portalTheme.ts
Normal 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%)',
|
||||
},
|
||||
});
|
||||
@@ -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
7255
backoffice.pen
Normal file
562
ema-portal.pen
Normal 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
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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';
|
||||
|
||||
53
libs/api/src/lib/base-api/base-query-with-reauth.ts
Normal 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;
|
||||
};
|
||||
@@ -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
@@ -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
@@ -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';
|
||||
40
libs/auth/src/lib/AuthConfig.tsx
Normal 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);
|
||||
}
|
||||
216
libs/auth/src/lib/components/AuthShell.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
24
libs/auth/src/lib/components/ProtectedRoute.tsx
Normal 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 />;
|
||||
}
|
||||
190
libs/auth/src/lib/pages/ForgotPasswordPage.tsx
Normal 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'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'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'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>
|
||||
);
|
||||
}
|
||||
172
libs/auth/src/lib/pages/LoginPage.tsx
Normal 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't have an account?{' '}
|
||||
<Anchor component={Link} to="/signup" fw={700}>
|
||||
Create one
|
||||
</Anchor>
|
||||
</Text>
|
||||
)}
|
||||
</Stack>
|
||||
</AuthShell>
|
||||
);
|
||||
}
|
||||
200
libs/auth/src/lib/pages/OTPVerificationPage.tsx
Normal 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'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>
|
||||
);
|
||||
}
|
||||
@@ -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 & 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>
|
||||
);
|
||||
}
|
||||
@@ -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;
|
||||
33
libs/auth/src/lib/store/signup.slice.ts
Normal 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;
|
||||
@@ -12,6 +12,7 @@ export interface AuthUser {
|
||||
hasSetPassword: boolean;
|
||||
hasFinishedRegistration: boolean;
|
||||
hasFinishedDMSOnboarding: boolean;
|
||||
isPhoneNumberVerified: boolean;
|
||||
}
|
||||
|
||||
export interface AuthState {
|
||||
31
libs/auth/src/lib/utils/auth-storage.ts
Normal 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';
|
||||
},
|
||||
};
|
||||
31
libs/auth/src/lib/utils/refresh-token.ts
Normal 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
@@ -0,0 +1,5 @@
|
||||
{
|
||||
"extends": "../../tsconfig.base.json",
|
||||
"compilerOptions": { "outDir": "../../dist/out-tsc" },
|
||||
"include": ["src/**/*.ts", "src/**/*.tsx"]
|
||||
}
|
||||
@@ -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%)',
|
||||
},
|
||||
});
|
||||
|
||||
@@ -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';
|
||||
|
||||
271
libs/ui/src/lib/layout/AppHeader.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
196
libs/ui/src/lib/layout/AppSidebar.tsx
Normal 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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
30
libs/ui/src/lib/layout/BrandAvatar.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
45
libs/ui/src/lib/layout/ColorSchemeToggle.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
83
libs/ui/src/lib/layout/LanguageSwitcher.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||