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 charset="UTF-8" />
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
<title>EMA Portal</title>
|
<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>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<div id="root"></div>
|
<div id="root"></div>
|
||||||
|
|||||||
@@ -1,17 +1,15 @@
|
|||||||
// src/main.tsx or App.tsx
|
import { RouterProvider } from 'react-router-dom';
|
||||||
import { AppProviders, configureIam } from "@tria-plc/iamui-common";
|
import { configureIam } from '@tria-plc/iamui-common';
|
||||||
import "@tria-plc/iamui-common/styles.css";
|
import { AppProviders } from './providers/AppProviders';
|
||||||
// import { AppProviders } from './providers/AppProviders';
|
import { router } from './router';
|
||||||
import { AppRouter } from './router';
|
|
||||||
configureIam({ apiUrl: 'http://localhost:3001/api'});
|
// IAM module configuration (used by the isolated /users admin route).
|
||||||
// import './styles.css';
|
configureIam({ apiUrl: 'http://localhost:3001/api' });
|
||||||
|
|
||||||
export function App() {
|
export function App() {
|
||||||
return (
|
return (
|
||||||
// 2. Wrap with Providers so hooks like useAuthUser() work
|
|
||||||
<AppProviders>
|
<AppProviders>
|
||||||
<AppRouter />
|
<RouterProvider router={router} />
|
||||||
</AppProviders>
|
</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() {
|
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 (
|
return (
|
||||||
<Stack gap="lg">
|
<Stack gap="lg">
|
||||||
<Title order={2}>My Dashboard</Title>
|
<DashboardHero name={holderName} />
|
||||||
<Paper p="md" shadow="sm" radius="md" withBorder>
|
|
||||||
<Text c="dimmed">
|
<SimpleGrid cols={{ base: 1, sm: 2, lg: 4 }} spacing="md">
|
||||||
Welcome to the EMA Portal. Your content will appear here.
|
<StatCard
|
||||||
</Text>
|
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>
|
</Paper>
|
||||||
</Stack>
|
</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 {
|
||||||
import { Outlet, useNavigate } from 'react-router-dom';
|
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() {
|
export function PortalLayout() {
|
||||||
|
const { t } = useTranslation();
|
||||||
const navigate = useNavigate();
|
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 (
|
return (
|
||||||
<AppShell header={{ height: 56 }} padding="md">
|
<AppShell
|
||||||
|
header={{ height: 60 }}
|
||||||
|
navbar={{
|
||||||
|
width: 260,
|
||||||
|
breakpoint: 'sm',
|
||||||
|
collapsed: { mobile: !opened },
|
||||||
|
}}
|
||||||
|
padding="md"
|
||||||
|
>
|
||||||
<AppShell.Header>
|
<AppShell.Header>
|
||||||
<Group h="100%" px="md" justify="space-between">
|
<Group h="100%" px="md" justify="space-between" wrap="nowrap">
|
||||||
<Text fw={700}>EMA Portal</Text>
|
<Group gap="sm" wrap="nowrap">
|
||||||
{isLoggedIn ? (
|
<Burger opened={opened} onClick={toggle} hiddenFrom="sm" size="sm" />
|
||||||
<Button
|
<UnstyledButton onClick={() => go('/dashboard')}>
|
||||||
variant="subtle"
|
<Group gap="xs" wrap="nowrap">
|
||||||
size="sm"
|
<ThemeIcon
|
||||||
onClick={() => {
|
size={36}
|
||||||
localStorage.removeItem('ema-portal-auth-token');
|
radius="md"
|
||||||
navigate('/login');
|
variant="gradient"
|
||||||
}}
|
gradient={{ from: 'emaPrimary.7', to: 'emaPrimary.5', deg: 135 }}
|
||||||
>
|
>
|
||||||
Logout
|
<IconAnchor size={22} />
|
||||||
</Button>
|
</ThemeIcon>
|
||||||
) : (
|
<div>
|
||||||
<Button variant="subtle" size="sm" onClick={() => navigate('/login')}>
|
<Text fw={700} lh={1.1}>
|
||||||
Login
|
{t('app.name')}
|
||||||
</Button>
|
</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>
|
</Group>
|
||||||
</AppShell.Header>
|
</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>
|
<AppShell.Main>
|
||||||
<Outlet />
|
<div key={location.pathname} className="ema-page-enter">
|
||||||
|
<Outlet />
|
||||||
|
</div>
|
||||||
</AppShell.Main>
|
</AppShell.Main>
|
||||||
</AppShell>
|
</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 { MantineProvider } from '@mantine/core';
|
||||||
import { Notifications } from '@mantine/notifications';
|
import { Notifications } from '@mantine/notifications';
|
||||||
import { emaTheme } from '@ema-platform/shared';
|
|
||||||
import type { ReactNode } from 'react';
|
import type { ReactNode } from 'react';
|
||||||
|
import { portalTheme } from '../theme/portalTheme';
|
||||||
|
|
||||||
export function MantineThemeProvider({ children }: { children: ReactNode }) {
|
export function MantineThemeProvider({ children }: { children: ReactNode }) {
|
||||||
return (
|
return (
|
||||||
<MantineProvider theme={emaTheme}>
|
<MantineProvider theme={portalTheme} defaultColorScheme="light">
|
||||||
<Notifications position="top-right" />
|
<Notifications position="top-right" />
|
||||||
{children}
|
{children}
|
||||||
</MantineProvider>
|
</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 type { ReactNode } from 'react';
|
||||||
|
import { I18nextProvider } from 'react-i18next';
|
||||||
|
import { i18n } from './i18n/config';
|
||||||
import { PortalLayout } from './layouts/PortalLayout';
|
import { PortalLayout } from './layouts/PortalLayout';
|
||||||
|
|
||||||
|
// Auth (standalone pages, no portal chrome)
|
||||||
import { LoginPage } from './features/auth/pages/LoginPage';
|
import { LoginPage } from './features/auth/pages/LoginPage';
|
||||||
import { SignupPage } from './features/auth/pages/SignupPage';
|
import { SignupPage } from './features/auth/pages/SignupPage';
|
||||||
import { SetPasswordPage } from './features/auth/pages/SetPasswordPage';
|
import { SetPasswordPage } from './features/auth/pages/SetPasswordPage';
|
||||||
|
|
||||||
|
// Portal feature pages
|
||||||
import { DashboardPage } from './features/dashboard/pages/DashboardPage';
|
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 {
|
function IsolatedIam({ children }: { children: ReactNode }) {
|
||||||
return localStorage.getItem('ema-portal-auth-token');
|
return <IamProviders>{children}</IamProviders>;
|
||||||
}
|
}
|
||||||
|
|
||||||
function ProtectedRoute({ children }: { children: ReactNode }) {
|
export const router = createBrowserRouter([
|
||||||
if (!getToken()) return <Navigate to="/login" replace />;
|
// Standalone auth pages
|
||||||
return <>{children}</>;
|
|
||||||
}
|
|
||||||
|
|
||||||
const router = createBrowserRouter([
|
|
||||||
{
|
|
||||||
element: <UserManagementLayout />,
|
|
||||||
children: [
|
|
||||||
{path: '/user', element: <UserManagementPage />}
|
|
||||||
]
|
|
||||||
},
|
|
||||||
{ path: '/login', element: <LoginPage /> },
|
{ path: '/login', element: <LoginPage /> },
|
||||||
{ path: '/signup', element: <SignupPage /> },
|
{ path: '/signup', element: <SignupPage /> },
|
||||||
{ path: '/set-password', element: <SetPasswordPage /> },
|
{ 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: [
|
children: [
|
||||||
{ path: '/', element: <Navigate to="/dashboard" replace /> },
|
{ path: '/', element: <Navigate to="/dashboard" replace /> },
|
||||||
{
|
{ path: '/dashboard', element: <DashboardPage /> },
|
||||||
path: '/dashboard',
|
{ path: '/services', element: <ServicesPage /> },
|
||||||
element: (
|
{ path: '/apply', element: <ApplyPage /> },
|
||||||
<ProtectedRoute>
|
{ path: '/applications', element: <ApplicationsListPage /> },
|
||||||
<DashboardPage />
|
{ path: '/applications/:id', element: <ApplicationDetailPage /> },
|
||||||
</ProtectedRoute>
|
{ 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 /> },
|
{ 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 { authReducer } from '../features/auth/store/auth.slice';
|
||||||
import { signupReducer } from '../features/auth/store/signup.slice';
|
import { signupReducer } from '../features/auth/store/signup.slice';
|
||||||
import { setPasswordReducer } from '../features/auth/store/set-password.slice';
|
import { setPasswordReducer } from '../features/auth/store/set-password.slice';
|
||||||
|
import { licensesReducer } from '../features/licenses/store/licenses.slice';
|
||||||
|
|
||||||
export const store = configureStore({
|
export const store = configureStore({
|
||||||
reducer: {
|
reducer: {
|
||||||
auth: authReducer,
|
auth: authReducer,
|
||||||
signup: signupReducer,
|
signup: signupReducer,
|
||||||
setPassword: setPasswordReducer,
|
setPassword: setPasswordReducer,
|
||||||
|
licenses: licensesReducer,
|
||||||
[baseApi.reducerPath]: baseApi.reducer,
|
[baseApi.reducerPath]: baseApi.reducer,
|
||||||
},
|
},
|
||||||
middleware: (getDefaultMiddleware) =>
|
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 { createRoot } from 'react-dom/client';
|
||||||
import '@mantine/core/styles.css';
|
import '@mantine/core/styles.css';
|
||||||
import '@mantine/notifications/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';
|
import { App } from './app/app';
|
||||||
|
|
||||||
const root = document.getElementById('root');
|
const root = document.getElementById('root');
|
||||||
|
|||||||
Reference in New Issue
Block a user