Files
mkd/components/admin/PermissionsSection.tsx

474 lines
21 KiB
TypeScript
Raw Permalink Normal View History

2026-02-04 00:17:04 +05:00
import React, { useEffect, useState } from 'react';
import { backendApi, PermissionTemplateRow, PortalUserRow } from '../../services/apiClient';
import { UserRole } from '../../types';
import { Loader2, Shield, Plus, Pencil, Trash2, CheckSquare, Square } from 'lucide-react';
import { ROLE_NAMES } from '../../constants/roleAccess';
import {
SECTION_LABELS,
SECTION_SUBS,
ALL_SECTION_KEYS,
getPermissionLevel,
permissionKey,
PERMISSION_LEVEL_LABELS,
type PermissionLevel,
} from '../../constants/permissions';
/** Текущий уровень доступа подраздела по массиву прав (для формы) */
function getSubLevelFromPermissions(permissions: string[], section: string, subId: string): PermissionLevel {
const base = `${section}_${subId}`;
if (permissions.includes(`${base}:own`)) return 'own';
if (permissions.includes(`${base}:edit`) || permissions.includes(base)) return 'edit';
if (permissions.includes(`${base}:read`)) return 'read';
return 'none';
}
/** Краткое описание прав для отображения в таблице */
function formatPermissionsShort(permissions: string[]): string {
if (!permissions.length) return '—';
if (permissions.includes('all')) return 'Все разделы';
const parts: string[] = [];
for (const section of ALL_SECTION_KEYS) {
if (permissions.includes(section)) {
parts.push(`${SECTION_LABELS[section] || section} (все)`);
} else {
const subs = SECTION_SUBS[section];
if (subs) {
const withLevel = subs
.map((s) => {
const lvl = getPermissionLevel(permissions, section, s.id);
if (lvl === 'none') return null;
const label = PERMISSION_LEVEL_LABELS[lvl];
return `${s.label}${label}`;
})
.filter(Boolean);
if (withLevel.length) parts.push(`${SECTION_LABELS[section]} (${withLevel.join('; ')})`);
}
}
}
return parts.join('; ') || '—';
}
export const PermissionsSection: React.FC = () => {
const [templates, setTemplates] = useState<PermissionTemplateRow[]>([]);
const [portalUsers, setPortalUsers] = useState<PortalUserRow[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [showForm, setShowForm] = useState(false);
const [editingId, setEditingId] = useState<number | null>(null);
const [formName, setFormName] = useState('');
const [formDescription, setFormDescription] = useState('');
const [formPermissions, setFormPermissions] = useState<string[]>([]);
const [formScope, setFormScope] = useState<'all' | 'own_district'>('all');
const [formForPosition, setFormForPosition] = useState('');
const [formSuggestedRole, setFormSuggestedRole] = useState<string>('');
const [saving, setSaving] = useState(false);
const [applyToUserId, setApplyToUserId] = useState<number | null>(null);
const load = async () => {
setLoading(true);
setError(null);
try {
const [t, u] = await Promise.all([
backendApi.getPermissionTemplates(),
backendApi.getPortalUsers(),
]);
setTemplates(t);
setPortalUsers(u);
} catch (e: any) {
setError(e?.message || 'Ошибка загрузки');
} finally {
setLoading(false);
}
};
useEffect(() => {
load();
}, []);
const toggleSection = (section: string) => {
setFormPermissions((prev) => {
const hasSection = prev.includes(section);
const subs = SECTION_SUBS[section];
const subKeyPrefixes = subs
? subs.flatMap((s) => {
const base = `${section}_${s.id}`;
return [base, `${base}:read`, `${base}:edit`, `${base}:own`];
})
: [];
if (hasSection) {
return prev.filter((p) => p !== section && !subKeyPrefixes.some((prefix) => p === prefix));
} else {
const filtered = prev.filter((p) => p !== section && !subKeyPrefixes.some((prefix) => p === prefix));
return filtered.concat([section]);
}
});
};
const setSubLevel = (section: string, subId: string, level: PermissionLevel) => {
setFormPermissions((prev) => {
const base = `${section}_${subId}`;
const toRemove = prev.filter(
(p) => p === base || p === `${base}:read` || p === `${base}:edit` || p === `${base}:own`
);
const next = prev.filter((p) => !toRemove.includes(p));
if (level !== 'none') {
next.push(permissionKey(section, subId, level));
}
return next.filter((p) => p !== section);
});
};
const hasSection = (section: string) => formPermissions.includes(section);
const getSubLevel = (section: string, subId: string) => getSubLevelFromPermissions(formPermissions, section, subId);
const hasSub = (section: string, subId: string) => getSubLevel(section, subId) !== 'none';
const handleCreate = () => {
setEditingId(null);
setFormName('');
setFormDescription('');
setFormPermissions([]);
setFormScope('all');
setFormForPosition('');
setFormSuggestedRole('');
setShowForm(true);
};
const handleEdit = (t: PermissionTemplateRow) => {
setEditingId(t.id);
setFormName(t.name);
setFormDescription(t.description || '');
setFormPermissions(t.permissions || []);
setFormScope(t.scope === 'own_district' ? 'own_district' : 'all');
setFormForPosition(t.forPosition || '');
setFormSuggestedRole(t.suggestedRole || '');
setShowForm(true);
};
const handleSave = async () => {
if (!formName.trim()) {
alert('Укажите название шаблона');
return;
}
setSaving(true);
try {
if (editingId != null) {
await backendApi.updatePermissionTemplate(editingId, {
name: formName.trim(),
description: formDescription.trim() || undefined,
permissions: formPermissions,
scope: formScope,
forPosition: formForPosition.trim() || null,
suggestedRole: formSuggestedRole || null,
});
} else {
await backendApi.createPermissionTemplate({
name: formName.trim(),
description: formDescription.trim() || undefined,
permissions: formPermissions,
scope: formScope,
forPosition: formForPosition.trim() || null,
suggestedRole: formSuggestedRole || null,
});
}
setShowForm(false);
await load();
} catch (e: any) {
alert(e?.message || 'Ошибка сохранения');
} finally {
setSaving(false);
}
};
const handleDelete = async (id: number) => {
if (!confirm('Удалить шаблон прав?')) return;
setSaving(true);
try {
await backendApi.deletePermissionTemplate(id);
await load();
} catch (e: any) {
alert(e?.message || 'Ошибка удаления');
} finally {
setSaving(false);
}
};
const handleApplyToUser = async (templateId: number, portalUserId: number) => {
const template = templates.find((t) => t.id === templateId);
if (!template) return;
setSaving(true);
try {
await backendApi.updatePortalUser(portalUserId, {
permissions: template.permissions.length > 0 ? template.permissions : null,
scope: template.scope || 'all',
role: template.suggestedRole || undefined,
});
setApplyToUserId(null);
await load();
} catch (e: any) {
alert(e?.message || 'Ошибка применения');
} finally {
setSaving(false);
}
};
if (loading) {
return (
<div className="flex items-center justify-center py-12">
<Loader2 className="w-8 h-8 animate-spin text-primary-500" />
</div>
);
}
return (
<div className="space-y-6">
<div className="flex justify-between items-center">
<h3 className="text-lg font-bold text-slate-800">Шаблоны прав</h3>
<button
type="button"
onClick={handleCreate}
className="flex items-center gap-2 px-4 py-2 bg-primary-600 text-white text-sm font-bold rounded-xl hover:bg-primary-700"
>
<Plus className="w-4 h-4" /> Создать шаблон
</button>
</div>
<p className="text-sm text-slate-500">
Сохраните набор прав как шаблон и применяйте его к пользователям или настраивайте права вручную в разделе «Пользователи».
</p>
{error && (
<div className="rounded-xl border border-red-200 bg-red-50 p-4 text-red-700 text-sm">{error}</div>
)}
{showForm && (
<div className="rounded-xl border border-slate-200 bg-slate-50 p-6 space-y-4">
<h4 className="font-bold text-slate-800">{editingId != null ? 'Редактирование шаблона' : 'Новый шаблон'}</h4>
<div>
<label className="block text-xs font-bold text-slate-600 mb-1">Название</label>
<input
type="text"
value={formName}
onChange={(e) => setFormName(e.target.value)}
placeholder="Например: Мастер участка"
className="w-full px-3 py-2 border border-slate-200 rounded-lg text-sm"
/>
</div>
<div>
<label className="block text-xs font-bold text-slate-600 mb-1">Описание (необязательно)</label>
<input
type="text"
value={formDescription}
onChange={(e) => setFormDescription(e.target.value)}
placeholder="Краткое описание"
className="w-full px-3 py-2 border border-slate-200 rounded-lg text-sm"
/>
</div>
<div>
<label className="block text-xs font-bold text-slate-600 mb-1">Обзор данных (дашборды)</label>
<select
value={formScope}
onChange={(e) => setFormScope(e.target.value as 'all' | 'own_district')}
className="w-full px-3 py-2 border border-slate-200 rounded-lg text-sm"
>
<option value="all">По всем участкам (как директор)</option>
<option value="own_district">Только свой участок (мастер / начальник участка)</option>
</select>
<p className="text-xs text-slate-500 mt-1">В сводках и отчётах пользователь увидит данные по всем участкам или только по своему.</p>
</div>
<div>
<label className="block text-xs font-bold text-slate-600 mb-1">Должность для привязки (необязательно)</label>
<input
type="text"
value={formForPosition}
onChange={(e) => setFormForPosition(e.target.value)}
placeholder="Например: Мастер участка, Начальник PR отдела"
className="w-full px-3 py-2 border border-slate-200 rounded-lg text-sm"
/>
<p className="text-xs text-slate-500 mt-1">При настройке прав пользователя можно применить шаблон по должности сотрудника.</p>
</div>
<div>
<label className="block text-xs font-bold text-slate-600 mb-1">Роль по шаблону (необязательно)</label>
<select
value={formSuggestedRole}
onChange={(e) => setFormSuggestedRole(e.target.value)}
className="w-full px-3 py-2 border border-slate-200 rounded-lg text-sm"
>
<option value=""> Не задана </option>
{(Object.keys(ROLE_NAMES) as UserRole[]).map((r) => (
<option key={r} value={r}>{ROLE_NAMES[r]}</option>
))}
</select>
<p className="text-xs text-slate-500 mt-1">При применении шаблона к пользователю можно подставить эту роль (права задаются шаблоном).</p>
</div>
<div>
<label className="block text-xs font-bold text-slate-600 mb-2">Разделы и подразделы (отчёты, вкладки)</label>
<p className="text-xs text-slate-500 mb-3">Отметьте раздел целиком или только нужные подразделы/отчёты.</p>
<div className="space-y-1 max-h-[320px] overflow-y-auto pr-2">
{ALL_SECTION_KEYS.map((section) => {
const subs = SECTION_SUBS[section];
const sectionChecked = hasSection(section);
return (
<div key={section} className="rounded-lg border border-slate-200 bg-white overflow-hidden">
<label className="flex items-center gap-2 cursor-pointer px-3 py-2 hover:bg-slate-50">
<input
type="checkbox"
checked={sectionChecked}
onChange={() => toggleSection(section)}
className="sr-only"
/>
{sectionChecked ? (
<CheckSquare className="w-4 h-4 text-primary-600 shrink-0" />
) : (
<Square className="w-4 h-4 text-slate-400 shrink-0" />
)}
<span className="text-sm font-medium text-slate-800">{SECTION_LABELS[section] || section}</span>
{subs && subs.length > 0 && !sectionChecked && (
<span className="text-xs text-slate-500">
({subs.filter((s) => hasSub(section, s.id)).length} из {subs.length})
</span>
)}
</label>
{subs && subs.length > 0 && !sectionChecked && (
<div className="pl-6 pr-3 pb-2 pt-0 space-y-2 border-t border-slate-100">
{subs.map((sub) => (
<div key={sub.id} className="flex items-center justify-between gap-2 py-1">
<span className="text-xs text-slate-600">{sub.label}</span>
<select
value={getSubLevel(section, sub.id)}
onChange={(e) => setSubLevel(section, sub.id, e.target.value as PermissionLevel)}
className="text-xs border border-slate-200 rounded-lg px-2 py-1 bg-white min-w-[140px]"
>
<option value="none">Нет доступа</option>
<option value="read">{PERMISSION_LEVEL_LABELS.read}</option>
<option value="edit">{PERMISSION_LEVEL_LABELS.edit}</option>
<option value="own">{PERMISSION_LEVEL_LABELS.own}</option>
</select>
</div>
))}
</div>
)}
</div>
);
})}
</div>
</div>
<div className="flex gap-2">
<button
type="button"
onClick={handleSave}
disabled={saving}
className="px-4 py-2 bg-primary-600 text-white text-sm font-bold rounded-lg hover:bg-primary-700 disabled:opacity-50"
>
{saving ? <Loader2 className="w-4 h-4 animate-spin inline" /> : 'Сохранить'}
</button>
<button
type="button"
onClick={() => setShowForm(false)}
className="px-4 py-2 border border-slate-200 text-slate-700 text-sm font-medium rounded-lg hover:bg-slate-50"
>
Отмена
</button>
</div>
</div>
)}
<div className="rounded-xl border border-slate-200 overflow-hidden">
<div className="overflow-x-auto">
<table className="w-full text-sm">
<thead className="bg-slate-50 border-b border-slate-200">
<tr>
<th className="text-left py-3 px-4 font-bold text-slate-700">Название</th>
<th className="text-left py-3 px-4 font-bold text-slate-700">Описание</th>
<th className="text-left py-3 px-4 font-bold text-slate-700">Обзор / Должность</th>
<th className="text-left py-3 px-4 font-bold text-slate-700">Разделы</th>
<th className="text-right py-3 px-4 font-bold text-slate-700">Действия</th>
</tr>
</thead>
<tbody>
{templates.length === 0 ? (
<tr>
<td colSpan={5} className="py-8 px-4 text-center text-slate-500">
Нет шаблонов. Создайте первый выше.
</td>
</tr>
) : (
templates.map((t) => (
<tr key={t.id} className="border-b border-slate-100 hover:bg-slate-50/50">
<td className="py-3 px-4 font-medium text-slate-800">{t.name}</td>
<td className="py-3 px-4 text-slate-600">{t.description || '—'}</td>
<td className="py-3 px-4 text-slate-600 text-xs">
{t.scope === 'own_district' ? 'Свой участок' : 'Все участки'}
{t.forPosition ? ` · ${t.forPosition}` : ''}
{t.suggestedRole ? ` · ${ROLE_NAMES[t.suggestedRole] || t.suggestedRole}` : ''}
</td>
<td className="py-3 px-4">
<span className="text-slate-600 text-xs">
{formatPermissionsShort(t.permissions || [])}
</span>
</td>
<td className="py-3 px-4 text-right flex items-center justify-end gap-1">
{applyToUserId === null ? (
<>
<button
type="button"
onClick={() => setApplyToUserId(t.id)}
className="p-2 text-slate-500 hover:text-primary-600"
title="Применить к пользователю"
>
<Shield className="w-4 h-4" />
</button>
<button
type="button"
onClick={() => handleEdit(t)}
className="p-2 text-slate-500 hover:text-primary-600"
title="Изменить"
>
<Pencil className="w-4 h-4" />
</button>
<button
type="button"
onClick={() => handleDelete(t.id)}
disabled={saving}
className="p-2 text-slate-500 hover:text-red-600"
title="Удалить"
>
<Trash2 className="w-4 h-4" />
</button>
</>
) : applyToUserId === t.id ? (
<div className="flex flex-col gap-1 items-end">
<span className="text-xs text-slate-600">Применить к:</span>
<select
className="text-xs border border-slate-200 rounded px-2 py-1"
onChange={(e) => {
const uid = e.target.value ? Number(e.target.value) : 0;
if (uid) handleApplyToUser(t.id, uid);
}}
>
<option value=""> Выберите пользователя </option>
{portalUsers.map((u) => (
<option key={u.id} value={u.id}>
{u.employeeName} ({u.login})
</option>
))}
</select>
<button
type="button"
onClick={() => setApplyToUserId(null)}
className="text-xs text-slate-500 hover:text-slate-700"
>
Отмена
</button>
</div>
) : null}
</td>
</tr>
))
)}
</tbody>
</table>
</div>
</div>
</div>
);
};