mirror of
https://github.com/Tria-plc/emaui.git
synced 2026-08-26 19:12:50 +00:00
frontend scalfolding
This commit is contained in:
@@ -4,6 +4,13 @@
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<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>
|
||||
|
||||
@@ -1,17 +1,15 @@
|
||||
// src/main.tsx or App.tsx
|
||||
import { AppProviders, configureIam } from "@tria-plc/iamui-common";
|
||||
import "@tria-plc/iamui-common/styles.css";
|
||||
// import { AppProviders } from './providers/AppProviders';
|
||||
import { AppRouter } from './router';
|
||||
configureIam({ apiUrl: 'http://localhost:3001/api'});
|
||||
// import './styles.css';
|
||||
import { RouterProvider } from 'react-router-dom';
|
||||
import { configureIam } from '@tria-plc/iamui-common';
|
||||
import { AppProviders } from './providers/AppProviders';
|
||||
import { router } from './router';
|
||||
|
||||
// IAM module configuration (used by the isolated /users admin route).
|
||||
configureIam({ apiUrl: 'http://localhost:3001/api' });
|
||||
|
||||
export function App() {
|
||||
return (
|
||||
// 2. Wrap with Providers so hooks like useAuthUser() work
|
||||
<AppProviders>
|
||||
<AppRouter />
|
||||
<RouterProvider router={router} />
|
||||
</AppProviders>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
21
apps/portal/src/app/components/ColorSchemeToggle.tsx
Normal file
21
apps/portal/src/app/components/ColorSchemeToggle.tsx
Normal file
@@ -0,0 +1,21 @@
|
||||
import { ActionIcon, useMantineColorScheme, useComputedColorScheme } 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 (
|
||||
<ActionIcon
|
||||
variant="subtle"
|
||||
size="lg"
|
||||
aria-label={t('common.toggleTheme')}
|
||||
onClick={() => setColorScheme(isDark ? 'light' : 'dark')}
|
||||
>
|
||||
{isDark ? <IconSun size={20} /> : <IconMoon size={20} />}
|
||||
</ActionIcon>
|
||||
);
|
||||
}
|
||||
67
apps/portal/src/app/components/EmptyState.tsx
Normal file
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>
|
||||
);
|
||||
}
|
||||
52
apps/portal/src/app/components/LanguageSwitcher.tsx
Normal file
52
apps/portal/src/app/components/LanguageSwitcher.tsx
Normal file
@@ -0,0 +1,52 @@
|
||||
import { Menu, ActionIcon, Button } from '@mantine/core';
|
||||
import { IconWorld, IconCheck } from '@tabler/icons-react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { SUPPORTED_LANGUAGES, type AppLanguage } from '../i18n/config';
|
||||
|
||||
interface LanguageSwitcherProps {
|
||||
/** `icon` renders a compact globe button; `button` shows the language label. */
|
||||
variant?: 'icon' | 'button';
|
||||
}
|
||||
|
||||
export function LanguageSwitcher({ variant = 'icon' }: LanguageSwitcherProps) {
|
||||
const { t, i18n } = useTranslation();
|
||||
const current = i18n.language as AppLanguage;
|
||||
|
||||
const change = (lng: AppLanguage) => {
|
||||
if (lng !== current) i18n.changeLanguage(lng);
|
||||
};
|
||||
|
||||
return (
|
||||
<Menu shadow="md" width={160} position="bottom-end" withinPortal>
|
||||
<Menu.Target>
|
||||
{variant === 'icon' ? (
|
||||
<ActionIcon variant="subtle" size="lg" aria-label={t('language.label')}>
|
||||
<IconWorld size={20} />
|
||||
</ActionIcon>
|
||||
) : (
|
||||
<Button
|
||||
variant="subtle"
|
||||
size="sm"
|
||||
leftSection={<IconWorld size={16} />}
|
||||
>
|
||||
{t(`language.${current}`)}
|
||||
</Button>
|
||||
)}
|
||||
</Menu.Target>
|
||||
<Menu.Dropdown>
|
||||
<Menu.Label>{t('language.label')}</Menu.Label>
|
||||
{SUPPORTED_LANGUAGES.map((lng) => (
|
||||
<Menu.Item
|
||||
key={lng}
|
||||
onClick={() => change(lng)}
|
||||
rightSection={
|
||||
current === lng ? <IconCheck size={16} /> : undefined
|
||||
}
|
||||
>
|
||||
{t(`language.${lng}`)}
|
||||
</Menu.Item>
|
||||
))}
|
||||
</Menu.Dropdown>
|
||||
</Menu>
|
||||
);
|
||||
}
|
||||
25
apps/portal/src/app/components/PageHeader.tsx
Normal file
25
apps/portal/src/app/components/PageHeader.tsx
Normal file
@@ -0,0 +1,25 @@
|
||||
import { Group, Stack, Text, Title } from '@mantine/core';
|
||||
import type { ReactNode } from 'react';
|
||||
|
||||
interface PageHeaderProps {
|
||||
title: string;
|
||||
subtitle?: string;
|
||||
/** Right-aligned actions (e.g. a primary button). */
|
||||
action?: ReactNode;
|
||||
}
|
||||
|
||||
export function PageHeader({ title, subtitle, action }: PageHeaderProps) {
|
||||
return (
|
||||
<Group justify="space-between" align="flex-end" wrap="wrap" gap="sm">
|
||||
<Stack gap={2}>
|
||||
<Title order={2}>{title}</Title>
|
||||
{subtitle && (
|
||||
<Text c="dimmed" size="sm">
|
||||
{subtitle}
|
||||
</Text>
|
||||
)}
|
||||
</Stack>
|
||||
{action}
|
||||
</Group>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,96 @@
|
||||
import { useMemo } from 'react';
|
||||
import { Box, useMantineTheme, useComputedColorScheme } from '@mantine/core';
|
||||
import {
|
||||
AreaChart,
|
||||
Area,
|
||||
XAxis,
|
||||
YAxis,
|
||||
CartesianGrid,
|
||||
Tooltip,
|
||||
ResponsiveContainer,
|
||||
} from 'recharts';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import type { Application } from '../../licenses/types/license.types';
|
||||
|
||||
const MONTHS_BACK = 6;
|
||||
|
||||
export function ApplicationsTrend({ applications }: { applications: Application[] }) {
|
||||
const theme = useMantineTheme();
|
||||
const { i18n } = useTranslation();
|
||||
const scheme = useComputedColorScheme('light');
|
||||
const locale = i18n.language === 'am' ? 'am-ET' : 'en-GB';
|
||||
|
||||
const accent = theme.colors.emaPrimary[6];
|
||||
const gridColor = scheme === 'dark' ? theme.colors.dark[4] : theme.colors.gray[2];
|
||||
const axisColor = scheme === 'dark' ? theme.colors.dark[2] : theme.colors.gray[6];
|
||||
|
||||
const data = useMemo(() => {
|
||||
const now = new Date();
|
||||
const buckets: { key: string; label: string; count: number }[] = [];
|
||||
for (let i = MONTHS_BACK - 1; i >= 0; i--) {
|
||||
const d = new Date(now.getFullYear(), now.getMonth() - i, 1);
|
||||
buckets.push({
|
||||
key: `${d.getFullYear()}-${d.getMonth()}`,
|
||||
label: d.toLocaleDateString(locale, { month: 'short' }),
|
||||
count: 0,
|
||||
});
|
||||
}
|
||||
const index = new Map(buckets.map((b, i) => [b.key, i]));
|
||||
applications.forEach((a) => {
|
||||
const d = new Date(a.submittedAt);
|
||||
const k = `${d.getFullYear()}-${d.getMonth()}`;
|
||||
const i = index.get(k);
|
||||
if (i !== undefined) buckets[i].count += 1;
|
||||
});
|
||||
return buckets;
|
||||
}, [applications, locale]);
|
||||
|
||||
return (
|
||||
<Box h={240}>
|
||||
<ResponsiveContainer width="100%" height="100%">
|
||||
<AreaChart data={data} margin={{ top: 10, right: 8, left: -18, bottom: 0 }}>
|
||||
<defs>
|
||||
<linearGradient id="ema-trend" x1="0" y1="0" x2="0" y2="1">
|
||||
<stop offset="0%" stopColor={accent} stopOpacity={0.35} />
|
||||
<stop offset="100%" stopColor={accent} stopOpacity={0} />
|
||||
</linearGradient>
|
||||
</defs>
|
||||
<CartesianGrid strokeDasharray="3 3" stroke={gridColor} vertical={false} />
|
||||
<XAxis
|
||||
dataKey="label"
|
||||
tick={{ fontSize: 12, fill: axisColor }}
|
||||
axisLine={false}
|
||||
tickLine={false}
|
||||
/>
|
||||
<YAxis
|
||||
allowDecimals={false}
|
||||
tick={{ fontSize: 12, fill: axisColor }}
|
||||
axisLine={false}
|
||||
tickLine={false}
|
||||
width={36}
|
||||
/>
|
||||
<Tooltip
|
||||
cursor={{ stroke: accent, strokeWidth: 1, strokeDasharray: '4 4' }}
|
||||
contentStyle={{
|
||||
borderRadius: 12,
|
||||
border: 'none',
|
||||
boxShadow: theme.shadows.md,
|
||||
background: scheme === 'dark' ? theme.colors.dark[6] : 'white',
|
||||
color: scheme === 'dark' ? 'white' : 'inherit',
|
||||
fontSize: 13,
|
||||
}}
|
||||
/>
|
||||
<Area
|
||||
type="monotone"
|
||||
dataKey="count"
|
||||
stroke={accent}
|
||||
strokeWidth={2.5}
|
||||
fill="url(#ema-trend)"
|
||||
dot={{ r: 3, fill: accent, strokeWidth: 0 }}
|
||||
activeDot={{ r: 5 }}
|
||||
/>
|
||||
</AreaChart>
|
||||
</ResponsiveContainer>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,90 @@
|
||||
import { Box, Button, Group, Stack, Text, Title } from '@mantine/core';
|
||||
import { IconFilePlus, IconCategory, IconAnchor } from '@tabler/icons-react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
export function DashboardHero({ name }: { name?: string }) {
|
||||
const { t } = useTranslation();
|
||||
const navigate = useNavigate();
|
||||
|
||||
return (
|
||||
<Box
|
||||
p={{ base: 'lg', sm: 'xl' }}
|
||||
style={{
|
||||
position: 'relative',
|
||||
overflow: 'hidden',
|
||||
borderRadius: 'var(--mantine-radius-lg)',
|
||||
background: 'linear-gradient(135deg, #2453a2 0%, #3160b7 45%, #1fc29d 130%)',
|
||||
color: 'white',
|
||||
boxShadow: 'var(--mantine-shadow-md)',
|
||||
}}
|
||||
>
|
||||
{/* decorative wash */}
|
||||
<IconAnchor
|
||||
size={220}
|
||||
stroke={1}
|
||||
aria-hidden
|
||||
style={{
|
||||
position: 'absolute',
|
||||
right: -30,
|
||||
bottom: -50,
|
||||
opacity: 0.12,
|
||||
}}
|
||||
/>
|
||||
<div
|
||||
aria-hidden
|
||||
style={{
|
||||
position: 'absolute',
|
||||
top: -60,
|
||||
right: 120,
|
||||
width: 200,
|
||||
height: 200,
|
||||
borderRadius: '50%',
|
||||
background: 'rgba(255,255,255,0.08)',
|
||||
}}
|
||||
/>
|
||||
|
||||
<Stack gap="md" style={{ position: 'relative', maxWidth: 620 }}>
|
||||
<Stack gap={4}>
|
||||
<Text fz="sm" fw={600} style={{ opacity: 0.85 }}>
|
||||
{t('dashboard.hero.greeting')}
|
||||
{name ? ',' : ''}
|
||||
</Text>
|
||||
{name && (
|
||||
<Title order={2} c="white">
|
||||
{name}
|
||||
</Title>
|
||||
)}
|
||||
<Text fz={{ base: 'sm', sm: 'md' }} style={{ opacity: 0.92 }} maw={520}>
|
||||
{t('dashboard.hero.subtitle')}
|
||||
</Text>
|
||||
</Stack>
|
||||
|
||||
<Group gap="sm">
|
||||
<Button
|
||||
variant="white"
|
||||
c="emaPrimary.7"
|
||||
leftSection={<IconFilePlus size={18} />}
|
||||
onClick={() => navigate('/apply')}
|
||||
>
|
||||
{t('dashboard.newApplication')}
|
||||
</Button>
|
||||
<Button
|
||||
leftSection={<IconCategory size={18} />}
|
||||
onClick={() => navigate('/services')}
|
||||
styles={{
|
||||
root: {
|
||||
background: 'rgba(255,255,255,0.16)',
|
||||
color: 'white',
|
||||
border: '1px solid rgba(255,255,255,0.35)',
|
||||
backdropFilter: 'blur(4px)',
|
||||
},
|
||||
}}
|
||||
>
|
||||
{t('dashboard.hero.browse')}
|
||||
</Button>
|
||||
</Group>
|
||||
</Stack>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,99 @@
|
||||
import { useMemo } from 'react';
|
||||
import { Box, Center, Group, Stack, Text } from '@mantine/core';
|
||||
import { PieChart, Pie, Cell, ResponsiveContainer } from 'recharts';
|
||||
import { useMantineTheme } from '@mantine/core';
|
||||
import type { Application, ApplicationStatus } from '../../licenses/types/license.types';
|
||||
import { APPLICATION_STATUS_COLORS } from '../../licenses/constants';
|
||||
import { useLicenseLabels } from '../../licenses/hooks/useLicenseLabels';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
/** Resolve a Mantine color name + shade to its hex via the theme. */
|
||||
function useColorResolver() {
|
||||
const theme = useMantineTheme();
|
||||
return (name: string, shade = 6) => theme.colors[name]?.[shade] ?? theme.colors.gray[6];
|
||||
}
|
||||
|
||||
export function StatusDonut({ applications }: { applications: Application[] }) {
|
||||
const { t } = useTranslation();
|
||||
const { applicationStatus } = useLicenseLabels();
|
||||
const resolve = useColorResolver();
|
||||
|
||||
const data = useMemo(() => {
|
||||
const counts = applications.reduce<Record<string, number>>((acc, a) => {
|
||||
acc[a.status] = (acc[a.status] ?? 0) + 1;
|
||||
return acc;
|
||||
}, {});
|
||||
return Object.entries(counts).map(([status, value]) => ({
|
||||
status: status as ApplicationStatus,
|
||||
value,
|
||||
color: resolve(APPLICATION_STATUS_COLORS[status as ApplicationStatus]),
|
||||
}));
|
||||
}, [applications, resolve]);
|
||||
|
||||
if (!data.length) {
|
||||
return (
|
||||
<Center h={220}>
|
||||
<Text c="dimmed" size="sm">
|
||||
{t('dashboard.charts.noData')}
|
||||
</Text>
|
||||
</Center>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Group align="center" gap="lg" wrap="nowrap">
|
||||
<Box w={180} h={180} style={{ position: 'relative' }}>
|
||||
<ResponsiveContainer width="100%" height="100%">
|
||||
<PieChart>
|
||||
<Pie
|
||||
data={data}
|
||||
dataKey="value"
|
||||
nameKey="status"
|
||||
cx="50%"
|
||||
cy="50%"
|
||||
innerRadius={58}
|
||||
outerRadius={84}
|
||||
paddingAngle={2}
|
||||
stroke="none"
|
||||
>
|
||||
{data.map((d) => (
|
||||
<Cell key={d.status} fill={d.color} />
|
||||
))}
|
||||
</Pie>
|
||||
</PieChart>
|
||||
</ResponsiveContainer>
|
||||
<Stack
|
||||
gap={0}
|
||||
align="center"
|
||||
style={{
|
||||
position: 'absolute',
|
||||
inset: 0,
|
||||
justifyContent: 'center',
|
||||
pointerEvents: 'none',
|
||||
}}
|
||||
>
|
||||
<Text fw={800} fz={28} lh={1}>
|
||||
{applications.length}
|
||||
</Text>
|
||||
<Text size="xs" c="dimmed">
|
||||
{t('dashboard.charts.total')}
|
||||
</Text>
|
||||
</Stack>
|
||||
</Box>
|
||||
|
||||
<Stack gap="xs" flex={1}>
|
||||
{data.map((d) => (
|
||||
<Group key={d.status} justify="space-between" wrap="nowrap" gap="xs">
|
||||
<Group gap={8} wrap="nowrap">
|
||||
<Box w={10} h={10} style={{ borderRadius: 3, background: d.color }} />
|
||||
<Text size="sm">{applicationStatus(d.status)}</Text>
|
||||
</Group>
|
||||
<Text size="sm" fw={600}>
|
||||
{d.value}
|
||||
</Text>
|
||||
</Group>
|
||||
))}
|
||||
</Stack>
|
||||
</Group>
|
||||
);
|
||||
}
|
||||
@@ -1,13 +1,197 @@
|
||||
import { Stack, Title, Text, Paper } from '@mantine/core';
|
||||
import { useEffect, useState } from 'react';
|
||||
import {
|
||||
Button,
|
||||
Card,
|
||||
Group,
|
||||
Paper,
|
||||
SimpleGrid,
|
||||
Skeleton,
|
||||
Stack,
|
||||
Text,
|
||||
ThemeIcon,
|
||||
Title,
|
||||
UnstyledButton,
|
||||
} from '@mantine/core';
|
||||
import {
|
||||
IconCertificate,
|
||||
IconClockHour4,
|
||||
IconFilePlus,
|
||||
IconAlertTriangle,
|
||||
IconArrowRight,
|
||||
IconRefresh,
|
||||
IconSearch,
|
||||
} from '@tabler/icons-react';
|
||||
import type { Icon } from '@tabler/icons-react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { StatCard } from '../../licenses/components/StatCard';
|
||||
import { ApplicationsTable } from '../../licenses/components/ApplicationsTable';
|
||||
import { useLicenses } from '../../licenses/hooks/useLicenses';
|
||||
import { DashboardHero } from '../components/DashboardHero';
|
||||
import { StatusDonut } from '../components/StatusDonut';
|
||||
import { ApplicationsTrend } from '../components/ApplicationsTrend';
|
||||
import type { ApplicationStatus } from '../../licenses/types/license.types';
|
||||
|
||||
const OPEN_STATUSES: ApplicationStatus[] = [
|
||||
'SUBMITTED',
|
||||
'UNDER_REVIEW',
|
||||
'INFO_REQUESTED',
|
||||
'PAYMENT_PENDING',
|
||||
];
|
||||
|
||||
export function DashboardPage() {
|
||||
const { t } = useTranslation();
|
||||
const navigate = useNavigate();
|
||||
const { applications, licenses } = useLicenses();
|
||||
|
||||
// Brief skeleton on the charts for a polished first paint.
|
||||
const [chartsLoading, setChartsLoading] = useState(true);
|
||||
useEffect(() => {
|
||||
const id = setTimeout(() => setChartsLoading(false), 550);
|
||||
return () => clearTimeout(id);
|
||||
}, []);
|
||||
|
||||
const activeLicenses = licenses.filter((l) => l.status === 'ACTIVE').length;
|
||||
const pending = applications.filter((a) => OPEN_STATUSES.includes(a.status)).length;
|
||||
const expiring = licenses.filter((l) => l.status === 'EXPIRING_SOON').length;
|
||||
const actionRequired = applications.filter((a) => a.status === 'INFO_REQUESTED').length;
|
||||
|
||||
const recent = applications.slice(0, 5);
|
||||
const holderName = licenses[0]?.holderName;
|
||||
|
||||
const quickActions: {
|
||||
icon: Icon;
|
||||
title: string;
|
||||
desc: string;
|
||||
to: string;
|
||||
color: string;
|
||||
}[] = [
|
||||
{
|
||||
icon: IconFilePlus,
|
||||
title: t('dashboard.quick.apply'),
|
||||
desc: t('dashboard.quick.applyDesc'),
|
||||
to: '/apply',
|
||||
color: 'emaPrimary',
|
||||
},
|
||||
{
|
||||
icon: IconRefresh,
|
||||
title: t('dashboard.quick.renew'),
|
||||
desc: t('dashboard.quick.renewDesc'),
|
||||
to: '/licenses',
|
||||
color: 'emaTeal',
|
||||
},
|
||||
{
|
||||
icon: IconSearch,
|
||||
title: t('dashboard.quick.track'),
|
||||
desc: t('dashboard.quick.trackDesc'),
|
||||
to: '/applications',
|
||||
color: 'indigo',
|
||||
},
|
||||
];
|
||||
|
||||
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>
|
||||
<DashboardHero name={holderName} />
|
||||
|
||||
<SimpleGrid cols={{ base: 1, sm: 2, lg: 4 }} spacing="md">
|
||||
<StatCard
|
||||
label={t('dashboard.stats.activeLicenses')}
|
||||
value={activeLicenses}
|
||||
icon={IconCertificate}
|
||||
color="teal"
|
||||
/>
|
||||
<StatCard
|
||||
label={t('dashboard.stats.pendingApplications')}
|
||||
value={pending}
|
||||
icon={IconClockHour4}
|
||||
color="indigo"
|
||||
/>
|
||||
<StatCard
|
||||
label={t('dashboard.stats.expiringSoon')}
|
||||
value={expiring}
|
||||
icon={IconAlertTriangle}
|
||||
color="orange"
|
||||
/>
|
||||
<StatCard
|
||||
label={t('dashboard.stats.actionRequired')}
|
||||
value={actionRequired}
|
||||
icon={IconAlertTriangle}
|
||||
color="red"
|
||||
/>
|
||||
</SimpleGrid>
|
||||
|
||||
<SimpleGrid cols={{ base: 1, lg: 3 }} spacing="md">
|
||||
{quickActions.map((qa) => (
|
||||
<UnstyledButton key={qa.to} onClick={() => navigate(qa.to)}>
|
||||
<Card withBorder padding="lg" h="100%" className="ema-hover-lift">
|
||||
<Group justify="space-between" wrap="nowrap">
|
||||
<Group gap="sm" wrap="nowrap">
|
||||
<ThemeIcon variant="light" color={qa.color} size={42}>
|
||||
<qa.icon size={22} />
|
||||
</ThemeIcon>
|
||||
<div>
|
||||
<Text fw={600}>{qa.title}</Text>
|
||||
<Text size="sm" c="dimmed">
|
||||
{qa.desc}
|
||||
</Text>
|
||||
</div>
|
||||
</Group>
|
||||
<IconArrowRight size={18} style={{ opacity: 0.5 }} />
|
||||
</Group>
|
||||
</Card>
|
||||
</UnstyledButton>
|
||||
))}
|
||||
</SimpleGrid>
|
||||
|
||||
<SimpleGrid cols={{ base: 1, lg: 2 }} spacing="md">
|
||||
<Paper p="lg" shadow="sm" withBorder>
|
||||
<Group justify="space-between" mb="md">
|
||||
<div>
|
||||
<Title order={4}>{t('dashboard.charts.trend')}</Title>
|
||||
<Text size="xs" c="dimmed">
|
||||
{t('dashboard.charts.trendPeriod')}
|
||||
</Text>
|
||||
</div>
|
||||
</Group>
|
||||
{chartsLoading ? (
|
||||
<Skeleton height={240} radius="md" />
|
||||
) : (
|
||||
<ApplicationsTrend applications={applications} />
|
||||
)}
|
||||
</Paper>
|
||||
|
||||
<Paper p="lg" shadow="sm" withBorder>
|
||||
<Title order={4} mb="md">
|
||||
{t('dashboard.charts.distribution')}
|
||||
</Title>
|
||||
{chartsLoading ? (
|
||||
<Group align="center" gap="lg" wrap="nowrap">
|
||||
<Skeleton circle height={180} width={180} />
|
||||
<Stack gap="sm" flex={1}>
|
||||
{Array.from({ length: 4 }).map((_, i) => (
|
||||
<Skeleton key={i} height={14} radius="sm" />
|
||||
))}
|
||||
</Stack>
|
||||
</Group>
|
||||
) : (
|
||||
<StatusDonut applications={applications} />
|
||||
)}
|
||||
</Paper>
|
||||
</SimpleGrid>
|
||||
|
||||
<Paper p="lg" shadow="sm" withBorder>
|
||||
<Group justify="space-between" mb="sm">
|
||||
<Title order={4}>{t('dashboard.recentApplications')}</Title>
|
||||
<Button
|
||||
variant="subtle"
|
||||
size="sm"
|
||||
rightSection={<IconArrowRight size={16} />}
|
||||
onClick={() => navigate('/applications')}
|
||||
>
|
||||
{t('common.viewAll')}
|
||||
</Button>
|
||||
</Group>
|
||||
<ApplicationsTable applications={recent} />
|
||||
</Paper>
|
||||
</Stack>
|
||||
);
|
||||
|
||||
@@ -0,0 +1,29 @@
|
||||
import { Text, Timeline } from '@mantine/core';
|
||||
import { IconCheck, IconClock } from '@tabler/icons-react';
|
||||
import type { TimelineEvent } from '../types/license.types';
|
||||
import { useLicenseLabels } from '../hooks/useLicenseLabels';
|
||||
|
||||
export function ApplicationTimeline({ history }: { history: TimelineEvent[] }) {
|
||||
const { applicationStatus, formatDate } = useLicenseLabels();
|
||||
|
||||
return (
|
||||
<Timeline active={history.length - 1} bulletSize={24} lineWidth={2}>
|
||||
{history.map((event, idx) => (
|
||||
<Timeline.Item
|
||||
key={`${event.status}-${idx}`}
|
||||
bullet={
|
||||
idx === history.length - 1 ? <IconClock size={14} /> : <IconCheck size={14} />
|
||||
}
|
||||
title={applicationStatus(event.status)}
|
||||
>
|
||||
<Text c="dimmed" size="sm">
|
||||
{event.note}
|
||||
</Text>
|
||||
<Text size="xs" mt={4} c="dimmed">
|
||||
{formatDate(event.date)}
|
||||
</Text>
|
||||
</Timeline.Item>
|
||||
))}
|
||||
</Timeline>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
import { ActionIcon, Anchor, Table, Tooltip } from '@mantine/core';
|
||||
import { IconEye } from '@tabler/icons-react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import type { Application } from '../types/license.types';
|
||||
import { ApplicationStatusBadge } from './StatusBadge';
|
||||
import { useLicenseLabels } from '../hooks/useLicenseLabels';
|
||||
import { EmptyState } from '../../../components/EmptyState';
|
||||
|
||||
export function ApplicationsTable({ applications }: { applications: Application[] }) {
|
||||
const navigate = useNavigate();
|
||||
const { t } = useTranslation();
|
||||
const { licenseType, requestType, formatDate } = useLicenseLabels();
|
||||
|
||||
if (!applications.length) {
|
||||
return (
|
||||
<EmptyState
|
||||
title={t('applications.empty')}
|
||||
action={{
|
||||
label: t('dashboard.newApplication'),
|
||||
onClick: () => navigate('/apply'),
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Table.ScrollContainer minWidth={760}>
|
||||
<Table striped highlightOnHover verticalSpacing="sm">
|
||||
<Table.Thead>
|
||||
<Table.Tr>
|
||||
<Table.Th>{t('applications.columns.reference')}</Table.Th>
|
||||
<Table.Th>{t('applications.columns.licenseType')}</Table.Th>
|
||||
<Table.Th>{t('applications.columns.request')}</Table.Th>
|
||||
<Table.Th>{t('applications.columns.subject')}</Table.Th>
|
||||
<Table.Th>{t('applications.columns.submitted')}</Table.Th>
|
||||
<Table.Th>{t('applications.columns.status')}</Table.Th>
|
||||
<Table.Th />
|
||||
</Table.Tr>
|
||||
</Table.Thead>
|
||||
<Table.Tbody>
|
||||
{applications.map((app) => (
|
||||
<Table.Tr key={app.id}>
|
||||
<Table.Td>
|
||||
<Anchor fw={600} onClick={() => navigate(`/applications/${app.id}`)}>
|
||||
{app.referenceNo}
|
||||
</Anchor>
|
||||
</Table.Td>
|
||||
<Table.Td>{licenseType(app.licenseType)}</Table.Td>
|
||||
<Table.Td>{requestType(app.requestType)}</Table.Td>
|
||||
<Table.Td>{app.subjectName}</Table.Td>
|
||||
<Table.Td>{formatDate(app.submittedAt)}</Table.Td>
|
||||
<Table.Td>
|
||||
<ApplicationStatusBadge status={app.status} />
|
||||
</Table.Td>
|
||||
<Table.Td>
|
||||
<Tooltip label={t('common.viewDetails')}>
|
||||
<ActionIcon
|
||||
variant="subtle"
|
||||
onClick={() => navigate(`/applications/${app.id}`)}
|
||||
>
|
||||
<IconEye size={18} />
|
||||
</ActionIcon>
|
||||
</Tooltip>
|
||||
</Table.Td>
|
||||
</Table.Tr>
|
||||
))}
|
||||
</Table.Tbody>
|
||||
</Table>
|
||||
</Table.ScrollContainer>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
import { Badge, Group, Stack, Text, ThemeIcon } from '@mantine/core';
|
||||
import {
|
||||
IconFileCheck,
|
||||
IconFileUpload,
|
||||
IconFileX,
|
||||
IconFileDots,
|
||||
} from '@tabler/icons-react';
|
||||
import type { ApplicationDocument, DocumentStatus } from '../types/license.types';
|
||||
import { DOCUMENT_STATUS_COLORS } from '../constants';
|
||||
import { useLicenseLabels } from '../hooks/useLicenseLabels';
|
||||
|
||||
const STATUS_ICON: Record<DocumentStatus, typeof IconFileCheck> = {
|
||||
VERIFIED: IconFileCheck,
|
||||
UPLOADED: IconFileUpload,
|
||||
REJECTED: IconFileX,
|
||||
PENDING: IconFileDots,
|
||||
};
|
||||
|
||||
export function DocumentsList({ documents }: { documents: ApplicationDocument[] }) {
|
||||
const { doc, documentStatus } = useLicenseLabels();
|
||||
|
||||
return (
|
||||
<Stack gap="xs">
|
||||
{documents.map((d) => {
|
||||
const Icon = STATUS_ICON[d.status];
|
||||
const color = DOCUMENT_STATUS_COLORS[d.status];
|
||||
return (
|
||||
<Group key={d.name} justify="space-between" wrap="nowrap">
|
||||
<Group gap="sm" wrap="nowrap">
|
||||
<ThemeIcon variant="light" color={color} size={34} radius="md">
|
||||
<Icon size={18} />
|
||||
</ThemeIcon>
|
||||
<Text size="sm">{doc(d.name)}</Text>
|
||||
</Group>
|
||||
<Badge variant="light" color={color} radius="sm">
|
||||
{documentStatus(d.status)}
|
||||
</Badge>
|
||||
</Group>
|
||||
);
|
||||
})}
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
import { ActionIcon, Anchor, Table, Tooltip } from '@mantine/core';
|
||||
import { IconEye } from '@tabler/icons-react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import type { License } from '../types/license.types';
|
||||
import { LicenseStatusBadge } from './StatusBadge';
|
||||
import { useLicenseLabels } from '../hooks/useLicenseLabels';
|
||||
|
||||
export function LicensesTable({ licenses }: { licenses: License[] }) {
|
||||
const navigate = useNavigate();
|
||||
const { t } = useTranslation();
|
||||
const { licenseType, formatDate } = useLicenseLabels();
|
||||
|
||||
return (
|
||||
<Table.ScrollContainer minWidth={760}>
|
||||
<Table striped highlightOnHover verticalSpacing="sm">
|
||||
<Table.Thead>
|
||||
<Table.Tr>
|
||||
<Table.Th>{t('licenses.columns.licenseNo')}</Table.Th>
|
||||
<Table.Th>{t('licenses.columns.type')}</Table.Th>
|
||||
<Table.Th>{t('licenses.columns.subject')}</Table.Th>
|
||||
<Table.Th>{t('licenses.columns.issued')}</Table.Th>
|
||||
<Table.Th>{t('licenses.columns.expires')}</Table.Th>
|
||||
<Table.Th>{t('licenses.columns.status')}</Table.Th>
|
||||
<Table.Th />
|
||||
</Table.Tr>
|
||||
</Table.Thead>
|
||||
<Table.Tbody>
|
||||
{licenses.map((lic) => (
|
||||
<Table.Tr key={lic.id}>
|
||||
<Table.Td>
|
||||
<Anchor fw={600} onClick={() => navigate(`/licenses/${lic.id}`)}>
|
||||
{lic.licenseNo}
|
||||
</Anchor>
|
||||
</Table.Td>
|
||||
<Table.Td>{licenseType(lic.licenseType)}</Table.Td>
|
||||
<Table.Td>{lic.subjectName}</Table.Td>
|
||||
<Table.Td>{formatDate(lic.issuedAt)}</Table.Td>
|
||||
<Table.Td>{formatDate(lic.expiresAt)}</Table.Td>
|
||||
<Table.Td>
|
||||
<LicenseStatusBadge status={lic.status} />
|
||||
</Table.Td>
|
||||
<Table.Td>
|
||||
<Tooltip label={t('common.viewDetails')}>
|
||||
<ActionIcon
|
||||
variant="subtle"
|
||||
onClick={() => navigate(`/licenses/${lic.id}`)}
|
||||
>
|
||||
<IconEye size={18} />
|
||||
</ActionIcon>
|
||||
</Tooltip>
|
||||
</Table.Td>
|
||||
</Table.Tr>
|
||||
))}
|
||||
</Table.Tbody>
|
||||
</Table>
|
||||
</Table.ScrollContainer>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,79 @@
|
||||
import { Badge, Button, Card, Group, List, Stack, Text, ThemeIcon } from '@mantine/core';
|
||||
import {
|
||||
IconClock,
|
||||
IconCalendarStats,
|
||||
IconArrowRight,
|
||||
IconFileText,
|
||||
} from '@tabler/icons-react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import type { LicenseType } from '../types/license.types';
|
||||
import {
|
||||
LICENSE_PROCESSING_DAYS,
|
||||
LICENSE_VALIDITY_YEARS,
|
||||
REQUIRED_DOCUMENTS,
|
||||
} from '../constants';
|
||||
import { useLicenseLabels } from '../hooks/useLicenseLabels';
|
||||
|
||||
export function ServiceCard({ type }: { type: LicenseType }) {
|
||||
const { t } = useTranslation();
|
||||
const navigate = useNavigate();
|
||||
const { licenseType, doc } = useLicenseLabels();
|
||||
|
||||
const [min, max] = LICENSE_PROCESSING_DAYS[type];
|
||||
const years = LICENSE_VALIDITY_YEARS[type];
|
||||
const docs = REQUIRED_DOCUMENTS[type];
|
||||
|
||||
return (
|
||||
<Card withBorder padding="lg" h="100%" className="ema-hover-lift">
|
||||
<Stack gap="sm" h="100%">
|
||||
<Group justify="space-between" align="flex-start" wrap="nowrap">
|
||||
<Text fw={650} lh={1.25}>
|
||||
{licenseType(type)}
|
||||
</Text>
|
||||
<ThemeIcon variant="light" radius="md" size={38} color="emaPrimary">
|
||||
<IconFileText size={20} />
|
||||
</ThemeIcon>
|
||||
</Group>
|
||||
|
||||
<Group gap="xs">
|
||||
<Badge
|
||||
variant="light"
|
||||
color="indigo"
|
||||
leftSection={<IconClock size={12} />}
|
||||
>
|
||||
{t('services.processingDays', { min, max })}
|
||||
</Badge>
|
||||
<Badge
|
||||
variant="light"
|
||||
color="teal"
|
||||
leftSection={<IconCalendarStats size={12} />}
|
||||
>
|
||||
{t('services.validityYears', { count: years })}
|
||||
</Badge>
|
||||
</Group>
|
||||
|
||||
<div>
|
||||
<Text size="xs" c="dimmed" fw={600} tt="uppercase" mb={4}>
|
||||
{t('services.requiredDocuments')}
|
||||
</Text>
|
||||
<List size="sm" spacing={2} c="dimmed">
|
||||
{docs.slice(0, 3).map((d) => (
|
||||
<List.Item key={d}>{doc(d)}</List.Item>
|
||||
))}
|
||||
{docs.length > 3 && <List.Item>+{docs.length - 3}</List.Item>}
|
||||
</List>
|
||||
</div>
|
||||
|
||||
<Button
|
||||
mt="auto"
|
||||
variant="light"
|
||||
rightSection={<IconArrowRight size={16} />}
|
||||
onClick={() => navigate(`/apply?type=${type}`)}
|
||||
>
|
||||
{t('services.apply')}
|
||||
</Button>
|
||||
</Stack>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
import { Group, Paper, Text, ThemeIcon } from '@mantine/core';
|
||||
import type { Icon } from '@tabler/icons-react';
|
||||
|
||||
interface StatCardProps {
|
||||
label: string;
|
||||
value: number | string;
|
||||
icon: Icon;
|
||||
color?: string;
|
||||
/** Optional helper line under the value. */
|
||||
hint?: string;
|
||||
}
|
||||
|
||||
export function StatCard({
|
||||
label,
|
||||
value,
|
||||
icon: IconCmp,
|
||||
color = 'emaPrimary',
|
||||
hint,
|
||||
}: StatCardProps) {
|
||||
return (
|
||||
<Paper
|
||||
p="lg"
|
||||
shadow="sm"
|
||||
withBorder
|
||||
className="ema-hover-lift"
|
||||
style={{ overflow: 'hidden', position: 'relative' }}
|
||||
>
|
||||
{/* faint accent wash in the corner */}
|
||||
<div
|
||||
aria-hidden
|
||||
style={{
|
||||
position: 'absolute',
|
||||
top: -28,
|
||||
right: -28,
|
||||
width: 96,
|
||||
height: 96,
|
||||
borderRadius: '50%',
|
||||
background: `var(--mantine-color-${color}-light)`,
|
||||
opacity: 0.5,
|
||||
}}
|
||||
/>
|
||||
<Group justify="space-between" align="flex-start" wrap="nowrap" style={{ position: 'relative' }}>
|
||||
<div>
|
||||
<Text size="xs" c="dimmed" tt="uppercase" fw={700} lts={0.3}>
|
||||
{label}
|
||||
</Text>
|
||||
<Text fw={800} fz={30} lh={1.1} mt={6}>
|
||||
{value}
|
||||
</Text>
|
||||
{hint && (
|
||||
<Text size="xs" c="dimmed" mt={4}>
|
||||
{hint}
|
||||
</Text>
|
||||
)}
|
||||
</div>
|
||||
<ThemeIcon color={color} variant="light" size={46} radius="md">
|
||||
<IconCmp size={24} />
|
||||
</ThemeIcon>
|
||||
</Group>
|
||||
</Paper>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
import { Badge } from '@mantine/core';
|
||||
import { APPLICATION_STATUS_COLORS, LICENSE_STATUS_COLORS } from '../constants';
|
||||
import type { ApplicationStatus, LicenseStatus } from '../types/license.types';
|
||||
import { useLicenseLabels } from '../hooks/useLicenseLabels';
|
||||
|
||||
export function ApplicationStatusBadge({ status }: { status: ApplicationStatus }) {
|
||||
const { applicationStatus } = useLicenseLabels();
|
||||
return (
|
||||
<Badge color={APPLICATION_STATUS_COLORS[status]} variant="light" radius="sm">
|
||||
{applicationStatus(status)}
|
||||
</Badge>
|
||||
);
|
||||
}
|
||||
|
||||
export function LicenseStatusBadge({ status }: { status: LicenseStatus }) {
|
||||
const { licenseStatus } = useLicenseLabels();
|
||||
return (
|
||||
<Badge color={LICENSE_STATUS_COLORS[status]} variant="light" radius="sm">
|
||||
{licenseStatus(status)}
|
||||
</Badge>
|
||||
);
|
||||
}
|
||||
145
apps/portal/src/app/features/licenses/constants.ts
Normal file
145
apps/portal/src/app/features/licenses/constants.ts
Normal file
@@ -0,0 +1,145 @@
|
||||
import type {
|
||||
ApplicationStatus,
|
||||
DocumentStatus,
|
||||
LicenseStatus,
|
||||
LicenseType,
|
||||
RequestType,
|
||||
} from './types/license.types';
|
||||
|
||||
export const LICENSE_TYPE_LABELS: Record<LicenseType, string> = {
|
||||
SEAFARER_COC: 'Certificate of Competency (CoC)',
|
||||
SEAFARER_COP: 'Certificate of Proficiency (CoP)',
|
||||
SEAMAN_BOOK: "Seafarer's Identity & Record Book",
|
||||
VESSEL_REGISTRATION: 'Vessel Registration Certificate',
|
||||
SHIP_RADIO: 'Ship Station Radio Licence',
|
||||
TONNAGE_CERTIFICATE: 'International Tonnage Certificate',
|
||||
SAFETY_MANAGEMENT: 'Safety Management Certificate',
|
||||
PORT_FACILITY: 'Port Facility Operation Licence',
|
||||
BOAT_OPERATOR: 'Inland Boat Operator Licence',
|
||||
};
|
||||
|
||||
/** Whether a licence type is about a person (seafarer) or an asset (vessel/facility). */
|
||||
export const LICENSE_SUBJECT_LABEL: Record<LicenseType, string> = {
|
||||
SEAFARER_COC: 'Seafarer full name',
|
||||
SEAFARER_COP: 'Seafarer full name',
|
||||
SEAMAN_BOOK: 'Seafarer full name',
|
||||
VESSEL_REGISTRATION: 'Vessel name',
|
||||
SHIP_RADIO: 'Vessel name',
|
||||
TONNAGE_CERTIFICATE: 'Vessel name',
|
||||
SAFETY_MANAGEMENT: 'Vessel name',
|
||||
PORT_FACILITY: 'Facility name',
|
||||
BOAT_OPERATOR: 'Operator full name',
|
||||
};
|
||||
|
||||
/** Documents typically required, keyed by licence type. */
|
||||
export const REQUIRED_DOCUMENTS: Record<LicenseType, string[]> = {
|
||||
SEAFARER_COC: ['Passport copy', 'Training certificate', 'Medical fitness certificate', 'Sea service record'],
|
||||
SEAFARER_COP: ['Passport copy', 'Course completion certificate', 'Medical fitness certificate'],
|
||||
SEAMAN_BOOK: ['Passport copy', 'Passport photo', 'Police clearance'],
|
||||
VESSEL_REGISTRATION: ['Bill of sale', 'Builder certificate', 'Tonnage measurement', 'Insurance certificate'],
|
||||
SHIP_RADIO: ['Vessel registration', 'Equipment specification', 'Operator certificate'],
|
||||
TONNAGE_CERTIFICATE: ['Vessel registration', 'Survey report', 'General arrangement plan'],
|
||||
SAFETY_MANAGEMENT: ['Vessel registration', 'Safety management manual', 'Audit report'],
|
||||
PORT_FACILITY: ['Business licence', 'Facility security assessment', 'Site plan'],
|
||||
BOAT_OPERATOR: ['ID copy', 'Training certificate', 'Medical fitness certificate'],
|
||||
};
|
||||
|
||||
export const REQUEST_TYPE_LABELS: Record<RequestType, string> = {
|
||||
NEW: 'New Application',
|
||||
RENEWAL: 'Renewal',
|
||||
AMENDMENT: 'Amendment',
|
||||
COMPLIANCE: 'Compliance Submission',
|
||||
};
|
||||
|
||||
export const APPLICATION_STATUS_LABELS: Record<ApplicationStatus, string> = {
|
||||
DRAFT: 'Draft',
|
||||
SUBMITTED: 'Submitted',
|
||||
UNDER_REVIEW: 'Under Review',
|
||||
INFO_REQUESTED: 'Information Requested',
|
||||
PAYMENT_PENDING: 'Payment Pending',
|
||||
APPROVED: 'Approved',
|
||||
REJECTED: 'Rejected',
|
||||
ISSUED: 'Issued',
|
||||
};
|
||||
|
||||
export const APPLICATION_STATUS_COLORS: Record<ApplicationStatus, string> = {
|
||||
DRAFT: 'gray',
|
||||
SUBMITTED: 'blue',
|
||||
UNDER_REVIEW: 'indigo',
|
||||
INFO_REQUESTED: 'orange',
|
||||
PAYMENT_PENDING: 'yellow',
|
||||
APPROVED: 'teal',
|
||||
REJECTED: 'red',
|
||||
ISSUED: 'green',
|
||||
};
|
||||
|
||||
export const LICENSE_STATUS_LABELS: Record<LicenseStatus, string> = {
|
||||
ACTIVE: 'Active',
|
||||
EXPIRING_SOON: 'Expiring Soon',
|
||||
EXPIRED: 'Expired',
|
||||
SUSPENDED: 'Suspended',
|
||||
};
|
||||
|
||||
export const LICENSE_STATUS_COLORS: Record<LicenseStatus, string> = {
|
||||
ACTIVE: 'green',
|
||||
EXPIRING_SOON: 'orange',
|
||||
EXPIRED: 'red',
|
||||
SUSPENDED: 'grape',
|
||||
};
|
||||
|
||||
export const DOCUMENT_STATUS_COLORS: Record<DocumentStatus, string> = {
|
||||
PENDING: 'gray',
|
||||
UPLOADED: 'blue',
|
||||
VERIFIED: 'teal',
|
||||
REJECTED: 'red',
|
||||
};
|
||||
|
||||
/** Service-catalog grouping for each licence type. */
|
||||
export type ServiceCategory = 'seafarer' | 'vessel' | 'facility';
|
||||
|
||||
export const LICENSE_CATEGORY: Record<LicenseType, ServiceCategory> = {
|
||||
SEAFARER_COC: 'seafarer',
|
||||
SEAFARER_COP: 'seafarer',
|
||||
SEAMAN_BOOK: 'seafarer',
|
||||
BOAT_OPERATOR: 'seafarer',
|
||||
VESSEL_REGISTRATION: 'vessel',
|
||||
SHIP_RADIO: 'vessel',
|
||||
TONNAGE_CERTIFICATE: 'vessel',
|
||||
SAFETY_MANAGEMENT: 'vessel',
|
||||
PORT_FACILITY: 'facility',
|
||||
};
|
||||
|
||||
/** Indicative processing window [min, max] in working days (template data). */
|
||||
export const LICENSE_PROCESSING_DAYS: Record<LicenseType, [number, number]> = {
|
||||
SEAFARER_COC: [10, 15],
|
||||
SEAFARER_COP: [5, 10],
|
||||
SEAMAN_BOOK: [3, 7],
|
||||
VESSEL_REGISTRATION: [10, 20],
|
||||
SHIP_RADIO: [5, 10],
|
||||
TONNAGE_CERTIFICATE: [7, 14],
|
||||
SAFETY_MANAGEMENT: [10, 15],
|
||||
PORT_FACILITY: [15, 30],
|
||||
BOAT_OPERATOR: [3, 7],
|
||||
};
|
||||
|
||||
/** Validity period in years (template data). */
|
||||
export const LICENSE_VALIDITY_YEARS: Record<LicenseType, number> = {
|
||||
SEAFARER_COC: 5,
|
||||
SEAFARER_COP: 5,
|
||||
SEAMAN_BOOK: 10,
|
||||
VESSEL_REGISTRATION: 5,
|
||||
SHIP_RADIO: 1,
|
||||
TONNAGE_CERTIFICATE: 5,
|
||||
SAFETY_MANAGEMENT: 5,
|
||||
PORT_FACILITY: 3,
|
||||
BOAT_OPERATOR: 3,
|
||||
};
|
||||
|
||||
/** Ordered pipeline used to render application progress. */
|
||||
export const APPLICATION_PIPELINE: ApplicationStatus[] = [
|
||||
'SUBMITTED',
|
||||
'UNDER_REVIEW',
|
||||
'PAYMENT_PENDING',
|
||||
'APPROVED',
|
||||
'ISSUED',
|
||||
];
|
||||
@@ -0,0 +1,35 @@
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import type {
|
||||
ApplicationStatus,
|
||||
DocumentStatus,
|
||||
LicenseStatus,
|
||||
LicenseType,
|
||||
RequestType,
|
||||
} from '../types/license.types';
|
||||
|
||||
/**
|
||||
* Resolves domain enum values to localized labels and formats dates in the
|
||||
* active language. Keeps component markup free of `t(\`enum.${v}\`)` noise.
|
||||
*/
|
||||
export function useLicenseLabels() {
|
||||
const { t, i18n } = useTranslation();
|
||||
const locale = i18n.language === 'am' ? 'am-ET' : 'en-GB';
|
||||
|
||||
const formatDate = (iso: string) =>
|
||||
new Date(iso).toLocaleDateString(locale, {
|
||||
year: 'numeric',
|
||||
month: 'short',
|
||||
day: 'numeric',
|
||||
});
|
||||
|
||||
return {
|
||||
licenseType: (v: LicenseType) => t(`licenseType.${v}`),
|
||||
subjectLabel: (v: LicenseType) => t(`subjectLabel.${v}`),
|
||||
requestType: (v: RequestType) => t(`requestType.${v}`),
|
||||
applicationStatus: (v: ApplicationStatus) => t(`applicationStatus.${v}`),
|
||||
licenseStatus: (v: LicenseStatus) => t(`licenseStatus.${v}`),
|
||||
documentStatus: (v: DocumentStatus) => t(`documentStatus.${v}`),
|
||||
doc: (name: string) => t(`documents.${name}`, { defaultValue: name }),
|
||||
formatDate,
|
||||
};
|
||||
}
|
||||
25
apps/portal/src/app/features/licenses/hooks/useLicenses.ts
Normal file
25
apps/portal/src/app/features/licenses/hooks/useLicenses.ts
Normal file
@@ -0,0 +1,25 @@
|
||||
import { useAppDispatch, useAppSelector } from '../../../store/hooks';
|
||||
import { submitApplication } from '../store/licenses.slice';
|
||||
import type { NewApplicationInput } from '../types/license.types';
|
||||
|
||||
export function useLicenses() {
|
||||
const dispatch = useAppDispatch();
|
||||
const applications = useAppSelector((s) => s.licenses.applications);
|
||||
const licenses = useAppSelector((s) => s.licenses.licenses);
|
||||
|
||||
return {
|
||||
applications,
|
||||
licenses,
|
||||
submit: (input: NewApplicationInput) => dispatch(submitApplication(input)),
|
||||
};
|
||||
}
|
||||
|
||||
export function useApplication(id: string | undefined) {
|
||||
return useAppSelector((s) =>
|
||||
s.licenses.applications.find((a) => a.id === id),
|
||||
);
|
||||
}
|
||||
|
||||
export function useLicense(id: string | undefined) {
|
||||
return useAppSelector((s) => s.licenses.licenses.find((l) => l.id === id));
|
||||
}
|
||||
@@ -0,0 +1,143 @@
|
||||
import {
|
||||
Alert,
|
||||
Button,
|
||||
Grid,
|
||||
Paper,
|
||||
SimpleGrid,
|
||||
Stack,
|
||||
Text,
|
||||
Title,
|
||||
} from '@mantine/core';
|
||||
import { IconAlertTriangle, IconArrowLeft } from '@tabler/icons-react';
|
||||
import { useNavigate, useParams } from 'react-router-dom';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { PageHeader } from '../../../components/PageHeader';
|
||||
import { EmptyState } from '../../../components/EmptyState';
|
||||
import { ApplicationStatusBadge } from '../components/StatusBadge';
|
||||
import { ApplicationTimeline } from '../components/ApplicationTimeline';
|
||||
import { DocumentsList } from '../components/DocumentsList';
|
||||
import { useApplication } from '../hooks/useLicenses';
|
||||
import { useLicenseLabels } from '../hooks/useLicenseLabels';
|
||||
|
||||
export function ApplicationDetailPage() {
|
||||
const { t } = useTranslation();
|
||||
const { id } = useParams();
|
||||
const navigate = useNavigate();
|
||||
const app = useApplication(id);
|
||||
const { licenseType, requestType, formatDate } = useLicenseLabels();
|
||||
|
||||
if (!app) {
|
||||
return (
|
||||
<EmptyState
|
||||
title={t('common.notFound')}
|
||||
action={{
|
||||
label: t('common.backToList'),
|
||||
onClick: () => navigate('/applications'),
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Stack gap="lg">
|
||||
<Button
|
||||
variant="subtle"
|
||||
size="sm"
|
||||
leftSection={<IconArrowLeft size={16} />}
|
||||
onClick={() => navigate('/applications')}
|
||||
w="fit-content"
|
||||
>
|
||||
{t('common.backToList')}
|
||||
</Button>
|
||||
|
||||
<PageHeader
|
||||
title={`${t('applicationDetail.title')} · ${app.referenceNo}`}
|
||||
subtitle={licenseType(app.licenseType)}
|
||||
action={<ApplicationStatusBadge status={app.status} />}
|
||||
/>
|
||||
|
||||
{app.status === 'INFO_REQUESTED' && (
|
||||
<Alert
|
||||
variant="light"
|
||||
color="orange"
|
||||
icon={<IconAlertTriangle size={18} />}
|
||||
title={t('applicationDetail.actionRequired')}
|
||||
>
|
||||
<Stack gap="xs">
|
||||
<Text size="sm">{app.notes ?? t('applicationDetail.actionRequiredDesc')}</Text>
|
||||
<Button color="orange" size="xs" w="fit-content">
|
||||
{t('applicationDetail.respond')}
|
||||
</Button>
|
||||
</Stack>
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
<Grid gutter="md">
|
||||
<Grid.Col span={{ base: 12, md: 7 }}>
|
||||
<Stack gap="md">
|
||||
<Paper p="md" shadow="sm" radius="md" withBorder>
|
||||
<Title order={5} mb="md">
|
||||
{t('applicationDetail.overview')}
|
||||
</Title>
|
||||
<SimpleGrid cols={{ base: 1, sm: 2 }} spacing="md">
|
||||
<Field label={t('applicationDetail.applicant')} value={app.applicantName} />
|
||||
<Field label={t('applicationDetail.subject')} value={app.subjectName} />
|
||||
<Field
|
||||
label={t('applicationDetail.requestType')}
|
||||
value={requestType(app.requestType)}
|
||||
/>
|
||||
<Field
|
||||
label={t('applicationDetail.licenseType')}
|
||||
value={licenseType(app.licenseType)}
|
||||
/>
|
||||
<Field
|
||||
label={t('applicationDetail.submittedOn')}
|
||||
value={formatDate(app.submittedAt)}
|
||||
/>
|
||||
<Field
|
||||
label={t('applicationDetail.lastUpdated')}
|
||||
value={formatDate(app.updatedAt)}
|
||||
/>
|
||||
{app.relatedLicenseNo && (
|
||||
<Field
|
||||
label={t('applicationDetail.relatedLicense')}
|
||||
value={app.relatedLicenseNo}
|
||||
/>
|
||||
)}
|
||||
</SimpleGrid>
|
||||
</Paper>
|
||||
|
||||
<Paper p="md" shadow="sm" radius="md" withBorder>
|
||||
<Title order={5} mb="md">
|
||||
{t('applicationDetail.documents')}
|
||||
</Title>
|
||||
<DocumentsList documents={app.documents} />
|
||||
</Paper>
|
||||
</Stack>
|
||||
</Grid.Col>
|
||||
|
||||
<Grid.Col span={{ base: 12, md: 5 }}>
|
||||
<Paper p="md" shadow="sm" radius="md" withBorder>
|
||||
<Title order={5} mb="md">
|
||||
{t('applicationDetail.progress')}
|
||||
</Title>
|
||||
<ApplicationTimeline history={app.history} />
|
||||
</Paper>
|
||||
</Grid.Col>
|
||||
</Grid>
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
|
||||
function Field({ label, value }: { label: string; value: string }) {
|
||||
return (
|
||||
<div>
|
||||
<Text size="xs" c="dimmed" tt="uppercase" fw={600}>
|
||||
{label}
|
||||
</Text>
|
||||
<Text size="sm" mt={2}>
|
||||
{value}
|
||||
</Text>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,83 @@
|
||||
import { useMemo, useState } from 'react';
|
||||
import { Button, Group, Paper, Select, Stack, TextInput } from '@mantine/core';
|
||||
import { IconFilePlus, IconSearch } from '@tabler/icons-react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { PageHeader } from '../../../components/PageHeader';
|
||||
import { ApplicationsTable } from '../components/ApplicationsTable';
|
||||
import { useLicenses } from '../hooks/useLicenses';
|
||||
import { useLicenseLabels } from '../hooks/useLicenseLabels';
|
||||
import { APPLICATION_STATUS_LABELS } from '../constants';
|
||||
import type { ApplicationStatus } from '../types/license.types';
|
||||
|
||||
const STATUS_VALUES = Object.keys(APPLICATION_STATUS_LABELS) as ApplicationStatus[];
|
||||
|
||||
export function ApplicationsListPage() {
|
||||
const { t } = useTranslation();
|
||||
const navigate = useNavigate();
|
||||
const { applications } = useLicenses();
|
||||
const { applicationStatus } = useLicenseLabels();
|
||||
|
||||
const [query, setQuery] = useState('');
|
||||
const [status, setStatus] = useState<string | null>(null);
|
||||
|
||||
const filtered = useMemo(() => {
|
||||
const q = query.trim().toLowerCase();
|
||||
return applications.filter((app) => {
|
||||
const matchesStatus = !status || app.status === status;
|
||||
const matchesQuery =
|
||||
!q ||
|
||||
app.referenceNo.toLowerCase().includes(q) ||
|
||||
app.subjectName.toLowerCase().includes(q) ||
|
||||
app.applicantName.toLowerCase().includes(q);
|
||||
return matchesStatus && matchesQuery;
|
||||
});
|
||||
}, [applications, query, status]);
|
||||
|
||||
const statusOptions = [
|
||||
{ value: '', label: t('common.all') },
|
||||
...STATUS_VALUES.map((s) => ({ value: s, label: applicationStatus(s) })),
|
||||
];
|
||||
|
||||
return (
|
||||
<Stack gap="lg">
|
||||
<PageHeader
|
||||
title={t('applications.title')}
|
||||
subtitle={t('applications.subtitle')}
|
||||
action={
|
||||
<Button
|
||||
leftSection={<IconFilePlus size={18} />}
|
||||
onClick={() => navigate('/apply')}
|
||||
>
|
||||
{t('dashboard.newApplication')}
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
|
||||
<Paper p="md" shadow="sm" radius="md" withBorder>
|
||||
<Stack gap="md">
|
||||
<Group gap="sm" wrap="wrap">
|
||||
<TextInput
|
||||
flex={1}
|
||||
miw={220}
|
||||
leftSection={<IconSearch size={16} />}
|
||||
placeholder={t('applications.searchPlaceholder')}
|
||||
value={query}
|
||||
onChange={(e) => setQuery(e.currentTarget.value)}
|
||||
/>
|
||||
<Select
|
||||
w={220}
|
||||
placeholder={t('common.status')}
|
||||
data={statusOptions}
|
||||
value={status ?? ''}
|
||||
onChange={(v) => setStatus(v || null)}
|
||||
clearable={false}
|
||||
/>
|
||||
</Group>
|
||||
|
||||
<ApplicationsTable applications={filtered} />
|
||||
</Stack>
|
||||
</Paper>
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
252
apps/portal/src/app/features/licenses/pages/ApplyPage.tsx
Normal file
252
apps/portal/src/app/features/licenses/pages/ApplyPage.tsx
Normal file
@@ -0,0 +1,252 @@
|
||||
import { useMemo, useState } from 'react';
|
||||
import {
|
||||
Alert,
|
||||
Button,
|
||||
Checkbox,
|
||||
Group,
|
||||
Paper,
|
||||
Select,
|
||||
SimpleGrid,
|
||||
Stack,
|
||||
Stepper,
|
||||
Text,
|
||||
Textarea,
|
||||
TextInput,
|
||||
} from '@mantine/core';
|
||||
import { IconCircleCheck, IconInfoCircle } from '@tabler/icons-react';
|
||||
import { useNavigate, useSearchParams } from 'react-router-dom';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { notify } from '@ema-platform/ui';
|
||||
import { REQUIRED_DOCUMENTS } from '../constants';
|
||||
import { PageHeader } from '../../../components/PageHeader';
|
||||
import { useLicenses } from '../hooks/useLicenses';
|
||||
import { useLicenseLabels } from '../hooks/useLicenseLabels';
|
||||
import type { LicenseType, RequestType } from '../types/license.types';
|
||||
import { LICENSE_TYPE_LABELS } from '../constants';
|
||||
|
||||
const APPLY_REQUEST_TYPES: RequestType[] = ['NEW', 'RENEWAL'];
|
||||
const LICENSE_TYPE_VALUES = Object.keys(LICENSE_TYPE_LABELS) as LicenseType[];
|
||||
|
||||
export function ApplyPage() {
|
||||
const { t } = useTranslation();
|
||||
const navigate = useNavigate();
|
||||
const [params] = useSearchParams();
|
||||
const { submit } = useLicenses();
|
||||
const { licenseType: ltLabel, subjectLabel, requestType: rtLabel, doc } =
|
||||
useLicenseLabels();
|
||||
|
||||
const presetType = params.get('type') as LicenseType | null;
|
||||
const presetRequest = params.get('request') as RequestType | null;
|
||||
|
||||
const [active, setActive] = useState(0);
|
||||
const [requestType, setRequestType] = useState<RequestType>(
|
||||
presetRequest && APPLY_REQUEST_TYPES.includes(presetRequest) ? presetRequest : 'NEW',
|
||||
);
|
||||
const [licenseType, setLicenseType] = useState<LicenseType | null>(
|
||||
presetType && LICENSE_TYPE_VALUES.includes(presetType) ? presetType : null,
|
||||
);
|
||||
const [applicantName, setApplicantName] = useState('');
|
||||
const [subjectName, setSubjectName] = useState('');
|
||||
const [notes, setNotes] = useState('');
|
||||
const [uploaded, setUploaded] = useState<Record<string, boolean>>({});
|
||||
|
||||
const requiredDocs = useMemo(
|
||||
() => (licenseType ? REQUIRED_DOCUMENTS[licenseType] : []),
|
||||
[licenseType],
|
||||
);
|
||||
|
||||
const subjectFieldLabel = licenseType
|
||||
? subjectLabel(licenseType)
|
||||
: t('apply.fields.applicantName');
|
||||
|
||||
const canContinue = () => {
|
||||
if (active === 0) return !!licenseType;
|
||||
if (active === 1) return !!applicantName.trim() && !!subjectName.trim();
|
||||
return true;
|
||||
};
|
||||
|
||||
const allDocsUploaded =
|
||||
requiredDocs.length > 0 && requiredDocs.every((d) => uploaded[d]);
|
||||
|
||||
const next = () => setActive((c) => Math.min(c + 1, 3));
|
||||
const prev = () => setActive((c) => Math.max(c - 1, 0));
|
||||
|
||||
const toggleDoc = (name: string) =>
|
||||
setUploaded((prevState) => ({ ...prevState, [name]: !prevState[name] }));
|
||||
|
||||
const handleSubmit = () => {
|
||||
if (!licenseType) return;
|
||||
submit({
|
||||
requestType,
|
||||
licenseType,
|
||||
applicantName: applicantName.trim(),
|
||||
subjectName: subjectName.trim(),
|
||||
notes: notes.trim() || undefined,
|
||||
documents: requiredDocs.filter((d) => uploaded[d]),
|
||||
});
|
||||
notify.success(t('apply.submitted'));
|
||||
navigate('/applications');
|
||||
};
|
||||
|
||||
return (
|
||||
<Stack gap="lg" maw={860} mx="auto">
|
||||
<PageHeader title={t('apply.title')} subtitle={t('apply.subtitle')} />
|
||||
|
||||
<Paper p="xl" shadow="sm" radius="md" withBorder>
|
||||
<Stepper active={active} onStepClick={setActive} size="sm">
|
||||
<Stepper.Step
|
||||
label={t('apply.steps.license')}
|
||||
description={t('apply.steps.licenseDesc')}
|
||||
>
|
||||
<Stack gap="md" mt="xl">
|
||||
<Select
|
||||
label={t('apply.fields.requestType')}
|
||||
data={APPLY_REQUEST_TYPES.map((v) => ({
|
||||
value: v,
|
||||
label: rtLabel(v),
|
||||
}))}
|
||||
value={requestType}
|
||||
onChange={(v) => setRequestType((v as RequestType) ?? 'NEW')}
|
||||
allowDeselect={false}
|
||||
/>
|
||||
<Select
|
||||
label={t('apply.fields.licenseType')}
|
||||
placeholder={t('apply.fields.licenseTypePlaceholder')}
|
||||
data={LICENSE_TYPE_VALUES.map((v) => ({
|
||||
value: v,
|
||||
label: ltLabel(v),
|
||||
}))}
|
||||
value={licenseType}
|
||||
onChange={(v) => setLicenseType(v as LicenseType)}
|
||||
searchable
|
||||
/>
|
||||
</Stack>
|
||||
</Stepper.Step>
|
||||
|
||||
<Stepper.Step
|
||||
label={t('apply.steps.details')}
|
||||
description={t('apply.steps.detailsDesc')}
|
||||
>
|
||||
<Stack gap="md" mt="xl">
|
||||
<TextInput
|
||||
label={t('apply.fields.applicantName')}
|
||||
placeholder={t('apply.fields.applicantPlaceholder')}
|
||||
value={applicantName}
|
||||
onChange={(e) => setApplicantName(e.currentTarget.value)}
|
||||
/>
|
||||
<TextInput
|
||||
label={subjectFieldLabel}
|
||||
placeholder={subjectFieldLabel}
|
||||
value={subjectName}
|
||||
onChange={(e) => setSubjectName(e.currentTarget.value)}
|
||||
/>
|
||||
<Textarea
|
||||
label={`${t('apply.fields.notes')} (${t('common.optional')})`}
|
||||
placeholder={t('apply.fields.notesPlaceholder')}
|
||||
minRows={3}
|
||||
autosize
|
||||
value={notes}
|
||||
onChange={(e) => setNotes(e.currentTarget.value)}
|
||||
/>
|
||||
</Stack>
|
||||
</Stepper.Step>
|
||||
|
||||
<Stepper.Step
|
||||
label={t('apply.steps.documents')}
|
||||
description={t('apply.steps.documentsDesc')}
|
||||
>
|
||||
<Stack gap="sm" mt="xl">
|
||||
<Alert
|
||||
variant="light"
|
||||
color="emaPrimary"
|
||||
icon={<IconInfoCircle size={18} />}
|
||||
>
|
||||
{t('apply.docs.hint')}
|
||||
</Alert>
|
||||
{requiredDocs.map((d) => (
|
||||
<Checkbox
|
||||
key={d}
|
||||
label={doc(d)}
|
||||
checked={!!uploaded[d]}
|
||||
onChange={() => toggleDoc(d)}
|
||||
/>
|
||||
))}
|
||||
</Stack>
|
||||
</Stepper.Step>
|
||||
|
||||
<Stepper.Completed>
|
||||
<Stack gap="md" mt="xl">
|
||||
<Alert
|
||||
variant="light"
|
||||
color="teal"
|
||||
icon={<IconCircleCheck size={18} />}
|
||||
title={t('apply.review.title')}
|
||||
>
|
||||
{t('apply.review.hint')}
|
||||
</Alert>
|
||||
<SimpleGrid cols={{ base: 1, sm: 2 }} spacing="md">
|
||||
<ReviewItem
|
||||
label={t('apply.fields.requestType')}
|
||||
value={rtLabel(requestType)}
|
||||
/>
|
||||
<ReviewItem
|
||||
label={t('apply.fields.licenseType')}
|
||||
value={licenseType ? ltLabel(licenseType) : '—'}
|
||||
/>
|
||||
<ReviewItem
|
||||
label={t('apply.fields.applicantName')}
|
||||
value={applicantName || '—'}
|
||||
/>
|
||||
<ReviewItem label={subjectFieldLabel} value={subjectName || '—'} />
|
||||
</SimpleGrid>
|
||||
<div>
|
||||
<Text size="sm" fw={600} mb={4}>
|
||||
{t('apply.docs.attached')}
|
||||
</Text>
|
||||
<Text size="sm" c="dimmed">
|
||||
{t('apply.docs.attachedCount', {
|
||||
count: requiredDocs.filter((d) => uploaded[d]).length,
|
||||
total: requiredDocs.length,
|
||||
})}
|
||||
</Text>
|
||||
</div>
|
||||
{!allDocsUploaded && (
|
||||
<Alert variant="light" color="orange">
|
||||
{t('apply.review.missingDocs')}
|
||||
</Alert>
|
||||
)}
|
||||
</Stack>
|
||||
</Stepper.Completed>
|
||||
</Stepper>
|
||||
|
||||
<Group justify="space-between" mt="xl">
|
||||
<Button variant="default" onClick={prev} disabled={active === 0}>
|
||||
{t('common.back')}
|
||||
</Button>
|
||||
{active < 3 ? (
|
||||
<Button onClick={next} disabled={!canContinue()}>
|
||||
{t('common.continue')}
|
||||
</Button>
|
||||
) : (
|
||||
<Button color="teal" onClick={handleSubmit}>
|
||||
{t('apply.submitAction')}
|
||||
</Button>
|
||||
)}
|
||||
</Group>
|
||||
</Paper>
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
|
||||
function ReviewItem({ label, value }: { label: string; value: string }) {
|
||||
return (
|
||||
<div>
|
||||
<Text size="xs" c="dimmed" tt="uppercase" fw={600}>
|
||||
{label}
|
||||
</Text>
|
||||
<Text size="sm" mt={2}>
|
||||
{value}
|
||||
</Text>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,158 @@
|
||||
import {
|
||||
Alert,
|
||||
Button,
|
||||
Card,
|
||||
Divider,
|
||||
Group,
|
||||
Paper,
|
||||
SimpleGrid,
|
||||
Stack,
|
||||
Text,
|
||||
ThemeIcon,
|
||||
Title,
|
||||
} from '@mantine/core';
|
||||
import {
|
||||
IconAlertTriangle,
|
||||
IconArrowLeft,
|
||||
IconCertificate,
|
||||
IconDownload,
|
||||
IconRefresh,
|
||||
} from '@tabler/icons-react';
|
||||
import { useNavigate, useParams } from 'react-router-dom';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { notify } from '@ema-platform/ui';
|
||||
import { PageHeader } from '../../../components/PageHeader';
|
||||
import { EmptyState } from '../../../components/EmptyState';
|
||||
import { LicenseStatusBadge } from '../components/StatusBadge';
|
||||
import { useLicense } from '../hooks/useLicenses';
|
||||
import { useLicenseLabels } from '../hooks/useLicenseLabels';
|
||||
|
||||
export function LicenseDetailPage() {
|
||||
const { t } = useTranslation();
|
||||
const { id } = useParams();
|
||||
const navigate = useNavigate();
|
||||
const license = useLicense(id);
|
||||
const { licenseType, formatDate } = useLicenseLabels();
|
||||
|
||||
if (!license) {
|
||||
return (
|
||||
<EmptyState
|
||||
icon={IconCertificate}
|
||||
title={t('common.notFound')}
|
||||
action={{
|
||||
label: t('common.backToList'),
|
||||
onClick: () => navigate('/licenses'),
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
const daysLeft = Math.ceil(
|
||||
(new Date(license.expiresAt).getTime() - Date.now()) / 86_400_000,
|
||||
);
|
||||
|
||||
return (
|
||||
<Stack gap="lg">
|
||||
<Button
|
||||
variant="subtle"
|
||||
size="sm"
|
||||
leftSection={<IconArrowLeft size={16} />}
|
||||
onClick={() => navigate('/licenses')}
|
||||
w="fit-content"
|
||||
>
|
||||
{t('common.backToList')}
|
||||
</Button>
|
||||
|
||||
<PageHeader
|
||||
title={license.licenseNo}
|
||||
subtitle={licenseType(license.licenseType)}
|
||||
action={<LicenseStatusBadge status={license.status} />}
|
||||
/>
|
||||
|
||||
{license.status === 'EXPIRED' && (
|
||||
<Alert color="red" variant="light" icon={<IconAlertTriangle size={18} />}>
|
||||
{t('licenses.detail.expired')}
|
||||
</Alert>
|
||||
)}
|
||||
{license.status === 'EXPIRING_SOON' && (
|
||||
<Alert color="orange" variant="light" icon={<IconAlertTriangle size={18} />}>
|
||||
{t('licenses.detail.expiringSoon')}
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
<Card withBorder radius="md" padding="xl">
|
||||
<Group justify="space-between" align="flex-start" wrap="nowrap">
|
||||
<Group gap="md" wrap="nowrap">
|
||||
<ThemeIcon
|
||||
size={56}
|
||||
radius="md"
|
||||
variant="gradient"
|
||||
gradient={{ from: 'emaPrimary.7', to: 'emaPrimary.5', deg: 135 }}
|
||||
>
|
||||
<IconCertificate size={30} />
|
||||
</ThemeIcon>
|
||||
<div>
|
||||
<Text fw={700} fz="lg">
|
||||
{licenseType(license.licenseType)}
|
||||
</Text>
|
||||
<Text c="dimmed" size="sm">
|
||||
{t('app.authority')}
|
||||
</Text>
|
||||
</div>
|
||||
</Group>
|
||||
{license.status !== 'EXPIRED' && daysLeft > 0 && (
|
||||
<Text c="dimmed" size="sm" ta="right">
|
||||
{t('licenses.detail.daysLeft', { count: daysLeft })}
|
||||
</Text>
|
||||
)}
|
||||
</Group>
|
||||
|
||||
<Divider my="lg" />
|
||||
|
||||
<SimpleGrid cols={{ base: 1, sm: 2 }} spacing="md">
|
||||
<Field label={t('licenses.detail.holder')} value={license.holderName} />
|
||||
<Field label={t('licenses.detail.subject')} value={license.subjectName} />
|
||||
<Field
|
||||
label={t('licenses.detail.issuedOn')}
|
||||
value={formatDate(license.issuedAt)}
|
||||
/>
|
||||
<Field
|
||||
label={t('licenses.detail.expiresOn')}
|
||||
value={formatDate(license.expiresAt)}
|
||||
/>
|
||||
</SimpleGrid>
|
||||
</Card>
|
||||
|
||||
<Paper p="md" shadow="sm" radius="md" withBorder>
|
||||
<Group>
|
||||
<Button
|
||||
leftSection={<IconDownload size={18} />}
|
||||
onClick={() => notify.info(t('common.download'))}
|
||||
>
|
||||
{t('licenses.detail.download')}
|
||||
</Button>
|
||||
<Button
|
||||
variant="light"
|
||||
leftSection={<IconRefresh size={18} />}
|
||||
onClick={() => navigate(`/apply?type=${license.licenseType}&request=RENEWAL`)}
|
||||
>
|
||||
{t('licenses.detail.renew')}
|
||||
</Button>
|
||||
</Group>
|
||||
</Paper>
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
|
||||
function Field({ label, value }: { label: string; value: string }) {
|
||||
return (
|
||||
<div>
|
||||
<Text size="xs" c="dimmed" tt="uppercase" fw={600}>
|
||||
{label}
|
||||
</Text>
|
||||
<Text size="sm" mt={2}>
|
||||
{value}
|
||||
</Text>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
import { Paper, Stack } from '@mantine/core';
|
||||
import { IconCertificate } from '@tabler/icons-react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { PageHeader } from '../../../components/PageHeader';
|
||||
import { EmptyState } from '../../../components/EmptyState';
|
||||
import { LicensesTable } from '../components/LicensesTable';
|
||||
import { useLicenses } from '../hooks/useLicenses';
|
||||
|
||||
export function MyLicensesPage() {
|
||||
const { t } = useTranslation();
|
||||
const navigate = useNavigate();
|
||||
const { licenses } = useLicenses();
|
||||
|
||||
return (
|
||||
<Stack gap="lg">
|
||||
<PageHeader title={t('licenses.title')} subtitle={t('licenses.subtitle')} />
|
||||
|
||||
{licenses.length ? (
|
||||
<Paper p="md" shadow="sm" radius="md" withBorder>
|
||||
<LicensesTable licenses={licenses} />
|
||||
</Paper>
|
||||
) : (
|
||||
<EmptyState
|
||||
icon={IconCertificate}
|
||||
title={t('licenses.empty')}
|
||||
action={{
|
||||
label: t('dashboard.newApplication'),
|
||||
onClick: () => navigate('/apply'),
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
39
apps/portal/src/app/features/licenses/pages/ServicesPage.tsx
Normal file
39
apps/portal/src/app/features/licenses/pages/ServicesPage.tsx
Normal file
@@ -0,0 +1,39 @@
|
||||
import { SimpleGrid, Stack, Title } from '@mantine/core';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { PageHeader } from '../../../components/PageHeader';
|
||||
import { ServiceCard } from '../components/ServiceCard';
|
||||
import {
|
||||
LICENSE_CATEGORY,
|
||||
type ServiceCategory,
|
||||
} from '../constants';
|
||||
import type { LicenseType } from '../types/license.types';
|
||||
|
||||
const CATEGORY_ORDER: ServiceCategory[] = ['seafarer', 'vessel', 'facility'];
|
||||
|
||||
export function ServicesPage() {
|
||||
const { t } = useTranslation();
|
||||
|
||||
const grouped = CATEGORY_ORDER.map((category) => ({
|
||||
category,
|
||||
types: (Object.keys(LICENSE_CATEGORY) as LicenseType[]).filter(
|
||||
(type) => LICENSE_CATEGORY[type] === category,
|
||||
),
|
||||
})).filter((g) => g.types.length > 0);
|
||||
|
||||
return (
|
||||
<Stack gap="xl">
|
||||
<PageHeader title={t('services.title')} subtitle={t('services.subtitle')} />
|
||||
|
||||
{grouped.map(({ category, types }) => (
|
||||
<Stack key={category} gap="sm">
|
||||
<Title order={4}>{t(`services.categoryLabel.${category}`)}</Title>
|
||||
<SimpleGrid cols={{ base: 1, sm: 2, lg: 3 }} spacing="md">
|
||||
{types.map((type) => (
|
||||
<ServiceCard key={type} type={type} />
|
||||
))}
|
||||
</SimpleGrid>
|
||||
</Stack>
|
||||
))}
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
172
apps/portal/src/app/features/licenses/store/licenses.slice.ts
Normal file
172
apps/portal/src/app/features/licenses/store/licenses.slice.ts
Normal file
@@ -0,0 +1,172 @@
|
||||
import { createSlice, nanoid, type PayloadAction } from '@reduxjs/toolkit';
|
||||
import { REQUIRED_DOCUMENTS } from '../constants';
|
||||
import type {
|
||||
Application,
|
||||
License,
|
||||
NewApplicationInput,
|
||||
} from '../types/license.types';
|
||||
|
||||
interface LicensesState {
|
||||
applications: Application[];
|
||||
licenses: License[];
|
||||
}
|
||||
|
||||
const daysFromNow = (days: number): string => {
|
||||
const d = new Date();
|
||||
d.setDate(d.getDate() + days);
|
||||
return d.toISOString();
|
||||
};
|
||||
|
||||
const seedApplications: Application[] = [
|
||||
{
|
||||
id: 'app-1001',
|
||||
referenceNo: 'EMA-2026-001042',
|
||||
requestType: 'NEW',
|
||||
licenseType: 'SEAFARER_COC',
|
||||
applicantName: 'Dawit Bekele',
|
||||
subjectName: 'Dawit Bekele',
|
||||
status: 'UNDER_REVIEW',
|
||||
submittedAt: daysFromNow(-9),
|
||||
updatedAt: daysFromNow(-2),
|
||||
documents: [
|
||||
{ name: 'Passport copy', status: 'VERIFIED' },
|
||||
{ name: 'Training certificate', status: 'VERIFIED' },
|
||||
{ name: 'Medical fitness certificate', status: 'UPLOADED' },
|
||||
{ name: 'Sea service record', status: 'UPLOADED' },
|
||||
],
|
||||
history: [
|
||||
{ status: 'SUBMITTED', date: daysFromNow(-9), note: 'Application received by EMA.' },
|
||||
{ status: 'UNDER_REVIEW', date: daysFromNow(-2), note: 'Assigned to licensing officer for review.' },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'app-1002',
|
||||
referenceNo: 'EMA-2026-000987',
|
||||
requestType: 'NEW',
|
||||
licenseType: 'VESSEL_REGISTRATION',
|
||||
applicantName: 'Blue Nile Shipping PLC',
|
||||
subjectName: 'MV Abay Star',
|
||||
status: 'INFO_REQUESTED',
|
||||
submittedAt: daysFromNow(-15),
|
||||
updatedAt: daysFromNow(-4),
|
||||
notes: 'Tonnage measurement document is illegible — please re-upload a clear scan.',
|
||||
documents: [
|
||||
{ name: 'Bill of sale', status: 'VERIFIED' },
|
||||
{ name: 'Builder certificate', status: 'VERIFIED' },
|
||||
{ name: 'Tonnage measurement', status: 'REJECTED' },
|
||||
{ name: 'Insurance certificate', status: 'UPLOADED' },
|
||||
],
|
||||
history: [
|
||||
{ status: 'SUBMITTED', date: daysFromNow(-15), note: 'Application received by EMA.' },
|
||||
{ status: 'UNDER_REVIEW', date: daysFromNow(-10), note: 'Document verification in progress.' },
|
||||
{ status: 'INFO_REQUESTED', date: daysFromNow(-4), note: 'Additional information requested from applicant.' },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'app-1003',
|
||||
referenceNo: 'EMA-2026-000654',
|
||||
requestType: 'RENEWAL',
|
||||
licenseType: 'SHIP_RADIO',
|
||||
applicantName: 'Blue Nile Shipping PLC',
|
||||
subjectName: 'MV Abay Star',
|
||||
status: 'ISSUED',
|
||||
submittedAt: daysFromNow(-40),
|
||||
updatedAt: daysFromNow(-25),
|
||||
relatedLicenseNo: 'EMA-SR-2021-0231',
|
||||
documents: [
|
||||
{ name: 'Vessel registration', status: 'VERIFIED' },
|
||||
{ name: 'Equipment specification', status: 'VERIFIED' },
|
||||
{ name: 'Operator certificate', status: 'VERIFIED' },
|
||||
],
|
||||
history: [
|
||||
{ status: 'SUBMITTED', date: daysFromNow(-40), note: 'Renewal application received.' },
|
||||
{ status: 'UNDER_REVIEW', date: daysFromNow(-35), note: 'Under review.' },
|
||||
{ status: 'PAYMENT_PENDING', date: daysFromNow(-30), note: 'Service fee invoice issued.' },
|
||||
{ status: 'APPROVED', date: daysFromNow(-27), note: 'Application approved.' },
|
||||
{ status: 'ISSUED', date: daysFromNow(-25), note: 'Licence issued and available for download.' },
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
const seedLicenses: License[] = [
|
||||
{
|
||||
id: 'lic-1',
|
||||
licenseNo: 'EMA-SR-2021-0231',
|
||||
licenseType: 'SHIP_RADIO',
|
||||
holderName: 'Blue Nile Shipping PLC',
|
||||
subjectName: 'MV Abay Star',
|
||||
status: 'ACTIVE',
|
||||
issuedAt: daysFromNow(-25),
|
||||
expiresAt: daysFromNow(340),
|
||||
},
|
||||
{
|
||||
id: 'lic-2',
|
||||
licenseNo: 'EMA-CC-2019-0098',
|
||||
licenseType: 'SEAFARER_COC',
|
||||
holderName: 'Dawit Bekele',
|
||||
subjectName: 'Dawit Bekele',
|
||||
status: 'EXPIRING_SOON',
|
||||
issuedAt: daysFromNow(-1800),
|
||||
expiresAt: daysFromNow(48),
|
||||
},
|
||||
{
|
||||
id: 'lic-3',
|
||||
licenseNo: 'EMA-VR-2018-0455',
|
||||
licenseType: 'VESSEL_REGISTRATION',
|
||||
holderName: 'Blue Nile Shipping PLC',
|
||||
subjectName: 'MV Tana',
|
||||
status: 'EXPIRED',
|
||||
issuedAt: daysFromNow(-2600),
|
||||
expiresAt: daysFromNow(-60),
|
||||
},
|
||||
];
|
||||
|
||||
const initialState: LicensesState = {
|
||||
applications: seedApplications,
|
||||
licenses: seedLicenses,
|
||||
};
|
||||
|
||||
const year = new Date().getFullYear();
|
||||
|
||||
const licensesSlice = createSlice({
|
||||
name: 'licenses',
|
||||
initialState,
|
||||
reducers: {
|
||||
submitApplication: {
|
||||
reducer(state, action: PayloadAction<Application>) {
|
||||
state.applications.unshift(action.payload);
|
||||
},
|
||||
prepare(input: NewApplicationInput) {
|
||||
const now = new Date().toISOString();
|
||||
const seq = Math.floor(100000 + Math.random() * 899999);
|
||||
const referenceNo = `EMA-${year}-${seq}`;
|
||||
const documents = (input.documents.length
|
||||
? input.documents
|
||||
: REQUIRED_DOCUMENTS[input.licenseType]
|
||||
).map((name) => ({ name, status: 'UPLOADED' as const }));
|
||||
|
||||
const application: Application = {
|
||||
id: `app-${nanoid(8)}`,
|
||||
referenceNo,
|
||||
requestType: input.requestType,
|
||||
licenseType: input.licenseType,
|
||||
applicantName: input.applicantName,
|
||||
subjectName: input.subjectName,
|
||||
relatedLicenseNo: input.relatedLicenseNo,
|
||||
notes: input.notes,
|
||||
status: 'SUBMITTED',
|
||||
submittedAt: now,
|
||||
updatedAt: now,
|
||||
documents,
|
||||
history: [
|
||||
{ status: 'SUBMITTED', date: now, note: 'Application received by EMA.' },
|
||||
],
|
||||
};
|
||||
return { payload: application };
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
export const { submitApplication } = licensesSlice.actions;
|
||||
export const licensesReducer = licensesSlice.reducer;
|
||||
82
apps/portal/src/app/features/licenses/types/license.types.ts
Normal file
82
apps/portal/src/app/features/licenses/types/license.types.ts
Normal file
@@ -0,0 +1,82 @@
|
||||
// Domain model for the Ethiopian Maritime Authority (EMA) licensing portal.
|
||||
|
||||
/** Categories of licence / certificate the Authority issues. */
|
||||
export type LicenseType =
|
||||
| 'SEAFARER_COC' // Certificate of Competency
|
||||
| 'SEAFARER_COP' // Certificate of Proficiency
|
||||
| 'SEAMAN_BOOK' // Seafarer's Identity & Record Book
|
||||
| 'VESSEL_REGISTRATION' // Vessel / Ship Registration Certificate
|
||||
| 'SHIP_RADIO' // Ship Station Radio Licence
|
||||
| 'TONNAGE_CERTIFICATE' // International Tonnage Certificate
|
||||
| 'SAFETY_MANAGEMENT' // Safety Management Certificate
|
||||
| 'PORT_FACILITY' // Port Facility Operation Licence
|
||||
| 'BOAT_OPERATOR'; // Inland Boat Operator Licence
|
||||
|
||||
/** What the applicant is asking the Authority to do. */
|
||||
export type RequestType = 'NEW' | 'RENEWAL' | 'AMENDMENT' | 'COMPLIANCE';
|
||||
|
||||
/** Lifecycle of a submitted application. */
|
||||
export type ApplicationStatus =
|
||||
| 'DRAFT'
|
||||
| 'SUBMITTED'
|
||||
| 'UNDER_REVIEW'
|
||||
| 'INFO_REQUESTED'
|
||||
| 'PAYMENT_PENDING'
|
||||
| 'APPROVED'
|
||||
| 'REJECTED'
|
||||
| 'ISSUED';
|
||||
|
||||
/** Health of an already-issued licence. */
|
||||
export type LicenseStatus = 'ACTIVE' | 'EXPIRING_SOON' | 'EXPIRED' | 'SUSPENDED';
|
||||
|
||||
export type DocumentStatus = 'PENDING' | 'UPLOADED' | 'VERIFIED' | 'REJECTED';
|
||||
|
||||
export interface ApplicationDocument {
|
||||
name: string;
|
||||
status: DocumentStatus;
|
||||
}
|
||||
|
||||
export interface TimelineEvent {
|
||||
status: ApplicationStatus;
|
||||
date: string; // ISO date
|
||||
note: string;
|
||||
}
|
||||
|
||||
export interface Application {
|
||||
id: string;
|
||||
referenceNo: string;
|
||||
requestType: RequestType;
|
||||
licenseType: LicenseType;
|
||||
applicantName: string;
|
||||
/** Vessel name or seafarer subject, depending on licence type. */
|
||||
subjectName: string;
|
||||
status: ApplicationStatus;
|
||||
submittedAt: string; // ISO date
|
||||
updatedAt: string; // ISO date
|
||||
/** Set once an amendment / compliance request targets an existing licence. */
|
||||
relatedLicenseNo?: string;
|
||||
notes?: string;
|
||||
documents: ApplicationDocument[];
|
||||
history: TimelineEvent[];
|
||||
}
|
||||
|
||||
export interface License {
|
||||
id: string;
|
||||
licenseNo: string;
|
||||
licenseType: LicenseType;
|
||||
holderName: string;
|
||||
subjectName: string;
|
||||
status: LicenseStatus;
|
||||
issuedAt: string; // ISO date
|
||||
expiresAt: string; // ISO date
|
||||
}
|
||||
|
||||
export interface NewApplicationInput {
|
||||
requestType: RequestType;
|
||||
licenseType: LicenseType;
|
||||
applicantName: string;
|
||||
subjectName: string;
|
||||
relatedLicenseNo?: string;
|
||||
notes?: string;
|
||||
documents: string[];
|
||||
}
|
||||
109
apps/portal/src/app/features/profile/pages/ProfilePage.tsx
Normal file
109
apps/portal/src/app/features/profile/pages/ProfilePage.tsx
Normal file
@@ -0,0 +1,109 @@
|
||||
import {
|
||||
Avatar,
|
||||
Button,
|
||||
Group,
|
||||
Paper,
|
||||
Select,
|
||||
SimpleGrid,
|
||||
Stack,
|
||||
TextInput,
|
||||
Title,
|
||||
} from '@mantine/core';
|
||||
import { IconDeviceFloppy } from '@tabler/icons-react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { notify } from '@ema-platform/ui';
|
||||
import { PageHeader } from '../../../components/PageHeader';
|
||||
import { SUPPORTED_LANGUAGES, type AppLanguage } from '../../../i18n/config';
|
||||
|
||||
export function ProfilePage() {
|
||||
const { t, i18n } = useTranslation();
|
||||
|
||||
const handleSave = () => notify.success(t('common.saved'));
|
||||
|
||||
return (
|
||||
<Stack gap="lg" maw={820}>
|
||||
<PageHeader title={t('profile.title')} subtitle={t('profile.subtitle')} />
|
||||
|
||||
<Paper p="lg" shadow="sm" radius="md" withBorder>
|
||||
<Group mb="lg">
|
||||
<Avatar color="emaPrimary" radius="xl" size={64}>
|
||||
BN
|
||||
</Avatar>
|
||||
<div>
|
||||
<Title order={4}>Blue Nile Shipping PLC</Title>
|
||||
<TextInput
|
||||
variant="unstyled"
|
||||
defaultValue="muluhabit@gmail.com"
|
||||
readOnly
|
||||
size="sm"
|
||||
/>
|
||||
</div>
|
||||
</Group>
|
||||
|
||||
<Stack gap="lg">
|
||||
<div>
|
||||
<Title order={5} mb="sm">
|
||||
{t('profile.personal')}
|
||||
</Title>
|
||||
<SimpleGrid cols={{ base: 1, sm: 2 }} spacing="md">
|
||||
<TextInput
|
||||
label={t('profile.fields.fullName')}
|
||||
defaultValue="Dawit Bekele"
|
||||
/>
|
||||
<TextInput
|
||||
label={t('profile.fields.organization')}
|
||||
defaultValue="Blue Nile Shipping PLC"
|
||||
/>
|
||||
</SimpleGrid>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Title order={5} mb="sm">
|
||||
{t('profile.contact')}
|
||||
</Title>
|
||||
<SimpleGrid cols={{ base: 1, sm: 2 }} spacing="md">
|
||||
<TextInput
|
||||
label={t('profile.fields.email')}
|
||||
defaultValue="muluhabit@gmail.com"
|
||||
/>
|
||||
<TextInput
|
||||
label={t('profile.fields.phone')}
|
||||
defaultValue="+251 91 234 5678"
|
||||
/>
|
||||
<TextInput
|
||||
label={t('profile.fields.address')}
|
||||
defaultValue="Addis Ababa, Ethiopia"
|
||||
/>
|
||||
</SimpleGrid>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Title order={5} mb="sm">
|
||||
{t('profile.preferences')}
|
||||
</Title>
|
||||
<Select
|
||||
maw={320}
|
||||
label={t('profile.fields.language')}
|
||||
data={SUPPORTED_LANGUAGES.map((lng) => ({
|
||||
value: lng,
|
||||
label: t(`language.${lng}`),
|
||||
}))}
|
||||
value={i18n.language}
|
||||
onChange={(v) => v && i18n.changeLanguage(v as AppLanguage)}
|
||||
allowDeselect={false}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<Group>
|
||||
<Button
|
||||
leftSection={<IconDeviceFloppy size={18} />}
|
||||
onClick={handleSave}
|
||||
>
|
||||
{t('common.save')}
|
||||
</Button>
|
||||
</Group>
|
||||
</Stack>
|
||||
</Paper>
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
77
apps/portal/src/app/features/support/pages/SupportPage.tsx
Normal file
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 '../../../components/PageHeader';
|
||||
|
||||
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
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;
|
||||
346
apps/portal/src/app/i18n/locales/am.ts
Normal file
346
apps/portal/src/app/i18n/locales/am.ts
Normal file
@@ -0,0 +1,346 @@
|
||||
// Amharic (አማርኛ) translations for the EMA portal.
|
||||
// Mirrors the key structure of en.ts. Maritime/government terminology follows
|
||||
// common usage of the Ethiopian Maritime Authority.
|
||||
|
||||
import type { Translations } from './en';
|
||||
|
||||
export const am: Translations = {
|
||||
app: {
|
||||
name: 'ኢባባ ፖርታል',
|
||||
authority: 'የኢትዮጵያ ባሕር ጉዳዮች ባለሥልጣን',
|
||||
tagline: 'የባሕር ፍቃድና የምስክር ወረቀት አገልግሎቶች',
|
||||
},
|
||||
|
||||
language: {
|
||||
label: 'ቋንቋ',
|
||||
en: 'English',
|
||||
am: 'አማርኛ',
|
||||
},
|
||||
|
||||
nav: {
|
||||
dashboard: 'ዳሽቦርድ',
|
||||
services: 'አገልግሎቶች',
|
||||
apply: 'አዲስ ማመልከቻ',
|
||||
applications: 'ማመልከቻዎቼ',
|
||||
licenses: 'ፍቃዶቼ',
|
||||
profile: 'መገለጫ',
|
||||
support: 'እገዛና ድጋፍ',
|
||||
},
|
||||
|
||||
common: {
|
||||
back: 'ተመለስ',
|
||||
continue: 'ቀጥል',
|
||||
submit: 'አስገባ',
|
||||
cancel: 'ሰርዝ',
|
||||
save: 'ለውጦችን አስቀምጥ',
|
||||
saved: 'ለውጦች ተቀምጠዋል',
|
||||
viewAll: 'ሁሉንም ይመልከቱ',
|
||||
viewDetails: 'ዝርዝር ይመልከቱ',
|
||||
search: 'ፈልግ',
|
||||
download: 'አውርድ',
|
||||
print: 'አትም',
|
||||
renew: 'አድስ',
|
||||
apply: 'አመልክት',
|
||||
applyNow: 'አሁን አመልክት',
|
||||
filter: 'አጣራ',
|
||||
all: 'ሁሉም',
|
||||
status: 'ሁኔታ',
|
||||
date: 'ቀን',
|
||||
actions: 'ድርጊቶች',
|
||||
loading: 'በመጫን ላይ…',
|
||||
none: 'ምንም የለም',
|
||||
optional: 'አማራጭ',
|
||||
required: 'ግዴታ',
|
||||
of: 'ከ',
|
||||
backToList: 'ወደ ዝርዝሩ ተመለስ',
|
||||
notFound: 'አልተገኘም',
|
||||
learnMore: 'ተጨማሪ ይወቁ',
|
||||
toggleTheme: 'ብርሃን / ጨለማ ገጽታ ቀይር',
|
||||
welcome: 'እንኳን ደህና መጡ',
|
||||
},
|
||||
|
||||
auth: {
|
||||
login: 'ግባ',
|
||||
logout: 'ውጣ',
|
||||
signup: 'ይመዝገቡ',
|
||||
account: 'መለያ',
|
||||
guest: 'እንግዳ ተጠቃሚ',
|
||||
},
|
||||
|
||||
dashboard: {
|
||||
title: 'ዳሽቦርዴ',
|
||||
subtitle: 'የፍቃዶችዎ፣ የማመልከቻዎችዎና የተገዢነት ሁኔታ አጠቃላይ እይታ።',
|
||||
newApplication: 'አዲስ ማመልከቻ',
|
||||
recentApplications: 'የቅርብ ጊዜ ማመልከቻዎች',
|
||||
applicationsByStatus: 'ማመልከቻዎች በሁኔታ',
|
||||
quickActions: 'ፈጣን ድርጊቶች',
|
||||
stats: {
|
||||
activeLicenses: 'ንቁ ፍቃዶች',
|
||||
pendingApplications: 'በመጠባበቅ ላይ ያሉ ማመልከቻዎች',
|
||||
expiringSoon: 'በቅርቡ የሚያበቁ',
|
||||
actionRequired: 'እርምጃ የሚፈልጉ',
|
||||
},
|
||||
quick: {
|
||||
apply: 'ለፍቃድ ያመልክቱ',
|
||||
applyDesc: 'አዲስ ማመልከቻ ይጀምሩ',
|
||||
renew: 'ፍቃድ ያድሱ',
|
||||
renewDesc: 'የምስክር ወረቀቶችዎን የጸኑ ያድርጉ',
|
||||
track: 'ማመልከቻ ይከታተሉ',
|
||||
trackDesc: 'ጥያቄዎ የት እንዳለ ይመልከቱ',
|
||||
},
|
||||
hero: {
|
||||
greeting: 'እንኳን ደህና መጡ',
|
||||
subtitle:
|
||||
'ለባሕር ፍቃዶች ያመልክቱ፣ ማመልከቻዎችዎን ይከታተሉ እና የምስክር ወረቀቶችዎን ያስተዳድሩ — ሁሉም በአንድ ቦታ።',
|
||||
browse: 'አገልግሎቶችን ይመልከቱ',
|
||||
},
|
||||
charts: {
|
||||
trend: 'ማመልከቻዎች በጊዜ ሂደት',
|
||||
trendPeriod: 'ያለፉት 6 ወራት',
|
||||
distribution: 'የሁኔታ ስርጭት',
|
||||
noData: 'እስካሁን የሚታይ መረጃ የለም',
|
||||
total: 'ጠቅላላ',
|
||||
},
|
||||
},
|
||||
|
||||
services: {
|
||||
title: 'የባሕር አገልግሎቶች',
|
||||
subtitle:
|
||||
'በየኢትዮጵያ ባሕር ጉዳዮች ባለሥልጣን የሚሰጡ ፍቃዶችንና የምስክር ወረቀቶችን ይመልከቱና ማመልከቻ ይጀምሩ።',
|
||||
seafarer: 'የመርከበኛ ምስክርነት',
|
||||
vessel: 'የመርከብ አገልግሎቶች',
|
||||
facility: 'ወደብና ተቋም',
|
||||
requiredDocuments: 'የሚያስፈልጉ ሰነዶች',
|
||||
processingTime: 'መደበኛ የማስኬጃ ጊዜ',
|
||||
validity: 'የጸናበት ጊዜ',
|
||||
apply: 'አመልክት',
|
||||
processingDays: '{{min}}–{{max}} የሥራ ቀናት',
|
||||
validityYears_one: '{{count}} ዓመት',
|
||||
validityYears_other: '{{count}} ዓመታት',
|
||||
categoryLabel: {
|
||||
seafarer: 'የመርከበኛ ምስክርነት',
|
||||
vessel: 'የመርከብ አገልግሎቶች',
|
||||
facility: 'ወደብና ተቋም',
|
||||
},
|
||||
},
|
||||
|
||||
apply: {
|
||||
title: 'ለፍቃድ ማመልከት',
|
||||
subtitle:
|
||||
'ማመልከቻዎን ለየኢትዮጵያ ባሕር ጉዳዮች ባለሥልጣን ለማስገባት ከታች ያሉትን ደረጃዎች ይሙሉ።',
|
||||
steps: {
|
||||
license: 'ፍቃድ',
|
||||
licenseDesc: 'ዓይነትና ጥያቄ',
|
||||
details: 'ዝርዝሮች',
|
||||
detailsDesc: 'የአመልካች መረጃ',
|
||||
documents: 'ሰነዶች',
|
||||
documentsDesc: 'የሚያስፈልጉ ማስገቢያዎች',
|
||||
review: 'ግምገማ',
|
||||
},
|
||||
fields: {
|
||||
requestType: 'የጥያቄ ዓይነት',
|
||||
licenseType: 'የፍቃድ ዓይነት',
|
||||
licenseTypePlaceholder: 'የሚፈልጉትን ፍቃድ ይምረጡ',
|
||||
applicantName: 'የአመልካች / ድርጅት ስም',
|
||||
applicantPlaceholder: 'ለምሳሌ ብሉ ናይል ሺፒንግ ኃ.የተ.የግ.ማ.',
|
||||
notes: 'ተጨማሪ ማስታወሻ',
|
||||
notesPlaceholder: 'ገምጋሚው መኮንን ሊያውቀው የሚገባ ማንኛውም ነገር',
|
||||
relatedLicense: 'ነባር የፍቃድ ቁጥር',
|
||||
},
|
||||
docs: {
|
||||
hint: 'እያንዳንዱን ሰነድ ካያያዙ በኋላ ምልክት ያድርጉ። ከማስገባትዎ በፊት ሁሉም የሚያስፈልጉ ሰነዶች መቅረብ አለባቸው።',
|
||||
attached: 'የተያያዙ ሰነዶች',
|
||||
attachedCount: '{{count}} ከ{{total}} የሚያስፈልጉ ሰነዶች',
|
||||
},
|
||||
review: {
|
||||
title: 'ማመልከቻዎን ይገምግሙ',
|
||||
hint: 'ከታች ያሉትን ዝርዝሮች ያረጋግጡና ለኢባባ ያስገቡ።',
|
||||
missingDocs:
|
||||
'አንዳንድ የሚያስፈልጉ ሰነዶች አሁንም ይጎድላሉ። አሁንም ማስገባት ይችላሉ፣ ነገር ግን ግምገማው ሊዘገይ ይችላል።',
|
||||
},
|
||||
submitted: 'ማመልከቻዎ ለኢባባ ቀርቧል።',
|
||||
submitAction: 'ማመልከቻ አስገባ',
|
||||
},
|
||||
|
||||
applications: {
|
||||
title: 'ማመልከቻዎቼ',
|
||||
subtitle: 'ለኢባባ ያስገቡትን እያንዳንዱን ጥያቄ ሁኔታ ይከታተሉ።',
|
||||
empty: 'እስካሁን ምንም ማመልከቻ የለም። ለፍቃድ በማመልከት ይጀምሩ።',
|
||||
searchPlaceholder: 'በማመሳከሪያ፣ በርዕሰ ጉዳይ ወይም በአመልካች ይፈልጉ',
|
||||
columns: {
|
||||
reference: 'የማመሳከሪያ ቁ.',
|
||||
licenseType: 'የፍቃድ ዓይነት',
|
||||
request: 'ጥያቄ',
|
||||
subject: 'ርዕሰ ጉዳይ',
|
||||
submitted: 'የቀረበበት',
|
||||
status: 'ሁኔታ',
|
||||
},
|
||||
},
|
||||
|
||||
applicationDetail: {
|
||||
title: 'ማመልከቻ',
|
||||
reference: 'የማመሳከሪያ ቁጥር',
|
||||
overview: 'አጠቃላይ እይታ',
|
||||
progress: 'ሂደት',
|
||||
documents: 'ሰነዶች',
|
||||
history: 'ታሪክ',
|
||||
applicant: 'አመልካች',
|
||||
subject: 'ርዕሰ ጉዳይ',
|
||||
requestType: 'የጥያቄ ዓይነት',
|
||||
licenseType: 'የፍቃድ ዓይነት',
|
||||
submittedOn: 'የቀረበበት ቀን',
|
||||
lastUpdated: 'መጨረሻ የተሻሻለበት',
|
||||
officerNote: 'ከኢባባ የተሰጠ ማስታወሻ',
|
||||
relatedLicense: 'ተዛማጅ ፍቃድ',
|
||||
actionRequired: 'እርምጃ ያስፈልጋል',
|
||||
actionRequiredDesc:
|
||||
'ማመልከቻዎ ከመቀጠሉ በፊት ኢባባ ተጨማሪ መረጃ ጠይቋል።',
|
||||
respond: 'ለጥያቄው ምላሽ ይስጡ',
|
||||
},
|
||||
|
||||
licenses: {
|
||||
title: 'ፍቃዶቼ',
|
||||
subtitle: 'በኢባባ ለእርስዎ የተሰጡ ሁሉም የምስክር ወረቀቶችና ፍቃዶች።',
|
||||
empty: 'እስካሁን የተሰጠዎት ፍቃድ የለም።',
|
||||
columns: {
|
||||
licenseNo: 'የፍቃድ ቁ.',
|
||||
type: 'ዓይነት',
|
||||
subject: 'ርዕሰ ጉዳይ',
|
||||
issued: 'የተሰጠበት',
|
||||
expires: 'የሚያበቃበት',
|
||||
status: 'ሁኔታ',
|
||||
},
|
||||
detail: {
|
||||
holder: 'የፍቃድ ባለቤት',
|
||||
subject: 'ርዕሰ ጉዳይ',
|
||||
issuedOn: 'የተሰጠበት ቀን',
|
||||
expiresOn: 'የሚያበቃበት ቀን',
|
||||
daysLeft: '{{count}} ቀናት ይቀራሉ',
|
||||
expired: 'ይህ ፍቃድ ጊዜው አልፎበታል።',
|
||||
expiringSoon: 'ይህ ፍቃድ በቅርቡ ያበቃል። ተገዢ ለመሆን ያድሱ።',
|
||||
download: 'የምስክር ወረቀት አውርድ',
|
||||
renew: 'ፍቃድ አድስ',
|
||||
},
|
||||
},
|
||||
|
||||
profile: {
|
||||
title: 'መገለጫዬ',
|
||||
subtitle: 'የመለያ ዝርዝሮችዎንና ምርጫዎችዎን ያስተዳድሩ።',
|
||||
personal: 'የግል መረጃ',
|
||||
contact: 'መገናኛ',
|
||||
preferences: 'ምርጫዎች',
|
||||
fields: {
|
||||
fullName: 'ሙሉ ስም',
|
||||
organization: 'ድርጅት',
|
||||
email: 'የኢሜይል አድራሻ',
|
||||
phone: 'ስልክ ቁጥር',
|
||||
address: 'አድራሻ',
|
||||
language: 'የሚመረጥ ቋንቋ',
|
||||
},
|
||||
},
|
||||
|
||||
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: 'በማመልከቻው ላይ "እርምጃ ያስፈልጋል" የሚል ማስታወሻ ያያሉ። ግምገማውን ለመቀጠል የተጠየቀውን ሰነድ ወይም ዝርዝር ያቅርቡ።',
|
||||
},
|
||||
},
|
||||
|
||||
// ---- Domain enums ----
|
||||
licenseType: {
|
||||
SEAFARER_COC: 'የብቃት ምስክር ወረቀት (CoC)',
|
||||
SEAFARER_COP: 'የክህሎት ምስክር ወረቀት (CoP)',
|
||||
SEAMAN_BOOK: 'የመርከበኛ መታወቂያና መዝገብ ደብተር',
|
||||
VESSEL_REGISTRATION: 'የመርከብ ምዝገባ ምስክር ወረቀት',
|
||||
SHIP_RADIO: 'የመርከብ ሬዲዮ ጣቢያ ፍቃድ',
|
||||
TONNAGE_CERTIFICATE: 'ዓለም አቀፍ የመርከብ ጭነት መለኪያ ምስክር ወረቀት',
|
||||
SAFETY_MANAGEMENT: 'የደህንነት አስተዳደር ምስክር ወረቀት',
|
||||
PORT_FACILITY: 'የወደብ ተቋም ማንቀሳቀሻ ፍቃድ',
|
||||
BOAT_OPERATOR: 'የውስጥ ውሃ ጀልባ አንቀሳቃሽ ፍቃድ',
|
||||
},
|
||||
|
||||
subjectLabel: {
|
||||
SEAFARER_COC: 'የመርከበኛ ሙሉ ስም',
|
||||
SEAFARER_COP: 'የመርከበኛ ሙሉ ስም',
|
||||
SEAMAN_BOOK: 'የመርከበኛ ሙሉ ስም',
|
||||
VESSEL_REGISTRATION: 'የመርከብ ስም',
|
||||
SHIP_RADIO: 'የመርከብ ስም',
|
||||
TONNAGE_CERTIFICATE: 'የመርከብ ስም',
|
||||
SAFETY_MANAGEMENT: 'የመርከብ ስም',
|
||||
PORT_FACILITY: 'የተቋም ስም',
|
||||
BOAT_OPERATOR: 'የአንቀሳቃሽ ሙሉ ስም',
|
||||
},
|
||||
|
||||
requestType: {
|
||||
NEW: 'አዲስ ማመልከቻ',
|
||||
RENEWAL: 'እድሳት',
|
||||
AMENDMENT: 'ማሻሻያ',
|
||||
COMPLIANCE: 'የተገዢነት ማቅረቢያ',
|
||||
},
|
||||
|
||||
applicationStatus: {
|
||||
DRAFT: 'ረቂቅ',
|
||||
SUBMITTED: 'ቀርቧል',
|
||||
UNDER_REVIEW: 'በግምገማ ላይ',
|
||||
INFO_REQUESTED: 'መረጃ ተጠይቋል',
|
||||
PAYMENT_PENDING: 'ክፍያ በመጠባበቅ ላይ',
|
||||
APPROVED: 'ጸድቋል',
|
||||
REJECTED: 'ተቀባይነት አላገኘም',
|
||||
ISSUED: 'ተሰጥቷል',
|
||||
},
|
||||
|
||||
licenseStatus: {
|
||||
ACTIVE: 'ንቁ',
|
||||
EXPIRING_SOON: 'በቅርቡ የሚያበቃ',
|
||||
EXPIRED: 'ጊዜው ያለፈበት',
|
||||
SUSPENDED: 'ታግዷል',
|
||||
},
|
||||
|
||||
documentStatus: {
|
||||
PENDING: 'በመጠባበቅ ላይ',
|
||||
UPLOADED: 'ተጭኗል',
|
||||
VERIFIED: 'ተረጋግጧል',
|
||||
REJECTED: 'ተቀባይነት አላገኘም',
|
||||
},
|
||||
|
||||
documents: {
|
||||
'Passport copy': 'የፓስፖርት ቅጂ',
|
||||
'Passport photo': 'የፓስፖርት ፎቶ',
|
||||
'Training certificate': 'የሥልጠና ምስክር ወረቀት',
|
||||
'Course completion certificate': 'የኮርስ ማጠናቀቂያ ምስክር ወረቀት',
|
||||
'Medical fitness certificate': 'የጤና ብቁነት ምስክር ወረቀት',
|
||||
'Sea service record': 'የባሕር አገልግሎት መዝገብ',
|
||||
'Police clearance': 'የፖሊስ ማረጋገጫ',
|
||||
'ID copy': 'የመታወቂያ ቅጂ',
|
||||
'Bill of sale': 'የሽያጭ ሰነድ',
|
||||
'Builder certificate': 'የገንቢ ምስክር ወረቀት',
|
||||
'Tonnage measurement': 'የጭነት መለኪያ',
|
||||
'Insurance certificate': 'የመድን ምስክር ወረቀት',
|
||||
'Vessel registration': 'የመርከብ ምዝገባ',
|
||||
'Equipment specification': 'የመሣሪያ ዝርዝር',
|
||||
'Operator certificate': 'የአንቀሳቃሽ ምስክር ወረቀት',
|
||||
'Survey report': 'የዳሰሳ ሪፖርት',
|
||||
'General arrangement plan': 'አጠቃላይ የአደረጃጀት ዕቅድ',
|
||||
'Safety management manual': 'የደህንነት አስተዳደር መመሪያ',
|
||||
'Audit report': 'የኦዲት ሪፖርት',
|
||||
'Business licence': 'የንግድ ፍቃድ',
|
||||
'Facility security assessment': 'የተቋም ደህንነት ግምገማ',
|
||||
'Site plan': 'የቦታ ዕቅድ',
|
||||
},
|
||||
};
|
||||
347
apps/portal/src/app/i18n/locales/en.ts
Normal file
347
apps/portal/src/app/i18n/locales/en.ts
Normal file
@@ -0,0 +1,347 @@
|
||||
// English translations for the EMA portal (user side).
|
||||
// Enum keys (licenseType.*, applicationStatus.*, etc.) match the union values
|
||||
// in features/licenses/types so components can resolve labels with
|
||||
// t(`licenseType.${value}`).
|
||||
|
||||
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',
|
||||
services: 'Services',
|
||||
apply: 'New Application',
|
||||
applications: 'My Applications',
|
||||
licenses: 'My Licences',
|
||||
profile: 'Profile',
|
||||
support: 'Help & Support',
|
||||
},
|
||||
|
||||
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',
|
||||
renew: 'Renew',
|
||||
apply: 'Apply',
|
||||
applyNow: 'Apply now',
|
||||
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 back',
|
||||
},
|
||||
|
||||
auth: {
|
||||
login: 'Log in',
|
||||
logout: 'Log out',
|
||||
signup: 'Sign up',
|
||||
account: 'Account',
|
||||
guest: 'Guest user',
|
||||
},
|
||||
|
||||
dashboard: {
|
||||
title: 'My Dashboard',
|
||||
subtitle: 'Overview of your licences, applications and compliance status.',
|
||||
newApplication: 'New Application',
|
||||
recentApplications: 'Recent Applications',
|
||||
applicationsByStatus: 'Applications by status',
|
||||
quickActions: 'Quick actions',
|
||||
stats: {
|
||||
activeLicenses: 'Active Licences',
|
||||
pendingApplications: 'Pending Applications',
|
||||
expiringSoon: 'Expiring Soon',
|
||||
actionRequired: 'Action Required',
|
||||
},
|
||||
quick: {
|
||||
apply: 'Apply for a licence',
|
||||
applyDesc: 'Start a new application',
|
||||
renew: 'Renew a licence',
|
||||
renewDesc: 'Keep your certificates valid',
|
||||
track: 'Track an application',
|
||||
trackDesc: 'See where your request stands',
|
||||
},
|
||||
hero: {
|
||||
greeting: 'Welcome back',
|
||||
subtitle:
|
||||
'Apply for maritime licences, track your applications and manage your certificates — all in one place.',
|
||||
browse: 'Browse services',
|
||||
},
|
||||
charts: {
|
||||
trend: 'Applications over time',
|
||||
trendPeriod: 'Last 6 months',
|
||||
distribution: 'Status distribution',
|
||||
noData: 'No data to display yet',
|
||||
total: 'Total',
|
||||
},
|
||||
},
|
||||
|
||||
services: {
|
||||
title: 'Maritime Services',
|
||||
subtitle:
|
||||
'Browse the licences and certificates issued by the Ethiopian Maritime Authority and start an application.',
|
||||
seafarer: 'Seafarer certification',
|
||||
vessel: 'Vessel & ship services',
|
||||
facility: 'Port & facility',
|
||||
requiredDocuments: 'Required documents',
|
||||
processingTime: 'Typical processing time',
|
||||
validity: 'Validity',
|
||||
apply: 'Apply',
|
||||
processingDays: '{{min}}–{{max}} working days',
|
||||
validityYears_one: '{{count}} year',
|
||||
validityYears_other: '{{count}} years',
|
||||
categoryLabel: {
|
||||
seafarer: 'Seafarer certification',
|
||||
vessel: 'Vessel & ship services',
|
||||
facility: 'Port & facility',
|
||||
},
|
||||
},
|
||||
|
||||
apply: {
|
||||
title: 'Apply for a Licence',
|
||||
subtitle:
|
||||
'Complete the steps below to submit your application to the Ethiopian Maritime Authority.',
|
||||
steps: {
|
||||
license: 'Licence',
|
||||
licenseDesc: 'Type & request',
|
||||
details: 'Details',
|
||||
detailsDesc: 'Applicant info',
|
||||
documents: 'Documents',
|
||||
documentsDesc: 'Required uploads',
|
||||
review: 'Review',
|
||||
},
|
||||
fields: {
|
||||
requestType: 'Request type',
|
||||
licenseType: 'Licence type',
|
||||
licenseTypePlaceholder: 'Select the licence you need',
|
||||
applicantName: 'Applicant / Company name',
|
||||
applicantPlaceholder: 'e.g. Blue Nile Shipping PLC',
|
||||
notes: 'Additional notes',
|
||||
notesPlaceholder: 'Anything the reviewing officer should know',
|
||||
relatedLicense: 'Existing licence number',
|
||||
},
|
||||
docs: {
|
||||
hint: 'Tick each document once you have attached it. All required documents must be provided before submitting.',
|
||||
attached: 'Documents attached',
|
||||
attachedCount: '{{count}} of {{total}} required documents',
|
||||
},
|
||||
review: {
|
||||
title: 'Review your application',
|
||||
hint: 'Confirm the details below, then submit to EMA.',
|
||||
missingDocs:
|
||||
'Some required documents are still missing. You can still submit, but review may be delayed.',
|
||||
},
|
||||
submitted: 'Your application has been submitted to EMA.',
|
||||
submitAction: 'Submit application',
|
||||
},
|
||||
|
||||
applications: {
|
||||
title: 'My Applications',
|
||||
subtitle: 'Track the status of every request you have submitted to EMA.',
|
||||
empty: 'No applications yet. Start by applying for a licence.',
|
||||
searchPlaceholder: 'Search by reference, subject or applicant',
|
||||
columns: {
|
||||
reference: 'Reference No.',
|
||||
licenseType: 'Licence Type',
|
||||
request: 'Request',
|
||||
subject: 'Subject',
|
||||
submitted: 'Submitted',
|
||||
status: 'Status',
|
||||
},
|
||||
},
|
||||
|
||||
applicationDetail: {
|
||||
title: 'Application',
|
||||
reference: 'Reference number',
|
||||
overview: 'Overview',
|
||||
progress: 'Progress',
|
||||
documents: 'Documents',
|
||||
history: 'History',
|
||||
applicant: 'Applicant',
|
||||
subject: 'Subject',
|
||||
requestType: 'Request type',
|
||||
licenseType: 'Licence type',
|
||||
submittedOn: 'Submitted on',
|
||||
lastUpdated: 'Last updated',
|
||||
officerNote: 'Note from EMA',
|
||||
relatedLicense: 'Related licence',
|
||||
actionRequired: 'Action required',
|
||||
actionRequiredDesc:
|
||||
'EMA has requested more information before your application can proceed.',
|
||||
respond: 'Respond to request',
|
||||
},
|
||||
|
||||
licenses: {
|
||||
title: 'My Licences',
|
||||
subtitle: 'All certificates and licences issued to you by EMA.',
|
||||
empty: 'You have no issued licences yet.',
|
||||
columns: {
|
||||
licenseNo: 'Licence No.',
|
||||
type: 'Type',
|
||||
subject: 'Subject',
|
||||
issued: 'Issued',
|
||||
expires: 'Expires',
|
||||
status: 'Status',
|
||||
},
|
||||
detail: {
|
||||
holder: 'Licence holder',
|
||||
subject: 'Subject',
|
||||
issuedOn: 'Issued on',
|
||||
expiresOn: 'Expires on',
|
||||
daysLeft: '{{count}} days remaining',
|
||||
expired: 'This licence has expired.',
|
||||
expiringSoon: 'This licence is expiring soon. Renew to stay compliant.',
|
||||
download: 'Download certificate',
|
||||
renew: 'Renew licence',
|
||||
},
|
||||
},
|
||||
|
||||
profile: {
|
||||
title: 'My Profile',
|
||||
subtitle: 'Manage your account details and preferences.',
|
||||
personal: 'Personal information',
|
||||
contact: 'Contact',
|
||||
preferences: 'Preferences',
|
||||
fields: {
|
||||
fullName: 'Full name',
|
||||
organization: 'Organization',
|
||||
email: 'Email address',
|
||||
phone: 'Phone number',
|
||||
address: 'Address',
|
||||
language: 'Preferred language',
|
||||
},
|
||||
},
|
||||
|
||||
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.',
|
||||
},
|
||||
},
|
||||
|
||||
// ---- Domain enums (keys match the type unions) ----
|
||||
licenseType: {
|
||||
SEAFARER_COC: 'Certificate of Competency (CoC)',
|
||||
SEAFARER_COP: 'Certificate of Proficiency (CoP)',
|
||||
SEAMAN_BOOK: "Seafarer's Identity & Record Book",
|
||||
VESSEL_REGISTRATION: 'Vessel Registration Certificate',
|
||||
SHIP_RADIO: 'Ship Station Radio Licence',
|
||||
TONNAGE_CERTIFICATE: 'International Tonnage Certificate',
|
||||
SAFETY_MANAGEMENT: 'Safety Management Certificate',
|
||||
PORT_FACILITY: 'Port Facility Operation Licence',
|
||||
BOAT_OPERATOR: 'Inland Boat Operator Licence',
|
||||
},
|
||||
|
||||
subjectLabel: {
|
||||
SEAFARER_COC: 'Seafarer full name',
|
||||
SEAFARER_COP: 'Seafarer full name',
|
||||
SEAMAN_BOOK: 'Seafarer full name',
|
||||
VESSEL_REGISTRATION: 'Vessel name',
|
||||
SHIP_RADIO: 'Vessel name',
|
||||
TONNAGE_CERTIFICATE: 'Vessel name',
|
||||
SAFETY_MANAGEMENT: 'Vessel name',
|
||||
PORT_FACILITY: 'Facility name',
|
||||
BOAT_OPERATOR: 'Operator full name',
|
||||
},
|
||||
|
||||
requestType: {
|
||||
NEW: 'New Application',
|
||||
RENEWAL: 'Renewal',
|
||||
AMENDMENT: 'Amendment',
|
||||
COMPLIANCE: 'Compliance Submission',
|
||||
},
|
||||
|
||||
applicationStatus: {
|
||||
DRAFT: 'Draft',
|
||||
SUBMITTED: 'Submitted',
|
||||
UNDER_REVIEW: 'Under Review',
|
||||
INFO_REQUESTED: 'Information Requested',
|
||||
PAYMENT_PENDING: 'Payment Pending',
|
||||
APPROVED: 'Approved',
|
||||
REJECTED: 'Rejected',
|
||||
ISSUED: 'Issued',
|
||||
},
|
||||
|
||||
licenseStatus: {
|
||||
ACTIVE: 'Active',
|
||||
EXPIRING_SOON: 'Expiring Soon',
|
||||
EXPIRED: 'Expired',
|
||||
SUSPENDED: 'Suspended',
|
||||
},
|
||||
|
||||
documentStatus: {
|
||||
PENDING: 'Pending',
|
||||
UPLOADED: 'Uploaded',
|
||||
VERIFIED: 'Verified',
|
||||
REJECTED: 'Rejected',
|
||||
},
|
||||
|
||||
documents: {
|
||||
'Passport copy': 'Passport copy',
|
||||
'Passport photo': 'Passport photo',
|
||||
'Training certificate': 'Training certificate',
|
||||
'Course completion certificate': 'Course completion certificate',
|
||||
'Medical fitness certificate': 'Medical fitness certificate',
|
||||
'Sea service record': 'Sea service record',
|
||||
'Police clearance': 'Police clearance',
|
||||
'ID copy': 'ID copy',
|
||||
'Bill of sale': 'Bill of sale',
|
||||
'Builder certificate': 'Builder certificate',
|
||||
'Tonnage measurement': 'Tonnage measurement',
|
||||
'Insurance certificate': 'Insurance certificate',
|
||||
'Vessel registration': 'Vessel registration',
|
||||
'Equipment specification': 'Equipment specification',
|
||||
'Operator certificate': 'Operator certificate',
|
||||
'Survey report': 'Survey report',
|
||||
'General arrangement plan': 'General arrangement plan',
|
||||
'Safety management manual': 'Safety management manual',
|
||||
'Audit report': 'Audit report',
|
||||
'Business licence': 'Business licence',
|
||||
'Facility security assessment': 'Facility security assessment',
|
||||
'Site plan': 'Site plan',
|
||||
},
|
||||
};
|
||||
|
||||
export type Translations = typeof en;
|
||||
@@ -1,36 +1,194 @@
|
||||
import { AppShell, Group, Text, Button } from '@mantine/core';
|
||||
import { Outlet, useNavigate } from 'react-router-dom';
|
||||
import {
|
||||
AppShell,
|
||||
Burger,
|
||||
Group,
|
||||
NavLink,
|
||||
ScrollArea,
|
||||
Stack,
|
||||
Text,
|
||||
ThemeIcon,
|
||||
Menu,
|
||||
Avatar,
|
||||
UnstyledButton,
|
||||
rem,
|
||||
} from '@mantine/core';
|
||||
import { useDisclosure } from '@mantine/hooks';
|
||||
import {
|
||||
IconLayoutDashboard,
|
||||
IconCategory,
|
||||
IconFilePlus,
|
||||
IconFileDescription,
|
||||
IconCertificate,
|
||||
IconUser,
|
||||
IconHelpCircle,
|
||||
IconLogin,
|
||||
IconLogout,
|
||||
IconAnchor,
|
||||
IconChevronRight,
|
||||
} from '@tabler/icons-react';
|
||||
import type { Icon } from '@tabler/icons-react';
|
||||
import { Outlet, useNavigate, useLocation } from 'react-router-dom';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { LanguageSwitcher } from '../components/LanguageSwitcher';
|
||||
import { ColorSchemeToggle } from '../components/ColorSchemeToggle';
|
||||
|
||||
interface NavItem {
|
||||
to: string;
|
||||
labelKey: string;
|
||||
icon: Icon;
|
||||
}
|
||||
|
||||
const NAV_ITEMS: NavItem[] = [
|
||||
{ to: '/dashboard', labelKey: 'nav.dashboard', icon: IconLayoutDashboard },
|
||||
{ to: '/services', labelKey: 'nav.services', icon: IconCategory },
|
||||
{ to: '/apply', labelKey: 'nav.apply', icon: IconFilePlus },
|
||||
{ to: '/applications', labelKey: 'nav.applications', icon: IconFileDescription },
|
||||
{ to: '/licenses', labelKey: 'nav.licenses', icon: IconCertificate },
|
||||
{ to: '/profile', labelKey: 'nav.profile', icon: IconUser },
|
||||
{ to: '/support', labelKey: 'nav.support', icon: IconHelpCircle },
|
||||
];
|
||||
|
||||
const TOKEN_KEY = 'ema-portal-auth-token';
|
||||
|
||||
export function PortalLayout() {
|
||||
const { t } = useTranslation();
|
||||
const navigate = useNavigate();
|
||||
const isLoggedIn = !!localStorage.getItem('ema-portal-auth-token');
|
||||
const location = useLocation();
|
||||
const [opened, { toggle, close }] = useDisclosure();
|
||||
|
||||
const isLoggedIn = !!localStorage.getItem(TOKEN_KEY);
|
||||
|
||||
const go = (to: string) => {
|
||||
navigate(to);
|
||||
close();
|
||||
};
|
||||
|
||||
const logout = () => {
|
||||
localStorage.removeItem(TOKEN_KEY);
|
||||
navigate('/login');
|
||||
};
|
||||
|
||||
return (
|
||||
<AppShell header={{ height: 56 }} padding="md">
|
||||
<AppShell
|
||||
header={{ height: 60 }}
|
||||
navbar={{
|
||||
width: 260,
|
||||
breakpoint: 'sm',
|
||||
collapsed: { mobile: !opened },
|
||||
}}
|
||||
padding="md"
|
||||
>
|
||||
<AppShell.Header>
|
||||
<Group h="100%" px="md" justify="space-between">
|
||||
<Text fw={700}>EMA Portal</Text>
|
||||
{isLoggedIn ? (
|
||||
<Button
|
||||
variant="subtle"
|
||||
size="sm"
|
||||
onClick={() => {
|
||||
localStorage.removeItem('ema-portal-auth-token');
|
||||
navigate('/login');
|
||||
}}
|
||||
>
|
||||
Logout
|
||||
</Button>
|
||||
) : (
|
||||
<Button variant="subtle" size="sm" onClick={() => navigate('/login')}>
|
||||
Login
|
||||
</Button>
|
||||
)}
|
||||
<Group h="100%" px="md" justify="space-between" wrap="nowrap">
|
||||
<Group gap="sm" wrap="nowrap">
|
||||
<Burger opened={opened} onClick={toggle} hiddenFrom="sm" size="sm" />
|
||||
<UnstyledButton onClick={() => go('/dashboard')}>
|
||||
<Group gap="xs" wrap="nowrap">
|
||||
<ThemeIcon
|
||||
size={36}
|
||||
radius="md"
|
||||
variant="gradient"
|
||||
gradient={{ from: 'emaPrimary.7', to: 'emaPrimary.5', deg: 135 }}
|
||||
>
|
||||
<IconAnchor size={22} />
|
||||
</ThemeIcon>
|
||||
<div>
|
||||
<Text fw={700} lh={1.1}>
|
||||
{t('app.name')}
|
||||
</Text>
|
||||
<Text size="xs" c="dimmed" lh={1.1} visibleFrom="xs">
|
||||
{t('app.authority')}
|
||||
</Text>
|
||||
</div>
|
||||
</Group>
|
||||
</UnstyledButton>
|
||||
</Group>
|
||||
|
||||
<Group gap={4} wrap="nowrap">
|
||||
<ColorSchemeToggle />
|
||||
<LanguageSwitcher />
|
||||
<UserMenu isLoggedIn={isLoggedIn} onLogin={() => navigate('/login')} onLogout={logout} />
|
||||
</Group>
|
||||
</Group>
|
||||
</AppShell.Header>
|
||||
|
||||
<AppShell.Navbar p="sm">
|
||||
<AppShell.Section grow component={ScrollArea}>
|
||||
<Stack gap={2}>
|
||||
{NAV_ITEMS.map((item) => {
|
||||
const ActiveIcon = item.icon;
|
||||
const active =
|
||||
location.pathname === item.to ||
|
||||
location.pathname.startsWith(`${item.to}/`);
|
||||
return (
|
||||
<NavLink
|
||||
key={item.to}
|
||||
active={active}
|
||||
label={t(item.labelKey)}
|
||||
leftSection={<ActiveIcon size={20} stroke={1.6} />}
|
||||
onClick={() => go(item.to)}
|
||||
variant="light"
|
||||
styles={{ root: { borderRadius: rem(8) } }}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</Stack>
|
||||
</AppShell.Section>
|
||||
|
||||
<AppShell.Section>
|
||||
<Text size="xs" c="dimmed" ta="center" py="xs">
|
||||
{t('app.authority')}
|
||||
</Text>
|
||||
</AppShell.Section>
|
||||
</AppShell.Navbar>
|
||||
|
||||
<AppShell.Main>
|
||||
<Outlet />
|
||||
<div key={location.pathname} className="ema-page-enter">
|
||||
<Outlet />
|
||||
</div>
|
||||
</AppShell.Main>
|
||||
</AppShell>
|
||||
);
|
||||
}
|
||||
|
||||
function UserMenu({
|
||||
isLoggedIn,
|
||||
onLogin,
|
||||
onLogout,
|
||||
}: {
|
||||
isLoggedIn: boolean;
|
||||
onLogin: () => void;
|
||||
onLogout: () => void;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
|
||||
return (
|
||||
<Menu shadow="md" width={200} position="bottom-end" withinPortal>
|
||||
<Menu.Target>
|
||||
<UnstyledButton>
|
||||
<Group gap="xs" wrap="nowrap">
|
||||
<Avatar color="emaPrimary" radius="xl" size={34}>
|
||||
{isLoggedIn ? 'U' : <IconUser size={18} />}
|
||||
</Avatar>
|
||||
<IconChevronRight size={14} style={{ opacity: 0.5 }} />
|
||||
</Group>
|
||||
</UnstyledButton>
|
||||
</Menu.Target>
|
||||
<Menu.Dropdown>
|
||||
<Menu.Label>{isLoggedIn ? t('auth.account') : t('auth.guest')}</Menu.Label>
|
||||
{isLoggedIn ? (
|
||||
<Menu.Item
|
||||
leftSection={<IconLogout size={16} />}
|
||||
onClick={onLogout}
|
||||
>
|
||||
{t('auth.logout')}
|
||||
</Menu.Item>
|
||||
) : (
|
||||
<Menu.Item leftSection={<IconLogin size={16} />} onClick={onLogin}>
|
||||
{t('auth.login')}
|
||||
</Menu.Item>
|
||||
)}
|
||||
</Menu.Dropdown>
|
||||
</Menu>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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,56 +1,75 @@
|
||||
import { createBrowserRouter, RouterProvider, 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';
|
||||
|
||||
// Auth (standalone pages, no portal chrome)
|
||||
import { LoginPage } from './features/auth/pages/LoginPage';
|
||||
import { SignupPage } from './features/auth/pages/SignupPage';
|
||||
import { SetPasswordPage } from './features/auth/pages/SetPasswordPage';
|
||||
|
||||
// Portal feature pages
|
||||
import { DashboardPage } from './features/dashboard/pages/DashboardPage';
|
||||
import { ServicesPage } from './features/licenses/pages/ServicesPage';
|
||||
import { ApplyPage } from './features/licenses/pages/ApplyPage';
|
||||
import { ApplicationsListPage } from './features/licenses/pages/ApplicationsListPage';
|
||||
import { ApplicationDetailPage } from './features/licenses/pages/ApplicationDetailPage';
|
||||
import { MyLicensesPage } from './features/licenses/pages/MyLicensesPage';
|
||||
import { LicenseDetailPage } from './features/licenses/pages/LicenseDetailPage';
|
||||
import { ProfilePage } from './features/profile/pages/ProfilePage';
|
||||
import { SupportPage } from './features/support/pages/SupportPage';
|
||||
|
||||
import { UserManagementPage, UserManagementLayout, Login} from "@tria-plc/iamui-common";
|
||||
// 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 getToken(): string | null {
|
||||
return localStorage.getItem('ema-portal-auth-token');
|
||||
function IsolatedIam({ children }: { children: ReactNode }) {
|
||||
return <IamProviders>{children}</IamProviders>;
|
||||
}
|
||||
|
||||
function ProtectedRoute({ children }: { children: ReactNode }) {
|
||||
if (!getToken()) return <Navigate to="/login" replace />;
|
||||
return <>{children}</>;
|
||||
}
|
||||
|
||||
const router = createBrowserRouter([
|
||||
{
|
||||
element: <UserManagementLayout />,
|
||||
children: [
|
||||
{path: '/user', element: <UserManagementPage />}
|
||||
]
|
||||
},
|
||||
export const router = createBrowserRouter([
|
||||
// Standalone auth pages
|
||||
{ path: '/login', element: <LoginPage /> },
|
||||
{ path: '/signup', element: <SignupPage /> },
|
||||
{ path: '/set-password', element: <SetPasswordPage /> },
|
||||
|
||||
// Portal (open access for this UI template).
|
||||
// Wrapped in the portal's own i18n instance so it is isolated from the
|
||||
// global i18next singleton that `@tria-plc/iamui-common` initializes.
|
||||
{
|
||||
element: <PortalLayout />,
|
||||
element: (
|
||||
<I18nextProvider i18n={i18n}>
|
||||
<PortalLayout />
|
||||
</I18nextProvider>
|
||||
),
|
||||
children: [
|
||||
{ path: '/', element: <Navigate to="/dashboard" replace /> },
|
||||
{
|
||||
path: '/dashboard',
|
||||
element: (
|
||||
<ProtectedRoute>
|
||||
<DashboardPage />
|
||||
</ProtectedRoute>
|
||||
),
|
||||
},
|
||||
{ path: '/dashboard', element: <DashboardPage /> },
|
||||
{ path: '/services', element: <ServicesPage /> },
|
||||
{ path: '/apply', element: <ApplyPage /> },
|
||||
{ path: '/applications', element: <ApplicationsListPage /> },
|
||||
{ path: '/applications/:id', element: <ApplicationDetailPage /> },
|
||||
{ path: '/licenses', element: <MyLicensesPage /> },
|
||||
{ path: '/licenses/:id', element: <LicenseDetailPage /> },
|
||||
{ path: '/profile', element: <ProfilePage /> },
|
||||
{ path: '/support', element: <SupportPage /> },
|
||||
],
|
||||
},
|
||||
|
||||
// IAM admin user management (isolated providers)
|
||||
{
|
||||
element: (
|
||||
<IsolatedIam>
|
||||
<UserManagementLayout />
|
||||
</IsolatedIam>
|
||||
),
|
||||
children: [{ path: '/users', element: <UserManagementPage /> }],
|
||||
},
|
||||
|
||||
{ path: '*', element: <Navigate to="/" replace /> },
|
||||
]);
|
||||
|
||||
export function AppRouter() {
|
||||
return (
|
||||
<Routes>
|
||||
<Route path='/login' element={<Login/>}/>
|
||||
<Route element={<UserManagementLayout />}>
|
||||
<Route path="/users" element={<UserManagementPage />} />
|
||||
</Route>
|
||||
</Routes>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -3,12 +3,14 @@ import { baseApi } from '@ema-platform/api';
|
||||
import { authReducer } from '../features/auth/store/auth.slice';
|
||||
import { signupReducer } from '../features/auth/store/signup.slice';
|
||||
import { setPasswordReducer } from '../features/auth/store/set-password.slice';
|
||||
import { licensesReducer } from '../features/licenses/store/licenses.slice';
|
||||
|
||||
export const store = configureStore({
|
||||
reducer: {
|
||||
auth: authReducer,
|
||||
signup: signupReducer,
|
||||
setPassword: setPasswordReducer,
|
||||
licenses: licensesReducer,
|
||||
[baseApi.reducerPath]: baseApi.reducer,
|
||||
},
|
||||
middleware: (getDefaultMiddleware) =>
|
||||
|
||||
60
apps/portal/src/app/theme/portal.css
Normal file
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
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,9 +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 '@tria-plc/iamui-common/styles.css';
|
||||
import './app/theme/portal.css';
|
||||
|
||||
import { configureIam } from "@tria-plc/iamui-common";
|
||||
import './app/i18n/config';
|
||||
import { App } from './app/app';
|
||||
|
||||
const root = document.getElementById('root');
|
||||
|
||||
Reference in New Issue
Block a user