Fix Gitea MCP ESM resolution error, update configuration, and include project tests/migration files

This commit is contained in:
Taehoon
2026-06-19 16:19:11 +09:00
parent b864d615ea
commit cda6dce0be
59 changed files with 9509 additions and 5798 deletions

View File

@@ -1,463 +1,485 @@
/**
* Project Master Analysis JS
* AVI (Activity Vitality Index) & VCI (Value Contribution Index) 분석 엔진
* OCI (Operational Consistency Index) 통합 버전
*/
// Chart.js 플러그인 전역 등록
if (typeof ChartDataLabels !== 'undefined') {
Chart.register(ChartDataLabels);
}
document.addEventListener('DOMContentLoaded', () => {
console.log("Business Analysis Engine initialized...");
loadProjectAnalysisData();
});
async function loadProjectAnalysisData() {
try {
const response = await fetch('/api/analysis/p-war');
const data = await response.json();
if (data.error) throw new Error(data.error);
renderVitalityLeaderboard(data);
renderValueCharts(data);
if (data.length > 0 && data[0].avg_info) {
const avg = data[0].avg_info;
const infoEl = document.getElementById('avg-system-info');
if (infoEl) infoEl.textContent = `* 시스템 종합 자산 건전도: ${avg.avg_risk}% (운영 표준 70.0% 대비)`;
}
} catch (e) {
console.error("분석 데이터 로딩 실패:", e);
}
}
function getStatusInfo(avi, isAutoDelete) {
if (isAutoDelete || avi < 10) return { label: '사망', class: 'badge-system', key: 'dead' };
if (avi < 30) return { label: '위험 노출', class: 'badge-danger', key: 'danger' };
if (avi < 70) return { label: '관리 주의', class: 'badge-warning', key: 'warning' };
return { label: '정상 운영', class: 'badge-active', key: 'active' };
}
function getVciGrade(vci) {
if (vci >= 10) return { label: 'Masterpiece', class: 'grade-mvp', desc: '시스템 가치를 견인하는 최우량 핵심 자산' };
if (vci >= 2) return { label: 'Blue Chip', class: 'grade-allstar', desc: '꾸준한 활력으로 가치를 창출하는 우량 자산' };
if (vci >= -2) return { label: 'Steady', class: 'grade-starter', desc: '표준 수준의 운영을 유지 중인 안정 자산' };
if (vci >= -10) return { label: 'Underperform', class: 'grade-bench', desc: '규모 대비 활력 부족으로 가치가 하락 중인 자산' };
return { label: 'Liability', class: 'grade-out', desc: '가치를 훼손 중인 고위험 방치 자산' };
}
function renderValueCharts(data) {
if (!data || data.length === 0) return;
// 1. 운영 활력 분포 (Doughnut)
try {
const stats = { active: [], warning: [], danger: [], dead: [] };
data.forEach(p => {
const status = getStatusInfo(p.p_war, p.is_auto_delete);
stats[status.key].push(p);
});
const statusCtx = document.getElementById('statusChart').getContext('2d');
if (window.myStatusChart) window.myStatusChart.destroy();
window.myStatusChart = new Chart(statusCtx, {
type: 'doughnut',
data: {
labels: ['정상 운영', '관리 주의', '위험 노출', '사망'],
datasets: [{
data: [stats.active.length, stats.warning.length, stats.danger.length, stats.dead.length],
backgroundColor: ['#1E5149', '#22c55e', '#f59e0b', '#ef4444'],
borderWidth: 0
}]
},
options: {
responsive: true,
maintainAspectRatio: false,
layout: { padding: 15 },
plugins: {
legend: { position: 'right', labels: { boxWidth: 10, font: { size: 11, weight: '700' }, usePointStyle: true } },
datalabels: { display: false }
},
cutout: '65%',
onClick: (e, elements) => {
if (elements.length > 0) {
const idx = elements[0].index;
openProjectListModal(['정상 운영', '관리 주의', '위험 노출', '사망'][idx], stats[['active', 'warning', 'danger', 'dead'][idx]]);
}
}
}
});
} catch (err) { console.error("도넛 차트 에러:", err); }
// 2. 전략적 자산 매트릭스 (Scatter) - 정밀 복구
try {
const sortedByAVI = [...data].sort((a, b) => b.p_war - a.p_war);
const top5Ids = sortedByAVI.slice(0, 5).map(p => p.project_nm);
const bottom5Ids = sortedByAVI.slice(-5).map(p => p.project_nm);
const largeProjects = data.filter(p => p.file_count > 450).map(p => p.project_nm);
const vipProjectNames = new Set([...top5Ids, ...bottom5Ids, ...largeProjects]);
const scatterData = data.map(p => {
const vci = p.risk_count || 0;
const absVci = Math.abs(vci);
return {
x: Math.min(500, p.file_count),
y: p.p_war,
label: p.project_nm,
isVip: vipProjectNames.has(p.project_nm),
vci: vci,
radius: Math.max(5, Math.min(25, 5 + (absVci / 10)))
};
});
const vitalityCtx = document.getElementById('forecastChart').getContext('2d');
if (window.myVitalityChart) window.myVitalityChart.destroy();
window.myVitalityChart = new Chart(vitalityCtx, {
type: 'scatter',
data: {
datasets: [{
data: scatterData,
backgroundColor: (ctx) => {
const p = ctx.raw;
if (!p) return '#94a3b8';
if (p.x >= 250 && p.y >= 50) return '#1E5149';
if (p.x < 250 && p.y >= 50) return '#22c55e';
if (p.x < 250 && p.y < 50) return '#94a3b8';
return '#ef4444';
},
pointRadius: (ctx) => ctx.raw ? ctx.raw.radius : 5,
hoverRadius: (ctx) => (ctx.raw ? ctx.raw.radius : 5) + 3
}]
},
options: {
responsive: true,
maintainAspectRatio: false,
layout: { padding: { top: 30, right: 45, left: 10, bottom: 10 } },
scales: {
x: {
type: 'linear', min: 0, max: 500,
title: { display: true, text: '자산 규모 (파일 수)', font: { size: 11, weight: '700' } },
grid: { display: false }
},
y: {
min: 0, max: 100,
title: { display: true, text: '운영 활력 (AVI %)', font: { size: 11, weight: '700' } },
grid: { display: false }
}
},
plugins: {
legend: { display: false },
datalabels: {
backgroundColor: 'rgba(255, 255, 255, 0.8)',
borderRadius: 4, padding: 4,
font: { size: 10, weight: '800' },
formatter: (v) => v ? v.label : '',
display: (ctx) => ctx.raw && ctx.raw.isVip,
clip: false
},
tooltip: {
callbacks: {
label: (ctx) => ` [${ctx.raw.label}] AVI: ${ctx.raw.y.toFixed(1)}% | VCI: ${ctx.raw.vci.toFixed(1)}`
}
}
}
},
plugins: [{
id: 'quadrants',
beforeDraw: (chart) => {
const { ctx, chartArea: { left, top, right, bottom }, scales: { x, y } } = chart;
const midX = x.getPixelForValue(250);
const midY = y.getPixelForValue(50);
ctx.save();
ctx.fillStyle = 'rgba(34, 197, 94, 0.03)'; ctx.fillRect(left, top, midX - left, midY - top);
ctx.fillStyle = 'rgba(30, 81, 73, 0.03)'; ctx.fillRect(midX, top, right - midX, midY - top);
ctx.fillStyle = 'rgba(148, 163, 184, 0.03)'; ctx.fillRect(left, midY, midX - left, bottom - midY);
ctx.fillStyle = 'rgba(239, 68, 68, 0.05)'; ctx.fillRect(midX, midY, right - midX, bottom - midY);
ctx.lineWidth = 2; ctx.strokeStyle = 'rgba(0,0,0,0.1)'; ctx.beginPath();
ctx.moveTo(midX, top); ctx.lineTo(midX, bottom); ctx.moveTo(left, midY); ctx.lineTo(right, midY); ctx.stroke();
ctx.font = 'bold 12px Pretendard'; ctx.textAlign = 'center'; ctx.fillStyle = 'rgba(0,0,0,0.2)';
ctx.fillText('활력 양호', (left + midX) / 2, (top + midY) / 2);
ctx.fillText('핵심 가치', (midX + right) / 2, (top + midY) / 2);
ctx.fillText('정체/소규모', (left + midX) / 2, (midY + bottom) / 2);
ctx.fillStyle = 'rgba(239, 68, 68, 0.4)'; ctx.fillText('자산 손실 위험', (midX + right) / 2, (midY + bottom) / 2);
ctx.restore();
}
}]
});
} catch (err) { console.error("전략 매트릭스 에러:", err); }
}
function renderVitalityLeaderboard(data) {
const container = document.getElementById('p-war-table-container');
if (!container) return;
const sortedData = [...data].sort((a, b) => a.p_war - b.p_war);
container.innerHTML = `
<div class="table-scroll-wrapper">
<table class="data-table p-war-table">
<thead>
<tr>
<th style="width: 250px;">프로젝트명</th>
<th>파일 수</th>
<th>정체 일수</th>
<th>상태 판정</th>
<th style="text-align:right;">가치 기여 (VCI) <button class="btn-help" onclick="event.stopPropagation(); openAnalysisModal('vci')">?</button></th>
<th>운영 활력 (AVI) <button class="btn-help" onclick="event.stopPropagation(); openAnalysisModal('avi')">?</button></th>
<th style="text-align:center;">업무 집중도 <button class="btn-help" onclick="event.stopPropagation(); openAnalysisModal('focus')">?</button></th>
<th>운영 일관성 (OCI) <button class="btn-help" onclick="event.stopPropagation(); openAnalysisModal('oci')">?</button></th>
</tr>
</thead>
<tbody>
${sortedData.map((p, idx) => {
const status = getStatusInfo(p.p_war, p.is_auto_delete);
const avi = p.p_war;
const vci = p.risk_count;
const oci = p.oci_score || 0;
const rowId = `project-${idx}`;
const grade = getVciGrade(vci);
let rhythmLabel = oci >= 80 ? "정기적" : oci >= 50 ? "안정적" : oci >= 20 ? "간헐적" : "불규칙";
let rhythmColor = oci >= 80 ? "#059669" : oci >= 50 ? "#1e5149" : oci >= 20 ? "#f59e0b" : "#dc2626";
// 존재 신뢰도 패널티 (ECV) 상세 설명 복구
let ecvText = "100% (데이터 실체 검증)";
let ecvClass = "highlight-val";
let ecvDesc = `현재 ${p.file_count}개의 유효 성과물이 확인됩니다. 시스템적으로 실체가 완벽히 존재하는 상태입니다.`;
if (p.file_count === 0) {
ecvText = "5% (유령 프로젝트 판명)";
ecvClass = "highlight-penalty";
ecvDesc = "데이터가 전무하여 프로젝트의 디지털 실체가 없습니다. 모든 분석에서 최하위 패널티가 적용됩니다.";
} else if (p.file_count < 10) {
ecvText = "40% (형식적 껍데기 판명)";
ecvClass = "highlight-penalty";
ecvDesc = "최소 수준의 문서만 존재하며, 실질적인 운영 가치를 인정하기 어려운 소규모 상태입니다.";
}
// 활동 품질 텍스트 복구
const qualityLabel = p.log_quality >= 1.0 ? '성과물 중심의 <b>실무 활동</b>' : p.log_quality >= 0.7 ? '구조 관리를 위한 <b>시스템 활동</b>' : '단순 행정 기반의 <b>형식 활동</b>';
const qualityDetail = p.log_quality >= 1.0 ? '최근 로그에서 파일 업로드/수정 등 가치 증분 활동이 명확히 포착되었습니다.' : p.log_quality >= 0.7 ? '폴더 생성/이동 등 구조적 관리는 이뤄지고 있으나, 직접적 결과물 생산은 부족합니다.' : '메일 확인, 권한 변경 등 시스템 유지성 활동 위주로 파악되어 품질 가중치가 낮게 적용되었습니다.';
return `
<tr class="project-row ${status.key === 'danger' ? 'row-danger' : status.key === 'warning' ? 'row-warning' : ''}" onclick="toggleProjectDetail('${rowId}')">
<td class="font-bold">${p.project_nm}</td>
<td>${p.file_count.toLocaleString()}</td>
<td>${p.days_stagnant}</td>
<td><span class="${status.class}">${status.label}</span></td>
<td style="text-align:right; font-weight:800; color:${vci >= 0 ? '#059669' : '#dc2626'};">
${vci > 0 ? '+' : ''}${vci.toFixed(1)}
</td>
<td class="p-war-value ${avi >= 70 ? 'text-plus' : 'text-minus'}">${avi.toFixed(1)}%</td>
<td style="text-align:center;">
<div style="display:flex; flex-direction:column; align-items:center; gap:4px;">
<span style="font-weight:800; font-size:12px; color:${p.work_effort >= 70 ? '#059669' : p.work_effort <= 30 ? '#dc2626' : '#6366f1'};">${p.work_effort}%</span>
<div style="width:40px; height:4px; background:#f1f5f9; border-radius:2px; overflow:hidden;">
<div style="width:${p.work_effort}%; height:100%; background:${p.work_effort >= 70 ? '#059669' : p.work_effort <= 30 ? '#dc2626' : '#6366f1'};"></div>
</div>
</div>
</td>
<td style="text-align:center;">
<div style="display:flex; align-items:center; justify-content:center; gap:8px;">
<span style="font-weight:800; font-size:13px; color:${rhythmColor};">${oci}%</span>
<span style="font-size:10px; padding:2px 6px; border-radius:10px; background:${rhythmColor}15; color:${rhythmColor}; border:1px solid ${rhythmColor}30;">${rhythmLabel}</span>
</div>
</td>
</tr>
<tr id="detail-${rowId}" class="detail-row">
<td colspan="8">
<div class="detail-container">
<div class="formula-explanation-card">
<div class="formula-header"> AI 위험 적응형 모델(AAS) 기반 인과관계 분석</div>
<div style="display: flex; gap: 20px; margin-bottom: 20px;">
<div class="work-effort-section" style="flex: 1; margin-bottom: 0;">
<div class="work-effort-header">
<span style="font-size: 13px; font-weight: 800; color: #1e5149;">📊 실질 업무 집중도 (Job Focus)</span>
<span style="font-size: 14px; font-weight: 800; color: ${p.work_effort >= 70 ? '#059669' : p.work_effort <= 30 ? '#dc2626' : '#6366f1'};">${p.work_effort}%</span>
</div>
<div class="work-effort-bar-bg"><div style="width: ${p.work_effort}%; height: 100%; background: ${p.work_effort >= 70 ? '#059669' : p.work_effort <= 30 ? '#dc2626' : '#6366f1'};"></div></div>
<div style="font-size: 11px; color: #64748b; line-height: 1.5;">최근 30 수집 이력 단순 로그 갱신이 아닌 <b>실제 성과물의 변동</b> . '' .</div>
</div>
<div style="flex: 1; background: #f8fafc; border-radius: 8px; padding: 16px; border: 1px solid #e2e8f0; display: flex; align-items: center; gap: 15px;">
<div style="text-align: center;">
<div style="font-size: 10px; color: #64748b; font-weight: 700; margin-bottom: 2px;">VCI GRADE</div>
<div class="grade-badge ${grade.class}" style="padding: 4px 12px; border-radius: 6px; font-weight: 900; font-size: 14px; display: inline-block;">${grade.label}</div>
</div>
<div style="font-size: 12px; color: #475569; line-height: 1.4; font-weight: 600;">${grade.desc}</div>
</div>
</div>
<div class="formula-steps-grid">
<div class="formula-step">
<div class="step-num">1</div>
<div class="step-content">
<div class="step-title">동적 위험 계수(λ) 산출</div>
<div style="font-size:11px; color:#64748b; margin-bottom:5px;">프로젝트 규모가 클수록 정보 망실 시의 충격을 반영하여 데이터의 하락 속도가 가속됩니다. 현재 <b>λ=${p.ai_lambda.toFixed(4)}</b> .</div>
<div class="math-logic">Dynamic λ = <span class="highlight-var">${p.ai_lambda.toFixed(4)}</span></div>
</div>
</div>
<div class="formula-step">
<div class="step-num">4</div>
<div class="step-content">
<div class="step-title">활동 품질 검증 (Quality)</div>
<div class="step-desc" style="font-size:11px; margin-bottom:5px;">최근 로그 분석 결과 <b>${qualityLabel}</b> . ${qualityDetail}</div>
<div class="math-logic">Quality Factor = <span class="${p.log_quality < 0.7 ? 'highlight-penalty' : 'highlight-val'}">${(p.log_quality * 100).toFixed(0)}%</span></div>
</div>
</div>
<div class="formula-step">
<div class="step-num">2</div>
<div class="step-content">
<div class="step-title">방치 시간 감쇄 적용</div>
<div style="font-size:11px; color:#64748b; margin-bottom:5px;">마지막 유효 활동 이후 <b>${p.days_stagnant}</b> .</div>
<div class="math-logic">Decay Result = <span class="highlight-val">${((avi / (p.file_count === 0 ? 0.05 : p.file_count < 10 ? 0.4 : 1) / p.log_quality) || 0).toFixed(1)}%</span></div>
</div>
</div>
<div class="formula-step">
<div class="step-num">3</div>
<div class="step-content">
<div class="step-title">존재 진정성 (ECV)</div>
<div style="font-size:11px; color:#64748b; margin-bottom:5px;">${ecvDesc} 파일 자체가 분석의 데이터 진정성을 보정하는 핵심 팩터로 작용합니다.</div>
<div class="math-logic">Entity Factor = <span class="${ecvClass}">${ecvText}</span></div>
</div>
</div>
</div>
<div style="margin-top: 20px; padding-top: 15px; border-top: 2px solid #1e5149; text-align: right; display: flex; justify-content: space-between; align-items: center;">
<div style="text-align: left; max-width: 70%;">
<div style="font-size: 13px; font-weight: 800; color: ${vci >= 0 ? '#059669' : '#dc2626'}; margin-bottom: 4px;">
가치 기여도 (VCI) 진단: ${vci >= 0 ? '+' : ''}${vci.toFixed(2)}
</div>
<div style="font-size: 11px; color: #64748b; line-height: 1.5;">
현재 프로젝트는 운영 표준(AVI 70%) 대비 <b>${Math.abs(avi - 70).toFixed(1)}%p ${avi >= 70 ? '상회' : '하회'}</b> ,
<b>${p.file_count}</b>의 자산 규모에 따른 <b>${((p.file_count / 200) + 0.5).toFixed(2)}</b> .
이는 시스템 전체 관점에서 <b>${vci >= 0 ? '순자산 가치를 증대' : '잠재적 기회비용을 손실'}</b> .
</div>
</div>
<div>
<span style="font-size: 13px; color: #64748b; font-weight: 700;">최종 AVI: </span>
<span style="color: #1e5149; font-size: 22px; font-weight: 900;">${avi.toFixed(1)}%</span>
</div>
</div>
</div>
</div>
</td>
</tr>`;
}).join('')}
</tbody>
</table>
</div>`;
}
function toggleProjectDetail(rowId) {
const container = document.querySelector('.table-scroll-wrapper');
const mainRow = document.querySelector(`tr[onclick*="toggleProjectDetail('${rowId}')"]`);
const detailRow = document.getElementById(`detail-${rowId}`);
if (detailRow && container) {
if (!detailRow.classList.contains('active')) {
document.querySelectorAll('.detail-row').forEach(row => row.classList.remove('active'));
detailRow.classList.add('active');
// 정밀 스크롤 이동 로직 복구
setTimeout(() => {
const headerH = container.querySelector('thead').offsetHeight || 45;
const targetScrollTop = mainRow.offsetTop - headerH;
container.scrollTo({ top: targetScrollTop, behavior: 'smooth' });
}, 100);
} else {
detailRow.classList.remove('active');
}
}
}
function openProjectListModal(label, projects) {
const modal = document.getElementById('analysisModal');
const title = document.getElementById('modalTitle');
const body = document.getElementById('modalBody');
title.innerText = `[${label}] 프로젝트 리스트 (${projects.length}건)`;
body.innerHTML = `
<div class="table-scroll-wrapper" style="max-height: 400px;">
<table class="data-table">
<thead><tr><th>프로젝트명</th><th>관리자</th><th>정체일</th><th>AVI</th></tr></thead>
<tbody>${projects.map(p => `<tr><td class="font-bold">${p.project_nm}</td><td>${p.master || '-'}</td><td>${p.days_stagnant}일</td><td style="font-weight:700; color:#1e5149;">${p.p_war.toFixed(1)}%</td></tr>`).join('')}</tbody>
</table>
</div>
<div class="modal-footer"><button class="btn btn-primary" onclick="closeAnalysisModal()">닫기</button></div>`;
modal.style.display = 'flex';
}
function openAnalysisModal(type) {
const modal = document.getElementById('analysisModal');
const title = document.getElementById('modalTitle');
const body = document.getElementById('modalBody');
if (type === 'avi') {
title.innerText = '운영 활력 지수 (AVI) 등급 가이드';
body.innerHTML = `
<div class="formula-box" style="margin-bottom:15px;">AVI = exp(-λ × days) × Quality × 100</div>
<p style="font-size:13px; color:#64748b; margin-bottom:15px;">자산의 가동 상태와 생존율을 나타내는 지표입니다.</p>
<table class="data-table" style="font-size:12px;">
<thead><tr style="background:#f8fafc;"><th>지수 (AVI)</th><th>등급</th><th>운영 상태</th></tr></thead>
<tbody>
<tr><td>90%↑</td><td style="font-weight:900; color:#059669;">Live</td><td>실시간 성과물이 도출되는 최상급 가동</td></tr>
<tr><td>70~90%</td><td style="font-weight:900; color:#1e5149;">Stable</td><td>주기적 업데이트가 이뤄지는 표준 안정</td></tr>
<tr><td>30~70%</td><td style="font-weight:900; color:#f59e0b;">Idle</td><td>관리가 필요한 유휴/정체 상태</td></tr>
<tr><td>10~30%</td><td style="font-weight:900; color:#dc2626;">Risk</td><td>자산 가치 소멸 직전의 위험 상태</td></tr>
<tr><td>10%↓</td><td style="font-weight:900; color:#64748b;">Frozen</td><td>운영이 중단된 사망/방치 상태</td></tr>
</tbody>
</table>
<div class="modal-footer"><button class="btn btn-primary" onclick="closeAnalysisModal()">닫기</button></div>`;
} else if (type === 'vci') {
title.innerText = '자산 가치 기여도 (VCI) 등급 가이드';
body.innerHTML = `
<div class="formula-box" style="margin-bottom:15px;">VCI = (AVI - 70.0) × (Files / 200 + 0.5)</div>
<p style="font-size:13px; color:#64748b; margin-bottom:15px;">운영 표준(AVI 70%) 대비 자산 가치 기여도에 따른 프로젝트 위상 분류입니다.</p>
<table class="data-table" style="font-size:12px;">
<thead><tr style="background:#f8fafc;"><th>점수 (VCI)</th><th>등급</th><th>운영 의미</th></tr></thead>
<tbody>
<tr><td>+10.0↑</td><td style="font-weight:900; color:#6366f1;">Masterpiece</td><td>시스템 가치를 견인하는 최우량 핵심 자산</td></tr>
<tr><td>+2.0 ~ +10.0</td><td style="font-weight:900; color:#059669;">Blue Chip</td><td>꾸준한 활력으로 가치를 창출하는 우량 자산</td></tr>
<tr><td>-2.0 ~ +2.0</td><td style="font-weight:900; color:#475569;">Steady</td><td>표준 수준의 운영을 유지 중인 안정 자산</td></tr>
<tr><td>-10.0 ~ -2.0</td><td style="font-weight:900; color:#f59e0b;">Underperform</td><td>규모 대비 활력 부족으로 가치 하락 중인 자산</td></tr>
<tr><td>-10.0↓</td><td style="font-weight:900; color:#dc2626;">Liability</td><td>가치를 훼손 중인 고위험 방치 자산</td></tr>
</tbody>
</table>
<div class="modal-footer"><button class="btn btn-primary" onclick="closeAnalysisModal()">닫기</button></div>`;
} else if (type === 'oci') {
title.innerText = '운영 일관성 지수 (OCI) 분석 가이드';
body.innerHTML = `
<div style="background:#f0fdf4; padding:15px; border-radius:8px; margin-bottom:15px;">
<strong style="color:#166534; display:block; margin-bottom:5px;">"얼마나 꾸준하게 관리되고 있는가?"</strong>
<p style="font-size:12.5px; color:#166534; margin:0;">미래 예측이 아닌, 최근 30일간의 <b>활동 리듬</b>과 <b>관리의 규칙성</b>을 분석하여 성실도를 점수화합니다.</p>
</div>
<table class="data-table" style="font-size:12px;">
<thead><tr style="background:#f8fafc;"><th>분석 결과</th><th>일관성 등급</th><th>관리 신뢰도</th></tr></thead>
<tbody>
<tr><td style="color:#059669;">80%↑</td><td style="font-weight:900; color:#059669;">매우 우수</td><td>주 단위의 정기적 관리가 완벽히 이뤄짐</td></tr>
<tr><td style="color:#1e5149;">50~80%</td><td style="font-weight:900; color:#1e5149;">양호</td><td>간헐적 정체는 있으나 꾸준히 관리됨</td></tr>
<tr><td style="color:#f59e0b;">20~50%</td><td style="font-weight:900; color:#f59e0b;">주의</td><td>돌발적 활동 위주, 관리의 리듬이 깨짐</td></tr>
<tr><td style="color:#dc2626;">20%↓</td><td style="font-weight:900; color:#dc2626;">매우 불량</td><td>장기 정체 중이거나 관리 의지 확인 불가</td></tr>
</tbody>
</table>
<div class="modal-footer"><button class="btn btn-primary" onclick="closeAnalysisModal()">닫기</button></div>`;
} else {
title.innerText = '업무 집중도 (Job Focus) 등급 가이드';
body.innerHTML = `
<p style="font-size:13px; color:#64748b; margin-bottom:15px;">최근 수집 로그 중 단순 행정 로그를 제외하고 실질적인 성과물(파일) 변동이 포착된 비율입니다.</p>
<table class="data-table" style="font-size:12px;">
<thead><tr style="background:#f8fafc;"><th>비율 (%)</th><th>등급</th><th>활동 성격</th></tr></thead>
<tbody>
<tr><td>80%↑</td><td style="font-weight:900; color:#6366f1;">Intensive</td><td>성과물 위주의 고밀도 집중 작업</td></tr>
<tr><td>50~80%</td><td style="font-weight:900; color:#059669;">Active</td><td>성과와 관리가 균형 잡힌 원활한 실행</td></tr>
<tr><td>20~50%</td><td style="font-weight:900; color:#f59e0b;">Maintenance</td><td>설정/행정 등 단순 관리 중심의 작업</td></tr>
<tr><td>20%↓</td><td style="font-weight:900; color:#dc2626;">Surface</td><td>실체적 변화가 적은 형식적 로그 중심</td></tr>
</tbody>
</table>
<div class="modal-footer"><button class="btn btn-primary" onclick="closeAnalysisModal()">닫기</button></div>`;
}
modal.style.display = 'flex';
}
function closeAnalysisModal() { document.getElementById('analysisModal').style.display = 'none'; }
/**
* Project Master Analysis JS
* AVI (Activity Vitality Index) & VCI (Value Contribution Index) 분석 엔진
* OCI (Operational Consistency Index) 통합 버전
*/
// Chart.js 플러그인 전역 등록
if (typeof ChartDataLabels !== 'undefined') {
Chart.register(ChartDataLabels);
}
document.addEventListener('DOMContentLoaded', () => {
console.log("Business Analysis Engine initialized...");
loadProjectAnalysisData();
});
async function loadProjectAnalysisData() {
try {
const response = await fetch('/api/analysis/p-war');
const data = await response.json();
if (data.error) throw new Error(data.error);
renderVitalityLeaderboard(data);
renderValueCharts(data);
if (data.length > 0 && data[0].avg_info) {
const avg = data[0].avg_info;
const infoEl = document.getElementById('avg-system-info');
if (infoEl) infoEl.textContent = `* 시스템 종합 운영 활력(AVI): ${avg.avg_risk}% (평균 관리 수준)`;
}
} catch (e) {
console.error("분석 데이터 로딩 실패:", e);
}
}
function getStatusInfo(avi, isAutoDelete) {
if (isAutoDelete || avi < 10) return { label: '사망', class: 'badge-system', key: 'dead' };
if (avi < 30) return { label: '위험 노출', class: 'badge-danger', key: 'danger' };
if (avi < 70) return { label: '관리 주의', class: 'badge-warning', key: 'warning' };
return { label: '정상 운영', class: 'badge-active', key: 'active' };
}
function getVciGrade(vci) {
if (vci >= 10) return { label: 'Masterpiece', class: 'grade-mvp', desc: '시스템 가치를 견인하는 최우량 핵심 자산' };
if (vci >= 2) return { label: 'Blue Chip', class: 'grade-allstar', desc: '꾸준한 활력으로 가치를 창출하는 우량 자산' };
if (vci >= -2) return { label: 'Steady', class: 'grade-starter', desc: '표준 수준의 운영을 유지 중인 안정 자산' };
if (vci >= -10) return { label: 'Underperform', class: 'grade-bench', desc: '규모 대비 활력 부족으로 가치가 하락 중인 자산' };
return { label: 'Liability', class: 'grade-out', desc: '가치를 훼손 중인 고위험 방치 자산' };
}
function renderValueCharts(data) {
if (!data || data.length === 0) return;
// 1. 운영 활력 분포 (Doughnut)
try {
const stats = { active: [], warning: [], danger: [], dead: [] };
data.forEach(p => {
const status = getStatusInfo(p.p_war, p.is_auto_delete);
stats[status.key].push(p);
});
const statusCtx = document.getElementById('statusChart').getContext('2d');
if (window.myStatusChart) window.myStatusChart.destroy();
window.myStatusChart = new Chart(statusCtx, {
type: 'doughnut',
data: {
labels: ['정상 운영', '관리 주의', '위험 노출', '사망'],
datasets: [{
data: [stats.active.length, stats.warning.length, stats.danger.length, stats.dead.length],
backgroundColor: ['#1E5149', '#22c55e', '#f59e0b', '#ef4444'],
borderWidth: 0
}]
},
options: {
responsive: true,
maintainAspectRatio: false,
layout: { padding: 15 },
plugins: {
legend: { position: 'right', labels: { boxWidth: 10, font: { size: 11, weight: '700' }, usePointStyle: true } },
datalabels: { display: false }
},
cutout: '65%',
onClick: (e, elements) => {
if (elements.length > 0) {
const idx = elements[0].index;
openProjectListModal(['정상 운영', '관리 주의', '위험 노출', '사망'][idx], stats[['active', 'warning', 'danger', 'dead'][idx]]);
}
}
}
});
} catch (err) { console.error("도넛 차트 에러:", err); }
// 2. 전략적 자산 매트릭스 (Scatter) - 정밀 복구
try {
const sortedByAVI = [...data].sort((a, b) => b.p_war - a.p_war);
const top5Ids = sortedByAVI.slice(0, 5).map(p => p.project_nm);
const bottom5Ids = sortedByAVI.slice(-5).map(p => p.project_nm);
const largeProjects = data.filter(p => p.file_count > 450).map(p => p.project_nm);
const vipProjectNames = new Set([...top5Ids, ...bottom5Ids, ...largeProjects]);
const scatterData = data.map(p => {
const vci = p.risk_count || 0;
const absVci = Math.abs(vci);
return {
x: Math.min(500, p.file_count),
y: p.p_war,
label: p.project_nm,
isVip: vipProjectNames.has(p.project_nm),
vci: vci,
radius: Math.max(5, Math.min(25, 5 + (absVci / 10)))
};
});
const vitalityCtx = document.getElementById('forecastChart').getContext('2d');
if (window.myVitalityChart) window.myVitalityChart.destroy();
window.myVitalityChart = new Chart(vitalityCtx, {
type: 'scatter',
data: {
datasets: [{
data: scatterData,
backgroundColor: (ctx) => {
const p = ctx.raw;
if (!p) return '#94a3b8';
if (p.x >= 250 && p.y >= 50) return '#1E5149';
if (p.x < 250 && p.y >= 50) return '#22c55e';
if (p.x < 250 && p.y < 50) return '#94a3b8';
return '#ef4444';
},
pointRadius: (ctx) => ctx.raw ? ctx.raw.radius : 5,
hoverRadius: (ctx) => (ctx.raw ? ctx.raw.radius : 5) + 3
}]
},
options: {
responsive: true,
maintainAspectRatio: false,
layout: { padding: { top: 30, right: 45, left: 10, bottom: 10 } },
scales: {
x: {
type: 'linear', min: 0, max: 500,
title: { display: true, text: '자산 규모 (파일 수)', font: { size: 11, weight: '700' } },
grid: { display: false }
},
y: {
min: 0, max: 100,
title: { display: true, text: '운영 활력 (AVI %)', font: { size: 11, weight: '700' } },
grid: { display: false }
}
},
plugins: {
legend: { display: false },
datalabels: {
backgroundColor: 'rgba(255, 255, 255, 0.8)',
borderRadius: 4, padding: 4,
font: { size: 10, weight: '800' },
formatter: (v) => v ? v.label : '',
display: (ctx) => ctx.raw && ctx.raw.isVip,
clip: false
},
tooltip: {
callbacks: {
label: (ctx) => ` [${ctx.raw.label}] AVI: ${ctx.raw.y.toFixed(1)}% | VCI: ${ctx.raw.vci.toFixed(1)}`
}
}
}
},
plugins: [{
id: 'quadrants',
beforeDraw: (chart) => {
const { ctx, chartArea: { left, top, right, bottom }, scales: { x, y } } = chart;
const midX = x.getPixelForValue(250);
const midY = y.getPixelForValue(50);
ctx.save();
ctx.fillStyle = 'rgba(34, 197, 94, 0.03)'; ctx.fillRect(left, top, midX - left, midY - top);
ctx.fillStyle = 'rgba(30, 81, 73, 0.03)'; ctx.fillRect(midX, top, right - midX, midY - top);
ctx.fillStyle = 'rgba(148, 163, 184, 0.03)'; ctx.fillRect(left, midY, midX - left, bottom - midY);
ctx.fillStyle = 'rgba(239, 68, 68, 0.05)'; ctx.fillRect(midX, midY, right - midX, bottom - midY);
ctx.lineWidth = 2; ctx.strokeStyle = 'rgba(0,0,0,0.1)'; ctx.beginPath();
ctx.moveTo(midX, top); ctx.lineTo(midX, bottom); ctx.moveTo(left, midY); ctx.lineTo(right, midY); ctx.stroke();
ctx.font = 'bold 12px Pretendard'; ctx.textAlign = 'center'; ctx.fillStyle = 'rgba(0,0,0,0.2)';
ctx.fillText('활력 양호', (left + midX) / 2, (top + midY) / 2);
ctx.fillText('핵심 가치', (midX + right) / 2, (top + midY) / 2);
ctx.fillText('정체/소규모', (left + midX) / 2, (midY + bottom) / 2);
ctx.fillStyle = 'rgba(239, 68, 68, 0.4)'; ctx.fillText('자산 손실 위험', (midX + right) / 2, (midY + bottom) / 2);
ctx.restore();
}
}]
});
} catch (err) { console.error("전략 매트릭스 에러:", err); }
}
function renderVitalityLeaderboard(data) {
const container = document.getElementById('p-war-table-container');
if (!container) return;
const sortedData = [...data].sort((a, b) => a.p_war - b.p_war);
container.innerHTML = `
<div class="table-scroll-wrapper">
<table class="data-table p-war-table">
<thead>
<tr>
<th style="width: 250px;">프로젝트명</th>
<th>파일 수</th>
<th>정체 일수</th>
<th>상태 판정</th>
<th style="text-align:right;">가치 기여 (VCI) <button class="btn-help" onclick="event.stopPropagation(); openAnalysisModal('vci')">?</button></th>
<th>운영 활력 (AVI) <button class="btn-help" onclick="event.stopPropagation(); openAnalysisModal('avi')">?</button></th>
<th style="text-align:center;">업무 집중도 <button class="btn-help" onclick="event.stopPropagation(); openAnalysisModal('focus')">?</button></th>
<th>운영 일관성 (OCI) <button class="btn-help" onclick="event.stopPropagation(); openAnalysisModal('oci')">?</button></th>
</tr>
</thead>
<tbody>
${sortedData.map((p, idx) => {
const status = getStatusInfo(p.p_war, p.is_auto_delete);
const avi = p.p_war;
const vci = p.risk_count;
const oci = p.oci_score || 0;
const rowId = `project-${idx}`;
const grade = getVciGrade(vci);
let rhythmLabel = oci >= 80 ? "정기적" : oci >= 50 ? "안정적" : oci >= 20 ? "간헐적" : "불규칙";
let rhythmColor = oci >= 80 ? "#059669" : oci >= 50 ? "#1e5149" : oci >= 20 ? "#f59e0b" : "#dc2626";
// 존재 신뢰도 패널티 (ECV) 상세 설명 복구
let ecvText = "100% (데이터 실체 검증)";
let ecvClass = "highlight-val";
let ecvDesc = `현재 ${p.file_count}개의 유효 성과물이 확인됩니다. 시스템적으로 실체가 완벽히 존재하는 상태입니다.`;
if (p.file_count === 0) {
ecvText = "5% (유령 프로젝트 판명)";
ecvClass = "highlight-penalty";
ecvDesc = "데이터가 전무하여 프로젝트의 디지털 실체가 없습니다. 모든 분석에서 최하위 패널티가 적용됩니다.";
} else if (p.file_count < 10) {
ecvText = "40% (형식적 껍데기 판명)";
ecvClass = "highlight-penalty";
ecvDesc = "최소 수준의 문서만 존재하며, 실질적인 운영 가치를 인정하기 어려운 소규모 상태입니다.";
}
// 활동 품질 텍스트 복구
const qualityLabel = p.log_quality >= 1.0 ? '성과물 중심의 <b>실무 활동</b>' : p.log_quality >= 0.7 ? '구조 관리를 위한 <b>시스템 활동</b>' : '단순 행정 기반의 <b>형식 활동</b>';
const qualityDetail = p.log_quality >= 1.0 ? '최근 로그에서 파일 업로드/수정 등 가치 증분 활동이 명확히 포착되었습니다.' : p.log_quality >= 0.7 ? '폴더 생성/이동 등 구조적 관리는 이뤄지고 있으나, 직접적 결과물 생산은 부족합니다.' : '메일 확인, 권한 변경 등 시스템 유지성 활동 위주로 파악되어 품질 가중치가 낮게 적용되었습니다.';
return `
<tr class="project-row ${status.key === 'danger' ? 'row-danger' : status.key === 'warning' ? 'row-warning' : ''}" onclick="toggleProjectDetail('${rowId}')">
<td class="font-bold">${p.project_nm}</td>
<td>${p.file_count.toLocaleString()}</td>
<td>${p.days_stagnant}</td>
<td><span class="${status.class}">${status.label}</span></td>
<td style="text-align:right; font-weight:800; color:${vci >= 0 ? '#059669' : '#dc2626'};">
${vci > 0 ? '+' : ''}${vci.toFixed(1)}
</td>
<td class="p-war-value ${avi >= 70 ? 'text-plus' : 'text-minus'}">${avi.toFixed(1)}%</td>
<td style="text-align:center;">
<div style="display:flex; flex-direction:column; align-items:center; gap:4px;">
<span style="font-weight:800; font-size:12px; color:${p.work_effort >= 70 ? '#059669' : p.work_effort <= 30 ? '#dc2626' : '#6366f1'};">${p.work_effort}%</span>
<div style="width:40px; height:4px; background:#f1f5f9; border-radius:2px; overflow:hidden;">
<div style="width:${p.work_effort}%; height:100%; background:${p.work_effort >= 70 ? '#059669' : p.work_effort <= 30 ? '#dc2626' : '#6366f1'};"></div>
</div>
</div>
</td>
<td style="text-align:center;">
<div style="display:flex; align-items:center; justify-content:center; gap:8px;">
<span style="font-weight:800; font-size:13px; color:${rhythmColor};">${oci}%</span>
<span style="font-size:10px; padding:2px 6px; border-radius:10px; background:${rhythmColor}15; color:${rhythmColor}; border:1px solid ${rhythmColor}30;">${rhythmLabel}</span>
</div>
</td>
</tr>
<tr id="detail-${rowId}" class="detail-row">
<td colspan="8">
<div class="detail-container">
<div class="formula-explanation-card">
<div class="formula-header"> AI 위험 적응형 모델(AAS) 기반 인과관계 분석</div>
<div style="display: flex; gap: 20px; margin-bottom: 20px;">
<div class="work-effort-section" style="flex: 1; margin-bottom: 0;">
<div class="work-effort-header">
<span style="font-size: 13px; font-weight: 800; color: #1e5149;">📊 실질 업무 집중도 (Job Focus)</span>
<span style="font-size: 14px; font-weight: 800; color: ${p.work_effort >= 70 ? '#059669' : p.work_effort <= 30 ? '#dc2626' : '#6366f1'};">${p.work_effort}%</span>
</div>
<div class="work-effort-bar-bg"><div style="width: ${p.work_effort}%; height: 100%; background: ${p.work_effort >= 70 ? '#059669' : p.work_effort <= 30 ? '#dc2626' : '#6366f1'};"></div></div>
<div style="font-size: 11px; color: #64748b; line-height: 1.5;">최근 30 수집 이력 단순 로그 갱신이 아닌 <b>실제 성과물의 변동</b> . '' .</div>
</div>
<div style="flex: 1; background: #f8fafc; border-radius: 8px; padding: 16px; border: 1px solid #e2e8f0; display: flex; align-items: center; gap: 15px;">
<div style="text-align: center;">
<div style="font-size: 10px; color: #64748b; font-weight: 700; margin-bottom: 2px;">VCI GRADE</div>
<div class="grade-badge ${grade.class}" style="padding: 4px 12px; border-radius: 6px; font-weight: 900; font-size: 14px; display: inline-block;">${grade.label}</div>
</div>
<div style="font-size: 12px; color: #475569; line-height: 1.4; font-weight: 600;">${grade.desc}</div>
</div>
</div>
<div class="formula-steps-grid">
<div class="formula-step">
<div class="step-num">1</div>
<div class="step-content">
<div class="step-title">동적 위험 계수(λ) 산출</div>
<div style="font-size:11px; color:#64748b; margin-bottom:5px;">프로젝트 규모가 클수록 정보 망실 시의 충격을 반영하여 데이터의 하락 속도가 가속됩니다. 현재 <b>λ=${p.ai_lambda.toFixed(4)}</b> .</div>
<div class="math-logic">Dynamic λ = <span class="highlight-var">${p.ai_lambda.toFixed(4)}</span></div>
</div>
</div>
<div class="formula-step">
<div class="step-num">4</div>
<div class="step-content">
<div class="step-title">활동 품질 검증 (Quality)</div>
<div class="step-desc" style="font-size:11px; margin-bottom:5px;">최근 로그 분석 결과 <b>${qualityLabel}</b> . ${qualityDetail}</div>
<div class="math-logic">Quality Factor = <span class="${p.log_quality < 0.7 ? 'highlight-penalty' : 'highlight-val'}">${(p.log_quality * 100).toFixed(0)}%</span></div>
</div>
</div>
<div class="formula-step">
<div class="step-num">2</div>
<div class="step-content">
<div class="step-title">방치 시간 감쇄 적용</div>
<div style="font-size:11px; color:#64748b; margin-bottom:5px;">마지막 유효 활동 이후 <b>${p.days_stagnant}</b> .</div>
<div class="math-logic">Decay Result = <span class="highlight-val">${((avi / (p.file_count === 0 ? 0.05 : p.file_count < 10 ? 0.4 : 1) / p.log_quality) || 0).toFixed(1)}%</span></div>
</div>
</div>
<div class="formula-step">
<div class="step-num">3</div>
<div class="step-content">
<div class="step-title">존재 진정성 (ECV)</div>
<div style="font-size:11px; color:#64748b; margin-bottom:5px;">${ecvDesc} 파일 자체가 분석의 데이터 진정성을 보정하는 핵심 팩터로 작용합니다.</div>
<div class="math-logic">Entity Factor = <span class="${ecvClass}">${ecvText}</span></div>
</div>
</div>
</div>
<div style="margin-top: 20px; padding-top: 15px; border-top: 2px solid #1e5149; text-align: right; display: flex; justify-content: space-between; align-items: center;">
<div style="text-align: left; max-width: 70%;">
<div style="display: flex; align-items: center; gap: 8px; margin-bottom: 4px;">
<div style="font-size: 13px; font-weight: 800; color: ${vci >= 0 ? '#059669' : '#dc2626'};">
가치 기여도 (VCI) 진단: ${vci >= 0 ? '+' : ''}${vci.toFixed(2)}
</div>
<div style="font-size: 10px; color: #94a3b8; background: #f8fafc; padding: 2px 6px; border-radius: 4px; border: 1px solid #e2e8f0;">
조직 평균 자산: ${p.avg_info.avg_files}
</div>
</div>
<div style="font-size: 11px; color: #64748b; line-height: 1.5;">
현재 프로젝트는 <b>포트폴리오 평균 관리 수준</b> 대비 <b>${Math.abs(vci / Math.max(0.2, p.file_count / p.avg_info.avg_files)).toFixed(1)}%p ${vci >= 0 ? '상회' : '하회'}</b> ,
<b>${p.file_count}</b>의 자산 규모에 따른 <b>${Math.max(0.2, p.file_count / p.avg_info.avg_files).toFixed(2)}</b> .
이는 시스템 전체 관점에서 <b>${vci >= 0 ? '순자산 가치를 증대' : '잠재적 기회비용을 손실'}</b> .
</div>
</div>
<div>
<span style="font-size: 13px; color: #64748b; font-weight: 700;">최종 AVI: </span>
<span style="color: #1e5149; font-size: 22px; font-weight: 900;">${avi.toFixed(1)}%</span>
</div>
</div>
</div>
</div>
</td>
</tr>`;
}).join('')}
</tbody>
</table>
</div>`;
}
function toggleProjectDetail(rowId) {
const container = document.querySelector('.table-scroll-wrapper');
const mainRow = document.querySelector(`tr[onclick*="toggleProjectDetail('${rowId}')"]`);
const detailRow = document.getElementById(`detail-${rowId}`);
if (detailRow && container) {
if (!detailRow.classList.contains('active')) {
document.querySelectorAll('.detail-row').forEach(row => row.classList.remove('active'));
detailRow.classList.add('active');
// 정밀 스크롤 이동 로직 복구
setTimeout(() => {
const headerH = container.querySelector('thead').offsetHeight || 45;
const targetScrollTop = mainRow.offsetTop - headerH;
container.scrollTo({ top: targetScrollTop, behavior: 'smooth' });
}, 100);
} else {
detailRow.classList.remove('active');
}
}
}
function openProjectListModal(label, projects) {
const modal = document.getElementById('analysisModal');
const title = document.getElementById('modalTitle');
const body = document.getElementById('modalBody');
title.innerText = `[${label}] 프로젝트 리스트 (${projects.length}건)`;
body.innerHTML = `
<div class="table-scroll-wrapper" style="max-height: 400px;">
<table class="data-table">
<thead><tr><th>프로젝트명</th><th>관리자</th><th>정체일</th><th>AVI</th></tr></thead>
<tbody>${projects.map(p => `<tr><td class="font-bold">${p.project_nm}</td><td>${p.master || '-'}</td><td>${p.days_stagnant}일</td><td style="font-weight:700; color:#1e5149;">${p.p_war.toFixed(1)}%</td></tr>`).join('')}</tbody>
</table>
</div>
<div class="modal-footer"><button class="btn btn-primary" onclick="closeAnalysisModal()">닫기</button></div>`;
modal.style.display = 'flex';
}
function openAnalysisModal(type) {
const modal = document.getElementById('analysisModal');
const title = document.getElementById('modalTitle');
const body = document.getElementById('modalBody');
if (type === 'avi') {
title.innerText = '운영 활력 지수 (AVI) 분석 가이드';
body.innerHTML = `
<div class="formula-box" style="margin-bottom:15px; background:#f1f5f9; padding:12px; border-radius:8px; font-family:'Consolas', monospace; font-weight:700; text-align:center;">
AVI = exp(-λ × Stagnant Days) × Quality × 100
</div>
<div style="margin-bottom:15px; font-size:13px; line-height:1.6; color:#334155;">
<p style="margin-bottom:8px;"><b>운영 활력 지수(AVI)</b>는 프로젝트가 현재 얼마나 건강하게 가동되고 있는지를 나타내는 '디지털 생존 지표'입니다.</p>
<ul style="padding-left:18px; margin-bottom:10px;">
<li style="margin-bottom:4px;"><b>지수 감쇄(Exponential Decay)</b>: 마지막 활동 이후 정체 기간이 길어질수록 자산의 최신성과 가치는 기하급수적으로 하락합니다.</li>
<li style="margin-bottom:4px;"><b>위험 가속 계수(λ)</b>: 자산 규모(파일 수)가 클수록 관리 부재 시의 정보 망실 위험이 크다고 판단하여, 더 가파른 감쇄 곡선을 적용합니다.</li>
<li style="margin-bottom:4px;"><b>활동 품질(Quality Factor)</b>: 단순 행정 로그(권한 변경 등)보다 실무 성과물(파일 업로드 등)이 발생했을 때 지수 복원력을 더 높게 부여합니다.</li>
</ul>
<p style="margin:0; font-weight:700; color:#1e5149;">※ 70% 미만 하락 시, 해당 프로젝트의 데이터 노후화 및 관리 방치 위험이 시작된 것으로 간주합니다.</p>
</div>
<table class="data-table" style="font-size:12px; width:100%;">
<thead><tr style="background:#f8fafc;"><th>지수 (AVI)</th><th>등급</th><th>운영 상태</th></tr></thead>
<tbody>
<tr><td class="font-bold">90%↑</td><td style="font-weight:900; color:#059669;">Live</td><td>실시간 성과물이 도출되는 최상급 가동 상태</td></tr>
<tr><td class="font-bold">70~90%</td><td style="font-weight:900; color:#1e5149;">Stable</td><td>주기적 업데이트가 이뤄지는 표준 안정 상태</td></tr>
<tr><td class="font-bold">30~70%</td><td style="font-weight:900; color:#f59e0b;">Idle</td><td>활력이 저하되어 관리가 필요한 정체 상태</td></tr>
<tr><td class="font-bold">10~30%</td><td style="font-weight:900; color:#dc2626;">Risk</td><td>데이터 노후화 및 자산 가치 소멸 위험 상태</td></tr>
<tr><td class="font-bold">10%↓</td><td style="font-weight:900; color:#64748b;">Frozen</td><td>운영이 중단된 사망/방치 상태</td></tr>
</tbody>
</table>
<div class="modal-footer"><button class="btn btn-primary" onclick="closeAnalysisModal()">닫기</button></div>`;
} else if (type === 'vci') {
title.innerText = '자산 가치 기여도 (VCI) 분석 가이드';
body.innerHTML = `
<p style="margin-bottom:12px; font-size:13.5px;">VCI는 야구의 <b>WAR(Wins Above Replacement)</b> 개념을 도입하여, 개별 프로젝트가 전체 포트폴리오 평균 대비 얼마나 조직의 가치에 기여하는지 산출한 지표입니다.</p>
<div class="formula-box" style="margin-bottom:15px; background:#f1f5f9; padding:12px; border-radius:8px; font-family:'Consolas', monospace; font-weight:700; text-align:center;">
VCI = (현재 AVI - 전체 평균 AVI) × (파일 규모 가중치)
</div>
<p style="margin-bottom:15px; font-size:13px; line-height:1.6;">
• <b>0.0 (평균)</b>: 우리 조직의 평균적인 관리 수준을 유지 중인 상태<br>
• <b>(+) 점수</b>: 평균 이상의 활력으로 조직의 디지털 자산 가치를 증대시킴<br>
• <b>(-) 점수</b>: 평균 이하의 방치로 인해 잠재적 기회비용 손실 발생 중
</p>
<table class="data-table" style="font-size:12px; width:100%;">
<thead><tr style="background:#f8fafc;"><th>점수 (VCI)</th><th>등급</th><th>운영 의미</th></tr></thead>
<tbody>
<tr><td>+10.0↑</td><td style="font-weight:900; color:#6366f1;">Masterpiece</td><td>시스템 가치를 견인하는 최우량 핵심 자산</td></tr>
<tr><td>+2.0 ~ +10.0</td><td style="font-weight:900; color:#059669;">Blue Chip</td><td>꾸준한 활력으로 가치를 창출하는 우량 자산</td></tr>
<tr><td>-2.0 ~ +2.0</td><td style="font-weight:900; color:#475569;">Steady</td><td>평균 수준의 운영을 유지 중인 안정 자산</td></tr>
<tr><td>-10.0 ~ -2.0</td><td style="font-weight:900; color:#f59e0b;">Underperform</td><td>평균 대비 활력 부족으로 가치 하락 중인 자산</td></tr>
<tr><td>-10.0↓</td><td style="font-weight:900; color:#dc2626;">Liability</td><td>가치를 훼손 중인 고위험 방치 자산</td></tr>
</tbody>
</table>
<div class="modal-footer"><button class="btn btn-primary" onclick="closeAnalysisModal()">닫기</button></div>`;
} else if (type === 'oci') {
title.innerText = '운영 일관성 지수 (OCI) 분석 가이드';
body.innerHTML = `
<div style="background:#f0fdf4; padding:15px; border-radius:8px; margin-bottom:15px;">
<strong style="color:#166534; display:block; margin-bottom:5px;">"얼마나 꾸준하게 관리되고 있는가?"</strong>
<p style="font-size:12.5px; color:#166534; margin:0;">미래 예측이 아닌, 최근 30일간의 <b>활동 리듬</b>과 <b>관리의 규칙성</b>을 분석하여 성실도를 점수화합니다.</p>
</div>
<table class="data-table" style="font-size:12px;">
<thead><tr style="background:#f8fafc;"><th>분석 결과</th><th>일관성 등급</th><th>관리 신뢰도</th></tr></thead>
<tbody>
<tr><td style="color:#059669;">80%↑</td><td style="font-weight:900; color:#059669;">매우 우수</td><td>주 단위의 정기적 관리가 완벽히 이뤄짐</td></tr>
<tr><td style="color:#1e5149;">50~80%</td><td style="font-weight:900; color:#1e5149;">양호</td><td>간헐적 정체는 있으나 꾸준히 관리됨</td></tr>
<tr><td style="color:#f59e0b;">20~50%</td><td style="font-weight:900; color:#f59e0b;">주의</td><td>돌발적 활동 위주, 관리의 리듬이 깨짐</td></tr>
<tr><td style="color:#dc2626;">20%↓</td><td style="font-weight:900; color:#dc2626;">매우 불량</td><td>장기 정체 중이거나 관리 의지 확인 불가</td></tr>
</tbody>
</table>
<div class="modal-footer"><button class="btn btn-primary" onclick="closeAnalysisModal()">닫기</button></div>`;
} else {
title.innerText = '업무 집중도 (Job Focus) 등급 가이드';
body.innerHTML = `
<p style="font-size:13px; color:#64748b; margin-bottom:15px;">최근 수집 로그 중 단순 행정 로그를 제외하고 실질적인 성과물(파일) 변동이 포착된 비율입니다.</p>
<table class="data-table" style="font-size:12px;">
<thead><tr style="background:#f8fafc;"><th>비율 (%)</th><th>등급</th><th>활동 성격</th></tr></thead>
<tbody>
<tr><td>80%↑</td><td style="font-weight:900; color:#6366f1;">Intensive</td><td>성과물 위주의 고밀도 집중 작업</td></tr>
<tr><td>50~80%</td><td style="font-weight:900; color:#059669;">Active</td><td>성과와 관리가 균형 잡힌 원활한 실행</td></tr>
<tr><td>20~50%</td><td style="font-weight:900; color:#f59e0b;">Maintenance</td><td>설정/행정 등 단순 관리 중심의 작업</td></tr>
<tr><td>20%↓</td><td style="font-weight:900; color:#dc2626;">Surface</td><td>실체적 변화가 적은 형식적 로그 중심</td></tr>
</tbody>
</table>
<div class="modal-footer"><button class="btn btn-primary" onclick="closeAnalysisModal()">닫기</button></div>`;
}
modal.style.display = 'flex';
}
function closeAnalysisModal() { document.getElementById('analysisModal').style.display = 'none'; }

