sync: update from workspace (latest ITSM/CICD/DR changes)
This commit is contained in:
parent
1a89a432c7
commit
dc0bead983
@ -28,7 +28,10 @@ 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'))
|
||||
const AiPlatform = lazy(() => import('./pages/AiPlatform'))
|
||||
// ── GUARDiA 기능 개선 v4 ──
|
||||
const AppDistribution = lazy(() => import('./pages/AppDistribution'))
|
||||
const NotificationRules = lazy(() => import('./pages/NotificationRules'))
|
||||
|
||||
function Loading() {
|
||||
return (
|
||||
@ -73,6 +76,9 @@ export default function App() {
|
||||
<Route path="billing" element={<BillingManage />} />
|
||||
<Route path="integrations" element={<IntegrationHub />} />
|
||||
<Route path="ai-platform" element={<AiPlatform />} />
|
||||
{/* GUARDiA 기능 개선 v4 */}
|
||||
<Route path="app-distribution" element={<AppDistribution />} />
|
||||
<Route path="notification-rules" element={<NotificationRules />} />
|
||||
</Route>
|
||||
<Route path="*" element={<Navigate to="/" replace />} />
|
||||
</Routes>
|
||||
|
||||
@ -43,6 +43,9 @@ const NAV: NavItem[] = [
|
||||
{ label: 'AI 플랫폼', icon: '🧠', path: '/ai-platform' },
|
||||
{ label: '외부 연동', icon: '🔗', path: '/integrations' },
|
||||
{ label: '구독 · 과금', icon: '💰', path: '/billing' },
|
||||
// ── GUARDiA 기능 개선 v4 ──
|
||||
{ label: '앱 배포', icon: '📱', path: '/app-distribution' },
|
||||
{ label: '알림 규칙', icon: '🔔', path: '/notification-rules' },
|
||||
]
|
||||
|
||||
/* Variant 스타일 색상 상수 */
|
||||
|
||||
222
frontend/src/pages/AppDistribution.tsx
Normal file
222
frontend/src/pages/AppDistribution.tsx
Normal file
@ -0,0 +1,222 @@
|
||||
import { useEffect, useState, useRef } from 'react'
|
||||
import { guardiaApi } from '../api/clients'
|
||||
|
||||
interface AppVersion {
|
||||
id: number; version: string; platform: string
|
||||
download_count: number; is_latest: boolean
|
||||
qr_url: string; landing_url: string
|
||||
file_size_mb: number; release_notes: string; created_at: string
|
||||
}
|
||||
|
||||
export default function AppDistribution() {
|
||||
const [versions, setVersions] = useState<AppVersion[]>([])
|
||||
const [latest, setLatest] = useState<any>(null)
|
||||
const [stats, setStats] = useState<any>(null)
|
||||
const [uploading, setUploading] = useState(false)
|
||||
const [version, setVersion] = useState('')
|
||||
const [notes, setNotes] = useState('')
|
||||
const [iosUrl, setIosUrl] = useState('')
|
||||
const [externalUrl, setExternalUrl] = useState('')
|
||||
const [tab, setTab] = useState<'upload'|'url'>('upload')
|
||||
const fileRef = useRef<HTMLInputElement>(null)
|
||||
|
||||
useEffect(() => { load() }, [])
|
||||
|
||||
async function load() {
|
||||
const [v, l, s] = await Promise.all([
|
||||
guardiaApi.get('/api/app/versions').then((r: any) => r.data).catch(() => []),
|
||||
guardiaApi.get('/api/app/latest').then((r: any) => r.data).catch(() => null),
|
||||
guardiaApi.get('/api/app/stats').then((r: any) => r.data).catch(() => null),
|
||||
])
|
||||
setVersions(v); setLatest(l); setStats(s)
|
||||
}
|
||||
|
||||
async function uploadApk() {
|
||||
const file = fileRef.current?.files?.[0]
|
||||
if (!file || !version) return alert('APK 파일과 버전을 입력하세요')
|
||||
setUploading(true)
|
||||
try {
|
||||
const form = new FormData()
|
||||
form.append('file', file)
|
||||
form.append('version', version)
|
||||
form.append('release_notes', notes)
|
||||
form.append('ios_url', iosUrl)
|
||||
const r: any = await guardiaApi.post('/api/app/upload', form, {
|
||||
headers: { 'Content-Type': 'multipart/form-data' }
|
||||
})
|
||||
alert(`✅ 배포 완료! 버전 ${version}\nQR 코드가 생성됐습니다.`)
|
||||
setVersion(''); setNotes(''); setIosUrl('')
|
||||
if (fileRef.current) fileRef.current.value = ''
|
||||
load()
|
||||
} catch (e: any) {
|
||||
alert(`오류: ${e.response?.data?.detail || e.message}`)
|
||||
} finally { setUploading(false) }
|
||||
}
|
||||
|
||||
async function setUrl() {
|
||||
if (!externalUrl || !version) return alert('URL과 버전을 입력하세요')
|
||||
await guardiaApi.post('/api/app/url', {
|
||||
android_url: externalUrl, version, release_notes: notes, ios_url: iosUrl || undefined
|
||||
})
|
||||
alert('QR 코드 생성됨'); setExternalUrl(''); setVersion(''); setNotes('')
|
||||
load()
|
||||
}
|
||||
|
||||
async function deleteVersion(id: number) {
|
||||
if (!confirm('이 버전을 삭제하시겠습니까?')) return
|
||||
await guardiaApi.delete(`/api/app/versions/${id}`)
|
||||
load()
|
||||
}
|
||||
|
||||
const S = { card: { background: '#fff', border: '1px solid #e2e8f0', borderRadius: 10, padding: 20, marginBottom: 16 } }
|
||||
|
||||
return (
|
||||
<div style={{ padding: '24px 28px' }}>
|
||||
<h2 style={{ margin: '0 0 20px', fontSize: 20, fontWeight: 700 }}>📱 모바일 앱 직접 배포</h2>
|
||||
<p style={{ color: '#64748b', marginBottom: 20 }}>APK를 업로드하면 QR 코드가 생성됩니다. 사용자는 QR 스캔만으로 앱을 설치할 수 있습니다 (앱스토어 불필요).</p>
|
||||
|
||||
{/* 현재 최신 버전 + QR */}
|
||||
{latest?.has_version && (
|
||||
<div style={{ ...S.card, display: 'flex', gap: 24, alignItems: 'flex-start', borderLeft: '4px solid #003366' }}>
|
||||
<div style={{ flex: 1 }}>
|
||||
<div style={{ fontSize: 11, color: '#64748b', marginBottom: 4 }}>현재 최신 버전</div>
|
||||
<div style={{ fontSize: 28, fontWeight: 800, color: '#003366' }}>v{latest.version}</div>
|
||||
<div style={{ fontSize: 13, color: '#64748b', marginTop: 4 }}>{latest.platform} · 총 {latest.download_count}회 다운로드</div>
|
||||
{latest.release_notes && <div style={{ fontSize: 12, color: '#475569', marginTop: 8, lineHeight: 1.6 }}>{latest.release_notes}</div>}
|
||||
<div style={{ marginTop: 12, display: 'flex', gap: 8 }}>
|
||||
<a href={latest.qr_url} target="_blank" rel="noreferrer"
|
||||
style={{ padding: '7px 14px', background: '#003366', color: '#fff', borderRadius: 8, fontSize: 12, textDecoration: 'none' }}>
|
||||
🖼️ QR 이미지 열기
|
||||
</a>
|
||||
<a href={latest.landing_url} target="_blank" rel="noreferrer"
|
||||
style={{ padding: '7px 14px', border: '1px solid #e2e8f0', borderRadius: 8, fontSize: 12, textDecoration: 'none', color: '#1e293b' }}>
|
||||
📄 랜딩 페이지
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
{/* QR 이미지 */}
|
||||
<div style={{ textAlign: 'center' }}>
|
||||
<img src={latest.qr_url} alt="QR" width={120} height={120}
|
||||
style={{ border: '2px solid #e2e8f0', borderRadius: 8 }}
|
||||
onError={(e: any) => { e.target.style.display = 'none' }} />
|
||||
<div style={{ fontSize: 11, color: '#64748b', marginTop: 4 }}>스캔하여 설치</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 통계 */}
|
||||
{stats && (
|
||||
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(3,1fr)', gap: 12, marginBottom: 16 }}>
|
||||
{[
|
||||
{ label: '총 다운로드', val: stats.total_downloads, icon: '📥' },
|
||||
{ label: 'Android', val: stats.android, icon: '🤖' },
|
||||
{ label: 'iOS', val: stats.ios, icon: '🍎' },
|
||||
].map(s => (
|
||||
<div key={s.label} style={{ ...S.card, textAlign: 'center', padding: 14 }}>
|
||||
<div style={{ fontSize: 22 }}>{s.icon}</div>
|
||||
<div style={{ fontSize: 24, fontWeight: 700, color: '#003366' }}>{s.val}</div>
|
||||
<div style={{ fontSize: 12, color: '#64748b' }}>{s.label}</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 업로드 폼 */}
|
||||
<div style={S.card}>
|
||||
<h3 style={{ fontSize: 14, fontWeight: 600, margin: '0 0 16px' }}>새 버전 배포</h3>
|
||||
<div style={{ display: 'flex', gap: 4, marginBottom: 16, borderBottom: '1px solid #e2e8f0', paddingBottom: 0 }}>
|
||||
{[{id:'upload',label:'APK 파일 업로드'},{id:'url',label:'외부 URL 연결'}].map(t => (
|
||||
<button key={t.id} onClick={() => setTab(t.id as any)} 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>
|
||||
|
||||
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 12, marginBottom: 12 }}>
|
||||
<div>
|
||||
<label style={{ fontSize: 12, fontWeight: 600, display: 'block', marginBottom: 4 }}>버전 *</label>
|
||||
<input value={version} onChange={e => setVersion(e.target.value)} placeholder="예: 1.2.3"
|
||||
style={{ width: '100%', padding: '8px 12px', border: '1px solid #e2e8f0', borderRadius: 6, fontSize: 13, boxSizing: 'border-box' }} />
|
||||
</div>
|
||||
<div>
|
||||
<label style={{ fontSize: 12, fontWeight: 600, display: 'block', marginBottom: 4 }}>iOS URL (선택)</label>
|
||||
<input value={iosUrl} onChange={e => setIosUrl(e.target.value)} placeholder="TestFlight URL"
|
||||
style={{ width: '100%', padding: '8px 12px', border: '1px solid #e2e8f0', borderRadius: 6, fontSize: 13, boxSizing: 'border-box' }} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{tab === 'upload' ? (
|
||||
<div>
|
||||
<label style={{ fontSize: 12, fontWeight: 600, display: 'block', marginBottom: 4 }}>APK 파일 *</label>
|
||||
<input type="file" accept=".apk" ref={fileRef}
|
||||
style={{ width: '100%', padding: '8px', border: '1px solid #e2e8f0', borderRadius: 6, fontSize: 13, boxSizing: 'border-box', marginBottom: 8 }} />
|
||||
</div>
|
||||
) : (
|
||||
<div>
|
||||
<label style={{ fontSize: 12, fontWeight: 600, display: 'block', marginBottom: 4 }}>Android 다운로드 URL *</label>
|
||||
<input value={externalUrl} onChange={e => setExternalUrl(e.target.value)} placeholder="https://expo.dev/... 또는 직접 APK URL"
|
||||
style={{ width: '100%', padding: '8px 12px', border: '1px solid #e2e8f0', borderRadius: 6, fontSize: 13, boxSizing: 'border-box', marginBottom: 8 }} />
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div style={{ marginBottom: 12 }}>
|
||||
<label style={{ fontSize: 12, fontWeight: 600, display: 'block', marginBottom: 4 }}>업데이트 내용</label>
|
||||
<textarea value={notes} onChange={e => setNotes(e.target.value)} rows={2} placeholder="이번 버전 변경사항..."
|
||||
style={{ width: '100%', padding: '8px 12px', border: '1px solid #e2e8f0', borderRadius: 6, fontSize: 13, resize: 'vertical', boxSizing: 'border-box' }} />
|
||||
</div>
|
||||
|
||||
<button
|
||||
onClick={tab === 'upload' ? uploadApk : setUrl}
|
||||
disabled={uploading}
|
||||
style={{ padding: '9px 20px', background: uploading ? '#94a3b8' : '#003366', color: '#fff', border: 'none', borderRadius: 8, fontSize: 14, fontWeight: 600, cursor: uploading ? 'not-allowed' : 'pointer' }}>
|
||||
{uploading ? '업로드 중...' : '🚀 배포 + QR 생성'}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* 버전 이력 */}
|
||||
<div style={S.card}>
|
||||
<h3 style={{ fontSize: 14, fontWeight: 600, margin: '0 0 12px' }}>버전 이력</h3>
|
||||
<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>
|
||||
{versions.length === 0 ? (
|
||||
<tr><td colSpan={7} style={{ textAlign: 'center', padding: 24, color: '#94a3b8' }}>배포 이력 없음</td></tr>
|
||||
) : versions.map(v => (
|
||||
<tr key={v.id} style={{ borderBottom: '1px solid #f1f5f9' }}>
|
||||
<td style={{ padding: '10px 12px', fontWeight: v.is_latest ? 700 : 400 }}>
|
||||
v{v.version} {v.is_latest && <span style={{ background: '#003366', color: '#fff', fontSize: 10, padding: '1px 6px', borderRadius: 8, marginLeft: 4 }}>최신</span>}
|
||||
</td>
|
||||
<td style={{ padding: '10px 12px' }}>{v.platform}</td>
|
||||
<td style={{ padding: '10px 12px', color: '#64748b' }}>{v.file_size_mb > 0 ? `${v.file_size_mb}MB` : '-'}</td>
|
||||
<td style={{ padding: '10px 12px' }}>{v.download_count}회</td>
|
||||
<td style={{ padding: '10px 12px' }}>
|
||||
<a href={v.qr_url} target="_blank" rel="noreferrer" style={{ fontSize: 12, color: '#003366' }}>QR ↗</a>
|
||||
</td>
|
||||
<td style={{ padding: '10px 12px', color: '#64748b', fontSize: 11 }}>
|
||||
{v.created_at ? new Date(v.created_at).toLocaleDateString('ko-KR') : '-'}
|
||||
</td>
|
||||
<td style={{ padding: '10px 12px' }}>
|
||||
{!v.is_latest && (
|
||||
<button onClick={() => deleteVersion(v.id)}
|
||||
style={{ padding: '3px 8px', border: '1px solid #fca5a5', color: '#dc2626', borderRadius: 4, background: 'none', cursor: 'pointer', fontSize: 11 }}>
|
||||
삭제
|
||||
</button>
|
||||
)}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
237
frontend/src/pages/NotificationRules.tsx
Normal file
237
frontend/src/pages/NotificationRules.tsx
Normal file
@ -0,0 +1,237 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
import { guardiaApi } from '../api/clients'
|
||||
|
||||
interface Condition { field: string; op: string; value: string }
|
||||
interface Rule {
|
||||
id: number; name: string; enabled: boolean
|
||||
conditions: Condition[]; channels: string[]
|
||||
silence_start?: string; silence_end?: string
|
||||
digest_mode: boolean; priority_filter: string
|
||||
created_at: string
|
||||
}
|
||||
|
||||
const FIELDS = ['sr_category','sr_priority','server_cpu','server_memory','server_disk','sr_status','tenant_id']
|
||||
const OPS = ['==','!=','>','>=','<','<=','contains']
|
||||
const CHANNELS = ['messenger','email','sms']
|
||||
const PRIORITIES = ['','CRITICAL','HIGH','MEDIUM','LOW']
|
||||
|
||||
export default function NotificationRules() {
|
||||
const [rules, setRules] = useState<Rule[]>([])
|
||||
const [editing, setEditing] = useState<Partial<Rule> | null>(null)
|
||||
const [testResult, setTestResult] = useState<Record<number,string>>({})
|
||||
|
||||
useEffect(() => { load() }, [])
|
||||
|
||||
async function load() {
|
||||
const r: any = await guardiaApi.get('/api/notify/rules').catch(() => ({ data: [] }))
|
||||
setRules(r.data)
|
||||
}
|
||||
|
||||
function newRule() {
|
||||
setEditing({
|
||||
name: '', enabled: true, conditions: [{ field: 'sr_priority', op: '==', value: 'CRITICAL' }],
|
||||
channels: ['messenger'], digest_mode: false, priority_filter: 'CRITICAL',
|
||||
})
|
||||
}
|
||||
|
||||
async function save() {
|
||||
if (!editing) return
|
||||
if (!editing.name) return alert('규칙 이름을 입력하세요')
|
||||
if (editing.id) {
|
||||
await guardiaApi.put(`/api/notify/rules/${editing.id}`, editing)
|
||||
} else {
|
||||
await guardiaApi.post('/api/notify/rules', editing)
|
||||
}
|
||||
setEditing(null); load()
|
||||
}
|
||||
|
||||
async function toggle(id: number, enabled: boolean) {
|
||||
await guardiaApi.patch(`/api/notify/rules/${id}/toggle`, { enabled: !enabled })
|
||||
load()
|
||||
}
|
||||
|
||||
async function del(id: number) {
|
||||
if (!confirm('삭제하시겠습니까?')) return
|
||||
await guardiaApi.delete(`/api/notify/rules/${id}`)
|
||||
load()
|
||||
}
|
||||
|
||||
async function test(id: number) {
|
||||
const r: any = await guardiaApi.post(`/api/notify/rules/${id}/test`).catch((e: any) => ({ data: { ok: false, message: e.message } }))
|
||||
setTestResult(prev => ({ ...prev, [id]: r.data.ok ? '✅ 테스트 발송 성공' : `❌ ${r.data.message}` }))
|
||||
setTimeout(() => setTestResult(prev => { const n = {...prev}; delete n[id]; return n }), 4000)
|
||||
}
|
||||
|
||||
function addCond() {
|
||||
setEditing(e => e ? { ...e, conditions: [...(e.conditions||[]), { field: 'sr_priority', op: '==', value: '' }] } : e)
|
||||
}
|
||||
|
||||
function removeCond(i: number) {
|
||||
setEditing(e => e ? { ...e, conditions: (e.conditions||[]).filter((_,idx) => idx !== i) } : e)
|
||||
}
|
||||
|
||||
function updateCond(i: number, key: keyof Condition, val: string) {
|
||||
setEditing(e => e ? { ...e, conditions: (e.conditions||[]).map((c, idx) => idx === i ? { ...c, [key]: val } : c) } : e)
|
||||
}
|
||||
|
||||
function toggleChannel(ch: string) {
|
||||
setEditing(e => {
|
||||
if (!e) return e
|
||||
const chs = e.channels || []
|
||||
return { ...e, channels: chs.includes(ch) ? chs.filter(c => c !== ch) : [...chs, ch] }
|
||||
})
|
||||
}
|
||||
|
||||
const S = {
|
||||
card: { background: '#fff', border: '1px solid #e2e8f0', borderRadius: 10, padding: 16, marginBottom: 12 },
|
||||
tag: (active: boolean) => ({ padding: '4px 10px', borderRadius: 12, fontSize: 12, fontWeight: 600, cursor: 'pointer', border: 'none',
|
||||
background: active ? '#003366' : '#f1f5f9', color: active ? '#fff' : '#64748b' }),
|
||||
}
|
||||
|
||||
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 }}>🔔 스마트 알림 규칙</h2>
|
||||
<button onClick={newRule}
|
||||
style={{ padding: '9px 18px', background: '#003366', color: '#fff', border: 'none', borderRadius: 8, fontSize: 13, fontWeight: 600, cursor: 'pointer' }}>
|
||||
+ 규칙 추가
|
||||
</button>
|
||||
</div>
|
||||
<p style={{ color: '#64748b', marginBottom: 20, fontSize: 13 }}>조건 기반 스마트 알림 규칙을 설정합니다. AND 조건으로 모두 충족 시 알림이 발송됩니다.</p>
|
||||
|
||||
{/* 규칙 목록 */}
|
||||
{rules.length === 0 && !editing && (
|
||||
<div style={{ textAlign: 'center', padding: 40, color: '#94a3b8', border: '2px dashed #e2e8f0', borderRadius: 10 }}>
|
||||
등록된 알림 규칙이 없습니다.<br />
|
||||
<button onClick={newRule} style={{ marginTop: 12, padding: '8px 16px', background: '#003366', color: '#fff', border: 'none', borderRadius: 8, cursor: 'pointer' }}>첫 규칙 만들기</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{rules.map(r => (
|
||||
<div key={r.id} style={{ ...S.card, opacity: r.enabled ? 1 : 0.6 }}>
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'flex-start' }}>
|
||||
<div style={{ flex: 1 }}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 8, marginBottom: 6 }}>
|
||||
<span style={{ fontSize: 15, fontWeight: 700 }}>{r.name}</span>
|
||||
<span style={{ padding: '2px 8px', borderRadius: 8, fontSize: 11, fontWeight: 600,
|
||||
background: r.enabled ? '#dcfce7' : '#f1f5f9', color: r.enabled ? '#166534' : '#64748b' }}>
|
||||
{r.enabled ? '활성' : '비활성'}
|
||||
</span>
|
||||
{r.digest_mode && <span style={{ padding: '2px 8px', borderRadius: 8, fontSize: 11, background: '#fef3c7', color: '#92400e' }}>다이제스트</span>}
|
||||
</div>
|
||||
<div style={{ fontSize: 12, color: '#64748b', marginBottom: 6 }}>
|
||||
조건: {(r.conditions||[]).map(c => `${c.field} ${c.op} "${c.value}"`).join(' AND ') || '없음'}
|
||||
</div>
|
||||
<div style={{ display: 'flex', gap: 4 }}>
|
||||
{(r.channels||[]).map(ch => (
|
||||
<span key={ch} style={{ padding: '2px 8px', background: '#eff6ff', color: '#1d4ed8', borderRadius: 8, fontSize: 11 }}>{ch}</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<div style={{ display: 'flex', gap: 6, marginLeft: 12 }}>
|
||||
{testResult[r.id] && <span style={{ fontSize: 12, alignSelf: 'center' }}>{testResult[r.id]}</span>}
|
||||
<button onClick={() => test(r.id)} style={{ padding: '5px 10px', border: '1px solid #e2e8f0', borderRadius: 6, background: 'none', cursor: 'pointer', fontSize: 12 }}>테스트</button>
|
||||
<button onClick={() => setEditing(r)} style={{ padding: '5px 10px', border: '1px solid #e2e8f0', borderRadius: 6, background: 'none', cursor: 'pointer', fontSize: 12 }}>편집</button>
|
||||
<button onClick={() => toggle(r.id, r.enabled)} style={{ padding: '5px 10px', border: '1px solid #e2e8f0', borderRadius: 6, background: 'none', cursor: 'pointer', fontSize: 12 }}>
|
||||
{r.enabled ? '비활성화' : '활성화'}
|
||||
</button>
|
||||
<button onClick={() => del(r.id)} style={{ padding: '5px 10px', border: '1px solid #fca5a5', color: '#dc2626', borderRadius: 6, background: 'none', cursor: 'pointer', fontSize: 12 }}>삭제</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
|
||||
{/* 편집 폼 */}
|
||||
{editing && (
|
||||
<div style={{ ...S.card, border: '2px solid #003366', marginTop: 8 }}>
|
||||
<h3 style={{ fontSize: 15, fontWeight: 700, margin: '0 0 16px', color: '#003366' }}>
|
||||
{editing.id ? '규칙 편집' : '새 규칙 만들기'}
|
||||
</h3>
|
||||
|
||||
<div style={{ marginBottom: 12 }}>
|
||||
<label style={{ fontSize: 12, fontWeight: 600, display: 'block', marginBottom: 4 }}>규칙 이름</label>
|
||||
<input value={editing.name||''} onChange={e => setEditing(v => ({ ...v!, name: e.target.value }))}
|
||||
placeholder="예: CRITICAL SR 즉시 알림"
|
||||
style={{ width: '100%', padding: '8px 12px', border: '1px solid #e2e8f0', borderRadius: 6, fontSize: 13, boxSizing: 'border-box' }} />
|
||||
</div>
|
||||
|
||||
{/* 조건 */}
|
||||
<div style={{ marginBottom: 12 }}>
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', marginBottom: 6 }}>
|
||||
<label style={{ fontSize: 12, fontWeight: 600 }}>조건 (AND)</label>
|
||||
<button onClick={addCond} style={{ fontSize: 11, color: '#003366', border: 'none', background: 'none', cursor: 'pointer' }}>+ 조건 추가</button>
|
||||
</div>
|
||||
{(editing.conditions||[]).map((c, i) => (
|
||||
<div key={i} style={{ display: 'flex', gap: 6, marginBottom: 6 }}>
|
||||
<select value={c.field} onChange={e => updateCond(i, 'field', e.target.value)}
|
||||
style={{ flex: 2, padding: '6px', border: '1px solid #e2e8f0', borderRadius: 6, fontSize: 12 }}>
|
||||
{FIELDS.map(f => <option key={f} value={f}>{f}</option>)}
|
||||
</select>
|
||||
<select value={c.op} onChange={e => updateCond(i, 'op', e.target.value)}
|
||||
style={{ flex: 1, padding: '6px', border: '1px solid #e2e8f0', borderRadius: 6, fontSize: 12 }}>
|
||||
{OPS.map(o => <option key={o} value={o}>{o}</option>)}
|
||||
</select>
|
||||
<input value={c.value} onChange={e => updateCond(i, 'value', e.target.value)} placeholder="값"
|
||||
style={{ flex: 2, padding: '6px 10px', border: '1px solid #e2e8f0', borderRadius: 6, fontSize: 12 }} />
|
||||
<button onClick={() => removeCond(i)} style={{ padding: '4px 8px', border: 'none', background: '#fef2f2', color: '#dc2626', borderRadius: 6, cursor: 'pointer', fontSize: 12 }}>×</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* 채널 */}
|
||||
<div style={{ marginBottom: 12 }}>
|
||||
<label style={{ fontSize: 12, fontWeight: 600, display: 'block', marginBottom: 6 }}>알림 채널</label>
|
||||
<div style={{ display: 'flex', gap: 6 }}>
|
||||
{CHANNELS.map(ch => (
|
||||
<button key={ch} onClick={() => toggleChannel(ch)}
|
||||
style={S.tag((editing.channels||[]).includes(ch))}>
|
||||
{ch === 'messenger' ? '📱 메신저' : ch === 'email' ? '📧 이메일' : '💬 SMS'}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 무음 시간 + 다이제스트 */}
|
||||
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr 1fr', gap: 12, marginBottom: 12 }}>
|
||||
<div>
|
||||
<label style={{ fontSize: 12, fontWeight: 600, display: 'block', marginBottom: 4 }}>무음 시작</label>
|
||||
<input type="time" value={editing.silence_start||''} onChange={e => setEditing(v => ({ ...v!, silence_start: e.target.value }))}
|
||||
style={{ width: '100%', padding: '7px 10px', border: '1px solid #e2e8f0', borderRadius: 6, fontSize: 13, boxSizing: 'border-box' }} />
|
||||
</div>
|
||||
<div>
|
||||
<label style={{ fontSize: 12, fontWeight: 600, display: 'block', marginBottom: 4 }}>무음 종료</label>
|
||||
<input type="time" value={editing.silence_end||''} onChange={e => setEditing(v => ({ ...v!, silence_end: e.target.value }))}
|
||||
style={{ width: '100%', padding: '7px 10px', border: '1px solid #e2e8f0', borderRadius: 6, fontSize: 13, boxSizing: 'border-box' }} />
|
||||
</div>
|
||||
<div>
|
||||
<label style={{ fontSize: 12, fontWeight: 600, display: 'block', marginBottom: 4 }}>우선순위 필터</label>
|
||||
<select value={editing.priority_filter||''} onChange={e => setEditing(v => ({ ...v!, priority_filter: e.target.value }))}
|
||||
style={{ width: '100%', padding: '7px 10px', border: '1px solid #e2e8f0', borderRadius: 6, fontSize: 13, boxSizing: 'border-box' }}>
|
||||
{PRIORITIES.map(p => <option key={p} value={p}>{p || '전체'}</option>)}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div style={{ marginBottom: 16 }}>
|
||||
<label style={{ display: 'flex', alignItems: 'center', gap: 8, cursor: 'pointer' }}>
|
||||
<input type="checkbox" checked={editing.digest_mode||false}
|
||||
onChange={e => setEditing(v => ({ ...v!, digest_mode: e.target.checked }))} />
|
||||
<span style={{ fontSize: 13 }}>다이제스트 모드 (묶어서 발송)</span>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div style={{ display: 'flex', gap: 8 }}>
|
||||
<button onClick={save}
|
||||
style={{ padding: '9px 20px', background: '#003366', color: '#fff', border: 'none', borderRadius: 8, fontSize: 14, fontWeight: 600, cursor: 'pointer' }}>
|
||||
저장
|
||||
</button>
|
||||
<button onClick={() => setEditing(null)}
|
||||
style={{ padding: '9px 20px', border: '1px solid #e2e8f0', borderRadius: 8, fontSize: 14, cursor: 'pointer', background: 'none' }}>
|
||||
취소
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
Loading…
Reference in New Issue
Block a user