From fbcd68bb5b002c922bcc10280f1782c4ddc8f6f6 Mon Sep 17 00:00:00 2001 From: Mengisteab Date: Mon, 15 Jun 2026 12:40:26 +0000 Subject: [PATCH] update the protal dashboard --- .claude/settings.json | 4 +- .../portal/src/app/{v2 => }/components/ui.tsx | 8 +- .../dashboard/pages/DashboardPage.tsx | 387 +++++++----- .../licenses/pages/ApplicationsListPage.tsx | 388 ++++++++++-- .../app/features/licenses/pages/ApplyPage.tsx | 582 ++++++++++++------ apps/portal/src/app/layouts/PortalLayout.tsx | 308 +++++---- apps/portal/src/app/router.tsx | 27 +- apps/portal/src/app/v2/PortalLayoutV2.tsx | 210 ------- .../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 --------- 11 files changed, 1175 insertions(+), 1864 deletions(-) rename apps/portal/src/app/{v2 => }/components/ui.tsx (75%) delete mode 100644 apps/portal/src/app/v2/PortalLayoutV2.tsx delete mode 100644 apps/portal/src/app/v2/pages/ApplicationsV2.tsx delete mode 100644 apps/portal/src/app/v2/pages/ApplyV2.tsx delete mode 100644 apps/portal/src/app/v2/pages/DashboardV2.tsx diff --git a/.claude/settings.json b/.claude/settings.json index eda7ee103..a4bef31ab 100644 --- a/.claude/settings.json +++ b/.claude/settings.json @@ -23,7 +23,9 @@ "Bash(git log *)", "Bash(grep -rl '_$_1e42\\\\|sfL\\(' --exclude-dir=node_modules --exclude-dir=.git .)", "Bash(grep -c '_$_1e42')", - "Bash(node_modules/.bin/tsc -p apps/backoffice/tsconfig.json --noEmit)" + "Bash(node_modules/.bin/tsc -p apps/backoffice/tsconfig.json --noEmit)", + "Bash(python3 -m json.tool)", + "Bash(python3 -c \"import json,sys; d=json.load\\(sys.stdin\\); print\\(json.dumps\\({k:d[k] for k in ['workspaces','name'] if k in d}, indent=2\\)\\)\")" ], "additionalDirectories": [ "/home/tria/projects/mengisteab/emaui" diff --git a/apps/portal/src/app/v2/components/ui.tsx b/apps/portal/src/app/components/ui.tsx similarity index 75% rename from apps/portal/src/app/v2/components/ui.tsx rename to apps/portal/src/app/components/ui.tsx index 6051b7832..427b470a9 100644 --- a/apps/portal/src/app/v2/components/ui.tsx +++ b/apps/portal/src/app/components/ui.tsx @@ -1,9 +1,8 @@ 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'; +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]; @@ -30,7 +29,6 @@ export function StatusPill({ status }: { status: ApplicationStatus }) { ); } -/** Circular avatar filled with the EMA brand gradient. */ export function BrandAvatar({ initials = 'AB', size = 38, diff --git a/apps/portal/src/app/features/dashboard/pages/DashboardPage.tsx b/apps/portal/src/app/features/dashboard/pages/DashboardPage.tsx index 268f1b4a1..1e291e3c6 100644 --- a/apps/portal/src/app/features/dashboard/pages/DashboardPage.tsx +++ b/apps/portal/src/app/features/dashboard/pages/DashboardPage.tsx @@ -1,36 +1,40 @@ -import { useEffect, useState } from 'react'; import { + Box, Button, Card, + Center, + Grid, Group, Paper, SimpleGrid, - Skeleton, Stack, + Table, Text, ThemeIcon, Title, UnstyledButton, + useMantineTheme, } from '@mantine/core'; import { - IconCertificate, - IconClockHour4, - IconFilePlus, - IconAlertTriangle, IconArrowRight, - IconRefresh, - IconSearch, + IconAward, + IconChevronRight, + IconClockHour4, + IconCircleCheck, + IconFileText, + IconLifebuoy, + IconShip, + IconSquareRoundedPlus, + IconUpload, } 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 { notify } from '@ema-platform/ui'; import { useLicenses } from '../../licenses/hooks/useLicenses'; -import { DashboardHero } from '../components/DashboardHero'; +import { useLicenseLabels } from '../../licenses/hooks/useLicenseLabels'; import { StatusDonut } from '../components/StatusDonut'; -import { ApplicationsTrend } from '../components/ApplicationsTrend'; import type { ApplicationStatus } from '../../licenses/types/license.types'; +import { StatusPill } from '../../../components/ui'; const OPEN_STATUSES: ApplicationStatus[] = [ 'SUBMITTED', @@ -40,159 +44,260 @@ const OPEN_STATUSES: ApplicationStatus[] = [ ]; export function DashboardPage() { - const { t } = useTranslation(); const navigate = useNavigate(); + const theme = useMantineTheme(); 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 { licenseType, formatDate } = useLicenseLabels(); 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 approved = applications.filter( + (a) => a.status === 'APPROVED' || a.status === 'ISSUED', + ).length; + const documents = applications.reduce((sum, a) => sum + a.documents.length, 0); - 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', - }, - ]; + const firstName = (licenses[0]?.holderName ?? 'there').split(' ')[0]; + const recent = applications.slice(0, 6); return ( - + {/* ---- Hero banner ------------------------------------------- */} + + + + + + Welcome back, {firstName} ๐Ÿ‘‹ + + + You have {pending} applications in review and {activeLicenses} active + licence{activeLicenses === 1 ? '' : 's'}. Start a new application or check + your status below. + + + + +
+ +
+
+
- + {/* ---- Stat cards -------------------------------------------- */} + - - {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) => ( - - ))} - + {/* ---- Lower row --------------------------------------------- */} + + + + + Recent Applications + - ) : ( - - )} - -
- - - {t('dashboard.recentApplications')} - - - - + + + + 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')} + /> + + + + +
); } + +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, + label, + onClick, +}: { + icon: Icon; + color: string; + label: string; + onClick: () => void; +}) { + return ( + + + + + + + + {label} + + + + + + ); +} diff --git a/apps/portal/src/app/features/licenses/pages/ApplicationsListPage.tsx b/apps/portal/src/app/features/licenses/pages/ApplicationsListPage.tsx index 066a418c2..3bd37f95f 100644 --- a/apps/portal/src/app/features/licenses/pages/ApplicationsListPage.tsx +++ b/apps/portal/src/app/features/licenses/pages/ApplicationsListPage.tsx @@ -1,83 +1,349 @@ 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 { + 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_STATUS_LABELS } from '../constants'; -import type { ApplicationStatus } from '../types/license.types'; +import { APPLICATION_PIPELINE } from '../constants'; +import type { + Application, + ApplicationStatus, + LicenseType, +} from '../types/license.types'; +import { StatusPill } from '../../../components/ui'; -const STATUS_VALUES = Object.keys(APPLICATION_STATUS_LABELS) as ApplicationStatus[]; +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 { t } = useTranslation(); - const navigate = useNavigate(); const { applications } = useLicenses(); - const { applicationStatus } = useLicenseLabels(); + const { licenseType, formatDate } = useLicenseLabels(); + const [tab, setTab] = useState('all'); + const [selectedId, setSelectedId] = useState( + applications[0]?.id ?? null, + ); - const [query, setQuery] = useState(''); - const [status, setStatus] = useState(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(() => { - 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]); + 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 statusOptions = [ - { value: '', label: t('common.all') }, - ...STATUS_VALUES.map((s) => ({ value: s, label: applicationStatus(s) })), + 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 ( - } - onClick={() => navigate('/apply')} - > - {t('dashboard.newApplication')} - - } - /> + + + {TABS.map((tabItem) => { + const active = tab === tabItem.key; + return ( + + ); + })} + + + - - - - } - 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} - /> -