View File

@@ -1,178 +1,178 @@
function renderPWarLeaderboard(data) {
const container = document.getElementById('p-war-table-container');
if (!container) return;
const sortedData = [...data].sort((a, b) => a.p_war - b.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: 280px;">프로젝트명</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;">
활력 지수 (AVI) <button class="btn-help" onclick="event.stopPropagation(); openAnalysisModal('avi')">?</button>
</th>
<th style="position: sticky; top: 0; z-index: 10; text-align:right;">가치 기여 (VCI)</th>
<th style="position: sticky; top: 0; z-index: 10; text-align:center;">업무 집중도</th>
<th style="position: sticky; top: 0; z-index: 10;">
운영 일관성 (OCI) <button class="btn-help" onclick="event.stopPropagation(); openAnalysisModal('oci')">?</button>
</th>
</tr>
</thead>
<tbody>
${sortedData.map((p, idx) => {
const status = getStatusInfo(p.p_war, p.is_auto_delete);
const avi = p.p_war;
const vci = p.risk_count;
const oci = p.oci_score || 0;
const rowId = `project-${idx}`;
let rhythmLabel = "";
let rhythmColor = "";
if (oci >= 80) { rhythmLabel = "정기적"; rhythmColor = "#059669"; }
else if (oci >= 50) { rhythmLabel = "안정적"; rhythmColor = "#1e5149"; }
else if (oci >= 20) { rhythmLabel = "간헐적"; rhythmColor = "#f59e0b"; }
else { rhythmLabel = "불규칙"; rhythmColor = "#dc2626"; }
// 존재 신뢰도 패널티 (ECV) 텍스트 준비
let ecvText = "100% (데이터 신뢰)";
let ecvClass = "highlight-val";
let ecvDesc = "충분한 성과물이 존재합니다.";
if (p.file_count === 0) {
ecvText = "5% (유령 프로젝트)";
ecvClass = "highlight-penalty";
ecvDesc = "성과물이 전무하여 시스템 가치가 소멸되었습니다.";
} else if (p.file_count < 10) {
ecvText = "40% (소규모 껍데기)";
ecvClass = "highlight-penalty";
ecvDesc = "최소 수준의 데이터만 존재하여 가치가 낮게 평가됩니다.";
}
// 활동 품질 텍스트 준비
const qualityLabel = p.log_quality >= 1.0 ? '성과물 직결 <b>실무 활동</b>' : p.log_quality >= 0.7 ? '시스템 <b>구조적 활동</b>' : '단순 <b>행정적 활동</b>';
return `
<tr class="project-row ${status.key === 'danger' ? 'row-danger' : status.key === 'warning' ? 'row-warning' : ''}"
onclick="toggleProjectDetail('${rowId}')">
<td class="font-bold">${p.project_nm}</td>
<td>${p.file_count.toLocaleString()}개</td>
<td>${p.days_stagnant}일</td>
<td><span class="${status.class}">${status.label}</span></td>
<td class="p-war-value ${avi >= 70 ? 'text-plus' : 'text-minus'}">
${avi.toFixed(1)}%
</td>
<td style="text-align:right; font-weight:700; color:${vci >= 0 ? '#059669' : '#dc2626'};">
${vci >= 0 ? '+' : ''}${vci.toFixed(2)}
</td>
<td style="text-align:center;">
<div style="display:flex; flex-direction:column; align-items:center; gap:4px;">
<span style="font-weight:800; font-size:12px; color:${p.work_effort >= 70 ? '#059669' : p.work_effort <= 30 ? '#dc2626' : '#6366f1'};">
${p.work_effort}%
</span>
<div style="width:40px; height:4px; background:#f1f5f9; border-radius:2px; overflow:hidden;">
<div style="width:${p.work_effort}%; height:100%; background:${p.work_effort >= 70 ? '#059669' : p.work_effort <= 30 ? '#dc2626' : '#6366f1'}; transition: width 0.5s;"></div>
</div>
</div>
</td>
<td style="text-align:center;">
<div style="display:flex; align-items:center; justify-content:center; gap:8px;">
<span style="font-weight:800; font-size:13px; color:${rhythmColor};">
${oci}%
</span>
<span style="font-size:10px; padding:2px 6px; border-radius:10px; background:${rhythmColor}15; color:${rhythmColor}; border:1px solid ${rhythmColor}30;">
${rhythmLabel}
</span>
</div>
</td>
</tr>
<tr id="detail-${rowId}" class="detail-row">
<td colspan="8">
<div class="detail-container">
<div class="formula-explanation-card">
<div style="font-size: 13px; font-weight: 700; color: #6366f1; margin-bottom: 15px;">
⚙️ AI 위험 적응형 모델(AAS) 산출 시뮬레이션
</div>
<!-- 업무 집중도 분석 (상단 배치) -->
<div style="background: #f8fafc; padding: 15px; border-radius: 8px; margin-bottom: 20px; border: 1px solid #eef2f6;">
<div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 8px;">
<span style="font-size: 13px; font-weight: 800; color: #1e5149;">📊 실질 업무 집중도 분석 (Job Focus)</span>
<span style="font-size: 14px; font-weight: 800; color: ${p.work_effort >= 70 ? '#059669' : p.work_effort <= 30 ? '#dc2626' : '#6366f1'};">
집중도 ${p.work_effort}%
</span>
</div>
<div style="width: 100%; height: 6px; background: #e2e8f0; border-radius: 3px; overflow: hidden; margin-bottom: 10px;">
<div style="width: ${p.work_effort}%; height: 100%; background: ${p.work_effort >= 70 ? '#059669' : p.work_effort <= 30 ? '#dc2626' : '#6366f1'}; transition: width 0.5s;"></div>
</div>
<div style="font-size: 11.5px; color: #64748b; line-height: 1.5;">
최근 30개 수집 이력 중 단순 로그 갱신이 아닌 <b>실제 파일 수의 변동</b>이 포착된 날의 비율입니다.
현재 이 프로젝트는 <b>${p.work_effort >= 70 ? '매우 밀도 높은 실무' : p.work_effort <= 30 ? '형식적 관리 위주의 정체' : '간헐적인 성과물'}</b> 상태를 보이고 있습니다.
</div>
</div>
<!-- 수식 단계 2x2 그리드 -->
<div style="display: grid; grid-template-columns: 1fr 1fr; gap: 20px;">
<div class="formula-step">
<div class="step-num">1</div>
<div class="step-content">
<div class="step-title">동적 위험 계수(λ) 산출</div>
<div style="font-size:11px; color:#64748b; margin-bottom:5px;">자산 규모(${p.file_count}개) 및 부서 위험도를 합산한 하락 속도입니다.</div>
<div class="math-logic">λ = <span class="highlight-var">${p.ai_lambda.toFixed(4)}</span></div>
</div>
</div>
<div class="formula-step">
<div class="step-num">4</div>
<div class="step-content">
<div class="step-title">활동 품질 검증 (Quality)</div>
<div class="step-desc" style="font-size:11px; margin-bottom:5px;">
최근 로그 분석 결과 ${qualityLabel}으로 판명되었습니다.
</div>
<div class="math-logic">Factor = <span class="${p.log_quality < 0.7 ? 'highlight-penalty' : 'highlight-val'}">${(p.log_quality * 100).toFixed(0)}%</span></div>
</div>
</div>
<div class="formula-step">
<div class="step-num">2</div>
<div class="step-content">
<div class="step-title">방치 시간 감쇄 적용</div>
<div style="font-size:11px; color:#64748b; margin-bottom:5px;">${p.days_stagnant}일간의 정체로 인한 가치 보존율입니다.</div>
<div class="math-logic">Result = <span class="highlight-val">${((avi / (p.file_count === 0 ? 0.05 : p.file_count < 10 ? 0.4 : 1) / p.log_quality) || 0).toFixed(1)}%</span></div>
</div>
</div>
<div class="formula-step">
<div class="step-num">3</div>
<div class="step-content">
<div class="step-title">존재 진정성 (ECV)</div>
<div style="font-size:11px; color:#64748b; margin-bottom:5px;">${ecvDesc}</div>
<div class="math-logic">Factor = <span class="${ecvClass}">${ecvText}</span></div>
</div>
</div>
</div>
<div style="margin-top: 20px; padding-top: 15px; border-top: 2px solid #1e5149; text-align: right; display: flex; justify-content: space-between; align-items: center;">
<div style="text-align: left;">
<div style="font-size: 12px; font-weight: 700; color: ${vci >= 0 ? '#059669' : '#dc2626'};">
가치 기여도 (VCI): ${vci >= 0 ? '+' : ''}${vci.toFixed(2)}
</div>
<div style="font-size: 10px; color: #94a3b8;">* AVI 70% 대비 프로젝트의 실질적 자산 하중 반영</div>
</div>
<div>
<span style="font-size: 13px; color: #64748b; font-weight: 700;">최종 AVI: </span>
<span style="color: #1e5149; font-size: 22px; font-weight: 900;">${avi.toFixed(1)}%</span>
</div>
</div>
</div>
</div>
</td>
</tr>
`;
}).join('')}
</tbody>
</table>
</div>
`;
}
function renderPWarLeaderboard(data) {
const container = document.getElementById('p-war-table-container');
if (!container) return;
const sortedData = [...data].sort((a, b) => a.p_war - b.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: 280px;">프로젝트명</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;">
활력 지수 (AVI) <button class="btn-help" onclick="event.stopPropagation(); openAnalysisModal('avi')">?</button>
</th>
<th style="position: sticky; top: 0; z-index: 10; text-align:right;">가치 기여 (VCI)</th>
<th style="position: sticky; top: 0; z-index: 10; text-align:center;">업무 집중도</th>
<th style="position: sticky; top: 0; z-index: 10;">
운영 일관성 (OCI) <button class="btn-help" onclick="event.stopPropagation(); openAnalysisModal('oci')">?</button>
</th>
</tr>
</thead>
<tbody>
${sortedData.map((p, idx) => {
const status = getStatusInfo(p.p_war, p.is_auto_delete);
const avi = p.p_war;
const vci = p.risk_count;
const oci = p.oci_score || 0;
const rowId = `project-${idx}`;
let rhythmLabel = "";
let rhythmColor = "";
if (oci >= 80) { rhythmLabel = "정기적"; rhythmColor = "#059669"; }
else if (oci >= 50) { rhythmLabel = "안정적"; rhythmColor = "#1e5149"; }
else if (oci >= 20) { rhythmLabel = "간헐적"; rhythmColor = "#f59e0b"; }
else { rhythmLabel = "불규칙"; rhythmColor = "#dc2626"; }
// 존재 신뢰도 패널티 (ECV) 텍스트 준비
let ecvText = "100% (데이터 신뢰)";
let ecvClass = "highlight-val";
let ecvDesc = "충분한 성과물이 존재합니다.";
if (p.file_count === 0) {
ecvText = "5% (유령 프로젝트)";
ecvClass = "highlight-penalty";
ecvDesc = "성과물이 전무하여 시스템 가치가 소멸되었습니다.";
} else if (p.file_count < 10) {
ecvText = "40% (소규모 껍데기)";
ecvClass = "highlight-penalty";
ecvDesc = "최소 수준의 데이터만 존재하여 가치가 낮게 평가됩니다.";
}
// 활동 품질 텍스트 준비
const qualityLabel = p.log_quality >= 1.0 ? '성과물 직결 <b>실무 활동</b>' : p.log_quality >= 0.7 ? '시스템 <b>구조적 활동</b>' : '단순 <b>행정적 활동</b>';
return `
<tr class="project-row ${status.key === 'danger' ? 'row-danger' : status.key === 'warning' ? 'row-warning' : ''}"
onclick="toggleProjectDetail('${rowId}')">
<td class="font-bold">${p.project_nm}</td>
<td>${p.file_count.toLocaleString()}개</td>
<td>${p.days_stagnant}일</td>
<td><span class="${status.class}">${status.label}</span></td>
<td class="p-war-value ${avi >= 70 ? 'text-plus' : 'text-minus'}">
${avi.toFixed(1)}%
</td>
<td style="text-align:right; font-weight:700; color:${vci >= 0 ? '#059669' : '#dc2626'};">
${vci >= 0 ? '+' : ''}${vci.toFixed(2)}
</td>
<td style="text-align:center;">
<div style="display:flex; flex-direction:column; align-items:center; gap:4px;">
<span style="font-weight:800; font-size:12px; color:${p.work_effort >= 70 ? '#059669' : p.work_effort <= 30 ? '#dc2626' : '#6366f1'};">
${p.work_effort}%
</span>
<div style="width:40px; height:4px; background:#f1f5f9; border-radius:2px; overflow:hidden;">
<div style="width:${p.work_effort}%; height:100%; background:${p.work_effort >= 70 ? '#059669' : p.work_effort <= 30 ? '#dc2626' : '#6366f1'}; transition: width 0.5s;"></div>
</div>
</div>
</td>
<td style="text-align:center;">
<div style="display:flex; align-items:center; justify-content:center; gap:8px;">
<span style="font-weight:800; font-size:13px; color:${rhythmColor};">
${oci}%
</span>
<span style="font-size:10px; padding:2px 6px; border-radius:10px; background:${rhythmColor}15; color:${rhythmColor}; border:1px solid ${rhythmColor}30;">
${rhythmLabel}
</span>
</div>
</td>
</tr>
<tr id="detail-${rowId}" class="detail-row">
<td colspan="8">
<div class="detail-container">
<div class="formula-explanation-card">
<div style="font-size: 13px; font-weight: 700; color: #6366f1; margin-bottom: 15px;">
⚙️ AI 위험 적응형 모델(AAS) 산출 시뮬레이션
</div>
<!-- 업무 집중도 분석 (상단 배치) -->
<div style="background: #f8fafc; padding: 15px; border-radius: 8px; margin-bottom: 20px; border: 1px solid #eef2f6;">
<div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 8px;">
<span style="font-size: 13px; font-weight: 800; color: #1e5149;">📊 실질 업무 집중도 분석 (Job Focus)</span>
<span style="font-size: 14px; font-weight: 800; color: ${p.work_effort >= 70 ? '#059669' : p.work_effort <= 30 ? '#dc2626' : '#6366f1'};">
집중도 ${p.work_effort}%
</span>
</div>
<div style="width: 100%; height: 6px; background: #e2e8f0; border-radius: 3px; overflow: hidden; margin-bottom: 10px;">
<div style="width: ${p.work_effort}%; height: 100%; background: ${p.work_effort >= 70 ? '#059669' : p.work_effort <= 30 ? '#dc2626' : '#6366f1'}; transition: width 0.5s;"></div>
</div>
<div style="font-size: 11.5px; color: #64748b; line-height: 1.5;">
최근 30개 수집 이력 중 단순 로그 갱신이 아닌 <b>실제 파일 수의 변동</b>이 포착된 날의 비율입니다.
현재 이 프로젝트는 <b>${p.work_effort >= 70 ? '매우 밀도 높은 실무' : p.work_effort <= 30 ? '형식적 관리 위주의 정체' : '간헐적인 성과물'}</b> 상태를 보이고 있습니다.
</div>
</div>
<!-- 수식 단계 2x2 그리드 -->
<div style="display: grid; grid-template-columns: 1fr 1fr; gap: 20px;">
<div class="formula-step">
<div class="step-num">1</div>
<div class="step-content">
<div class="step-title">동적 위험 계수(λ) 산출</div>
<div style="font-size:11px; color:#64748b; margin-bottom:5px;">자산 규모(${p.file_count}개) 및 부서 위험도를 합산한 하락 속도입니다.</div>
<div class="math-logic">λ = <span class="highlight-var">${p.ai_lambda.toFixed(4)}</span></div>
</div>
</div>
<div class="formula-step">
<div class="step-num">4</div>
<div class="step-content">
<div class="step-title">활동 품질 검증 (Quality)</div>
<div class="step-desc" style="font-size:11px; margin-bottom:5px;">
최근 로그 분석 결과 ${qualityLabel}으로 판명되었습니다.
</div>
<div class="math-logic">Factor = <span class="${p.log_quality < 0.7 ? 'highlight-penalty' : 'highlight-val'}">${(p.log_quality * 100).toFixed(0)}%</span></div>
</div>
</div>
<div class="formula-step">
<div class="step-num">2</div>
<div class="step-content">
<div class="step-title">방치 시간 감쇄 적용</div>
<div style="font-size:11px; color:#64748b; margin-bottom:5px;">${p.days_stagnant}일간의 정체로 인한 가치 보존율입니다.</div>
<div class="math-logic">Result = <span class="highlight-val">${((avi / (p.file_count === 0 ? 0.05 : p.file_count < 10 ? 0.4 : 1) / p.log_quality) || 0).toFixed(1)}%</span></div>
</div>
</div>
<div class="formula-step">
<div class="step-num">3</div>
<div class="step-content">
<div class="step-title">존재 진정성 (ECV)</div>
<div style="font-size:11px; color:#64748b; margin-bottom:5px;">${ecvDesc}</div>
<div class="math-logic">Factor = <span class="${ecvClass}">${ecvText}</span></div>
</div>
</div>
</div>
<div style="margin-top: 20px; padding-top: 15px; border-top: 2px solid #1e5149; text-align: right; display: flex; justify-content: space-between; align-items: center;">
<div style="text-align: left;">
<div style="font-size: 12px; font-weight: 700; color: ${vci >= 0 ? '#059669' : '#dc2626'};">
가치 기여도 (VCI): ${vci >= 0 ? '+' : ''}${vci.toFixed(2)}
</div>
<div style="font-size: 10px; color: #94a3b8;">* AVI 70% 대비 프로젝트의 실질적 자산 하중 반영</div>
</div>
<div>
<span style="font-size: 13px; color: #64748b; font-weight: 700;">최종 AVI: </span>
<span style="color: #1e5149; font-size: 22px; font-weight: 900;">${avi.toFixed(1)}%</span>
</div>
</div>
</div>
</div>
</td>
</tr>
`;
}).join('')}
</tbody>
</table>
</div>
`;
}

485
js/analysis_test.js Normal file
View File

@@ -0,0 +1,485 @@
/**
* Project Master Analysis JS (TEST VERSION)
* AVI (Activity Vitality Index) & VCI (Value Contribution Index) 분석 엔진
* OCI (Operational Consistency Index) 통합 버전
*/
// Chart.js 플러그인 전역 등록
if (typeof ChartDataLabels !== 'undefined') {
Chart.register(ChartDataLabels);
}
document.addEventListener('DOMContentLoaded', () => {
console.log("Business Analysis Engine (TEST) initialized...");
loadProjectAnalysisData();
});
async function loadProjectAnalysisData() {
try {
const response = await fetch('/api/analysis/p-war');
const data = await response.json();
if (data.error) throw new Error(data.error);
renderVitalityLeaderboard(data);
renderValueCharts(data);
if (data.length > 0 && data[0].avg_info) {
const avg = data[0].avg_info;
const infoEl = document.getElementById('avg-system-info');
if (infoEl) infoEl.textContent = `* 시스템 종합 운영 활력(AVI): ${avg.avg_risk}% (평균 관리 수준) [TEST MODE]`;
}
} catch (e) {
console.error("분석 데이터 로딩 실패:", e);
}
}
function getStatusInfo(avi, isAutoDelete) {
if (isAutoDelete || avi < 10) return { label: '사망', class: 'badge-system', key: 'dead' };
if (avi < 30) return { label: '위험 노출', class: 'badge-danger', key: 'danger' };
if (avi < 70) return { label: '관리 주의', class: 'badge-warning', key: 'warning' };
return { label: '정상 운영', class: 'badge-active', key: 'active' };
}
function getVciGrade(vci) {
if (vci >= 10) return { label: 'Masterpiece', class: 'grade-mvp', desc: '시스템 가치를 견인하는 최우량 핵심 자산' };
if (vci >= 2) return { label: 'Blue Chip', class: 'grade-allstar', desc: '꾸준한 활력으로 가치를 창출하는 우량 자산' };
if (vci >= -2) return { label: 'Steady', class: 'grade-starter', desc: '표준 수준의 운영을 유지 중인 안정 자산' };
if (vci >= -10) return { label: 'Underperform', class: 'grade-bench', desc: '규모 대비 활력 부족으로 가치가 하락 중인 자산' };
return { label: 'Liability', class: 'grade-out', desc: '가치를 훼손 중인 고위험 방치 자산' };
}
function renderValueCharts(data) {
if (!data || data.length === 0) return;
// 1. 운영 활력 분포 (Doughnut)
try {
const stats = { active: [], warning: [], danger: [], dead: [] };
data.forEach(p => {
const status = getStatusInfo(p.p_war, p.is_auto_delete);
stats[status.key].push(p);
});
const statusCtx = document.getElementById('statusChart').getContext('2d');
if (window.myStatusChart) window.myStatusChart.destroy();
window.myStatusChart = new Chart(statusCtx, {
type: 'doughnut',
data: {
labels: ['정상 운영', '관리 주의', '위험 노출', '사망'],
datasets: [{
data: [stats.active.length, stats.warning.length, stats.danger.length, stats.dead.length],
backgroundColor: ['#1E5149', '#22c55e', '#f59e0b', '#ef4444'],
borderWidth: 0
}]
},
options: {
responsive: true,
maintainAspectRatio: false,
layout: { padding: 15 },
plugins: {
legend: { position: 'right', labels: { boxWidth: 10, font: { size: 11, weight: '700' }, usePointStyle: true } },
datalabels: { display: false }
},
cutout: '65%',
onClick: (e, elements) => {
if (elements.length > 0) {
const idx = elements[0].index;
openProjectListModal(['정상 운영', '관리 주의', '위험 노출', '사망'][idx], stats[['active', 'warning', 'danger', 'dead'][idx]]);
}
}
}
});
} catch (err) { console.error("도넛 차트 에러:", err); }
// 2. 전략적 자산 매트릭스 (Scatter) - 정밀 복구
try {
const sortedByAVI = [...data].sort((a, b) => b.p_war - a.p_war);
const top5Ids = sortedByAVI.slice(0, 5).map(p => p.project_nm);
const bottom5Ids = sortedByAVI.slice(-5).map(p => p.project_nm);
const largeProjects = data.filter(p => p.file_count > 450).map(p => p.project_nm);
const vipProjectNames = new Set([...top5Ids, ...bottom5Ids, ...largeProjects]);
const scatterData = data.map(p => {
const vci = p.risk_count || 0;
const absVci = Math.abs(vci);
return {
x: Math.min(500, p.file_count),
y: p.p_war,
label: p.project_nm,
isVip: vipProjectNames.has(p.project_nm),
vci: vci,
radius: Math.max(5, Math.min(25, 5 + (absVci / 10)))
};
});
const vitalityCtx = document.getElementById('forecastChart').getContext('2d');
if (window.myVitalityChart) window.myVitalityChart.destroy();
window.myVitalityChart = new Chart(vitalityCtx, {
type: 'scatter',
data: {
datasets: [{
data: scatterData,
backgroundColor: (ctx) => {
const p = ctx.raw;
if (!p) return '#94a3b8';
if (p.x >= 250 && p.y >= 50) return '#1E5149';
if (p.x < 250 && p.y >= 50) return '#22c55e';
if (p.x < 250 && p.y < 50) return '#94a3b8';
return '#ef4444';
},
pointRadius: (ctx) => ctx.raw ? ctx.raw.radius : 5,
hoverRadius: (ctx) => (ctx.raw ? ctx.raw.radius : 5) + 3
}]
},
options: {
responsive: true,
maintainAspectRatio: false,
layout: { padding: { top: 30, right: 45, left: 10, bottom: 10 } },
scales: {
x: {
type: 'linear', min: 0, max: 500,
title: { display: true, text: '자산 규모 (파일 수)', font: { size: 11, weight: '700' } },
grid: { display: false }
},
y: {
min: 0, max: 100,
title: { display: true, text: '운영 활력 (AVI %)', font: { size: 11, weight: '700' } },
grid: { display: false }
}
},
plugins: {
legend: { display: false },
datalabels: {
backgroundColor: 'rgba(255, 255, 255, 0.8)',
borderRadius: 4, padding: 4,
font: { size: 10, weight: '800' },
formatter: (v) => v ? v.label : '',
display: (ctx) => ctx.raw && ctx.raw.isVip,
clip: false
},
tooltip: {
callbacks: {
label: (ctx) => ` [${ctx.raw.label}] AVI: ${ctx.raw.y.toFixed(1)}% | VCI: ${ctx.raw.vci.toFixed(1)}`
}
}
}
},
plugins: [{
id: 'quadrants',
beforeDraw: (chart) => {
const { ctx, chartArea: { left, top, right, bottom }, scales: { x, y } } = chart;
const midX = x.getPixelForValue(250);
const midY = y.getPixelForValue(50);
ctx.save();
ctx.fillStyle = 'rgba(34, 197, 94, 0.03)'; ctx.fillRect(left, top, midX - left, midY - top);
ctx.fillStyle = 'rgba(30, 81, 73, 0.03)'; ctx.fillRect(midX, top, right - midX, midY - top);
ctx.fillStyle = 'rgba(148, 163, 184, 0.03)'; ctx.fillRect(left, midY, midX - left, bottom - midY);
ctx.fillStyle = 'rgba(239, 68, 68, 0.05)'; ctx.fillRect(midX, midY, right - midX, bottom - midY);
ctx.lineWidth = 2; ctx.strokeStyle = 'rgba(0,0,0,0.1)'; ctx.beginPath();
ctx.moveTo(midX, top); ctx.lineTo(midX, bottom); ctx.moveTo(left, midY); ctx.lineTo(right, midY); ctx.stroke();
ctx.font = 'bold 12px Pretendard'; ctx.textAlign = 'center'; ctx.fillStyle = 'rgba(0,0,0,0.2)';
ctx.fillText('활력 양호', (left + midX) / 2, (top + midY) / 2);
ctx.fillText('핵심 가치', (midX + right) / 2, (top + midY) / 2);
ctx.fillText('정체/소규모', (left + midX) / 2, (midY + bottom) / 2);
ctx.fillStyle = 'rgba(239, 68, 68, 0.4)'; ctx.fillText('자산 손실 위험', (midX + right) / 2, (midY + bottom) / 2);
ctx.restore();
}
}]
});
} catch (err) { console.error("전략 매트릭스 에러:", err); }
}
function renderVitalityLeaderboard(data) {
const container = document.getElementById('p-war-table-container');
if (!container) return;
const sortedData = [...data].sort((a, b) => a.p_war - b.p_war);
container.innerHTML = `
<div class="table-scroll-wrapper">
<table class="data-table p-war-table">
<thead>
<tr>
<th style="width: 250px;">프로젝트명</th>
<th>파일 수</th>
<th>정체 일수</th>
<th>상태 판정</th>
<th style="text-align:right;">가치 기여 (VCI) <button class="btn-help" onclick="event.stopPropagation(); openAnalysisModal('vci')">?</button></th>
<th>운영 활력 (AVI) <button class="btn-help" onclick="event.stopPropagation(); openAnalysisModal('avi')">?</button></th>
<th style="text-align:center;">업무 집중도 <button class="btn-help" onclick="event.stopPropagation(); openAnalysisModal('focus')">?</button></th>
<th>운영 일관성 (OCI) <button class="btn-help" onclick="event.stopPropagation(); openAnalysisModal('oci')">?</button></th>
</tr>
</thead>
<tbody>
${sortedData.map((p, idx) => {
const status = getStatusInfo(p.p_war, p.is_auto_delete);
const avi = p.p_war;
const vci = p.risk_count;
const oci = p.oci_score || 0;
const rowId = `project-${idx}`;
const grade = getVciGrade(vci);
let rhythmLabel = oci >= 80 ? "정기적" : oci >= 50 ? "안정적" : oci >= 20 ? "간헐적" : "불규칙";
let rhythmColor = oci >= 80 ? "#059669" : oci >= 50 ? "#1e5149" : oci >= 20 ? "#f59e0b" : "#dc2626";
// 존재 신뢰도 패널티 (ECV) 상세 설명 복구
let ecvText = "100% (데이터 실체 검증)";
let ecvClass = "highlight-val";
let ecvDesc = `현재 ${p.file_count}개의 유효 성과물이 확인됩니다. 시스템적으로 실체가 완벽히 존재하는 상태입니다.`;
if (p.file_count === 0) {
ecvText = "5% (유령 프로젝트 판명)";
ecvClass = "highlight-penalty";
ecvDesc = "데이터가 전무하여 프로젝트의 디지털 실체가 없습니다. 모든 분석에서 최하위 패널티가 적용됩니다.";
} else if (p.file_count < 10) {
ecvText = "40% (형식적 껍데기 판명)";
ecvClass = "highlight-penalty";
ecvDesc = "최소 수준의 문서만 존재하며, 실질적인 운영 가치를 인정하기 어려운 소규모 상태입니다.";
}
// 활동 품질 텍스트 복구
const qualityLabel = p.log_quality >= 1.0 ? '성과물 중심의 <b>실무 활동</b>' : p.log_quality >= 0.7 ? '구조 관리를 위한 <b>시스템 활동</b>' : '단순 행정 기반의 <b>형식 활동</b>';
const qualityDetail = p.log_quality >= 1.0 ? '최근 로그에서 파일 업로드/수정 등 가치 증분 활동이 명확히 포착되었습니다.' : p.log_quality >= 0.7 ? '폴더 생성/이동 등 구조적 관리는 이뤄지고 있으나, 직접적 결과물 생산은 부족합니다.' : '메일 확인, 권한 변경 등 시스템 유지성 활동 위주로 파악되어 품질 가중치가 낮게 적용되었습니다.';
return `
<tr class="project-row ${status.key === 'danger' ? 'row-danger' : status.key === 'warning' ? 'row-warning' : ''}" onclick="toggleProjectDetail('${rowId}')">
<td class="font-bold">${p.project_nm}</td>
<td>${p.file_count.toLocaleString()}</td>
<td>${p.days_stagnant}</td>
<td><span class="${status.class}">${status.label}</span></td>
<td style="text-align:right; font-weight:800; color:${vci >= 0 ? '#059669' : '#dc2626'};">
${vci > 0 ? '+' : ''}${vci.toFixed(1)}
</td>
<td class="p-war-value ${avi >= 70 ? 'text-plus' : 'text-minus'}">${avi.toFixed(1)}%</td>
<td style="text-align:center;">
<div style="display:flex; flex-direction:column; align-items:center; gap:4px;">
<span style="font-weight:800; font-size:12px; color:${p.work_effort >= 70 ? '#059669' : p.work_effort <= 30 ? '#dc2626' : '#6366f1'};">${p.work_effort}%</span>
<div style="width:40px; height:4px; background:#f1f5f9; border-radius:2px; overflow:hidden;">
<div style="width:${p.work_effort}%; height:100%; background:${p.work_effort >= 70 ? '#059669' : p.work_effort <= 30 ? '#dc2626' : '#6366f1'};"></div>
</div>
</div>
</td>
<td style="text-align:center;">
<div style="display:flex; align-items:center; justify-content:center; gap:8px;">
<span style="font-weight:800; font-size:13px; color:${rhythmColor};">${oci}%</span>
<span style="font-size:10px; padding:2px 6px; border-radius:10px; background:${rhythmColor}15; color:${rhythmColor}; border:1px solid ${rhythmColor}30;">${rhythmLabel}</span>
</div>
</td>
</tr>
<tr id="detail-${rowId}" class="detail-row">
<td colspan="8">
<div class="detail-container">
<div class="formula-explanation-card">
<div class="formula-header"> AI 위험 적응형 모델(AAS) 기반 인과관계 분석</div>
<div style="display: flex; gap: 20px; margin-bottom: 20px;">
<div class="work-effort-section" style="flex: 1; margin-bottom: 0;">
<div class="work-effort-header">
<span style="font-size: 13px; font-weight: 800; color: #1e5149;">📊 실질 업무 집중도 (Job Focus)</span>
<span style="font-size: 14px; font-weight: 800; color: ${p.work_effort >= 70 ? '#059669' : p.work_effort <= 30 ? '#dc2626' : '#6366f1'};">${p.work_effort}%</span>
</div>
<div class="work-effort-bar-bg"><div style="width: ${p.work_effort}%; height: 100%; background: ${p.work_effort >= 70 ? '#059669' : p.work_effort <= 30 ? '#dc2626' : '#6366f1'};"></div></div>
<div style="font-size: 11px; color: #64748b; line-height: 1.5;">최근 30 수집 이력 단순 로그 갱신이 아닌 <b>실제 성과물의 변동</b> . '' .</div>
</div>
<div style="flex: 1; background: #f8fafc; border-radius: 8px; padding: 16px; border: 1px solid #e2e8f0; display: flex; align-items: center; gap: 15px;">
<div style="text-align: center;">
<div style="font-size: 10px; color: #64748b; font-weight: 700; margin-bottom: 2px;">VCI GRADE</div>
<div class="grade-badge ${grade.class}" style="padding: 4px 12px; border-radius: 6px; font-weight: 900; font-size: 14px; display: inline-block;">${grade.label}</div>
</div>
<div style="font-size: 12px; color: #475569; line-height: 1.4; font-weight: 600;">${grade.desc}</div>
</div>
</div>
<div class="formula-steps-grid">
<div class="formula-step">
<div class="step-num">1</div>
<div class="step-content">
<div class="step-title">동적 위험 계수(λ) 산출</div>
<div style="font-size:11px; color:#64748b; margin-bottom:5px;">프로젝트 규모가 클수록 정보 망실 시의 충격을 반영하여 데이터의 하락 속도가 가속됩니다. 현재 <b>λ=${p.ai_lambda.toFixed(4)}</b> .</div>
<div class="math-logic">Dynamic λ = <span class="highlight-var">${p.ai_lambda.toFixed(4)}</span></div>
</div>
</div>
<div class="formula-step">
<div class="step-num">4</div>
<div class="step-content">
<div class="step-title">활동 품질 검증 (Quality)</div>
<div class="step-desc" style="font-size:11px; margin-bottom:5px;">최근 로그 분석 결과 <b>${qualityLabel}</b> . ${qualityDetail}</div>
<div class="math-logic">Quality Factor = <span class="${p.log_quality < 0.7 ? 'highlight-penalty' : 'highlight-val'}">${(p.log_quality * 100).toFixed(0)}%</span></div>
</div>
</div>
<div class="formula-step">
<div class="step-num">2</div>
<div class="step-content">
<div class="step-title">방치 시간 감쇄 적용</div>
<div style="font-size:11px; color:#64748b; margin-bottom:5px;">마지막 유효 활동 이후 <b>${p.days_stagnant}</b> .</div>
<div class="math-logic">Decay Result = <span class="highlight-val">${((avi / (p.file_count === 0 ? 0.05 : p.file_count < 10 ? 0.4 : 1) / p.log_quality) || 0).toFixed(1)}%</span></div>
</div>
</div>
<div class="formula-step">
<div class="step-num">3</div>
<div class="step-content">
<div class="step-title">존재 진정성 (ECV)</div>
<div style="font-size:11px; color:#64748b; margin-bottom:5px;">${ecvDesc} 파일 자체가 분석의 데이터 진정성을 보정하는 핵심 팩터로 작용합니다.</div>
<div class="math-logic">Entity Factor = <span class="${ecvClass}">${ecvText}</span></div>
</div>
</div>
</div>
<div style="margin-top: 20px; padding-top: 15px; border-top: 2px solid #1e5149; text-align: right; display: flex; justify-content: space-between; align-items: center;">
<div style="text-align: left; max-width: 70%;">
<div style="display: flex; align-items: center; gap: 8px; margin-bottom: 4px;">
<div style="font-size: 13px; font-weight: 800; color: ${vci >= 0 ? '#059669' : '#dc2626'};">
가치 기여도 (VCI) 진단: ${vci >= 0 ? '+' : ''}${vci.toFixed(2)}
</div>
<div style="font-size: 10px; color: #94a3b8; background: #f8fafc; padding: 2px 6px; border-radius: 4px; border: 1px solid #e2e8f0;">
조직 평균 자산: ${p.avg_info.avg_files}
</div>
</div>
<div style="font-size: 11px; color: #64748b; line-height: 1.5;">
현재 프로젝트는 <b>포트폴리오 평균 관리 수준</b> 대비 <b>${Math.abs(vci / Math.max(0.2, p.file_count / p.avg_info.avg_files)).toFixed(1)}%p ${vci >= 0 ? '상회' : '하회'}</b> ,
<b>${p.file_count}</b>의 자산 규모에 따른 <b>${Math.max(0.2, p.file_count / p.avg_info.avg_files).toFixed(2)}</b> .
이는 시스템 전체 관점에서 <b>${vci >= 0 ? '순자산 가치를 증대' : '잠재적 기회비용을 손실'}</b> .
</div>
</div>
<div>
<span style="font-size: 13px; color: #64748b; font-weight: 700;">최종 AVI: </span>
<span style="color: #1e5149; font-size: 22px; font-weight: 900;">${avi.toFixed(1)}%</span>
</div>
</div>
</div>
</div>
</td>
</tr>`;
}).join('')}
</tbody>
</table>
</div>`;
}
function toggleProjectDetail(rowId) {
const container = document.querySelector('.table-scroll-wrapper');
const mainRow = document.querySelector(`tr[onclick*="toggleProjectDetail('${rowId}')"]`);
const detailRow = document.getElementById(`detail-${rowId}`);
if (detailRow && container) {
if (!detailRow.classList.contains('active')) {
document.querySelectorAll('.detail-row').forEach(row => row.classList.remove('active'));
detailRow.classList.add('active');
// 정밀 스크롤 이동 로직 복구
setTimeout(() => {
const headerH = container.querySelector('thead').offsetHeight || 45;
const targetScrollTop = mainRow.offsetTop - headerH;
container.scrollTo({ top: targetScrollTop, behavior: 'smooth' });
}, 100);
} else {
detailRow.classList.remove('active');
}
}
}
function openProjectListModal(label, projects) {
const modal = document.getElementById('analysisModal');
const title = document.getElementById('modalTitle');
const body = document.getElementById('modalBody');
title.innerText = `[${label}] 프로젝트 리스트 (${projects.length}건)`;
body.innerHTML = `
<div class="table-scroll-wrapper" style="max-height: 400px;">
<table class="data-table">
<thead><tr><th>프로젝트명</th><th>관리자</th><th>정체일</th><th>AVI</th></tr></thead>
<tbody>${projects.map(p => `<tr><td class="font-bold">${p.project_nm}</td><td>${p.master || '-'}</td><td>${p.days_stagnant}일</td><td style="font-weight:700; color:#1e5149;">${p.p_war.toFixed(1)}%</td></tr>`).join('')}</tbody>
</table>
</div>
<div class="modal-footer"><button class="btn btn-primary" onclick="closeAnalysisModal()">닫기</button></div>`;
modal.style.display = 'flex';
}
function openAnalysisModal(type) {
const modal = document.getElementById('analysisModal');
const title = document.getElementById('modalTitle');
const body = document.getElementById('modalBody');
if (type === 'avi') {
title.innerText = '운영 활력 지수 (AVI) 분석 가이드 (TEST)';
body.innerHTML = `
<div class="formula-box" style="margin-bottom:15px; background:#f1f5f9; padding:12px; border-radius:8px; font-family:'Consolas', monospace; font-weight:700; text-align:center;">
AVI = exp(-λ × Stagnant Days) × Quality × 100
</div>
<div style="margin-bottom:15px; font-size:13px; line-height:1.6; color:#334155;">
<p style="margin-bottom:8px;"><b>운영 활력 지수(AVI)</b>는 프로젝트가 현재 얼마나 건강하게 가동되고 있는지를 나타내는 '디지털 생존 지표'입니다.</p>
<ul style="padding-left:18px; margin-bottom:10px;">
<li style="margin-bottom:4px;"><b>지수 감쇄(Exponential Decay)</b>: 마지막 활동 이후 정체 기간이 길어질수록 자산의 최신성과 가치는 기하급수적으로 하락합니다.</li>
<li style="margin-bottom:4px;"><b>위험 가속 계수(λ)</b>: 자산 규모(파일 수)가 클수록 관리 부재 시의 정보 망실 위험이 크다고 판단하여, 더 가파른 감쇄 곡선을 적용합니다.</li>
<li style="margin-bottom:4px;"><b>활동 품질(Quality Factor)</b>: 단순 행정 로그(권한 변경 등)보다 실무 성과물(파일 업로드 등)이 발생했을 때 지수 복원력을 더 높게 부여합니다.</li>
</ul>
<p style="margin:0; font-weight:700; color:#1e5149;">※ 70% 미만 하락 시, 해당 프로젝트의 데이터 노후화 및 관리 방치 위험이 시작된 것으로 간주합니다.</p>
</div>
<table class="data-table" style="font-size:12px; width:100%;">
<thead><tr style="background:#f8fafc;"><th>지수 (AVI)</th><th>등급</th><th>운영 상태</th></tr></thead>
<tbody>
<tr><td class="font-bold">90%↑</td><td style="font-weight:900; color:#059669;">Live</td><td>실시간 성과물이 도출되는 최상급 가동 상태</td></tr>
<tr><td class="font-bold">70~90%</td><td style="font-weight:900; color:#1e5149;">Stable</td><td>주기적 업데이트가 이뤄지는 표준 안정 상태</td></tr>
<tr><td class="font-bold">30~70%</td><td style="font-weight:900; color:#f59e0b;">Idle</td><td>활력이 저하되어 관리가 필요한 정체 상태</td></tr>
<tr><td class="font-bold">10~30%</td><td style="font-weight:900; color:#dc2626;">Risk</td><td>데이터 노후화 및 자산 가치 소멸 위험 상태</td></tr>
<tr><td class="font-bold">10%↓</td><td style="font-weight:900; color:#64748b;">Frozen</td><td>운영이 중단된 사망/방치 상태</td></tr>
</tbody>
</table>
<div class="modal-footer"><button class="btn btn-primary" onclick="closeAnalysisModal()">닫기</button></div>`;
} else if (type === 'vci') {
title.innerText = '자산 가치 기여도 (VCI) 분석 가이드 (TEST)';
body.innerHTML = `
<p style="margin-bottom:12px; font-size:13.5px;">VCI는 야구의 <b>WAR(Wins Above Replacement)</b> 개념을 도입하여, 개별 프로젝트가 전체 포트폴리오 평균 대비 얼마나 조직의 가치에 기여하는지 산출한 지표입니다.</p>
<div class="formula-box" style="margin-bottom:15px; background:#f1f5f9; padding:12px; border-radius:8px; font-family:'Consolas', monospace; font-weight:700; text-align:center;">
VCI = (현재 AVI - 전체 평균 AVI) × (파일 규모 가중치)
</div>
<p style="margin-bottom:15px; font-size:13px; line-height:1.6;">
• <b>0.0 (평균)</b>: 우리 조직의 평균적인 관리 수준을 유지 중인 상태<br>
• <b>(+) 점수</b>: 평균 이상의 활력으로 조직의 디지털 자산 가치를 증대시킴<br>
• <b>(-) 점수</b>: 평균 이하의 방치로 인해 잠재적 기회비용 손실 발생 중
</p>
<table class="data-table" style="font-size:12px; width:100%;">
<thead><tr style="background:#f8fafc;"><th>점수 (VCI)</th><th>등급</th><th>운영 의미</th></tr></thead>
<tbody>
<tr><td>+10.0↑</td><td style="font-weight:900; color:#6366f1;">Masterpiece</td><td>시스템 가치를 견인하는 최우량 핵심 자산</td></tr>
<tr><td>+2.0 ~ +10.0</td><td style="font-weight:900; color:#059669;">Blue Chip</td><td>꾸준한 활력으로 가치를 창출하는 우량 자산</td></tr>
<tr><td>-2.0 ~ +2.0</td><td style="font-weight:900; color:#475569;">Steady</td><td>평균 수준의 운영을 유지 중인 안정 자산</td></tr>
<tr><td>-10.0 ~ -2.0</td><td style="font-weight:900; color:#f59e0b;">Underperform</td><td>평균 대비 활력 부족으로 가치 하락 중인 자산</td></tr>
<tr><td>-10.0↓</td><td style="font-weight:900; color:#dc2626;">Liability</td><td>가치를 훼손 중인 고위험 방치 자산</td></tr>
</tbody>
</table>
<div class="modal-footer"><button class="btn btn-primary" onclick="closeAnalysisModal()">닫기</button></div>`;
} else if (type === 'oci') {
title.innerText = '운영 일관성 지수 (OCI) 분석 가이드 (TEST)';
body.innerHTML = `
<div style="background:#f0fdf4; padding:15px; border-radius:8px; margin-bottom:15px;">
<strong style="color:#166534; display:block; margin-bottom:5px;">"얼마나 꾸준하게 관리되고 있는가?"</strong>
<p style="font-size:12.5px; color:#166534; margin:0;">미래 예측이 아닌, 최근 30일간의 <b>활동 리듬</b>과 <b>관리의 규칙성</b>을 분석하여 성실도를 점수화합니다.</p>
</div>
<table class="data-table" style="font-size:12px;">
<thead><tr style="background:#f8fafc;"><th>분석 결과</th><th>일관성 등급</th><th>관리 신뢰도</th></tr></thead>
<tbody>
<tr><td style="color:#059669;">80%↑</td><td style="font-weight:900; color:#059669;">매우 우수</td><td>주 단위의 정기적 관리가 완벽히 이뤄짐</td></tr>
<tr><td style="color:#1e5149;">50~80%</td><td style="font-weight:900; color:#1e5149;">양호</td><td>간헐적 정체는 있으나 꾸준히 관리됨</td></tr>
<tr><td style="color:#f59e0b;">20~50%</td><td style="font-weight:900; color:#f59e0b;">주의</td><td>돌발적 활동 위주, 관리의 리듬이 깨짐</td></tr>
<tr><td style="color:#dc2626;">20%↓</td><td style="font-weight:900; color:#dc2626;">매우 불량</td><td>장기 정체 중이거나 관리 의지 확인 불가</td></tr>
</tbody>
</table>
<div class="modal-footer"><button class="btn btn-primary" onclick="closeAnalysisModal()">닫기</button></div>`;
} else {
title.innerText = '업무 집중도 (Job Focus) 등급 가이드 (TEST)';
body.innerHTML = `
<p style="font-size:13px; color:#64748b; margin-bottom:15px;">최근 수집 로그 중 단순 행정 로그를 제외하고 실질적인 성과물(파일) 변동이 포착된 비율입니다.</p>
<table class="data-table" style="font-size:12px;">
<thead><tr style="background:#f8fafc;"><th>비율 (%)</th><th>등급</th><th>활동 성격</th></tr></thead>
<tbody>
<tr><td>80%↑</td><td style="font-weight:900; color:#6366f1;">Intensive</td><td>성과물 위주의 고밀도 집중 작업</td></tr>
<tr><td>50~80%</td><td style="font-weight:900; color:#059669;">Active</td><td>성과와 관리가 균형 잡힌 원활한 실행</td></tr>
<tr><td>20~50%</td><td style="font-weight:900; color:#f59e0b;">Maintenance</td><td>설정/행정 등 단순 관리 중심의 작업</td></tr>
<tr><td>20%↓</td><td style="font-weight:900; color:#dc2626;">Surface</td><td>실체적 변화가 적은 형식적 로그 중심</td></tr>
</tbody>
</table>
<div class="modal-footer"><button class="btn btn-primary" onclick="closeAnalysisModal()">닫기</button></div>`;
}
modal.style.display = 'flex';
}
function closeAnalysisModal() { document.getElementById('analysisModal').style.display = 'none'; }

View File

@@ -1,78 +1,78 @@
/**
* Project Master Overseas Common JS
* 공통 네비게이션, 통합 모달 관리, 유틸리티
*/
// --- 공통 상수 ---
const API = {
INQUIRIES: '/api/inquiries',
PROJECT_DATA: '/project-data',
PROJECT_ACTIVITY: '/project-activity',
AVAILABLE_DATES: '/available-dates',
SYNC: '/sync',
STOP_SYNC: '/stop-sync',
AUTH_CRAWL: '/auth/crawl',
ANALYZE_FILE: '/analyze-file',
ATTACHMENTS: '/attachments'
};
// --- 네비게이션 ---
function navigateTo(path) {
location.href = path;
}
// --- 통합 모달 관리자 ---
const ModalManager = {
open(modalId) {
const modal = document.getElementById(modalId);
if (modal) {
modal.style.display = 'flex';
// 포커스 자동 이동 (ID 입력란이 있으면)
const firstInput = modal.querySelector('input');
if (firstInput) firstInput.focus();
}
},
close(modalId) {
const modal = document.getElementById(modalId);
if (modal) modal.style.display = 'none';
},
closeAll() {
document.querySelectorAll('.modal-overlay').forEach(m => m.style.display = 'none');
}
};
// --- 유틸리티 함수 ---
const Utils = {
formatDate(dateStr) {
if (!dateStr) return '-';
return dateStr.replace(/-/g, '.');
},
// 상태별 CSS 클래스 매핑
getStatusClass(status) {
const map = {
'완료': 'status-complete',
'작업 중': 'status-working',
'확인 중': 'status-checking',
'정상': 'active',
'주의': 'warning',
'방치': 'stale',
'데이터 없음': 'unknown'
};
return map[status] || 'status-pending';
},
// 한글 파일명 인코딩 안전 처리
getSafeFileUrl(filename) {
return `/sample_files/${encodeURIComponent(filename)}`;
}
};
// --- 전역 이벤트 ---
document.addEventListener('keydown', (e) => {
if (e.key === 'Escape') ModalManager.closeAll();
});
document.addEventListener('DOMContentLoaded', () => {
console.log("Common module initialized.");
});
/**
* Project Master Overseas Common JS
* 공통 네비게이션, 통합 모달 관리, 유틸리티
*/
// --- 공통 상수 ---
const API = {
INQUIRIES: '/api/inquiries',
PROJECT_DATA: '/project-data',
PROJECT_ACTIVITY: '/project-activity',
AVAILABLE_DATES: '/available-dates',
SYNC: '/sync',
STOP_SYNC: '/stop-sync',
AUTH_CRAWL: '/auth/crawl',
ANALYZE_FILE: '/analyze-file',
ATTACHMENTS: '/attachments'
};
// --- 네비게이션 ---
function navigateTo(path) {
location.href = path;
}
// --- 통합 모달 관리자 ---
const ModalManager = {
open(modalId) {
const modal = document.getElementById(modalId);
if (modal) {
modal.style.display = 'flex';
// 포커스 자동 이동 (ID 입력란이 있으면)
const firstInput = modal.querySelector('input');
if (firstInput) firstInput.focus();
}
},
close(modalId) {
const modal = document.getElementById(modalId);
if (modal) modal.style.display = 'none';
},
closeAll() {
document.querySelectorAll('.modal-overlay').forEach(m => m.style.display = 'none');
}
};
// --- 유틸리티 함수 ---
const Utils = {
formatDate(dateStr) {
if (!dateStr) return '-';
return dateStr.replace(/-/g, '.');
},
// 상태별 CSS 클래스 매핑
getStatusClass(status) {
const map = {
'완료': 'status-complete',
'작업 중': 'status-working',
'확인 중': 'status-checking',
'정상': 'active',
'주의': 'warning',
'방치': 'stale',
'데이터 없음': 'unknown'
};
return map[status] || 'status-pending';
},
// 한글 파일명 인코딩 안전 처리
getSafeFileUrl(filename) {
return `/sample_files/${encodeURIComponent(filename)}`;
}
};
// --- 전역 이벤트 ---
document.addEventListener('keydown', (e) => {
if (e.key === 'Escape') ModalManager.closeAll();
});
document.addEventListener('DOMContentLoaded', () => {
console.log("Common module initialized.");
});

View File

@@ -1,237 +1,237 @@
/**
* Project Master Overseas Dashboard JS
* 기능: 데이터 로드, 활성도 분석, 인증 모달 제어, 크롤링 동기화 및 중단
*/
// --- 글로벌 상태 관리 ---
let rawData = [];
let projectActivityDetails = [];
let isCrawling = false;
const CONTINENT_ORDER = { "아시아": 1, "아프리카": 2, "아메리카": 3, "지사": 4 };
// --- 초기화 ---
async function init() {
console.log("Dashboard Initializing...");
if (!document.getElementById('projectAccordion')) return;
await loadAvailableDates();
await loadDataByDate();
}
// --- 데이터 통신 및 로드 ---
async function loadAvailableDates() {
try {
const response = await fetch(API.AVAILABLE_DATES);
const dates = await response.json();
if (dates?.length > 0) {
const selectHtml = `
<select id="dateSelector" onchange="loadDataByDate(this.value)"
style="margin-left:10px; border:none; background:none; font-weight:700; cursor:pointer; font-family:inherit; color:inherit;">
${dates.map(d => `<option value="${d}">${d}</option>`).join('')}
</select>`;
const baseDateStrong = document.getElementById('baseDate');
if (baseDateStrong) baseDateStrong.innerHTML = selectHtml;
}
} catch (e) { console.error("날짜 로드 실패:", e); }
}
async function loadDataByDate(selectedDate = "") {
try {
await loadActivityAnalysis(selectedDate);
const url = selectedDate ? `${API.PROJECT_DATA}?date=${selectedDate}` : `${API.PROJECT_DATA}?t=${Date.now()}`;
const response = await fetch(url);
const data = await response.json();
if (data.error) throw new Error(data.error);
rawData = data.projects || [];
renderDashboard(rawData);
} catch (e) {
console.error("데이터 로드 실패:", e);
alert("데이터를 가져오는 데 실패했습니다.");
}
}
async function loadActivityAnalysis(date = "") {
const dashboard = document.getElementById('activityDashboard');
if (!dashboard) return;
try {
const url = date ? `${API.PROJECT_ACTIVITY}?date=${date}` : API.PROJECT_ACTIVITY;
const response = await fetch(url);
const data = await response.json();
if (data.error) return;
const { summary, details } = data;
projectActivityDetails = details;
dashboard.innerHTML = `
<div class="activity-card active" onclick="showActivityDetails('active')">
<div class="label">정상 (7일 이내)</div><div class="count">${summary.active}</div>
</div>
<div class="activity-card warning" onclick="showActivityDetails('warning')">
<div class="label">주의 (14일 이내)</div><div class="count">${summary.warning}</div>
</div>
<div class="activity-card stale" onclick="showActivityDetails('stale')">
<div class="label">방치 (14일 초과 / 폴더자동삭제)</div><div class="count">${summary.stale}</div>
</div>
<div class="activity-card unknown" onclick="showActivityDetails('unknown')">
<div class="label">데이터 없음 (파일 0개)</div><div class="count">${summary.unknown}</div>
</div>`;
} catch (e) { console.error("분석 로드 실패:", e); }
}
// --- 렌더링 엔진 ---
function renderDashboard(data) {
const container = document.getElementById('projectAccordion');
container.innerHTML = '';
const grouped = groupData(data);
Object.keys(grouped).sort((a, b) => (CONTINENT_ORDER[a] || 99) - (CONTINENT_ORDER[b] || 99)).forEach(continent => {
const continentDiv = document.createElement('div');
continentDiv.className = 'continent-group active';
let html = `<div class="continent-header" onclick="toggleGroup(this)"><span>${continent}</span><span class="toggle-icon">▼</span></div><div class="continent-body">`;
Object.keys(grouped[continent]).sort().forEach(country => {
html += `<div class="country-group active"><div class="country-header" onclick="toggleGroup(this)"><span>${country}</span><span class="toggle-icon">▼</span></div><div class="country-body"><div class="accordion-container">
<div class="accordion-list-header"><div>프로젝트명</div><div>담당부서</div><div>담당자</div><div style="text-align:center;">파일수</div><div>최근로그</div></div>
${grouped[continent][country].sort((a, b) => a[0].localeCompare(b[0])).map(p => createProjectHtml(p)).join('')}</div></div></div>`;
});
html += `</div>`;
continentDiv.innerHTML = html;
container.appendChild(continentDiv);
});
}
function groupData(data) {
const res = {};
data.forEach(item => {
const c1 = item[5] || "기타", c2 = item[6] || "미분류";
if (!res[c1]) res[c1] = {};
if (!res[c1][c2]) res[c1][c2] = [];
res[c1][c2].push(item);
});
return res;
}
function createProjectHtml(p) {
const [name, dept, admin, logRaw, files] = p;
const recentLog = (!logRaw || logRaw === "X" || logRaw === "데이터 없음") ? "기록 없음" : logRaw;
const logTime = recentLog !== "기록 없음" ? recentLog.split(',')[0] : "기록 없음";
const isStaleLog = recentLog.replace(/\s/g, "").includes("폴더자동삭제");
const isNoFiles = (files === 0 || files === null);
const statusClass = isNoFiles ? "status-error" : "";
let logStyleClass = "";
if (isStaleLog) logStyleClass = "error-text";
else if (recentLog === "기록 없음") logStyleClass = "warning-text";
const logBoldStyle = isStaleLog ? 'font-weight: 800;' : '';
return `
<div class="accordion-item ${statusClass}">
<div class="accordion-header" onclick="toggleAccordion(this)">
<div class="repo-title" title="${name}">${name}</div><div class="repo-dept">${dept}</div><div class="repo-admin">${admin}</div><div class="repo-files ${isNoFiles ? 'error-text' : ''}">${files || 0}</div><div class="repo-log ${logStyleClass}" style="${logBoldStyle}" title="${recentLog}">${recentLog}</div>
</div>
<div class="accordion-body">
<div class="detail-grid">
<div class="detail-section">
<h4>참여 인원 상세</h4>
<table class="data-table">
<thead><tr><th>이름</th><th>소속</th><th>권한</th></tr></thead>
<tbody><tr><td>${admin}</td><td>${dept}</td><td>관리자</td></tr></tbody>
</table>
</div>
<div class="detail-section">
<h4>최근 활동</h4>
<table class="data-table">
<thead><tr><th>유형</th><th>내용</th><th>일시</th></tr></thead>
<tbody><tr><td><span class="badge">로그</span></td><td>동기화 완료</td><td>${logTime}</td></tr></tbody>
</table>
</div>
</div>
</div>
</div>`;
}
// --- 이벤트 핸들러 ---
function toggleGroup(h) { h.parentElement.classList.toggle('active'); }
function toggleAccordion(h) {
const item = h.parentElement;
item.parentElement.querySelectorAll('.accordion-item').forEach(el => { if (el !== item) el.classList.remove('active'); });
item.classList.toggle('active');
}
function showActivityDetails(status) {
const names = { active: '정상', warning: '주의', stale: '방치', unknown: '데이터 없음' };
const filtered = (projectActivityDetails || []).filter(d => d.status === status);
document.getElementById('modalTitle').innerText = `${names[status]} 목록 (${filtered.length}개)`;
document.getElementById('modalTableBody').innerHTML = filtered.map(p => {
const o = rawData.find(r => r[0] === p.name);
return `<tr class="modal-row" onclick="scrollToProject('${p.name}')"><td><strong>${p.name}</strong></td><td>${o ? o[1] : "-"}</td><td>${o ? o[2] : "-"}</td></tr>`;
}).join('');
ModalManager.open('activityDetailModal');
}
function scrollToProject(name) {
ModalManager.close('activityDetailModal');
const target = Array.from(document.querySelectorAll('.repo-title')).find(t => t.innerText.trim() === name.trim())?.closest('.accordion-header');
if (target) {
let p = target.parentElement;
while (p && p !== document.body) {
if (p.classList.contains('continent-group') || p.classList.contains('country-group')) p.classList.add('active');
p = p.parentElement;
}
target.parentElement.classList.add('active');
const pos = target.getBoundingClientRect().top + window.pageYOffset - 260;
window.scrollTo({ top: pos, behavior: 'smooth' });
target.style.backgroundColor = 'var(--primary-lv-1)';
setTimeout(() => target.style.backgroundColor = '', 2000);
}
}
// --- 크롤링 및 인증 제어 ---
async function syncData() {
if (isCrawling) {
if (confirm("크롤링을 중단하시겠습니까?")) {
const res = await fetch(API.STOP_SYNC);
if ((await res.json()).success) document.getElementById('syncBtn').innerText = "중단 요청 중...";
}
return;
}
document.getElementById('authId').value = '';
document.getElementById('authPw').value = '';
document.getElementById('authErrorMessage').style.display = 'none';
ModalManager.open('authModal');
}
async function submitAuth() {
const id = document.getElementById('authId').value, pw = document.getElementById('authPw').value, err = document.getElementById('authErrorMessage');
try {
const res = await fetch(API.AUTH_CRAWL, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ user_id: id, password: pw }) });
const data = await res.json();
if (data.success) { ModalManager.close('authModal'); startCrawlProcess(); }
else { err.innerText = "크롤링을 할 수 없습니다."; err.style.display = 'block'; }
} catch { err.innerText = "서버 연결 실패"; err.style.display = 'block'; }
}
async function startCrawlProcess() {
isCrawling = true;
const btn = document.getElementById('syncBtn'), logC = document.getElementById('logConsole'), logB = document.getElementById('logBody');
btn.classList.add('loading'); btn.style.backgroundColor = 'var(--error-color)'; btn.innerHTML = `<span class="spinner"></span> 크롤링 중단`;
logC.style.display = 'block'; logB.innerHTML = '<div style="color:#aaa; margin-bottom:10px;">>>> 엔진 초기화 중...</div>';
try {
const res = await fetch(API.SYNC);
const reader = res.body.getReader(), decoder = new TextDecoder();
while (true) {
const { done, value } = await reader.read(); if (done) break;
decoder.decode(value).split('\n').forEach(line => {
if (line.startsWith('data: ')) {
const p = JSON.parse(line.substring(6));
if (p.type === 'log') {
const div = document.createElement('div'); div.innerText = `[${new Date().toLocaleTimeString()}] ${p.message}`;
logB.appendChild(div); logC.scrollTop = logC.scrollHeight;
} else if (p.type === 'done') { init(); alert(`동기화 종료`); logC.style.display = 'none'; }
}
});
}
} catch { alert("스트림 끊김"); }
finally { isCrawling = false; btn.classList.remove('loading'); btn.style.backgroundColor = ''; btn.innerHTML = `<span class="spinner"></span> 데이터 동기화 (크롤링)`; }
}
document.addEventListener('DOMContentLoaded', init);
/**
* Project Master Overseas Dashboard JS
* 기능: 데이터 로드, 활성도 분석, 인증 모달 제어, 크롤링 동기화 및 중단
*/
// --- 글로벌 상태 관리 ---
let rawData = [];
let projectActivityDetails = [];
let isCrawling = false;
const CONTINENT_ORDER = { "아시아": 1, "아프리카": 2, "아메리카": 3, "지사": 4 };
// --- 초기화 ---
async function init() {
console.log("Dashboard Initializing...");
if (!document.getElementById('projectAccordion')) return;
await loadAvailableDates();
await loadDataByDate();
}
// --- 데이터 통신 및 로드 ---
async function loadAvailableDates() {
try {
const response = await fetch(API.AVAILABLE_DATES);
const dates = await response.json();
if (dates?.length > 0) {
const selectHtml = `
<select id="dateSelector" onchange="loadDataByDate(this.value)"
style="margin-left:10px; border:none; background:none; font-weight:700; cursor:pointer; font-family:inherit; color:inherit;">
${dates.map(d => `<option value="${d}">${d}</option>`).join('')}
</select>`;
const baseDateStrong = document.getElementById('baseDate');
if (baseDateStrong) baseDateStrong.innerHTML = selectHtml;
}
} catch (e) { console.error("날짜 로드 실패:", e); }
}
async function loadDataByDate(selectedDate = "") {
try {
await loadActivityAnalysis(selectedDate);
const url = selectedDate ? `${API.PROJECT_DATA}?date=${selectedDate}` : `${API.PROJECT_DATA}?t=${Date.now()}`;
const response = await fetch(url);
const data = await response.json();
if (data.error) throw new Error(data.error);
rawData = data.projects || [];
renderDashboard(rawData);
} catch (e) {
console.error("데이터 로드 실패:", e);
alert("데이터를 가져오는 데 실패했습니다.");
}
}
async function loadActivityAnalysis(date = "") {
const dashboard = document.getElementById('activityDashboard');
if (!dashboard) return;
try {
const url = date ? `${API.PROJECT_ACTIVITY}?date=${date}` : API.PROJECT_ACTIVITY;
const response = await fetch(url);
const data = await response.json();
if (data.error) return;
const { summary, details } = data;
projectActivityDetails = details;
dashboard.innerHTML = `
<div class="activity-card active" onclick="showActivityDetails('active')">
<div class="label">정상 (7일 이내)</div><div class="count">${summary.active}</div>
</div>
<div class="activity-card warning" onclick="showActivityDetails('warning')">
<div class="label">주의 (14일 이내)</div><div class="count">${summary.warning}</div>
</div>
<div class="activity-card stale" onclick="showActivityDetails('stale')">
<div class="label">방치 (14일 초과 / 폴더자동삭제)</div><div class="count">${summary.stale}</div>
</div>
<div class="activity-card unknown" onclick="showActivityDetails('unknown')">
<div class="label">데이터 없음 (파일 0개)</div><div class="count">${summary.unknown}</div>
</div>`;
} catch (e) { console.error("분석 로드 실패:", e); }
}
// --- 렌더링 엔진 ---
function renderDashboard(data) {
const container = document.getElementById('projectAccordion');
container.innerHTML = '';
const grouped = groupData(data);
Object.keys(grouped).sort((a, b) => (CONTINENT_ORDER[a] || 99) - (CONTINENT_ORDER[b] || 99)).forEach(continent => {
const continentDiv = document.createElement('div');
continentDiv.className = 'continent-group active';
let html = `<div class="continent-header" onclick="toggleGroup(this)"><span>${continent}</span><span class="toggle-icon">▼</span></div><div class="continent-body">`;
Object.keys(grouped[continent]).sort().forEach(country => {
html += `<div class="country-group active"><div class="country-header" onclick="toggleGroup(this)"><span>${country}</span><span class="toggle-icon">▼</span></div><div class="country-body"><div class="accordion-container">
<div class="accordion-list-header"><div>프로젝트명</div><div>담당부서</div><div>담당자</div><div style="text-align:center;">파일수</div><div>최근로그</div></div>
${grouped[continent][country].sort((a, b) => a[0].localeCompare(b[0])).map(p => createProjectHtml(p)).join('')}</div></div></div>`;
});
html += `</div>`;
continentDiv.innerHTML = html;
container.appendChild(continentDiv);
});
}
function groupData(data) {
const res = {};
data.forEach(item => {
const c1 = item[5] || "기타", c2 = item[6] || "미분류";
if (!res[c1]) res[c1] = {};
if (!res[c1][c2]) res[c1][c2] = [];
res[c1][c2].push(item);
});
return res;
}
function createProjectHtml(p) {
const [name, dept, admin, logRaw, files] = p;
const recentLog = (!logRaw || logRaw === "X" || logRaw === "데이터 없음") ? "기록 없음" : logRaw;
const logTime = recentLog !== "기록 없음" ? recentLog.split(',')[0] : "기록 없음";
const isStaleLog = recentLog.replace(/\s/g, "").includes("폴더자동삭제");
const isNoFiles = (files === 0 || files === null);
const statusClass = isNoFiles ? "status-error" : "";
let logStyleClass = "";
if (isStaleLog) logStyleClass = "error-text";
else if (recentLog === "기록 없음") logStyleClass = "warning-text";
const logBoldStyle = isStaleLog ? 'font-weight: 800;' : '';
return `
<div class="accordion-item ${statusClass}">
<div class="accordion-header" onclick="toggleAccordion(this)">
<div class="repo-title" title="${name}">${name}</div><div class="repo-dept">${dept}</div><div class="repo-admin">${admin}</div><div class="repo-files ${isNoFiles ? 'error-text' : ''}">${files || 0}</div><div class="repo-log ${logStyleClass}" style="${logBoldStyle}" title="${recentLog}">${recentLog}</div>
</div>
<div class="accordion-body">
<div class="detail-grid">
<div class="detail-section">
<h4>참여 인원 상세</h4>
<table class="data-table">
<thead><tr><th>이름</th><th>소속</th><th>권한</th></tr></thead>
<tbody><tr><td>${admin}</td><td>${dept}</td><td>관리자</td></tr></tbody>
</table>
</div>
<div class="detail-section">
<h4>최근 활동</h4>
<table class="data-table">
<thead><tr><th>유형</th><th>내용</th><th>일시</th></tr></thead>
<tbody><tr><td><span class="badge">로그</span></td><td>동기화 완료</td><td>${logTime}</td></tr></tbody>
</table>
</div>
</div>
</div>
</div>`;
}
// --- 이벤트 핸들러 ---
function toggleGroup(h) { h.parentElement.classList.toggle('active'); }
function toggleAccordion(h) {
const item = h.parentElement;
item.parentElement.querySelectorAll('.accordion-item').forEach(el => { if (el !== item) el.classList.remove('active'); });
item.classList.toggle('active');
}
function showActivityDetails(status) {
const names = { active: '정상', warning: '주의', stale: '방치', unknown: '데이터 없음' };
const filtered = (projectActivityDetails || []).filter(d => d.status === status);
document.getElementById('modalTitle').innerText = `${names[status]} 목록 (${filtered.length}개)`;
document.getElementById('modalTableBody').innerHTML = filtered.map(p => {
const o = rawData.find(r => r[0] === p.name);
return `<tr class="modal-row" onclick="scrollToProject('${p.name}')"><td><strong>${p.name}</strong></td><td>${o ? o[1] : "-"}</td><td>${o ? o[2] : "-"}</td></tr>`;
}).join('');
ModalManager.open('activityDetailModal');
}
function scrollToProject(name) {
ModalManager.close('activityDetailModal');
const target = Array.from(document.querySelectorAll('.repo-title')).find(t => t.innerText.trim() === name.trim())?.closest('.accordion-header');
if (target) {
let p = target.parentElement;
while (p && p !== document.body) {
if (p.classList.contains('continent-group') || p.classList.contains('country-group')) p.classList.add('active');
p = p.parentElement;
}
target.parentElement.classList.add('active');
const pos = target.getBoundingClientRect().top + window.pageYOffset - 260;
window.scrollTo({ top: pos, behavior: 'smooth' });
target.style.backgroundColor = 'var(--primary-lv-1)';
setTimeout(() => target.style.backgroundColor = '', 2000);
}
}
// --- 크롤링 및 인증 제어 ---
async function syncData() {
if (isCrawling) {
if (confirm("크롤링을 중단하시겠습니까?")) {
const res = await fetch(API.STOP_SYNC);
if ((await res.json()).success) document.getElementById('syncBtn').innerText = "중단 요청 중...";
}
return;
}
document.getElementById('authId').value = '';
document.getElementById('authPw').value = '';
document.getElementById('authErrorMessage').style.display = 'none';
ModalManager.open('authModal');
}
async function submitAuth() {
const id = document.getElementById('authId').value, pw = document.getElementById('authPw').value, err = document.getElementById('authErrorMessage');
try {
const res = await fetch(API.AUTH_CRAWL, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ user_id: id, password: pw }) });
const data = await res.json();
if (data.success) { ModalManager.close('authModal'); startCrawlProcess(); }
else { err.innerText = "크롤링을 할 수 없습니다."; err.style.display = 'block'; }
} catch { err.innerText = "서버 연결 실패"; err.style.display = 'block'; }
}
async function startCrawlProcess() {
isCrawling = true;
const btn = document.getElementById('syncBtn'), logC = document.getElementById('logConsole'), logB = document.getElementById('logBody');
btn.classList.add('loading'); btn.style.backgroundColor = 'var(--error-color)'; btn.innerHTML = `<span class="spinner"></span> 크롤링 중단`;
logC.style.display = 'block'; logB.innerHTML = '<div style="color:#aaa; margin-bottom:10px;">>>> 엔진 초기화 중...</div>';
try {
const res = await fetch(API.SYNC);
const reader = res.body.getReader(), decoder = new TextDecoder();
while (true) {
const { done, value } = await reader.read(); if (done) break;
decoder.decode(value).split('\n').forEach(line => {
if (line.startsWith('data: ')) {
const p = JSON.parse(line.substring(6));
if (p.type === 'log') {
const div = document.createElement('div'); div.innerText = `[${new Date().toLocaleTimeString()}] ${p.message}`;
logB.appendChild(div); logC.scrollTop = logC.scrollHeight;
} else if (p.type === 'done') { init(); alert(`동기화 종료`); logC.style.display = 'none'; }
}
});
}
} catch { alert("스트림 끊김"); }
finally { isCrawling = false; btn.classList.remove('loading'); btn.style.backgroundColor = ''; btn.innerHTML = `<span class="spinner"></span> 데이터 동기화 (크롤링)`; }
}
document.addEventListener('DOMContentLoaded', init);

