feat(control-center): adapt AnnouncementsPage and IncidentsPage mobile-first styling

This commit is contained in:
ochenstarik 2026-09-11 09:38:37 +07:00
parent b81206a2bb
commit b8739a5096
2 changed files with 231 additions and 137 deletions

View file

@ -3,9 +3,13 @@ import {
BellRing, BellRing,
Send, Send,
Plus, Plus,
X,
Users,
CheckCircle2,
} from 'lucide-react'; } from 'lucide-react';
import { useKccStore, useOperatorRole } from '../store/kccStore'; import { useKccStore, useOperatorRole } from '../store/kccStore';
import { StatusBadge } from '../components/StatusBadge'; import { StatusBadge } from '../components/StatusBadge';
import { RU_ANNOUNCEMENT_PRIORITY } from '../locales/ru';
export const AnnouncementsPage: React.FC = () => { export const AnnouncementsPage: React.FC = () => {
const announcements = useKccStore((state) => state.announcements); const announcements = useKccStore((state) => state.announcements);
@ -74,15 +78,15 @@ export const AnnouncementsPage: React.FC = () => {
}; };
return ( return (
<div className="space-y-6"> <div className="space-y-4 md:space-y-6">
{/* Header */} {/* 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-3 border-b border-krona-border pb-4">
<div> <div>
<h1 className="text-xl font-bold text-zinc-100 flex items-center gap-2"> <h1 className="text-lg md:text-xl font-bold text-krona-primary flex items-center gap-2">
<BellRing className="w-5 h-5 text-amber-400" /> <BellRing className="w-5 h-5 text-amber-500 shrink-0" />
<span>Оповещения и системные сообщения</span> <span>Оповещения и системные сообщения</span>
</h1> </h1>
<p className="text-xs text-zinc-400 mt-0.5"> <p className="text-xs text-krona-secondary mt-0.5">
Таргетированные сервисные рассылки, уведомления об обновлениях модулей и плановом ТО Таргетированные сервисные рассылки, уведомления об обновлениях модулей и плановом ТО
</p> </p>
</div> </div>
@ -91,102 +95,105 @@ export const AnnouncementsPage: React.FC = () => {
type="button" type="button"
disabled={!permissions.canCreateAnnouncements} disabled={!permissions.canCreateAnnouncements}
onClick={() => setIsCreating(!isCreating)} onClick={() => setIsCreating(!isCreating)}
className="px-3 py-1.5 rounded-lg bg-amber-500 hover:bg-amber-400 text-zinc-950 font-bold text-xs flex items-center gap-1.5 transition-colors disabled:opacity-40" className="min-h-[44px] px-4 py-2.5 rounded-lg bg-amber-500 hover:bg-amber-400 text-zinc-950 font-bold text-xs flex items-center justify-center gap-2 transition-colors disabled:opacity-40 shadow-xs shrink-0"
> >
<Plus className="w-4 h-4" /> {isCreating ? <X className="w-4 h-4" /> : <Plus className="w-4 h-4" />}
<span>{isCreating ? 'Закрыть редактор' : 'Создать объявление'}</span> <span>{isCreating ? 'Закрыть редактор' : 'Создать оповещение'}</span>
</button> </button>
</div> </div>
{/* New Announcement Form (Сценарий 7) */} {/* New Announcement Form (Modal or Expandable) */}
{isCreating && ( {isCreating && (
<form <form
onSubmit={handleSendAnnouncement} onSubmit={handleSendAnnouncement}
className="rounded-xl border border-amber-500/40 bg-[#0a1c15] p-5 space-y-4 shadow-xl animate-in fade-in slide-in-from-top-2" className="rounded-xl border border-amber-500/40 bg-krona-surface p-4 md:p-5 space-y-4 shadow-lg animate-in fade-in slide-in-from-top-2"
> >
<div className="flex justify-between items-center border-b border-zinc-800 pb-3"> <div className="flex justify-between items-center border-b border-krona-border pb-3">
<div className="flex items-center gap-2 text-sm font-bold text-zinc-100"> <div className="flex items-center gap-2 text-sm font-bold text-krona-primary">
<Send className="w-4 h-4 text-amber-400" /> <Send className="w-4 h-4 text-amber-500" />
<span>Конструктор операторского оповещения</span> <span>Конструктор операторского оповещения</span>
</div> </div>
<span className="text-xs text-amber-300/80 font-mono">DEMO: без внешней отправки SMS/Email</span> <span className="text-[11px] text-amber-600 dark:text-amber-300 font-mono">
ДЕМО: In-App доставка
</span>
</div> </div>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4 text-xs"> <div className="grid grid-cols-1 md:grid-cols-2 gap-4 text-xs">
{/* Title */} {/* Title */}
<div className="space-y-1.5"> <div className="space-y-1.5">
<label className="font-semibold text-zinc-300">Заголовок оповещения:</label> <label className="font-semibold text-krona-primary">Заголовок оповещения:</label>
<input <input
type="text" type="text"
required required
value={title} value={title}
onChange={(e) => setTitle(e.target.value)} onChange={(e) => setTitle(e.target.value)}
placeholder="Например: Доступен модуль Omnichannel 3.0" placeholder="Например: Доступно обновление платформы v3.0"
className="w-full bg-zinc-950 border border-zinc-700 rounded-lg p-2.5 text-xs text-zinc-100 placeholder-zinc-500 focus:outline-hidden focus:border-amber-500" className="w-full bg-krona-workspace border border-krona-border rounded-lg px-3 py-2.5 text-xs text-krona-primary placeholder:text-krona-secondary/60 focus:outline-hidden focus:border-amber-500 min-h-[44px]"
/> />
</div> </div>
{/* Priority */} {/* Priority */}
<div className="space-y-1.5"> <div className="space-y-1.5">
<label className="font-semibold text-zinc-300">Приоритет доставки:</label> <label className="font-semibold text-krona-primary">Приоритет доставки:</label>
<select <select
value={priority} value={priority}
onChange={(e) => setPriority(e.target.value as typeof priority)} onChange={(e) => setPriority(e.target.value as typeof priority)}
className="w-full bg-zinc-950 border border-zinc-700 rounded-lg p-2.5 text-xs text-zinc-100 focus:outline-hidden" className="w-full bg-krona-workspace border border-krona-border rounded-lg px-3 py-2.5 text-xs text-krona-primary focus:outline-hidden min-h-[44px]"
> >
<option value="normal">Обычный (Информационный баннер)</option> <option value="normal">Обычный (Информационный баннер)</option>
<option value="important">Важный (Уведомление в шапке)</option> <option value="important">Важный (Уведомление в шапке)</option>
<option value="critical">Критический (Блокирующий модальный диалог)</option> <option value="critical">Критический (Блокирующий диалог)</option>
</select> </select>
</div> </div>
</div> </div>
{/* Audience selection */} {/* Audience selection */}
<div className="space-y-2 text-xs"> <div className="space-y-2 text-xs">
<label className="font-semibold text-zinc-300">Целевая аудитория тенантов:</label> <label className="font-semibold text-krona-primary">Целевая аудитория организаций:</label>
<div className="grid grid-cols-3 gap-2"> <div className="grid grid-cols-1 sm:grid-cols-3 gap-2">
<button <button
type="button" type="button"
onClick={() => setAudienceType('all')} onClick={() => setAudienceType('all')}
className={`p-2 rounded-lg border text-xs font-medium transition-colors ${ className={`min-h-[44px] p-2.5 rounded-lg border text-xs font-medium transition-colors flex items-center justify-center gap-1.5 ${
audienceType === 'all' audienceType === 'all'
? 'bg-amber-500/20 text-amber-300 border-amber-500/50 font-bold' ? 'bg-amber-500/15 text-amber-600 dark:text-amber-300 border-amber-500/50 font-bold'
: 'bg-zinc-950 text-zinc-400 border-zinc-800' : 'bg-krona-workspace text-krona-secondary border-krona-border hover:text-krona-primary'
}`} }`}
> >
Все организации ({organizations.length}) <Users className="w-3.5 h-3.5" />
<span>Все ({organizations.length})</span>
</button> </button>
<button <button
type="button" type="button"
onClick={() => setAudienceType('module')} onClick={() => setAudienceType('module')}
className={`p-2 rounded-lg border text-xs font-medium transition-colors ${ className={`min-h-[44px] p-2.5 rounded-lg border text-xs font-medium transition-colors flex items-center justify-center gap-1.5 ${
audienceType === 'module' audienceType === 'module'
? 'bg-amber-500/20 text-amber-300 border-amber-500/50 font-bold' ? 'bg-amber-500/15 text-amber-600 dark:text-amber-300 border-amber-500/50 font-bold'
: 'bg-zinc-950 text-zinc-400 border-zinc-800' : 'bg-krona-workspace text-krona-secondary border-krona-border hover:text-krona-primary'
}`} }`}
> >
По установленному модулю <span>По модулю</span>
</button> </button>
<button <button
type="button" type="button"
onClick={() => setAudienceType('specific')} onClick={() => setAudienceType('specific')}
className={`p-2 rounded-lg border text-xs font-medium transition-colors ${ className={`min-h-[44px] p-2.5 rounded-lg border text-xs font-medium transition-colors flex items-center justify-center gap-1.5 ${
audienceType === 'specific' audienceType === 'specific'
? 'bg-amber-500/20 text-amber-300 border-amber-500/50 font-bold' ? 'bg-amber-500/15 text-amber-600 dark:text-amber-300 border-amber-500/50 font-bold'
: 'bg-zinc-950 text-zinc-400 border-zinc-800' : 'bg-krona-workspace text-krona-secondary border-krona-border hover:text-krona-primary'
}`} }`}
> >
Выборочные организации ({selectedTenants.length}) <span>Выборочные ({selectedTenants.length})</span>
</button> </button>
</div> </div>
{audienceType === 'module' && ( {audienceType === 'module' && (
<div className="pt-2 flex items-center gap-2"> <div className="pt-2 flex flex-col sm:flex-row sm:items-center gap-2">
<span className="text-zinc-400 text-xs">Выберите модуль:</span> <span className="text-krona-secondary text-xs">Выберите модуль:</span>
<select <select
value={selectedModuleId} value={selectedModuleId}
onChange={(e) => setSelectedModuleId(e.target.value)} onChange={(e) => setSelectedModuleId(e.target.value)}
className="bg-zinc-900 border border-zinc-700 rounded-md px-2.5 py-1 text-xs text-zinc-200" className="bg-krona-workspace border border-krona-border rounded-lg px-3 py-2 text-xs text-krona-primary min-h-[44px]"
> >
{modules.map((m) => ( {modules.map((m) => (
<option key={m.moduleId} value={m.moduleId}> <option key={m.moduleId} value={m.moduleId}>
@ -200,73 +207,94 @@ export const AnnouncementsPage: React.FC = () => {
{/* Body */} {/* Body */}
<div className="space-y-1.5 text-xs"> <div className="space-y-1.5 text-xs">
<label className="font-semibold text-zinc-300">Текст сообщения:</label> <label className="font-semibold text-krona-primary">Текст сообщения:</label>
<textarea <textarea
required required
rows={3} rows={3}
value={body} value={body}
onChange={(e) => setBody(e.target.value)} onChange={(e) => setBody(e.target.value)}
placeholder="Введите текст объявления для операторов и пользователей организаций..." placeholder="Введите текст объявления для операторов и пользователей организаций..."
className="w-full bg-zinc-950 border border-zinc-700 rounded-lg p-2.5 text-xs text-zinc-100 placeholder-zinc-500 focus:outline-hidden focus:border-amber-500 resize-none" className="w-full bg-krona-workspace border border-krona-border rounded-lg p-3 text-xs text-krona-primary placeholder:text-krona-secondary/60 focus:outline-hidden focus:border-amber-500 resize-none"
/> />
</div> </div>
{/* Actions */} {/* Actions */}
<div className="flex justify-end gap-2 pt-2 border-t border-zinc-800"> <div className="flex flex-col-reverse sm:flex-row justify-end gap-2 pt-2 border-t border-krona-border">
<button <button
type="button" type="button"
onClick={() => setIsCreating(false)} onClick={() => setIsCreating(false)}
className="px-3 py-1.5 rounded-lg border border-zinc-700 text-zinc-400 hover:text-zinc-200 text-xs font-medium" className="min-h-[44px] px-4 py-2.5 rounded-lg border border-krona-border text-krona-secondary hover:text-krona-primary text-xs font-medium transition-colors"
> >
Отмена Отмена
</button> </button>
<button <button
type="submit" type="submit"
className="px-4 py-1.5 rounded-lg bg-amber-500 hover:bg-amber-400 text-zinc-950 font-bold text-xs flex items-center gap-1.5 shadow-md shadow-amber-900/30" className="min-h-[44px] px-5 py-2.5 rounded-lg bg-amber-500 hover:bg-amber-400 text-zinc-950 font-bold text-xs flex items-center justify-center gap-2 shadow-xs"
> >
<Send className="w-3.5 h-3.5" /> <Send className="w-4 h-4" />
<span>Опубликовать (Reason + Audit)</span> <span>Опубликовать с подтверждением</span>
</button> </button>
</div> </div>
</form> </form>
)} )}
{/* Announcements List */} {/* Announcements List - Mobile-First Cards */}
<div className="space-y-3"> <div className="space-y-3">
{announcements.map((anc) => ( {announcements.map((anc) => {
<div key={anc.announcementId} className="rounded-xl border border-zinc-800 bg-[#091a13] p-5 space-y-3 shadow-lg"> const priorityLabel = RU_ANNOUNCEMENT_PRIORITY[anc.priority] || anc.priority;
<div className="flex flex-col sm:flex-row sm:items-center justify-between gap-2 border-b border-zinc-850 pb-3"> const isCritical = anc.priority === 'critical';
const isImportant = anc.priority === 'important';
return (
<div
key={anc.announcementId}
className="rounded-xl border border-krona-border bg-krona-surface p-4 md:p-5 space-y-3 shadow-xs"
>
<div className="flex flex-col sm:flex-row sm:items-center justify-between gap-2 border-b border-krona-border pb-3">
<div> <div>
<div className="flex items-center gap-2 mb-1"> <div className="flex flex-wrap items-center gap-2 mb-1">
<span className="font-bold text-sm text-zinc-100">{anc.title}</span> <span className="font-bold text-sm text-krona-primary">{anc.title}</span>
<StatusBadge status={anc.status === 'completed' ? 'healthy' : 'warning'} size="sm" customLabel={anc.status.toUpperCase()} /> <StatusBadge
<span className="text-[10px] uppercase font-bold text-amber-400 px-1.5 py-0.5 rounded-xs bg-amber-500/10 border border-amber-500/30"> status={anc.status === 'completed' ? 'healthy' : 'warning'}
{anc.priority} size="sm"
customLabel={anc.status === 'completed' ? 'Доставлено' : 'Отправка'}
/>
<span
className={`text-[10px] font-bold px-2 py-0.5 rounded-md border ${
isCritical
? 'bg-rose-500/15 text-rose-600 dark:text-rose-300 border-rose-500/40'
: isImportant
? 'bg-amber-500/15 text-amber-600 dark:text-amber-300 border-amber-500/40'
: 'bg-krona-workspace text-krona-secondary border-krona-border'
}`}
>
{priorityLabel}
</span> </span>
</div> </div>
<div className="text-[11px] text-zinc-400"> <div className="text-[11px] text-krona-secondary">
Автор: <span className="text-zinc-200">{anc.author}</span> • Опубликовано:{' '} Автор: <span className="text-krona-primary font-medium">{anc.author}</span> • Опубликовано:{' '}
{new Date(anc.createdAt).toLocaleString('ru-RU')} {new Date(anc.createdAt).toLocaleDateString('ru-RU')}
</div> </div>
</div> </div>
{/* Delivery statistics */} {/* Delivery statistics */}
<div className="flex items-center gap-3 text-xs shrink-0 font-mono"> <div className="flex items-center gap-3 text-xs shrink-0 font-mono">
<div className="text-right"> <div className="text-left sm:text-right">
<div className="text-[10px] text-zinc-500">Доставлено</div> <div className="text-[10px] text-krona-secondary">Доставлено</div>
<div className="font-bold text-emerald-400">{anc.stats.delivered} / {anc.stats.totalTargeted}</div> <div className="font-bold text-emerald-600 dark:text-emerald-400 flex items-center gap-1">
<CheckCircle2 className="w-3 h-3 inline" />
<span>{anc.stats.delivered} / {anc.stats.totalTargeted}</span>
</div> </div>
<div className="text-right">
<div className="text-[10px] text-zinc-500">Каналы</div>
<div className="text-zinc-300 font-semibold">{anc.channels.join(', ')}</div>
</div> </div>
</div> </div>
</div> </div>
<p className="text-xs text-zinc-300 leading-relaxed">{anc.body}</p> <p className="text-xs text-krona-secondary leading-relaxed">{anc.body}</p>
</div> </div>
))} );
})}
</div> </div>
</div> </div>
); );
}; };

View file

@ -3,16 +3,20 @@ import {
AlertTriangle, AlertTriangle,
Search, Search,
ChevronRight, ChevronRight,
ShieldAlert,
Server,
Building2,
Package,
} from 'lucide-react'; } from 'lucide-react';
import { useKccStore } from '../store/kccStore'; import { useKccStore } from '../store/kccStore';
import { StatusBadge } from '../components/StatusBadge'; import { StatusBadge } from '../components/StatusBadge';
import { Incident } from '../contracts'; import { RU_INCIDENT_SEVERITY, RU_INCIDENT_STATUS } from '../locales/ru';
export const IncidentsPage: React.FC = () => { export const IncidentsPage: React.FC = () => {
const incidents = useKccStore((state) => state.incidents); const incidents = useKccStore((state) => state.incidents);
const openInspector = useKccStore((state) => state.openInspector); const openInspector = useKccStore((state) => state.openInspector);
const [statusFilter, setStatusFilter] = useState<'all' | Incident['status']>('all'); const [statusFilter, setStatusFilter] = useState<'all' | 'open' | 'mitigating' | 'resolved'>('all');
const [search, setSearch] = useState(''); const [search, setSearch] = useState('');
const filtered = incidents.filter((inc) => { const filtered = incidents.filter((inc) => {
@ -21,57 +25,81 @@ export const IncidentsPage: React.FC = () => {
inc.incidentNumber.toLowerCase().includes(search.toLowerCase()) || inc.incidentNumber.toLowerCase().includes(search.toLowerCase()) ||
inc.summary.toLowerCase().includes(search.toLowerCase()) || inc.summary.toLowerCase().includes(search.toLowerCase()) ||
inc.description.toLowerCase().includes(search.toLowerCase()); inc.description.toLowerCase().includes(search.toLowerCase());
const matchStatus = statusFilter === 'all' || inc.status === statusFilter;
let matchStatus = true;
if (statusFilter === 'open') {
matchStatus = inc.status === 'investigating';
} else if (statusFilter === 'mitigating') {
matchStatus = inc.status === 'identified' || inc.status === 'monitoring';
} else if (statusFilter === 'resolved') {
matchStatus = inc.status === 'resolved';
}
return matchSearch && matchStatus; return matchSearch && matchStatus;
}); });
const unresolvedCount = incidents.filter((i) => i.status !== 'resolved').length;
const criticalCount = incidents.filter(
(i) => i.status !== 'resolved' && (i.severity === 'P0_CRITICAL' || i.severity === 'P1_MAJOR')
).length;
return ( return (
<div className="space-y-6"> <div className="space-y-4 md:space-y-6">
{/* Header */} {/* 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-3 border-b border-krona-border pb-4">
<div> <div>
<h1 className="text-xl font-bold text-zinc-100 flex items-center gap-2"> <h1 className="text-lg md:text-xl font-bold text-krona-primary flex items-center gap-2">
<AlertTriangle className="w-5 h-5 text-rose-400" /> <AlertTriangle className="w-5 h-5 text-rose-500 shrink-0" />
<span>Инциденты платформы (Incidents Desk)</span> <span>Журнал инцидентов платформы</span>
</h1> </h1>
<p className="text-xs text-zinc-400 mt-0.5"> <p className="text-xs text-krona-secondary mt-0.5">
Оперативное реагирование на сбои серверных узлов, деградацию модулей и инфраструктурные аварии Оперативное реагирование на сбои серверных узлов, деградацию модулей и инфраструктурные аварии
</p> </p>
</div> </div>
<div className="text-xs text-zinc-400"> <div className="flex flex-wrap items-center gap-2 text-xs text-krona-secondary">
Всего инцидентов: <span className="font-bold text-zinc-100">{incidents.length}</span> (открыто:{' '} <span className="px-2.5 py-1 rounded-md bg-krona-workspace border border-krona-border">
<span className="font-bold text-rose-400">{incidents.filter((i) => i.status !== 'resolved').length}</span>) Всего: <strong className="text-krona-primary">{incidents.length}</strong>
</span>
<span className="px-2.5 py-1 rounded-md bg-amber-500/10 border border-amber-500/30 text-amber-500 font-semibold">
В работе: <strong>{unresolvedCount}</strong>
</span>
{criticalCount > 0 && (
<span className="px-2.5 py-1 rounded-md bg-rose-500/10 border border-rose-500/30 text-rose-500 font-bold">
Критических: {criticalCount}
</span>
)}
</div> </div>
</div> </div>
{/* Filter and Search Bar */} {/* Filter and Search Bar */}
<div className="flex flex-col md:flex-row gap-3 items-stretch md:items-center justify-between"> <div className="flex flex-col md:flex-row gap-3 items-stretch md:items-center justify-between">
<div className="relative flex-1 max-w-md"> <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-secondary absolute left-3 top-1/2 -translate-y-1/2 pointer-events-none" />
<input <input
type="text" type="text"
value={search} value={search}
onChange={(e) => setSearch(e.target.value)} onChange={(e) => setSearch(e.target.value)}
placeholder="Поиск по номеру, описанию или теме..." 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-workspace border border-krona-border rounded-lg pl-9 pr-3 py-2.5 text-xs text-krona-primary placeholder:text-krona-secondary/60 focus:outline-hidden focus:border-amber-500 transition-colors min-h-[44px]"
/> />
</div> </div>
<div className="flex items-center gap-1 overflow-x-auto pb-1 text-xs"> {/* Status Filter Tabs with min 44px touch targets */}
<div className="flex items-center gap-1.5 overflow-x-auto pb-1 text-xs -mx-1 px-1">
{[ {[
{ id: 'all', label: 'Все инциденты' }, { id: 'all', label: 'Все инциденты' },
{ id: 'open', label: 'Открытые' }, { id: 'open', label: 'Открытые' },
{ id: 'mitigating', label: 'В процессе митигации' }, { id: 'mitigating', label: 'В процессе' },
{ id: 'resolved', label: 'Разрешенные' }, { id: 'resolved', label: 'Устранённые' },
].map((tab) => ( ].map((tab) => (
<button <button
key={tab.id} key={tab.id}
onClick={() => setStatusFilter(tab.id as typeof statusFilter)} 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 whitespace-nowrap ${
statusFilter === tab.id statusFilter === tab.id
? 'bg-amber-500/20 text-amber-300 border border-amber-500/40 font-bold' ? 'bg-amber-500/15 text-amber-600 dark:text-amber-300 border border-amber-500/40 font-bold shadow-xs'
: 'bg-zinc-900 text-zinc-400 border border-zinc-800 hover:text-zinc-200' : 'bg-krona-workspace text-krona-secondary border border-krona-border hover:text-krona-primary'
}`} }`}
> >
{tab.label} {tab.label}
@ -80,58 +108,96 @@ export const IncidentsPage: React.FC = () => {
</div> </div>
</div> </div>
{/* Incidents List */} {/* Incidents List - Mobile-First Cards */}
<div className="space-y-3"> <div className="space-y-3">
{filtered.map((inc) => ( {filtered.map((inc) => {
const isCritical = inc.severity === 'P0_CRITICAL';
const isMajor = inc.severity === 'P1_MAJOR';
const severityInfo = RU_INCIDENT_SEVERITY[inc.severity] || {
label: inc.severity,
short: inc.severity,
};
const statusText = RU_INCIDENT_STATUS[inc.status] || inc.status;
return (
<div <div
key={inc.incidentId} key={inc.incidentId}
onClick={() => openInspector('incident', inc.incidentId)} onClick={() => openInspector('incident', inc.incidentId)}
className="p-5 rounded-xl border border-zinc-800 bg-[#091a13] hover:border-zinc-700 cursor-pointer transition-colors space-y-3 shadow-lg group" className={`p-4 md:p-5 rounded-xl border bg-krona-surface transition-all cursor-pointer space-y-3 shadow-xs hover:border-amber-500/50 group ${
isCritical
? 'border-rose-500/40 bg-rose-500/5'
: isMajor
? 'border-amber-500/30'
: 'border-krona-border'
}`}
> >
<div className="flex flex-col sm:flex-row sm:items-center justify-between gap-2 border-b border-zinc-850 pb-3"> {/* Card Header */}
<div className="flex items-center gap-2.5"> <div className="flex flex-col sm:flex-row sm:items-center justify-between gap-2 border-b border-krona-border pb-3">
<span className="font-mono font-bold text-sm text-zinc-100 group-hover:text-amber-300 transition-colors"> <div className="flex flex-wrap items-center gap-2">
<span className="font-mono font-bold text-sm text-krona-primary group-hover:text-amber-500 transition-colors">
{inc.incidentNumber} {inc.incidentNumber}
</span> </span>
<StatusBadge status={inc.status} size="sm" /> <StatusBadge status={inc.status} size="sm" customLabel={statusText} />
<span className="text-[10px] uppercase font-extrabold px-2 py-0.5 rounded-xs bg-rose-950/80 text-rose-300 border border-rose-800/60 font-mono"> <span
{inc.severity} className={`text-[11px] font-bold px-2 py-0.5 rounded-md border font-mono ${
isCritical
? 'bg-rose-500/15 text-rose-600 dark:text-rose-300 border-rose-500/40'
: isMajor
? 'bg-amber-500/15 text-amber-600 dark:text-amber-300 border-amber-500/40'
: 'bg-sky-500/15 text-sky-600 dark:text-sky-300 border-sky-500/40'
}`}
>
{severityInfo.short}
</span> </span>
</div> </div>
<div className="flex items-center gap-3 text-xs text-zinc-400"> <div className="flex items-center gap-2 text-xs text-krona-secondary">
<span>Ответственный: <strong className="text-zinc-200">{inc.ownerName}</strong></span> <span>Ответственный: <strong className="text-krona-primary font-medium">{inc.ownerName}</strong></span>
<span>•</span> <span>•</span>
<span>{new Date(inc.openedAt).toLocaleString('ru-RU')}</span> <span>{new Date(inc.openedAt).toLocaleDateString('ru-RU')}</span>
</div> </div>
</div> </div>
{/* Summary and Description */}
<div className="space-y-1"> <div className="space-y-1">
<h3 className="font-bold text-sm text-zinc-100">{inc.summary}</h3> <h3 className="font-bold text-sm text-krona-primary">{inc.summary}</h3>
<p className="text-xs text-zinc-300 leading-relaxed">{inc.description}</p> <p className="text-xs text-krona-secondary leading-relaxed line-clamp-2 md:line-clamp-none">
{inc.description}
</p>
</div> </div>
<div className="pt-2 border-t border-zinc-850 flex items-center justify-between text-xs text-zinc-400"> {/* Card Footer: Impact Metrics & Inspector Trigger */}
<div className="flex gap-4"> <div className="pt-2 border-t border-krona-border flex flex-col sm:flex-row sm:items-center justify-between gap-3 text-xs text-krona-secondary">
<span>Затронуто тенантов: <strong className="text-zinc-200">{inc.affectedTenantIds.length}</strong></span> <div className="flex flex-wrap items-center gap-3 md:gap-5">
<span>Узлов: <strong className="text-zinc-200">{inc.affectedServerIds.length}</strong></span> <span className="inline-flex items-center gap-1">
<span>Модулей: <strong className="text-zinc-200">{inc.affectedModuleIds.length}</strong></span> <Building2 className="w-3.5 h-3.5 text-krona-secondary" />
Организаций: <strong className="text-krona-primary font-medium">{inc.affectedTenantIds.length}</strong>
</span>
<span className="inline-flex items-center gap-1">
<Server className="w-3.5 h-3.5 text-krona-secondary" />
Узлов: <strong className="text-krona-primary font-medium">{inc.affectedServerIds.length}</strong>
</span>
<span className="inline-flex items-center gap-1">
<Package className="w-3.5 h-3.5 text-krona-secondary" />
Модулей: <strong className="text-krona-primary font-medium">{inc.affectedModuleIds.length}</strong>
</span>
</div> </div>
<button <div className="inline-flex items-center gap-1 text-xs font-semibold text-amber-500 hover:text-amber-600 transition-colors min-h-[44px]">
type="button"
className="inline-flex items-center gap-1 text-xs text-amber-400 hover:text-amber-300 font-medium"
>
<span>Детали расследования</span> <span>Детали расследования</span>
<ChevronRight className="w-3.5 h-3.5" /> <ChevronRight className="w-4 h-4 group-hover:translate-x-0.5 transition-transform" />
</button>
</div> </div>
</div> </div>
))} </div>
);
})}
{filtered.length === 0 && ( {filtered.length === 0 && (
<div className="p-8 rounded-xl border border-zinc-800 bg-[#091a13] text-center text-xs text-zinc-500"> <div className="p-8 rounded-xl border border-krona-border bg-krona-surface text-center space-y-2">
Инциденты не найдены <ShieldAlert className="w-8 h-8 text-krona-secondary mx-auto opacity-50" />
<p className="text-xs text-krona-secondary">
Инциденты не найдены по выбранным критериям фильтрации
</p>
</div> </div>
)} )}
</div> </div>