Files
mkd/App.tsx

1218 lines
53 KiB
TypeScript
Raw Permalink Normal View History

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