feat(control-center): adapt AnnouncementsPage and IncidentsPage mobile-first styling
This commit is contained in:
parent
b81206a2bb
commit
b8739a5096
2 changed files with 231 additions and 137 deletions
|
|
@ -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';
|
||||||
<div>
|
const isImportant = anc.priority === 'important';
|
||||||
<div className="flex items-center gap-2 mb-1">
|
|
||||||
<span className="font-bold text-sm text-zinc-100">{anc.title}</span>
|
return (
|
||||||
<StatusBadge status={anc.status === 'completed' ? 'healthy' : 'warning'} size="sm" customLabel={anc.status.toUpperCase()} />
|
<div
|
||||||
<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">
|
key={anc.announcementId}
|
||||||
{anc.priority}
|
className="rounded-xl border border-krona-border bg-krona-surface p-4 md:p-5 space-y-3 shadow-xs"
|
||||||
</span>
|
>
|
||||||
|
<div className="flex flex-col sm:flex-row sm:items-center justify-between gap-2 border-b border-krona-border pb-3">
|
||||||
|
<div>
|
||||||
|
<div className="flex flex-wrap items-center gap-2 mb-1">
|
||||||
|
<span className="font-bold text-sm text-krona-primary">{anc.title}</span>
|
||||||
|
<StatusBadge
|
||||||
|
status={anc.status === 'completed' ? 'healthy' : 'warning'}
|
||||||
|
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>
|
||||||
|
</div>
|
||||||
|
<div className="text-[11px] text-krona-secondary">
|
||||||
|
Автор: <span className="text-krona-primary font-medium">{anc.author}</span> • Опубликовано:{' '}
|
||||||
|
{new Date(anc.createdAt).toLocaleDateString('ru-RU')}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="text-[11px] text-zinc-400">
|
|
||||||
Автор: <span className="text-zinc-200">{anc.author}</span> • Опубликовано:{' '}
|
{/* Delivery statistics */}
|
||||||
{new Date(anc.createdAt).toLocaleString('ru-RU')}
|
<div className="flex items-center gap-3 text-xs shrink-0 font-mono">
|
||||||
|
<div className="text-left sm:text-right">
|
||||||
|
<div className="text-[10px] text-krona-secondary">Доставлено</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>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Delivery statistics */}
|
<p className="text-xs text-krona-secondary leading-relaxed">{anc.body}</p>
|
||||||
<div className="flex items-center gap-3 text-xs shrink-0 font-mono">
|
|
||||||
<div className="text-right">
|
|
||||||
<div className="text-[10px] text-zinc-500">Доставлено</div>
|
|
||||||
<div className="font-bold text-emerald-400">{anc.stats.delivered} / {anc.stats.totalTargeted}</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>
|
||||||
|
);
|
||||||
<p className="text-xs text-zinc-300 leading-relaxed">{anc.body}</p>
|
})}
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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) => {
|
||||||
<div
|
const isCritical = inc.severity === 'P0_CRITICAL';
|
||||||
key={inc.incidentId}
|
const isMajor = inc.severity === 'P1_MAJOR';
|
||||||
onClick={() => openInspector('incident', inc.incidentId)}
|
const severityInfo = RU_INCIDENT_SEVERITY[inc.severity] || {
|
||||||
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"
|
label: inc.severity,
|
||||||
>
|
short: inc.severity,
|
||||||
<div className="flex flex-col sm:flex-row sm:items-center justify-between gap-2 border-b border-zinc-850 pb-3">
|
};
|
||||||
<div className="flex items-center gap-2.5">
|
const statusText = RU_INCIDENT_STATUS[inc.status] || inc.status;
|
||||||
<span className="font-mono font-bold text-sm text-zinc-100 group-hover:text-amber-300 transition-colors">
|
|
||||||
{inc.incidentNumber}
|
return (
|
||||||
</span>
|
<div
|
||||||
<StatusBadge status={inc.status} size="sm" />
|
key={inc.incidentId}
|
||||||
<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">
|
onClick={() => openInspector('incident', inc.incidentId)}
|
||||||
{inc.severity}
|
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 ${
|
||||||
</span>
|
isCritical
|
||||||
|
? 'border-rose-500/40 bg-rose-500/5'
|
||||||
|
: isMajor
|
||||||
|
? 'border-amber-500/30'
|
||||||
|
: 'border-krona-border'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{/* Card Header */}
|
||||||
|
<div className="flex flex-col sm:flex-row sm:items-center justify-between gap-2 border-b border-krona-border pb-3">
|
||||||
|
<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}
|
||||||
|
</span>
|
||||||
|
<StatusBadge status={inc.status} size="sm" customLabel={statusText} />
|
||||||
|
<span
|
||||||
|
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>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center gap-2 text-xs text-krona-secondary">
|
||||||
|
<span>Ответственный: <strong className="text-krona-primary font-medium">{inc.ownerName}</strong></span>
|
||||||
|
<span>•</span>
|
||||||
|
<span>{new Date(inc.openedAt).toLocaleDateString('ru-RU')}</span>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex items-center gap-3 text-xs text-zinc-400">
|
{/* Summary and Description */}
|
||||||
<span>Ответственный: <strong className="text-zinc-200">{inc.ownerName}</strong></span>
|
<div className="space-y-1">
|
||||||
<span>•</span>
|
<h3 className="font-bold text-sm text-krona-primary">{inc.summary}</h3>
|
||||||
<span>{new Date(inc.openedAt).toLocaleString('ru-RU')}</span>
|
<p className="text-xs text-krona-secondary leading-relaxed line-clamp-2 md:line-clamp-none">
|
||||||
</div>
|
{inc.description}
|
||||||
</div>
|
</p>
|
||||||
|
|
||||||
<div className="space-y-1">
|
|
||||||
<h3 className="font-bold text-sm text-zinc-100">{inc.summary}</h3>
|
|
||||||
<p className="text-xs text-zinc-300 leading-relaxed">{inc.description}</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="pt-2 border-t border-zinc-850 flex items-center justify-between text-xs text-zinc-400">
|
|
||||||
<div className="flex gap-4">
|
|
||||||
<span>Затронуто тенантов: <strong className="text-zinc-200">{inc.affectedTenantIds.length}</strong></span>
|
|
||||||
<span>Узлов: <strong className="text-zinc-200">{inc.affectedServerIds.length}</strong></span>
|
|
||||||
<span>Модулей: <strong className="text-zinc-200">{inc.affectedModuleIds.length}</strong></span>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<button
|
{/* Card Footer: Impact Metrics & Inspector Trigger */}
|
||||||
type="button"
|
<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">
|
||||||
className="inline-flex items-center gap-1 text-xs text-amber-400 hover:text-amber-300 font-medium"
|
<div className="flex flex-wrap items-center gap-3 md:gap-5">
|
||||||
>
|
<span className="inline-flex items-center gap-1">
|
||||||
<span>Детали расследования</span>
|
<Building2 className="w-3.5 h-3.5 text-krona-secondary" />
|
||||||
<ChevronRight className="w-3.5 h-3.5" />
|
Организаций: <strong className="text-krona-primary font-medium">{inc.affectedTenantIds.length}</strong>
|
||||||
</button>
|
</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 className="inline-flex items-center gap-1 text-xs font-semibold text-amber-500 hover:text-amber-600 transition-colors min-h-[44px]">
|
||||||
|
<span>Детали расследования</span>
|
||||||
|
<ChevronRight className="w-4 h-4 group-hover:translate-x-0.5 transition-transform" />
|
||||||
|
</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>
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue