640 lines
31 KiB
TypeScript
640 lines
31 KiB
TypeScript
import React, { useEffect, useState, useMemo } from 'react';
|
||
import { NavLink, Outlet, useLocation, useNavigate } from 'react-router-dom';
|
||
import {
|
||
LayoutDashboard,
|
||
Building2,
|
||
CreditCard,
|
||
Package,
|
||
Smartphone,
|
||
Server,
|
||
BellRing,
|
||
AlertTriangle,
|
||
FileCheck2,
|
||
RotateCcw,
|
||
CheckCircle2,
|
||
Radio,
|
||
ExternalLink,
|
||
Menu,
|
||
X,
|
||
ShieldAlert,
|
||
Sun,
|
||
Moon,
|
||
Sparkles,
|
||
ShieldCheck,
|
||
ChevronRight,
|
||
SlidersHorizontal,
|
||
ChevronLeft,
|
||
} from 'lucide-react';
|
||
import { useKccStore, useOperatorRole } from '../store/kccStore';
|
||
import { OperatorSessionSwitcher } from './OperatorSessionSwitcher';
|
||
import { InspectorDrawer } from './InspectorDrawer';
|
||
import { ConfirmOperatorActionModal } from './ConfirmOperatorAction';
|
||
import { ServerMonitorOutageBanner } from './ServerMonitorOutageBanner';
|
||
import { useThemeStore, ThemeMode } from '../../../stores/theme';
|
||
import { RU_ROLES, RU_THEMES, RU_UI } from '../locales/ru';
|
||
|
||
export const ControlCenterShell: React.FC = () => {
|
||
const location = useLocation();
|
||
const navigate = useNavigate();
|
||
|
||
const isInitialized = useKccStore((state) => state.isInitialized);
|
||
const initialize = useKccStore((state) => state.initialize);
|
||
const resetToDefaults = useKccStore((state) => state.resetToDefaults);
|
||
const serverMonitorOutage = useKccStore((state) => state.serverMonitorOutage);
|
||
const toggleServerMonitorOutage = useKccStore((state) => state.toggleServerMonitorOutage);
|
||
const incidents = useKccStore((state) => state.incidents);
|
||
|
||
const { role, operator } = useOperatorRole();
|
||
const themeMode = useThemeStore((state) => state.themeMode);
|
||
const setTheme = useThemeStore((state) => state.setTheme);
|
||
|
||
const [utcTime, setUtcTime] = useState('');
|
||
const [isResetting, setIsResetting] = useState(false);
|
||
const [showSavedFeedback, setShowSavedFeedback] = useState(false);
|
||
|
||
// Mobile / Tablet Drawer states
|
||
const [isMobileDrawerOpen, setIsMobileDrawerOpen] = useState(false);
|
||
const [isTabletSidebarCollapsed, setIsTabletSidebarCollapsed] = useState(false);
|
||
|
||
useEffect(() => {
|
||
if (!isInitialized) {
|
||
initialize();
|
||
}
|
||
}, [isInitialized, initialize]);
|
||
|
||
useEffect(() => {
|
||
const updateTime = () => {
|
||
const now = new Date();
|
||
setUtcTime(now.toISOString().replace('T', ' ').slice(0, 19) + ' UTC');
|
||
};
|
||
updateTime();
|
||
const interval = setInterval(updateTime, 1000);
|
||
return () => clearInterval(interval);
|
||
}, []);
|
||
|
||
// Close mobile drawer on route change
|
||
useEffect(() => {
|
||
setIsMobileDrawerOpen(false);
|
||
}, [location.pathname]);
|
||
|
||
const handleReset = async () => {
|
||
if (confirm('Сбросить демонстрационные данные платформы к исходному состоянию?')) {
|
||
setIsResetting(true);
|
||
await resetToDefaults();
|
||
setIsResetting(false);
|
||
setShowSavedFeedback(true);
|
||
setTimeout(() => setShowSavedFeedback(false), 3000);
|
||
}
|
||
};
|
||
|
||
// Critical events calculation
|
||
const criticalCount = useMemo(() => {
|
||
let count = 0;
|
||
if (serverMonitorOutage) count += 1;
|
||
count += incidents.filter(
|
||
(i) => i.status !== 'resolved' && (i.severity === 'P0_CRITICAL' || i.severity === 'P1_MAJOR')
|
||
).length;
|
||
return count;
|
||
}, [serverMonitorOutage, incidents]);
|
||
|
||
// Navigation Items
|
||
const primaryMobileNav = [
|
||
{ to: '/control', label: 'Обзор', icon: LayoutDashboard, end: true },
|
||
{ to: '/control/organizations', label: 'Организации', icon: Building2 },
|
||
{ to: '/control/incidents', label: 'Инциденты', icon: AlertTriangle, badge: criticalCount > 0 ? criticalCount : undefined },
|
||
{ to: '/control/infrastructure', label: 'Серверы', icon: Server },
|
||
];
|
||
|
||
const allNavItems = [
|
||
{ to: '/control', label: 'Обзор', icon: LayoutDashboard, end: true },
|
||
{ to: '/control/organizations', label: 'Организации', icon: Building2 },
|
||
{ to: '/control/billing', label: 'Подписки и платежи', icon: CreditCard },
|
||
{ to: '/control/modules', label: 'Каталог модулей', icon: Package },
|
||
{ to: '/control/installations', label: 'Установки', icon: Smartphone },
|
||
{ to: '/control/infrastructure', label: 'Серверы', icon: Server },
|
||
{ to: '/control/announcements', label: 'Оповещения', icon: BellRing },
|
||
{ to: '/control/incidents', label: 'Инциденты', icon: AlertTriangle, badge: criticalCount > 0 ? criticalCount : undefined },
|
||
{ to: '/control/audit', label: 'Журнал аудита', icon: FileCheck2 },
|
||
];
|
||
|
||
const moreNavItems = [
|
||
{ to: '/control/billing', label: 'Подписки и платежи', icon: CreditCard },
|
||
{ to: '/control/modules', label: 'Каталог модулей', icon: Package },
|
||
{ to: '/control/installations', label: 'Установки', icon: Smartphone },
|
||
{ to: '/control/announcements', label: 'Оповещения', icon: BellRing },
|
||
{ to: '/control/audit', label: 'Журнал аудита', icon: FileCheck2 },
|
||
];
|
||
|
||
// Current section title
|
||
const currentTitle = useMemo(() => {
|
||
const current = allNavItems.find((item) =>
|
||
item.end ? location.pathname === item.to : location.pathname.startsWith(item.to)
|
||
);
|
||
return current?.label || 'Control Center';
|
||
}, [location.pathname, allNavItems]);
|
||
|
||
const currentRoleLabel = RU_ROLES[role]?.shortLabel || role;
|
||
|
||
return (
|
||
<div
|
||
className="min-h-screen max-w-full overflow-x-hidden bg-krona-app text-krona-primary flex flex-col font-sans selection:bg-krona-gold selection:text-zinc-950 transition-colors duration-200"
|
||
data-testid="control-center-shell"
|
||
>
|
||
{/* ========================================================================= */}
|
||
{/* 1. DESKTOP & TABLET HEADER (Hidden on mobile < md) */}
|
||
{/* ========================================================================= */}
|
||
<header className="hidden md:flex h-14 border-b border-krona-border bg-krona-workspace/95 backdrop-blur-md px-4 items-center justify-between z-30 shrink-0 max-w-full">
|
||
<div className="flex items-center gap-3 lg:gap-4 min-w-0">
|
||
{/* Logo & Platform info */}
|
||
<div className="flex items-center gap-2.5 min-w-0">
|
||
<div className="w-8 h-8 rounded-lg bg-krona-surface border border-krona-gold/40 flex items-center justify-center text-krona-gold font-extrabold shadow-sm shrink-0">
|
||
K
|
||
</div>
|
||
<div className="min-w-0">
|
||
<div className="text-xs font-black tracking-wider text-krona-primary uppercase flex items-center gap-1.5">
|
||
<span className="shrink-0">Krona Control Center</span>
|
||
<span className="text-[9px] px-1.5 py-0.5 rounded-sm bg-status-success-soft text-status-success-text font-mono">
|
||
PROD v1.0
|
||
</span>
|
||
</div>
|
||
<div className="text-[10px] text-krona-tertiary font-mono tracking-wider">
|
||
Кабинет владельца платформы
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<div className="hidden xl:flex items-center gap-2 text-[11px] text-krona-tertiary font-mono pl-3 border-l border-krona-border shrink-0">
|
||
<span>{utcTime}</span>
|
||
<span>•</span>
|
||
<span className="flex items-center gap-1 text-status-success-text">
|
||
<span className="w-1.5 h-1.5 rounded-full bg-status-success-text animate-pulse shrink-0" />
|
||
<span>{RU_UI.system.synced}</span>
|
||
</span>
|
||
</div>
|
||
</div>
|
||
|
||
{/* Right Controls: Outage simulation, Theme Switcher, Operator Switcher, Reset */}
|
||
<div className="flex items-center gap-2 lg:gap-3 shrink-0">
|
||
{/* Circuit Breaker Outage toggle */}
|
||
<button
|
||
type="button"
|
||
onClick={toggleServerMonitorOutage}
|
||
className={`min-h-[38px] px-2.5 py-1.5 rounded-lg border text-xs font-medium flex items-center gap-1.5 transition-colors ${
|
||
serverMonitorOutage
|
||
? 'border-status-danger-border bg-status-danger-soft text-status-danger-text animate-pulse font-semibold'
|
||
: 'border-krona-border bg-krona-surface hover:bg-krona-hover text-krona-secondary'
|
||
}`}
|
||
title="Имитация сбоя канала телеметрии Server Monitor (Circuit Breaker)"
|
||
data-testid="simulate-outage-toggle"
|
||
>
|
||
<Radio className="w-3.5 h-3.5 shrink-0" />
|
||
<span className="hidden lg:inline">
|
||
{serverMonitorOutage ? 'Сбой мониторинга (Активен)' : 'Имитировать сбой'}
|
||
</span>
|
||
</button>
|
||
|
||
{/* Theme Switcher in Desktop Header */}
|
||
<div className="flex items-center bg-krona-surface border border-krona-border rounded-lg p-0.5" role="radiogroup" aria-label="Выбор темы">
|
||
<button
|
||
type="button"
|
||
onClick={() => setTheme('soft-light')}
|
||
className={`p-1.5 rounded-md text-xs transition-colors ${
|
||
themeMode === 'soft-light' ? 'bg-krona-workspace text-krona-gold font-semibold shadow-xs' : 'text-krona-secondary hover:text-krona-primary'
|
||
}`}
|
||
title="Мягкая светлая тема"
|
||
data-testid="theme-toggle-soft-light"
|
||
>
|
||
<Sparkles className="w-3.5 h-3.5" />
|
||
</button>
|
||
<button
|
||
type="button"
|
||
onClick={() => setTheme('light')}
|
||
className={`p-1.5 rounded-md text-xs transition-colors ${
|
||
themeMode === 'light' ? 'bg-krona-workspace text-krona-gold font-semibold shadow-xs' : 'text-krona-secondary hover:text-krona-primary'
|
||
}`}
|
||
title="Классическая светлая тема"
|
||
data-testid="theme-toggle-light"
|
||
>
|
||
<Sun className="w-3.5 h-3.5" />
|
||
</button>
|
||
<button
|
||
type="button"
|
||
onClick={() => setTheme('dark')}
|
||
className={`p-1.5 rounded-md text-xs transition-colors ${
|
||
themeMode === 'dark' ? 'bg-krona-workspace text-krona-gold font-semibold shadow-xs' : 'text-krona-secondary hover:text-krona-primary'
|
||
}`}
|
||
title="Тёмная тема"
|
||
data-testid="theme-toggle-dark"
|
||
>
|
||
<Moon className="w-3.5 h-3.5" />
|
||
</button>
|
||
</div>
|
||
|
||
{/* Reset Demo Data */}
|
||
<button
|
||
type="button"
|
||
onClick={handleReset}
|
||
disabled={isResetting}
|
||
className="min-h-[38px] px-2.5 py-1.5 rounded-lg border border-krona-border bg-krona-surface hover:bg-krona-hover text-krona-secondary transition-colors text-xs flex items-center gap-1.5"
|
||
title="Сброс демонстрационных данных к начальным"
|
||
data-testid="reset-demo-button"
|
||
>
|
||
<RotateCcw className={`w-3.5 h-3.5 ${isResetting ? 'animate-spin text-krona-gold' : ''}`} />
|
||
<span className="hidden xl:inline">Сбросить данные</span>
|
||
</button>
|
||
|
||
{/* Operator Role Switcher */}
|
||
<OperatorSessionSwitcher />
|
||
</div>
|
||
</header>
|
||
|
||
{/* ========================================================================= */}
|
||
{/* 2. MOBILE HEADER (< md) */}
|
||
{/* ========================================================================= */}
|
||
<header
|
||
className="md:hidden h-14 border-b border-krona-border bg-krona-workspace/95 backdrop-blur-md px-3 flex items-center justify-between z-30 shrink-0 sticky top-0"
|
||
data-testid="mobile-header"
|
||
>
|
||
{/* Left: Menu toggle + Logo + Title */}
|
||
<div className="flex items-center gap-2 min-w-0">
|
||
<button
|
||
type="button"
|
||
onClick={() => setIsMobileDrawerOpen(true)}
|
||
className="min-w-[44px] min-h-[44px] flex items-center justify-center rounded-lg border border-krona-border bg-krona-surface text-krona-primary hover:bg-krona-hover transition-colors"
|
||
aria-label="Открыть меню панели оператора"
|
||
data-testid="mobile-menu-button"
|
||
>
|
||
<Menu className="w-5 h-5" />
|
||
</button>
|
||
|
||
<div className="flex items-center gap-1.5 min-w-0">
|
||
<div className="w-7 h-7 rounded-md bg-krona-surface border border-krona-gold/40 flex items-center justify-center text-krona-gold font-extrabold text-xs shrink-0">
|
||
K
|
||
</div>
|
||
<div className="min-w-0">
|
||
<div className="text-xs font-bold text-krona-primary truncate leading-tight">
|
||
{currentTitle}
|
||
</div>
|
||
<div className="text-[10px] text-krona-gold font-medium truncate">
|
||
Control Center
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
{/* Right: Critical Badge + Operator Profile button */}
|
||
<div className="flex items-center gap-1.5 shrink-0">
|
||
{criticalCount > 0 && (
|
||
<button
|
||
type="button"
|
||
onClick={() => navigate('/control/incidents')}
|
||
className="min-w-[44px] min-h-[44px] flex items-center justify-center rounded-lg bg-status-danger-soft border border-status-danger-border text-status-danger-text text-xs font-bold"
|
||
aria-label={`Критические события: ${criticalCount}`}
|
||
title="Есть критические события платформы"
|
||
data-testid="mobile-critical-indicator"
|
||
>
|
||
<ShieldAlert className="w-4 h-4 animate-pulse mr-1" />
|
||
<span>{criticalCount}</span>
|
||
</button>
|
||
)}
|
||
|
||
<button
|
||
type="button"
|
||
onClick={() => setIsMobileDrawerOpen(true)}
|
||
className="min-w-[44px] min-h-[44px] px-2.5 flex items-center gap-1.5 rounded-lg border border-krona-gold/30 bg-krona-gold/10 text-krona-gold-text text-xs font-semibold"
|
||
aria-label={`Профиль оператора: ${currentRoleLabel}`}
|
||
title="Профиль оператора платформы"
|
||
data-testid="mobile-operator-profile-btn"
|
||
>
|
||
<ShieldCheck className="w-4 h-4 text-krona-gold shrink-0" />
|
||
<span className="truncate max-w-[80px]">{currentRoleLabel}</span>
|
||
</button>
|
||
</div>
|
||
</header>
|
||
|
||
{/* ========================================================================= */}
|
||
{/* 3. MAIN LAYOUT (Sidebar on Desktop/Tablet + Content Body) */}
|
||
{/* ========================================================================= */}
|
||
<div className="flex-1 flex min-h-0 relative max-w-full">
|
||
{/* Desktop & Tablet Sidebar */}
|
||
<aside
|
||
className={`hidden md:flex flex-col border-r border-krona-border bg-krona-workspace transition-all duration-200 shrink-0 z-20 ${
|
||
isTabletSidebarCollapsed ? 'w-16' : 'w-56 lg:w-64'
|
||
}`}
|
||
data-testid="desktop-sidebar"
|
||
>
|
||
{/* Tablet Collapse Toggle */}
|
||
<div className="p-2 border-b border-krona-border flex items-center justify-between">
|
||
<span className={`text-[11px] font-semibold text-krona-tertiary uppercase tracking-wider px-2 ${isTabletSidebarCollapsed ? 'hidden' : 'block'}`}>
|
||
Навигация
|
||
</span>
|
||
<button
|
||
type="button"
|
||
onClick={() => setIsTabletSidebarCollapsed(!isTabletSidebarCollapsed)}
|
||
className="p-1.5 rounded-md hover:bg-krona-hover text-krona-secondary hover:text-krona-primary transition-colors ml-auto"
|
||
title={isTabletSidebarCollapsed ? 'Развернуть меню' : 'Свернуть меню'}
|
||
>
|
||
{isTabletSidebarCollapsed ? <ChevronRight className="w-4 h-4" /> : <ChevronLeft className="w-4 h-4" />}
|
||
</button>
|
||
</div>
|
||
|
||
{/* Navigation Links */}
|
||
<nav className="flex-1 p-2 space-y-1 overflow-y-auto">
|
||
{allNavItems.map((item) => {
|
||
const Icon = item.icon;
|
||
return (
|
||
<NavLink
|
||
key={item.to}
|
||
to={item.to}
|
||
end={item.end}
|
||
className={({ isActive }) =>
|
||
`flex items-center min-h-[40px] px-3 py-2 rounded-lg text-xs font-medium transition-colors ${
|
||
isActive
|
||
? 'bg-krona-gold/15 text-krona-gold font-bold border border-krona-gold/30'
|
||
: 'text-krona-secondary hover:bg-krona-hover hover:text-krona-primary'
|
||
}`
|
||
}
|
||
title={item.label}
|
||
data-testid={`nav-${item.to.replace('/control', '').replace('/', '') || 'overview'}`}
|
||
>
|
||
<Icon className="w-4 h-4 shrink-0" />
|
||
{!isTabletSidebarCollapsed && (
|
||
<span className="ml-2.5 truncate flex-1">{item.label}</span>
|
||
)}
|
||
{!isTabletSidebarCollapsed && item.badge && item.badge > 0 && (
|
||
<span className="ml-auto px-1.5 py-0.5 rounded-full text-[10px] font-mono font-bold bg-status-danger-soft text-status-danger-text border border-status-danger-border">
|
||
{item.badge}
|
||
</span>
|
||
)}
|
||
</NavLink>
|
||
);
|
||
})}
|
||
</nav>
|
||
|
||
{/* Footer Info in Sidebar */}
|
||
{!isTabletSidebarCollapsed && (
|
||
<div className="p-3 border-t border-krona-border text-[11px] text-krona-tertiary font-mono space-y-1 bg-krona-surface/30">
|
||
<div className="flex items-center justify-between">
|
||
<span>{RU_UI.system.nodeLabel}:</span>
|
||
<span className="font-semibold text-krona-secondary">srv-msk-prod-01</span>
|
||
</div>
|
||
<div className="flex items-center justify-between">
|
||
<span>{RU_UI.scope}:</span>
|
||
<span className="text-status-success-text">Боевой (Live)</span>
|
||
</div>
|
||
<NavLink
|
||
to="/"
|
||
className="mt-2 pt-2 border-t border-krona-border flex items-center gap-1.5 text-krona-gold hover:underline text-[11px]"
|
||
>
|
||
<ExternalLink className="w-3.5 h-3.5" />
|
||
<span>{RU_UI.system.exitToClient}</span>
|
||
</NavLink>
|
||
</div>
|
||
)}
|
||
</aside>
|
||
|
||
{/* Main Content Area */}
|
||
<main className="flex-1 min-w-0 max-w-full overflow-y-auto overflow-x-hidden p-3 sm:p-5 lg:p-6 pb-20 md:pb-6">
|
||
<div className="max-w-7xl mx-auto w-full">
|
||
{/* Feedback Alert if Database Reset */}
|
||
{showSavedFeedback && (
|
||
<div
|
||
className="mb-4 p-3.5 rounded-xl border border-status-success-border bg-status-success-soft text-status-success-text text-xs flex items-center gap-2 shadow-md animate-in fade-in"
|
||
role="status"
|
||
>
|
||
<CheckCircle2 className="w-4 h-4 shrink-0" />
|
||
<span>Демонстрационные данные успешно сброшены к эталонному состоянию.</span>
|
||
</div>
|
||
)}
|
||
|
||
{/* Outage Banner */}
|
||
<ServerMonitorOutageBanner />
|
||
|
||
{/* Outlet for Page Routes */}
|
||
<Outlet />
|
||
</div>
|
||
</main>
|
||
</div>
|
||
|
||
{/* ========================================================================= */}
|
||
{/* 4. MOBILE BOTTOM BAR (< md) */}
|
||
{/* ========================================================================= */}
|
||
<nav
|
||
className="md:hidden fixed bottom-0 left-0 right-0 h-16 border-t border-krona-border bg-krona-workspace/95 backdrop-blur-md px-1 flex items-center justify-around z-40"
|
||
data-testid="mobile-bottom-bar"
|
||
aria-label="Основная мобильная навигация"
|
||
>
|
||
{primaryMobileNav.map((item) => {
|
||
const Icon = item.icon;
|
||
const isActive = item.end ? location.pathname === item.to : location.pathname.startsWith(item.to);
|
||
|
||
return (
|
||
<NavLink
|
||
key={item.to}
|
||
to={item.to}
|
||
end={item.end}
|
||
className={`flex-1 min-h-[48px] min-w-[44px] flex flex-col items-center justify-center rounded-lg py-1 transition-colors relative ${
|
||
isActive ? 'text-krona-gold font-bold' : 'text-krona-secondary hover:text-krona-primary'
|
||
}`}
|
||
data-testid={`bottom-nav-${item.to.replace('/control', '').replace('/', '') || 'overview'}`}
|
||
>
|
||
<Icon className={`w-5 h-5 ${isActive ? 'stroke-[2.5]' : ''}`} />
|
||
<span className="text-[10px] mt-0.5 leading-none truncate max-w-[64px]">{item.label}</span>
|
||
{item.badge && item.badge > 0 && (
|
||
<span className="absolute top-1 right-3 w-4 h-4 rounded-full bg-status-danger-text text-white text-[9px] font-bold flex items-center justify-center animate-pulse">
|
||
{item.badge}
|
||
</span>
|
||
)}
|
||
</NavLink>
|
||
);
|
||
})}
|
||
|
||
{/* "More" Button */}
|
||
<button
|
||
type="button"
|
||
onClick={() => setIsMobileDrawerOpen(true)}
|
||
className={`flex-1 min-h-[48px] min-w-[44px] flex flex-col items-center justify-center rounded-lg py-1 transition-colors ${
|
||
isMobileDrawerOpen ? 'text-krona-gold font-bold' : 'text-krona-secondary hover:text-krona-primary'
|
||
}`}
|
||
aria-label="Ещё разделы и настройки"
|
||
data-testid="bottom-nav-more"
|
||
>
|
||
<SlidersHorizontal className="w-5 h-5" />
|
||
<span className="text-[10px] mt-0.5 leading-none">Ещё</span>
|
||
</button>
|
||
</nav>
|
||
|
||
{/* ========================================================================= */}
|
||
{/* 5. MOBILE DRAWER / BOTTOM SHEET */}
|
||
{/* ========================================================================= */}
|
||
{isMobileDrawerOpen && (
|
||
<div
|
||
className="fixed inset-0 z-50 flex flex-col justify-end bg-black/60 backdrop-blur-xs md:hidden animate-in fade-in duration-200"
|
||
role="dialog"
|
||
aria-modal="true"
|
||
aria-label="Меню оператора платформы"
|
||
onClick={() => setIsMobileDrawerOpen(false)}
|
||
>
|
||
<div
|
||
className="w-full max-h-[85vh] bg-krona-workspace border-t border-krona-border rounded-t-2xl flex flex-col overflow-hidden shadow-2xl animate-in slide-in-from-bottom duration-250"
|
||
onClick={(e) => e.stopPropagation()}
|
||
>
|
||
{/* Sheet Header */}
|
||
<div className="px-4 py-3 border-b border-krona-border flex items-center justify-between shrink-0 bg-krona-surface">
|
||
<div className="flex items-center gap-2">
|
||
<div className="w-6 h-6 rounded-md bg-krona-gold/20 text-krona-gold font-bold flex items-center justify-center text-xs">
|
||
K
|
||
</div>
|
||
<span className="font-bold text-sm text-krona-primary">Панель оператора</span>
|
||
</div>
|
||
<button
|
||
type="button"
|
||
onClick={() => setIsMobileDrawerOpen(false)}
|
||
className="min-w-[44px] min-h-[44px] flex items-center justify-center rounded-lg text-krona-secondary hover:text-krona-primary hover:bg-krona-hover"
|
||
aria-label="Закрыть меню"
|
||
data-testid="close-mobile-drawer"
|
||
>
|
||
<X className="w-5 h-5" />
|
||
</button>
|
||
</div>
|
||
|
||
{/* Scrollable Sheet Content */}
|
||
<div className="p-4 overflow-y-auto space-y-4">
|
||
{/* Operator Role Section */}
|
||
<div className="p-3 rounded-xl bg-krona-surface border border-krona-border">
|
||
<div className="text-[11px] font-semibold text-krona-tertiary uppercase tracking-wider mb-2">
|
||
Сессия оператора
|
||
</div>
|
||
<div className="flex items-center justify-between gap-2">
|
||
<div className="min-w-0">
|
||
<div className="font-bold text-sm text-krona-primary">{operator.name}</div>
|
||
<div className="text-xs text-krona-gold font-medium">{RU_ROLES[role]?.label}</div>
|
||
</div>
|
||
<OperatorSessionSwitcher variant="compact" />
|
||
</div>
|
||
</div>
|
||
|
||
{/* Theme Switcher Section */}
|
||
<div className="p-3 rounded-xl bg-krona-surface border border-krona-border">
|
||
<div className="text-[11px] font-semibold text-krona-tertiary uppercase tracking-wider mb-2">
|
||
{RU_UI.system.themeLabel}
|
||
</div>
|
||
<div className="grid grid-cols-3 gap-2">
|
||
{(['soft-light', 'light', 'dark'] as ThemeMode[]).map((t) => (
|
||
<button
|
||
key={t}
|
||
type="button"
|
||
onClick={() => setTheme(t)}
|
||
className={`min-h-[44px] px-2 py-2 rounded-lg text-xs font-semibold flex flex-col items-center justify-center transition-colors border ${
|
||
themeMode === t
|
||
? 'border-krona-gold bg-krona-gold/15 text-krona-gold shadow-xs'
|
||
: 'border-krona-border bg-krona-workspace text-krona-secondary hover:text-krona-primary'
|
||
}`}
|
||
data-testid={`mobile-theme-${t}`}
|
||
>
|
||
{t === 'soft-light' && <Sparkles className="w-4 h-4 mb-0.5" />}
|
||
{t === 'light' && <Sun className="w-4 h-4 mb-0.5" />}
|
||
{t === 'dark' && <Moon className="w-4 h-4 mb-0.5" />}
|
||
<span className="text-[10px] text-center leading-tight">{RU_THEMES[t]}</span>
|
||
</button>
|
||
))}
|
||
</div>
|
||
</div>
|
||
|
||
{/* More Navigation Links */}
|
||
<div className="p-3 rounded-xl bg-krona-surface border border-krona-border">
|
||
<div className="text-[11px] font-semibold text-krona-tertiary uppercase tracking-wider mb-2">
|
||
Все разделы
|
||
</div>
|
||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-1.5">
|
||
{allNavItems.map((item) => {
|
||
const Icon = item.icon;
|
||
const isActive = item.end ? location.pathname === item.to : location.pathname.startsWith(item.to);
|
||
|
||
return (
|
||
<NavLink
|
||
key={item.to}
|
||
to={item.to}
|
||
end={item.end}
|
||
className={`min-h-[44px] px-3 py-2.5 rounded-lg text-xs flex items-center justify-between border transition-colors ${
|
||
isActive
|
||
? 'border-krona-gold/40 bg-krona-gold/15 text-krona-gold font-bold'
|
||
: 'border-transparent text-krona-secondary hover:text-krona-primary hover:bg-krona-hover'
|
||
}`}
|
||
data-testid={`drawer-nav-${item.to.replace('/control', '').replace('/', '') || 'overview'}`}
|
||
>
|
||
<div className="flex items-center gap-2.5 min-w-0">
|
||
<Icon className="w-4 h-4 shrink-0" />
|
||
<span className="truncate">{item.label}</span>
|
||
</div>
|
||
{item.badge && item.badge > 0 && (
|
||
<span className="px-2 py-0.5 rounded-full text-[10px] font-bold bg-status-danger-soft text-status-danger-text border border-status-danger-border">
|
||
{item.badge}
|
||
</span>
|
||
)}
|
||
</NavLink>
|
||
);
|
||
})}
|
||
</div>
|
||
</div>
|
||
|
||
{/* System Actions */}
|
||
<div className="p-3 rounded-xl bg-krona-surface border border-krona-border space-y-2">
|
||
<div className="text-[11px] font-semibold text-krona-tertiary uppercase tracking-wider">
|
||
Сервисные действия
|
||
</div>
|
||
|
||
<button
|
||
type="button"
|
||
onClick={toggleServerMonitorOutage}
|
||
className={`w-full min-h-[44px] px-3 py-2 rounded-lg text-xs font-semibold flex items-center justify-between border transition-colors ${
|
||
serverMonitorOutage
|
||
? 'border-status-danger-border bg-status-danger-soft text-status-danger-text'
|
||
: 'border-krona-border bg-krona-workspace text-krona-secondary hover:text-krona-primary'
|
||
}`}
|
||
data-testid="drawer-toggle-outage"
|
||
>
|
||
<div className="flex items-center gap-2">
|
||
<Radio className="w-4 h-4" />
|
||
<span>Сбой мониторинга (Circuit Breaker)</span>
|
||
</div>
|
||
<span className="text-[11px] font-mono">
|
||
{serverMonitorOutage ? 'ВКЛ' : 'ВЫКЛ'}
|
||
</span>
|
||
</button>
|
||
|
||
<button
|
||
type="button"
|
||
onClick={handleReset}
|
||
disabled={isResetting}
|
||
className="w-full min-h-[44px] px-3 py-2 rounded-lg text-xs font-medium border border-krona-border bg-krona-workspace text-krona-secondary hover:text-krona-primary flex items-center justify-center gap-2 transition-colors"
|
||
data-testid="drawer-reset-data"
|
||
>
|
||
<RotateCcw className={`w-4 h-4 ${isResetting ? 'animate-spin text-krona-gold' : ''}`} />
|
||
<span>Сбросить демоданные к эталону</span>
|
||
</button>
|
||
|
||
<NavLink
|
||
to="/"
|
||
className="w-full min-h-[44px] px-3 py-2 rounded-lg text-xs font-medium border border-krona-border bg-krona-workspace text-krona-gold hover:bg-krona-hover flex items-center justify-center gap-2 transition-colors"
|
||
>
|
||
<ExternalLink className="w-4 h-4" />
|
||
<span>Перейти в Krona Business OS</span>
|
||
</NavLink>
|
||
</div>
|
||
|
||
{/* Node info */}
|
||
<div className="text-center text-[11px] text-krona-tertiary font-mono pt-1 pb-2">
|
||
srv-msk-prod-01 • Данные синхронизированы (IndexedDB)
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
)}
|
||
|
||
{/* Global Contextual Modals */}
|
||
<InspectorDrawer />
|
||
<ConfirmOperatorActionModal />
|
||
</div>
|
||
);
|
||
};
|