sync: update from workspace (latest ITSM/CICD/DR changes)

This commit is contained in:
DESKTOP-TKLFCPR\ython 2026-06-02 19:50:02 +09:00
parent 1a89a432c7
commit dc0bead983
4 changed files with 469 additions and 1 deletions

View File

@ -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>

View File

@ -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 스타일 색상 상수 */

View 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>
)
}

View 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>
)
}