feat(frontend): 나머지 개발 — ITSM/Manager/Messenger 신규 UI
ITSM static (app.js + index.html): - 사이드바: AI플랫폼·분석KPI·클라우드·외부연동·SaaS 5개 그룹 추가 - 23개 신규 뷰 핸들러 (rag_search, kpi_dashboard, bi_dashboard, jira_sync 등) - 액션 헬퍼 함수 20개+ (재계산, 파인튜닝, 보고서 생성, 데이터 기여 등) Manager 5개 신규 페이지: - KpiDashboard.tsx: KPI 신호등 대시보드 + 재계산 - BiAnalytics.tsx: SR트렌드·카테고리파이·MTTR·엔지니어워크로드 - BillingManage.tsx: 구독플랜·사용량·청구서 이력 - IntegrationHub.tsx: Jira/Slack/ServiceNow/ERP/Kakao/SSO 탭 - AiPlatform.tsx: AI인사이트·LearningLoop·예측·벤치마킹 Messenger 신규 탭: - insights.tsx: AI 주간 인사이트 + SLA 예측 + 이상 감지 Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
parent
fc0ba65e05
commit
caa70a608b
File diff suppressed because it is too large
Load Diff
@ -128,6 +128,72 @@
|
|||||||
<div class="nav-item" data-view="timetable">
|
<div class="nav-item" data-view="timetable">
|
||||||
<span class="nav-icon">📅</span> 작업 타임테이블
|
<span class="nav-icon">📅</span> 작업 타임테이블
|
||||||
</div>
|
</div>
|
||||||
|
<!-- ── GUARDiA 확장 v3 ─────────────────────── -->
|
||||||
|
<div class="nav-separator"></div>
|
||||||
|
|
||||||
|
<!-- AI 플랫폼 -->
|
||||||
|
<div class="nav-group-header" onclick="toggleNavGroup(this)" aria-expanded="false">
|
||||||
|
<span class="nav-icon">🧠</span><span>AI 플랫폼</span>
|
||||||
|
<span class="nav-arrow" aria-hidden="true">▾</span>
|
||||||
|
</div>
|
||||||
|
<div class="nav-group-body" role="group">
|
||||||
|
<div class="nav-sub-item" data-view="rag_search">RAG 하이브리드 검색</div>
|
||||||
|
<div class="nav-sub-item" data-view="ai_insights">AI 운영 인사이트</div>
|
||||||
|
<div class="nav-sub-item" data-view="ai_workflow">자율 워크플로우</div>
|
||||||
|
<div class="nav-sub-item" data-view="learning_loop">Learning Loop</div>
|
||||||
|
<div class="nav-sub-item" data-view="multimodal">멀티모달 분석</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 분석·KPI -->
|
||||||
|
<div class="nav-group-header" onclick="toggleNavGroup(this)" aria-expanded="false">
|
||||||
|
<span class="nav-icon">📈</span><span>분석 · KPI</span>
|
||||||
|
<span class="nav-arrow" aria-hidden="true">▾</span>
|
||||||
|
</div>
|
||||||
|
<div class="nav-group-body" role="group">
|
||||||
|
<div class="nav-sub-item" data-view="kpi_dashboard">KPI 대시보드</div>
|
||||||
|
<div class="nav-sub-item" data-view="bi_dashboard">BI 대시보드</div>
|
||||||
|
<div class="nav-sub-item" data-view="predictive">예측 분석</div>
|
||||||
|
<div class="nav-sub-item" data-view="benchmark">벤치마킹</div>
|
||||||
|
<div class="nav-sub-item" data-view="auto_report">자동 보고서</div>
|
||||||
|
<div class="nav-sub-item" data-view="cohort">코호트 분석</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 클라우드 -->
|
||||||
|
<div class="nav-group-header" onclick="toggleNavGroup(this)" aria-expanded="false">
|
||||||
|
<span class="nav-icon">☁️</span><span>클라우드 · 컨테이너</span>
|
||||||
|
<span class="nav-arrow" aria-hidden="true">▾</span>
|
||||||
|
</div>
|
||||||
|
<div class="nav-group-body" role="group">
|
||||||
|
<div class="nav-sub-item" data-view="kubernetes">Kubernetes</div>
|
||||||
|
<div class="nav-sub-item" data-view="container_alerts">컨테이너 알림</div>
|
||||||
|
<div class="nav-sub-item" data-view="ncloud">NCloud 관리</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 외부 연동 -->
|
||||||
|
<div class="nav-group-header" onclick="toggleNavGroup(this)" aria-expanded="false">
|
||||||
|
<span class="nav-icon">🔗</span><span>외부 연동</span>
|
||||||
|
<span class="nav-arrow" aria-hidden="true">▾</span>
|
||||||
|
</div>
|
||||||
|
<div class="nav-group-body" role="group">
|
||||||
|
<div class="nav-sub-item" data-view="jira_sync">Jira 동기화</div>
|
||||||
|
<div class="nav-sub-item" data-view="servicenow">ServiceNow</div>
|
||||||
|
<div class="nav-sub-item" data-view="slack_config">Slack 설정</div>
|
||||||
|
<div class="nav-sub-item" data-view="sso_config">SSO 인증</div>
|
||||||
|
<div class="nav-sub-item" data-view="erp_config">ERP 연동</div>
|
||||||
|
<div class="nav-sub-item" data-view="kakao_config">카카오 알림톡</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- SaaS 관리 -->
|
||||||
|
<div class="nav-group-header" onclick="toggleNavGroup(this)" aria-expanded="false">
|
||||||
|
<span class="nav-icon">💼</span><span>SaaS 관리</span>
|
||||||
|
<span class="nav-arrow" aria-hidden="true">▾</span>
|
||||||
|
</div>
|
||||||
|
<div class="nav-group-body" role="group">
|
||||||
|
<div class="nav-sub-item" data-view="tenant_portal">테넌트 포털</div>
|
||||||
|
<div class="nav-sub-item" data-view="billing">구독 · 과금</div>
|
||||||
|
<div class="nav-sub-item" data-view="white_label">브랜딩 설정</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div class="nav-separator"></div>
|
<div class="nav-separator"></div>
|
||||||
<a class="nav-item nav-link-ext" href="/license" id="nav-license">
|
<a class="nav-item nav-link-ext" href="/license" id="nav-license">
|
||||||
<span class="nav-icon">🔏</span> 라이선스 관리
|
<span class="nav-icon">🔏</span> 라이선스 관리
|
||||||
|
|||||||
@ -23,6 +23,12 @@ const DrConsole = lazy(() => import('./pages/DrConsole'))
|
|||||||
const NetworkConsole = lazy(() => import('./pages/NetworkConsole'))
|
const NetworkConsole = lazy(() => import('./pages/NetworkConsole'))
|
||||||
const CsapConsole = lazy(() => import('./pages/CsapConsole'))
|
const CsapConsole = lazy(() => import('./pages/CsapConsole'))
|
||||||
const ScrapingManager = lazy(() => import('./pages/ScrapingManager'))
|
const ScrapingManager = lazy(() => import('./pages/ScrapingManager'))
|
||||||
|
// ── GUARDiA 확장 v3 ──
|
||||||
|
const KpiDashboard = lazy(() => import('./pages/KpiDashboard'))
|
||||||
|
const BiAnalytics = lazy(() => import('./pages/BiAnalytics'))
|
||||||
|
const BillingManage = lazy(() => import('./pages/BillingManage'))
|
||||||
|
const IntegrationHub = lazy(() => import('./pages/IntegrationHub'))
|
||||||
|
const AiPlatform = lazy(() => import('./pages/AiPlatform'))
|
||||||
|
|
||||||
function Loading() {
|
function Loading() {
|
||||||
return (
|
return (
|
||||||
@ -61,6 +67,12 @@ export default function App() {
|
|||||||
<Route path="network" element={<NetworkConsole />} />
|
<Route path="network" element={<NetworkConsole />} />
|
||||||
<Route path="csap" element={<CsapConsole />} />
|
<Route path="csap" element={<CsapConsole />} />
|
||||||
<Route path="scraping" element={<ScrapingManager />} />
|
<Route path="scraping" element={<ScrapingManager />} />
|
||||||
|
{/* GUARDiA 확장 v3 */}
|
||||||
|
<Route path="kpi" element={<KpiDashboard />} />
|
||||||
|
<Route path="bi" element={<BiAnalytics />} />
|
||||||
|
<Route path="billing" element={<BillingManage />} />
|
||||||
|
<Route path="integrations" element={<IntegrationHub />} />
|
||||||
|
<Route path="ai-platform" element={<AiPlatform />} />
|
||||||
</Route>
|
</Route>
|
||||||
<Route path="*" element={<Navigate to="/" replace />} />
|
<Route path="*" element={<Navigate to="/" replace />} />
|
||||||
</Routes>
|
</Routes>
|
||||||
|
|||||||
@ -21,6 +21,12 @@ const PAGE_TITLES: Record<string, string> = {
|
|||||||
'/dr': 'DR 재해복구 관제',
|
'/dr': 'DR 재해복구 관제',
|
||||||
'/network': '네트워크 장비 관제',
|
'/network': '네트워크 장비 관제',
|
||||||
'/csap': 'CSAP 보안 점검',
|
'/csap': 'CSAP 보안 점검',
|
||||||
|
// GUARDiA 확장 v3
|
||||||
|
'/kpi': 'KPI 대시보드',
|
||||||
|
'/bi': 'BI 대시보드',
|
||||||
|
'/billing': '구독 · 과금 관리',
|
||||||
|
'/integrations': '외부 연동 허브',
|
||||||
|
'/ai-platform': 'AI 플랫폼',
|
||||||
}
|
}
|
||||||
|
|
||||||
export function AppLayout() {
|
export function AppLayout() {
|
||||||
|
|||||||
@ -35,6 +35,14 @@ const NAV: NavItem[] = [
|
|||||||
{ label: '네트워크 장비', icon: '', path: '/network' },
|
{ label: '네트워크 장비', icon: '', path: '/network' },
|
||||||
{ label: 'CSAP 점검', icon: '', path: '/csap' },
|
{ label: 'CSAP 점검', icon: '', path: '/csap' },
|
||||||
]},
|
]},
|
||||||
|
// ── GUARDiA 확장 v3 ──
|
||||||
|
{ label: '분석 · KPI', icon: '📈', children: [
|
||||||
|
{ label: 'KPI 대시보드', icon: '', path: '/kpi' },
|
||||||
|
{ label: 'BI 대시보드', icon: '', path: '/bi' },
|
||||||
|
]},
|
||||||
|
{ label: 'AI 플랫폼', icon: '🧠', path: '/ai-platform' },
|
||||||
|
{ label: '외부 연동', icon: '🔗', path: '/integrations' },
|
||||||
|
{ label: '구독 · 과금', icon: '💰', path: '/billing' },
|
||||||
]
|
]
|
||||||
|
|
||||||
/* Variant 스타일 색상 상수 */
|
/* Variant 스타일 색상 상수 */
|
||||||
|
|||||||
199
workspace/guardia-manager/frontend/src/pages/AiPlatform.tsx
Normal file
199
workspace/guardia-manager/frontend/src/pages/AiPlatform.tsx
Normal file
@ -0,0 +1,199 @@
|
|||||||
|
import { useEffect, useState } from 'react'
|
||||||
|
import { guardiaApi } from '../api/clients'
|
||||||
|
|
||||||
|
type Tab = 'insights' | 'learning' | 'predict' | 'benchmark'
|
||||||
|
|
||||||
|
export default function AiPlatform() {
|
||||||
|
const [tab, setTab] = useState<Tab>('insights')
|
||||||
|
const tabs: { id: Tab; label: string; icon: string }[] = [
|
||||||
|
{ id: 'insights', label: 'AI 인사이트', icon: '🧠' },
|
||||||
|
{ id: 'learning', label: 'Learning Loop', icon: '🔄' },
|
||||||
|
{ id: 'predict', label: '예측 분석', icon: '🔮' },
|
||||||
|
{ id: 'benchmark', label: '벤치마킹', icon: '📊' },
|
||||||
|
]
|
||||||
|
return (
|
||||||
|
<div style={{ padding: '24px 28px' }}>
|
||||||
|
<h2 style={{ margin: '0 0 20px', fontSize: 20, fontWeight: 700 }}>🧠 AI 플랫폼</h2>
|
||||||
|
<div style={{ display: 'flex', gap: 4, marginBottom: 20, borderBottom: '1px solid #e2e8f0' }}>
|
||||||
|
{tabs.map(t => (
|
||||||
|
<button key={t.id} onClick={() => setTab(t.id)} style={{
|
||||||
|
padding: '8px 16px', border: 'none', background: 'none', cursor: 'pointer',
|
||||||
|
fontSize: 13, fontWeight: tab === t.id ? 700 : 400,
|
||||||
|
color: tab === t.id ? '#003366' : '#64748b',
|
||||||
|
borderBottom: tab === t.id ? '2px solid #003366' : '2px solid transparent',
|
||||||
|
}}>{t.icon} {t.label}</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
{tab === 'insights' && <InsightsTab />}
|
||||||
|
{tab === 'learning' && <LearningTab />}
|
||||||
|
{tab === 'predict' && <PredictTab />}
|
||||||
|
{tab === 'benchmark' && <BenchmarkTab />}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function Card({ title, children }: { title: string; children: React.ReactNode }) {
|
||||||
|
return (
|
||||||
|
<div style={{ background: '#fff', border: '1px solid #e2e8f0', borderRadius: 10, padding: 20, marginBottom: 16 }}>
|
||||||
|
<h3 style={{ fontSize: 14, fontWeight: 600, margin: '0 0 14px' }}>{title}</h3>
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function InsightsTab() {
|
||||||
|
const [weekly, setWeekly] = useState<any>(null)
|
||||||
|
const [anomalies, setAnomalies] = useState<any>(null)
|
||||||
|
useEffect(() => {
|
||||||
|
guardiaApi.get('/api/insights/weekly').then((r: any) => setWeekly(r.data)).catch(() => {})
|
||||||
|
guardiaApi.get('/api/insights/anomalies').then((r: any) => setAnomalies(r.data)).catch(() => {})
|
||||||
|
}, [])
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
{anomalies?.anomalies?.length > 0 && (
|
||||||
|
<div style={{ background: '#fef2f2', border: '1px solid #fecaca', borderRadius: 8, padding: 12, marginBottom: 16 }}>
|
||||||
|
<strong style={{ fontSize: 13 }}>⚠️ 이상 감지 ({anomalies.anomalies.length}건)</strong>
|
||||||
|
{anomalies.anomalies.map((a: any, i: number) => (
|
||||||
|
<div key={i} style={{ fontSize: 12, marginTop: 6, color: '#7f1d1d' }}>{a.message}</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(3,1fr)', gap: 12, marginBottom: 16 }}>
|
||||||
|
{weekly && [
|
||||||
|
{ label: '신규 SR', val: weekly.stats?.total || 0, color: '#003366' },
|
||||||
|
{ label: '완료율', val: `${weekly.stats?.completion_rate || 0}%`, color: '#10B981' },
|
||||||
|
{ label: '미처리', val: weekly.stats?.open || 0, color: '#EF4444' },
|
||||||
|
].map(s => (
|
||||||
|
<div key={s.label} style={{ background: '#fff', border: `1px solid #e2e8f0`, borderLeft: `4px solid ${s.color}`, borderRadius: 8, padding: 16 }}>
|
||||||
|
<div style={{ fontSize: 28, fontWeight: 700, color: s.color }}>{s.val}</div>
|
||||||
|
<div style={{ fontSize: 11, color: '#64748b' }}>{s.label}</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<Card title="🤖 AI 주간 인사이트">
|
||||||
|
<p style={{ fontSize: 13, lineHeight: 1.8, whiteSpace: 'pre-wrap', color: '#374151' }}>
|
||||||
|
{weekly?.ai_insight || '데이터 수집 중...'}
|
||||||
|
</p>
|
||||||
|
</Card>
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function LearningTab() {
|
||||||
|
const [status, setStatus] = useState<any>(null)
|
||||||
|
const [quality, setQuality] = useState<any>(null)
|
||||||
|
const [history, setHistory] = useState<any[]>([])
|
||||||
|
useEffect(() => {
|
||||||
|
guardiaApi.get('/api/learn/status').then((r: any) => setStatus(r.data)).catch(() => {})
|
||||||
|
guardiaApi.get('/api/learn/quality').then((r: any) => setQuality(r.data)).catch(() => {})
|
||||||
|
guardiaApi.get('/api/learn/history?limit=10').then((r: any) => setHistory(r.data || [])).catch(() => {})
|
||||||
|
}, [])
|
||||||
|
async function startTrain() {
|
||||||
|
await guardiaApi.post('/api/learn/train')
|
||||||
|
guardiaApi.get('/api/learn/history?limit=10').then((r: any) => setHistory(r.data || [])).catch(() => {})
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 16 }}>
|
||||||
|
<Card title="📦 학습 데이터">
|
||||||
|
<div style={{ fontSize: 36, fontWeight: 700, color: '#003366' }}>{status?.available_samples || 0}</div>
|
||||||
|
<div style={{ fontSize: 12, color: '#64748b' }}>수집 가능 샘플</div>
|
||||||
|
<div style={{ fontSize: 12, marginTop: 6 }}>
|
||||||
|
RAG 피드백: {status?.high_quality_rag || 0} · SR 이력: {status?.sr_samples || 0}
|
||||||
|
</div>
|
||||||
|
<button onClick={startTrain} disabled={!status?.ready_to_train} style={{
|
||||||
|
marginTop: 12, padding: '7px 16px', background: status?.ready_to_train ? '#003366' : '#94a3b8',
|
||||||
|
color: '#fff', border: 'none', borderRadius: 6, cursor: status?.ready_to_train ? 'pointer' : 'not-allowed', fontSize: 12,
|
||||||
|
}}>🚀 파인튜닝 시작</button>
|
||||||
|
</Card>
|
||||||
|
<Card title="📈 모델 품질">
|
||||||
|
<div style={{ fontSize: 48, fontWeight: 700, color: '#003366', textAlign: 'center' }}>{quality?.quality_grade || 'N/A'}</div>
|
||||||
|
<div style={{ textAlign: 'center', fontSize: 12, color: '#64748b' }}>
|
||||||
|
평균 {quality?.avg_rating || 0}점 · 긍정 {quality?.positive_rate || 0}%
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
<Card title="📋 학습 이력">
|
||||||
|
<table style={{ width: '100%', borderCollapse: 'collapse', fontSize: 12 }}>
|
||||||
|
<thead><tr style={{ borderBottom: '1px solid #f1f5f9' }}>
|
||||||
|
{['모델', '상태', '샘플', '시작'].map(h => <th key={h} style={{ textAlign: 'left', padding: '6px 8px', color: '#64748b', fontSize: 11 }}>{h}</th>)}
|
||||||
|
</tr></thead>
|
||||||
|
<tbody>
|
||||||
|
{history.slice(0, 8).map((h: any) => (
|
||||||
|
<tr key={h.id} style={{ borderBottom: '1px solid #f8fafc' }}>
|
||||||
|
<td style={{ padding: '8px', fontFamily: 'monospace', fontSize: 11 }}>{h.model_name || '-'}</td>
|
||||||
|
<td style={{ padding: '8px', color: h.status === 'SUCCESS' ? '#10B981' : h.status === 'FAILED' ? '#EF4444' : '#F59E0B' }}>{h.status}</td>
|
||||||
|
<td style={{ padding: '8px' }}>{h.samples_used || 0}</td>
|
||||||
|
<td style={{ padding: '8px', color: '#94a3b8', fontSize: 11 }}>{h.started_at ? new Date(h.started_at).toLocaleDateString('ko-KR') : '-'}</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
{!history.length && <tr><td colSpan={4} style={{ textAlign: 'center', padding: 16, color: '#94a3b8' }}>이력 없음</td></tr>}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</Card>
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function PredictTab() {
|
||||||
|
const [sla, setSla] = useState<any>(null)
|
||||||
|
const [surge, setSurge] = useState<any>(null)
|
||||||
|
useEffect(() => {
|
||||||
|
guardiaApi.get('/api/predict/sla-breach').then((r: any) => setSla(r.data)).catch(() => {})
|
||||||
|
guardiaApi.get('/api/predict/sr-surge').then((r: any) => setSurge(r.data)).catch(() => {})
|
||||||
|
}, [])
|
||||||
|
return (
|
||||||
|
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 16 }}>
|
||||||
|
<Card title="📉 SLA 위반 예측 (7일)">
|
||||||
|
<div style={{ fontSize: 36, fontWeight: 700, color: sla?.status === 'CRITICAL' ? '#EF4444' : sla?.status === 'WARNING' ? '#F59E0B' : '#10B981' }}>
|
||||||
|
{Math.round((sla?.breach_probability_7d || 0) * 100)}%
|
||||||
|
</div>
|
||||||
|
<div style={{ fontSize: 12, color: '#64748b' }}>현재 SLA: {sla?.current_rate || 0}% · 목표: {sla?.target || 95}%</div>
|
||||||
|
{sla?.insight && <p style={{ fontSize: 12, lineHeight: 1.7, marginTop: 10, color: '#374151' }}>{sla.insight}</p>}
|
||||||
|
</Card>
|
||||||
|
<Card title="📈 SR 급증 감지">
|
||||||
|
<div style={{ fontSize: 36, fontWeight: 700, color: surge?.status === 'SURGE' ? '#EF4444' : surge?.status === 'HIGH' ? '#F59E0B' : '#10B981' }}>
|
||||||
|
{surge?.surge_ratio || 1}x
|
||||||
|
</div>
|
||||||
|
<div style={{ fontSize: 12, color: '#64748b' }}>오늘 {surge?.today_count || 0}건 · 7일 평균 {surge?.avg_7d || 0}건</div>
|
||||||
|
{surge?.insight && <p style={{ fontSize: 12, lineHeight: 1.7, marginTop: 10, color: '#374151' }}>{surge.insight}</p>}
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function BenchmarkTab() {
|
||||||
|
const [comp, setComp] = useState<any>(null)
|
||||||
|
const [rank, setRank] = useState<any>(null)
|
||||||
|
useEffect(() => {
|
||||||
|
guardiaApi.get('/api/benchmark/comparison').then((r: any) => setComp(r.data)).catch(() => {})
|
||||||
|
guardiaApi.get('/api/benchmark/my-rank').then((r: any) => setRank(r.data)).catch(() => {})
|
||||||
|
}, [])
|
||||||
|
async function contribute() { await guardiaApi.post('/api/benchmark/contribute') }
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<Card title="📊 업계 평균 대비 비교">
|
||||||
|
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(3,1fr)', gap: 16 }}>
|
||||||
|
{(comp?.comparison || []).map((m: any) => (
|
||||||
|
<div key={m.metric} style={{ textAlign: 'center', padding: 16, background: '#f8fafc', borderRadius: 8 }}>
|
||||||
|
<div style={{ fontSize: 11, color: '#64748b', marginBottom: 6 }}>{m.metric}</div>
|
||||||
|
<div style={{ fontSize: 28, fontWeight: 700, color: m.status === 'ABOVE' ? '#10B981' : '#EF4444' }}>
|
||||||
|
{m.mine}<span style={{ fontSize: 12 }}>{m.unit}</span>
|
||||||
|
</div>
|
||||||
|
<div style={{ fontSize: 11, marginTop: 4 }}>업계 평균: {m.industry}{m.unit}</div>
|
||||||
|
<div style={{ fontSize: 11, color: m.status === 'ABOVE' ? '#10B981' : '#EF4444', marginTop: 2 }}>
|
||||||
|
{m.status === 'ABOVE' ? '▲ 평균 이상' : '▼ 평균 이하'}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
<div style={{ marginTop: 12 }}>
|
||||||
|
<button onClick={contribute} style={{ padding: '7px 16px', border: '1px solid #e2e8f0', borderRadius: 6, cursor: 'pointer', fontSize: 12 }}>
|
||||||
|
📤 익명 데이터 기여
|
||||||
|
</button>
|
||||||
|
<span style={{ fontSize: 11, color: '#94a3b8', marginLeft: 8 }}>기여 시 더 정확한 벤치마킹 가능 (완전 익명화)</span>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
150
workspace/guardia-manager/frontend/src/pages/BiAnalytics.tsx
Normal file
150
workspace/guardia-manager/frontend/src/pages/BiAnalytics.tsx
Normal file
@ -0,0 +1,150 @@
|
|||||||
|
import { useEffect, useState } from 'react'
|
||||||
|
import {
|
||||||
|
LineChart, Line, BarChart, Bar, PieChart, Pie, Cell,
|
||||||
|
XAxis, YAxis, CartesianGrid, Tooltip, Legend, ResponsiveContainer,
|
||||||
|
} from 'recharts'
|
||||||
|
import { guardiaApi } from '../api/clients'
|
||||||
|
|
||||||
|
const COLORS = ['#003366', '#00A0C8', '#10B981', '#F59E0B', '#EF4444', '#8B5CF6']
|
||||||
|
|
||||||
|
export default function BiAnalytics() {
|
||||||
|
const [overview, setOverview] = useState<any>(null)
|
||||||
|
const [srTrend, setSrTrend] = useState<any>(null)
|
||||||
|
const [pie, setPie] = useState<any>(null)
|
||||||
|
const [mttr, setMttr] = useState<any>(null)
|
||||||
|
const [tab, setTab] = useState<'overview' | 'trend' | 'engineer' | 'mttr'>('overview')
|
||||||
|
|
||||||
|
useEffect(() => { loadData() }, [])
|
||||||
|
|
||||||
|
async function loadData() {
|
||||||
|
const [o, t, p, m] = await Promise.all([
|
||||||
|
guardiaApi.get('/api/bi/overview').then((r: any) => r.data).catch(() => null),
|
||||||
|
guardiaApi.get('/api/bi/sr-trend?days=30').then((r: any) => r.data).catch(() => null),
|
||||||
|
guardiaApi.get('/api/bi/category-pie?days=30').then((r: any) => r.data).catch(() => null),
|
||||||
|
guardiaApi.get('/api/bi/mttr-trend?months=6').then((r: any) => r.data).catch(() => null),
|
||||||
|
])
|
||||||
|
setOverview(o); setSrTrend(t); setPie(p); setMttr(m)
|
||||||
|
}
|
||||||
|
|
||||||
|
const tabs = [
|
||||||
|
{ id: 'overview', label: '개요' },
|
||||||
|
{ id: 'trend', label: 'SR 트렌드' },
|
||||||
|
{ id: 'engineer', label: '엔지니어' },
|
||||||
|
{ id: 'mttr', label: 'MTTR' },
|
||||||
|
] as const
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div style={{ padding: '24px 28px' }}>
|
||||||
|
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 20 }}>
|
||||||
|
<h2 style={{ margin: 0, fontSize: 20, fontWeight: 700 }}>📊 BI 대시보드</h2>
|
||||||
|
<button onClick={loadData} style={{ padding: '6px 14px', border: '1px solid #e2e8f0', borderRadius: 6, background: '#fff', cursor: 'pointer' }}>🔄</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 탭 */}
|
||||||
|
<div style={{ display: 'flex', gap: 4, marginBottom: 20, borderBottom: '1px solid #e2e8f0', paddingBottom: 0 }}>
|
||||||
|
{tabs.map(t => (
|
||||||
|
<button key={t.id} onClick={() => setTab(t.id)} style={{
|
||||||
|
padding: '8px 16px', border: 'none', background: 'none', cursor: 'pointer',
|
||||||
|
fontSize: 13, fontWeight: tab === t.id ? 700 : 400,
|
||||||
|
color: tab === t.id ? '#003366' : '#64748b',
|
||||||
|
borderBottom: tab === t.id ? '2px solid #003366' : '2px solid transparent',
|
||||||
|
}}>{t.label}</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 개요 탭 */}
|
||||||
|
{tab === 'overview' && (
|
||||||
|
<>
|
||||||
|
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fit, minmax(150px, 1fr))', gap: 12, marginBottom: 20 }}>
|
||||||
|
{(overview?.cards || []).map((c: any) => (
|
||||||
|
<div key={c.key} style={{ background: '#fff', border: '1px solid #e2e8f0', borderLeft: '4px solid #003366', borderRadius: 8, padding: 14 }}>
|
||||||
|
<div style={{ fontSize: 24, fontWeight: 700 }}>{c.value}</div>
|
||||||
|
<div style={{ fontSize: 11, color: '#64748b' }}>{c.label} ({c.unit})</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
{pie && (
|
||||||
|
<div style={{ background: '#fff', border: '1px solid #e2e8f0', borderRadius: 10, padding: 20 }}>
|
||||||
|
<h3 style={{ fontSize: 14, fontWeight: 600, margin: '0 0 16px' }}>SR 카테고리 분포 (30일)</h3>
|
||||||
|
<ResponsiveContainer width="100%" height={240}>
|
||||||
|
<PieChart>
|
||||||
|
<Pie data={pie.data || []} dataKey="count" nameKey="category" cx="50%" cy="50%" outerRadius={90} label={({category, pct}) => `${category} ${pct}%`}>
|
||||||
|
{(pie.data || []).map((_: any, i: number) => <Cell key={i} fill={COLORS[i % COLORS.length]} />)}
|
||||||
|
</Pie>
|
||||||
|
<Tooltip />
|
||||||
|
</PieChart>
|
||||||
|
</ResponsiveContainer>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* SR 트렌드 탭 */}
|
||||||
|
{tab === 'trend' && srTrend && (
|
||||||
|
<div style={{ background: '#fff', border: '1px solid #e2e8f0', borderRadius: 10, padding: 20 }}>
|
||||||
|
<h3 style={{ fontSize: 14, fontWeight: 600, margin: '0 0 16px' }}>SR 생성/완료 추이 (30일)</h3>
|
||||||
|
<ResponsiveContainer width="100%" height={280}>
|
||||||
|
<LineChart data={(srTrend.labels || []).map((d: string, i: number) => ({
|
||||||
|
date: d.slice(5),
|
||||||
|
신규: srTrend.datasets[0]?.data[i] || 0,
|
||||||
|
완료: srTrend.datasets[1]?.data[i] || 0,
|
||||||
|
}))}>
|
||||||
|
<CartesianGrid strokeDasharray="3 3" stroke="#f0f0f0" />
|
||||||
|
<XAxis dataKey="date" tick={{ fontSize: 11 }} />
|
||||||
|
<YAxis tick={{ fontSize: 11 }} />
|
||||||
|
<Tooltip />
|
||||||
|
<Legend />
|
||||||
|
<Line type="monotone" dataKey="신규" stroke="#003366" strokeWidth={2} dot={false} />
|
||||||
|
<Line type="monotone" dataKey="완료" stroke="#10B981" strokeWidth={2} dot={false} />
|
||||||
|
</LineChart>
|
||||||
|
</ResponsiveContainer>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* MTTR 탭 */}
|
||||||
|
{tab === 'mttr' && mttr && (
|
||||||
|
<div style={{ background: '#fff', border: '1px solid #e2e8f0', borderRadius: 10, padding: 20 }}>
|
||||||
|
<h3 style={{ fontSize: 14, fontWeight: 600, margin: '0 0 16px' }}>MTTR 월별 추이 (시간)</h3>
|
||||||
|
<ResponsiveContainer width="100%" height={280}>
|
||||||
|
<BarChart data={(mttr.raw || []).map((r: any) => ({ month: r.month, mttr: r.mttr_hours }))}>
|
||||||
|
<CartesianGrid strokeDasharray="3 3" stroke="#f0f0f0" />
|
||||||
|
<XAxis dataKey="month" tick={{ fontSize: 11 }} />
|
||||||
|
<YAxis tick={{ fontSize: 11 }} />
|
||||||
|
<Tooltip formatter={(v: any) => `${v}시간`} />
|
||||||
|
<Bar dataKey="mttr" fill="#6366F1" radius={[4, 4, 0, 0]} />
|
||||||
|
</BarChart>
|
||||||
|
</ResponsiveContainer>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{tab === 'engineer' && <EngineerLoad />}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function EngineerLoad() {
|
||||||
|
const [data, setData] = useState<any>(null)
|
||||||
|
useEffect(() => {
|
||||||
|
guardiaApi.get('/api/bi/engineer-load?days=30').then((r: any) => setData(r.data)).catch(() => {})
|
||||||
|
}, [])
|
||||||
|
if (!data) return <div style={{ padding: 32, textAlign: 'center' }}>⏳</div>
|
||||||
|
const combined = (data.labels || []).map((name: string, i: number) => ({
|
||||||
|
name, 완료: data.datasets[0]?.data[i] || 0, 진행중: data.datasets[1]?.data[i] || 0,
|
||||||
|
}))
|
||||||
|
return (
|
||||||
|
<div style={{ background: '#fff', border: '1px solid #e2e8f0', borderRadius: 10, padding: 20 }}>
|
||||||
|
<h3 style={{ fontSize: 14, fontWeight: 600, margin: '0 0 16px' }}>엔지니어별 SR 워크로드</h3>
|
||||||
|
<ResponsiveContainer width="100%" height={280}>
|
||||||
|
<BarChart data={combined} layout="vertical">
|
||||||
|
<CartesianGrid strokeDasharray="3 3" stroke="#f0f0f0" />
|
||||||
|
<XAxis type="number" tick={{ fontSize: 11 }} />
|
||||||
|
<YAxis type="category" dataKey="name" tick={{ fontSize: 11 }} width={90} />
|
||||||
|
<Tooltip />
|
||||||
|
<Legend />
|
||||||
|
<Bar dataKey="완료" fill="#10B981" stackId="a" />
|
||||||
|
<Bar dataKey="진행중" fill="#F59E0B" stackId="a" />
|
||||||
|
</BarChart>
|
||||||
|
</ResponsiveContainer>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
118
workspace/guardia-manager/frontend/src/pages/BillingManage.tsx
Normal file
118
workspace/guardia-manager/frontend/src/pages/BillingManage.tsx
Normal file
@ -0,0 +1,118 @@
|
|||||||
|
import { useEffect, useState } from 'react'
|
||||||
|
import { guardiaApi } from '../api/clients'
|
||||||
|
|
||||||
|
export default function BillingManage() {
|
||||||
|
const [sub, setSub] = useState<any>(null)
|
||||||
|
const [usage, setUsage] = useState<any>(null)
|
||||||
|
const [invoices, setInvoices] = useState<any[]>([])
|
||||||
|
const [plans, setPlans] = useState<any[]>([])
|
||||||
|
|
||||||
|
useEffect(() => { load() }, [])
|
||||||
|
|
||||||
|
async function load() {
|
||||||
|
const [s, u, i, p] = await Promise.all([
|
||||||
|
guardiaApi.get('/api/billing/subscription').then((r: any) => r.data).catch(() => null),
|
||||||
|
guardiaApi.get('/api/billing/usage').then((r: any) => r.data).catch(() => null),
|
||||||
|
guardiaApi.get('/api/billing/invoices').then((r: any) => r.data).catch(() => []),
|
||||||
|
guardiaApi.get('/api/billing/plans').then((r: any) => r.data).catch(() => []),
|
||||||
|
])
|
||||||
|
setSub(s); setUsage(u); setInvoices(i || []); setPlans(p || [])
|
||||||
|
}
|
||||||
|
|
||||||
|
async function generateInvoice() {
|
||||||
|
await guardiaApi.post('/api/billing/invoices/generate')
|
||||||
|
load()
|
||||||
|
}
|
||||||
|
|
||||||
|
const pctColor = (pct: number) => pct > 90 ? '#ef4444' : pct > 70 ? '#f59e0b' : '#003366'
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div style={{ padding: '24px 28px' }}>
|
||||||
|
<h2 style={{ margin: '0 0 20px', fontSize: 20, fontWeight: 700 }}>💰 구독 · 과금 관리</h2>
|
||||||
|
|
||||||
|
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 16, marginBottom: 20 }}>
|
||||||
|
{/* 현재 구독 */}
|
||||||
|
<div style={{ background: '#fff', border: '1px solid #e2e8f0', borderRadius: 10, padding: 20 }}>
|
||||||
|
<h3 style={{ fontSize: 14, fontWeight: 600, margin: '0 0 12px' }}>📋 현재 구독</h3>
|
||||||
|
<div style={{ fontSize: 28, fontWeight: 700, color: '#003366' }}>{sub?.plan || 'COMMUNITY'}</div>
|
||||||
|
<div style={{ fontSize: 13, color: '#64748b', marginTop: 4 }}>
|
||||||
|
{sub?.price ? `월 ${sub.price.toLocaleString()}원` : '무료'} · {sub?.billing_cycle || 'MONTHLY'}
|
||||||
|
</div>
|
||||||
|
<div style={{ marginTop: 16, display: 'flex', gap: 8 }}>
|
||||||
|
{plans.slice(0, 3).map((p: any) => (
|
||||||
|
<button key={p.code} style={{
|
||||||
|
padding: '6px 12px', borderRadius: 6, fontSize: 12, cursor: 'pointer',
|
||||||
|
background: sub?.plan === p.code ? '#003366' : '#f8fafc',
|
||||||
|
color: sub?.plan === p.code ? '#fff' : '#1e293b',
|
||||||
|
border: `1px solid ${sub?.plan === p.code ? '#003366' : '#e2e8f0'}`,
|
||||||
|
}}>
|
||||||
|
{p.name}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 사용량 */}
|
||||||
|
<div style={{ background: '#fff', border: '1px solid #e2e8f0', borderRadius: 10, padding: 20 }}>
|
||||||
|
<h3 style={{ fontSize: 14, fontWeight: 600, margin: '0 0 12px' }}>📊 이번 달 사용량</h3>
|
||||||
|
{usage && [
|
||||||
|
{ label: '서버', used: usage.servers?.used || 0, limit: usage.servers?.limit || 20 },
|
||||||
|
{ label: '사용자', used: usage.users?.used || 0, limit: usage.users?.limit || 10 },
|
||||||
|
].map(item => {
|
||||||
|
const pct = item.limit > 0 ? Math.min(100, Math.round(item.used / item.limit * 100)) : 0
|
||||||
|
return (
|
||||||
|
<div key={item.label} style={{ marginBottom: 14 }}>
|
||||||
|
<div style={{ display: 'flex', justifyContent: 'space-between', fontSize: 12, marginBottom: 4 }}>
|
||||||
|
<span style={{ fontWeight: 600 }}>{item.label}</span>
|
||||||
|
<span style={{ color: pctColor(pct) }}>{item.used} / {item.limit < 0 ? '∞' : item.limit}</span>
|
||||||
|
</div>
|
||||||
|
<div style={{ background: '#f1f5f9', borderRadius: 4, height: 8 }}>
|
||||||
|
<div style={{ width: `${pct}%`, height: 8, background: pctColor(pct), borderRadius: 4, transition: 'width .4s' }} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
<div style={{ fontSize: 13, color: '#64748b' }}>SR (이번 달): {usage?.sr_this_month || 0}건</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 청구서 */}
|
||||||
|
<div style={{ background: '#fff', border: '1px solid #e2e8f0', borderRadius: 10, padding: 20 }}>
|
||||||
|
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 14 }}>
|
||||||
|
<h3 style={{ fontSize: 14, fontWeight: 600, margin: 0 }}>🧾 청구서 이력 ({invoices.length}건)</h3>
|
||||||
|
<button onClick={generateInvoice} style={{ padding: '6px 14px', background: '#003366', color: '#fff', border: 'none', borderRadius: 6, cursor: 'pointer', fontSize: 12 }}>
|
||||||
|
+ 청구서 생성
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<table style={{ width: '100%', borderCollapse: 'collapse', fontSize: 13 }}>
|
||||||
|
<thead>
|
||||||
|
<tr style={{ borderBottom: '1px solid #e2e8f0' }}>
|
||||||
|
{['기간', '플랜', '금액', '상태', '생성일'].map(h => (
|
||||||
|
<th key={h} style={{ textAlign: 'left', padding: '8px 12px', fontSize: 11, fontWeight: 600, color: '#64748b' }}>{h}</th>
|
||||||
|
))}
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{invoices.length === 0 ? (
|
||||||
|
<tr><td colSpan={5} style={{ textAlign: 'center', padding: 24, color: '#94a3b8' }}>청구서 없음</td></tr>
|
||||||
|
) : invoices.slice(0, 12).map((inv: any) => (
|
||||||
|
<tr key={inv.id} style={{ borderBottom: '1px solid #f1f5f9' }}>
|
||||||
|
<td style={{ padding: '10px 12px' }}>{inv.period}</td>
|
||||||
|
<td style={{ padding: '10px 12px' }}>{inv.plan || '-'}</td>
|
||||||
|
<td style={{ padding: '10px 12px' }}>{inv.amount ? `${inv.amount.toLocaleString()}원` : '무료'}</td>
|
||||||
|
<td style={{ padding: '10px 12px' }}>
|
||||||
|
<span style={{ background: inv.status === 'PAID' ? '#dcfce7' : '#fef3c7', color: inv.status === 'PAID' ? '#166534' : '#92400e', padding: '2px 8px', borderRadius: 10, fontSize: 11 }}>
|
||||||
|
{inv.status}
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
<td style={{ padding: '10px 12px', color: '#64748b', fontSize: 11 }}>
|
||||||
|
{inv.created_at ? new Date(inv.created_at).toLocaleDateString('ko-KR') : '-'}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
213
workspace/guardia-manager/frontend/src/pages/IntegrationHub.tsx
Normal file
213
workspace/guardia-manager/frontend/src/pages/IntegrationHub.tsx
Normal file
@ -0,0 +1,213 @@
|
|||||||
|
import { useEffect, useState } from 'react'
|
||||||
|
import { guardiaApi } from '../api/clients'
|
||||||
|
|
||||||
|
type Tab = 'jira' | 'slack' | 'servicenow' | 'erp' | 'kakao' | 'sso'
|
||||||
|
|
||||||
|
export default function IntegrationHub() {
|
||||||
|
const [tab, setTab] = useState<Tab>('jira')
|
||||||
|
const tabs: { id: Tab; label: string; icon: string }[] = [
|
||||||
|
{ id: 'jira', label: 'Jira', icon: '🔵' },
|
||||||
|
{ id: 'slack', label: 'Slack', icon: '💬' },
|
||||||
|
{ id: 'servicenow', label: 'ServiceNow', icon: '🔗' },
|
||||||
|
{ id: 'erp', label: 'ERP', icon: '🏢' },
|
||||||
|
{ id: 'kakao', label: '카카오', icon: '💛' },
|
||||||
|
{ id: 'sso', label: 'SSO', icon: '🔐' },
|
||||||
|
]
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div style={{ padding: '24px 28px' }}>
|
||||||
|
<h2 style={{ margin: '0 0 20px', fontSize: 20, fontWeight: 700 }}>🔗 외부 연동 허브</h2>
|
||||||
|
<div style={{ display: 'flex', gap: 4, marginBottom: 20, borderBottom: '1px solid #e2e8f0' }}>
|
||||||
|
{tabs.map(t => (
|
||||||
|
<button key={t.id} onClick={() => setTab(t.id)} style={{
|
||||||
|
padding: '8px 16px', border: 'none', background: 'none', cursor: 'pointer',
|
||||||
|
fontSize: 13, fontWeight: tab === t.id ? 700 : 400,
|
||||||
|
color: tab === t.id ? '#003366' : '#64748b',
|
||||||
|
borderBottom: tab === t.id ? '2px solid #003366' : '2px solid transparent',
|
||||||
|
}}>{t.icon} {t.label}</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
{tab === 'jira' && <JiraTab />}
|
||||||
|
{tab === 'slack' && <SlackTab />}
|
||||||
|
{tab === 'servicenow' && <ServiceNowTab />}
|
||||||
|
{tab === 'erp' && <ERPTab />}
|
||||||
|
{tab === 'kakao' && <KakaoTab />}
|
||||||
|
{tab === 'sso' && <SSOTab />}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function Card({ children, style }: { children: React.ReactNode; style?: React.CSSProperties }) {
|
||||||
|
return <div style={{ background: '#fff', border: '1px solid #e2e8f0', borderRadius: 10, padding: 20, ...style }}>{children}</div>
|
||||||
|
}
|
||||||
|
|
||||||
|
function JiraTab() {
|
||||||
|
const [cfg, setCfg] = useState<any>(null)
|
||||||
|
const [mappings, setMappings] = useState<any[]>([])
|
||||||
|
useEffect(() => {
|
||||||
|
guardiaApi.get('/api/jira/config').then((r: any) => setCfg(r.data)).catch(() => {})
|
||||||
|
guardiaApi.get('/api/jira/mappings').then((r: any) => setMappings(r.data || [])).catch(() => {})
|
||||||
|
}, [])
|
||||||
|
return (
|
||||||
|
<div style={{ display: 'grid', gridTemplateColumns: '1fr 2fr', gap: 16 }}>
|
||||||
|
<Card>
|
||||||
|
<h3 style={{ fontSize: 14, fontWeight: 600, margin: '0 0 12px' }}>Jira 연동 설정</h3>
|
||||||
|
{cfg ? (
|
||||||
|
<div style={{ fontSize: 13, lineHeight: 2 }}>
|
||||||
|
<div>URL: {cfg.base_url}</div>
|
||||||
|
<div>프로젝트: {cfg.project_key}</div>
|
||||||
|
<div style={{ color: '#10B981' }}>✅ 연동됨</div>
|
||||||
|
</div>
|
||||||
|
) : <p style={{ color: '#94a3b8', fontSize: 13 }}>설정 없음</p>}
|
||||||
|
</Card>
|
||||||
|
<Card>
|
||||||
|
<h3 style={{ fontSize: 14, fontWeight: 600, margin: '0 0 12px' }}>SR-Issue 매핑 ({mappings.length}건)</h3>
|
||||||
|
<table style={{ width: '100%', borderCollapse: 'collapse', fontSize: 12 }}>
|
||||||
|
<thead><tr style={{ borderBottom: '1px solid #f1f5f9' }}>
|
||||||
|
{['SR ID', 'Jira Key', '동기화 시간'].map(h => <th key={h} style={{ textAlign: 'left', padding: '6px 8px', color: '#64748b', fontSize: 11 }}>{h}</th>)}
|
||||||
|
</tr></thead>
|
||||||
|
<tbody>
|
||||||
|
{mappings.slice(0, 8).map((m: any) => (
|
||||||
|
<tr key={m.id} style={{ borderBottom: '1px solid #f8fafc' }}>
|
||||||
|
<td style={{ padding: '8px' }}>SR-{m.sr_id}</td>
|
||||||
|
<td style={{ padding: '8px', color: '#003366' }}>{m.jira_key}</td>
|
||||||
|
<td style={{ padding: '8px', color: '#94a3b8', fontSize: 11 }}>{m.synced_at ? new Date(m.synced_at).toLocaleDateString('ko-KR') : '-'}</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
{!mappings.length && <tr><td colSpan={3} style={{ textAlign: 'center', padding: 16, color: '#94a3b8' }}>매핑 없음</td></tr>}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function SlackTab() {
|
||||||
|
const [cfg, setCfg] = useState<any>(null)
|
||||||
|
const [webhook, setWebhook] = useState('')
|
||||||
|
const [channel, setChannel] = useState('#guardia-ops')
|
||||||
|
useEffect(() => { guardiaApi.get('/api/slack/config').then((r: any) => setCfg(r.data)).catch(() => {}) }, [])
|
||||||
|
async function save() {
|
||||||
|
await guardiaApi.post('/api/slack/config', { name: 'Slack', webhook_url: webhook, default_channel: channel })
|
||||||
|
const r: any = await guardiaApi.get('/api/slack/config')
|
||||||
|
setCfg(r.data)
|
||||||
|
}
|
||||||
|
async function testMsg() { await guardiaApi.get('/api/slack/test') }
|
||||||
|
return (
|
||||||
|
<Card style={{ maxWidth: 520 }}>
|
||||||
|
<h3 style={{ fontSize: 14, fontWeight: 600, margin: '0 0 12px' }}>Slack 연동</h3>
|
||||||
|
{cfg && <div style={{ fontSize: 13, marginBottom: 12, color: '#10B981' }}>✅ 연동됨 ({cfg.default_channel})</div>}
|
||||||
|
<div style={{ marginBottom: 12 }}>
|
||||||
|
<label style={{ fontSize: 12, fontWeight: 600, display: 'block', marginBottom: 4 }}>Webhook URL</label>
|
||||||
|
<input value={webhook} onChange={e => setWebhook(e.target.value)} placeholder="https://hooks.slack.com/..." style={{ width: '100%', padding: '8px 12px', border: '1px solid #e2e8f0', borderRadius: 6, fontSize: 13, boxSizing: 'border-box' }} />
|
||||||
|
</div>
|
||||||
|
<div style={{ marginBottom: 16 }}>
|
||||||
|
<label style={{ fontSize: 12, fontWeight: 600, display: 'block', marginBottom: 4 }}>기본 채널</label>
|
||||||
|
<input value={channel} onChange={e => setChannel(e.target.value)} style={{ width: '100%', padding: '8px 12px', border: '1px solid #e2e8f0', borderRadius: 6, fontSize: 13, boxSizing: 'border-box' }} />
|
||||||
|
</div>
|
||||||
|
<div style={{ display: 'flex', gap: 8 }}>
|
||||||
|
<button onClick={save} style={{ padding: '7px 16px', background: '#003366', color: '#fff', border: 'none', borderRadius: 6, cursor: 'pointer', fontSize: 12 }}>저장</button>
|
||||||
|
{cfg && <button onClick={testMsg} style={{ padding: '7px 16px', border: '1px solid #e2e8f0', borderRadius: 6, cursor: 'pointer', fontSize: 12 }}>테스트</button>}
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function ServiceNowTab() {
|
||||||
|
const [url, setUrl] = useState(''); const [user, setUser] = useState(''); const [pw, setPw] = useState('')
|
||||||
|
async function save() { await guardiaApi.post('/api/servicenow/config', { instance_url: url, username: user, password: pw }) }
|
||||||
|
return (
|
||||||
|
<Card style={{ maxWidth: 520 }}>
|
||||||
|
<h3 style={{ fontSize: 14, fontWeight: 600, margin: '0 0 12px' }}>ServiceNow 연동</h3>
|
||||||
|
{[['인스턴스 URL', url, setUrl, 'https://company.service-now.com'], ['사용자명', user, setUser, ''], ['비밀번호', pw, setPw, '']].map(([label, val, set, ph]: any) => (
|
||||||
|
<div key={label as string} style={{ marginBottom: 12 }}>
|
||||||
|
<label style={{ fontSize: 12, fontWeight: 600, display: 'block', marginBottom: 4 }}>{label as string}</label>
|
||||||
|
<input type={label === '비밀번호' ? 'password' : 'text'} value={val as string} onChange={(e: any) => (set as Function)(e.target.value)} placeholder={ph as string}
|
||||||
|
style={{ width: '100%', padding: '8px 12px', border: '1px solid #e2e8f0', borderRadius: 6, fontSize: 13, boxSizing: 'border-box' }} />
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
<button onClick={save} style={{ padding: '7px 16px', background: '#003366', color: '#fff', border: 'none', borderRadius: 6, cursor: 'pointer', fontSize: 12 }}>저장</button>
|
||||||
|
</Card>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function ERPTab() {
|
||||||
|
const [cfgs, setCfgs] = useState<any[]>([])
|
||||||
|
useEffect(() => { guardiaApi.get('/api/erp/config').then((r: any) => setCfgs(r.data || [])).catch(() => {}) }, [])
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<h3 style={{ fontSize: 14, fontWeight: 600, margin: '0 0 12px' }}>등록된 ERP 연동 ({cfgs.length}개)</h3>
|
||||||
|
{cfgs.map((c: any) => (
|
||||||
|
<Card key={c.id} style={{ marginBottom: 10, display: 'flex', alignItems: 'center', gap: 12 }}>
|
||||||
|
<span style={{ fontSize: 24 }}>🏢</span>
|
||||||
|
<div style={{ flex: 1 }}>
|
||||||
|
<div style={{ fontWeight: 600, fontSize: 13 }}>{c.name}</div>
|
||||||
|
<div style={{ fontSize: 11, color: '#64748b' }}>{c.erp_type} · {c.base_url}</div>
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
))}
|
||||||
|
{!cfgs.length && <p style={{ color: '#94a3b8' }}>등록된 연동 없음</p>}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function KakaoTab() {
|
||||||
|
const [cfg, setCfg] = useState<any>(null)
|
||||||
|
const [history, setHistory] = useState<any[]>([])
|
||||||
|
useEffect(() => {
|
||||||
|
guardiaApi.get('/api/kakao/config').then((r: any) => setCfg(r.data)).catch(() => {})
|
||||||
|
guardiaApi.get('/api/kakao/history').then((r: any) => setHistory(r.data || [])).catch(() => {})
|
||||||
|
}, [])
|
||||||
|
return (
|
||||||
|
<div style={{ display: 'grid', gridTemplateColumns: '1fr 2fr', gap: 16 }}>
|
||||||
|
<Card>
|
||||||
|
<h3 style={{ fontSize: 14, fontWeight: 600, margin: '0 0 12px' }}>카카오 알림톡</h3>
|
||||||
|
{cfg ? <div style={{ fontSize: 13, color: '#10B981' }}>✅ 연동됨<br/><span style={{ color: '#64748b' }}>발신: {cfg.sender}</span></div>
|
||||||
|
: <p style={{ color: '#94a3b8', fontSize: 13 }}>미설정</p>}
|
||||||
|
</Card>
|
||||||
|
<Card>
|
||||||
|
<h3 style={{ fontSize: 14, fontWeight: 600, margin: '0 0 12px' }}>발송 이력</h3>
|
||||||
|
<table style={{ width: '100%', borderCollapse: 'collapse', fontSize: 12 }}>
|
||||||
|
<thead><tr style={{ borderBottom: '1px solid #f1f5f9' }}>
|
||||||
|
{['템플릿', '수신', '결과', '시간'].map(h => <th key={h} style={{ textAlign: 'left', padding: '4px 8px', color: '#64748b', fontSize: 11 }}>{h}</th>)}
|
||||||
|
</tr></thead>
|
||||||
|
<tbody>
|
||||||
|
{history.slice(0, 8).map((h: any) => (
|
||||||
|
<tr key={h.id} style={{ borderBottom: '1px solid #f8fafc' }}>
|
||||||
|
<td style={{ padding: '6px 8px' }}>{h.template}</td>
|
||||||
|
<td style={{ padding: '6px 8px' }}>{h.receivers}명</td>
|
||||||
|
<td style={{ padding: '6px 8px', color: h.success ? '#10B981' : '#EF4444' }}>{h.success ? '성공' : '실패'}</td>
|
||||||
|
<td style={{ padding: '6px 8px', color: '#94a3b8', fontSize: 11 }}>{h.sent_at ? new Date(h.sent_at).toLocaleDateString('ko-KR') : '-'}</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
{!history.length && <tr><td colSpan={4} style={{ textAlign: 'center', padding: 12, color: '#94a3b8' }}>이력 없음</td></tr>}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function SSOTab() {
|
||||||
|
const [configs, setConfigs] = useState<any[]>([])
|
||||||
|
useEffect(() => { guardiaApi.get('/api/sso/config').then((r: any) => setConfigs(r.data || [])).catch(() => {}) }, [])
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<h3 style={{ fontSize: 14, fontWeight: 600, margin: '0 0 12px' }}>등록된 SSO IdP ({configs.length}개)</h3>
|
||||||
|
{configs.map((c: any) => (
|
||||||
|
<Card key={c.id} style={{ marginBottom: 10, display: 'flex', alignItems: 'center', gap: 12 }}>
|
||||||
|
<span style={{ fontSize: 24 }}>{c.provider_type === 'SAML' ? '🏛️' : '🔑'}</span>
|
||||||
|
<div style={{ flex: 1 }}>
|
||||||
|
<div style={{ fontWeight: 600, fontSize: 13 }}>{c.name}</div>
|
||||||
|
<div style={{ fontSize: 11, color: '#64748b' }}>{c.provider_type} · {c.is_active ? '활성' : '비활성'}</div>
|
||||||
|
</div>
|
||||||
|
<a href={`/api/sso/login/${c.id}`} style={{ fontSize: 12, color: '#003366', textDecoration: 'none' }}>테스트 →</a>
|
||||||
|
</Card>
|
||||||
|
))}
|
||||||
|
{!configs.length && <p style={{ color: '#94a3b8' }}>등록된 IdP 없음</p>}
|
||||||
|
<div style={{ marginTop: 16 }}>
|
||||||
|
<a href="/api/sso/metadata" target="_blank" style={{ fontSize: 12, color: '#64748b' }}>📄 SP Metadata XML</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
119
workspace/guardia-manager/frontend/src/pages/KpiDashboard.tsx
Normal file
119
workspace/guardia-manager/frontend/src/pages/KpiDashboard.tsx
Normal file
@ -0,0 +1,119 @@
|
|||||||
|
import { useEffect, useState } from 'react'
|
||||||
|
import { guardiaApi } from '../api/clients'
|
||||||
|
|
||||||
|
const STATUS_COLOR: Record<string, string> = {
|
||||||
|
GREEN: '#22c55e', YELLOW: '#f59e0b', RED: '#ef4444', NO_DATA: '#94a3b8',
|
||||||
|
}
|
||||||
|
const STATUS_ICON: Record<string, string> = {
|
||||||
|
GREEN: '✅', YELLOW: '⚠️', RED: '❌', NO_DATA: '❔',
|
||||||
|
}
|
||||||
|
|
||||||
|
interface KPI {
|
||||||
|
id: number; name: string; display_name: string
|
||||||
|
unit: string; direction: string; target: number
|
||||||
|
current_value: number | null; status: string
|
||||||
|
achievement_pct: number | null; last_calculated_at: string | null
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Dashboard { kpis: KPI[]; summary: Record<string, number>; overall_status: string }
|
||||||
|
|
||||||
|
function GaugeBar({ pct }: { pct: number | null }) {
|
||||||
|
const p = Math.min(100, Math.max(0, pct ?? 0))
|
||||||
|
const color = p >= 95 ? '#22c55e' : p >= 80 ? '#f59e0b' : '#ef4444'
|
||||||
|
return (
|
||||||
|
<div style={{ background: '#f1f5f9', borderRadius: 4, height: 6, marginTop: 6 }}>
|
||||||
|
<div style={{ width: `${p}%`, height: 6, background: color, borderRadius: 4, transition: 'width .4s' }} />
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function KpiDashboard() {
|
||||||
|
const [data, setData] = useState<Dashboard | null>(null)
|
||||||
|
const [loading, setLoading] = useState(true)
|
||||||
|
|
||||||
|
useEffect(() => { load() }, [])
|
||||||
|
|
||||||
|
async function load() {
|
||||||
|
setLoading(true)
|
||||||
|
try {
|
||||||
|
const d = await guardiaApi.get('/api/kpi/dashboard').then((r: any) => r.data)
|
||||||
|
setData(d)
|
||||||
|
} catch { /* 오류 무시 */ }
|
||||||
|
setLoading(false)
|
||||||
|
}
|
||||||
|
|
||||||
|
async function applyTemplates() {
|
||||||
|
await guardiaApi.post('/api/kpi/apply-template', {
|
||||||
|
template_names: ['MTTR', 'FCR', 'SLA_COMPLIANCE', 'SR_BACKLOG', 'DEPLOY_SUCCESS_RATE'],
|
||||||
|
})
|
||||||
|
load()
|
||||||
|
}
|
||||||
|
|
||||||
|
async function recalc(id: number) {
|
||||||
|
await guardiaApi.post(`/api/kpi/${id}/calculate`)
|
||||||
|
load()
|
||||||
|
}
|
||||||
|
|
||||||
|
if (loading) return <div style={{ padding: 32, textAlign: 'center' }}>⏳ 로딩 중...</div>
|
||||||
|
|
||||||
|
const overall = data?.overall_status || 'NO_DATA'
|
||||||
|
const summary = data?.summary || {}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div style={{ padding: '24px 28px' }}>
|
||||||
|
{/* 헤더 */}
|
||||||
|
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 20 }}>
|
||||||
|
<div>
|
||||||
|
<h2 style={{ margin: 0, fontSize: 20, fontWeight: 700 }}>
|
||||||
|
{STATUS_ICON[overall]} KPI 대시보드
|
||||||
|
</h2>
|
||||||
|
<p style={{ margin: '4px 0 0', color: '#64748b', fontSize: 13 }}>
|
||||||
|
GREEN:{summary.GREEN||0} · YELLOW:{summary.YELLOW||0} · RED:{summary.RED||0}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div style={{ display: 'flex', gap: 8 }}>
|
||||||
|
<button onClick={load} style={{ padding: '6px 14px', border: '1px solid #e2e8f0', borderRadius: 6, background: '#fff', cursor: 'pointer' }}>
|
||||||
|
🔄 새로고침
|
||||||
|
</button>
|
||||||
|
{(!data?.kpis.length) && (
|
||||||
|
<button onClick={applyTemplates} style={{ padding: '6px 14px', background: '#003366', color: '#fff', border: 'none', borderRadius: 6, cursor: 'pointer' }}>
|
||||||
|
📋 템플릿 적용
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* KPI 카드 그리드 */}
|
||||||
|
{data?.kpis.length === 0 ? (
|
||||||
|
<div style={{ padding: 48, textAlign: 'center', border: '2px dashed #e2e8f0', borderRadius: 12 }}>
|
||||||
|
<p style={{ color: '#94a3b8' }}>KPI가 없습니다.</p>
|
||||||
|
<button onClick={applyTemplates} style={{ padding: '8px 20px', background: '#003366', color: '#fff', border: 'none', borderRadius: 8, cursor: 'pointer' }}>
|
||||||
|
내장 KPI 5개 등록
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fill, minmax(240px, 1fr))', gap: 16 }}>
|
||||||
|
{data?.kpis.map(k => (
|
||||||
|
<div key={k.id} style={{ background: '#fff', border: `1px solid #e2e8f0`, borderLeft: `4px solid ${STATUS_COLOR[k.status]}`, borderRadius: 10, padding: 18 }}>
|
||||||
|
<div style={{ display: 'flex', justifyContent: 'space-between', marginBottom: 8 }}>
|
||||||
|
<span style={{ fontSize: 13, fontWeight: 600, color: '#1e293b' }}>{k.display_name}</span>
|
||||||
|
<span style={{ fontSize: 11, background: STATUS_COLOR[k.status], color: '#fff', padding: '2px 8px', borderRadius: 10 }}>{k.status}</span>
|
||||||
|
</div>
|
||||||
|
<div style={{ fontSize: 30, fontWeight: 700, color: STATUS_COLOR[k.status] }}>
|
||||||
|
{k.current_value !== null ? k.current_value : '—'}
|
||||||
|
<span style={{ fontSize: 14, marginLeft: 4, color: '#64748b' }}>{k.unit}</span>
|
||||||
|
</div>
|
||||||
|
<div style={{ fontSize: 12, color: '#94a3b8', marginTop: 4 }}>
|
||||||
|
목표: {k.target}{k.unit} · 달성: {k.achievement_pct !== null ? `${k.achievement_pct}%` : '—'}
|
||||||
|
</div>
|
||||||
|
<GaugeBar pct={k.achievement_pct} />
|
||||||
|
<button onClick={() => recalc(k.id)} style={{ marginTop: 10, width: '100%', padding: '5px', border: '1px solid #e2e8f0', borderRadius: 6, background: '#f8fafc', cursor: 'pointer', fontSize: 12 }}>
|
||||||
|
↻ 재계산
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@ -84,6 +84,13 @@ export default function TabLayout() {
|
|||||||
tabBarIcon: ({ focused }) => <TabIcon icon="🔀" label="네트워크" focused={focused} />,
|
tabBarIcon: ({ focused }) => <TabIcon icon="🔀" label="네트워크" focused={focused} />,
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
|
<Tabs.Screen
|
||||||
|
name="insights"
|
||||||
|
options={{
|
||||||
|
title: 'AI 인사이트',
|
||||||
|
tabBarIcon: ({ focused }) => <TabIcon icon="🧠" label="AI" focused={focused} />,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
<Tabs.Screen
|
<Tabs.Screen
|
||||||
name="settings"
|
name="settings"
|
||||||
options={{
|
options={{
|
||||||
|
|||||||
131
workspace/guardia-messenger/app/(tabs)/insights.tsx
Normal file
131
workspace/guardia-messenger/app/(tabs)/insights.tsx
Normal file
@ -0,0 +1,131 @@
|
|||||||
|
import { useEffect, useState } from 'react'
|
||||||
|
import { View, Text, ScrollView, StyleSheet, TouchableOpacity, ActivityIndicator, RefreshControl } from 'react-native'
|
||||||
|
import { COLORS } from '../../constants/Config'
|
||||||
|
import { apiClient } from '../../services/api'
|
||||||
|
import { useAuth } from '../../hooks/useAuth'
|
||||||
|
|
||||||
|
interface Weekly { stats: any; ai_insight: string; top_categories: any[] }
|
||||||
|
interface Anomaly { anomalies: any[]; today_sr: number; avg_7d: number; open_sr: number }
|
||||||
|
interface Predict { breach_probability_7d: number; current_rate: number; status: string; insight: string }
|
||||||
|
|
||||||
|
const STATUS_COLOR: Record<string, string> = {
|
||||||
|
CRITICAL: '#ef4444', WARNING: '#f59e0b', NORMAL: '#22c55e', NO_DATA: '#94a3b8',
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function InsightsScreen() {
|
||||||
|
const { token } = useAuth()
|
||||||
|
const [weekly, setWeekly] = useState<Weekly | null>(null)
|
||||||
|
const [anomaly, setAnomaly] = useState<Anomaly | null>(null)
|
||||||
|
const [predict, setPredict] = useState<Predict | null>(null)
|
||||||
|
const [loading, setLoading] = useState(true)
|
||||||
|
const [refreshing, setRefreshing] = useState(false)
|
||||||
|
|
||||||
|
useEffect(() => { load() }, [])
|
||||||
|
|
||||||
|
async function load() {
|
||||||
|
setLoading(true)
|
||||||
|
try {
|
||||||
|
const [w, a, p] = await Promise.all([
|
||||||
|
apiClient.get('/api/insights/weekly', token),
|
||||||
|
apiClient.get('/api/insights/anomalies', token),
|
||||||
|
apiClient.get('/api/predict/sla-breach', token),
|
||||||
|
])
|
||||||
|
setWeekly(w); setAnomaly(a); setPredict(p)
|
||||||
|
} catch { /* 오류 무시 */ }
|
||||||
|
setLoading(false); setRefreshing(false)
|
||||||
|
}
|
||||||
|
|
||||||
|
function onRefresh() { setRefreshing(true); load() }
|
||||||
|
|
||||||
|
if (loading) return (
|
||||||
|
<View style={s.center}>
|
||||||
|
<ActivityIndicator color={COLORS.accent} size="large" />
|
||||||
|
</View>
|
||||||
|
)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ScrollView style={s.scroll} refreshControl={<RefreshControl refreshing={refreshing} onRefresh={onRefresh} tintColor={COLORS.accent} />}>
|
||||||
|
{/* 이상 감지 알림 */}
|
||||||
|
{(anomaly?.anomalies?.length || 0) > 0 && (
|
||||||
|
<View style={s.alertCard}>
|
||||||
|
<Text style={s.alertTitle}>⚠️ 이상 감지 {anomaly!.anomalies.length}건</Text>
|
||||||
|
{anomaly!.anomalies.map((a: any, i: number) => (
|
||||||
|
<Text key={i} style={s.alertMsg}>{a.message}</Text>
|
||||||
|
))}
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* 통계 카드 */}
|
||||||
|
<View style={s.statsRow}>
|
||||||
|
{[
|
||||||
|
{ label: '신규 SR', val: weekly?.stats?.total || 0, color: COLORS.primary },
|
||||||
|
{ label: '완료율', val: `${weekly?.stats?.completion_rate || 0}%`, color: '#22c55e' },
|
||||||
|
{ label: '미처리', val: weekly?.stats?.open || 0, color: '#ef4444' },
|
||||||
|
].map(item => (
|
||||||
|
<View key={item.label} style={[s.statCard, { borderTopColor: item.color }]}>
|
||||||
|
<Text style={[s.statVal, { color: item.color }]}>{item.val}</Text>
|
||||||
|
<Text style={s.statLabel}>{item.label}</Text>
|
||||||
|
</View>
|
||||||
|
))}
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{/* SLA 예측 */}
|
||||||
|
{predict && (
|
||||||
|
<View style={[s.card, { borderLeftColor: STATUS_COLOR[predict.status] || '#94a3b8', borderLeftWidth: 4 }]}>
|
||||||
|
<Text style={s.cardTitle}>📉 SLA 위반 예측 (7일)</Text>
|
||||||
|
<Text style={[s.bigNum, { color: STATUS_COLOR[predict.status] || '#94a3b8' }]}>
|
||||||
|
{Math.round((predict.breach_probability_7d || 0) * 100)}%
|
||||||
|
</Text>
|
||||||
|
<Text style={s.subText}>현재 SLA: {predict.current_rate || 0}%</Text>
|
||||||
|
{predict.insight ? <Text style={s.insightText}>{predict.insight}</Text> : null}
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* AI 주간 인사이트 */}
|
||||||
|
{weekly?.ai_insight && (
|
||||||
|
<View style={s.card}>
|
||||||
|
<Text style={s.cardTitle}>🤖 AI 주간 인사이트</Text>
|
||||||
|
<Text style={s.insightText}>{weekly.ai_insight}</Text>
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* 상위 카테고리 */}
|
||||||
|
{(weekly?.top_categories?.length || 0) > 0 && (
|
||||||
|
<View style={s.card}>
|
||||||
|
<Text style={s.cardTitle}>📊 SR 상위 카테고리</Text>
|
||||||
|
{weekly!.top_categories.map((c: any, i: number) => (
|
||||||
|
<View key={i} style={s.categoryRow}>
|
||||||
|
<Text style={s.categoryName}>{c.category}</Text>
|
||||||
|
<View style={s.barBg}>
|
||||||
|
<View style={[s.barFill, { width: `${Math.min(100, c.count * 5)}%` as any }]} />
|
||||||
|
</View>
|
||||||
|
<Text style={s.categoryCount}>{c.count}건</Text>
|
||||||
|
</View>
|
||||||
|
))}
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
</ScrollView>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const s = StyleSheet.create({
|
||||||
|
scroll: { flex: 1, backgroundColor: '#f8fafc' },
|
||||||
|
center: { flex: 1, justifyContent: 'center', alignItems: 'center' },
|
||||||
|
alertCard: { margin: 12, padding: 14, backgroundColor: '#fef2f2', borderRadius: 12, borderLeftWidth: 4, borderLeftColor: '#ef4444' },
|
||||||
|
alertTitle: { fontWeight: '700', fontSize: 14, color: '#7f1d1d', marginBottom: 4 },
|
||||||
|
alertMsg: { fontSize: 12, color: '#991b1b', marginTop: 3 },
|
||||||
|
statsRow: { flexDirection: 'row', padding: 12, gap: 8 },
|
||||||
|
statCard: { flex: 1, backgroundColor: '#fff', borderRadius: 12, padding: 14, alignItems: 'center', borderTopWidth: 3, shadowColor: '#000', shadowOpacity: 0.05, shadowRadius: 4, elevation: 2 },
|
||||||
|
statVal: { fontSize: 24, fontWeight: '800' },
|
||||||
|
statLabel: { fontSize: 11, color: '#64748b', marginTop: 2 },
|
||||||
|
card: { margin: 12, marginTop: 0, backgroundColor: '#fff', borderRadius: 12, padding: 16, shadowColor: '#000', shadowOpacity: 0.05, shadowRadius: 4, elevation: 2 },
|
||||||
|
cardTitle: { fontSize: 14, fontWeight: '700', color: '#1e293b', marginBottom: 10 },
|
||||||
|
bigNum: { fontSize: 36, fontWeight: '800' },
|
||||||
|
subText: { fontSize: 12, color: '#64748b', marginTop: 2 },
|
||||||
|
insightText: { fontSize: 13, lineHeight: 20, color: '#374151', marginTop: 8 },
|
||||||
|
categoryRow: { flexDirection: 'row', alignItems: 'center', gap: 8, marginBottom: 8 },
|
||||||
|
categoryName: { fontSize: 12, width: 80, color: '#374151' },
|
||||||
|
barBg: { flex: 1, backgroundColor: '#f1f5f9', borderRadius: 3, height: 6 },
|
||||||
|
barFill: { height: 6, backgroundColor: COLORS.primary, borderRadius: 3 },
|
||||||
|
categoryCount:{ fontSize: 11, color: '#64748b', width: 30, textAlign: 'right' },
|
||||||
|
})
|
||||||
Loading…
Reference in New Issue
Block a user