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

1218 lines
53 KiB
TypeScript
Executable File
Raw Permalink Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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, 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 = () => (
<div className="min-h-[40vh] flex items-center justify-center">
<div className="text-slate-500 text-sm font-medium">Загрузка...</div>
</div>
);
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<boolean | null>(null);
const [allowedSections, setAllowedSections] = useState<string[]>([]);
/** Детальные права (разделы и подразделы/отчёты). null = по роли, все подразделы разрешены */
const [userPermissions, setUserPermissions] = useState<string[] | null>(null);
const [authError, setAuthError] = useState<string | null>(null);
const [authLoading, setAuthLoading] = useState(false);
const [activeTab, setActiveTab] = useState(() => {
const saved = localStorage.getItem('mkd_activeTab');
return saved || 'dashboard';
});
const [currentUser, setCurrentUser] = useState<User>(() => {
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<Building | null>(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<User | null>(null);
const [integrationEnabled, setIntegrationEnabled] = useState<boolean>(() => {
const saved = localStorage.getItem(INTEGRATION_ENABLED_KEY);
if (saved === 'false') return false;
return true;
});
const [financeOpenInvoiceId, setFinanceOpenInvoiceId] = useState<number | null>(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<string | null>(null);
const [dashboardOpenNewsId, setDashboardOpenNewsId] = useState<number | null>(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<string>('user-1');
const [roleAdminRoles, setRoleAdminRoles] = useState<FinanceUserRoleRow[]>([]);
const [roleAdminLoading, setRoleAdminLoading] = useState(false);
const [roleAdminSelectedRole, setRoleAdminSelectedRole] = useState<FinanceApproverRole>('manager');
const [domaAISettings, setDomaAISettings] = useState<DomaAISettings>(() => {
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<any>(`/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 <LazyFallback />;
}
return (
<BuildingReportPage
mode="published"
reportId={reportId}
buildingId={reportInfo?.buildingId}
buildingAddress={reportInfo?.address || `Отчет ${reportId}`}
month={reportInfo?.month}
/>
);
};
return (
<Suspense fallback={<LazyFallback />}>
<PublishedReportLoader />
</Suspense>
);
}
// Если открыли публичную ссылку на 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 (
<Suspense fallback={<LazyFallback />}>
<NPSSurveyPage surveyId={surveyId} apartment={apartment || undefined} />
</Suspense>
);
}
}
// Экран входа: нет токена или сессия не восстановлена
if (isAuthenticated === false) {
return (
<Suspense fallback={<LazyFallback />}>
<LoginPage
apiError={authError}
loading={authLoading}
onLoginSuccess={(user, token) => {
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);
}}
/>
</Suspense>
);
}
// Загрузка сессии (проверка токена)
if (isAuthenticated === null) {
return (
<div className="min-h-screen flex items-center justify-center bg-slate-100">
<div className="text-slate-500 text-sm font-medium">Загрузка...</div>
</div>
);
}
// ====== Админка ролей согласования счетов (скрыто в меню пользователя) ======
const loadUserApproverRoles = async (userId: string) => {
try {
setRoleAdminLoading(true);
const roles = await apiClient.get<FinanceUserRoleRow[]>(`/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 (
<BuildingCharacteristics
building={selectedBuilding}
onBack={handleBackToDashboard}
onBuildingUpdate={setSelectedBuilding}
/>
);
}
return <DashboardNavigation currentUser={currentUser} onSelectBuilding={handleSelectBuilding} allowedPermissions={userPermissions} />;
}
if (activeTab === 'dashboard') {
const allowedDashboardBlocks = allowedSubsForSection(userPermissions ?? [], 'dashboard');
return (
<SummaryDashboard
currentUserRole={currentUser.role}
currentUser={currentUser}
allowedDashboardBlocks={allowedDashboardBlocks}
initialOpenTaskModal={pendingQuickAction === 'dashboard_task'}
onQuickActionHandled={() => setPendingQuickAction(null)}
openNewsId={dashboardOpenNewsId}
onCloseNews={() => setDashboardOpenNewsId(null)}
onNavigateToModule={(tabId) => setActiveTab(tabId)}
/>
);
}
if (activeTab === 'requests') {
return <ApplicationsModule integrationEnabled={integrationEnabled} allowedPermissions={userPermissions} />;
}
if (activeTab === 'pr') {
return <PRModule allowedPermissions={userPermissions} />;
}
if (activeTab === 'finance') {
return (
<FinanceModule
currentUser={currentUser}
externalInvoiceId={financeOpenInvoiceId}
externalInvoicePrefill={financeInvoicePrefill}
onInvoiceHandled={() => {
setFinanceOpenInvoiceId(null);
setFinanceInvoicePrefill(null);
}}
allowedPermissions={userPermissions}
/>
);
}
if (activeTab === 'hr') {
return <HRModule currentUser={currentUser} allowedPermissions={userPermissions} />;
}
if (activeTab === 'office') {
return <OfficeModule allowedPermissions={userPermissions} />;
}
if (activeTab === 'legal') {
return <LegalModule allowedPermissions={userPermissions} />;
}
if (activeTab === 'development') {
return <DevelopmentModule allowedPermissions={userPermissions} />;
}
if (activeTab === 'admin') {
return <AdminModule allowedPermissions={userPermissions} />;
}
return (
<div className="flex flex-col items-center justify-center h-[60vh] text-slate-400 animate-fade-in">
<div className="w-20 h-20 bg-slate-100 rounded-full flex items-center justify-center mb-6 shadow-inner">
<span className="text-3xl grayscale">🏗</span>
</div>
<h2 className="text-xl font-bold text-slate-700">Раздел в разработке</h2>
<p className="text-sm mt-2 text-slate-500 text-center max-w-xs">Функционал "{activeTab}" скоро появится в центре управления.</p>
</div>
);
};
return (
<PermissionsProvider permissions={userPermissions}>
<div className="min-h-screen bg-slate-50 pb-24 md:pb-28 relative font-sans">
{/* Top Header */}
<header className="sticky top-0 z-40 bg-white/80 backdrop-blur-md border-b border-slate-200 px-4 md:px-8 py-3">
<div className="max-w-5xl mx-auto flex items-center justify-between">
{/* Logo & Brand */}
<div className="flex items-center gap-3">
<div className="w-9 h-9 rounded-xl bg-gradient-to-br from-primary-600 to-primary-800 flex items-center justify-center shadow-lg shadow-primary-500/20">
<span className="text-white font-bold text-xs">ЦУ</span>
</div>
<div>
<h1 className="text-sm font-bold text-slate-900 leading-none uppercase tracking-tight">Центр управления</h1>
<p className="text-[9px] font-bold text-primary-600 uppercase tracking-wider mt-0.5">жилым фондом</p>
</div>
</div>
{/* User & Actions */}
<div className="flex items-center gap-3">
<ConnectionIndicator />
<div className="relative">
<button
type="button"
onClick={() => setIsNotificationPanelOpen((v) => !v)}
className="relative p-2 text-slate-400 hover:text-slate-600 transition-colors"
aria-label="Уведомления"
>
<Bell className="w-5 h-5" />
{notificationUnreadCount > 0 && (
<span className="absolute top-1.5 right-2 w-1.5 h-1.5 bg-red-500 rounded-full border border-white" />
)}
</button>
<NotificationPanel
isOpen={isNotificationPanelOpen}
onClose={() => 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 (_) {}
}
}}
/>
</div>
{/* User Menu */}
<div className="relative pl-2 border-l border-slate-200">
<button
onClick={() => setIsUserMenuOpen(!isUserMenuOpen)}
className="flex items-center gap-2 hover:opacity-80 transition-opacity"
>
<img
src={currentUser.avatar}
alt="Profile"
className="w-8 h-8 rounded-full border border-white shadow-sm cursor-pointer"
/>
<div className="hidden md:block">
<p className="text-xs font-bold text-slate-800">{currentUser.name}</p>
</div>
<ChevronDown className={`w-4 h-4 text-slate-400 transition-transform ${isUserMenuOpen ? 'rotate-180' : ''}`} />
</button>
{/* Dropdown Menu */}
{isUserMenuOpen && (
<>
<div
className="fixed inset-0 z-40"
onClick={() => setIsUserMenuOpen(false)}
/>
<div className="absolute right-0 top-full mt-2 w-56 max-w-[calc(100vw-2rem)] bg-white rounded-xl shadow-2xl border border-slate-200 py-2 z-50 animate-fade-in">
{/* User Info */}
<div className="px-4 py-3 border-b border-slate-100">
<div className="flex items-center gap-3">
<img
src={currentUser.avatar}
alt="Profile"
className="w-10 h-10 rounded-full border border-slate-200"
/>
<div className="flex-1 min-w-0">
<p className="text-sm font-bold text-slate-800 truncate">{currentUser.name}</p>
<p className="text-xs text-slate-500">{ROLE_NAMES[currentUser.role]}</p>
</div>
</div>
</div>
{/* Профиль */}
<div className="py-1">
<button
onClick={() => {
setIsProfileOpen(true);
setEditableUser(currentUser);
setIsUserMenuOpen(false);
}}
className="w-full flex items-center gap-3 px-4 py-2.5 text-sm text-slate-700 hover:bg-slate-50 transition-colors"
>
<UserIcon className="w-4 h-4 text-slate-400" />
<span className="font-medium">Профиль</span>
</button>
</div>
{/* Logout */}
<div className="border-t border-slate-100 pt-1">
<button
onClick={handleLogout}
className="w-full flex items-center gap-3 px-4 py-2.5 text-sm text-red-600 hover:bg-red-50 transition-colors"
>
<LogOut className="w-4 h-4" />
<span className="font-medium">Выход</span>
</button>
</div>
</div>
</>
)}
</div>
</div>
</div>
</header>
{/* Main Content */}
<main className="max-w-5xl mx-auto px-4 md:px-8 py-6">
<Suspense fallback={<LazyFallback />}>
{renderContent()}
</Suspense>
</main>
{/* Settings Modal */}
{isSettingsOpen && (
<div className="fixed inset-0 z-[100] flex items-center justify-center p-4 bg-slate-900/80 backdrop-blur-md animate-fade-in" onClick={() => setIsSettingsOpen(false)}>
<div className="bg-white rounded-[2.5rem] w-full max-w-4xl h-[85vh] flex flex-col shadow-2xl animate-slide-up" onClick={e => e.stopPropagation()}>
{/* Header */}
<div className="p-6 border-b border-slate-200 flex-shrink-0">
<div className="flex justify-between items-start">
<div>
<h3 className="text-2xl font-black text-slate-900">Настройки</h3>
<p className="text-xs text-slate-500 mt-1">Управление параметрами системы</p>
</div>
<button onClick={() => setIsSettingsOpen(false)} className="p-2 hover:bg-slate-100 rounded-full"><X className="w-6 h-6 text-slate-400"/></button>
</div>
</div>
{/* Content: tabs on mobile, two columns on desktop */}
<div className="flex flex-1 overflow-hidden flex-col lg:flex-row">
{/* Mobile: horizontal tabs */}
<div className="lg:hidden flex p-1 bg-slate-200/50 rounded-2xl mx-4 mt-2 mb-0 overflow-x-auto no-scrollbar gap-1 flex-shrink-0">
<button
onClick={() => setActiveSettingsTab('role')}
className={`flex-shrink-0 min-w-[7rem] flex items-center justify-center gap-2 px-4 py-2.5 rounded-xl text-[10px] font-black uppercase tracking-wider transition-all ${
activeSettingsTab === 'role' ? 'bg-white text-primary-600 shadow-sm' : 'text-slate-500 hover:bg-slate-100'
}`}
>
<UserCog className="w-3.5 h-3.5" /> Роль
</button>
<button
onClick={() => setActiveSettingsTab('integrations')}
className={`flex-shrink-0 min-w-[7rem] flex items-center justify-center gap-2 px-4 py-2.5 rounded-xl text-[10px] font-black uppercase tracking-wider transition-all ${
activeSettingsTab === 'integrations' ? 'bg-white text-primary-600 shadow-sm' : 'text-slate-500 hover:bg-slate-100'
}`}
>
<Plug className="w-3.5 h-3.5" /> Интеграции
</button>
</div>
{/* Desktop: left sidebar */}
<div className="hidden lg:flex w-64 border-r border-slate-200 bg-slate-50 flex-shrink-0 overflow-y-auto">
<div className="p-4 space-y-1">
<button
onClick={() => setActiveSettingsTab('role')}
className={`w-full flex items-center gap-3 px-4 py-3 rounded-xl text-sm font-medium transition-colors ${
activeSettingsTab === 'role'
? 'bg-primary-600 text-white shadow-lg shadow-primary-900/20'
: 'text-slate-700 hover:bg-slate-200'
}`}
>
<UserCog className={`w-5 h-5 ${activeSettingsTab === 'role' ? 'text-white' : 'text-slate-400'}`} />
<span>Роль пользователя</span>
</button>
<button
onClick={() => setActiveSettingsTab('integrations')}
className={`w-full flex items-center gap-3 px-4 py-3 rounded-xl text-sm font-medium transition-colors ${
activeSettingsTab === 'integrations'
? 'bg-primary-600 text-white shadow-lg shadow-primary-900/20'
: 'text-slate-700 hover:bg-slate-200'
}`}
>
<Plug className={`w-5 h-5 ${activeSettingsTab === 'integrations' ? 'text-white' : 'text-slate-400'}`} />
<span>Интеграции</span>
</button>
</div>
</div>
{/* Right Content */}
<div className="flex-1 overflow-y-auto min-h-0">
<div className="p-8">
{activeSettingsTab === 'role' && (
<div>
<h4 className="font-bold text-slate-800 text-lg mb-2">Роль пользователя</h4>
<p className="text-xs text-slate-500 mb-6">Текущая роль определяет доступные разделы системы</p>
<div className="space-y-4">
<div>
<label className="block text-xs font-bold text-slate-700 mb-2">Выберите роль (для демо)</label>
<button
onClick={toggleRole}
className="flex items-center justify-between w-full px-4 py-3 bg-slate-100 hover:bg-slate-200 rounded-xl text-sm font-bold text-slate-700 transition-colors"
>
<span>{ROLE_NAMES[currentUser.role]}</span>
<ChevronDown className="w-4 h-4" />
</button>
</div>
</div>
</div>
)}
{activeSettingsTab === 'integrations' && (
<div>
<h4 className="font-bold text-slate-800 text-lg mb-2">Интеграции</h4>
<p className="text-xs text-slate-500 mb-6">Настройка подключений к внешним сервисам</p>
{/* Настройки Дома.АИ */}
<div className="bg-slate-50 rounded-xl p-6">
<div className="flex items-center gap-3 mb-6">
<div className="w-10 h-10 rounded-lg bg-primary-100 flex items-center justify-center">
<span className="text-primary-600 font-bold text-sm">Д.АИ</span>
</div>
<div>
<h5 className="font-bold text-slate-800">Дома.АИ</h5>
<p className="text-xs text-slate-500">Интеграция с системой управления заявками</p>
</div>
</div>
<div className="space-y-4">
<div>
<label className="block text-xs font-bold text-slate-700 mb-1.5">
Адрес API
</label>
<input
type="text"
value={domaAISettings.apiUrl}
onChange={(e) => 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"
/>
</div>
<div>
<label className="block text-xs font-bold text-slate-700 mb-1.5">
Токен доступа
</label>
<textarea
value={domaAISettings.token || ''}
onChange={(e) => setDomaAISettings({ ...domaAISettings, token: e.target.value })}
placeholder="Вставьте токен Doma AI"
className="w-full px-4 py-2.5 bg-white border border-slate-200 rounded-lg text-xs text-slate-800 focus:outline-none focus:ring-2 focus:ring-primary-500 focus:border-transparent min-h-[80px]"
/>
<p className="text-xs text-slate-500 mt-1.5">
Используйте токен, полученный от Doma AI. При его наличии логин/пароль не требуются.
</p>
</div>
<button
onClick={async () => {
if (!domaAISettings.apiUrl) {
alert('Укажите адрес API');
return;
}
if (!domaAISettings.token) {
alert('Вставьте токен доступа Doma AI');
return;
}
try {
await backendApi.saveDomaSettings({
apiUrl: domaAISettings.apiUrl,
token: domaAISettings.token || '',
});
settingsService.saveDomaAISettings(domaAISettings);
const { domaGraphQLClient } = await import('./services/domaGraphQLClient');
domaGraphQLClient.updateSettings();
domaGraphQLClient.setToken(domaAISettings.token || '');
alert('Настройки Дома.АИ успешно сохранены!');
} catch (e: any) {
alert(e?.message || 'Ошибка сохранения');
}
}}
className="w-full px-4 py-2.5 bg-primary-600 hover:bg-primary-700 text-white font-bold text-sm rounded-lg transition-colors"
>
Сохранить настройки
</button>
</div>
</div>
</div>
)}
</div>
</div>
</div>
</div>
</div>
)}
{/* Profile / Settings Modal */}
{isProfileOpen && (
<Suspense fallback={<div className="fixed inset-0 z-[100] flex items-center justify-center bg-slate-900/80"><div className="text-white">Загрузка...</div></div>}>
<ProfileSettingsModal
user={editableUser || currentUser}
onClose={() => setIsProfileOpen(false)}
onSave={(u) => {
setCurrentUser(u);
setEditableUser(u);
try {
localStorage.setItem(CURRENT_USER_STORAGE_KEY, JSON.stringify(u));
} catch (e) {
console.warn('[App] Не удалось сохранить профиль');
}
}}
/>
</Suspense>
)}
{/* Admin: Approver roles modal (hidden under user menu) */}
{isRoleAdminOpen && (
<div
className="fixed inset-0 z-[110] flex items-center justify-center p-4 bg-slate-900/80 backdrop-blur-md animate-fade-in"
onClick={() => setIsRoleAdminOpen(false)}
>
<div
className="bg-white rounded-[2.5rem] w-full max-w-2xl max-h-[90vh] overflow-y-auto shadow-2xl animate-slide-up"
onClick={(e) => e.stopPropagation()}
>
<div className="p-8 border-b border-slate-200 sticky top-0 bg-white z-10 rounded-t-[2.5rem]">
<div className="flex justify-between items-start">
<div>
<h3 className="text-2xl font-black text-slate-900">Роли пользователей (согласование счетов)</h3>
<p className="text-xs text-slate-500 mt-1">Скрытый экран для назначения ролей manager/finance_manager/financier</p>
</div>
<button onClick={() => setIsRoleAdminOpen(false)} className="p-2 hover:bg-slate-100 rounded-full">
<X className="w-6 h-6 text-slate-400" />
</button>
</div>
</div>
<div className="p-8 space-y-6">
<div className="bg-slate-50 border border-slate-200 rounded-2xl p-5">
<label className="block text-xs font-bold text-slate-700 mb-2">User ID</label>
<div className="flex flex-col md:flex-row gap-3">
<input
value={roleAdminUserId}
onChange={(e) => setRoleAdminUserId(e.target.value)}
placeholder="user-1"
className="flex-1 px-4 py-2.5 bg-white border border-slate-200 rounded-xl text-sm text-slate-800 focus:outline-none focus:ring-2 focus:ring-primary-500"
/>
<button
onClick={() => loadUserApproverRoles(roleAdminUserId)}
disabled={roleAdminLoading || !roleAdminUserId.trim()}
className="px-4 py-2.5 bg-slate-900 hover:bg-slate-800 text-white font-bold text-sm rounded-xl transition-colors disabled:bg-slate-300 disabled:cursor-not-allowed"
>
{roleAdminLoading ? 'Загрузка...' : 'Загрузить роли'}
</button>
</div>
<p className="text-[11px] text-slate-500 mt-2">
Подсказка: в демо сейчас часто используется `user-1`.
</p>
</div>
<div className="bg-white border border-slate-200 rounded-2xl p-5">
<div className="flex flex-col md:flex-row md:items-end gap-3 justify-between">
<div className="flex-1">
<label className="block text-xs font-bold text-slate-700 mb-2">Добавить роль</label>
<select
value={roleAdminSelectedRole}
onChange={(e) => setRoleAdminSelectedRole(e.target.value as FinanceApproverRole)}
className="w-full px-4 py-2.5 bg-white border border-slate-200 rounded-xl text-sm text-slate-800 focus:outline-none focus:ring-2 focus:ring-primary-500"
>
<option value="manager">manager (руководитель)</option>
<option value="finance_manager">finance_manager (фин. руководитель)</option>
<option value="financier">financier (финансист)</option>
<option value="finance_director">finance_director</option>
<option value="director">director</option>
<option value="top_management">top_management</option>
</select>
</div>
<button
onClick={() => addUserApproverRole(roleAdminUserId, roleAdminSelectedRole)}
disabled={roleAdminLoading || !roleAdminUserId.trim()}
className="px-4 py-2.5 bg-primary-600 hover:bg-primary-700 text-white font-bold text-sm rounded-xl transition-colors disabled:bg-slate-300 disabled:cursor-not-allowed"
>
Добавить
</button>
</div>
<div className="mt-5">
<h4 className="text-sm font-bold text-slate-800 mb-3">Текущие роли</h4>
{roleAdminRoles.length === 0 ? (
<div className="text-sm text-slate-500 bg-slate-50 border border-slate-200 rounded-xl p-4">
Роли не найдены (или не загружены).
</div>
) : (
<div className="space-y-2">
{roleAdminRoles.map((r) => (
<div
key={r.id}
className="flex items-center justify-between gap-3 p-3 bg-slate-50 border border-slate-200 rounded-xl"
>
<div className="min-w-0">
<p className="text-sm font-bold text-slate-800">{r.role}</p>
<p className="text-[11px] text-slate-500">id: {r.id}</p>
</div>
<button
onClick={() => removeUserApproverRole(r.id)}
disabled={roleAdminLoading}
className="px-3 py-2 bg-red-50 hover:bg-red-100 text-red-700 font-bold text-xs rounded-lg transition-colors disabled:bg-slate-200 disabled:text-slate-400"
>
Удалить
</button>
</div>
))}
</div>
)}
</div>
</div>
</div>
</div>
</div>
)}
{/* Bottom Navigation */}
<Navigation
activeTab={activeTab}
setActiveTab={(tab) => {
setActiveTab(tab);
setSelectedBuilding(null);
}}
currentUserRole={currentUser.role}
allowedSections={allowedSections.length > 0 ? allowedSections : undefined}
/>
</div>
</PermissionsProvider>
);
}