Compare commits

...

2 commits

9 changed files with 1787 additions and 835 deletions

View file

@ -1,5 +1,5 @@
import React, { useEffect, useState } from 'react';
import { NavLink, Outlet } from 'react-router-dom';
import React, { useEffect, useState, useMemo } from 'react';
import { NavLink, Outlet, useLocation, useNavigate } from 'react-router-dom';
import {
LayoutDashboard,
Building2,
@ -14,24 +14,48 @@ import {
CheckCircle2,
Radio,
ExternalLink,
Menu,
X,
ShieldAlert,
Sun,
Moon,
Sparkles,
ShieldCheck,
ChevronRight,
SlidersHorizontal,
ChevronLeft,
} from 'lucide-react';
import { useKccStore } from '../store/kccStore';
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();
@ -48,8 +72,13 @@ export const ControlCenterShell: React.FC = () => {
return () => clearInterval(interval);
}, []);
// Close mobile drawer on route change
useEffect(() => {
setIsMobileDrawerOpen(false);
}, [location.pathname]);
const handleReset = async () => {
if (confirm('Сбросить состояние демонстрационной базы данных к исходному состоянию?')) {
if (confirm('Сбросить демонстрационные данные платформы к исходному состоянию?')) {
setIsResetting(true);
await resetToDefaults();
setIsResetting(false);
@ -58,107 +87,260 @@ export const ControlCenterShell: React.FC = () => {
}
};
const navItems = [
// 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/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 },
{ to: '/control/audit', label: 'Неизменяемый аудит', icon: FileCheck2 },
{ 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-[#07130e] text-zinc-100 flex flex-col font-sans selection:bg-amber-500 selection:text-zinc-950">
{/* Top Console Bar */}
<header className="h-14 border-b border-zinc-800 bg-[#0a1b14] px-2.5 sm:px-4 flex items-center justify-between z-30 shrink-0 max-w-full overflow-hidden">
<div className="flex items-center gap-2 sm:gap-4 min-w-0">
{/* Logo & Title */}
<div className="flex items-center gap-2 sm:gap-2.5 min-w-0">
<div className="w-7 h-7 sm:w-8 sm:h-8 rounded-lg bg-emerald-950 border border-amber-500/40 flex items-center justify-center text-amber-400 font-extrabold shadow-sm shrink-0">
<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-widest text-amber-400 uppercase flex items-center gap-1.5">
<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.2 rounded-xs bg-emerald-900/80 text-emerald-300 font-mono hidden sm:inline">
DEMO v1.0
<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-zinc-400 font-mono tracking-wider hidden sm:block">
<div className="text-[10px] text-krona-tertiary font-mono tracking-wider">
Кабинет владельца платформы
</div>
</div>
</div>
<div className="flex items-center gap-2 text-[11px] text-zinc-500 font-mono pl-2 sm:pl-4 border-l border-zinc-800 shrink-0">
<span className="hidden xl:inline">{utcTime}</span>
<span className="hidden xl:inline">•</span>
<span className="flex items-center gap-1 text-emerald-400">
<span className="w-1.5 h-1.5 rounded-full bg-emerald-400 animate-pulse shrink-0" />
<span className="text-[10px] sm:text-xs">IndexedDB Synced</span>
<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 Header Actions */}
<div className="flex items-center gap-1.5 sm:gap-3 shrink-0">
{/* Server Monitor Simulation Toggle */}
{/* 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={`flex items-center gap-1.5 px-2 sm:px-2.5 py-1 rounded-md text-xs font-medium border transition-colors ${
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
? 'bg-rose-950 text-rose-300 border-rose-700/60'
: 'bg-zinc-900 text-zinc-400 border-zinc-800 hover:text-zinc-200'
? '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"
title="Имитация сбоя канала телеметрии Server Monitor (Circuit Breaker)"
data-testid="simulate-outage-toggle"
>
<Radio className={`w-3.5 h-3.5 ${serverMonitorOutage ? 'text-rose-400 animate-pulse' : 'text-zinc-500'} shrink-0`} />
<span className="hidden md:inline">{serverMonitorOutage ? 'SM Outage Sim (Активен)' : 'Simulate SM Outage'}</span>
<Radio className="w-3.5 h-3.5 shrink-0" />
<span className="hidden lg:inline">
{serverMonitorOutage ? 'Сбой мониторинга (Активен)' : 'Имитировать сбой'}
</span>
</button>
{/* Reset Demo DB 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="hidden sm:flex items-center gap-1.5 px-2 sm:px-2.5 py-1 rounded-md text-xs font-medium bg-zinc-900 hover:bg-zinc-800 text-zinc-300 border border-zinc-800 transition-colors disabled:opacity-50 shrink-0"
title="Сбросить демонстрационные данные к эталону"
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' : ''} shrink-0`} />
<span className="hidden md:inline">Сброс Demo</span>
<RotateCcw className={`w-3.5 h-3.5 ${isResetting ? 'animate-spin text-krona-gold' : ''}`} />
<span className="hidden xl:inline">Сбросить данные</span>
</button>
{/* Operator RBAC Switcher */}
{/* Operator Role Switcher */}
<OperatorSessionSwitcher />
{/* Return to Client App Link */}
<NavLink
to="/"
className="hidden xl:flex items-center gap-1 text-xs text-zinc-500 hover:text-zinc-300 transition-colors pl-2"
title="Перейти в клиентское приложение платформы"
>
<span>В приложение</span>
<ExternalLink className="w-3 h-3" />
</NavLink>
</div>
</header>
{/* Main Console Layout */}
<div className="flex-1 flex flex-col md:flex-row overflow-hidden max-w-full">
{/* Compact Operator Sidebar */}
<aside
className="w-full md:w-56 border-b md:border-b-0 md:border-r border-zinc-800 bg-[#091712] flex flex-row md:flex-col shrink-0 overflow-x-auto md:overflow-y-auto"
aria-label="Навигация Control Center"
>
<div className="p-2 md:p-3 flex flex-row md:flex-col gap-1 shrink-0">
<div className="hidden md:block px-3 py-1.5 text-[10px] font-bold text-zinc-500 uppercase tracking-widest">
Управление
{/* ========================================================================= */}
{/* 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>
{navItems.map((item) => {
<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
@ -166,50 +348,292 @@ export const ControlCenterShell: React.FC = () => {
to={item.to}
end={item.end}
className={({ isActive }) =>
`flex items-center gap-2 px-2.5 py-1.5 md:px-3 md:py-2 rounded-lg text-xs font-medium whitespace-nowrap transition-colors ${
`flex items-center min-h-[40px] px-3 py-2 rounded-lg text-xs font-medium transition-colors ${
isActive
? 'bg-amber-500/15 text-amber-300 border border-amber-500/30 font-semibold'
: 'text-zinc-400 hover:text-zinc-200 hover:bg-zinc-800/60'
? '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" />
<span>{item.label}</span>
{!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>
);
})}
</div>
</nav>
{/* Sidebar Footer Info */}
<div className="hidden md:block mt-auto p-3 border-t border-zinc-800/80 text-[11px] text-zinc-500 space-y-1 bg-zinc-950/40">
<div className="flex justify-between items-center">
<span>Security Gate:</span>
<span className="text-emerald-400 font-semibold">Active</span>
</div>
<div className="flex justify-between items-center">
<span>Node Identity:</span>
<span className="text-zinc-400 font-mono">178.212.13.102</span>
</div>
</div>
</aside>
{/* Content Workspace */}
<main className="flex-1 flex flex-col overflow-y-auto bg-[#07130e] p-3 sm:p-4 md:p-6 min-w-0 max-w-full">
<ServerMonitorOutageBanner />
{showSavedFeedback && (
<div className="mb-4 p-3 rounded-lg bg-emerald-950/80 border border-emerald-700/60 text-emerald-200 text-xs flex items-center gap-2 animate-in fade-in">
<CheckCircle2 className="w-4 h-4 text-emerald-400" />
<span>Демонстрационные данные успешно сброшены и сохранены в IndexedDB.</span>
{/* 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>
)}
<Outlet />
</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>
{/* Contextual Inspector Drawer */}
<InspectorDrawer />
{/* ========================================================================= */}
{/* 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);
{/* Modal Dialog for Dangerous Operations */}
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>
);

View file

@ -1,13 +1,15 @@
import React, { useState, useRef, useEffect } from 'react';
import { ShieldCheck, ChevronDown, UserCircle } from 'lucide-react';
import { useKccStore, useOperatorRole } from '../store/kccStore';
import { OPERATOR_ROLE_DEFINITIONS, OperatorRole } from '../contracts';
import { OperatorRole } from '../contracts';
import { RU_ROLES } from '../locales/ru';
interface OperatorSessionSwitcherProps {
onRoleChange?: (role: OperatorRole) => void;
variant?: 'compact' | 'full';
}
export const OperatorSessionSwitcher: React.FC<OperatorSessionSwitcherProps> = ({ onRoleChange }) => {
export const OperatorSessionSwitcher: React.FC<OperatorSessionSwitcherProps> = ({ onRoleChange, variant = 'full' }) => {
const { role, operator } = useOperatorRole();
const setActiveRole = useKccStore((state) => state.setActiveRole);
const [isOpen, setIsOpen] = useState(false);
@ -32,73 +34,85 @@ export const OperatorSessionSwitcher: React.FC<OperatorSessionSwitcherProps> = (
'release_manager',
];
const handleSelectRole = (newRole: OperatorRole) => {
setActiveRole(newRole);
setIsOpen(false);
onRoleChange?.(newRole);
};
const currentRoleInfo = RU_ROLES[role] || {
label: role,
shortLabel: role,
description: '',
};
return (
<div className="relative inline-block text-left" ref={dropdownRef}>
<button
type="button"
onClick={() => setIsOpen(!isOpen)}
className="flex items-center gap-2.5 px-3 py-1.5 rounded-lg border border-amber-500/30 bg-amber-500/10 hover:bg-amber-500/20 text-amber-300 transition-colors text-xs font-medium focus:outline-hidden focus:ring-1 focus:ring-amber-400"
className="flex items-center gap-2 px-3 py-2 min-h-[44px] rounded-lg border border-krona-gold/30 bg-krona-gold/10 hover:bg-krona-gold/20 text-krona-gold-text transition-colors text-xs font-medium focus:outline-hidden focus:ring-2 focus:ring-krona-gold"
aria-expanded={isOpen}
aria-haspopup="true"
aria-label="Сессия оператора"
title="Сменить роль оператора платформы (RBAC демо)"
title="Сменить роль оператора платформы (RBAC)"
data-testid="operator-session-button"
>
<ShieldCheck className="w-4 h-4 text-amber-400 shrink-0" />
<ShieldCheck className="w-4 h-4 text-krona-gold shrink-0" />
<div className="text-left leading-tight">
<div className="font-bold text-amber-200 hidden sm:block">{operator.name}</div>
<div className="text-[10px] text-amber-400/80 uppercase tracking-wider font-mono">
{OPERATOR_ROLE_DEFINITIONS[role].label}
{variant === 'full' && (
<div className="font-bold text-krona-primary hidden sm:block">{operator.name}</div>
)}
<div className="text-[11px] text-krona-gold font-semibold uppercase tracking-wider">
{currentRoleInfo.label}
</div>
</div>
<ChevronDown className={`w-3.5 h-3.5 text-amber-400 transition-transform ${isOpen ? 'rotate-180' : ''}`} />
<ChevronDown className={`w-3.5 h-3.5 text-krona-gold transition-transform ${isOpen ? 'rotate-180' : ''}`} />
</button>
{isOpen && (
<div
className="absolute right-0 mt-2 w-80 rounded-xl border border-zinc-700/80 bg-[#0d1e17] shadow-2xl z-50 p-2 text-zinc-200 animate-in fade-in slide-in-from-top-2 duration-150"
className="absolute right-0 mt-2 w-80 max-w-[calc(100vw-2rem)] rounded-xl border border-krona-border bg-krona-surface shadow-2xl z-50 p-2 text-krona-primary animate-in fade-in slide-in-from-top-2 duration-150"
role="menu"
>
<div className="px-3 py-2 border-b border-zinc-800 text-xs text-zinc-400">
<span className="font-semibold text-zinc-200">Операторская сессия (RBAC Demo)</span>
<p className="text-[11px] text-zinc-500 mt-0.5">
<div className="px-3 py-2 border-b border-krona-border text-xs text-krona-secondary">
<span className="font-semibold text-krona-primary">Роль оператора (RBAC)</span>
<p className="text-[11px] text-krona-tertiary mt-0.5">
Выберите роль для проверки разграничения прав доступа:
</p>
</div>
<div className="py-1 space-y-1">
<div className="py-1 space-y-1 max-h-72 overflow-y-auto">
{rolesList.map((r) => {
const def = OPERATOR_ROLE_DEFINITIONS[r];
const isSelected = r === role;
const info = RU_ROLES[r];
const isCurrent = r === role;
return (
<button
key={r}
type="button"
onClick={() => {
setActiveRole(r);
onRoleChange?.(r);
setIsOpen(false);
}}
className={`w-full text-left px-3 py-2.5 rounded-lg text-xs transition-colors flex items-start gap-2.5 ${
isSelected
? 'bg-amber-500/20 text-amber-200 border border-amber-500/40 font-medium'
: 'hover:bg-zinc-800/80 text-zinc-300'
onClick={() => handleSelectRole(r)}
className={`w-full text-left px-3 py-2.5 min-h-[44px] rounded-lg text-xs transition-colors flex items-start gap-2.5 ${
isCurrent
? 'bg-krona-gold/15 text-krona-gold font-semibold border border-krona-gold/30'
: 'hover:bg-krona-hover text-krona-secondary hover:text-krona-primary'
}`}
role="menuitem"
data-testid={`role-option-${r}`}
>
<UserCircle
className={`w-4 h-4 mt-0.5 shrink-0 ${isSelected ? 'text-amber-400' : 'text-zinc-400'}`}
/>
<div>
<div className="font-bold flex items-center gap-1.5">
<span>{def.label}</span>
{isSelected && (
<span className="text-[10px] px-1.5 py-0.2 rounded-xs bg-amber-500 text-zinc-950 font-extrabold uppercase">
Активна
<UserCircle className={`w-4 h-4 mt-0.5 shrink-0 ${isCurrent ? 'text-krona-gold' : 'text-krona-tertiary'}`} />
<div className="flex-1 min-w-0">
<div className="flex items-center justify-between">
<span className="font-medium text-krona-primary">{info.label}</span>
{isCurrent && (
<span className="text-[10px] bg-krona-gold/20 text-krona-gold px-1.5 py-0.2 rounded font-mono">
Текущая
</span>
)}
</div>
<div className="text-[11px] text-zinc-400 mt-0.5 leading-snug">{def.description}</div>
<p className="text-[11px] text-krona-tertiary mt-0.5 line-clamp-2">
{info.description}
</p>
</div>
</button>
);

View file

@ -10,19 +10,19 @@ export const ServerMonitorOutageBanner: React.FC = () => {
return (
<div
className="mb-4 rounded-xl border border-red-500/50 bg-red-950/40 p-4 text-red-200 flex flex-col sm:flex-row items-start sm:items-center justify-between gap-3 shadow-lg shadow-red-950/20 animate-in fade-in slide-in-from-top-1"
className="mb-4 rounded-xl border border-status-danger-border bg-status-danger-soft p-4 text-status-danger-text flex flex-col sm:flex-row items-start sm:items-center justify-between gap-3 shadow-md animate-in fade-in slide-in-from-top-1"
role="alert"
>
<div className="flex items-start gap-3">
<div className="p-2 rounded-lg bg-red-500/20 text-red-400 border border-red-500/40 shrink-0 mt-0.5">
<div className="p-2 rounded-lg bg-status-danger-text/10 text-status-danger-text border border-status-danger-border shrink-0 mt-0.5">
<AlertOctagon className="w-5 h-5" />
</div>
<div>
<div className="font-bold text-sm text-red-300">
Внимание: Сервис Server Monitor временно недоступен (Circuit Breaker Tripped)
<div className="font-bold text-sm">
Авария: Сервис мониторинга недоступен (Circuit Breaker Tripped)
</div>
<div className="text-xs text-red-300/80 mt-0.5">
Прямой mTLS канал с агентами узлов не отвечает. Разделы организаций, подписок и биллинга продолжают штатную
<div className="text-xs opacity-90 mt-0.5">
Прямой mTLS-канал с агентами узлов не отвечает. Разделы организаций, подписок и биллинга продолжают штатную
работу. Показаны последние закешированные данные телеметрии.
</div>
</div>
@ -31,10 +31,10 @@ export const ServerMonitorOutageBanner: React.FC = () => {
<button
type="button"
onClick={toggleOutage}
className="shrink-0 px-3 py-1.5 rounded-lg border border-red-500/40 bg-red-500/20 hover:bg-red-500/30 text-red-200 text-xs font-semibold flex items-center gap-1.5 transition-colors"
className="shrink-0 px-3 py-2 min-h-[44px] rounded-lg border border-status-danger-border bg-krona-surface hover:bg-krona-hover text-status-danger-text text-xs font-semibold flex items-center gap-2 transition-colors self-stretch sm:self-auto justify-center"
>
<RefreshCw className="w-3.5 h-3.5" />
<span>Восстановить связь (Demo)</span>
<span>Восстановить связь</span>
</button>
</div>
);

View file

@ -61,167 +61,171 @@ export const StatusBadge: React.FC<StatusBadgeProps> = ({ status, customLabel, s
// Platform & tenant
active: {
label: 'Активна',
className: 'bg-emerald-950/80 text-emerald-400 border-emerald-700/50',
className: 'bg-status-success-soft text-status-success-text border-status-success-border',
icon: <CheckCircle2 className={iconSize} aria-hidden="true" />,
},
trial: {
label: 'Trial (Пробный)',
className: 'bg-amber-950/80 text-amber-300 border-amber-700/50',
label: 'Пробный период',
className: 'bg-status-info-soft text-status-info-text border-status-info-border',
icon: <Clock className={iconSize} aria-hidden="true" />,
},
grace: {
label: 'Grace Period',
className: 'bg-orange-950/80 text-orange-400 border-orange-700/50',
label: 'Льготный период',
className: 'bg-status-warning-soft text-status-warning-text border-status-warning-border',
icon: <AlertTriangle className={iconSize} aria-hidden="true" />,
},
suspended: {
label: 'Приостановлена',
className: 'bg-rose-950/80 text-rose-400 border-rose-700/50',
className: 'bg-status-danger-soft text-status-danger-text border-status-danger-border',
icon: <XCircle className={iconSize} aria-hidden="true" />,
},
closed: {
label: 'Закрыта',
className: 'bg-zinc-900 text-zinc-400 border-zinc-700/50',
className: 'bg-krona-muted text-krona-secondary border-krona-border',
icon: <XCircle className={iconSize} aria-hidden="true" />,
},
normal: {
label: 'Оплачена',
className: 'bg-emerald-950/80 text-emerald-400 border-emerald-700/50',
className: 'bg-status-success-soft text-status-success-text border-status-success-border',
icon: <CheckCircle2 className={iconSize} aria-hidden="true" />,
},
overdue: {
label: 'Просрочена',
className: 'bg-rose-950/80 text-rose-400 border-rose-700/50',
className: 'bg-status-danger-soft text-status-danger-text border-status-danger-border',
icon: <XCircle className={iconSize} aria-hidden="true" />,
},
expiring_7d: {
label: 'Истекает (< 7 дн.)',
className: 'bg-amber-950/80 text-amber-300 border-amber-700/50 font-semibold',
className: 'bg-status-warning-soft text-status-warning-text border-status-warning-border font-semibold',
icon: <Clock className={iconSize} aria-hidden="true" />,
},
expiring_30d: {
label: 'Истекает (< 30 дн.)',
className: 'bg-sky-950/80 text-sky-300 border-sky-700/50',
className: 'bg-status-info-soft text-status-info-text border-status-info-border',
icon: <Clock className={iconSize} aria-hidden="true" />,
},
// Health
healthy: {
label: 'В норме',
className: 'bg-emerald-950/80 text-emerald-400 border-emerald-700/50',
label: 'Работает нормально',
className: 'bg-status-success-soft text-status-success-text border-status-success-border',
icon: <CheckCircle2 className={iconSize} aria-hidden="true" />,
},
degraded: {
label: 'Деградация',
className: 'bg-amber-950/80 text-amber-400 border-amber-700/50',
label: 'Работает с ограничениями',
className: 'bg-status-warning-soft text-status-warning-text border-status-warning-border',
icon: <AlertTriangle className={iconSize} aria-hidden="true" />,
},
warning: {
label: 'Предупреждение',
className: 'bg-amber-950/80 text-amber-400 border-amber-700/50',
className: 'bg-status-warning-soft text-status-warning-text border-status-warning-border',
icon: <AlertTriangle className={iconSize} aria-hidden="true" />,
},
critical: {
label: 'Критический',
className: 'bg-red-950/80 text-red-400 border-red-700/50 font-semibold animate-pulse',
label: 'Критическое состояние',
className: 'bg-status-danger-soft text-status-danger-text border-status-danger-border font-semibold animate-pulse',
icon: <ShieldAlert className={iconSize} aria-hidden="true" />,
},
offline: {
label: 'Offline / Сбой',
className: 'bg-zinc-900 text-zinc-400 border-zinc-700/60',
label: 'Недоступен',
className: 'bg-status-danger-soft text-status-danger-text border-status-danger-border',
icon: <Radio className={iconSize} aria-hidden="true" />,
},
unknown: {
label: 'Неизвестно',
className: 'bg-zinc-900 text-zinc-500 border-zinc-800',
className: 'bg-krona-muted text-krona-secondary border-krona-border',
icon: <Radio className={iconSize} aria-hidden="true" />,
},
// Installations
supported: {
label: 'Поддерживается',
className: 'bg-emerald-950/80 text-emerald-400 border-emerald-700/50',
className: 'bg-status-success-soft text-status-success-text border-status-success-border',
icon: <CheckCircle2 className={iconSize} aria-hidden="true" />,
},
outdated: {
label: 'Устарела',
className: 'bg-amber-950/80 text-amber-300 border-amber-700/50',
className: 'bg-status-warning-soft text-status-warning-text border-status-warning-border',
icon: <Smartphone className={iconSize} aria-hidden="true" />,
},
blocked: {
label: 'Заблокирована',
className: 'bg-rose-950/80 text-rose-400 border-rose-700/50',
className: 'bg-status-danger-soft text-status-danger-text border-status-danger-border',
icon: <XCircle className={iconSize} aria-hidden="true" />,
},
revoked: {
label: 'Отозвана',
className: 'bg-zinc-900 text-zinc-400 border-zinc-700',
className: 'bg-status-danger-soft text-status-danger-text border-status-danger-border',
icon: <XCircle className={iconSize} aria-hidden="true" />,
},
// Rollout
draft: {
label: 'Черновик',
className: 'bg-zinc-900 text-zinc-400 border-zinc-700',
icon: <Clock className={iconSize} aria-hidden="true" />,
},
// Modules
pilot: {
label: 'Пилот (Pilot)',
className: 'bg-indigo-950/80 text-indigo-300 border-indigo-700/50',
className: 'bg-status-info-soft text-status-info-text border-status-info-border',
icon: <Zap className={iconSize} aria-hidden="true" />,
},
draft: {
label: 'Черновик (Draft)',
className: 'bg-krona-muted text-krona-secondary border-krona-border',
icon: <Clock className={iconSize} aria-hidden="true" />,
},
percentage: {
label: 'Поэтапная',
className: 'bg-sky-950/80 text-sky-300 border-sky-700/50',
icon: <Server className={iconSize} aria-hidden="true" />,
label: 'Поэтапный релиз',
className: 'bg-status-warning-soft text-status-warning-text border-status-warning-border',
icon: <Zap className={iconSize} aria-hidden="true" />,
},
general_availability: {
label: 'Релиз (GA)',
className: 'bg-emerald-950/80 text-emerald-400 border-emerald-700/50',
label: 'Общая доступность (GA)',
className: 'bg-status-success-soft text-status-success-text border-status-success-border',
icon: <CheckCircle2 className={iconSize} aria-hidden="true" />,
},
paused: {
label: 'На паузе',
className: 'bg-amber-950/80 text-amber-300 border-amber-700/50',
label: 'Приостановлен',
className: 'bg-status-warning-soft text-status-warning-text border-status-warning-border',
icon: <AlertTriangle className={iconSize} aria-hidden="true" />,
},
rolled_back: {
label: 'Откатан (Rollback)',
className: 'bg-rose-950/80 text-rose-400 border-rose-700/50',
label: 'Отозван (Rollback)',
className: 'bg-status-danger-soft text-status-danger-text border-status-danger-border',
icon: <XCircle className={iconSize} aria-hidden="true" />,
},
// Incidents
open: {
label: 'Открыт',
className: 'bg-rose-950/80 text-rose-400 border-rose-700/50 font-semibold',
icon: <AlertTriangle className={iconSize} aria-hidden="true" />,
className: 'bg-status-danger-soft text-status-danger-text border-status-danger-border font-semibold',
icon: <ShieldAlert className={iconSize} aria-hidden="true" />,
},
mitigating: {
label: 'Митигация',
className: 'bg-amber-950/80 text-amber-300 border-amber-700/50',
icon: <Clock className={iconSize} aria-hidden="true" />,
label: 'Устраняется',
className: 'bg-status-warning-soft text-status-warning-text border-status-warning-border',
icon: <Server className={iconSize} aria-hidden="true" />,
},
resolved: {
label: 'Разрешен',
className: 'bg-emerald-950/80 text-emerald-400 border-emerald-700/50',
label: 'Устранён',
className: 'bg-status-success-soft text-status-success-text border-status-success-border',
icon: <CheckCircle2 className={iconSize} aria-hidden="true" />,
},
};
const config = configs[status] || configs.unknown;
const label = customLabel || config.label;
const current = configs[status] || {
label: status,
className: 'bg-krona-muted text-krona-secondary border-krona-border',
icon: <Radio className={iconSize} aria-hidden="true" />,
};
const displayText = customLabel || current.label;
return (
<span
className={`inline-flex items-center gap-1.5 rounded-md border font-medium tabular-nums tracking-wide transition-colors ${
isSmall ? 'px-2 py-0.5 text-xs' : 'px-2.5 py-1 text-xs'
} ${config.className}`}
data-status={status}
data-testid="status-badge"
className={`inline-flex items-center gap-1.5 rounded-full border px-2.5 py-0.5 font-medium transition-colors ${
isSmall ? 'text-[11px] py-0.5 px-2' : 'text-xs'
} ${current.className}`}
data-testid={`status-badge-${status}`}
>
{config.icon}
<span>{label}</span>
{current.icon}
<span>{displayText}</span>
</span>
);
};

View file

@ -0,0 +1,180 @@
import type { OperatorRole, ServerStatus, SubscriptionStatus, IncidentSeverity, IncidentStatus, AuditActionType } from '../contracts';
export const RU_ROLES: Record<OperatorRole, { label: string; shortLabel: string; description: string }> = {
platform_owner: {
label: 'Владелец платформы',
shortLabel: 'Владелец',
description: 'Полный доступ ко всей инфраструктуре, биллингу, лицензиям и управлению платформой',
},
platform_admin: {
label: 'Администратор платформы',
shortLabel: 'Админ',
description: 'Управление организациями, инсталляциями, серверами и техническими параметрами',
},
support_operator: {
label: 'Оператор поддержки',
shortLabel: 'Поддержка',
description: 'Диагностика организаций, сброс кэша, техническая помощь и расследования',
},
billing_manager: {
label: 'Менеджер биллинга',
shortLabel: 'Биллинг',
description: 'Финансовые показатели, тарифы, подписки, продления и льготные периоды',
},
security_auditor: {
label: 'Аудитор безопасности',
shortLabel: 'Аудитор',
description: 'Только чтение: сквозной журнал аудита (SHA-256), события безопасности и логи',
},
release_manager: {
label: 'Релиз-инженер',
shortLabel: 'Релизы',
description: 'Управление версиями модулей, каталогом расширений и плановыми обновлениями',
},
};
export const RU_SERVER_STATUS: Record<ServerStatus, { label: string; badgeVariant: 'success' | 'warning' | 'danger' | 'neutral' }> = {
healthy: { label: 'Работает нормально', badgeVariant: 'success' },
degraded: { label: 'Работает с ограничениями', badgeVariant: 'warning' },
offline: { label: 'Недоступен', badgeVariant: 'danger' },
};
export const RU_SUBSCRIPTION_STATUS: Record<SubscriptionStatus, { label: string; badgeVariant: 'success' | 'warning' | 'danger' | 'info' | 'neutral' }> = {
trial: { label: 'Пробный период', badgeVariant: 'info' },
active: { label: 'Активна', badgeVariant: 'success' },
grace: { label: 'Льготный период', badgeVariant: 'warning' },
suspended: { label: 'Приостановлена', badgeVariant: 'danger' },
canceled: { label: 'Отменена', badgeVariant: 'neutral' },
};
export const RU_INCIDENT_SEVERITY: Record<IncidentSeverity, { label: string; short: string }> = {
P0_CRITICAL: { label: 'P0 Критическая авария', short: 'P0 Критическая' },
P1_MAJOR: { label: 'P1 Серьёзный инцидент', short: 'P1 Серьёзный' },
P2_DEGRADED: { label: 'P2 Ограничение работы', short: 'P2 Деградация' },
P3_INFO: { label: 'P3 Информационный', short: 'P3 Инфо' },
};
export const RU_INCIDENT_STATUS: Record<IncidentStatus, string> = {
investigating: 'Расследуется',
identified: 'Локализован',
monitoring: 'Мониторинг устранения',
resolved: 'Устранён',
};
export const RU_AUDIT_ACTION: Record<AuditActionType, string> = {
suspend_tenant: 'Приостановка организации',
resume_tenant: 'Возобновление организации',
drain_node: 'Вывод сервера на обслуживание (Drain)',
enable_maintenance: 'Включение режима тех. обслуживания',
disable_maintenance: 'Выключение режима тех. обслуживания',
grant_grace_period: 'Предоставление льготного периода',
restart_gateway: 'Перезапуск шлюза обмена',
invalidate_cache: 'Инвалидация распределённого кэша',
rollback_module: 'Откат версии модуля',
circuit_breaker_trip: 'Аварийное срабатывание Circuit Breaker',
circuit_breaker_reset: 'Восстановление связи мониторинга',
};
export const RU_MODULE_CATEGORY: Record<string, string> = {
core: 'Базовые модули',
trade: 'Торговля и продажи',
kitchen: 'Кухня и производство',
finance: 'Финансы и учёт',
management: 'Управление персоналом',
analytics: 'Аналитика и AI',
};
export const RU_PLATFORMS: Record<string, string> = {
web: 'Веб-версия',
desktop: 'Компьютеры (Desktop)',
android: 'Android-устройства',
};
export const RU_THEMES = {
'soft-light': 'Мягкая светлая',
light: 'Классическая светлая',
dark: 'Тёмная',
} as const;
export const RU_ROLLOUT_STAGE: Record<string, string> = {
draft: 'Черновик',
pilot: 'Пилот (15%)',
percentage: 'Раскатка (50%)',
general_availability: 'Полный релиз (100%)',
paused: 'На паузе',
rolled_back: 'Откат',
};
export const RU_INSTALLATION_STATUS: Record<string, string> = {
supported: 'Поддерживается',
outdated: 'Устарела',
blocked: 'Заблокирована',
revoked: 'Отозвана',
};
export const RU_ANNOUNCEMENT_PRIORITY: Record<string, string> = {
normal: 'Обычный',
important: 'Важный',
critical: 'Критический',
};
export const RU_UI = {
brand: 'Krona Control Center',
tagline: 'Кабинет оператора платформы',
scope: 'Контур платформы',
productionScope: 'Боевой контур (Production)',
gateTitle: 'Защита доступа оператора',
gateSubtitle: 'Демонстрационный стенд закрытого кабинета оператора платформы',
gatePinPlaceholder: 'Введите код доступа (например: krona2026 или 8492)...',
gateSubmit: 'Войти в консоль оператора',
gateDemoLogin: 'Быстрый демо-вход в 1 клик (Режим ревьюера)',
gateError: 'Неверный код доступа. Попробуйте krona2026 или 8492.',
forbiddenTitle: 'Доступ ограничен политикой безопасности',
forbiddenDescription: 'У текущей роли оператора недостаточно прав для выполнения этого действия.',
forbiddenBack: 'Вернуться в Обзор',
nav: {
overview: 'Обзор',
organizations: 'Организации',
incidents: 'Инциденты',
servers: 'Серверы',
more: 'Ещё',
billing: 'Подписки и платежи',
modules: 'Каталог модулей',
installations: 'Установки',
announcements: 'Оповещения',
audit: 'Журнал аудита',
settings: 'Настройки оператора',
},
overviewTabs: {
attention: 'Требует внимания',
metrics: 'Показатели',
infrastructure: 'Инфраструктура',
},
filters: {
title: 'Фильтры',
apply: 'Применить',
reset: 'Сбросить фильтры',
searchPlaceholder: 'Поиск...',
statusAll: 'Все статусы',
roleAll: 'Все роли',
},
inspector: {
title: 'Инспектор объекта 360°',
tabOverview: 'Сводка',
tabModules: 'Модули',
tabInstallations: 'Установки',
tabBilling: 'Биллинг',
close: 'Закрыть',
},
system: {
synced: 'Синхронизировано',
syncing: 'Синхронизация...',
nodeLabel: 'Узел',
resetDemo: 'Сбросить демоданные',
simulateOutage: 'Имитировать сбой мониторинга',
restoreMonitoring: 'Восстановить мониторинг',
themeLabel: 'Тема оформления',
exitToClient: 'Перейти в Krona Business OS',
operatorProfile: 'Профиль оператора',
},
};

View file

@ -3,6 +3,10 @@ import {
CreditCard,
RefreshCw,
SlidersHorizontal,
Calendar,
CheckCircle2,
Clock,
ShieldAlert,
} from 'lucide-react';
import { useKccStore, useOperatorRole } from '../store/kccStore';
import { StatusBadge } from '../components/StatusBadge';
@ -14,6 +18,7 @@ export const BillingPage: React.FC = () => {
const renewSubscription = useKccStore((state) => state.renewSubscription);
const grantGracePeriod = useKccStore((state) => state.grantGracePeriod);
const openConfirmModal = useKccStore((state) => state.openConfirmModal);
const openInspector = useKccStore((state) => state.openInspector);
const { permissions } = useOperatorRole();
const [filter, setFilter] = useState<'all' | 'expiring_7d' | 'expiring_30d' | 'overdue' | 'grace'>('all');
@ -21,7 +26,7 @@ export const BillingPage: React.FC = () => {
const selectedSub = subscriptions.find((s) => s.subscriptionId === selectedSubId) || subscriptions[0];
// Form state
// Form state for renewal calculator
const [newPeriod, setNewPeriod] = useState<'month' | 'year'>('year');
const [customModules, setCustomModules] = useState<string[] | null>(null);
@ -33,20 +38,20 @@ export const BillingPage: React.FC = () => {
});
// Calculate live preview amount
const baseRatePerMonth = 1500000; // 15 000,00 ₽ базовый
const moduleRatePerMonth = 400000; // 4 000,00 ₽ за модуль
const baseRatePerMonth = 1500000; // 15 000,00 ₽
const moduleRatePerMonth = 400000; // 4 000,00 ₽
const monthlyTotal = baseRatePerMonth + activeModulesToDisplay.length * moduleRatePerMonth;
const calculatedTotal = newPeriod === 'year' ? monthlyTotal * 10 : monthlyTotal;
const handleRenew = () => {
if (!selectedSub) return;
openConfirmModal({
title: `Продление подписки: ${selectedSub.tenantName}`,
actionName: `subscription.renew (${newPeriod})`,
impactSummary: `Подписка будет продлена на ${newPeriod === 'year' ? '1 год' : '1 месяц'}. Состав модулей (${activeModulesToDisplay.length}): ${activeModulesToDisplay.join(', ')}. Расчетная сумма: ${calculatedTotal / 100} ₽.`,
title: `Продление подписки: ${selectedSub.organizationName}`,
actionName: 'grant_grace_period',
impactSummary: `Подписка будет продлена на ${newPeriod === 'year' ? '1 год' : '1 месяц'}. Состав модулей: ${activeModulesToDisplay.join(', ')}. Расчётная сумма: ${calculatedTotal / 100} ₽.`,
targetType: 'subscription',
targetId: selectedSub.subscriptionId,
targetName: selectedSub.tenantName,
targetName: selectedSub.organizationName,
onConfirm: async (reason) => {
await renewSubscription(selectedSub.subscriptionId, newPeriod, activeModulesToDisplay, reason);
},
@ -56,217 +61,222 @@ export const BillingPage: React.FC = () => {
const handleGrantGrace = () => {
if (!selectedSub) return;
openConfirmModal({
title: `Предоставление Grace Period: ${selectedSub.tenantName}`,
actionName: 'subscription.grace_period_granted',
impactSummary: `Организации будет предоставлена отсрочка блокировки на 10 дней без остановки сервисов.`,
title: `Предоставление льготного периода: ${selectedSub.organizationName}`,
actionName: 'grant_grace_period',
impactSummary: `Организации будет предоставлен льготный период (Grace Period) на 14 дней с сохранением доступа ко всем модулям.`,
targetType: 'subscription',
targetId: selectedSub.subscriptionId,
targetName: selectedSub.tenantName,
targetName: selectedSub.organizationName,
onConfirm: async (reason) => {
await grantGracePeriod(selectedSub.subscriptionId, 10, reason);
await grantGracePeriod(selectedSub.subscriptionId, 14, reason);
},
});
};
const toggleModuleSelection = (moduleCode: string) => {
if (activeModulesToDisplay.includes(moduleCode)) {
setCustomModules(activeModulesToDisplay.filter((m) => m !== moduleCode));
} else {
setCustomModules([...activeModulesToDisplay, moduleCode]);
}
};
return (
<div className="space-y-6">
<div className="space-y-4 sm:space-y-6" data-testid="billing-page">
{/* Header */}
<div className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-3 border-b border-zinc-800 pb-4">
<div className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-2.5 border-b border-krona-border pb-4">
<div>
<h1 className="text-xl font-bold text-zinc-100 flex items-center gap-2">
<CreditCard className="w-5 h-5 text-amber-400" />
<span>Подписки, лицензии и Entitlements</span>
<h1 className="text-lg sm:text-xl font-bold text-krona-primary flex items-center gap-2">
<CreditCard className="w-5 h-5 text-krona-gold" />
<span>Подписки и биллинг платформы</span>
</h1>
<p className="text-xs text-zinc-400 mt-0.5">
Управление правами доступа, сроками действия тарифов и составом включенных модулей
<p className="text-xs text-krona-secondary mt-0.5">
Контроль сроков действия лицензий, предоставление льготных периодов и продление тарифов
</p>
</div>
{/* Filters */}
<div className="flex items-center gap-1.5 overflow-x-auto pb-1 text-xs">
{[
{ id: 'all', label: 'Все' },
{ id: 'expiring_7d', label: 'Истекает 7 дней' },
{ id: 'expiring_30d', label: 'Истекает 30 дней' },
{ id: 'grace', label: 'Grace Period' },
{ id: 'suspended', label: 'Просрочено' },
].map((tab) => (
<button
key={tab.id}
onClick={() => setFilter(tab.id as typeof filter)}
className={`px-3 py-1.5 rounded-lg font-medium transition-colors ${
filter === tab.id
? 'bg-amber-500/20 text-amber-300 border border-amber-500/40 font-semibold'
: 'bg-zinc-900 text-zinc-400 border border-zinc-800 hover:text-zinc-200 hover:bg-zinc-800'
}`}
>
{tab.label}
</button>
))}
<div className="text-xs text-krona-secondary">
Всего подписок: <span className="font-bold text-krona-primary">{subscriptions.length}</span> (найдено:{' '}
<span className="font-bold text-krona-gold">{filteredSubs.length}</span>)
</div>
</div>
{/* Main Grid: Subscriptions Table + Simulator Panel */}
<div className="grid grid-cols-1 xl:grid-cols-3 gap-6">
{/* Left 2 Columns: Subscriptions List */}
<div className="xl:col-span-2 rounded-xl border border-zinc-800 bg-[#091a13] overflow-hidden shadow-xl">
<div className="overflow-x-auto">
<table className="w-full text-left text-xs border-collapse">
<thead>
<tr className="border-b border-zinc-800 bg-zinc-950/70 text-zinc-400 font-semibold uppercase tracking-wider text-[11px]">
<th className="py-3 px-4">Организация / Счёт</th>
<th className="py-3 px-3">Статус</th>
<th className="py-3 px-3">Период</th>
<th className="py-3 px-3 text-right">Сумма тарифа</th>
<th className="py-3 px-3">Срок действия</th>
<th className="py-3 px-3">Grace</th>
<th className="py-3 px-4 text-center">Выбор</th>
</tr>
</thead>
<tbody className="divide-y divide-zinc-800/60">
{filteredSubs.map((sub) => {
const isSelected = sub.subscriptionId === (selectedSub?.subscriptionId ?? '');
return (
<tr
key={sub.subscriptionId}
onClick={() => {
setSelectedSubId(sub.subscriptionId);
setCustomModules(null);
setNewPeriod(sub.period);
}}
className={`cursor-pointer transition-colors ${
isSelected ? 'bg-amber-500/10 border-l-2 border-amber-400' : 'hover:bg-zinc-800/40'
}`}
>
<td className="py-3 px-4">
<div className="font-bold text-zinc-100">{sub.tenantName}</div>
<div className="text-[11px] text-zinc-400 font-mono mt-0.5">{sub.billingAccountRef}</div>
</td>
<td className="py-3 px-3">
<StatusBadge status={sub.status} size="sm" />
</td>
<td className="py-3 px-3 text-zinc-300 font-medium">
{sub.period === 'year' ? 'Годовой' : 'Месячный'}
</td>
<td className="py-3 px-3 text-right">
<MoneyDisplay minorUnits={sub.amountMinorUnits} className="text-amber-400 font-bold" />
</td>
<td className="py-3 px-3 text-zinc-300 font-mono text-[11px]">
до {new Date(sub.validUntil).toLocaleDateString('ru-RU')}
</td>
<td className="py-3 px-3 text-zinc-400 text-[11px]">
{sub.graceUntil ? new Date(sub.graceUntil).toLocaleDateString('ru-RU') : '—'}
</td>
<td className="py-3 px-4 text-center">
<input
type="radio"
name="selected_sub"
checked={isSelected}
onChange={() => {
setSelectedSubId(sub.subscriptionId);
setCustomModules(null);
setNewPeriod(sub.period);
}}
className="accent-amber-400 cursor-pointer"
/>
</td>
</tr>
);
})}
</tbody>
</table>
</div>
{/* Filter Tabs */}
<div className="flex items-center gap-1.5 overflow-x-auto pb-1 text-xs">
{[
{ id: 'all', label: 'Все подписки' },
{ id: 'expiring_7d', label: 'Истекают (< 7 дн.)' },
{ id: 'expiring_30d', label: 'Истекают (< 30 дн.)' },
{ id: 'grace', label: 'Льготный период' },
{ id: 'overdue', label: 'Просроченные' },
].map((tab) => (
<button
key={tab.id}
type="button"
onClick={() => setFilter(tab.id as typeof filter)}
className={`min-h-[44px] px-3.5 py-2 rounded-lg font-medium transition-colors shrink-0 ${
filter === tab.id
? 'bg-krona-gold/15 text-krona-gold border border-krona-gold/30 font-bold'
: 'bg-krona-surface text-krona-secondary border border-krona-border hover:text-krona-primary hover:bg-krona-hover'
}`}
>
{tab.label}
</button>
))}
</div>
{/* Two Column Layout: List on Left, Calculator on Right */}
<div className="grid grid-cols-1 lg:grid-cols-12 gap-4">
{/* Left Column: Subscriptions List */}
<div className="lg:col-span-7 space-y-3">
{filteredSubs.map((sub) => {
const isSelected = sub.subscriptionId === selectedSub?.subscriptionId;
const validUntilDate = new Date(sub.validUntil);
return (
<div
key={sub.subscriptionId}
onClick={() => {
setSelectedSubId(sub.subscriptionId);
setCustomModules(null);
}}
className={`p-4 rounded-xl border transition-all cursor-pointer space-y-3 ${
isSelected
? 'border-krona-gold bg-krona-surface shadow-md ring-1 ring-krona-gold/30'
: 'border-krona-border bg-krona-surface/60 hover:bg-krona-surface hover:border-krona-border'
}`}
data-testid={`subscription-card-${sub.subscriptionId}`}
>
<div className="flex items-start justify-between gap-2">
<div>
<h3 className="font-bold text-sm text-krona-primary">{sub.organizationName}</h3>
<div className="text-[11px] text-krona-tertiary mt-0.5">
Тарифный план: <span className="font-semibold text-krona-primary">{sub.planName}</span>
</div>
</div>
<StatusBadge status={sub.status} size="sm" />
</div>
<div className="grid grid-cols-2 gap-2 text-xs py-2 border-y border-krona-border">
<div>
<div className="text-[10px] text-krona-tertiary">Стоимость в месяц</div>
<MoneyDisplay minorUnits={sub.priceMinorUnits} currency={sub.currency} className="font-bold text-krona-gold text-xs" />
</div>
<div>
<div className="text-[10px] text-krona-tertiary">Действует до</div>
<div className="font-bold text-krona-primary flex items-center gap-1">
<Calendar className="w-3.5 h-3.5 text-krona-gold" />
<span>{validUntilDate.toLocaleDateString('ru-RU')}</span>
</div>
</div>
</div>
<div className="flex items-center justify-between pt-1">
<div className="text-[11px] text-krona-secondary">
Подключено модулей: <span className="font-semibold text-krona-primary">{sub.activeModules.length}</span>
</div>
<button
type="button"
onClick={(e) => {
e.stopPropagation();
openInspector({ type: 'subscription', id: sub.subscriptionId, title: sub.organizationName, data: sub });
}}
className="min-h-[44px] px-3 text-xs font-semibold text-krona-gold hover:underline flex items-center gap-1"
>
<span>360° Инспектор</span>
</button>
</div>
</div>
);
})}
{filteredSubs.length === 0 && (
<div className="p-8 text-center text-krona-tertiary rounded-xl border border-krona-border bg-krona-surface text-xs">
Подписки не найдены по выбранным критериям
</div>
)}
</div>
{/* Right Column: Live Renewal & Module Composition Simulator */}
<div className="space-y-6">
{/* Right Column: Calculator & Renewal Actions */}
<div className="lg:col-span-5">
{selectedSub ? (
<div className="rounded-xl border border-zinc-800 bg-[#0a1c15] p-5 space-y-4 shadow-xl">
<div className="flex items-center gap-2 text-sm font-bold text-zinc-100">
<SlidersHorizontal className="w-4 h-4 text-amber-400" />
<span>Продление и состав модулей (Preview)</span>
<div className="p-5 rounded-xl border border-krona-border bg-krona-surface space-y-4 sticky top-20 shadow-sm">
<div className="border-b border-krona-border pb-3">
<div className="text-[11px] font-semibold text-krona-gold uppercase tracking-wider">
Управление подпиской
</div>
<h2 className="text-base font-bold text-krona-primary mt-0.5">{selectedSub.organizationName}</h2>
<div className="text-xs text-krona-secondary mt-0.5">
Текущий статус: <StatusBadge status={selectedSub.status} size="sm" />
</div>
</div>
<div className="p-3 rounded-lg bg-zinc-950/70 border border-zinc-800 text-xs space-y-1">
<div className="text-zinc-400">Организация:</div>
<div className="font-bold text-zinc-200 text-sm">{selectedSub.tenantName}</div>
<div className="text-[11px] text-zinc-500 font-mono">{selectedSub.billingAccountRef}</div>
</div>
{/* Period selection */}
<div className="space-y-1.5 text-xs">
<label className="font-semibold text-zinc-300">Период действия тарифа:</label>
{/* Period Switcher */}
<div className="space-y-1.5">
<label className="block text-xs font-semibold text-krona-secondary">
Период продления лицензии
</label>
<div className="grid grid-cols-2 gap-2">
<button
type="button"
onClick={() => setNewPeriod('month')}
className={`py-2 px-3 rounded-lg font-medium border text-xs transition-colors ${
className={`min-h-[44px] py-2 px-3 rounded-lg text-xs font-semibold border transition-colors ${
newPeriod === 'month'
? 'bg-amber-500/20 text-amber-300 border-amber-500/50 font-bold'
: 'bg-zinc-900 text-zinc-400 border-zinc-800 hover:bg-zinc-800'
? 'border-krona-gold bg-krona-gold/15 text-krona-gold'
: 'border-krona-border bg-krona-workspace text-krona-secondary hover:text-krona-primary'
}`}
>
1 месяц
1 месяц (Базовый)
</button>
<button
type="button"
onClick={() => setNewPeriod('year')}
className={`py-2 px-3 rounded-lg font-medium border text-xs transition-colors ${
className={`min-h-[44px] py-2 px-3 rounded-lg text-xs font-semibold border transition-colors ${
newPeriod === 'year'
? 'bg-amber-500/20 text-amber-300 border-amber-500/50 font-bold'
: 'bg-zinc-900 text-zinc-400 border-zinc-800 hover:bg-zinc-800'
? 'border-krona-gold bg-krona-gold/15 text-krona-gold'
: 'border-krona-border bg-krona-workspace text-krona-secondary hover:text-krona-primary'
}`}
>
1 год (скидка 17%)
1 год (Скидка 16%)
</button>
</div>
</div>
{/* Modules selection */}
<div className="space-y-2 text-xs">
<div className="flex justify-between items-center font-semibold text-zinc-300">
<span>Включенные модули:</span>
<span className="text-amber-400 font-mono">{activeModulesToDisplay.length} выбрано</span>
</div>
<div className="space-y-1.5 max-h-48 overflow-y-auto pr-1">
{modules.map((m) => {
const isChecked = activeModulesToDisplay.includes(m.moduleId);
const isCore = m.moduleId === 'krona-core';
{/* Included Modules Checklist */}
<div className="space-y-2">
<label className="block text-xs font-semibold text-krona-secondary">
Состав модулей ({activeModulesToDisplay.length})
</label>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-1.5 max-h-48 overflow-y-auto p-1">
{modules.map((mod) => {
const isChecked = activeModulesToDisplay.includes(mod.code);
return (
<label
key={m.moduleId}
className={`flex items-center justify-between p-2 rounded-lg border text-xs cursor-pointer transition-colors ${
isChecked ? 'bg-zinc-900/90 border-zinc-700 text-zinc-100' : 'bg-zinc-950/40 border-zinc-800/60 text-zinc-500'
<button
key={mod.code}
type="button"
onClick={() => toggleModuleSelection(mod.code)}
className={`min-h-[40px] px-2.5 py-1.5 rounded-lg text-xs border text-left flex items-center justify-between transition-colors ${
isChecked
? 'border-krona-gold/40 bg-krona-gold/10 text-krona-primary'
: 'border-krona-border bg-krona-workspace text-krona-tertiary hover:text-krona-secondary'
}`}
>
<div className="flex items-center gap-2">
<input
type="checkbox"
checked={isChecked}
disabled={isCore}
onChange={(e) => {
if (e.target.checked) setCustomModules([...activeModulesToDisplay, m.moduleId]);
else setCustomModules(activeModulesToDisplay.filter((id) => id !== m.moduleId));
}}
className="accent-amber-400"
/>
<span className="font-medium">{m.name}</span>
</div>
<span className="text-[10px] text-zinc-400 font-mono">v{m.latestVersion}</span>
</label>
<span className="truncate">{mod.name}</span>
{isChecked && <CheckCircle2 className="w-3.5 h-3.5 text-krona-gold shrink-0 ml-1" />}
</button>
);
})}
</div>
</div>
{/* Price calculation preview */}
<div className="p-3.5 rounded-xl bg-zinc-950 border border-amber-500/30 text-xs space-y-2">
<div className="flex justify-between text-zinc-400">
<span>Расчетная сумма (Preview):</span>
<MoneyDisplay minorUnits={calculatedTotal} className="text-sm font-bold text-amber-400" />
{/* Total Calculation */}
<div className="p-3.5 rounded-xl bg-krona-workspace border border-krona-border flex items-center justify-between">
<div>
<div className="text-[11px] text-krona-tertiary">Расчётная стоимость продления</div>
<MoneyDisplay minorUnits={calculatedTotal} currency={selectedSub.currency} className="text-lg font-bold text-krona-gold" />
</div>
<div className="text-[10px] text-zinc-500">
* Демо-расчет стоимости лицензии (minor units RUB). Реальные транзакции не производятся.
<div className="text-right text-[11px] text-krona-secondary">
{newPeriod === 'year' ? 'За 12 месяцев' : 'За 1 месяц'}
</div>
</div>
@ -274,43 +284,28 @@ export const BillingPage: React.FC = () => {
<div className="space-y-2 pt-2">
<button
type="button"
disabled={!permissions.canEditEntitlements}
onClick={handleRenew}
className="w-full py-2 px-4 rounded-lg bg-amber-500 hover:bg-amber-400 text-zinc-950 font-bold text-xs transition-colors flex items-center justify-center gap-1.5 shadow-md shadow-amber-900/30 disabled:opacity-40"
className="w-full min-h-[44px] px-4 py-2.5 rounded-lg bg-krona-gold hover:bg-krona-gold-hover text-zinc-950 font-bold text-xs flex items-center justify-center gap-2 transition-colors shadow-sm"
data-testid="renew-subscription-btn"
>
<RefreshCw className="w-3.5 h-3.5" />
<span>Продлить тариф с новым составом</span>
<RefreshCw className="w-4 h-4" />
<span>Продлить подписку (Выставить счёт)</span>
</button>
<button
type="button"
disabled={!permissions.canGrantTrialGrace}
onClick={handleGrantGrace}
className="w-full py-2 px-4 rounded-lg bg-zinc-900 border border-zinc-700 hover:bg-zinc-800 text-zinc-300 font-medium text-xs transition-colors disabled:opacity-40"
className="w-full min-h-[44px] px-4 py-2 rounded-lg border border-status-warning-border bg-status-warning-soft text-status-warning-text font-semibold text-xs flex items-center justify-center gap-2 transition-colors"
data-testid="grant-grace-btn"
>
Предоставить льготу (Grace 10 дней)
<Clock className="w-4 h-4" />
<span>Предоставить льготный период (14 дн.)</span>
</button>
</div>
{/* Subscription Timeline History */}
<div className="pt-3 border-t border-zinc-800 space-y-2 text-xs">
<div className="font-semibold text-zinc-300">История изменений подписки:</div>
<div className="space-y-1.5 text-[11px]">
{selectedSub.history.map((h, i) => (
<div key={i} className="p-2 rounded-md bg-zinc-950/60 border border-zinc-850">
<div className="flex justify-between text-zinc-400 text-[10px]">
<span className="font-bold text-zinc-300">{h.actor}</span>
<span>{new Date(h.eventAt).toLocaleDateString('ru-RU')}</span>
</div>
<div className="text-zinc-200 mt-0.5">{h.description}</div>
</div>
))}
</div>
</div>
</div>
) : (
<div className="p-8 rounded-xl border border-zinc-800 bg-[#0a1c15] text-center text-xs text-zinc-500">
Выберите подписку в таблице слева для управления
<div className="p-8 text-center text-krona-tertiary rounded-xl border border-krona-border bg-krona-surface text-xs">
Выберите организацию из списка для продления или изменения состава модулей
</div>
)}
</div>

View file

@ -2,7 +2,15 @@ import React, { useState } from 'react';
import {
Server,
Search,
PlusCircle,
ChevronDown,
ChevronUp,
ChevronRight,
ShieldAlert,
Activity,
HardDrive,
Cpu,
Clock,
ExternalLink,
} from 'lucide-react';
import { useKccStore, useOperatorRole } from '../store/kccStore';
import { StatusBadge } from '../components/StatusBadge';
@ -20,29 +28,37 @@ export const InfrastructurePage: React.FC = () => {
const [search, setSearch] = useState('');
const [providerFilter, setProviderFilter] = useState<'all' | 'Selectel' | 'Timeweb Cloud'>('all');
const [expandedDetails, setExpandedDetails] = useState<Record<string, boolean>>({});
const toggleDetails = (id: string, e: React.MouseEvent) => {
e.stopPropagation();
setExpandedDetails((prev) => ({ ...prev, [id]: !prev[id] }));
};
const filteredServers = servers.filter((srv) => {
const matchSearch =
!search ||
srv.name.toLowerCase().includes(search.toLowerCase()) ||
srv.ipAddress.includes(search) ||
srv.hostname.toLowerCase().includes(search.toLowerCase());
srv.hostname.toLowerCase().includes(search.toLowerCase()) ||
srv.organizationName.toLowerCase().includes(search.toLowerCase());
const matchProvider = providerFilter === 'all' || srv.hostingProvider === providerFilter;
return matchSearch && matchProvider;
});
const handleCreateIncident = (srv: ServerNode) => {
const handleCreateIncident = (srv: ServerNode, e: React.MouseEvent) => {
e.stopPropagation();
openConfirmModal({
title: `Создать инцидент для узла: ${srv.name}`,
actionName: 'incident.create_from_alert',
impactSummary: `Будет открыт критический операторский инцидент, затронутые организации получат оповещение о расследовании дежурной сменой.`,
targetType: 'infrastructure',
title: `Создать инцидент для сервера: ${srv.name}`,
actionName: 'circuit_breaker_trip',
impactSummary: `Будет открыт критический инцидент платформы, затронутые организации получат уведомление о расследовании.`,
targetType: 'server',
targetId: srv.serverId,
targetName: srv.name,
onConfirm: async (reason) => {
await createIncidentFromAlert(
srv.serverId,
`Критический сбой узла ${srv.name} (${srv.status.toUpperCase()} alert)`,
`Сбой сервера ${srv.name} (${srv.status})`,
'critical',
reason
);
@ -50,12 +66,13 @@ export const InfrastructurePage: React.FC = () => {
});
};
const handleDiagnostics = (srv: ServerNode) => {
const handleDiagnostics = (srv: ServerNode, e: React.MouseEvent) => {
e.stopPropagation();
openConfirmModal({
title: `Запрос телеметрии: ${srv.name}`,
actionName: 'infrastructure.diagnostics_dump',
impactSummary: `Агенту Server Monitor Agent v${srv.agentVersion} будет направлен запрос выгрузки снапшота ресурсов.`,
targetType: 'infrastructure',
actionName: 'invalidate_cache',
impactSummary: `Агенту мониторинга v${srv.agentVersion} будет направлен запрос выгрузки моментального снимка состояния.`,
targetType: 'server',
targetId: srv.serverId,
targetName: srv.name,
onConfirm: async (reason) => {
@ -65,41 +82,42 @@ export const InfrastructurePage: React.FC = () => {
};
return (
<div className="space-y-6">
<div className="space-y-4 sm:space-y-6" data-testid="infrastructure-page">
{/* Header */}
<div className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-3 border-b border-zinc-800 pb-4">
<div className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-2.5 border-b border-krona-border pb-4">
<div>
<h1 className="text-xl font-bold text-zinc-100 flex items-center gap-2">
<Server className="w-5 h-5 text-amber-400" />
<span>Инфраструктурные узлы (Server Monitor Manager)</span>
<h1 className="text-lg sm:text-xl font-bold text-krona-primary flex items-center gap-2">
<Server className="w-5 h-5 text-krona-gold" />
<span>Инфраструктурные серверы</span>
</h1>
<p className="text-xs text-zinc-400 mt-0.5">
Антикоррупционный адаптер Server Monitor: телеметрия VPS, сертификаты mTLS, бэкапы и инспекция узлов
<p className="text-xs text-krona-secondary mt-0.5">
Управление вычислительными узлами, телеметрией mTLS, бэкапами и нагрузкой
</p>
</div>
<div className="text-xs text-zinc-400">
Всего узлов: <span className="font-bold text-zinc-100">{servers.length}</span> (Selectel:{' '}
<span className="text-emerald-400 font-bold">{servers.filter((s) => s.hostingProvider === 'Selectel').length}</span>, Timeweb:{' '}
<span className="text-sky-400 font-bold">{servers.filter((s) => s.hostingProvider === 'Timeweb Cloud').length}</span>)
<div className="text-xs text-krona-secondary">
Всего узлов: <span className="font-bold text-krona-primary">{servers.length}</span> (Selectel:{' '}
<span className="font-bold text-krona-gold">{servers.filter((s) => s.hostingProvider === 'Selectel').length}</span>, Timeweb:{' '}
<span className="font-bold text-krona-gold">{servers.filter((s) => s.hostingProvider === 'Timeweb Cloud').length}</span>)
</div>
</div>
{/* Filter and Search Bar */}
<div className="flex flex-col md:flex-row gap-3 items-stretch md:items-center justify-between">
{/* Search */}
<div className="relative flex-1 max-w-md">
<Search className="w-4 h-4 text-zinc-400 absolute left-3 top-1/2 -translate-y-1/2" />
<Search className="w-4 h-4 text-krona-tertiary absolute left-3 top-1/2 -translate-y-1/2" />
<input
type="text"
value={search}
onChange={(e) => setSearch(e.target.value)}
placeholder="Поиск по имени сервера, IP или хосту..."
className="w-full bg-[#0b1d16] border border-zinc-800 rounded-lg pl-9 pr-3 py-2 text-xs text-zinc-100 placeholder-zinc-500 focus:outline-hidden focus:border-amber-500 transition-colors"
placeholder="Поиск по имени сервера, IP, хосту или организации..."
className="w-full bg-krona-surface border border-krona-border rounded-lg pl-9 pr-3 py-2.5 min-h-[44px] text-xs text-krona-primary placeholder-krona-tertiary focus:outline-hidden focus:border-krona-gold transition-colors"
/>
</div>
<div className="flex items-center gap-1 text-xs">
<span className="text-zinc-500 text-[11px] mr-1">Хостинг:</span>
{/* Provider Filters */}
<div className="flex items-center gap-1.5 overflow-x-auto pb-1 text-xs">
{[
{ id: 'all', label: 'Все провайдеры' },
{ id: 'Selectel', label: 'Selectel' },
@ -107,11 +125,12 @@ export const InfrastructurePage: React.FC = () => {
].map((tab) => (
<button
key={tab.id}
type="button"
onClick={() => setProviderFilter(tab.id as typeof providerFilter)}
className={`px-3 py-1.5 rounded-lg font-medium transition-colors ${
className={`min-h-[44px] px-3.5 py-2 rounded-lg font-medium transition-colors shrink-0 ${
providerFilter === tab.id
? 'bg-amber-500/20 text-amber-300 border border-amber-500/40 font-bold'
: 'bg-zinc-900 text-zinc-400 border border-zinc-800 hover:text-zinc-200'
? 'bg-krona-gold/15 text-krona-gold border border-krona-gold/30 font-bold'
: 'bg-krona-surface text-krona-secondary border border-krona-border hover:text-krona-primary hover:bg-krona-hover'
}`}
>
{tab.label}
@ -120,148 +139,194 @@ export const InfrastructurePage: React.FC = () => {
</div>
</div>
{/* Servers Grid */}
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
{/* Servers Grid (Responsive Cards) */}
<div className="grid grid-cols-1 md:grid-cols-2 gap-3 sm:gap-4" data-testid="servers-list">
{filteredServers.map((srv) => {
const srvInstances = instances.filter((i) => i.serverId === srv.serverId);
const hasOfflineInstances = srvInstances.some((i) => i.health === 'offline');
const isProblematic = srv.status === 'offline' || srv.status === 'critical' || srv.status === 'warning';
const isExpanded = !!expandedDetails[srv.serverId];
const lastSignalDate = new Date(srv.metrics.lastHeartbeatAt);
return (
<div
key={srv.serverId}
onClick={() => openInspector('server', srv.serverId)}
className={`p-4 rounded-xl border bg-[#091a13] hover:border-zinc-700 cursor-pointer transition-all space-y-3 relative group ${
srv.status === 'offline'
? 'border-zinc-700 bg-zinc-950/80'
: srv.status === 'critical'
? 'border-rose-800/70 bg-rose-950/20'
: 'border-zinc-800'
onClick={() => openInspector({ type: 'server', id: srv.serverId, title: srv.name, data: srv })}
className={`p-4 rounded-xl border bg-krona-surface hover:border-krona-gold/40 cursor-pointer transition-all space-y-3 relative shadow-xs ${
srv.status === 'critical' || srv.status === 'offline'
? 'border-status-danger-border bg-status-danger-soft/20'
: srv.status === 'warning'
? 'border-status-warning-border bg-status-warning-soft/20'
: 'border-krona-border'
}`}
data-testid={`server-card-${srv.serverId}`}
>
{/* Header */}
<div className="flex items-start justify-between">
<div>
<div className="flex items-center gap-2 mb-1">
<span className="font-bold text-sm text-zinc-100 group-hover:text-amber-300 transition-colors">
{/* Header: Name + Status */}
<div className="flex items-start justify-between gap-2">
<div className="min-w-0">
<div className="flex items-center gap-2 flex-wrap">
<span className="font-bold text-sm text-krona-primary truncate">
{srv.name}
</span>
<StatusBadge status={srv.status} size="sm" />
</div>
<div className="text-[11px] text-zinc-400 font-mono">
{srv.ipAddress} • {srv.hostingProvider} ({srv.region})
<div className="text-[11px] text-krona-tertiary font-mono mt-0.5 truncate">
{srv.hostname} • {srv.ipAddress} • {srv.hostingProvider} ({srv.region})
</div>
</div>
<div className="text-right">
<span className="text-[10px] text-zinc-500 uppercase tracking-wider block font-mono">
Agent v{srv.agentVersion}
</span>
<span className="text-[10px] text-zinc-400 font-mono">
{srv.certificateDaysRemaining}д до серт.
</span>
{/* Agent version badge */}
<span className="text-[10px] text-krona-tertiary font-mono bg-krona-workspace px-2 py-0.5 rounded-sm shrink-0 border border-krona-border">
Агент v{srv.agentVersion}
</span>
</div>
{/* Destination / Organization */}
<div className="text-xs text-krona-secondary flex items-center justify-between">
<div>
<span className="text-krona-tertiary">Назначение: </span>
<span className="font-semibold text-krona-primary">{srv.organizationName}</span>
</div>
<div className="text-[11px] text-krona-tertiary flex items-center gap-1">
<Clock className="w-3.5 h-3.5" />
<span>Сигнал: {lastSignalDate.toLocaleTimeString('ru-RU')}</span>
</div>
</div>
{/* Resource Gauges */}
<div className="grid grid-cols-3 gap-2 pt-2 border-t border-zinc-850 text-xs">
{/* Resource Gauges (CPU, RAM, Disk) */}
<div className="grid grid-cols-3 gap-2 pt-2 border-t border-krona-border text-xs">
{/* CPU */}
<div className="p-2 rounded-lg bg-zinc-950/80 border border-zinc-800 space-y-1">
<div className="flex justify-between text-[11px] text-zinc-400">
<span>CPU:</span>
<span className="font-bold font-mono text-zinc-100">{srv.cpuLoadPercent}%</span>
<div className="p-2 rounded-lg bg-krona-workspace border border-krona-border space-y-1">
<div className="flex justify-between text-[11px] text-krona-tertiary">
<span>CPU</span>
<span className={`font-bold font-mono ${srv.metrics.cpuPercent > 80 ? 'text-status-danger-text' : 'text-krona-primary'}`}>
{srv.metrics.cpuPercent}%
</span>
</div>
<div className="w-full bg-zinc-800 h-1 rounded-full overflow-hidden">
<div className="w-full bg-krona-surface h-1.5 rounded-full overflow-hidden">
<div
className={`h-full ${srv.cpuLoadPercent > 80 ? 'bg-rose-500' : 'bg-emerald-500'}`}
style={{ width: `${srv.cpuLoadPercent}%` }}
className={`h-full ${srv.metrics.cpuPercent > 80 ? 'bg-red-500' : 'bg-emerald-500'}`}
style={{ width: `${srv.metrics.cpuPercent}%` }}
/>
</div>
</div>
{/* RAM */}
<div className="p-2 rounded-lg bg-zinc-950/80 border border-zinc-800 space-y-1">
<div className="flex justify-between text-[11px] text-zinc-400">
<span>RAM:</span>
<span className="font-bold font-mono text-zinc-100">
{Math.round((srv.ramUsedBytes / srv.ramTotalBytes) * 100)}%
<div className="p-2 rounded-lg bg-krona-workspace border border-krona-border space-y-1">
<div className="flex justify-between text-[11px] text-krona-tertiary">
<span>RAM</span>
<span className={`font-bold font-mono ${srv.metrics.ramPercent > 80 ? 'text-status-danger-text' : 'text-krona-primary'}`}>
{srv.metrics.ramPercent}%
</span>
</div>
<div className="w-full bg-zinc-800 h-1 rounded-full overflow-hidden">
<div className="w-full bg-krona-surface h-1.5 rounded-full overflow-hidden">
<div
className="h-full bg-emerald-500"
style={{ width: `${(srv.ramUsedBytes / srv.ramTotalBytes) * 100}%` }}
className={`h-full ${srv.metrics.ramPercent > 80 ? 'bg-red-500' : 'bg-emerald-500'}`}
style={{ width: `${srv.metrics.ramPercent}%` }}
/>
</div>
</div>
{/* Disk */}
<div className="p-2 rounded-lg bg-zinc-950/80 border border-zinc-800 space-y-1">
<div className="flex justify-between text-[11px] text-zinc-400">
<span>Диск:</span>
<span className="font-bold font-mono text-zinc-100">
{Math.round((srv.diskUsedBytes / srv.diskTotalBytes) * 100)}%
<div className="p-2 rounded-lg bg-krona-workspace border border-krona-border space-y-1">
<div className="flex justify-between text-[11px] text-krona-tertiary">
<span>Диск</span>
<span className={`font-bold font-mono ${srv.metrics.diskPercent > 85 ? 'text-status-danger-text' : 'text-krona-primary'}`}>
{srv.metrics.diskPercent}%
</span>
</div>
<div className="w-full bg-zinc-800 h-1 rounded-full overflow-hidden">
<div className="w-full bg-krona-surface h-1.5 rounded-full overflow-hidden">
<div
className={`h-full ${srv.diskUsedBytes / srv.diskTotalBytes > 0.85 ? 'bg-rose-500' : 'bg-amber-400'}`}
style={{ width: `${(srv.diskUsedBytes / srv.diskTotalBytes) * 100}%` }}
className={`h-full ${srv.metrics.diskPercent > 85 ? 'bg-red-500' : 'bg-emerald-500'}`}
style={{ width: `${srv.metrics.diskPercent}%` }}
/>
</div>
</div>
</div>
{/* Heartbeat timeline */}
<div className="pt-1 flex justify-between items-center text-xs">
<span className="text-[11px] text-zinc-400">Heartbeat (24h):</span>
<HeartbeatTimeline status={srv.status} count={16} />
</div>
{/* Secondary details collapsible accordion */}
{isExpanded && (
<div className="pt-2 border-t border-krona-border space-y-2.5 text-xs animate-in fade-in duration-150">
<div className="grid grid-cols-2 gap-2 text-[11px] text-krona-secondary">
<div>
Резервная копия:{' '}
<span className={srv.backup.state === 'failed' ? 'text-status-danger-text font-bold' : 'text-status-success-text font-semibold'}>
{srv.backup.state === 'healthy' ? 'В норме' : srv.backup.state === 'failed' ? 'Ошибка' : srv.backup.state}
</span>
</div>
<div>
Срок mTLS-сертификата:{' '}
<span className="font-mono text-krona-primary font-semibold">{srv.certificateDaysRemaining} дней</span>
</div>
</div>
{/* Installed modules & Backups status footer */}
<div className="pt-2 border-t border-zinc-850 flex items-center justify-between text-[11px] text-zinc-400">
<div>
Инстансов модулей:{' '}
<span className={`font-bold ${hasOfflineInstances ? 'text-rose-400' : 'text-zinc-200'}`}>
{srvInstances.length}
</span>
</div>
<div className="flex items-center gap-1.5">
<span>Бэкап:</span>
<StatusBadge
status={srv.backup.state === 'ok' ? 'healthy' : srv.backup.state === 'failed' ? 'critical' : 'warning'}
size="sm"
customLabel={srv.backup.state.toUpperCase()}
/>
</div>
</div>
<div>
<div className="text-[11px] text-krona-tertiary mb-1">История сигналов (Heartbeat):</div>
<HeartbeatTimeline history={srv.metrics.heartbeatHistory} compact />
</div>
{/* Quick Actions (Сценарий 6) */}
{isProblematic && (
<div className="pt-2 flex gap-2" onClick={(e) => e.stopPropagation()}>
<button
type="button"
disabled={!permissions.canManageIncidents}
onClick={() => handleCreateIncident(srv)}
className="px-2.5 py-1 rounded-md bg-rose-950/80 hover:bg-rose-900/80 text-rose-300 border border-rose-800/60 text-xs font-semibold flex items-center gap-1 transition-colors disabled:opacity-40"
>
<PlusCircle className="w-3.5 h-3.5" />
<span>Создать инцидент</span>
</button>
<button
type="button"
disabled={!permissions.canRequestDiagnostics}
onClick={() => handleDiagnostics(srv)}
className="px-2.5 py-1 rounded-md bg-zinc-900 hover:bg-zinc-800 text-zinc-300 border border-zinc-700 text-xs font-medium transition-colors disabled:opacity-40"
>
Запрос диагностики
</button>
<div className="flex items-center gap-2 pt-1">
<button
type="button"
onClick={(e) => handleDiagnostics(srv, e)}
className="min-h-[38px] px-3 py-1.5 rounded-lg border border-krona-border bg-krona-workspace text-krona-secondary hover:text-krona-primary text-xs font-medium"
>
Диагностика
</button>
{(srv.status === 'critical' || srv.status === 'offline') && (
<button
type="button"
onClick={(e) => handleCreateIncident(srv, e)}
className="min-h-[38px] px-3 py-1.5 rounded-lg border border-status-danger-border bg-status-danger-soft text-status-danger-text text-xs font-bold"
>
Открыть инцидент
</button>
)}
</div>
</div>
)}
{/* Action Buttons Row */}
<div className="flex items-center justify-between pt-1 gap-2">
<button
type="button"
onClick={(e) => toggleDetails(srv.serverId, e)}
className="min-h-[44px] px-2.5 text-xs text-krona-secondary hover:text-krona-primary flex items-center gap-1 font-medium"
aria-expanded={isExpanded}
>
{isExpanded ? (
<>
<span>Скрыть детали</span>
<ChevronUp className="w-3.5 h-3.5" />
</>
) : (
<>
<span>Вторичные сведения</span>
<ChevronDown className="w-3.5 h-3.5" />
</>
)}
</button>
<button
type="button"
onClick={(e) => {
e.stopPropagation();
openInspector({ type: 'server', id: srv.serverId, title: srv.name, data: srv });
}}
className="min-h-[44px] px-3 flex items-center gap-1.5 text-xs font-semibold text-krona-gold bg-krona-gold/10 hover:bg-krona-gold/20 rounded-lg border border-krona-gold/30 transition-colors"
data-testid={`inspect-server-${srv.serverId}`}
>
<span>Подробности</span>
<ChevronRight className="w-4 h-4" />
</button>
</div>
</div>
);
})}
{filteredServers.length === 0 && (
<div className="col-span-full p-8 text-center text-krona-tertiary rounded-xl border border-krona-border bg-krona-surface text-xs">
Серверные узлы не найдены по заданным критериям фильтрации
</div>
)}
</div>
</div>
);

View file

@ -3,6 +3,10 @@ import {
Building2,
Search,
ChevronRight,
Filter,
Users,
CreditCard,
Layers,
} from 'lucide-react';
import { useKccStore } from '../store/kccStore';
import { StatusBadge } from '../components/StatusBadge';
@ -27,22 +31,22 @@ export const OrganizationsPage: React.FC = () => {
});
return (
<div className="space-y-6">
<div className="space-y-4 sm:space-y-6" data-testid="organizations-page">
{/* Header */}
<div className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-3 border-b border-zinc-800 pb-4">
<div className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-2.5 border-b border-krona-border pb-4">
<div>
<h1 className="text-xl font-bold text-zinc-100 flex items-center gap-2">
<Building2 className="w-5 h-5 text-amber-400" />
<span>Организации платформы (Tenants)</span>
<h1 className="text-lg sm:text-xl font-bold text-krona-primary flex items-center gap-2">
<Building2 className="w-5 h-5 text-krona-gold" />
<span>Организации платформы</span>
</h1>
<p className="text-xs text-zinc-400 mt-0.5">
Реестр клиентских организаций, управление статусами, тарифами и 360-анализ тенантов
<p className="text-xs text-krona-secondary mt-0.5">
Реестр организаций, управление статусами, тарифами и 360°-анализ тенантов
</p>
</div>
<div className="text-xs text-zinc-400">
Всего организаций: <span className="font-bold text-zinc-100">{organizations.length}</span> (найдено:{' '}
<span className="font-bold text-amber-400">{filtered.length}</span>)
<div className="text-xs text-krona-secondary">
Всего: <span className="font-bold text-krona-primary">{organizations.length}</span> (найдено:{' '}
<span className="font-bold text-krona-gold">{filtered.length}</span>)
</div>
</div>
@ -50,32 +54,33 @@ export const OrganizationsPage: React.FC = () => {
<div className="flex flex-col md:flex-row gap-3 items-stretch md:items-center justify-between">
{/* Search */}
<div className="relative flex-1 max-w-md">
<Search className="w-4 h-4 text-zinc-400 absolute left-3 top-1/2 -translate-y-1/2" />
<Search className="w-4 h-4 text-krona-tertiary absolute left-3 top-1/2 -translate-y-1/2" />
<input
type="text"
value={search}
onChange={(e) => setSearch(e.target.value)}
placeholder="Поиск по названию, ИНН или домену..."
className="w-full bg-[#0b1d16] border border-zinc-800 rounded-lg pl-9 pr-3 py-2 text-xs text-zinc-100 placeholder-zinc-500 focus:outline-hidden focus:border-amber-500 transition-colors"
className="w-full bg-krona-surface border border-krona-border rounded-lg pl-9 pr-3 py-2.5 min-h-[44px] text-xs text-krona-primary placeholder-krona-tertiary focus:outline-hidden focus:border-krona-gold transition-colors"
/>
</div>
{/* Status Filters */}
<div className="flex items-center gap-1 overflow-x-auto pb-1 text-xs">
<div className="flex items-center gap-1.5 overflow-x-auto pb-1 text-xs">
{[
{ id: 'all', label: 'Все' },
{ id: 'active', label: 'Active' },
{ id: 'trial', label: 'Trial' },
{ id: 'grace', label: 'Grace' },
{ id: 'suspended', label: 'Suspended' },
{ id: 'active', label: 'Активные' },
{ id: 'trial', label: 'Пробные' },
{ id: 'grace', label: 'Льготные' },
{ id: 'suspended', label: 'Приостановленные' },
].map((tab) => (
<button
key={tab.id}
type="button"
onClick={() => setStatusFilter(tab.id as typeof statusFilter)}
className={`px-3 py-1.5 rounded-lg font-medium transition-colors ${
className={`min-h-[44px] px-3.5 py-2 rounded-lg font-medium transition-colors shrink-0 ${
statusFilter === tab.id
? 'bg-amber-500/20 text-amber-300 border border-amber-500/40 font-semibold'
: 'bg-zinc-900/60 text-zinc-400 border border-zinc-800 hover:text-zinc-200 hover:bg-zinc-800'
? 'bg-krona-gold/15 text-krona-gold border border-krona-gold/30 font-bold'
: 'bg-krona-surface text-krona-secondary border border-krona-border hover:text-krona-primary hover:bg-krona-hover'
}`}
>
{tab.label}
@ -84,12 +89,79 @@ export const OrganizationsPage: React.FC = () => {
</div>
</div>
{/* Organizations Table */}
<div className="rounded-xl border border-zinc-800 bg-[#091a13] overflow-hidden shadow-xl">
{/* ========================================================================= */}
{/* 1. MOBILE CARD VIEW (< lg) */}
{/* ========================================================================= */}
<div className="lg:hidden space-y-3" data-testid="orgs-mobile-cards">
{filtered.map((org) => (
<div
key={org.tenantId}
onClick={() => openInspector({ type: 'organization', id: org.tenantId, title: org.displayName, data: org })}
className="rounded-xl border border-krona-border bg-krona-surface p-4 space-y-3 shadow-sm hover:border-krona-gold/40 transition-colors cursor-pointer"
>
{/* Header: Title + Badge */}
<div className="flex items-start justify-between gap-2">
<div className="min-w-0">
<h3 className="font-bold text-sm text-krona-primary truncate">{org.displayName}</h3>
<div className="text-[11px] text-krona-tertiary font-mono mt-0.5">
ИНН {org.inn} • {org.domain}
</div>
</div>
<StatusBadge status={org.platformStatus} size="sm" />
</div>
{/* Metrics Row */}
<div className="grid grid-cols-3 gap-2 py-2 border-y border-krona-border text-center text-xs">
<div className="p-1 rounded-md bg-krona-workspace/50">
<div className="text-[10px] text-krona-tertiary">Пользователи</div>
<div className="font-bold text-krona-primary">{org.userCount} <span className="text-[10px] font-normal text-krona-tertiary">/ {org.branchCount} т.</span></div>
</div>
<div className="p-1 rounded-md bg-krona-workspace/50">
<div className="text-[10px] text-krona-tertiary">Модули</div>
<div className="font-bold text-krona-primary">{org.installedModulesCount}</div>
</div>
<div className="p-1 rounded-md bg-krona-workspace/50">
<div className="text-[10px] text-krona-tertiary">MRR</div>
<MoneyDisplay minorUnits={org.mrrMinorUnits} className="font-bold text-krona-gold text-xs" />
</div>
</div>
{/* Bottom: Tariff + Inspector Action */}
<div className="flex items-center justify-between pt-1">
<div className="text-xs text-krona-secondary">
Тариф: <span className="font-semibold text-krona-primary">{org.tariffPlan}</span>
</div>
<button
type="button"
onClick={(e) => {
e.stopPropagation();
openInspector({ type: 'organization', id: org.tenantId, title: org.displayName, data: org });
}}
className="min-h-[44px] min-w-[44px] px-3 flex items-center gap-1 text-xs font-semibold text-krona-gold bg-krona-gold/10 hover:bg-krona-gold/20 rounded-lg border border-krona-gold/30 transition-colors"
data-testid={`inspect-org-${org.tenantId}`}
>
<span>360° Инспектор</span>
<ChevronRight className="w-4 h-4" />
</button>
</div>
</div>
))}
{filtered.length === 0 && (
<div className="p-8 text-center text-krona-tertiary rounded-xl border border-krona-border bg-krona-surface text-xs">
Организации не найдены по заданным критериям фильтрации
</div>
)}
</div>
{/* ========================================================================= */}
{/* 2. DESKTOP & TABLET TABLE VIEW (hidden on mobile, visible >= lg) */}
{/* ========================================================================= */}
<div className="hidden lg:block rounded-xl border border-krona-border bg-krona-surface overflow-hidden shadow-sm">
<div className="overflow-x-auto">
<table className="w-full text-left text-xs border-collapse">
<thead>
<tr className="border-b border-zinc-800 bg-zinc-950/70 text-zinc-400 font-semibold uppercase tracking-wider text-[11px]">
<tr className="border-b border-krona-border bg-krona-workspace text-krona-tertiary font-semibold uppercase tracking-wider text-[11px]">
<th className="py-3 px-4">Организация / ИНН</th>
<th className="py-3 px-3">Статус</th>
<th className="py-3 px-3">Тарифный план</th>
@ -101,42 +173,42 @@ export const OrganizationsPage: React.FC = () => {
<th className="py-3 px-4 text-right">Действие</th>
</tr>
</thead>
<tbody className="divide-y divide-zinc-800/60">
<tbody className="divide-y divide-krona-border">
{filtered.map((org) => (
<tr
key={org.tenantId}
onClick={() => openInspector('organization', org.tenantId)}
className="hover:bg-zinc-800/40 cursor-pointer transition-colors group"
onClick={() => openInspector({ type: 'organization', id: org.tenantId, title: org.displayName, data: org })}
className="hover:bg-krona-hover cursor-pointer transition-colors group"
>
<td className="py-3 px-4">
<div className="font-bold text-zinc-100 group-hover:text-amber-300 transition-colors">
<div className="font-bold text-krona-primary group-hover:text-krona-gold transition-colors">
{org.displayName}
</div>
<div className="text-[11px] text-zinc-400 font-mono mt-0.5">
<div className="text-[11px] text-krona-tertiary font-mono mt-0.5">
ИНН {org.inn} • {org.domain}
</div>
</td>
<td className="py-3 px-3">
<StatusBadge status={org.platformStatus} size="sm" />
</td>
<td className="py-3 px-3 font-medium text-zinc-300">{org.tariffPlan}</td>
<td className="py-3 px-3 font-medium text-krona-primary">{org.tariffPlan}</td>
<td className="py-3 px-3 text-center tabular-nums">
<span className="font-bold text-zinc-100">{org.userCount}</span>
<span className="text-zinc-500 text-[10px]"> / {org.branchCount}</span>
<span className="font-bold text-krona-primary">{org.userCount}</span>
<span className="text-krona-tertiary text-[10px]"> / {org.branchCount}</span>
</td>
<td className="py-3 px-3 text-center font-bold text-zinc-200 tabular-nums">
<td className="py-3 px-3 text-center font-bold text-krona-primary tabular-nums">
{org.installedModulesCount}
</td>
<td className="py-3 px-3 text-right">
<MoneyDisplay minorUnits={org.mrrMinorUnits} className="text-amber-400 font-bold" />
<MoneyDisplay minorUnits={org.mrrMinorUnits} className="text-krona-gold font-bold" />
</td>
<td className="py-3 px-3 text-right">
<MoneyDisplay
minorUnits={org.outstandingDebtMinorUnits}
className={org.outstandingDebtMinorUnits > 0 ? 'text-rose-400 font-bold' : 'text-zinc-400'}
className={org.outstandingDebtMinorUnits > 0 ? 'text-status-danger-text font-bold' : 'text-krona-tertiary'}
/>
</td>
<td className="py-3 px-3 text-zinc-400 text-[11px]">
<td className="py-3 px-3 text-krona-tertiary text-[11px]">
{new Date(org.lastActiveAt).toLocaleString('ru-RU', {
day: '2-digit',
month: '2-digit',
@ -149,12 +221,13 @@ export const OrganizationsPage: React.FC = () => {
type="button"
onClick={(e) => {
e.stopPropagation();
openInspector('organization', org.tenantId);
openInspector({ type: 'organization', id: org.tenantId, title: org.displayName, data: org });
}}
className="inline-flex items-center gap-1 px-2.5 py-1 rounded-md bg-zinc-900 border border-zinc-700 text-zinc-300 hover:text-amber-300 hover:border-amber-500/40 text-[11px] font-medium transition-colors"
className="inline-flex items-center gap-1 px-3 py-2 min-h-[38px] rounded-lg bg-krona-workspace border border-krona-border text-krona-primary hover:text-krona-gold hover:border-krona-gold/40 text-[11px] font-medium transition-colors"
data-testid={`inspect-org-btn-${org.tenantId}`}
>
<span>360° Карточка</span>
<ChevronRight className="w-3 h-3" />
<span>360° Инспектор</span>
<ChevronRight className="w-3.5 h-3.5" />
</button>
</td>
</tr>
@ -162,7 +235,7 @@ export const OrganizationsPage: React.FC = () => {
{filtered.length === 0 && (
<tr>
<td colSpan={9} className="py-8 text-center text-zinc-500">
<td colSpan={9} className="py-8 text-center text-krona-tertiary">
Организации не найдены по заданным критериям фильтрации
</td>
</tr>

View file

@ -1,4 +1,4 @@
import React from 'react';
import React, { useState, useMemo } from 'react';
import { useNavigate } from 'react-router-dom';
import {
Building2,
@ -9,12 +9,20 @@ import {
ShieldAlert,
ArrowUpRight,
Activity,
Radio,
AlertTriangle,
CheckCircle2,
Clock,
Layers,
HelpCircle,
Database,
} from 'lucide-react';
import { useKccStore, useOperatorRole } from '../store/kccStore';
import { MoneyDisplay } from '../components/MoneyDisplay';
import { StatusBadge } from '../components/StatusBadge';
import { HeartbeatTimeline } from '../components/HeartbeatTimeline';
import { RU_ROLES, RU_INCIDENT_SEVERITY, RU_INCIDENT_STATUS } from '../locales/ru';
type OverviewTab = 'attention' | 'metrics' | 'infrastructure';
export const OverviewPage: React.FC = () => {
const navigate = useNavigate();
@ -27,6 +35,7 @@ export const OverviewPage: React.FC = () => {
const servers = useKccStore((state) => state.servers);
const incidents = useKccStore((state) => state.incidents);
const openInspector = useKccStore((state) => state.openInspector);
const serverMonitorOutage = useKccStore((state) => state.serverMonitorOutage);
// Aggregates
const totalOrgs = organizations.length;
@ -41,6 +50,7 @@ export const OverviewPage: React.FC = () => {
const expiring7d = subscriptions.filter((s) => s.status === 'expiring_7d');
const expiring30d = subscriptions.filter((s) => s.status === 'expiring_30d');
const overdueSubs = subscriptions.filter((s) => s.status === 'overdue');
const webInstalls = installations.filter((i) => i.kind === 'web').length;
const desktopInstalls = installations.filter((i) => i.kind === 'desktop').length;
@ -52,334 +62,521 @@ export const OverviewPage: React.FC = () => {
const instancesHealthy = instances.filter((i) => i.health === 'healthy');
const serversHealthy = servers.filter((s) => s.status === 'healthy').length;
const serversWarning = servers.filter((s) => s.status === 'warning').length;
const serversCritical = servers.filter((s) => s.status === 'critical').length;
const serversOffline = servers.filter((s) => s.status === 'offline').length;
const serversProblematic = servers.filter((s) => s.status !== 'healthy');
const failedBackups = servers.filter((s) => s.backup.state === 'failed');
const openIncidents = incidents.filter((i) => i.status !== 'resolved');
// Total attention items count
const attentionCount = useMemo(() => {
let count = openIncidents.length;
count += serversProblematic.length;
count += instancesOffline.length;
count += instancesDegraded.length;
count += expiring7d.length;
count += overdueSubs.length;
if (serverMonitorOutage) count += 1;
return count;
}, [openIncidents, serversProblematic, instancesOffline, instancesDegraded, expiring7d, overdueSubs, serverMonitorOutage]);
// Default tab is 'attention' if there are problems, otherwise 'metrics'
const [activeTab, setActiveTab] = useState<OverviewTab>(() => (attentionCount > 0 ? 'attention' : 'metrics'));
return (
<div className="space-y-6">
<div className="space-y-4 sm:space-y-6" data-testid="overview-page">
{/* Top Headline */}
<div className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-2 border-b border-zinc-800 pb-4">
<div className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-2.5 border-b border-krona-border pb-4">
<div>
<h1 className="text-xl font-bold text-zinc-100 flex items-center gap-2">
<span>Сводная операторская панель</span>
<span className="text-xs px-2 py-0.5 rounded-full bg-emerald-950 text-emerald-400 border border-emerald-700/60 font-mono">
Production Scope
<h1 className="text-lg sm:text-xl font-bold text-krona-primary flex items-center gap-2 flex-wrap">
<span>Сводная панель платформы</span>
<span className="text-[11px] px-2.5 py-0.5 rounded-full bg-status-success-soft text-status-success-text border border-status-success-border font-medium">
Контур платформы
</span>
</h1>
<p className="text-xs text-zinc-400 mt-0.5">
Оперативный контроль тенантов, подписок, модульных инстансов и инфраструктурных узлов Krona OS
<p className="text-xs text-krona-secondary mt-0.5">
Оперативный контроль организаций, лицензий, модульных инстансов и серверных узлов Krona
</p>
</div>
<div className="flex items-center gap-2 text-xs">
<span className="text-zinc-400">Роль оператора:</span>
<span className="font-semibold text-amber-400 bg-amber-500/10 px-2 py-1 rounded-md border border-amber-500/20">
{operator.name} ({role})
<span className="text-krona-tertiary">Оператор:</span>
<span className="font-semibold text-krona-gold bg-krona-gold/10 px-2.5 py-1 rounded-md border border-krona-gold/20">
{operator.name} ({RU_ROLES[role]?.shortLabel || role})
</span>
</div>
</div>
{/* Top Row KPI Cards */}
<div className="grid grid-cols-1 sm:grid-cols-2 md:grid-cols-3 xl:grid-cols-6 gap-3">
{/* Orgs */}
<div
onClick={() => navigate('/control/organizations')}
className="p-3.5 rounded-xl bg-[#0b1d16] border border-zinc-800 hover:border-zinc-700 cursor-pointer transition-colors space-y-2"
{/* Tab Switcher (Mobile-First Priority Controls) */}
<div className="flex items-center gap-1.5 p-1 bg-krona-surface border border-krona-border rounded-xl max-w-full overflow-x-auto">
<button
type="button"
onClick={() => setActiveTab('attention')}
className={`flex-1 min-h-[44px] min-w-[120px] px-3 py-2 rounded-lg text-xs font-semibold flex items-center justify-center gap-2 transition-colors ${
activeTab === 'attention'
? 'bg-krona-workspace text-krona-primary shadow-xs border border-krona-border font-bold'
: 'text-krona-secondary hover:text-krona-primary hover:bg-krona-hover'
}`}
data-testid="tab-attention"
>
<div className="flex justify-between items-center text-zinc-400 text-xs">
<span>Организации</span>
<Building2 className="w-4 h-4 text-emerald-400" />
</div>
<div className="text-2xl font-black text-zinc-100 tabular-nums">{totalOrgs}</div>
<div className="flex gap-1.5 text-[10px] text-zinc-400 flex-wrap">
<span className="text-emerald-400 font-semibold">{activeOrgs} act</span>
<span>•</span>
<span className="text-amber-400">{trialOrgs} trial</span>
<span>•</span>
<span className="text-orange-400">{graceOrgs} grace</span>
<span>•</span>
<span className="text-rose-400">{suspendedOrgs} susp</span>
</div>
</div>
<ShieldAlert className={`w-4 h-4 shrink-0 ${attentionCount > 0 ? 'text-status-danger-text' : 'text-status-success-text'}`} />
<span>Требует внимания</span>
{attentionCount > 0 && (
<span className="px-1.5 py-0.2 rounded-full text-[10px] font-mono font-bold bg-status-danger-soft text-status-danger-text border border-status-danger-border">
{attentionCount}
</span>
)}
</button>
{/* Users & Branches */}
<div className="p-3.5 rounded-xl bg-[#0b1d16] border border-zinc-800 space-y-2">
<div className="flex justify-between items-center text-zinc-400 text-xs">
<span>Пользователи / Точки</span>
<Users className="w-4 h-4 text-sky-400" />
</div>
<div className="text-2xl font-black text-zinc-100 tabular-nums">{totalUsers}</div>
<div className="text-[10px] text-zinc-400">{totalBranches} филиалов в сети</div>
</div>
{/* MRR Revenue */}
<div
onClick={() => navigate('/control/billing')}
className="p-3.5 rounded-xl bg-[#0b1d16] border border-zinc-800 hover:border-zinc-700 cursor-pointer transition-colors space-y-2"
<button
type="button"
onClick={() => setActiveTab('metrics')}
className={`flex-1 min-h-[44px] min-w-[110px] px-3 py-2 rounded-lg text-xs font-semibold flex items-center justify-center gap-2 transition-colors ${
activeTab === 'metrics'
? 'bg-krona-workspace text-krona-primary shadow-xs border border-krona-border font-bold'
: 'text-krona-secondary hover:text-krona-primary hover:bg-krona-hover'
}`}
data-testid="tab-metrics"
>
<div className="flex justify-between items-center text-zinc-400 text-xs">
<span>MRR Доход</span>
<CreditCard className="w-4 h-4 text-amber-400" />
</div>
<div className="text-xl font-black text-amber-400">
<MoneyDisplay minorUnits={totalMrrMinorUnits} />
</div>
<div className="text-[10px] text-zinc-400">
Истекают: <span className="text-amber-300 font-semibold">{expiring7d.length} за 7д</span> / {expiring30d.length} за 30д
</div>
</div>
<Activity className="w-4 h-4 shrink-0 text-krona-gold" />
<span>Показатели</span>
</button>
{/* Installations */}
<div
onClick={() => navigate('/control/installations')}
className="p-3.5 rounded-xl bg-[#0b1d16] border border-zinc-800 hover:border-zinc-700 cursor-pointer transition-colors space-y-2"
<button
type="button"
onClick={() => setActiveTab('infrastructure')}
className={`flex-1 min-h-[44px] min-w-[120px] px-3 py-2 rounded-lg text-xs font-semibold flex items-center justify-center gap-2 transition-colors ${
activeTab === 'infrastructure'
? 'bg-krona-workspace text-krona-primary shadow-xs border border-krona-border font-bold'
: 'text-krona-secondary hover:text-krona-primary hover:bg-krona-hover'
}`}
data-testid="tab-infrastructure"
>
<div className="flex justify-between items-center text-zinc-400 text-xs">
<span>Установки ПО</span>
<Smartphone className="w-4 h-4 text-indigo-400" />
</div>
<div className="text-2xl font-black text-zinc-100 tabular-nums">
{installations.length}
</div>
<div className="text-[10px] text-zinc-400">
Web: {webInstalls} • Desk: {desktopInstalls} • <span className="text-amber-400 font-semibold">Andr: {androidInstalls}</span>
</div>
</div>
{/* Modules Health */}
<div
onClick={() => navigate('/control/modules')}
className="p-3.5 rounded-xl bg-[#0b1d16] border border-zinc-800 hover:border-zinc-700 cursor-pointer transition-colors space-y-2"
>
<div className="flex justify-between items-center text-zinc-400 text-xs">
<span>Инстансы модулей</span>
<Activity className="w-4 h-4 text-emerald-400" />
</div>
<div className="text-2xl font-black text-zinc-100 tabular-nums">{instances.length}</div>
<div className="text-[10px] text-zinc-400">
<span className="text-emerald-400 font-semibold">{instancesHealthy.length} ok</span> •{' '}
<span className="text-amber-400">{instancesDegraded.length} degr</span> •{' '}
<span className="text-rose-400 font-bold">{instancesOffline.length} off</span>
</div>
</div>
{/* VPS Servers */}
<div
onClick={() => navigate('/control/infrastructure')}
className="p-3.5 rounded-xl bg-[#0b1d16] border border-zinc-800 hover:border-zinc-700 cursor-pointer transition-colors space-y-2"
>
<div className="flex justify-between items-center text-zinc-400 text-xs">
<span>Серверы & VPS</span>
<Server className="w-4 h-4 text-teal-400" />
</div>
<div className="text-2xl font-black text-zinc-100 tabular-nums">{servers.length}</div>
<div className="text-[10px] text-zinc-400">
<span className="text-emerald-400 font-semibold">{serversHealthy} ok</span> •{' '}
<span className="text-amber-400">{serversWarning} warn</span> •{' '}
<span className="text-rose-400 font-bold">{serversCritical + serversOffline} crit/off</span>
</div>
</div>
<Server className="w-4 h-4 shrink-0 text-krona-gold" />
<span>Инфраструктура</span>
</button>
</div>
{/* Central Strip & Operator Queue */}
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
{/* Central 2 Columns: Infrastructure & Modules Health Matrix */}
<div className="lg:col-span-2 space-y-6">
{/* Section: Infrastructure Status Strip */}
<div className="rounded-xl border border-zinc-800 bg-[#091a13] p-4 space-y-4">
{/* ========================================================================= */}
{/* TAB 1: ТРЕБУЕТ ВНИМАНИЯ (CRITICAL / PROBLEMS PRIORITY) */}
{/* ========================================================================= */}
{activeTab === 'attention' && (
<div className="space-y-4 animate-in fade-in duration-150" data-testid="attention-section">
{attentionCount === 0 && (
<div className="p-6 rounded-xl border border-status-success-border bg-status-success-soft text-status-success-text text-center space-y-2">
<CheckCircle2 className="w-10 h-10 mx-auto text-status-success-text" />
<div className="font-bold text-base">Все системы работают в штатном режиме</div>
<p className="text-xs opacity-90 max-w-md mx-auto">
Нет открытых аварий, серверов с деградацией или просроченных подписок, требующих вмешательства оператора.
</p>
</div>
)}
{/* 1. Открытые инциденты платформы */}
{openIncidents.length > 0 && (
<div className="rounded-xl border border-krona-border bg-krona-surface p-4 space-y-3">
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<ShieldAlert className="w-4 h-4 text-status-danger-text" />
<span className="font-bold text-sm text-krona-primary">
Открытые инциденты ({openIncidents.length})
</span>
</div>
<button
type="button"
onClick={() => navigate('/control/incidents')}
className="min-h-[44px] px-3 text-xs text-krona-gold hover:underline flex items-center gap-1 font-medium"
>
<span>Все инциденты</span>
<ArrowUpRight className="w-3.5 h-3.5" />
</button>
</div>
<div className="space-y-2">
{openIncidents.map((inc) => (
<div
key={inc.id}
onClick={() => openInspector({ type: 'incident', id: inc.id, title: inc.title, data: inc })}
className="p-3.5 rounded-lg border border-status-danger-border bg-status-danger-soft cursor-pointer hover:opacity-95 transition-opacity space-y-1.5"
>
<div className="flex items-center justify-between gap-2 flex-wrap">
<div className="flex items-center gap-2">
<span className="text-[10px] font-mono font-bold bg-status-danger-text text-white px-2 py-0.5 rounded-sm">
{RU_INCIDENT_SEVERITY[inc.severity]?.short || inc.severity}
</span>
<span className="font-bold text-xs text-status-danger-text">{inc.title}</span>
</div>
<span className="text-[11px] font-medium text-status-danger-text">
{RU_INCIDENT_STATUS[inc.status] || inc.status}
</span>
</div>
<p className="text-xs text-krona-secondary line-clamp-2">{inc.impactSummary}</p>
<div className="text-[11px] text-krona-tertiary">
Затронуто: {inc.affectedTenants.length} орг. • Узлы: {inc.affectedNodes.join(', ')}
</div>
</div>
))}
</div>
</div>
)}
{/* 2. Проблемные серверы */}
{serversProblematic.length > 0 && (
<div className="rounded-xl border border-krona-border bg-krona-surface p-4 space-y-3">
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<Server className="w-4 h-4 text-status-warning-text" />
<span className="font-bold text-sm text-krona-primary">
Серверы с предупреждениями ({serversProblematic.length})
</span>
</div>
<button
type="button"
onClick={() => navigate('/control/infrastructure')}
className="min-h-[44px] px-3 text-xs text-krona-gold hover:underline flex items-center gap-1 font-medium"
>
<span>Инфраструктура</span>
<ArrowUpRight className="w-3.5 h-3.5" />
</button>
</div>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3">
{serversProblematic.map((srv) => (
<div
key={srv.id}
onClick={() => openInspector({ type: 'server', id: srv.id, title: srv.hostname, data: srv })}
className="p-3.5 rounded-lg border border-krona-border bg-krona-workspace cursor-pointer hover:border-krona-gold/40 transition-colors space-y-2"
>
<div className="flex items-center justify-between">
<div className="font-bold text-xs text-krona-primary font-mono">{srv.hostname}</div>
<StatusBadge status={srv.status} size="sm" />
</div>
<div className="grid grid-cols-3 gap-1 text-[11px] text-krona-secondary font-mono">
<div>CPU: <span className={srv.metrics.cpuPercent > 80 ? 'text-status-danger-text font-bold' : ''}>{srv.metrics.cpuPercent}%</span></div>
<div>RAM: <span className={srv.metrics.ramPercent > 80 ? 'text-status-danger-text font-bold' : ''}>{srv.metrics.ramPercent}%</span></div>
<div>Диск: <span className={srv.metrics.diskPercent > 85 ? 'text-status-danger-text font-bold' : ''}>{srv.metrics.diskPercent}%</span></div>
</div>
<div className="text-[11px] text-krona-tertiary">
Организация: {srv.organizationName}
</div>
</div>
))}
</div>
</div>
)}
{/* 3. Модули с деградацией или сбоем */}
{(instancesOffline.length > 0 || instancesDegraded.length > 0) && (
<div className="rounded-xl border border-krona-border bg-krona-surface p-4 space-y-3">
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<Layers className="w-4 h-4 text-status-warning-text" />
<span className="font-bold text-sm text-krona-primary">
Модули с деградацией ({instancesOffline.length + instancesDegraded.length})
</span>
</div>
<button
type="button"
onClick={() => navigate('/control/modules')}
className="min-h-[44px] px-3 text-xs text-krona-gold hover:underline flex items-center gap-1 font-medium"
>
<span>Каталог</span>
<ArrowUpRight className="w-3.5 h-3.5" />
</button>
</div>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-2.5">
{[...instancesOffline, ...instancesDegraded].map((inst) => (
<div
key={inst.id}
onClick={() => openInspector({ type: 'instance', id: inst.id, title: inst.moduleCode, data: inst })}
className="p-3 rounded-lg border border-krona-border bg-krona-workspace cursor-pointer hover:border-krona-gold/40 transition-colors flex items-center justify-between"
>
<div>
<div className="font-bold text-xs text-krona-primary">{inst.moduleCode} v{inst.version}</div>
<div className="text-[11px] text-krona-tertiary">Организация: {inst.organizationId}</div>
</div>
<StatusBadge status={inst.health} size="sm" />
</div>
))}
</div>
</div>
)}
{/* 4. Истекающие и просроченные подписки */}
{(expiring7d.length > 0 || overdueSubs.length > 0) && (
<div className="rounded-xl border border-krona-border bg-krona-surface p-4 space-y-3">
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<CreditCard className="w-4 h-4 text-status-warning-text" />
<span className="font-bold text-sm text-krona-primary">
Истекающие подписки ({expiring7d.length + overdueSubs.length})
</span>
</div>
<button
type="button"
onClick={() => navigate('/control/billing')}
className="min-h-[44px] px-3 text-xs text-krona-gold hover:underline flex items-center gap-1 font-medium"
>
<span>Биллинг</span>
<ArrowUpRight className="w-3.5 h-3.5" />
</button>
</div>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-2.5">
{[...overdueSubs, ...expiring7d].map((sub) => (
<div
key={sub.id}
onClick={() => openInspector({ type: 'subscription', id: sub.id, title: sub.organizationName, data: sub })}
className="p-3 rounded-lg border border-krona-border bg-krona-workspace cursor-pointer hover:border-krona-gold/40 transition-colors flex items-center justify-between"
>
<div>
<div className="font-bold text-xs text-krona-primary">{sub.organizationName}</div>
<div className="text-[11px] text-krona-tertiary">
Тариф: {sub.planName} • До: {new Date(sub.validUntil).toLocaleDateString('ru-RU')}
</div>
</div>
<div className="text-right">
<MoneyDisplay minorUnits={sub.priceMinorUnits} currency={sub.currency} className="font-bold text-xs text-krona-primary" />
<div className="mt-1"><StatusBadge status={sub.status} size="sm" /></div>
</div>
</div>
))}
</div>
</div>
)}
</div>
)}
{/* ========================================================================= */}
{/* TAB 2: ПОКАЗАТЕЛИ (KEY METRICS & KPI) */}
{/* ========================================================================= */}
{activeTab === 'metrics' && (
<div className="space-y-4 animate-in fade-in duration-150" data-testid="metrics-section">
{/* 6 Key KPI Cards */}
<div className="grid grid-cols-2 sm:grid-cols-3 xl:grid-cols-6 gap-2.5 sm:gap-3">
{/* Orgs */}
<div
onClick={() => navigate('/control/organizations')}
className="p-3.5 rounded-xl border border-krona-border bg-krona-surface hover:border-krona-gold/40 cursor-pointer transition-colors"
>
<div className="flex items-center justify-between text-krona-tertiary mb-1">
<span className="text-[11px] font-medium">Организации</span>
<Building2 className="w-4 h-4 text-krona-gold" />
</div>
<div className="text-lg sm:text-xl font-bold text-krona-primary">{totalOrgs}</div>
<div className="text-[11px] text-status-success-text mt-0.5">{activeOrgs} активных</div>
</div>
{/* Users */}
<div className="p-3.5 rounded-xl border border-krona-border bg-krona-surface">
<div className="flex items-center justify-between text-krona-tertiary mb-1">
<span className="text-[11px] font-medium">Пользователи</span>
<Users className="w-4 h-4 text-krona-gold" />
</div>
<div className="text-lg sm:text-xl font-bold text-krona-primary">{totalUsers}</div>
<div className="text-[11px] text-krona-secondary mt-0.5">{totalBranches} филиалов</div>
</div>
{/* MRR */}
<div
onClick={() => navigate('/control/billing')}
className="p-3.5 rounded-xl border border-krona-border bg-krona-surface hover:border-krona-gold/40 cursor-pointer transition-colors col-span-2 sm:col-span-1"
>
<div className="flex items-center justify-between text-krona-tertiary mb-1">
<span className="text-[11px] font-medium">Выручка (MRR)</span>
<CreditCard className="w-4 h-4 text-krona-gold" />
</div>
<MoneyDisplay
minorUnits={totalMrrMinorUnits}
currency="RUB"
className="text-lg sm:text-xl font-bold text-krona-gold"
/>
<div className="text-[11px] text-krona-secondary mt-0.5">В месяц</div>
</div>
{/* Subscriptions */}
<div
onClick={() => navigate('/control/billing')}
className="p-3.5 rounded-xl border border-krona-border bg-krona-surface hover:border-krona-gold/40 cursor-pointer transition-colors"
>
<div className="flex items-center justify-between text-krona-tertiary mb-1">
<span className="text-[11px] font-medium">Подписки</span>
<Clock className="w-4 h-4 text-krona-gold" />
</div>
<div className="text-lg sm:text-xl font-bold text-krona-primary">{subscriptions.length}</div>
<div className="text-[11px] text-krona-secondary mt-0.5">{expiring7d.length} истекают</div>
</div>
{/* Installations */}
<div
onClick={() => navigate('/control/installations')}
className="p-3.5 rounded-xl border border-krona-border bg-krona-surface hover:border-krona-gold/40 cursor-pointer transition-colors"
>
<div className="flex items-center justify-between text-krona-tertiary mb-1">
<span className="text-[11px] font-medium">Установки</span>
<Smartphone className="w-4 h-4 text-krona-gold" />
</div>
<div className="text-lg sm:text-xl font-bold text-krona-primary">{installations.length}</div>
<div className="text-[11px] text-krona-secondary mt-0.5">Android: {androidInstalls}</div>
</div>
{/* Servers */}
<div
onClick={() => navigate('/control/infrastructure')}
className="p-3.5 rounded-xl border border-krona-border bg-krona-surface hover:border-krona-gold/40 cursor-pointer transition-colors"
>
<div className="flex items-center justify-between text-krona-tertiary mb-1">
<span className="text-[11px] font-medium">Серверы</span>
<Server className="w-4 h-4 text-krona-gold" />
</div>
<div className="text-lg sm:text-xl font-bold text-krona-primary">{servers.length}</div>
<div className="text-[11px] text-status-success-text mt-0.5">{serversHealthy} в норме</div>
</div>
</div>
{/* Detailed Statistics Cards */}
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
{/* Orgs Breakdown */}
<div className="rounded-xl border border-krona-border bg-krona-surface p-4 space-y-3">
<div className="font-bold text-sm text-krona-primary flex items-center justify-between">
<span>Статус организаций</span>
<span className="text-xs text-krona-secondary font-normal">Всего: {totalOrgs}</span>
</div>
<div className="space-y-2">
<div className="flex items-center justify-between text-xs">
<span className="flex items-center gap-2">
<span className="w-2.5 h-2.5 rounded-full bg-status-success-text" />
<span className="text-krona-secondary">Активные организации</span>
</span>
<span className="font-bold text-krona-primary">{activeOrgs}</span>
</div>
<div className="flex items-center justify-between text-xs">
<span className="flex items-center gap-2">
<span className="w-2.5 h-2.5 rounded-full bg-status-info-text" />
<span className="text-krona-secondary">Пробный период (Trial)</span>
</span>
<span className="font-bold text-krona-primary">{trialOrgs}</span>
</div>
<div className="flex items-center justify-between text-xs">
<span className="flex items-center gap-2">
<span className="w-2.5 h-2.5 rounded-full bg-status-warning-text" />
<span className="text-krona-secondary">Льготный период (Grace)</span>
</span>
<span className="font-bold text-krona-primary">{graceOrgs}</span>
</div>
<div className="flex items-center justify-between text-xs">
<span className="flex items-center gap-2">
<span className="w-2.5 h-2.5 rounded-full bg-status-danger-text" />
<span className="text-krona-secondary">Приостановлены (Suspended)</span>
</span>
<span className="font-bold text-krona-primary">{suspendedOrgs}</span>
</div>
</div>
</div>
{/* Installations Breakdown */}
<div className="rounded-xl border border-krona-border bg-krona-surface p-4 space-y-3">
<div className="font-bold text-sm text-krona-primary flex items-center justify-between">
<span>Платформы клиентов</span>
<span className="text-xs text-krona-secondary font-normal">Всего: {installations.length}</span>
</div>
<div className="space-y-2">
<div className="flex items-center justify-between text-xs">
<span className="text-krona-secondary">Веб-приложения (Web SPA)</span>
<span className="font-bold text-krona-primary">{webInstalls}</span>
</div>
<div className="flex items-center justify-between text-xs">
<span className="text-krona-secondary">Рабочие станции (Desktop)</span>
<span className="font-bold text-krona-primary">{desktopInstalls}</span>
</div>
<div className="flex items-center justify-between text-xs">
<span className="text-krona-secondary">Мобильные кассы (Android)</span>
<span className="font-bold text-krona-primary">{androidInstalls}</span>
</div>
{outdatedAndroid > 0 && (
<div className="flex items-center justify-between text-xs text-status-warning-text font-medium pt-1 border-t border-krona-border">
<span>Устаревшие версии Android:</span>
<span className="font-bold">{outdatedAndroid}</span>
</div>
)}
</div>
</div>
</div>
</div>
)}
{/* ========================================================================= */}
{/* TAB 3: ИНФРАСТРУКТУРА (SERVERS & TELEMETRY) */}
{/* ========================================================================= */}
{activeTab === 'infrastructure' && (
<div className="space-y-4 animate-in fade-in duration-150" data-testid="infrastructure-section">
<div className="rounded-xl border border-krona-border bg-krona-surface p-4 space-y-3">
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<Server className="w-4 h-4 text-amber-400" />
<h3 className="text-sm font-bold text-zinc-200">
Узлы серверной инфраструктуры (Server Monitor Live)
</h3>
<div>
<h3 className="font-bold text-sm text-krona-primary">Инфраструктурные серверы Krona</h3>
<p className="text-xs text-krona-secondary">Текущая загрузка вычислительных узлов платформы</p>
</div>
<button
type="button"
onClick={() => navigate('/control/infrastructure')}
className="text-xs text-amber-400 hover:text-amber-300 font-medium flex items-center gap-1"
className="min-h-[44px] px-3 text-xs text-krona-gold hover:underline flex items-center gap-1 font-medium"
>
<span>Все серверы ({servers.length})</span>
<span>Управление</span>
<ArrowUpRight className="w-3.5 h-3.5" />
</button>
</div>
<div className="space-y-2.5">
{servers.slice(0, 5).map((srv) => (
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-3">
{servers.map((srv) => (
<div
key={srv.serverId}
onClick={() => openInspector('server', srv.serverId)}
className="p-3 rounded-lg bg-zinc-950/60 border border-zinc-800/80 hover:border-zinc-700 cursor-pointer transition-colors flex flex-col sm:flex-row sm:items-center justify-between gap-3 text-xs"
key={srv.id}
onClick={() => openInspector({ type: 'server', id: srv.id, title: srv.hostname, data: srv })}
className="p-3.5 rounded-lg border border-krona-border bg-krona-workspace cursor-pointer hover:border-krona-gold/40 transition-colors space-y-2.5"
>
<div className="space-y-1">
<div className="flex items-center gap-2">
<span className="font-bold text-zinc-100">{srv.name}</span>
<StatusBadge status={srv.status} size="sm" />
<span className="text-[10px] text-zinc-400 font-mono">
{srv.hostingProvider} • {srv.region}
</span>
<div className="flex items-center justify-between">
<div>
<div className="font-bold text-xs text-krona-primary font-mono">{srv.hostname}</div>
<div className="text-[11px] text-krona-tertiary">{srv.organizationName}</div>
</div>
<div className="text-zinc-400 font-mono text-[11px]">
IP: {srv.ipAddress} • CPU: {srv.cpuLoadPercent}% • RAM:{' '}
{Math.round((srv.ramUsedBytes / srv.ramTotalBytes) * 100)}% • Диск:{' '}
{Math.round((srv.diskUsedBytes / srv.diskTotalBytes) * 100)}%
<StatusBadge status={srv.status} size="sm" />
</div>
<div className="grid grid-cols-3 gap-2 text-center text-xs py-1 bg-krona-surface/40 rounded-md border border-krona-border">
<div>
<div className="text-[10px] text-krona-tertiary">CPU</div>
<div className={`font-mono font-bold ${srv.metrics.cpuPercent > 80 ? 'text-status-danger-text' : 'text-krona-primary'}`}>
{srv.metrics.cpuPercent}%
</div>
</div>
<div>
<div className="text-[10px] text-krona-tertiary">RAM</div>
<div className={`font-mono font-bold ${srv.metrics.ramPercent > 80 ? 'text-status-danger-text' : 'text-krona-primary'}`}>
{srv.metrics.ramPercent}%
</div>
</div>
<div>
<div className="text-[10px] text-krona-tertiary">Диск</div>
<div className={`font-mono font-bold ${srv.metrics.diskPercent > 85 ? 'text-status-danger-text' : 'text-krona-primary'}`}>
{srv.metrics.diskPercent}%
</div>
</div>
</div>
<div className="flex items-center gap-4 shrink-0">
<div className="space-y-1 text-right">
<div className="text-[10px] text-zinc-400">Heartbeat (24h)</div>
<HeartbeatTimeline status={srv.status} count={16} />
</div>
</div>
</div>
))}
</div>
</div>
{/* Section: Offline & Degraded Module Instances Alert Lane */}
<div className="rounded-xl border border-zinc-800 bg-[#091a13] p-4 space-y-3">
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<Radio className="w-4 h-4 text-rose-400" />
<h3 className="text-sm font-bold text-zinc-200">
Модули, требующие вмешательства (Degraded / Offline)
</h3>
</div>
<span className="text-xs text-zinc-400">
{instancesOffline.length + instancesDegraded.length} инстансов
</span>
</div>
<div className="space-y-2">
{[...instancesOffline, ...instancesDegraded].map((inst) => (
<div
key={inst.moduleInstanceId}
onClick={() => openInspector('server', inst.serverId)}
className="p-3 rounded-lg bg-zinc-950/70 border border-zinc-800/90 hover:border-zinc-700 cursor-pointer transition-colors flex items-center justify-between text-xs"
>
<div className="space-y-0.5">
<div className="flex items-center gap-2">
<span className="font-semibold text-zinc-100">{inst.moduleName}</span>
<StatusBadge status={inst.health} size="sm" />
<span className="text-[10px] text-zinc-400 font-mono">v{inst.currentVersion}</span>
</div>
<div className="text-zinc-400 text-[11px]">
Тенант: <span className="text-zinc-200">{inst.tenantName}</span> • Узел:{' '}
<span className="text-amber-400/90 font-mono">{inst.serverName}</span>
</div>
</div>
<div className="text-right">
<span className="text-[11px] text-zinc-400">
Last HB: {new Date(inst.lastHeartbeatAt).toLocaleTimeString('ru-RU')}
<div className="text-[11px] text-krona-tertiary flex items-center justify-between">
<span>Резервная копия:</span>
<span className={srv.backup.state === 'failed' ? 'text-status-danger-text font-bold' : 'text-status-success-text'}>
{srv.backup.state === 'healthy' ? 'Успешно' : srv.backup.state === 'failed' ? 'Ошибка' : srv.backup.state}
</span>
</div>
</div>
))}
{instancesOffline.length + instancesDegraded.length === 0 && (
<div className="p-4 rounded-lg bg-zinc-950/40 text-center text-xs text-zinc-500">
Все экземпляры модулей функционируют в штатном режиме
</div>
)}
</div>
</div>
</div>
{/* Right Column: Operator Action Queue (Очередь операторских действий) */}
<div className="space-y-6">
<div className="rounded-xl border border-amber-500/30 bg-[#0a1c15] p-4 space-y-4">
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<ShieldAlert className="w-4 h-4 text-amber-400" />
<h3 className="text-sm font-bold text-amber-300">Очередь внимания (Action Queue)</h3>
</div>
<span className="text-[10px] px-2 py-0.5 rounded-full bg-amber-500/20 text-amber-300 font-mono font-bold">
{openIncidents.length + expiring7d.length + outdatedAndroid} событий
</span>
</div>
<div className="space-y-3 text-xs">
{/* Critical Incidents */}
{openIncidents.map((inc) => (
<div
key={inc.incidentId}
onClick={() => openInspector('incident', inc.incidentId)}
className="p-3 rounded-lg bg-rose-950/40 border border-rose-800/50 hover:border-rose-700 cursor-pointer transition-colors space-y-1.5"
>
<div className="flex justify-between items-center">
<span className="font-mono font-bold text-rose-300">{inc.incidentNumber}</span>
<StatusBadge status={inc.status} size="sm" />
<div className="pt-1">
<HeartbeatTimeline history={srv.metrics.heartbeatHistory} compact />
</div>
<p className="font-semibold text-zinc-100">{inc.summary}</p>
<div className="text-[11px] text-zinc-400 flex justify-between">
<span>Severity: {inc.severity.toUpperCase()}</span>
<span>{new Date(inc.openedAt).toLocaleTimeString('ru-RU')}</span>
</div>
</div>
))}
{/* Expiring Subscriptions (7 days) */}
{expiring7d.map((sub) => (
<div
key={sub.subscriptionId}
onClick={() => navigate('/control/billing')}
className="p-3 rounded-lg bg-amber-950/30 border border-amber-700/40 hover:border-amber-600 cursor-pointer transition-colors space-y-1.5"
>
<div className="flex justify-between items-center">
<span className="font-semibold text-amber-200">{sub.tenantName}</span>
<StatusBadge status="grace" customLabel="7 дней" size="sm" />
</div>
<div className="flex justify-between text-[11px] text-zinc-400">
<span>Сумма тарифа:</span>
<MoneyDisplay minorUnits={sub.amountMinorUnits} className="text-amber-400 font-bold" />
</div>
<div className="text-[10px] text-zinc-500">
Окончание: {new Date(sub.validUntil).toLocaleDateString('ru-RU')}
</div>
</div>
))}
{/* Outdated Android Terminals */}
{outdatedAndroid > 0 && (
<div
onClick={() => navigate('/control/installations')}
className="p-3 rounded-lg bg-zinc-950 border border-zinc-800 hover:border-zinc-700 cursor-pointer transition-colors space-y-1.5"
>
<div className="flex justify-between items-center">
<span className="font-semibold text-zinc-200">Устаревшие ТСД Android</span>
<StatusBadge status="outdated" size="sm" />
</div>
<p className="text-[11px] text-zinc-400">
Обнаружено {outdatedAndroid} терминалов Zebra/Honeywell с устаревшей версией приложения.
</p>
</div>
)}
{/* Failed Backups */}
{failedBackups.map((srv) => (
<div
key={srv.serverId}
onClick={() => openInspector('server', srv.serverId)}
className="p-3 rounded-lg bg-rose-950/30 border border-rose-800/40 hover:border-rose-700 cursor-pointer transition-colors space-y-1.5"
>
<div className="flex justify-between items-center">
<span className="font-semibold text-rose-300">Сбой резервного копирования</span>
<StatusBadge status="critical" size="sm" customLabel="BACKUP FAIL" />
</div>
<p className="text-[11px] text-zinc-300">
Узел {srv.name}: No space left on device при ночной ротации.
</p>
</div>
))}
</div>
</div>
</div>
</div>
)}
</div>
);
};