mirror of
https://github.com/Tria-plc/emaui.git
synced 2026-08-26 19:12:50 +00:00
feat: change usermanagement from git submodule to componenet based
This commit is contained in:
1
.gitignore
vendored
1
.gitignore
vendored
@@ -30,4 +30,5 @@ temp_interactive_push.bat
|
||||
|
||||
apps/backoffice/public/_um/
|
||||
apps/backoffice/public/tinymce/
|
||||
local-packages/iamui-extracted/
|
||||
|
||||
|
||||
3
.gitmodules
vendored
3
.gitmodules
vendored
@@ -1,3 +0,0 @@
|
||||
[submodule "user-management"]
|
||||
path = user-management
|
||||
url = git@github.com:Tria-plc/iamui.git
|
||||
@@ -3,8 +3,6 @@ import type {
|
||||
Department,
|
||||
Profession,
|
||||
ListResponse,
|
||||
CreateDepartmentPayload,
|
||||
UpdateDepartmentPayload,
|
||||
CreateProfessionPayload,
|
||||
UpdateProfessionPayload,
|
||||
} from '../types/configuration';
|
||||
@@ -15,22 +13,6 @@ const configurationApi = baseApi.injectEndpoints({
|
||||
query: () => '/departments',
|
||||
providesTags: ['Api'],
|
||||
}),
|
||||
createDepartment: builder.mutation<Department, CreateDepartmentPayload>({
|
||||
query: (body) => ({ url: '/departments', method: 'POST', body }),
|
||||
invalidatesTags: ['Api'],
|
||||
}),
|
||||
updateDepartment: builder.mutation<Department, UpdateDepartmentPayload>({
|
||||
query: ({ id, ...body }) => ({
|
||||
url: `/departments/${id}`,
|
||||
method: 'PUT',
|
||||
body,
|
||||
}),
|
||||
invalidatesTags: ['Api'],
|
||||
}),
|
||||
deleteDepartment: builder.mutation<void, string>({
|
||||
query: (id) => ({ url: `/departments/${id}`, method: 'DELETE' }),
|
||||
invalidatesTags: ['Api'],
|
||||
}),
|
||||
|
||||
getProfessions: builder.query<ListResponse<Profession>, void>({
|
||||
query: () => '/professions',
|
||||
@@ -58,9 +40,6 @@ const configurationApi = baseApi.injectEndpoints({
|
||||
|
||||
export const {
|
||||
useGetDepartmentsQuery,
|
||||
useCreateDepartmentMutation,
|
||||
useUpdateDepartmentMutation,
|
||||
useDeleteDepartmentMutation,
|
||||
useGetProfessionsQuery,
|
||||
useCreateProfessionMutation,
|
||||
useUpdateProfessionMutation,
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useState } from 'react';
|
||||
import { useState, useEffect, useCallback } from 'react';
|
||||
import {
|
||||
Stack,
|
||||
Title,
|
||||
@@ -12,308 +12,208 @@ import {
|
||||
Modal,
|
||||
Text,
|
||||
Select,
|
||||
Badge,
|
||||
Paper,
|
||||
Loader,
|
||||
Center,
|
||||
Alert,
|
||||
} from '@mantine/core';
|
||||
import { useForm } from '@mantine/form';
|
||||
import { useDisclosure } from '@mantine/hooks';
|
||||
import { IconEdit, IconTrash, IconPlus, IconBuilding, IconBriefcase, IconMap } from '@tabler/icons-react';
|
||||
import { IconEdit, IconTrash, IconPlus, IconBriefcase, IconMap, IconInfoCircle } from '@tabler/icons-react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { notify } from '@ema-platform/ui';
|
||||
import { LocationPage } from '../../location/pages/LocationPage';
|
||||
import type { Department, Profession } from '../types/configuration';
|
||||
import {
|
||||
useGetDepartmentsQuery,
|
||||
useGetProfessionsQuery,
|
||||
useCreateProfessionMutation,
|
||||
useUpdateProfessionMutation,
|
||||
useDeleteProfessionMutation,
|
||||
} from '../api/configuration-api';
|
||||
import type { Profession } from '../types/configuration';
|
||||
|
||||
let nextId = 1;
|
||||
const uid = () => String(nextId++);
|
||||
|
||||
const MOCK_DEPARTMENTS: Department[] = [
|
||||
{ id: uid(), code: 'IT', names: { en: 'Information Technology', am: 'ኢንፎርሜሽን ቴክኖሎጂ' }, description: 'Handles all IT infrastructure and systems.', createdAt: new Date().toISOString(), updatedAt: new Date().toISOString() },
|
||||
{ id: uid(), code: 'HR', names: { en: 'Human Resources', am: 'የሰው ኃይል ሀብት' }, description: 'Manages personnel and recruitment.', createdAt: new Date().toISOString(), updatedAt: new Date().toISOString() },
|
||||
{ id: uid(), code: 'FIN', names: { en: 'Finance', am: 'ፋይናንስ' }, description: 'Oversees budgeting and accounting.', createdAt: new Date().toISOString(), updatedAt: new Date().toISOString() },
|
||||
];
|
||||
|
||||
const MOCK_PROFESSIONS: Profession[] = [
|
||||
{ id: uid(), code: 'SWE', names: { en: 'Software Engineer', am: 'የሶፍትዌር መሐንዲስ' }, description: 'Develops and maintains software applications.', departmentId: '1', createdAt: new Date().toISOString(), updatedAt: new Date().toISOString() },
|
||||
{ id: uid(), code: 'SRE', names: { en: 'Site Reliability Engineer', am: 'የጣቢያ አስተማማኝነት መሐንዲስ' }, description: 'Ensures system reliability and uptime.', departmentId: '1', createdAt: new Date().toISOString(), updatedAt: new Date().toISOString() },
|
||||
{ id: uid(), code: 'HRM', names: { en: 'HR Manager', am: 'የሰው ኃይል አስተዳዳሪ' }, description: 'Leads the HR team and strategy.', departmentId: '2', createdAt: new Date().toISOString(), updatedAt: new Date().toISOString() },
|
||||
];
|
||||
|
||||
function DepartmentTab() {
|
||||
const { t } = useTranslation();
|
||||
const [departments, setDepartments] = useState<Department[]>(MOCK_DEPARTMENTS);
|
||||
const [editingDept, setEditingDept] = useState<Department | null>(null);
|
||||
const [showDeptForm, setShowDeptForm] = useState(false);
|
||||
const [deleteTarget, setDeleteTarget] = useState<Department | null>(null);
|
||||
const [deleteOpened, { open: openDelete, close: closeDelete }] = useDisclosure(false);
|
||||
|
||||
const deptForm = useForm({
|
||||
initialValues: { code: '', nameEn: '', nameAm: '', description: '' },
|
||||
validate: {
|
||||
code: (v) => (!v ? t('configuration.validation.codeRequired') : null),
|
||||
nameEn: (v) => (!v ? t('configuration.validation.nameEnRequired') : null),
|
||||
nameAm: (v) => (!v ? t('configuration.validation.nameAmRequired') : null),
|
||||
},
|
||||
});
|
||||
|
||||
const resetDeptForm = () => {
|
||||
deptForm.reset();
|
||||
setEditingDept(null);
|
||||
setShowDeptForm(false);
|
||||
};
|
||||
|
||||
const handleEditDept = (dept: Department) => {
|
||||
setEditingDept(dept);
|
||||
deptForm.setValues({
|
||||
code: dept.code,
|
||||
nameEn: dept.names.en,
|
||||
nameAm: dept.names.am,
|
||||
description: dept.description,
|
||||
});
|
||||
setShowDeptForm(true);
|
||||
};
|
||||
|
||||
const handleDeleteDept = (dept: Department) => {
|
||||
setDeleteTarget(dept);
|
||||
openDelete();
|
||||
};
|
||||
|
||||
const confirmDeleteDept = () => {
|
||||
if (!deleteTarget) return;
|
||||
setDepartments((prev) => prev.filter((d) => d.id !== deleteTarget.id));
|
||||
notify.success(t('configuration.deleted'));
|
||||
closeDelete();
|
||||
setDeleteTarget(null);
|
||||
};
|
||||
|
||||
const handleDeptSubmit = deptForm.onSubmit((values) => {
|
||||
const now = new Date().toISOString();
|
||||
if (editingDept) {
|
||||
setDepartments((prev) =>
|
||||
prev.map((d) =>
|
||||
d.id === editingDept.id
|
||||
? { ...d, code: values.code, names: { en: values.nameEn, am: values.nameAm }, description: values.description, updatedAt: now }
|
||||
: d
|
||||
)
|
||||
);
|
||||
notify.success(t('configuration.updated'));
|
||||
} else {
|
||||
const newDept: Department = {
|
||||
id: uid(),
|
||||
code: values.code,
|
||||
names: { en: values.nameEn, am: values.nameAm },
|
||||
description: values.description,
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
};
|
||||
setDepartments((prev) => [...prev, newDept]);
|
||||
notify.success(t('configuration.created'));
|
||||
}
|
||||
resetDeptForm();
|
||||
});
|
||||
|
||||
return (
|
||||
<>
|
||||
<Group justify="space-between" mb="md">
|
||||
<Text fw={600} size="sm">{t('configuration.departmentsList')}</Text>
|
||||
{!showDeptForm && (
|
||||
<Button
|
||||
variant="light"
|
||||
leftSection={<IconPlus size={16} />}
|
||||
onClick={() => { resetDeptForm(); setShowDeptForm(true); }}
|
||||
size="sm"
|
||||
>
|
||||
{t('configuration.addDepartment')}
|
||||
</Button>
|
||||
)}
|
||||
</Group>
|
||||
|
||||
{showDeptForm && (
|
||||
<Paper p="md" withBorder mb="md" radius="md">
|
||||
<form onSubmit={handleDeptSubmit}>
|
||||
<Stack gap="sm">
|
||||
<TextInput
|
||||
label={t('configuration.code')}
|
||||
placeholder="e.g., IT, HR"
|
||||
{...deptForm.getInputProps('code')}
|
||||
size="sm"
|
||||
/>
|
||||
<TextInput
|
||||
label={t('configuration.nameEn')}
|
||||
placeholder="English name"
|
||||
{...deptForm.getInputProps('nameEn')}
|
||||
size="sm"
|
||||
/>
|
||||
<TextInput
|
||||
label={t('configuration.nameAm')}
|
||||
placeholder="የአማርኛ ስም"
|
||||
{...deptForm.getInputProps('nameAm')}
|
||||
size="sm"
|
||||
/>
|
||||
<Textarea
|
||||
label={t('configuration.description')}
|
||||
placeholder="Optional description"
|
||||
{...deptForm.getInputProps('description')}
|
||||
size="sm"
|
||||
autosize
|
||||
minRows={2}
|
||||
/>
|
||||
<Group justify="flex-end">
|
||||
<Button variant="default" onClick={resetDeptForm} size="sm">
|
||||
{t('configuration.cancel')}
|
||||
</Button>
|
||||
<Button type="submit" size="sm">
|
||||
{editingDept ? t('configuration.update') : t('configuration.create')}
|
||||
</Button>
|
||||
</Group>
|
||||
</Stack>
|
||||
</form>
|
||||
</Paper>
|
||||
)}
|
||||
|
||||
<Table striped highlightOnHover>
|
||||
<Table.Thead>
|
||||
<Table.Tr>
|
||||
<Table.Th>{t('configuration.code')}</Table.Th>
|
||||
<Table.Th>{t('configuration.nameEn')}</Table.Th>
|
||||
<Table.Th>{t('configuration.nameAm')}</Table.Th>
|
||||
<Table.Th>{t('configuration.description')}</Table.Th>
|
||||
<Table.Th />
|
||||
</Table.Tr>
|
||||
</Table.Thead>
|
||||
<Table.Tbody>
|
||||
{departments.map((dept) => (
|
||||
<Table.Tr key={dept.id}>
|
||||
<Table.Td>
|
||||
<Badge size="sm" variant="light" color="blue">{dept.code}</Badge>
|
||||
</Table.Td>
|
||||
<Table.Td>{dept.names.en}</Table.Td>
|
||||
<Table.Td>{dept.names.am}</Table.Td>
|
||||
<Table.Td>
|
||||
<Text size="sm" lineClamp={2} maw={200}>{dept.description}</Text>
|
||||
</Table.Td>
|
||||
<Table.Td>
|
||||
<Group gap="xs">
|
||||
<ActionIcon variant="subtle" color="blue" size="sm" onClick={() => handleEditDept(dept)}>
|
||||
<IconEdit size={14} />
|
||||
</ActionIcon>
|
||||
<ActionIcon variant="subtle" color="red" size="sm" onClick={() => handleDeleteDept(dept)}>
|
||||
<IconTrash size={14} />
|
||||
</ActionIcon>
|
||||
</Group>
|
||||
</Table.Td>
|
||||
</Table.Tr>
|
||||
))}
|
||||
{departments.length === 0 && (
|
||||
<Table.Tr>
|
||||
<Table.Td colSpan={5}>
|
||||
<Text c="dimmed" ta="center" py="xl">
|
||||
{t('configuration.noDepartments')}
|
||||
</Text>
|
||||
</Table.Td>
|
||||
</Table.Tr>
|
||||
)}
|
||||
</Table.Tbody>
|
||||
</Table>
|
||||
|
||||
<Modal opened={deleteOpened} onClose={closeDelete} title={t('configuration.confirmDelete')} size="sm">
|
||||
<Text mb="md">
|
||||
{t('configuration.deleteConfirmText', { name: deleteTarget?.names.en ?? '' })}
|
||||
</Text>
|
||||
<Group justify="flex-end">
|
||||
<Button variant="default" onClick={closeDelete} size="sm">{t('configuration.cancel')}</Button>
|
||||
<Button color="red" onClick={confirmDeleteDept} size="sm">{t('configuration.delete')}</Button>
|
||||
</Group>
|
||||
</Modal>
|
||||
</>
|
||||
);
|
||||
interface ProfFormValues {
|
||||
nameEn: string;
|
||||
nameAm: string;
|
||||
descEn: string;
|
||||
descAm: string;
|
||||
departmentId: string;
|
||||
}
|
||||
|
||||
function ProfessionTab() {
|
||||
const { t } = useTranslation();
|
||||
const [departments] = useState<Department[]>(MOCK_DEPARTMENTS);
|
||||
const [professions, setProfessions] = useState<Profession[]>(MOCK_PROFESSIONS);
|
||||
const [editingProf, setEditingProf] = useState<Profession | null>(null);
|
||||
const [showProfForm, setShowProfForm] = useState(false);
|
||||
const [deleteTarget, setDeleteTarget] = useState<Profession | null>(null);
|
||||
const [deleteOpened, { open: openDelete, close: closeDelete }] = useDisclosure(false);
|
||||
interface ProfFormProps {
|
||||
editingProf: Profession | null;
|
||||
deptOptions: { value: string; label: string }[];
|
||||
isSubmitting: boolean;
|
||||
onSubmit: (values: ProfFormValues, isEdit: boolean) => void;
|
||||
onCancel: () => void;
|
||||
}
|
||||
|
||||
const profForm = useForm({
|
||||
initialValues: { code: '', nameEn: '', nameAm: '', description: '', departmentId: '' },
|
||||
function ProfessionForm({ editingProf, deptOptions, isSubmitting, onSubmit, onCancel }: ProfFormProps) {
|
||||
const { t } = useTranslation();
|
||||
const form = useForm<ProfFormValues>({
|
||||
initialValues: { nameEn: '', nameAm: '', descEn: '', descAm: '', departmentId: '' },
|
||||
validate: {
|
||||
code: (v) => (!v ? t('configuration.validation.codeRequired') : null),
|
||||
nameEn: (v) => (!v ? t('configuration.validation.nameEnRequired') : null),
|
||||
nameAm: (v) => (!v ? t('configuration.validation.nameAmRequired') : null),
|
||||
departmentId: (v) => (!v ? t('configuration.validation.departmentRequired') : null),
|
||||
},
|
||||
});
|
||||
|
||||
const resetProfForm = () => {
|
||||
profForm.reset();
|
||||
setEditingProf(null);
|
||||
setShowProfForm(false);
|
||||
};
|
||||
|
||||
const handleEditProf = (prof: Profession) => {
|
||||
setEditingProf(prof);
|
||||
profForm.setValues({
|
||||
code: prof.code,
|
||||
nameEn: prof.names.en,
|
||||
nameAm: prof.names.am,
|
||||
description: prof.description,
|
||||
departmentId: prof.departmentId,
|
||||
});
|
||||
setShowProfForm(true);
|
||||
};
|
||||
|
||||
const handleDeleteProf = (prof: Profession) => {
|
||||
setDeleteTarget(prof);
|
||||
openDelete();
|
||||
};
|
||||
|
||||
const confirmDeleteProf = () => {
|
||||
if (!deleteTarget) return;
|
||||
setProfessions((prev) => prev.filter((p) => p.id !== deleteTarget.id));
|
||||
notify.success(t('configuration.deleted'));
|
||||
closeDelete();
|
||||
setDeleteTarget(null);
|
||||
};
|
||||
|
||||
const handleProfSubmit = profForm.onSubmit((values) => {
|
||||
const now = new Date().toISOString();
|
||||
useEffect(() => {
|
||||
if (editingProf) {
|
||||
setProfessions((prev) =>
|
||||
prev.map((p) =>
|
||||
p.id === editingProf.id
|
||||
? { ...p, code: values.code, names: { en: values.nameEn, am: values.nameAm }, description: values.description, departmentId: values.departmentId, updatedAt: now }
|
||||
: p
|
||||
)
|
||||
);
|
||||
notify.success(t('configuration.updated'));
|
||||
} else {
|
||||
const newProf: Profession = {
|
||||
id: uid(),
|
||||
code: values.code,
|
||||
names: { en: values.nameEn, am: values.nameAm },
|
||||
description: values.description,
|
||||
departmentId: values.departmentId,
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
};
|
||||
setProfessions((prev) => [...prev, newProf]);
|
||||
notify.success(t('configuration.created'));
|
||||
form.setValues({
|
||||
nameEn: editingProf.name.en,
|
||||
nameAm: editingProf.name.am,
|
||||
descEn: editingProf.description.en ?? '',
|
||||
descAm: editingProf.description.am ?? '',
|
||||
departmentId: editingProf.departmentId,
|
||||
});
|
||||
}
|
||||
resetProfForm();
|
||||
});
|
||||
}, [editingProf]);
|
||||
|
||||
const deptOptions = departments.map((d) => ({
|
||||
const handleSubmit = form.onSubmit((values) => onSubmit(values, !!editingProf));
|
||||
|
||||
return (
|
||||
<Paper p="md" withBorder mb="md" radius="md">
|
||||
<form onSubmit={handleSubmit}>
|
||||
<Stack gap="sm">
|
||||
<TextInput
|
||||
label={t('configuration.nameEn')}
|
||||
placeholder="English name"
|
||||
{...form.getInputProps('nameEn')}
|
||||
size="sm"
|
||||
/>
|
||||
<TextInput
|
||||
label={t('configuration.nameAm')}
|
||||
placeholder="የአማርኛ ስም"
|
||||
{...form.getInputProps('nameAm')}
|
||||
size="sm"
|
||||
/>
|
||||
<Textarea
|
||||
label={t('configuration.descEn')}
|
||||
placeholder="English description"
|
||||
{...form.getInputProps('descEn')}
|
||||
size="sm"
|
||||
autosize
|
||||
minRows={2}
|
||||
/>
|
||||
<Textarea
|
||||
label={t('configuration.descAm')}
|
||||
placeholder="የአማርኛ መግለጫ"
|
||||
{...form.getInputProps('descAm')}
|
||||
size="sm"
|
||||
autosize
|
||||
minRows={2}
|
||||
/>
|
||||
<Select
|
||||
label={t('configuration.department')}
|
||||
placeholder={t('configuration.selectDepartment')}
|
||||
data={deptOptions}
|
||||
{...form.getInputProps('departmentId')}
|
||||
size="sm"
|
||||
searchable
|
||||
/>
|
||||
<Group justify="flex-end">
|
||||
<Button variant="default" onClick={onCancel} size="sm">
|
||||
{t('configuration.cancel')}
|
||||
</Button>
|
||||
<Button type="submit" size="sm" loading={isSubmitting}>
|
||||
{editingProf ? t('configuration.update') : t('configuration.create')}
|
||||
</Button>
|
||||
</Group>
|
||||
</Stack>
|
||||
</form>
|
||||
</Paper>
|
||||
);
|
||||
}
|
||||
|
||||
function ProfessionTab() {
|
||||
const { t } = useTranslation();
|
||||
const { data: deptRes } = useGetDepartmentsQuery();
|
||||
const { data: profRes, isLoading, isError } = useGetProfessionsQuery();
|
||||
const [createProfession, { isLoading: isCreating }] = useCreateProfessionMutation();
|
||||
const [updateProfession, { isLoading: isUpdating }] = useUpdateProfessionMutation();
|
||||
const [deleteProfession] = useDeleteProfessionMutation();
|
||||
|
||||
const departments = deptRes?.items ?? [];
|
||||
const professions = profRes?.items ?? [];
|
||||
|
||||
const [editingProf, setEditingProf] = useState<Profession | null>(null);
|
||||
const [showProfForm, setShowProfForm] = useState(false);
|
||||
const [deleteTarget, setDeleteTarget] = useState<Profession | null>(null);
|
||||
const [deleteOpened, { open: openDelete, close: closeDelete }] = useDisclosure(false);
|
||||
|
||||
const deptOptions = departments.filter((d) => d.isActive).map((d) => ({
|
||||
value: d.id,
|
||||
label: `${d.code} — ${d.names.en}`,
|
||||
label: d.name.en,
|
||||
}));
|
||||
|
||||
const getDeptName = (deptId: string) => {
|
||||
const resetProfForm = useCallback(() => {
|
||||
setEditingProf(null);
|
||||
setShowProfForm(false);
|
||||
}, []);
|
||||
|
||||
const handleEditProf = useCallback((prof: Profession) => {
|
||||
setEditingProf(prof);
|
||||
setShowProfForm(true);
|
||||
}, []);
|
||||
|
||||
const handleDeleteProf = useCallback((prof: Profession) => {
|
||||
setDeleteTarget(prof);
|
||||
openDelete();
|
||||
}, [openDelete]);
|
||||
|
||||
const confirmDeleteProf = useCallback(async () => {
|
||||
if (!deleteTarget) return;
|
||||
try {
|
||||
await deleteProfession(deleteTarget.id).unwrap();
|
||||
notify.success(t('configuration.deleted'));
|
||||
closeDelete();
|
||||
setDeleteTarget(null);
|
||||
} catch {
|
||||
notify.error(t('configuration.error'));
|
||||
}
|
||||
}, [deleteTarget, deleteProfession, closeDelete, t]);
|
||||
|
||||
const handleProfSubmit = useCallback(async (values: ProfFormValues) => {
|
||||
const name = { en: values.nameEn, am: values.nameAm };
|
||||
const description = { en: values.descEn, am: values.descAm };
|
||||
|
||||
try {
|
||||
if (editingProf) {
|
||||
await updateProfession({
|
||||
id: editingProf.id,
|
||||
name,
|
||||
description,
|
||||
departmentId: values.departmentId,
|
||||
}).unwrap();
|
||||
notify.success(t('configuration.updated'));
|
||||
} else {
|
||||
await createProfession({
|
||||
departmentId: values.departmentId,
|
||||
name,
|
||||
description,
|
||||
}).unwrap();
|
||||
notify.success(t('configuration.created'));
|
||||
}
|
||||
resetProfForm();
|
||||
} catch {
|
||||
notify.error(t('configuration.error'));
|
||||
}
|
||||
}, [editingProf, createProfession, updateProfession, resetProfForm, t]);
|
||||
|
||||
const getDeptName = useCallback((deptId: string) => {
|
||||
const dept = departments.find((d) => d.id === deptId);
|
||||
return dept ? `${dept.code} — ${dept.names.en}` : '-';
|
||||
};
|
||||
return dept ? dept.name.en : '-';
|
||||
}, [departments]);
|
||||
|
||||
if (isLoading) {
|
||||
return <Center py="xl"><Loader /></Center>;
|
||||
}
|
||||
|
||||
if (isError) {
|
||||
return <Alert icon={<IconInfoCircle size={16} />} color="red" title={t('configuration.error')} />;
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
@@ -323,7 +223,7 @@ function ProfessionTab() {
|
||||
<Button
|
||||
variant="light"
|
||||
leftSection={<IconPlus size={16} />}
|
||||
onClick={() => { resetProfForm(); setShowProfForm(true); }}
|
||||
onClick={() => setShowProfForm(true)}
|
||||
size="sm"
|
||||
>
|
||||
{t('configuration.addProfession')}
|
||||
@@ -332,60 +232,18 @@ function ProfessionTab() {
|
||||
</Group>
|
||||
|
||||
{showProfForm && (
|
||||
<Paper p="md" withBorder mb="md" radius="md">
|
||||
<form onSubmit={handleProfSubmit}>
|
||||
<Stack gap="sm">
|
||||
<TextInput
|
||||
label={t('configuration.code')}
|
||||
placeholder="e.g., SWE, HRM"
|
||||
{...profForm.getInputProps('code')}
|
||||
size="sm"
|
||||
/>
|
||||
<TextInput
|
||||
label={t('configuration.nameEn')}
|
||||
placeholder="English name"
|
||||
{...profForm.getInputProps('nameEn')}
|
||||
size="sm"
|
||||
/>
|
||||
<TextInput
|
||||
label={t('configuration.nameAm')}
|
||||
placeholder="የአማርኛ ስም"
|
||||
{...profForm.getInputProps('nameAm')}
|
||||
size="sm"
|
||||
/>
|
||||
<Textarea
|
||||
label={t('configuration.description')}
|
||||
placeholder="Optional description"
|
||||
{...profForm.getInputProps('description')}
|
||||
size="sm"
|
||||
autosize
|
||||
minRows={2}
|
||||
/>
|
||||
<Select
|
||||
label={t('configuration.department')}
|
||||
placeholder={t('configuration.selectDepartment')}
|
||||
data={deptOptions}
|
||||
{...profForm.getInputProps('departmentId')}
|
||||
size="sm"
|
||||
searchable
|
||||
/>
|
||||
<Group justify="flex-end">
|
||||
<Button variant="default" onClick={resetProfForm} size="sm">
|
||||
{t('configuration.cancel')}
|
||||
</Button>
|
||||
<Button type="submit" size="sm">
|
||||
{editingProf ? t('configuration.update') : t('configuration.create')}
|
||||
</Button>
|
||||
</Group>
|
||||
</Stack>
|
||||
</form>
|
||||
</Paper>
|
||||
<ProfessionForm
|
||||
editingProf={editingProf}
|
||||
deptOptions={deptOptions}
|
||||
isSubmitting={isCreating || isUpdating}
|
||||
onSubmit={handleProfSubmit}
|
||||
onCancel={resetProfForm}
|
||||
/>
|
||||
)}
|
||||
|
||||
<Table striped highlightOnHover>
|
||||
<Table.Thead>
|
||||
<Table.Tr>
|
||||
<Table.Th>{t('configuration.code')}</Table.Th>
|
||||
<Table.Th>{t('configuration.nameEn')}</Table.Th>
|
||||
<Table.Th>{t('configuration.nameAm')}</Table.Th>
|
||||
<Table.Th>{t('configuration.description')}</Table.Th>
|
||||
@@ -394,15 +252,12 @@ function ProfessionTab() {
|
||||
</Table.Tr>
|
||||
</Table.Thead>
|
||||
<Table.Tbody>
|
||||
{professions.map((prof) => (
|
||||
{professions.filter((p) => p.isActive).map((prof) => (
|
||||
<Table.Tr key={prof.id}>
|
||||
<Table.Td>{prof.name.en}</Table.Td>
|
||||
<Table.Td>{prof.name.am}</Table.Td>
|
||||
<Table.Td>
|
||||
<Badge size="sm" variant="light" color="teal">{prof.code}</Badge>
|
||||
</Table.Td>
|
||||
<Table.Td>{prof.names.en}</Table.Td>
|
||||
<Table.Td>{prof.names.am}</Table.Td>
|
||||
<Table.Td>
|
||||
<Text size="sm" lineClamp={2} maw={200}>{prof.description}</Text>
|
||||
<Text size="sm" lineClamp={2} maw={200}>{prof.description.en ?? prof.description.am}</Text>
|
||||
</Table.Td>
|
||||
<Table.Td>{getDeptName(prof.departmentId)}</Table.Td>
|
||||
<Table.Td>
|
||||
@@ -419,7 +274,7 @@ function ProfessionTab() {
|
||||
))}
|
||||
{professions.length === 0 && (
|
||||
<Table.Tr>
|
||||
<Table.Td colSpan={6}>
|
||||
<Table.Td colSpan={5}>
|
||||
<Text c="dimmed" ta="center" py="xl">
|
||||
{t('configuration.noProfessions')}
|
||||
</Text>
|
||||
@@ -431,7 +286,7 @@ function ProfessionTab() {
|
||||
|
||||
<Modal opened={deleteOpened} onClose={closeDelete} title={t('configuration.confirmDelete')} size="sm">
|
||||
<Text mb="md">
|
||||
{t('configuration.deleteConfirmText', { name: deleteTarget?.names.en ?? '' })}
|
||||
{t('configuration.deleteConfirmText', { name: deleteTarget?.name?.en ?? '' })}
|
||||
</Text>
|
||||
<Group justify="flex-end">
|
||||
<Button variant="default" onClick={closeDelete} size="sm">{t('configuration.cancel')}</Button>
|
||||
@@ -449,11 +304,8 @@ export function ConfigurationPage() {
|
||||
<Stack gap="lg">
|
||||
<Title order={2}>{t('configuration.title')}</Title>
|
||||
|
||||
<Tabs defaultValue="departments">
|
||||
<Tabs defaultValue="professions">
|
||||
<Tabs.List>
|
||||
<Tabs.Tab value="departments" leftSection={<IconBuilding size={16} />}>
|
||||
{t('configuration.departments')}
|
||||
</Tabs.Tab>
|
||||
<Tabs.Tab value="professions" leftSection={<IconBriefcase size={16} />}>
|
||||
{t('configuration.professions')}
|
||||
</Tabs.Tab>
|
||||
@@ -462,10 +314,6 @@ export function ConfigurationPage() {
|
||||
</Tabs.Tab>
|
||||
</Tabs.List>
|
||||
|
||||
<Tabs.Panel value="departments" pt="md">
|
||||
<DepartmentTab />
|
||||
</Tabs.Panel>
|
||||
|
||||
<Tabs.Panel value="professions" pt="md">
|
||||
<ProfessionTab />
|
||||
</Tabs.Panel>
|
||||
|
||||
@@ -5,20 +5,20 @@ export interface NamePair {
|
||||
|
||||
export interface Department {
|
||||
id: string;
|
||||
code: string;
|
||||
names: NamePair;
|
||||
description: string;
|
||||
name: NamePair;
|
||||
description: NamePair;
|
||||
isActive: boolean;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
export interface Profession {
|
||||
id: string;
|
||||
code: string;
|
||||
names: NamePair;
|
||||
description: string;
|
||||
departmentId: string;
|
||||
department?: Department;
|
||||
name: NamePair;
|
||||
description: NamePair;
|
||||
isActive: boolean;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
}
|
||||
@@ -28,23 +28,16 @@ export interface ListResponse<T> {
|
||||
items: T[];
|
||||
}
|
||||
|
||||
export interface CreateDepartmentPayload {
|
||||
code: string;
|
||||
names: NamePair;
|
||||
description: string;
|
||||
}
|
||||
|
||||
export interface UpdateDepartmentPayload extends CreateDepartmentPayload {
|
||||
id: string;
|
||||
}
|
||||
|
||||
export interface CreateProfessionPayload {
|
||||
code: string;
|
||||
names: NamePair;
|
||||
description: string;
|
||||
departmentId: string;
|
||||
name: NamePair;
|
||||
description: NamePair;
|
||||
}
|
||||
|
||||
export interface UpdateProfessionPayload extends CreateProfessionPayload {
|
||||
export interface UpdateProfessionPayload {
|
||||
id: string;
|
||||
departmentId?: string;
|
||||
name?: NamePair;
|
||||
description?: NamePair;
|
||||
isActive?: boolean;
|
||||
}
|
||||
|
||||
@@ -1,96 +0,0 @@
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import { useNavigate, useLocation } from 'react-router-dom';
|
||||
import { authStorage } from '@ema-platform/auth';
|
||||
|
||||
/**
|
||||
* Same-origin host for the user-management module.
|
||||
*
|
||||
* The host app (React 19 / Mantine 8 / Tailwind 3) embeds the module (React 18 /
|
||||
* Mantine 7 / Tailwind 4) via an iframe so the two never share a React tree,
|
||||
* router, or CSS — the version mismatch is fully isolated by the document
|
||||
* boundary. The module is built into apps/backoffice/public/_um and served by
|
||||
* THIS same server at <origin>/_um/, so there is no second server and no second
|
||||
* port. Override the mount path with VITE_USER_MANAGEMENT_BASE (default /_um).
|
||||
*
|
||||
* SSO: the module and host authenticate against the SAME backend, so the host's
|
||||
* token is valid in the module. The module posts `UM_REQUEST_AUTH`; we reply with
|
||||
* our stored token. Route-sync mirrors the module's internal route into the host
|
||||
* URL (/um/<path>) so a refresh deep-links back to the selected menu.
|
||||
*/
|
||||
|
||||
function readToken(): string | null {
|
||||
const escaped = 'auth-token'.replace(/([.$?*|{}()[\]\\/+^])/g, '\\$1');
|
||||
const match = document.cookie.match(new RegExp('(?:^|; )' + escaped + '=([^;]*)'));
|
||||
return authStorage.getToken() ?? (match ? decodeURIComponent(match[1]) : null);
|
||||
}
|
||||
|
||||
function readRefreshToken(): string | null {
|
||||
return authStorage.getRefreshToken() ?? null;
|
||||
}
|
||||
|
||||
export default function UserManagementHostPage() {
|
||||
const navigate = useNavigate();
|
||||
const location = useLocation();
|
||||
const iframeRef = useRef<HTMLIFrameElement>(null);
|
||||
|
||||
// Same-origin sub-path the module is served from (matches the module's Vite
|
||||
// `base` + the apps/backoffice/public/_um build). Same origin ⇒ no second port.
|
||||
const mountBase = (
|
||||
(import.meta.env.VITE_USER_MANAGEMENT_BASE as string | undefined) ?? '/_um'
|
||||
).replace(/\/$/, '');
|
||||
const moduleOrigin = window.location.origin;
|
||||
|
||||
// Deep-link: the host route is /um/*, so whatever follows /um is the module's
|
||||
// own route. Compute src ONCE (frozen) so later parent-URL updates don't reload.
|
||||
const [iframeSrc] = useState(() => {
|
||||
const sub = location.pathname.replace(/^\/um(?=\/|$)/, '');
|
||||
return mountBase + sub + location.search;
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
const onMessage = (event: MessageEvent) => {
|
||||
if (event.origin !== moduleOrigin) return;
|
||||
const data = event.data as { type?: string; path?: string } | undefined;
|
||||
if (!data) return;
|
||||
|
||||
if (data.type === 'UM_REQUEST_AUTH') {
|
||||
const token = readToken();
|
||||
const refreshToken = readRefreshToken();
|
||||
const target = iframeRef.current?.contentWindow;
|
||||
if (token && target) {
|
||||
target.postMessage({ type: 'UM_AUTH_TOKEN', token, refreshToken }, moduleOrigin);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (data.type === 'UM_ROUTE_CHANGED' && typeof data.path === 'string') {
|
||||
// Allow the module to navigate the host away by using /return/<path>.
|
||||
// Add a nav item with href: "/return/dashboard" in project.theme.ts
|
||||
// navItems to send the user back to the host app.
|
||||
const returnMatch = data.path.match(/^\/return\/(.+)/);
|
||||
if (returnMatch) {
|
||||
navigate('/' + returnMatch[1], { replace: true });
|
||||
return;
|
||||
}
|
||||
const target = '/um' + data.path;
|
||||
if (window.location.pathname + window.location.search !== target) {
|
||||
navigate(target, { replace: true });
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
window.addEventListener('message', onMessage);
|
||||
return () => window.removeEventListener('message', onMessage);
|
||||
}, [moduleOrigin, navigate]);
|
||||
|
||||
return (
|
||||
<div style={{ position: 'fixed', inset: 0 }}>
|
||||
<iframe
|
||||
ref={iframeRef}
|
||||
title="User Management"
|
||||
src={iframeSrc}
|
||||
style={{ width: '100%', height: '100%', border: 0, display: 'block' }}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,134 @@
|
||||
import { useCallback, useEffect, useRef } from 'react';
|
||||
import { createRoot, type Root } from 'react-dom/client';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { UserManagementApp } from '@tria-plc/iamui';
|
||||
import type { DesignConfig, UserManagementSessionOptions } from '@tria-plc/iamui';
|
||||
import '@tria-plc/iamui/style.css';
|
||||
import './um-overrides.css';
|
||||
|
||||
const UM_CONFIG: DesignConfig = {
|
||||
brand: {
|
||||
appName: 'Ethiopian Maritime Licence',
|
||||
logoUrl: '/assets/emaLogo.jpg',
|
||||
},
|
||||
colors: {
|
||||
primary: '#2563eb',
|
||||
sidebar: '#ffffff',
|
||||
background: '#f8fafc',
|
||||
foreground: '#1e293b',
|
||||
border: '#e2e8f0',
|
||||
mutedForeground: '#94a3b8',
|
||||
card: '#ffffff',
|
||||
},
|
||||
typography: {
|
||||
fontFamily: 'Inter, ui-sans-serif, system-ui, sans-serif',
|
||||
},
|
||||
layout: {
|
||||
userManagementView: 'classic',
|
||||
sidebarBrandLabel: 'Ethiopian Maritime Authority',
|
||||
sidebarBrandSublabel: 'User Management',
|
||||
sidebarBackground: '#ffffff',
|
||||
sidebarColor: '#1e293b',
|
||||
sidebarMutedColor: '#94a3b8',
|
||||
sidebarActiveBackground: '#eff6ff',
|
||||
sidebarActiveColor: '#2563eb',
|
||||
sidebarHoverBackground: '#f8fafc',
|
||||
sidebarBorder: '#e2e8f0',
|
||||
sidebarWidth: '280px',
|
||||
sidebarCollapsedWidth: '80px',
|
||||
modalAccentColor: '#2563eb',
|
||||
modalHeaderBackground: '#f8fafc',
|
||||
modalHeaderEditBackground: '#eff6ff',
|
||||
modalIconBackground: '#eff6ff',
|
||||
modalIconColor: '#2563eb',
|
||||
modalTitleColor: '#1e293b',
|
||||
modalFocusColor: '#2563eb',
|
||||
modalSurface: '#ffffff',
|
||||
},
|
||||
};
|
||||
|
||||
const UM_RUNTIME = {
|
||||
basename: '/um',
|
||||
apiUrl: import.meta.env.VITE_BASE_API_URL,
|
||||
};
|
||||
|
||||
const buttonStyle: React.CSSProperties = {
|
||||
position: 'fixed',
|
||||
top: 12,
|
||||
left: 12,
|
||||
zIndex: 9999,
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: 6,
|
||||
padding: '8px 16px',
|
||||
border: '1px solid #e2e8f0',
|
||||
borderRadius: 8,
|
||||
background: '#ffffff',
|
||||
color: '#2563eb',
|
||||
fontSize: 14,
|
||||
fontWeight: 600,
|
||||
cursor: 'pointer',
|
||||
fontFamily: 'Inter, ui-sans-serif, system-ui, sans-serif',
|
||||
boxShadow: '0 1px 3px rgba(0,0,0,0.08)',
|
||||
transition: 'all 150ms ease',
|
||||
};
|
||||
|
||||
export default function UserManagementPage() {
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
const rootRef = useRef<Root | null>(null);
|
||||
const navigate = useNavigate();
|
||||
|
||||
const handleReturn = useCallback(() => {
|
||||
navigate('/dashboard');
|
||||
}, [navigate]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!containerRef.current) return;
|
||||
|
||||
const token = localStorage.getItem('ema-backoffice-auth-token') ?? '';
|
||||
const refreshToken = localStorage.getItem('ema-backoffice-refresh-token') ?? undefined;
|
||||
|
||||
const session: UserManagementSessionOptions = {
|
||||
initialSession: token
|
||||
? { token, refreshToken, rememberMe: true }
|
||||
: null,
|
||||
enableEmbeddedAuthBridge: false,
|
||||
};
|
||||
|
||||
rootRef.current = createRoot(containerRef.current);
|
||||
rootRef.current.render(
|
||||
<UserManagementApp config={UM_CONFIG} runtime={UM_RUNTIME} session={session} />,
|
||||
);
|
||||
|
||||
return () => {
|
||||
if (rootRef.current) {
|
||||
rootRef.current.unmount();
|
||||
rootRef.current = null;
|
||||
}
|
||||
};
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<>
|
||||
<button
|
||||
onClick={handleReturn}
|
||||
style={buttonStyle}
|
||||
onMouseEnter={(e) => {
|
||||
e.currentTarget.style.background = '#f8fafc';
|
||||
e.currentTarget.style.boxShadow = '0 1px 6px rgba(0,0,0,0.12)';
|
||||
}}
|
||||
onMouseLeave={(e) => {
|
||||
e.currentTarget.style.background = '#ffffff';
|
||||
e.currentTarget.style.boxShadow = '0 1px 3px rgba(0,0,0,0.08)';
|
||||
}}
|
||||
>
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||
<path d="m12 19-7-7 7-7" />
|
||||
<path d="M19 12H5" />
|
||||
</svg>
|
||||
Return to EMA
|
||||
</button>
|
||||
<div ref={containerRef} style={{ position: 'fixed', inset: 0 }} />
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
.um-theme-light {
|
||||
--background: #ffffff;
|
||||
--foreground: #1f2937;
|
||||
--card: #ffffff;
|
||||
--card-foreground: #1f2937;
|
||||
--popover: #ffffff;
|
||||
--popover-foreground: #1f2937;
|
||||
--secondary: #f1f5f9;
|
||||
--secondary-foreground: #1e293b;
|
||||
--muted: #f1f5f9;
|
||||
--muted-foreground: #64748b;
|
||||
--accent: var(--primary);
|
||||
--accent-foreground: var(--primary-foreground);
|
||||
--border: #e2e8f0;
|
||||
--input: #e2e8f0;
|
||||
--sidebar: #ffffff;
|
||||
--sidebar-foreground: #1e293b;
|
||||
--sidebar-accent: #f1f5f9;
|
||||
--sidebar-accent-foreground: #1e293b;
|
||||
--sidebar-border: #e2e8f0;
|
||||
--sidebar-ring: var(--primary);
|
||||
}
|
||||
@@ -231,10 +231,10 @@ export const am: Translations = {
|
||||
professionsList: 'ሙያዎች',
|
||||
addDepartment: 'ክፍል ያክሉ',
|
||||
addProfession: 'ሙያ ያክሉ',
|
||||
code: 'ኮድ',
|
||||
nameEn: 'ስም (እንግሊዝኛ)',
|
||||
nameAm: 'ስም (አማርኛ)',
|
||||
description: 'መግለጫ',
|
||||
descEn: 'መግለጫ (እንግሊዝኛ)',
|
||||
descAm: 'መግለጫ (አማርኛ)',
|
||||
department: 'ክፍል',
|
||||
selectDepartment: 'ክፍል ይምረጡ',
|
||||
cancel: 'ሰርዝ',
|
||||
@@ -251,7 +251,6 @@ export const am: Translations = {
|
||||
noDepartments: 'ገና ምንም ክፍሎች አልተገለጹም',
|
||||
noProfessions: 'ገና ምንም ሙያዎች አልተገለጹም',
|
||||
validation: {
|
||||
codeRequired: 'ኮድ ያስፈልጋል',
|
||||
nameEnRequired: 'የእንግሊዝኛ ስም ያስፈልጋል',
|
||||
nameAmRequired: 'የአማርኛ ስም ያስፈልጋል',
|
||||
departmentRequired: 'ክፍል ያስፈልጋል',
|
||||
|
||||
@@ -230,10 +230,10 @@ export const en = {
|
||||
professionsList: 'Professions',
|
||||
addDepartment: 'Add Department',
|
||||
addProfession: 'Add Profession',
|
||||
code: 'Code',
|
||||
nameEn: 'Name (English)',
|
||||
nameAm: 'Name (Amharic)',
|
||||
description: 'Description',
|
||||
descEn: 'Description (English)',
|
||||
descAm: 'Description (Amharic)',
|
||||
department: 'Department',
|
||||
selectDepartment: 'Select department',
|
||||
cancel: 'Cancel',
|
||||
@@ -250,7 +250,6 @@ export const en = {
|
||||
noDepartments: 'No departments defined yet',
|
||||
noProfessions: 'No professions defined yet',
|
||||
validation: {
|
||||
codeRequired: 'Code is required',
|
||||
nameEnRequired: 'English name is required',
|
||||
nameAmRequired: 'Amharic name is required',
|
||||
departmentRequired: 'Department is required',
|
||||
|
||||
@@ -19,6 +19,7 @@ import {
|
||||
IconSettings,
|
||||
IconUser,
|
||||
IconUsers,
|
||||
IconUserShield,
|
||||
} from '@tabler/icons-react';
|
||||
import { notify } from '@ema-platform/ui';
|
||||
import { SUPPORTED_LANGUAGES } from '../i18n/config';
|
||||
@@ -26,7 +27,7 @@ import { useAppDispatch, useAppSelector } from '../store/hooks';
|
||||
|
||||
const NAV_ITEMS: NavItem[] = [
|
||||
{ to: '/dashboard', label: 'Dashboard', icon: IconLayoutDashboard },
|
||||
{ to: '/um/user-management/dashboard', label: 'User Management', icon: IconUsers },
|
||||
{ to: '/um/user-management/dashboard', label: 'User Management', icon: IconUserShield },
|
||||
{ to: '/seaman-book-queue', label: 'Seaman Book Queue', icon: IconBook2 },
|
||||
{ to: '/coc-queue', label: 'CoC / CoP Queue', icon: IconShieldCheck },
|
||||
{ to: '/endorsement-queue', label: 'Endorsement Queue', icon: IconRubberStamp },
|
||||
|
||||
@@ -12,7 +12,7 @@ import { AuthLayout } from '../layouts/AuthLayout';
|
||||
import { BackofficeLayout } from '../layouts/BackofficeLayout';
|
||||
import { ProtectedRoute } from './ProtectedRoute';
|
||||
import { DashboardPage } from '../features/dashboard/pages/DashboardPage';
|
||||
import UserManagementHostPage from '../features/user-management-host/UserManagementHostPage';
|
||||
import UserManagementPage from '../features/user-management/UserManagementPage';
|
||||
import { ProfilePage } from '../features/profile/pages/ProfilePage';
|
||||
import { ConfigurationPage } from '../features/configuration/pages/ConfigurationPage';
|
||||
import { LocationPage } from '../features/location/pages/LocationPage';
|
||||
@@ -36,7 +36,9 @@ const router = createBrowserRouter([
|
||||
{ path: '/otp-verify', element: <OTPVerificationPage /> },
|
||||
],
|
||||
},
|
||||
{ path: '/um/*', element: <UserManagementHostPage /> },
|
||||
{ path: '/um/*', element: <UserManagementPage /> },
|
||||
{ path: '/', element: <Navigate to="/dashboard" replace /> },
|
||||
{ path: '/profile-setup', element: <Navigate to="/dashboard" replace /> },
|
||||
{
|
||||
element: <ProtectedRoute />,
|
||||
children: [
|
||||
|
||||
@@ -2,20 +2,6 @@ import { defineConfig } from 'vite';
|
||||
import react from '@vitejs/plugin-react';
|
||||
import { nxViteTsPaths } from '@nx/vite/plugins/nx-tsconfig-paths.plugin';
|
||||
|
||||
function userManagementSpaFallback() {
|
||||
const rewrite = (req) => {
|
||||
const url = req.url || '';
|
||||
if (!url.startsWith('/_um/') && url !== '/_um') return;
|
||||
if (/\.[a-zA-Z0-9]+$/.test(url.split('?')[0])) return; // real assets pass through
|
||||
req.url = '/_um/index.html';
|
||||
};
|
||||
return {
|
||||
name: 'user-management-spa-fallback',
|
||||
configureServer(s) { s.middlewares.use((req, _res, next) => { rewrite(req); next(); }); },
|
||||
configurePreviewServer(s) { s.middlewares.use((req, _res, next) => { rewrite(req); next(); }); },
|
||||
};
|
||||
}
|
||||
|
||||
export default defineConfig({
|
||||
root: __dirname,
|
||||
cacheDir: '../../node_modules/.vite/apps/backoffice',
|
||||
@@ -24,7 +10,7 @@ export default defineConfig({
|
||||
host: 'localhost',
|
||||
},
|
||||
preview: { port: 4201, host: 'localhost' },
|
||||
plugins: [react(), nxViteTsPaths(), userManagementSpaFallback()],
|
||||
plugins: [react(), nxViteTsPaths()],
|
||||
resolve: {
|
||||
dedupe: ['react', 'react-dom', 'react-router-dom', '@tanstack/react-query'],
|
||||
},
|
||||
|
||||
@@ -1,10 +1,7 @@
|
||||
import { RouterProvider } from 'react-router-dom';
|
||||
import { configureIam } from '@tria-plc/iamui-common';
|
||||
import { AppProviders } from './providers/AppProviders';
|
||||
import { router } from './router';
|
||||
|
||||
// IAM module configuration (used by the isolated /users admin route).
|
||||
configureIam({ apiUrl: 'http://localhost:3001/api' });
|
||||
|
||||
export function App() {
|
||||
return (
|
||||
|
||||
@@ -30,7 +30,6 @@ import {
|
||||
IconShieldCheck,
|
||||
} from '@tabler/icons-react';
|
||||
import { authStorage } from '@ema-platform/auth';
|
||||
import { useAppSelector } from '../../../store/hooks';
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Mock data
|
||||
@@ -77,7 +76,7 @@ async function generateCertificate(profileId: string): Promise<Blob> {
|
||||
const token = authStorage.getToken();
|
||||
if (!token) throw new Error('No auth token found');
|
||||
const res = await fetch(
|
||||
`${API_BASE}/profiles/generate-seafarer-certificate/e04c4a7c-0feb-4af6-ab00-5ef6600ee2b4`,
|
||||
`${API_BASE}/profiles/generate-seafarer-certificate/${profileId}`,
|
||||
{ headers: { Authorization: `Bearer ${token}` } },
|
||||
);
|
||||
if (!res.ok) throw new Error(`Failed to generate certificate (${res.status})`);
|
||||
@@ -95,8 +94,7 @@ function downloadBlob(blob: Blob, filename: string) {
|
||||
|
||||
export function CertificatesPage() {
|
||||
const navigate = useNavigate();
|
||||
const user = useAppSelector((state) => state.auth.user);
|
||||
const profileId = user?.id ?? '';
|
||||
const profileId = authStorage.getProfileId() ?? '';
|
||||
const [previewUrl, setPreviewUrl] = useState<string | null>(null);
|
||||
const [previewTitle, setPreviewTitle] = useState('');
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
@@ -0,0 +1,564 @@
|
||||
import { useEffect, useMemo, useRef, useState } from 'react';
|
||||
import {
|
||||
Box,
|
||||
Button,
|
||||
Center,
|
||||
Group,
|
||||
Loader,
|
||||
Paper,
|
||||
Select,
|
||||
SimpleGrid,
|
||||
Stack,
|
||||
Text,
|
||||
TextInput,
|
||||
Title,
|
||||
rem,
|
||||
} from '@mantine/core';
|
||||
import {
|
||||
IconArrowLeft,
|
||||
IconArrowRight,
|
||||
IconCheck,
|
||||
IconCircleCheck,
|
||||
IconMapPin,
|
||||
IconUser,
|
||||
} from '@tabler/icons-react';
|
||||
import { useForm } from 'react-hook-form';
|
||||
import { zodResolver } from '@hookform/resolvers/zod';
|
||||
import { z } from 'zod';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { useApiMutation } from '@ema-platform/api';
|
||||
import { notify } from '@ema-platform/ui';
|
||||
import { authStorage, setUser } from '@ema-platform/auth';
|
||||
import { useAppDispatch, useAppSelector } from '../../../store/hooks';
|
||||
|
||||
const GENDERS = ['MALE', 'FEMALE'];
|
||||
const MARITAL_STATUSES = ['SINGLE', 'MARRIED', 'DIVORCED', 'WIDOWED'];
|
||||
const ID_TYPES = ['NID', 'VITAL', 'PASSPORT', 'DRIVERS_LICENSE'];
|
||||
|
||||
const STEPS = [
|
||||
{ label: 'Profile', icon: IconUser },
|
||||
{ label: 'Address', icon: IconMapPin },
|
||||
];
|
||||
|
||||
const profileSchema = z.object({
|
||||
professionId: z.string().min(1, 'Select your profession'),
|
||||
firstName: z.string().min(3, 'First name must be at least 3 characters'),
|
||||
middleName: z.string().min(3, 'Middle name must be at least 3 characters'),
|
||||
lastName: z.string().min(3, 'Last name must be at least 3 characters'),
|
||||
gender: z.string().min(1, 'Select your gender'),
|
||||
dob: z.string().min(1, 'Select your date of birth'),
|
||||
pob: z.string().optional(),
|
||||
maritalStatus: z.string().min(1, 'Select your marital status'),
|
||||
});
|
||||
|
||||
type ProfileValues = z.infer<typeof profileSchema>;
|
||||
|
||||
const addressSchema = z.object({
|
||||
idType: z.string().min(1, 'Select ID type'),
|
||||
idNumber: z.string().min(1, 'Enter ID number'),
|
||||
nationality: z.string().min(1, 'Enter nationality'),
|
||||
primaryPhoneNumber: z.string().min(1, 'Enter primary phone number'),
|
||||
secondaryPhoneNumber: z.string().optional(),
|
||||
email: z.string().email('Invalid email').optional().or(z.literal('')),
|
||||
regionId: z.string().optional(),
|
||||
cityId: z.string().optional(),
|
||||
subcityId: z.string().optional(),
|
||||
woredaId: z.string().optional(),
|
||||
kebeleId: z.string().optional(),
|
||||
streetAddress: z.string().optional(),
|
||||
postalAddress: z.string().optional(),
|
||||
emergencyContactName: z.string().optional(),
|
||||
emergencyContactPhone: z.string().optional(),
|
||||
emergencyContactRelation: z.string().optional(),
|
||||
});
|
||||
|
||||
type AddressValues = z.infer<typeof addressSchema>;
|
||||
|
||||
function StepIndicator({ active, completed }: { active: number; completed: number[] }) {
|
||||
return (
|
||||
<Box mb={32}>
|
||||
<Group gap={0} align="center" wrap="nowrap">
|
||||
{STEPS.map((step, i) => {
|
||||
const isDone = completed.includes(i);
|
||||
const isCurrent = active === i;
|
||||
return (
|
||||
<Group key={i} gap={0} align="center" style={{ flex: i < STEPS.length - 1 ? 1 : 'none' }}>
|
||||
<Stack gap={4} align="center" style={{ minWidth: rem(40) }}>
|
||||
<Box
|
||||
style={{
|
||||
width: rem(40),
|
||||
height: rem(40),
|
||||
borderRadius: '50%',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
backgroundColor: isDone
|
||||
? 'var(--mantine-color-blue-8)'
|
||||
: isCurrent
|
||||
? 'var(--mantine-color-blue-7)'
|
||||
: 'var(--mantine-color-gray-1)',
|
||||
border: isCurrent ? '2.5px solid var(--mantine-color-blue-5)' : '2px solid transparent',
|
||||
boxShadow: isCurrent || isDone ? '0 2px 8px rgba(34, 139, 230, 0.2)' : 'none',
|
||||
flexShrink: 0,
|
||||
transition: 'all 0.2s ease',
|
||||
}}
|
||||
>
|
||||
{isDone ? (
|
||||
<IconCheck size={18} color="white" stroke={2.5} />
|
||||
) : (
|
||||
<Text fw={700} fz="sm" c={isCurrent ? 'white' : 'gray.5'}>
|
||||
{i + 1}
|
||||
</Text>
|
||||
)}
|
||||
</Box>
|
||||
<Text
|
||||
fz="xs"
|
||||
fw={isCurrent ? 700 : 400}
|
||||
c={isCurrent ? 'blue.7' : 'dimmed'}
|
||||
style={{ whiteSpace: 'nowrap' }}
|
||||
>
|
||||
{step.label}
|
||||
</Text>
|
||||
</Stack>
|
||||
|
||||
{i < STEPS.length - 1 && (
|
||||
<Box
|
||||
style={{
|
||||
flex: 1,
|
||||
height: rem(2),
|
||||
backgroundColor: isDone
|
||||
? 'var(--mantine-color-blue-8)'
|
||||
: 'var(--mantine-color-gray-2)',
|
||||
marginBottom: rem(22),
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</Group>
|
||||
);
|
||||
})}
|
||||
</Group>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
function inferUserType(professionName: string): string {
|
||||
const name = professionName.toLowerCase();
|
||||
if (name.includes('seafarer')) return 'SEAFARER';
|
||||
return 'EMPLOYEE';
|
||||
}
|
||||
|
||||
export function ProfileSetupPage() {
|
||||
const navigate = useNavigate();
|
||||
const dispatch = useAppDispatch();
|
||||
const user = useAppSelector((state) => state.auth.user);
|
||||
const [active, setActive] = useState(0);
|
||||
const [completed, setCompleted] = useState<number[]>([]);
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
const [professions, setProfessions] = useState<Array<{ id: string; name: { en: string } }>>([]);
|
||||
const [professionsLoading, setProfessionsLoading] = useState(true);
|
||||
const [profileTrigger] = useApiMutation<{ id: string }>();
|
||||
const [addressTrigger] = useApiMutation<unknown>();
|
||||
const [meTrigger] = useApiMutation<{ id: string }>();
|
||||
const [fetchProfessions] = useApiMutation<{ count: number; items: Array<{ id: string; name: { en: string } }> }>();
|
||||
const fetched = useRef(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (fetched.current) return;
|
||||
fetched.current = true;
|
||||
fetchProfessions({ url: '/professions?take=100', method: 'GET' })
|
||||
.unwrap()
|
||||
.then((data) => setProfessions(data.items ?? []))
|
||||
.catch(() => setProfessions([]))
|
||||
.finally(() => setProfessionsLoading(false));
|
||||
}, [fetchProfessions]);
|
||||
|
||||
const professionOptions = useMemo(
|
||||
() => professions.map((p) => ({ value: p.id, label: p.name.en })),
|
||||
[professions],
|
||||
);
|
||||
|
||||
const professionNameMap = useMemo(() => {
|
||||
const map: Record<string, string> = {};
|
||||
professions.forEach((p) => {
|
||||
map[p.id] = p.name.en;
|
||||
});
|
||||
return map;
|
||||
}, [professions]);
|
||||
|
||||
const {
|
||||
register: profileRegister,
|
||||
handleSubmit: profileHandleSubmit,
|
||||
formState: { errors: profileErrors },
|
||||
setValue: profileSetValue,
|
||||
watch: profileWatch,
|
||||
trigger: profileTriggerValidation,
|
||||
} = useForm<ProfileValues>({
|
||||
resolver: zodResolver(profileSchema),
|
||||
defaultValues: {
|
||||
professionId: '',
|
||||
firstName: '',
|
||||
middleName: '',
|
||||
lastName: '',
|
||||
gender: '',
|
||||
dob: '',
|
||||
pob: '',
|
||||
maritalStatus: '',
|
||||
},
|
||||
});
|
||||
|
||||
const {
|
||||
register: addressRegister,
|
||||
handleSubmit: addressHandleSubmit,
|
||||
formState: { errors: addressErrors },
|
||||
setValue: addressSetValue,
|
||||
watch: addressWatch,
|
||||
trigger: addressTriggerValidation,
|
||||
} = useForm<AddressValues>({
|
||||
resolver: zodResolver(addressSchema),
|
||||
defaultValues: {
|
||||
idType: '',
|
||||
idNumber: '',
|
||||
nationality: '',
|
||||
primaryPhoneNumber: '',
|
||||
secondaryPhoneNumber: '',
|
||||
email: '',
|
||||
regionId: '',
|
||||
cityId: '',
|
||||
subcityId: '',
|
||||
woredaId: '',
|
||||
kebeleId: '',
|
||||
streetAddress: '',
|
||||
postalAddress: '',
|
||||
emergencyContactName: '',
|
||||
emergencyContactPhone: '',
|
||||
emergencyContactRelation: '',
|
||||
},
|
||||
});
|
||||
|
||||
const onNext = async () => {
|
||||
const valid = await profileTriggerValidation();
|
||||
if (!valid) return;
|
||||
setCompleted((prev) => (prev.includes(active) ? prev : [...prev, active]));
|
||||
setActive((c) => c + 1);
|
||||
};
|
||||
|
||||
const onSubmitAddress = async () => {
|
||||
const valid = await addressTriggerValidation();
|
||||
if (!valid) return;
|
||||
|
||||
setSubmitting(true);
|
||||
try {
|
||||
const pv = profileWatch();
|
||||
const av = addressWatch();
|
||||
const selectedProfessionName = professionNameMap[pv.professionId] ?? '';
|
||||
|
||||
const profileResult = await profileTrigger({
|
||||
url: '/profiles',
|
||||
method: 'POST',
|
||||
body: {
|
||||
userId: user?.id,
|
||||
type: inferUserType(selectedProfessionName),
|
||||
professionId: pv.professionId,
|
||||
firstName: pv.firstName,
|
||||
middleName: pv.middleName,
|
||||
lastName: pv.lastName,
|
||||
gender: pv.gender,
|
||||
dob: pv.dob,
|
||||
pob: pv.pob || undefined,
|
||||
maritalStatus: pv.maritalStatus,
|
||||
},
|
||||
}).unwrap();
|
||||
authStorage.setProfileId(profileResult.id);
|
||||
|
||||
await addressTrigger({
|
||||
url: `/addresses/profile/${profileResult.id}`,
|
||||
method: 'POST',
|
||||
body: {
|
||||
idType: av.idType,
|
||||
idNumber: av.idNumber,
|
||||
nationality: av.nationality,
|
||||
primaryPhoneNumber: av.primaryPhoneNumber,
|
||||
secondaryPhoneNumber: av.secondaryPhoneNumber || undefined,
|
||||
email: av.email || undefined,
|
||||
regionId: av.regionId || undefined,
|
||||
cityId: av.cityId || undefined,
|
||||
subcityId: av.subcityId || undefined,
|
||||
woredaId: av.woredaId || undefined,
|
||||
kebeleId: av.kebeleId || undefined,
|
||||
streetAddress: av.streetAddress || undefined,
|
||||
postalAddess: av.postalAddress || undefined,
|
||||
emergencyContactName: av.emergencyContactName || undefined,
|
||||
emergencyContactPhone: av.emergencyContactPhone || undefined,
|
||||
emergencyContactRelation: av.emergencyContactRelation || undefined,
|
||||
},
|
||||
}).unwrap();
|
||||
|
||||
const me = await meTrigger({ url: '/auth/me', method: 'GET' }).unwrap();
|
||||
dispatch(setUser(me));
|
||||
|
||||
notify.success('Profile setup complete!');
|
||||
navigate('/dashboard');
|
||||
} catch {
|
||||
notify.error('Failed to save profile. Please try again.');
|
||||
} finally {
|
||||
setSubmitting(false);
|
||||
}
|
||||
};
|
||||
|
||||
if (!user) {
|
||||
return (
|
||||
<Center mih="100vh">
|
||||
<Text c="dimmed">Please log in first.</Text>
|
||||
</Center>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Center mih="100vh" bg="gray.0">
|
||||
<Paper withBorder radius="lg" p="xl" maw={900} w="100%" mx="md">
|
||||
<Stack gap="md">
|
||||
<div>
|
||||
<Title order={3}>Complete Your Profile</Title>
|
||||
<Text fz="sm" c="dimmed">
|
||||
Set up your profile and address to get started
|
||||
</Text>
|
||||
</div>
|
||||
|
||||
<StepIndicator active={active} completed={completed} />
|
||||
|
||||
{active === 0 && (
|
||||
<>
|
||||
<Text fw={600} fz="sm" tt="uppercase" c="gray.6" mb="sm">
|
||||
Personal Information
|
||||
</Text>
|
||||
<SimpleGrid cols={{ base: 1, sm: 2, lg: 3 }} spacing="md">
|
||||
<Select
|
||||
label="Profession"
|
||||
placeholder={professionsLoading ? 'Loading...' : 'Select'}
|
||||
required
|
||||
data={professionOptions}
|
||||
error={profileErrors.professionId?.message}
|
||||
value={profileWatch('professionId')}
|
||||
onChange={(val) => profileSetValue('professionId', val || '', { shouldValidate: true })}
|
||||
onBlur={() => profileTriggerValidation('professionId')}
|
||||
name="professionId"
|
||||
searchable
|
||||
disabled={professionsLoading}
|
||||
rightSection={professionsLoading ? <Loader size="xs" /> : undefined}
|
||||
/>
|
||||
<TextInput
|
||||
label="First Name"
|
||||
placeholder="Enter first name"
|
||||
required
|
||||
{...profileRegister('firstName')}
|
||||
error={profileErrors.firstName?.message}
|
||||
/>
|
||||
<TextInput
|
||||
label="Middle Name"
|
||||
placeholder="Enter middle name"
|
||||
required
|
||||
{...profileRegister('middleName')}
|
||||
error={profileErrors.middleName?.message}
|
||||
/>
|
||||
<TextInput
|
||||
label="Last Name"
|
||||
placeholder="Enter last name"
|
||||
required
|
||||
{...profileRegister('lastName')}
|
||||
error={profileErrors.lastName?.message}
|
||||
/>
|
||||
<Select
|
||||
label="Gender"
|
||||
placeholder="Select"
|
||||
required
|
||||
data={GENDERS}
|
||||
error={profileErrors.gender?.message}
|
||||
value={profileWatch('gender')}
|
||||
onChange={(val) => profileSetValue('gender', val || '', { shouldValidate: true })}
|
||||
onBlur={() => profileTriggerValidation('gender')}
|
||||
name="gender"
|
||||
/>
|
||||
<TextInput
|
||||
label="Date of Birth"
|
||||
type="date"
|
||||
required
|
||||
{...profileRegister('dob')}
|
||||
error={profileErrors.dob?.message}
|
||||
/>
|
||||
<TextInput
|
||||
label="Place of Birth"
|
||||
placeholder="City, Region"
|
||||
{...profileRegister('pob')}
|
||||
error={profileErrors.pob?.message}
|
||||
/>
|
||||
<Select
|
||||
label="Marital Status"
|
||||
placeholder="Select"
|
||||
required
|
||||
data={MARITAL_STATUSES}
|
||||
error={profileErrors.maritalStatus?.message}
|
||||
value={profileWatch('maritalStatus')}
|
||||
onChange={(val) => profileSetValue('maritalStatus', val || '', { shouldValidate: true })}
|
||||
onBlur={() => profileTriggerValidation('maritalStatus')}
|
||||
name="maritalStatus"
|
||||
/>
|
||||
</SimpleGrid>
|
||||
</>
|
||||
)}
|
||||
|
||||
{active === 1 && (
|
||||
<>
|
||||
<Text fw={600} fz="sm" tt="uppercase" c="gray.6" mb="sm">
|
||||
Identity & Contact
|
||||
</Text>
|
||||
<SimpleGrid cols={{ base: 1, sm: 2, lg: 3 }} spacing="md">
|
||||
<Select
|
||||
label="ID Type"
|
||||
placeholder="Select"
|
||||
required
|
||||
data={ID_TYPES}
|
||||
error={addressErrors.idType?.message}
|
||||
value={addressWatch('idType')}
|
||||
onChange={(val) => addressSetValue('idType', val || '', { shouldValidate: true })}
|
||||
onBlur={() => addressTriggerValidation('idType')}
|
||||
name="idType"
|
||||
/>
|
||||
<TextInput
|
||||
label="ID Number"
|
||||
placeholder="Enter ID number"
|
||||
required
|
||||
{...addressRegister('idNumber')}
|
||||
error={addressErrors.idNumber?.message}
|
||||
/>
|
||||
<TextInput
|
||||
label="Nationality"
|
||||
placeholder="e.g. Ethiopian"
|
||||
required
|
||||
{...addressRegister('nationality')}
|
||||
error={addressErrors.nationality?.message}
|
||||
/>
|
||||
<TextInput
|
||||
label="Primary Phone"
|
||||
placeholder="+251 9XX XXX XXX"
|
||||
required
|
||||
{...addressRegister('primaryPhoneNumber')}
|
||||
error={addressErrors.primaryPhoneNumber?.message}
|
||||
/>
|
||||
<TextInput
|
||||
label="Secondary Phone"
|
||||
placeholder="+251 9XX XXX XXX"
|
||||
{...addressRegister('secondaryPhoneNumber')}
|
||||
error={addressErrors.secondaryPhoneNumber?.message}
|
||||
/>
|
||||
<TextInput
|
||||
label="Email"
|
||||
type="email"
|
||||
placeholder="email@example.com"
|
||||
{...addressRegister('email')}
|
||||
error={addressErrors.email?.message}
|
||||
/>
|
||||
</SimpleGrid>
|
||||
|
||||
<Text fw={600} fz="sm" tt="uppercase" c="gray.6" mt="lg" mb="sm">
|
||||
Address
|
||||
</Text>
|
||||
<SimpleGrid cols={{ base: 1, sm: 2, lg: 3 }} spacing="md">
|
||||
<TextInput
|
||||
label="Region ID"
|
||||
placeholder="Region UUID (optional)"
|
||||
{...addressRegister('regionId')}
|
||||
error={addressErrors.regionId?.message}
|
||||
/>
|
||||
<TextInput
|
||||
label="City ID"
|
||||
placeholder="City UUID (optional)"
|
||||
{...addressRegister('cityId')}
|
||||
error={addressErrors.cityId?.message}
|
||||
/>
|
||||
<TextInput
|
||||
label="Subcity ID"
|
||||
placeholder="Subcity UUID (optional)"
|
||||
{...addressRegister('subcityId')}
|
||||
error={addressErrors.subcityId?.message}
|
||||
/>
|
||||
<TextInput
|
||||
label="Woreda ID"
|
||||
placeholder="Woreda UUID (optional)"
|
||||
{...addressRegister('woredaId')}
|
||||
error={addressErrors.woredaId?.message}
|
||||
/>
|
||||
<TextInput
|
||||
label="Kebele ID"
|
||||
placeholder="Kebele UUID (optional)"
|
||||
{...addressRegister('kebeleId')}
|
||||
error={addressErrors.kebeleId?.message}
|
||||
/>
|
||||
<TextInput
|
||||
label="Street Address"
|
||||
placeholder="Street name, house number"
|
||||
{...addressRegister('streetAddress')}
|
||||
error={addressErrors.streetAddress?.message}
|
||||
/>
|
||||
</SimpleGrid>
|
||||
|
||||
<Text fw={600} fz="sm" tt="uppercase" c="gray.6" mt="lg" mb="sm">
|
||||
Emergency Contact
|
||||
</Text>
|
||||
<SimpleGrid cols={{ base: 1, sm: 2, lg: 3 }} spacing="md">
|
||||
<TextInput
|
||||
label="Contact Name"
|
||||
placeholder="Full name"
|
||||
{...addressRegister('emergencyContactName')}
|
||||
error={addressErrors.emergencyContactName?.message}
|
||||
/>
|
||||
<TextInput
|
||||
label="Contact Phone"
|
||||
placeholder="+251 9XX XXX XXX"
|
||||
{...addressRegister('emergencyContactPhone')}
|
||||
error={addressErrors.emergencyContactPhone?.message}
|
||||
/>
|
||||
<TextInput
|
||||
label="Relationship"
|
||||
placeholder="Spouse, Parent, etc."
|
||||
{...addressRegister('emergencyContactRelation')}
|
||||
error={addressErrors.emergencyContactRelation?.message}
|
||||
/>
|
||||
</SimpleGrid>
|
||||
</>
|
||||
)}
|
||||
|
||||
<Group justify="space-between" mt="xl">
|
||||
<Button variant="default" onClick={() => navigate('/dashboard')}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Group gap="sm">
|
||||
{active > 0 && (
|
||||
<Button
|
||||
variant="default"
|
||||
leftSection={<IconArrowLeft size={16} />}
|
||||
onClick={() => setActive((c) => c - 1)}
|
||||
>
|
||||
Previous
|
||||
</Button>
|
||||
)}
|
||||
{active < STEPS.length - 1 ? (
|
||||
<Button rightSection={<IconArrowRight size={16} />} onClick={onNext}>
|
||||
Next Step
|
||||
</Button>
|
||||
) : (
|
||||
<Button
|
||||
color="blue"
|
||||
leftSection={<IconCircleCheck size={16} />}
|
||||
onClick={onSubmitAddress}
|
||||
loading={submitting}
|
||||
>
|
||||
Complete Setup
|
||||
</Button>
|
||||
)}
|
||||
</Group>
|
||||
</Group>
|
||||
</Stack>
|
||||
</Paper>
|
||||
</Center>
|
||||
);
|
||||
}
|
||||
@@ -1,6 +1,5 @@
|
||||
import { Provider } from 'react-redux';
|
||||
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
|
||||
import { AuthProvider } from '@tria-plc/iamui-common';
|
||||
import { AuthConfigProvider } from '@ema-platform/auth';
|
||||
import type { ReactNode } from 'react';
|
||||
import { store } from '../store';
|
||||
@@ -16,19 +15,17 @@ export function AppProviders({ children }: { children: ReactNode }) {
|
||||
<ErrorBoundary>
|
||||
<Provider store={store}>
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<AuthProvider>
|
||||
<AuthConfigProvider
|
||||
value={{
|
||||
appName: 'Portal',
|
||||
storagePrefix: 'ema-portal',
|
||||
loginRedirectPath: '/dashboard',
|
||||
enableSignup: true,
|
||||
enableForgotPassword: true,
|
||||
}}
|
||||
>
|
||||
<MantineThemeProvider>{children}</MantineThemeProvider>
|
||||
</AuthConfigProvider>
|
||||
</AuthProvider>
|
||||
<AuthConfigProvider
|
||||
value={{
|
||||
appName: 'Portal',
|
||||
storagePrefix: 'ema-portal',
|
||||
loginRedirectPath: '/dashboard',
|
||||
enableSignup: true,
|
||||
enableForgotPassword: true,
|
||||
}}
|
||||
>
|
||||
<MantineThemeProvider>{children}</MantineThemeProvider>
|
||||
</AuthConfigProvider>
|
||||
</QueryClientProvider>
|
||||
</Provider>
|
||||
</ErrorBoundary>
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import { createBrowserRouter, Navigate } from 'react-router-dom';
|
||||
import type { ReactNode } from 'react';
|
||||
import { I18nextProvider } from 'react-i18next';
|
||||
import { i18n } from './i18n/config';
|
||||
import { PortalLayout } from './layouts/PortalLayout';
|
||||
@@ -8,6 +7,9 @@ import { ProtectedRoute } from './components/ProtectedRoute';
|
||||
// Auth (standalone pages, no portal chrome)
|
||||
import { LoginPage, SignupPage, OTPVerificationPage, ForgotPasswordPage } from '@ema-platform/auth';
|
||||
|
||||
// Profile setup
|
||||
import { ProfileSetupPage } from './features/profile-setup/pages/ProfileSetupPage';
|
||||
|
||||
// Portal feature pages
|
||||
import { DashboardPage } from './features/dashboard/pages/DashboardPage';
|
||||
import { ProfilePage } from './features/profile/pages/ProfilePage';
|
||||
@@ -29,18 +31,6 @@ import { CoCApplicationPage } from './features/certificates/pages/CoCApplication
|
||||
// Phase 3 — Endorsement
|
||||
import { EndorsementPage } from './features/endorsement/pages/EndorsementPage';
|
||||
|
||||
// IAM (admin user management) — kept reachable but isolated under its own
|
||||
// provider so it does not depend on the portal's provider tree.
|
||||
import {
|
||||
AppProviders as IamProviders,
|
||||
UserManagementLayout,
|
||||
UserManagementPage,
|
||||
} from '@tria-plc/iamui-common';
|
||||
|
||||
function IsolatedIam({ children }: { children: ReactNode }) {
|
||||
return <IamProviders>{children}</IamProviders>;
|
||||
}
|
||||
|
||||
export const router = createBrowserRouter([
|
||||
// Public auth pages
|
||||
{ path: '/login', element: <LoginPage /> },
|
||||
@@ -55,6 +45,10 @@ export const router = createBrowserRouter([
|
||||
element: <ProtectedRoute><ForgotPasswordPage /></ProtectedRoute>,
|
||||
path: '/forgot-password',
|
||||
},
|
||||
{
|
||||
element: <ProtectedRoute><ProfileSetupPage /></ProtectedRoute>,
|
||||
path: '/profile-setup',
|
||||
},
|
||||
|
||||
// Portal — protected
|
||||
{
|
||||
@@ -93,17 +87,5 @@ export const router = createBrowserRouter([
|
||||
],
|
||||
},
|
||||
|
||||
// IAM admin user management (isolated providers) — protected
|
||||
{
|
||||
element: (
|
||||
<ProtectedRoute>
|
||||
<IsolatedIam>
|
||||
<UserManagementLayout />
|
||||
</IsolatedIam>
|
||||
</ProtectedRoute>
|
||||
),
|
||||
children: [{ path: '/users', element: <UserManagementPage /> }],
|
||||
},
|
||||
|
||||
{ path: '*', element: <Navigate to="/" replace /> },
|
||||
]);
|
||||
|
||||
@@ -2,7 +2,6 @@ import { StrictMode } from 'react';
|
||||
import { createRoot } from 'react-dom/client';
|
||||
import '@mantine/core/styles.css';
|
||||
import '@mantine/notifications/styles.css';
|
||||
import '@tria-plc/iamui-common/styles.css';
|
||||
import './app/theme/portal.css';
|
||||
|
||||
import './app/i18n/config';
|
||||
|
||||
@@ -45,6 +45,7 @@ export function LoginPage() {
|
||||
const [rememberMe, setRememberMe] = useState(true);
|
||||
const [loginTrigger] = useApiMutation<LoginPayload>();
|
||||
const [meTrigger] = useApiMutation<AuthUser>();
|
||||
const [profileCheckTrigger] = useApiMutation<{ count: number; items: Array<{ userId: string }> }>();
|
||||
|
||||
const {
|
||||
register,
|
||||
@@ -70,6 +71,21 @@ export function LoginPage() {
|
||||
}).unwrap();
|
||||
dispatch(setUser(me));
|
||||
|
||||
try {
|
||||
const profiles = await profileCheckTrigger({
|
||||
url: '/profiles?take=10000&skip=0',
|
||||
method: 'GET',
|
||||
}).unwrap();
|
||||
const hasProfile = profiles.items?.some((p) => p.userId === me.id);
|
||||
|
||||
if (!hasProfile) {
|
||||
navigate('/profile-setup');
|
||||
return;
|
||||
}
|
||||
} catch {
|
||||
// profile check failed — proceed to dashboard anyway
|
||||
}
|
||||
|
||||
if (me.isPhoneNumberVerified) {
|
||||
navigate(loginRedirectPath);
|
||||
} else {
|
||||
|
||||
@@ -21,8 +21,10 @@ 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),
|
||||
clear: () => {
|
||||
[key('auth-token'), key('refresh-token'), key('auth-user')].forEach((k) =>
|
||||
[key('auth-token'), key('refresh-token'), key('auth-user'), key('profile-id')].forEach((k) =>
|
||||
localStorage.removeItem(k),
|
||||
);
|
||||
document.cookie =
|
||||
|
||||
4025
package-lock.json
generated
4025
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -3,16 +3,14 @@
|
||||
"version": "0.0.1",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"backoffice": "npm run build:user-management && nx serve @ema-platform/backoffice",
|
||||
"backoffice": "nx serve @ema-platform/backoffice",
|
||||
"portal": "nx serve @ema-platform/portal",
|
||||
"dev:all": "npm run build:user-management && nx run-many -t serve -p @ema-platform/portal @ema-platform/backoffice --parallel=2",
|
||||
"dev:all": "nx run-many -t serve -p @ema-platform/portal @ema-platform/backoffice --parallel=2",
|
||||
"build:backoffice": "nx build @ema-platform/backoffice",
|
||||
"build:portal": "nx build @ema-platform/portal",
|
||||
"lint": "nx run-many -t lint",
|
||||
"test": "nx run-many -t test",
|
||||
"format": "prettier --write .",
|
||||
"build:user-management": "cd user-management-config && npm run build",
|
||||
"backoffice:no-build": "nx serve @fhc-platform/backoffice"
|
||||
"format": "prettier --write ."
|
||||
},
|
||||
"dependencies": {
|
||||
"@daypicker/ethiopic": "^10.0.1",
|
||||
|
||||
Submodule user-management deleted from e025182e92
@@ -1,223 +0,0 @@
|
||||
/**
|
||||
* fhc.theme.ts — Federal Housing Corporation (FHC) look & feel preset.
|
||||
*
|
||||
* ┌─────────────────────────────────────────────────────────────────────────┐
|
||||
* │ HOST-OWNED config. Lives in app-config/, NOT inside the user-management │
|
||||
* │ module. At submodule-split time this whole folder moves to the host repo. │
|
||||
* │ It is fully self-contained — no imports from the module. │
|
||||
* └─────────────────────────────────────────────────────────────────────────┘
|
||||
*
|
||||
* WHAT IT GIVES YOU
|
||||
* - The FHC Mantine color palettes: fhcBlue, fhcBrick, fhcGold, fhcGray
|
||||
* - The FHC layout design tokens (brick-gradient sidebar, glassy header,
|
||||
* page background, brand colors, sizes) under `theme.other.fhcLayout`
|
||||
* (light) and `theme.other.fhcLayoutDark` (dark) — the classic shell reads
|
||||
* these via useFhcLayout()
|
||||
* - FHC typography (Plus Jakarta Sans), radii, shadows and component defaults
|
||||
*
|
||||
* HOW TO USE — in app-config/project.theme.ts:
|
||||
*
|
||||
* import { fhcMantineTheme } from "./fhc.theme";
|
||||
*
|
||||
* export const projectTheme: DesignConfig = {
|
||||
* typography: { fontFamily: "Plus Jakarta Sans, sans-serif" },
|
||||
* mantineTheme: fhcMantineTheme, // escape hatch — merges the FHC theme in
|
||||
* };
|
||||
*
|
||||
* Load the font once in index.html:
|
||||
* <link href="https://fonts.googleapis.com/css2?family=Plus+Jakarta+Sans:wght@400;500;600;700;800;900&display=swap" rel="stylesheet" />
|
||||
*/
|
||||
|
||||
import type { MantineColorsTuple, MantineThemeOverride } from "@mantine/core";
|
||||
|
||||
/** Mantine 10-shade color scales used across the FHC UI. */
|
||||
export const FHC_COLORS = {
|
||||
fhcBlue: [
|
||||
"#EEF4FC",
|
||||
"#D9E8FA",
|
||||
"#BCD5F5",
|
||||
"#96BDEB",
|
||||
"#6FA4E0",
|
||||
"#4A90E2",
|
||||
"#4b7fe5",
|
||||
"#2C669D",
|
||||
"#224F7A",
|
||||
"#173654",
|
||||
],
|
||||
fhcBrick: [
|
||||
"#F6ECE8",
|
||||
"#EACFC4",
|
||||
"#DBAD99",
|
||||
"#C9876B",
|
||||
"#B86B49",
|
||||
"#A85735",
|
||||
"#8C462B",
|
||||
"#703622",
|
||||
"#55281A",
|
||||
"#3D1E14",
|
||||
],
|
||||
fhcGold: [
|
||||
"#FFFBE6",
|
||||
"#FFF3BF",
|
||||
"#FEE98A",
|
||||
"#FCDD57",
|
||||
"#F9CF2F",
|
||||
"#FFD700",
|
||||
"#D9B700",
|
||||
"#B39400",
|
||||
"#8C7300",
|
||||
"#665300",
|
||||
],
|
||||
fhcGray: [
|
||||
"#F8FAFC",
|
||||
"#F1F5F9",
|
||||
"#E2E8F0",
|
||||
"#CBD5E1",
|
||||
"#94A3B8",
|
||||
"#64748B",
|
||||
"#475569",
|
||||
"#334155",
|
||||
"#1E293B",
|
||||
"#0F172A",
|
||||
],
|
||||
} as const;
|
||||
|
||||
/**
|
||||
* Layout design tokens — the brick-gradient sidebar, glassy header, page
|
||||
* surfaces, brand colors and sizes. Mirrored under `theme.other.fhcLayout`.
|
||||
*/
|
||||
export const FHC_LAYOUT = {
|
||||
sidebar: {
|
||||
bg: "linear-gradient(180deg, #1A3A5C 0%, #0F2440 100%)",
|
||||
headerBg: "rgba(26, 58, 92, 0.82)",
|
||||
footerBg: "rgba(15, 36, 64, 0.62)",
|
||||
border: "rgba(255,255,255,0.10)",
|
||||
text: "rgba(255,255,255,0.76)",
|
||||
mutedText: "rgba(255,255,255,0.42)",
|
||||
childText: "rgba(255,255,255,0.68)",
|
||||
activeText: "#FFFFFF",
|
||||
iconBg: "rgba(255,255,255,0.06)",
|
||||
iconActiveBg: "rgba(255,255,255,0.12)",
|
||||
hoverBg: "rgba(255,255,255,0.08)",
|
||||
activeBg: "rgba(255,255,255,0.15)",
|
||||
activeBorder: "rgba(255,255,255,0.14)",
|
||||
sectionLine: "rgba(255,255,255,0.10)",
|
||||
rail: "linear-gradient(180deg, #4A90E2 0%, #1A3A5C 100%)",
|
||||
},
|
||||
header: {
|
||||
bg: "rgba(255,255,255,0.92)",
|
||||
border: "rgba(15, 23, 42, 0.08)",
|
||||
searchBg: "#F9FAFB",
|
||||
searchBorder: "#E5E7EB",
|
||||
title: "#1F2937",
|
||||
subtitle: "#6B7280",
|
||||
},
|
||||
page: {
|
||||
bg: "#F8FAFC",
|
||||
cardBg: "rgba(255,255,255,0.92)",
|
||||
},
|
||||
brand: {
|
||||
brick: "#1A3A5C",
|
||||
brickDark: "#0F2440",
|
||||
blue: "#4A90E2",
|
||||
blueDark: "#4b7fe5",
|
||||
gold: "#4A90E2",
|
||||
text: "#1F2937",
|
||||
},
|
||||
sizes: {
|
||||
sidebarExpanded: 288,
|
||||
sidebarCollapsed: 80,
|
||||
headerHeight: 64,
|
||||
},
|
||||
} as const;
|
||||
|
||||
/**
|
||||
* Dark-mode counterpart of FHC_LAYOUT. The brick-gradient sidebar, accent rail
|
||||
* and sizes are intentionally kept (they already read well on dark), while the
|
||||
* glassy white header, page background, card surfaces and dark text are flipped
|
||||
* to dark equivalents.
|
||||
*/
|
||||
export const FHC_LAYOUT_DARK = {
|
||||
...FHC_LAYOUT,
|
||||
header: {
|
||||
bg: "rgba(26, 27, 30, 0.92)",
|
||||
border: "rgba(255,255,255,0.08)",
|
||||
searchBg: "#25262B",
|
||||
searchBorder: "#2C2E33",
|
||||
title: "#F1F5F9",
|
||||
subtitle: "#9CA3AF",
|
||||
},
|
||||
page: {
|
||||
bg: "#141517",
|
||||
cardBg: "rgba(26, 27, 30, 0.92)",
|
||||
},
|
||||
brand: {
|
||||
...FHC_LAYOUT.brand,
|
||||
text: "#F1F5F9",
|
||||
},
|
||||
} as const;
|
||||
|
||||
/**
|
||||
* Full Mantine theme override carrying the FHC palettes, layout tokens,
|
||||
* typography, radii, shadows and component defaults. Pass this as the
|
||||
* `mantineTheme` escape hatch in project.theme.ts.
|
||||
*
|
||||
* Note: BOTH `fhcLayout` (light) and `fhcLayoutDark` (dark) are published under
|
||||
* `other` — the module's useFhcLayout() reads the matching one per color scheme.
|
||||
*/
|
||||
export const fhcMantineTheme: MantineThemeOverride = {
|
||||
fontFamily: "Plus Jakarta Sans, sans-serif",
|
||||
headings: {
|
||||
fontFamily: "Plus Jakarta Sans, sans-serif",
|
||||
},
|
||||
defaultRadius: "md",
|
||||
radius: {
|
||||
xs: "6px",
|
||||
sm: "8px",
|
||||
md: "10px",
|
||||
lg: "14px",
|
||||
xl: "18px",
|
||||
},
|
||||
shadows: {
|
||||
xs: "0 1px 2px rgba(15, 23, 42, 0.04)",
|
||||
sm: "0 2px 8px rgba(15, 23, 42, 0.06)",
|
||||
md: "0 4px 20px rgba(15, 23, 42, 0.08)",
|
||||
lg: "0 8px 30px rgba(15, 23, 42, 0.12)",
|
||||
},
|
||||
colors: {
|
||||
fhcBlue: FHC_COLORS.fhcBlue as unknown as MantineColorsTuple,
|
||||
fhcBrick: FHC_COLORS.fhcBrick as unknown as MantineColorsTuple,
|
||||
fhcGold: FHC_COLORS.fhcGold as unknown as MantineColorsTuple,
|
||||
fhcGray: FHC_COLORS.fhcGray as unknown as MantineColorsTuple,
|
||||
},
|
||||
other: {
|
||||
fhcLayout: FHC_LAYOUT,
|
||||
fhcLayoutDark: FHC_LAYOUT_DARK,
|
||||
},
|
||||
components: {
|
||||
Paper: {
|
||||
defaultProps: {
|
||||
radius: "lg",
|
||||
shadow: "sm",
|
||||
},
|
||||
},
|
||||
NavLink: {
|
||||
defaultProps: {
|
||||
radius: "md",
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
/**
|
||||
* Optional convenience: the bits of a DesignConfig that carry the FHC look.
|
||||
* Spread this into your projectTheme if you also want FHC as the primary brand
|
||||
* (this re-tints buttons/links to fhcBlue). Leave it out to keep your own brand
|
||||
* color while still getting the fhc* palettes + layout tokens via `mantineTheme`.
|
||||
*/
|
||||
export const fhcDesignPreset = {
|
||||
colors: { primary: "#4b7fe5" },
|
||||
typography: { fontFamily: "Plus Jakarta Sans, sans-serif" },
|
||||
shape: { radius: "10px" },
|
||||
mantineTheme: fhcMantineTheme,
|
||||
};
|
||||
@@ -1,16 +0,0 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
|
||||
<meta
|
||||
name="viewport"
|
||||
content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no, shrink-to-fit=no"
|
||||
/>
|
||||
<title>EMA Portal</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script type="module" src="/main.tsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -1,27 +0,0 @@
|
||||
import { StrictMode } from "react";
|
||||
import { createRoot } from "react-dom/client";
|
||||
// Consume the reusable module via its public barrel (@/ → ../user-management/src).
|
||||
import i18n from "@/i18n";
|
||||
import { UserManagementApp } from "@/index";
|
||||
// Your project's config lives HERE in the host folder (resolved via @app-config).
|
||||
// The module never imports it; the host passes it in.
|
||||
import { projectTheme } from "@app-config/project.theme";
|
||||
|
||||
// Override submodule translation texts for EMA branding
|
||||
// (the submodule is a readonly git submodule, so we patch i18n at runtime here)
|
||||
i18n.addResourceBundle("en", "translation", {
|
||||
auth: {
|
||||
welcomeHeadline: "Welcome to EMA Portal",
|
||||
paperlessOffice: "Ethiopian Maritime Authority",
|
||||
welcomeSubtext: "Sign in to access your maritime services efficiently.",
|
||||
},
|
||||
organization: {
|
||||
"Back to EMA": "Back to EMA",
|
||||
},
|
||||
}, true, true);
|
||||
|
||||
createRoot(document.getElementById("root")!).render(
|
||||
<StrictMode>
|
||||
<UserManagementApp config={projectTheme} />
|
||||
</StrictMode>
|
||||
);
|
||||
@@ -1 +0,0 @@
|
||||
../user-management/node_modules
|
||||
@@ -1,12 +0,0 @@
|
||||
{
|
||||
"name": "user-management-host",
|
||||
"private": true,
|
||||
"version": "0.0.0",
|
||||
"type": "module",
|
||||
"description": "Host wrapper for the user-management module. Owns branding/theme (project.theme.ts / fhc.theme.ts), the Vite build (vite.config.ts), the HTML shell (index.html) and the entry (main.tsx). Consumes the module from ../user-management/src.",
|
||||
"scripts": {
|
||||
"dev": "vite --port 4202 --host 0.0.0.0",
|
||||
"build": "vite build",
|
||||
"preview": "vite preview --port 4202 --host"
|
||||
}
|
||||
}
|
||||
@@ -1,7 +0,0 @@
|
||||
// Tailwind is handled by the @tailwindcss/vite plugin (see vite.config.ts), so
|
||||
// PostCSS needs no plugins here. This local config exists to stop Vite from
|
||||
// walking up to the monorepo root postcss.config.js (Tailwind v3), which would
|
||||
// conflict with this package's Tailwind v4 setup.
|
||||
export default {
|
||||
plugins: {},
|
||||
};
|
||||
@@ -1,220 +0,0 @@
|
||||
/**
|
||||
* project.theme.ts — HOST-OWNED config for THIS project / organisation (FHC).
|
||||
*
|
||||
* ┌─────────────────────────────────────────────────────────────────────────┐
|
||||
* │ Lives in app-config/, OUTSIDE the user-management module. The module │
|
||||
* │ never imports this file — the host passes it in via │
|
||||
* │ <UserManagementApp config={projectTheme} /> (see ../src/main.tsx). │
|
||||
* │ At submodule-split time, this whole folder moves to the host repo. │
|
||||
* └─────────────────────────────────────────────────────────────────────────┘
|
||||
*
|
||||
* Every field is optional — remove lines you don't need to override.
|
||||
*
|
||||
* Flow:
|
||||
* project.theme.ts → design.config.ts (module engine) → CSS vars + Mantine theme
|
||||
* TenantConfig.ts → overrides --primary at runtime per hostname
|
||||
*
|
||||
* The TenantConfig layer runs AFTER this, so per-hostname primary-color overrides
|
||||
* still work on top of whatever you set here.
|
||||
*/
|
||||
|
||||
import type { DesignConfig } from "@/config/design.config";
|
||||
import { fhcMantineTheme } from "./fhc.theme";
|
||||
|
||||
export const projectTheme: DesignConfig = {
|
||||
// ─────────────────────────────────────────────────────────────────────────
|
||||
// BRANDING
|
||||
// Replace with your organisation's assets.
|
||||
// ─────────────────────────────────────────────────────────────────────────
|
||||
brand: {
|
||||
appName: "EMA Portal", // EMA — shown in the browser tab
|
||||
logoUrl: "/assets/ema-logo.png",
|
||||
faviconUrl: "/assets/ema-logo.png",
|
||||
},
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────
|
||||
// COLORS
|
||||
// Change `primary` to your brand hex and everything cascades automatically.
|
||||
// Shades primary-50 → primary-950 are computed via CSS color-mix in index.css.
|
||||
// TenantConfig overrides this per-hostname, so localhost vs edrsc.com can
|
||||
// still have different colors.
|
||||
// ─────────────────────────────────────────────────────────────────────────
|
||||
colors: {
|
||||
primary: "#4b7fe5", // FHC blue (fhcBlue-6) — buttons, links, active states
|
||||
// // "#5D2E1F" brick (FHC chrome) is used by the sidebar/modal skin below
|
||||
// // "#2563eb" blue | "#7c3aed" purple
|
||||
// // "#16a34a" green | "#dc2626" red
|
||||
// // "#f59e0b" amber | "#0284c7" sky
|
||||
|
||||
// primaryForeground: "#ffffff", // text on primary-colored bg — rarely needs changing
|
||||
|
||||
// secondary: "#f1f5f9", // TODO: subtle secondary UI color
|
||||
// background: "#ffffff", // TODO: page background
|
||||
// foreground: "#0f172a", // TODO: main text color
|
||||
// border: "#e2e8f0", // TODO: input / card borders
|
||||
// muted: "#f8fafc", // TODO: disabled input / tag backgrounds
|
||||
// mutedForeground: "#94a3b8", // TODO: placeholder / helper text
|
||||
// card: "#ffffff", // TODO: card background (if different from page)
|
||||
// sidebar: "#f8fafc", // TODO: sidebar background
|
||||
// danger: "#dc2626", // TODO: error / destructive color
|
||||
},
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────
|
||||
// TYPOGRAPHY
|
||||
// Load the font FIRST in index.html (Google Fonts link or @font-face) then
|
||||
// set fontFamily here. The fallback chain is used if the custom font fails.
|
||||
// ─────────────────────────────────────────────────────────────────────────
|
||||
typography: {
|
||||
fontFamily: "Plus Jakarta Sans, Inter, ui-sans-serif, system-ui, sans-serif",
|
||||
// // TODO: "Poppins, Inter, sans-serif"
|
||||
// // TODO: "Cairo, Inter, sans-serif" (Arabic)
|
||||
// // TODO: "Noto Serif Ethiopic, serif" (Amharic)
|
||||
|
||||
// headingFontFamily: undefined, // TODO: separate heading font if desired
|
||||
// baseFontSize: "16px", // TODO: "14px" for compact dashboards
|
||||
},
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────
|
||||
// SHAPE
|
||||
// ─────────────────────────────────────────────────────────────────────────
|
||||
shape: {
|
||||
radius: "0.625rem", // TODO: "0" sharp | "0.5rem" subtle | "1rem" very rounded
|
||||
},
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────
|
||||
// SHADOWS
|
||||
// Leave commented to use Mantine/Tailwind defaults.
|
||||
// ─────────────────────────────────────────────────────────────────────────
|
||||
// shadows: {
|
||||
// card: "0 1px 3px rgba(0,0,0,0.08), 0 4px 16px rgba(0,0,0,0.06)",
|
||||
// dropdown: "0 8px 30px rgba(0,0,0,0.12)",
|
||||
// modal: "0 20px 60px rgba(0,0,0,0.16)",
|
||||
// },
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────
|
||||
// MANTINE COMPONENT DEFAULTS
|
||||
// These become the <MantineProvider theme> defaults for every component.
|
||||
// ─────────────────────────────────────────────────────────────────────────
|
||||
components: {
|
||||
buttonDefaultVariant: "filled", // TODO: "light" | "outline" | "subtle"
|
||||
inputDefaultSize: "sm", // TODO: "xs" | "md" | "lg"
|
||||
inputRadius: "md", // TODO: "xs" | "lg" | "xl"
|
||||
modalRadius: "lg", // TODO: "md" | "xl"
|
||||
tableHighlightOnHover: true,
|
||||
tableStriped: false, // TODO: "odd" | "even" | true
|
||||
},
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────
|
||||
// USER-MANAGEMENT LAYOUT / NAVIGATION
|
||||
// Pick the navigation chrome and style the side menu — all from here.
|
||||
// "classic" → app-wide SIDE MENU, no top tabs
|
||||
// "legacy" → top TAB bar, no side menu
|
||||
// Each value is also exposed as a --um-* CSS var, so tweaks apply instantly.
|
||||
// ─────────────────────────────────────────────────────────────────────────
|
||||
layout: {
|
||||
userManagementView: "legacy", // "legacy" → top-tab UI, "classic" → side menu
|
||||
// showTopBar: false, // TODO: overrides VITE_SHOW_TOP_BAR
|
||||
|
||||
// ── Dimensions ──────────────────────────────────────────────────────────
|
||||
sidebarWidth: "288px", // TODO: expanded side-menu width
|
||||
sidebarCollapsedWidth: "80px", // TODO: icon-only width
|
||||
headerHeight: "64px", // TODO: top bar height
|
||||
// contentMaxWidth: "1440px", // TODO: cap the content column
|
||||
|
||||
// ── Side-menu skin (defaults follow the FHC brick theme) ───────────────
|
||||
sidebarBackground: "linear-gradient(180deg, #1A3A5C 0%, #0F2440 100%)",
|
||||
sidebarColor: "rgba(255,255,255,0.76)",
|
||||
sidebarMutedColor: "rgba(255,255,255,0.42)",
|
||||
sidebarActiveBackground: "rgba(255,255,255,0.15)",
|
||||
sidebarActiveColor: "#FFFFFF",
|
||||
sidebarHoverBackground: "rgba(255,255,255,0.08)",
|
||||
sidebarBorder: "rgba(255,255,255,0.10)",
|
||||
sidebarRail: "linear-gradient(180deg, #4A90E2 0%, #1A3A5C 100%)",
|
||||
sidebarBrandLabel: "EMA Portal",
|
||||
sidebarBrandSublabel: "User Management",
|
||||
|
||||
// ── THE MENU (data, shared by the side menu AND the top tabs) ──────────
|
||||
// Edit/add/remove freely. `icon` is a name from the registry in
|
||||
// navConfig.tsx (users, dashboard, content, position, settings, excel,
|
||||
// archive, units, activity, organizations, …). `label` is an i18n key
|
||||
// under "organization.<label>" (raw string shown if no translation).
|
||||
// Remove this array entirely to fall back to the built-in defaults.
|
||||
//
|
||||
// SHOW / HIDE A MENU: set `enabled: false` on any item to stop it
|
||||
// rendering in BOTH the side menu and the top tabs — without deleting it.
|
||||
// Omitting `enabled` (or `true`) keeps it visible. Toggle these per project.
|
||||
navItems: [
|
||||
// The menu is ROLE-FILTERED (see navConfig.tsx): each role sees only its own
|
||||
// block. Every href below has a matching route in the embedded router
|
||||
// (src/App.tsx), which is now a superset of org-admin + super-admin routes.
|
||||
|
||||
// ── Org-admin / unit-admin surface ──
|
||||
{ label: "dashboard", href: "/user-management/user_management-dashboard", icon: "dashboard", roles: ["admin", "unit_admin"], isPrimary: true, enabled: true },
|
||||
{ label: "userManagement", href: "/user-management/user_management", icon: "users", roles: ["admin", "unit_admin"], isPrimary: true, enabled: true },
|
||||
{ label: "contentManagement", displayLabel: "contentManagement", href: "/user-management/content-management", icon: "content", roles: ["admin", "unit_admin"], isPrimary: true, enabled: true },
|
||||
{ label: "Position", displayLabel: "positionTypes", href: "/user-management/position-management", icon: "position", roles: ["admin", "unit_admin", "super_admin"], isPrimary: true, enabled: true },
|
||||
{ label: "settings", displayLabel: "settings", href: "/user-management/organization-settings", icon: "settings", roles: ["admin", "unit_admin"], isPrimary: true, enabled: true },
|
||||
{ label: "Bulk", displayLabel: "bulkUpload", href: "/user-management/bulk-upload", icon: "excel", roles: ["admin", "unit_admin"], isPrimary: true, enabled: true },
|
||||
{ label: "Archive Users", displayLabel: "Archive Users", href: "/user-management/archives", icon: "archive", roles: ["admin", "unit_admin"], isPrimary: true, enabled: true },
|
||||
{ label: "Archived Units & Positions", displayLabel: "Archived Units & Positions", href: "/user-management/archived", icon: "units", roles: ["admin", "unit_admin"], isPrimary: true, enabled: true },
|
||||
|
||||
// ── Super-admin surface (routes now wired in App.tsx) ──
|
||||
{ label: "dashboard", href: "/user-management/dashboard", icon: "dashboard", roles: ["super_admin"], isPrimary: true, enabled: true },
|
||||
{ label: "organizations", href: "/user-management/organizations", icon: "organizations", roles: ["super_admin"], isPrimary: true, enabled: true },
|
||||
{ label: "organizationAdmins", href: "/user-management/organization_admins", icon: "admins", roles: ["super_admin"], isPrimary: true, enabled: true },
|
||||
{ label: "externalUsers", href: "/user-management/external_users", icon: "admins", roles: ["super_admin"], isPrimary: true, enabled: true },
|
||||
{ label: "Migrated Records", displayLabel: "migratedRecords", href: "/user-management/migrated-records-management", icon: "file", roles: ["super_admin"], isPrimary: true, enabled: true },
|
||||
{ label: "Archive Users", displayLabel: "Archive Users", href: "/user-management/archive-users", icon: "archive", roles: ["super_admin"], isPrimary: true, enabled: true },
|
||||
{ label: "Archived Organizations", displayLabel: "Archived Organizations", href: "/user-management/archived-organizations", icon: "archive", roles: ["super_admin"], isPrimary: true, enabled: true },
|
||||
{ label: "activityLog", href: "/user-management/activity_log", icon: "activity", roles: ["super_admin"], isPrimary: false, enabled: true },
|
||||
{ label: "setting", href: "/user-management/settings", icon: "settings", roles: ["super_admin"], isPrimary: false, enabled: true },
|
||||
{ label: "Letter Template", href: "/user-management/templates", icon: "file", roles: ["super_admin"], isPrimary: false, enabled: true },
|
||||
|
||||
// ── Return to host app ─────────────────────────────────────────────────
|
||||
// Clicking navigates the HOST (parent window) to the destination. The
|
||||
// /return/ prefix is intercepted by UserManagementHostPage.tsx to tell the
|
||||
// host to navigate itself, not the iframe.
|
||||
{ label: "Back to EMA", href: "/return/dashboard", icon: "dashboard", roles: ["admin", "unit_admin", "super_admin"], isPrimary: false, enabled: true },
|
||||
],
|
||||
|
||||
// ── Top tab bar skin (legacy view) ─────────────────────────────────────
|
||||
menuBackground: "#ffffff", // TODO: tab bar background
|
||||
menuColor: "#334155", // inactive tab text
|
||||
menuActiveColor: "#4b7fe5", // active tab text (FHC blue)
|
||||
menuActiveBorderColor: "#4b7fe5", // active tab underline
|
||||
menuHoverColor: "#4b7fe5", // tab hover text
|
||||
|
||||
// ── Create / edit modal skin (shared BackofficeModal) ──────────────────
|
||||
modalAccentColor: "#4b7fe5", // blue top strip
|
||||
modalHeaderBackground: "#EEF4FC", // header bg (view)
|
||||
modalHeaderEditBackground: "#D9E8FA", // header bg (edit)
|
||||
modalIconBackground: "#D9E8FA", // header icon chip bg
|
||||
modalIconColor: "#4b7fe5", // header icon chip color
|
||||
modalTitleColor: "#1F2937", // modal title text
|
||||
modalFocusColor: "#4b7fe5", // input focus ring inside modals
|
||||
modalSurface: "#ffffff", // modal body surface
|
||||
},
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────
|
||||
// EXTRA CSS VARS
|
||||
// Inject any CSS custom property that isn't covered above.
|
||||
// Keys are variable names WITHOUT the leading "--".
|
||||
// ─────────────────────────────────────────────────────────────────────────
|
||||
// cssVars: {
|
||||
// "sidebar-width": "260px",
|
||||
// "header-height": "64px",
|
||||
// "content-max-width": "1440px",
|
||||
// "custom-gradient": "linear-gradient(135deg, #18aa9d 0%, #0f7a70 100%)",
|
||||
// },
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────
|
||||
// MANTINE THEME ESCAPE HATCH
|
||||
// Any Mantine theme key — merged on top of everything above.
|
||||
// Full list: https://mantine.dev/theming/theme-object/
|
||||
// ─────────────────────────────────────────────────────────────────────────
|
||||
// The FHC look & feel preset — provides the fhcBlue/fhcBrick/fhcGold/fhcGray
|
||||
// palettes and the `other.fhcLayout` / `other.fhcLayoutDark` tokens that the
|
||||
// "classic" user-management view renders with. Lives alongside this file in
|
||||
// app-config/ so the whole host config moves together at submodule-split time.
|
||||
mantineTheme: fhcMantineTheme,
|
||||
};
|
||||
@@ -1,25 +0,0 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2020",
|
||||
"useDefineForClassFields": true,
|
||||
"lib": ["ES2020", "DOM", "DOM.Iterable"],
|
||||
"module": "ESNext",
|
||||
"moduleResolution": "bundler",
|
||||
"jsx": "react-jsx",
|
||||
"esModuleInterop": true,
|
||||
"allowSyntheticDefaultImports": true,
|
||||
"allowImportingTsExtensions": true,
|
||||
"resolveJsonModule": true,
|
||||
"isolatedModules": true,
|
||||
"moduleDetection": "force",
|
||||
"noEmit": true,
|
||||
"skipLibCheck": true,
|
||||
"strict": false,
|
||||
"baseUrl": ".",
|
||||
"paths": {
|
||||
"@/*": ["../user-management/src/*"],
|
||||
"@app-config/*": ["./*"]
|
||||
}
|
||||
},
|
||||
"include": ["main.tsx", "project.theme.ts", "fhc.theme.ts", "../user-management/src"]
|
||||
}
|
||||
@@ -1,17 +0,0 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2022",
|
||||
"lib": ["ES2023"],
|
||||
"module": "ESNext",
|
||||
"skipLibCheck": true,
|
||||
"moduleResolution": "bundler",
|
||||
"allowImportingTsExtensions": true,
|
||||
"isolatedModules": true,
|
||||
"moduleDetection": "force",
|
||||
"noEmit": true,
|
||||
"strict": true,
|
||||
"noUnusedLocals": false,
|
||||
"noUnusedParameters": false
|
||||
},
|
||||
"include": ["vite.config.ts"]
|
||||
}
|
||||
@@ -1,107 +0,0 @@
|
||||
import fs from "fs";
|
||||
import path from "path";
|
||||
import react from "@vitejs/plugin-react";
|
||||
import tailwindcss from "@tailwindcss/vite";
|
||||
import { defineConfig, loadEnv } from "vite";
|
||||
|
||||
export default defineConfig(({ mode }) => {
|
||||
const env = loadEnv(mode, process.cwd(), "");
|
||||
|
||||
// Same-origin sub-path the host server serves the module from. `base` makes
|
||||
// built asset URLs resolve under it AND is read back inside the module
|
||||
// (import.meta.env.BASE_URL) to set the router basename. Override with UM_BASE.
|
||||
const base = env.UM_BASE || "/_um/";
|
||||
|
||||
// Build straight into the host app's public dir so its ONE server serves the
|
||||
// module at <origin>/_um/ — no second server, same origin as the host.
|
||||
const outDir = path.resolve(__dirname, "../apps/backoffice/public/_um");
|
||||
|
||||
return {
|
||||
base,
|
||||
plugins: [
|
||||
tailwindcss(),
|
||||
react(),
|
||||
{
|
||||
// TinyMCE is self-hosted; the module references it at the ABSOLUTE path
|
||||
// /tinymce/..., which resolves at the host origin. Mirror the assets into
|
||||
// BOTH the module public dir AND the host public root. Regenerated on
|
||||
// build, so neither copy is a hand-managed artifact.
|
||||
name: "copy-tinymce-assets",
|
||||
buildStart() {
|
||||
const src = path.resolve(__dirname, "node_modules/tinymce");
|
||||
const dests = [
|
||||
path.resolve(__dirname, "../user-management/public/tinymce"),
|
||||
path.resolve(__dirname, "../apps/backoffice/public/tinymce"),
|
||||
];
|
||||
const runtimeEntries = [
|
||||
"tinymce.min.js",
|
||||
"icons",
|
||||
"models",
|
||||
"plugins",
|
||||
"skins",
|
||||
"themes",
|
||||
];
|
||||
|
||||
if (!fs.existsSync(src)) {
|
||||
throw new Error(
|
||||
"[copy-tinymce-assets] node_modules/tinymce not found in this app. Run `npm install` here first."
|
||||
);
|
||||
}
|
||||
|
||||
for (const dest of dests) {
|
||||
fs.mkdirSync(dest, { recursive: true });
|
||||
for (const entry of runtimeEntries) {
|
||||
const entrySrc = path.resolve(src, entry);
|
||||
const entryDest = path.resolve(dest, entry);
|
||||
if (!fs.existsSync(entrySrc)) continue;
|
||||
fs.cpSync(entrySrc, entryDest, { recursive: true, force: true });
|
||||
}
|
||||
}
|
||||
},
|
||||
},
|
||||
],
|
||||
assetsInclude: ["**/*.TTF"],
|
||||
// Static assets (incl. tinymce/) live in the module's public dir.
|
||||
publicDir: path.resolve(__dirname, "../user-management/public"),
|
||||
build: {
|
||||
rollupOptions: {
|
||||
external: [
|
||||
"file-type",
|
||||
"readable-web-to-node-stream",
|
||||
"strtok3",
|
||||
"token-types",
|
||||
],
|
||||
},
|
||||
outDir,
|
||||
assetsDir: "assets",
|
||||
sourcemap: false,
|
||||
emptyOutDir: true,
|
||||
minify: "esbuild",
|
||||
},
|
||||
resolve: {
|
||||
alias: [
|
||||
// @app-config = this host folder itself (project.theme.ts / fhc.theme.ts).
|
||||
{ find: /^@app-config\//, replacement: path.resolve(__dirname) + "/" },
|
||||
// @/ = the reusable module's source in the SIBLING module folder.
|
||||
{ find: /^@\//, replacement: path.resolve(__dirname, "../user-management/src") + "/" },
|
||||
],
|
||||
// node_modules is linked to the module's, but pin the singletons so the host
|
||||
// entry and the module code share ONE React 18 (no "invalid hook call").
|
||||
dedupe: ["react", "react-dom", "react-router-dom", "@tanstack/react-query", "@mantine/core", "@mantine/hooks"],
|
||||
},
|
||||
server: {
|
||||
port: env.DEV_PORT ? Number(env.DEV_PORT) : 5173,
|
||||
strictPort: true,
|
||||
fs: { allow: [path.resolve(__dirname, "..")] },
|
||||
},
|
||||
optimizeDeps: {
|
||||
exclude: ["file-type", "readable-web-to-node-stream", "strtok3", "token-types"],
|
||||
},
|
||||
esbuild: {
|
||||
drop: mode === "production" ? ["console", "debugger"] : [],
|
||||
},
|
||||
define: {
|
||||
global: {},
|
||||
},
|
||||
};
|
||||
});
|
||||
Reference in New Issue
Block a user