1218 lines
53 KiB
TypeScript
Executable File
1218 lines
53 KiB
TypeScript
Executable File
|
||
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>
|
||
);
|
||
} |