added configuration page

This commit is contained in:
mengstabketemaw
2026-06-19 15:24:37 +03:00
parent 4e0a98f814
commit ddd91b6125
7 changed files with 671 additions and 4 deletions

View File

@@ -0,0 +1,68 @@
import { baseApi } from '@ema-platform/api';
import type {
Department,
Profession,
ListResponse,
CreateDepartmentPayload,
UpdateDepartmentPayload,
CreateProfessionPayload,
UpdateProfessionPayload,
} from '../types/configuration';
const configurationApi = baseApi.injectEndpoints({
endpoints: (builder) => ({
getDepartments: builder.query<ListResponse<Department>, void>({
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',
providesTags: ['Api'],
}),
createProfession: builder.mutation<Profession, CreateProfessionPayload>({
query: (body) => ({ url: '/professions', method: 'POST', body }),
invalidatesTags: ['Api'],
}),
updateProfession: builder.mutation<Profession, UpdateProfessionPayload>({
query: ({ id, ...body }) => ({
url: `/professions/${id}`,
method: 'PUT',
body,
}),
invalidatesTags: ['Api'],
}),
deleteProfession: builder.mutation<void, string>({
query: (id) => ({ url: `/professions/${id}`, method: 'DELETE' }),
invalidatesTags: ['Api'],
}),
}),
overrideExisting: false,
});
export const {
useGetDepartmentsQuery,
useCreateDepartmentMutation,
useUpdateDepartmentMutation,
useDeleteDepartmentMutation,
useGetProfessionsQuery,
useCreateProfessionMutation,
useUpdateProfessionMutation,
useDeleteProfessionMutation,
} = configurationApi;

View File

@@ -0,0 +1,479 @@
import { useState } from 'react';
import {
Stack,
Title,
Tabs,
Group,
Button,
TextInput,
Textarea,
Table,
ActionIcon,
Modal,
Text,
Select,
Badge,
Paper,
} 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 { useTranslation } from 'react-i18next';
import { notify } from '@ema-platform/ui';
import { LocationPage } from '../../location/pages/LocationPage';
import type { Department, 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>
</>
);
}
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);
const profForm = useForm({
initialValues: { code: '', nameEn: '', nameAm: '', description: '', 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();
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'));
}
resetProfForm();
});
const deptOptions = departments.map((d) => ({
value: d.id,
label: `${d.code}${d.names.en}`,
}));
const getDeptName = (deptId: string) => {
const dept = departments.find((d) => d.id === deptId);
return dept ? `${dept.code}${dept.names.en}` : '-';
};
return (
<>
<Group justify="space-between" mb="md">
<Text fw={600} size="sm">{t('configuration.professionsList')}</Text>
{!showProfForm && (
<Button
variant="light"
leftSection={<IconPlus size={16} />}
onClick={() => { resetProfForm(); setShowProfForm(true); }}
size="sm"
>
{t('configuration.addProfession')}
</Button>
)}
</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>
)}
<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>{t('configuration.department')}</Table.Th>
<Table.Th />
</Table.Tr>
</Table.Thead>
<Table.Tbody>
{professions.map((prof) => (
<Table.Tr key={prof.id}>
<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>
</Table.Td>
<Table.Td>{getDeptName(prof.departmentId)}</Table.Td>
<Table.Td>
<Group gap="xs">
<ActionIcon variant="subtle" color="blue" size="sm" onClick={() => handleEditProf(prof)}>
<IconEdit size={14} />
</ActionIcon>
<ActionIcon variant="subtle" color="red" size="sm" onClick={() => handleDeleteProf(prof)}>
<IconTrash size={14} />
</ActionIcon>
</Group>
</Table.Td>
</Table.Tr>
))}
{professions.length === 0 && (
<Table.Tr>
<Table.Td colSpan={6}>
<Text c="dimmed" ta="center" py="xl">
{t('configuration.noProfessions')}
</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={confirmDeleteProf} size="sm">{t('configuration.delete')}</Button>
</Group>
</Modal>
</>
);
}
export function ConfigurationPage() {
const { t } = useTranslation();
return (
<Stack gap="lg">
<Title order={2}>{t('configuration.title')}</Title>
<Tabs defaultValue="departments">
<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>
<Tabs.Tab value="locations" leftSection={<IconMap size={16} />}>
{t('location.title')}
</Tabs.Tab>
</Tabs.List>
<Tabs.Panel value="departments" pt="md">
<DepartmentTab />
</Tabs.Panel>
<Tabs.Panel value="professions" pt="md">
<ProfessionTab />
</Tabs.Panel>
<Tabs.Panel value="locations" pt="md">
<LocationPage />
</Tabs.Panel>
</Tabs>
</Stack>
);
}

View File

