diff --git a/apps/portal/src/app/components/ColorSchemeToggle.tsx b/apps/portal/src/app/components/ColorSchemeToggle.tsx index 3f57d6041..e7bcd0ae8 100644 --- a/apps/portal/src/app/components/ColorSchemeToggle.tsx +++ b/apps/portal/src/app/components/ColorSchemeToggle.tsx @@ -1,4 +1,4 @@ -import { ActionIcon, useMantineColorScheme, useComputedColorScheme } from '@mantine/core'; +import { UnstyledButton, useMantineColorScheme, useComputedColorScheme, rem } from '@mantine/core'; import { IconSun, IconMoon } from '@tabler/icons-react'; import { useTranslation } from 'react-i18next'; @@ -9,13 +9,37 @@ export function ColorSchemeToggle() { const isDark = computed === 'dark'; return ( - setColorScheme(isDark ? 'light' : 'dark')} + style={{ + display: 'flex', + alignItems: 'center', + justifyContent: 'center', + width: rem(38), + height: rem(38), + borderRadius: rem(12), + background: 'var(--mantine-color-body)', + color: 'var(--mantine-color-gray-6)', + boxShadow: '0 1px 3px rgba(0,0,0,0.04), 0 0 0 1px rgba(0,0,0,0.06)', + transition: 'all 150ms ease', + cursor: 'pointer', + border: 'none', + }} + onMouseEnter={(e) => { + e.currentTarget.style.background = 'var(--mantine-color-primary-light)'; + e.currentTarget.style.color = 'var(--mantine-color-primary-6)'; + e.currentTarget.style.boxShadow = + '0 1px 3px rgba(0,0,0,0.04), 0 0 0 1px var(--mantine-color-primary-2)'; + }} + onMouseLeave={(e) => { + e.currentTarget.style.background = 'var(--mantine-color-body)'; + e.currentTarget.style.color = 'var(--mantine-color-gray-6)'; + e.currentTarget.style.boxShadow = + '0 1px 3px rgba(0,0,0,0.04), 0 0 0 1px rgba(0,0,0,0.06)'; + }} > - {isDark ? : } - + {isDark ? : } + ); } diff --git a/apps/portal/src/app/components/LanguageSwitcher.tsx b/apps/portal/src/app/components/LanguageSwitcher.tsx index 3ea0d1f5c..18e8d9db9 100644 --- a/apps/portal/src/app/components/LanguageSwitcher.tsx +++ b/apps/portal/src/app/components/LanguageSwitcher.tsx @@ -1,5 +1,5 @@ -import { Menu, ActionIcon, Button } from '@mantine/core'; -import { IconWorld, IconCheck } from '@tabler/icons-react'; +import { Menu, UnstyledButton, Text, rem } from '@mantine/core'; +import { IconWorld, IconCheck, IconChevronDown } from '@tabler/icons-react'; import { useTranslation } from 'react-i18next'; import { SUPPORTED_LANGUAGES, type AppLanguage } from '../i18n/config'; @@ -19,21 +19,51 @@ export function LanguageSwitcher({ variant = 'icon' }: LanguageSwitcherProps) { return ( - {variant === 'icon' ? ( - - - - ) : ( - - )} + { + e.currentTarget.style.background = 'var(--mantine-color-primary-light)'; + e.currentTarget.style.color = 'var(--mantine-color-primary-6)'; + e.currentTarget.style.boxShadow = + '0 1px 3px rgba(0,0,0,0.04), 0 0 0 1px var(--mantine-color-primary-2)'; + }} + onMouseLeave={(e) => { + e.currentTarget.style.background = 'var(--mantine-color-body)'; + e.currentTarget.style.color = 'var(--mantine-color-gray-6)'; + e.currentTarget.style.boxShadow = + '0 1px 3px rgba(0,0,0,0.04), 0 0 0 1px rgba(0,0,0,0.06)'; + }} + > + + {variant !== 'icon' && ( + <> + + {t(`language.${current}`)} + + + + )} + - + {t('language.label')} {SUPPORTED_LANGUAGES.map((lng) => ( : undefined } + style={{ borderRadius: rem(8) }} > {t(`language.${lng}`)} diff --git a/apps/portal/src/app/components/ui.tsx b/apps/portal/src/app/components/ui.tsx index 427b470a9..071dd0b4a 100644 --- a/apps/portal/src/app/components/ui.tsx +++ b/apps/portal/src/app/components/ui.tsx @@ -1,33 +1,4 @@ -import { Badge, Box, useMantineTheme } from '@mantine/core'; -import { APPLICATION_STATUS_COLORS } from '../features/licenses/constants'; -import type { ApplicationStatus } from '../features/licenses/types/license.types'; -import { useLicenseLabels } from '../features/licenses/hooks/useLicenseLabels'; - -export function StatusPill({ status }: { status: ApplicationStatus }) { - const { applicationStatus } = useLicenseLabels(); - const color = APPLICATION_STATUS_COLORS[status]; - return ( - - } - > - {applicationStatus(status)} - - ); -} +import { Box, useMantineTheme } from '@mantine/core'; export function BrandAvatar({ initials = 'AB', diff --git a/apps/portal/src/app/features/dashboard/components/ApplicationsTrend.tsx b/apps/portal/src/app/features/dashboard/components/ApplicationsTrend.tsx deleted file mode 100644 index 5e4f7b278..000000000 --- a/apps/portal/src/app/features/dashboard/components/ApplicationsTrend.tsx +++ /dev/null @@ -1,96 +0,0 @@ -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/StatusDonut.tsx b/apps/portal/src/app/features/dashboard/components/StatusDonut.tsx deleted file mode 100644 index 339c703ef..000000000 --- a/apps/portal/src/app/features/dashboard/components/StatusDonut.tsx +++ /dev/null @@ -1,99 +0,0 @@ -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 1e291e3c6..bcfcf2dac 100644 --- a/apps/portal/src/app/features/dashboard/pages/DashboardPage.tsx +++ b/apps/portal/src/app/features/dashboard/pages/DashboardPage.tsx @@ -1,14 +1,10 @@ import { - Box, - Button, Card, Center, - Grid, Group, Paper, SimpleGrid, Stack, - Table, Text, ThemeIcon, Title, @@ -16,48 +12,17 @@ import { useMantineTheme, } from '@mantine/core'; import { - IconArrowRight, - IconAward, IconChevronRight, - IconClockHour4, - IconCircleCheck, - IconFileText, IconLifebuoy, IconShip, - IconSquareRoundedPlus, - IconUpload, + IconUserPlus, } from '@tabler/icons-react'; import type { Icon } from '@tabler/icons-react'; import { useNavigate } from 'react-router-dom'; -import { notify } from '@ema-platform/ui'; -import { useLicenses } from '../../licenses/hooks/useLicenses'; -import { useLicenseLabels } from '../../licenses/hooks/useLicenseLabels'; -import { StatusDonut } from '../components/StatusDonut'; -import type { ApplicationStatus } from '../../licenses/types/license.types'; -import { StatusPill } from '../../../components/ui'; - -const OPEN_STATUSES: ApplicationStatus[] = [ - 'SUBMITTED', - 'UNDER_REVIEW', - 'INFO_REQUESTED', - 'PAYMENT_PENDING', -]; export function DashboardPage() { const navigate = useNavigate(); const theme = useMantineTheme(); - const { applications, licenses } = useLicenses(); - const { licenseType, formatDate } = useLicenseLabels(); - - const activeLicenses = licenses.filter((l) => l.status === 'ACTIVE').length; - const pending = applications.filter((a) => OPEN_STATUSES.includes(a.status)).length; - const approved = applications.filter( - (a) => a.status === 'APPROVED' || a.status === 'ISSUED', - ).length; - const documents = applications.reduce((sum, a) => sum + a.documents.length, 0); - - const firstName = (licenses[0]?.holderName ?? 'there').split(' ')[0]; - const recent = applications.slice(0, 6); return ( @@ -71,24 +36,13 @@ export function DashboardPage() { - Welcome back, {firstName} 👋 + Welcome to the EMA Portal - You have {pending} applications in review and {activeLicenses} active - licence{activeLicenses === 1 ? '' : 's'}. Start a new application or check - your status below. + Manage your seafarer profile, submit applications, and track your + maritime credentials all in one place. -
- {/* ---- Stat cards -------------------------------------------- */} - - - - - - - - {/* ---- Lower row --------------------------------------------- */} - - - - - Recent Applications - - - - - - - Application - Type - Submitted - Status - - - - - {recent.map((app) => ( - navigate('/applications')} - > - - - {app.referenceNo} - - - - - {licenseType(app.licenseType)} - - - - - {formatDate(app.submittedAt)} - - - - - - - - - - ))} - -
-
-
- - - - - - Application Status - - - - - - - Quick actions - - - navigate('/apply')} - /> - notify.info('Document upload — coming soon.')} - /> - navigate('/support')} - /> - - + {/* ---- Quick actions ----------------------------------------- */} + + + + Quick actions + + + navigate('/seafarer-registration')} + /> + navigate('/support')} + /> - -
+ + ); } -function StatCard({ - icon: CardIcon, - color, - value, - label, - trend, - trendColor = 'gray', -}: { - icon: Icon; - color: string; - value: number; - label: string; - trend: string; - trendColor?: string; -}) { - return ( - - - - - - - - {trend} - - - - {value} - - - {label} - - - - ); -} - function QuickAction({ icon: ActionIconCmp, color, diff --git a/apps/portal/src/app/features/licenses/components/ApplicationTimeline.tsx b/apps/portal/src/app/features/licenses/components/ApplicationTimeline.tsx deleted file mode 100644 index 4bfa31763..000000000 --- a/apps/portal/src/app/features/licenses/components/ApplicationTimeline.tsx +++ /dev/null @@ -1,29 +0,0 @@ -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 deleted file mode 100644 index 2c6b6075b..000000000 --- a/apps/portal/src/app/features/licenses/components/ApplicationsTable.tsx +++ /dev/null @@ -1,72 +0,0 @@ -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 deleted file mode 100644 index 5db6389d3..000000000 --- a/apps/portal/src/app/features/licenses/components/DocumentsList.tsx +++ /dev/null @@ -1,43 +0,0 @@ -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 deleted file mode 100644 index 55c44035d..000000000 --- a/apps/portal/src/app/features/licenses/components/LicensesTable.tsx +++ /dev/null @@ -1,59 +0,0 @@ -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 deleted file mode 100644 index 5f43955ba..000000000 --- a/apps/portal/src/app/features/licenses/components/ServiceCard.tsx +++ /dev/null @@ -1,79 +0,0 @@ -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 deleted file mode 100644 index e2fb43615..000000000 --- a/apps/portal/src/app/features/licenses/components/StatCard.tsx +++ /dev/null @@ -1,62 +0,0 @@ -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 deleted file mode 100644 index 2e3f07f64..000000000 --- a/apps/portal/src/app/features/licenses/components/StatusBadge.tsx +++ /dev/null @@ -1,22 +0,0 @@ -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 deleted file mode 100644 index f1824f3e8..000000000 --- a/apps/portal/src/app/features/licenses/constants.ts +++ /dev/null @@ -1,145 +0,0 @@ -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 deleted file mode 100644 index 106b3efbc..000000000 --- a/apps/portal/src/app/features/licenses/hooks/useLicenseLabels.ts +++ /dev/null @@ -1,35 +0,0 @@ -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 deleted file mode 100644 index 6d052c567..000000000 --- a/apps/portal/src/app/features/licenses/hooks/useLicenses.ts +++ /dev/null @@ -1,25 +0,0 @@ -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 deleted file mode 100644 index dcae165c3..000000000 --- a/apps/portal/src/app/features/licenses/pages/ApplicationDetailPage.tsx +++ /dev/null @@ -1,143 +0,0 @@ -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 deleted file mode 100644 index 3bd37f95f..000000000 --- a/apps/portal/src/app/features/licenses/pages/ApplicationsListPage.tsx +++ /dev/null @@ -1,349 +0,0 @@ -import { useMemo, useState } from 'react'; -import { - ActionIcon, - Badge, - Box, - Button, - Divider, - Grid, - Group, - Paper, - Stack, - Table, - Text, - ThemeIcon, - Timeline, - Title, - UnstyledButton, -} from '@mantine/core'; -import { - IconAdjustmentsHorizontal, - IconCheck, - IconChevronRight, - IconClock, - IconCircleX, - IconDownload, - IconMessageCircle, -} from '@tabler/icons-react'; -import { notify } from '@ema-platform/ui'; -import { useLicenses } from '../hooks/useLicenses'; -import { useLicenseLabels } from '../hooks/useLicenseLabels'; -import { APPLICATION_PIPELINE } from '../constants'; -import type { - Application, - ApplicationStatus, - LicenseType, -} from '../types/license.types'; -import { StatusPill } from '../../../components/ui'; - -const SERVICE_FEE: Record = { - SEAFARER_COC: 1200, - SEAFARER_COP: 900, - SEAMAN_BOOK: 600, - VESSEL_REGISTRATION: 3500, - SHIP_RADIO: 900, - TONNAGE_CERTIFICATE: 2400, - SAFETY_MANAGEMENT: 5000, - PORT_FACILITY: 5000, - BOAT_OPERATOR: 700, -}; - -const etb = (n: number) => `ETB ${n.toLocaleString()}`; - -const PIPELINE_LABELS: Record = { - SUBMITTED: 'Application submitted', - UNDER_REVIEW: 'Document verification & review', - PAYMENT_PENDING: 'Payment & assessment', - APPROVED: 'Approval decision', - ISSUED: 'License issued', -}; - -function stageIndex(status: ApplicationStatus): number { - if (status === 'DRAFT') return 0; - if (status === 'INFO_REQUESTED') return 1; - if (status === 'REJECTED') return 3; - const i = APPLICATION_PIPELINE.indexOf(status); - return i < 0 ? 0 : i; -} - -type TabKey = 'all' | 'review' | 'approved' | 'pending'; - -export function ApplicationsListPage() { - const { applications } = useLicenses(); - const { licenseType, formatDate } = useLicenseLabels(); - const [tab, setTab] = useState('all'); - const [selectedId, setSelectedId] = useState( - applications[0]?.id ?? null, - ); - - const counts = useMemo( - () => ({ - all: applications.length, - review: applications.filter((a) => a.status === 'UNDER_REVIEW').length, - approved: applications.filter( - (a) => a.status === 'APPROVED' || a.status === 'ISSUED', - ).length, - pending: applications.filter((a) => - ['SUBMITTED', 'INFO_REQUESTED', 'PAYMENT_PENDING'].includes(a.status), - ).length, - }), - [applications], - ); - - const filtered = useMemo(() => { - switch (tab) { - case 'review': - return applications.filter((a) => a.status === 'UNDER_REVIEW'); - case 'approved': - return applications.filter( - (a) => a.status === 'APPROVED' || a.status === 'ISSUED', - ); - case 'pending': - return applications.filter((a) => - ['SUBMITTED', 'INFO_REQUESTED', 'PAYMENT_PENDING'].includes(a.status), - ); - default: - return applications; - } - }, [applications, tab]); - - const selected = - applications.find((a) => a.id === selectedId) ?? filtered[0] ?? applications[0]; - - const TABS: { key: TabKey; label: string; count: number }[] = [ - { key: 'all', label: 'All', count: counts.all }, - { key: 'review', label: 'In Review', count: counts.review }, - { key: 'approved', label: 'Approved', count: counts.approved }, - { key: 'pending', label: 'Pending', count: counts.pending }, - ]; - - return ( - - - - {TABS.map((tabItem) => { - const active = tab === tabItem.key; - return ( - - ); - })} - - - - - - - - - - - Application - Type - Submitted - Fee - Status - - - - - {filtered.map((app) => { - const isSelected = selected?.id === app.id; - return ( - setSelectedId(app.id)} - style={{ - cursor: 'pointer', - background: isSelected - ? 'var(--mantine-color-emaPrimary-light)' - : undefined, - }} - > - - - {app.referenceNo} - - - - - {licenseType(app.licenseType)} - - - - - {formatDate(app.submittedAt)} - - - - - {etb(SERVICE_FEE[app.licenseType])} - - - - - - - - - - ); - })} - -
-
-
- - - {selected && } - -
-
- ); -} - -function TimelinePanel({ application }: { application: Application }) { - const { licenseType, formatDate } = useLicenseLabels(); - const current = stageIndex(application.status); - const historyDates = new Map( - application.history.map((h) => [h.status, h.date] as const), - ); - - return ( - - - -
- {application.referenceNo} - - {licenseType(application.licenseType)} - -
- -
- - - - {APPLICATION_PIPELINE.map((status, i) => { - const done = i < current; - const isCurrent = i === current; - const date = historyDates.get(status); - return ( - : undefined} - title={ - - {PIPELINE_LABELS[status]} - - } - lineVariant={done ? 'solid' : 'dashed'} - > - - {date - ? formatDate(date) - : isCurrent - ? 'In progress' - : 'Pending'} - - - ); - })} - - - - - - - - Last updated {formatDate(application.updatedAt)} - - -
- - - - } - label="Download submission receipt" - onClick={() => notify.info('Receipt download — coming soon.')} - /> - } - label="Message case officer" - onClick={() => notify.info('Messaging — coming soon.')} - /> - } - label="Withdraw application" - danger - onClick={() => notify.info('Withdrawal — coming soon.')} - /> - - -
- ); -} - -function ActionRow({ - icon, - label, - onClick, - danger, -}: { - icon: React.ReactNode; - label: string; - onClick: () => void; - danger?: boolean; -}) { - return ( - - - - {icon} - - - {label} - - - - - ); -} diff --git a/apps/portal/src/app/features/licenses/pages/ApplyPage.tsx b/apps/portal/src/app/features/licenses/pages/ApplyPage.tsx deleted file mode 100644 index d45596056..000000000 --- a/apps/portal/src/app/features/licenses/pages/ApplyPage.tsx +++ /dev/null @@ -1,468 +0,0 @@ -import { useMemo, useState } from 'react'; -import { - Alert, - Box, - Button, - Center, - Checkbox, - Divider, - Grid, - Group, - Paper, - Select, - SimpleGrid, - Stack, - Stepper, - Text, - Textarea, - TextInput, - ThemeIcon, - Title, -} from '@mantine/core'; -import { - IconArrowLeft, - IconArrowRight, - IconCircleCheck, - IconCloudUpload, - IconDeviceMobile, - IconId, - IconInfoCircle, - IconLifebuoy, - IconMapPin, - IconMessageCircle, - IconShip, - IconStack2, - IconUser, -} from '@tabler/icons-react'; -import { useNavigate } from 'react-router-dom'; -import { notify } from '@ema-platform/ui'; -import { useLicenses } from '../hooks/useLicenses'; -import { useLicenseLabels } from '../hooks/useLicenseLabels'; -import { - LICENSE_PROCESSING_DAYS, - LICENSE_TYPE_LABELS, - LICENSE_VALIDITY_YEARS, - REQUEST_TYPE_LABELS, - REQUIRED_DOCUMENTS, -} from '../constants'; -import type { LicenseType, RequestType } from '../types/license.types'; - -const SERVICE_FEE: Record = { - SEAFARER_COC: 1200, - SEAFARER_COP: 900, - SEAMAN_BOOK: 600, - VESSEL_REGISTRATION: 3500, - SHIP_RADIO: 900, - TONNAGE_CERTIFICATE: 2400, - SAFETY_MANAGEMENT: 5000, - PORT_FACILITY: 5000, - BOAT_OPERATOR: 700, -}; - -const CATEGORIES = ['Deck Officer', 'Engine Officer', 'Ratings', 'Electro-technical', 'Other']; -const REGIONS = [ - 'Addis Ababa', - 'Dire Dawa', - 'Amhara', - 'Oromia', - 'Tigray', - 'Afar', - 'Somali', - 'Sidama', - 'South Ethiopia', - 'Gambela', - 'Benishangul-Gumuz', - 'Harari', -]; - -const LICENSE_OPTIONS = (Object.keys(LICENSE_TYPE_LABELS) as LicenseType[]).map((v) => ({ - value: v, - label: LICENSE_TYPE_LABELS[v], -})); -const REQUEST_OPTIONS = (['NEW', 'RENEWAL'] as RequestType[]).map((v) => ({ - value: v, - label: REQUEST_TYPE_LABELS[v], -})); - -export function ApplyPage() { - const navigate = useNavigate(); - const { submit } = useLicenses(); - const { subjectLabel, doc } = useLicenseLabels(); - - const [active, setActive] = useState(0); - const [licenseType, setLicenseType] = useState('SEAFARER_COC'); - const [category, setCategory] = useState('Deck Officer'); - const [region, setRegion] = useState('Addis Ababa'); - const [requestType, setRequestType] = useState('NEW'); - const [fullName, setFullName] = useState(''); - const [idNumber, setIdNumber] = useState(''); - const [phone, setPhone] = useState(''); - const [subjectName, setSubjectName] = useState(''); - const [notes, setNotes] = useState(''); - const [uploaded, setUploaded] = useState>({}); - - const requiredDocs = useMemo( - () => (licenseType ? REQUIRED_DOCUMENTS[licenseType] : []), - [licenseType], - ); - const attached = requiredDocs.filter((d) => uploaded[d]).length; - - const next = () => setActive((c) => Math.min(c + 1, 3)); - const prev = () => setActive((c) => Math.max(c - 1, 0)); - - const canContinue = () => { - if (active === 0) return !!licenseType && !!fullName.trim(); - if (active === 1) return !!subjectName.trim(); - return true; - }; - - const handleSubmit = () => { - if (!licenseType) return; - submit({ - requestType, - licenseType, - applicantName: fullName.trim(), - subjectName: subjectName.trim() || fullName.trim(), - notes: notes.trim() || undefined, - documents: requiredDocs.filter((d) => uploaded[d]), - }); - notify.success('Your application has been submitted to EMA.'); - navigate('/applications'); - }; - - return ( - - - - - - - - - - - - - - {active === 0 && ( - - - } - data={CATEGORIES} - value={category} - onChange={setCategory} - /> - setRequestType((v as RequestType) ?? 'NEW')} - allowDeselect={false} - /> - setSubjectName(e.currentTarget.value)} - /> -