+ {Array.isArray(data) && data.map((item, i) => (
+
+
+
+
{item.name?.[0] || '?'}
+
{item.name}
+
{item.tasks_count || item.total || 0} تسک
+
+
+
بار کاری
+
80 ? 'var(--danger)' : (item.load_percent || item.load || 0) > 50 ? 'var(--warning)' : 'var(--success)' }}>
+ {item.load_percent || item.load || 0}%
+
+
+
+
80 ? 'var(--danger)' : (item.load_percent || item.load || 0) > 50 ? 'var(--warning)' : 'var(--success)' }} />
+
+ ))}
+
+ );
+
+ case 'sprint-progress':
+ return (
+
+
پیشرفت اسپرینتها
+
+
+
+
+
+
+
+
+
+
+ );
+
+ case 'time-estimate':
+ return (
+
+
+
+ | عنوان | پروژه | زمان تخمینی | زمان واقعی | تفاوت |
+
+ {Array.isArray(data) && data.map((t, i) => (
+
+ | {t.title} |
+ {t.project?.title || '—'} |
+ {t.estimated_time || t.estimated || '—'} ساعت |
+ {t.actual_time || t.actual || '—'} ساعت |
+
+ 0 ? 'badge-danger' : 'badge-success'}`}>
+ {t.diff || t.difference || 0} ساعت
+
+ |
+
+ ))}
+
+
+
+
+ );
+
+ case 'recent-activities':
+ return (
+
+ {Array.isArray(data) && data.map((a, i) => (
+
+
{a.user?.[0] || a.user_name?.[0] || '?'}
+
+
{a.description}
+
{a.user_name && {a.user_name}} {formatJalaliDateTime(a.created_at, a.time_ago || '')}
+
+
+ ))}
+
+ );
+
+ case 'risky-projects':
+ return (
+
+ {Array.isArray(data) && data.map((p, i) => (
+
+
{p.title}
+
+
ریسک: {riskLabels[p.risk_level] || p.risk_level}
+
تسکهای عقبافتاده: {p.delayed_tasks || 0}
+
پیشرفت: {p.progress || 0}%
+
+
+
+ ))}
+
+ );
+
+ default:
+ return null;
+ }
+ };
+
+ return (
+
+
+
+
+ {tabs.map(tab => (
+
+ ))}
+
+ {renderTabContent()}
+
+ );
+}
diff --git a/frontend/src/pages/Roles.jsx b/frontend/src/pages/Roles.jsx
new file mode 100644
index 0000000..19623c5
--- /dev/null
+++ b/frontend/src/pages/Roles.jsx
@@ -0,0 +1,567 @@
+import { useEffect, useMemo, useState } from 'react';
+import toast from 'react-hot-toast';
+import {
+ Check,
+ KeyRound,
+ Layers3,
+ Plus,
+ Save,
+ Search,
+ Shield,
+ Trash2,
+ X,
+ UserCog,
+ Users,
+} from 'lucide-react';
+import api from '../services/api';
+import Modal from '../components/Modal';
+import ConfirmDialog from '../components/ConfirmDialog';
+import EmptyState from '../components/EmptyState';
+
+const emptyRoleForm = {
+ name: '',
+ display_name: '',
+ description: '',
+ guard_name: 'web',
+ permissions: [],
+};
+
+const moduleLabels = {
+ projects: 'پروژهها',
+ tasks: 'تسکها',
+ sprints: 'اسپرینتها',
+ backlog: 'بکلاگ',
+ meetings: 'جلسات',
+ files: 'فایلها',
+ reports: 'گزارشها',
+ team: 'تیم',
+ roles: 'نقشها',
+ settings: 'تنظیمات',
+ notifications: 'اعلانها',
+ comments: 'نظرات',
+ other: 'سایر',
+};
+
+const actionLabels = {
+ view: 'مشاهده',
+ create: 'ایجاد',
+ edit: 'ویرایش',
+ delete: 'حذف',
+ report: 'گزارش',
+ export: 'خروجی گرفتن',
+ assign: 'تخصیص کاربر',
+ upload: 'آپلود',
+ approve: 'تأیید',
+ reject: 'رد کردن',
+ comment: 'ثبت نظر',
+ close: 'بستن',
+ start: 'شروع',
+ end: 'پایان',
+ cancel: 'لغو',
+};
+
+const actionOrder = ['view', 'create', 'edit', 'assign', 'report', 'export', 'upload', 'comment', 'approve', 'reject', 'start', 'end', 'close', 'cancel', 'delete'];
+
+const flattenPermissionGroups = (groups) => {
+ if (!Array.isArray(groups)) return [];
+ if (groups.length && groups[0]?.permissions) {
+ return groups.flatMap((group) => group.permissions || []);
+ }
+ return groups;
+};
+
+const rolePermissionIds = (role) => (role?.permissions || []).map((permission) => permission.id);
+const getModuleLabel = (module) => moduleLabels[module] || module || 'سایر';
+const getPermissionLabel = (permission) => {
+ const action = permission.name?.split('.')[1];
+ return permission.display_name || actionLabels[action] || 'دسترسی';
+};
+const isDangerPermission = (permission) => permission.name?.endsWith('.delete') || getPermissionLabel(permission).includes('حذف');
+const sortPermissions = (items) => [...items].sort((a, b) => {
+ const actionA = a.name?.split('.')[1] || '';
+ const actionB = b.name?.split('.')[1] || '';
+ const indexA = actionOrder.includes(actionA) ? actionOrder.indexOf(actionA) : actionOrder.length;
+ const indexB = actionOrder.includes(actionB) ? actionOrder.indexOf(actionB) : actionOrder.length;
+ if (indexA !== indexB) return indexA - indexB;
+ return getPermissionLabel(a).localeCompare(getPermissionLabel(b), 'fa');
+});
+
+export default function Roles() {
+ const [roles, setRoles] = useState([]);
+ const [permissions, setPermissions] = useState([]);
+ const [users, setUsers] = useState([]);
+ const [loading, setLoading] = useState(true);
+ const [selectedRoleId, setSelectedRoleId] = useState(null);
+ const [permissionDraft, setPermissionDraft] = useState([]);
+ const [roleSearch, setRoleSearch] = useState('');
+ const [permissionSearch, setPermissionSearch] = useState('');
+ const [userSearch, setUserSearch] = useState('');
+ const [savingPerms, setSavingPerms] = useState(false);
+ const [roleModal, setRoleModal] = useState(false);
+ const [editingRole, setEditingRole] = useState(null);
+ const [roleForm, setRoleForm] = useState(emptyRoleForm);
+ const [savingRole, setSavingRole] = useState(false);
+ const [deletingRole, setDeletingRole] = useState(null);
+ const [userRoleDrafts, setUserRoleDrafts] = useState({});
+ const [savingUserId, setSavingUserId] = useState(null);
+
+ const fetchAll = async () => {
+ setLoading(true);
+ try {
+ const [rolesRes, permissionsRes, usersRes] = await Promise.all([
+ api.get('/roles', { params: { per_page: 300, sort_by: 'name', sort_dir: 'asc' } }),
+ api.get('/permissions'),
+ api.get('/users', { params: { per_page: 300, sort_by: 'name', sort_dir: 'asc' } }),
+ ]);
+ const nextRoles = rolesRes.data.data || [];
+ const nextPermissions = flattenPermissionGroups(permissionsRes.data.data || []);
+ const nextUsers = usersRes.data.data || [];
+ setRoles(nextRoles);
+ setPermissions(nextPermissions);
+ setUsers(nextUsers);
+ setUserRoleDrafts(Object.fromEntries(nextUsers.map((user) => [user.id, (user.roles || []).map((role) => role.id)])));
+ setSelectedRoleId((current) => current || nextRoles[0]?.id || null);
+ } catch {
+ toast.error('خطا در دریافت نقشها و دسترسیها');
+ } finally {
+ setLoading(false);
+ }
+ };
+
+ useEffect(() => { fetchAll(); }, []);
+
+ const selectedRole = useMemo(() => roles.find((role) => role.id === selectedRoleId) || roles[0] || null, [roles, selectedRoleId]);
+
+ useEffect(() => {
+ if (selectedRole) setPermissionDraft(rolePermissionIds(selectedRole));
+ }, [selectedRole]);
+
+ const filteredRoles = useMemo(() => {
+ const q = roleSearch.trim().toLowerCase();
+ return roles.filter((role) => !q || role.name.toLowerCase().includes(q) || role.display_name?.toLowerCase().includes(q));
+ }, [roles, roleSearch]);
+
+ const permissionGroups = useMemo(() => {
+ const q = permissionSearch.trim().toLowerCase();
+ return permissions
+ .filter((permission) => !q || permission.name.toLowerCase().includes(q) || permission.display_name?.toLowerCase().includes(q) || permission.module?.toLowerCase().includes(q))
+ .reduce((acc, permission) => {
+ const module = permission.module || permission.name?.split('.')[0] || 'other';
+ if (!acc[module]) acc[module] = [];
+ acc[module].push(permission);
+ return acc;
+ }, {});
+ }, [permissions, permissionSearch]);
+
+ const filteredUsers = useMemo(() => {
+ const q = userSearch.trim().toLowerCase();
+ return users.filter((user) => !q || user.name?.toLowerCase().includes(q) || user.email?.toLowerCase().includes(q));
+ }, [users, userSearch]);
+
+ const summary = useMemo(() => ({
+ roles: roles.length,
+ permissions: permissions.length,
+ users: users.length,
+ modules: new Set(permissions.map((permission) => permission.module)).size,
+ }), [roles, permissions, users]);
+
+ const permissionsChanged = selectedRole && JSON.stringify([...permissionDraft].sort()) !== JSON.stringify(rolePermissionIds(selectedRole).sort());
+
+ const togglePermission = (permissionId) => {
+ setPermissionDraft((prev) => prev.includes(permissionId) ? prev.filter((id) => id !== permissionId) : [...prev, permissionId]);
+ };
+
+ const setModulePermissions = (modulePermissions, checked) => {
+ const ids = modulePermissions.map((permission) => permission.id);
+ setPermissionDraft((prev) => checked ? Array.from(new Set([...prev, ...ids])) : prev.filter((id) => !ids.includes(id)));
+ };
+
+ const saveRolePermissions = async () => {
+ if (!selectedRole) return;
+ setSavingPerms(true);
+ try {
+ const { data } = await api.post(`/roles/${selectedRole.id}/permissions`, { permissions: permissionDraft });
+ const updated = data.data;
+ setRoles((prev) => prev.map((role) => role.id === updated.id ? updated : role));
+ setPermissionDraft(rolePermissionIds(updated));
+ toast.success('دسترسیهای نقش ذخیره شد');
+ } catch (err) {
+ toast.error(err.response?.data?.message || 'خطا در ذخیره دسترسیها');
+ } finally {
+ setSavingPerms(false);
+ }
+ };
+
+ const openCreateRole = () => {
+ setEditingRole(null);
+ setRoleForm(emptyRoleForm);
+ setRoleModal(true);
+ };
+
+ const openEditRole = (role) => {
+ setEditingRole(role);
+ setRoleForm({
+ name: role.name || '',
+ display_name: role.display_name || '',
+ description: role.description || '',
+ guard_name: role.guard_name || 'web',
+ permissions: rolePermissionIds(role),
+ });
+ setRoleModal(true);
+ };
+
+ const saveRole = async (e) => {
+ e.preventDefault();
+ if (!roleForm.name.trim() || !roleForm.display_name.trim()) {
+ toast.error('نام سیستمی و نام نمایشی الزامی است');
+ return;
+ }
+ setSavingRole(true);
+ try {
+ const payload = {
+ ...roleForm,
+ name: roleForm.name.trim(),
+ display_name: roleForm.display_name.trim(),
+ };
+ const { data } = editingRole
+ ? await api.put(`/roles/${editingRole.id}`, payload)
+ : await api.post('/roles', payload);
+ const saved = data.data;
+ setRoles((prev) => editingRole ? prev.map((role) => role.id === saved.id ? saved : role) : [...prev, saved]);
+ setSelectedRoleId(saved.id);
+ setRoleModal(false);
+ toast.success(editingRole ? 'نقش بهروزرسانی شد' : 'نقش جدید ایجاد شد');
+ } catch (err) {
+ const firstError = Object.values(err.response?.data?.errors || {})[0]?.[0];
+ toast.error(firstError || err.response?.data?.message || 'خطا در ذخیره نقش');
+ } finally {
+ setSavingRole(false);
+ }
+ };
+
+ const deleteRole = async () => {
+ try {
+ await api.delete(`/roles/${deletingRole.id}`);
+ setRoles((prev) => prev.filter((role) => role.id !== deletingRole.id));
+ setUsers((prev) => prev.map((user) => ({ ...user, roles: (user.roles || []).filter((role) => role.id !== deletingRole.id) })));
+ setUserRoleDrafts((prev) => Object.fromEntries(Object.entries(prev).map(([userId, roleIds]) => [userId, roleIds.filter((roleId) => roleId !== deletingRole.id)])));
+ setSelectedRoleId((current) => current === deletingRole.id ? roles.find((role) => role.id !== deletingRole.id)?.id || null : current);
+ setDeletingRole(null);
+ toast.success('نقش حذف شد');
+ } catch (err) {
+ toast.error(err.response?.data?.message || 'خطا در حذف نقش');
+ }
+ };
+
+ const toggleRoleFormPermission = (permissionId) => {
+ setRoleForm((prev) => ({
+ ...prev,
+ permissions: prev.permissions.includes(permissionId)
+ ? prev.permissions.filter((id) => id !== permissionId)
+ : [...prev.permissions, permissionId],
+ }));
+ };
+
+ const toggleUserRole = (userId, roleId) => {
+ setUserRoleDrafts((prev) => {
+ const current = prev[userId] || [];
+ return {
+ ...prev,
+ [userId]: current.includes(roleId) ? current.filter((id) => id !== roleId) : [...current, roleId],
+ };
+ });
+ };
+
+ const saveUserRoles = async (user) => {
+ setSavingUserId(user.id);
+ try {
+ const { data } = await api.put(`/users/${user.id}/roles`, { roles: userRoleDrafts[user.id] || [] });
+ const updated = data.data;
+ setUsers((prev) => prev.map((item) => item.id === updated.id ? updated : item));
+ setUserRoleDrafts((prev) => ({ ...prev, [updated.id]: (updated.roles || []).map((role) => role.id) }));
+ toast.success('سطح دسترسی کاربر ذخیره شد');
+ } catch (err) {
+ toast.error(err.response?.data?.message || 'خطا در ذخیره نقشهای کاربر');
+ } finally {
+ setSavingUserId(null);
+ }
+ };
+
+ if (loading) {
+ return (
+
+ );
+ }
+
+ return (
+
+
+
+
+
+
+
+
+
+
+ {roles.length === 0 ? (
+
ایجاد نقش} />
+ ) : (
+
+
+
+
+
+
+
+
{selectedRole?.guard_name === 'web' ? 'وب' : 'سامانه'}
+
{selectedRole?.display_name || selectedRole?.name}
+
{selectedRole?.description || 'توضیحی برای این نقش ثبت نشده است.'}
+
+
+
+
+
+
+
+ (user.roles || []).some((role) => role.id === selectedRole?.id)).length} />
+
+
+
+
+
+
+
+
+
دسترسیهای نقش
+ هر تغییر با ذخیره روی همین نقش اعمال میشود.
+
+
+
+ setPermissionSearch(e.target.value)} placeholder="جستجوی دسترسی" />
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
سطح دسترسی کاربران
+ برای هر کاربر میتوانید یک یا چند نقش انتخاب کنید.
+
+
+
+ setUserSearch(e.target.value)} placeholder="جستجوی کاربر" />
+
+
+
+
+ {filteredUsers.map((user) => {
+ const originalRoleIds = (user.roles || []).map((role) => role.id);
+ const draftRoleIds = userRoleDrafts[user.id] || [];
+ const changed = JSON.stringify([...originalRoleIds].sort()) !== JSON.stringify([...draftRoleIds].sort());
+ return (
+
+
{user.name?.[0] || '?'}
+
+ {user.name}
+ {user.email}
+
+
+ {roles.map((role) => (
+
+ ))}
+
+
+
+ );
+ })}
+
+
+
+
+ )}
+
+ {roleModal && (
+ setRoleModal(false)} footer={
+ <>
+
+
+ >
+ }>
+
+
+ )}
+
+ {deletingRole && (
+ setDeletingRole(null)}
+ />
+ )}
+
+ );
+}
+
+function PermissionMatrix({ groups, selectedIds, onToggle, onToggleModule }) {
+ const entries = Object.entries(groups).sort(([moduleA], [moduleB]) => getModuleLabel(moduleA).localeCompare(getModuleLabel(moduleB), 'fa'));
+ if (!entries.length) {
+ return
دسترسیای یافت نشد
;
+ }
+
+ return (
+
+ {entries.map(([module, modulePermissions]) => {
+ const sortedPermissions = sortPermissions(modulePermissions);
+ const selectedCount = sortedPermissions.filter((permission) => selectedIds.includes(permission.id)).length;
+ const allChecked = selectedCount === sortedPermissions.length;
+ return (
+
+
+
+ {getModuleLabel(module)}
+ {selectedCount} از {sortedPermissions.length} دسترسی فعال
+
+
+
+
+ {sortedPermissions.map((permission) => {
+ const checked = selectedIds.includes(permission.id);
+ const danger = isDangerPermission(permission);
+ return (
+
+ );
+ })}
+
+
+ );
+ })}
+
+ );
+}
+
+function SummaryItem({ label, value, icon: Icon }) {
+ return (
+
+
+ {label}
+ {value}
+
+ );
+}
+
+function Info({ label, value }) {
+ return
{label}{value ?? '—'}
;
+}
+
+function Field({ label, children }) {
+ return
{children}
;
+}
diff --git a/frontend/src/pages/Settings.jsx b/frontend/src/pages/Settings.jsx
new file mode 100644
index 0000000..b2dbe76
--- /dev/null
+++ b/frontend/src/pages/Settings.jsx
@@ -0,0 +1,562 @@
+import { useEffect, useMemo, useRef, useState } from 'react';
+import toast from 'react-hot-toast';
+import {
+ Bell,
+ BriefcaseBusiness,
+ Building2,
+ Check,
+ ChevronLeft,
+ Database,
+ ListChecks,
+ Mail,
+ Plus,
+ Save,
+ Search,
+ Settings as SettingsIcon,
+ ShieldCheck,
+ SlidersHorizontal,
+ Trash2,
+ Users,
+ X,
+} from 'lucide-react';
+import api from '../services/api';
+import ThemeAccentPicker from '../components/ThemeAccentPicker';
+import { applyThemeMode, getThemeMode, themeModes } from '../utils/themeMode';
+
+const groupMeta = {
+ general: { label: 'عمومی', icon: Building2 },
+ task: { label: 'تسکها', icon: ListChecks },
+ project: { label: 'پروژهها', icon: BriefcaseBusiness },
+ backlog: { label: 'بکلاگ', icon: Database },
+ notification: { label: 'اعلانها', icon: Bell },
+ mail: { label: 'ایمیل', icon: Mail },
+ security: { label: 'امنیت', icon: ShieldCheck },
+ app: { label: 'برنامه', icon: SlidersHorizontal },
+ people: { label: 'منابع انسانی', icon: Users },
+ personalization: { label: 'شخصیسازی', icon: SlidersHorizontal },
+};
+
+const typeLabels = {
+ string: 'متن کوتاه',
+ textarea: 'متن بلند',
+ number: 'عدد',
+ boolean: 'کلید روشن/خاموش',
+ list: 'لیست',
+ json: 'ساختار داده',
+};
+
+const keyLabels = {
+ company_name: 'نام شرکت',
+ company_phone: 'شماره تماس شرکت',
+ company_address: 'آدرس شرکت',
+ company_logo: 'مسیر لوگوی شرکت',
+ default_task_statuses: 'وضعیتهای پیشفرض تسک',
+ default_priorities: 'اولویتهای پیشفرض',
+ default_project_statuses: 'وضعیتهای پیشفرض پروژه',
+ risk_levels: 'سطوح ریسک',
+ backlog_types: 'نوعهای بکلاگ',
+ backlog_statuses: 'وضعیتهای بکلاگ',
+ notification_types: 'نوعهای اعلان',
+ smtp_settings: 'تنظیمات SMTP',
+ job_titles: 'عنوانهای شغلی',
+};
+
+const emptySetting = {
+ key: '',
+ value: '',
+ group: 'general',
+ type: 'string',
+};
+
+const isPlainObject = (value) => value && typeof value === 'object' && !Array.isArray(value);
+
+const inferType = (setting) => {
+ if (setting.type) return setting.type;
+ if (Array.isArray(setting.value)) return 'list';
+ if (isPlainObject(setting.value)) return 'json';
+ if (typeof setting.value === 'boolean') return 'boolean';
+ if (typeof setting.value === 'number') return 'number';
+ if (typeof setting.value === 'string' && setting.value.length > 90) return 'textarea';
+ return 'string';
+};
+
+const getGroupLabel = (group) => groupMeta[group]?.label || group || 'عمومی';
+const getKeyLabel = (key) => keyLabels[key] || key.replaceAll('_', ' ');
+
+const toDraft = (setting) => ({
+ value: setting.value ?? '',
+ group: setting.group || 'general',
+ type: inferType(setting),
+});
+
+const normalizeByType = (type, value) => {
+ if (type === 'boolean') return value === true || value === 'true';
+ if (type === 'number') return value === '' || value === null ? null : Number(value);
+ if (type === 'list') return Array.isArray(value) ? value.map(String).filter((item) => item.trim()) : String(value || '').split('\n').map((item) => item.trim()).filter(Boolean);
+ if (type === 'json') return isPlainObject(value) ? value : {};
+ return value ?? '';
+};
+
+const createPayload = (form) => ({
+ ...form,
+ value: normalizeByType(form.type, form.value),
+});
+
+export default function Settings() {
+ const [settings, setSettings] = useState([]);
+ const [drafts, setDrafts] = useState({});
+ const [loading, setLoading] = useState(true);
+ const [activeGroup, setActiveGroup] = useState('');
+ const [search, setSearch] = useState('');
+ const [savingId, setSavingId] = useState(null);
+ const [addOpen, setAddOpen] = useState(false);
+ const [addForm, setAddForm] = useState(emptySetting);
+ const [adding, setAdding] = useState(false);
+
+ const fetchSettings = async () => {
+ setLoading(true);
+ try {
+ const { data } = await api.get('/settings');
+ const nextSettings = data.data || [];
+ setSettings(nextSettings);
+ setDrafts(Object.fromEntries(nextSettings.map((setting) => [setting.id, toDraft(setting)])));
+ setActiveGroup((current) => current || nextSettings[0]?.group || 'personalization');
+ } catch {
+ toast.error('خطا در دریافت تنظیمات');
+ } finally {
+ setLoading(false);
+ }
+ };
+
+ useEffect(() => { fetchSettings(); }, []);
+
+ const groups = useMemo(() => {
+ const counts = settings.reduce((acc, setting) => {
+ const group = setting.group || 'general';
+ acc[group] = (acc[group] || 0) + 1;
+ return acc;
+ }, {});
+ return Object.entries(counts).map(([key, count]) => ({ key, count }));
+ }, [settings]);
+
+ const visibleSettings = useMemo(() => {
+ const q = search.trim().toLowerCase();
+ return settings.filter((setting) => {
+ const group = setting.group || 'general';
+ const draft = drafts[setting.id] || toDraft(setting);
+ const key = setting.key || '';
+ const label = getKeyLabel(key);
+ const valueText = typeof draft.value === 'string' ? draft.value : JSON.stringify(draft.value || '');
+ const matchesGroup = group === activeGroup;
+ const matchesSearch = !q || key.toLowerCase().includes(q) || label.toLowerCase().includes(q) || valueText.toLowerCase().includes(q);
+ return matchesGroup && matchesSearch;
+ });
+ }, [settings, drafts, activeGroup, search]);
+
+ const summary = useMemo(() => ({
+ total: settings.length,
+ groups: groups.length,
+ structured: settings.filter((setting) => ['list', 'json'].includes(inferType(setting))).length,
+ missingType: settings.filter((setting) => !setting.type).length,
+ }), [settings, groups.length]);
+
+ const updateDraft = (id, patch) => {
+ setDrafts((prev) => ({ ...prev, [id]: { ...prev[id], ...patch } }));
+ };
+
+ const resetSetting = (setting) => {
+ updateDraft(setting.id, toDraft(setting));
+ };
+
+ const saveSetting = async (setting) => {
+ const draft = drafts[setting.id] || toDraft(setting);
+ const payload = {
+ value: normalizeByType(draft.type, draft.value),
+ group: draft.group,
+ type: draft.type,
+ };
+ if (draft.type === 'number' && Number.isNaN(payload.value)) {
+ toast.error('مقدار عددی معتبر نیست');
+ return;
+ }
+ setSavingId(setting.id);
+ try {
+ const { data } = await api.put(`/settings/${setting.id}`, payload);
+ const updated = data.data;
+ setSettings((prev) => prev.map((item) => (item.id === setting.id ? updated : item)));
+ setDrafts((prev) => ({ ...prev, [setting.id]: toDraft(updated) }));
+ toast.success('تنظیم ذخیره شد');
+ } catch (err) {
+ toast.error(err.response?.data?.message || 'خطا در ذخیره تنظیم');
+ } finally {
+ setSavingId(null);
+ }
+ };
+
+ const addSetting = async (e) => {
+ e.preventDefault();
+ if (!addForm.key.trim()) {
+ toast.error('کلید تنظیم الزامی است');
+ return;
+ }
+ setAdding(true);
+ try {
+ const { data } = await api.post('/settings', createPayload(addForm));
+ const created = data.data;
+ setSettings((prev) => [...prev, created]);
+ setDrafts((prev) => ({ ...prev, [created.id]: toDraft(created) }));
+ setActiveGroup(created.group || 'general');
+ setAddOpen(false);
+ setAddForm(emptySetting);
+ toast.success('تنظیم جدید ایجاد شد');
+ } catch (err) {
+ const firstError = Object.values(err.response?.data?.errors || {})[0]?.[0];
+ toast.error(firstError || err.response?.data?.message || 'خطا در ایجاد تنظیم');
+ } finally {
+ setAdding(false);
+ }
+ };
+
+ const activeGroupTitle = activeGroup === 'personalization' ? 'شخصیسازی' : getGroupLabel(activeGroup);
+
+ return (
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
{activeGroupTitle}
+ {activeGroup === 'personalization' ? 'ظاهر و رنگبندی پنل' : `${visibleSettings.length} مورد`}
+
+
+
+ setSearch(e.target.value)} placeholder="جستجو در کلید، عنوان یا مقدار" />
+
+
+
+ {activeGroup === 'personalization' ? (
+
+ ) : loading ? (
+
+ {[1, 2, 3, 4].map((item) =>
)}
+
+ ) : visibleSettings.length === 0 ? (
+
+
+ تنظیمی یافت نشد
+ فیلتر یا عبارت جستجو را تغییر دهید.
+
+ ) : (
+
+ {visibleSettings.map((setting) => (
+ updateDraft(setting.id, patch)}
+ onReset={() => resetSetting(setting)}
+ onSave={() => saveSetting(setting)}
+ />
+ ))}
+
+ )}
+
+
+
+ {addOpen &&
setAddOpen(false)} />}
+ {addOpen && (
+
+
+
تنظیم جدید
+
+
+
+
+ )}
+
+ );
+}
+
+function AppearancePanel() {
+ const [mode, setMode] = useState(getThemeMode);
+
+ useEffect(() => {
+ applyThemeMode(mode);
+ }, [mode]);
+
+ return (
+
+
+ ظاهر سامانه
+
+
+
+
+
+
+ );
+}
+
+function SummaryItem({ label, value, icon: Icon }) {
+ return (
+
+
+ {label}
+ {value}
+
+ );
+}
+
+function SettingRow({ setting, draft, saving, onChange, onReset, onSave }) {
+ const originalType = inferType(setting);
+ const hasChanged = JSON.stringify({
+ value: normalizeByType(draft.type, draft.value),
+ group: draft.group,
+ type: draft.type,
+ }) !== JSON.stringify({
+ value: normalizeByType(originalType, setting.value ?? ''),
+ group: setting.group || 'general',
+ type: originalType,
+ });
+
+ return (
+
+
+
+ {getKeyLabel(setting.key)}
+ {setting.key}
+
+
+ {getGroupLabel(draft.group)}
+ {typeLabels[draft.type] || draft.type}
+
+
+
+
+
+ onChange({ value })} />
+
+
+
+ onChange({ group: e.target.value })} />
+
+
+
+
+
+
+
+
+
+
+
+
+ );
+}
+
+function ValueEditor({ type, value, onChange }) {
+ if (type === 'boolean') {
+ const checked = value === true || value === 'true';
+ return (
+
+
+
+
+ );
+ }
+
+ if (type === 'number') {
+ return
onChange(e.target.value)} />;
+ }
+
+ if (type === 'textarea') {
+ return