kcg-ai-monitoring/frontend/src/features/monitoring/MonitoringDashboard.tsx
Nan Kyung Lee 353c960c3f feat: SFR-05~14 화면 시안 전면 반영 및 UI 신규 구현
- SFR-05: 위험도지도 좌측 필터 패널 + 우측 격자상세(SHAP) + 내보내기
- SFR-06: 단속계획 3단 메뉴 구조 + SFR-11 하위 11개 화면
- SFR-07: 순찰경로 가중치 슬라이더(α/β/γ) + 시나리오 + 결과통계
- SFR-08: 다함정최적화 커버리지/중복 슬라이더 + 함정별 상세 + 일괄승인
- SFR-09: 불법어선탐지 필터탭 + 탐지요약 + SHAP 패널 + AIS등급
- SFR-11: 단속 사건관리 통합(리스트→등록→상세→수정), AI탐지연계
- SFR-12: 경보현황판 지도중심 레이아웃 + 5등급 경보 + 필터 + 선박목록
- SFR-13: 통계분석 세로스크롤 대시보드 + 기관비교표 + 보고서생성
- SFR-14: 외부서비스 비식별정책 + API정의 + 이용현황 + 장비구성도
- SFR-15: 모바일서비스 상태바+경보+퀵메뉴+위치정보+지도+네비바
- 공통: OSM 지도 적용, Vite CORS 프록시 수정, 3단 메뉴 지원

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-08 17:05:44 +09:00

426 lines
26 KiB
TypeScript