245
js/dashboard_test.js Normal file
View File

@@ -0,0 +1,245 @@
/**
* Project Master Overseas Dashboard JS (TEST VERSION)
* 기능: 데이터 로드, 활성도 분석, 인증 모달 제어, 크롤링 동기화 및 중단
*/
// --- 글로벌 상태 관리 ---
let rawData = [];
let projectActivityDetails = [];
let isCrawling = false;
const CONTINENT_ORDER = { "아시아": 1, "아프리카": 2, "아메리카": 3, "지사": 4 };
// --- 초기화 ---
async function init() {
console.log("Dashboard (TEST) Initializing...");
if (!document.getElementById('projectAccordion')) return;
await loadAvailableDates();
await loadDataByDate();
}
// --- 데이터 통신 및 로드 ---
async function loadAvailableDates() {
try {
const response = await fetch(API.AVAILABLE_DATES);
const dates = await response.json(); // YYYY.MM.DD 형식 리스트
if (dates?.length > 0) {
// 날짜 형식 변환 (YYYY.MM.DD -> YYYY-MM-DD)
const formattedDates = dates.map(d => d.replace(/\./g, '-')).sort();
const minDate = formattedDates[0];
const maxDate = new Date().toISOString().split('T')[0]; // 오늘
const defaultDate = formattedDates[formattedDates.length - 1]; // 가장 최신 수집일
const dateInputHtml = `
<input type="date" id="dateSelector"
min="${minDate}" max="${maxDate}" value="${defaultDate}"
onchange="loadDataByDate(this.value)"
style="margin-left:10px; border:1px solid var(--border-color); border-radius:4px; padding:2px 8px; font-weight:700; cursor:pointer; font-family:inherit; color:var(--text-main); font-size:14px;">
`;
const baseDateStrong = document.getElementById('baseDate');
if (baseDateStrong) baseDateStrong.innerHTML = dateInputHtml;
}
} catch (e) { console.error("날짜 로드 실패:", e); }
}
async function loadDataByDate(selectedDate = "") {
try {
await loadActivityAnalysis(selectedDate);
const url = selectedDate ? `${API.PROJECT_DATA}?date=${selectedDate}` : `${API.PROJECT_DATA}?t=${Date.now()}`;
const response = await fetch(url);
const data = await response.json();
if (data.error) throw new Error(data.error);
rawData = data.projects || [];
renderDashboard(rawData);
} catch (e) {
console.error("데이터 로드 실패:", e);
alert("데이터를 가져오는 데 실패했습니다.");
}
}
async function loadActivityAnalysis(date = "") {
const dashboard = document.getElementById('activityDashboard');
if (!dashboard) return;
try {
const url = date ? `${API.PROJECT_ACTIVITY}?date=${date}` : API.PROJECT_ACTIVITY;
const response = await fetch(url);
const data = await response.json();
if (data.error) return;
const { summary, details } = data;
projectActivityDetails = details;
dashboard.innerHTML = `
<div class="activity-card active" onclick="showActivityDetails('active')">
<div class="label">정상 (7일 이내) [TEST]</div><div class="count">${summary.active}</div>
</div>
<div class="activity-card warning" onclick="showActivityDetails('warning')">
<div class="label">주의 (14일 이내) [TEST]</div><div class="count">${summary.warning}</div>
</div>
<div class="activity-card stale" onclick="showActivityDetails('stale')">
<div class="label">방치 (14일 초과) [TEST]</div><div class="count">${summary.stale}</div>
</div>
<div class="activity-card unknown" onclick="showActivityDetails('unknown')">
<div class="label">데이터 없음 (파일 0개) [TEST]</div><div class="count">${summary.unknown}</div>
</div>`;
} catch (e) { console.error("분석 로드 실패:", e); }
}
// --- 렌더링 엔진 ---
function renderDashboard(data) {
const container = document.getElementById('projectAccordion');
container.innerHTML = '';
const grouped = groupData(data);
Object.keys(grouped).sort((a, b) => (CONTINENT_ORDER[a] || 99) - (CONTINENT_ORDER[b] || 99)).forEach(continent => {
const continentDiv = document.createElement('div');
continentDiv.className = 'continent-group active';
let html = `<div class="continent-header" onclick="toggleGroup(this)"><span>${continent}</span><span class="toggle-icon">▼</span></div><div class="continent-body">`;
Object.keys(grouped[continent]).sort().forEach(country => {
html += `<div class="country-group active"><div class="country-header" onclick="toggleGroup(this)"><span>${country}</span><span class="toggle-icon">▼</span></div><div class="country-body"><div class="accordion-container">
<div class="accordion-list-header"><div>프로젝트명</div><div>담당부서</div><div>담당자</div><div style="text-align:center;">파일수</div><div>최근로그</div></div>
${grouped[continent][country].sort((a, b) => a[0].localeCompare(b[0])).map(p => createProjectHtml(p)).join('')}</div></div></div>`;
});
html += `</div>`;
continentDiv.innerHTML = html;
container.appendChild(continentDiv);
});
}
function groupData(data) {
const res = {};
data.forEach(item => {
const c1 = item[5] || "기타", c2 = item[6] || "미분류";
if (!res[c1]) res[c1] = {};
if (!res[c1][c2]) res[c1][c2] = [];
res[c1][c2].push(item);
});
return res;
}
function createProjectHtml(p) {
const [name, dept, admin, logRaw, files] = p;
const recentLog = (!logRaw || logRaw === "X" || logRaw === "데이터 없음") ? "기록 없음" : logRaw;
const logTime = recentLog !== "기록 없음" ? recentLog.split(',')[0] : "기록 없음";
const isStaleLog = recentLog.replace(/\s/g, "").includes("폴더자동삭제");
const isNoFiles = (files === 0 || files === null);
const statusClass = isNoFiles ? "status-error" : "";
let logStyleClass = "";
if (isStaleLog) logStyleClass = "error-text";
else if (recentLog === "기록 없음") logStyleClass = "warning-text";
const logBoldStyle = isStaleLog ? 'font-weight: 800;' : '';
return `
<div class="accordion-item ${statusClass}">
<div class="accordion-header" onclick="toggleAccordion(this)">
<div class="repo-title" title="${name}">${name}</div><div class="repo-dept">${dept}</div><div class="repo-admin">${admin}</div><div class="repo-files ${isNoFiles ? 'error-text' : ''}">${files || 0}</div><div class="repo-log ${logStyleClass}" style="${logBoldStyle}" title="${recentLog}">${recentLog}</div>
</div>
<div class="accordion-body">
<div class="detail-grid">
<div class="detail-section">
<h4>참여 인원 상세 (TEST)</h4>
<table class="data-table">
<thead><tr><th>이름</th><th>소속</th><th>권한</th></tr></thead>
<tbody><tr><td>${admin}</td><td>${dept}</td><td>관리자</td></tr></tbody>
</table>
</div>
<div class="detail-section">
<h4>최근 활동 (TEST)</h4>
<table class="data-table">
<thead><tr><th>유형</th><th>내용</th><th>일시</th></tr></thead>
<tbody><tr><td><span class="badge">로그</span></td><td>동기화 완료</td><td>${logTime}</td></tr></tbody>
</table>
</div>
</div>
</div>
</div>`;
}
// --- 이벤트 핸들러 ---
function toggleGroup(h) { h.parentElement.classList.toggle('active'); }
function toggleAccordion(h) {
const item = h.parentElement;
item.parentElement.querySelectorAll('.accordion-item').forEach(el => { if (el !== item) el.classList.remove('active'); });
item.classList.toggle('active');
}
function showActivityDetails(status) {
const names = { active: '정상', warning: '주의', stale: '방치', unknown: '데이터 없음' };
const filtered = (projectActivityDetails || []).filter(d => d.status === status);
document.getElementById('modalTitle').innerText = `${names[status]} 목록 (${filtered.length}개) [TEST]`;
document.getElementById('modalTableBody').innerHTML = filtered.map(p => {
const o = rawData.find(r => r[0] === p.name);
return `<tr class="modal-row" onclick="scrollToProject('${p.name}')"><td><strong>${p.name}</strong></td><td>${o ? o[1] : "-"}</td><td>${o ? o[2] : "-"}</td></tr>`;
}).join('');
ModalManager.open('activityDetailModal');
}
function scrollToProject(name) {
ModalManager.close('activityDetailModal');
const target = Array.from(document.querySelectorAll('.repo-title')).find(t => t.innerText.trim() === name.trim())?.closest('.accordion-header');
if (target) {
let p = target.parentElement;
while (p && p !== document.body) {
if (p.classList.contains('continent-group') || p.classList.contains('country-group')) p.classList.add('active');
p = p.parentElement;
}
target.parentElement.classList.add('active');
const pos = target.getBoundingClientRect().top + window.pageYOffset - 260;
window.scrollTo({ top: pos, behavior: 'smooth' });
target.style.backgroundColor = 'var(--primary-lv-1)';
setTimeout(() => target.style.backgroundColor = '', 2000);
}
}
// --- 크롤링 및 인증 제어 ---
async function syncData() {
if (isCrawling) {
if (confirm("크롤링을 중단하시겠습니까?")) {
const res = await fetch(API.STOP_SYNC);
if ((await res.json()).success) document.getElementById('syncBtn').innerText = "중단 요청 중...";
}
return;
}
document.getElementById('authId').value = '';
document.getElementById('authPw').value = '';
document.getElementById('authErrorMessage').style.display = 'none';
ModalManager.open('authModal');
}
async function submitAuth() {
const id = document.getElementById('authId').value, pw = document.getElementById('authPw').value, err = document.getElementById('authErrorMessage');
try {
const res = await fetch(API.AUTH_CRAWL, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ user_id: id, password: pw }) });
const data = await res.json();
if (data.success) { ModalManager.close('authModal'); startCrawlProcess(); }
else { err.innerText = "크롤링을 할 수 없습니다."; err.style.display = 'block'; }
} catch { err.innerText = "서버 연결 실패"; err.style.display = 'block'; }
}
async function startCrawlProcess() {
isCrawling = true;
const btn = document.getElementById('syncBtn'), logC = document.getElementById('logConsole'), logB = document.getElementById('logBody');
btn.classList.add('loading'); btn.style.backgroundColor = 'var(--error-color)'; btn.innerHTML = `<span class="spinner"></span> 크롤링 중단`;
logC.style.display = 'block'; logB.innerHTML = '<div style="color:#aaa; margin-bottom:10px;">>>> 엔진 초기화 중...</div>';
try {
const res = await fetch(API.SYNC);
const reader = res.body.getReader(), decoder = new TextDecoder();
while (true) {
const { done, value } = await reader.read(); if (done) break;
decoder.decode(value).split('\n').forEach(line => {
if (line.startsWith('data: ')) {
const p = JSON.parse(line.substring(6));
if (p.type === 'log') {
const div = document.createElement('div'); div.innerText = `[${new Date().toLocaleTimeString()}] ${p.message}`;
logB.appendChild(div); logC.scrollTop = logC.scrollHeight;
} else if (p.type === 'done') { init(); alert(`동기화 종료`); logC.style.display = 'none'; }
}
});
}
} catch { alert("스트림 끊김"); }
finally { isCrawling = false; btn.classList.remove('loading'); btn.style.backgroundColor = ''; btn.innerHTML = `<span class="spinner"></span> 데이터 동기화 (크롤링)`; }
}
document.addEventListener('DOMContentLoaded', init);

