Initial commit MKD fixes

This commit is contained in:
Arsen
2026-02-04 00:17:04 +05:00
commit de94ad707b
312 changed files with 138754 additions and 0 deletions

120
components/admin/AISection.tsx Executable file
View 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>
);
};

View 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>
);
};

View 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>
);
};

View 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>
);
};

View 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>
);
};

View 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>
);
}

View 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>
);
};

View 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>
);
};

View 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>
);
};

View 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> A1A2, A6, B3B7 (HTTPS + JWT_SECRET). Без этого высоки риски штрафов и компрометации.</li>
<li><strong>Важно:</strong> A3A5, A7, C1C4, E1E3, E5, F1, F3. Требования 152-ФЗ, аудит, базовая защита от взлома и вирусов.</li>
<li><strong>Далее:</strong> C5C7, D1D2, 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
View 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>
);
};