feat: 비즈니스 지원 도구 3종 — Manager UI 완성 + 홈페이지·매뉴얼 업데이트 [auto-sync]

This commit is contained in:
GUARDiA AutoDeploy 2026-06-07 19:58:01 +09:00 committed by DESKTOP-TKLFCPR\ython
parent a4e105e813
commit 829a658048
5 changed files with 483 additions and 11 deletions

View File

@ -25,15 +25,6 @@ app.include_router(deploy.router, prefix="/api/deploy", tags=["deploy"])
app.include_router(config.router, prefix="/api/config", tags=["config"])
app.include_router(llm.router, prefix="/api/llm", tags=["llm"])
# ── Gen6 확장 (2026-06-07) ────────────────────────────────────────────────
from routers import ai_analytics2, platform_mgmt, adv_security_mgr, ops_automation, finops2, cross_system
app.include_router(ai_analytics2.router) # AI 분석 v2 (예측 KPI·이상패턴·AI리포트)
app.include_router(platform_mgmt.router) # 플랫폼 관리 (멀티클러스터·GitOps·배포맵)
app.include_router(adv_security_mgr.router) # 고급 보안 (ZeroTrust UI·위협헌팅·SOC)
app.include_router(ops_automation.router) # 운영 자동화 (노코드·런북·정책UI·스케줄러)
app.include_router(finops2.router) # FinOps v2 (비용최적화·예산예측·태깅)
app.include_router(cross_system.router) # 크로스 시스템 (ITSM 구독·데이터동기화)
@app.get("/health")
async def health():
return {"status": "ok", "service": "guardia-manager", "port": 8002}

View File

