feat: implement P-WAR system analysis and inquiries sorting functionality

This commit is contained in:
2026-03-19 17:59:50 +09:00
parent faa7c8e221
commit 0953f49db1
10 changed files with 797 additions and 25 deletions

Binary file not shown.

122
js/analysis.js Normal file
View File

@@ -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 = `
<div class="table-scroll-wrapper">
<table class="data-table p-war-table">
<thead>
<tr>
<th style="position: sticky; top: 0; z-index: 10; width: 250px;">프로젝트명</th>
<th style="position: sticky; top: 0; z-index: 10; width: 140px;">관리 상태</th>
<th style="position: sticky; top: 0; z-index: 10;">파일 수</th>
<th style="position: sticky; top: 0; z-index: 10;">방치일</th>
<th style="position: sticky; top: 0; z-index: 10;">미결리스크</th>
<th style="position: sticky; top: 0; z-index: 10;">P-WAR (기여도)</th>
</tr>
</thead>
<tbody>
${sortedData.map(p => {
let statusBadge = "";
if (p.is_auto_delete) {
statusBadge = '<span class="badge-system">잠김예정 프로젝트</span>';
} else if (p.p_war > 0) {
statusBadge = '<span class="badge-active">운영 중</span>';
} else if (p.p_war <= -0.3) {
statusBadge = '<span class="badge-danger">방치-삭제대상</span>';
} else {
statusBadge = '<span class="badge-warning">위험군</span>';
}
return `
<tr class="${p.is_auto_delete || p.p_war <= -0.3 ? 'row-danger' : p.p_war < 0 ? 'row-warning' : ''}">
<td class="font-bold">${p.project_nm}</td>
<td>${statusBadge}</td>
<td>${p.file_count.toLocaleString()}</td>
<td>${p.days_stagnant}</td>
<td>${p.risk_count}</td>
<td class="p-war-value ${p.p_war >= 0 ? 'text-plus' : 'text-minus'}">
${p.p_war > 0 ? '+' : ''}${p.p_war}
</td>
</tr>
`;
}).join('')}
</tbody>
</table>
</div>
`;
}
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 => `
<div class="risk-item high">
<div class="risk-project">${p.project_nm} (${p.master})</div>
<div class="risk-reason">
${p.is_auto_delete ? '[잠김예정] 활동 부재로 인한 시스템 자동 삭제 발생' : `P-WAR ${p.p_war} (대체 수준 이하 정체)`}
</div>
<div class="risk-status">위험</div>
</div>
`).join('');
}

View File

@@ -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');

View File

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

301
style/analysis.css Normal file
View File

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

View File

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

136
templates/analysis.html Normal file
View File