View File

@@ -1,314 +1,314 @@
/**
* Project Master Overseas Inquiries JS
* 기능: 문의사항 로드, 필터링, 답변 관리, 아코디언 및 이미지 모달
*/
// --- 초기화 ---
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 keyword = document.getElementById('searchKeyword').value;
const params = new URLSearchParams({
pm_type: pmType,
category: category,
keyword: keyword
});
try {
const response = await fetch(`${API.INQUIRIES}?${params}`);
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');
if (header && thead) {
const headerHeight = header.offsetHeight;
const totalOffset = 36 + headerHeight;
document.querySelectorAll('.inquiry-table thead th').forEach(th => {
th.style.top = totalOffset + 'px';
});
}
}
function renderInquiryList(data) {
const tbody = document.getElementById('inquiryList');
tbody.innerHTML = data.map(item => `
<tr class="inquiry-row" onclick="toggleAccordion(${item.id})">
<td title="${item.no}">${item.no}</td>
<td style="text-align:center;">
${item.image_url ? `<img src="${item.image_url}" class="img-thumbnail" alt="thumbnail">` : '<span class="no-img">없음</span>'}
</td>
<td title="${item.pm_type}">${item.pm_type}</td>
<td title="${item.browser || 'Chrome'}">${item.browser || 'Chrome'}</td>
<td title="${item.category}">${item.category}</td>
<td title="${item.project_nm}">${item.project_nm}</td>
<td class="content-preview" title="${item.content}">${item.content}</td>
<td title="${item.author}">${item.author}</td>
<td title="${item.reg_date}">${item.reg_date}</td>
<td class="content-preview" title="${item.reply || ''}" style="color: #1e5149; font-weight: 500;">${item.reply || '-'}</td>
<td><span class="status-badge ${Utils.getStatusClass(item.status)}">${item.status}</span></td>
</tr>
<tr id="detail-${item.id}" class="detail-row">
<td colspan="11">
<div class="detail-container">
<button class="btn-close-accordion" onclick="toggleAccordion(${item.id})">접기</button>
<div class="detail-content-wrapper">
<div class="detail-meta-grid">
<div><span class="detail-label">작성자:</span> ${item.author}</div>
<div><span class="detail-label">등록일:</span> ${item.reg_date}</div>
<div><span class="detail-label">시스템:</span> ${item.pm_type}</div>
<div><span class="detail-label">환경:</span> ${item.browser || 'Chrome'} / ${item.device || 'PC'}</div>
</div>
<div class="detail-q-section">
<h4 style="margin-top:0; margin-bottom:10px; color:#1e5149;">[질문 내용]</h4>
<div style="line-height:1.6; white-space: pre-wrap;">${item.content}</div>
</div>
${item.image_url ? `
<div class="detail-image-section" id="img-section-${item.id}">
<div class="image-section-header" onclick="toggleImageSection(${item.id})">
<h4>
<span>🖼️</span> [첨부 이미지]
<span style="font-size:11px; color:#888; font-weight:normal;">(클릭 시 크게 보기)</span>
</h4>
<span class="toggle-icon">▼</span>
</div>
<div class="image-section-content collapsed" id="img-content-${item.id}">
<img src="${item.image_url}" class="preview-img" alt="Inquiry Image" style="cursor: pointer;" onclick="event.stopPropagation(); openImageModal(this.src)">
</div>
</div>
` : ''}
<div class="detail-a-section">
<h4 style="margin-top:0; margin-bottom:10px; color:#1e5149;">[조치 및 답변]</h4>
<div id="reply-form-${item.id}" class="reply-edit-form readonly">
<textarea id="reply-text-${item.id}" disabled placeholder="답변 내용이 없습니다.">${item.reply || ''}</textarea>
<div style="display:flex; justify-content: space-between; align-items: center;">
<div style="display:flex; gap:15px; align-items:center;">
<div class="filter-group" style="flex-direction:row; align-items:center; gap:8px;">
<label style="margin:0;">처리상태:</label>
<select id="reply-status-${item.id}" disabled style="padding:5px 10px;">
<option value="완료" ${item.status === '완료' ? 'selected' : ''}>완료</option>
<option value="작업 중" ${item.status === '작업 중' ? 'selected' : ''}>작업 중</option>
<option value="확인 중" ${item.status === '확인 중' ? 'selected' : ''}>확인 중</option>
<option value="개발예정" ${item.status === '개발예정' ? 'selected' : ''}>개발예정</option>
<option value="미확인" ${item.status === '미확인' ? 'selected' : ''}>미확인</option>
</select>
</div>
<div class="filter-group" style="flex-direction:row; align-items:center; gap:8px;">
<label style="margin:0;">처리자:</label>
<input type="text" id="reply-handler-${item.id}" disabled value="${item.handler || ''}" placeholder="이름 입력" style="padding:5px 10px; width:100px;">
</div>
</div>
<div style="display:flex; gap:8px;">
<button class="btn-edit sync-btn" onclick="enableEdit(${item.id})" style="background:#1e5149; color:#fff; border:none;">${item.reply ? '수정하기' : '답변작성'}</button>
<button class="btn-save sync-btn" onclick="saveReply(${item.id})" style="background:#1e5149; color:#fff; border:none;">저장</button>
<button class="btn-delete sync-btn" onclick="deleteReply(${item.id})" style="background:#f44336; color:#fff; border:none;">삭제</button>
<button class="btn-cancel sync-btn" onclick="cancelEdit(${item.id})" style="background:#666; color:#fff; border:none;">취소</button>
</div>
</div>
${item.handled_date ? `<div style="margin-top:10px; font-size:12px; color:#888; text-align:right;">최종 수정일: ${item.handled_date}</div>` : ''}
</div>
</div>
</div>
</div>
</td>
</tr>
`).join('');
}
function enableEdit(id) {
const form = document.getElementById(`reply-form-${id}`);
form.classList.replace('readonly', 'editable');
const elements = [`reply-text-${id}`, `reply-status-${id}`, `reply-handler-${id}`];
elements.forEach(elId => document.getElementById(elId).disabled = false);
document.getElementById(`reply-text-${id}`).focus();
}
async function cancelEdit(id) {
try {
const response = await fetch(`${API.INQUIRIES}/${id}`);
const item = await response.json();
const txt = document.getElementById(`reply-text-${id}`);
const status = document.getElementById(`reply-status-${id}`);
const handler = document.getElementById(`reply-handler-${id}`);
txt.value = item.reply || '';
status.value = item.status;
handler.value = item.handler || '';
[txt, status, handler].forEach(el => el.disabled = true);
document.getElementById(`reply-form-${id}`).classList.replace('editable', 'readonly');
} catch { loadInquiries(); }
}
async function saveReply(id) {
const reply = document.getElementById(`reply-text-${id}`).value;
const status = document.getElementById(`reply-status-${id}`).value;
const handler = document.getElementById(`reply-handler-${id}`).value;
if (!reply.trim() || !handler.trim()) return alert("내용과 처리자를 모두 입력해 주세요.");
try {
const response = await fetch(`${API.INQUIRIES}/${id}/reply`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ reply, status, handler })
});
if ((await response.json()).success) { alert("저장되었습니다."); loadInquiries(); }
} catch { alert("저장 중 오류가 발생했습니다."); }
}
async function deleteReply(id) {
if (!confirm("답변을 삭제하시겠습니까?")) return;
try {
const response = await fetch(`${API.INQUIRIES}/${id}/reply`, { method: 'DELETE' });
if ((await response.json()).success) { alert("삭제되었습니다."); loadInquiries(); }
} catch { alert("삭제 중 오류가 발생했습니다."); }
}
function toggleAccordion(id) {
const detailRow = document.getElementById(`detail-${id}`);
if (!detailRow) return;
const inquiryRow = detailRow.previousElementSibling;
const isActive = detailRow.classList.contains('active');
document.querySelectorAll('.detail-row.active').forEach(row => {
if (row.id !== `detail-${id}`) {
row.classList.remove('active');
if (row.previousElementSibling) row.previousElementSibling.classList.remove('active-row');
}
});
if (isActive) {
detailRow.classList.remove('active');
inquiryRow.classList.remove('active-row');
} else {
detailRow.classList.add('active');
inquiryRow.classList.add('active-row');
scrollToRow(inquiryRow);
}
}
function scrollToRow(row) {
setTimeout(() => {
const headerHeight = document.getElementById('stickyHeader').offsetHeight;
const totalOffset = 36 + headerHeight + 40;
const offsetPosition = (row.getBoundingClientRect().top + window.pageYOffset) - totalOffset;
window.scrollTo({ top: offsetPosition, behavior: 'smooth' });
}, 100);
}
function updateStats(data) {
const counts = {
Total: data.length,
Complete: data.filter(i => i.status === '완료').length,
Working: data.filter(i => i.status === '작업 중').length,
Checking: data.filter(i => i.status === '확인 중').length,
Pending: data.filter(i => i.status === '개발예정').length,
Unconfirmed: data.filter(i => i.status === '미확인').length
};
Object.keys(counts).forEach(k => {
const el = document.getElementById(`count${k}`);
if (el) el.textContent = counts[k].toLocaleString();
});
}
function openImageModal(src) {
document.getElementById('modalImage').src = src;
ModalManager.open('imageModal');
}
function toggleImageSection(id) {
const section = document.getElementById(`img-section-${id}`);
const content = document.getElementById(`img-content-${id}`);
const icon = section.querySelector('.toggle-icon');
const isCollapsed = content.classList.toggle('collapsed');
section.classList.toggle('active', !isCollapsed);
icon.textContent = isCollapsed ? '▼' : '▲';
}
document.addEventListener('DOMContentLoaded', loadInquiries);
window.addEventListener('resize', initStickyHeader);
/**
* Project Master Overseas Inquiries JS
* 기능: 문의사항 로드, 필터링, 답변 관리, 아코디언 및 이미지 모달
*/
// --- 초기화 ---
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 keyword = document.getElementById('searchKeyword').value;
const params = new URLSearchParams({
pm_type: pmType,
category: category,
keyword: keyword
});
try {
const response = await fetch(`${API.INQUIRIES}?${params}`);
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');
if (header && thead) {
const headerHeight = header.offsetHeight;
const totalOffset = 36 + headerHeight;
document.querySelectorAll('.inquiry-table thead th').forEach(th => {
th.style.top = totalOffset + 'px';
});
}
}
function renderInquiryList(data) {
const tbody = document.getElementById('inquiryList');
tbody.innerHTML = data.map(item => `
<tr class="inquiry-row" onclick="toggleAccordion(${item.id})">
<td title="${item.no}">${item.no}</td>
<td style="text-align:center;">
${item.image_url ? `<img src="${item.image_url}" class="img-thumbnail" alt="thumbnail">` : '<span class="no-img">없음</span>'}
</td>
<td title="${item.pm_type}">${item.pm_type}</td>
<td title="${item.browser || 'Chrome'}">${item.browser || 'Chrome'}</td>
<td title="${item.category}">${item.category}</td>
<td title="${item.project_nm}">${item.project_nm}</td>
<td class="content-preview" title="${item.content}">${item.content}</td>
<td title="${item.author}">${item.author}</td>
<td title="${item.reg_date}">${item.reg_date}</td>
<td class="content-preview" title="${item.reply || ''}" style="color: #1e5149; font-weight: 500;">${item.reply || '-'}</td>
<td><span class="status-badge ${Utils.getStatusClass(item.status)}">${item.status}</span></td>
</tr>
<tr id="detail-${item.id}" class="detail-row">
<td colspan="11">
<div class="detail-container">
<button class="btn-close-accordion" onclick="toggleAccordion(${item.id})">접기</button>
<div class="detail-content-wrapper">
<div class="detail-meta-grid">
<div><span class="detail-label">작성자:</span> ${item.author}</div>
<div><span class="detail-label">등록일:</span> ${item.reg_date}</div>
<div><span class="detail-label">시스템:</span> ${item.pm_type}</div>
<div><span class="detail-label">환경:</span> ${item.browser || 'Chrome'} / ${item.device || 'PC'}</div>
</div>
<div class="detail-q-section">
<h4 style="margin-top:0; margin-bottom:10px; color:#1e5149;">[질문 내용]</h4>
<div style="line-height:1.6; white-space: pre-wrap;">${item.content}</div>
</div>
${item.image_url ? `
<div class="detail-image-section" id="img-section-${item.id}">
<div class="image-section-header" onclick="toggleImageSection(${item.id})">
<h4>
<span>🖼️</span> [첨부 이미지]
<span style="font-size:11px; color:#888; font-weight:normal;">(클릭 시 크게 보기)</span>
</h4>
<span class="toggle-icon">▼</span>
</div>
<div class="image-section-content collapsed" id="img-content-${item.id}">
<img src="${item.image_url}" class="preview-img" alt="Inquiry Image" style="cursor: pointer;" onclick="event.stopPropagation(); openImageModal(this.src)">
</div>
</div>
` : ''}
<div class="detail-a-section">
<h4 style="margin-top:0; margin-bottom:10px; color:#1e5149;">[조치 및 답변]</h4>
<div id="reply-form-${item.id}" class="reply-edit-form readonly">
<textarea id="reply-text-${item.id}" disabled placeholder="답변 내용이 없습니다.">${item.reply || ''}</textarea>
<div style="display:flex; justify-content: space-between; align-items: center;">
<div style="display:flex; gap:15px; align-items:center;">
<div class="filter-group" style="flex-direction:row; align-items:center; gap:8px;">
<label style="margin:0;">처리상태:</label>
<select id="reply-status-${item.id}" disabled style="padding:5px 10px;">
<option value="완료" ${item.status === '완료' ? 'selected' : ''}>완료</option>
<option value="작업 중" ${item.status === '작업 중' ? 'selected' : ''}>작업 중</option>
<option value="확인 중" ${item.status === '확인 중' ? 'selected' : ''}>확인 중</option>
<option value="개발예정" ${item.status === '개발예정' ? 'selected' : ''}>개발예정</option>
<option value="미확인" ${item.status === '미확인' ? 'selected' : ''}>미확인</option>
</select>
</div>
<div class="filter-group" style="flex-direction:row; align-items:center; gap:8px;">
<label style="margin:0;">처리자:</label>
<input type="text" id="reply-handler-${item.id}" disabled value="${item.handler || ''}" placeholder="이름 입력" style="padding:5px 10px; width:100px;">
</div>
</div>
<div style="display:flex; gap:8px;">
<button class="btn-edit sync-btn" onclick="enableEdit(${item.id})" style="background:#1e5149; color:#fff; border:none;">${item.reply ? '수정하기' : '답변작성'}</button>
<button class="btn-save sync-btn" onclick="saveReply(${item.id})" style="background:#1e5149; color:#fff; border:none;">저장</button>
<button class="btn-delete sync-btn" onclick="deleteReply(${item.id})" style="background:#f44336; color:#fff; border:none;">삭제</button>
<button class="btn-cancel sync-btn" onclick="cancelEdit(${item.id})" style="background:#666; color:#fff; border:none;">취소</button>
</div>
</div>
${item.handled_date ? `<div style="margin-top:10px; font-size:12px; color:#888; text-align:right;">최종 수정일: ${item.handled_date}</div>` : ''}
</div>
</div>
</div>
</div>
</td>
</tr>
`).join('');
}
function enableEdit(id) {
const form = document.getElementById(`reply-form-${id}`);
form.classList.replace('readonly', 'editable');
const elements = [`reply-text-${id}`, `reply-status-${id}`, `reply-handler-${id}`];
elements.forEach(elId => document.getElementById(elId).disabled = false);
document.getElementById(`reply-text-${id}`).focus();
}
async function cancelEdit(id) {
try {
const response = await fetch(`${API.INQUIRIES}/${id}`);
const item = await response.json();
const txt = document.getElementById(`reply-text-${id}`);
const status = document.getElementById(`reply-status-${id}`);
const handler = document.getElementById(`reply-handler-${id}`);
txt.value = item.reply || '';
status.value = item.status;
handler.value = item.handler || '';
[txt, status, handler].forEach(el => el.disabled = true);
document.getElementById(`reply-form-${id}`).classList.replace('editable', 'readonly');
} catch { loadInquiries(); }
}
async function saveReply(id) {
const reply = document.getElementById(`reply-text-${id}`).value;
const status = document.getElementById(`reply-status-${id}`).value;
const handler = document.getElementById(`reply-handler-${id}`).value;
if (!reply.trim() || !handler.trim()) return alert("내용과 처리자를 모두 입력해 주세요.");
try {
const response = await fetch(`${API.INQUIRIES}/${id}/reply`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ reply, status, handler })
});
if ((await response.json()).success) { alert("저장되었습니다."); loadInquiries(); }
} catch { alert("저장 중 오류가 발생했습니다."); }
}
async function deleteReply(id) {
if (!confirm("답변을 삭제하시겠습니까?")) return;
try {
const response = await fetch(`${API.INQUIRIES}/${id}/reply`, { method: 'DELETE' });
if ((await response.json()).success) { alert("삭제되었습니다."); loadInquiries(); }
} catch { alert("삭제 중 오류가 발생했습니다."); }
}
function toggleAccordion(id) {
const detailRow = document.getElementById(`detail-${id}`);
if (!detailRow) return;
const inquiryRow = detailRow.previousElementSibling;
const isActive = detailRow.classList.contains('active');
document.querySelectorAll('.detail-row.active').forEach(row => {
if (row.id !== `detail-${id}`) {
row.classList.remove('active');
if (row.previousElementSibling) row.previousElementSibling.classList.remove('active-row');
}
});
if (isActive) {
detailRow.classList.remove('active');
inquiryRow.classList.remove('active-row');
} else {
detailRow.classList.add('active');
inquiryRow.classList.add('active-row');
scrollToRow(inquiryRow);
}
}
function scrollToRow(row) {
setTimeout(() => {
const headerHeight = document.getElementById('stickyHeader').offsetHeight;
const totalOffset = 36 + headerHeight + 40;
const offsetPosition = (row.getBoundingClientRect().top + window.pageYOffset) - totalOffset;
window.scrollTo({ top: offsetPosition, behavior: 'smooth' });
}, 100);
}
function updateStats(data) {
const counts = {
Total: data.length,
Complete: data.filter(i => i.status === '완료').length,
Working: data.filter(i => i.status === '작업 중').length,
Checking: data.filter(i => i.status === '확인 중').length,
Pending: data.filter(i => i.status === '개발예정').length,
Unconfirmed: data.filter(i => i.status === '미확인').length
};
Object.keys(counts).forEach(k => {
const el = document.getElementById(`count${k}`);
if (el) el.textContent = counts[k].toLocaleString();
});
}
function openImageModal(src) {
document.getElementById('modalImage').src = src;
ModalManager.open('imageModal');
}
function toggleImageSection(id) {
const section = document.getElementById(`img-section-${id}`);
const content = document.getElementById(`img-content-${id}`);
const icon = section.querySelector('.toggle-icon');
const isCollapsed = content.classList.toggle('collapsed');
section.classList.toggle('active', !isCollapsed);
icon.textContent = isCollapsed ? '▼' : '▲';
}
document.addEventListener('DOMContentLoaded', loadInquiries);
window.addEventListener('resize', initStickyHeader);