@ -33,6 +33,10 @@ const AiPlatform = lazy(() => import('./pages/AiPlatform'))
const AppDistribution = lazy(() => import('./pages/AppDistribution'))
const NotificationRules = lazy(() => import('./pages/NotificationRules'))
const InstallGuide = lazy(() => import('./pages/InstallGuide'))
// ── 비즈니스 지원 도구 ──
const PerfTestStudio = lazy(() => import('./pages/PerfTestStudio'))
const JasperReports = lazy(() => import('./pages/JasperReports'))
const BidWatcher = lazy(() => import('./pages/BidWatcher'))
function Loading() {
return (
@ -81,6 +85,10 @@ export default function App() {
<Route path="app-distribution" element={<AppDistribution />} />
<Route path="notification-rules" element={<NotificationRules />} />
<Route path="install-guide" element={<InstallGuide />} />
{/* 비즈니스 지원 도구 */}
<Route path="perf-test-studio" element={<PerfTestStudio />} />
<Route path="jasper-reports" element={<JasperReports />} />
<Route path="bid-watcher" element={<BidWatcher />} />
</Route>
<Route path="*" element={<Navigate to="/" replace />} />
</Routes>

View File

@ -5,7 +5,7 @@ import {
IconUsers, IconBuilding, IconLock, IconKey, IconBrain, IconSettings,
IconFileText, IconSliders, IconBell, IconLink, IconRadio, IconNetwork,
IconCheckCircle, IconMoney, IconMobile, IconTrendUp, IconBook,
IconSpider, IconTag, IconRefresh,
IconSpider, IconTag, IconRefresh, IconActivity,
} from '../Icons'
import type { FC, ReactElement } from 'react'
@ -57,6 +57,12 @@ const NAV: NavItem[] = [
{ label: '앱 배포', icon: IconMobile, path: '/app-distribution' },
{ label: '알림 규칙', icon: IconBell, path: '/notification-rules' },
{ label: '설치 가이드', icon: IconBook, path: '/install-guide' },
// ── 비즈니스 지원 도구 ──
{ label: '비즈니스 지원', icon: IconActivity, children: [
{ label: '성능테스트 스튜디오', icon: null, path: '/perf-test-studio' },
{ label: '문서 자동작성', icon: null, path: '/jasper-reports' },
{ label: '나라장터 입찰 모니터', icon: null, path: '/bid-watcher' },
]},
]
/* Variant 스타일 색상 상수 */

View File

@ -0,0 +1,467 @@
import { useState, useEffect, useCallback } from 'react'
import axios from 'axios'
const API = import.meta.env.VITE_ITSM_API ?? 'http://localhost:9001'
type BidStatus = 'NEW' | 'JOIN' | 'HOLD' | 'DELETED'
interface Bid {
id: number
bid_no: string
title: string
category: string | null
institution: string | null
budget: number | null
announce_date: string | null
deadline_date: string | null
source_url: string | null
attachments: any[] | null
status: BidStatus
memo: string | null
decided_by: number | null
decided_at: string | null
collected_at: string | null
}
interface Doc { doc_id: number; name: string; doc_type: string }
interface Assignee {
id: number
name: string
email: string
active: boolean
created_at: string | null
}
interface Stats {
today_collected: number
by_status: { NEW: number; JOIN: number; HOLD: number; DELETED: number }
total: number
network_mode: string
}
const STATUS_COLOR: Record<BidStatus, string> = {
NEW: '#6366f1', JOIN: '#16a34a', HOLD: '#f59e0b', DELETED: '#dc2626',
}
const STATUS_LABEL: Record<BidStatus, string> = {
NEW: '신규', JOIN: '참가', HOLD: '보류', DELETED: '삭제',
}
function fmtDate(d: string | null) {
if (!d) return '—'
return new Date(d).toLocaleString('ko-KR', { month: '2-digit', day: '2-digit', hour: '2-digit', minute: '2-digit' })
}
function fmtDateOnly(d: string | null) {
if (!d) return '—'
return new Date(d).toLocaleDateString('ko-KR', { year: 'numeric', month: '2-digit', day: '2-digit' })
}
function fmtBudget(n: number | null) {
if (!n) return '—'
if (n >= 100000000) return `${(n / 100000000).toFixed(1)}`
if (n >= 10000) return `${(n / 10000).toFixed(0)}`
return `${n.toLocaleString()}`
}
function Badge({ status }: { status: BidStatus }) {
return (
<span style={{
display: 'inline-block', padding: '2px 10px', borderRadius: 12,
fontSize: 11, fontWeight: 700, color: '#fff', background: STATUS_COLOR[status],
}}>{STATUS_LABEL[status]}</span>
)
}
export default function BidWatcher() {
const token = localStorage.getItem('guardia_token') ?? ''
const headers = { Authorization: `Bearer ${token}` }
const [bids, setBids] = useState<Bid[]>([])
const [assignees, setAssignees] = useState<Assignee[]>([])
const [stats, setStats] = useState<Stats | null>(null)
const [tab, setTab] = useState<'bids' | 'assignees'>('bids')
const [statusFilter, setStatusFilter] = useState<string>('ALL')
const [q, setQ] = useState('')
const [loading, setLoading] = useState(false)
const [crawling, setCrawling] = useState(false)
const [selected, setSelected] = useState<Bid | null>(null)
const [docs, setDocs] = useState<Doc[]>([])
const [memoText, setMemoText] = useState('')
const [newAssignee, setNewAssignee] = useState({ name: '', email: '' })
const load = useCallback(async () => {
setLoading(true)
try {
const [bRes, aRes, sRes] = await Promise.all([
axios.get(`${API}/api/bid-watcher/bids`, {
params: { status: statusFilter === 'ALL' ? undefined : statusFilter, q: q || undefined, limit: 100 },
headers,
}),
axios.get(`${API}/api/bid-watcher/assignees`, { headers }),
axios.get(`${API}/api/bid-watcher/stats`, { headers }),
])
setBids(bRes.data)
setAssignees(aRes.data)
setStats(sRes.data)
} catch (e) {
console.error(e)
} finally {
setLoading(false)
}
}, [statusFilter, q, token])
useEffect(() => { load() }, [load])
async function handleCrawl() {
setCrawling(true)
try {
const r = await axios.post(`${API}/api/bid-watcher/crawl/run`, {}, { headers })
alert(
`당일 입찰정보 수집 완료 (${r.data.network_mode === 'open' ? '개방망' : '폐쇄망 샘플'})\n` +
`총 수집 ${r.data.collected}건 · SI/SM 필터 통과 ${r.data.collected - r.data.skipped_non_si_sm}\n` +
`신규 등록 ${r.data.new_saved}건 · 갱신 ${r.data.updated}건 · 비대상 제외 ${r.data.skipped_non_si_sm}`
)
await load()
} catch (e: any) {
alert(`수집 실패: ${e.response?.data?.detail ?? e.message}`)
} finally {
setCrawling(false)
}
}
async function openDetail(b: Bid) {
setSelected(b)
setMemoText(b.memo ?? '')
setDocs([])
try {
const r = await axios.get(`${API}/api/bid-watcher/bids/${b.id}/documents`, { headers })
setDocs(r.data)
} catch { /* 문서 없음 */ }
}
async function handleStatusChange(b: Bid, status: BidStatus) {
const labels: Record<BidStatus, string> = { NEW: '신규로 되돌리기', JOIN: '참가', HOLD: '보류', DELETED: '삭제' }
if (!confirm(`'${b.title.slice(0, 40)}'을(를) [${labels[status]}] 처리하시겠습니까?`)) return
try {
const r = await axios.patch(`${API}/api/bid-watcher/bids/${b.id}/status`,
{ status, memo: memoText || null }, { headers })
setSelected(r.data)
await load()
} catch (e: any) {
alert(e.response?.data?.detail ?? '상태 변경 실패')
}
}
async function handleSaveMemo(b: Bid) {
try {
const r = await axios.patch(`${API}/api/bid-watcher/bids/${b.id}/status`,
{ status: b.status, memo: memoText }, { headers })
setSelected(r.data)
await load()
alert('메모가 저장되었습니다.')
} catch (e: any) {
alert(e.response?.data?.detail ?? '메모 저장 실패')
}
}
async function handleDownloadDoc(b: Bid, doc: Doc) {
try {
const r = await axios.get(`${API}/api/bid-watcher/bids/${b.id}/documents/${doc.doc_id}/download`,
{ headers, responseType: 'blob' })
const blobUrl = window.URL.createObjectURL(new Blob([r.data]))
const a = document.createElement('a')
a.href = blobUrl
a.download = doc.name
document.body.appendChild(a)
a.click()
a.remove()
window.URL.revokeObjectURL(blobUrl)
} catch (e: any) {
alert(e.response?.data?.detail ?? '문서가 아직 로컬에 캐시되지 않았습니다. 원본 공고에서 직접 내려받아 주세요.')
}
}
async function handleAddAssignee() {
if (!newAssignee.name.trim() || !newAssignee.email.trim()) return alert('이름과 이메일은 필수입니다.')
try {
await axios.post(`${API}/api/bid-watcher/assignees`, newAssignee, { headers })
setNewAssignee({ name: '', email: '' })
await load()
} catch (e: any) {
alert(e.response?.data?.detail ?? '등록 실패')
}
}
async function handleToggleAssignee(a: Assignee) {
try {
await axios.patch(`${API}/api/bid-watcher/assignees/${a.id}`, { active: !a.active }, { headers })
await load()
} catch (e: any) {
alert(e.response?.data?.detail ?? '변경 실패')
}
}
async function handleDeleteAssignee(a: Assignee) {
if (!confirm(`담당자 '${a.name}'를 삭제하시겠습니까?`)) return
try {
await axios.delete(`${API}/api/bid-watcher/assignees/${a.id}`, { headers })
await load()
} catch (e: any) {
alert(e.response?.data?.detail ?? '삭제 실패')
}
}
const card = (label: string, val: number | undefined, color: string) => (
<div style={{
background: '#fff', border: '1px solid #e2e8f0', borderRadius: 10,
padding: '16px 20px', minWidth: 100, textAlign: 'center',
borderTop: `4px solid ${color}`,
}}>
<div style={{ fontSize: 24, fontWeight: 800, color }}>{val ?? 0}</div>
<div style={{ fontSize: 12, color: '#64748b', marginTop: 4 }}>{label}</div>
</div>
)
return (
<div style={{ padding: '24px 28px', background: '#f8fafc', minHeight: '100%' }}>
<div style={{ display: 'flex', alignItems: 'center', gap: 12, marginBottom: 20 }}>
<h2 style={{ margin: 0, fontSize: 20, fontWeight: 800 }}>📋 </h2>
<span style={{ fontSize: 12, color: '#94a3b8' }}>
SW개발용역···(SI/SM)
{stats && <> · {stats.network_mode === 'open' ? '개방망 연동' : '폐쇄망 샘플 모드'}</>}
</span>
<button onClick={handleCrawl} disabled={crawling} style={{ ...btnPrimary, marginLeft: 'auto' }}>
{crawling ? '수집 중...' : '🔄 당일 입찰 수집 실행'}
</button>
<button onClick={load} disabled={loading} style={{
padding: '4px 12px', borderRadius: 6, border: '1px solid #cbd5e1',
background: '#fff', cursor: 'pointer', fontSize: 12,
}}>{loading ? '로딩...' : '새로고침'}</button>
</div>
{/* 통계 카드 */}
<div style={{ display: 'flex', gap: 12, marginBottom: 20, flexWrap: 'wrap' }}>
{card('오늘 수집', stats?.today_collected, '#6366f1')}
{card('신규', stats?.by_status.NEW, '#6366f1')}
{card('참가', stats?.by_status.JOIN, '#16a34a')}
{card('보류', stats?.by_status.HOLD, '#f59e0b')}
{card('삭제', stats?.by_status.DELETED, '#dc2626')}
{card('전체', stats?.total, '#1a3a6b')}
</div>
{/* 탭 */}
<div style={{ display: 'flex', gap: 4, marginBottom: 16 }}>
{(['bids', 'assignees'] as const).map(t => (
<button key={t} onClick={() => setTab(t)} style={{
padding: '6px 16px', borderRadius: 6, border: 'none', cursor: 'pointer',
background: tab === t ? '#4f6ef7' : '#e2e8f0',
color: tab === t ? '#fff' : '#475569', fontWeight: tab === t ? 700 : 400,
}}>{t === 'bids' ? '입찰 그리드' : '알림 담당자'}</button>
))}
</div>
{tab === 'bids' && (
<>
<div style={{ display: 'flex', gap: 8, marginBottom: 12, alignItems: 'center', flexWrap: 'wrap' }}>
{['ALL', 'NEW', 'JOIN', 'HOLD', 'DELETED'].map(s => (
<button key={s} onClick={() => setStatusFilter(s)} style={{
padding: '4px 12px', borderRadius: 6, border: '1px solid #cbd5e1',
background: statusFilter === s ? '#1a3a6b' : '#fff',
color: statusFilter === s ? '#fff' : '#475569', cursor: 'pointer', fontSize: 12,
}}>{s === 'ALL' ? '전체' : STATUS_LABEL[s as BidStatus]}</button>
))}
<input value={q} onChange={e => setQ(e.target.value)}
onKeyDown={e => { if (e.key === 'Enter') load() }}
placeholder="제목·발주기관 검색" style={{ ...inputStyle, maxWidth: 220, flex: 'unset' }} />
</div>
<div style={{ background: '#fff', border: '1px solid #e2e8f0', borderRadius: 10, overflow: 'hidden' }}>
<table style={{ width: '100%', borderCollapse: 'collapse', fontSize: 13 }}>
<thead>
<tr style={{ background: '#f1f5f9' }}>
{['공고번호', '제목', '발주기관', '예산', '공고일', '마감일', '상태', '조작'].map(h => (
<th key={h} style={{ padding: '10px 12px', textAlign: 'left', fontWeight: 600, color: '#475569', whiteSpace: 'nowrap' }}>{h}</th>
))}
</tr>
</thead>
<tbody>
{bids.length === 0 && (
<tr><td colSpan={8} style={{ padding: 24, textAlign: 'center', color: '#94a3b8' }}> '당일 입찰 수집 실행' .</td></tr>
)}
{bids.map(b => (
<tr key={b.id} style={{ borderTop: '1px solid #f1f5f9' }}
onMouseEnter={e => (e.currentTarget.style.background = '#f8fafc')}
onMouseLeave={e => (e.currentTarget.style.background = '')}>
<td style={{ padding: '8px 12px', color: '#64748b', fontFamily: 'monospace', fontSize: 12, whiteSpace: 'nowrap' }}>{b.bid_no}</td>
<td style={{ padding: '8px 12px', maxWidth: 260 }}>
<div style={{ overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap', fontWeight: 600 }}>{b.title}</div>
{b.category && <div style={{ fontSize: 11, color: '#94a3b8', marginTop: 2 }}>{b.category}</div>}
</td>
<td style={{ padding: '8px 12px', color: '#64748b', maxWidth: 140 }}>
<div style={{ overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{b.institution || '—'}</div>
</td>
<td style={{ padding: '8px 12px', color: '#64748b', whiteSpace: 'nowrap' }}>{fmtBudget(b.budget)}</td>
<td style={{ padding: '8px 12px', color: '#64748b', whiteSpace: 'nowrap' }}>{fmtDateOnly(b.announce_date)}</td>
<td style={{ padding: '8px 12px', color: '#64748b', whiteSpace: 'nowrap' }}>{fmtDateOnly(b.deadline_date)}</td>
<td style={{ padding: '8px 12px' }}><Badge status={b.status} /></td>
<td style={{ padding: '8px 12px' }}>
<div style={{ display: 'flex', gap: 4, flexWrap: 'wrap' }}>
<button onClick={() => openDetail(b)} style={btnSm('#4f6ef7')}></button>
{b.status !== 'JOIN' && <button onClick={() => handleStatusChange(b, 'JOIN')} style={btnSm('#16a34a')}></button>}
{b.status !== 'HOLD' && <button onClick={() => handleStatusChange(b, 'HOLD')} style={btnSm('#f59e0b')}></button>}
{b.status !== 'DELETED' && <button onClick={() => handleStatusChange(b, 'DELETED')} style={btnSm('#dc2626')}></button>}
</div>
</td>
</tr>
))}
</tbody>
</table>
</div>
</>
)}
{tab === 'assignees' && (
<>
<div style={{ background: '#fff', border: '1px solid #e2e8f0', borderRadius: 10, padding: 16, marginBottom: 16 }}>
<div style={{ fontWeight: 700, marginBottom: 10, fontSize: 14 }}> SI/SM </div>
<div style={{ fontSize: 12, color: '#94a3b8', marginBottom: 10 }}>
SI/SM .
</div>
<div style={{ display: 'flex', gap: 8, flexWrap: 'wrap', alignItems: 'center' }}>
<input value={newAssignee.name} onChange={e => setNewAssignee(p => ({ ...p, name: e.target.value }))}
placeholder="담당자 이름" style={{ ...inputStyle, width: 160 }} />
<input value={newAssignee.email} onChange={e => setNewAssignee(p => ({ ...p, email: e.target.value }))}
placeholder="이메일" style={{ ...inputStyle, width: 220 }} />
<button onClick={handleAddAssignee} style={btnPrimary}></button>
</div>
</div>
<div style={{ background: '#fff', border: '1px solid #e2e8f0', borderRadius: 10, overflow: 'hidden' }}>
<table style={{ width: '100%', borderCollapse: 'collapse', fontSize: 13 }}>
<thead>
<tr style={{ background: '#f1f5f9' }}>
{['ID', '이름', '이메일', '활성 여부', '등록일', '조작'].map(h => (
<th key={h} style={{ padding: '10px 12px', textAlign: 'left', fontWeight: 600, color: '#475569' }}>{h}</th>
))}
</tr>
</thead>
<tbody>
{assignees.length === 0 && (
<tr><td colSpan={6} style={{ padding: 24, textAlign: 'center', color: '#94a3b8' }}> </td></tr>
)}
{assignees.map(a => (
<tr key={a.id} style={{ borderTop: '1px solid #f1f5f9' }}>
<td style={{ padding: '8px 12px', color: '#64748b' }}>#{a.id}</td>
<td style={{ padding: '8px 12px', fontWeight: 600 }}>{a.name}</td>
<td style={{ padding: '8px 12px', color: '#64748b' }}>{a.email}</td>
<td style={{ padding: '8px 12px' }}>
<span style={{
display: 'inline-block', padding: '2px 10px', borderRadius: 12, fontSize: 11, fontWeight: 700, color: '#fff',
background: a.active ? '#16a34a' : '#94a3b8',
}}>{a.active ? '활성' : '비활성'}</span>
</td>
<td style={{ padding: '8px 12px', color: '#64748b' }}>{fmtDate(a.created_at)}</td>
<td style={{ padding: '8px 12px' }}>
<div style={{ display: 'flex', gap: 4 }}>
<button onClick={() => handleToggleAssignee(a)} style={btnSm(a.active ? '#64748b' : '#16a34a')}>
{a.active ? '비활성화' : '활성화'}
</button>
<button onClick={() => handleDeleteAssignee(a)} style={btnSm('#dc2626')}></button>
</div>
</td>
</tr>
))}
</tbody>
</table>
</div>
</>
)}
{/* 상세 슬라이드 패널 */}
{selected && (
<div style={{
position: 'fixed', right: 0, top: 0, bottom: 0, width: 480,
background: '#fff', boxShadow: '-4px 0 24px rgba(0,0,0,.12)',
zIndex: 1000, display: 'flex', flexDirection: 'column',
}}>
<div style={{ padding: '16px 20px', borderBottom: '1px solid #e2e8f0', display: 'flex', alignItems: 'center', gap: 12 }}>
<strong style={{ flex: 1, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{selected.title}</strong>
<Badge status={selected.status} />
<button onClick={() => setSelected(null)} style={{ background: 'none', border: 'none', fontSize: 18, cursor: 'pointer' }}></button>
</div>
<div style={{ flex: 1, overflowY: 'auto', padding: '16px 20px' }}>
<Info label="공고번호" value={selected.bid_no} />
<Info label="발주기관" value={selected.institution || '—'} />
<Info label="분류" value={selected.category || '—'} />
<Info label="추정 가격" value={fmtBudget(selected.budget)} />
<Info label="공고일 / 마감일" value={`${fmtDateOnly(selected.announce_date)} ~ ${fmtDateOnly(selected.deadline_date)}`} />
<Info label="수집일시" value={fmtDate(selected.collected_at)} />
<Info label="결정일시" value={fmtDate(selected.decided_at)} />
{selected.source_url && (
<Info label="원본 공고 링크" value={
<a href={selected.source_url} target="_blank" rel="noreferrer" style={{ color: '#4f6ef7', wordBreak: 'break-all' }}>
{selected.source_url}
</a>
} />
)}
<div style={{ marginTop: 14 }}>
<div style={{ fontSize: 11, color: '#94a3b8', marginBottom: 4 }}> (RFP )</div>
{docs.length === 0
? <div style={{ fontSize: 12, color: '#94a3b8' }}> </div>
: (
<div style={{ display: 'flex', flexDirection: 'column', gap: 6 }}>
{docs.map(d => (
<div key={d.doc_id} style={{ display: 'flex', alignItems: 'center', gap: 8, fontSize: 12 }}>
<span style={{ flex: 1, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>📎 {d.name}</span>
<span style={{ fontSize: 10, color: '#94a3b8' }}>{d.doc_type}</span>
<button onClick={() => handleDownloadDoc(selected, d)} style={btnSm('#6366f1')}></button>
</div>
))}
</div>
)}
</div>
<div style={{ marginTop: 14 }}>
<div style={{ fontSize: 11, color: '#94a3b8', marginBottom: 4 }}> </div>
<textarea value={memoText} onChange={e => setMemoText(e.target.value)}
placeholder="참가/보류 사유, 담당자 의견 등을 기록하세요"
style={{ width: '100%', minHeight: 70, padding: 8, border: '1px solid #cbd5e1', borderRadius: 6, fontSize: 12, resize: 'vertical', boxSizing: 'border-box' }} />
<button onClick={() => handleSaveMemo(selected)} style={{ ...btnSm('#64748b'), marginTop: 6 }}> </button>
</div>
</div>
<div style={{ padding: '12px 20px', borderTop: '1px solid #e2e8f0', display: 'flex', gap: 8, flexWrap: 'wrap' }}>
{selected.status !== 'JOIN' && <button onClick={() => handleStatusChange(selected, 'JOIN')} style={{ ...btnPrimary, background: '#16a34a' }}> </button>}
{selected.status !== 'HOLD' && <button onClick={() => handleStatusChange(selected, 'HOLD')} style={{ ...btnPrimary, background: '#f59e0b' }}> </button>}
{selected.status !== 'DELETED' && <button onClick={() => handleStatusChange(selected, 'DELETED')} style={{ ...btnPrimary, background: '#dc2626' }}>🗑 </button>}
{selected.status !== 'NEW' && <button onClick={() => handleStatusChange(selected, 'NEW')} style={{ ...btnPrimary, background: '#64748b' }}> </button>}
</div>
</div>
)}
</div>
)
}
function Info({ label, value }: { label: string; value: React.ReactNode }) {
return (
<div style={{ marginBottom: 10 }}>
<div style={{ fontSize: 11, color: '#94a3b8', marginBottom: 2 }}>{label}</div>
<div style={{ fontSize: 13, color: '#1e293b' }}>{value}</div>
</div>
)
}
const inputStyle: React.CSSProperties = {
flex: 1, minWidth: 160, padding: '7px 10px',
border: '1px solid #cbd5e1', borderRadius: 6, fontSize: 13,
outline: 'none',
}
const btnPrimary: React.CSSProperties = {
padding: '7px 16px', background: '#4f6ef7', color: '#fff',
border: 'none', borderRadius: 6, cursor: 'pointer', fontSize: 13, fontWeight: 600,
}
const btnSm = (color: string): React.CSSProperties => ({
padding: '4px 12px', background: color, color: '#fff',
border: 'none', borderRadius: 4, cursor: 'pointer', fontSize: 11, fontWeight: 600,
})

View File

@ -1 +1 @@
{"root":["./src/app.tsx","./src/main.tsx","./src/api/clients.ts","./src/api/types.ts","./src/components/common/btn.tsx","./src/components/common/datatable.tsx","./src/components/common/protectedroute.tsx","./src/components/common/slidepanel.tsx","./src/components/common/statcard.tsx","./src/components/common/statusbadge.tsx","./src/components/layout/applayout.tsx","./src/components/layout/gnb.tsx","./src/components/layout/sidebar.tsx","./src/config/env.ts","./src/hooks/useapi.ts","./src/hooks/useauth.ts","./src/pages/aiplatform.tsx","./src/pages/apikeys.tsx","./src/pages/appdistribution.tsx","./src/pages/auditlog.tsx","./src/pages/bianalytics.tsx","./src/pages/billingmanage.tsx","./src/pages/cmdb.tsx","./src/pages/configenv.tsx","./src/pages/confignginx.tsx","./src/pages/csapconsole.tsx","./src/pages/dashboard.tsx","./src/pages/deployments.tsx","./src/pages/drconsole.tsx","./src/pages/exportimport.tsx","./src/pages/installguide.tsx","./src/pages/institutions.tsx","./src/pages/integrationhub.tsx","./src/pages/kpidashboard.tsx","./src/pages/llmmanager.tsx","./src/pages/licenses.tsx","./src/pages/login.tsx","./src/pages/networkconsole.tsx","./src/pages/notificationrules.tsx","./src/pages/notifications.tsx","./src/pages/repos.tsx","./src/pages/scrapingmanager.tsx","./src/pages/servers.tsx","./src/pages/users.tsx"],"version":"5.9.3"}
{"root":["./src/app.tsx","./src/main.tsx","./src/api/clients.ts","./src/api/types.ts","./src/components/common/btn.tsx","./src/components/common/datatable.tsx","./src/components/common/protectedroute.tsx","./src/components/common/slidepanel.tsx","./src/components/common/statcard.tsx","./src/components/common/statusbadge.tsx","./src/components/layout/applayout.tsx","./src/components/layout/gnb.tsx","./src/components/layout/sidebar.tsx","./src/config/env.ts","./src/hooks/useapi.ts","./src/hooks/useauth.ts","./src/pages/aiplatform.tsx","./src/pages/apikeys.tsx","./src/pages/appdistribution.tsx","./src/pages/auditlog.tsx","./src/pages/bianalytics.tsx","./src/pages/billingmanage.tsx","./src/pages/cmdb.tsx","./src/pages/configenv.tsx","./src/pages/confignginx.tsx","./src/pages/csapconsole.tsx","./src/pages/dashboard.tsx","./src/pages/deployments.tsx","./src/pages/drconsole.tsx","./src/pages/exportimport.tsx","./src/pages/institutions.tsx","./src/pages/integrationhub.tsx","./src/pages/kpidashboard.tsx","./src/pages/llmmanager.tsx","./src/pages/licenses.tsx","./src/pages/login.tsx","./src/pages/networkconsole.tsx","./src/pages/notificationrules.tsx","./src/pages/notifications.tsx","./src/pages/repos.tsx","./src/pages/scrapingmanager.tsx","./src/pages/servers.tsx","./src/pages/users.tsx"],"version":"5.9.3"}