@@ -0,0 +1,136 @@
<!DOCTYPE html>
<html lang="ko">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>데이터 분석 - Project Master Sabermetrics</title>
<link rel="stylesheet" as="style" crossorigin
href="https://cdn.jsdelivr.net/gh/orioncactus/pretendard@v1.3.9/dist/web/static/pretendard.min.css" />
<link rel="stylesheet" href="style/common.css">
<link rel="stylesheet" href="style/analysis.css">
</head>
<body>
<nav class="topbar">
<div class="topbar-header">
<a href="/">
<h2>Project Master Test</h2>
</a>
</div>
<ul class="nav-list">
<li class="nav-item" onclick="location.href='/dashboard'">대시보드</li>
<li class="nav-item" onclick="location.href='/inquiries'">문의사항</li>
<li class="nav-item" onclick="location.href='/mailTest'">메일관리</li>
<li class="nav-item active" onclick="location.href='/analysis'">분석</li>
</ul>
</nav>
<main class="analysis-content">
<header class="analysis-header">
<div class="title-group">
<div class="ai-badge">AI Sabermetrics</div>
<h2>시스템 운영 빅데이터 분석</h2>
<p>수집된 활동 로그 및 문의사항 데이터를 기반으로 한 통계적 성능 지표 (Beta)</p>
</div>
<div class="analysis-actions">
<button class="btn-refresh" onclick="location.reload()">데이터 갱신</button>
</div>
</header>
<!-- 핵심 세이버메트릭스 지표 요약 -->
<section class="metrics-grid">
<div class="metric-card sra">
<div class="metric-info">
<span class="label" data-tooltip="Avg. P-WAR Score: 시스템 내 모든 프로젝트의 평균 기여도입니다. 양수(+)가 높을수록 시스템이 활발하게 운영되고 있음을 의미합니다.">평균 P-WAR (기여도) <i class="info-icon">?</i></span>
<h3 class="value">0.00</h3>
<span class="trend up">대체 수준(0.0) 대비</span>
</div>
<div class="metric-chart-mini" id="sraChart"></div>
</div>
<div class="metric-card iwar">
<div class="metric-info">
<span class="label" data-tooltip="Total Pending Risks: 현재 해결되지 않고 방치된 문의사항의 총합입니다. P-WAR 감점 요인입니다.">미결 리스크 총합 <i class="info-icon">?</i></span>
<h3 class="value">0</h3>
<span class="trend steady">실시간 집계</span>
</div>
<div class="metric-chart-mini" id="iwarChart"></div>
</div>
<div class="metric-card piso">
<div class="metric-info">
<span class="label" data-tooltip="Active Resource Scale: P-WAR가 양수(+)인 활성 프로젝트들이 관리 중인 총 파일 규모입니다.">활성 자원 규모 <i class="info-icon">?</i></span>
<h3 class="value">0</h3>
<span class="trend up">시스템 기여 자원</span>
</div>
<div class="metric-chart-mini" id="pisoChart"></div>
</div>
<div class="metric-card stability">
<div class="metric-info">
<span class="label" data-tooltip="Zombie Project Rate: P-WAR 점수가 -1.0 이하인 '대체 수준 미달' 프로젝트의 비중입니다.">좀비 프로젝트 비율 <i class="info-icon">?</i></span>
<h3 class="value">0%</h3>
<span class="trend steady">집중 관리 대상</span>
</div>
<div class="metric-chart-mini" id="stabilityChart"></div>
</div>
</section>
<!-- 메인 분석 영역 -->
<div class="analysis-main-grid">
<!-- P-WAR 분석 테이블 -->
<div class="analysis-card timeline-analysis">
<div class="card-header">
<div style="display: flex; flex-direction: column; gap: 4px;">
<h4>Project Performance Above Replacement (P-WAR Ranking)</h4>
<p style="font-size: 11px; color: #888; margin: 0;">대체 수준(Replacement Level) 프로젝트 대비 기여도를 측정합니다.</p>
</div>
<div class="card-tools">
<span id="avg-system-info" style="font-size: 11px; color: #888;">* 0.0 = 시스템 평균 계산 중...</span>
</div>
</div>
<div class="card-body">
<!-- P-WAR 판정 가이드 범례 수정 -->
<div class="d-war-guide">
<div class="guide-item active-low"><span>양수(+)</span> 운영 중</div>
<div class="guide-item warning-mid"><span>음수(-)</span> 위험군</div>
<div class="guide-item danger-high"><span>-0.3 이하</span> 방치-삭제대상</div>
<div class="guide-item hazard-critical"><span>시스템삭제</span> 잠김예정 프로젝트</div>
</div>
<div class="chart-placeholder">
<p>R-Engine 시각화 대기 중...</p>
</div>
</div>
</div>
<!-- 위험 신호 및 예측 -->
<div class="analysis-card risk-prediction">
<div class="card-header">
<h4>Deep Learning 기반 장애 예보 (Risk Signal)</h4>
</div>
<div class="card-body">
<div class="risk-signal-list">
<div class="risk-item high">
<div class="risk-project">프로젝트 A (해외/중동)</div>
<div class="risk-reason">파일 급증 대비 활동 정체 (P-ISO 급락)</div>
<div class="risk-status">위험</div>
</div>
<div class="risk-item warning">
<div class="risk-project">프로젝트 B (기술개발)</div>
<div class="risk-reason">특정 환경(IE/Edge) 문의 집중 발생</div>
<div class="risk-status">주의</div>
</div>
<div class="risk-item safe">
<div class="risk-project">프로젝트 C (국내/장헌)</div>
<div class="risk-reason">로그 활동성 및 해결률 안정적 유지</div>
<div class="risk-status">안전</div>
</div>
</div>
</div>
</div>
</div>
</main>
<script src="js/common.js"></script>
<script src="js/analysis.js"></script>
</body>
</html>

