Files
mkd/components/admin/PermissionsSection.tsx
2026-02-04 00:17:04 +05:00

474 lines
21 KiB
TypeScript
Executable File
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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>
);
};