import React, { useState, useEffect, lazy, Suspense } from 'react'; import { Navigation } from './components/Navigation'; import { Bell, ChevronDown, Settings, User as UserIcon, LogOut, X, Plug, UserCog, Building2 } from 'lucide-react'; import { Building, User, UserRole } from './types'; import { CURRENT_USER_MOCK, NAV_ITEMS } from './constants'; import { storageService } from './services/storageService'; import { backendApi, syncCachedRequests, getAuthToken, setAuthToken, clearAuth, fetchGuestToken } from './services/apiClient'; import { ConnectionIndicator } from './components/ConnectionIndicator'; import { NotificationPanel } from './components/NotificationPanel'; import { connectionService } from './services/connectionService'; import { settingsService, DomaAISettings } from './services/settingsService'; import { apiClient } from './services/apiClient'; import { PermissionsProvider } from './contexts/PermissionsContext'; import { allowedSubsForSection } from './constants/permissions'; import { ROLE_NAMES, ROLE_ACCESS } from './constants/roleAccess'; // Lazy-loaded modules (reduce initial bundle size) const BuildingCharacteristics = lazy(() => import('./components/BuildingCharacteristics').then(m => ({ default: m.BuildingCharacteristics }))); const DashboardNavigation = lazy(() => import('./components/DashboardNavigation').then(m => ({ default: m.DashboardNavigation }))); const SummaryDashboard = lazy(() => import('./components/SummaryDashboard').then(m => ({ default: m.SummaryDashboard }))); const HRModule = lazy(() => import('./components/HRModule').then(m => ({ default: m.HRModule }))); const ApplicationsModule = lazy(() => import('./components/ApplicationsModule').then(m => ({ default: m.ApplicationsModule }))); const PRModule = lazy(() => import('./components/PRModule').then(m => ({ default: m.PRModule }))); const FinanceModule = lazy(() => import('./components/FinanceModule').then(m => ({ default: m.FinanceModule }))); const OfficeModule = lazy(() => import('./components/OfficeModule').then(m => ({ default: m.OfficeModule }))); const LegalModule = lazy(() => import('./components/LegalModule').then(m => ({ default: m.LegalModule }))); const DevelopmentModule = lazy(() => import('./components/DevelopmentModule').then(m => ({ default: m.DevelopmentModule }))); const AdminModule = lazy(() => import('./components/AdminModule').then(m => ({ default: m.AdminModule }))); const BuildingReportPage = lazy(() => import('./components/pr/BuildingReportPage').then(m => ({ default: m.BuildingReportPage }))); const NPSSurveyPage = lazy(() => import('./components/pr/NPSSurveyPage').then(m => ({ default: m.NPSSurveyPage }))); const LoginPage = lazy(() => import('./components/LoginPage').then(m => ({ default: m.LoginPage }))); const ProfileSettingsModal = lazy(() => import('./components/ProfileSettingsModal').then(m => ({ default: m.ProfileSettingsModal }))); const LazyFallback = () => (
Загрузка...
); const CURRENT_USER_STORAGE_KEY = 'mkd_currentUser'; const INTEGRATION_ENABLED_KEY = 'mkd_integrationEnabled'; const PORTAL_LOGIN_KEY = 'mkd_portalLogin'; export default function App() { // ====== Публичные страницы отчетов (/reports/:id) ====== const [pathname, setPathname] = useState(() => window.location.pathname); const [isAuthenticated, setIsAuthenticated] = useState(null); const [allowedSections, setAllowedSections] = useState([]); /** Детальные права (разделы и подразделы/отчёты). null = по роли, все подразделы разрешены */ const [userPermissions, setUserPermissions] = useState(null); const [authError, setAuthError] = useState(null); const [authLoading, setAuthLoading] = useState(false); const [activeTab, setActiveTab] = useState(() => { const saved = localStorage.getItem('mkd_activeTab'); return saved || 'dashboard'; }); const [currentUser, setCurrentUser] = useState(() => { try { const saved = localStorage.getItem(CURRENT_USER_STORAGE_KEY); if (saved) { return JSON.parse(saved) as User; } } catch (e) { console.warn('[App] Не удалось прочитать сохранённый профиль пользователя, используем значения по умолчанию'); } return CURRENT_USER_MOCK; }); const [selectedBuilding, setSelectedBuilding] = useState(null); const [isUserMenuOpen, setIsUserMenuOpen] = useState(false); const [isSettingsOpen, setIsSettingsOpen] = useState(false); const [isProfileOpen, setIsProfileOpen] = useState(false); const [isRoleAdminOpen, setIsRoleAdminOpen] = useState(false); const [activeSettingsTab, setActiveSettingsTab] = useState<'role' | 'integrations'>('role'); const [editableUser, setEditableUser] = useState(null); const [integrationEnabled, setIntegrationEnabled] = useState(() => { const saved = localStorage.getItem(INTEGRATION_ENABLED_KEY); if (saved === 'false') return false; return true; }); const [financeOpenInvoiceId, setFinanceOpenInvoiceId] = useState(null); const [notificationUnreadCount, setNotificationUnreadCount] = useState(0); const [isNotificationPanelOpen, setIsNotificationPanelOpen] = useState(false); const [financeInvoicePrefill, setFinanceInvoicePrefill] = useState<{ purposeType: 'event'; purposeEventId?: string; purposeDescription?: string; totalAmount?: number; } | null>(null); const [pendingQuickAction, setPendingQuickAction] = useState(null); const [dashboardOpenNewsId, setDashboardOpenNewsId] = useState(null); useEffect(() => { const onPopState = () => setPathname(window.location.pathname); window.addEventListener('popstate', onPopState); return () => window.removeEventListener('popstate', onPopState); }, []); // Восстановление сессии по токену; параллельно при необходимости загружаем список зданий для savedBuildingId useEffect(() => { if (pathname.startsWith('/reports/') || pathname.startsWith('/nps/')) { return; } const token = getAuthToken(); if (!token) { setIsAuthenticated(false); return; } const savedBuildingId = localStorage.getItem('mkd_selectedBuildingId'); const needBuildings = !!savedBuildingId && !storageService.getBuildingById(savedBuildingId); let cancelled = false; const mePromise = backendApi.getMe(); const buildingsPromise = needBuildings ? backendApi.getBuildings() : Promise.resolve(null); Promise.all([mePromise, buildingsPromise]) .then(([me, buildings]) => { if (cancelled) return; setCurrentUser({ ...me, id: me.id, name: me.name, role: me.role as UserRole, avatar: me.avatar || 'https://picsum.photos/id/1005/64/64', }); setAllowedSections(me.allowedSections || []); setUserPermissions(me.permissions ?? null); setIsAuthenticated(true); setAuthError(null); if (needBuildings && buildings) { const found = buildings.find((b: Building) => b.id === savedBuildingId); if (found) setSelectedBuilding(found); else localStorage.removeItem('mkd_selectedBuildingId'); } }) .catch(() => { if (!cancelled) { setIsAuthenticated(false); setAuthError(null); if (needBuildings) localStorage.removeItem('mkd_selectedBuildingId'); } }); return () => { cancelled = true; }; }, [pathname]); // Установка selectedBuilding из localStorage, если здание уже есть в storage (без запроса к API) useEffect(() => { const savedBuildingId = localStorage.getItem('mkd_selectedBuildingId'); if (savedBuildingId) { const building = storageService.getBuildingById(savedBuildingId); if (building) setSelectedBuilding(building); } }, []); useEffect(() => { const onLogout = () => setIsAuthenticated(false); window.addEventListener('mkd-auth-logout', onLogout); return () => window.removeEventListener('mkd-auth-logout', onLogout); }, []); // --- All hooks must run before any conditional return (Rules of Hooks) --- type FinanceApproverRole = 'manager' | 'finance_manager' | 'financier' | 'finance_director' | 'director' | 'top_management'; type FinanceUserRoleRow = { id: number; userId: string; role: FinanceApproverRole; createdAt?: string; updatedAt?: string }; const [roleAdminUserId, setRoleAdminUserId] = useState('user-1'); const [roleAdminRoles, setRoleAdminRoles] = useState([]); const [roleAdminLoading, setRoleAdminLoading] = useState(false); const [roleAdminSelectedRole, setRoleAdminSelectedRole] = useState('manager'); const [domaAISettings, setDomaAISettings] = useState(() => { const saved = settingsService.getDomaAISettings(); return saved || { apiUrl: '', token: '', }; }); // --- All useEffects must run before any conditional return (Rules of Hooks) --- useEffect(() => { if (isSettingsOpen) { backendApi.getDomaSettings() .then((data) => { setDomaAISettings({ apiUrl: data.apiUrl || '', token: data.token || '' }); }) .catch(() => { const saved = settingsService.getDomaAISettings(); if (saved) setDomaAISettings(saved); }); } }, [isSettingsOpen]); useEffect(() => { if (isProfileOpen) { setEditableUser(currentUser); } }, [isProfileOpen, currentUser]); useEffect(() => { try { localStorage.setItem(CURRENT_USER_STORAGE_KEY, JSON.stringify(currentUser)); } catch (e) { console.warn('[App] Не удалось сохранить профиль пользователя в localStorage'); } }, [currentUser]); useEffect(() => { localStorage.setItem('mkd_activeTab', activeTab); }, [activeTab]); const fetchNotificationUnreadCount = React.useCallback(async () => { if (!getAuthToken()) return; try { const { count } = await backendApi.getUnreadCount(); setNotificationUnreadCount(count); } catch { setNotificationUnreadCount(0); } }, []); useEffect(() => { fetchNotificationUnreadCount(); const interval = setInterval(fetchNotificationUnreadCount, 60000); const onFocus = () => fetchNotificationUnreadCount(); window.addEventListener('focus', onFocus); return () => { clearInterval(interval); window.removeEventListener('focus', onFocus); }; }, [fetchNotificationUnreadCount]); useEffect(() => { const allowed = allowedSections.length > 0 ? (allowedSections.includes('all') ? ['dashboard', 'objects', 'requests', 'pr', 'finance', 'legal', 'development', 'hr', 'office', 'admin'] : allowedSections) : ROLE_ACCESS[currentUser.role]; if (allowed && !allowed.includes('all') && !allowed.includes(activeTab)) { const firstAllowed = NAV_ITEMS.find(item => allowed.includes(item.id))?.id || 'dashboard'; if (firstAllowed && firstAllowed !== activeTab) { setActiveTab(firstAllowed); } } }, [currentUser.role, activeTab, allowedSections]); useEffect(() => { localStorage.setItem(INTEGRATION_ENABLED_KEY, String(integrationEnabled)); }, [integrationEnabled]); useEffect(() => { let lastStatus = connectionService.getStatus(); const unsubscribe = connectionService.subscribe((status) => { if (lastStatus === 'disconnected' && status === 'connected') { syncCachedRequests().then((result) => { if (result.success > 0) { console.log(`Синхронизировано ${result.success} запросов из кэша`); } }).catch((error) => { console.error('Ошибка синхронизации кэша:', error); }); } lastStatus = status; }); return () => { unsubscribe(); }; }, []); useEffect(() => { const handler = (event: any) => { const d = event?.detail; if (d?.invoiceId) { setFinanceOpenInvoiceId(d.invoiceId); setFinanceInvoicePrefill(null); setActiveTab('finance'); return; } if (d?.purposeType === 'event' || d?.purposeEventId) { setFinanceOpenInvoiceId(null); setFinanceInvoicePrefill({ purposeType: 'event', purposeEventId: d?.purposeEventId, purposeDescription: d?.purposeDescription ?? '', totalAmount: d?.totalAmount }); setActiveTab('finance'); } }; window.addEventListener('mkd-open-finance-invoice', handler as EventListener); return () => window.removeEventListener('mkd-open-finance-invoice', handler as EventListener); }, []); // Если открыли публичную ссылку на отчет - рендерим сразу опубликованную версию без портала if (pathname.startsWith('/reports/')) { const parts = pathname.split('/').filter(Boolean); const reportId = parts[1] || 'demo'; // Компонент для загрузки отчета и отображения const PublishedReportLoader = () => { const [reportInfo, setReportInfo] = React.useState<{ address: string; buildingId?: string; month?: string } | null>(null); const [isLoading, setIsLoading] = React.useState(true); React.useEffect(() => { const loadReport = async () => { if (reportId === 'demo') { setReportInfo({ address: 'Кавказская, 12' }); setIsLoading(false); return; } try { // Гость без учётки: если нет токена — получаем гостевой (доступ только к отчётам) if (!getAuthToken()) { const guestToken = await fetchGuestToken(); setAuthToken(guestToken); } const report = await apiClient.get(`/pr/reports/${reportId}`); console.log('[PublishedReportLoader] Загружен отчет:', { reportId, hasAddress: !!report.address, hasContent: !!report.content, hasBuildingData: !!report.building_data, contentBuildingAddress: report.content?.building?.address }); // Извлекаем адрес из разных возможных мест (приоритет: content.building.address > address > building_data) let address = report.content?.building?.address || report.address || (report.building_data?.passport?.address) || (report.building_data?.passport?.general?.address); // Если адрес все еще не найден, используем fallback if (!address) { address = `Отчет ${reportId}`; console.warn('[PublishedReportLoader] Адрес не найден, используем fallback'); } setReportInfo({ address: address, buildingId: report.buildingId || report.building_id, month: report.month }); } catch (err) { console.error('Error loading report:', err); setReportInfo({ address: `Отчет ${reportId}` }); } finally { setIsLoading(false); } }; loadReport(); }, [reportId]); if (isLoading) { return ; } return ( ); }; return ( }> ); } // Если открыли публичную ссылку на NPS опрос - рендерим страницу опроса без портала if (pathname.startsWith('/nps/')) { const parts = pathname.split('/').filter(Boolean); const surveyId = parts[1] || null; if (surveyId) { // Получаем номер квартиры из URL параметров const urlParams = new URLSearchParams(window.location.search); const apartment = urlParams.get('apartment'); return ( }> ); } } // Экран входа: нет токена или сессия не восстановлена if (isAuthenticated === false) { return ( }> { setCurrentUser({ id: user.id, name: user.name, role: user.role as UserRole, avatar: user.avatar || 'https://picsum.photos/id/1005/64/64', }); backendApi.getMe().then((me) => { setAllowedSections(me.allowedSections || []); setUserPermissions(me.permissions ?? null); }).catch(() => {}); setIsAuthenticated(true); setAuthError(null); }} /> ); } // Загрузка сессии (проверка токена) if (isAuthenticated === null) { return (
Загрузка...
); } // ====== Админка ролей согласования счетов (скрыто в меню пользователя) ====== const loadUserApproverRoles = async (userId: string) => { try { setRoleAdminLoading(true); const roles = await apiClient.get(`/finance/user-roles?userId=${encodeURIComponent(userId)}`); setRoleAdminRoles(Array.isArray(roles) ? roles : []); } catch (err: any) { console.error('Error loading user roles:', err); setRoleAdminRoles([]); alert(err?.message || 'Ошибка загрузки ролей пользователя'); } finally { setRoleAdminLoading(false); } }; const addUserApproverRole = async (userId: string, role: FinanceApproverRole) => { try { setRoleAdminLoading(true); await apiClient.post(`/finance/user-roles`, { userId, role }); await loadUserApproverRoles(userId); } catch (err: any) { console.error('Error adding user role:', err); alert(err?.message || 'Ошибка добавления роли'); } finally { setRoleAdminLoading(false); } }; const removeUserApproverRole = async (id: number) => { try { setRoleAdminLoading(true); await apiClient.delete(`/finance/user-roles/${id}`); await loadUserApproverRoles(roleAdminUserId); } catch (err: any) { console.error('Error removing user role:', err); alert(err?.message || 'Ошибка удаления роли'); } finally { setRoleAdminLoading(false); } }; const handleSelectBuilding = (building: Building) => { setSelectedBuilding(building); // Сохраняем ID выбранного здания в localStorage localStorage.setItem('mkd_selectedBuildingId', building.id); }; const handleBackToDashboard = () => { setSelectedBuilding(null); // Удаляем сохраненное здание из localStorage localStorage.removeItem('mkd_selectedBuildingId'); }; // Demo function to switch roles const toggleRole = () => { const roles: UserRole[] = ['DIRECTOR', 'ENGINEER', 'MASTER', 'LAWYER', 'FINANCIER', 'HR_MANAGER', 'PR_MANAGER']; const nextIndex = (roles.indexOf(currentUser.role) + 1) % roles.length; const newRole = roles[nextIndex]; // Update user role setCurrentUser({ ...currentUser, role: newRole }); }; const handleLogout = () => { if (confirm('Вы уверены, что хотите выйти?')) { clearAuth(); setCurrentUser(CURRENT_USER_MOCK); setAllowedSections([]); setIsAuthenticated(false); setIsUserMenuOpen(false); } }; const setModuleSubtab = (key: string, subtab: string) => { try { localStorage.setItem(key, subtab); } catch (_) {} }; const handleQuickAction = (action: string) => { if (action === 'dashboard_task') { setPendingQuickAction('dashboard_task'); setActiveTab('dashboard'); return; } if (action === 'inspection') { setActiveTab('objects'); if (!selectedBuilding) { setTimeout(() => alert('Выберите дом для начала осмотра'), 100); } return; } if (action === 'task') { setActiveTab('objects'); if (!selectedBuilding) { setTimeout(() => alert('Выберите дом для постановки задачи'), 100); } return; } if (action === 'request') { setActiveTab('requests'); return; } if (action === 'office_invoice') { setActiveTab('office'); setModuleSubtab('mkd_subTab_office', 'dashboard'); return; } if (action === 'office_repair') { setActiveTab('office'); setModuleSubtab('mkd_subTab_office', 'repair'); return; } if (action === 'office_article') { setActiveTab('office'); setModuleSubtab('mkd_subTab_office', 'knowledge'); return; } if (action === 'office_document') { setActiveTab('office'); setModuleSubtab('mkd_subTab_office', 'docs'); return; } if (action === 'office_equipment') { setActiveTab('office'); setModuleSubtab('mkd_subTab_office', 'facility'); return; } if (action === 'office_order') { setActiveTab('office'); setModuleSubtab('mkd_subTab_office', 'supply'); return; } if (action === 'office_meeting') { setActiveTab('office'); setModuleSubtab('mkd_subTab_office', 'meetings'); return; } if (action === 'pr_feedback' || action === 'pr_incident') { setActiveTab('pr'); setModuleSubtab('mkd_subTab_pr', 'feedback'); return; } if (action === 'pr_photo') { setActiveTab('pr'); setModuleSubtab('mkd_subTab_pr', 'photos'); return; } if (action === 'pr_nps') { setActiveTab('pr'); setModuleSubtab('mkd_subTab_pr', 'nps'); return; } if (action === 'invoice') { setActiveTab('finance'); setModuleSubtab('mkd_subTab_finance', 'invoices'); return; } if (action === 'finance_calendar') { setActiveTab('finance'); setModuleSubtab('mkd_subTab_finance', 'calendar'); return; } if (action === 'legal_contract') { setActiveTab('legal'); setModuleSubtab('mkd_subTab_legal', 'contracts'); return; } if (action === 'legal_court') { setActiveTab('legal'); setModuleSubtab('mkd_subTab_legal', 'courts'); return; } if (action === 'legal_debtor' || action === 'legal_action') { setActiveTab('legal'); setModuleSubtab('mkd_subTab_legal', 'preTrial'); return; } if (action === 'legal_petition') { setActiveTab('legal'); setModuleSubtab('mkd_subTab_legal', 'debt'); return; } if (action === 'development_oss') { setActiveTab('development'); setModuleSubtab('mkd_subTab_development', 'oss'); return; } if (action === 'development_pipeline') { setActiveTab('development'); setModuleSubtab('mkd_subTab_development', 'pipeline'); return; } if (action === 'development_marketing') { setActiveTab('development'); setModuleSubtab('mkd_subTab_development', 'marketing'); return; } if (action === 'hire') { setActiveTab('hr'); setModuleSubtab('mkd_subTab_hr', 'hiring'); return; } if (action === 'hr_vacancy') { setActiveTab('hr'); setModuleSubtab('mkd_subTab_hr', 'vacancies'); return; } if (action === 'hr_employee') { setActiveTab('hr'); setModuleSubtab('mkd_subTab_hr', 'employees'); return; } if (action === 'hr_calendar') { setActiveTab('hr'); setModuleSubtab('mkd_subTab_hr', 'calendar'); return; } }; const renderContent = () => { if (activeTab === 'objects') { if (selectedBuilding) { return ( ); } return ; } if (activeTab === 'dashboard') { const allowedDashboardBlocks = allowedSubsForSection(userPermissions ?? [], 'dashboard'); return ( setPendingQuickAction(null)} openNewsId={dashboardOpenNewsId} onCloseNews={() => setDashboardOpenNewsId(null)} onNavigateToModule={(tabId) => setActiveTab(tabId)} /> ); } if (activeTab === 'requests') { return ; } if (activeTab === 'pr') { return ; } if (activeTab === 'finance') { return ( { setFinanceOpenInvoiceId(null); setFinanceInvoicePrefill(null); }} allowedPermissions={userPermissions} /> ); } if (activeTab === 'hr') { return ; } if (activeTab === 'office') { return ; } if (activeTab === 'legal') { return ; } if (activeTab === 'development') { return ; } if (activeTab === 'admin') { return ; } return (
🏗️

Раздел в разработке

Функционал "{activeTab}" скоро появится в центре управления.

); }; return (
{/* Top Header */}
{/* Logo & Brand */}
ЦУ

Центр управления

жилым фондом

{/* User & Actions */}
setIsNotificationPanelOpen(false)} unreadCount={notificationUnreadCount} onUnreadCountChange={setNotificationUnreadCount} onNavigate={(entityType, entityId) => { if (entityType === 'application') { setActiveTab('requests'); } else if (entityType === 'payment_invoice') { setActiveTab('finance'); setFinanceOpenInvoiceId(parseInt(entityId, 10)); } else if (entityType === 'repair_request') { setActiveTab('office'); try { localStorage.setItem('mkd_subTab_office', 'repair'); } catch (_) {} } else if (entityType === 'incident') { setActiveTab('pr'); try { localStorage.setItem('mkd_subTab_pr', 'feedback'); } catch (_) {} } else if (entityType === 'pipeline') { setActiveTab('development'); } else if (entityType === 'pre_trial_work') { setActiveTab('legal'); try { localStorage.setItem('mkd_subTab_legal', 'pre_trial'); } catch (_) {} } else if (entityType === 'training') { setActiveTab('hr'); try { localStorage.setItem('mkd_subTab_hr', 'training'); } catch (_) {} } else if (entityType === 'company_news') { setActiveTab('dashboard'); const id = parseInt(entityId, 10); if (!Number.isNaN(id)) setDashboardOpenNewsId(id); } else if (entityType === 'outage') { setActiveTab('requests'); try { localStorage.setItem('mkd_subTab_requests', 'outages'); } catch (_) {} } else if (entityType === 'oss') { setActiveTab('development'); try { localStorage.setItem('mkd_subTab_development', 'oss'); } catch (_) {} } else if (entityType === 'legal_debtor') { setActiveTab('legal'); try { localStorage.setItem('mkd_subTab_legal', 'debt'); } catch (_) {} } else if (entityType === 'pr_event') { setActiveTab('pr'); try { localStorage.setItem('mkd_subTab_pr', 'events'); } catch (_) {} } else if (entityType === 'nps_survey') { setActiveTab('pr'); try { localStorage.setItem('mkd_subTab_pr', 'nps'); } catch (_) {} } }} />
{/* User Menu */}
{/* Dropdown Menu */} {isUserMenuOpen && ( <>
setIsUserMenuOpen(false)} />
{/* User Info */}
Profile

{currentUser.name}

{ROLE_NAMES[currentUser.role]}

{/* Профиль */}
{/* Logout */}
)}
{/* Main Content */}
}> {renderContent()}
{/* Settings Modal */} {isSettingsOpen && (
setIsSettingsOpen(false)}>
e.stopPropagation()}> {/* Header */}

Настройки

Управление параметрами системы

{/* Content: tabs on mobile, two columns on desktop */}
{/* Mobile: horizontal tabs */}
{/* Desktop: left sidebar */}
{/* Right Content */}
{activeSettingsTab === 'role' && (

Роль пользователя

Текущая роль определяет доступные разделы системы

)} {activeSettingsTab === 'integrations' && (

Интеграции

Настройка подключений к внешним сервисам

{/* Настройки Дома.АИ */}
Д.АИ
Дома.АИ

Интеграция с системой управления заявками

setDomaAISettings({ ...domaAISettings, apiUrl: e.target.value })} placeholder="https://your-domain.doma.ai/admin/api" className="w-full px-4 py-2.5 bg-white border border-slate-200 rounded-lg text-sm text-slate-800 focus:outline-none focus:ring-2 focus:ring-primary-500 focus:border-transparent" />