From 47adb10aa250c357a8288900721c23d6c82bbc30 Mon Sep 17 00:00:00 2001 From: Mengisteab Date: Thu, 11 Jun 2026 07:24:45 +0000 Subject: [PATCH] second design options --- .claude/settings.json | 9 + apps/portal/src/app/router.tsx | 22 + apps/portal/src/app/v2/PortalLayoutV2.tsx | 210 ++++++++ apps/portal/src/app/v2/components/ui.tsx | 61 +++ .../src/app/v2/pages/ApplicationsV2.tsx | 352 +++++++++++++ apps/portal/src/app/v2/pages/ApplyV2.tsx | 470 ++++++++++++++++++ apps/portal/src/app/v2/pages/DashboardV2.tsx | 303 +++++++++++ 7 files changed, 1427 insertions(+) create mode 100644 .claude/settings.json create mode 100644 apps/portal/src/app/v2/PortalLayoutV2.tsx create mode 100644 apps/portal/src/app/v2/components/ui.tsx create mode 100644 apps/portal/src/app/v2/pages/ApplicationsV2.tsx create mode 100644 apps/portal/src/app/v2/pages/ApplyV2.tsx create mode 100644 apps/portal/src/app/v2/pages/DashboardV2.tsx diff --git a/.claude/settings.json b/.claude/settings.json new file mode 100644 index 000000000..bbb685af2 --- /dev/null +++ b/.claude/settings.json @@ -0,0 +1,9 @@ +{ + "permissions": { + "allow": [ + "Bash(curl -s -o /dev/null -w \"HTTP %{http_code}\\\\n\" http://localhost:4200/v2/dashboard)", + "Bash(pkill -f \"nx run @ema-platform/portal:serve\")", + "Bash(pkill -f \"vite\")" + ] + } +} diff --git a/apps/portal/src/app/router.tsx b/apps/portal/src/app/router.tsx index a9f5eadeb..fea4203de 100644 --- a/apps/portal/src/app/router.tsx +++ b/apps/portal/src/app/router.tsx @@ -12,6 +12,12 @@ import { OTPVerificationPage } from './features/auth/pages/OTPVerificationPage'; // Portal feature pages import { DashboardPage } from './features/dashboard/pages/DashboardPage'; import { ServicesPage } from './features/licenses/pages/ServicesPage'; + +// Redesigned portal pages (new look & feel) — kept alongside the originals. +import { PortalLayoutV2 } from './v2/PortalLayoutV2'; +import { DashboardV2 } from './v2/pages/DashboardV2'; +import { ApplicationsV2 } from './v2/pages/ApplicationsV2'; +import { ApplyV2 } from './v2/pages/ApplyV2'; import { ApplyPage } from './features/licenses/pages/ApplyPage'; import { ApplicationsListPage } from './features/licenses/pages/ApplicationsListPage'; import { ApplicationDetailPage } from './features/licenses/pages/ApplicationDetailPage'; @@ -61,6 +67,22 @@ export const router = createBrowserRouter([ ], }, + // Redesigned portal (new look & feel) under /v2 — isolated in the portal i18n + // instance, same as the original portal routes. + { + element: ( + + + + ), + children: [ + { path: '/v2', element: }, + { path: '/v2/dashboard', element: }, + { path: '/v2/applications', element: }, + { path: '/v2/apply', element: }, + ], + }, + // IAM admin user management (isolated providers) { element: ( diff --git a/apps/portal/src/app/v2/PortalLayoutV2.tsx b/apps/portal/src/app/v2/PortalLayoutV2.tsx new file mode 100644 index 000000000..20f405354 --- /dev/null +++ b/apps/portal/src/app/v2/PortalLayoutV2.tsx @@ -0,0 +1,210 @@ +import { + ActionIcon, + AppShell, + Box, + Burger, + Group, + Indicator, + NavLink, + ScrollArea, + Stack, + Text, + TextInput, + rem, +} from '@mantine/core'; +import { useDisclosure } from '@mantine/hooks'; +import { + IconBell, + IconCertificate, + IconFileDescription, + IconFolder, + IconLayoutDashboard, + IconLifebuoy, + IconLogout, + IconPencil, + IconSearch, +} from '@tabler/icons-react'; +import type { Icon } from '@tabler/icons-react'; +import { Outlet, useLocation, useNavigate } from 'react-router-dom'; +import { notify } from '@ema-platform/ui'; +import { LanguageSwitcher } from '../components/LanguageSwitcher'; +import { ColorSchemeToggle } from '../components/ColorSchemeToggle'; +import { BrandMark } from '../features/auth/components/AuthShell'; +import { BrandAvatar } from './components/ui'; + +interface NavItem { + label: string; + icon: Icon; + to?: string; + soon?: boolean; +} + +const NAV_ITEMS: NavItem[] = [ + { to: '/v2/dashboard', label: 'Dashboard', icon: IconLayoutDashboard }, + { to: '/v2/applications', label: 'My Applications', icon: IconFileDescription }, + { to: '/v2/apply', label: 'Apply for License', icon: IconPencil }, + { to: '/licenses', label: 'My Licenses', icon: IconCertificate }, + { label: 'Documents', icon: IconFolder, soon: true }, + { to: '/support', label: 'Support', icon: IconLifebuoy }, +]; + +const PAGE_META: Record = { + '/v2/dashboard': { + title: 'Dashboard', + subtitle: new Date().toLocaleDateString('en-GB', { + weekday: 'long', + day: '2-digit', + month: 'long', + year: 'numeric', + }), + }, + '/v2/applications': { + title: 'My Applications', + subtitle: 'Track the status of every application', + }, + '/v2/apply': { + title: 'Apply for a License', + subtitle: 'New application', + }, +}; + +export function PortalLayoutV2() { + const navigate = useNavigate(); + const location = useLocation(); + const [opened, { toggle, close }] = useDisclosure(); + + const meta = PAGE_META[location.pathname] ?? { + title: 'EMA Portal', + subtitle: 'Ethiopian Maritime Authority', + }; + + const go = (item: NavItem) => { + if (item.soon) { + notify.info(`${item.label} — coming soon.`); + return; + } + if (item.to) { + navigate(item.to); + close(); + } + }; + + return ( + + {/* ---- Topbar ---------------------------------------------------- */} + + + + +
+ + {meta.title} + + + {meta.subtitle} + +
+
+ + + } + placeholder="Search applications..." + /> + + + + + + + + + +
+
+ + {/* ---- Sidebar -------------------------------------------------- */} + + + + +
+ + EMA Portal + + + Ethiopian Maritime Authority + +
+
+
+ + + + {NAV_ITEMS.map((item) => { + const ItemIcon = item.icon; + const active = + !!item.to && + (location.pathname === item.to || + location.pathname.startsWith(`${item.to}/`)); + return ( + } + onClick={() => go(item)} + variant="light" + styles={{ root: { borderRadius: rem(10) }, label: { fontWeight: 500 } }} + /> + ); + })} + + + + + + + + + Abebe Bekele + + + Applicant + + + navigate('/login')} + > + + + + +
+ + +
+ +
+
+
+ ); +} diff --git a/apps/portal/src/app/v2/components/ui.tsx b/apps/portal/src/app/v2/components/ui.tsx new file mode 100644 index 000000000..6051b7832 --- /dev/null +++ b/apps/portal/src/app/v2/components/ui.tsx @@ -0,0 +1,61 @@ +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'; + +/** Pill status badge with a colored dot — matches the design system. */ +export function StatusPill({ status }: { status: ApplicationStatus }) { + const { applicationStatus } = useLicenseLabels(); + const color = APPLICATION_STATUS_COLORS[status]; + return ( + + } + > + {applicationStatus(status)} + + ); +} + +/** Circular avatar filled with the EMA brand gradient. */ +export function BrandAvatar({ + initials = 'AB', + size = 38, +}: { + initials?: string; + size?: number; +}) { + const theme = useMantineTheme(); + return ( + + {initials} + + ); +} diff --git a/apps/portal/src/app/v2/pages/ApplicationsV2.tsx b/apps/portal/src/app/v2/pages/ApplicationsV2.tsx new file mode 100644 index 000000000..57d6e5762 --- /dev/null +++ b/apps/portal/src/app/v2/pages/ApplicationsV2.tsx @@ -0,0 +1,352 @@ +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 '../../features/licenses/hooks/useLicenses'; +import { useLicenseLabels } from '../../features/licenses/hooks/useLicenseLabels'; +import { APPLICATION_PIPELINE } from '../../features/licenses/constants'; +import type { + Application, + ApplicationStatus, + LicenseType, +} from '../../features/licenses/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 ApplicationsV2() { + 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 ( + + {/* ---- Filter tabs ------------------------------------------- */} + + + {TABS.map((tabItem) => { + const active = tab === tabItem.key; + return ( + + ); + })} + + + + + + {/* ---- Applications table ---------------------------------- */} + + + + + + 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])} + + + + + + + + + + ); + })} + +
+
+
+ + {/* ---- Status timeline ------------------------------------- */} + + {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/v2/pages/ApplyV2.tsx b/apps/portal/src/app/v2/pages/ApplyV2.tsx new file mode 100644 index 000000000..6c1b8550d --- /dev/null +++ b/apps/portal/src/app/v2/pages/ApplyV2.tsx @@ -0,0 +1,470 @@ +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 '../../features/licenses/hooks/useLicenses'; +import { useLicenseLabels } from '../../features/licenses/hooks/useLicenseLabels'; +import { + LICENSE_PROCESSING_DAYS, + LICENSE_TYPE_LABELS, + LICENSE_VALIDITY_YEARS, + REQUEST_TYPE_LABELS, + REQUIRED_DOCUMENTS, +} from '../../features/licenses/constants'; +import type { LicenseType, RequestType } from '../../features/licenses/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 ApplyV2() { + 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('/v2/applications'); + }; + + return ( + + + + + + + + + + + + {/* ---- Form card ------------------------------------------- */} + + + {active === 0 && ( + + + } + data={CATEGORIES} + value={category} + onChange={setCategory} + /> + setRequestType((v as RequestType) ?? 'NEW')} + allowDeselect={false} + /> + setSubjectName(e.currentTarget.value)} + /> +