@@ -0,0 +1,50 @@
export interface NamePair {
en: string;
am: string;
}
export interface Department {
id: string;
code: string;
names: NamePair;
description: string;
createdAt: string;
updatedAt: string;
}
export interface Profession {
id: string;
code: string;
names: NamePair;
description: string;
departmentId: string;
department?: Department;
createdAt: string;
updatedAt: string;
}
export interface ListResponse<T> {
count: number;
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;
}
export interface UpdateProfessionPayload extends CreateProfessionPayload {
id: string;
}

View File

@@ -206,4 +206,39 @@ export const am: Translations = {
passwordMismatch: 'የይለፍ ቃላት አይዛመዱም',
},
},
configuration: {
title: 'ውቅረት',
departments: 'ክፍሎች',
professions: 'ሙያዎች',
departmentsList: 'ክፍሎች',
professionsList: 'ሙያዎች',
addDepartment: 'ክፍል ያክሉ',
addProfession: 'ሙያ ያክሉ',
code: 'ኮድ',
nameEn: 'ስም (እንግሊዝኛ)',
nameAm: 'ስም (አማርኛ)',
description: 'መግለጫ',
department: 'ክፍል',
selectDepartment: 'ክፍል ይምረጡ',
cancel: 'ሰርዝ',
create: 'ፍጠር',
update: 'አዘምን',
edit: 'አስተካክል',
delete: 'ሰርዝ',
created: 'በተሳካ ሁኔታ ተፈጥሯል',
updated: 'በተሳካ ሁኔታ ዘምኗል',
deleted: 'በተሳካ ሁኔታ ተሰርዟል',
error: 'አንድ ስህተት ተፈጥሯል',
confirmDelete: 'መሰረዝን ያረጋግጡ',
deleteConfirmText: 'እርግጠኛ ነዎት {{name}}ን መሰረዝ ይፈልጋሉ?',
noDepartments: 'ገና ምንም ክፍሎች አልተገለጹም',
noProfessions: 'ገና ምንም ሙያዎች አልተገለጹም',
validation: {
codeRequired: 'ኮድ ያስፈልጋል',
nameEnRequired: 'የእንግሊዝኛ ስም ያስፈልጋል',
nameAmRequired: 'የአማርኛ ስም ያስፈልጋል',
departmentRequired: 'ክፍል ያስፈልጋል',
},
},
};

View File

@@ -205,6 +205,41 @@ export const en = {
passwordMismatch: 'Passwords do not match',
},
},
configuration: {
title: 'Configuration',
departments: 'Departments',
professions: 'Professions',
departmentsList: 'Departments',
professionsList: 'Professions',
addDepartment: 'Add Department',
addProfession: 'Add Profession',
code: 'Code',
nameEn: 'Name (English)',
nameAm: 'Name (Amharic)',
description: 'Description',
department: 'Department',
selectDepartment: 'Select department',
cancel: 'Cancel',
create: 'Create',
update: 'Update',
edit: 'Edit',
delete: 'Delete',
created: 'Created successfully',
updated: 'Updated successfully',
deleted: 'Deleted successfully',
error: 'Something went wrong',
confirmDelete: 'Confirm Delete',
deleteConfirmText: 'Are you sure you want to delete {{name}}?',
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',
},
},
};
export type Translations = typeof en;

View File

@@ -9,7 +9,7 @@ import {
IconLayoutDashboard,
IconUsers,
IconUser,
IconMap,
IconSettings,
} from '@tabler/icons-react';
import { notify } from '@ema-platform/ui';
import { SUPPORTED_LANGUAGES } from '../i18n/config';
@@ -26,7 +26,7 @@ interface NavItem {
const NAV_ITEMS: NavItem[] = [
{ to: '/dashboard', label: 'Dashboard', icon: IconLayoutDashboard },
{ to: '/um/user-management/dashboard', label: 'User Management', icon: IconUsers },
{ to: '/locations', label: 'Locations', icon: IconMap },
{ to: '/configuration', label: 'Configuration', icon: IconSettings },
{ to: '/profile', label: 'Profile', icon: IconUser },
];

View File

@@ -14,7 +14,7 @@ import { ProtectedRoute } from './ProtectedRoute';
import { DashboardPage } from '../features/dashboard/pages/DashboardPage';
import UserManagementHostPage from '../features/user-management-host/UserManagementHostPage';
import { ProfilePage } from '../features/profile/pages/ProfilePage';
import { LocationPage } from '../features/location/pages/LocationPage';
import { ConfigurationPage } from '../features/configuration/pages/ConfigurationPage';
const router = createBrowserRouter([
{
@@ -35,7 +35,7 @@ const router = createBrowserRouter([
{ index: true, element: <Navigate to="/dashboard" replace /> },
{ path: 'dashboard', element: <DashboardPage /> },
{ path: 'profile', element: <ProfilePage /> },
{ path: 'locations', element: <LocationPage /> },
{ path: 'configuration', element: <ConfigurationPage /> },
],
},
],