Files
mkd/components/DashboardNavigation.tsx

588 lines
30 KiB
TypeScript
Raw Permalink Normal View History

2026-02-04 00:17:04 +05:00
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}
/>
)}
</>
);
};