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

675 lines
27 KiB
TypeScript
Executable File
Raw Permalink Blame History

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