474 lines
21 KiB
TypeScript
Executable File
474 lines
21 KiB
TypeScript
Executable File
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>
|
||
);
|
||
};
|