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>
214 lines
11 KiB
TypeScript
214 lines
11 KiB
TypeScript
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>
|
|
)
|
|
}
|