diff --git a/backend/main.py b/backend/main.py index a32f18e..6f64e28 100644 --- a/backend/main.py +++ b/backend/main.py @@ -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} diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index ca8ad0a..99eb350 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -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() { } /> } /> } /> + {/* 비즈니스 지원 도구 */} + } /> + } /> + } /> } /> diff --git a/frontend/src/components/layout/Sidebar.tsx b/frontend/src/components/layout/Sidebar.tsx index d9b3264..3388116 100644 --- a/frontend/src/components/layout/Sidebar.tsx +++ b/frontend/src/components/layout/Sidebar.tsx @@ -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 스타일 색상 상수 */ diff --git a/frontend/src/pages/BidWatcher.tsx b/frontend/src/pages/BidWatcher.tsx new file mode 100644 index 0000000..6e3bc33 --- /dev/null +++ b/frontend/src/pages/BidWatcher.tsx @@ -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 = { + NEW: '#6366f1', JOIN: '#16a34a', HOLD: '#f59e0b', DELETED: '#dc2626', +} +const STATUS_LABEL: Record = { + 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 ( + {STATUS_LABEL[status]} + ) +} + +export default function BidWatcher() { + const token = localStorage.getItem('guardia_token') ?? '' + const headers = { Authorization: `Bearer ${token}` } + + const [bids, setBids] = useState([]) + const [assignees, setAssignees] = useState([]) + const [stats, setStats] = useState(null) + const [tab, setTab] = useState<'bids' | 'assignees'>('bids') + const [statusFilter, setStatusFilter] = useState('ALL') + const [q, setQ] = useState('') + const [loading, setLoading] = useState(false) + const [crawling, setCrawling] = useState(false) + const [selected, setSelected] = useState(null) + const [docs, setDocs] = useState([]) + 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 = { 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) => ( +
+
{val ?? 0}
+
{label}
+
+ ) + + return ( +
+
+

📋 나라장터 입찰 모니터

+ + 당일 SW개발용역·유지보수·시스템구축·고도화(SI/SM) 입찰정보 자동 수집 + {stats && <> · {stats.network_mode === 'open' ? '개방망 연동' : '폐쇄망 샘플 모드'}} + + + +
+ + {/* 통계 카드 */} +
+ {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')} +
+ + {/* 탭 */} +
+ {(['bids', 'assignees'] as const).map(t => ( + + ))} +
+ + {tab === 'bids' && ( + <> +
+ {['ALL', 'NEW', 'JOIN', 'HOLD', 'DELETED'].map(s => ( + + ))} + setQ(e.target.value)} + onKeyDown={e => { if (e.key === 'Enter') load() }} + placeholder="제목·발주기관 검색" style={{ ...inputStyle, maxWidth: 220, flex: 'unset' }} /> +
+ +
+ + + + {['공고번호', '제목', '발주기관', '예산', '공고일', '마감일', '상태', '조작'].map(h => ( + + ))} + + + + {bids.length === 0 && ( + + )} + {bids.map(b => ( + (e.currentTarget.style.background = '#f8fafc')} + onMouseLeave={e => (e.currentTarget.style.background = '')}> + + + + + + + + + + ))} + +
{h}
수집된 입찰 정보 없음 — 상단의 '당일 입찰 수집 실행'을 눌러보세요.
{b.bid_no} +
{b.title}
+ {b.category &&
{b.category}
} +
+
{b.institution || '—'}
+
{fmtBudget(b.budget)}{fmtDateOnly(b.announce_date)}{fmtDateOnly(b.deadline_date)} +
+ + {b.status !== 'JOIN' && } + {b.status !== 'HOLD' && } + {b.status !== 'DELETED' && } +
+
+
+ + )} + + {tab === 'assignees' && ( + <> +
+
신규 SI/SM 입찰 알림 담당자 등록
+
+ 당일 수집에서 신규 SI/SM 공고가 등록되면 활성 담당자에게 알림이 전송됩니다. +
+
+ setNewAssignee(p => ({ ...p, name: e.target.value }))} + placeholder="담당자 이름" style={{ ...inputStyle, width: 160 }} /> + setNewAssignee(p => ({ ...p, email: e.target.value }))} + placeholder="이메일" style={{ ...inputStyle, width: 220 }} /> + +
+
+ +
+ + + + {['ID', '이름', '이메일', '활성 여부', '등록일', '조작'].map(h => ( + + ))} + + + + {assignees.length === 0 && ( + + )} + {assignees.map(a => ( + + + + + + + + + ))} + +
{h}
등록된 담당자 없음
#{a.id}{a.name}{a.email} + {a.active ? '활성' : '비활성'} + {fmtDate(a.created_at)} +
+ + +
+
+
+ + )} + + {/* 상세 슬라이드 패널 */} + {selected && ( +
+
+ {selected.title} + + +
+
+ + + + + + + + {selected.source_url && ( + + {selected.source_url} + + } /> + )} + +
+
관련 문서 (RFP 등)
+ {docs.length === 0 + ?
첨부된 문서 없음
+ : ( +
+ {docs.map(d => ( +
+ 📎 {d.name} + {d.doc_type} + +
+ ))} +
+ )} +
+ +
+
운영 메모
+