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

588 lines
30 KiB
TypeScript
Executable File
Raw 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, { useState, useEffect, useMemo, useCallback } from 'react';
import { District, Building, User, DomaApplication, Employee } from '../types';
import { storageService } from '../services/storageService';
import { domaService } from '../services/domaService';
import { backendApi } from '../services/apiClient';
import { Plus, Search, ArrowLeft } from 'lucide-react';
import { PerformanceCard } from './objects/PerformanceCard';
// FIX: Removed incorrect self-imports of DistrictModal and BuildingModal which do not exist and were causing circular dependencies.
import { DistrictsSummary } from './objects/DistrictsSummary';
import { DistrictStaffModal } from './objects/DistrictStaffModal';
import { DeleteConfirmModal } from './objects/DeleteConfirmModal';
import { MoveBuildingsModal } from './objects/MoveBuildingsModal';
import { REFRESH_EVENTS } from '../constants/refreshEvents';
import { readCache, saveCache } from '../hooks/useCachedFetch';
import { canAccessSub } from '../constants/permissions';
interface Props {
currentUser: User;
onSelectBuilding: (building: Building) => void;
allowedPermissions?: string[] | null;
}
// Кеш из localStorage для мгновенного отображения (без ожидания API)
const getCachedDistricts = () => storageService.getDistricts();
const getCachedBuildings = () => storageService.getAllBuildings();
const CACHE_EMPLOYEES = 'mkd_hr_employee_registry'; // общий кеш с EmployeeRegistry
const getCachedEmployees = () => readCache<Employee[]>(CACHE_EMPLOYEES, []);
export const DashboardNavigation: React.FC<Props> = ({ currentUser, onSelectBuilding, allowedPermissions }) => {
const [districts, setDistricts] = useState<District[]>(getCachedDistricts);
const [buildings, setBuildings] = useState<Building[]>(getCachedBuildings);
const [applications, setApplications] = useState<DomaApplication[]>([]);
const [employees, setEmployees] = useState<Employee[]>(getCachedEmployees);
const [loading, setLoading] = useState(() => {
const d = getCachedDistricts();
const b = getCachedBuildings();
return d.length === 0 && b.length === 0; // есть кеш — не показываем спиннер
});
const [selectedDistrict, setSelectedDistrict] = useState<District | null>(null);
const [searchQuery, setSearchQuery] = useState('');
const [newBuildingAddress, setNewBuildingAddress] = useState('');
const [isCreateBuildingOpen, setIsCreateBuildingOpen] = useState(false);
const [staffModalDistrict, setStaffModalDistrict] = useState<District | null>(null);
const [districtToDelete, setDistrictToDelete] = useState<District | null>(null);
const [isDeleting, setIsDeleting] = useState(false);
const [showMoveBuildingsModal, setShowMoveBuildingsModal] = useState(false);
const [buildingToDelete, setBuildingToDelete] = useState<Building | null>(null);
const [isDeletingBuilding, setIsDeletingBuilding] = useState(false);
const canManage = ['DIRECTOR', 'ENGINEER'].includes(currentUser.role);
// Участки и дома — сначала (разблокируют интерфейс), сотрудники — следом в фоне
const fetchData = useCallback(async (showLoading = true) => {
if (showLoading) setLoading(true);
// Фаза 1: участки + дома (критично для отображения)
const [districtsResult, buildingsResult] = await Promise.allSettled([
backendApi.getDistricts().catch(() => storageService.getDistricts()),
backendApi.getBuildings().catch(() => storageService.getAllBuildings()),
]);
const allDistricts = districtsResult.status === 'fulfilled' ? districtsResult.value : storageService.getDistricts();
const allBuildings = buildingsResult.status === 'fulfilled' ? buildingsResult.value : storageService.getAllBuildings();
if (districtsResult.status === 'rejected') {
console.warn('[DashboardNavigation] Backend districts unavailable, using local storage/mocks:', districtsResult.reason);
}
if (buildingsResult.status === 'rejected') {
console.warn('[DashboardNavigation] Backend buildings unavailable, using local storage/mocks:', buildingsResult.reason);
}
// Фильтрация участков по правам доступа и scope
let filteredDistricts = allDistricts;
// Проверка прав доступа к разделу objects
const hasObjectsAccess = !allowedPermissions || allowedPermissions.length === 0 ||
allowedPermissions.includes('all') ||
canAccessSub(allowedPermissions, 'objects');
if (!hasObjectsAccess) {
filteredDistricts = [];
} else {
// #region agent log
console.log('[DashboardNavigation] Filter districts:', { role: currentUser.role, scope: currentUser.scope, assignedDistrictId: currentUser.assignedDistrictId, allDistrictsCount: allDistricts.length });
fetch('http://localhost:7243/ingest/a8a7528a-e6ae-43df-b788-a3a4c916ecb1',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({location:'DashboardNavigation.tsx:filterDistricts',message:'before filter',data:{role:currentUser.role,scope:currentUser.scope,assignedDistrictId:currentUser.assignedDistrictId,allDistrictsCount:allDistricts.length},timestamp:Date.now(),sessionId:'debug-session',hypothesisId:'A'})}).catch(()=>{});
// #endregion
// Фильтрация по scope (own_district) — показываем все участки, на которые назначен сотрудник (может быть несколько)
const userDistrictIds = (currentUser.assignedDistrictIds && currentUser.assignedDistrictIds.length > 0)
? currentUser.assignedDistrictIds
: (currentUser.assignedDistrictId ? [currentUser.assignedDistrictId] : []);
if (currentUser.scope === 'own_district' && userDistrictIds.length > 0) {
filteredDistricts = filteredDistricts.filter((d: District) => userDistrictIds.includes(d.id));
// #region agent log
console.log('[DashboardNavigation] Filtered by scope:', { filteredCount: filteredDistricts.length, assignedDistrictIds: userDistrictIds });
fetch('http://localhost:7243/ingest/a8a7528a-e6ae-43df-b788-a3a4c916ecb1',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({location:'DashboardNavigation.tsx:filterDistricts',message:'filtered by scope',data:{filteredCount:filteredDistricts.length,assignedDistrictIds:userDistrictIds},timestamp:Date.now(),sessionId:'debug-session',hypothesisId:'B'})}).catch(()=>{});
// #endregion
}
// Фильтрация по роли (MASTER видит только свои участки) — fallback: все участки из назначений или по старой логике один
else if (currentUser.role === 'MASTER' && userDistrictIds.length > 0) {
filteredDistricts = filteredDistricts.filter((d: District) => userDistrictIds.includes(d.id));
// #region agent log
console.log('[DashboardNavigation] Filtered by MASTER role:', { filteredCount: filteredDistricts.length });
fetch('http://localhost:7243/ingest/a8a7528a-e6ae-43df-b788-a3a4c916ecb1',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({location:'DashboardNavigation.tsx:filterDistricts',message:'filtered by MASTER role',data:{filteredCount:filteredDistricts.length},timestamp:Date.now(),sessionId:'debug-session',hypothesisId:'C'})}).catch(()=>{});
// #endregion
}
// #region agent log
console.log('[DashboardNavigation] Final districts:', { finalCount: filteredDistricts.length, districtIds: filteredDistricts.map(d => d.id) });
fetch('http://localhost:7243/ingest/a8a7528a-e6ae-43df-b788-a3a4c916ecb1',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({location:'DashboardNavigation.tsx:filterDistricts',message:'after filter',data:{finalCount:filteredDistricts.length,districtIds:filteredDistricts.map(d=>d.id)},timestamp:Date.now(),sessionId:'debug-session',hypothesisId:'D'})}).catch(()=>{});
// #endregion
}
setDistricts(filteredDistricts);
setBuildings(allBuildings);
setLoading(false);
// Кешируем для мгновенного отображения при следующем открытии
try {
localStorage.setItem('mkd_districts_v2', JSON.stringify(allDistricts));
localStorage.setItem('mkd_buildings_v2', JSON.stringify(allBuildings));
} catch (_) {}
// Фаза 2: сотрудники в фоне (для staffCount и модалки штата)
backendApi.getEmployees().catch(() => getCachedEmployees()).then(allEmployees => {
setEmployees(allEmployees);
saveCache(CACHE_EMPLOYEES, allEmployees);
});
}, [currentUser]);
// Заявки из Doma AI — только по таймеру (не блокируют загрузку участков/домов)
const fetchApplications = useCallback(async () => {
try {
const apps = await domaService.getApplications();
setApplications(apps);
} catch (e) {
console.warn('[DashboardNavigation] Ошибка загрузки заявок Doma AI:', e);
setApplications([]);
}
}, []);
// Первичная загрузка: при наличии кеша — без спиннера, иначе — показываем загрузку
useEffect(() => {
const hasCache = getCachedDistricts().length > 0 || getCachedBuildings().length > 0;
fetchData(!hasCache);
}, [fetchData]);
// Фоновое обновление участков/домов каждые 10 секунд
useEffect(() => {
const interval = setInterval(() => fetchData(false), 10 * 1000);
return () => clearInterval(interval);
}, [fetchData]);
// Заявки Doma AI: первый запуск через 2 сек, далее каждые 10 секунд по таймеру
useEffect(() => {
const t = setTimeout(fetchApplications, 2000);
const interval = setInterval(fetchApplications, 10 * 1000);
return () => {
clearTimeout(t);
clearInterval(interval);
};
}, [fetchApplications]);
useEffect(() => {
const onRefresh = () => fetchData(false);
window.addEventListener(REFRESH_EVENTS.dashboard, onRefresh);
return () => window.removeEventListener(REFRESH_EVENTS.dashboard, onRefresh);
}, [fetchData]);
// Штат: мгновенное обновление при изменении сотрудников (create/update в HR)
useEffect(() => {
const onRefresh = () => {
backendApi.getEmployees().catch(() => getCachedEmployees()).then(allEmployees => {
setEmployees(allEmployees);
saveCache(CACHE_EMPLOYEES, allEmployees);
});
};
window.addEventListener(REFRESH_EVENTS.employees, onRefresh);
return () => window.removeEventListener(REFRESH_EVENTS.employees, onRefresh);
}, []);
const aggregatedData = useMemo(() => {
if (loading) return {};
const districtStats: any = {};
districts.forEach(d => { districtStats[d.id] = { district: d, buildings: [], applications: [] }; });
buildings.forEach(b => { if (districtStats[b.districtId]) districtStats[b.districtId].buildings.push(b); });
applications.forEach(app => {
const b = buildings.find(build => build.passport.address === app.address);
if (b && districtStats[b.districtId]) districtStats[b.districtId].applications.push(app);
});
return districtStats;
}, [districts, buildings, applications, loading]);
// Проверка прав доступа к разделу objects
const hasObjectsAccess = !allowedPermissions || allowedPermissions.length === 0 ||
allowedPermissions.includes('all') ||
canAccessSub(allowedPermissions, 'objects');
if (loading) return <div className="p-10 text-center animate-pulse font-bold text-slate-400 uppercase tracking-widest">Загрузка активов...</div>;
if (!hasObjectsAccess) {
return (
<div className="p-10 text-center">
<p className="text-slate-500 font-medium mb-2">Нет доступа к разделу «Участки»</p>
<p className="text-sm text-slate-400">Обратитесь к администратору для настройки прав доступа.</p>
</div>
);
}
// VIEW: IF SINGLE DISTRICT SELECTED (Building list within district)
if (selectedDistrict) {
const districtBuildings = buildings.filter(b => b.districtId === selectedDistrict.id && b.passport.address.toLowerCase().includes(searchQuery.toLowerCase()));
// Debug: log buildingToDelete state
if (buildingToDelete) {
console.log('buildingToDelete is set in render:', buildingToDelete.id, buildingToDelete.passport.address);
}
return (
<>
<div className="animate-fade-in space-y-6">
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<button onClick={() => setSelectedDistrict(null)} className="p-2 -ml-2 rounded-full hover:bg-slate-100"><ArrowLeft className="w-5 h-5" /></button>
<div>
<h2 className="text-lg font-bold text-slate-800 leading-none">{selectedDistrict.name}</h2>
<p className="text-[10px] text-slate-400 font-bold uppercase mt-1">Список домов участка</p>
</div>
</div>
{canManage && (
<button
onClick={() => setIsCreateBuildingOpen(true)}
className="bg-primary-600 text-white px-3 py-2 rounded-xl text-xs font-bold flex items-center gap-2 shadow-md active:scale-95 transition-transform"
>
<Plus className="w-4 h-4" /> Дом
</button>
)}
</div>
{isCreateBuildingOpen && selectedDistrict && (
<form
onSubmit={async (e) => {
e.preventDefault();
if (!newBuildingAddress.trim()) return;
try {
// создаём полный объект Building локально
const createdLocal = storageService.createBuilding({
address: newBuildingAddress.trim(),
districtId: selectedDistrict.id,
});
setBuildings((prev) => [...prev, createdLocal]);
// пытаемся записать в БД
try {
await backendApi.createBuilding(createdLocal);
window.dispatchEvent(new CustomEvent(REFRESH_EVENTS.dashboard));
await fetchData();
} catch (err) {
console.error('Failed to persist building to backend, local only:', err);
}
} finally {
setIsCreateBuildingOpen(false);
setNewBuildingAddress('');
}
}}
className="bg-white border border-slate-200 rounded-2xl p-4 flex flex-col md:flex-row gap-3 items-stretch md:items-end shadow-sm"
>
<div className="flex-1">
<label className="block text-[10px] font-black uppercase text-slate-400 mb-1">
Адрес дома
</label>
<input
value={newBuildingAddress}
onChange={(e) => setNewBuildingAddress(e.target.value)}
placeholder="Например: ул. Новая, д.10"
className="w-full border border-slate-200 rounded-xl px-3 py-2 text-sm focus:ring-2 focus:ring-primary-500 outline-none"
/>
</div>
<div className="flex gap-2">
<button
type="button"
onClick={() => {
setIsCreateBuildingOpen(false);
setNewBuildingAddress('');
}}
className="px-4 py-2 rounded-xl border border-slate-200 text-xs font-bold text-slate-500 bg-white hover:bg-slate-50"
>
Отмена
</button>
<button
type="submit"
className="px-4 py-2 rounded-xl bg-primary-600 text-white text-xs font-bold hover:bg-primary-700 active:scale-95 transition-transform"
>
Сохранить
</button>
</div>
</form>
)}
<div className="relative">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-slate-400" />
<input type="text" placeholder="Поиск по адресу..." value={searchQuery} onChange={e => setSearchQuery(e.target.value)} className="w-full pl-10 pr-4 py-2.5 rounded-xl border border-slate-200 text-sm outline-none focus:ring-2 focus:ring-primary-500 shadow-sm" />
</div>
<div className="grid grid-cols-1 gap-4">
{districtBuildings.map(b => (
<PerformanceCard
key={b.id} title={b.passport.address}
subtitle={`${b.passport.general.floors} эт. • ${b.passport.general.totalArea} м²`}
applications={applications.filter(a => a.address === b.passport.address)}
onClick={() => onSelectBuilding(b)} type="building"
onDelete={canManage ? () => {
console.log('Delete button clicked for building:', b.id, b.passport.address);
console.log('Setting buildingToDelete state...');
setBuildingToDelete(b);
console.log('buildingToDelete state should be set now');
} : undefined}
/>
))}
</div>
</div>
{buildingToDelete && (
<DeleteConfirmModal
isOpen={!!buildingToDelete}
onClose={() => {
console.log('Delete modal onClose called (district view), isDeletingBuilding:', isDeletingBuilding);
if (!isDeletingBuilding) {
setBuildingToDelete(null);
}
}}
onConfirm={async () => {
if (!buildingToDelete) {
console.error('buildingToDelete is null');
return;
}
console.log('Confirming deletion of building:', buildingToDelete.id, buildingToDelete.passport.address);
setIsDeletingBuilding(true);
try {
// Пытаемся удалить на сервере
try {
console.log('Attempting to delete building on server:', buildingToDelete.id);
await backendApi.deleteBuilding(buildingToDelete.id);
window.dispatchEvent(new CustomEvent(REFRESH_EVENTS.dashboard));
console.log('Building deleted on server successfully');
} catch (serverError: any) {
// Если сервер недоступен, продолжаем с локальным удалением
console.warn('Server deletion failed, deleting locally:', serverError);
}
// Удаляем из localStorage
console.log('Deleting building from localStorage:', buildingToDelete.id);
storageService.deleteBuilding(buildingToDelete.id);
// Удаляем из состояния
console.log('Removing building from state:', buildingToDelete.id);
setBuildings(prev => {
const filtered = prev.filter(b => b.id !== buildingToDelete.id);
console.log('Buildings after filter:', filtered.length, 'of', prev.length);
return filtered;
});
// Закрываем модальное окно
console.log('Closing delete modal');
setBuildingToDelete(null);
// Обновляем список домов из БД (если сервер доступен)
try {
console.log('Refreshing data from server');
await fetchData();
} catch (fetchError) {
console.warn('Failed to refresh data after deletion:', fetchError);
}
} catch (error: any) {
console.error('Failed to delete building:', error);
// Показываем ошибку пользователю
let errorMessage = 'Ошибка при удалении дома';
if (error?.message) {
errorMessage = error.message;
} else if (error?.error) {
errorMessage = error.error;
} else if (typeof error === 'string') {
errorMessage = error;
}
alert(errorMessage);
} finally {
setIsDeletingBuilding(false);
}
}}
title="Удаление дома"
message="Вы уверены, что хотите удалить этот дом?"
itemName={buildingToDelete.passport.address}
isLoading={isDeletingBuilding}
/>
)}
</>
);
}
return (
<>
<div className="animate-fade-in pb-20 space-y-6">
<div className="min-h-[280px] sm:min-h-[360px] md:min-h-[500px]">
<DistrictsSummary
aggregatedData={aggregatedData}
onSelectDistrict={setSelectedDistrict}
canManage={canManage}
onAddDistrict={async ({ name, managerName }) => {
try {
const created = await backendApi.createDistrict({ name, managerName });
window.dispatchEvent(new CustomEvent(REFRESH_EVENTS.dashboard));
await fetchData();
} catch (error) {
console.error('Failed to create district via backend, falling back to local storage:', error);
const createdLocal = storageService.createDistrict({ name, managerName });
setDistricts((prev) => [...prev, createdLocal]);
}
}}
onDeleteDistrict={async (d) => {
// Проверяем, есть ли дома, привязанные к этому участку
const districtBuildings = buildings.filter(b => b.districtId === d.id);
if (districtBuildings.length > 0) {
// Показываем модальное окно перемещения домов
setDistrictToDelete(d);
setShowMoveBuildingsModal(true);
} else {
// Если домов нет, сразу показываем окно подтверждения удаления
setDistrictToDelete(d);
}
}}
onViewStaff={(d) => setStaffModalDistrict(d)}
onDistrictUpdated={() => fetchData()}
role={currentUser.role}
employees={employees}
/>
</div>
</div>
{staffModalDistrict && (
<DistrictStaffModal
district={staffModalDistrict}
employees={employees}
onClose={() => setStaffModalDistrict(null)}
/>
)}
{districtToDelete && showMoveBuildingsModal && (
<MoveBuildingsModal
isOpen={showMoveBuildingsModal}
onClose={() => {
setShowMoveBuildingsModal(false);
setDistrictToDelete(null);
}}
onComplete={async () => {
// После перемещения домов обновляем данные
await fetchData();
setShowMoveBuildingsModal(false);
// Автоматически показываем окно подтверждения удаления
// (districtToDelete уже установлен)
}}
sourceDistrict={districtToDelete}
buildings={buildings.filter(b => b.districtId === districtToDelete.id)}
allDistricts={districts}
/>
)}
{districtToDelete && !showMoveBuildingsModal && (
<DeleteConfirmModal
isOpen={!!districtToDelete}
onClose={() => {
if (!isDeleting) {
setDistrictToDelete(null);
}
}}
onConfirm={async () => {
if (!districtToDelete) return;
setIsDeleting(true);
try {
await backendApi.deleteDistrict(districtToDelete.id);
window.dispatchEvent(new CustomEvent(REFRESH_EVENTS.dashboard));
storageService.deleteDistrict(districtToDelete.id);
// Обновляем список участков из БД
await fetchData();
setDistrictToDelete(null);
} catch (error: any) {
console.error('Failed to delete district:', error);
// Показываем ошибку пользователю
let errorMessage = 'Ошибка при удалении участка';
if (error?.message) {
errorMessage = error.message;
} else if (error?.error) {
errorMessage = error.error;
} else if (typeof error === 'string') {
errorMessage = error;
}
alert(errorMessage);
} finally {
setIsDeleting(false);
}
}}
title="Удаление участка"
message="Вы уверены, что хотите удалить этот участок?"
itemName={districtToDelete.name}
isLoading={isDeleting}
/>
)}
{buildingToDelete && (
<DeleteConfirmModal
isOpen={!!buildingToDelete}
onClose={() => {
console.log('Delete modal onClose called, isDeletingBuilding:', isDeletingBuilding);
if (!isDeletingBuilding) {
setBuildingToDelete(null);
}
}}
onConfirm={async () => {
if (!buildingToDelete) {
console.error('buildingToDelete is null');
return;
}
console.log('Confirming deletion of building:', buildingToDelete.id, buildingToDelete.passport.address);
setIsDeletingBuilding(true);
try {
// Пытаемся удалить на сервере
try {
console.log('Attempting to delete building on server:', buildingToDelete.id);
await backendApi.deleteBuilding(buildingToDelete.id);
console.log('Building deleted on server successfully');
} catch (serverError: any) {
// Если сервер недоступен, продолжаем с локальным удалением
console.warn('Server deletion failed, deleting locally:', serverError);
}
// Удаляем из localStorage
console.log('Deleting building from localStorage:', buildingToDelete.id);
storageService.deleteBuilding(buildingToDelete.id);
// Удаляем из состояния
console.log('Removing building from state:', buildingToDelete.id);
setBuildings(prev => {
const filtered = prev.filter(b => b.id !== buildingToDelete.id);
console.log('Buildings after filter:', filtered.length, 'of', prev.length);
return filtered;
});
// Закрываем модальное окно
console.log('Closing delete modal');
setBuildingToDelete(null);
// Обновляем список домов из БД (если сервер доступен)
try {
console.log('Refreshing data from server');
await fetchData();
} catch (fetchError) {
console.warn('Failed to refresh data after deletion:', fetchError);
}
} catch (error: any) {
console.error('Failed to delete building:', error);
// Показываем ошибку пользователю
let errorMessage = 'Ошибка при удалении дома';
if (error?.message) {
errorMessage = error.message;
} else if (error?.error) {
errorMessage = error.error;
} else if (typeof error === 'string') {
errorMessage = error;
}
alert(errorMessage);
} finally {
setIsDeletingBuilding(false);
}
}}
title="Удаление дома"
message="Вы уверены, что хотите удалить этот дом?"
itemName={buildingToDelete.passport.address}
isLoading={isDeletingBuilding}
/>
)}
</>
);
};