diff --git a/apps/portal/index.html b/apps/portal/index.html index 8ab26144e..e464611aa 100644 --- a/apps/portal/index.html +++ b/apps/portal/index.html @@ -4,6 +4,13 @@ EMA Portal +
diff --git a/apps/portal/src/app/app.tsx b/apps/portal/src/app/app.tsx index 8a0b8df61..6b88b3ca3 100644 --- a/apps/portal/src/app/app.tsx +++ b/apps/portal/src/app/app.tsx @@ -1,17 +1,15 @@ -// src/main.tsx or App.tsx -import { AppProviders, configureIam } from "@tria-plc/iamui-common"; -import "@tria-plc/iamui-common/styles.css"; -// import { AppProviders } from './providers/AppProviders'; -import { AppRouter } from './router'; -configureIam({ apiUrl: 'http://localhost:3001/api'}); -// import './styles.css'; +import { RouterProvider } from 'react-router-dom'; +import { configureIam } from '@tria-plc/iamui-common'; +import { AppProviders } from './providers/AppProviders'; +import { router } from './router'; + +// IAM module configuration (used by the isolated /users admin route). +configureIam({ apiUrl: 'http://localhost:3001/api' }); export function App() { return ( - // 2. Wrap with Providers so hooks like useAuthUser() work - + ); } - diff --git a/apps/portal/src/app/components/ColorSchemeToggle.tsx b/apps/portal/src/app/components/ColorSchemeToggle.tsx new file mode 100644 index 000000000..3f57d6041 --- /dev/null +++ b/apps/portal/src/app/components/ColorSchemeToggle.tsx @@ -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 ( + setColorScheme(isDark ? 'light' : 'dark')} + > + {isDark ? : } + + ); +} diff --git a/apps/portal/src/app/components/EmptyState.tsx b/apps/portal/src/app/components/EmptyState.tsx new file mode 100644 index 000000000..63e56236e --- /dev/null +++ b/apps/portal/src/app/components/EmptyState.tsx @@ -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 ( + + + + + {title} + + {description && ( + + {description} + + )} + {action && ( + + )} + + + ); +} + +/** 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 ( + + + {/* document */} + + + + + + + + + {/* waves */} + + + + ); +} diff --git a/apps/portal/src/app/components/LanguageSwitcher.tsx b/apps/portal/src/app/components/LanguageSwitcher.tsx new file mode 100644 index 000000000..3ea0d1f5c --- /dev/null +++ b/apps/portal/src/app/components/LanguageSwitcher.tsx @@ -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 ( + + + {variant === 'icon' ? ( + + + + ) : ( + + )} + + + {t('language.label')} + {SUPPORTED_LANGUAGES.map((lng) => ( + change(lng)} + rightSection={ + current === lng ? : undefined + } + > + {t(`language.${lng}`)} + + ))} + + + ); +} diff --git a/apps/portal/src/app/components/PageHeader.tsx b/apps/portal/src/app/components/PageHeader.tsx new file mode 100644 index 000000000..0e3d9d6de --- /dev/null +++ b/apps/portal/src/app/components/PageHeader.tsx @@ -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 ( + + + {title} + {subtitle && ( + + {subtitle} + + )} + + {action} + + ); +} diff --git a/apps/portal/src/app/features/dashboard/components/ApplicationsTrend.tsx b/apps/portal/src/app/features/dashboard/components/ApplicationsTrend.tsx new file mode 100644 index 000000000..5e4f7b278 --- /dev/null +++ b/apps/portal/src/app/features/dashboard/components/ApplicationsTrend.tsx @@ -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 ( + + + + + + + + + + + + + + + + + + ); +} diff --git a/apps/portal/src/app/features/dashboard/components/DashboardHero.tsx b/apps/portal/src/app/features/dashboard/components/DashboardHero.tsx new file mode 100644 index 000000000..9c71d1b0b --- /dev/null +++ b/apps/portal/src/app/features/dashboard/components/DashboardHero.tsx @@ -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 ( + + {/* decorative wash */} + +
+ + + + + {t('dashboard.hero.greeting')} + {name ? ',' : ''} + + {name && ( + + {name} + + )} + + {t('dashboard.hero.subtitle')} + + + + + + + + + + ); +} diff --git a/apps/portal/src/app/features/dashboard/components/StatusDonut.tsx b/apps/portal/src/app/features/dashboard/components/StatusDonut.tsx new file mode 100644 index 000000000..339c703ef --- /dev/null +++ b/apps/portal/src/app/features/dashboard/components/StatusDonut.tsx @@ -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>((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 ( +
+ + {t('dashboard.charts.noData')} + +
+ ); + } + + return ( + + + + + + {data.map((d) => ( + + ))} + + + + + + {applications.length} + + + {t('dashboard.charts.total')} + + + + + + {data.map((d) => ( + + + + {applicationStatus(d.status)} + + + {d.value} + + + ))} + + + ); +} diff --git a/apps/portal/src/app/features/dashboard/pages/DashboardPage.tsx b/apps/portal/src/app/features/dashboard/pages/DashboardPage.tsx index 8052214b2..268f1b4a1 100644 --- a/apps/portal/src/app/features/dashboard/pages/DashboardPage.tsx +++ b/apps/portal/src/app/features/dashboard/pages/DashboardPage.tsx @@ -1,13 +1,197 @@ -import { Stack, Title, Text, Paper } from '@mantine/core'; +import { useEffect, useState } from 'react'; +import { + Button, + Card, + Group, + Paper, + SimpleGrid, + Skeleton, + Stack, + Text, + ThemeIcon, + Title, + UnstyledButton, +} from '@mantine/core'; +import { + IconCertificate, + IconClockHour4, + IconFilePlus, + IconAlertTriangle, + IconArrowRight, + IconRefresh, + IconSearch, +} from '@tabler/icons-react'; +import type { Icon } from '@tabler/icons-react'; +import { useNavigate } from 'react-router-dom'; +import { useTranslation } from 'react-i18next'; +import { StatCard } from '../../licenses/components/StatCard'; +import { ApplicationsTable } from '../../licenses/components/ApplicationsTable'; +import { useLicenses } from '../../licenses/hooks/useLicenses'; +import { DashboardHero } from '../components/DashboardHero'; +import { StatusDonut } from '../components/StatusDonut'; +import { ApplicationsTrend } from '../components/ApplicationsTrend'; +import type { ApplicationStatus } from '../../licenses/types/license.types'; + +const OPEN_STATUSES: ApplicationStatus[] = [ + 'SUBMITTED', + 'UNDER_REVIEW', + 'INFO_REQUESTED', + 'PAYMENT_PENDING', +]; export function DashboardPage() { + const { t } = useTranslation(); + const navigate = useNavigate(); + const { applications, licenses } = useLicenses(); + + // Brief skeleton on the charts for a polished first paint. + const [chartsLoading, setChartsLoading] = useState(true); + useEffect(() => { + const id = setTimeout(() => setChartsLoading(false), 550); + return () => clearTimeout(id); + }, []); + + const activeLicenses = licenses.filter((l) => l.status === 'ACTIVE').length; + const pending = applications.filter((a) => OPEN_STATUSES.includes(a.status)).length; + const expiring = licenses.filter((l) => l.status === 'EXPIRING_SOON').length; + const actionRequired = applications.filter((a) => a.status === 'INFO_REQUESTED').length; + + const recent = applications.slice(0, 5); + const holderName = licenses[0]?.holderName; + + const quickActions: { + icon: Icon; + title: string; + desc: string; + to: string; + color: string; + }[] = [ + { + icon: IconFilePlus, + title: t('dashboard.quick.apply'), + desc: t('dashboard.quick.applyDesc'), + to: '/apply', + color: 'emaPrimary', + }, + { + icon: IconRefresh, + title: t('dashboard.quick.renew'), + desc: t('dashboard.quick.renewDesc'), + to: '/licenses', + color: 'emaTeal', + }, + { + icon: IconSearch, + title: t('dashboard.quick.track'), + desc: t('dashboard.quick.trackDesc'), + to: '/applications', + color: 'indigo', + }, + ]; + return ( - My Dashboard - - - Welcome to the EMA Portal. Your content will appear here. - + + + + + + + + + + + {quickActions.map((qa) => ( + navigate(qa.to)}> + + + + + + +
+ {qa.title} + + {qa.desc} + +
+
+ +
+
+
+ ))} +
+ + + + +
+ {t('dashboard.charts.trend')} + + {t('dashboard.charts.trendPeriod')} + +
+
+ {chartsLoading ? ( + + ) : ( + + )} +
+ + + + {t('dashboard.charts.distribution')} + + {chartsLoading ? ( + + + + {Array.from({ length: 4 }).map((_, i) => ( + + ))} + + + ) : ( + + )} + +
+ + + + {t('dashboard.recentApplications')} + + +
); diff --git a/apps/portal/src/app/features/licenses/components/ApplicationTimeline.tsx b/apps/portal/src/app/features/licenses/components/ApplicationTimeline.tsx new file mode 100644 index 000000000..4bfa31763 --- /dev/null +++ b/apps/portal/src/app/features/licenses/components/ApplicationTimeline.tsx @@ -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 ( + + {history.map((event, idx) => ( + : + } + title={applicationStatus(event.status)} + > + + {event.note} + + + {formatDate(event.date)} + + + ))} + + ); +} diff --git a/apps/portal/src/app/features/licenses/components/ApplicationsTable.tsx b/apps/portal/src/app/features/licenses/components/ApplicationsTable.tsx new file mode 100644 index 000000000..2c6b6075b --- /dev/null +++ b/apps/portal/src/app/features/licenses/components/ApplicationsTable.tsx @@ -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 ( + navigate('/apply'), + }} + /> + ); + } + + return ( + + + + + {t('applications.columns.reference')} + {t('applications.columns.licenseType')} + {t('applications.columns.request')} + {t('applications.columns.subject')} + {t('applications.columns.submitted')} + {t('applications.columns.status')} + + + + + {applications.map((app) => ( + + + navigate(`/applications/${app.id}`)}> + {app.referenceNo} + + + {licenseType(app.licenseType)} + {requestType(app.requestType)} + {app.subjectName} + {formatDate(app.submittedAt)} + + + + + + navigate(`/applications/${app.id}`)} + > + + + + + + ))} + +
+
+ ); +} diff --git a/apps/portal/src/app/features/licenses/components/DocumentsList.tsx b/apps/portal/src/app/features/licenses/components/DocumentsList.tsx new file mode 100644 index 000000000..5db6389d3 --- /dev/null +++ b/apps/portal/src/app/features/licenses/components/DocumentsList.tsx @@ -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 = { + VERIFIED: IconFileCheck, + UPLOADED: IconFileUpload, + REJECTED: IconFileX, + PENDING: IconFileDots, +}; + +export function DocumentsList({ documents }: { documents: ApplicationDocument[] }) { + const { doc, documentStatus } = useLicenseLabels(); + + return ( + + {documents.map((d) => { + const Icon = STATUS_ICON[d.status]; + const color = DOCUMENT_STATUS_COLORS[d.status]; + return ( + + + + + + {doc(d.name)} + + + {documentStatus(d.status)} + + + ); + })} + + ); +} diff --git a/apps/portal/src/app/features/licenses/components/LicensesTable.tsx b/apps/portal/src/app/features/licenses/components/LicensesTable.tsx new file mode 100644 index 000000000..55c44035d --- /dev/null +++ b/apps/portal/src/app/features/licenses/components/LicensesTable.tsx @@ -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 ( + + + + + {t('licenses.columns.licenseNo')} + {t('licenses.columns.type')} + {t('licenses.columns.subject')} + {t('licenses.columns.issued')} + {t('licenses.columns.expires')} + {t('licenses.columns.status')} + + + + + {licenses.map((lic) => ( + + + navigate(`/licenses/${lic.id}`)}> + {lic.licenseNo} + + + {licenseType(lic.licenseType)} + {lic.subjectName} + {formatDate(lic.issuedAt)} + {formatDate(lic.expiresAt)} + + + + + + navigate(`/licenses/${lic.id}`)} + > + + + + + + ))} + +
+
+ ); +} diff --git a/apps/portal/src/app/features/licenses/components/ServiceCard.tsx b/apps/portal/src/app/features/licenses/components/ServiceCard.tsx new file mode 100644 index 000000000..5f43955ba --- /dev/null +++ b/apps/portal/src/app/features/licenses/components/ServiceCard.tsx @@ -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 ( + + + + + {licenseType(type)} + + + + + + + + } + > + {t('services.processingDays', { min, max })} + + } + > + {t('services.validityYears', { count: years })} + + + +
+ + {t('services.requiredDocuments')} + + + {docs.slice(0, 3).map((d) => ( + {doc(d)} + ))} + {docs.length > 3 && +{docs.length - 3}} + +
+ + +
+
+ ); +} diff --git a/apps/portal/src/app/features/licenses/components/StatCard.tsx b/apps/portal/src/app/features/licenses/components/StatCard.tsx new file mode 100644 index 000000000..e2fb43615 --- /dev/null +++ b/apps/portal/src/app/features/licenses/components/StatCard.tsx @@ -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 ( + + {/* faint accent wash in the corner */} +
+ +
+ + {label} + + + {value} + + {hint && ( + + {hint} + + )} +
+ + + +
+ + ); +} diff --git a/apps/portal/src/app/features/licenses/components/StatusBadge.tsx b/apps/portal/src/app/features/licenses/components/StatusBadge.tsx new file mode 100644 index 000000000..2e3f07f64 --- /dev/null +++ b/apps/portal/src/app/features/licenses/components/StatusBadge.tsx @@ -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 ( + + {applicationStatus(status)} + + ); +} + +export function LicenseStatusBadge({ status }: { status: LicenseStatus }) { + const { licenseStatus } = useLicenseLabels(); + return ( + + {licenseStatus(status)} + + ); +} diff --git a/apps/portal/src/app/features/licenses/constants.ts b/apps/portal/src/app/features/licenses/constants.ts new file mode 100644 index 000000000..f1824f3e8 --- /dev/null +++ b/apps/portal/src/app/features/licenses/constants.ts @@ -0,0 +1,145 @@ +import type { + ApplicationStatus, + DocumentStatus, + LicenseStatus, + LicenseType, + RequestType, +} from './types/license.types'; + +export const LICENSE_TYPE_LABELS: Record = { + 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 = { + 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 = { + 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 = { + NEW: 'New Application', + RENEWAL: 'Renewal', + AMENDMENT: 'Amendment', + COMPLIANCE: 'Compliance Submission', +}; + +export const APPLICATION_STATUS_LABELS: Record = { + 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 = { + 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 = { + ACTIVE: 'Active', + EXPIRING_SOON: 'Expiring Soon', + EXPIRED: 'Expired', + SUSPENDED: 'Suspended', +}; + +export const LICENSE_STATUS_COLORS: Record = { + ACTIVE: 'green', + EXPIRING_SOON: 'orange', + EXPIRED: 'red', + SUSPENDED: 'grape', +}; + +export const DOCUMENT_STATUS_COLORS: Record = { + 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 = { + 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 = { + 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 = { + 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', +]; diff --git a/apps/portal/src/app/features/licenses/hooks/useLicenseLabels.ts b/apps/portal/src/app/features/licenses/hooks/useLicenseLabels.ts new file mode 100644 index 000000000..106b3efbc --- /dev/null +++ b/apps/portal/src/app/features/licenses/hooks/useLicenseLabels.ts @@ -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, + }; +} diff --git a/apps/portal/src/app/features/licenses/hooks/useLicenses.ts b/apps/portal/src/app/features/licenses/hooks/useLicenses.ts new file mode 100644 index 000000000..6d052c567 --- /dev/null +++ b/apps/portal/src/app/features/licenses/hooks/useLicenses.ts @@ -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)); +} diff --git a/apps/portal/src/app/features/licenses/pages/ApplicationDetailPage.tsx b/apps/portal/src/app/features/licenses/pages/ApplicationDetailPage.tsx new file mode 100644 index 000000000..dcae165c3 --- /dev/null +++ b/apps/portal/src/app/features/licenses/pages/ApplicationDetailPage.tsx @@ -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 ( + navigate('/applications'), + }} + /> + ); + } + + return ( + + + + } + /> + + {app.status === 'INFO_REQUESTED' && ( + } + title={t('applicationDetail.actionRequired')} + > + + {app.notes ?? t('applicationDetail.actionRequiredDesc')} + + + + )} + + + + + + + {t('applicationDetail.overview')} + + + + + + + + + {app.relatedLicenseNo && ( + + )} + + + + + + {t('applicationDetail.documents')} + + + + + + + + + + {t('applicationDetail.progress')} + + + + + + + ); +} + +function Field({ label, value }: { label: string; value: string }) { + return ( +
+ + {label} + + + {value} + +
+ ); +} diff --git a/apps/portal/src/app/features/licenses/pages/ApplicationsListPage.tsx b/apps/portal/src/app/features/licenses/pages/ApplicationsListPage.tsx new file mode 100644 index 000000000..066a418c2 --- /dev/null +++ b/apps/portal/src/app/features/licenses/pages/ApplicationsListPage.tsx @@ -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(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 ( + + } + onClick={() => navigate('/apply')} + > + {t('dashboard.newApplication')} + + } + /> + + + + + } + placeholder={t('applications.searchPlaceholder')} + value={query} + onChange={(e) => setQuery(e.currentTarget.value)} + /> + ({ + value: v, + label: rtLabel(v), + }))} + value={requestType} + onChange={(v) => setRequestType((v as RequestType) ?? 'NEW')} + allowDeselect={false} + /> +