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

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