import { useEffect, useState, useMemo, useRef, useCallback } from 'react';
import { useTranslation } from 'react-i18next';
import { Card, CardContent } from '@shared/components/ui/card';
import { Badge } from '@shared/components/ui/badge';
import {
Activity, AlertTriangle, Ship, Eye, Anchor, Radar, Shield, Bell,
Clock, Target, ChevronRight, Filter, MapPin, Calendar,
Volume2, Search, RotateCcw,
} from 'lucide-react';
import type { LucideIcon } from 'lucide-react';
import { AreaChart, PieChart } from '@lib/charts';
import { useKpiStore } from '@stores/kpiStore';
import { useEventStore } from '@stores/eventStore';
import { getHourlyStats, type PredictionStatsHourly } from '@/services/kpi';
import { SystemStatusPanel } from './SystemStatusPanel';
import { BaseMap, createMarkerLayer, useMapLayers, type MapHandle, type MarkerData } from '@lib/map';
import maplibregl from 'maplibre-gl';
/**
* SFR-12: 모니터링 및 경보 현황판(대시보드)
*
* RFP 정의:
* 관할 구역별 위험도, 의심 선박·어구, 단속 계획·실적, 경보 상태 등을
* 한눈에 확인할 수 있는 상황실 전용 대시보드 기능
*
* 기능①: 메인 통합 대시보드 (위험도 지도 + 의심선박/어구 + 경보 + 단속이력)
* 기능②: 시각·청각적 경보 강조 (5등급 색상·깜박임·알림음)
* 기능③: 필터링·Drill-down (관할해역·기간·경보유형·위험등급)
*/
// ─── Mock 데이터 ───
const MOCK_ALERTS = [
{ time: '14:28', type: 'Dark Vessel', level: 'CRITICAL', area: '서해 NLL', vessel: '미상선박', mmsi: '-' },
{ time: '14:12', type: 'AIS 조작', level: 'HIGH', area: '서해 5도', vessel: '冀黄港渔05001', mmsi: '412876001' },
{ time: '13:55', type: '공조 조업', level: 'CRITICAL', area: '서해 NLL', vessel: '鲁荣渔56555', mmsi: '412999888' },
{ time: '13:30', type: '협정선 접근', level: 'MEDIUM', area: '동해 EEZ', vessel: 'LIAO DONG 77', mmsi: '412345678' },
{ time: '12:44', type: '불법 어구', level: 'HIGH', area: '제주 남방', vessel: '-', mmsi: '-' },
{ time: '12:10', type: '금어기 조업', level: 'MEDIUM', area: '남해', vessel: '대한호', mmsi: '440987654' },
{ time: '11:35', type: 'Dark Vessel', level: 'HIGH', area: '서해 NLL', vessel: '미상선박-B', mmsi: '-' },
];
const MOCK_VESSELS = [
{ mmsi: '412999888', name: '鲁荣渔56555', type: 'Dark Vessel', lat: 37.20, lng: 124.63, risk: 5, time: '14:28', status: '경보발령' },
{ mmsi: '412876001', name: '冀黄港渔05001', type: 'AIS 조작', lat: 37.75, lng: 125.02, risk: 4, time: '14:12', status: '모니터링' },
{ mmsi: '412345678', name: 'LIAO DONG 77', type: '협정선 접근', lat: 36.80, lng: 129.50, risk: 3, time: '13:30', status: '모니터링' },
{ mmsi: '440987654', name: '대한호', type: '불법어구', lat: 34.50, lng: 128.20, risk: 3, time: '12:44', status: '확인중' },
{ mmsi: '-', name: '미상선박-B', type: 'Dark Vessel', lat: 37.50, lng: 124.80, risk: 4, time: '11:35', status: '추적중' },
{ mmsi: '412234567', name: 'MIN RONG 8', type: '공조 조업', lat: 36.20, lng: 125.30, risk: 5, time: '13:55', status: '경보발령' },
];
const RISK_COLORS: Record<number, string> = { 5: '#ef4444', 4: '#f97316', 3: '#eab308', 2: '#3b82f6', 1: '#06b6d4' };
const RISK_LABELS: Record<number, string> = { 5: '5등급(적)', 4: '4등급(주황)', 3: '3등급(황)', 2: '2등급(청)', 1: '1등급(녹)' };
const LV: Record<string, string> = {
CRITICAL: 'text-red-400 bg-red-500/15 border-red-500/30',
HIGH: 'text-orange-400 bg-orange-500/15 border-orange-500/30',
MEDIUM: 'text-yellow-400 bg-yellow-500/15 border-yellow-500/30',
LOW: 'text-blue-400 bg-blue-500/15 border-blue-500/30',
};
const LV_LABEL: Record<string, string> = { CRITICAL: '긴급', HIGH: '고위험', MEDIUM: '주의', LOW: '관심' };
// PIE 색상
const PIE_COLOR_MAP: Record<string, string> = {
'EEZ 침범': '#ef4444', '다크베셀': '#f97316', 'MMSI 변조': '#eab308',
'불법환적': '#a855f7', '어구 불법': '#6b7280',
};
export function MonitoringDashboard() {
const { t } = useTranslation('dashboard');
const kpiStore = useKpiStore();
const eventStore = useEventStore();
const [hourlyStats, setHourlyStats] = useState<PredictionStatsHourly[]>([]);
// ─── 필터 상태 ───
const [filterArea, setFilterArea] = useState('전국');
const [filterPeriod, setFilterPeriod] = useState('금일');
const [filterAlertType, setFilterAlertType] = useState('전체');
const [filterRisk, setFilterRisk] = useState('전체');
useEffect(() => { if (!kpiStore.loaded) kpiStore.load(); }, [kpiStore.loaded, kpiStore.load]);
useEffect(() => { if (!eventStore.loaded) eventStore.load(); }, [eventStore.loaded, eventStore.load]);
useEffect(() => { getHourlyStats(24).then(setHourlyStats).catch(() => setHourlyStats([])); }, []);
// 24시간 추이 차트 데이터
const TREND = useMemo(() => hourlyStats.map((h) => {
const hourLabel = h.statHour ? `${new Date(h.statHour).getHours()}` : '';
let riskScore = 0, total = 0;
if (h.byRiskLevel) {
const weights: Record<string, number> = { CRITICAL: 100, HIGH: 70, MEDIUM: 40, LOW: 10 };
Object.entries(h.byRiskLevel).forEach(([k, v]) => { const cnt = Number(v) || 0; riskScore += (weights[k.toUpperCase()] ?? 0) * cnt; total += cnt; });
}
return { h: hourLabel, risk: total > 0 ? Math.round(riskScore / total) : 0, alarms: (h.eventCount ?? 0) + (h.criticalCount ?? 0) };
}), [hourlyStats]);
// PIE 데이터
const PIE = kpiStore.violationTypes.map((v) => ({ name: v.type, value: v.pct, color: PIE_COLOR_MAP[v.type] ?? '#6b7280' }));
// ─── 지도: MapLibre 네이티브 마커 ───
const mapRef = useRef<MapHandle>(null);
const onMapReady = useCallback((map: maplibregl.Map) => {
MOCK_VESSELS.forEach(v => {
const color = RISK_COLORS[v.risk] ?? '#3b82f6';
const el = document.createElement('div');
el.style.cssText = 'display:flex;flex-direction:column;align-items:center;z-index:10;';
const label = document.createElement('div');
label.style.cssText = `font-size:9px;font-weight:800;color:${color};white-space:nowrap;text-shadow:-1px -1px 0 rgba(0,0,0,0.8),1px -1px 0 rgba(0,0,0,0.8),-1px 1px 0 rgba(0,0,0,0.8),1px 1px 0 rgba(0,0,0,0.8);margin-bottom:2px;`;
label.textContent = `🚢 ${v.name}`;
el.appendChild(label);
const dot = document.createElement('div');
const isAlert = v.risk >= 4;
dot.style.cssText = `width:${isAlert ? 14 : 10}px;height:${isAlert ? 14 : 10}px;border-radius:50%;background:${color};border:2px solid rgba(255,255,255,0.8);box-shadow:0 0 8px ${color};${isAlert ? 'animation:pulse 1.5s infinite;' : ''}`;
el.appendChild(dot);
new maplibregl.Marker({ element: el, anchor: 'bottom' }).setLngLat([v.lng, v.lat]).addTo(map);
});
}, []);
// 경보 깜박임 CSS
const alertFlash = (level: string) => level === 'CRITICAL' ? 'animate-pulse' : '';
const filterCls = "bg-surface-overlay border border-slate-700/50 rounded-lg px-2.5 py-1.5 text-[10px] text-heading focus:outline-none focus:border-blue-500/60 appearance-none";
return (
<div className="space-y-0">
{/* ════════ 지도 중심 영역 (전체 폭, 높이 큼) ════════ */}
<div className="relative">
<BaseMap ref={mapRef} center={[36.0, 127.0]} zoom={6} height={560} forceTheme="light" onMapReady={onMapReady} />
{/* 지도 위 오버레이: 헤더 + KPI */}
<div className="absolute top-0 left-0 right-0 z-[1000] pointer-events-none">
{/* 헤더 바 */}
<div className="pointer-events-auto flex items-center justify-between px-5 py-2.5 bg-background/80 backdrop-blur-sm border-b border-border">
<div className="flex items-center gap-2">
<Activity className="w-5 h-5 text-green-400" />
<h2 className="text-base font-bold text-heading">{t('monitoring.title')}</h2>
</div>
<div className="flex items-center gap-2 text-[10px] text-hint">
<Clock className="w-3.5 h-3.5" />
<span>{new Date().toLocaleString('ko-KR', { year: 'numeric', month: '2-digit', day: '2-digit', hour: '2-digit', minute: '2-digit' })}</span>
<span className="text-heading font-medium ml-1">| {filterArea}</span>
</div>
</div>
{/* KPI 4개 — 지도 위 상단 */}
<div className="pointer-events-auto flex gap-2 px-5 py-2">
{[
{ label: '오늘 경보', value: `${MOCK_ALERTS.length}`, sub: '▲2', icon: Bell, color: '#ef4444' },
{ label: '의심 선박', value: `${MOCK_VESSELS.length}`, sub: `고위험 ${MOCK_VESSELS.filter(v => v.risk >= 4).length}`, icon: Ship, color: '#f97316' },
{ label: '의심 어구', value: '12개', sub: '위반 4', icon: Anchor, color: '#eab308' },
{ label: '단속 완료', value: '3건', sub: '오늘', icon: Shield, color: '#10b981' },
].map(k => (
<div key={k.label} className="flex items-center gap-2 px-3 py-2 bg-background/90 backdrop-blur-sm border border-border rounded-xl">
<k.icon className="w-4 h-4" style={{ color: k.color }} />
<span className="text-lg font-bold" style={{ color: k.color }}>{k.value}</span>
<span className="text-[9px] text-hint">{k.label}</span>
<span className="text-[8px] text-hint">({k.sub})</span>
</div>
))}
</div>
</div>
{/* 지도 위 오버레이: 필터 바 (하단) */}
<div className="absolute bottom-0 left-0 right-0 z-[1000] pointer-events-auto px-5 py-2 bg-background/80 backdrop-blur-sm border-t border-border flex items-center gap-3">
<Filter className="w-3.5 h-3.5 text-blue-400 shrink-0" />
<select value={filterArea} onChange={e => setFilterArea(e.target.value)} className={filterCls} title="관할 해역">
{['전국', '서해청', '남해청', '동해청', '제주청', '중부청'].map(v => <option key={v}>{v}</option>)}
</select>
<select value={filterPeriod} onChange={e => setFilterPeriod(e.target.value)} className={filterCls} title="기간">
{['금일', '최근 24h', '최근 7일', '사용자 정의'].map(v => <option key={v}>{v}</option>)}
</select>
<select value={filterAlertType} onChange={e => setFilterAlertType(e.target.value)} className={filterCls} title="경보 유형">
{['전체', 'AIS 조작', 'Dark Vessel', '공조 조업', '금어기', '협정선', '불법 어구'].map(v => <option key={v}>{v}</option>)}
</select>
<select value={filterRisk} onChange={e => setFilterRisk(e.target.value)} className={filterCls} title="위험 등급">
{['전체', '5등급(긴급)', '4등급(고위험)', '3등급(주의)', '2등급(관심)', '1등급(참고)'].map(v => <option key={v}>{v}</option>)}
</select>
<button type="button" className="flex items-center gap-1 px-3 py-1.5 bg-blue-600 hover:bg-blue-500 rounded-lg text-[10px] text-heading font-medium transition-colors">
<Search className="w-3 h-3" />
</button>
<button type="button" className="flex items-center gap-1 px-3 py-1.5 border border-border rounded-lg text-[10px] text-hint hover:text-label hover:bg-surface-overlay transition-colors">
<RotateCcw className="w-3 h-3" />
</button>
<div className="ml-auto text-[9px] text-hint">{MOCK_VESSELS.length} </div>
</div>
{/* 지도 위 오버레이: 범례 (좌측 하단 필터 위) */}
<div className="absolute bottom-12 left-5 z-[1000] bg-background/90 backdrop-blur-sm border border-border rounded-lg px-3 py-2">
<div className="text-[8px] text-hint font-bold mb-1"> </div>
<div className="flex gap-2">
{[5, 4, 3, 2, 1].map(r => (
<div key={r} className="flex items-center gap-1">
<div className="w-2.5 h-2.5 rounded-full" style={{ backgroundColor: RISK_COLORS[r] }} />
<span className="text-[7px] text-muted-foreground">{RISK_LABELS[r]}</span>
</div>
))}
</div>
</div>
{/* 지도 위 오버레이: 최근 경보 (우측) */}
<div className="absolute top-[88px] right-5 z-[1000] w-64 pointer-events-auto">
<div className="bg-background/90 backdrop-blur-sm border border-border rounded-xl overflow-hidden">
<div className="px-3 py-2 border-b border-border flex items-center gap-1.5">
<Bell className="w-3 h-3 text-red-400" />
<span className="text-[10px] font-bold text-heading"> </span>
<span title="경보 알림음 활성"><Volume2 className="w-3 h-3 text-red-400 ml-auto animate-pulse" /></span>
</div>
<div className="p-1.5 space-y-1 max-h-[350px] overflow-y-auto">
{MOCK_ALERTS.slice(0, 5).map((a, i) => (
<div
key={i}
className={`flex items-center gap-1.5 px-2 py-1.5 rounded-lg cursor-pointer hover:bg-surface-overlay ${
a.level === 'CRITICAL' ? 'bg-red-500/10' : a.level === 'HIGH' ? 'bg-orange-500/10' : ''
} ${alertFlash(a.level)}`}
>
<span className="text-[9px] text-hint font-mono w-9 shrink-0">{a.time}</span>
<span className={`px-1 py-0.5 rounded text-[7px] font-bold shrink-0 ${LV[a.level]}`}>{LV_LABEL[a.level]}</span>
<span className="text-[9px] text-heading font-medium truncate">{a.type}</span>
</div>
))}
</div>
</div>
</div>
</div>
{/* ════════ 지도 아래 정보 영역 ════════ */}
<div className="p-5 space-y-4">
{/* 시스템 상태 */}
<SystemStatusPanel />
{/* ── 기능① 단속 계획·실적 요약 (SFR-06 연계) + 경보 설정 요약 ── */}
<div className="grid grid-cols-2 gap-3">
{/* 단속 계획·실적 */}
<Card>
<CardContent className="p-4">
<div className="text-[11px] font-bold text-heading mb-3 flex items-center gap-2">
<Shield className="w-4 h-4 text-orange-400" /> · <span className="text-[8px] text-hint font-normal">(SFR-06 )</span>
</div>
<div className="grid grid-cols-4 gap-2">
{[
{ label: '금일 계획', value: '5건', color: 'text-blue-400' },
{ label: '경보 발령', value: '2건', color: 'text-red-400' },
{ label: '투입 함정', value: '10척', color: 'text-cyan-400' },
{ label: '단속 완료', value: '3건', color: 'text-green-400' },
].map(s => (
<div key={s.label} className="bg-surface-overlay rounded-lg p-2.5 text-center">
<div className={`text-lg font-bold ${s.color}`}>{s.value}</div>
<div className="text-[9px] text-hint">{s.label}</div>
</div>
))}
</div>
<div className="mt-3 space-y-1.5">
{[
{ zone: '서해 NLL 인근', risk: 85, status: 'APPROVED', ships: '2척' },
{ zone: '동해 EEZ 인근', risk: 65, status: 'DRAFT', ships: '1척' },
{ zone: '제주 남방 해역', risk: 78, status: 'APPROVED', ships: '2척' },
].map(p => (
<div key={p.zone} className="flex items-center gap-2 px-3 py-1.5 bg-surface-overlay rounded-lg text-[10px]">
<span className="text-heading font-medium flex-1">{p.zone}</span>
<span className={`font-bold ${p.risk >= 80 ? 'text-red-400' : 'text-orange-400'}`}>{p.risk}</span>
<span className="text-hint">{p.ships}</span>
<span className={`px-1.5 py-0.5 rounded text-[9px] ${p.status === 'APPROVED' ? 'bg-green-500/10 text-green-400' : 'bg-yellow-500/10 text-yellow-400'}`}>{p.status}</span>
</div>
))}
</div>
</CardContent>
</Card>
{/* 기능② 시각·청각적 경보 강조 설정 현황 */}
<Card>
<CardContent className="p-4">
<div className="text-[11px] font-bold text-heading mb-3 flex items-center gap-2">
<Volume2 className="w-4 h-4 text-red-400" /> <span className="text-[8px] text-hint font-normal">()</span>
</div>
<div className="space-y-2">
{[
{ label: '5등급(긴급)', desc: '빨강 깜박임 + 3회 연속 경고음 + 팝업', color: 'bg-red-500', active: true },
{ label: '4등급(고위험)', desc: '주황 강조 + 단일 알림음 + 팝업', color: 'bg-orange-500', active: true },
{ label: '3등급(주의)', desc: '노랑 테두리 + 무음', color: 'bg-yellow-500', active: true },
{ label: '2등급(관심)', desc: '파랑 테두리 + 무음', color: 'bg-blue-500', active: false },
{ label: '1등급(참고)', desc: '회색 표시 + 로그만', color: 'bg-gray-500', active: false },
].map(s => (
<div key={s.label} className="flex items-center gap-2 px-3 py-2 bg-surface-overlay rounded-lg">
<div className={`w-3 h-3 rounded-full ${s.color} ${s.label.includes('긴급') ? 'animate-pulse' : ''}`} />
<span className="text-[10px] text-heading font-medium w-24">{s.label}</span>
<span className="text-[9px] text-hint flex-1">{s.desc}</span>
<div className={`w-8 h-4 rounded-full relative ${s.active ? 'bg-blue-600' : 'bg-slate-700'}`}>
<div className="w-3 h-3 bg-white rounded-full absolute top-0.5 shadow-sm" style={{ left: s.active ? '18px' : '2px' }} />
</div>
</div>
))}
</div>
<div className="mt-3 grid grid-cols-2 gap-2 text-[9px]">
<div className="bg-surface-overlay rounded-lg p-2 flex items-center gap-2">
<span className="text-hint"> </span>
<span className="text-heading font-medium">30 </span>
</div>
<div className="bg-surface-overlay rounded-lg p-2 flex items-center gap-2">
<span className="text-hint"> </span>
<span className="text-heading font-medium"> + </span>
</div>
<div className="bg-surface-overlay rounded-lg p-2 flex items-center gap-2 col-span-2">
<span className="text-hint">SFR-17 </span>
<span className="text-heading font-medium"> AI </span>
</div>
</div>
</CardContent>
</Card>
</div>
{/* 의심 선박 목록 */}
<Card>
<CardContent className="p-0">
<div className="px-4 py-2.5 border-b border-border flex items-center gap-2">
<Ship className="w-4 h-4 text-orange-400" />
<span className="text-[11px] font-bold text-heading"> </span>
<span className="text-[9px] text-hint ml-auto">{MOCK_VESSELS.length}</span>
</div>
<table className="w-full text-xs">
<thead>
<tr className="border-b border-border bg-surface-overlay">
{['MMSI', '선박명', '탐지유형', '위치', '위험도', '시각', '상태'].map(h => (
<th key={h} className="text-left px-4 py-2 text-hint font-medium">{h}</th>
))}
</tr>
</thead>
<tbody>
{MOCK_VESSELS.map(v => (
<tr key={v.mmsi + v.name} className="border-b border-border hover:bg-surface-overlay/50 cursor-pointer transition-colors">
<td className="px-4 py-2 text-heading font-mono text-[10px]">{v.mmsi}</td>
<td className="px-4 py-2 text-heading font-medium">{v.name}</td>
<td className="px-4 py-2"><span className="px-1.5 py-0.5 rounded bg-red-500/10 text-red-400 text-[10px]">{v.type}</span></td>
<td className="px-4 py-2 text-label font-mono text-[10px]">{v.lat.toFixed(1)}°N {v.lng.toFixed(1)}°E</td>
<td className="px-4 py-2">
<span className="px-1.5 py-0.5 rounded text-[10px] font-bold" style={{ backgroundColor: `${RISK_COLORS[v.risk]}20`, color: RISK_COLORS[v.risk] }}>
{RISK_LABELS[v.risk]}
</span>
</td>
<td className="px-4 py-2 text-hint font-mono text-[10px]">{v.time}</td>
<td className="px-4 py-2">
<span className={`px-1.5 py-0.5 rounded text-[10px] font-medium ${
v.status === '경보발령' ? 'bg-red-500/10 text-red-400 animate-pulse' :
v.status === '추적중' ? 'bg-blue-500/10 text-blue-400' :
'bg-yellow-500/10 text-yellow-400'
}`}>{v.status}</span>
</td>
</tr>
))}
</tbody>
</table>
</CardContent>
</Card>
{/* ── 기능③ Drill-down 안내 ── */}
<Card>
<CardContent className="p-4">
<div className="text-[11px] font-bold text-heading mb-3 flex items-center gap-2">
<Target className="w-4 h-4 text-cyan-400" /> Drill-down <span className="text-[8px] text-hint font-normal">()</span>
</div>
<div className="grid grid-cols-2 gap-3">
<div className="bg-surface-overlay rounded-lg p-3 flex items-start gap-3">
<MapPin className="w-5 h-5 text-blue-400 shrink-0 mt-0.5" />
<div>
<div className="text-[10px] text-heading font-medium"> </div>
<div className="text-[9px] text-hint mt-0.5"> · , , .</div>
</div>
</div>
<div className="bg-surface-overlay rounded-lg p-3 flex items-start gap-3">
<Eye className="w-5 h-5 text-purple-400 shrink-0 mt-0.5" />
<div>
<div className="text-[10px] text-heading font-medium"> (SHAP) + </div>
<div className="text-[9px] text-hint mt-0.5"> AI (SHAP ), , .</div>
</div>
</div>
</div>
</CardContent>
</Card>
{/* 차트: 24시간 추이 + 탐지 유형 분포 */}
<div className="grid grid-cols-3 gap-3">
<Card className="col-span-2"><CardContent className="p-4">
<div className="text-[11px] font-bold text-heading mb-3 flex items-center gap-2">
<Clock className="w-4 h-4 text-blue-400" /> 24 ·
</div>
<AreaChart data={TREND} xKey="h" height={180} series={[
{ key: 'risk', name: '위험도', color: '#ef4444' },
{ key: 'alarms', name: '경보', color: '#3b82f6' },
]} />
</CardContent></Card>
<Card><CardContent className="p-4">
<div className="text-[11px] font-bold text-heading mb-3"> </div>
<PieChart data={PIE} height={120} innerRadius={25} outerRadius={48} />
<div className="space-y-1 mt-2">
{PIE.map(d => (
<div key={d.name} className="flex justify-between text-[10px]">
<div className="flex items-center gap-1.5">
<div className="w-2 h-2 rounded-full" style={{ backgroundColor: d.color }} />
<span className="text-muted-foreground">{d.name}</span>
</div>
<span className="text-heading font-bold">{d.value}%</span>
</div>
))}
</div>
</CardContent></Card>
</div>
{/* 실시간 이벤트 타임라인 */}
<Card><CardContent className="p-4">
<div className="text-[11px] font-bold text-heading mb-3 flex items-center gap-2">
<Bell className="w-4 h-4 text-red-400" />
</div>
<div className="space-y-1.5">
{eventStore.events.slice(0, 6).map((e, i) => (
<div key={i} className="flex items-center gap-3 px-3 py-2 bg-surface-overlay rounded-lg">
<span className="text-[10px] text-hint font-mono w-10">{e.time.includes(' ') ? e.time.split(' ')[1].slice(0, 5) : e.time}</span>
<Badge className={`border-0 text-[9px] w-16 text-center ${LV[e.level] ?? LV.LOW}`}>{e.level}</Badge>
<span className="text-[11px] text-heading font-medium flex-1">{e.title}</span>
<span className="text-[10px] text-hint">{e.detail}</span>
</div>
))}
</div>
</CardContent></Card>
</div>
</div>
);
}