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