mirror of
https://github.com/Tria-plc/emaui.git
synced 2026-08-29 23:28:12 +00:00
feat: change usermanagement from git submodule to componenet based
This commit is contained in:
@@ -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: [
|
||||
|
||||
Reference in New Issue
Block a user