RBAC: Phase 1-3, Total Salary fix, employee creation fix, permission groups, backup script
Made-with: Cursor
This commit is contained in:
@@ -43,8 +43,8 @@ export default function AuditLogs() {
|
||||
fetchLogs();
|
||||
}, [fetchLogs]);
|
||||
|
||||
const formatDate = (d: string) =>
|
||||
new Date(d).toLocaleString('ar-SA', { dateStyle: 'medium', timeStyle: 'short' });
|
||||
const formatDate = (d: string | null | undefined) =>
|
||||
d ? new Date(d).toLocaleString('ar-SA', { dateStyle: 'medium', timeStyle: 'short' }) : '-';
|
||||
|
||||
const getActionLabel = (a: string) => {
|
||||
const labels: Record<string, string> = {
|
||||
|
||||
@@ -7,6 +7,7 @@ import { usePathname } from 'next/navigation'
|
||||
import {
|
||||
Users,
|
||||
Shield,
|
||||
UsersRound,
|
||||
Database,
|
||||
Settings,
|
||||
FileText,
|
||||
@@ -27,6 +28,7 @@ function AdminLayoutContent({ children }: { children: React.ReactNode }) {
|
||||
{ icon: LayoutDashboard, label: 'لوحة التحكم', href: '/admin', exact: true },
|
||||
{ icon: Users, label: 'إدارة المستخدمين', href: '/admin/users' },
|
||||
{ icon: Shield, label: 'الأدوار والصلاحيات', href: '/admin/roles' },
|
||||
{ icon: UsersRound, label: 'مجموعات الصلاحيات', href: '/admin/permission-groups' },
|
||||
{ icon: Database, label: 'النسخ الاحتياطي', href: '/admin/backup' },
|
||||
{ icon: Settings, label: 'إعدادات النظام', href: '/admin/settings' },
|
||||
{ icon: FileText, label: 'سجل العمليات', href: '/admin/audit-logs' },
|
||||
|
||||
@@ -50,7 +50,8 @@ export default function AdminDashboard() {
|
||||
return labels[a] || a;
|
||||
};
|
||||
|
||||
const formatTime = (d: string) => {
|
||||
const formatTime = (d: string | null | undefined) => {
|
||||
if (!d) return '-';
|
||||
const diff = Date.now() - new Date(d).getTime();
|
||||
const mins = Math.floor(diff / 60000);
|
||||
const hours = Math.floor(diff / 3600000);
|
||||
|
||||
371
frontend/src/app/admin/permission-groups/page.tsx
Normal file
371
frontend/src/app/admin/permission-groups/page.tsx
Normal file
@@ -0,0 +1,371 @@
|
||||
'use client';
|
||||
|
||||
import { useState, useEffect, useCallback } from 'react';
|
||||
import { UsersRound, Edit, Users, Check, X, Plus } from 'lucide-react';
|
||||
import { permissionGroupsAPI } from '@/lib/api/admin';
|
||||
import type { PermissionGroup } from '@/lib/api/admin';
|
||||
import Modal from '@/components/Modal';
|
||||
import LoadingSpinner from '@/components/LoadingSpinner';
|
||||
|
||||
const MODULES = [
|
||||
{ id: 'contacts', name: 'إدارة جهات الاتصال', nameEn: 'Contact Management' },
|
||||
{ id: 'crm', name: 'إدارة علاقات العملاء', nameEn: 'CRM' },
|
||||
{ id: 'inventory', name: 'المخزون والأصول', nameEn: 'Inventory & Assets' },
|
||||
{ id: 'projects', name: 'المهام والمشاريع', nameEn: 'Tasks & Projects' },
|
||||
{ id: 'hr', name: 'الموارد البشرية', nameEn: 'HR Management' },
|
||||
{ id: 'marketing', name: 'التسويق', nameEn: 'Marketing' },
|
||||
{ id: 'admin', name: 'لوحة الإدارة', nameEn: 'Admin' },
|
||||
];
|
||||
|
||||
const ACTIONS = [
|
||||
{ id: 'read', name: 'عرض' },
|
||||
{ id: 'create', name: 'إنشاء' },
|
||||
{ id: 'update', name: 'تعديل' },
|
||||
{ id: 'delete', name: 'حذف' },
|
||||
{ id: 'export', name: 'تصدير' },
|
||||
{ id: 'approve', name: 'اعتماد' },
|
||||
{ id: 'merge', name: 'دمج' },
|
||||
];
|
||||
|
||||
function hasAction(perm: { actions?: unknown } | undefined, action: string): boolean {
|
||||
if (!perm?.actions) return false;
|
||||
const actions = Array.isArray(perm.actions) ? perm.actions : [];
|
||||
return actions.includes('*') || actions.includes('all') || actions.includes(action);
|
||||
}
|
||||
|
||||
function buildPermissionsFromMatrix(matrix: Record<string, Record<string, boolean>>) {
|
||||
return MODULES.filter((m) => Object.values(matrix[m.id] || {}).some(Boolean)).map((m) => {
|
||||
const actions = ACTIONS.filter((a) => matrix[m.id]?.[a.id]).map((a) => a.id);
|
||||
return {
|
||||
module: m.id,
|
||||
resource: '*',
|
||||
actions: actions.length === ACTIONS.length ? ['*'] : actions,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
function buildMatrixFromPermissions(permissions: { module: string; resource: string; actions: string[] }[]) {
|
||||
const matrix: Record<string, Record<string, boolean>> = {};
|
||||
for (const m of MODULES) {
|
||||
matrix[m.id] = {};
|
||||
const perm = permissions.find((p) => p.module === m.id && (p.resource === '*' || p.resource === m.id));
|
||||
const hasAll = perm && (Array.isArray(perm.actions)
|
||||
? perm.actions.includes('*') || perm.actions.includes('all')
|
||||
: false);
|
||||
for (const a of ACTIONS) {
|
||||
matrix[m.id][a.id] = hasAll || hasAction(perm, a.id);
|
||||
}
|
||||
}
|
||||
return matrix;
|
||||
}
|
||||
|
||||
export default function PermissionGroupsPage() {
|
||||
const [groups, setGroups] = useState<PermissionGroup[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [selectedId, setSelectedId] = useState<string | null>(null);
|
||||
const [showEditModal, setShowEditModal] = useState(false);
|
||||
const [showCreateModal, setShowCreateModal] = useState(false);
|
||||
const [createForm, setCreateForm] = useState({ name: '', nameAr: '', description: '' });
|
||||
type PermissionMatrix = Record<string, Record<string, boolean>>;
|
||||
const [permissionMatrix, setPermissionMatrix] = useState<PermissionMatrix>({});
|
||||
const [saving, setSaving] = useState(false);
|
||||
|
||||
const fetchGroups = useCallback(async () => {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
try {
|
||||
const list = await permissionGroupsAPI.getAll();
|
||||
setGroups(list);
|
||||
if (selectedId && !list.find((g) => g.id === selectedId)) setSelectedId(null);
|
||||
} catch (err: unknown) {
|
||||
setError(err instanceof Error ? err.message : 'فشل تحميل المجموعات');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [selectedId]);
|
||||
|
||||
useEffect(() => {
|
||||
fetchGroups();
|
||||
}, [fetchGroups]);
|
||||
|
||||
const currentGroup = groups.find((g) => g.id === selectedId);
|
||||
|
||||
useEffect(() => {
|
||||
if (currentGroup) {
|
||||
setPermissionMatrix(buildMatrixFromPermissions(currentGroup.permissions || []));
|
||||
}
|
||||
}, [currentGroup?.id, currentGroup?.permissions]);
|
||||
|
||||
const handleCreate = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
if (!createForm.name.trim()) {
|
||||
alert('الاسم مطلوب');
|
||||
return;
|
||||
}
|
||||
setSaving(true);
|
||||
try {
|
||||
const group = await permissionGroupsAPI.create({
|
||||
name: createForm.name.trim(),
|
||||
nameAr: createForm.nameAr.trim() || undefined,
|
||||
description: createForm.description.trim() || undefined,
|
||||
});
|
||||
setShowCreateModal(false);
|
||||
setCreateForm({ name: '', nameAr: '', description: '' });
|
||||
await fetchGroups();
|
||||
setSelectedId(group.id);
|
||||
setShowEditModal(true);
|
||||
} catch (err: unknown) {
|
||||
alert(err instanceof Error ? err.message : 'فشل الإنشاء');
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleSavePermissions = async () => {
|
||||
if (!selectedId) return;
|
||||
setSaving(true);
|
||||
try {
|
||||
const permissions = buildPermissionsFromMatrix(permissionMatrix);
|
||||
await permissionGroupsAPI.updatePermissions(selectedId, permissions);
|
||||
setShowEditModal(false);
|
||||
fetchGroups();
|
||||
} catch (err: unknown) {
|
||||
alert(err instanceof Error ? err.message : 'فشل الحفظ');
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleTogglePermission = (moduleId: string, actionId: string) => {
|
||||
setPermissionMatrix((prev) => ({
|
||||
...prev,
|
||||
[moduleId]: {
|
||||
...(prev[moduleId] || {}),
|
||||
[actionId]: !prev[moduleId]?.[actionId],
|
||||
},
|
||||
}));
|
||||
};
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="mb-8 flex items-center justify-between">
|
||||
<div>
|
||||
<h1 className="text-3xl font-bold text-gray-900 mb-2">مجموعات الصلاحيات</h1>
|
||||
<p className="text-gray-600">مجموعات اختيارية تضيف صلاحيات إضافية للمستخدمين بغض النظر عن وظائفهم</p>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => setShowCreateModal(true)}
|
||||
className="flex items-center gap-2 px-6 py-3 bg-blue-600 text-white rounded-lg hover:bg-blue-700 transition-all shadow-md"
|
||||
>
|
||||
<Plus className="h-5 w-5" />
|
||||
<span className="font-semibold">إضافة مجموعة</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{loading ? (
|
||||
<div className="flex justify-center p-12">
|
||||
<LoadingSpinner />
|
||||
</div>
|
||||
) : error ? (
|
||||
<div className="text-center text-red-600 p-12">{error}</div>
|
||||
) : (
|
||||
<div className="grid grid-cols-1 lg:grid-cols-3 gap-8">
|
||||
<div className="lg:col-span-1 space-y-4">
|
||||
<h2 className="text-xl font-bold text-gray-900 mb-4">المجموعات ({groups.length})</h2>
|
||||
{groups.map((g) => (
|
||||
<div
|
||||
key={g.id}
|
||||
onClick={() => setSelectedId(g.id)}
|
||||
className={`p-4 rounded-xl border-2 cursor-pointer transition-all ${
|
||||
selectedId === g.id ? 'border-blue-600 bg-blue-50' : 'border-gray-200 bg-white hover:border-blue-300'
|
||||
}`}
|
||||
>
|
||||
<div className="flex items-center gap-3 mb-2">
|
||||
<div className={`p-2 rounded-lg ${selectedId === g.id ? 'bg-blue-600' : 'bg-blue-100'}`}>
|
||||
<UsersRound className={`h-5 w-5 ${selectedId === g.id ? 'text-white' : 'text-blue-600'}`} />
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="font-bold text-gray-900">{g.nameAr || g.name}</h3>
|
||||
<p className="text-xs text-gray-600">{g.name}</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-sm text-gray-600">
|
||||
<Users className="h-4 w-4 inline mr-1" />
|
||||
{g._count?.userRoles ?? 0} مستخدم
|
||||
</span>
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
setSelectedId(g.id);
|
||||
setShowEditModal(true);
|
||||
}}
|
||||
className="p-1.5 text-blue-600 hover:bg-blue-50 rounded"
|
||||
>
|
||||
<Edit className="h-4 w-4" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="lg:col-span-2">
|
||||
{currentGroup ? (
|
||||
<div className="bg-white rounded-xl shadow-lg border p-6">
|
||||
<div className="flex justify-between mb-6">
|
||||
<div>
|
||||
<h2 className="text-2xl font-bold text-gray-900">{currentGroup.nameAr || currentGroup.name}</h2>
|
||||
<p className="text-gray-600">{currentGroup.name}</p>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => setShowEditModal(true)}
|
||||
className="px-4 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700"
|
||||
>
|
||||
تعديل الصلاحيات
|
||||
</button>
|
||||
</div>
|
||||
<h3 className="text-lg font-bold mb-4">مصفوفة الصلاحيات</h3>
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full">
|
||||
<thead>
|
||||
<tr className="border-b-2 border-gray-200">
|
||||
<th className="px-4 py-3 text-right text-sm font-bold min-w-[200px]">الوحدة</th>
|
||||
{ACTIONS.map((a) => (
|
||||
<th key={a.id} className="px-4 py-3 text-center text-sm font-bold">{a.name}</th>
|
||||
))}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-gray-200">
|
||||
{MODULES.map((m) => (
|
||||
<tr key={m.id} className="hover:bg-gray-50">
|
||||
<td className="px-4 py-4">
|
||||
<p className="font-semibold">{m.name}</p>
|
||||
<p className="text-xs text-gray-600">{m.nameEn}</p>
|
||||
</td>
|
||||
{ACTIONS.map((a) => {
|
||||
const has = permissionMatrix[m.id]?.[a.id];
|
||||
return (
|
||||
<td key={a.id} className="px-4 py-4 text-center">
|
||||
<div
|
||||
className={`w-10 h-10 rounded-lg flex items-center justify-center mx-auto ${
|
||||
has ? 'bg-green-500 text-white' : 'bg-gray-200 text-gray-500'
|
||||
}`}
|
||||
>
|
||||
{has ? <Check className="h-6 w-6" /> : <X className="h-6 w-6" />}
|
||||
</div>
|
||||
</td>
|
||||
);
|
||||
})}
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="bg-white rounded-xl shadow-lg border p-12 text-center">
|
||||
<UsersRound className="h-16 w-16 text-gray-300 mx-auto mb-4" />
|
||||
<h3 className="text-xl font-bold text-gray-900 mb-2">اختر مجموعة</h3>
|
||||
<p className="text-gray-600">أو أنشئ مجموعة جديدة لإضافة صلاحيات اختيارية للمستخدمين</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<Modal isOpen={showCreateModal} onClose={() => setShowCreateModal(false)} title="إضافة مجموعة صلاحيات" size="md">
|
||||
<form onSubmit={handleCreate} className="space-y-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">Name *</label>
|
||||
<input
|
||||
type="text"
|
||||
value={createForm.name}
|
||||
onChange={(e) => setCreateForm((p) => ({ ...p, name: e.target.value }))}
|
||||
className="w-full px-3 py-2 border border-gray-300 rounded-lg"
|
||||
placeholder="e.g. Campaign Approver"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">Name (Arabic)</label>
|
||||
<input
|
||||
type="text"
|
||||
value={createForm.nameAr}
|
||||
onChange={(e) => setCreateForm((p) => ({ ...p, nameAr: e.target.value }))}
|
||||
className="w-full px-3 py-2 border border-gray-300 rounded-lg"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">Description</label>
|
||||
<textarea
|
||||
value={createForm.description}
|
||||
onChange={(e) => setCreateForm((p) => ({ ...p, description: e.target.value }))}
|
||||
className="w-full px-3 py-2 border border-gray-300 rounded-lg"
|
||||
rows={2}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex gap-3 justify-end pt-4">
|
||||
<button type="button" onClick={() => setShowCreateModal(false)} className="px-6 py-3 border rounded-lg">
|
||||
Cancel
|
||||
</button>
|
||||
<button type="submit" disabled={saving} className="px-6 py-3 bg-blue-600 text-white rounded-lg disabled:opacity-50">
|
||||
{saving ? 'Creating...' : 'Create'}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</Modal>
|
||||
|
||||
<Modal
|
||||
isOpen={showEditModal}
|
||||
onClose={() => setShowEditModal(false)}
|
||||
title={`تعديل صلاحيات: ${currentGroup?.nameAr || currentGroup?.name || ''}`}
|
||||
size="2xl"
|
||||
>
|
||||
{currentGroup && (
|
||||
<div>
|
||||
<div className="overflow-x-auto mb-6">
|
||||
<table className="w-full">
|
||||
<thead>
|
||||
<tr className="border-b-2 border-gray-200">
|
||||
<th className="px-4 py-3 text-right text-sm font-bold">الوحدة</th>
|
||||
{ACTIONS.map((a) => (
|
||||
<th key={a.id} className="px-4 py-3 text-center text-sm font-bold">{a.name}</th>
|
||||
))}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-gray-200">
|
||||
{MODULES.map((m) => (
|
||||
<tr key={m.id}>
|
||||
<td className="px-4 py-4 font-semibold">{m.name}</td>
|
||||
{ACTIONS.map((a) => (
|
||||
<td key={a.id} className="px-4 py-4 text-center">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => handleTogglePermission(m.id, a.id)}
|
||||
className={`w-10 h-10 rounded-lg flex items-center justify-center mx-auto ${
|
||||
permissionMatrix[m.id]?.[a.id] ? 'bg-green-500 text-white' : 'bg-gray-200 text-gray-500'
|
||||
}`}
|
||||
>
|
||||
{permissionMatrix[m.id]?.[a.id] ? <Check className="h-6 w-6" /> : <X className="h-6 w-6" />}
|
||||
</button>
|
||||
</td>
|
||||
))}
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<div className="flex gap-3 justify-end">
|
||||
<button onClick={() => setShowEditModal(false)} className="px-6 py-3 border rounded-lg">
|
||||
إلغاء
|
||||
</button>
|
||||
<button onClick={handleSavePermissions} disabled={saving} className="px-6 py-3 bg-blue-600 text-white rounded-lg disabled:opacity-50">
|
||||
{saving ? 'جاري الحفظ...' : 'حفظ'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</Modal>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,9 +1,10 @@
|
||||
'use client';
|
||||
|
||||
import { useState, useEffect, useCallback } from 'react';
|
||||
import { Shield, Edit, Users, Check, X } from 'lucide-react';
|
||||
import { Shield, Edit, Users, Check, X, Plus } from 'lucide-react';
|
||||
import { positionsAPI } from '@/lib/api/admin';
|
||||
import type { PositionRole, PositionPermission } from '@/lib/api/admin';
|
||||
import { departmentsAPI } from '@/lib/api/employees';
|
||||
import type { PositionRole, PositionPermission, CreatePositionData } from '@/lib/api/admin';
|
||||
import Modal from '@/components/Modal';
|
||||
import LoadingSpinner from '@/components/LoadingSpinner';
|
||||
|
||||
@@ -59,12 +60,25 @@ function buildMatrixFromPermissions(permissions: PositionPermission[]): Record<s
|
||||
return matrix;
|
||||
}
|
||||
|
||||
const initialCreateForm: CreatePositionData & { description?: string } = {
|
||||
title: '',
|
||||
titleAr: '',
|
||||
code: '',
|
||||
departmentId: '',
|
||||
level: 5,
|
||||
description: '',
|
||||
};
|
||||
|
||||
export default function RolesManagement() {
|
||||
const [roles, setRoles] = useState<PositionRole[]>([]);
|
||||
const [departments, setDepartments] = useState<{ id: string; name: string; nameAr?: string | null }[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [selectedRoleId, setSelectedRoleId] = useState<string | null>(null);
|
||||
const [showEditModal, setShowEditModal] = useState(false);
|
||||
const [showCreateModal, setShowCreateModal] = useState(false);
|
||||
const [createForm, setCreateForm] = useState(initialCreateForm);
|
||||
const [createErrors, setCreateErrors] = useState<Record<string, string>>({});
|
||||
const [permissionMatrix, setPermissionMatrix] = useState<Record<string, Record<string, boolean>>>({});
|
||||
const [saving, setSaving] = useState(false);
|
||||
|
||||
@@ -88,8 +102,44 @@ export default function RolesManagement() {
|
||||
fetchRoles();
|
||||
}, [fetchRoles]);
|
||||
|
||||
useEffect(() => {
|
||||
departmentsAPI.getAll().then((depts) => setDepartments(depts)).catch(() => {});
|
||||
}, []);
|
||||
|
||||
const currentRole = roles.find((r) => r.id === selectedRoleId);
|
||||
|
||||
const handleCreateRole = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
const errs: Record<string, string> = {};
|
||||
if (!createForm.title?.trim()) errs.title = 'Required';
|
||||
if (!createForm.code?.trim()) errs.code = 'Required';
|
||||
if (!createForm.departmentId) errs.departmentId = 'Required';
|
||||
setCreateErrors(errs);
|
||||
if (Object.keys(errs).length > 0) return;
|
||||
|
||||
setSaving(true);
|
||||
try {
|
||||
const position = await positionsAPI.create({
|
||||
title: createForm.title.trim(),
|
||||
titleAr: createForm.titleAr?.trim() || undefined,
|
||||
code: createForm.code.trim(),
|
||||
departmentId: createForm.departmentId,
|
||||
level: createForm.level ?? 5,
|
||||
description: createForm.description?.trim() || undefined,
|
||||
});
|
||||
setShowCreateModal(false);
|
||||
setCreateForm(initialCreateForm);
|
||||
setCreateErrors({});
|
||||
await fetchRoles();
|
||||
setSelectedRoleId(position.id);
|
||||
setShowEditModal(true);
|
||||
} catch (err: unknown) {
|
||||
setCreateErrors({ form: err instanceof Error ? err.message : 'Failed to create role' });
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (currentRole) {
|
||||
setPermissionMatrix(buildMatrixFromPermissions(currentRole.permissions || []));
|
||||
@@ -133,6 +183,13 @@ export default function RolesManagement() {
|
||||
<h1 className="text-3xl font-bold text-gray-900 mb-2">الأدوار والصلاحيات</h1>
|
||||
<p className="text-gray-600">إدارة أدوار المستخدمين ومصفوفة الصلاحيات</p>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => setShowCreateModal(true)}
|
||||
className="flex items-center gap-2 px-6 py-3 bg-purple-600 text-white rounded-lg hover:bg-purple-700 transition-all shadow-md hover:shadow-lg"
|
||||
>
|
||||
<Plus className="h-5 w-5" />
|
||||
<span className="font-semibold">إضافة دور</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{loading ? (
|
||||
@@ -269,6 +326,104 @@ export default function RolesManagement() {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Create Role Modal */}
|
||||
<Modal
|
||||
isOpen={showCreateModal}
|
||||
onClose={() => {
|
||||
setShowCreateModal(false);
|
||||
setCreateForm(initialCreateForm);
|
||||
setCreateErrors({});
|
||||
}}
|
||||
title="إضافة دور جديد"
|
||||
size="md"
|
||||
>
|
||||
<form onSubmit={handleCreateRole} className="space-y-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">Title (English) *</label>
|
||||
<input
|
||||
type="text"
|
||||
value={createForm.title}
|
||||
onChange={(e) => setCreateForm((p) => ({ ...p, title: e.target.value }))}
|
||||
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-purple-500"
|
||||
placeholder="e.g. Sales Representative"
|
||||
/>
|
||||
{createErrors.title && <p className="text-red-500 text-xs mt-1">{createErrors.title}</p>}
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">Title (Arabic)</label>
|
||||
<input
|
||||
type="text"
|
||||
value={createForm.titleAr || ''}
|
||||
onChange={(e) => setCreateForm((p) => ({ ...p, titleAr: e.target.value }))}
|
||||
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-purple-500"
|
||||
placeholder="مندوب مبيعات"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">Code *</label>
|
||||
<input
|
||||
type="text"
|
||||
value={createForm.code}
|
||||
onChange={(e) => setCreateForm((p) => ({ ...p, code: e.target.value }))}
|
||||
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-purple-500"
|
||||
placeholder="SALES_REP"
|
||||
/>
|
||||
{createErrors.code && <p className="text-red-500 text-xs mt-1">{createErrors.code}</p>}
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">Department *</label>
|
||||
<select
|
||||
value={createForm.departmentId}
|
||||
onChange={(e) => setCreateForm((p) => ({ ...p, departmentId: e.target.value }))}
|
||||
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-purple-500"
|
||||
>
|
||||
<option value="">Select department</option>
|
||||
{departments.map((d) => (
|
||||
<option key={d.id} value={d.id}>{d.nameAr || d.name}</option>
|
||||
))}
|
||||
</select>
|
||||
{createErrors.departmentId && <p className="text-red-500 text-xs mt-1">{createErrors.departmentId}</p>}
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">Level</label>
|
||||
<input
|
||||
type="number"
|
||||
min={1}
|
||||
value={createForm.level ?? 5}
|
||||
onChange={(e) => setCreateForm((p) => ({ ...p, level: parseInt(e.target.value, 10) || 5 }))}
|
||||
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-purple-500"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">Description</label>
|
||||
<textarea
|
||||
value={createForm.description || ''}
|
||||
onChange={(e) => setCreateForm((p) => ({ ...p, description: e.target.value }))}
|
||||
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-purple-500"
|
||||
rows={2}
|
||||
placeholder="Optional description"
|
||||
/>
|
||||
</div>
|
||||
{createErrors.form && <p className="text-red-500 text-sm">{createErrors.form}</p>}
|
||||
<div className="flex gap-3 justify-end pt-4">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowCreateModal(false)}
|
||||
className="px-6 py-3 border border-gray-300 text-gray-700 rounded-lg hover:bg-gray-50 font-medium"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
type="submit"
|
||||
disabled={saving}
|
||||
className="px-6 py-3 bg-purple-600 text-white rounded-lg hover:bg-purple-700 font-semibold disabled:opacity-50"
|
||||
>
|
||||
{saving ? 'Creating...' : 'Create Role'}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</Modal>
|
||||
|
||||
{/* Edit Permissions Modal */}
|
||||
<Modal
|
||||
isOpen={showEditModal}
|
||||
|
||||
@@ -13,7 +13,7 @@ import {
|
||||
Shield,
|
||||
Calendar,
|
||||
} from 'lucide-react';
|
||||
import { usersAPI, statsAPI, positionsAPI } from '@/lib/api/admin';
|
||||
import { usersAPI, statsAPI, positionsAPI, userRolesAPI, permissionGroupsAPI } from '@/lib/api/admin';
|
||||
import { employeesAPI } from '@/lib/api/employees';
|
||||
import type { User, CreateUserData, UpdateUserData } from '@/lib/api/admin';
|
||||
import type { Employee } from '@/lib/api/employees';
|
||||
@@ -567,6 +567,10 @@ function EditUserModal({
|
||||
employeeId: null,
|
||||
isActive: true,
|
||||
});
|
||||
const [userRoles, setUserRoles] = useState<{ id: string; role: { id: string; name: string; nameAr?: string | null } }[]>([]);
|
||||
const [permissionGroups, setPermissionGroups] = useState<{ id: string; name: string; nameAr?: string | null }[]>([]);
|
||||
const [assignRoleId, setAssignRoleId] = useState('');
|
||||
const [rolesLoading, setRolesLoading] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (user) {
|
||||
@@ -580,6 +584,41 @@ function EditUserModal({
|
||||
}
|
||||
}, [user]);
|
||||
|
||||
useEffect(() => {
|
||||
if (isOpen && user) {
|
||||
userRolesAPI.getAll(user.id).then((r) => setUserRoles(r)).catch(() => setUserRoles([]));
|
||||
permissionGroupsAPI.getAll().then((g) => setPermissionGroups(g)).catch(() => setPermissionGroups([]));
|
||||
}
|
||||
}, [isOpen, user?.id]);
|
||||
|
||||
const handleAssignRole = async () => {
|
||||
if (!user || !assignRoleId) return;
|
||||
setRolesLoading(true);
|
||||
try {
|
||||
await userRolesAPI.assign(user.id, assignRoleId);
|
||||
const updated = await userRolesAPI.getAll(user.id);
|
||||
setUserRoles(updated);
|
||||
setAssignRoleId('');
|
||||
} catch (err: unknown) {
|
||||
alert(err instanceof Error ? err.message : 'فشل الإضافة');
|
||||
} finally {
|
||||
setRolesLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleRemoveRole = async (roleId: string) => {
|
||||
if (!user) return;
|
||||
setRolesLoading(true);
|
||||
try {
|
||||
await userRolesAPI.remove(user.id, roleId);
|
||||
setUserRoles((prev) => prev.filter((ur) => ur.role.id !== roleId));
|
||||
} catch (err: unknown) {
|
||||
alert(err instanceof Error ? err.message : 'فشل الإزالة');
|
||||
} finally {
|
||||
setRolesLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
if (!user) return;
|
||||
@@ -670,6 +709,50 @@ function EditUserModal({
|
||||
الحساب نشط
|
||||
</label>
|
||||
</div>
|
||||
<div className="border-t pt-4 mt-4">
|
||||
<label className="block text-sm font-semibold text-gray-700 mb-2">مجموعات الصلاحيات الإضافية</label>
|
||||
<p className="text-xs text-gray-600 mb-2">صلاحيات اختيارية تضاف إلى صلاحيات الوظيفة</p>
|
||||
<div className="flex gap-2 mb-3">
|
||||
<select
|
||||
value={assignRoleId}
|
||||
onChange={(e) => setAssignRoleId(e.target.value)}
|
||||
className="flex-1 px-4 py-2 border border-gray-300 rounded-lg"
|
||||
>
|
||||
<option value="">إضافة مجموعة...</option>
|
||||
{permissionGroups
|
||||
.filter((g) => !userRoles.some((ur) => ur.role.id === g.id))
|
||||
.map((g) => (
|
||||
<option key={g.id} value={g.id}>{g.nameAr || g.name}</option>
|
||||
))}
|
||||
</select>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleAssignRole}
|
||||
disabled={!assignRoleId || rolesLoading}
|
||||
className="px-4 py-2 bg-blue-100 text-blue-700 rounded-lg hover:bg-blue-200 disabled:opacity-50"
|
||||
>
|
||||
إضافة
|
||||
</button>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
{userRoles.map((ur) => (
|
||||
<div key={ur.id} className="flex items-center justify-between py-2 px-3 bg-gray-50 rounded-lg">
|
||||
<span className="font-medium">{ur.role.nameAr || ur.role.name}</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => handleRemoveRole(ur.role.id)}
|
||||
disabled={rolesLoading}
|
||||
className="text-red-600 hover:text-red-700 text-sm disabled:opacity-50"
|
||||
>
|
||||
إزالة
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
{userRoles.length === 0 && (
|
||||
<p className="text-sm text-gray-500 py-2">لا توجد مجموعات إضافية</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex gap-3 justify-end pt-4">
|
||||
<button type="button" onClick={onClose} className="px-6 py-3 border border-gray-300 text-gray-700 rounded-lg hover:bg-gray-50 font-medium">
|
||||
إلغاء
|
||||
|
||||
@@ -902,11 +902,11 @@ function CRMContent() {
|
||||
<td className="px-6 py-4">
|
||||
<div>
|
||||
<span className="text-sm font-semibold text-gray-900">
|
||||
{deal.estimatedValue.toLocaleString()} SAR
|
||||
{(deal.estimatedValue ?? 0).toLocaleString()} SAR
|
||||
</span>
|
||||
{deal.actualValue && (
|
||||
{(deal.actualValue ?? 0) > 0 && (
|
||||
<p className="text-xs text-green-600">
|
||||
Actual: {deal.actualValue.toLocaleString()}
|
||||
Actual: {(deal.actualValue ?? 0).toLocaleString()}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
'use client'
|
||||
|
||||
import { useState, useEffect } from 'react'
|
||||
import ProtectedRoute from '@/components/ProtectedRoute'
|
||||
import { useAuth } from '@/contexts/AuthContext'
|
||||
import { useLanguage } from '@/contexts/LanguageContext'
|
||||
@@ -18,10 +19,20 @@ import {
|
||||
Bell,
|
||||
Shield
|
||||
} from 'lucide-react'
|
||||
import { dashboardAPI } from '@/lib/api'
|
||||
|
||||
function DashboardContent() {
|
||||
const { user, logout, hasPermission } = useAuth()
|
||||
const { t, language, dir } = useLanguage()
|
||||
const [stats, setStats] = useState({ contacts: 0, activeTasks: 0, notifications: 0 })
|
||||
|
||||
useEffect(() => {
|
||||
dashboardAPI.getStats()
|
||||
.then((res) => {
|
||||
if (res.data?.data) setStats(res.data.data)
|
||||
})
|
||||
.catch(() => {})
|
||||
}, [])
|
||||
|
||||
const allModules = [
|
||||
{
|
||||
@@ -128,7 +139,7 @@ function DashboardContent() {
|
||||
</div>
|
||||
|
||||
{/* Admin Panel Link - Only for admins */}
|
||||
{(hasPermission('admin', 'view') || user?.role?.name === 'المدير العام' || user?.role?.nameEn === 'General Manager') && (
|
||||
{hasPermission('admin', 'view') && (
|
||||
<Link
|
||||
href="/admin"
|
||||
className="p-2 hover:bg-red-50 rounded-lg transition-colors relative group"
|
||||
@@ -144,7 +155,9 @@ function DashboardContent() {
|
||||
{/* Notifications */}
|
||||
<button className="p-2 hover:bg-gray-100 rounded-lg transition-colors relative">
|
||||
<Bell className="h-5 w-5 text-gray-600" />
|
||||
<span className="absolute top-1 right-1 h-2 w-2 bg-red-500 rounded-full"></span>
|
||||
{stats.notifications > 0 && (
|
||||
<span className="absolute top-1 right-1 h-2 w-2 bg-red-500 rounded-full"></span>
|
||||
)}
|
||||
</button>
|
||||
|
||||
{/* Settings */}
|
||||
@@ -193,7 +206,7 @@ function DashboardContent() {
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className="text-sm text-gray-600">المهام النشطة</p>
|
||||
<p className="text-3xl font-bold text-gray-900 mt-1">12</p>
|
||||
<p className="text-3xl font-bold text-gray-900 mt-1">{stats.activeTasks}</p>
|
||||
</div>
|
||||
<div className="bg-green-100 p-3 rounded-lg">
|
||||
<CheckSquare className="h-8 w-8 text-green-600" />
|
||||
@@ -205,7 +218,7 @@ function DashboardContent() {
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className="text-sm text-gray-600">الإشعارات</p>
|
||||
<p className="text-3xl font-bold text-gray-900 mt-1">5</p>
|
||||
<p className="text-3xl font-bold text-gray-900 mt-1">{stats.notifications}</p>
|
||||
</div>
|
||||
<div className="bg-orange-100 p-3 rounded-lg">
|
||||
<Bell className="h-8 w-8 text-orange-600" />
|
||||
@@ -217,7 +230,7 @@ function DashboardContent() {
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className="text-sm text-gray-600">جهات الاتصال</p>
|
||||
<p className="text-3xl font-bold text-gray-900 mt-1">248</p>
|
||||
<p className="text-3xl font-bold text-gray-900 mt-1">{stats.contacts}</p>
|
||||
</div>
|
||||
<div className="bg-purple-100 p-3 rounded-lg">
|
||||
<Users className="h-8 w-8 text-purple-600" />
|
||||
@@ -268,37 +281,7 @@ function DashboardContent() {
|
||||
{/* Recent Activity */}
|
||||
<div className="bg-white rounded-xl shadow-md p-6 border border-gray-100">
|
||||
<h3 className="text-xl font-bold text-gray-900 mb-4">النشاط الأخير</h3>
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-start gap-3 p-3 hover:bg-gray-50 rounded-lg transition-colors">
|
||||
<div className="bg-blue-100 p-2 rounded-lg">
|
||||
<Users className="h-5 w-5 text-blue-600" />
|
||||
</div>
|
||||
<div className="flex-1">
|
||||
<p className="text-sm font-semibold text-gray-900">تم إضافة عميل جديد</p>
|
||||
<p className="text-xs text-gray-600">منذ ساعتين</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-start gap-3 p-3 hover:bg-gray-50 rounded-lg transition-colors">
|
||||
<div className="bg-green-100 p-2 rounded-lg">
|
||||
<TrendingUp className="h-5 w-5 text-green-600" />
|
||||
</div>
|
||||
<div className="flex-1">
|
||||
<p className="text-sm font-semibold text-gray-900">تم إغلاق صفقة جديدة</p>
|
||||
<p className="text-xs text-gray-600">منذ 4 ساعات</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-start gap-3 p-3 hover:bg-gray-50 rounded-lg transition-colors">
|
||||
<div className="bg-orange-100 p-2 rounded-lg">
|
||||
<CheckSquare className="h-5 w-5 text-orange-600" />
|
||||
</div>
|
||||
<div className="flex-1">
|
||||
<p className="text-sm font-semibold text-gray-900">تم إكمال مهمة</p>
|
||||
<p className="text-xs text-gray-600">منذ يوم واحد</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<p className="text-gray-500 text-center py-6">لا يوجد نشاط حديث</p>
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
|
||||
@@ -23,9 +23,221 @@ import {
|
||||
Building2,
|
||||
User,
|
||||
CheckCircle2,
|
||||
XCircle
|
||||
XCircle,
|
||||
Network
|
||||
} from 'lucide-react'
|
||||
import { employeesAPI, Employee, CreateEmployeeData, UpdateEmployeeData, EmployeeFilters, departmentsAPI, positionsAPI } from '@/lib/api/employees'
|
||||
import dynamic from 'next/dynamic'
|
||||
import { employeesAPI, Employee, CreateEmployeeData, UpdateEmployeeData, EmployeeFilters, departmentsAPI, positionsAPI, type Department } from '@/lib/api/employees'
|
||||
|
||||
const OrgChart = dynamic(() => import('@/components/hr/OrgChart'), { ssr: false })
|
||||
|
||||
// Form fields extracted to module level to prevent focus loss on re-render
|
||||
function EmployeeFormFields({
|
||||
formData,
|
||||
setFormData,
|
||||
formErrors,
|
||||
departments,
|
||||
positions,
|
||||
loadingDepts,
|
||||
isEdit,
|
||||
onCancel,
|
||||
submitting,
|
||||
}: {
|
||||
formData: CreateEmployeeData
|
||||
setFormData: React.Dispatch<React.SetStateAction<CreateEmployeeData>>
|
||||
formErrors: Record<string, string>
|
||||
departments: any[]
|
||||
positions: any[]
|
||||
loadingDepts: boolean
|
||||
isEdit: boolean
|
||||
onCancel: () => void
|
||||
submitting: boolean
|
||||
}) {
|
||||
return (
|
||||
<div className="space-y-4 max-h-[60vh] overflow-y-auto pr-2">
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||
First Name <span className="text-red-500">*</span>
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={formData.firstName}
|
||||
onChange={(e) => setFormData((prev) => ({ ...prev, firstName: e.target.value }))}
|
||||
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-red-500"
|
||||
/>
|
||||
{formErrors.firstName && <p className="text-red-500 text-xs mt-1">{formErrors.firstName}</p>}
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||
Last Name <span className="text-red-500">*</span>
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={formData.lastName}
|
||||
onChange={(e) => setFormData((prev) => ({ ...prev, lastName: e.target.value }))}
|
||||
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-red-500"
|
||||
/>
|
||||
{formErrors.lastName && <p className="text-red-500 text-xs mt-1">{formErrors.lastName}</p>}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||
Email <span className="text-red-500">*</span>
|
||||
</label>
|
||||
<input
|
||||
type="email"
|
||||
value={formData.email}
|
||||
onChange={(e) => setFormData((prev) => ({ ...prev, email: e.target.value }))}
|
||||
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-red-500"
|
||||
/>
|
||||
{formErrors.email && <p className="text-red-500 text-xs mt-1">{formErrors.email}</p>}
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||
Mobile <span className="text-red-500">*</span>
|
||||
</label>
|
||||
<input
|
||||
type="tel"
|
||||
value={formData.mobile}
|
||||
onChange={(e) => setFormData((prev) => ({ ...prev, mobile: e.target.value }))}
|
||||
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-red-500"
|
||||
/>
|
||||
{formErrors.mobile && <p className="text-red-500 text-xs mt-1">{formErrors.mobile}</p>}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||
Department <span className="text-red-500">*</span>
|
||||
</label>
|
||||
<select
|
||||
value={formData.departmentId}
|
||||
onChange={(e) => setFormData((prev) => ({ ...prev, departmentId: e.target.value }))}
|
||||
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-red-500"
|
||||
disabled={loadingDepts}
|
||||
>
|
||||
<option value="">Select Department</option>
|
||||
{departments.map((dept) => (
|
||||
<option key={dept.id} value={dept.id}>{dept.nameAr || dept.name}</option>
|
||||
))}
|
||||
</select>
|
||||
{formErrors.departmentId && <p className="text-red-500 text-xs mt-1">{formErrors.departmentId}</p>}
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||
Position <span className="text-red-500">*</span>
|
||||
</label>
|
||||
<select
|
||||
value={formData.positionId}
|
||||
onChange={(e) => setFormData((prev) => ({ ...prev, positionId: e.target.value }))}
|
||||
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-red-500"
|
||||
disabled={loadingDepts}
|
||||
>
|
||||
<option value="">Select Position</option>
|
||||
{positions.map((pos) => (
|
||||
<option key={pos.id} value={pos.id}>{pos.titleAr || pos.title}</option>
|
||||
))}
|
||||
</select>
|
||||
{formErrors.positionId && <p className="text-red-500 text-xs mt-1">{formErrors.positionId}</p>}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||
Employment Type <span className="text-red-500">*</span>
|
||||
</label>
|
||||
<select
|
||||
value={formData.employmentType}
|
||||
onChange={(e) => setFormData((prev) => ({ ...prev, employmentType: e.target.value }))}
|
||||
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-red-500"
|
||||
>
|
||||
<option value="FULL_TIME">Full Time - دوام كامل</option>
|
||||
<option value="PART_TIME">Part Time - دوام جزئي</option>
|
||||
<option value="CONTRACT">Contract - عقد</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||
Contract Type
|
||||
</label>
|
||||
<select
|
||||
value={formData.contractType || ''}
|
||||
onChange={(e) => setFormData((prev) => ({ ...prev, contractType: e.target.value }))}
|
||||
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-red-500"
|
||||
>
|
||||
<option value="UNLIMITED">Unlimited - غير محدود</option>
|
||||
<option value="FIXED">Fixed - محدد</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||
Hire Date <span className="text-red-500">*</span>
|
||||
</label>
|
||||
<input
|
||||
type="date"
|
||||
value={formData.hireDate}
|
||||
onChange={(e) => setFormData((prev) => ({ ...prev, hireDate: e.target.value }))}
|
||||
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-red-500"
|
||||
/>
|
||||
{formErrors.hireDate && <p className="text-red-500 text-xs mt-1">{formErrors.hireDate}</p>}
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||
Base Salary (SAR) <span className="text-red-500">*</span>
|
||||
</label>
|
||||
<input
|
||||
type="number"
|
||||
min="0"
|
||||
value={formData.baseSalary}
|
||||
onChange={(e) => setFormData((prev) => ({ ...prev, baseSalary: parseFloat(e.target.value) || 0 }))}
|
||||
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-red-500"
|
||||
/>
|
||||
{formErrors.baseSalary && <p className="text-red-500 text-xs mt-1">{formErrors.baseSalary}</p>}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-end gap-3 pt-4 border-t">
|
||||
<button
|
||||
type="button"
|
||||
onClick={onCancel}
|
||||
className="px-4 py-2 border border-gray-300 text-gray-700 rounded-lg hover:bg-gray-50 transition-colors"
|
||||
disabled={submitting}
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
type="submit"
|
||||
className="flex items-center gap-2 px-4 py-2 bg-red-600 text-white rounded-lg hover:bg-red-700 transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
disabled={submitting}
|
||||
>
|
||||
{submitting ? (
|
||||
<>
|
||||
<Loader2 className="h-4 w-4 animate-spin" />
|
||||
{isEdit ? 'Updating...' : 'Creating...'}
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
{isEdit ? 'Update Employee' : 'Create Employee'}
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function HRContent() {
|
||||
// State Management
|
||||
@@ -79,6 +291,42 @@ function HRContent() {
|
||||
const [positions, setPositions] = useState<any[]>([])
|
||||
const [loadingDepts, setLoadingDepts] = useState(false)
|
||||
|
||||
// Tabs: employees | departments | orgchart
|
||||
const [activeTab, setActiveTab] = useState<'employees' | 'departments' | 'orgchart'>('employees')
|
||||
const [hierarchy, setHierarchy] = useState<Department[]>([])
|
||||
const [loadingHierarchy, setLoadingHierarchy] = useState(false)
|
||||
|
||||
// Department CRUD
|
||||
const [showDeptModal, setShowDeptModal] = useState(false)
|
||||
const [editingDept, setEditingDept] = useState<Department | null>(null)
|
||||
const [deptFormData, setDeptFormData] = useState({ name: '', nameAr: '', code: '', parentId: '' as string, description: '' })
|
||||
const [deptFormErrors, setDeptFormErrors] = useState<Record<string, string>>({})
|
||||
const [deptDeleteConfirm, setDeptDeleteConfirm] = useState<string | null>(null)
|
||||
|
||||
const fetchDepartments = useCallback(async () => {
|
||||
setLoadingDepts(true)
|
||||
try {
|
||||
const depts = await departmentsAPI.getAll()
|
||||
setDepartments(depts)
|
||||
} catch (err) {
|
||||
console.error('Failed to load departments:', err)
|
||||
} finally {
|
||||
setLoadingDepts(false)
|
||||
}
|
||||
}, [])
|
||||
|
||||
const fetchHierarchy = useCallback(async () => {
|
||||
setLoadingHierarchy(true)
|
||||
try {
|
||||
const tree = await departmentsAPI.getHierarchy()
|
||||
setHierarchy(tree)
|
||||
} catch (err) {
|
||||
console.error('Failed to load hierarchy:', err)
|
||||
} finally {
|
||||
setLoadingHierarchy(false)
|
||||
}
|
||||
}, [])
|
||||
|
||||
// Fetch Departments & Positions
|
||||
useEffect(() => {
|
||||
const fetchData = async () => {
|
||||
@@ -99,6 +347,11 @@ function HRContent() {
|
||||
fetchData()
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
if (activeTab === 'departments') fetchDepartments()
|
||||
if (activeTab === 'orgchart') fetchHierarchy()
|
||||
}, [activeTab, fetchDepartments, fetchHierarchy])
|
||||
|
||||
// Fetch Employees (with debouncing for search)
|
||||
const fetchEmployees = useCallback(async () => {
|
||||
setLoading(true)
|
||||
@@ -290,7 +543,7 @@ function HRContent() {
|
||||
departmentId: employee.departmentId,
|
||||
positionId: employee.positionId,
|
||||
reportingToId: employee.reportingToId,
|
||||
baseSalary: employee.baseSalary
|
||||
baseSalary: employee.baseSalary ?? (employee as any).basicSalary ?? 0
|
||||
})
|
||||
setShowEditModal(true)
|
||||
}
|
||||
@@ -300,198 +553,79 @@ function HRContent() {
|
||||
setShowDeleteDialog(true)
|
||||
}
|
||||
|
||||
// Calculate stats
|
||||
// Department CRUD
|
||||
const openDeptModal = (dept?: Department) => {
|
||||
if (dept) {
|
||||
setEditingDept(dept)
|
||||
setDeptFormData({
|
||||
name: dept.name,
|
||||
nameAr: dept.nameAr || '',
|
||||
code: dept.code,
|
||||
parentId: dept.parentId || '',
|
||||
description: dept.description || ''
|
||||
})
|
||||
} else {
|
||||
setEditingDept(null)
|
||||
setDeptFormData({ name: '', nameAr: '', code: '', parentId: '', description: '' })
|
||||
}
|
||||
setDeptFormErrors({})
|
||||
setShowDeptModal(true)
|
||||
}
|
||||
|
||||
const validateDeptForm = () => {
|
||||
const err: Record<string, string> = {}
|
||||
if (!deptFormData.name?.trim()) err.name = 'Name is required'
|
||||
if (!deptFormData.code?.trim()) err.code = 'Code is required'
|
||||
setDeptFormErrors(err)
|
||||
return Object.keys(err).length === 0
|
||||
}
|
||||
|
||||
const handleSaveDept = async (e: React.FormEvent) => {
|
||||
e.preventDefault()
|
||||
if (!validateDeptForm()) return
|
||||
try {
|
||||
if (editingDept) {
|
||||
await departmentsAPI.update(editingDept.id, {
|
||||
...deptFormData,
|
||||
parentId: deptFormData.parentId || null
|
||||
})
|
||||
toast.success('Department updated')
|
||||
} else {
|
||||
await departmentsAPI.create({
|
||||
...deptFormData,
|
||||
parentId: deptFormData.parentId || undefined
|
||||
})
|
||||
toast.success('Department created')
|
||||
}
|
||||
setShowDeptModal(false)
|
||||
fetchDepartments()
|
||||
if (activeTab === 'orgchart') fetchHierarchy()
|
||||
setDepartments(await departmentsAPI.getAll())
|
||||
} catch (err: any) {
|
||||
toast.error(err.response?.data?.message || 'Failed to save department')
|
||||
}
|
||||
}
|
||||
|
||||
const handleDeleteDept = async (id: string) => {
|
||||
try {
|
||||
await departmentsAPI.delete(id)
|
||||
toast.success('Department deleted')
|
||||
setDeptDeleteConfirm(null)
|
||||
fetchDepartments()
|
||||
if (activeTab === 'orgchart') fetchHierarchy()
|
||||
setDepartments(await departmentsAPI.getAll())
|
||||
} catch (err: any) {
|
||||
toast.error(err.response?.data?.message || 'Failed to delete department')
|
||||
setDeptDeleteConfirm(null)
|
||||
}
|
||||
}
|
||||
|
||||
// Calculate stats (coerce to number - API may return Decimal as string/object)
|
||||
const activeEmployees = employees.filter(e => e.status === 'ACTIVE').length
|
||||
const totalSalary = employees.reduce((sum, e) => sum + e.baseSalary, 0)
|
||||
|
||||
// Render Form Fields Component
|
||||
const FormFields = ({ isEdit = false }: { isEdit?: boolean }) => (
|
||||
<div className="space-y-4 max-h-[60vh] overflow-y-auto pr-2">
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||
First Name <span className="text-red-500">*</span>
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={formData.firstName}
|
||||
onChange={(e) => setFormData({ ...formData, firstName: e.target.value })}
|
||||
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-red-500"
|
||||
/>
|
||||
{formErrors.firstName && <p className="text-red-500 text-xs mt-1">{formErrors.firstName}</p>}
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||
Last Name <span className="text-red-500">*</span>
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={formData.lastName}
|
||||
onChange={(e) => setFormData({ ...formData, lastName: e.target.value })}
|
||||
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-red-500"
|
||||
/>
|
||||
{formErrors.lastName && <p className="text-red-500 text-xs mt-1">{formErrors.lastName}</p>}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||
Email <span className="text-red-500">*</span>
|
||||
</label>
|
||||
<input
|
||||
type="email"
|
||||
value={formData.email}
|
||||
onChange={(e) => setFormData({ ...formData, email: e.target.value })}
|
||||
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-red-500"
|
||||
/>
|
||||
{formErrors.email && <p className="text-red-500 text-xs mt-1">{formErrors.email}</p>}
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||
Mobile <span className="text-red-500">*</span>
|
||||
</label>
|
||||
<input
|
||||
type="tel"
|
||||
value={formData.mobile}
|
||||
onChange={(e) => setFormData({ ...formData, mobile: e.target.value })}
|
||||
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-red-500"
|
||||
/>
|
||||
{formErrors.mobile && <p className="text-red-500 text-xs mt-1">{formErrors.mobile}</p>}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||
Department <span className="text-red-500">*</span>
|
||||
</label>
|
||||
<select
|
||||
value={formData.departmentId}
|
||||
onChange={(e) => setFormData({ ...formData, departmentId: e.target.value })}
|
||||
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-red-500"
|
||||
disabled={loadingDepts}
|
||||
>
|
||||
<option value="">Select Department</option>
|
||||
{departments.map(dept => (
|
||||
<option key={dept.id} value={dept.id}>{dept.nameAr || dept.name}</option>
|
||||
))}
|
||||
</select>
|
||||
{formErrors.departmentId && <p className="text-red-500 text-xs mt-1">{formErrors.departmentId}</p>}
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||
Position <span className="text-red-500">*</span>
|
||||
</label>
|
||||
<select
|
||||
value={formData.positionId}
|
||||
onChange={(e) => setFormData({ ...formData, positionId: e.target.value })}
|
||||
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-red-500"
|
||||
disabled={loadingDepts}
|
||||
>
|
||||
<option value="">Select Position</option>
|
||||
{positions.map(pos => (
|
||||
<option key={pos.id} value={pos.id}>{pos.titleAr || pos.title}</option>
|
||||
))}
|
||||
</select>
|
||||
{formErrors.positionId && <p className="text-red-500 text-xs mt-1">{formErrors.positionId}</p>}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||
Employment Type <span className="text-red-500">*</span>
|
||||
</label>
|
||||
<select
|
||||
value={formData.employmentType}
|
||||
onChange={(e) => setFormData({ ...formData, employmentType: e.target.value })}
|
||||
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-red-500"
|
||||
>
|
||||
<option value="FULL_TIME">Full Time - دوام كامل</option>
|
||||
<option value="PART_TIME">Part Time - دوام جزئي</option>
|
||||
<option value="CONTRACT">Contract - عقد</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||
Contract Type
|
||||
</label>
|
||||
<select
|
||||
value={formData.contractType || ''}
|
||||
onChange={(e) => setFormData({ ...formData, contractType: e.target.value })}
|
||||
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-red-500"
|
||||
>
|
||||
<option value="UNLIMITED">Unlimited - غير محدود</option>
|
||||
<option value="FIXED">Fixed - محدد</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||
Hire Date <span className="text-red-500">*</span>
|
||||
</label>
|
||||
<input
|
||||
type="date"
|
||||
value={formData.hireDate}
|
||||
onChange={(e) => setFormData({ ...formData, hireDate: e.target.value })}
|
||||
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-red-500"
|
||||
/>
|
||||
{formErrors.hireDate && <p className="text-red-500 text-xs mt-1">{formErrors.hireDate}</p>}
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||
Base Salary (SAR) <span className="text-red-500">*</span>
|
||||
</label>
|
||||
<input
|
||||
type="number"
|
||||
min="0"
|
||||
value={formData.baseSalary}
|
||||
onChange={(e) => setFormData({ ...formData, baseSalary: parseFloat(e.target.value) })}
|
||||
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-red-500"
|
||||
/>
|
||||
{formErrors.baseSalary && <p className="text-red-500 text-xs mt-1">{formErrors.baseSalary}</p>}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-end gap-3 pt-4 border-t">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
isEdit ? setShowEditModal(false) : setShowCreateModal(false)
|
||||
resetForm()
|
||||
}}
|
||||
className="px-4 py-2 border border-gray-300 text-gray-700 rounded-lg hover:bg-gray-50 transition-colors"
|
||||
disabled={submitting}
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
type="submit"
|
||||
className="flex items-center gap-2 px-4 py-2 bg-red-600 text-white rounded-lg hover:bg-red-700 transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
disabled={submitting}
|
||||
>
|
||||
{submitting ? (
|
||||
<>
|
||||
<Loader2 className="h-4 w-4 animate-spin" />
|
||||
{isEdit ? 'Updating...' : 'Creating...'}
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
{isEdit ? 'Update Employee' : 'Create Employee'}
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
const totalSalary = employees.reduce((sum, e) => {
|
||||
const sal = e.baseSalary ?? (e as any).basicSalary ?? 0
|
||||
return sum + Number(sal)
|
||||
}, 0)
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-gray-50">
|
||||
@@ -518,19 +652,77 @@ function HRContent() {
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-3">
|
||||
<button
|
||||
onClick={() => {
|
||||
resetForm()
|
||||
setShowCreateModal(true)
|
||||
}}
|
||||
className="flex items-center gap-2 px-4 py-2 bg-red-600 text-white rounded-lg hover:bg-red-700 transition-colors"
|
||||
>
|
||||
<Plus className="h-4 w-4" />
|
||||
Add Employee
|
||||
</button>
|
||||
{activeTab === 'employees' && (
|
||||
<button
|
||||
onClick={() => {
|
||||
resetForm()
|
||||
setShowCreateModal(true)
|
||||
}}
|
||||
className="flex items-center gap-2 px-4 py-2 bg-red-600 text-white rounded-lg hover:bg-red-700 transition-colors"
|
||||
>
|
||||
<Plus className="h-4 w-4" />
|
||||
Add Employee
|
||||
</button>
|
||||
)}
|
||||
{activeTab === 'departments' && (
|
||||
<button
|
||||
onClick={() => openDeptModal()}
|
||||
className="flex items-center gap-2 px-4 py-2 bg-red-600 text-white rounded-lg hover:bg-red-700 transition-colors"
|
||||
>
|
||||
<Plus className="h-4 w-4" />
|
||||
Add Department
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Tabs */}
|
||||
<div className="border-b border-gray-200 bg-white">
|
||||
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
|
||||
<nav className="flex gap-4">
|
||||
<button
|
||||
onClick={() => setActiveTab('employees')}
|
||||
className={`py-4 px-2 border-b-2 font-medium text-sm transition-colors ${
|
||||
activeTab === 'employees'
|
||||
? 'border-red-600 text-red-600'
|
||||
: 'border-transparent text-gray-500 hover:text-gray-700'
|
||||
}`}
|
||||
>
|
||||
<span className="flex items-center gap-2">
|
||||
<Users className="h-4 w-4" />
|
||||
الموظفون / Employees
|
||||
</span>
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setActiveTab('departments')}
|
||||
className={`py-4 px-2 border-b-2 font-medium text-sm transition-colors ${
|
||||
activeTab === 'departments'
|
||||
? 'border-red-600 text-red-600'
|
||||
: 'border-transparent text-gray-500 hover:text-gray-700'
|
||||
}`}
|
||||
>
|
||||
<span className="flex items-center gap-2">
|
||||
<Building2 className="h-4 w-4" />
|
||||
الأقسام / Departments
|
||||
</span>
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setActiveTab('orgchart')}
|
||||
className={`py-4 px-2 border-b-2 font-medium text-sm transition-colors ${
|
||||
activeTab === 'orgchart'
|
||||
? 'border-red-600 text-red-600'
|
||||
: 'border-transparent text-gray-500 hover:text-gray-700'
|
||||
}`}
|
||||
>
|
||||
<span className="flex items-center gap-2">
|
||||
<Network className="h-4 w-4" />
|
||||
الهيكل التنظيمي / Org Chart
|
||||
</span>
|
||||
</button>
|
||||
</nav>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<main className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
|
||||
@@ -577,7 +769,9 @@ function HRContent() {
|
||||
<div>
|
||||
<p className="text-sm text-gray-600">Total Salary</p>
|
||||
<p className="text-3xl font-bold text-gray-900 mt-1">
|
||||
{(totalSalary / 1000).toFixed(0)}K
|
||||
{totalSalary >= 1000
|
||||
? `${(totalSalary / 1000).toFixed(1)}K`
|
||||
: totalSalary.toLocaleString()}
|
||||
</p>
|
||||
<p className="text-xs text-gray-600 mt-1">SAR</p>
|
||||
</div>
|
||||
@@ -588,6 +782,9 @@ function HRContent() {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Employees Tab */}
|
||||
{activeTab === 'employees' && (
|
||||
<>
|
||||
{/* Filters and Search */}
|
||||
<div className="bg-white rounded-xl shadow-sm p-6 border border-gray-200 mb-6">
|
||||
<div className="flex flex-col md:flex-row gap-4">
|
||||
@@ -710,7 +907,7 @@ function HRContent() {
|
||||
</td>
|
||||
<td className="px-6 py-4">
|
||||
<span className="text-sm font-semibold text-gray-900">
|
||||
{employee.baseSalary.toLocaleString()} SAR
|
||||
{Number(employee.baseSalary ?? (employee as any).basicSalary ?? 0).toLocaleString()} SAR
|
||||
</span>
|
||||
</td>
|
||||
<td className="px-6 py-4">
|
||||
@@ -791,6 +988,79 @@ function HRContent() {
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Departments Tab */}
|
||||
{activeTab === 'departments' && (
|
||||
<div className="bg-white rounded-xl shadow-sm border border-gray-200 overflow-hidden">
|
||||
{loadingDepts ? (
|
||||
<div className="p-12"><LoadingSpinner size="lg" message="Loading departments..." /></div>
|
||||
) : departments.length === 0 ? (
|
||||
<div className="p-12 text-center">
|
||||
<Building2 className="h-12 w-12 text-gray-400 mx-auto mb-4" />
|
||||
<p className="text-gray-600 mb-4">No departments yet. Add your first department.</p>
|
||||
<button onClick={() => openDeptModal()} className="px-4 py-2 bg-red-600 text-white rounded-lg hover:bg-red-700">
|
||||
Add Department
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full">
|
||||
<thead className="bg-gray-50 border-b border-gray-200">
|
||||
<tr>
|
||||
<th className="px-6 py-4 text-right text-xs font-semibold text-gray-700 uppercase">Name</th>
|
||||
<th className="px-6 py-4 text-right text-xs font-semibold text-gray-700 uppercase">Code</th>
|
||||
<th className="px-6 py-4 text-right text-xs font-semibold text-gray-700 uppercase">Parent</th>
|
||||
<th className="px-6 py-4 text-right text-xs font-semibold text-gray-700 uppercase">Employees</th>
|
||||
<th className="px-6 py-4 text-right text-xs font-semibold text-gray-700 uppercase">Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-gray-200">
|
||||
{departments.map((dept) => (
|
||||
<tr key={dept.id} className="hover:bg-gray-50">
|
||||
<td className="px-6 py-4">
|
||||
<span className="font-semibold text-gray-900">{dept.nameAr || dept.name}</span>
|
||||
</td>
|
||||
<td className="px-6 py-4 text-sm text-gray-600">{dept.code}</td>
|
||||
<td className="px-6 py-4 text-sm text-gray-600">{dept.parent?.nameAr || dept.parent?.name || '-'}</td>
|
||||
<td className="px-6 py-4 text-sm">{dept._count?.employees ?? 0}</td>
|
||||
<td className="px-6 py-4">
|
||||
<div className="flex gap-2">
|
||||
<button onClick={() => openDeptModal(dept)} className="p-2 hover:bg-blue-50 text-blue-600 rounded-lg" title="Edit">
|
||||
<Edit className="h-4 w-4" />
|
||||
</button>
|
||||
{deptDeleteConfirm === dept.id ? (
|
||||
<span className="flex gap-1">
|
||||
<button onClick={() => handleDeleteDept(dept.id)} className="px-2 py-1 text-xs bg-red-600 text-white rounded">Confirm</button>
|
||||
<button onClick={() => setDeptDeleteConfirm(null)} className="px-2 py-1 text-xs border rounded">Cancel</button>
|
||||
</span>
|
||||
) : (
|
||||
<button onClick={() => setDeptDeleteConfirm(dept.id)} className="p-2 hover:bg-red-50 text-red-600 rounded-lg" title="Delete">
|
||||
<Trash2 className="h-4 w-4" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Org Chart Tab */}
|
||||
{activeTab === 'orgchart' && (
|
||||
<div className="bg-white rounded-xl shadow-sm border border-gray-200 overflow-hidden">
|
||||
{loadingHierarchy ? (
|
||||
<div className="p-12"><LoadingSpinner size="lg" message="Loading org chart..." /></div>
|
||||
) : (
|
||||
<OrgChart hierarchy={hierarchy} />
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</main>
|
||||
|
||||
{/* Create Modal */}
|
||||
@@ -804,7 +1074,93 @@ function HRContent() {
|
||||
size="xl"
|
||||
>
|
||||
<form onSubmit={handleCreate}>
|
||||
<FormFields />
|
||||
<EmployeeFormFields
|
||||
formData={formData}
|
||||
setFormData={setFormData}
|
||||
formErrors={formErrors}
|
||||
departments={departments}
|
||||
positions={positions}
|
||||
loadingDepts={loadingDepts}
|
||||
isEdit={false}
|
||||
onCancel={() => {
|
||||
setShowCreateModal(false)
|
||||
resetForm()
|
||||
}}
|
||||
submitting={submitting}
|
||||
/>
|
||||
</form>
|
||||
</Modal>
|
||||
|
||||
{/* Department Modal */}
|
||||
<Modal
|
||||
isOpen={showDeptModal}
|
||||
onClose={() => { setShowDeptModal(false); setEditingDept(null) }}
|
||||
title={editingDept ? 'Edit Department' : 'Add Department'}
|
||||
>
|
||||
<form onSubmit={handleSaveDept} className="space-y-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">Name (EN) *</label>
|
||||
<input
|
||||
type="text"
|
||||
value={deptFormData.name}
|
||||
onChange={(e) => setDeptFormData((p) => ({ ...p, name: e.target.value }))}
|
||||
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-red-500"
|
||||
/>
|
||||
{deptFormErrors.name && <p className="text-red-500 text-xs mt-1">{deptFormErrors.name}</p>}
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">Name (AR)</label>
|
||||
<input
|
||||
type="text"
|
||||
value={deptFormData.nameAr}
|
||||
onChange={(e) => setDeptFormData((p) => ({ ...p, nameAr: e.target.value }))}
|
||||
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-red-500"
|
||||
dir="rtl"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">Code *</label>
|
||||
<input
|
||||
type="text"
|
||||
value={deptFormData.code}
|
||||
onChange={(e) => setDeptFormData((p) => ({ ...p, code: e.target.value.toUpperCase() }))}
|
||||
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-red-500"
|
||||
placeholder="e.g. HR, IT, SALES"
|
||||
/>
|
||||
{deptFormErrors.code && <p className="text-red-500 text-xs mt-1">{deptFormErrors.code}</p>}
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">Parent Department</label>
|
||||
<select
|
||||
value={deptFormData.parentId}
|
||||
onChange={(e) => setDeptFormData((p) => ({ ...p, parentId: e.target.value }))}
|
||||
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-red-500"
|
||||
>
|
||||
<option value="">— None (Root) —</option>
|
||||
{departments
|
||||
.filter((d) => !editingDept || d.id !== editingDept.id)
|
||||
.map((d) => (
|
||||
<option key={d.id} value={d.id}>{d.nameAr || d.name} ({d.code})</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">Description</label>
|
||||
<textarea
|
||||
value={deptFormData.description}
|
||||
onChange={(e) => setDeptFormData((p) => ({ ...p, description: e.target.value }))}
|
||||
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-red-500"
|
||||
rows={2}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex justify-end gap-2 pt-4">
|
||||
<button type="button" onClick={() => setShowDeptModal(false)} className="px-4 py-2 border border-gray-300 rounded-lg hover:bg-gray-50">
|
||||
Cancel
|
||||
</button>
|
||||
<button type="submit" className="px-4 py-2 bg-red-600 text-white rounded-lg hover:bg-red-700">
|
||||
{editingDept ? 'Update' : 'Create'}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</Modal>
|
||||
|
||||
@@ -819,7 +1175,20 @@ function HRContent() {
|
||||
size="xl"
|
||||
>
|
||||
<form onSubmit={handleEdit}>
|
||||
<FormFields isEdit />
|
||||
<EmployeeFormFields
|
||||
formData={formData}
|
||||
setFormData={setFormData}
|
||||
formErrors={formErrors}
|
||||
departments={departments}
|
||||
positions={positions}
|
||||
loadingDepts={loadingDepts}
|
||||
isEdit
|
||||
onCancel={() => {
|
||||
setShowEditModal(false)
|
||||
resetForm()
|
||||
}}
|
||||
submitting={submitting}
|
||||
/>
|
||||
</form>
|
||||
</Modal>
|
||||
|
||||
|
||||
4
frontend/src/app/icon.svg
Normal file
4
frontend/src/app/icon.svg
Normal file
@@ -0,0 +1,4 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32">
|
||||
<rect width="32" height="32" rx="4" fill="#2563eb"/>
|
||||
<text x="16" y="22" text-anchor="middle" fill="white" font-size="14" font-family="sans-serif" font-weight="bold">Z</text>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 247 B |
@@ -110,13 +110,11 @@ export default function LoginPage() {
|
||||
</button>
|
||||
</form>
|
||||
|
||||
{/* Demo Credentials */}
|
||||
{/* System Administrator */}
|
||||
<div className="mt-6 p-4 bg-blue-50 rounded-lg border border-blue-200">
|
||||
<h3 className="text-sm font-semibold text-blue-900 mb-2">الحسابات التجريبية:</h3>
|
||||
<div className="text-sm text-blue-800 space-y-1">
|
||||
<p>• <strong>المدير العام:</strong> gm@atmata.com / Admin@123</p>
|
||||
<p>• <strong>مدير المبيعات:</strong> sales.manager@atmata.com / Admin@123</p>
|
||||
<p>• <strong>مندوب مبيعات:</strong> sales.rep@atmata.com / Admin@123</p>
|
||||
<h3 className="text-sm font-semibold text-blue-900 mb-2">مدير النظام:</h3>
|
||||
<div className="text-sm text-blue-800">
|
||||
<p><strong>admin@system.local</strong> / Admin@123</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -628,9 +628,9 @@ function MarketingContent() {
|
||||
<span className="text-sm font-semibold text-gray-900">
|
||||
{(campaign.budget || 0).toLocaleString()} SAR
|
||||
</span>
|
||||
{campaign.actualCost && (
|
||||
{(campaign.actualCost ?? 0) > 0 && (
|
||||
<p className="text-xs text-gray-600">
|
||||
Spent: {campaign.actualCost.toLocaleString()}
|
||||
Spent: {(campaign.actualCost ?? 0).toLocaleString()}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
116
frontend/src/components/hr/OrgChart.tsx
Normal file
116
frontend/src/components/hr/OrgChart.tsx
Normal file
@@ -0,0 +1,116 @@
|
||||
'use client'
|
||||
|
||||
import { Tree, TreeNode } from 'react-organizational-chart'
|
||||
import type { Department } from '@/lib/api/employees'
|
||||
import { Building2, Users } from 'lucide-react'
|
||||
|
||||
// Force LTR for org chart - RTL breaks the tree layout and connecting lines
|
||||
function DeptNode({ dept }: { dept: Department }) {
|
||||
const empCount = dept._count?.employees ?? dept.employees?.length ?? 0
|
||||
const childCount = dept.children?.length ?? 0
|
||||
return (
|
||||
<div className="inline-flex flex-col items-center" dir="ltr">
|
||||
<div className="px-4 py-3 bg-white border-2 border-blue-200 rounded-lg shadow-md hover:shadow-lg transition-shadow min-w-[180px] max-w-[220px]">
|
||||
<div className="flex items-center gap-2 mb-1">
|
||||
<Building2 className="h-5 w-5 text-blue-600 flex-shrink-0" />
|
||||
<span className="font-semibold text-gray-900 truncate">{dept.nameAr || dept.name}</span>
|
||||
</div>
|
||||
<p className="text-xs text-gray-500 mb-1">{dept.code}</p>
|
||||
<div className="flex gap-2 text-xs text-gray-600">
|
||||
<span className="flex items-center gap-1">
|
||||
<Users className="h-3 w-3" />
|
||||
{empCount} موظف
|
||||
</span>
|
||||
{childCount > 0 && (
|
||||
<span>• {childCount} أقسام فرعية</span>
|
||||
)}
|
||||
</div>
|
||||
{dept.employees && dept.employees.length > 0 && (
|
||||
<div className="mt-2 pt-2 border-t border-gray-100 space-y-0.5">
|
||||
{dept.employees.slice(0, 3).map((emp: any) => (
|
||||
<p key={emp.id} className="text-xs text-gray-600 truncate">
|
||||
{emp.firstNameAr || emp.firstName} {emp.lastNameAr || emp.lastName}
|
||||
{emp.position && ` - ${emp.position.titleAr || emp.position.title}`}
|
||||
</p>
|
||||
))}
|
||||
{dept.employees.length > 3 && (
|
||||
<p className="text-xs text-gray-500">+{dept.employees.length - 3} أكثر</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export function OrgChartTree({ dept }: { dept: Department }) {
|
||||
if (!dept.children?.length) {
|
||||
return <TreeNode label={<DeptNode dept={dept} />} />
|
||||
}
|
||||
return (
|
||||
<TreeNode label={<DeptNode dept={dept} />}>
|
||||
{dept.children.map((child) => (
|
||||
<OrgChartTree key={child.id} dept={child} />
|
||||
))}
|
||||
</TreeNode>
|
||||
)
|
||||
}
|
||||
|
||||
interface OrgChartProps {
|
||||
hierarchy: Department[]
|
||||
}
|
||||
|
||||
export default function OrgChart({ hierarchy }: OrgChartProps) {
|
||||
if (hierarchy.length === 0) {
|
||||
return (
|
||||
<div className="p-12 text-center text-gray-500">
|
||||
<Building2 className="h-16 w-16 mx-auto mb-4 text-gray-300" />
|
||||
<p>لا توجد أقسام. أضف أقساماً من تبويب الأقسام.</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
if (hierarchy.length === 1) {
|
||||
const root = hierarchy[0]
|
||||
return (
|
||||
<div className="p-6 overflow-auto min-h-[400px]" dir="ltr" style={{ direction: 'ltr' }}>
|
||||
<div className="inline-block">
|
||||
<Tree
|
||||
label={<DeptNode dept={root} />}
|
||||
lineWidth="2px"
|
||||
lineColor="#93c5fd"
|
||||
lineBorderRadius="4px"
|
||||
lineHeight="24px"
|
||||
nodePadding="16px"
|
||||
>
|
||||
{root.children?.map((child) => (
|
||||
<OrgChartTree key={child.id} dept={child} />
|
||||
))}
|
||||
</Tree>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
return (
|
||||
<div className="p-6 overflow-auto min-h-[400px]" dir="ltr" style={{ direction: 'ltr' }}>
|
||||
<div className="inline-block">
|
||||
<Tree
|
||||
label={
|
||||
<div className="px-4 py-3 bg-blue-50 border-2 border-blue-200 rounded-lg min-w-[200px] text-center">
|
||||
<p className="font-bold text-gray-900">الشركة</p>
|
||||
<p className="text-xs text-gray-500">الجذر التنظيمي</p>
|
||||
</div>
|
||||
}
|
||||
lineWidth="2px"
|
||||
lineColor="#93c5fd"
|
||||
lineBorderRadius="4px"
|
||||
lineHeight="24px"
|
||||
nodePadding="16px"
|
||||
>
|
||||
{hierarchy.map((dept) => (
|
||||
<OrgChartTree key={dept.id} dept={dept} />
|
||||
))}
|
||||
</Tree>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -77,6 +77,10 @@ export const contactsAPI = {
|
||||
api.post('/contacts/merge', { sourceId, targetId, reason }),
|
||||
}
|
||||
|
||||
export const dashboardAPI = {
|
||||
getStats: () => api.get<{ success: boolean; data: { contacts: number; activeTasks: number; notifications: number } }>('/dashboard/stats'),
|
||||
}
|
||||
|
||||
export const crmAPI = {
|
||||
// Deals
|
||||
getDeals: (params?: any) => api.get('/crm/deals', { params }),
|
||||
|
||||
@@ -132,12 +132,34 @@ export interface PositionRole {
|
||||
_count?: { employees: number };
|
||||
}
|
||||
|
||||
export interface CreatePositionData {
|
||||
title: string;
|
||||
titleAr?: string;
|
||||
code: string;
|
||||
departmentId: string;
|
||||
level?: number;
|
||||
description?: string;
|
||||
}
|
||||
|
||||
export const positionsAPI = {
|
||||
getAll: async (): Promise<PositionRole[]> => {
|
||||
const response = await api.get('/admin/positions');
|
||||
return response.data.data || [];
|
||||
},
|
||||
|
||||
create: async (data: CreatePositionData): Promise<PositionRole> => {
|
||||
const response = await api.post('/admin/positions', data);
|
||||
return response.data.data;
|
||||
},
|
||||
|
||||
update: async (
|
||||
id: string,
|
||||
data: Partial<CreatePositionData & { isActive?: boolean }>
|
||||
): Promise<PositionRole> => {
|
||||
const response = await api.put(`/admin/positions/${id}`, data);
|
||||
return response.data.data;
|
||||
},
|
||||
|
||||
updatePermissions: async (
|
||||
positionId: string,
|
||||
permissions: Array<{ module: string; resource: string; actions: string[] }>
|
||||
@@ -189,6 +211,53 @@ export const rolesAPI = {
|
||||
},
|
||||
};
|
||||
|
||||
// Permission Groups API (Phase 3 - multi-group)
|
||||
export interface PermissionGroup {
|
||||
id: string;
|
||||
name: string;
|
||||
nameAr?: string | null;
|
||||
description?: string | null;
|
||||
isActive: boolean;
|
||||
permissions: { id: string; module: string; resource: string; actions: string[] }[];
|
||||
_count?: { userRoles: number };
|
||||
}
|
||||
|
||||
export const permissionGroupsAPI = {
|
||||
getAll: async (): Promise<PermissionGroup[]> => {
|
||||
const response = await api.get('/admin/permission-groups');
|
||||
return response.data.data || [];
|
||||
},
|
||||
create: async (data: { name: string; nameAr?: string; description?: string }) => {
|
||||
const response = await api.post('/admin/permission-groups', data);
|
||||
return response.data.data;
|
||||
},
|
||||
update: async (id: string, data: Partial<{ name: string; nameAr: string; description: string; isActive: boolean }>) => {
|
||||
const response = await api.put(`/admin/permission-groups/${id}`, data);
|
||||
return response.data.data;
|
||||
},
|
||||
updatePermissions: async (
|
||||
id: string,
|
||||
permissions: Array<{ module: string; resource: string; actions: string[] }>
|
||||
) => {
|
||||
const response = await api.put(`/admin/permission-groups/${id}/permissions`, { permissions });
|
||||
return response.data.data;
|
||||
},
|
||||
};
|
||||
|
||||
export const userRolesAPI = {
|
||||
getAll: async (userId: string) => {
|
||||
const response = await api.get(`/admin/users/${userId}/roles`);
|
||||
return response.data.data || [];
|
||||
},
|
||||
assign: async (userId: string, roleId: string) => {
|
||||
const response = await api.post(`/admin/users/${userId}/roles`, { roleId });
|
||||
return response.data.data;
|
||||
},
|
||||
remove: async (userId: string, roleId: string) => {
|
||||
await api.delete(`/admin/users/${userId}/roles/${roleId}`);
|
||||
},
|
||||
};
|
||||
|
||||
// Audit Logs API
|
||||
export interface AuditLog {
|
||||
id: string;
|
||||
|
||||
@@ -84,8 +84,12 @@ export const employeesAPI = {
|
||||
|
||||
const response = await api.get(`/hr/employees?${params.toString()}`)
|
||||
const { data, pagination } = response.data
|
||||
const employees = (data || []).map((e: any) => ({
|
||||
...e,
|
||||
baseSalary: e.baseSalary ?? e.basicSalary ?? 0,
|
||||
}))
|
||||
return {
|
||||
employees: data || [],
|
||||
employees,
|
||||
total: pagination?.total || 0,
|
||||
page: pagination?.page || 1,
|
||||
pageSize: pagination?.pageSize || 20,
|
||||
@@ -96,7 +100,8 @@ export const employeesAPI = {
|
||||
// Get single employee by ID
|
||||
getById: async (id: string): Promise<Employee> => {
|
||||
const response = await api.get(`/hr/employees/${id}`)
|
||||
return response.data.data
|
||||
const e = response.data.data
|
||||
return e ? { ...e, baseSalary: e.baseSalary ?? e.basicSalary ?? 0 } : e
|
||||
},
|
||||
|
||||
// Create new employee
|
||||
@@ -118,10 +123,40 @@ export const employeesAPI = {
|
||||
}
|
||||
|
||||
// Departments API
|
||||
export interface Department {
|
||||
id: string
|
||||
name: string
|
||||
nameAr?: string | null
|
||||
code: string
|
||||
parentId?: string | null
|
||||
parent?: { id: string; name: string; nameAr?: string | null }
|
||||
description?: string | null
|
||||
isActive?: boolean
|
||||
children?: Department[]
|
||||
employees?: any[]
|
||||
positions?: any[]
|
||||
_count?: { children: number; employees: number }
|
||||
}
|
||||
|
||||
export const departmentsAPI = {
|
||||
getAll: async (): Promise<any[]> => {
|
||||
const response = await api.get('/hr/departments')
|
||||
return response.data.data
|
||||
},
|
||||
getHierarchy: async (): Promise<Department[]> => {
|
||||
const response = await api.get('/hr/departments/hierarchy')
|
||||
return response.data.data
|
||||
},
|
||||
create: async (data: { name: string; nameAr?: string; code: string; parentId?: string; description?: string }) => {
|
||||
const response = await api.post('/hr/departments', data)
|
||||
return response.data.data
|
||||
},
|
||||
update: async (id: string, data: Partial<{ name: string; nameAr: string; code: string; parentId: string | null; description: string; isActive: boolean }>) => {
|
||||
const response = await api.put(`/hr/departments/${id}`, data)
|
||||
return response.data.data
|
||||
},
|
||||
delete: async (id: string) => {
|
||||
await api.delete(`/hr/departments/${id}`)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user