View File

@@ -22,10 +22,8 @@
<ul class="nav-list">
<li class="nav-item active" onclick="location.href='/dashboard'">대시보드</li>
<li class="nav-item" onclick="location.href='/inquiries'">문의사항</li>
<li class="nav-item" onclick="alert('준비 중입니다.')">로그관리</li>
<li class="nav-item" onclick="alert('준비 중입니다.')">파일관리</li>
<li class="nav-item" onclick="alert('준비 중입니다.')">인원관리</li>
<li class="nav-item" onclick="alert('준비 중입니다.')">공지사항</li>
<li class="nav-item" onclick="location.href='/mailTest'">메일관리</li>
<li class="nav-item" onclick="location.href='/analysis'">분석</li>
</ul>
</nav>

View File

@@ -22,10 +22,8 @@
<ul class="nav-list">
<li class="nav-item" onclick="location.href='/dashboard'">대시보드</li>
<li class="nav-item active" onclick="location.href='/inquiries'">문의사항</li>
<li class="nav-item" onclick="alert('준비 중입니다.')">로그관리</li>
<li class="nav-item" onclick="alert('준비 중입니다.')">파일관리</li>
<li class="nav-item" onclick="alert('준비 중입니다.')">인원관리</li>
<li class="nav-item" onclick="alert('준비 중입니다.')">공지사항</li>
<li class="nav-item" onclick="location.href='/mailTest'">메일관리</li>
<li class="nav-item" onclick="location.href='/analysis'">분석</li>
</ul>
</nav>
@@ -136,17 +134,33 @@
<table class="inquiry-table">
<thead>
<tr>
<th width="50">No</th>
<th width="50" class="sortable" onclick="handleSort('no')">
<div class="header-content">No <span id="sort-no" class="sort-icon"></span></div>
</th>
<th width="80">이미지</th>
<th width="120">PM 종류</th>
<th width="100">환경</th>
<th width="150">구분</th>
<th>프로젝트</th>
<th width="120" class="sortable" onclick="handleSort('pm_type')">
<div class="header-content">PM 종류 <span id="sort-pm_type" class="sort-icon"></span></div>
</th>
<th width="100" class="sortable" onclick="handleSort('browser')">
<div class="header-content">환경 <span id="sort-browser" class="sort-icon"></span></div>
</th>
<th width="150" class="sortable" onclick="handleSort('category')">
<div class="header-content">구분 <span id="sort-category" class="sort-icon"></span></div>
</th>
<th class="sortable" onclick="handleSort('project_nm')">
<div class="header-content">프로젝트 <span id="sort-project_nm" class="sort-icon"></span></div>
</th>
<th width="400">문의내용</th>
<th width="100">작성자</th>
<th width="120">날짜</th>
<th width="100" class="sortable" onclick="handleSort('author')">
<div class="header-content">작성자 <span id="sort-author" class="sort-icon"></span></div>
</th>
<th width="120" class="sortable" onclick="handleSort('reg_date')">
<div class="header-content">날짜 <span id="sort-reg_date" class="sort-icon"></span></div>
</th>
<th width="400">답변내용</th>
<th width="100">상태</th>
<th width="100" class="sortable" onclick="handleSort('status')">
<div class="header-content">상태 <span id="sort-status" class="sort-icon"></span></div>
</th>
</tr>
</thead>
<tbody id="inquiryList">

View File

@@ -22,9 +22,9 @@
</div>
<ul class="nav-list">
<li class="nav-item" onclick="location.href='/dashboard'">대시보드</li>
<li class="nav-item" onclick="location.href='/inquiries'">문의사항</li>
<li class="nav-item active" onclick="location.href='/mailTest'">메일관리</li>
<li class="nav-item">로그관리</li>
<li class="nav-item">파일관리</li>
<li class="nav-item" onclick="location.href='/analysis'">분석</li>
</ul>
</nav>