feat: 비즈니스 지원 도구 3종 — Manager UI 완성 + 홈페이지·매뉴얼 업데이트 [auto-sync]
This commit is contained in:
parent
a4e105e813
commit
829a658048
@ -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}
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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 스타일 색상 상수 */
|
||||
|
||||
467
frontend/src/pages/BidWatcher.tsx
Normal file
467
frontend/src/pages/BidWatcher.tsx
Normal 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,
|
||||
})
|
||||
@ -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"}
|
||||
Loading…
Reference in New Issue
Block a user