diff --git a/.gitignore b/.gitignore index 8cf1f5936..4cbce0b01 100644 --- a/.gitignore +++ b/.gitignore @@ -1,9 +1,11 @@ # dependencies node_modules/ +.nx # build output **/dist/ .next/ +.nx/ coverage/ *.tsbuildinfo **/*.tsbuildinfo @@ -19,7 +21,13 @@ coverage/ .DS_Store .idea/ .vscode/ +.claude/ .npmrc branch_structure.json temp_auto_push.bat temp_interactive_push.bat +.nx + +apps/backoffice/public/_um/ +apps/backoffice/public/tinymce/ + diff --git a/.gitmodules b/.gitmodules new file mode 100644 index 000000000..850934ff5 --- /dev/null +++ b/.gitmodules @@ -0,0 +1,3 @@ +[submodule "user-management"] + path = user-management + url = git@github.com:Tria-plc/iamui.git diff --git a/apps/backoffice/index.html b/apps/backoffice/index.html index fb1950a11..8d1d8072c 100644 --- a/apps/backoffice/index.html +++ b/apps/backoffice/index.html @@ -3,6 +3,8 @@ + + EMA Backoffice diff --git a/apps/backoffice/public/assets/MainDashboard.png b/apps/backoffice/public/assets/MainDashboard.png new file mode 100644 index 000000000..1ac4fb7ee Binary files /dev/null and b/apps/backoffice/public/assets/MainDashboard.png differ diff --git a/apps/backoffice/public/assets/StatsCard.png b/apps/backoffice/public/assets/StatsCard.png new file mode 100644 index 000000000..ae699996e Binary files /dev/null and b/apps/backoffice/public/assets/StatsCard.png differ diff --git a/apps/backoffice/public/assets/eiarLogo.jpg b/apps/backoffice/public/assets/eiarLogo.jpg new file mode 100644 index 000000000..b35ba3e7b Binary files /dev/null and b/apps/backoffice/public/assets/eiarLogo.jpg differ diff --git a/apps/backoffice/public/assets/ema-logo.png b/apps/backoffice/public/assets/ema-logo.png new file mode 100644 index 000000000..b35ba3e7b Binary files /dev/null and b/apps/backoffice/public/assets/ema-logo.png differ diff --git a/apps/backoffice/public/assets/smart-office-logo.svg b/apps/backoffice/public/assets/smart-office-logo.svg new file mode 100644 index 000000000..398ad472f --- /dev/null +++ b/apps/backoffice/public/assets/smart-office-logo.svg @@ -0,0 +1,11 @@ + + + + + + + + + + + diff --git a/apps/backoffice/public/brand/ema-black.png b/apps/backoffice/public/brand/ema-black.png new file mode 100644 index 000000000..3274ce8fa Binary files /dev/null and b/apps/backoffice/public/brand/ema-black.png differ diff --git a/apps/backoffice/public/brand/ema-white.png b/apps/backoffice/public/brand/ema-white.png new file mode 100644 index 000000000..3274ce8fa Binary files /dev/null and b/apps/backoffice/public/brand/ema-white.png differ diff --git a/apps/backoffice/public/ema-logo.png b/apps/backoffice/public/ema-logo.png new file mode 100644 index 000000000..b35ba3e7b Binary files /dev/null and b/apps/backoffice/public/ema-logo.png differ diff --git a/apps/backoffice/src/app/app.tsx b/apps/backoffice/src/app/app.tsx index 154525112..9f6a7420b 100644 --- a/apps/backoffice/src/app/app.tsx +++ b/apps/backoffice/src/app/app.tsx @@ -1,17 +1,7 @@ -import { useEffect } from 'react'; -import { configureIam } from '@tria-plc/iamui-common'; import { AppProviders } from './providers/AppProviders'; import { AppRouter } from './router'; -const BASE_API_URL = - (import.meta as { env?: Record }).env?.['VITE_BASE_API_URL'] ?? - 'http://localhost:3001/api'; - export function App() { - useEffect(() => { - configureIam({ apiUrl: BASE_API_URL }); - }, []); - return ( diff --git a/apps/backoffice/src/app/features/dashboard/pages/DashboardPage.tsx b/apps/backoffice/src/app/features/dashboard/pages/DashboardPage.tsx index cd3b72ccc..a7c58d48b 100644 --- a/apps/backoffice/src/app/features/dashboard/pages/DashboardPage.tsx +++ b/apps/backoffice/src/app/features/dashboard/pages/DashboardPage.tsx @@ -1,43 +1,445 @@ -import type { ElementType } from 'react'; -import { Grid, Paper, Text, Title, Stack, Group } from '@mantine/core'; -import { IconBox, IconUsers, IconActivity } from '@tabler/icons-react'; +import { useState, type ElementType, type ReactNode } from 'react'; +import { useTranslation } from 'react-i18next'; +import { + Paper, + Text, + Title, + Stack, + Group, + SimpleGrid, + Grid, + ThemeIcon, + Badge, + Avatar, + SegmentedControl, + Button, + Box, + UnstyledButton, + Anchor, +} from '@mantine/core'; +import { + IconUsers, + IconUserCheck, + IconUserPlus, + IconClockHour4, + IconShieldLock, + IconMail, + IconUpload, + IconDownload, + IconClipboardList, + IconArrowUpRight, + IconArrowDownRight, + IconArrowRight, +} from '@tabler/icons-react'; +import { + ResponsiveContainer, + BarChart, + Bar, + XAxis, + CartesianGrid, + Tooltip, + Cell, + PieChart, + Pie, +} from 'recharts'; -interface StatCardProps { - label: string; - value: string; - icon: ElementType; - color: string; +/* ------------------------------------------------------------------ */ +/* sample data — swap for live API results */ +/* ------------------------------------------------------------------ */ + +const REGISTRATIONS = [ + { month: 'Nov', value: 320 }, + { month: 'Dec', value: 410 }, + { month: 'Jan', value: 380 }, + { month: 'Feb', value: 520 }, + { month: 'Mar', value: 470 }, + { month: 'Apr', value: 610 }, + { month: 'May', value: 560 }, + { month: 'Jun', value: 720 }, +]; + +const ROLES = [ + { name: 'Admin', value: 18, color: '#1d4ed8' }, + { name: 'Staff', value: 42, color: '#3b82f6' }, + { name: 'Viewer', value: 30, color: '#60a5fa' }, + { name: 'Guest', value: 10, color: '#93c5fd' }, +]; + +function useKpis(t: (key: string) => string) { + return [ + { label: t('dashboard.kpis.totalUsers'), value: '9,431', icon: IconUsers, color: 'blue', trend: '+12.5%', up: true }, + { label: t('dashboard.kpis.activeUsers'), value: '7,218', icon: IconUserCheck, color: 'indigo', trend: '+8.2%', up: true }, + { label: t('dashboard.kpis.newThisMonth'), value: '642', icon: IconUserPlus, color: 'cyan', trend: '+23.1%', up: true }, + { label: t('dashboard.kpis.pendingApprovals'), value: '37', icon: IconClockHour4, color: 'orange', trend: '-4.0%', up: false }, + ]; } -function StatCard({ label, value, icon: Icon, color }: StatCardProps) { +function useQuickLinks(t: (key: string) => string) { + return [ + { label: t('dashboard.quickLinks.addUser'), desc: t('dashboard.quickLinks.addUserDesc'), icon: IconUserPlus, color: 'blue' }, + { label: t('dashboard.quickLinks.rolesAndPermissions'), desc: t('dashboard.quickLinks.rolesAndPermissionsDesc'), icon: IconShieldLock, color: 'indigo' }, + { label: t('dashboard.quickLinks.inviteMembers'), desc: t('dashboard.quickLinks.inviteMembersDesc'), icon: IconMail, color: 'cyan' }, + { label: t('dashboard.quickLinks.importUsers'), desc: t('dashboard.quickLinks.importUsersDesc'), icon: IconUpload, color: 'violet' }, + { label: t('dashboard.quickLinks.exportData'), desc: t('dashboard.quickLinks.exportDataDesc'), icon: IconDownload, color: 'blue' }, + { label: t('dashboard.quickLinks.auditLog'), desc: t('dashboard.quickLinks.auditLogDesc'), icon: IconClipboardList, color: 'grape' }, + ]; +} + +type UserStatus = 'Active' | 'Pending' | 'Invited'; +const STATUS_COLOR: Record = { + Active: 'teal', + Pending: 'orange', + Invited: 'blue', +}; + +const RECENT_USERS: { + name: string; + email: string; + initials: string; + color: string; + status: UserStatus; +}[] = [ + { name: 'Sara Tesfaye', email: 'sara.t@ema.gov.et', initials: 'ST', color: 'blue', status: 'Active' }, + { name: 'Daniel Bekele', email: 'daniel.b@ema.gov.et', initials: 'DB', color: 'indigo', status: 'Active' }, + { name: 'Hanna Girma', email: 'hanna.g@ema.gov.et', initials: 'HG', color: 'violet', status: 'Pending' }, + { name: 'Yonas Alemu', email: 'yonas.a@ema.gov.et', initials: 'YA', color: 'cyan', status: 'Active' }, + { name: 'Meron Tadesse', email: 'meron.t@ema.gov.et', initials: 'MT', color: 'grape', status: 'Invited' }, +]; + +/* ------------------------------------------------------------------ */ +/* small building blocks */ +/* ------------------------------------------------------------------ */ + +function SectionCard({ children }: { children: ReactNode }) { return ( - - - - - {label} - - {value} - - - + + {children} ); } -export function DashboardPage() { +function CardHeading({ title, subtitle }: { title: string; subtitle?: string }) { return ( - - Dashboard - - - + + + {title} + + {subtitle && ( + + {subtitle} + + )} + + ); +} + +function StatCard({ + label, + value, + icon: Icon, + color, + trend, + up, +}: { + label: string; + value: string; + icon: ElementType; + color: string; + trend: string; + up: boolean; +}) { + return ( + + + + + + : } + > + {trend} + + + + {value} + + + {label} + + + ); +} + +function QuickLinkTile({ + label, + desc, + icon: Icon, + color, +}: { + label: string; + desc: string; + icon: ElementType; + color: string; +}) { + return ( + { + e.currentTarget.style.borderColor = 'var(--mantine-color-blue-3)'; + e.currentTarget.style.boxShadow = 'var(--mantine-shadow-sm)'; + }} + onMouseLeave={(e) => { + e.currentTarget.style.borderColor = 'var(--mantine-color-gray-2)'; + e.currentTarget.style.boxShadow = 'none'; + }} + > + + + + + {label} + + + {desc} + + + ); +} + +/* ------------------------------------------------------------------ */ +/* page */ +/* ------------------------------------------------------------------ */ + +export function DashboardPage() { + const { t } = useTranslation(); + const [range, setRange] = useState('week'); + const kpis = useKpis(t); + const quickLinks = useQuickLinks(t); + + return ( + + {/* header */} + + + {t('dashboard.title')} + + {t('dashboard.subtitle')} + + + + + + + + + {/* KPIs */} + + {kpis.map((k) => ( + + ))} + + + {/* charts */} + + + + + + + + +18.2% + + + {t('dashboard.charts.vsPreviousPeriod')} + + + + + + + + + + + + + + + + + {REGISTRATIONS.map((entry, i) => ( + + ))} + + + + + - - + + + + + + + + + + {ROLES.map((r) => ( + + ))} + + [`${value}%`, name]} + /> + + + + + 9,431 + + + {t('dashboard.charts.totalUsers')} + + + + + {ROLES.map((r) => ( + + + + + {r.name} + + + + {r.value}% + + + ))} + + + - - + + + {/* quick links + recent users */} + + + + + + {quickLinks.map((q) => ( + + ))} + + + + + + + + + {t('dashboard.recentUsers.title')} + + + + {t('common.viewAll')} + + + + + + {RECENT_USERS.map((u, i) => ( + + + + {u.initials} + + + + {u.name} + + + {u.email} + + + + + {u.status} + + + ))} + + diff --git a/apps/backoffice/src/app/features/location/api/location-api.ts b/apps/backoffice/src/app/features/location/api/location-api.ts new file mode 100644 index 000000000..46c2af92d --- /dev/null +++ b/apps/backoffice/src/app/features/location/api/location-api.ts @@ -0,0 +1,68 @@ +import { baseApi } from '@ema-platform/api'; +import type { + Location, + LocationType, + ListResponse, + CreateLocationPayload, + UpdateLocationPayload, + CreateLocationTypePayload, + UpdateLocationTypePayload, +} from '../types/location'; + +const locationApi = baseApi.injectEndpoints({ + endpoints: (builder) => ({ + getLocationTypes: builder.query, void>({ + query: () => '/location-types', + providesTags: ['Api'], + }), + createLocationType: builder.mutation({ + query: (body) => ({ url: '/location-types', method: 'POST', body }), + invalidatesTags: ['Api'], + }), + updateLocationType: builder.mutation({ + query: ({ id, ...body }) => ({ + url: `/location-types/${id}`, + method: 'PUT', + body, + }), + invalidatesTags: ['Api'], + }), + deleteLocationType: builder.mutation({ + query: (id) => ({ url: `/location-types/${id}`, method: 'DELETE' }), + invalidatesTags: ['Api'], + }), + + getLocations: builder.query, { parentId?: string; locationTypeId?: string; take?: number; skip?: number }>({ + query: (params) => ({ url: '/locations', params }), + providesTags: ['Api'], + }), + createLocation: builder.mutation({ + query: (body) => ({ url: '/locations', method: 'POST', body }), + invalidatesTags: ['Api'], + }), + updateLocation: builder.mutation({ + query: ({ id, ...body }) => ({ + url: `/locations/${id}`, + method: 'PUT', + body, + }), + invalidatesTags: ['Api'], + }), + deleteLocation: builder.mutation({ + query: (id) => ({ url: `/locations/${id}`, method: 'DELETE' }), + invalidatesTags: ['Api'], + }), + }), + overrideExisting: false, +}); + +export const { + useGetLocationTypesQuery, + useCreateLocationTypeMutation, + useUpdateLocationTypeMutation, + useDeleteLocationTypeMutation, + useGetLocationsQuery, + useCreateLocationMutation, + useUpdateLocationMutation, + useDeleteLocationMutation, +} = locationApi; diff --git a/apps/backoffice/src/app/features/location/components/LocationDetail.tsx b/apps/backoffice/src/app/features/location/components/LocationDetail.tsx new file mode 100644 index 000000000..f91292497 --- /dev/null +++ b/apps/backoffice/src/app/features/location/components/LocationDetail.tsx @@ -0,0 +1,129 @@ +import { + Paper, + Title, + Text, + Group, + Button, + Stack, + Badge, + Divider, + ActionIcon, + Menu, + rem, +} from '@mantine/core'; +import { + IconEdit, + IconTrash, + IconPlus, + IconDots, +} from '@tabler/icons-react'; +import { useTranslation } from 'react-i18next'; +import type { Location, LocationType } from '../types/location'; + +interface LocationDetailProps { + location: Location; + locationTypes: LocationType[]; + onAddChild: () => void; + onEdit: () => void; + onDelete: () => void; +} + +export function LocationDetail({ + location, + locationTypes, + onAddChild, + onEdit, + onDelete, +}: LocationDetailProps) { + const { t } = useTranslation(); + const typeInfo = locationTypes.find( + (lt) => lt.id === location.locationTypeId, + ); + const isLeaf = + !locationTypes.some( + (lt) => lt.level === (typeInfo?.level ?? 0) + 1, + ); + + return ( + + + {location.names.en} + + + + + + + + + + } + onClick={onEdit} + > + {t('location.edit')} + + } + color="red" + onClick={onDelete} + > + {t('location.delete')} + + + + + + + + + {location.code} + + {typeInfo && ( + + {typeInfo.names.en} + + )} + + + + + +
+ + {t('location.nameEn')} + + {location.names.en} +
+
+ + {t('location.nameAm')} + + {location.names.am} +
+
+ + {t('location.code')} + + {location.code} +
+ {typeInfo && ( +
+ + {t('location.type')} + + {typeInfo.names.en} (Level {typeInfo.level}) +
+ )} +
+
+ ); +} diff --git a/apps/backoffice/src/app/features/location/components/LocationForm.tsx b/apps/backoffice/src/app/features/location/components/LocationForm.tsx new file mode 100644 index 000000000..0cfee89bb --- /dev/null +++ b/apps/backoffice/src/app/features/location/components/LocationForm.tsx @@ -0,0 +1,198 @@ +import { useEffect, useMemo } from 'react'; +import { + TextInput, + Button, + Group, + Stack, + Paper, + Title, + Text, + Badge, + Select, +} from '@mantine/core'; +import { useForm } from '@mantine/form'; +import { useTranslation } from 'react-i18next'; +import { notify } from '@ema-platform/ui'; +import type { Location, LocationType } from '../types/location'; + +interface LocationFormValues { + code: string; + namesEn: string; + namesAm: string; + locationTypeId: string; +} + +interface LocationFormProps { + locationTypes: LocationType[]; + parentLocation?: Location | null; + editingLocation?: Location | null; + onSubmit: (values: { + code: string; + names: { en: string; am: string }; + locationTypeId: string; + parentId?: string | null; + }) => void; + onCancel: () => void; + isSubmitting?: boolean; +} + +export function LocationForm({ + locationTypes, + parentLocation, + editingLocation, + onSubmit, + onCancel, + isSubmitting, +}: LocationFormProps) { + const { t } = useTranslation(); + + const isEditing = !!editingLocation; + + const { type, allAtLevel, typesAvailable } = useMemo(() => { + if (isEditing) { + const lt = locationTypes.find((t) => t.id === editingLocation.locationTypeId); + return { type: lt ?? null, allAtLevel: [lt].filter(Boolean) as LocationType[], typesAvailable: false }; + } + + if (parentLocation) { + const parentLevel = + locationTypes.find((lt) => lt.id === parentLocation.locationTypeId)?.level ?? 0; + const nextLevel = locationTypes + .filter((lt) => lt.level === parentLevel + 1) + .sort((a, b) => a.level - b.level); + return { type: nextLevel[0] ?? null, allAtLevel: nextLevel, typesAvailable: nextLevel.length > 1 }; + } + + const minLevel = Math.min(...locationTypes.map((lt) => lt.level)); + const roots = locationTypes + .filter((lt) => lt.level === minLevel) + .sort((a, b) => a.level - b.level); + return { type: roots[0] ?? null, allAtLevel: roots, typesAvailable: roots.length > 1 }; + }, [locationTypes, parentLocation, editingLocation, isEditing]); + + const form = useForm({ + initialValues: { + code: '', + namesEn: '', + namesAm: '', + locationTypeId: typesAvailable ? '' : (type?.id ?? ''), + }, + validate: { + code: (v) => (!v ? t('location.validation.codeRequired') : null), + namesEn: (v) => (!v ? t('location.validation.nameEnRequired') : null), + namesAm: (v) => (!v ? t('location.validation.nameAmRequired') : null), + locationTypeId: (v) => { + if (typesAvailable && !v) return t('location.validation.typeRequired'); + return null; + }, + }, + }); + + useEffect(() => { + if (editingLocation) { + form.setValues({ + code: editingLocation.code, + namesEn: editingLocation.names.en, + namesAm: editingLocation.names.am, + locationTypeId: editingLocation.locationTypeId, + }); + } + }, [editingLocation]); + + const handleSubmit = form.onSubmit((values) => { + const resolvedTypeId = typesAvailable ? values.locationTypeId : type?.id; + if (!resolvedTypeId) { + notify.error(t('location.noTypesAvailable')); + return; + } + onSubmit({ + code: values.code, + names: { en: values.namesEn, am: values.namesAm }, + locationTypeId: resolvedTypeId, + parentId: editingLocation ? editingLocation.parentId : parentLocation?.id ?? null, + }); + }); + + const title = isEditing + ? t('location.editTitle') + : t('location.addTitle'); + + return ( + + + {title} + + {parentLocation && !isEditing && ( + + )} +
+ + {typesAvailable ? ( + handleSelect(val)} + disabled={isDisabled} + searchable + clearable + size="sm" + style={{ minWidth: 160, flex: 1 }} + nothingFoundMessage={t('location.noOptions')} + required={required && levelIdx === levels.length - 1} + /> + ); + })} + + + {selectedPath && ( + + {selectedChain.map((loc) => { + const typeInfo = typeMap.get(loc.locationTypeId); + return ( + + {typeInfo ? `${typeInfo.names.en}: ` : ''} + {loc.names.en} + + ); + })} + + )} + + ); +} diff --git a/apps/portal/src/app/features/location/types/location.ts b/apps/portal/src/app/features/location/types/location.ts new file mode 100644 index 000000000..aba11d271 --- /dev/null +++ b/apps/portal/src/app/features/location/types/location.ts @@ -0,0 +1,24 @@ +export interface NamePair { + en: string; + am: string; +} + +export interface LocationType { + id: string; + code: string; + names: NamePair; + level: number; +} + +export interface Location { + id: string; + code: string; + names: NamePair; + locationTypeId: string; + parentId: string | null; +} + +export interface ListResponse { + count: number; + items: T[]; +} diff --git a/apps/portal/src/app/features/profile/pages/ProfilePage.module.css b/apps/portal/src/app/features/profile/pages/ProfilePage.module.css new file mode 100644 index 000000000..3c37f92a1 --- /dev/null +++ b/apps/portal/src/app/features/profile/pages/ProfilePage.module.css @@ -0,0 +1,56 @@ +/* Segmented "pill" tab bar — light gray track with a white active pill. */ +.list { + display: inline-flex; + gap: 6px; + padding: 5px; + background: var(--mantine-color-gray-1); + border-radius: var(--mantine-radius-md); + border: none; + flex-wrap: wrap; +} + +.tab { + border: none; + border-radius: 10px; + padding: 9px 18px; + font-weight: 500; + color: var(--mantine-color-gray-7); + background: transparent; + transition: + background-color 120ms ease, + color 120ms ease, + box-shadow 120ms ease; +} + +.tab:hover { + background: transparent; + color: var(--mantine-color-gray-9); +} + +.tab[data-active], +.tab[data-active]:hover { + background: var(--mantine-color-body); + color: var(--mantine-color-emaPrimary-7); + font-weight: 600; + box-shadow: var(--mantine-shadow-xs); +} + +/* Selectable option card (language + appearance). */ +.choice { + border: 1px solid var(--mantine-color-gray-3); + border-radius: var(--mantine-radius-md); + background: var(--mantine-color-body); + transition: + border-color 120ms ease, + background-color 120ms ease; +} + +.choice:hover { + border-color: var(--mantine-color-gray-4); +} + +.choiceActive, +.choiceActive:hover { + border-color: var(--mantine-color-emaPrimary-6); + background: var(--mantine-color-emaPrimary-0); +} diff --git a/apps/portal/src/app/features/profile/pages/ProfilePage.tsx b/apps/portal/src/app/features/profile/pages/ProfilePage.tsx new file mode 100644 index 000000000..2c4493fa9 --- /dev/null +++ b/apps/portal/src/app/features/profile/pages/ProfilePage.tsx @@ -0,0 +1,607 @@ +import { useEffect, useState } from 'react'; +import { + Badge, + Box, + Button, + Divider, + Group, + Paper, + PasswordInput, + SimpleGrid, + Stack, + Switch, + Tabs, + Text, + TextInput, + Title, + UnstyledButton, + useMantineColorScheme, + type MantineColorScheme, +} from '@mantine/core'; +import { + IconAt, + IconBell, + IconCheck, + IconCircle, + IconCircleCheckFilled, + IconDeviceDesktop, + IconDeviceFloppy, + IconLock, + IconMail, + IconMoon, + IconPhone, + IconSettings, + IconShieldLock, + IconSun, + IconUser, + IconUserCircle, +} from '@tabler/icons-react'; +import { useForm } from 'react-hook-form'; +import { zodResolver } from '@hookform/resolvers/zod'; +import { z } from 'zod'; +import { useTranslation } from 'react-i18next'; +import { notify, PageHeader } from '@ema-platform/ui'; +import { useApiMutation } from '@ema-platform/api'; +import { SUPPORTED_LANGUAGES, type AppLanguage } from '../../../i18n/config'; +import { useAppDispatch, useAppSelector } from '../../../store/hooks'; +import { setUser } from '@ema-platform/auth'; +import type { AuthUser } from '@ema-platform/auth'; +import classes from './ProfilePage.module.css'; + +function getInitials(name: string, fallback: string) { + const source = name?.trim() || fallback?.trim() || ''; + if (!source) return '?'; + const parts = source.split(/\s+/); + const letters = parts.length > 1 ? parts[0][0] + parts[1][0] : source.slice(0, 2); + return letters.toUpperCase(); +} + +/** 0–4 rough strength score used by the meter on the security tab. */ +function passwordScore(pw: string) { + if (!pw) return 0; + let score = 0; + if (pw.length >= 8) score++; + if (/[a-z]/.test(pw) && /[A-Z]/.test(pw)) score++; + if (/\d/.test(pw)) score++; + if (/[^A-Za-z0-9]/.test(pw)) score++; + return score; +} + +export function ProfilePage() { + const { t, i18n } = useTranslation(); + const dispatch = useAppDispatch(); + const user = useAppSelector((state) => state.auth.user); + const { colorScheme, setColorScheme } = useMantineColorScheme(); + + const [updateTrigger] = useApiMutation(); + const [meTrigger] = useApiMutation(); + const [passwordTrigger] = useApiMutation(); + + const [isSavingProfile, setIsSavingProfile] = useState(false); + const [isSavingPassword, setIsSavingPassword] = useState(false); + + // UI-only preferences (no backend wiring yet). + const [twoStepEnabled, setTwoStepEnabled] = useState(false); + const [emailNotifications, setEmailNotifications] = useState(true); + + // Load the latest profile from the server on mount so the form always + // reflects the current account information (the cached user may be stale). + useEffect(() => { + let active = true; + meTrigger({ url: '/auth/me', method: 'GET' }) + .unwrap() + .then((me) => { + if (active) dispatch(setUser(me)); + }) + .catch(() => { + /* fall back to the cached user already in the store */ + }); + return () => { + active = false; + }; + // meTrigger/dispatch are stable; run once on mount. + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); + + // ---- Profile form ---- + const profileSchema = z.object({ + nameEn: z.string().min(1, { message: t('profile.validation.nameRequired') }), + nameAm: z.string().min(1, { message: t('profile.validation.nameRequired') }), + username: z + .string() + .min(1, { message: t('profile.validation.usernameRequired') }), + email: z.string().email({ message: t('profile.validation.emailInvalid') }), + phoneNumber: z + .string() + .min(1, { message: t('profile.validation.phoneRequired') }), + }); + type ProfileValues = z.infer; + + const { + register: registerProfile, + handleSubmit: handleProfileSubmit, + reset: resetProfile, + formState: { errors: profileErrors }, + } = useForm({ + resolver: zodResolver(profileSchema), + values: { + nameEn: user?.name?.en ?? '', + nameAm: user?.name?.am ?? '', + username: user?.username ?? '', + email: user?.email ?? '', + phoneNumber: user?.phoneNumber ?? '', + }, + }); + + const onSaveProfile = async (values: ProfileValues) => { + setIsSavingProfile(true); + try { + await updateTrigger({ + url: '/auth/update-profile', + method: 'PATCH', + body: { + email: values.email, + username: values.username, + phoneNumber: values.phoneNumber, + name: { am: values.nameAm, en: values.nameEn }, + }, + }).unwrap(); + + // Refresh the cached user so the rest of the app stays in sync. + const me = await meTrigger({ url: '/auth/me', method: 'GET' }).unwrap(); + dispatch(setUser(me)); + + notify.success(t('profile.profileUpdated')); + } catch { + notify.error(t('profile.updateFailed')); + } finally { + setIsSavingProfile(false); + } + }; + + // ---- Password form ---- + const passwordSchema = z + .object({ + oldPassword: z + .string() + .min(1, { message: t('profile.validation.passwordMin') }), + newPassword: z + .string() + .min(8, { message: t('profile.validation.passwordMin') }), + confirmPassword: z + .string() + .min(8, { message: t('profile.validation.passwordMin') }), + }) + .refine((data) => data.newPassword === data.confirmPassword, { + message: t('profile.validation.passwordMismatch'), + path: ['confirmPassword'], + }); + type PasswordValues = z.infer; + + const { + register: registerPassword, + handleSubmit: handlePasswordSubmit, + reset: resetPassword, + watch: watchPassword, + formState: { errors: passwordErrors }, + } = useForm({ + resolver: zodResolver(passwordSchema), + defaultValues: { oldPassword: '', newPassword: '', confirmPassword: '' }, + }); + + const onChangePassword = async (values: PasswordValues) => { + setIsSavingPassword(true); + try { + await passwordTrigger({ + url: '/auth/change-password', + method: 'PATCH', + body: { + oldPassword: values.oldPassword, + newPassword: values.newPassword, + confirmPassword: values.confirmPassword, + }, + }).unwrap(); + + notify.success(t('profile.passwordChanged')); + resetPassword(); + } catch { + notify.error(t('profile.passwordFailed')); + } finally { + setIsSavingPassword(false); + } + }; + + const displayName = user?.name?.en || user?.username || ''; + const score = passwordScore(watchPassword('newPassword')); + const strengthLabels = [ + '', + t('profile.strength.weak'), + t('profile.strength.fair'), + t('profile.strength.good'), + t('profile.strength.strong'), + ]; + const strengthColors = ['gray', 'red', 'orange', 'emaPrimary', 'emaTeal']; + + const flags: Record = { en: '🇬🇧', am: '🇪🇹' }; + // Mantine uses 'auto' for the system option. + const appearanceOptions: { + value: MantineColorScheme; + label: string; + icon: typeof IconSun; + }[] = [ + { value: 'light', label: t('profile.appearance.light'), icon: IconSun }, + { value: 'dark', label: t('profile.appearance.dark'), icon: IconMoon }, + { value: 'auto', label: t('profile.appearance.system'), icon: IconDeviceDesktop }, + ]; + + return ( + + + + {/* Profile summary */} + + + + + {getInitials(displayName, user?.email ?? '')} + + + +
+ + {displayName || '—'} + + {user?.isPhoneNumberVerified + ? t('profile.verified') + : t('profile.unverified')} + + + + + + {user?.email} + + +
+ + + + {user?.username && ( + } + > + {user.username} + + )} +
+
+ + {/* Tabs */} + + + }> + {t('profile.tabs.profile')} + + }> + {t('profile.tabs.security')} + + }> + {t('profile.tabs.preferences')} + + + + {/* ---- Profile ---- */} + + + + +
+ {t('profile.personal')} + + {t('profile.personalHint')} + + + } + error={profileErrors.nameEn?.message} + {...registerProfile('nameEn')} + /> + } + error={profileErrors.nameAm?.message} + {...registerProfile('nameAm')} + /> + } + error={profileErrors.username?.message} + {...registerProfile('username')} + /> + +
+ + + +
+ + {t('profile.contact')} + + + } + error={profileErrors.email?.message} + {...registerProfile('email')} + /> + } + error={profileErrors.phoneNumber?.message} + {...registerProfile('phoneNumber')} + /> + +
+ + + + + +
+ +
+
+ + {/* ---- Security ---- */} + + +
+ +
+ {t('profile.security')} + + {t('profile.securityHint')} + + + } + error={passwordErrors.oldPassword?.message} + {...registerPassword('oldPassword')} + /> + + } + error={passwordErrors.newPassword?.message} + {...registerPassword('newPassword')} + /> + } + error={passwordErrors.confirmPassword?.message} + {...registerPassword('confirmPassword')} + /> + + + {score > 0 && ( + + + + {t('profile.strength.label')} + + + {strengthLabels[score]} + + + + {[1, 2, 3, 4].map((i) => ( + + ))} + + + )} + +
+ + + + +
+ {t('profile.twoStep.title')} + + {t('profile.twoStep.desc')} + +
+ setTwoStepEnabled(e.currentTarget.checked)} + /> +
+ + + + +
+
+
+
+ + {/* ---- Preferences ---- */} + + + +
+ {t('profile.languageTitle')} + + {t('profile.languageHint')} + + + {SUPPORTED_LANGUAGES.map((lng) => { + const active = i18n.language === lng; + return ( + i18n.changeLanguage(lng)} + className={`${classes.choice} ${active ? classes.choiceActive : ''}`} + p="md" + > + + {flags[lng]} +
+ + {t(`language.${lng}`)} + + + {lng === 'en' ? 'English (United States)' : 'Amharic'} + +
+ {active ? ( + + ) : ( + + )} +
+
+ ); + })} +
+
+ + + +
+ {t('profile.appearance.title')} + + {t('profile.appearance.subtitle')} + + + {appearanceOptions.map(({ value, label, icon: Icon }) => { + const active = colorScheme === value; + return ( + setColorScheme(value)} + className={`${classes.choice} ${active ? classes.choiceActive : ''}`} + p="md" + > + + + + {label} + + {active && ( + + )} + + + ); + })} + +
+ + + + + + +
+ {t('profile.notifications.title')} + + {t('profile.notifications.desc')} + +
+
+ setEmailNotifications(e.currentTarget.checked)} + /> +
+ + + + +
+
+
+
+
+ ); +} diff --git a/apps/portal/src/app/features/seafarer/pages/SeafarerProfilePage.tsx b/apps/portal/src/app/features/seafarer/pages/SeafarerProfilePage.tsx new file mode 100644 index 000000000..f1e7f00f6 --- /dev/null +++ b/apps/portal/src/app/features/seafarer/pages/SeafarerProfilePage.tsx @@ -0,0 +1,701 @@ +import { useEffect, useState } from 'react'; +import { + ActionIcon, + Alert, + Avatar, + Badge, + Box, + Button, + Card, + Divider, + Group, + Modal, + Paper, + Select, + SimpleGrid, + Skeleton, + Stack, + Table, + Tabs, + Text, + TextInput, + Textarea, + ThemeIcon, + Title, + rem, +} from '@mantine/core'; +import { useDisclosure } from '@mantine/hooks'; +import { + IconAnchor, + IconArrowLeft, + IconBook, + IconBriefcase, + IconCertificate, + IconCheck, + IconClock, + IconEdit, + IconFileText, + IconHeartbeat, + IconHistory, + IconLayoutDashboard, + IconPlus, + IconPrinter, + IconShip, + IconUser, + IconX, +} from '@tabler/icons-react'; +import { useNavigate, useParams } from 'react-router-dom'; +import { notify } from '@ema-platform/ui'; +import type { Seafarer } from './SeafarerRegistryPage'; + +// --------------------------------------------------------------------------- +// Extended profile types +// --------------------------------------------------------------------------- +interface TrainingRecord { + id: string; + course: string; + institution: string; + certNo: string; + issueDate: string; + expiry: string; + status: 'Approved' | 'Pending' | 'Expired'; +} + +interface MedicalRecord { + id: string; + examType: string; + issuedBy: string; + issueDate: string; + expiry: string; + result: 'Fit' | 'Unfit' | 'Conditional'; + remarks: string; +} + +interface SeaServiceRecord { + id: string; + vesselName: string; + vesselType: string; + rank: string; + flag: string; + from: string; + to: string; + engagementPort: string; +} + +interface CertificationRecord { + id: string; + name: string; + certNo: string; + issuedBy: string; + issueDate: string; + expiry: string; + type: string; + status: 'Valid' | 'Expired' | 'Pending'; +} + +interface HistoryEntry { + id: string; + action: string; + performedBy: string; + date: string; + notes: string; +} + +interface SeafarerProfile extends Seafarer { + dob: string; + nationalId: string; + passportNo: string; + bookNumber: string; + permanentAddress: string; + training: TrainingRecord[]; + medical: MedicalRecord[]; + seaService: SeaServiceRecord[]; + certifications: CertificationRecord[]; + history: HistoryEntry[]; +} + +// --------------------------------------------------------------------------- +// Dummy API — replace bodies with real fetch calls +// --------------------------------------------------------------------------- +async function fetchSeafarerProfile(id: string): Promise { + await new Promise((r) => setTimeout(r, 800)); + return { + id, + seafarerId: 'SF-2024-0001', + firstName: 'Abebe', + lastName: 'Girma', + email: 'abebe.g@email.com', + gender: 'Male', + nationality: 'Ethiopian', + mobile: '+251 911 234 567', + region: 'Addis Ababa', + registeredAt: '2024-01-10', + medicalStatus: 'Fit', + bookStatus: 'Active', + status: 'Active', + dob: '1988-03-15', + nationalId: 'ET-1234567', + passportNo: 'EP123456', + bookNumber: 'SB-2024-0001', + permanentAddress: 'Bole Sub-City, Woreda 03, House No. 456, Addis Ababa', + training: [ + { id: '1', course: 'Personal Survival Techniques', institution: 'Ethiopian Maritime Institute', certNo: 'PST-2023-0456', issueDate: '2023-01-10', expiry: '2028-01-14', status: 'Approved' }, + { id: '2', course: 'Fire Prevention and Fire Fighting', institution: 'Djibouti Maritime Academy', certNo: 'FFF-2023-0789', issueDate: '2023-03-05', expiry: '2028-03-07', status: 'Approved' }, + { id: '3', course: 'Elementary First Aid', institution: 'Ethiopian Maritime Institute', certNo: 'EFA-2023-0102', issueDate: '2023-01-10', expiry: '2028-01-10', status: 'Approved' }, + ], + medical: [ + { id: '1', examType: 'STCW Medical Certificate', issuedBy: 'EMA Medical Center', issueDate: '2023-06-15', expiry: '2025-06-15', result: 'Fit', remarks: 'No medical conditions noted.' }, + { id: '2', examType: 'Pre-Employment Medical', issuedBy: 'Addis Ababa General Hospital', issueDate: '2022-01-10', expiry: '2024-01-10', result: 'Fit', remarks: 'All tests within normal range.' }, + ], + seaService: [ + { id: '1', vesselName: 'MV Ethiopian Star', vesselType: 'Bulk Carrier', rank: 'Ordinary Seaman', flag: 'Ethiopia', from: '2022-03-01', to: '2023-02-28', engagementPort: 'Djibouti' }, + { id: '2', vesselName: 'MV Red Sea Express', vesselType: 'Container Ship', rank: 'Able Seaman', flag: 'Djibouti', from: '2023-04-01', to: '2024-03-31', engagementPort: 'Berbera' }, + ], + certifications: [ + { id: '1', name: 'STCW Basic Safety Training', certNo: 'BST-2023-0001', issuedBy: 'Ethiopian Maritime Authority', issueDate: '2023-01-15', expiry: '2028-01-15', type: 'STCW', status: 'Valid' }, + { id: '2', name: 'Certificate of Competency — Deck Rating', certNo: 'COC-2023-0234', issuedBy: 'Ethiopian Maritime Authority', issueDate: '2023-07-01', expiry: '2028-07-01', type: 'COC', status: 'Valid' }, + ], + history: [ + { id: '1', action: 'Profile Created', performedBy: 'System', date: '2024-01-10', notes: 'Initial registration submitted.' }, + { id: '2', action: 'Status → Active', performedBy: 'Admin Officer', date: '2024-01-15', notes: 'All documents verified and approved.' }, + { id: '3', action: 'Training Record Added', performedBy: 'Abebe Girma', date: '2024-02-20', notes: 'PST certificate uploaded.' }, + ], + }; +} + +async function updateSeafarerStatus(_id: string, _status: string): Promise { + await new Promise((r) => setTimeout(r, 600)); +} + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- +const STATUS_COLOR: Record = { + Active: 'teal', Pending: 'yellow', Suspended: 'red', + Approved: 'teal', Expired: 'red', Valid: 'teal', + Fit: 'teal', Unfit: 'red', Conditional: 'orange', +}; + +function Chip({ value }: { value: string }) { + return {value}; +} + +function InfoField({ label, value }: { label: string; value: string }) { + return ( +
+ {label} + {value || '—'} +
+ ); +} + +function SectionCard({ title, children, action }: { title: string; children: React.ReactNode; action?: React.ReactNode }) { + return ( + + + {title} + {action} + + + {children} + + ); +} + +// --------------------------------------------------------------------------- +// Tab: Overview +// --------------------------------------------------------------------------- +function OverviewTab({ profile, onStatusChange }: { profile: SeafarerProfile; onStatusChange: (s: 'Active' | 'Suspended') => void }) { + return ( + + + + + + + + + + + + + + + + + + + +
+ Reg. Status + +
+
+ Medical Status + +
+ +
+ Book Status + +
+
+ + + {profile.status !== 'Active' && ( + + )} + {profile.status !== 'Suspended' && ( + + )} + + +
+ + + Permanent Address + {profile.permanentAddress || '—'} + +
+ ); +} + +// --------------------------------------------------------------------------- +// Tab: Training +// --------------------------------------------------------------------------- +function TrainingTab({ records, onAdd }: { records: TrainingRecord[]; onAdd: () => void }) { + return ( + + + Training Records + + + + + + + {['Course', 'Institution', 'Cert. No.', 'Issue Date', 'Expiry', 'Status', 'Actions'].map((h) => ( + {h} + ))} + + + + {records.map((r) => ( + + {r.course} + {r.institution} + {r.certNo} + {r.issueDate} + {r.expiry} + + + + + + ))} + +
+ {records.length === 0 && No training records found.} +
+ ); +} + +// --------------------------------------------------------------------------- +// Tab: Medical +// --------------------------------------------------------------------------- +function MedicalTab({ records, onAdd }: { records: MedicalRecord[]; onAdd: () => void }) { + return ( + + + Medical Records + + + + + + + {['Exam Type', 'Issued By', 'Issue Date', 'Expiry', 'Result', 'Remarks', 'Actions'].map((h) => ( + {h} + ))} + + + + {records.map((r) => ( + + {r.examType} + {r.issuedBy} + {r.issueDate} + {r.expiry} + + {r.remarks} + + + + + ))} + +
+ {records.length === 0 && No medical records found.} +
+ ); +} + +// --------------------------------------------------------------------------- +// Tab: Sea Service +// --------------------------------------------------------------------------- +function SeaServiceTab({ records, onAdd }: { records: SeaServiceRecord[]; onAdd: () => void }) { + return ( + + + Sea Service Records + + + + + + + {['Vessel Name', 'Type', 'Rank', 'Flag', 'From', 'To', 'Engagement Port', 'Actions'].map((h) => ( + {h} + ))} + + + + {records.map((r) => ( + + {r.vesselName} + {r.vesselType} + {r.rank} + {r.flag} + {r.from} + {r.to} + {r.engagementPort} + + + + + ))} + +
+ {records.length === 0 && No sea service records found.} +
+ ); +} + +// --------------------------------------------------------------------------- +// Tab: Certifications +// --------------------------------------------------------------------------- +function CertificationsTab({ records, onAdd }: { records: CertificationRecord[]; onAdd: () => void }) { + return ( + + + Certifications + + + + + + + {['Certificate', 'Cert. No.', 'Type', 'Issued By', 'Issue Date', 'Expiry', 'Status', 'Actions'].map((h) => ( + {h} + ))} + + + + {records.map((r) => ( + + {r.name} + {r.certNo} + {r.type} + {r.issuedBy} + {r.issueDate} + {r.expiry} + + + + + + ))} + +
+ {records.length === 0 && No certifications found.} +
+ ); +} + +// --------------------------------------------------------------------------- +// Tab: History +// --------------------------------------------------------------------------- +function HistoryTab({ entries }: { entries: HistoryEntry[] }) { + return ( + + Activity History + + + {entries.map((e) => ( + + + + +
+ + {e.action} + by {e.performedBy} + + {e.date} + {e.notes && {e.notes}} +
+
+ ))} + {entries.length === 0 && No history found.} +
+
+ ); +} + +// --------------------------------------------------------------------------- +// Add Record Modal (generic) +// --------------------------------------------------------------------------- +function AddTrainingModal({ opened, onClose }: { opened: boolean; onClose: () => void }) { + return ( + + + + + + + + + + + + +