diff --git a/apps/backoffice/src/app/features/configuration/api/configuration-api.ts b/apps/backoffice/src/app/features/configuration/api/configuration-api.ts index 13676b2a6..678e78cfb 100644 --- a/apps/backoffice/src/app/features/configuration/api/configuration-api.ts +++ b/apps/backoffice/src/app/features/configuration/api/configuration-api.ts @@ -1,6 +1,6 @@ import { baseApi } from '@ema-platform/api'; import type { - Department, + Organization, Profession, ListResponse, CreateProfessionPayload, @@ -9,8 +9,8 @@ import type { const configurationApi = baseApi.injectEndpoints({ endpoints: (builder) => ({ - getDepartments: builder.query, void>({ - query: () => '/departments', + getOrganizations: builder.query, void>({ + query: () => '/organizations', providesTags: ['Api'], }), @@ -35,11 +35,11 @@ const configurationApi = baseApi.injectEndpoints({ invalidatesTags: ['Api'], }), }), - overrideExisting: false, + overrideExisting: true, }); export const { - useGetDepartmentsQuery, + useGetOrganizationsQuery, useGetProfessionsQuery, useCreateProfessionMutation, useUpdateProfessionMutation, diff --git a/apps/backoffice/src/app/features/configuration/pages/ConfigurationPage.tsx b/apps/backoffice/src/app/features/configuration/pages/ConfigurationPage.tsx index 367ef657f..687b5991b 100644 --- a/apps/backoffice/src/app/features/configuration/pages/ConfigurationPage.tsx +++ b/apps/backoffice/src/app/features/configuration/pages/ConfigurationPage.tsx @@ -19,12 +19,13 @@ import { } from '@mantine/core'; import { useForm } from '@mantine/form'; import { useDisclosure } from '@mantine/hooks'; -import { IconEdit, IconTrash, IconPlus, IconBriefcase, IconMap, IconInfoCircle } from '@tabler/icons-react'; +import { IconEdit, IconTrash, IconPlus, IconBriefcase, IconMap, IconCertificate, IconInfoCircle } from '@tabler/icons-react'; import { useTranslation } from 'react-i18next'; import { notify } from '@ema-platform/ui'; import { LocationPage } from '../../location/pages/LocationPage'; +import { CertificationPage } from '../../certification/pages/CertificationPage'; import { - useGetDepartmentsQuery, + useGetOrganizationsQuery, useGetProfessionsQuery, useCreateProfessionMutation, useUpdateProfessionMutation, @@ -129,13 +130,13 @@ function ProfessionForm({ editingProf, deptOptions, isSubmitting, onSubmit, onCa function ProfessionTab() { const { t } = useTranslation(); - const { data: deptRes } = useGetDepartmentsQuery(); + const { data: deptRes } = useGetOrganizationsQuery(); const { data: profRes, isLoading, isError } = useGetProfessionsQuery(); const [createProfession, { isLoading: isCreating }] = useCreateProfessionMutation(); const [updateProfession, { isLoading: isUpdating }] = useUpdateProfessionMutation(); const [deleteProfession] = useDeleteProfessionMutation(); - const departments = deptRes?.items ?? []; + const departments = Array.isArray(deptRes) ? deptRes : (deptRes?.items ?? []); const professions = profRes?.items ?? []; const [editingProf, setEditingProf] = useState(null); @@ -143,9 +144,9 @@ function ProfessionTab() { const [deleteTarget, setDeleteTarget] = useState(null); const [deleteOpened, { open: openDelete, close: closeDelete }] = useDisclosure(false); - const deptOptions = departments.filter((d) => d.isActive).map((d) => ({ + const deptOptions = departments.filter((d) => d?.status?.toLowerCase() === 'active').map((d) => ({ value: d.id, - label: d.name.en, + label: d.name?.en ?? d.name ?? '', })); const resetProfForm = useCallback(() => { @@ -312,6 +313,9 @@ export function ConfigurationPage() { }> {t('location.title')} + }> + Certifications + @@ -321,6 +325,10 @@ export function ConfigurationPage() { + + + + ); diff --git a/apps/backoffice/src/app/features/configuration/types/configuration.ts b/apps/backoffice/src/app/features/configuration/types/configuration.ts index 8ee1fde74..3e1c4af94 100644 --- a/apps/backoffice/src/app/features/configuration/types/configuration.ts +++ b/apps/backoffice/src/app/features/configuration/types/configuration.ts @@ -3,11 +3,15 @@ export interface NamePair { am: string; } -export interface Department { +export interface Organization { id: string; name: NamePair; - description: NamePair; - isActive: boolean; + key: string; + isSuperAdmin: boolean; + isGovernmentOrganization: boolean; + status: string; + parentId: string | null; + organizationTypeId: string; createdAt: string; updatedAt: string; } @@ -15,7 +19,7 @@ export interface Department { export interface Profession { id: string; departmentId: string; - department?: Department; + department?: Organization; name: NamePair; description: NamePair; isActive: boolean; diff --git a/apps/backoffice/src/app/layouts/BackofficeLayout.tsx b/apps/backoffice/src/app/layouts/BackofficeLayout.tsx index 52b57d129..210b2553e 100644 --- a/apps/backoffice/src/app/layouts/BackofficeLayout.tsx +++ b/apps/backoffice/src/app/layouts/BackofficeLayout.tsx @@ -20,7 +20,6 @@ import { IconUser, IconUsers, IconUserShield, - IconCertificate, IconQuestionMark, IconClipboardList, IconReport, @@ -41,7 +40,6 @@ const NAV_ITEMS: NavItem[] = [ { to: '/analytics', label: 'Analytics', icon: IconChartBar }, { to: '/medical-verification', label: 'Medical Verification', icon: IconHeart }, { to: '/locations', label: 'Locations', icon: IconMap }, - { to: '/certifications', label: 'Certifications', icon: IconCertificate }, { to: '/questions', label: 'Questions', icon: IconQuestionMark }, { to: '/exams', label: 'Examinations', icon: IconClipboardList }, { to: '/exam-results', label: 'Exam Results', icon: IconReport }, diff --git a/apps/backoffice/src/app/router/index.tsx b/apps/backoffice/src/app/router/index.tsx index 14c581d3c..b017c7ce9 100644 --- a/apps/backoffice/src/app/router/index.tsx +++ b/apps/backoffice/src/app/router/index.tsx @@ -26,7 +26,6 @@ import { MedicalVerificationPage } from '../features/medical-verification/pages/ import { PaymentConfigPage } from '../features/payment-config/pages/PaymentConfigPage'; import { SeafarerRegistryPage } from '../features/seafarer-registry/pages/SeafarerRegistryPage'; import { SeamanBookQueuePage } from '../features/seaman-book-queue/pages/SeamanBookQueuePage'; -import { CertificationPage } from '../features/certification/pages/CertificationPage'; import { QuestionPage } from '../features/question/pages/QuestionPage'; import { ExamPage } from '../features/exam/pages/ExamPage'; import { ExamDetailPage } from '../features/exam/pages/ExamDetailPage'; @@ -65,7 +64,6 @@ const router = createBrowserRouter([ { path: 'payment-config', element: }, { path: 'seafarer-registry', element: }, { path: 'seaman-book-queue', element: }, - { path: 'certifications', element: }, { path: 'questions', element: }, { path: 'exams', element: }, { path: 'exams/:id', element: }, diff --git a/apps/backoffice/src/app/store/index.ts b/apps/backoffice/src/app/store/index.ts index 141736dea..bb3ca733e 100644 --- a/apps/backoffice/src/app/store/index.ts +++ b/apps/backoffice/src/app/store/index.ts @@ -8,7 +8,7 @@ import { refreshAccessToken, logout, } from '@ema-platform/auth'; -import type { AuthUser } from '@ema-platform/auth'; +import type { AuthUser, CurrentProfile } from '@ema-platform/auth'; import { preferencesReducer } from './preferences.slice'; configureAuthStorage('ema-backoffice'); @@ -16,8 +16,9 @@ configureAuthStorage('ema-backoffice'); const preloadedAuth = (() => { const token = authStorage.getToken(); const user = authStorage.getUser(); + const profile = authStorage.getProfile(); if (token && user) { - return { token, user, isAuthenticated: true }; + return { token, user, isAuthenticated: true, currentProfile: profile ?? null }; } return undefined; })(); diff --git a/apps/portal/src/app/features/profile/pages/ProfilePage.tsx b/apps/portal/src/app/features/profile/pages/ProfilePage.tsx index c4361492a..97ba640ce 100644 --- a/apps/portal/src/app/features/profile/pages/ProfilePage.tsx +++ b/apps/portal/src/app/features/profile/pages/ProfilePage.tsx @@ -45,7 +45,8 @@ import { z } from 'zod'; import { useTranslation } from 'react-i18next'; import { notify, PageHeader } from '@ema-platform/ui'; import { useApiMutation } from '@ema-platform/api'; -import { authStorage, setUser } from '@ema-platform/auth'; +import { authStorage, setUser, setCurrentProfile } from '@ema-platform/auth'; +import type { CurrentProfile } from '@ema-platform/auth'; import { SUPPORTED_LANGUAGES, type AppLanguage } from '../../../i18n/config'; import { useAppDispatch, useAppSelector } from '../../../store/hooks'; import type { AuthUser } from '@ema-platform/auth'; @@ -83,6 +84,7 @@ export function ProfilePage() { const { t, i18n } = useTranslation(); const dispatch = useAppDispatch(); const user = useAppSelector((state) => state.auth.user); + const currentProfile = useAppSelector((state) => state.auth.currentProfile); const { colorScheme, setColorScheme } = useMantineColorScheme(); const [updateTrigger] = useApiMutation(); @@ -124,8 +126,8 @@ export function ProfilePage() { return map; }, [professions]); - // ---- Fetch profile data (for Profile & Address tabs) ---- - const [fetchProfile] = useApiMutation>(); + // ---- Profile data (from stored currentProfile) ---- + const [fetchProfile] = useApiMutation<{ total: number; items: CurrentProfile[] }>(); const [updateProfile] = useApiMutation(); const [updateAddress] = useApiMutation(); @@ -134,51 +136,67 @@ export function ProfilePage() { const [profileId, setProfileId] = useState(null); const [addressId, setAddressId] = useState(null); const [dataLoading, setDataLoading] = useState(true); + const profileFetched = useRef(false); useEffect(() => { - if (!user) return; - fetchProfile({ url: `/profiles/by-user/${user.id}`, method: 'GET' }) - .unwrap() - .then((data) => { - setProfileId(data.id); - setLoadedProfile({ - professionId: data.professionId || data.profession?.id || '', - firstName: data.firstName || '', - middleName: data.middleName || '', - lastName: data.lastName || '', - gender: data.gender || '', - dob: data.dob ? data.dob.split('T')[0] : '', - pob: data.pob || '', - maritalStatus: data.maritalStatus || '', - }); - - if (data.address) { - setAddressId(data.address.id); - setLoadedAddress({ - idType: data.address.idType || '', - idNumber: data.address.idNumber || '', - nationality: data.address.nationality || '', - primaryPhoneNumber: data.address.primaryPhoneNumber || '', - secondaryPhoneNumber: data.address.secondaryPhoneNumber || '', - email: data.address.email || '', - regionId: data.address.regionId || '', - cityId: data.address.cityId || '', - subcityId: data.address.subCityId || '', - woredaId: data.address.woredaId || '', - kebeleId: data.address.kebeleId || '', - streetAddress: data.address.streetAddress || '', - postalAddress: data.address.postalAddress || '', - emergencyContactName: data.address.emergencyContactName || '', - emergencyContactPhone: data.address.emergencyContactPhone || '', - emergencyContactRelation: data.address.emergencycontactRelation || '', - }); - } - setDataLoading(false); - }) - .catch(() => { - setDataLoading(false); + if (currentProfile) { + setProfileId(currentProfile.id); + setLoadedProfile({ + professionId: currentProfile.professionId || currentProfile.profession?.id || '', + firstName: currentProfile.firstName || '', + middleName: currentProfile.middleName || '', + lastName: currentProfile.lastName || '', + gender: currentProfile.gender || '', + dob: currentProfile.dob ? currentProfile.dob.split('T')[0] : '', + pob: currentProfile.pob || '', + maritalStatus: currentProfile.maritalStatus || '', }); - }, [user, fetchProfile]); + + if (currentProfile.address) { + setAddressId(currentProfile.address.id); + setLoadedAddress({ + idType: currentProfile.address.idType || '', + idNumber: currentProfile.address.idNumber || '', + nationality: currentProfile.address.nationality || '', + primaryPhoneNumber: currentProfile.address.primaryPhoneNumber || '', + secondaryPhoneNumber: currentProfile.address.secondaryPhoneNumber || '', + email: currentProfile.address.email || '', + regionId: currentProfile.address.regionId || '', + cityId: currentProfile.address.cityId || '', + subcityId: currentProfile.address.subCityId || '', + woredaId: currentProfile.address.woredaId || '', + kebeleId: currentProfile.address.kebeleId || '', + streetAddress: currentProfile.address.streetAddress || '', + postalAddress: currentProfile.address.postalAddress || '', + emergencyContactName: currentProfile.address.emergencyContactName || '', + emergencyContactPhone: currentProfile.address.emergencyContactPhone || '', + emergencyContactRelation: currentProfile.address.emergencycontactRelation || '', + }); + } + setDataLoading(false); + } else if (user && !profileFetched.current) { + profileFetched.current = true; + const profileId = authStorage.getProfileId(); + if (profileId) { + const q = `w=user_id:=:${user.id}&i=user,address,profession`; + fetchProfile({ url: `/profiles?q=${encodeURIComponent(q)}`, method: 'GET' }) + .unwrap() + .then((result) => { + if (result.total > 0 && result.items.length > 0) { + const profile = result.items[0]; + dispatch(setCurrentProfile(profile)); + } else { + setDataLoading(false); + } + }) + .catch(() => setDataLoading(false)); + } else { + setDataLoading(false); + } + } else { + setDataLoading(false); + } + }, [currentProfile, user, fetchProfile, dispatch]); // Load the latest user from the server on mount useEffect(() => { diff --git a/apps/portal/src/app/store/index.ts b/apps/portal/src/app/store/index.ts index 1dddd6952..a48e46406 100644 --- a/apps/portal/src/app/store/index.ts +++ b/apps/portal/src/app/store/index.ts @@ -8,15 +8,16 @@ import { refreshAccessToken, logout, } from '@ema-platform/auth'; -import type { AuthUser } from '@ema-platform/auth'; +import type { AuthUser, CurrentProfile } from '@ema-platform/auth'; configureAuthStorage('ema-portal'); const preloadedAuth = (() => { const token = authStorage.getToken(); const user = authStorage.getUser(); + const profile = authStorage.getProfile(); if (token && user) { - return { token, user, isAuthenticated: true }; + return { token, user, isAuthenticated: true, currentProfile: profile ?? null }; } return undefined; })(); diff --git a/libs/auth/src/index.ts b/libs/auth/src/index.ts index 35e4d8c44..f1919dd50 100644 --- a/libs/auth/src/index.ts +++ b/libs/auth/src/index.ts @@ -6,8 +6,8 @@ export { LoginPage } from './lib/pages/LoginPage'; export { SignupPage } from './lib/pages/SignupPage'; export { ForgotPasswordPage } from './lib/pages/ForgotPasswordPage'; export { OTPVerificationPage } from './lib/pages/OTPVerificationPage'; -export { authReducer, loginSuccess, setUser, logout, hydrateAuth } from './lib/store/auth.slice'; +export { authReducer, loginSuccess, setUser, setCurrentProfile, clearCurrentProfile, logout, hydrateAuth } from './lib/store/auth.slice'; export { signupReducer, setSignupData, setSignupStep, resetSignup } from './lib/store/signup.slice'; export { configureAuthStorage, authStorage } from './lib/utils/auth-storage'; export { refreshAccessToken } from './lib/utils/refresh-token'; -export type { AuthUser, AuthState, LoginPayload } from './lib/types/auth.types'; +export type { AuthUser, AuthState, LoginPayload, CurrentProfile, CurrentProfileAddress, CurrentProfileProfession } from './lib/types/auth.types'; diff --git a/libs/auth/src/lib/pages/LoginPage.tsx b/libs/auth/src/lib/pages/LoginPage.tsx index b48092608..142c7cdac 100644 --- a/libs/auth/src/lib/pages/LoginPage.tsx +++ b/libs/auth/src/lib/pages/LoginPage.tsx @@ -26,8 +26,8 @@ import { useDispatch } from 'react-redux'; import { useApiMutation } from '@ema-platform/api'; import { notify } from '@ema-platform/ui'; import { AuthShell } from '../components/AuthShell'; -import { loginSuccess, setUser } from '../store/auth.slice'; -import type { LoginPayload, AuthUser } from '../types/auth.types'; +import { loginSuccess, setUser, setCurrentProfile } from '../store/auth.slice'; +import type { LoginPayload, AuthUser, CurrentProfile } from '../types/auth.types'; import { useAuthConfig } from '../AuthConfig'; import { authStorage } from '../utils/auth-storage'; @@ -48,7 +48,7 @@ export function LoginPage() { const [serverError, setServerError] = useState(null); const [loginTrigger] = useApiMutation(); const [meTrigger] = useApiMutation(); - const [profileCheckTrigger] = useApiMutation<{ id: string }>(); + const [profileCheckTrigger] = useApiMutation<{ total: number; items: CurrentProfile[] }>(); const { register, @@ -76,12 +76,17 @@ export function LoginPage() { let hasProfile = false; try { - const profile = await profileCheckTrigger({ - url: `/profiles/by-user/${me.id}`, + const q = `w=user_id:=:${me.id}&i=user,address,profession`; + const result = await profileCheckTrigger({ + url: `/profiles?q=${encodeURIComponent(q)}`, method: 'GET', }).unwrap(); - authStorage.setProfileId(profile.id); - hasProfile = true; + if (result.total > 0 && result.items.length > 0) { + const profile = result.items[0]; + authStorage.setProfileId(profile.id); + dispatch(setCurrentProfile(profile)); + hasProfile = true; + } } catch { // profile not found — redirect to setup } diff --git a/libs/auth/src/lib/pages/OTPVerificationPage.tsx b/libs/auth/src/lib/pages/OTPVerificationPage.tsx index 6c3172355..17f25762e 100644 --- a/libs/auth/src/lib/pages/OTPVerificationPage.tsx +++ b/libs/auth/src/lib/pages/OTPVerificationPage.tsx @@ -1,5 +1,6 @@ import { useEffect, useState } from 'react'; import { + Alert, Anchor, Button, Center, @@ -46,6 +47,7 @@ export function OTPVerificationPage() { const [verifyTrigger, { isLoading: loading }] = useApiMutation(); const [resendTrigger, { isLoading: resending }] = useApiMutation(); const [secondsLeft, setSecondsLeft] = useState(RESEND_SECONDS); + const [serverError, setServerError] = useState(null); const { control, @@ -72,8 +74,11 @@ export function OTPVerificationPage() { notify.success('Phone number verified successfully'); navigate(needsProfile ? '/profile-setup' : loginRedirectPath); - } catch (err) { - const msg = err instanceof Error ? err.message : 'Something went wrong'; + } catch (err: unknown) { + const msg = + (err as { data?: { message?: string } })?.data?.message ?? + (err instanceof Error ? err.message : 'Something went wrong'); + setServerError(msg); notify.error(msg); } }; @@ -82,15 +87,19 @@ export function OTPVerificationPage() { if (secondsLeft > 0 || resending) return; try { await resendTrigger({ - url: '/auth/resend-otp', - method: 'POST', - body: { email }, + url: '/auth/generate-verification-code', + method: 'PATCH', + body: { email, phoneNumber, type: 'verify-phone-number' }, }).unwrap(); notify.success('Verification code resent to your email'); setSecondsLeft(RESEND_SECONDS); - } catch (err) { - const msg = err instanceof Error ? err.message : 'Something went wrong'; + setServerError(null); + } catch (err: unknown) { + const msg = + (err as { data?: { message?: string } })?.data?.message ?? + (err instanceof Error ? err.message : 'Something went wrong'); + setServerError(msg); notify.error(msg); } }; @@ -118,6 +127,12 @@ export function OTPVerificationPage() { + {serverError && ( + setServerError(null)}> + {serverError} + + )} +
) { + state.currentProfile = action.payload; + authStorage.setProfile(action.payload); + }, + clearCurrentProfile(state) { + state.currentProfile = null; + authStorage.removeProfile(); + }, logout(state) { state.user = null; state.token = null; state.isAuthenticated = false; + state.currentProfile = null; authStorage.clear(); }, hydrateAuth(state) { const token = authStorage.getToken(); const user = authStorage.getUser(); + const profile = authStorage.getProfile(); if (token && user) { state.token = token; state.user = user; state.isAuthenticated = true; + if (profile) { + state.currentProfile = profile; + } } }, }, }); -export const { loginSuccess, setUser, logout, hydrateAuth } = authSlice.actions; +export const { loginSuccess, setUser, setCurrentProfile, clearCurrentProfile, logout, hydrateAuth } = authSlice.actions; export const authReducer = authSlice.reducer; diff --git a/libs/auth/src/lib/types/auth.types.ts b/libs/auth/src/lib/types/auth.types.ts index 7163f3d72..9c7aec3ea 100644 --- a/libs/auth/src/lib/types/auth.types.ts +++ b/libs/auth/src/lib/types/auth.types.ts @@ -19,6 +19,7 @@ export interface AuthState { user: AuthUser | null; token: string | null; isAuthenticated: boolean; + currentProfile: CurrentProfile | null; } export interface LoginPayload { @@ -26,3 +27,57 @@ export interface LoginPayload { refreshToken: string; isPhoneNumberVerified: boolean; } + +export interface CurrentProfileAddress { + id: string; + idType: string; + idNumber: string; + nationality: string; + regionId: string | null; + cityId: string | null; + subCityId: string | null; + woredaId: string | null; + kebeleId: string | null; + streetAddress: string | null; + houseNumber: string | null; + primaryPhoneNumber: string; + secondaryPhoneNumber: string | null; + email: string | null; + website: string | null; + postalAddress: string | null; + emergencyContactName: string | null; + emergencyContactPhone: string | null; + emergencycontactRelation: string | null; + isActive: boolean; +} + +export interface CurrentProfileProfession { + id: string; + departmentId: string; + name: { en: string }; + description: { en: string }; + isActive: boolean; +} + +export interface CurrentProfile { + id: string; + userId: string; + professionId: string; + addressId: string; + type: string; + firstName: string; + middleName: string; + lastName: string; + gender: string; + dob: string; + pob: string; + maritalStatus: string; + isComplete: boolean; + user: AuthUser; + address: CurrentProfileAddress; + profession: CurrentProfileProfession; +} + +export interface CurrentProfileState { + profile: CurrentProfile | null; +} diff --git a/libs/auth/src/lib/utils/auth-storage.ts b/libs/auth/src/lib/utils/auth-storage.ts index fa4cb909d..22fc1757f 100644 --- a/libs/auth/src/lib/utils/auth-storage.ts +++ b/libs/auth/src/lib/utils/auth-storage.ts @@ -23,8 +23,17 @@ export const authStorage = { setUser: (u: T) => localStorage.setItem(key('auth-user'), JSON.stringify(u)), getProfileId: () => localStorage.getItem(key('profile-id')) ?? undefined, setProfileId: (id: string) => localStorage.setItem(key('profile-id'), id), + getProfile: (): T | null => { + try { + return JSON.parse(localStorage.getItem(key('current-profile')) ?? 'null') as T | null; + } catch { + return null; + } + }, + setProfile: (p: T) => localStorage.setItem(key('current-profile'), JSON.stringify(p)), + removeProfile: () => localStorage.removeItem(key('current-profile')), clear: () => { - [key('auth-token'), key('refresh-token'), key('auth-user'), key('profile-id')].forEach((k) => + [key('auth-token'), key('refresh-token'), key('auth-user'), key('profile-id'), key('current-profile')].forEach((k) => localStorage.removeItem(k), ); document.cookie =