View File

@@ -1,312 +1,312 @@
/**
* Project Master Overseas Mail Management JS
* 기능: 첨부파일 로드, AI 분석, 메일 목록 렌더링, 미리보기, 주소록 관리
*/
let currentFiles = [];
let editingIndex = -1;
const HIERARCHY = {
"행정": { "계약": ["계약관리", "기성관리", "업무지시서", "인원관리"], "업무관리": ["업무일지(2025)", "업무일지(2025년 이전)", "발주처 정기보고", "본사업무보고", "공사감독일지", "양식서류"] },
"설계성과품": { "시방서": ["공사시방서"], "설계도면": ["공통", "토공", "비탈면안전공", "배수공", "교량공", "포장공"], "수량산출서": ["토공", "배수공"], "내역서": ["단가산출서"], "보고서": ["실시설계보고서", "지반조사보고서"], "측량계산부": ["측량계산부"], "설계단계 수행협의": ["회의·협의"] },
"시공검측": { "토공": ["검측 (깨기)", "검측 (노체)"], "배수공": ["검측 (V형측구)", "검측 (종배수관)"], "구조물공": ["검측 (평목교)"], "포장공": ["검측 (기층)"] },
"설계변경": { "실정보고": ["토공", "배수공", "안전관리"], "기술지원 검토": ["토공", "구조물&부대공"] }
};
const MAIL_SAMPLES = {
inbound: [
{ person: "라오스 농림부", email: "pany.s@lao.gov.la", time: "2026-03-05", title: "ITTC 교육센터 착공식 일정 협의", summary: "착공식 관련하여 정부 측 인사의 일정을 반영한 최종 공문을 송부합니다.", active: true },
{ person: "현대건설 (김철수 소장)", email: "cs.kim@hdec.co.kr", time: "2026-03-04", title: "[긴급] 어천-공주(4차) 하도급 변경계약 통보", summary: "철거공사 물량 변동에 따른 계약 금액 조정 건입니다. 검토 후 승인 부탁드립니다.", active: false }
],
outbound: [
{ person: "공사관리부 (본사)", email: "hq_pm@projectmaster.com", time: "2026-03-04", title: "어천-공주 2월 월간 공정보고서 제출", summary: "2월 한 달간의 주요 공정 및 예산 집행 현황 보고서입니다.", active: false }
],
drafts: [], deleted: []
};
let currentMailTab = 'inbound';
let filteredMails = [];
// --- 첨부파일 데이터 로드 및 렌더링 ---
async function loadAttachments() {
try {
const res = await fetch(API.ATTACHMENTS);
currentFiles = await res.json();
renderFiles();
} catch (e) { console.error("Failed to load attachments:", e); }
}
function renderFiles() {
const isAiActive = document.getElementById('aiToggle').checked;
const container = document.getElementById('attachmentList');
if (!container) return;
container.innerHTML = '';
currentFiles.forEach((file, index) => {
const item = document.createElement('div');
item.className = 'attachment-item-wrap';
item.style.marginBottom = "8px";
let pathText = "경로를 선택해주세요";
let modeClass = "manual-mode";
if (file.analysis) {
const prefix = file.analysis.isManual ? "선택 경로: " : "추천: ";
pathText = `${prefix}${file.analysis.suggested_path}`;
modeClass = file.analysis.isManual ? "manual-mode" : "smart-mode";
} else if (isAiActive) {
pathText = "AI 분석 대기 중...";
modeClass = "smart-mode";
}
item.innerHTML = `
<div class="attachment-item" onclick="showPreview(${index}, event)" style="position:relative;">
<span class="file-icon" style="pointer-events:none;">📄</span>
<div class="file-details" style="pointer-events:none;">
<div class="file-name" title="${file.name}">${file.name}</div>
<div class="file-size">${file.size}</div>
</div>
<div class="btn-group" onclick="event.stopPropagation()" style="position:relative; z-index:2;">
<span id="recommend-${index}" class="ai-recommend path-display ${modeClass}" onclick="openPathModal(${index}, event)">${pathText}</span>
${isAiActive ? `<button class="btn-upload btn-ai" onclick="startAnalysis(${index}, event)">AI 분석</button>` : ''}
<button class="btn-upload btn-normal" onclick="confirmUpload(${index}, event)">파일업로드</button>
</div>
</div>
<div id="log-area-${index}" class="file-log-area"><div id="log-content-${index}"></div></div>
`;
container.appendChild(item);
});
}
// --- AI 분석 실행 ---
async function startAnalysis(index, event) {
if (event) event.stopPropagation();
const file = currentFiles[index];
if (!file) return;
// UI 상태 업데이트: 분석 중 표시
const logArea = document.getElementById(`log-area-${index}`);
const logContent = document.getElementById(`log-content-${index}`);
if (logArea) logArea.classList.add('active');
if (logContent) {
logContent.innerHTML = `<div class="ai-status-msg">
<span class="ai-loading-spinner"></span>
AI가 문서를 정밀 분석 중입니다...
</div>`;
}
try {
const res = await fetch(`${API.ANALYZE_FILE}?filename=${encodeURIComponent(file.name)}`);
const result = await res.json();
if (result.error) {
if (logContent) logContent.innerHTML = `<div style="color:var(--error-color);">오류: ${result.error}</div>`;
return;
}
// 분석 결과 저장 및 UI 갱신
currentFiles[index].analysis = result.final_result;
currentFiles[index].analysis.isManual = false;
if (logContent) {
logContent.innerHTML = `
<div class="ai-analysis-result">
<div style="font-weight:700; color:var(--primary-lv-6); margin-bottom:4px;">✨ AI 분석 완료</div>
<div style="font-size:11px; color:var(--text-sub); line-height:1.4;">${result.final_result.reason}</div>
</div>
`;
}
renderFiles();
} catch (e) {
console.error("AI Analysis failed:", e);
if (logContent) logContent.innerHTML = `<div style="color:var(--error-color);">분석 실패: 네트워크 오류가 발생했습니다.</div>`;
}
}
// --- 미리보기 제어 ---
function showPreview(index, event) {
if (event && (event.target.closest('.btn-group') || event.target.closest('.path-display'))) return;
const file = currentFiles[index];
if (!file) return;
const previewArea = document.getElementById('mailPreviewArea');
const toggleIcon = document.getElementById('previewToggleIcon');
const fullViewBtn = document.getElementById('fullViewBtn');
const previewContainer = document.getElementById('previewContainer');
if (previewArea) {
previewArea.classList.add('active');
if (toggleIcon) toggleIcon.innerText = '▶';
}
const fileUrl = Utils.getSafeFileUrl(file.name);
if (fullViewBtn) {
fullViewBtn.style.display = 'block';
fullViewBtn.onclick = () => window.open(fileUrl, 'PMFullView', 'width=1000,height=800');
}
if (file.name.toLowerCase().endsWith('.pdf')) {
previewContainer.innerHTML = `<iframe src="${fileUrl}#page=1" style="width:100%; height:100%; border:none;"></iframe>`;
} else {
previewContainer.innerHTML = `<div class="flex-column flex-center" style="height:100%; padding:20px; text-align:center;"><img src="/sample.png" style="max-width:80%; max-height:60%; margin-bottom:20px;"><div style="font-weight:700; color:var(--primary-color);">${file.name}</div></div>`;
}
document.querySelectorAll('.attachment-item').forEach(item => item.classList.remove('active'));
if (event?.currentTarget) event.currentTarget.classList.add('active');
}
function togglePreviewAuto() {
const area = document.getElementById('mailPreviewArea');
const icon = document.getElementById('previewToggleIcon');
if (area) {
const isActive = area.classList.toggle('active');
if (icon) icon.innerText = isActive ? '▶' : '◀';
}
}
// --- 메일 리스트 제어 ---
function renderMailList(tabType, mailsToShow = null) {
currentMailTab = tabType;
const container = document.querySelector('.mail-items-container');
if (!container) return;
const mails = mailsToShow || MAIL_SAMPLES[tabType] || [];
filteredMails = mails;
updateBulkActionBar();
container.innerHTML = mails.map((mail, idx) => `
<div class="mail-item ${mail.active ? 'active' : ''}" onclick="selectMailItem(this, ${idx})">
<input type="checkbox" class="mail-item-checkbox" onclick="event.stopPropagation()" onchange="updateBulkActionBar()">
<div class="mail-item-content">
<div class="flex-between" style="margin-bottom:6px;">
<span style="font-weight:700; font-size:14px; color:${mail.active ? 'var(--primary-color)' : 'var(--text-main)'};">${mail.person}</span>
<div class="mail-item-info"><span class="mail-date">${mail.time}</span></div>
</div>
<div style="font-weight:600; font-size:12px; margin-bottom:4px; color:#2d3748;">${mail.title}</div>
<div class="text-truncate" style="-webkit-line-clamp:2; display:-webkit-box; -webkit-box-orient:vertical; white-space:normal; font-size:11px; color:var(--text-sub);">${mail.summary}</div>
</div>
</div>
`).join('');
const activeIdx = mails.findIndex(m => m.active);
if (activeIdx !== -1) updateMailContent(mails[activeIdx]);
}
function selectMailItem(el, index) {
document.querySelectorAll('.mail-item').forEach(item => item.classList.remove('active'));
el.classList.add('active');
const mail = filteredMails[index];
if (mail) updateMailContent(mail);
}
function updateMailContent(mail) {
const title = document.querySelector('.mail-content-header h2');
if (title) title.innerText = mail.title;
document.querySelector('.mail-body').innerHTML = mail.summary.replace(/\n/g, '<br>') + "<br><br>본 내용은 샘플 데이터입니다.";
}
function switchMailTab(el, tabType) {
document.querySelectorAll('.mail-tab').forEach(tab => tab.classList.remove('active'));
el.classList.add('active');
renderMailList(tabType);
}
// --- 경로 선택 모달 ---
function openPathModal(index, event) {
if (event) event.stopPropagation();
editingIndex = index;
const tabSelect = document.getElementById('tabSelect');
if (tabSelect) {
tabSelect.innerHTML = Object.keys(HIERARCHY).map(tab => `<option value="${tab}">${tab}</option>`).join('');
updateCategories();
ModalManager.open('pathModal');
}
}
function updateCategories() {
const tab = document.getElementById('tabSelect').value;
document.getElementById('categorySelect').innerHTML = Object.keys(HIERARCHY[tab]).map(cat => `<option value="${cat}">${cat}</option>`).join('');
updateSubs();
}
function updateSubs() {
const tab = document.getElementById('tabSelect').value;
const cat = document.getElementById('categorySelect').value;
document.getElementById('subSelect').innerHTML = HIERARCHY[tab][cat].map(sub => `<option value="${sub}">${sub}</option>`).join('');
}
function applyPathSelection() {
const path = `${document.getElementById('tabSelect').value} > ${document.getElementById('categorySelect').value} > ${document.getElementById('subSelect').value}`;
if (!currentFiles[editingIndex].analysis) currentFiles[editingIndex].analysis = {};
currentFiles[editingIndex].analysis.suggested_path = path;
currentFiles[editingIndex].analysis.isManual = true;
renderFiles();
ModalManager.close('pathModal');
}
// --- 주소록 관리 ---
let addressBookData = [
{ name: "이태훈", dept: "PM Overseas / 선임연구원", email: "th.lee@projectmaster.com", phone: "010-1234-5678" },
{ name: "Pany S.", dept: "라오스 농림부 / 국장", email: "pany.s@lao.gov.la", phone: "+856-20-1234-5678" }
];
let contactEditingIndex = -1;
function openAddressBook() { renderAddressBook(); ModalManager.open('addressBookModal'); }
function closeAddressBook() { ModalManager.close('addressBookModal'); }
function renderAddressBook() {
const body = document.getElementById('addressBookBody');
if (!body) return;
body.innerHTML = addressBookData.map((c, idx) => `
<tr>
<td><strong>${c.name}</strong></td><td>${c.dept}</td><td>${c.email}</td><td>${c.phone}</td>
<td style="text-align:right;">
<button class="_button-xsmall" onclick="editContact(${idx})">수정</button>
<button class="_button-xsmall" style="color:var(--error-color); border-color:#feb2b2; background:#fff5f5;" onclick="deleteContact(${idx})">삭제</button>
</td>
</tr>`).join('');
}
function toggleAddContactForm() {
const form = document.getElementById('addContactForm');
if (form.style.display === 'none') form.style.display = 'block';
else { form.style.display = 'none'; contactEditingIndex = -1; }
}
function editContact(index) {
const c = addressBookData[index];
contactEditingIndex = index;
document.getElementById('newContactName').value = c.name;
document.getElementById('newContactDept').value = c.dept;
document.getElementById('newContactEmail').value = c.email;
document.getElementById('newContactPhone').value = c.phone;
document.getElementById('addContactForm').style.display = 'block';
}
function deleteContact(index) {
if (confirm(`'${addressBookData[index].name}'님을 삭제하시겠습니까?`)) { addressBookData.splice(index, 1); renderAddressBook(); }
}
function addContact() {
const name = document.getElementById('newContactName').value;
if (!name) return alert("이름을 입력해주세요.");
const data = { name, dept: document.getElementById('newContactDept').value, email: document.getElementById('newContactEmail').value, phone: document.getElementById('newContactPhone').value };
if (contactEditingIndex > -1) addressBookData[contactEditingIndex] = data;
else addressBookData.push(data);
renderAddressBook(); toggleAddContactForm();
}
// --- 공통 액션 ---
function updateBulkActionBar() {
const count = document.querySelectorAll('.mail-item-checkbox:checked').length;
const bar = document.getElementById('mailBulkActions');
if (count > 0) { bar.classList.add('active'); document.getElementById('selectedCount').innerText = `${count}개 선택됨`; }
else bar.classList.remove('active');
}
// 초기화
document.addEventListener('DOMContentLoaded', () => {
loadAttachments();
renderMailList('inbound');
});
/**
* Project Master Overseas Mail Management JS
* 기능: 첨부파일 로드, AI 분석, 메일 목록 렌더링, 미리보기, 주소록 관리
*/
let currentFiles = [];
let editingIndex = -1;
const HIERARCHY = {
"행정": { "계약": ["계약관리", "기성관리", "업무지시서", "인원관리"], "업무관리": ["업무일지(2025)", "업무일지(2025년 이전)", "발주처 정기보고", "본사업무보고", "공사감독일지", "양식서류"] },
"설계성과품": { "시방서": ["공사시방서"], "설계도면": ["공통", "토공", "비탈면안전공", "배수공", "교량공", "포장공"], "수량산출서": ["토공", "배수공"], "내역서": ["단가산출서"], "보고서": ["실시설계보고서", "지반조사보고서"], "측량계산부": ["측량계산부"], "설계단계 수행협의": ["회의·협의"] },
"시공검측": { "토공": ["검측 (깨기)", "검측 (노체)"], "배수공": ["검측 (V형측구)", "검측 (종배수관)"], "구조물공": ["검측 (평목교)"], "포장공": ["검측 (기층)"] },
"설계변경": { "실정보고": ["토공", "배수공", "안전관리"], "기술지원 검토": ["토공", "구조물&부대공"] }
};
const MAIL_SAMPLES = {
inbound: [
{ person: "라오스 농림부", email: "pany.s@lao.gov.la", time: "2026-03-05", title: "ITTC 교육센터 착공식 일정 협의", summary: "착공식 관련하여 정부 측 인사의 일정을 반영한 최종 공문을 송부합니다.", active: true },
{ person: "현대건설 (김철수 소장)", email: "cs.kim@hdec.co.kr", time: "2026-03-04", title: "[긴급] 어천-공주(4차) 하도급 변경계약 통보", summary: "철거공사 물량 변동에 따른 계약 금액 조정 건입니다. 검토 후 승인 부탁드립니다.", active: false }
],
outbound: [
{ person: "공사관리부 (본사)", email: "hq_pm@projectmaster.com", time: "2026-03-04", title: "어천-공주 2월 월간 공정보고서 제출", summary: "2월 한 달간의 주요 공정 및 예산 집행 현황 보고서입니다.", active: false }
],
drafts: [], deleted: []
};
let currentMailTab = 'inbound';
let filteredMails = [];
// --- 첨부파일 데이터 로드 및 렌더링 ---
async function loadAttachments() {
try {
const res = await fetch(API.ATTACHMENTS);
currentFiles = await res.json();
renderFiles();
} catch (e) { console.error("Failed to load attachments:", e); }
}
function renderFiles() {
const isAiActive = document.getElementById('aiToggle').checked;
const container = document.getElementById('attachmentList');
if (!container) return;
container.innerHTML = '';
currentFiles.forEach((file, index) => {
const item = document.createElement('div');
item.className = 'attachment-item-wrap';
item.style.marginBottom = "8px";
let pathText = "경로를 선택해주세요";
let modeClass = "manual-mode";
if (file.analysis) {
const prefix = file.analysis.isManual ? "선택 경로: " : "추천: ";
pathText = `${prefix}${file.analysis.suggested_path}`;
modeClass = file.analysis.isManual ? "manual-mode" : "smart-mode";
} else if (isAiActive) {
pathText = "AI 분석 대기 중...";
modeClass = "smart-mode";
}
item.innerHTML = `
<div class="attachment-item" onclick="showPreview(${index}, event)" style="position:relative;">
<span class="file-icon" style="pointer-events:none;">📄</span>
<div class="file-details" style="pointer-events:none;">
<div class="file-name" title="${file.name}">${file.name}</div>
<div class="file-size">${file.size}</div>
</div>
<div class="btn-group" onclick="event.stopPropagation()" style="position:relative; z-index:2;">
<span id="recommend-${index}" class="ai-recommend path-display ${modeClass}" onclick="openPathModal(${index}, event)">${pathText}</span>
${isAiActive ? `<button class="btn-upload btn-ai" onclick="startAnalysis(${index}, event)">AI 분석</button>` : ''}
<button class="btn-upload btn-normal" onclick="confirmUpload(${index}, event)">파일업로드</button>
</div>
</div>
<div id="log-area-${index}" class="file-log-area"><div id="log-content-${index}"></div></div>
`;
container.appendChild(item);
});
}
// --- AI 분석 실행 ---
async function startAnalysis(index, event) {
if (event) event.stopPropagation();
const file = currentFiles[index];
if (!file) return;
// UI 상태 업데이트: 분석 중 표시
const logArea = document.getElementById(`log-area-${index}`);
const logContent = document.getElementById(`log-content-${index}`);
if (logArea) logArea.classList.add('active');
if (logContent) {
logContent.innerHTML = `<div class="ai-status-msg">
<span class="ai-loading-spinner"></span>
AI가 문서를 정밀 분석 중입니다...
</div>`;
}
try {
const res = await fetch(`${API.ANALYZE_FILE}?filename=${encodeURIComponent(file.name)}`);
const result = await res.json();
if (result.error) {
if (logContent) logContent.innerHTML = `<div style="color:var(--error-color);">오류: ${result.error}</div>`;
return;
}
// 분석 결과 저장 및 UI 갱신
currentFiles[index].analysis = result.final_result;
currentFiles[index].analysis.isManual = false;
if (logContent) {
logContent.innerHTML = `
<div class="ai-analysis-result">
<div style="font-weight:700; color:var(--primary-lv-6); margin-bottom:4px;">✨ AI 분석 완료</div>
<div style="font-size:11px; color:var(--text-sub); line-height:1.4;">${result.final_result.reason}</div>
</div>
`;
}
renderFiles();
} catch (e) {
console.error("AI Analysis failed:", e);
if (logContent) logContent.innerHTML = `<div style="color:var(--error-color);">분석 실패: 네트워크 오류가 발생했습니다.</div>`;
}
}
// --- 미리보기 제어 ---
function showPreview(index, event) {
if (event && (event.target.closest('.btn-group') || event.target.closest('.path-display'))) return;
const file = currentFiles[index];
if (!file) return;
const previewArea = document.getElementById('mailPreviewArea');
const toggleIcon = document.getElementById('previewToggleIcon');
const fullViewBtn = document.getElementById('fullViewBtn');
const previewContainer = document.getElementById('previewContainer');
if (previewArea) {
previewArea.classList.add('active');
if (toggleIcon) toggleIcon.innerText = '▶';
}
const fileUrl = Utils.getSafeFileUrl(file.name);
if (fullViewBtn) {
fullViewBtn.style.display = 'block';
fullViewBtn.onclick = () => window.open(fileUrl, 'PMFullView', 'width=1000,height=800');
}
if (file.name.toLowerCase().endsWith('.pdf')) {
previewContainer.innerHTML = `<iframe src="${fileUrl}#page=1" style="width:100%; height:100%; border:none;"></iframe>`;
} else {
previewContainer.innerHTML = `<div class="flex-column flex-center" style="height:100%; padding:20px; text-align:center;"><img src="/sample.png" style="max-width:80%; max-height:60%; margin-bottom:20px;"><div style="font-weight:700; color:var(--primary-color);">${file.name}</div></div>`;
}
document.querySelectorAll('.attachment-item').forEach(item => item.classList.remove('active'));
if (event?.currentTarget) event.currentTarget.classList.add('active');
}
function togglePreviewAuto() {
const area = document.getElementById('mailPreviewArea');
const icon = document.getElementById('previewToggleIcon');
if (area) {
const isActive = area.classList.toggle('active');
if (icon) icon.innerText = isActive ? '▶' : '◀';
}
}
// --- 메일 리스트 제어 ---
function renderMailList(tabType, mailsToShow = null) {
currentMailTab = tabType;
const container = document.querySelector('.mail-items-container');
if (!container) return;
const mails = mailsToShow || MAIL_SAMPLES[tabType] || [];
filteredMails = mails;
updateBulkActionBar();
container.innerHTML = mails.map((mail, idx) => `
<div class="mail-item ${mail.active ? 'active' : ''}" onclick="selectMailItem(this, ${idx})">
<input type="checkbox" class="mail-item-checkbox" onclick="event.stopPropagation()" onchange="updateBulkActionBar()">
<div class="mail-item-content">
<div class="flex-between" style="margin-bottom:6px;">
<span style="font-weight:700; font-size:14px; color:${mail.active ? 'var(--primary-color)' : 'var(--text-main)'};">${mail.person}</span>
<div class="mail-item-info"><span class="mail-date">${mail.time}</span></div>
</div>
<div style="font-weight:600; font-size:12px; margin-bottom:4px; color:#2d3748;">${mail.title}</div>
<div class="text-truncate" style="-webkit-line-clamp:2; display:-webkit-box; -webkit-box-orient:vertical; white-space:normal; font-size:11px; color:var(--text-sub);">${mail.summary}</div>
</div>
</div>
`).join('');
const activeIdx = mails.findIndex(m => m.active);
if (activeIdx !== -1) updateMailContent(mails[activeIdx]);
}
function selectMailItem(el, index) {
document.querySelectorAll('.mail-item').forEach(item => item.classList.remove('active'));
el.classList.add('active');
const mail = filteredMails[index];
if (mail) updateMailContent(mail);
}
function updateMailContent(mail) {
const title = document.querySelector('.mail-content-header h2');
if (title) title.innerText = mail.title;
document.querySelector('.mail-body').innerHTML = mail.summary.replace(/\n/g, '<br>') + "<br><br>본 내용은 샘플 데이터입니다.";
}
function switchMailTab(el, tabType) {
document.querySelectorAll('.mail-tab').forEach(tab => tab.classList.remove('active'));
el.classList.add('active');
renderMailList(tabType);
}
// --- 경로 선택 모달 ---
function openPathModal(index, event) {
if (event) event.stopPropagation();
editingIndex = index;
const tabSelect = document.getElementById('tabSelect');
if (tabSelect) {
tabSelect.innerHTML = Object.keys(HIERARCHY).map(tab => `<option value="${tab}">${tab}</option>`).join('');
updateCategories();
ModalManager.open('pathModal');
}
}
function updateCategories() {
const tab = document.getElementById('tabSelect').value;
document.getElementById('categorySelect').innerHTML = Object.keys(HIERARCHY[tab]).map(cat => `<option value="${cat}">${cat}</option>`).join('');
updateSubs();
}
function updateSubs() {
const tab = document.getElementById('tabSelect').value;
const cat = document.getElementById('categorySelect').value;
document.getElementById('subSelect').innerHTML = HIERARCHY[tab][cat].map(sub => `<option value="${sub}">${sub}</option>`).join('');
}
function applyPathSelection() {
const path = `${document.getElementById('tabSelect').value} > ${document.getElementById('categorySelect').value} > ${document.getElementById('subSelect').value}`;
if (!currentFiles[editingIndex].analysis) currentFiles[editingIndex].analysis = {};
currentFiles[editingIndex].analysis.suggested_path = path;
currentFiles[editingIndex].analysis.isManual = true;
renderFiles();
ModalManager.close('pathModal');
}
// --- 주소록 관리 ---
let addressBookData = [
{ name: "이태훈", dept: "PM Overseas / 선임연구원", email: "th.lee@projectmaster.com", phone: "010-1234-5678" },
{ name: "Pany S.", dept: "라오스 농림부 / 국장", email: "pany.s@lao.gov.la", phone: "+856-20-1234-5678" }
];
let contactEditingIndex = -1;
function openAddressBook() { renderAddressBook(); ModalManager.open('addressBookModal'); }
function closeAddressBook() { ModalManager.close('addressBookModal'); }
function renderAddressBook() {
const body = document.getElementById('addressBookBody');
if (!body) return;
body.innerHTML = addressBookData.map((c, idx) => `
<tr>
<td><strong>${c.name}</strong></td><td>${c.dept}</td><td>${c.email}</td><td>${c.phone}</td>
<td style="text-align:right;">
<button class="_button-xsmall" onclick="editContact(${idx})">수정</button>
<button class="_button-xsmall" style="color:var(--error-color); border-color:#feb2b2; background:#fff5f5;" onclick="deleteContact(${idx})">삭제</button>
</td>
</tr>`).join('');
}
function toggleAddContactForm() {
const form = document.getElementById('addContactForm');
if (form.style.display === 'none') form.style.display = 'block';
else { form.style.display = 'none'; contactEditingIndex = -1; }
}
function editContact(index) {
const c = addressBookData[index];
contactEditingIndex = index;
document.getElementById('newContactName').value = c.name;
document.getElementById('newContactDept').value = c.dept;
document.getElementById('newContactEmail').value = c.email;
document.getElementById('newContactPhone').value = c.phone;
document.getElementById('addContactForm').style.display = 'block';
}
function deleteContact(index) {
if (confirm(`'${addressBookData[index].name}'님을 삭제하시겠습니까?`)) { addressBookData.splice(index, 1); renderAddressBook(); }
}
function addContact() {
const name = document.getElementById('newContactName').value;
if (!name) return alert("이름을 입력해주세요.");
const data = { name, dept: document.getElementById('newContactDept').value, email: document.getElementById('newContactEmail').value, phone: document.getElementById('newContactPhone').value };
if (contactEditingIndex > -1) addressBookData[contactEditingIndex] = data;
else addressBookData.push(data);
renderAddressBook(); toggleAddContactForm();
}
// --- 공통 액션 ---
function updateBulkActionBar() {
const count = document.querySelectorAll('.mail-item-checkbox:checked').length;
const bar = document.getElementById('mailBulkActions');
if (count > 0) { bar.classList.add('active'); document.getElementById('selectedCount').innerText = `${count}개 선택됨`; }
else bar.classList.remove('active');
}
// 초기화
document.addEventListener('DOMContentLoaded', () => {
loadAttachments();
renderMailList('inbound');
});