diff --git a/__pycache__/server.cpython-312.pyc b/__pycache__/server.cpython-312.pyc index 5f24040..b9302e9 100644 Binary files a/__pycache__/server.cpython-312.pyc and b/__pycache__/server.cpython-312.pyc differ diff --git a/js/analysis.js b/js/analysis.js new file mode 100644 index 0000000..16940aa --- /dev/null +++ b/js/analysis.js @@ -0,0 +1,122 @@ +/** + * Project Master Analysis JS + * P-WAR (Project Performance Above Replacement) 분석 엔진 + */ + +document.addEventListener('DOMContentLoaded', () => { + console.log("Analysis engine initialized..."); + loadPWarData(); +}); + +async function loadPWarData() { + try { + const response = await fetch('/api/analysis/p-war'); + const data = await response.json(); + + if (data.error) throw new Error(data.error); + + updateSummaryMetrics(data); + renderPWarLeaderboard(data); + renderRiskSignals(data); + + // 시스템 평균 정보 표시 + if (data.length > 0 && data[0].avg_info) { + const avg = data[0].avg_info; + document.getElementById('avg-system-info').textContent = + `* 0.0 = 시스템 평균 (파일 ${avg.avg_files.toLocaleString()}개 / 방치 ${avg.avg_stagnant}일 / 리스크 ${avg.avg_risk}건)`; + } + + } catch (e) { + console.error("분석 데이터 로딩 실패:", e); + } +} + +function updateSummaryMetrics(data) { + // 1. 평균 P-WAR 산출 + const avgPWar = data.reduce((acc, cur) => acc + cur.p_war, 0) / data.length; + document.querySelector('.metric-card.sra .value').textContent = avgPWar.toFixed(2); + + // 2. 고위험 좀비 프로젝트 비율 (P-WAR < -1.0 기준) + const zombieCount = data.filter(p => p.p_war < -1.0).length; + const zombieRate = (zombieCount / data.length) * 100; + document.querySelector('.metric-card.stability .value').textContent = `${zombieRate.toFixed(1)}%`; + + // 3. 총 활성 리소스 규모 + const totalActiveFiles = data.filter(p => p.p_war > 0).reduce((acc, cur) => acc + cur.file_count, 0); + document.querySelector('.metric-card.piso .value').textContent = (totalActiveFiles / 1000).toFixed(1) + "k"; + + // 4. 방치 리스크 총합 + const totalRisks = data.reduce((acc, cur) => acc + cur.risk_count, 0); + document.querySelector('.metric-card.iwar .value').textContent = totalRisks; +} + +function renderPWarLeaderboard(data) { + const container = document.querySelector('.timeline-analysis .card-body'); + + const sortedData = [...data].sort((a, b) => b.p_war - a.p_war); + + container.innerHTML = ` +
+ + + + + + + + + + + + + ${sortedData.map(p => { + let statusBadge = ""; + if (p.is_auto_delete) { + statusBadge = '잠김예정 프로젝트'; + } else if (p.p_war > 0) { + statusBadge = '운영 중'; + } else if (p.p_war <= -0.3) { + statusBadge = '방치-삭제대상'; + } else { + statusBadge = '위험군'; + } + + return ` + + + + + + + + + `; + }).join('')} + +
프로젝트명관리 상태파일 수방치일미결리스크P-WAR (기여도)
${p.project_nm}${statusBadge}${p.file_count.toLocaleString()}개${p.days_stagnant}일${p.risk_count}건 + ${p.p_war > 0 ? '+' : ''}${p.p_war} +
+
+ `; +} + +function renderRiskSignals(data) { + const container = document.querySelector('.risk-signal-list'); + + // 1. 시스템 삭제(잠김예정) 프로젝트 우선 추출 + const autoDeleted = data.filter(p => p.is_auto_delete).slice(0, 3); + // 2. 그 외 P-WAR가 낮은 순(음수)으로 추출 + const highRiskProjects = data.filter(p => p.p_war < -1.0 && !p.is_auto_delete).slice(0, 5 - autoDeleted.length); + + const combined = [...autoDeleted, ...highRiskProjects]; + + container.innerHTML = combined.map(p => ` +
+
${p.project_nm} (${p.master})
+
+ ${p.is_auto_delete ? '[잠김예정] 활동 부재로 인한 시스템 자동 삭제 발생' : `P-WAR ${p.p_war} (대체 수준 이하 정체)`} +
+
위험
+
+ `).join(''); +} diff --git a/js/inquiries.js b/js/inquiries.js index e615c37..74197d0 100644 --- a/js/inquiries.js +++ b/js/inquiries.js @@ -4,12 +4,14 @@ */ // --- 초기화 --- +let allInquiries = []; +let currentSort = { field: 'no', direction: 'desc' }; + async function loadInquiries() { initStickyHeader(); const pmType = document.getElementById('filterPmType').value; const category = document.getElementById('filterCategory').value; - const status = document.getElementById('filterStatus').value; const keyword = document.getElementById('searchKeyword').value; const params = new URLSearchParams({ @@ -20,17 +22,87 @@ async function loadInquiries() { try { const response = await fetch(`${API.INQUIRIES}?${params}`); - const data = await response.json(); - - updateStats(data); - - const filteredData = status ? data.filter(item => item.status === status) : data; - renderInquiryList(filteredData); + allInquiries = await response.json(); + + refreshInquiryBoard(); } catch (e) { console.error("데이터 로딩 중 오류 발생:", e); } } +function refreshInquiryBoard() { + const status = document.getElementById('filterStatus').value; + + // 1. 상태 필터링 + let filteredData = status ? allInquiries.filter(item => item.status === status) : [...allInquiries]; + + // 2. 정렬 적용 + filteredData = sortData(filteredData); + + // 3. 통계 및 리스트 렌더링 + updateStats(allInquiries); + updateSortUI(); + renderInquiryList(filteredData); +} + +function handleSort(field) { + if (currentSort.field === field) { + currentSort.direction = currentSort.direction === 'asc' ? 'desc' : 'asc'; + } else { + currentSort.field = field; + currentSort.direction = 'asc'; + } + refreshInquiryBoard(); +} + +function sortData(data) { + const { field, direction } = currentSort; + const modifier = direction === 'asc' ? 1 : -1; + + return data.sort((a, b) => { + let valA = a[field]; + let valB = b[field]; + + // 숫자형 변환 시도 (No 필드 등) + if (field === 'no' || !isNaN(valA)) { + valA = Number(valA); + valB = Number(valB); + } + + // null/undefined 처리 + if (valA === null || valA === undefined) valA = ""; + if (valB === null || valB === undefined) valB = ""; + + if (valA < valB) return -1 * modifier; + if (valA > valB) return 1 * modifier; + return 0; + }); +} + +function updateSortUI() { + // 모든 헤더 클래스 및 아이콘 초기화 + document.querySelectorAll('.inquiry-table thead th.sortable').forEach(th => { + th.classList.remove('active-sort'); + const icon = th.querySelector('.sort-icon'); + if (icon) { + // 레이아웃 시프트 방지를 위해 투명한 기본 아이콘(또는 공백) 유지 + icon.textContent = "▲"; + icon.style.opacity = "0"; + } + }); + + // 현재 정렬된 헤더 강조 및 아이콘 표시 + const activeTh = document.querySelector(`.inquiry-table thead th[onclick*="'${currentSort.field}'"]`); + if (activeTh) { + activeTh.classList.add('active-sort'); + const icon = activeTh.querySelector('.sort-icon'); + if (icon) { + icon.textContent = currentSort.direction === 'asc' ? "▲" : "▼"; + icon.style.opacity = "1"; + } + } +} + function initStickyHeader() { const header = document.getElementById('stickyHeader'); const thead = document.querySelector('.inquiry-table thead'); diff --git a/server.py b/server.py index aa8244c..4b45c10 100644 --- a/server.py +++ b/server.py @@ -76,6 +76,10 @@ async def get_mail_test(request: Request): async def get_inquiries_page(request: Request): return templates.TemplateResponse("inquiries.html", {"request": request}) +@app.get("/analysis") +async def get_analysis_page(request: Request): + return templates.TemplateResponse("analysis.html", {"request": request}) + class InquiryReplyRequest(BaseModel): reply: str status: str @@ -251,6 +255,95 @@ async def stop_sync(): crawl_stop_event.set() return {"success": True} +@app.get("/api/analysis/p-war") +async def get_p_war_analysis(): + """P-WAR(Project Performance Above Replacement) 분석 API - 실제 평균 기반""" + try: + with get_db_connection() as conn: + with conn.cursor() as cursor: + cursor.execute(DashboardQueries.GET_LAST_CRAWL_DATE) + last_date = cursor.fetchone()['last_date'] + + cursor.execute(DashboardQueries.GET_PROJECT_LIST, (last_date,)) + projects = cursor.fetchall() + + cursor.execute("SELECT project_nm, COUNT(*) as cnt FROM inquiries WHERE status != '완료' GROUP BY project_nm") + inquiry_risks = {row['project_nm']: row['cnt'] for row in cursor.fetchall()} + + import math + temp_data = [] + total_files = 0 + total_stagnant = 0 + total_risk = 0 + count = len(projects) + + if count == 0: return [] + + # 1. 1차 순회: 전체 합계 계산 (평균 산출용) + for p in projects: + file_count = int(p['file_count']) if p['file_count'] else 0 + log = p['recent_log'] + + days_stagnant = 10 + if log and log != "데이터 없음": + match = re.search(r'(\d{4})\.(\d{2})\.(\d{2})', log) + if match: + log_date = datetime.strptime(match.group(0), "%Y.%m.%d").date() + days_stagnant = (last_date - log_date).days + + risk_count = inquiry_risks.get(p['project_nm'], 0) + + total_files += file_count + total_stagnant += days_stagnant + total_risk += risk_count + temp_data.append((p, file_count, days_stagnant, risk_count)) + + # 2. 시스템 실제 평균(Mean) 산출 + avg_files = total_files / count + avg_stagnant = 5 # 사용자 요청에 따라 방치 기준을 5일로 강제 고정 (엄격한 판정) + avg_risk = total_risk / count + + # 3. 평균 수준의 프로젝트 가치(V_avg) 정의 + v_rep = ( (1 / (1 + avg_stagnant)) * math.log10(avg_files + 1) ) - (avg_risk * 0.5) + + results = [] + # 4. 2차 순회: P-WAR 산출 (개별 가치 - 평균 가치) + for p, f_cnt, d_stg, r_cnt in temp_data: + name = p['short_nm'] or p['project_nm'] + log = p['recent_log'] or "" + is_auto_delete = "폴더자동삭제" in log.replace(" ", "") + + activity_factor = 1 / (1 + d_stg) + scale_factor = math.log10(f_cnt + 1) + v_project = (activity_factor * scale_factor) - (r_cnt * 0.5) + + # [추가] 폴더 자동 삭제 페널티 부여 (실질적 관리 부재) + if is_auto_delete: + v_project -= 1.5 + + p_war = v_project - v_rep + + results.append({ + "project_nm": name, + "file_count": f_cnt, + "days_stagnant": d_stg, + "risk_count": r_cnt, + "p_war": round(p_war, 3), + "is_auto_delete": is_auto_delete, + "master": p['master'], + "dept": p['department'], + "avg_info": { + "avg_files": round(avg_files, 1), + "avg_stagnant": round(avg_stagnant, 1), + "avg_risk": round(avg_risk, 2) + } + }) + + results.sort(key=lambda x: x['p_war']) + return results + except Exception as e: + return {"error": str(e)} + @app.get("/attachments") async def get_attachments(): path = "sample" diff --git a/style/analysis.css b/style/analysis.css new file mode 100644 index 0000000..6e87950 --- /dev/null +++ b/style/analysis.css @@ -0,0 +1,301 @@ +/* Analysis Page Styles */ + +.analysis-content { + padding: 24px; + max-width: 1400px; + margin: var(--topbar-h, 36px) auto 0; +} + +.analysis-header { + display: flex; + justify-content: space-between; + align-items: flex-end; + padding: 10px 0 30px 0; + margin-bottom: 10px; +} + +.ai-badge { + display: inline-block; + padding: 4px 12px; + border-radius: 20px; + background: var(--ai-color, linear-gradient(135deg, #6366f1 0%, #a855f7 100%)); + color: #fff; + font-size: 11px; + font-weight: 700; + margin-bottom: 10px; + text-transform: uppercase; + letter-spacing: 0.5px; +} + +.analysis-header h2 { font-size: 24px; font-weight: 800; color: #111; margin: 0; } +.analysis-header p { font-size: 13px; color: #666; margin-top: 6px; } + +.btn-refresh { + padding: 10px 20px; + background: #fff; + border: 1px solid #ddd; + border-radius: 8px; + font-size: 13px; + font-weight: 600; + cursor: pointer; + transition: all 0.2s; +} +.btn-refresh:hover { background: #f8f9fa; border-color: #bbb; } + +/* 1. Metrics Grid */ +.metrics-grid { + display: grid; + grid-template-columns: repeat(4, 1fr); + gap: 20px; + margin-bottom: 30px; +} + +.metric-card { + background: #fff; + padding: 24px; + border-radius: 16px; + border: 1px solid #eef0f2; + box-shadow: 0 4px 20px rgba(0,0,0,0.04); + display: flex; + flex-direction: column; + gap: 12px; +} + +.metric-card .label { + font-size: 12px; + font-weight: 600; + color: #888; + display: flex; + align-items: center; + gap: 5px; + position: relative; /* 툴팁 배치를 위해 추가 */ +} + +/* 툴팁 스타일 추가 */ +.metric-card .label:hover::after { + content: attr(data-tooltip); + position: absolute; + bottom: 100%; + left: 0; + width: 220px; + padding: 12px; + background: #1e293b; + color: #fff; + font-size: 11px; + font-weight: 400; + line-height: 1.5; + border-radius: 8px; + box-shadow: 0 10px 15px -3px rgba(0, 0, 0, 0.1); + z-index: 10; + margin-bottom: 10px; + pointer-events: none; + white-space: normal; +} + +.metric-card .label:hover::before { + content: ''; + position: absolute; + bottom: 100%; + left: 20px; + border: 6px solid transparent; + border-top-color: #1e293b; + margin-bottom: -2px; + z-index: 10; +} +.info-icon { width: 14px; height: 14px; border-radius: 50%; background: #eee; display: inline-flex; align-items: center; justify-content: center; font-size: 9px; cursor: help; font-style: normal; } + +.metric-card .value { font-size: 32px; font-weight: 800; color: #1e5149; margin: 0; } + +.trend { font-size: 11px; font-weight: 700; } +.trend.up { color: #d32f2f; } +.trend.down { color: #1976d2; } +.trend.steady { color: #666; } + +/* 2. Main Grid Layout */ +.analysis-main-grid { + display: grid; + grid-template-columns: 2fr 1fr; + gap: 24px; + margin-bottom: 24px; +} + +.analysis-card { + background: #fff; + border-radius: 16px; + border: 1px solid #eef2f6; + box-shadow: 0 4px 20px rgba(0,0,0,0.04); + overflow: hidden; +} + +.card-header { + padding: 20px 24px; + border-bottom: 1px solid #f1f5f9; + display: flex; + justify-content: space-between; + align-items: center; +} + +.card-header h4 { margin: 0; font-size: 15px; font-weight: 700; color: #334155; } + +.card-body { padding: 24px; } + +/* 테이블 스크롤 래퍼 */ +.table-scroll-wrapper { + max-height: 600px; + overflow-y: auto; + border-radius: 8px; + border: 1px solid #eef2f6; +} + +/* 스크롤바 커스텀 */ +.table-scroll-wrapper::-webkit-scrollbar { width: 6px; } +.table-scroll-wrapper::-webkit-scrollbar-track { background: #f8fafc; } +.table-scroll-wrapper::-webkit-scrollbar-thumb { background: #cbd5e1; border-radius: 3px; } +.table-scroll-wrapper::-webkit-scrollbar-thumb:hover { background: #94a3b8; } + +.chart-placeholder { + height: 300px; + background: #f8fafc; + border-radius: 12px; + display: flex; + align-items: center; + justify-content: center; + color: #94a3b8; + border: 1px dashed #e2e8f0; +} + +/* D-WAR 테이블 스타일 추가 */ +.d-war-table { width: 100%; border-radius: 12px; overflow: hidden; } +.d-war-table th { background: #f1f5f9; color: #475569; font-size: 11px; padding: 12px; } +.d-war-table td { padding: 14px 12px; border-bottom: 1px solid #f1f5f9; } +.d-war-value { font-weight: 800; color: #1e5149; text-align: center; font-size: 15px; } +.p-war-value { font-weight: 800; text-align: center; font-size: 15px; } +.text-plus { color: #1d4ed8; } +.text-minus { color: #dc2626; } + +/* 관리 상태 배지 스타일 */ +.badge-system { + display: inline-block; + padding: 4px 10px; + background: #450a0a; + color: #fecaca; + border: 1px solid #7f1d1d; + font-size: 11px; + font-weight: 800; + border-radius: 6px; + white-space: nowrap; +} + +.badge-active { + display: inline-block; + padding: 4px 10px; + background: #f0fdf4; + color: #166534; + border: 1px solid #dcfce7; + font-size: 11px; + font-weight: 700; + border-radius: 6px; + white-space: nowrap; +} + +.badge-warning { + display: inline-block; + padding: 4px 10px; + background: #fffbeb; + color: #92400e; + border: 1px solid #fef3c7; + font-size: 11px; + font-weight: 700; + border-radius: 6px; + white-space: nowrap; +} + +.badge-danger { + display: inline-block; + padding: 4px 10px; + background: #fef2f2; + color: #991b1b; + border: 1px solid #fee2e2; + font-size: 11px; + font-weight: 700; + border-radius: 6px; + white-space: nowrap; +} + +/* 행 강조 스타일 수정 */ +.row-danger { background: #fff1f2 !important; } +.row-warning { background: #fffaf0 !important; } +.row-success { background: #f0fdf4 !important; } + +.font-bold { font-weight: 700; } + +/* P-WAR 가이드 스타일 */ +.d-war-guide { + display: flex; + gap: 20px; + margin-bottom: 20px; + padding: 12px 20px; + background: #f8fafc; + border-radius: 8px; + border: 1px solid #e2e8f0; +} + +.guide-item { + font-size: 12px; + font-weight: 600; + color: #64748b; + display: flex; + align-items: center; + gap: 8px; +} + +.guide-item span { + padding: 2px 8px; + border-radius: 4px; + font-size: 11px; + color: #fff; +} + +.active-low span { background: #2563eb; } +.warning-mid span { background: #22c55e; } +.danger-high span { background: #f59e0b; } +.hazard-critical span { background: #ef4444; } + +/* 3. Risk Signal List */ +.risk-signal-list { display: flex; flex-direction: column; gap: 12px; } + +.risk-item { + padding: 16px; + border-radius: 12px; + display: grid; + grid-template-columns: 1fr 40px; + gap: 4px; + position: relative; +} + +.risk-project { font-size: 13px; font-weight: 700; color: #1e293b; } +.risk-reason { font-size: 11px; color: #64748b; margin-top: 4px; } +.risk-status { + grid-row: span 2; + display: flex; + align-items: center; + justify-content: center; + font-size: 11px; + font-weight: 800; + border-radius: 8px; +} + +.risk-item.high { background: #fff1f2; border-left: 4px solid #f43f5e; } +.risk-item.high .risk-status { color: #f43f5e; } +.risk-item.warning { background: #fffbeb; border-left: 4px solid #f59e0b; } +.risk-item.warning .risk-status { color: #f59e0b; } +.risk-item.safe { background: #f0fdf4; border-left: 4px solid #22c55e; } +.risk-item.safe .risk-status { color: #22c55e; } + +/* 4. Factor Section */ +.factor-grid { display: flex; flex-direction: column; gap: 16px; } +.factor-item { display: grid; grid-template-columns: 200px 1fr 60px; align-items: center; gap: 20px; } +.factor-name { font-size: 13px; font-weight: 600; color: #475569; } +.factor-bar-wrapper { height: 8px; background: #f1f5f9; border-radius: 4px; overflow: hidden; } +.factor-bar { height: 100%; background: var(--ai-color, #6366f1); border-radius: 4px; } +.factor-value { font-size: 12px; font-weight: 700; color: #1e5149; text-align: right; } diff --git a/style/inquiries.css b/style/inquiries.css index 88f6e9e..b6711bf 100644 --- a/style/inquiries.css +++ b/style/inquiries.css @@ -108,6 +108,42 @@ z-index: 900; } +/* 정렬 가능한 헤더 스타일 추가 */ +.inquiry-table thead th.sortable { + cursor: pointer; + user-select: none; + transition: background 0.2s; + white-space: nowrap; +} + +.inquiry-table thead th.sortable .header-content { + display: flex; + align-items: center; + gap: 4px; +} + +.sort-icon { + display: inline-flex; + flex-direction: column; + justify-content: center; + width: 12px; + height: 12px; + font-size: 8px; + color: #ccc; + line-height: 1; + margin-left: 2px; +} + +.inquiry-table thead th.active-sort { + color: #1e5149; + background: #f0f7f6; +} + +.inquiry-table thead th.active-sort .sort-icon { + color: #1e5149; + font-size: 10px; +} + .inquiry-table td { padding: 14px 16px; font-size: 13px; diff --git a/templates/analysis.html b/templates/analysis.html new file mode 100644 index 0000000..37c963d --- /dev/null +++ b/templates/analysis.html @@ -0,0 +1,136 @@ + + + + + + + 데이터 분석 - Project Master Sabermetrics + + + + + + + + +
+
+
+
AI Sabermetrics
+

시스템 운영 빅데이터 분석

+

수집된 활동 로그 및 문의사항 데이터를 기반으로 한 통계적 성능 지표 (Beta)

+
+
+ +
+
+ + +
+
+
+ 평균 P-WAR (기여도) ? +

0.00

+ 대체 수준(0.0) 대비 +
+
+
+
+
+ 미결 리스크 총합 ? +

0

+ 실시간 집계 +
+
+
+
+
+ 활성 자원 규모 ? +

0

+ 시스템 기여 자원 +
+
+
+
+
+ 좀비 프로젝트 비율 ? +

0%

+ 집중 관리 대상 +
+
+
+
+ + +
+ +
+
+
+

Project Performance Above Replacement (P-WAR Ranking)

+

대체 수준(Replacement Level) 프로젝트 대비 기여도를 측정합니다.

+
+
+ * 0.0 = 시스템 평균 계산 중... +
+
+
+ +
+
양수(+) 운영 중
+
음수(-) 위험군
+
-0.3 이하 방치-삭제대상
+
시스템삭제 잠김예정 프로젝트
+
+
+

R-Engine 시각화 대기 중...

+
+
+
+ + +
+
+

Deep Learning 기반 장애 예보 (Risk Signal)

+
+
+
+
+
프로젝트 A (해외/중동)
+
파일 급증 대비 활동 정체 (P-ISO 급락)
+
위험
+
+
+
프로젝트 B (기술개발)
+
특정 환경(IE/Edge) 문의 집중 발생
+
주의
+
+
+
프로젝트 C (국내/장헌)
+
로그 활동성 및 해결률 안정적 유지
+
안전
+
+
+
+
+
+
+ + + + + + \ No newline at end of file diff --git a/templates/dashboard.html b/templates/dashboard.html index 34300be..5eb189b 100644 --- a/templates/dashboard.html +++ b/templates/dashboard.html @@ -22,10 +22,8 @@ diff --git a/templates/inquiries.html b/templates/inquiries.html index 13b6b4c..3b39ca1 100644 --- a/templates/inquiries.html +++ b/templates/inquiries.html @@ -22,10 +22,8 @@ @@ -136,17 +134,33 @@ - + - - - - + + + + - - + + - + diff --git a/templates/mailTest.html b/templates/mailTest.html index a71474d..4f98cdd 100644 --- a/templates/mailTest.html +++ b/templates/mailTest.html @@ -22,9 +22,9 @@
No +
No
+
이미지PM 종류환경구분프로젝트 +
PM 종류
+
+
환경
+
+
구분
+
+
프로젝트
+
문의내용작성자날짜 +
작성자
+
+
날짜
+
답변내용상태 +
상태
+