feat: implement P-WAR system analysis and inquiries sorting functionality
This commit is contained in:
122
js/analysis.js
Normal file
122
js/analysis.js
Normal 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('');
|
||||
}
|
||||
@@ -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');
|
||||
|
||||
Reference in New Issue
Block a user