Initial commit MKD fixes
This commit is contained in:
120
components/admin/AISection.tsx
Executable file
120
components/admin/AISection.tsx
Executable file
@@ -0,0 +1,120 @@
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import { backendApi } from '../../services/apiClient';
|
||||
import { Bot, Loader2 } from 'lucide-react';
|
||||
|
||||
export const AISection: React.FC = () => {
|
||||
const [enabled, setEnabled] = useState(false);
|
||||
const [url, setUrl] = useState('');
|
||||
const [apiKey, setApiKey] = useState('');
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [saving, setSaving] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
backendApi
|
||||
.getAIChatSettings()
|
||||
.then((data) => {
|
||||
setEnabled(data.enabled !== false);
|
||||
setUrl(data.url || '');
|
||||
setApiKey(data.apiKey || '');
|
||||
})
|
||||
.catch(() => {
|
||||
setEnabled(false);
|
||||
setUrl('');
|
||||
setApiKey('');
|
||||
})
|
||||
.finally(() => setLoading(false));
|
||||
}, []);
|
||||
|
||||
const handleSave = async () => {
|
||||
setSaving(true);
|
||||
try {
|
||||
await backendApi.saveAIChatSettings({
|
||||
enabled,
|
||||
url: url.trim(),
|
||||
apiKey: apiKey.trim(),
|
||||
});
|
||||
window.dispatchEvent(new CustomEvent('mkd-ai-status-changed'));
|
||||
alert('Настройки ИИ сохранены');
|
||||
} catch (e: unknown) {
|
||||
const msg = e && typeof e === 'object' && 'message' in e ? String((e as { message: string }).message) : 'Ошибка сохранения';
|
||||
alert(msg);
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="flex items-center justify-center py-12 text-slate-500">
|
||||
<Loader2 className="w-8 h-8 animate-spin" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="flex items-center gap-3 mb-6">
|
||||
<div className="w-12 h-12 rounded-xl bg-primary-50 flex items-center justify-center">
|
||||
<Bot className="w-6 h-6 text-primary-600" />
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="text-lg font-bold text-slate-800">ИИ-помощник</h3>
|
||||
<p className="text-sm text-slate-500">Включение чата с ИИ и настройка адреса API (OpenAI-совместимый) и токена</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-3">
|
||||
<button
|
||||
type="button"
|
||||
role="switch"
|
||||
aria-checked={enabled}
|
||||
onClick={() => setEnabled((v) => !v)}
|
||||
className={`relative inline-flex h-7 w-12 flex-shrink-0 rounded-full border-2 border-transparent transition-colors focus:outline-none focus:ring-2 focus:ring-primary-500 focus:ring-offset-2 ${
|
||||
enabled ? 'bg-primary-600' : 'bg-slate-200'
|
||||
}`}
|
||||
>
|
||||
<span
|
||||
className={`pointer-events-none inline-block h-6 w-6 transform rounded-full bg-white shadow ring-0 transition ${
|
||||
enabled ? 'translate-x-5' : 'translate-x-1'
|
||||
}`}
|
||||
/>
|
||||
</button>
|
||||
<span className="text-sm font-medium text-slate-700">{enabled ? 'ИИ включён' : 'ИИ выключен'}</span>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-slate-700 mb-1">Адрес API</label>
|
||||
<input
|
||||
type="url"
|
||||
value={url}
|
||||
onChange={(e) => setUrl(e.target.value)}
|
||||
placeholder="https://ai.iieasy.ru/v1/chat/completions"
|
||||
className="w-full rounded-xl border border-slate-200 px-4 py-2.5 text-sm focus:outline-none focus:ring-2 focus:ring-primary-500"
|
||||
/>
|
||||
<p className="text-xs text-slate-500 mt-1">URL эндпоинта Chat Completions (OpenAI-совместимый)</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-slate-700 mb-1">Токен (API key)</label>
|
||||
<input
|
||||
type="password"
|
||||
value={apiKey}
|
||||
onChange={(e) => setApiKey(e.target.value)}
|
||||
placeholder="Опционально, если API требует авторизацию"
|
||||
className="w-full rounded-xl border border-slate-200 px-4 py-2.5 text-sm focus:outline-none focus:ring-2 focus:ring-primary-500"
|
||||
autoComplete="off"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleSave}
|
||||
disabled={saving}
|
||||
className="rounded-xl bg-primary-600 text-white px-5 py-2.5 text-sm font-medium hover:bg-primary-700 disabled:opacity-50 flex items-center gap-2"
|
||||
>
|
||||
{saving ? <Loader2 className="w-4 h-4 animate-spin" /> : null}
|
||||
Сохранить
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
127
components/admin/BackupsSection.tsx
Executable file
127
components/admin/BackupsSection.tsx
Executable file
@@ -0,0 +1,127 @@
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import { backendApi } from '../../services/apiClient';
|
||||
import { DatabaseBackup, Loader2, Download } from 'lucide-react';
|
||||
|
||||
type BackupItem = { filename: string; createdAt: string };
|
||||
|
||||
export const BackupsSection: React.FC = () => {
|
||||
const [backups, setBackups] = useState<BackupItem[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [creating, setCreating] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const load = async () => {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
try {
|
||||
const list = await backendApi.getBackups();
|
||||
setBackups(list);
|
||||
} catch (e: any) {
|
||||
setError(e?.message || 'Ошибка загрузки списка');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
load();
|
||||
}, []);
|
||||
|
||||
const handleCreate = async () => {
|
||||
setCreating(true);
|
||||
setError(null);
|
||||
try {
|
||||
await backendApi.createBackup();
|
||||
await load();
|
||||
} catch (e: any) {
|
||||
setError(e?.message || 'Ошибка создания резервной копии');
|
||||
} finally {
|
||||
setCreating(false);
|
||||
}
|
||||
};
|
||||
|
||||
const formatDate = (iso: string) => {
|
||||
try {
|
||||
const d = new Date(iso);
|
||||
return d.toLocaleString('ru-RU', {
|
||||
day: '2-digit',
|
||||
month: '2-digit',
|
||||
year: 'numeric',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
});
|
||||
} catch {
|
||||
return iso;
|
||||
}
|
||||
};
|
||||
|
||||
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}
|
||||
disabled={creating}
|
||||
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 disabled:opacity-50"
|
||||
>
|
||||
{creating ? <Loader2 className="w-4 h-4 animate-spin" /> : <DatabaseBackup className="w-4 h-4" />}
|
||||
Создать резервную копию
|
||||
</button>
|
||||
</div>
|
||||
<p className="text-sm text-slate-500">
|
||||
Создаётся дамп PostgreSQL (pg_dump). Убедитесь, что pg_dump доступен на сервере (PostgreSQL bin в PATH).
|
||||
</p>
|
||||
|
||||
{error && (
|
||||
<div className="rounded-xl border border-red-200 bg-red-50 p-4 text-red-700 text-sm">{error}</div>
|
||||
)}
|
||||
|
||||
{loading ? (
|
||||
<div className="flex items-center justify-center py-12">
|
||||
<Loader2 className="w-8 h-8 animate-spin text-primary-500" />
|
||||
</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-right py-3 px-4 font-bold text-slate-700">Действие</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{backups.length === 0 ? (
|
||||
<tr>
|
||||
<td colSpan={3} className="py-8 px-4 text-center text-slate-500">
|
||||
Резервных копий пока нет. Нажмите «Создать резервную копию».
|
||||
</td>
|
||||
</tr>
|
||||
) : (
|
||||
backups.map((b) => (
|
||||
<tr key={b.filename} className="border-b border-slate-100 hover:bg-slate-50/50">
|
||||
<td className="py-3 px-4 font-medium text-slate-800">{b.filename}</td>
|
||||
<td className="py-3 px-4 text-slate-600">{formatDate(b.createdAt)}</td>
|
||||
<td className="py-3 px-4 text-right">
|
||||
<a
|
||||
href={backendApi.getBackupDownloadUrl(b.filename)}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="inline-flex items-center gap-1.5 px-3 py-1.5 bg-slate-100 hover:bg-slate-200 text-slate-700 text-xs font-medium rounded-lg"
|
||||
>
|
||||
<Download className="w-3.5 h-3.5" /> Скачать
|
||||
</a>
|
||||
</td>
|
||||
</tr>
|
||||
))
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
240
components/admin/CompanySection.tsx
Executable file
240
components/admin/CompanySection.tsx
Executable file
@@ -0,0 +1,240 @@
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import { apiClient } from '../../services/apiClient';
|
||||
import { Building2, Loader2 } from 'lucide-react';
|
||||
|
||||
type CompanySettings = {
|
||||
name: string;
|
||||
fullName?: string | null;
|
||||
address?: string | null;
|
||||
phone?: string | null;
|
||||
email?: string | null;
|
||||
website?: string | null;
|
||||
licenseNumber?: string | null;
|
||||
licenseValidUntil?: string | null;
|
||||
logoUrl?: string | null;
|
||||
};
|
||||
|
||||
const emptyCompany: CompanySettings = {
|
||||
name: '',
|
||||
fullName: '',
|
||||
address: '',
|
||||
phone: '',
|
||||
email: '',
|
||||
website: '',
|
||||
licenseNumber: '',
|
||||
licenseValidUntil: '',
|
||||
logoUrl: '',
|
||||
};
|
||||
|
||||
export const CompanySection: React.FC = () => {
|
||||
const [form, setForm] = useState<CompanySettings>(emptyCompany);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const [isSaving, setIsSaving] = useState(false);
|
||||
|
||||
const load = async () => {
|
||||
try {
|
||||
setIsLoading(true);
|
||||
const data = await apiClient.get<any>('/settings/company');
|
||||
setForm({
|
||||
name: data.name || '',
|
||||
fullName: data.fullName ?? '',
|
||||
address: data.address ?? '',
|
||||
phone: data.phone ?? '',
|
||||
email: data.email ?? '',
|
||||
website: data.website ?? '',
|
||||
licenseNumber: data.licenseNumber ?? '',
|
||||
licenseValidUntil: data.licenseValidUntil
|
||||
? String(data.licenseValidUntil).substring(0, 10)
|
||||
: '',
|
||||
logoUrl: data.logoUrl ?? '',
|
||||
});
|
||||
} catch (err) {
|
||||
console.error('Error loading company settings:', err);
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
load();
|
||||
}, []);
|
||||
|
||||
const handleChange = (field: keyof CompanySettings, value: string) => {
|
||||
setForm((prev) => ({ ...prev, [field]: value }));
|
||||
};
|
||||
|
||||
const handleSave = async () => {
|
||||
if (!form.name) return;
|
||||
try {
|
||||
setIsSaving(true);
|
||||
await apiClient.put('/settings/company', {
|
||||
name: form.name,
|
||||
fullName: form.fullName || null,
|
||||
address: form.address || null,
|
||||
phone: form.phone || null,
|
||||
email: form.email || null,
|
||||
website: form.website || null,
|
||||
licenseNumber: form.licenseNumber || null,
|
||||
licenseValidUntil: form.licenseValidUntil || null,
|
||||
logoUrl: form.logoUrl || null,
|
||||
});
|
||||
alert('Данные управляющей компании сохранены');
|
||||
} catch (err: any) {
|
||||
console.error('Error saving company settings:', err);
|
||||
alert(`Не удалось сохранить: ${err?.message || 'Неизвестная ошибка'}`);
|
||||
} finally {
|
||||
setIsSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="flex items-center justify-center py-12">
|
||||
<Loader2 className="w-8 h-8 animate-spin text-primary-600" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="flex items-center gap-3 mb-6">
|
||||
<div className="w-10 h-10 rounded-xl bg-primary-50 flex items-center justify-center">
|
||||
<Building2 className="w-5 h-5 text-primary-600" />
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="text-lg font-bold text-slate-800">Управляющая компания</h3>
|
||||
<p className="text-xs text-slate-500">
|
||||
Эти данные сохраняются в базе и подставляются в отчеты жителям и PR-отчеты
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-4 max-w-2xl">
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label className="block text-xs font-black text-slate-500 uppercase mb-1">
|
||||
Краткое наименование
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={form.name}
|
||||
onChange={(e) => handleChange('name', e.target.value)}
|
||||
className="w-full px-3 py-2 rounded-xl border border-slate-200 text-sm"
|
||||
placeholder='Например: УК "Дружба"'
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-xs font-black text-slate-500 uppercase mb-1">
|
||||
Полное наименование
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={form.fullName || ''}
|
||||
onChange={(e) => handleChange('fullName', e.target.value)}
|
||||
className="w-full px-3 py-2 rounded-xl border border-slate-200 text-sm"
|
||||
placeholder='Например: ООО "Управляющая компания Дружба"'
|
||||
/>
|
||||
</div>
|
||||
<div className="md:col-span-2">
|
||||
<label className="block text-xs font-black text-slate-500 uppercase mb-1">
|
||||
Адрес
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={form.address || ''}
|
||||
onChange={(e) => handleChange('address', e.target.value)}
|
||||
className="w-full px-3 py-2 rounded-xl border border-slate-200 text-sm"
|
||||
placeholder="Юридический / фактический адрес"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label className="block text-xs font-black text-slate-500 uppercase mb-1">
|
||||
Телефон
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={form.phone || ''}
|
||||
onChange={(e) => handleChange('phone', e.target.value)}
|
||||
className="w-full px-3 py-2 rounded-xl border border-slate-200 text-sm"
|
||||
placeholder="+7 (___) ___-__-__"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-xs font-black text-slate-500 uppercase mb-1">
|
||||
Email
|
||||
</label>
|
||||
<input
|
||||
type="email"
|
||||
value={form.email || ''}
|
||||
onChange={(e) => handleChange('email', e.target.value)}
|
||||
className="w-full px-3 py-2 rounded-xl border border-slate-200 text-sm"
|
||||
placeholder="info@company.ru"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-xs font-black text-slate-500 uppercase mb-1">
|
||||
Сайт
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={form.website || ''}
|
||||
onChange={(e) => handleChange('website', e.target.value)}
|
||||
className="w-full px-3 py-2 rounded-xl border border-slate-200 text-sm"
|
||||
placeholder="https://..."
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-xs font-black text-slate-500 uppercase mb-1">
|
||||
Номер лицензии
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={form.licenseNumber || ''}
|
||||
onChange={(e) => handleChange('licenseNumber', e.target.value)}
|
||||
className="w-full px-3 py-2 rounded-xl border border-slate-200 text-sm"
|
||||
placeholder="Лицензия №..."
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-xs font-black text-slate-500 uppercase mb-1">
|
||||
Лицензия действительна до
|
||||
</label>
|
||||
<input
|
||||
type="date"
|
||||
value={form.licenseValidUntil || ''}
|
||||
onChange={(e) => handleChange('licenseValidUntil', e.target.value)}
|
||||
className="w-full px-3 py-2 rounded-xl border border-slate-200 text-sm"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-xs font-black text-slate-500 uppercase mb-1">
|
||||
Логотип (URL)
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={form.logoUrl || ''}
|
||||
onChange={(e) => handleChange('logoUrl', e.target.value)}
|
||||
className="w-full px-3 py-2 rounded-xl border border-slate-200 text-sm"
|
||||
placeholder="https://.../logo.png"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="pt-4">
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleSave}
|
||||
disabled={isSaving || !form.name}
|
||||
className="px-5 py-2 rounded-xl text-xs font-black uppercase tracking-wider bg-primary-600 text-white hover:bg-primary-700 disabled:opacity-60 disabled:cursor-not-allowed flex items-center gap-2"
|
||||
>
|
||||
{isSaving && <Loader2 className="w-4 h-4 animate-spin" />}
|
||||
{isSaving ? 'Сохранение...' : 'Сохранить реквизиты'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
674
components/admin/DataCleanupSection.tsx
Executable file
674
components/admin/DataCleanupSection.tsx
Executable file
@@ -0,0 +1,674 @@
|
||||
import React, { useCallback, useEffect, useState } from 'react';
|
||||
import { backendApi, PortalUserRow } from '../../services/apiClient';
|
||||
import { Building, District, Employee } from '../../types';
|
||||
import { Trash2, Loader2, AlertTriangle } from 'lucide-react';
|
||||
|
||||
type EntityType =
|
||||
| 'buildings'
|
||||
| 'employees'
|
||||
| 'districts'
|
||||
| 'portal-users'
|
||||
| 'permission-templates'
|
||||
| 'vacancies'
|
||||
| 'candidates'
|
||||
| 'training-programs'
|
||||
| 'expense-categories'
|
||||
| 'expense-items'
|
||||
| 'supply-requests'
|
||||
| 'office-inventory'
|
||||
| 'office-documents'
|
||||
| 'hr-template-documents'
|
||||
| 'doma-address-mappings'
|
||||
| 'doma-employee-mappings'
|
||||
| 'pr-work-photos'
|
||||
| 'pr-events'
|
||||
| 'accounts';
|
||||
|
||||
const ENTITY_LABELS: Record<EntityType, string> = {
|
||||
buildings: 'Дома',
|
||||
employees: 'Сотрудники',
|
||||
districts: 'Участки',
|
||||
'portal-users': 'Пользователи портала',
|
||||
'permission-templates': 'Шаблоны прав',
|
||||
vacancies: 'Вакансии',
|
||||
candidates: 'Кандидаты',
|
||||
'training-programs': 'Программы обучения',
|
||||
'expense-categories': 'Категории расходов',
|
||||
'expense-items': 'Статьи расходов',
|
||||
'supply-requests': 'Заявки на ТМЦ',
|
||||
'office-inventory': 'Склад (офис)',
|
||||
'office-documents': 'Документы (офис)',
|
||||
'hr-template-documents': 'Типовые документы (HR)',
|
||||
'doma-address-mappings': 'Сопоставления адресов (Doma)',
|
||||
'doma-employee-mappings': 'Сопоставления сотрудников (Doma)',
|
||||
'pr-work-photos': 'Фото работ (ПР)',
|
||||
'pr-events': 'Мероприятия (ПР)',
|
||||
accounts: 'Лицевые счета',
|
||||
};
|
||||
|
||||
function getItemId(item: any, entityType: EntityType): string {
|
||||
const id = item.id ?? item.candidateId ?? item.vacancy_id;
|
||||
return String(id);
|
||||
}
|
||||
|
||||
export const DataCleanupSection: React.FC = () => {
|
||||
const [entityType, setEntityType] = useState<EntityType>('buildings');
|
||||
const [items, setItems] = useState<any[]>([]);
|
||||
const [buildingsForAccounts, setBuildingsForAccounts] = useState<Building[]>([]);
|
||||
const [selectedBuildingId, setSelectedBuildingId] = useState<string>('');
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [deletingId, setDeletingId] = useState<string | number | null>(null);
|
||||
const [bulkDeleting, setBulkDeleting] = useState(false);
|
||||
const [selectedIds, setSelectedIds] = useState<Set<string>>(new Set());
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const loadItems = useCallback(async () => {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
try {
|
||||
switch (entityType) {
|
||||
case 'buildings': {
|
||||
const b = await backendApi.getBuildings();
|
||||
setItems(b);
|
||||
break;
|
||||
}
|
||||
case 'employees': {
|
||||
const e = await backendApi.getEmployees();
|
||||
setItems(e);
|
||||
break;
|
||||
}
|
||||
case 'districts': {
|
||||
const d = await backendApi.getDistricts();
|
||||
setItems(d);
|
||||
break;
|
||||
}
|
||||
case 'portal-users': {
|
||||
const pu = await backendApi.getPortalUsers();
|
||||
setItems(pu);
|
||||
break;
|
||||
}
|
||||
case 'permission-templates': {
|
||||
const pt = await backendApi.getPermissionTemplates();
|
||||
setItems(pt);
|
||||
break;
|
||||
}
|
||||
case 'vacancies': {
|
||||
const v = await backendApi.getVacancies();
|
||||
setItems(v);
|
||||
break;
|
||||
}
|
||||
case 'candidates': {
|
||||
const c = await backendApi.getCandidates();
|
||||
setItems(c);
|
||||
break;
|
||||
}
|
||||
case 'training-programs': {
|
||||
const t = await backendApi.getTrainingPrograms();
|
||||
setItems(t);
|
||||
break;
|
||||
}
|
||||
case 'expense-categories': {
|
||||
const ec = await backendApi.getExpenseCategories();
|
||||
setItems(ec);
|
||||
break;
|
||||
}
|
||||
case 'expense-items': {
|
||||
const ei = await backendApi.getExpenseItems();
|
||||
setItems(ei);
|
||||
break;
|
||||
}
|
||||
case 'supply-requests': {
|
||||
const sr = await backendApi.getSupplyRequests();
|
||||
setItems(sr);
|
||||
break;
|
||||
}
|
||||
case 'office-inventory': {
|
||||
const inv = await backendApi.getOfficeInventory();
|
||||
setItems(inv);
|
||||
break;
|
||||
}
|
||||
case 'office-documents': {
|
||||
const doc = await backendApi.getOfficeDocuments();
|
||||
setItems(doc);
|
||||
break;
|
||||
}
|
||||
case 'hr-template-documents': {
|
||||
const hr = await backendApi.getHrTemplateDocuments();
|
||||
setItems(hr);
|
||||
break;
|
||||
}
|
||||
case 'doma-address-mappings':
|
||||
case 'doma-employee-mappings': {
|
||||
const res = await backendApi.getDomaMappings();
|
||||
const data = res?.data || { addresses: [], employees: [] };
|
||||
setItems(entityType === 'doma-address-mappings' ? data.addresses : data.employees);
|
||||
break;
|
||||
}
|
||||
case 'pr-work-photos': {
|
||||
const wp = await backendApi.getWorkPhotos();
|
||||
setItems(wp);
|
||||
break;
|
||||
}
|
||||
case 'pr-events': {
|
||||
const ev = await backendApi.getPREvents({ limit: 500 });
|
||||
setItems(ev);
|
||||
break;
|
||||
}
|
||||
case 'accounts': {
|
||||
if (selectedBuildingId) {
|
||||
const building = await backendApi.getBuilding(selectedBuildingId);
|
||||
setItems(building?.accounts || []);
|
||||
} else {
|
||||
const b = await backendApi.getBuildings();
|
||||
setBuildingsForAccounts(b);
|
||||
setItems([]);
|
||||
}
|
||||
break;
|
||||
}
|
||||
default:
|
||||
setItems([]);
|
||||
}
|
||||
} catch (err: any) {
|
||||
setError(err?.message || 'Ошибка загрузки');
|
||||
setItems([]);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [entityType, selectedBuildingId]);
|
||||
|
||||
useEffect(() => {
|
||||
loadItems();
|
||||
}, [loadItems]);
|
||||
|
||||
useEffect(() => {
|
||||
setSelectedIds(new Set());
|
||||
}, [entityType, selectedBuildingId]);
|
||||
|
||||
const getDeleteFnForItem = useCallback((item: any): (() => Promise<void>) => {
|
||||
switch (entityType) {
|
||||
case 'buildings': return () => backendApi.deleteBuilding(item.id);
|
||||
case 'employees': return () => backendApi.deleteEmployee(item.id);
|
||||
case 'districts': return () => backendApi.deleteDistrict(item.id);
|
||||
case 'portal-users': return () => backendApi.deletePortalUser(item.id);
|
||||
case 'permission-templates': return () => backendApi.deletePermissionTemplate(item.id);
|
||||
case 'vacancies': return () => backendApi.deleteVacancy(item.id);
|
||||
case 'candidates': return () => backendApi.deleteCandidate(item.id);
|
||||
case 'training-programs': return () => backendApi.deleteTrainingProgram(item.id);
|
||||
case 'expense-categories': return () => backendApi.deleteExpenseCategory(item.id);
|
||||
case 'expense-items': return () => backendApi.deleteExpenseItem(item.id);
|
||||
case 'supply-requests': return () => backendApi.deleteSupplyRequest(item.id);
|
||||
case 'office-inventory': return () => backendApi.deleteOfficeInventory(item.id);
|
||||
case 'office-documents': return () => backendApi.deleteOfficeDocument(item.id);
|
||||
case 'hr-template-documents': return () => backendApi.deleteHrTemplateDocument(item.id);
|
||||
case 'doma-address-mappings': return () => backendApi.deleteDomaAddressMapping(item.id);
|
||||
case 'doma-employee-mappings': return () => backendApi.deleteDomaEmployeeMapping(item.id);
|
||||
case 'pr-work-photos': return () => backendApi.deleteWorkPhoto(item.id);
|
||||
case 'pr-events': return () => backendApi.deletePREvent(item.id);
|
||||
case 'accounts': return () => backendApi.deleteAccount(selectedBuildingId, item.id);
|
||||
default: return async () => {};
|
||||
}
|
||||
}, [entityType, selectedBuildingId]);
|
||||
|
||||
const handleDeleteSelected = async () => {
|
||||
if (selectedIds.size === 0) return;
|
||||
if (!confirm(`Удалить выбранные записи (${selectedIds.size})? Действие необратимо.`)) return;
|
||||
setBulkDeleting(true);
|
||||
setError(null);
|
||||
try {
|
||||
const toDelete = items.filter((it) => selectedIds.has(getItemId(it, entityType)));
|
||||
for (const item of toDelete) {
|
||||
await getDeleteFnForItem(item)();
|
||||
}
|
||||
setSelectedIds(new Set());
|
||||
await loadItems();
|
||||
} catch (err: any) {
|
||||
setError(err?.message || 'Ошибка удаления');
|
||||
} finally {
|
||||
setBulkDeleting(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDeleteAll = async () => {
|
||||
if (items.length === 0) return;
|
||||
if (!confirm(`Удалить все записи (${items.length}) в разделе «${ENTITY_LABELS[entityType]}»? Это необратимо. Рекомендуется создать резервную копию.`)) return;
|
||||
setBulkDeleting(true);
|
||||
setError(null);
|
||||
try {
|
||||
for (const item of items) {
|
||||
await getDeleteFnForItem(item)();
|
||||
}
|
||||
setSelectedIds(new Set());
|
||||
await loadItems();
|
||||
} catch (err: any) {
|
||||
setError(err?.message || 'Ошибка удаления');
|
||||
} finally {
|
||||
setBulkDeleting(false);
|
||||
}
|
||||
};
|
||||
|
||||
const toggleSelectAll = () => {
|
||||
if (selectedIds.size === items.length) {
|
||||
setSelectedIds(new Set());
|
||||
} else {
|
||||
setSelectedIds(new Set(items.map((it) => getItemId(it, entityType))));
|
||||
}
|
||||
};
|
||||
|
||||
const toggleSelect = (id: string) => {
|
||||
setSelectedIds((prev) => {
|
||||
const next = new Set(prev);
|
||||
if (next.has(id)) next.delete(id);
|
||||
else next.add(id);
|
||||
return next;
|
||||
});
|
||||
};
|
||||
|
||||
const handleDelete = async (
|
||||
id: string | number,
|
||||
label: string,
|
||||
deleteFn: () => Promise<void>
|
||||
) => {
|
||||
if (!confirm(`Удалить «${label}»? Действие необратимо.`)) return;
|
||||
setDeletingId(id);
|
||||
setError(null);
|
||||
try {
|
||||
await deleteFn();
|
||||
await loadItems();
|
||||
} catch (err: any) {
|
||||
setError(err?.message || 'Ошибка удаления');
|
||||
} finally {
|
||||
setDeletingId(null);
|
||||
}
|
||||
};
|
||||
|
||||
const getTableHeaders = (): { main: string; sub?: string } => {
|
||||
switch (entityType) {
|
||||
case 'buildings':
|
||||
return { main: 'Адрес' };
|
||||
case 'employees':
|
||||
return { main: 'ФИО', sub: 'Должность' };
|
||||
case 'districts':
|
||||
return { main: 'Название', sub: 'Руководитель' };
|
||||
case 'portal-users':
|
||||
return { main: 'Логин / Сотрудник' };
|
||||
case 'permission-templates':
|
||||
return { main: 'Название', sub: 'Описание' };
|
||||
case 'vacancies':
|
||||
return { main: 'Должность', sub: 'Отдел / Статус' };
|
||||
case 'candidates':
|
||||
return { main: 'ФИО', sub: 'Вакансия / Этап' };
|
||||
case 'training-programs':
|
||||
return { main: 'Название', sub: 'Тип / Категория' };
|
||||
case 'expense-categories':
|
||||
return { main: 'Название', sub: 'Код' };
|
||||
case 'expense-items':
|
||||
return { main: 'Название', sub: 'Категория' };
|
||||
case 'supply-requests':
|
||||
return { main: 'Товар / Заявитель', sub: 'Статус' };
|
||||
case 'office-inventory':
|
||||
return { main: 'Наименование', sub: 'Категория / Кол-во' };
|
||||
case 'office-documents':
|
||||
return { main: 'Рег. № / Тема', sub: 'Корреспондент' };
|
||||
case 'hr-template-documents':
|
||||
return { main: 'Название', sub: 'Файл / Дата' };
|
||||
case 'doma-address-mappings':
|
||||
return { main: 'Адрес Doma', sub: 'Сопоставленный дом' };
|
||||
case 'doma-employee-mappings':
|
||||
return { main: 'Имя Doma', sub: 'Сотрудник' };
|
||||
case 'pr-work-photos':
|
||||
return { main: 'Работа / Дата', sub: 'Адрес' };
|
||||
case 'pr-events':
|
||||
return { main: 'Название', sub: 'Дата / Тип' };
|
||||
case 'accounts':
|
||||
return { main: 'Лицевой счёт', sub: 'Квартира / Владелец' };
|
||||
default:
|
||||
return { main: 'Название' };
|
||||
}
|
||||
};
|
||||
|
||||
const renderRowContent = (item: any) => {
|
||||
const id = item.id ?? item.candidateId ?? item.vacancy_id;
|
||||
const del = (fn: () => Promise<void>, label: string) => (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => handleDelete(id, label, fn)}
|
||||
disabled={deletingId === id}
|
||||
className="inline-flex items-center gap-1.5 px-3 py-1.5 text-red-600 hover:bg-red-50 text-xs font-medium rounded-lg disabled:opacity-50"
|
||||
>
|
||||
{deletingId === id ? <Loader2 className="w-3.5 h-3.5 animate-spin" /> : <Trash2 className="w-3.5 h-3.5" />}
|
||||
Удалить
|
||||
</button>
|
||||
);
|
||||
|
||||
switch (entityType) {
|
||||
case 'buildings':
|
||||
return (
|
||||
<>
|
||||
<td className="py-3 px-4 font-medium text-slate-800">{item.passport?.address || item.id}</td>
|
||||
<td className="py-3 px-4 text-right">
|
||||
{del(() => backendApi.deleteBuilding(item.id), item.passport?.address || item.id)}
|
||||
</td>
|
||||
</>
|
||||
);
|
||||
case 'employees':
|
||||
return (
|
||||
<>
|
||||
<td className="py-3 px-4 font-medium text-slate-800">{item.name}</td>
|
||||
<td className="py-3 px-4 text-slate-600">{item.position}</td>
|
||||
<td className="py-3 px-4 text-right">
|
||||
{del(() => backendApi.deleteEmployee(item.id), item.name)}
|
||||
</td>
|
||||
</>
|
||||
);
|
||||
case 'districts':
|
||||
return (
|
||||
<>
|
||||
<td className="py-3 px-4 font-medium text-slate-800">{item.name}</td>
|
||||
<td className="py-3 px-4 text-slate-600">{item.managerName}</td>
|
||||
<td className="py-3 px-4 text-right">
|
||||
{del(() => backendApi.deleteDistrict(item.id), item.name)}
|
||||
</td>
|
||||
</>
|
||||
);
|
||||
case 'portal-users':
|
||||
return (
|
||||
<>
|
||||
<td className="py-3 px-4">
|
||||
<span className="font-medium text-slate-800">{item.login}</span>
|
||||
<span className="block text-xs text-slate-500">{item.employeeName} · {item.employeePosition}</span>
|
||||
</td>
|
||||
<td className="py-3 px-4 text-right">
|
||||
{del(() => backendApi.deletePortalUser(item.id), item.login)}
|
||||
</td>
|
||||
</>
|
||||
);
|
||||
case 'permission-templates':
|
||||
return (
|
||||
<>
|
||||
<td className="py-3 px-4 font-medium text-slate-800">{item.name}</td>
|
||||
<td className="py-3 px-4 text-slate-600">{item.description || '—'}</td>
|
||||
<td className="py-3 px-4 text-right">
|
||||
{del(() => backendApi.deletePermissionTemplate(item.id), item.name)}
|
||||
</td>
|
||||
</>
|
||||
);
|
||||
case 'vacancies':
|
||||
return (
|
||||
<>
|
||||
<td className="py-3 px-4 font-medium text-slate-800">{item.title || item.position || item.name || item.id}</td>
|
||||
<td className="py-3 px-4 text-slate-600">{[item.department, item.status].filter(Boolean).join(' / ') || '—'}</td>
|
||||
<td className="py-3 px-4 text-right">
|
||||
{del(() => backendApi.deleteVacancy(item.id), item.title || item.position || String(item.id))}
|
||||
</td>
|
||||
</>
|
||||
);
|
||||
case 'candidates':
|
||||
return (
|
||||
<>
|
||||
<td className="py-3 px-4 font-medium text-slate-800">{item.full_name || item.name || item.id}</td>
|
||||
<td className="py-3 px-4 text-slate-600">{[item.vacancy_id, item.stage].filter(Boolean).join(' / ') || '—'}</td>
|
||||
<td className="py-3 px-4 text-right">
|
||||
{del(() => backendApi.deleteCandidate(item.id), item.full_name || item.name || String(item.id))}
|
||||
</td>
|
||||
</>
|
||||
);
|
||||
case 'training-programs':
|
||||
return (
|
||||
<>
|
||||
<td className="py-3 px-4 font-medium text-slate-800">{item.title}</td>
|
||||
<td className="py-3 px-4 text-slate-600">{[item.type, item.category].filter(Boolean).join(' / ') || '—'}</td>
|
||||
<td className="py-3 px-4 text-right">
|
||||
{del(() => backendApi.deleteTrainingProgram(item.id), item.title)}
|
||||
</td>
|
||||
</>
|
||||
);
|
||||
case 'expense-categories':
|
||||
return (
|
||||
<>
|
||||
<td className="py-3 px-4 font-medium text-slate-800">{item.name}</td>
|
||||
<td className="py-3 px-4 text-slate-600">{item.code || '—'}</td>
|
||||
<td className="py-3 px-4 text-right">
|
||||
{del(() => backendApi.deleteExpenseCategory(item.id), item.name)}
|
||||
</td>
|
||||
</>
|
||||
);
|
||||
case 'expense-items':
|
||||
return (
|
||||
<>
|
||||
<td className="py-3 px-4 font-medium text-slate-800">{item.name}</td>
|
||||
<td className="py-3 px-4 text-slate-600">{item.category_name || item.categoryId || '—'}</td>
|
||||
<td className="py-3 px-4 text-right">
|
||||
{del(() => backendApi.deleteExpenseItem(item.id), item.name)}
|
||||
</td>
|
||||
</>
|
||||
);
|
||||
case 'supply-requests':
|
||||
return (
|
||||
<>
|
||||
<td className="py-3 px-4 font-medium text-slate-800">{item.item_name || item.itemName} / {item.requester_name || item.requesterName}</td>
|
||||
<td className="py-3 px-4 text-slate-600">{item.status || '—'}</td>
|
||||
<td className="py-3 px-4 text-right">
|
||||
{del(() => backendApi.deleteSupplyRequest(item.id), item.item_name || item.itemName || String(item.id))}
|
||||
</td>
|
||||
</>
|
||||
);
|
||||
case 'office-inventory':
|
||||
return (
|
||||
<>
|
||||
<td className="py-3 px-4 font-medium text-slate-800">{item.name}</td>
|
||||
<td className="py-3 px-4 text-slate-600">{item.category || '—'} / {item.quantity ?? '—'}</td>
|
||||
<td className="py-3 px-4 text-right">
|
||||
{del(() => backendApi.deleteOfficeInventory(item.id), item.name)}
|
||||
</td>
|
||||
</>
|
||||
);
|
||||
case 'office-documents':
|
||||
return (
|
||||
<>
|
||||
<td className="py-3 px-4 font-medium text-slate-800">{item.reg_number || item.regNumber} — {item.title}</td>
|
||||
<td className="py-3 px-4 text-slate-600">{item.correspondent || '—'}</td>
|
||||
<td className="py-3 px-4 text-right">
|
||||
{del(() => backendApi.deleteOfficeDocument(item.id), item.title || item.reg_number || String(item.id))}
|
||||
</td>
|
||||
</>
|
||||
);
|
||||
case 'hr-template-documents':
|
||||
return (
|
||||
<>
|
||||
<td className="py-3 px-4 font-medium text-slate-800">{item.name}</td>
|
||||
<td className="py-3 px-4 text-slate-600">{(item.originalFilename || item.filePath || '—') + (item.createdAt ? ` / ${item.createdAt.slice(0, 10)}` : '')}</td>
|
||||
<td className="py-3 px-4 text-right">
|
||||
{del(() => backendApi.deleteHrTemplateDocument(item.id), item.name)}
|
||||
</td>
|
||||
</>
|
||||
);
|
||||
case 'doma-address-mappings':
|
||||
return (
|
||||
<>
|
||||
<td className="py-3 px-4 font-medium text-slate-800">{item.domaAddress}</td>
|
||||
<td className="py-3 px-4 text-slate-600">{item.buildingAddress || '—'}</td>
|
||||
<td className="py-3 px-4 text-right">
|
||||
{del(() => backendApi.deleteDomaAddressMapping(item.id), item.domaAddress)}
|
||||
</td>
|
||||
</>
|
||||
);
|
||||
case 'doma-employee-mappings':
|
||||
return (
|
||||
<>
|
||||
<td className="py-3 px-4 font-medium text-slate-800">{item.domaName}</td>
|
||||
<td className="py-3 px-4 text-slate-600">{item.employeeName || '—'}</td>
|
||||
<td className="py-3 px-4 text-right">
|
||||
{del(() => backendApi.deleteDomaEmployeeMapping(item.id), item.domaName)}
|
||||
</td>
|
||||
</>
|
||||
);
|
||||
case 'pr-work-photos':
|
||||
return (
|
||||
<>
|
||||
<td className="py-3 px-4 font-medium text-slate-800">{item.workName || item.work_name} / {item.workDate || item.work_date}</td>
|
||||
<td className="py-3 px-4 text-slate-600">{item.address || '—'}</td>
|
||||
<td className="py-3 px-4 text-right">
|
||||
{del(() => backendApi.deleteWorkPhoto(item.id), item.workName || item.work_name || String(item.id))}
|
||||
</td>
|
||||
</>
|
||||
);
|
||||
case 'pr-events':
|
||||
return (
|
||||
<>
|
||||
<td className="py-3 px-4 font-medium text-slate-800">{item.title}</td>
|
||||
<td className="py-3 px-4 text-slate-600">{(item.date || item.eventDate) + (item.type ? ` / ${item.type}` : '')}</td>
|
||||
<td className="py-3 px-4 text-right">
|
||||
{del(() => backendApi.deletePREvent(item.id), item.title)}
|
||||
</td>
|
||||
</>
|
||||
);
|
||||
case 'accounts':
|
||||
return (
|
||||
<>
|
||||
<td className="py-3 px-4 font-medium text-slate-800">{item.accountNumber || item.account_number || item.id}</td>
|
||||
<td className="py-3 px-4 text-slate-600">{(item.apartment || item.apartmentNumber) + (item.ownerName ? ` / ${item.ownerName}` : '')}</td>
|
||||
<td className="py-3 px-4 text-right">
|
||||
{del(() => backendApi.deleteAccount(selectedBuildingId, item.id), item.accountNumber || item.account_number || String(item.id))}
|
||||
</td>
|
||||
</>
|
||||
);
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
const headers = getTableHeaders();
|
||||
const colCount = headers.sub ? 3 : 2;
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div>
|
||||
<h3 className="text-lg font-bold text-slate-800">Очистка данных</h3>
|
||||
<p className="text-sm text-slate-500 mt-1">
|
||||
Удаление записей из базы по всем типам сущностей. Действие необратимо — при необходимости создайте резервную копию.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-wrap items-center gap-3">
|
||||
<label className="text-sm font-bold text-slate-700">Тип данных:</label>
|
||||
<select
|
||||
value={entityType}
|
||||
onChange={(e) => {
|
||||
setEntityType(e.target.value as EntityType);
|
||||
if (e.target.value !== 'accounts') setSelectedBuildingId('');
|
||||
}}
|
||||
className="px-3 py-2 border border-slate-200 rounded-lg text-sm min-w-[220px]"
|
||||
>
|
||||
{(Object.keys(ENTITY_LABELS) as EntityType[]).map((t) => (
|
||||
<option key={t} value={t}>{ENTITY_LABELS[t]}</option>
|
||||
))}
|
||||
</select>
|
||||
{entityType === 'accounts' && (
|
||||
<>
|
||||
<label className="text-sm text-slate-600">Дом:</label>
|
||||
<select
|
||||
value={selectedBuildingId}
|
||||
onChange={(e) => setSelectedBuildingId(e.target.value)}
|
||||
className="px-3 py-2 border border-slate-200 rounded-lg text-sm min-w-[240px]"
|
||||
>
|
||||
<option value="">— Выберите дом —</option>
|
||||
{buildingsForAccounts.map((b) => (
|
||||
<option key={b.id} value={b.id}>{b.passport?.address || b.id}</option>
|
||||
))}
|
||||
</select>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<div className="rounded-xl border border-red-200 bg-red-50 p-4 text-red-700 text-sm flex items-center gap-2">
|
||||
<AlertTriangle className="w-5 h-5 flex-shrink-0" />
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{loading ? (
|
||||
<div className="flex items-center justify-center py-12">
|
||||
<Loader2 className="w-8 h-8 animate-spin text-primary-500" />
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
{items.length > 0 && (
|
||||
<div className="flex flex-wrap items-center gap-3 py-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleDeleteSelected}
|
||||
disabled={selectedIds.size === 0 || bulkDeleting}
|
||||
className="inline-flex items-center gap-2 px-4 py-2 text-sm font-medium text-red-700 bg-red-50 border border-red-200 rounded-lg hover:bg-red-100 disabled:opacity-50 disabled:pointer-events-none"
|
||||
>
|
||||
{bulkDeleting ? <Loader2 className="w-4 h-4 animate-spin" /> : <Trash2 className="w-4 h-4" />}
|
||||
Удалить выбранные ({selectedIds.size})
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleDeleteAll}
|
||||
disabled={bulkDeleting}
|
||||
className="inline-flex items-center gap-2 px-4 py-2 text-sm font-medium text-white bg-red-600 border border-red-700 rounded-lg hover:bg-red-700 disabled:opacity-50 disabled:pointer-events-none"
|
||||
>
|
||||
{bulkDeleting ? <Loader2 className="w-4 h-4 animate-spin" /> : <Trash2 className="w-4 h-4" />}
|
||||
Удалить все ({items.length})
|
||||
</button>
|
||||
</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>
|
||||
{items.length > 0 && (
|
||||
<th className="w-10 py-3 px-4 text-left">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={items.length > 0 && selectedIds.size === items.length}
|
||||
onChange={toggleSelectAll}
|
||||
disabled={bulkDeleting}
|
||||
className="rounded border-slate-300 text-red-600 focus:ring-red-500"
|
||||
/>
|
||||
</th>
|
||||
)}
|
||||
<th className="text-left py-3 px-4 font-bold text-slate-700">{headers.main}</th>
|
||||
{headers.sub && <th className="text-left py-3 px-4 font-bold text-slate-700">{headers.sub}</th>}
|
||||
<th className="text-right py-3 px-4 font-bold text-slate-700">Действие</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{items.length === 0 && (
|
||||
<tr>
|
||||
<td colSpan={colCount} className="py-8 px-4 text-center text-slate-500">
|
||||
{entityType === 'accounts' && !selectedBuildingId
|
||||
? 'Выберите дом'
|
||||
: `Нет записей: ${ENTITY_LABELS[entityType]}`}
|
||||
</td>
|
||||
</tr>
|
||||
)}
|
||||
{items.map((item) => {
|
||||
const id = getItemId(item, entityType);
|
||||
return (
|
||||
<tr key={`${entityType}-${id}`} className="border-b border-slate-100 hover:bg-slate-50/50">
|
||||
<td className="w-10 py-3 px-4">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={selectedIds.has(id)}
|
||||
onChange={() => toggleSelect(id)}
|
||||
disabled={bulkDeleting}
|
||||
className="rounded border-slate-300 text-red-600 focus:ring-red-500"
|
||||
/>
|
||||
</td>
|
||||
{renderRowContent(item)}
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
144
components/admin/DataImportSection.tsx
Executable file
144
components/admin/DataImportSection.tsx
Executable file
@@ -0,0 +1,144 @@
|
||||
import React, { useState, useRef } from 'react';
|
||||
import { backendApi } from '../../services/apiClient';
|
||||
import { Upload, Download, Loader2, FileSpreadsheet } from 'lucide-react';
|
||||
|
||||
type ImportType = 'districts' | 'buildings' | 'employees' | 'accounts';
|
||||
|
||||
const IMPORT_LABELS: Record<ImportType, string> = {
|
||||
districts: 'Участки',
|
||||
buildings: 'Дома',
|
||||
employees: 'Сотрудники',
|
||||
accounts: 'Лицевые счета',
|
||||
};
|
||||
|
||||
export const DataImportSection: React.FC = () => {
|
||||
const [importType, setImportType] = useState<ImportType>('districts');
|
||||
const [file, setFile] = useState<File | null>(null);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [result, setResult] = useState<{ created: number; errors: Array<{ row: number; message: string }>; total: number } | null>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
const handleDownloadTemplate = () => {
|
||||
const url = backendApi.getImportTemplateUrl(importType);
|
||||
window.open(url, '_blank');
|
||||
};
|
||||
|
||||
const handleFileChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const f = e.target.files?.[0];
|
||||
setFile(f || null);
|
||||
setResult(null);
|
||||
setError(null);
|
||||
};
|
||||
|
||||
const handleImport = async () => {
|
||||
if (!file) {
|
||||
setError('Выберите файл');
|
||||
return;
|
||||
}
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
setResult(null);
|
||||
try {
|
||||
const data = await backendApi.importData(importType, file);
|
||||
setResult(data);
|
||||
} catch (e: any) {
|
||||
setError(e?.message || 'Ошибка импорта');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
setFile(null);
|
||||
if (fileInputRef.current) {
|
||||
fileInputRef.current.value = '';
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<h3 className="text-lg font-bold text-slate-800">Загрузка данных</h3>
|
||||
<p className="text-sm text-slate-500">
|
||||
Скачайте шаблон, заполните его и загрузите файл (CSV или XLSX). Поддерживаются: участки, дома, сотрудники, лицевые счета.
|
||||
</p>
|
||||
|
||||
<div className="rounded-xl border border-slate-200 bg-slate-50 p-6 space-y-4">
|
||||
<div>
|
||||
<label className="block text-xs font-bold text-slate-700 mb-2">Тип данных</label>
|
||||
<select
|
||||
value={importType}
|
||||
onChange={(e) => {
|
||||
setImportType(e.target.value as ImportType);
|
||||
setFile(null);
|
||||
setResult(null);
|
||||
setError(null);
|
||||
}}
|
||||
className="w-full max-w-xs px-3 py-2 border border-slate-200 rounded-lg text-sm"
|
||||
>
|
||||
{(Object.keys(IMPORT_LABELS) as ImportType[]).map((t) => (
|
||||
<option key={t} value={t}>{IMPORT_LABELS[t]}</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleDownloadTemplate}
|
||||
className="flex items-center gap-2 px-4 py-2 bg-white border border-slate-200 text-slate-700 text-sm font-medium rounded-lg hover:bg-slate-50"
|
||||
>
|
||||
<Download className="w-4 h-4" /> Скачать шаблон
|
||||
</button>
|
||||
<label className="flex items-center gap-2 px-4 py-2 bg-white border border-slate-200 text-slate-700 text-sm font-medium rounded-lg hover:bg-slate-50 cursor-pointer">
|
||||
<FileSpreadsheet className="w-4 h-4" />
|
||||
Выбрать файл
|
||||
<input
|
||||
ref={fileInputRef}
|
||||
type="file"
|
||||
accept=".csv,.xlsx,.xls"
|
||||
onChange={handleFileChange}
|
||||
className="hidden"
|
||||
/>
|
||||
</label>
|
||||
{file && (
|
||||
<span className="flex items-center gap-2 px-3 py-2 text-slate-600 text-sm">
|
||||
{file.name}
|
||||
</span>
|
||||
)}
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleImport}
|
||||
disabled={loading || !file}
|
||||
className="flex items-center gap-2 px-4 py-2 bg-primary-600 text-white text-sm font-bold rounded-lg hover:bg-primary-700 disabled:opacity-50"
|
||||
>
|
||||
{loading ? <Loader2 className="w-4 h-4 animate-spin" /> : <Upload className="w-4 h-4" />}
|
||||
Загрузить
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<div className="rounded-lg border border-red-200 bg-red-50 p-4 text-red-700 text-sm">{error}</div>
|
||||
)}
|
||||
|
||||
{result && (
|
||||
<div className="rounded-lg border border-slate-200 bg-white p-4 text-sm">
|
||||
<p className="font-medium text-slate-800">
|
||||
Импорт завершён: создано/обновлено записей — <strong>{result.created}</strong>, всего строк — {result.total}.
|
||||
</p>
|
||||
{result.errors.length > 0 && (
|
||||
<div className="mt-3">
|
||||
<p className="text-slate-600 font-medium mb-1">Ошибки по строкам:</p>
|
||||
<ul className="list-disc list-inside text-slate-600 space-y-0.5 max-h-40 overflow-y-auto">
|
||||
{result.errors.slice(0, 20).map((e, i) => (
|
||||
<li key={i}>Строка {e.row}: {e.message}</li>
|
||||
))}
|
||||
{result.errors.length > 20 && (
|
||||
<li className="text-slate-500">… и ещё {result.errors.length - 20}</li>
|
||||
)}
|
||||
</ul>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
434
components/admin/IntegrationsSection.tsx
Executable file
434
components/admin/IntegrationsSection.tsx
Executable file
@@ -0,0 +1,434 @@
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import { backendApi } from '../../services/apiClient';
|
||||
import { Building, ParsingSettings } from '../../types';
|
||||
import { settingsService, DomaAISettings } from '../../services/settingsService';
|
||||
import { apiClient } from '../../services/apiClient';
|
||||
import { DomaPendingMappings } from '../applications/DomaPendingMappings';
|
||||
import { Loader2, Plug, Link2 } from 'lucide-react';
|
||||
|
||||
export const IntegrationsSection: React.FC = () => {
|
||||
const [domaApiUrl, setDomaApiUrl] = useState('');
|
||||
const [domaToken, setDomaToken] = useState('');
|
||||
const [domaSaving, setDomaSaving] = useState(false);
|
||||
const [domaSyncing, setDomaSyncing] = useState(false);
|
||||
const [dadataEnabled, setDadataEnabled] = useState(true);
|
||||
const [dadataApiKey, setDadataApiKey] = useState('');
|
||||
const [dadataSecret, setDadataSecret] = useState('');
|
||||
const [dadataLoading, setDadataLoading] = useState(true);
|
||||
const [dadataSaving, setDadataSaving] = useState(false);
|
||||
const [parsingSettings, setParsingSettings] = useState<ParsingSettings[]>([]);
|
||||
const [parsingLoading, setParsingLoading] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
const loadDoma = async () => {
|
||||
try {
|
||||
const data = await backendApi.getDomaSettings();
|
||||
setDomaApiUrl(data.apiUrl || '');
|
||||
setDomaToken(data.token || '');
|
||||
} catch {
|
||||
const doma = settingsService.getDomaAISettings();
|
||||
if (doma) {
|
||||
setDomaApiUrl(doma.apiUrl || '');
|
||||
setDomaToken(doma.token || '');
|
||||
}
|
||||
}
|
||||
};
|
||||
loadDoma();
|
||||
loadDadata();
|
||||
loadParsing();
|
||||
}, []);
|
||||
|
||||
const loadParsing = async () => {
|
||||
setParsingLoading(true);
|
||||
try {
|
||||
const data = await apiClient.get<any[]>('/pr/parsing-settings');
|
||||
const processed = data.map((item: any) => ({
|
||||
...item,
|
||||
buildingId: item.buildingId ?? (item.settings?.building_id ?? null),
|
||||
}));
|
||||
setParsingSettings(processed);
|
||||
} catch {
|
||||
setParsingSettings([]);
|
||||
} finally {
|
||||
setParsingLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const updateParsing = async (source: 'yandex_maps' | '2gis', updates: Partial<ParsingSettings>) => {
|
||||
try {
|
||||
const apiUpdates: Record<string, unknown> = {};
|
||||
if (updates.enabled !== undefined) apiUpdates.enabled = updates.enabled;
|
||||
if (updates.urlTemplate !== undefined) apiUpdates.url_template = updates.urlTemplate;
|
||||
if (updates.apiKey !== undefined) apiUpdates.api_key = updates.apiKey;
|
||||
if (updates.parsingIntervalHours !== undefined) apiUpdates.parsing_interval_hours = updates.parsingIntervalHours;
|
||||
if (updates.settings !== undefined) apiUpdates.settings = updates.settings;
|
||||
await apiClient.put(`/pr/parsing-settings/${source}`, apiUpdates);
|
||||
await loadParsing();
|
||||
alert('Настройки парсинга сохранены');
|
||||
} catch (e: any) {
|
||||
alert(e?.message || 'Ошибка сохранения');
|
||||
}
|
||||
};
|
||||
|
||||
const testParsing = async (source: 'yandex_maps' | '2gis') => {
|
||||
try {
|
||||
const result = await apiClient.post<{ parsed?: number; found?: number }>(`/pr/parsing-settings/${source}/test`, {});
|
||||
alert(`Тестовый запрос завершён. Найдено отзывов: ${result?.found ?? result?.parsed ?? 0}`);
|
||||
} catch (e: any) {
|
||||
alert(`Ошибка: ${e?.message || 'Неизвестная ошибка'}`);
|
||||
}
|
||||
};
|
||||
|
||||
const loadDadata = async () => {
|
||||
setDadataLoading(true);
|
||||
try {
|
||||
const data = await backendApi.getDadataSettings();
|
||||
setDadataEnabled(data.enabled !== false);
|
||||
setDadataApiKey(data.apiKey || '');
|
||||
setDadataSecret(data.secret || '');
|
||||
} catch {
|
||||
setDadataApiKey('');
|
||||
setDadataSecret('');
|
||||
} finally {
|
||||
setDadataLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const saveDoma = async () => {
|
||||
if (!domaApiUrl.trim()) {
|
||||
alert('Укажите адрес API');
|
||||
return;
|
||||
}
|
||||
setDomaSaving(true);
|
||||
try {
|
||||
const apiUrl = domaApiUrl.trim();
|
||||
const token = domaToken.trim() || '';
|
||||
await backendApi.saveDomaSettings({ apiUrl, token });
|
||||
const settings: DomaAISettings = { apiUrl, token: token || undefined };
|
||||
settingsService.saveDomaAISettings(settings);
|
||||
const { domaGraphQLClient } = await import('../../services/domaGraphQLClient');
|
||||
domaGraphQLClient.updateSettings();
|
||||
domaGraphQLClient.setToken(settings.token || '');
|
||||
alert('Настройки Дома.АИ сохранены');
|
||||
} catch (e: any) {
|
||||
alert(e?.message || 'Ошибка сохранения');
|
||||
} finally {
|
||||
setDomaSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
const runDomaSync = async () => {
|
||||
setDomaSyncing(true);
|
||||
try {
|
||||
const result = await backendApi.domaSyncNow();
|
||||
alert(`Синхронизация завершена. Загружено заявок: ${result?.synced ?? 0}`);
|
||||
} catch (e: any) {
|
||||
if (e?.message?.includes('401') || (e?.message && e.message.indexOf('авторизац') !== -1)) {
|
||||
alert('Требуется авторизация. Войдите в систему и нажмите «Синхронизировать» снова.');
|
||||
} else {
|
||||
alert(e?.message || 'Ошибка синхронизации');
|
||||
}
|
||||
} finally {
|
||||
setDomaSyncing(false);
|
||||
}
|
||||
};
|
||||
|
||||
const saveDadata = async () => {
|
||||
setDadataSaving(true);
|
||||
try {
|
||||
await backendApi.saveDadataSettings({
|
||||
enabled: dadataEnabled,
|
||||
apiKey: dadataApiKey.trim(),
|
||||
secret: dadataSecret.trim(),
|
||||
});
|
||||
alert('Настройки DaData сохранены');
|
||||
} catch (e: any) {
|
||||
alert(e?.message || 'Ошибка сохранения');
|
||||
} finally {
|
||||
setDadataSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-8">
|
||||
<h3 className="text-lg font-bold text-slate-800">Интеграции</h3>
|
||||
<p className="text-sm text-slate-500">
|
||||
Doma AI, DaData, парсинг отзывов с карт и другие внешние сервисы. Сопоставление адресов и исполнителей Doma с вашей базой — в блоке Дома.АИ ниже.
|
||||
</p>
|
||||
|
||||
{/* Doma AI */}
|
||||
<div className="rounded-xl border border-slate-200 bg-slate-50 p-6">
|
||||
<div className="flex items-center gap-3 mb-4">
|
||||
<div className="w-10 h-10 rounded-lg bg-primary-100 flex items-center justify-center">
|
||||
<span className="text-primary-600 font-bold text-sm">Д.АИ</span>
|
||||
</div>
|
||||
<div>
|
||||
<h4 className="font-bold text-slate-800">Дома.АИ</h4>
|
||||
<p className="text-xs text-slate-500">Заявки, API и сопоставление адресов/исполнителей</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="space-y-3">
|
||||
<div>
|
||||
<label className="block text-xs font-bold text-slate-700 mb-1">Адрес API</label>
|
||||
<input
|
||||
type="text"
|
||||
value={domaApiUrl}
|
||||
onChange={(e) => setDomaApiUrl(e.target.value)}
|
||||
placeholder="https://your-domain.doma.ai/admin/api"
|
||||
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-700 mb-1">Токен доступа</label>
|
||||
<textarea
|
||||
value={domaToken}
|
||||
onChange={(e) => setDomaToken(e.target.value)}
|
||||
placeholder="Токен Doma AI"
|
||||
className="w-full px-3 py-2 border border-slate-200 rounded-lg text-sm min-h-[60px]"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<button
|
||||
onClick={saveDoma}
|
||||
disabled={domaSaving}
|
||||
className="px-4 py-2 bg-primary-600 text-white text-sm font-bold rounded-lg hover:bg-primary-700 disabled:opacity-50"
|
||||
>
|
||||
{domaSaving ? <Loader2 className="w-4 h-4 animate-spin inline" /> : 'Сохранить'}
|
||||
</button>
|
||||
<button
|
||||
onClick={runDomaSync}
|
||||
disabled={domaSyncing || !domaApiUrl.trim()}
|
||||
className="px-4 py-2 bg-slate-600 text-white text-sm font-bold rounded-lg hover:bg-slate-700 disabled:opacity-50"
|
||||
>
|
||||
{domaSyncing ? <Loader2 className="w-4 h-4 animate-spin inline" /> : 'Синхронизировать сейчас'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Сопоставления Doma AI: ожидающие и существующие */}
|
||||
<div className="mt-6 pt-6 border-t border-slate-200">
|
||||
<h5 className="text-sm font-bold text-slate-700 mb-3">Сопоставления Doma AI</h5>
|
||||
<p className="text-xs text-slate-500 mb-4">
|
||||
Связь адресов и исполнителей из Doma AI с домами и сотрудниками вашей базы. Ожидающие — после синхронизации; существующие можно править или удалить в разделе «Очистка данных».
|
||||
</p>
|
||||
<DomaPendingMappings />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* DaData */}
|
||||
<div className="rounded-xl border border-slate-200 bg-slate-50 p-6">
|
||||
<div className="flex items-center gap-3 mb-4">
|
||||
<Plug className="w-10 h-10 text-slate-500" />
|
||||
<div>
|
||||
<h4 className="font-bold text-slate-800">DaData</h4>
|
||||
<p className="text-xs text-slate-500">Проверка контрагентов по ИНН в юр. отделе</p>
|
||||
</div>
|
||||
</div>
|
||||
{dadataLoading ? (
|
||||
<div className="flex items-center gap-2 text-slate-500 text-sm"><Loader2 className="w-4 h-4 animate-spin" /> Загрузка...</div>
|
||||
) : (
|
||||
<div className="space-y-3">
|
||||
<label className="flex items-center gap-2 cursor-pointer">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={dadataEnabled}
|
||||
onChange={(e) => setDadataEnabled(e.target.checked)}
|
||||
className="w-4 h-4 text-primary-600 rounded"
|
||||
/>
|
||||
<span className="text-sm text-slate-700">Включено</span>
|
||||
</label>
|
||||
<div>
|
||||
<label className="block text-xs font-bold text-slate-700 mb-1">API Key (Token)</label>
|
||||
<input
|
||||
type="password"
|
||||
value={dadataApiKey}
|
||||
onChange={(e) => setDadataApiKey(e.target.value)}
|
||||
placeholder="Токен DaData"
|
||||
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-700 mb-1">Secret</label>
|
||||
<input
|
||||
type="password"
|
||||
value={dadataSecret}
|
||||
onChange={(e) => setDadataSecret(e.target.value)}
|
||||
placeholder="Секретный ключ DaData"
|
||||
className="w-full px-3 py-2 border border-slate-200 rounded-lg text-sm"
|
||||
/>
|
||||
</div>
|
||||
<button
|
||||
onClick={saveDadata}
|
||||
disabled={dadataSaving}
|
||||
className="px-4 py-2 bg-primary-600 text-white text-sm font-bold rounded-lg hover:bg-primary-700 disabled:opacity-50"
|
||||
>
|
||||
{dadataSaving ? <Loader2 className="w-4 h-4 animate-spin inline" /> : 'Сохранить'}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Подключение API отзывов */}
|
||||
<div className="rounded-xl border border-slate-200 bg-slate-50 p-6">
|
||||
<h4 className="font-bold text-slate-800 mb-2">Подключение API отзывов</h4>
|
||||
<p className="text-xs text-slate-500 mb-4">
|
||||
Для 2ГИС укажите API ключ и URL страницы организации. Для Яндекса отзывы через API недоступны.
|
||||
</p>
|
||||
{parsingLoading ? (
|
||||
<div className="flex items-center gap-2 text-slate-500 text-sm"><Loader2 className="w-4 h-4 animate-spin" /> Загрузка...</div>
|
||||
) : (
|
||||
<div className="space-y-4">
|
||||
{parsingSettings.find((s) => s.source === 'yandex_maps') && (
|
||||
<ParsingCard
|
||||
setting={parsingSettings.find((s) => s.source === 'yandex_maps')!}
|
||||
sourceName="Яндекс Карты"
|
||||
onUpdate={(u) => updateParsing('yandex_maps', u)}
|
||||
onTest={() => testParsing('yandex_maps')}
|
||||
/>
|
||||
)}
|
||||
{parsingSettings.find((s) => s.source === '2gis') && (
|
||||
<ParsingCard
|
||||
setting={parsingSettings.find((s) => s.source === '2gis')!}
|
||||
sourceName="2ГИС"
|
||||
onUpdate={(u) => updateParsing('2gis', u)}
|
||||
onTest={() => testParsing('2gis')}
|
||||
/>
|
||||
)}
|
||||
{parsingSettings.length === 0 && (
|
||||
<p className="text-sm text-slate-500">Настройки парсинга не заданы</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Другие интеграции */}
|
||||
<div className="rounded-xl border border-dashed border-slate-200 bg-slate-50/50 p-6">
|
||||
<div className="flex items-center gap-3 text-slate-500">
|
||||
<Link2 className="w-8 h-8" />
|
||||
<div>
|
||||
<h4 className="font-bold text-slate-600">Другие интеграции</h4>
|
||||
<p className="text-xs">Здесь можно добавить новые интеграции по мере необходимости</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
function ParsingCard({
|
||||
setting,
|
||||
sourceName,
|
||||
onUpdate,
|
||||
onTest,
|
||||
}: {
|
||||
setting: ParsingSettings;
|
||||
sourceName: string;
|
||||
onUpdate: (u: Partial<ParsingSettings>) => void;
|
||||
onTest: () => void;
|
||||
}) {
|
||||
const [enabled, setEnabled] = useState(setting.enabled);
|
||||
const [urlTemplate, setUrlTemplate] = useState(setting.urlTemplate || '');
|
||||
const [apiKey, setApiKey] = useState(setting.apiKey || '');
|
||||
const [interval, setInterval] = useState(setting.parsingIntervalHours ?? 24);
|
||||
const [buildingId, setBuildingId] = useState(setting.buildingId || '');
|
||||
const [buildings, setBuildings] = useState<Building[]>([]);
|
||||
const [isLoadingBuildings, setIsLoadingBuildings] = useState(true);
|
||||
|
||||
useEffect(() => {
|
||||
const load = async () => {
|
||||
try {
|
||||
setIsLoadingBuildings(true);
|
||||
const data = await backendApi.getBuildings();
|
||||
setBuildings(data);
|
||||
} catch {
|
||||
setBuildings([]);
|
||||
} finally {
|
||||
setIsLoadingBuildings(false);
|
||||
}
|
||||
};
|
||||
load();
|
||||
}, []);
|
||||
|
||||
const handleSave = () => {
|
||||
onUpdate({
|
||||
enabled,
|
||||
urlTemplate: urlTemplate || undefined,
|
||||
apiKey: apiKey || undefined,
|
||||
parsingIntervalHours: interval,
|
||||
settings: { ...(setting.settings || {}), building_id: buildingId || null },
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="border border-slate-200 rounded-lg p-4 bg-white">
|
||||
<div className="flex justify-between items-center mb-3">
|
||||
<h5 className="font-bold text-slate-800 text-sm">{sourceName}</h5>
|
||||
<label className="flex items-center gap-2 cursor-pointer text-xs">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={enabled}
|
||||
onChange={(e) => setEnabled(e.target.checked)}
|
||||
className="w-3.5 h-3.5 text-primary-600 rounded"
|
||||
/>
|
||||
Включено
|
||||
</label>
|
||||
</div>
|
||||
<div className="space-y-2 text-sm">
|
||||
<div>
|
||||
<label className="block text-xs text-slate-600 mb-0.5">URL шаблон</label>
|
||||
<input
|
||||
type="text"
|
||||
value={urlTemplate}
|
||||
onChange={(e) => setUrlTemplate(e.target.value)}
|
||||
placeholder="https://2gis.ru/ufa/firm/2393065583658695/tab/reviews"
|
||||
className="w-full px-2 py-1.5 border border-slate-200 rounded text-sm"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-xs text-slate-600 mb-0.5">API ключ</label>
|
||||
<input
|
||||
type="password"
|
||||
value={apiKey}
|
||||
onChange={(e) => setApiKey(e.target.value)}
|
||||
className="w-full px-2 py-1.5 border border-slate-200 rounded text-sm"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-xs text-slate-600 mb-0.5">Интервал (часов)</label>
|
||||
<input
|
||||
type="number"
|
||||
value={interval}
|
||||
onChange={(e) => setInterval(parseInt(e.target.value, 10) || 24)}
|
||||
min={1}
|
||||
className="w-full px-2 py-1.5 border border-slate-200 rounded text-sm"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-xs text-slate-600 mb-0.5">Дом (опционально)</label>
|
||||
{isLoadingBuildings ? (
|
||||
<div className="w-full px-2 py-1.5 border border-slate-200 rounded text-sm text-slate-400">Загрузка домов...</div>
|
||||
) : (
|
||||
<select
|
||||
value={buildingId}
|
||||
onChange={(e) => setBuildingId(e.target.value)}
|
||||
className="w-full px-2 py-1.5 border border-slate-200 rounded text-sm"
|
||||
>
|
||||
<option value="">Выберите дом (опционально)</option>
|
||||
{buildings.map((b) => (
|
||||
<option key={b.id} value={b.id}>{b.passport?.address || b.id}</option>
|
||||
))}
|
||||
</select>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex gap-2 mt-3">
|
||||
<button onClick={handleSave} className="px-3 py-1.5 bg-primary-600 text-white text-xs font-bold rounded-lg hover:bg-primary-700">
|
||||
Сохранить
|
||||
</button>
|
||||
<button onClick={onTest} className="px-3 py-1.5 bg-slate-100 text-slate-700 text-xs font-medium rounded-lg hover:bg-slate-200">
|
||||
Тест
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
473
components/admin/PermissionsSection.tsx
Executable file
473
components/admin/PermissionsSection.tsx
Executable file
@@ -0,0 +1,473 @@
|
||||
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>
|
||||
);
|
||||
};
|
||||
248
components/admin/PositionsSection.tsx
Executable file
248
components/admin/PositionsSection.tsx
Executable file
@@ -0,0 +1,248 @@
|
||||
import React, { useCallback, useEffect, useState } from 'react';
|
||||
import { backendApi } from '../../services/apiClient';
|
||||
import { Position } from '../../types';
|
||||
import { Briefcase, Loader2, Plus, Pencil, Trash2, X } from 'lucide-react';
|
||||
|
||||
export const PositionsSection: React.FC = () => {
|
||||
const [positions, setPositions] = useState<Position[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [editingId, setEditingId] = useState<string | null>(null);
|
||||
const [isCreateOpen, setIsCreateOpen] = useState(false);
|
||||
const [createName, setCreateName] = useState('');
|
||||
const [createIsManagerial, setCreateIsManagerial] = useState(false);
|
||||
const [editName, setEditName] = useState('');
|
||||
const [editIsManagerial, setEditIsManagerial] = useState(false);
|
||||
const [deleteConfirmId, setDeleteConfirmId] = useState<string | null>(null);
|
||||
|
||||
const load = useCallback(async () => {
|
||||
try {
|
||||
setLoading(true);
|
||||
const list = await backendApi.getPositions();
|
||||
setPositions(list);
|
||||
} catch (err) {
|
||||
console.error('Error loading positions:', err);
|
||||
setPositions([]);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
load();
|
||||
}, [load]);
|
||||
|
||||
const handleCreate = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
if (!createName.trim()) return;
|
||||
try {
|
||||
setSaving(true);
|
||||
await backendApi.createPosition({ name: createName.trim(), isManagerial: createIsManagerial });
|
||||
setIsCreateOpen(false);
|
||||
setCreateName('');
|
||||
setCreateIsManagerial(false);
|
||||
await load();
|
||||
} catch (err: any) {
|
||||
console.error(err);
|
||||
alert(err?.message || 'Ошибка создания должности');
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
const startEdit = (p: Position) => {
|
||||
setEditingId(p.id);
|
||||
setEditName(p.name);
|
||||
setEditIsManagerial(p.isManagerial ?? false);
|
||||
};
|
||||
|
||||
const cancelEdit = () => {
|
||||
setEditingId(null);
|
||||
};
|
||||
|
||||
const handleUpdate = async (id: string) => {
|
||||
if (!editName.trim()) return;
|
||||
try {
|
||||
setSaving(true);
|
||||
await backendApi.updatePosition(id, { name: editName.trim(), isManagerial: editIsManagerial });
|
||||
setEditingId(null);
|
||||
await load();
|
||||
} catch (err: any) {
|
||||
console.error(err);
|
||||
alert(err?.message || 'Ошибка сохранения');
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDelete = async (id: string) => {
|
||||
try {
|
||||
setSaving(true);
|
||||
await backendApi.deletePosition(id);
|
||||
setDeleteConfirmId(null);
|
||||
await load();
|
||||
} catch (err: any) {
|
||||
console.error(err);
|
||||
alert(err?.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-600" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="flex items-center gap-3 mb-6">
|
||||
<div className="w-10 h-10 rounded-xl bg-primary-50 flex items-center justify-center">
|
||||
<Briefcase className="w-5 h-5 text-primary-600" />
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="text-lg font-bold text-slate-800">Справочник должностей</h3>
|
||||
<p className="text-xs text-slate-500">
|
||||
Должности для назначения сотрудникам. Отметьте «Руководящая должность» для должностей руководителей (мастер, начальник участка и т.д.)
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex justify-end mb-4">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setIsCreateOpen(true)}
|
||||
className="flex items-center gap-2 px-4 py-2 rounded-xl bg-primary-600 text-white text-sm font-bold hover:bg-primary-700"
|
||||
>
|
||||
<Plus className="w-4 h-4" /> Добавить должность
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{isCreateOpen && (
|
||||
<form
|
||||
onSubmit={handleCreate}
|
||||
className="mb-6 p-4 bg-slate-50 rounded-2xl border border-slate-200 flex flex-wrap items-end gap-4"
|
||||
>
|
||||
<div className="flex-1 min-w-[200px]">
|
||||
<label className="block text-[10px] font-black uppercase text-slate-500 mb-1">Название должности</label>
|
||||
<input
|
||||
type="text"
|
||||
value={createName}
|
||||
onChange={(e) => setCreateName(e.target.value)}
|
||||
placeholder="Например: Мастер участка"
|
||||
className="w-full p-2.5 border border-slate-200 rounded-xl text-sm"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<label className="flex items-center gap-2 cursor-pointer">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={createIsManagerial}
|
||||
onChange={(e) => setCreateIsManagerial(e.target.checked)}
|
||||
className="rounded border-slate-300"
|
||||
/>
|
||||
<span className="text-sm font-medium text-slate-700">Руководящая должность</span>
|
||||
</label>
|
||||
<div className="flex gap-2">
|
||||
<button type="button" onClick={() => { setIsCreateOpen(false); setCreateName(''); setCreateIsManagerial(false); }} className="px-4 py-2.5 rounded-xl border border-slate-200 text-slate-600 text-sm font-bold">
|
||||
Отмена
|
||||
</button>
|
||||
<button type="submit" disabled={saving} className="px-4 py-2.5 rounded-xl bg-primary-600 text-white text-sm font-bold disabled:opacity-50">
|
||||
{saving ? 'Сохранение...' : 'Сохранить'}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
)}
|
||||
|
||||
<div className="border border-slate-200 rounded-2xl overflow-hidden">
|
||||
<table className="w-full text-left">
|
||||
<thead className="bg-slate-50 border-b border-slate-200">
|
||||
<tr>
|
||||
<th className="p-3 text-[10px] font-black uppercase text-slate-500">Должность</th>
|
||||
<th className="p-3 text-[10px] font-black uppercase text-slate-500 w-48">Руководящая должность</th>
|
||||
<th className="p-3 text-[10px] font-black uppercase text-slate-500 w-28">Действия</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{positions.length === 0 ? (
|
||||
<tr>
|
||||
<td colSpan={3} className="p-6 text-center text-slate-500 text-sm">
|
||||
Нет должностей. Добавьте первую.
|
||||
</td>
|
||||
</tr>
|
||||
) : (
|
||||
positions.map((p) => (
|
||||
<tr key={p.id} className="border-b border-slate-100 last:border-0 hover:bg-slate-50/50">
|
||||
<td className="p-3">
|
||||
{editingId === p.id ? (
|
||||
<input
|
||||
type="text"
|
||||
value={editName}
|
||||
onChange={(e) => setEditName(e.target.value)}
|
||||
className="w-full p-2 border border-slate-200 rounded-lg text-sm"
|
||||
autoFocus
|
||||
/>
|
||||
) : (
|
||||
<span className="font-medium text-slate-800">{p.name}</span>
|
||||
)}
|
||||
</td>
|
||||
<td className="p-3">
|
||||
{editingId === p.id ? (
|
||||
<label className="flex items-center gap-2 cursor-pointer">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={editIsManagerial}
|
||||
onChange={(e) => setEditIsManagerial(e.target.checked)}
|
||||
className="rounded border-slate-300"
|
||||
/>
|
||||
<span className="text-sm text-slate-600">Руководящая</span>
|
||||
</label>
|
||||
) : (
|
||||
<span className={p.isManagerial ? 'text-emerald-600 font-medium' : 'text-slate-400'}>
|
||||
{p.isManagerial ? 'Да' : 'Нет'}
|
||||
</span>
|
||||
)}
|
||||
</td>
|
||||
<td className="p-3">
|
||||
{editingId === p.id ? (
|
||||
<div className="flex items-center gap-2">
|
||||
<button type="button" onClick={() => handleUpdate(p.id)} disabled={saving} className="px-3 py-1.5 rounded-lg bg-primary-600 text-white text-xs font-bold hover:bg-primary-700 disabled:opacity-50">
|
||||
Сохранить
|
||||
</button>
|
||||
<button type="button" onClick={cancelEdit} className="p-2 rounded-lg border border-slate-200 text-slate-500 hover:bg-slate-100" title="Отмена">
|
||||
<X className="w-4 h-4" />
|
||||
</button>
|
||||
</div>
|
||||
) : deleteConfirmId === p.id ? (
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-xs text-slate-500">Удалить?</span>
|
||||
<button type="button" onClick={() => handleDelete(p.id)} disabled={saving} className="text-xs font-bold text-red-600 hover:underline">
|
||||
Да
|
||||
</button>
|
||||
<button type="button" onClick={() => setDeleteConfirmId(null)} className="text-xs font-bold text-slate-500 hover:underline">
|
||||
Нет
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex items-center gap-1">
|
||||
<button type="button" onClick={() => startEdit(p)} className="p-2 rounded-lg text-slate-400 hover:bg-slate-100 hover:text-primary-600" title="Редактировать">
|
||||
<Pencil className="w-4 h-4" />
|
||||
</button>
|
||||
<button type="button" onClick={() => setDeleteConfirmId(p.id)} className="p-2 rounded-lg text-slate-400 hover:bg-red-50 hover:text-red-500" title="Удалить">
|
||||
<Trash2 className="w-4 h-4" />
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</td>
|
||||
</tr>
|
||||
))
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
203
components/admin/ResponsibilityZonesSection.tsx
Executable file
203
components/admin/ResponsibilityZonesSection.tsx
Executable file
@@ -0,0 +1,203 @@
|
||||
import React, { useEffect, useState, useMemo } from 'react';
|
||||
import { backendApi } from '../../services/apiClient';
|
||||
import { SECTION_LABELS, SECTION_SUBS, RESPONSIBILITY_GROUPS } from '../../constants/permissions';
|
||||
import { Loader2, Users, Save, ChevronDown, ChevronRight } from 'lucide-react';
|
||||
|
||||
type Assignment = { employeeId: string; section: string; subId: string };
|
||||
|
||||
const SECTIONS_WITH_SUBS = (Object.keys(SECTION_SUBS) as string[]).filter(
|
||||
(s) => SECTION_SUBS[s] && SECTION_SUBS[s].length > 0 && s !== 'dashboard'
|
||||
);
|
||||
|
||||
export const ResponsibilityZonesSection: React.FC = () => {
|
||||
const [assignments, setAssignments] = useState<Assignment[]>([]);
|
||||
const [employees, setEmployees] = useState<Array<{ id: string; name: string }>>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [saving, setSaving] = useState<string | null>(null);
|
||||
const [expandedSections, setExpandedSections] = useState<Set<string>>(new Set(['hr', 'finance', 'requests']));
|
||||
|
||||
const byZone = useMemo(() => {
|
||||
const map: Record<string, string[]> = {};
|
||||
for (const a of assignments) {
|
||||
const key = `${a.section}_${a.subId}`;
|
||||
if (!map[key]) map[key] = [];
|
||||
map[key].push(a.employeeId);
|
||||
}
|
||||
return map;
|
||||
}, [assignments]);
|
||||
|
||||
const load = async () => {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
try {
|
||||
const [resp, emp] = await Promise.all([
|
||||
backendApi.getResponsibility(),
|
||||
backendApi.getEmployeesList(),
|
||||
]);
|
||||
setAssignments(resp.assignments || []);
|
||||
setEmployees(emp || []);
|
||||
} catch (e: unknown) {
|
||||
setError(e instanceof Error ? e.message : 'Ошибка загрузки');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
load();
|
||||
}, []);
|
||||
|
||||
const toggleSection = (section: string) => {
|
||||
setExpandedSections((prev) => {
|
||||
const next = new Set(prev);
|
||||
if (next.has(section)) next.delete(section);
|
||||
else next.add(section);
|
||||
return next;
|
||||
});
|
||||
};
|
||||
|
||||
const getSelectedForZone = (section: string, subId: string): string[] => {
|
||||
return byZone[`${section}_${subId}`] || [];
|
||||
};
|
||||
|
||||
const setSelectedForZone = (section: string, subId: string, employeeIds: string[]) => {
|
||||
setAssignments((prev) => {
|
||||
const key = `${section}_${subId}`;
|
||||
const rest = prev.filter((a) => a.section !== section || a.subId !== subId);
|
||||
const next = [...rest, ...employeeIds.map((employeeId) => ({ employeeId, section, subId }))];
|
||||
return next;
|
||||
});
|
||||
};
|
||||
|
||||
const saveZone = async (section: string, subId: string) => {
|
||||
const key = `${section}_${subId}`;
|
||||
setSaving(key);
|
||||
try {
|
||||
const employeeIds = byZone[key] || [];
|
||||
await backendApi.putResponsibility({ section, subId, employeeIds });
|
||||
} catch (e: unknown) {
|
||||
setError(e instanceof Error ? e.message : 'Ошибка сохранения');
|
||||
} finally {
|
||||
setSaving(null);
|
||||
}
|
||||
};
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="flex items-center justify-center py-12">
|
||||
<Loader2 className="w-8 h-8 text-primary-500 animate-spin" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<div className="rounded-xl bg-red-50 border border-red-200 p-4 text-red-700 text-sm">
|
||||
{error}
|
||||
<button
|
||||
type="button"
|
||||
onClick={load}
|
||||
className="ml-3 underline font-medium"
|
||||
>
|
||||
Повторить
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<p className="text-slate-600 text-sm max-w-2xl">
|
||||
Назначьте сотрудников ответственными за подразделы модулей. Это используется для уведомлений (например, новая вакансия → ответственные за «Кадры → Вакансии») и для учёта эффективности по зонам.
|
||||
</p>
|
||||
<p className="text-slate-500 text-xs max-w-2xl">
|
||||
Группы зон (например, «ЗП и обучение» vs «Найм и адаптация» в Кадрах) заданы в настройках для наглядности; привязка выполняется по каждому подразделу.
|
||||
</p>
|
||||
|
||||
<div className="space-y-2">
|
||||
{SECTIONS_WITH_SUBS.map((section) => {
|
||||
const subs = SECTION_SUBS[section];
|
||||
const sectionLabel = SECTION_LABELS[section] || section;
|
||||
const isExpanded = expandedSections.has(section);
|
||||
const groups = RESPONSIBILITY_GROUPS[section];
|
||||
|
||||
return (
|
||||
<div key={section} className="border border-slate-200 rounded-xl overflow-hidden bg-white">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => toggleSection(section)}
|
||||
className="w-full flex items-center gap-2 px-4 py-3 text-left font-semibold text-slate-800 bg-slate-50 hover:bg-slate-100 transition-colors"
|
||||
>
|
||||
{isExpanded ? (
|
||||
<ChevronDown className="w-4 h-4 text-slate-500" />
|
||||
) : (
|
||||
<ChevronRight className="w-4 h-4 text-slate-500" />
|
||||
)}
|
||||
<Users className="w-4 h-4 text-primary-600" />
|
||||
{sectionLabel}
|
||||
</button>
|
||||
{isExpanded && (
|
||||
<div className="p-4 pt-0 space-y-4">
|
||||
{groups && groups.length > 0 && (
|
||||
<div className="text-xs text-slate-500 mb-2">
|
||||
Группы: {groups.map((g) => g.name).join(' · ')}
|
||||
</div>
|
||||
)}
|
||||
{subs.map((sub) => {
|
||||
const zoneKey = `${section}_${sub.id}`;
|
||||
const selected = getSelectedForZone(section, sub.id);
|
||||
const savingThis = saving === zoneKey;
|
||||
|
||||
return (
|
||||
<div
|
||||
key={zoneKey}
|
||||
className="flex flex-wrap items-center gap-3 py-2 border-b border-slate-100 last:border-0"
|
||||
>
|
||||
<div className="min-w-[12rem] text-sm font-medium text-slate-700">
|
||||
{sub.label}
|
||||
</div>
|
||||
<div className="flex-1 flex flex-wrap items-center gap-2">
|
||||
<select
|
||||
multiple
|
||||
value={selected}
|
||||
onChange={(e) => {
|
||||
const opts = Array.from(e.target.selectedOptions, (o) => o.value);
|
||||
setSelectedForZone(section, sub.id, opts);
|
||||
}}
|
||||
className="min-w-[14rem] min-h-[2rem] text-sm border border-slate-300 rounded-lg px-2 py-1 bg-white"
|
||||
size={Math.min(4, employees.length + 1)}
|
||||
title="Удерживайте Ctrl (Cmd) для выбора нескольких"
|
||||
>
|
||||
{employees.map((emp) => (
|
||||
<option key={emp.id} value={emp.id}>
|
||||
{emp.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => saveZone(section, sub.id)}
|
||||
disabled={savingThis}
|
||||
className="flex items-center gap-1 px-3 py-1.5 text-xs font-medium rounded-lg bg-primary-600 text-white hover:bg-primary-700 disabled:opacity-50"
|
||||
>
|
||||
{savingThis ? (
|
||||
<Loader2 className="w-3 h-3 animate-spin" />
|
||||
) : (
|
||||
<Save className="w-3 h-3" />
|
||||
)}
|
||||
Сохранить
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
462
components/admin/SecuritySection.tsx
Executable file
462
components/admin/SecuritySection.tsx
Executable file
@@ -0,0 +1,462 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { ChevronDown, ChevronRight, AlertTriangle, CheckCircle2, Shield, List, Ban, BarChart3, Loader2, RefreshCw, Plus, Trash2 } from 'lucide-react';
|
||||
import { backendApi } from '../../services/apiClient';
|
||||
|
||||
type BlockId = 'A' | 'B' | 'C' | 'D' | 'E' | 'F';
|
||||
|
||||
const BLOCKS: Array<{ id: BlockId; title: string; steps: Array<{ id: string; action: string; responsible: string; result: string }> }> = [
|
||||
{ id: 'A', title: 'Организация (152-ФЗ)', steps: [
|
||||
{ id: 'A1', action: 'Назначить ответственного за обработку ПДн (приказ руководителя, круг обязанностей)', responsible: 'Руководство', result: 'Приказ, ФИО ответственного' },
|
||||
{ id: 'A2', action: 'Разработать политику обработки персональных данных (цели, основания, состав ПДн, сроки хранения, права субъектов, меры защиты)', responsible: 'Ответственный + юрист/внешний консультант', result: 'Документ «Политика обработки ПДн»' },
|
||||
{ id: 'A3', action: 'Опубликовать политику (сайт, личный кабинет, приложение)', responsible: 'Ответственный / админ', result: 'Ссылка на политику доступна пользователям' },
|
||||
{ id: 'A4', action: 'Подготовить формы согласия на обработку ПДн (для сотрудников, жильцов, контактов в заявках; при спецкатегориях — отдельное согласие)', responsible: 'Ответственный + юрист', result: 'Формы согласий, чекбоксы в формах сбора данных' },
|
||||
{ id: 'A5', action: 'Внедрить сбор согласий при регистрации/добавлении сотрудников и контактов', responsible: 'Разработка', result: 'Согласие фиксируется при вводе ПДн' },
|
||||
{ id: 'A6', action: 'Подать уведомление в РКН о обработке ПДн (до начала обработки; цели, категории данных, меры защиты)', responsible: 'Ответственный', result: 'Подтверждение приёма уведомления РКН' },
|
||||
{ id: 'A7', action: 'Принять локальные акты: положение об обработке и защите ПДн, регламент доступа к БД и файлам, инструкция для администраторов', responsible: 'Ответственный', result: 'Приказы/положения в организации' },
|
||||
]},
|
||||
{ id: 'B', title: 'HTTPS и секреты (защита при передаче)', steps: [
|
||||
{ id: 'B1', action: 'Определить домен и поддомены; проверить доступ к DNS для DNS-01', responsible: 'Инфраструктура', result: 'Список доменов, доступ к DNS' },
|
||||
{ id: 'B2', action: 'Выпустить wildcard-сертификат (Let\'s Encrypt, DNS-01) или отдельные сертификаты; сохранить в volume/каталог для прокси', responsible: 'Инфраструктура', result: 'Сертификаты на диске/volume' },
|
||||
{ id: 'B3', action: 'Добавить reverse proxy (Traefik / Caddy / Nginx) в docker-compose: 443 и 80, редирект 80→443, проксирование на приложение по HTTP', responsible: 'Разработка / DevOps', result: 'Работающий прокси в Docker' },
|
||||
{ id: 'B4', action: 'Настроить HSTS (Strict-Transport-Security) в прокси', responsible: 'Разработка / DevOps', result: 'HSTS включён' },
|
||||
{ id: 'B5', action: 'В проде задать VITE_API_BASE_URL с https; собирать фронт при деплое с этим значением', responsible: 'Разработка / DevOps', result: 'Все запросы к API идут по HTTPS' },
|
||||
{ id: 'B6', action: 'Убрать дефолтный JWT_SECRET из кода: при отсутствии process.env.JWT_SECRET не запускать сервер; хранить секрет в env/секретах', responsible: 'Разработка', result: 'JWT_SECRET только из env' },
|
||||
{ id: 'B7', action: 'Настроить автообновление сертификатов и перезагрузку прокси после обновления', responsible: 'Инфраструктура', result: 'Сертификаты продлеваются автоматически' },
|
||||
]},
|
||||
{ id: 'C', title: 'Хранение и доступ', steps: [
|
||||
{ id: 'C1', action: 'Проверить, что пароли нигде не логируются; при создании/обновлении пользователя всегда bcrypt-хеш', responsible: 'Разработка', result: 'Аудит логов и кода auth' },
|
||||
{ id: 'C2', action: 'Ограничить доступ ОС к папке backend/uploads (только процесс приложения); при необходимости шифрование тома', responsible: 'Инфраструктура', result: 'Права на uploads проверены' },
|
||||
{ id: 'C3', action: 'Зафиксировать в регламенте доступ ролей к полям с ПДн; при необходимости не отдавать в API чувствительные поля', responsible: 'Ответственный + разработка', result: 'Регламент, при необходимости доработка API' },
|
||||
{ id: 'C4', action: 'Логирование доступа к критичным операциям (изменение сотрудника, экспорт, смена пароля) — без паролей и паспортных данных', responsible: 'Разработка', result: 'Аудит-логи на критические действия' },
|
||||
{ id: 'C5', action: 'Зафиксировать в политике ПДн сроки хранения; при необходимости удаление/обезличивание по истечении срока', responsible: 'Ответственный + разработка', result: 'Сроки в документе, при необходимости автоматизация' },
|
||||
{ id: 'C6', action: '(Опционально) Убрать хранение пароля Doma AI в localStorage: токен или proxy авторизации через бэкенд', responsible: 'Разработка', result: 'Пароль не в localStorage' },
|
||||
{ id: 'C7', action: '(Опционально) Шифрование чувствительных полей в БД (паспорт, СНИЛС, реквизиты); ключ вне БД', responsible: 'Разработка', result: 'Принято решение и при необходимости реализовано' },
|
||||
]},
|
||||
{ id: 'D', title: 'Реагирование и обучение', steps: [
|
||||
{ id: 'D1', action: 'Разработать порядок реагирования на инциденты (обнаружение, фиксация, уведомление РКН и субъектов ПДн, устранение причин)', responsible: 'Ответственный + юрист', result: 'Документ «Порядок реагирования на утечки ПДн»' },
|
||||
{ id: 'D2', action: 'Провести инструктаж сотрудников по работе с ПДн (запрет передачи паролей вне регламента, только HTTPS)', responsible: 'Ответственный', result: 'Журнал инструктажей / запись о проведении' },
|
||||
]},
|
||||
{ id: 'E', title: 'Защита от взлома', steps: [
|
||||
{ id: 'E1', action: 'Rate limit на /api/auth/login (блокировка или задержка после N неудачных попыток с одного IP/логина)', responsible: 'Разработка', result: 'Защита от брутфорса входа' },
|
||||
{ id: 'E2', action: 'Валидация ввода; параметризованные запросы к БД; защита от XSS (CSP) и CSRF (токены при необходимости)', responsible: 'Разработка', result: 'Снижение риска SQL-инъекций, XSS, CSRF' },
|
||||
{ id: 'E3', action: 'Защитные заголовки HTTP: X-Content-Type-Options, X-Frame-Options, Referrer-Policy, при необходимости CSP', responsible: 'Разработка / DevOps', result: 'Защита от XSS и clickjacking' },
|
||||
{ id: 'E4', action: 'Регулярная проверка уязвимостей (npm audit, Snyk) и обновление backend/frontend-пакетов; исправление критичных CVE', responsible: 'Разработка', result: 'Нет известных критичных уязвимостей' },
|
||||
{ id: 'E5', action: 'Firewall (только 80/443 снаружи, SSH по ключу или с ограничением IP); секреты только в env/vault', responsible: 'Инфраструктура', result: 'Сокращение поверхности атаки' },
|
||||
{ id: 'E6', action: 'Логирование неудачных входов и подозрительных запросов; при возможности алерты при аномалиях', responsible: 'Разработка / DevOps', result: 'Быстрое выявление попыток взлома' },
|
||||
]},
|
||||
{ id: 'F', title: 'Защита от вирусов и вредоносного ПО', steps: [
|
||||
{ id: 'F1', action: 'Антивирус на рабочих местах и серверах: регулярное сканирование, обновление баз', responsible: 'Инфраструктура / ИТ', result: 'Снижение риска заражения' },
|
||||
{ id: 'F2', action: 'Проверка загружаемых файлов: ограничение типов и размера; при возможности сканирование при загрузке (ClamAV)', responsible: 'Разработка / DevOps', result: 'Загрузки не становятся каналом вирусов' },
|
||||
{ id: 'F3', action: 'Обновление ОС и ПО: патчи хоста и образов Docker; Node, PostgreSQL и прокси до поддерживаемых версий', responsible: 'Инфраструктура', result: 'Меньше уязвимостей в стеке' },
|
||||
{ id: 'F4', action: 'Не запускать непроверенные скрипты; зависимости только из доверенных реестров (npm); при необходимости проверка целостности', responsible: 'Разработка', result: 'Снижение риска supply-chain атак' },
|
||||
{ id: 'F5', action: 'Политика для сотрудников: не открывать подозрительные вложения, не ставить неподтверждённое ПО; ограничение прав учёток', responsible: 'Ответственный / ИТ', result: 'Меньше риска заноса вирусов' },
|
||||
]},
|
||||
];
|
||||
|
||||
type TabId = 'dashboard' | 'plan';
|
||||
|
||||
function formatDate(iso: string) {
|
||||
try {
|
||||
return new Date(iso).toLocaleString('ru-RU', { day: '2-digit', month: '2-digit', year: 'numeric', hour: '2-digit', minute: '2-digit', second: '2-digit' });
|
||||
} catch {
|
||||
return iso;
|
||||
}
|
||||
}
|
||||
|
||||
export const SecuritySection: React.FC = () => {
|
||||
const [tab, setTab] = useState<TabId>('dashboard');
|
||||
const [expanded, setExpanded] = useState<Set<BlockId>>(new Set(['A', 'B']));
|
||||
|
||||
// Дашборд: настройки, статистика, логи, чёрный список
|
||||
const [settings, setSettings] = useState<{ captchaEnabled: boolean; turnstileSiteKey: string | null; turnstileSecretKeySet: boolean } | null>(null);
|
||||
const [stats, setStats] = useState<{ failedLast24h: number; totalAttemptsLast24h: number; blockedCount: number } | null>(null);
|
||||
const [logs, setLogs] = useState<{ items: Array<{ id: number; ip: string; loginMasked: string; success: boolean; createdAt: string }>; total: number; page: number; limit: number } | null>(null);
|
||||
const [blacklist, setBlacklist] = useState<Array<{ id: number; type: string; value: string; reason: string | null; createdAt: string }>>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [logsPage, setLogsPage] = useState(1);
|
||||
const [logsSuccessFilter, setLogsSuccessFilter] = useState<'all' | true | false>('all');
|
||||
const [blacklistForm, setBlacklistForm] = useState({ type: 'ip' as 'ip' | 'login', value: '', reason: '' });
|
||||
const [blacklistSubmitting, setBlacklistSubmitting] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
// Форма настройки капчи (синхронизируем site key из settings при загрузке)
|
||||
const [captchaSiteKey, setCaptchaSiteKey] = useState('');
|
||||
const [captchaSecretKey, setCaptchaSecretKey] = useState('');
|
||||
const [captchaSaving, setCaptchaSaving] = useState(false);
|
||||
|
||||
const loadDashboard = async () => {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
try {
|
||||
const [settingsRes, statsRes, logsRes, blacklistRes] = await Promise.all([
|
||||
backendApi.getSecuritySettings(),
|
||||
backendApi.getSecurityStats(),
|
||||
backendApi.getSecurityLogs({ page: logsPage, limit: 20, success: logsSuccessFilter === 'all' ? undefined : logsSuccessFilter }),
|
||||
backendApi.getSecurityBlacklist(),
|
||||
]);
|
||||
setSettings(settingsRes);
|
||||
setCaptchaSiteKey(settingsRes.turnstileSiteKey || '');
|
||||
setStats(statsRes);
|
||||
setLogs(logsRes);
|
||||
setBlacklist(blacklistRes);
|
||||
} catch (e: unknown) {
|
||||
setError((e as Error)?.message || 'Ошибка загрузки дашборда');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (tab === 'dashboard') loadDashboard();
|
||||
}, [tab, logsPage, logsSuccessFilter]);
|
||||
|
||||
const toggle = (id: BlockId) => {
|
||||
setExpanded((prev) => {
|
||||
const next = new Set(prev);
|
||||
if (next.has(id)) next.delete(id);
|
||||
else next.add(id);
|
||||
return next;
|
||||
});
|
||||
};
|
||||
|
||||
const handleAddBlacklist = async () => {
|
||||
const val = blacklistForm.value.trim();
|
||||
if (!val) return;
|
||||
setBlacklistSubmitting(true);
|
||||
setError(null);
|
||||
try {
|
||||
await backendApi.addSecurityBlacklist({ type: blacklistForm.type, value: val, reason: blacklistForm.reason.trim() || undefined });
|
||||
setBlacklistForm({ ...blacklistForm, value: '', reason: '' });
|
||||
await loadDashboard();
|
||||
} catch (e: unknown) {
|
||||
setError((e as Error)?.message || 'Ошибка добавления');
|
||||
} finally {
|
||||
setBlacklistSubmitting(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDeleteBlacklist = async (id: number) => {
|
||||
try {
|
||||
await backendApi.deleteSecurityBlacklist(id);
|
||||
setBlacklist((prev) => prev.filter((r) => r.id !== id));
|
||||
} catch (e: unknown) {
|
||||
setError((e as Error)?.message || 'Ошибка удаления');
|
||||
}
|
||||
};
|
||||
|
||||
const handleSaveCaptcha = async () => {
|
||||
setCaptchaSaving(true);
|
||||
setError(null);
|
||||
try {
|
||||
const res = await backendApi.saveSecuritySettings({
|
||||
turnstileSiteKey: captchaSiteKey.trim() || undefined,
|
||||
turnstileSecretKey: captchaSecretKey.trim() || undefined,
|
||||
});
|
||||
setSettings(res);
|
||||
setCaptchaSiteKey(res.turnstileSiteKey || '');
|
||||
setCaptchaSecretKey('');
|
||||
await loadDashboard();
|
||||
} catch (e: unknown) {
|
||||
setError((e as Error)?.message || 'Ошибка сохранения');
|
||||
} finally {
|
||||
setCaptchaSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="flex gap-2 border-b border-slate-200">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setTab('dashboard')}
|
||||
className={`flex items-center gap-2 px-4 py-2 text-sm font-medium rounded-t-lg transition-colors ${tab === 'dashboard' ? 'bg-slate-100 text-slate-800 border border-slate-200 border-b-0 -mb-px' : 'text-slate-600 hover:bg-slate-50'}`}
|
||||
>
|
||||
<Shield className="w-4 h-4" />
|
||||
Дашборд безопасности
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setTab('plan')}
|
||||
className={`flex items-center gap-2 px-4 py-2 text-sm font-medium rounded-t-lg transition-colors ${tab === 'plan' ? 'bg-slate-100 text-slate-800 border border-slate-200 border-b-0 -mb-px' : 'text-slate-600 hover:bg-slate-50'}`}
|
||||
>
|
||||
<List className="w-4 h-4" />
|
||||
План (152-ФЗ)
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{tab === 'dashboard' && (
|
||||
<div className="space-y-6">
|
||||
{error && (
|
||||
<div className="rounded-xl border border-red-200 bg-red-50 p-4 text-red-700 text-sm flex items-center justify-between">
|
||||
<span>{error}</span>
|
||||
<button type="button" onClick={() => setError(null)} className="text-red-500 hover:text-red-700">×</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex items-center justify-between">
|
||||
<h3 className="text-lg font-bold text-slate-800">Мониторинг и настройки</h3>
|
||||
<button
|
||||
type="button"
|
||||
onClick={loadDashboard}
|
||||
disabled={loading}
|
||||
className="flex items-center gap-2 px-3 py-1.5 text-sm text-slate-600 hover:bg-slate-100 rounded-lg"
|
||||
>
|
||||
{loading ? <Loader2 className="w-4 h-4 animate-spin" /> : <RefreshCw className="w-4 h-4" />}
|
||||
Обновить
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{loading && !settings ? (
|
||||
<div className="flex items-center justify-center py-12"><Loader2 className="w-8 h-8 animate-spin text-slate-400" /></div>
|
||||
) : (
|
||||
<>
|
||||
{/* Настройка капчи Turnstile */}
|
||||
<div className="rounded-xl border border-slate-200 bg-white p-4">
|
||||
<div className="flex items-center gap-2 text-slate-500 text-sm mb-3">
|
||||
<Shield className="w-4 h-4" /> Капча (Turnstile)
|
||||
</div>
|
||||
<div className="flex flex-wrap items-end gap-3 mb-2">
|
||||
<span className="font-semibold text-slate-800">
|
||||
{settings?.captchaEnabled ? 'Включена' : 'Выключена'}
|
||||
</span>
|
||||
<span className="text-xs text-slate-500">
|
||||
Задайте ключи из <a href="https://dash.cloudflare.com/?to=/:account/turnstile" target="_blank" rel="noopener noreferrer" className="text-primary-600 hover:underline">Cloudflare Turnstile</a> и сохраните — виджет появится на форме входа.
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-3 items-end">
|
||||
<label className="flex flex-col gap-1">
|
||||
<span className="text-xs font-medium text-slate-600">Site key (публичный)</span>
|
||||
<input
|
||||
type="text"
|
||||
value={captchaSiteKey}
|
||||
onChange={(e) => setCaptchaSiteKey(e.target.value)}
|
||||
placeholder="0x4AAAAAAA..."
|
||||
className="text-sm border border-slate-200 rounded-lg px-3 py-2 min-w-[200px] font-mono"
|
||||
/>
|
||||
</label>
|
||||
<label className="flex flex-col gap-1">
|
||||
<span className="text-xs font-medium text-slate-600">Secret key</span>
|
||||
<input
|
||||
type="password"
|
||||
value={captchaSecretKey}
|
||||
onChange={(e) => setCaptchaSecretKey(e.target.value)}
|
||||
placeholder={settings?.turnstileSecretKeySet ? 'Оставьте пустым, чтобы не менять' : 'Секретный ключ'}
|
||||
className="text-sm border border-slate-200 rounded-lg px-3 py-2 min-w-[200px] font-mono"
|
||||
/>
|
||||
</label>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleSaveCaptcha}
|
||||
disabled={captchaSaving}
|
||||
className="flex items-center gap-2 px-4 py-2 bg-primary-600 text-white text-sm font-medium rounded-lg hover:bg-primary-700 disabled:opacity-50"
|
||||
>
|
||||
{captchaSaving ? <Loader2 className="w-4 h-4 animate-spin" /> : null}
|
||||
Сохранить
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Статистика */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
|
||||
<div className="rounded-xl border border-slate-200 bg-white p-4">
|
||||
<div className="flex items-center gap-2 text-slate-500 text-sm mb-1"><BarChart3 className="w-4 h-4" /> Неудачных входов за 24 ч</div>
|
||||
<div className="font-semibold text-slate-800">{stats?.failedLast24h ?? '—'}</div>
|
||||
</div>
|
||||
<div className="rounded-xl border border-slate-200 bg-white p-4">
|
||||
<div className="flex items-center gap-2 text-slate-500 text-sm mb-1"><BarChart3 className="w-4 h-4" /> Всего попыток за 24 ч</div>
|
||||
<div className="font-semibold text-slate-800">{stats?.totalAttemptsLast24h ?? '—'}</div>
|
||||
</div>
|
||||
<div className="rounded-xl border border-slate-200 bg-white p-4">
|
||||
<div className="flex items-center gap-2 text-slate-500 text-sm mb-1"><Ban className="w-4 h-4" /> В чёрном списке</div>
|
||||
<div className="font-semibold text-slate-800">{stats?.blockedCount ?? '—'}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Логи входа */}
|
||||
<div className="rounded-xl border border-slate-200 bg-white overflow-hidden">
|
||||
<div className="p-4 border-b border-slate-100 flex flex-wrap items-center gap-3">
|
||||
<h4 className="font-semibold text-slate-800">Логи входа</h4>
|
||||
<select
|
||||
value={logsSuccessFilter === 'all' ? 'all' : logsSuccessFilter ? 'true' : 'false'}
|
||||
onChange={(e) => { const v = e.target.value; setLogsSuccessFilter(v === 'all' ? 'all' : v === 'true'); setLogsPage(1); }}
|
||||
className="text-sm border border-slate-200 rounded-lg px-2 py-1"
|
||||
>
|
||||
<option value="all">Все</option>
|
||||
<option value="true">Успешные</option>
|
||||
<option value="false">Неудачные</option>
|
||||
</select>
|
||||
</div>
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full text-sm">
|
||||
<thead>
|
||||
<tr className="bg-slate-50 text-left text-xs font-semibold text-slate-500 uppercase tracking-wider">
|
||||
<th className="px-4 py-2">IP</th>
|
||||
<th className="px-4 py-2">Логин (маска)</th>
|
||||
<th className="px-4 py-2">Результат</th>
|
||||
<th className="px-4 py-2">Время</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{logs?.items?.length ? logs.items.map((row) => (
|
||||
<tr key={row.id} className="border-t border-slate-100">
|
||||
<td className="px-4 py-2 font-mono text-slate-700">{row.ip}</td>
|
||||
<td className="px-4 py-2 text-slate-600">{row.loginMasked || '—'}</td>
|
||||
<td className="px-4 py-2">
|
||||
{row.success ? <span className="text-emerald-600">Успех</span> : <span className="text-red-600">Ошибка</span>}
|
||||
</td>
|
||||
<td className="px-4 py-2 text-slate-500">{formatDate(row.createdAt)}</td>
|
||||
</tr>
|
||||
)) : (
|
||||
<tr><td colSpan={4} className="px-4 py-6 text-center text-slate-500">Нет записей</td></tr>
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
{logs && logs.total > logs.limit && (
|
||||
<div className="p-3 border-t border-slate-100 flex items-center justify-between text-sm text-slate-600">
|
||||
<span>Всего: {logs.total}</span>
|
||||
<div className="flex gap-2">
|
||||
<button type="button" disabled={logsPage <= 1} onClick={() => setLogsPage((p) => p - 1)} className="px-2 py-1 rounded border border-slate-200 disabled:opacity-50">Назад</button>
|
||||
<span>Стр. {logsPage}</span>
|
||||
<button type="button" disabled={logsPage * logs.limit >= logs.total} onClick={() => setLogsPage((p) => p + 1)} className="px-2 py-1 rounded border border-slate-200 disabled:opacity-50">Вперёд</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Чёрный список */}
|
||||
<div className="rounded-xl border border-slate-200 bg-white overflow-hidden">
|
||||
<div className="p-4 border-b border-slate-100">
|
||||
<h4 className="font-semibold text-slate-800 mb-3">Чёрный список (IP или логин)</h4>
|
||||
<div className="flex flex-wrap gap-2 items-end">
|
||||
<select
|
||||
value={blacklistForm.type}
|
||||
onChange={(e) => setBlacklistForm({ ...blacklistForm, type: e.target.value as 'ip' | 'login' })}
|
||||
className="text-sm border border-slate-200 rounded-lg px-3 py-2"
|
||||
>
|
||||
<option value="ip">IP</option>
|
||||
<option value="login">Логин</option>
|
||||
</select>
|
||||
<input
|
||||
type="text"
|
||||
value={blacklistForm.value}
|
||||
onChange={(e) => setBlacklistForm({ ...blacklistForm, value: e.target.value })}
|
||||
placeholder={blacklistForm.type === 'ip' ? '192.168.1.1' : 'логин'}
|
||||
className="text-sm border border-slate-200 rounded-lg px-3 py-2 min-w-[140px]"
|
||||
/>
|
||||
<input
|
||||
type="text"
|
||||
value={blacklistForm.reason}
|
||||
onChange={(e) => setBlacklistForm({ ...blacklistForm, reason: e.target.value })}
|
||||
placeholder="Причина (необяз.)"
|
||||
className="text-sm border border-slate-200 rounded-lg px-3 py-2 min-w-[120px]"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleAddBlacklist}
|
||||
disabled={!blacklistForm.value.trim() || blacklistSubmitting}
|
||||
className="flex items-center gap-1 px-3 py-2 bg-slate-800 text-white text-sm font-medium rounded-lg hover:bg-slate-700 disabled:opacity-50"
|
||||
>
|
||||
{blacklistSubmitting ? <Loader2 className="w-4 h-4 animate-spin" /> : <Plus className="w-4 h-4" />}
|
||||
Добавить
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full text-sm">
|
||||
<thead>
|
||||
<tr className="bg-slate-50 text-left text-xs font-semibold text-slate-500 uppercase tracking-wider">
|
||||
<th className="px-4 py-2">Тип</th>
|
||||
<th className="px-4 py-2">Значение</th>
|
||||
<th className="px-4 py-2">Причина</th>
|
||||
<th className="px-4 py-2">Добавлено</th>
|
||||
<th className="px-4 py-2 w-20"></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{blacklist.length ? blacklist.map((row) => (
|
||||
<tr key={row.id} className="border-t border-slate-100">
|
||||
<td className="px-4 py-2 font-mono text-slate-600">{row.type}</td>
|
||||
<td className="px-4 py-2 text-slate-800">{row.value}</td>
|
||||
<td className="px-4 py-2 text-slate-500">{row.reason || '—'}</td>
|
||||
<td className="px-4 py-2 text-slate-500">{formatDate(row.createdAt)}</td>
|
||||
<td className="px-4 py-2">
|
||||
<button type="button" onClick={() => handleDeleteBlacklist(row.id)} className="p-1 text-red-600 hover:bg-red-50 rounded" title="Удалить"><Trash2 className="w-4 h-4" /></button>
|
||||
</td>
|
||||
</tr>
|
||||
)) : (
|
||||
<tr><td colSpan={5} className="px-4 py-6 text-center text-slate-500">Пусто</td></tr>
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{tab === 'plan' && (
|
||||
<>
|
||||
<p className="text-sm text-slate-600">
|
||||
План защиты персональных данных (152-ФЗ), HTTPS, шифрование при передаче и хранении, защита от взлома и от вирусов. Выполняйте шаги по порядку; приоритеты указаны ниже.
|
||||
</p>
|
||||
<div className="rounded-xl border border-amber-200 bg-amber-50/80 p-4">
|
||||
<h3 className="text-xs font-bold uppercase tracking-wider text-amber-800 mb-2 flex items-center gap-2"><AlertTriangle className="w-4 h-4" /> Приоритеты</h3>
|
||||
<ul className="text-sm text-slate-700 space-y-1">
|
||||
<li><strong>Критично:</strong> A1–A2, A6, B3–B7 (HTTPS + JWT_SECRET). Без этого высоки риски штрафов и компрометации.</li>
|
||||
<li><strong>Важно:</strong> A3–A5, A7, C1–C4, E1–E3, E5, F1, F3. Требования 152-ФЗ, аудит, базовая защита от взлома и вирусов.</li>
|
||||
<li><strong>Далее:</strong> C5–C7, D1–D2, E4, E6, F2, F4, F5.</li>
|
||||
</ul>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
{BLOCKS.map((block) => {
|
||||
const isOpen = expanded.has(block.id);
|
||||
return (
|
||||
<div key={block.id} className="rounded-xl border border-slate-200 bg-white overflow-hidden">
|
||||
<button type="button" onClick={() => toggle(block.id)} className="w-full flex items-center gap-2 py-3 px-4 text-left font-semibold text-slate-800 hover:bg-slate-50 transition-colors">
|
||||
{isOpen ? <ChevronDown className="w-4 h-4" /> : <ChevronRight className="w-4 h-4" />}
|
||||
<span className="text-xs uppercase tracking-wider text-slate-500">Блок {block.id}</span>
|
||||
<span className="ml-1">{block.title}</span>
|
||||
</button>
|
||||
{isOpen && (
|
||||
<div className="border-t border-slate-100 p-4 bg-slate-50/50">
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full text-sm border-collapse">
|
||||
<thead>
|
||||
<tr className="text-left text-xs font-semibold text-slate-500 uppercase tracking-wider">
|
||||
<th className="pb-2 pr-4">№</th>
|
||||
<th className="pb-2 pr-4">Действие</th>
|
||||
<th className="pb-2 pr-4 whitespace-nowrap">Ответственный</th>
|
||||
<th className="pb-2">Результат</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{block.steps.map((step) => (
|
||||
<tr key={step.id} className="border-t border-slate-100 first:border-t-0">
|
||||
<td className="py-2 pr-4 font-mono text-slate-600">{step.id}</td>
|
||||
<td className="py-2 pr-4 text-slate-700">{step.action}</td>
|
||||
<td className="py-2 pr-4 text-slate-600 whitespace-nowrap">{step.responsible}</td>
|
||||
<td className="py-2 text-slate-600">{step.result}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
<div className="rounded-xl border border-slate-200 bg-slate-50 p-4 flex items-start gap-3">
|
||||
<CheckCircle2 className="w-5 h-5 text-emerald-600 shrink-0 mt-0.5" />
|
||||
<div className="text-sm text-slate-600">
|
||||
<p className="font-medium text-slate-800 mb-1">Шифрование по закону</p>
|
||||
<p>Передача: TLS (HTTPS) везде. Пароли: bcrypt (уже используется). Хранение в БД: разграничение доступа; при повышенных рисках — шифрование чувствительных полей. Биометрия не используется — ст. 19 572-ФЗ не применяется.</p>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
746
components/admin/UsersSection.tsx
Executable file
746
components/admin/UsersSection.tsx
Executable file
@@ -0,0 +1,746 @@
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import { backendApi, PortalUserRow } from '../../services/apiClient';
|
||||
import { Employee } from '../../types';
|
||||
import { UserRole } from '../../types';
|
||||
import { District } from '../../types';
|
||||
import { UserPlus, Loader2, Pencil, Trash2, UsersRound, Shield, CheckSquare, Square, X, UserCog } from 'lucide-react';
|
||||
import { PermissionTemplateRow } from '../../services/apiClient';
|
||||
import {
|
||||
SECTION_LABELS,
|
||||
SECTION_SUBS,
|
||||
ALL_SECTION_KEYS,
|
||||
getPermissionLevel,
|
||||
permissionKey,
|
||||
PERMISSION_LEVEL_LABELS,
|
||||
type PermissionLevel,
|
||||
} from '../../constants/permissions';
|
||||
import { ROLE_NAMES } from '../../constants/roleAccess';
|
||||
|
||||
type CreateMode = 'select' | 'createNew';
|
||||
|
||||
export const UsersSection: React.FC = () => {
|
||||
const [portalUsers, setPortalUsers] = useState<PortalUserRow[]>([]);
|
||||
const [employees, setEmployees] = useState<Employee[]>([]);
|
||||
const [districts, setDistricts] = useState<District[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [showCreateForm, setShowCreateForm] = useState(false);
|
||||
const [createMode, setCreateMode] = useState<CreateMode>('select');
|
||||
const [editingId, setEditingId] = useState<number | null>(null);
|
||||
const [formEmployeeId, setFormEmployeeId] = useState('');
|
||||
const [formLogin, setFormLogin] = useState('');
|
||||
const [formPassword, setFormPassword] = useState('');
|
||||
const [formEmail, setFormEmail] = useState('');
|
||||
const [formRole, setFormRole] = useState<UserRole>('ENGINEER');
|
||||
const [empName, setEmpName] = useState('');
|
||||
const [empPosition, setEmpPosition] = useState('');
|
||||
const [empPhone, setEmpPhone] = useState('');
|
||||
const [empSalary, setEmpSalary] = useState('');
|
||||
const [empDistrictId, setEmpDistrictId] = useState('');
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [createError, setCreateError] = useState<string | null>(null);
|
||||
const [permissionsModalUserId, setPermissionsModalUserId] = useState<number | null>(null);
|
||||
const [permissionTemplates, setPermissionTemplates] = useState<PermissionTemplateRow[]>([]);
|
||||
const [userPermissions, setUserPermissions] = useState<string[]>([]);
|
||||
const [userScope, setUserScope] = useState<'all' | 'own_district'>('all');
|
||||
|
||||
const load = async () => {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
try {
|
||||
const [users, emps, dists, templates] = await Promise.all([
|
||||
backendApi.getPortalUsers(),
|
||||
backendApi.getEmployees(),
|
||||
backendApi.getDistricts(),
|
||||
backendApi.getPermissionTemplates(),
|
||||
]);
|
||||
setPortalUsers(users);
|
||||
setEmployees(emps);
|
||||
setDistricts(dists);
|
||||
setPermissionTemplates(templates);
|
||||
} catch (e: any) {
|
||||
setError(e?.message || 'Ошибка загрузки');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
load();
|
||||
}, []);
|
||||
|
||||
const openPermissionsModal = (u: PortalUserRow) => {
|
||||
setPermissionsModalUserId(u.id);
|
||||
setUserPermissions(Array.isArray(u.permissions) ? u.permissions : []);
|
||||
setUserScope(u.scope === 'own_district' ? 'own_district' : 'all');
|
||||
};
|
||||
|
||||
const applyTemplateToUser = async (userId: number, t: PermissionTemplateRow) => {
|
||||
setSaving(true);
|
||||
try {
|
||||
await backendApi.updatePortalUser(userId, {
|
||||
permissions: (t.permissions?.length ?? 0) > 0 ? (t.permissions ?? []) : null,
|
||||
scope: t.scope || 'all',
|
||||
role: t.suggestedRole || undefined,
|
||||
});
|
||||
setUserPermissions(t.permissions ?? []);
|
||||
setUserScope(t.scope || 'all');
|
||||
await load();
|
||||
} catch (e: any) {
|
||||
alert(e?.message || 'Ошибка применения шаблона');
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
const toggleUserSection = (section: string) => {
|
||||
setUserPermissions((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 || p.startsWith(prefix + ':')));
|
||||
} else {
|
||||
return prev.filter((p) => p !== section && !subKeyPrefixes.some((prefix) => p === prefix || p.startsWith(prefix + ':'))).concat([section]);
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
const setUserSubLevel = (section: string, subId: string, level: PermissionLevel) => {
|
||||
setUserPermissions((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 hasUserSection = (section: string) => userPermissions.includes(section);
|
||||
const getUserSubLevel = (section: string, subId: string) => getPermissionLevel(userPermissions, section, subId);
|
||||
const hasUserSub = (section: string, subId: string) => getUserSubLevel(section, subId) !== 'none';
|
||||
|
||||
const saveUserPermissions = async () => {
|
||||
if (permissionsModalUserId == null) return;
|
||||
setSaving(true);
|
||||
try {
|
||||
await backendApi.updatePortalUser(permissionsModalUserId, {
|
||||
permissions: userPermissions.length > 0 ? userPermissions : null,
|
||||
scope: userScope,
|
||||
});
|
||||
setPermissionsModalUserId(null);
|
||||
await load();
|
||||
} catch (e: any) {
|
||||
alert(e?.message || 'Ошибка сохранения');
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
const currentModalUser = permissionsModalUserId != null ? portalUsers.find((u) => u.id === permissionsModalUserId) : null;
|
||||
const templatesByPosition = permissionTemplates.filter((t) => t.forPosition && t.forPosition.trim());
|
||||
|
||||
const employeeIdsWithUser = new Set(portalUsers.map((u) => u.employeeId));
|
||||
const employeesWithoutUser = employees.filter((e) => !employeeIdsWithUser.has(e.id));
|
||||
|
||||
const handleCreate = async () => {
|
||||
if (!formEmployeeId || !formLogin.trim()) {
|
||||
setCreateError('Выберите сотрудника и укажите логин');
|
||||
return;
|
||||
}
|
||||
setSaving(true);
|
||||
setCreateError(null);
|
||||
try {
|
||||
await backendApi.createPortalUser({
|
||||
employeeId: formEmployeeId,
|
||||
login: formLogin.trim(),
|
||||
password: formPassword.trim() || undefined,
|
||||
email: formEmail.trim() || undefined,
|
||||
role: formRole,
|
||||
});
|
||||
setShowCreateForm(false);
|
||||
setFormEmployeeId('');
|
||||
setFormLogin('');
|
||||
setFormPassword('');
|
||||
setFormEmail('');
|
||||
setFormRole('ENGINEER');
|
||||
await load();
|
||||
} catch (e: any) {
|
||||
setCreateError(e?.message || 'Ошибка создания');
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleCreateEmployeeAndUser = async () => {
|
||||
if (!empName.trim() || !empPosition.trim() || !empPhone.trim() || !empSalary.trim() || !formLogin.trim()) {
|
||||
setCreateError('Заполните ФИО, должность, телефон, зарплату и логин');
|
||||
return;
|
||||
}
|
||||
const salaryNum = parseFloat(empSalary.replace(/\s/g, '').replace(',', '.'));
|
||||
if (isNaN(salaryNum) || salaryNum < 0) {
|
||||
setCreateError('Укажите корректную зарплату');
|
||||
return;
|
||||
}
|
||||
setSaving(true);
|
||||
setCreateError(null);
|
||||
try {
|
||||
await backendApi.createEmployeeWithUser({
|
||||
name: empName.trim(),
|
||||
position: empPosition.trim(),
|
||||
phone: empPhone.trim(),
|
||||
status: 'active',
|
||||
salary: salaryNum,
|
||||
assignedDistrictId: empDistrictId.trim() || undefined,
|
||||
login: formLogin.trim(),
|
||||
email: formEmail.trim() || undefined,
|
||||
role: formRole,
|
||||
});
|
||||
setShowCreateForm(false);
|
||||
setCreateMode('select');
|
||||
setEmpName('');
|
||||
setEmpPosition('');
|
||||
setEmpPhone('');
|
||||
setEmpSalary('');
|
||||
setEmpDistrictId('');
|
||||
setFormEmployeeId('');
|
||||
setFormLogin('');
|
||||
setFormEmail('');
|
||||
setFormRole('ENGINEER');
|
||||
await load();
|
||||
} catch (e: any) {
|
||||
setCreateError(e?.message || 'Ошибка создания');
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleUpdate = async (id: number, updates: { login?: string; email?: string; role?: string }) => {
|
||||
setSaving(true);
|
||||
setCreateError(null);
|
||||
try {
|
||||
await backendApi.updatePortalUser(id, updates);
|
||||
setEditingId(null);
|
||||
await load();
|
||||
} catch (e: any) {
|
||||
setCreateError(e?.message || 'Ошибка сохранения');
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDelete = async (id: number) => {
|
||||
if (!confirm('Удалить пользователя портала? Сотрудник останется в системе.')) return;
|
||||
setSaving(true);
|
||||
setCreateError(null);
|
||||
try {
|
||||
await backendApi.deletePortalUser(id);
|
||||
await load();
|
||||
} catch (e: any) {
|
||||
setCreateError(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={() => {
|
||||
setShowCreateForm(true);
|
||||
setCreateError(null);
|
||||
setCreateMode(employeesWithoutUser.length === 0 ? 'createNew' : 'select');
|
||||
setFormEmployeeId(employeesWithoutUser[0]?.id || '');
|
||||
setFormLogin('');
|
||||
setFormPassword('');
|
||||
setFormEmail('');
|
||||
setFormRole('ENGINEER');
|
||||
setEmpName('');
|
||||
setEmpPosition('');
|
||||
setEmpPhone('');
|
||||
setEmpSalary('');
|
||||
setEmpDistrictId('');
|
||||
}}
|
||||
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"
|
||||
>
|
||||
<UserPlus 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>
|
||||
)}
|
||||
{createError && (
|
||||
<div className="rounded-xl border border-red-200 bg-red-50 p-4 text-red-700 text-sm">{createError}</div>
|
||||
)}
|
||||
|
||||
{showCreateForm && (
|
||||
<div className="rounded-xl border border-slate-200 bg-slate-50 p-6 space-y-4">
|
||||
<h4 className="font-bold text-slate-800">Новый пользователь</h4>
|
||||
<div className="flex gap-2 p-1 bg-slate-200/50 rounded-xl">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setCreateMode('select')}
|
||||
className={`flex-1 flex items-center justify-center gap-2 py-2 px-4 rounded-lg text-sm font-medium transition-colors ${createMode === 'select' ? 'bg-white text-primary-600 shadow' : 'text-slate-600 hover:text-slate-800'}`}
|
||||
>
|
||||
<UsersRound className="w-4 h-4" /> Выбрать сотрудника
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setCreateMode('createNew')}
|
||||
className={`flex-1 flex items-center justify-center gap-2 py-2 px-4 rounded-lg text-sm font-medium transition-colors ${createMode === 'createNew' ? 'bg-white text-primary-600 shadow' : 'text-slate-600 hover:text-slate-800'}`}
|
||||
>
|
||||
<UserCog className="w-4 h-4" /> Создать сотрудника и пользователя
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{createMode === 'select' ? (
|
||||
employeesWithoutUser.length === 0 ? (
|
||||
<div className="flex items-center gap-3 p-4 bg-amber-50 border border-amber-200 rounded-lg text-amber-800 text-sm">
|
||||
<UsersRound className="w-5 h-5 flex-shrink-0" />
|
||||
<div>
|
||||
<p className="font-medium">Нет сотрудников без пользователя</p>
|
||||
<p className="mt-1">Переключитесь на вкладку <strong>«Создать сотрудника и пользователя»</strong> — так можно сразу добавить нового сотрудника и учётную запись портала.</p>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<div>
|
||||
<label className="block text-xs font-bold text-slate-600 mb-1">Сотрудник</label>
|
||||
<select
|
||||
value={formEmployeeId}
|
||||
onChange={(e) => setFormEmployeeId(e.target.value)}
|
||||
className="w-full px-3 py-2 border border-slate-200 rounded-lg text-sm"
|
||||
>
|
||||
<option value="">— Выберите —</option>
|
||||
{employeesWithoutUser.map((e) => (
|
||||
<option key={e.id} value={e.id}>{e.name} ({e.position})</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-xs font-bold text-slate-600 mb-1">Логин</label>
|
||||
<input
|
||||
type="text"
|
||||
value={formLogin}
|
||||
onChange={(e) => setFormLogin(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="password"
|
||||
value={formPassword}
|
||||
onChange={(e) => setFormPassword(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">Email (необязательно)</label>
|
||||
<input
|
||||
type="email"
|
||||
value={formEmail}
|
||||
onChange={(e) => setFormEmail(e.target.value)}
|
||||
placeholder="email@example.com"
|
||||
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={formRole}
|
||||
onChange={(e) => setFormRole(e.target.value as UserRole)}
|
||||
className="w-full px-3 py-2 border border-slate-200 rounded-lg text-sm"
|
||||
>
|
||||
{(Object.keys(ROLE_NAMES) as UserRole[]).map((r) => (
|
||||
<option key={r} value={r}>{ROLE_NAMES[r]}</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleCreate}
|
||||
disabled={saving || !formEmployeeId || !formLogin.trim()}
|
||||
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={() => { setShowCreateForm(false); setCreateError(null); }}
|
||||
className="px-4 py-2 border border-slate-200 text-slate-700 text-sm font-medium rounded-lg hover:bg-slate-50"
|
||||
>
|
||||
Отмена
|
||||
</button>
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
) : (
|
||||
<>
|
||||
<p className="text-xs text-slate-600 font-medium">Данные сотрудника</p>
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3">
|
||||
<div>
|
||||
<label className="block text-xs font-bold text-slate-600 mb-1">ФИО</label>
|
||||
<input
|
||||
type="text"
|
||||
value={empName}
|
||||
onChange={(e) => setEmpName(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={empPosition}
|
||||
onChange={(e) => setEmpPosition(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={empPhone}
|
||||
onChange={(e) => setEmpPhone(e.target.value)}
|
||||
placeholder="+7 900 123-45-67"
|
||||
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={empSalary}
|
||||
onChange={(e) => setEmpSalary(e.target.value)}
|
||||
placeholder="50000"
|
||||
className="w-full px-3 py-2 border border-slate-200 rounded-lg text-sm"
|
||||
/>
|
||||
</div>
|
||||
<div className="sm:col-span-2">
|
||||
<label className="block text-xs font-bold text-slate-600 mb-1">Участок (необязательно)</label>
|
||||
<select
|
||||
value={empDistrictId}
|
||||
onChange={(e) => setEmpDistrictId(e.target.value)}
|
||||
className="w-full px-3 py-2 border border-slate-200 rounded-lg text-sm"
|
||||
>
|
||||
<option value="">— Не выбран —</option>
|
||||
{districts.map((d) => (
|
||||
<option key={d.id} value={d.id}>{d.name}</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
<p className="text-xs text-slate-600 font-medium pt-2">Данные пользователя портала</p>
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3">
|
||||
<div>
|
||||
<label className="block text-xs font-bold text-slate-600 mb-1">Логин</label>
|
||||
<input
|
||||
type="text"
|
||||
value={formLogin}
|
||||
onChange={(e) => setFormLogin(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">Email (необязательно)</label>
|
||||
<input
|
||||
type="email"
|
||||
value={formEmail}
|
||||
onChange={(e) => setFormEmail(e.target.value)}
|
||||
placeholder="email@example.com"
|
||||
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={formRole}
|
||||
onChange={(e) => setFormRole(e.target.value as UserRole)}
|
||||
className="w-full px-3 py-2 border border-slate-200 rounded-lg text-sm"
|
||||
>
|
||||
{(Object.keys(ROLE_NAMES) as UserRole[]).map((r) => (
|
||||
<option key={r} value={r}>{ROLE_NAMES[r]}</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex gap-2 pt-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleCreateEmployeeAndUser}
|
||||
disabled={saving || !empName.trim() || !empPosition.trim() || !empPhone.trim() || !empSalary.trim() || !formLogin.trim()}
|
||||
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={() => { setShowCreateForm(false); setCreateError(null); }}
|
||||
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-right py-3 px-4 font-bold text-slate-700">Действия</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{portalUsers.length === 0 ? (
|
||||
<tr>
|
||||
<td colSpan={4} className="py-8 px-4 text-center text-slate-500">
|
||||
Нет пользователей портала. Создайте первого выше.
|
||||
</td>
|
||||
</tr>
|
||||
) : (
|
||||
portalUsers.map((u) => (
|
||||
<tr key={u.id} className="border-b border-slate-100 hover:bg-slate-50/50">
|
||||
<td className="py-3 px-4">
|
||||
<span className="font-medium text-slate-800">{u.employeeName}</span>
|
||||
<span className="block text-xs text-slate-500">{u.employeePosition}</span>
|
||||
</td>
|
||||
<td className="py-3 px-4">{editingId === u.id ? (
|
||||
<input
|
||||
type="text"
|
||||
defaultValue={u.login}
|
||||
onBlur={(e) => {
|
||||
const v = e.target.value.trim();
|
||||
if (v && v !== u.login) handleUpdate(u.id, { login: v });
|
||||
}}
|
||||
onKeyDown={(e) => e.key === 'Enter' && (e.target as HTMLInputElement).blur()}
|
||||
className="w-full max-w-[140px] px-2 py-1 border border-slate-200 rounded text-sm"
|
||||
/>
|
||||
) : (
|
||||
u.login
|
||||
)}</td>
|
||||
<td className="py-3 px-4">
|
||||
{editingId === u.id ? (
|
||||
<select
|
||||
defaultValue={u.role}
|
||||
onChange={(e) => {
|
||||
const r = e.target.value as UserRole;
|
||||
if (r !== u.role) handleUpdate(u.id, { role: r });
|
||||
setEditingId(null);
|
||||
}}
|
||||
className="px-2 py-1 border border-slate-200 rounded text-sm"
|
||||
>
|
||||
{(Object.keys(ROLE_NAMES) as UserRole[]).map((r) => (
|
||||
<option key={r} value={r}>{ROLE_NAMES[r]}</option>
|
||||
))}
|
||||
</select>
|
||||
) : (
|
||||
ROLE_NAMES[u.role] || u.role
|
||||
)}
|
||||
</td>
|
||||
<td className="py-3 px-4 text-right">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => openPermissionsModal(u)}
|
||||
className="p-2 text-slate-500 hover:text-primary-600"
|
||||
title="Настроить права"
|
||||
>
|
||||
<Shield className="w-4 h-4" />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setEditingId(editingId === u.id ? null : u.id)}
|
||||
className="p-2 text-slate-500 hover:text-primary-600"
|
||||
title="Изменить"
|
||||
>
|
||||
<Pencil className="w-4 h-4" />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => handleDelete(u.id)}
|
||||
disabled={saving}
|
||||
className="p-2 text-slate-500 hover:text-red-600"
|
||||
title="Удалить"
|
||||
>
|
||||
<Trash2 className="w-4 h-4" />
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
))
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{permissionsModalUserId != null && (
|
||||
<div className="fixed inset-0 z-[100] flex items-center justify-center p-4 bg-slate-900/80 backdrop-blur-md" onClick={() => setPermissionsModalUserId(null)}>
|
||||
<div className="bg-white rounded-2xl shadow-2xl w-full max-w-lg max-h-[90vh] overflow-y-auto p-6" onClick={(e) => e.stopPropagation()}>
|
||||
<div className="flex justify-between items-center mb-4">
|
||||
<h4 className="font-bold text-slate-800">Права доступа</h4>
|
||||
<button type="button" onClick={() => setPermissionsModalUserId(null)} className="p-2 hover:bg-slate-100 rounded-full">
|
||||
<X className="w-5 h-5 text-slate-500" />
|
||||
</button>
|
||||
</div>
|
||||
<p className="text-sm text-slate-500 mb-4">Права задаются шаблоном или вручную. Роль можно подставить из шаблона. Пустой список — права по роли.</p>
|
||||
{templatesByPosition.length > 0 && currentModalUser && (
|
||||
<div className="mb-4">
|
||||
<label className="block text-xs font-bold text-slate-600 mb-2">Применить по должности</label>
|
||||
<select
|
||||
className="w-full px-3 py-2 border border-slate-200 rounded-lg text-sm"
|
||||
onChange={(e) => {
|
||||
const id = Number(e.target.value);
|
||||
const t = permissionTemplates.find((x) => x.id === id);
|
||||
if (t && permissionsModalUserId != null) applyTemplateToUser(permissionsModalUserId, t);
|
||||
}}
|
||||
>
|
||||
<option value="">— Выберите шаблон по должности —</option>
|
||||
{templatesByPosition.map((t) => (
|
||||
<option key={t.id} value={t.id}>{t.forPosition} → {t.name}</option>
|
||||
))}
|
||||
</select>
|
||||
{currentModalUser && (
|
||||
<p className="text-xs text-slate-500 mt-1">Должность пользователя: {currentModalUser.employeePosition}</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
{permissionTemplates.length > 0 && (
|
||||
<div className="mb-4">
|
||||
<label className="block text-xs font-bold text-slate-600 mb-2">Применить шаблон</label>
|
||||
<select
|
||||
className="w-full px-3 py-2 border border-slate-200 rounded-lg text-sm"
|
||||
onChange={(e) => {
|
||||
const id = Number(e.target.value);
|
||||
const t = permissionTemplates.find((x) => x.id === id);
|
||||
if (t && permissionsModalUserId != null) applyTemplateToUser(permissionsModalUserId, t);
|
||||
}}
|
||||
>
|
||||
<option value="">— Выберите шаблон —</option>
|
||||
{permissionTemplates.map((t) => (
|
||||
<option key={t.id} value={t.id}>{t.name}</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
)}
|
||||
<div className="mb-4">
|
||||
<label className="block text-xs font-bold text-slate-600 mb-1">Обзор данных (дашборды)</label>
|
||||
<select
|
||||
value={userScope}
|
||||
onChange={(e) => setUserScope(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>
|
||||
</div>
|
||||
<div className="mb-4">
|
||||
<label className="block text-xs font-bold text-slate-600 mb-2">Разделы и подразделы (вручную)</label>
|
||||
<div className="space-y-1 max-h-[280px] overflow-y-auto pr-2">
|
||||
{ALL_SECTION_KEYS.map((section) => {
|
||||
const subs = SECTION_SUBS[section];
|
||||
const sectionChecked = hasUserSection(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={() => toggleUserSection(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) => hasUserSub(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={getUserSubLevel(section, sub.id)}
|
||||
onChange={(e) => setUserSubLevel(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={saveUserPermissions}
|
||||
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={() => setPermissionsModalUserId(null)}
|
||||
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>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
Reference in New Issue
Block a user