feat: refactor department to organization and move certifications to configuration tab commit

This commit is contained in:
mengstabketemaw
2026-06-29 15:57:42 +03:00
parent 3493fbbae7
commit c46747c98e
14 changed files with 215 additions and 89 deletions

View File

@@ -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<ListResponse<Department>, void>({
query: () => '/departments',
getOrganizations: builder.query<ListResponse<Organization>, 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,

View File

@@ -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<Profession | null>(null);
@@ -143,9 +144,9 @@ function ProfessionTab() {
const [deleteTarget, setDeleteTarget] = useState<Profession | null>(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() {
<Tabs.Tab value="locations" leftSection={<IconMap size={16} />}>
{t('location.title')}
</Tabs.Tab>
<Tabs.Tab value="certifications" leftSection={<IconCertificate size={16} />}>
Certifications
</Tabs.Tab>
</Tabs.List>
<Tabs.Panel value="professions" pt="md">
@@ -321,6 +325,10 @@ export function ConfigurationPage() {
<Tabs.Panel value="locations" pt="md">
<LocationPage />
</Tabs.Panel>
<Tabs.Panel value="certifications" pt="md">
<CertificationPage />
</Tabs.Panel>
</Tabs>
</Stack>
);

View File

@@ -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;

View File

@@ -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 },

View File

@@ -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: <PaymentConfigPage /> },
{ path: 'seafarer-registry', element: <SeafarerRegistryPage /> },
{ path: 'seaman-book-queue', element: <SeamanBookQueuePage /> },
{ path: 'certifications', element: <CertificationPage /> },
{ path: 'questions', element: <QuestionPage /> },
{ path: 'exams', element: <ExamPage /> },
{ path: 'exams/:id', element: <ExamDetailPage /> },

View File

@@ -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<AuthUser>();
const profile = authStorage.getProfile<CurrentProfile>();
if (token && user) {
return { token, user, isAuthenticated: true };
return { token, user, isAuthenticated: true, currentProfile: profile ?? null };
}
return undefined;
})();

View File

@@ -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<AuthUser>();
@@ -124,8 +126,8 @@ export function ProfilePage() {
return map;
}, [professions]);
// ---- Fetch profile data (for Profile & Address tabs) ----
const [fetchProfile] = useApiMutation<Record<string, any>>();
// ---- Profile data (from stored currentProfile) ----
const [fetchProfile] = useApiMutation<{ total: number; items: CurrentProfile[] }>();
const [updateProfile] = useApiMutation<unknown>();
const [updateAddress] = useApiMutation<unknown>();
@@ -134,51 +136,67 @@ export function ProfilePage() {
const [profileId, setProfileId] = useState<string | null>(null);
const [addressId, setAddressId] = useState<string | null>(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(() => {

View File

@@ -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<AuthUser>();
const profile = authStorage.getProfile<CurrentProfile>();
if (token && user) {
return { token, user, isAuthenticated: true };
return { token, user, isAuthenticated: true, currentProfile: profile ?? null };
}
return undefined;
})();

View File

@@ -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';

View File

@@ -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<string | null>(null);
const [loginTrigger] = useApiMutation<LoginPayload>();
const [meTrigger] = useApiMutation<AuthUser>();
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
}

View File

@@ -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<string | null>(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() {
</Text>
</div>
{serverError && (
<Alert variant="light" color="red" withCloseButton onClose={() => setServerError(null)}>
{serverError}
</Alert>
)}
<form onSubmit={handleSubmit(onSubmit)}>
<Stack gap="md">
<Controller
@@ -127,8 +142,8 @@ export function OTPVerificationPage() {
<Stack gap={6} align="center">
<PinInput
length={CODE_LENGTH}
type="number"
inputMode="numeric"
type="text"
inputMode="text"
oneTimeCode
size="md"
gap="sm"

View File

@@ -1,11 +1,12 @@
import { createSlice, type PayloadAction } from '@reduxjs/toolkit';
import type { AuthState, AuthUser, LoginPayload } from '../types/auth.types';
import type { AuthState, AuthUser, CurrentProfile, LoginPayload } from '../types/auth.types';
import { authStorage } from '../utils/auth-storage';
const initialState: AuthState = {
user: null,
token: null,
isAuthenticated: false,
currentProfile: null,
};
const authSlice = createSlice({
@@ -22,23 +23,36 @@ const authSlice = createSlice({
state.user = action.payload;
authStorage.setUser(action.payload);
},
setCurrentProfile(state, action: PayloadAction<CurrentProfile>) {
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<AuthUser>();
const profile = authStorage.getProfile<CurrentProfile>();
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;

View File

@@ -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;
}

View File

@@ -23,8 +23,17 @@ export const authStorage = {
setUser: <T>(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 = unknown>(): T | null => {
try {
return JSON.parse(localStorage.getItem(key('current-profile')) ?? 'null') as T | null;
} catch {
return null;
}
},
setProfile: <T>(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 =