Files
ITAM/src/views/Dashboard/HwDashboard.ts

1768 lines
87 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { state } from '../../core/state';
import { openHwModal } from '../../components/Modal/HWModal';
import { calculateAssetAge, normalizeDate } from '../../core/utils';
import { ASSET_SCHEMA } from '../../core/schema';
import { createIcons, DollarSign, Monitor, AlertTriangle, Activity, ChevronLeft, ChevronRight, UserCheck, TrendingUp, TrendingDown, Building2, X, FileText } from 'lucide';
declare var Chart: any;
declare global {
interface Window { lucide: any; }
}
let jobChartInstance: any = null;
let totalPcMismatchByCorpChartInstance: any = null;
let totalServerMismatchByPurposeChartInstance: any = null;
// 4p charts
let jobChartInstance4p: any = null;
let corpChartInstance4p: any = null;
let totalServerMismatchByPurposeChartInstance4p: any = null;
let serverServiceChartInstance4p: any = null;
let serverStatusChartInstance4p: any = null;
let pcFlowChartInstance: any = null;
// ─── 서버 용도별 카테고리 분류 헬퍼 ───
function categorizePurpose(purpose: string): string {
if (!purpose) return '기타/일반';
const lower = purpose.toLowerCase();
if (
lower.includes('해석') ||
lower.includes('abaqus') ||
lower.includes('ai') ||
lower.includes('시뮬레이션') ||
lower.includes('processing') ||
lower.includes('매핑') ||
lower.includes('측량') ||
lower.includes('렌더링') ||
lower.includes('gpu')
) {
return '해석/분석/AI';
}
if (
lower.includes('개발') ||
lower.includes('test') ||
lower.includes('테스트') ||
lower.includes('dev') ||
lower.includes('unity') ||
lower.includes('유니티')
) {
return '개발/테스트';
}
if (
lower.includes('was') ||
lower.includes('web') ||
lower.includes('웹') ||
lower.includes('배포') ||
lower.includes('nginx') ||
lower.includes('apache') ||
lower.includes('홈페이지') ||
lower.includes('서비스')
) {
return '서비스/웹/WAS';
}
if (
lower.includes('postgresql') ||
lower.includes('postgres') ||
lower.includes('db') ||
lower.includes('데이터') ||
lower.includes('스토리지') ||
lower.includes('storage') ||
lower.includes('mysql') ||
lower.includes('sql') ||
lower.includes('oracle')
) {
return 'DB/스토리지';
}
if (
lower.includes('백업') ||
lower.includes('backup') ||
lower.includes('ids') ||
lower.includes('ips') ||
lower.includes('crowdsec') ||
lower.includes('opnsense') ||
lower.includes('관리') ||
lower.includes('인증') ||
lower.includes('보안')
) {
return '백업/관리/보안';
}
return '기타/일반';
}
// ─── 네트워크 트래픽 문자열을 숫자(GB)로 파싱하는 헬퍼 ───
function parseTrafficToGb(trafficStr: string): number {
if (!trafficStr || trafficStr === '-' || trafficStr.includes('N/A')) return 0;
const num = parseFloat(trafficStr.replace(/[^0-9.]/g, ''));
if (isNaN(num)) return 0;
return num;
}
// ─── 100점 만점 감점형 성능 점수 계산 (CPU + RAM + GPU + 연식) ───
function calculatePcScoreDeductive(cpu: string, ram: string, gpu: string, purchaseDate: string): number {
let score = 100;
if (!cpu) cpu = '';
if (!ram) ram = '';
if (!gpu) gpu = '';
const cpuUpper = cpu.toUpperCase();
const ramUpper = ram.toUpperCase();
const gpuUpper = gpu.toUpperCase();
// 1. CPU 등급 감점 (최대 -30점)
let cpuDeduction = 0;
if (cpuUpper.includes('I9') || cpuUpper.includes('RYZEN 9') || cpuUpper.includes('RYZEN9')) {
cpuDeduction = 0;
} else if (cpuUpper.includes('I7') || cpuUpper.includes('RYZEN 7') || cpuUpper.includes('RYZEN7')) {
cpuDeduction = 5;
} else if (cpuUpper.includes('I5') || cpuUpper.includes('RYZEN 5') || cpuUpper.includes('RYZEN5')) {
cpuDeduction = 15;
} else if (cpuUpper.includes('I3') || cpuUpper.includes('RYZEN 3') || cpuUpper.includes('RYZEN3')) {
cpuDeduction = 25;
} else {
cpuDeduction = 30;
}
score -= cpuDeduction;
// 2. CPU 세대 노후 감점 (최대 -15점)
let genDeduction = 0;
const intelMatch = cpuUpper.match(/I\d-?(\d+)/);
let gen = 0;
if (intelMatch && intelMatch[1]) {
const numStr = intelMatch[1];
if (numStr.length === 5) gen = parseInt(numStr.substring(0, 2), 10);
else if (numStr.length === 4) gen = parseInt(numStr.substring(0, 1), 10);
}
const amdMatch = cpuUpper.match(/RYZEN\s?\d\s?-?(\d+)/);
let amdGen = 0;
if (amdMatch && amdMatch[1] && !intelMatch) {
const numStr = amdMatch[1];
if (numStr.length === 4) amdGen = parseInt(numStr.substring(0, 1), 10);
}
if (intelMatch) {
if (gen >= 12) genDeduction = 0;
else if (gen >= 10) genDeduction = 5;
else if (gen >= 8) genDeduction = 10;
else genDeduction = 15;
} else if (amdMatch) {
if (amdGen >= 5) genDeduction = 0;
else if (amdGen >= 3) genDeduction = 5;
else genDeduction = 10;
} else {
genDeduction = 15;
}
score -= genDeduction;
// 3. RAM 용량 감점 (최대 -25점)
const ramMatch = ramUpper.match(/(\d+)\s*GB/);
let ramDeduction = 25;
if (ramMatch && ramMatch[1]) {
const ramVal = parseInt(ramMatch[1], 10);
if (ramVal >= 32) ramDeduction = 0;
else if (ramVal >= 16) ramDeduction = 10;
else if (ramVal >= 8) ramDeduction = 20;
else ramDeduction = 25;
}
score -= ramDeduction;
// 4. GPU 성능 감점 (최대 -25점)
let gpuDeduction = 25;
if (!gpuUpper || gpuUpper === '-' || gpuUpper.trim() === '') {
gpuDeduction = 25;
} else if (
gpuUpper.includes('RTX 4090') || gpuUpper.includes('RTX 4080') || gpuUpper.includes('RTX 4070') ||
gpuUpper.includes('RTX A5000') || gpuUpper.includes('RTX A6000') || gpuUpper.includes('RTX A4000')
) {
gpuDeduction = 0;
} else if (
gpuUpper.includes('RTX 3070') || gpuUpper.includes('RTX 3060') || gpuUpper.includes('RTX 2060') ||
gpuUpper.includes('RTX A2000') || gpuUpper.includes('RTX A3000') || gpuUpper.includes('QUADRO')
) {
gpuDeduction = 5;
} else if (
gpuUpper.includes('GTX 1660') || gpuUpper.includes('GTX 1080') || gpuUpper.includes('GTX 1070') ||
gpuUpper.includes('GTX 1060') || gpuUpper.includes('RX 6700') || gpuUpper.includes('RX 6600')
) {
gpuDeduction = 15;
} else {
gpuDeduction = 25;
}
score -= gpuDeduction;
// 5. 연식(노후도) 감점 (최대 -15점)
let age = 0;
if (purchaseDate && purchaseDate !== '-') {
let normalized = purchaseDate.replace(/\./g, '-').trim();
if (/^\d{6}$/.test(normalized)) {
normalized = `${normalized.substring(0, 4)}-${normalized.substring(4, 6)}`;
}
const purchase = new Date(normalized);
if (!isNaN(purchase.getTime())) {
// 2026년 5월 31일 기준 경과연수 계산
const mockToday = new Date('2026-05-31');
const diffMs = mockToday.getTime() - purchase.getTime();
age = diffMs / (1000 * 60 * 60 * 24 * 365.25);
age = Math.max(0, parseFloat(age.toFixed(1)));
}
}
let ageDeduction = 0;
if (age < 1) ageDeduction = 0;
else if (age < 2) ageDeduction = 3;
else if (age < 3) ageDeduction = 6;
else if (age < 4) ageDeduction = 9;
else if (age < 5) ageDeduction = 12;
else ageDeduction = 15;
score -= ageDeduction;
return Math.max(10, score);
}
// ─── 권장 PC사양 점수 로컬스토리지 처리 ───
function getRecommendedScores(jobs: string[]): Record<string, number> {
const stored = localStorage.getItem('recommended_pc_scores');
let scores: Record<string, number> = {};
if (stored) {
try {
scores = JSON.parse(stored);
} catch (e) {
console.error(e);
}
}
const defaultScores: Record<string, number> = {
'AI 개발자': 95,
'3D 개발자': 90,
'프로그램 개발자': 80,
'웹 개발자': 75,
'3D 디자이너': 90,
'편집 디자이너': 75,
'UXUI 디자이너': 70,
'BIM모델러': 75,
'엔지니어': 60,
'기획자': 50,
'감리원': 40,
'관리직': 40,
'미분류': 40
};
jobs.forEach(job => {
if (scores[job] === undefined) {
scores[job] = defaultScores[job] || 40;
}
});
return scores;
}
function saveRecommendedScores(scores: Record<string, number>) {
localStorage.setItem('recommended_pc_scores', JSON.stringify(scores));
}
function showSpecMismatchModal(criticalPcList: any[], jobScores: any, allHw: any[], filterStatus?: '사양 부족' | '오버스펙') {
// 기존 모달 제거
const existing = document.getElementById('spec-mismatch-modal');
if (existing) existing.remove();
const filteredList = filterStatus ? criticalPcList.filter(p => p['_spec_status'] === filterStatus) : criticalPcList;
const titleText = filterStatus ? `PC ${filterStatus} 대상자 목록` : 'PC 사양 부적합 대상자 목록';
let rows = '';
if (filteredList.length === 0) {
rows = '<tr><td colspan="8" style="text-align:center; padding:2rem; color:#94A3B8;">부적합 대상자가 없습니다.</td></tr>';
} else {
for (const pc of filteredList) {
const job = pc[ASSET_SCHEMA.USER_POSITION.key] || '미분류';
const score = pc['_pc_score'];
const avg = Math.round(jobScores[job].avg);
const status = pc['_spec_status'];
const color = status === '사양 부족' ? '#E11D48' : '#F59E0B';
const bg = status === '사양 부족' ? '#FFE4E6' : '#FEF3C7';
const userName = pc[ASSET_SCHEMA.CURRENT_USER.key] || '-';
const assetCode = pc[ASSET_SCHEMA.ASSET_CODE.key] || '-';
const corp = pc[ASSET_SCHEMA.PURCHASE_CORP.key] || '-';
const cpuStr = pc[ASSET_SCHEMA.CPU.key] || '-';
const ramStr = pc[ASSET_SCHEMA.RAM.key] || '-';
const gpuStr = pc[ASSET_SCHEMA.GPU.key] || '-';
rows += '<tr class="clickable-row" data-id="' + pc.id + '" style="cursor:pointer; transition: background 0.2s;">';
rows += '<td style="font-weight:600;">' + userName + '</td>';
rows += '<td style="color:#64748B; font-size:12px;">' + job + '</td>';
rows += '<td>' + corp + '</td>';
rows += '<td>' + assetCode + '</td>';
rows += '<td style="font-size:12px;">' + cpuStr + ' / ' + ramStr + ' / ' + gpuStr + '</td>';
rows += '<td style="font-weight:700; text-align:center;">' + score + '</td>';
rows += '<td style="color:#64748B; text-align:center;">' + avg + '</td>';
rows += '<td style="text-align:center;"><span style="color:' + color + '; background:' + bg + '; padding:4px 10px; border-radius:4px; font-size:12px; font-weight:700;">' + status + '</span></td>';
rows += '</tr>';
}
}
const backdrop = document.createElement('div');
backdrop.id = 'spec-mismatch-modal';
backdrop.style.cssText = 'position:fixed;top:0;left:0;width:100%;height:100%;background:rgba(0,0,0,0.5);z-index:9999;display:flex;align-items:center;justify-content:center;animation:fadeIn 0.2s ease;';
const modal = document.createElement('div');
modal.style.cssText = 'background:white;border-radius:16px;width:95%;max-width:1280px;max-height:80vh;overflow:hidden;box-shadow:0 25px 50px rgba(0,0,0,0.25);display:flex;flex-direction:column;';
modal.innerHTML =
'<div style="display:flex;justify-content:space-between;align-items:center;padding:1.25rem 1.5rem;border-bottom:1px solid #E2E8F0;">' +
'<h3 style="margin:0;font-size:1.25rem;font-weight:800;color:#1E293B;display:flex;align-items:center;gap:0.5rem;">' +
'<i data-lucide="user-check" style="color:#6366F1;width:22px;"></i> ' + titleText +
'</h3>' +
'<button id="close-spec-modal" style="background:none;border:none;cursor:pointer;padding:4px;border-radius:8px;color:#64748B;transition:all 0.2s;" onmouseover="this.style.background=\'#F1F5F9\'" onmouseout="this.style.background=\'none\'">' +
'<i data-lucide="x" style="width:20px;height:20px;"></i>' +
'</button>' +
'</div>' +
'<div style="padding:1rem 1.5rem;overflow-y:auto;flex:1;">' +
'<div style="display:flex;gap:1rem;margin-bottom:1rem;">' +
'<div style="flex:1;background:#FFE4E6;border-radius:10px;padding:0.75rem 1rem;display:flex;align-items:center;gap:0.75rem;">' +
'<span style="font-size:1.5rem;">🔻</span>' +
'<div><div style="font-size:0.75rem;color:#9F1239;font-weight:600;">사양 부족</div><div style="font-size:1.25rem;font-weight:800;color:#E11D48;">' + criticalPcList.filter(p => p['_spec_status'] === '사양 부족').length + '명</div></div>' +
'</div>' +
'<div style="flex:1;background:#FEF3C7;border-radius:10px;padding:0.75rem 1rem;display:flex;align-items:center;gap:0.75rem;">' +
'<span style="font-size:1.5rem;">🔺</span>' +
'<div><div style="font-size:0.75rem;color:#92400E;font-weight:600;">오버스펙</div><div style="font-size:1.25rem;font-weight:800;color:#F59E0B;">' + criticalPcList.filter(p => p['_spec_status'] === '오버스펙').length + '명</div></div>' +
'</div>' +
'</div>' +
'<div class="table-premium">' +
'<table>' +
'<thead>' +
'<tr><th>사용자</th><th>직무</th><th>가족사</th><th>자산번호</th><th>사양(CPU/RAM/GPU)</th><th>점수</th><th>직무평균</th><th>상태</th></tr>' +
'</thead>' +
'<tbody>' + rows + '</tbody>' +
'</table>' +
'</div>' +
'</div>';
backdrop.appendChild(modal);
document.body.appendChild(backdrop);
// 아이콘 초기화
setTimeout(() => {
if (window.lucide) window.lucide.createIcons();
else createIcons({ icons: { UserCheck, X } });
}, 50);
// 닫기 이벤트
const closeBtn = document.getElementById('close-spec-modal');
if (closeBtn) closeBtn.addEventListener('click', () => backdrop.remove());
backdrop.addEventListener('click', (e) => { if (e.target === backdrop) backdrop.remove(); });
// 행 클릭 → 자산 상세 모달
modal.querySelectorAll('.clickable-row').forEach(row => {
row.addEventListener('click', () => {
const id = row.getAttribute('data-id');
const asset = allHw.find((h: any) => h.id === id);
if (asset) openHwModal(asset, 'view');
});
});
}
function showServerStatusModal(serverList: any[], allHw: any[], titleText: string) {
// 기존 모달 제거
const existing = document.getElementById('server-status-modal');
if (existing) existing.remove();
let rows = '';
if (serverList.length === 0) {
rows = '<tr><td colspan="9" style="text-align:center; padding:2rem; color:#94A3B8;">해당하는 장비가 없습니다.</td></tr>';
} else {
serverList.forEach((a, idx) => {
const score = a['_server_score'] || 0;
const status = a['_server_status'];
const service = a.service_type || '내부서비스';
let badgeColor = '#EF4444';
let badgeBg = '#FEE2E2';
if (status === '자원 과잉') { badgeColor = '#D97706'; badgeBg = '#FEF3C7'; }
else if (status === '방치 의심') { badgeColor = '#475569'; badgeBg = '#F1F5F9'; }
else if (status === '적정') { badgeColor = '#10B981'; badgeBg = '#D1FAE5'; }
else if (status === '자원 부족') { badgeColor = '#EF4444'; badgeBg = '#FEE2E2'; }
// 사용 리소스 및 트래픽 렌더링 준비
let resourceHtml = '-';
let trafficHtml = '-';
const isInactive = a.is_inactive === true || String(a.is_inactive) === 'true';
if (!isInactive) {
const cpuUsage = a.cpu_usage !== undefined ? (typeof a.cpu_usage === 'number' ? a.cpu_usage : parseFloat(String(a.cpu_usage || '0'))) : 0;
const ramUsage = a.ram_usage !== undefined ? (typeof a.ram_usage === 'number' ? a.ram_usage : parseFloat(String(a.ram_usage || '0'))) : 0;
const hasWarning = cpuUsage > 75 || ramUsage > 80;
if (hasWarning) {
resourceHtml = `<span style="color:#EF4444; font-weight:700;">CPU ${cpuUsage}% / RAM ${ramUsage}%</span>`;
} else {
resourceHtml = `CPU ${cpuUsage}% / RAM ${ramUsage}%`;
}
trafficHtml = a.network_traffic || '-';
} else {
resourceHtml = `<span style="color:#94A3B8;">-</span>`;
trafficHtml = `<span style="color:#94A3B8;">0 GB (N/A)</span>`;
}
rows += `<tr class="clickable-row" data-id="${a.id}" style="cursor:pointer; transition: background 0.2s;" onmouseover="this.style.background='#F8FAFC'" onmouseout="this.style.background='none'">`;
rows += `<td style="font-weight:700; text-align:center; color:#94A3B8;">${idx + 1}</td>`;
rows += `<td style="font-weight:600; color:#1E293B;">${a.asset_name || a[ASSET_SCHEMA.ASSET_CODE.key]}</td>`;
rows += `<td><span style="background:#EEF2FF;color:#4F46E5;padding:2px 6px;border-radius:4px;font-size:11px;font-weight:700;">${service}</span></td>`;
rows += `<td>${a.current_dept || '-'}</td>`;
rows += `<td>${a.location || '-'}</td>`;
rows += `<td style="font-size:12px;">${resourceHtml}</td>`;
rows += `<td style="font-size:12px;">${trafficHtml}</td>`;
rows += `<td style="font-weight:700; text-align:center;">${score}점</td>`;
rows += `<td style="text-align:center;"><span style="color:${badgeColor};background:${badgeBg};padding:4px 10px;border-radius:4px;font-size:12px;font-weight:700;">${status}</span></td>`;
rows += '</tr>';
});
}
const backdrop = document.createElement('div');
backdrop.id = 'server-status-modal';
backdrop.style.cssText = 'position:fixed;top:0;left:0;width:100%;height:100%;background:rgba(0,0,0,0.5);z-index:9999;display:flex;align-items:center;justify-content:center;animation:fadeIn 0.2s ease;';
const modal = document.createElement('div');
modal.style.cssText = 'background:white;border-radius:16px;width:95%;max-width:1280px;max-height:80vh;overflow:hidden;box-shadow:0 25px 50px rgba(0,0,0,0.25);display:flex;flex-direction:column;';
modal.innerHTML =
'<div style="display:flex;justify-content:space-between;align-items:center;padding:1.25rem 1.5rem;border-bottom:1px solid #E2E8F0;">' +
'<h3 style="margin:0;font-size:1.25rem;font-weight:800;color:#1E293B;display:flex;align-items:center;gap:0.5rem;">' +
'<i data-lucide="monitor" style="color:#6366F1;width:22px;"></i> ' + titleText +
'</h3>' +
'<button id="close-server-modal" style="background:none;border:none;cursor:pointer;padding:4px;border-radius:8px;color:#64748B;transition:all 0.2s;" onmouseover="this.style.background=\'#F1F5F9\'" onmouseout="this.style.background=\'none\'">' +
'<i data-lucide="x" style="width:20px;height:20px;"></i>' +
'</button>' +
'</div>' +
'<div style="padding:1rem 1.5rem;overflow-y:auto;flex:1;">' +
'<div class="table-premium">' +
'<table>' +
'<thead>' +
'<tr><th style="width:50px;text-align:center;">순위</th><th>장비명</th><th>서비스 유형</th><th>소속 부서</th><th>설치 위치</th><th>월 평균 리소스 사용량 (CPU/RAM)</th><th>월 데이터 전송량</th><th style="text-align:center;">점수</th><th style="text-align:center;">상태</th></tr>' +
'</thead>' +
'<tbody>' + rows + '</tbody>' +
'</table>' +
'</div>' +
'</div>';
backdrop.appendChild(modal);
document.body.appendChild(backdrop);
// 아이콘 초기화
setTimeout(() => {
if (window.lucide) window.lucide.createIcons();
else createIcons({ icons: { Monitor, X } });
}, 50);
// 닫기 이벤트
const closeBtn = document.getElementById('close-server-modal');
if (closeBtn) closeBtn.addEventListener('click', () => backdrop.remove());
backdrop.addEventListener('click', (e) => { if (e.target === backdrop) backdrop.remove(); });
// 행 클릭 → 자산 상세 모달
modal.querySelectorAll('.clickable-row').forEach(row => {
row.addEventListener('click', () => {
const id = row.getAttribute('data-id');
const asset = allHw.find((h: any) => h.id === id);
if (asset) {
backdrop.remove();
openHwModal(asset, 'view');
}
});
});
}
// ─── 가족사별 통계 데이터 빌드 ───
function buildCorpScores(pcs: any[]): { labels: string[]; avgs: number[]; unders: number[]; overs: number[] } {
const FAMILY_CORPS = ['한맥', '삼안', 'PTC', '바론'];
const labels: string[] = [];
const avgs: number[] = [];
const unders: number[] = [];
const overs: number[] = [];
for (const corp of FAMILY_CORPS) {
const corpPcs = pcs.filter((p: any) => p[ASSET_SCHEMA.PURCHASE_CORP.key] === corp);
const total = corpPcs.length;
labels.push(corp);
if (total === 0) {
avgs.push(0);
unders.push(0);
overs.push(0);
continue;
}
let corpTotalScore = 0;
let corpUnder = 0;
let corpOver = 0;
for (const pc of corpPcs) {
corpTotalScore += pc['_pc_score'] || 0;
if (pc['_spec_status'] === '사양 부족') corpUnder++;
if (pc['_spec_status'] === '오버스펙') corpOver++;
}
avgs.push(Math.round(corpTotalScore / total));
unders.push(corpUnder);
overs.push(corpOver);
}
return { labels, avgs, unders, overs };
}
// ─── 서버 적정성 테이블 행 빌드 ───
function buildServerStatusTableRows(list: any[]): string {
if (list.length === 0) {
return '<tr><td colspan="8" style="text-align:center; padding:1.5rem; color:#94A3B8; font-size:12px;">대상 장비가 없습니다.</td></tr>';
}
let rows = '';
list.forEach((a, idx) => {
const score = a['_server_score'] || 0;
const status = a['_server_status'];
const service = a.service_type || '내부서비스';
let badgeColor = '#EF4444';
let badgeBg = '#FEE2E2';
if (status === '자원 과잉') { badgeColor = '#D97706'; badgeBg = '#FEF3C7'; }
else if (status === '방치 의심') { badgeColor = '#475569'; badgeBg = '#F1F5F9'; }
else if (status === '적정') { badgeColor = '#10B981'; badgeBg = '#D1FAE5'; }
else if (status === '자원 부족') { badgeColor = '#EF4444'; badgeBg = '#FEE2E2'; }
// 사용 리소스 및 트래픽 렌더링 준비
let resourceHtml = '-';
let trafficHtml = '-';
const isInactive = a.is_inactive === true || String(a.is_inactive) === 'true';
if (!isInactive) {
const cpuUsage = a.cpu_usage !== undefined ? (typeof a.cpu_usage === 'number' ? a.cpu_usage : parseFloat(String(a.cpu_usage || '0'))) : 0;
const ramUsage = a.ram_usage !== undefined ? (typeof a.ram_usage === 'number' ? a.ram_usage : parseFloat(String(a.ram_usage || '0'))) : 0;
const hasWarning = cpuUsage > 75 || ramUsage > 80;
if (hasWarning) {
resourceHtml = `<span style="color:#EF4444; font-weight:700;">CPU ${cpuUsage}% / RAM ${ramUsage}%</span>`;
} else {
resourceHtml = `CPU ${cpuUsage}% / RAM ${ramUsage}%`;
}
trafficHtml = a.network_traffic || '-';
} else {
resourceHtml = `<span style="color:#94A3B8;">-</span>`;
trafficHtml = `<span style="color:#94A3B8;">0 GB (N/A)</span>`;
}
rows += `<tr class="clickable-row" data-id="${a.id}" style="cursor:pointer; transition: background 0.2s;">`;
rows += `<td style="text-align:center;font-weight:700;color:#94A3B8;">${idx + 1}</td>`;
rows += `<td style="font-weight:600;color:#1E293B;">${a.asset_name || a[ASSET_SCHEMA.ASSET_CODE.key]}</td>`;
rows += `<td><span style="background:#EEF2FF;color:#4F46E5;padding:2px 6px;border-radius:4px;font-size:11px;font-weight:700;">${service}</span></td>`;
rows += `<td style="font-size:12px;">${resourceHtml}</td>`;
rows += `<td style="font-size:12px;">${trafficHtml}</td>`;
rows += `<td style="text-align:center;font-weight:700;color:#1E293B;">${score}점</td>`;
rows += `<td style="text-align:center;"><span style="color:${badgeColor};background:${badgeBg};padding:2px 8px;border-radius:4px;font-size:11px;font-weight:700;">${status}</span></td>`;
rows += '</tr>';
});
return rows;
}
// ═══════════════════════════════════════════════
// ─── MAIN RENDER FUNCTION ───
// ═══════════════════════════════════════════════
export function renderHwDashboard(container: HTMLElement) {
const allHw = state.masterData.hw || [];
// --- PC FLOW LOGS DATA PREP ---
const logs = state.masterData.logs || [];
const now = new Date();
const currentYearMonth = `${now.getFullYear()}-${String(now.getMonth() + 1).padStart(2, '0')}`;
let currentMonthCheckout = 0;
let currentMonthReturn = 0;
let currentMonthMove = 0;
let totalCheckout = 0;
let totalReturn = 0;
let totalMove = 0;
const flowLogs = logs.filter((log: any) => {
const details = log.details || '';
const isFlow = details.includes('[불출]') || details.includes('[반납]') || details.includes('[입고]') || details.includes('[이동]') || details.includes('[이관]');
if (isFlow) {
const logDate = log.log_date || '';
const isCurrentMonth = logDate.startsWith(currentYearMonth);
if (details.includes('[불출]')) {
totalCheckout++;
if (isCurrentMonth) currentMonthCheckout++;
} else if (details.includes('[반납]') || details.includes('[입고]')) {
totalReturn++;
if (isCurrentMonth) currentMonthReturn++;
} else if (details.includes('[이동]') || details.includes('[이관]')) {
totalMove++;
if (isCurrentMonth) currentMonthMove++;
}
return true;
}
return false;
});
const recentFlowLogs = flowLogs.slice(0, 5);
let recentFlowLogsHtml = '';
if (recentFlowLogs.length === 0) {
recentFlowLogsHtml = '<tr><td colspan="4" style="text-align:center; padding:1.5rem; color:#94A3B8; font-size:12px;">최근 유동 이력이 없습니다.</td></tr>';
} else {
recentFlowLogs.forEach((log: any) => {
const details = log.details || '';
let badgeHtml = '';
if (details.includes('[불출]')) {
badgeHtml = '<span style="background:#E0F2FE;color:#0369A1;padding:2px 6px;border-radius:4px;font-size:11px;font-weight:700;">불출</span>';
} else if (details.includes('[반납]') || details.includes('[입고]')) {
badgeHtml = '<span style="background:#DCFCE7;color:#15803D;padding:2px 6px;border-radius:4px;font-size:11px;font-weight:700;">입고</span>';
} else if (details.includes('[이동]') || details.includes('[이관]')) {
badgeHtml = '<span style="background:#FEF3C7;color:#B45309;padding:2px 6px;border-radius:4px;font-size:11px;font-weight:700;">이동</span>';
}
const cleanDetails = details.replace(/^\[(불출|반납|입고|이동|이관)\]\s*/, '');
recentFlowLogsHtml += `
<tr style="border-bottom: 1px solid #F1F5F9; font-size: 13px;">
<td style="padding: 8px; color: #64748B;">${log.log_date || '-'}</td>
<td style="padding: 8px;">${badgeHtml}</td>
<td style="padding: 8px; font-weight: 600; color: #334155;">${log.log_user || '시스템'}</td>
<td style="padding: 8px; color: #475569;" title="${details}">${cleanDetails}</td>
</tr>
`;
});
}
// --- PC DATA PREP ---
const pcs = allHw.filter(a => {
const cat = a[ASSET_SCHEMA.CATEGORY.key] || '';
const type = a[ASSET_SCHEMA.ASSET_TYPE.key] || '';
const job = a[ASSET_SCHEMA.USER_POSITION.key] || '';
const status = a[ASSET_SCHEMA.HW_STATUS.key] || '';
const user = a[ASSET_SCHEMA.CURRENT_USER.key] || '';
return (cat === 'PC' || type === '개인PC' || type === '노트북' || type === '공용PC') &&
job !== '재고PC' &&
status === '사용중' &&
user.trim() !== '';
});
const jobScores: Record<string, { totalScore: number; count: number; avg: number }> = {};
pcs.forEach(pc => {
const job = pc[ASSET_SCHEMA.USER_POSITION.key] || '미분류';
const cpu = pc[ASSET_SCHEMA.CPU.key];
const ram = pc[ASSET_SCHEMA.RAM.key];
const gpu = pc[ASSET_SCHEMA.GPU.key];
const pDate = pc[ASSET_SCHEMA.PURCHASE_DATE.key];
const score = calculatePcScoreDeductive(cpu, ram, gpu, pDate);
pc['_pc_score'] = score;
if (!jobScores[job]) jobScores[job] = { totalScore: 0, count: 0, avg: 0 };
jobScores[job].totalScore += score;
jobScores[job].count += 1;
});
let totalPcScore = 0;
Object.keys(jobScores).forEach(job => {
jobScores[job].avg = jobScores[job].totalScore / jobScores[job].count;
totalPcScore += jobScores[job].totalScore;
});
const overallPcAvg = pcs.length > 0 ? Math.round(totalPcScore / pcs.length) : 0;
const jobsList = Object.keys(jobScores).sort((a, b) => jobScores[b].avg - jobScores[a].avg);
const recommendedScores = getRecommendedScores(jobsList);
let overSpecCount = 0;
let underSpecCount = 0;
const criticalPcList: any[] = [];
pcs.forEach(pc => {
const job = pc[ASSET_SCHEMA.USER_POSITION.key] || '미분류';
const score = pc['_pc_score'];
const avg = jobScores[job].avg;
if (score < avg * 0.8) {
underSpecCount++;
pc['_spec_status'] = '사양 부족';
criticalPcList.push(pc);
} else if (score > avg * 1.3) {
overSpecCount++;
pc['_spec_status'] = '오버스펙';
criticalPcList.push(pc);
} else {
pc['_spec_status'] = '적정';
}
});
criticalPcList.sort((a, b) => {
const jobA = a[ASSET_SCHEMA.USER_POSITION.key] || '미분류';
const jobB = b[ASSET_SCHEMA.USER_POSITION.key] || '미분류';
const ratioA = a['_pc_score'] / jobScores[jobA].avg;
const ratioB = b['_pc_score'] / jobScores[jobB].avg;
return ratioA - ratioB;
});
// --- SERVER DATA PREP ---
const servers = allHw.filter(a => {
const cat = a[ASSET_SCHEMA.CATEGORY.key] || '';
const type = a[ASSET_SCHEMA.ASSET_TYPE.key] || '';
return cat === '서버' || cat === '스토리지' || type === '서버' || type === 'NAS' || type === '가상서버(VM)' || type === '공용PC' || type === '테스트 PC' || type === '회의실 PC';
});
let serverTotalValue = 0;
let serverTotalAge = 0;
let serverCountWithDate = 0;
let serverOver5YearsCount = 0;
const serverAgeGroups = { stable: 0, warning: 0, critical: 0 };
// 서비스 유형 카운트
const serverServiceGroups = { internal: 0, external: 0, public: 0 };
// 적정성 분석 상태 카운트
const serverStatusGroups = { optimal: 0, underSpec: 0, overSpec: 0, inactive: 0 };
servers.forEach(a => {
const amountStr = String(a[ASSET_SCHEMA.PURCHASE_AMOUNT.key] || '0').replace(/[^0-9]/g, '');
serverTotalValue += parseInt(amountStr, 10) || 0;
// 구매연령
const pDate = a[ASSET_SCHEMA.PURCHASE_DATE.key] || a.purchase_date;
if (pDate) {
const age = calculateAssetAge(pDate);
serverTotalAge += age;
serverCountWithDate++;
if (age >= 5) { serverOver5YearsCount++; serverAgeGroups.critical++; }
else if (age >= 3) { serverAgeGroups.warning++; }
else { serverAgeGroups.stable++; }
}
// 서비스 유형 판단 및 집계
const serviceType = a.service_type || '내부서비스';
if (serviceType === '외부서비스') serverServiceGroups.external++;
else if (serviceType === '회의용/공용') serverServiceGroups.public++;
else serverServiceGroups.internal++;
// 사양 점수 및 적정성 평가
const score = calculatePcScoreDeductive(a.cpu, a.ram, a.gpu, pDate);
a['_server_score'] = score;
const isInactive = a.is_inactive === true || String(a.is_inactive) === 'true';
// 안전한 CPU, RAM 사용량 파싱
const cpuUsage = a.cpu_usage !== undefined ? (typeof a.cpu_usage === 'number' ? a.cpu_usage : parseFloat(String(a.cpu_usage || '0'))) : 0;
const ramUsage = a.ram_usage !== undefined ? (typeof a.ram_usage === 'number' ? a.ram_usage : parseFloat(String(a.ram_usage || '0'))) : 0;
const trafficGb = parseTrafficToGb(a.network_traffic);
if (isInactive) {
a['_server_status'] = '방치 의심';
serverStatusGroups.inactive++;
} else {
// 1. 자원 부족 판별 (오직 사용 리소스 기반: CPU > 75% 또는 RAM > 80% 또는 일일 트래픽 > 500 GB)
const isUnderProvisioned = cpuUsage > 75 || ramUsage > 80 || trafficGb > 500;
// 2. 자원 과잉 판별 (오직 사용 리소스 기반: CPU < 10% 이고 RAM < 20% 이고 일일 트래픽 < 5 GB)
const isOverProvisioned = cpuUsage < 10 && ramUsage < 20 && trafficGb < 5;
if (isUnderProvisioned) {
a['_server_status'] = '자원 부족';
serverStatusGroups.underSpec++;
} else if (isOverProvisioned) {
a['_server_status'] = '자원 과잉';
serverStatusGroups.overSpec++;
} else {
a['_server_status'] = '적정';
serverStatusGroups.optimal++;
}
}
});
const serverAvgAge = serverCountWithDate > 0 ? (serverTotalAge / serverCountWithDate).toFixed(1) : '0';
const serverOver5Rate = servers.length > 0 ? Math.round((serverOver5YearsCount / servers.length) * 100) : 0;
const serverFormattedValue = new Intl.NumberFormat('ko-KR').format(serverTotalValue);
// 리스트: 자원 과잉 장비 TOP 5
const overSpecList = servers
.filter(a => a['_server_status'] === '자원 과잉')
.sort((a, b) => b['_server_score'] - a['_server_score'])
.slice(0, 5);
// 리스트: 자원 부족 장비 TOP 5
const underSpecList = servers
.filter(a => a['_server_status'] === '자원 부족')
.sort((a, b) => a['_server_score'] - b['_server_score'])
.slice(0, 5);
// 리스트: 방치 의심 장비 (회수/재배치 필요) TOP 5
const inactiveList = servers
.filter(a => a['_server_status'] === '방치 의심')
.slice(0, 5);
// --- TOTAL EXEC DASHBOARD STATS (종합 대시보드 통계 연산) ---
let totalAssetValue = 0;
allHw.forEach(a => {
const amt = parseInt(String(a[ASSET_SCHEMA.PURCHASE_AMOUNT.key] || '0').replace(/[^0-9]/g, ''), 10) || 0;
totalAssetValue += amt;
});
let costSavingPotential = 0;
servers.forEach(a => {
const status = a['_server_status'];
if (status === '자원 과잉' || status === '방치 의심') {
const amt = parseInt(String(a[ASSET_SCHEMA.PURCHASE_AMOUNT.key] || '0').replace(/[^0-9]/g, ''), 10) || 0;
costSavingPotential += amt;
}
});
pcs.forEach(pc => {
if (pc['_spec_status'] === '오버스펙') {
const amt = parseInt(String(pc[ASSET_SCHEMA.PURCHASE_AMOUNT.key] || '0').replace(/[^0-9]/g, ''), 10) || 0;
costSavingPotential += amt;
}
});
const totalEvaluatedDevices = pcs.length + servers.length;
let optimalDevicesCount = 0;
pcs.forEach(pc => { if (pc['_spec_status'] === '적정') optimalDevicesCount++; });
servers.forEach(s => { if (s['_server_status'] === '적정') optimalDevicesCount++; });
const assetOptimizationRate = totalEvaluatedDevices > 0 ? Math.round((optimalDevicesCount / totalEvaluatedDevices) * 100) : 0;
let pcOver5YearsCount = 0;
pcs.forEach(pc => {
const pDate = pc[ASSET_SCHEMA.PURCHASE_DATE.key];
if (pDate && calculateAssetAge(pDate) >= 5) pcOver5YearsCount++;
});
let totalOver5YearsCount = pcOver5YearsCount;
servers.forEach(s => {
const pDate = s[ASSET_SCHEMA.PURCHASE_DATE.key] || s.purchase_date;
if (pDate && calculateAssetAge(pDate) >= 5) totalOver5YearsCount++;
});
let totalResourceBottleneckCount = 0;
servers.forEach(s => {
if (s['_server_status'] === '자원 부족') totalResourceBottleneckCount++;
});
let totalInactiveCount = 0;
servers.forEach(s => {
if (s['_server_status'] === '방치 의심') totalInactiveCount++;
});
// 용도별 서버 자원 과부족 대수 집계
const PURPOSE_CATEGORIES = ['개발/테스트', '서비스/웹/WAS', 'DB/스토리지', '해석/분석/AI', '백업/관리/보안', '기타/일반'];
const purposeServerUnders = PURPOSE_CATEGORIES.map(cat =>
servers.filter(s => categorizePurpose(s[ASSET_SCHEMA.ASSET_PURPOSE.key] || s.asset_purpose) === cat && s['_server_status'] === '자원 부족').length
);
const purposeServerOvers = PURPOSE_CATEGORIES.map(cat =>
servers.filter(s => categorizePurpose(s[ASSET_SCHEMA.ASSET_PURPOSE.key] || s.asset_purpose) === cat && s['_server_status'] === '자원 과잉').length
);
// 차트용 데이터
const assetTypesCount = {
pc: pcs.length,
server: servers.filter(s => s.asset_type.includes('서버') || s.asset_type.includes('VM')).length,
storage: servers.filter(s => s.asset_type.toUpperCase().includes('NAS') || s.asset_type.toUpperCase().includes('스토리지') || s.asset_type.toUpperCase().includes('STO')).length,
other: allHw.length - pcs.length - servers.length
};
const pcStatusSummary = {
optimal: pcs.filter(p => p._spec_status === '적정').length,
over: pcs.filter(p => p._spec_status === '오버스펙').length,
under: pcs.filter(p => p._spec_status === '사양 부족').length,
inactive: 0
};
const serverStatusSummary = {
optimal: serverStatusGroups.optimal,
over: serverStatusGroups.overSpec,
under: serverStatusGroups.underSpec,
inactive: serverStatusGroups.inactive
};
// --- PRE-BUILD HTML ---
const corpScores = buildCorpScores(pcs);
// 가족사별 평균 점수 텍스트 리스트
let corpAvgListHtml = '';
for (let ci = 0; ci < corpScores.labels.length; ci++) {
corpAvgListHtml += '<div style="display:flex;justify-content:space-between;padding:0.35rem 0;border-bottom:1px solid #F1F5F9;flex:1;align-items:center;">';
corpAvgListHtml += '<span style="font-weight:600;color:#334155;font-size:0.95rem;">' + corpScores.labels[ci] + '</span>';
corpAvgListHtml += '<span style="font-weight:800;color:#3B82F6;font-size:1.05rem;">' + corpScores.avgs[ci] + '점</span>';
corpAvgListHtml += '</div>';
}
// --- RENDER ---
container.innerHTML =
'<div class="view-container" style="overflow-x: hidden;">' +
'<div class="dashboard-header-wrapper">' +
'<div style="display:flex; align-items:center; gap:1rem;">' +
'<h2 style="font-size: 1.8rem; font-weight: 800; margin: 0; color: #1E293B;">Executive Dashboard <span style="font-size:1rem; color:#64748B; font-weight:500;">(PC &amp; Server)</span></h2>' +
'<button id="btn-open-proposal" title="PC 사양 적정성 분석 기획서 보기" style="display:flex; align-items:center; gap:0.35rem; background:#EEF2F6; border:1px solid #CBD5E1; border-radius:8px; padding:6px 12px; font-size:0.875rem; font-weight:600; color:#334155; cursor:pointer; transition:all 0.2s;" onmouseover="this.style.background=\'#E2E8F0\'" onmouseout="this.style.background=\'#EEF2F6\'">' +
'<i data-lucide="file-text" style="width:16px; height:16px; color:#4F46E5;"></i>' +
'기획서 보기' +
'</button>' +
'</div>' +
'<div class="slider-controls">' +
'<button id="slider-prev" class="slider-nav-btn" disabled><i data-lucide="chevron-left"></i></button>' +
'<span id="slider-indicator" class="slider-indicator">1 / 5</span>' +
'<button id="slider-next" class="slider-nav-btn"><i data-lucide="chevron-right"></i></button>' +
'</div>' +
'</div>' +
'<div class="dashboard-slider-viewport">' +
'<div class="dashboard-slider-track" id="dashboard-slider-track">' +
// ── SLIDE 1: TOTAL EXECUTIVE DASHBOARD ──
'<div class="dashboard-slide">' +
'<h3 class="dashboard-section-title" style="color:#0F172A; border-bottom:2px solid #E2E8F0; display:inline-block; margin-bottom:1.5rem;">📊 전사 IT 자산 및 자원 최적화 요약</h3>' +
// KPI Row
'<div class="dashboard-grid" style="grid-template-columns: repeat(4, 1fr);">' +
'<div class="stat-card">' +
'<div class="stat-icon icon-red"><i data-lucide="monitor"></i></div>' +
'<span class="stat-label">PC 사양 부족 장비</span>' +
'<div class="stat-value stat-value-danger" style="font-size:1.8rem;">' + underSpecCount + '<span style="font-size:1rem; font-weight:600; color:#64748B;">대</span></div>' +
'<div style="font-size: 0.8125rem; color:var(--text-muted); margin-top: 0.5rem;">개인용 PC 대비 ' + (pcs.length > 0 ? Math.round((underSpecCount / pcs.length) * 100) : 0) + '% 비율</div>' +
'</div>' +
'<div class="stat-card">' +
'<div class="stat-icon icon-yellow"><i data-lucide="monitor"></i></div>' +
'<span class="stat-label">PC 오버스펙 장비</span>' +
'<div class="stat-value" style="color:#F59E0B; font-size:1.8rem;">' + overSpecCount + '<span style="font-size:1rem; font-weight:600; color:#64748B;">대</span></div>' +
'<div style="font-size: 0.8125rem; color:var(--text-muted); margin-top: 0.5rem;">개인용 PC 대비 ' + (pcs.length > 0 ? Math.round((overSpecCount / pcs.length) * 100) : 0) + '% 비율</div>' +
'</div>' +
'<div class="stat-card">' +
'<div class="stat-icon icon-red"><i data-lucide="activity"></i></div>' +
'<span class="stat-label">서버 자원 부족 장비</span>' +
'<div class="stat-value stat-value-danger" style="font-size:1.8rem;">' + serverStatusGroups.underSpec + '<span style="font-size:1rem; font-weight:600; color:#64748B;">대</span></div>' +
'<div style="font-size: 0.8125rem; color:var(--text-muted); margin-top: 0.5rem;">서버/NAS 대비 ' + (servers.length > 0 ? Math.round((serverStatusGroups.underSpec / servers.length) * 100) : 0) + '% 비율</div>' +
'</div>' +
'<div class="stat-card">' +
'<div class="stat-icon icon-yellow"><i data-lucide="activity"></i></div>' +
'<span class="stat-label">서버 자원 과잉 장비</span>' +
'<div class="stat-value" style="color:#D97706; font-size:1.8rem;">' + serverStatusGroups.overSpec + '<span style="font-size:1rem; font-weight:600; color:#64748B;">대</span></div>' +
'<div style="font-size: 0.8125rem; color:var(--text-muted); margin-top: 0.5rem;">서버/NAS 대비 ' + (servers.length > 0 ? Math.round((serverStatusGroups.overSpec / servers.length) * 100) : 0) + '% 비율</div>' +
'</div>' +
'</div>' +
// Charts
'<div class="dashboard-layout-2col" style="margin-bottom: 2rem;">' +
'<div class="dashboard-card">' +
'<h4 class="dashboard-section-title" style="color:#EF4444;">가족사별 PC 사양 과부족 현황</h4>' +
'<div style="height:280px;display:flex;align-items:center;justify-content:center;"><canvas id="chart-total-pc-mismatch-by-corp"></canvas></div>' +
'</div>' +
'<div class="dashboard-card">' +
'<h4 class="dashboard-section-title">용도별 서버 자원 과부족 현황</h4>' +
'<div style="height:280px;display:flex;align-items:center;justify-content:center;"><canvas id="chart-total-server-mismatch-by-purpose"></canvas></div>' +
'</div>' +
'</div>' +
'</div>' +
// ── SLIDE 2: PC DASHBOARD ──
'<div class="dashboard-slide">' +
'<h3 class="dashboard-section-title" style="color:#0F172A; border-bottom:2px solid #E2E8F0; display:inline-block; margin-bottom:1.5rem;">💻 PC 사양 적정성 분석</h3>' +
// KPI Row
'<div class="dashboard-grid" style="grid-template-columns: repeat(4, 1fr);">' +
'<div class="stat-card">' +
'<div class="stat-icon icon-blue"><i data-lucide="monitor"></i></div>' +
'<span class="stat-label">전사 평균 PC 사양 점수</span>' +
'<div class="stat-value" style="font-size:1.8rem;">' + overallPcAvg + '<span style="font-size:1rem; font-weight:600; color:#64748B;">점</span></div>' +
'<div style="font-size: 0.8125rem; color:var(--text-muted); margin-top: 0.5rem;">운영 중인 PC 총 ' + pcs.length + '대 기준</div>' +
'</div>' +
'<div class="stat-card" id="kpi-under-spec" style="cursor:pointer;">' +
'<div class="stat-icon icon-red"><i data-lucide="trending-down"></i></div>' +
'<span class="stat-label">사양 부족 인원 (교체 검토)</span>' +
'<div class="stat-value stat-value-danger">' + underSpecCount + '<span style="font-size:1rem; font-weight:600; color:#64748B;">명</span></div>' +
'<div style="font-size: 0.8125rem; color:var(--text-muted); margin-top: 0.5rem;">직무 평균 대비 20% 이상 미달 &nbsp;▸ 클릭하여 상세보기</div>' +
'</div>' +
'<div class="stat-card" id="kpi-over-spec" style="cursor:pointer;">' +
'<div class="stat-icon icon-yellow"><i data-lucide="trending-up"></i></div>' +
'<span class="stat-label">오버스펙 인원 (회수 검토)</span>' +
'<div class="stat-value" style="color:#F59E0B;">' + overSpecCount + '<span style="font-size:1rem; font-weight:600; color:#64748B;">명</span></div>' +
'<div style="font-size: 0.8125rem; color:var(--text-muted); margin-top: 0.5rem;">직무 평균 대비 30% 이상 초과 &nbsp;▸ 클릭하여 상세보기</div>' +
'</div>' +
'<div class="stat-card" style="border: 1px solid rgba(239,68,68,0.3); background: rgba(254,226,226,0.15);">' +
'<div class="stat-icon" style="background:rgba(239,68,68,0.1);color:#EF4444;"><i data-lucide="alert-triangle"></i></div>' +
'<span class="stat-label" style="color:#EF4444;">교체/회수 대상 비율</span>' +
'<div class="stat-value stat-value-danger" style="font-size:1.8rem;">' + (pcs.length > 0 ? Math.round(((underSpecCount + overSpecCount) / pcs.length) * 100) : 0) + '<span style="font-size:1rem; font-weight:600; color:#64748B;">%</span></div>' +
'<div style="width:100%;height:4px;background:#E2E8F0;border-radius:2px;overflow:hidden;margin-top:1rem;">' +
'<div style="width:' + (pcs.length > 0 ? Math.round(((underSpecCount + overSpecCount) / pcs.length) * 100) : 0) + '%;height:100%;background:linear-gradient(90deg,#F59E0B,#E11D48);"></div>' +
'</div>' +
'<div style="font-size: 0.75rem; color:#94A3B8; margin-top: 0.75rem; line-height: 1.3;">* 산출식: (사양 부족 + 오버스펙 인원) / 전체 PC 수량 × 100</div>' +
'</div>' +
'</div>' +
// Charts + Corp Stats
'<div class="dashboard-layout-2col">' +
'<div class="dashboard-card">' +
'<h4 class="dashboard-section-title">직무별 평균 PC 사양 점수</h4>' +
'<div><canvas id="chart-job-scores"></canvas></div>' +
'</div>' +
'<div class="dashboard-card" style="padding:1.25rem 1.5rem;">' +
'<h4 class="dashboard-section-title" style="display:flex;align-items:center;gap:0.5rem;margin-bottom:1rem;">' +
'<i data-lucide="building-2" style="color:#6366F1;width:20px;"></i> 가족사별 PC 사양 현황' +
'</h4>' +
'<div style="display:flex;gap:1.5rem;flex:1;align-items:stretch;height:calc(100% - 3.5rem);">' +
'<div style="flex:1;display:flex;flex-direction:column;border-right:1px solid #E2E8F0;padding-right:1.25rem;padding-bottom:0.5rem;">' +
'<div style="font-size:0.85rem;color:#94A3B8;font-weight:700;text-transform:uppercase;margin-bottom:0.75rem;">평균 점수</div>' +
'<div style="display:flex;flex-direction:column;justify-content:space-between;flex:1;height:100%;">' +
corpAvgListHtml +
'</div>' +
'</div>' +
'<div style="flex:2;display:flex;align-items:center;justify-content:center;"><canvas id="chart-corp-scores" style="max-height:280px;width:100%;"></canvas></div>' +
'</div>' +
'</div>' +
'</div>' +
'</div>' +
// ── SLIDE 2: SERVER DASHBOARD ──
'<div class="dashboard-slide">' +
'<h3 class="dashboard-section-title" style="color:#0F172A; border-bottom:2px solid #E2E8F0; display:inline-block; margin-bottom:1.5rem;">🖥️ 서버 및 공용 인프라 분석</h3>' +
'<div class="dashboard-grid" style="grid-template-columns: repeat(4, 1fr);">' +
'<div class="stat-card" id="kpi-server-total" style="cursor:pointer;">' +
'<div class="stat-icon icon-blue"><i data-lucide="monitor"></i></div>' +
'<span class="stat-label">총 운영 서버/NAS 수량</span>' +
'<div class="stat-value" style="font-size:1.8rem;">' + servers.length + '<span style="font-size:1rem; font-weight:600; color:#64748B;">대</span></div>' +
'<div style="font-size: 0.75rem; color:#64748B; margin-top: 0.5rem;">실제 도입 가치: ' + serverFormattedValue + '원 &nbsp;▸ 클릭시 목록보기</div>' +
'</div>' +
'<div class="stat-card" id="kpi-server-external" style="cursor:pointer;">' +
'<div class="stat-icon icon-green"><i data-lucide="activity"></i></div>' +
'<span class="stat-label">외부 운영 서비스 비율</span>' +
'<div class="stat-value" style="color:#10B981;">' + serverServiceGroups.external + '<span style="font-size:1rem; font-weight:600; color:#64748B;">대 (' + Math.round((serverServiceGroups.external / servers.length) * 100) + '%)</span></div>' +
'<div style="font-size: 0.75rem; color:#64748B; margin-top: 0.5rem;">사내용 인프라 ' + serverServiceGroups.internal + '대 / 회의실 ' + serverServiceGroups.public + '대 &nbsp;▸ 클릭시 목록보기</div>' +
'</div>' +
'<div class="stat-card" id="kpi-server-overspec" style="cursor:pointer;">' +
'<div class="stat-icon icon-yellow"><i data-lucide="trending-up"></i></div>' +
'<span class="stat-label">자원 과잉 장비</span>' +
'<div class="stat-value" style="color:#F59E0B;">' + serverStatusGroups.overSpec + '<span style="font-size:1rem; font-weight:600; color:#64748B;">대</span></div>' +
'<div style="font-size: 0.75rem; color:#64748B; margin-top: 0.5rem;">리소스 사용률 및 데이터 전송량이 극히 저조한 장비 &nbsp;▸ 클릭시 목록보기</div>' +
'</div>' +
'<div class="stat-card" id="kpi-server-critical" style="cursor:pointer;">' +
'<div class="stat-icon icon-red"><i data-lucide="trending-down"></i></div>' +
'<span class="stat-label">자원 부족 장비</span>' +
'<div class="stat-value stat-value-danger">' + (serverStatusGroups.underSpec + serverStatusGroups.inactive) + '<span style="font-size:1rem; font-weight:600; color:#64748B;">대</span></div>' +
'<div style="font-size: 0.75rem; color:#94A3B8; margin-top: 0.5rem;">자원 부족 ' + serverStatusGroups.underSpec + '대 / 방치 장비 ' + serverStatusGroups.inactive + '대 &nbsp;▸ 클릭시 목록보기</div>' +
'</div>' +
'</div>' +
// 차트 영역 (3열)
'<div class="dashboard-layout-3col" style="margin-bottom: 2rem;">' +
'<div class="dashboard-card">' +
'<h4 class="dashboard-section-title">서비스 유형 분포</h4>' +
'<div><canvas id="chart-server-service"></canvas></div>' +
'</div>' +
'<div class="dashboard-card">' +
'<h4 class="dashboard-section-title">용도별 서버 자원 과부족 현황</h4>' +
'<div><canvas id="chart-total-server-mismatch-by-purpose"></canvas></div>' +
'</div>' +
'<div class="dashboard-card">' +
'<h4 class="dashboard-section-title">서버 적정성 분석</h4>' +
'<div><canvas id="chart-server-status"></canvas></div>' +
'</div>' +
'</div>' +
'</div>' +
// ── SLIDE 4: ALL DETAILS EXECUTIVE CARDS ──
'<div class="dashboard-slide">' +
'<h3 class="dashboard-section-title" style="color:#0F172A; border-bottom:2px solid #E2E8F0; display:inline-block; margin-bottom:0.5rem; font-size:1.3rem; padding-bottom:0.25rem;">📋 전사 PC 및 서버 상세 현황 (종합 상황판)</h3>' +
// 세로로 분할된 2열 구조 컨테이너 (140% 내용 확대)
'<div style="display: grid; grid-template-columns: 1fr 1.2fr; gap: 0.75rem; flex: 1; min-height: 0; zoom: 1.4;">' +
// 왼쪽 열: PC 현황 (height: 100% 적용)
'<div style="display: flex; flex-direction: column; gap: 0.5rem; background: rgba(255,255,255,0.45); border-radius: 12px; padding: 0.5rem; border: 1px solid rgba(99,102,241,0.12); box-sizing: border-box; min-height: 0; height: 100%;">' +
'<h4 style="font-size: 0.9rem; font-weight: 800; color: #1E293B; margin: 0 0 2px 0; display: flex; align-items: center; gap: 0.25rem;">' +
'<i data-lucide="monitor" style="width:15px; height:15px; color:#3B82F6;"></i> PC 현황 요약' +
'</h4>' +
// 1행: PC KPI 카드 3개 가로 배치
'<div style="display: grid; grid-template-columns: repeat(3, 1fr); gap: 0.4rem; margin-bottom: 0.2rem;">' +
'<div class="stat-card" style="padding: 0.55rem 0.65rem; min-height: unset; border-radius: 8px; box-shadow: 0 1px 4px rgba(0,0,0,0.02); background: rgba(255, 255, 255, 0.85); display: flex; align-items: center; gap: 0.45rem; border: 1px solid rgba(99,102,241,0.05);">' +
'<div style="background: rgba(59,130,246,0.1); color: #3B82F6; padding: 0.35rem; border-radius: 6px; display: flex; align-items: center; justify-content: center; flex-shrink: 0;"><i data-lucide="monitor" style="width: 15px; height: 15px;"></i></div>' +
'<div style="display: flex; flex-direction: column; min-width: 0;">' +
'<span class="stat-label" style="font-size: 0.68rem; color: #64748B; font-weight: 600; margin-bottom: 1px; letter-spacing: 0;">평균 PC 점수</span>' +
'<div class="stat-value" style="font-size: 1.15rem; font-weight: 800; color: #1E293B; margin-top: 0; line-height: 1.2; display: flex; align-items: baseline; background: none; -webkit-text-fill-color: initial;">' + overallPcAvg + '<span style="font-size:0.75rem; font-weight:600; color:#64748B; margin-left: 2px;">점</span></div>' +
'</div>' +
'</div>' +
'<div class="stat-card" id="kpi-under-spec-4p" style="cursor:pointer; padding: 0.55rem 0.65rem; min-height: unset; border-radius: 8px; box-shadow: 0 1px 4px rgba(0,0,0,0.02); background: rgba(255, 255, 255, 0.85); display: flex; align-items: center; gap: 0.45rem; border: 1px solid rgba(239,68,68,0.1);">' +
'<div style="background: rgba(239,68,68,0.1); color: #EF4444; padding: 0.35rem; border-radius: 6px; display: flex; align-items: center; justify-content: center; flex-shrink: 0;"><i data-lucide="trending-down" style="width: 15px; height: 15px;"></i></div>' +
'<div style="display: flex; flex-direction: column; min-width: 0;">' +
'<span class="stat-label" style="font-size: 0.68rem; color: #EF4444; font-weight: 600; margin-bottom: 1px; letter-spacing: 0;">사양 부족(교체)</span>' +
'<div class="stat-value" style="font-size: 1.15rem; font-weight: 800; color: #EF4444; margin-top: 0; line-height: 1.2; display: flex; align-items: baseline; background: none; -webkit-text-fill-color: initial;">' + underSpecCount + '<span style="font-size:0.75rem; font-weight:600; color:#EF4444; margin-left: 2px;">명</span></div>' +
'</div>' +
'</div>' +
'<div class="stat-card" id="kpi-over-spec-4p" style="cursor:pointer; padding: 0.55rem 0.65rem; min-height: unset; border-radius: 8px; box-shadow: 0 1px 4px rgba(0,0,0,0.02); background: rgba(255, 255, 255, 0.85); display: flex; align-items: center; gap: 0.45rem; border: 1px solid rgba(245,158,11,0.1);">' +
'<div style="background: rgba(245,158,11,0.1); color: #F59E0B; padding: 0.35rem; border-radius: 6px; display: flex; align-items: center; justify-content: center; flex-shrink: 0;"><i data-lucide="trending-up" style="width: 15px; height: 15px;"></i></div>' +
'<div style="display: flex; flex-direction: column; min-width: 0;">' +
'<span class="stat-label" style="font-size: 0.68rem; color: #F59E0B; font-weight: 600; margin-bottom: 1px; letter-spacing: 0;">오버스펙(회수)</span>' +
'<div class="stat-value" style="font-size: 1.15rem; font-weight: 800; color: #F59E0B; margin-top: 0; line-height: 1.2; display: flex; align-items: baseline; background: none; -webkit-text-fill-color: initial;">' + overSpecCount + '<span style="font-size:0.75rem; font-weight:600; color:#64748B; margin-left: 2px;">명</span></div>' +
'</div>' +
'</div>' +
'</div>' +
// 2행: PC 그래프 2개 가로 배치
'<div style="display: grid; grid-template-columns: 1fr 1fr; gap: 0.4rem; flex: 1; min-height: 0;">' +
'<div class="dashboard-card" style="min-height: unset; height: 100%; padding: 0.4rem 0.6rem; border-radius: 8px; display:flex; flex-direction:column; background: rgba(255, 255, 255, 0.85); margin-bottom: 0;">' +
'<h5 style="font-size: 0.75rem; font-weight: 800; margin: 0 0 2px 0; color: #475569;">직무별 평균 PC 사양 점수</h5>' +
'<div style="flex: 1; min-height: 0; position: relative;"><canvas id="chart-job-scores-4p" style="position: absolute; top:0; left:0; width:100%; height:100%;"></canvas></div>' +
'</div>' +
'<div class="dashboard-card" style="min-height: unset; height: 100%; padding: 0.4rem 0.6rem; border-radius: 8px; display:flex; flex-direction:column; background: rgba(255, 255, 255, 0.85); margin-bottom: 0;">' +
'<h5 style="font-size: 0.75rem; font-weight: 800; margin: 0 0 2px 0; color: #475569;">가족사별 PC 사양 현황</h5>' +
'<div style="flex: 1; min-height: 0; position: relative;"><canvas id="chart-corp-scores-4p" style="position: absolute; top:0; left:0; width:100%; height:100%;"></canvas></div>' +
'</div>' +
'</div>' +
'</div>' +
// 오른쪽 열: 서버 현황 (height: 100% 적용)
'<div style="display: flex; flex-direction: column; gap: 0.5rem; background: rgba(255,255,255,0.45); border-radius: 12px; padding: 0.5rem; border: 1px solid rgba(16,185,129,0.12); box-sizing: border-box; min-height: 0; height: 100%;">' +
'<h4 style="font-size: 0.9rem; font-weight: 800; color: #1E293B; margin: 0 0 2px 0; display: flex; align-items: center; gap: 0.25rem;">' +
'<i data-lucide="activity" style="width:15px; height:15px; color:#10B981;"></i> 서버 및 인프라 현황 요약' +
'</h4>' +
// 1행: 서버 KPI 카드 4개 가로 배치
'<div style="display: grid; grid-template-columns: repeat(4, 1fr); gap: 0.4rem; margin-bottom: 0.2rem;">' +
'<div class="stat-card" id="kpi-server-total-4p" style="cursor:pointer; padding: 0.55rem 0.6rem; min-height: unset; border-radius: 8px; box-shadow: 0 1px 4px rgba(0,0,0,0.02); background: rgba(255, 255, 255, 0.85); display: flex; align-items: center; gap: 0.4rem; border: 1px solid rgba(16,185,129,0.05);">' +
'<div style="background: rgba(59,130,246,0.1); color: #3B82F6; padding: 0.3rem; border-radius: 6px; display: flex; align-items: center; justify-content: center; flex-shrink: 0;"><i data-lucide="monitor" style="width: 14px; height: 14px;"></i></div>' +
'<div style="display: flex; flex-direction: column; min-width: 0;">' +
'<span class="stat-label" style="font-size: 0.65rem; color: #64748B; font-weight: 600; margin-bottom: 1px; letter-spacing: 0;">총 서버 수량</span>' +
'<div class="stat-value" style="font-size: 1.1rem; font-weight: 800; color: #1E293B; margin-top: 0; line-height: 1.2; display: flex; align-items: baseline; background: none; -webkit-text-fill-color: initial;">' + servers.length + '<span style="font-size:0.7rem; font-weight:600; color:#64748B; margin-left: 2px;">대</span></div>' +
'</div>' +
'</div>' +
'<div class="stat-card" id="kpi-server-external-4p" style="cursor:pointer; padding: 0.55rem 0.6rem; min-height: unset; border-radius: 8px; box-shadow: 0 1px 4px rgba(0,0,0,0.02); background: rgba(255, 255, 255, 0.85); display: flex; align-items: center; gap: 0.4rem; border: 1px solid rgba(16,185,129,0.05);">' +
'<div style="background: rgba(16,185,129,0.1); color: #10B981; padding: 0.3rem; border-radius: 6px; display: flex; align-items: center; justify-content: center; flex-shrink: 0;"><i data-lucide="activity" style="width: 14px; height: 14px;"></i></div>' +
'<div style="display: flex; flex-direction: column; min-width: 0;">' +
'<span class="stat-label" style="font-size: 0.65rem; color: #10B981; font-weight: 600; margin-bottom: 1px; letter-spacing: 0;">외부 서비스</span>' +
'<div class="stat-value" style="font-size: 1.1rem; font-weight: 800; color: #10B981; margin-top: 0; line-height: 1.2; display: flex; align-items: baseline; background: none; -webkit-text-fill-color: initial;">' + Math.round((serverServiceGroups.external / servers.length) * 100) + '<span style="font-size:0.7rem; font-weight:600; color:#64748B; margin-left: 2px;">%</span></div>' +
'</div>' +
'</div>' +
'<div class="stat-card" id="kpi-server-overspec-4p" style="cursor:pointer; padding: 0.55rem 0.6rem; min-height: unset; border-radius: 8px; box-shadow: 0 1px 4px rgba(0,0,0,0.02); background: rgba(255, 255, 255, 0.85); display: flex; align-items: center; gap: 0.4rem; border: 1px solid rgba(245,158,11,0.1);">' +
'<div style="background: rgba(245,158,11,0.1); color: #F59E0B; padding: 0.35rem; border-radius: 6px; display: flex; align-items: center; justify-content: center; flex-shrink: 0;"><i data-lucide="trending-up" style="width: 14px; height: 14px;"></i></div>' +
'<div style="display: flex; flex-direction: column; min-width: 0;">' +
'<span class="stat-label" style="font-size: 0.65rem; color: #F59E0B; font-weight: 600; margin-bottom: 1px; letter-spacing: 0;">자원 과잉</span>' +
'<div class="stat-value" style="font-size: 1.1rem; font-weight: 800; color: #F59E0B; margin-top: 0; line-height: 1.2; display: flex; align-items: baseline; background: none; -webkit-text-fill-color: initial;">' + serverStatusGroups.overSpec + '<span style="font-size:0.7rem; font-weight:600; color:#64748B; margin-left: 2px;">대</span></div>' +
'</div>' +
'</div>' +
'<div class="stat-card" id="kpi-server-critical-4p" style="cursor:pointer; padding: 0.55rem 0.6rem; min-height: unset; border-radius: 8px; box-shadow: 0 1px 4px rgba(0,0,0,0.02); background: rgba(255, 255, 255, 0.85); display: flex; align-items: center; gap: 0.4rem; border: 1px solid rgba(239,68,68,0.1);">' +
'<div style="background: rgba(239,68,68,0.1); color: #EF4444; padding: 0.35rem; border-radius: 6px; display: flex; align-items: center; justify-content: center; flex-shrink: 0;"><i data-lucide="trending-down" style="width: 14px; height: 14px;"></i></div>' +
'<div style="display: flex; flex-direction: column; min-width: 0;">' +
'<span class="stat-label" style="font-size: 0.65rem; color: #EF4444; font-weight: 600; margin-bottom: 1px; letter-spacing: 0;">자원 부족</span>' +
'<div class="stat-value" style="font-size: 1.1rem; font-weight: 800; color: #EF4444; margin-top: 0; line-height: 1.2; display: flex; align-items: baseline; background: none; -webkit-text-fill-color: initial;">' + (serverStatusGroups.underSpec + serverStatusGroups.inactive) + '<span style="font-size:0.7rem; font-weight:600; color:#EF4444; margin-left: 2px;">대</span></div>' +
'</div>' +
'</div>' +
'</div>' +
// 2행: 서버 그래프 2개 가로 배치
'<div style="display: grid; grid-template-columns: 1fr 1fr; gap: 0.4rem; flex: 1; min-height: 0;">' +
'<div class="dashboard-card" style="min-height: unset; height: 100%; padding: 0.4rem 0.6rem; border-radius: 8px; display:flex; flex-direction:column; background: rgba(255, 255, 255, 0.85); margin-bottom: 0;">' +
'<h5 style="font-size: 0.75rem; font-weight: 800; margin: 0 0 2px 0; color: #475569;">용도별 서버 자원 과부족 현황</h5>' +
'<div style="flex: 1; min-height: 0; position: relative;"><canvas id="chart-total-server-mismatch-by-purpose-4p" style="position: absolute; top:0; left:0; width:100%; height:100%;"></canvas></div>' +
'</div>' +
'<div class="dashboard-card" style="min-height: unset; height: 100%; padding: 0.4rem 0.6rem; border-radius: 8px; display:flex; flex-direction:column; background: rgba(255, 255, 255, 0.85); margin-bottom: 0;">' +
'<h5 style="font-size: 0.75rem; font-weight: 800; margin: 0 0 2px 0; color: #475569;">서버 적정성 분석</h5>' +
'<div style="flex: 1; min-height: 0; position: relative;"><canvas id="chart-server-status-4p" style="position: absolute; top:0; left:0; width:100%; height:100%;"></canvas></div>' +
'</div>' +
'</div>' +
'</div>' +
'</div>' +
'</div>' +
'</div>' +
'</div>';
// --- INIT ---
setTimeout(() => {
if (window.lucide) {
window.lucide.createIcons();
} else {
createIcons({ icons: { DollarSign, Monitor, AlertTriangle, Activity, ChevronLeft, ChevronRight, UserCheck, TrendingUp, TrendingDown, Building2, X, FileText } });
}
initCharts(
jobScores,
recommendedScores,
corpScores,
serverAgeGroups,
serverServiceGroups,
serverStatusGroups,
purposeServerUnders,
purposeServerOvers,
totalCheckout,
totalReturn,
totalMove
);
// 기획서 보기 버튼 클릭 이벤트 바인딩
const btnProposal = document.getElementById('btn-open-proposal');
if (btnProposal) {
btnProposal.addEventListener('click', () => {
window.open('/PC_사양_적정성_분석_기획서.html', '_blank');
});
}
// 서버 테이블 행 클릭
container.querySelectorAll('.clickable-row').forEach(row => {
row.addEventListener('click', () => {
const id = row.getAttribute('data-id');
const asset = allHw.find(h => h.id === id);
if (asset) openHwModal(asset, 'view');
});
});
// KPI 카드 클릭 → 모달
const kpiUnder = document.getElementById('kpi-under-spec');
const kpiOver = document.getElementById('kpi-over-spec');
if (kpiUnder) kpiUnder.addEventListener('click', () => showSpecMismatchModal(criticalPcList, jobScores, allHw, '사양 부족'));
if (kpiOver) kpiOver.addEventListener('click', () => showSpecMismatchModal(criticalPcList, jobScores, allHw, '오버스펙'));
// 서버 KPI 카드 클릭 → 모달 연동
const kpiSvrTotal = document.getElementById('kpi-server-total');
const kpiSvrExternal = document.getElementById('kpi-server-external');
const kpiSvrOverspec = document.getElementById('kpi-server-overspec');
const kpiSvrCritical = document.getElementById('kpi-server-critical');
if (kpiSvrTotal) kpiSvrTotal.addEventListener('click', () => showServerStatusModal(servers, allHw, '전체 서버 및 공용 장비 목록'));
if (kpiSvrExternal) kpiSvrExternal.addEventListener('click', () => showServerStatusModal(servers.filter(s => s.service_type === '외부서비스'), allHw, '외부 운영 서비스 장비 목록'));
if (kpiSvrOverspec) kpiSvrOverspec.addEventListener('click', () => showServerStatusModal(servers.filter(s => s._server_status === '자원 과잉'), allHw, '자원 과잉 장비 목록'));
if (kpiSvrCritical) kpiSvrCritical.addEventListener('click', () => showServerStatusModal(servers.filter(s => s._server_status === '자원 부족' || s._server_status === '방치 의심'), allHw, '자원 부족 및 방치 의심 장비 목록'));
// Slider
const track = document.getElementById('dashboard-slider-track') as HTMLElement;
const btnPrev = document.getElementById('slider-prev') as HTMLButtonElement;
const btnNext = document.getElementById('slider-next') as HTMLButtonElement;
const indicator = document.getElementById('slider-indicator') as HTMLElement;
let currentSlide = 0;
const totalSlides = 4;
const updateSlider = () => {
track.style.transform = 'translateX(-' + (currentSlide * 25) + '%)';
btnPrev.disabled = currentSlide === 0;
btnNext.disabled = currentSlide === totalSlides - 1;
indicator.textContent = (currentSlide + 1) + ' / ' + totalSlides;
};
if (btnPrev) btnPrev.addEventListener('click', () => { if (currentSlide > 0) { currentSlide--; updateSlider(); } });
if (btnNext) btnNext.addEventListener('click', () => { if (currentSlide < totalSlides - 1) { currentSlide++; updateSlider(); } });
// 4p KPI 카드 클릭 → 모달 연동
const kpiUnder4p = document.getElementById('kpi-under-spec-4p');
const kpiOver4p = document.getElementById('kpi-over-spec-4p');
if (kpiUnder4p) kpiUnder4p.addEventListener('click', () => showSpecMismatchModal(criticalPcList, jobScores, allHw, '사양 부족'));
if (kpiOver4p) kpiOver4p.addEventListener('click', () => showSpecMismatchModal(criticalPcList, jobScores, allHw, '오버스펙'));
const kpiSvrTotal4p = document.getElementById('kpi-server-total-4p');
const kpiSvrExternal4p = document.getElementById('kpi-server-external-4p');
const kpiSvrOverspec4p = document.getElementById('kpi-server-overspec-4p');
const kpiSvrCritical4p = document.getElementById('kpi-server-critical-4p');
if (kpiSvrTotal4p) kpiSvrTotal4p.addEventListener('click', () => showServerStatusModal(servers, allHw, '전체 서버 및 공용 장비 목록'));
if (kpiSvrExternal4p) kpiSvrExternal4p.addEventListener('click', () => showServerStatusModal(servers.filter(s => s.service_type === '외부서비스'), allHw, '외부 운영 서비스 장비 목록'));
if (kpiSvrOverspec4p) kpiSvrOverspec4p.addEventListener('click', () => showServerStatusModal(servers.filter(s => s._server_status === '자원 과잉'), allHw, '자원 과잉 장비 목록'));
if (kpiSvrCritical4p) kpiSvrCritical4p.addEventListener('click', () => showServerStatusModal(servers.filter(s => s._server_status === '자원 부족' || s._server_status === '방치 의심'), allHw, '자원 부족 및 방치 의심 장비 목록'));
}, 100);
}
// ─── CHART INIT ───
function initCharts(
jobScores: any,
recommendedScores: any,
corpScores: any,
ageGroups: any,
serviceGroups: any,
statusGroups: any,
purposeServerUnders?: any,
purposeServerOvers?: any,
totalCheckout?: number,
totalReturn?: number,
totalMove?: number
) {
// 직무별 점수
const jobCtx = document.getElementById('chart-job-scores') as HTMLCanvasElement;
if (jobCtx && typeof Chart !== 'undefined') {
const labels = Object.keys(jobScores).sort((a, b) => jobScores[b].avg - jobScores[a].avg);
const avgData = labels.map(l => Math.round(jobScores[l].avg));
const recomData = labels.map(l => recommendedScores[l] || 0);
if (jobChartInstance) {
jobChartInstance.destroy();
jobChartInstance = null;
}
jobChartInstance = new Chart(jobCtx, {
type: 'bar',
data: {
labels: labels,
datasets: [
{
type: 'line',
label: '권장 목표 점수',
data: recomData,
borderColor: '#EF4444',
borderWidth: 2,
borderDash: [5, 5],
fill: false,
pointBackgroundColor: '#EF4444',
order: 1
},
{
type: 'bar',
label: '평균 PC 사양 점수',
data: avgData,
backgroundColor: '#6366F1',
borderRadius: 6,
order: 2
}
]
},
options: {
responsive: true,
maintainAspectRatio: false,
plugins: {
legend: {
display: true,
position: 'top',
labels: {
boxWidth: 12,
usePointStyle: true
}
}
},
scales: {
y: { beginAtZero: true, max: 100, grid: { color: '#F1F5F9' }, border: { display: false } },
x: { grid: { display: false }, border: { display: false } }
},
animation: { duration: 1000, easing: 'easeOutQuart' }
}
});
}
// 가족사별 사양 부족/오버스펙 인원 (Bar)
const corpCtx = document.getElementById('chart-corp-scores') as HTMLCanvasElement;
if (corpCtx && typeof Chart !== 'undefined') {
new Chart(corpCtx, {
type: 'bar',
data: {
labels: corpScores.labels,
datasets: [
{ label: '사양 부족', data: corpScores.unders, backgroundColor: '#E11D48', borderRadius: 4 },
{ label: '오버스펙', data: corpScores.overs, backgroundColor: '#F59E0B', borderRadius: 4 }
]
},
options: {
responsive: true,
maintainAspectRatio: false,
plugins: {
legend: { position: 'bottom', labels: { padding: 15, usePointStyle: true, boxWidth: 8 } }
},
scales: {
y: { beginAtZero: true, ticks: { stepSize: 1 }, grid: { color: '#F1F5F9' }, border: { display: false }, title: { display: true, text: '인원(명)', color: '#94A3B8', font: { size: 11 } } },
x: { grid: { display: false }, border: { display: false } }
},
animation: { duration: 1500, easing: 'easeOutQuart' }
}
});
}
// 서버 노후도 분포 (Doughnut)
const agingCtx = document.getElementById('chart-server-aging') as HTMLCanvasElement;
if (agingCtx && typeof Chart !== 'undefined') {
new Chart(agingCtx, {
type: 'doughnut',
data: {
labels: ['안정 (3년 미만)', '주의 (3~5년)', '위험 (5년 이상)'],
datasets: [{ data: [ageGroups.stable, ageGroups.warning, ageGroups.critical], backgroundColor: ['#10B981', '#F59E0B', '#E11D48'], borderWidth: 0, hoverOffset: 8 }]
},
options: {
responsive: true, maintainAspectRatio: false,
plugins: { legend: { position: 'bottom', labels: { padding: 10, usePointStyle: true, boxWidth: 10 } } },
cutout: '75%', animation: { animateScale: true, animateRotate: true }
}
});
}
// 서비스 유형 분포 (Doughnut)
const serviceCtx = document.getElementById('chart-server-service') as HTMLCanvasElement;
if (serviceCtx && typeof Chart !== 'undefined') {
new Chart(serviceCtx, {
type: 'doughnut',
data: {
labels: ['내부인프라/백업', '외부서비스/운영', '회의용/공용'],
datasets: [{
data: [serviceGroups.internal, serviceGroups.external, serviceGroups.public],
backgroundColor: ['#6366F1', '#10B981', '#F59E0B'],
borderWidth: 0,
hoverOffset: 8
}]
},
options: {
responsive: true,
maintainAspectRatio: false,
plugins: {
legend: { position: 'bottom', labels: { padding: 10, usePointStyle: true, boxWidth: 10 } }
},
cutout: '75%',
animation: { animateScale: true, animateRotate: true }
}
});
}
// 자원 적정성 상태 분포 (Bar)
const statusCtx = document.getElementById('chart-server-status') as HTMLCanvasElement;
if (statusCtx && typeof Chart !== 'undefined') {
new Chart(statusCtx, {
type: 'bar',
data: {
labels: ['적정', '자원 부족', '자원 과잉', '방치 의심'],
datasets: [{
label: '장비 수(대)',
data: [statusGroups.optimal, statusGroups.underSpec, statusGroups.overSpec, statusGroups.inactive],
backgroundColor: ['#10B981', '#EF4444', '#F59E0B', '#64748B'],
borderRadius: 6
}]
},
options: {
responsive: true,
maintainAspectRatio: false,
plugins: {
legend: { display: false }
},
scales: {
y: { beginAtZero: true, ticks: { stepSize: 5 }, grid: { color: '#F1F5F9' }, border: { display: false } },
x: { grid: { display: false }, border: { display: false } }
}
}
});
}
// ─── 종합 대시보드 차트 초기화 ───
// 1. 가족사별 PC 사양 과부족 현황 (Grouped Bar Chart)
const totalPcMismatchCtx = document.getElementById('chart-total-pc-mismatch-by-corp') as HTMLCanvasElement;
if (totalPcMismatchCtx && typeof Chart !== 'undefined' && corpScores) {
if (totalPcMismatchByCorpChartInstance) {
totalPcMismatchByCorpChartInstance.destroy();
totalPcMismatchByCorpChartInstance = null;
}
totalPcMismatchByCorpChartInstance = new Chart(totalPcMismatchCtx, {
type: 'bar',
data: {
labels: corpScores.labels,
datasets: [
{
label: '사양 부족 (명)',
data: corpScores.unders,
backgroundColor: '#E11D48',
borderRadius: 4
},
{
label: '오버스펙 (명)',
data: corpScores.overs,
backgroundColor: '#F59E0B',
borderRadius: 4
}
]
},
options: {
responsive: true,
maintainAspectRatio: false,
plugins: {
legend: {
position: 'bottom',
labels: {
padding: 10,
usePointStyle: true,
boxWidth: 10
}
}
},
scales: {
x: {
grid: { display: false },
border: { display: false }
},
y: {
beginAtZero: true,
ticks: { stepSize: 1 },
grid: { color: '#F1F5F9' },
border: { display: false },
title: {
display: true,
text: '인원(명)',
color: '#94A3B8',
font: { size: 11 }
}
}
},
animation: {
duration: 1200,
easing: 'easeOutQuart'
}
}
});
}
// 2. 용도별 서버 자원 과부족 현황 (Grouped Bar Chart)
const totalServerMismatchCtx = document.getElementById('chart-total-server-mismatch-by-purpose') as HTMLCanvasElement;
if (totalServerMismatchCtx && typeof Chart !== 'undefined' && purposeServerUnders && purposeServerOvers) {
if (totalServerMismatchByPurposeChartInstance) {
totalServerMismatchByPurposeChartInstance.destroy();
totalServerMismatchByPurposeChartInstance = null;
}
totalServerMismatchByPurposeChartInstance = new Chart(totalServerMismatchCtx, {
type: 'bar',
data: {
labels: ['개발/테스트', '서비스/웹/WAS', 'DB/스토리지', '해석/분석/AI', '백업/관리/보안', '기타/일반'],
datasets: [
{
label: '자원 부족 (대)',
data: purposeServerUnders,
backgroundColor: '#EF4444',
borderRadius: 4
},
{
label: '자원 과잉 (대)',
data: purposeServerOvers,
backgroundColor: '#F59E0B',
borderRadius: 4
}
]
},
options: {
responsive: true,
maintainAspectRatio: false,
plugins: {
legend: {
position: 'bottom',
labels: {
padding: 10,
usePointStyle: true,
boxWidth: 10
}
}
},
scales: {
x: {
grid: { display: false },
border: { display: false }
},
y: {
beginAtZero: true,
ticks: { stepSize: 1 },
grid: { color: '#F1F5F9' },
border: { display: false },
title: {
display: true,
text: '장비 수(대)',
color: '#94A3B8',
font: { size: 11 }
}
}
},
animation: {
duration: 1200,
easing: 'easeOutQuart'
}
}
});
}
// ─── 4페이지(종합 카드판) 차트 초기화 ───
// 1. 직무별 평균 PC 사양 점수 (4p)
const jobCtx4p = document.getElementById('chart-job-scores-4p') as HTMLCanvasElement;
if (jobCtx4p && typeof Chart !== 'undefined') {
const labels = Object.keys(jobScores).sort((a, b) => jobScores[b].avg - jobScores[a].avg);
const avgData = labels.map(l => Math.round(jobScores[l].avg));
const recomData = labels.map(l => recommendedScores[l] || 0);
if (jobChartInstance4p) {
jobChartInstance4p.destroy();
jobChartInstance4p = null;
}
jobChartInstance4p = new Chart(jobCtx4p, {
type: 'bar',
data: {
labels: labels,
datasets: [
{
type: 'line',
label: '권장 목표',
data: recomData,
borderColor: '#EF4444',
borderWidth: 1.5,
borderDash: [3, 3],
fill: false,
pointBackgroundColor: '#EF4444',
pointRadius: 2,
order: 1
},
{
type: 'bar',
label: '평균 PC 점수',
data: avgData,
backgroundColor: '#6366F1',
borderRadius: 4,
order: 2
}
]
},
options: {
responsive: true,
maintainAspectRatio: false,
plugins: {
legend: {
display: true,
position: 'top',
labels: { boxWidth: 8, usePointStyle: true, font: { size: 9 }, padding: 4 }
}
},
scales: {
y: { beginAtZero: true, max: 100, ticks: { font: { size: 8.5 } }, grid: { color: '#F1F5F9' }, border: { display: false } },
x: { ticks: { font: { size: 8.5 } }, grid: { display: false }, border: { display: false } }
},
animation: { duration: 800, easing: 'easeOutQuart' }
}
});
}
// 2. 가족사별 PC 사양 현황 (4p)
const corpCtx4p = document.getElementById('chart-corp-scores-4p') as HTMLCanvasElement;
if (corpCtx4p && typeof Chart !== 'undefined') {
if (corpChartInstance4p) {
corpChartInstance4p.destroy();
corpChartInstance4p = null;
}
corpChartInstance4p = new Chart(corpCtx4p, {
type: 'bar',
data: {
labels: corpScores.labels,
datasets: [
{ label: '부족', data: corpScores.unders, backgroundColor: '#E11D48', borderRadius: 3 },
{ label: '과잉', data: corpScores.overs, backgroundColor: '#F59E0B', borderRadius: 3 }
]
},
options: {
responsive: true,
maintainAspectRatio: false,
plugins: {
legend: { position: 'bottom', labels: { padding: 4, usePointStyle: true, boxWidth: 6, font: { size: 9 } } }
},
scales: {
y: { beginAtZero: true, ticks: { stepSize: 1, font: { size: 8.5 } }, grid: { color: '#F1F5F9' }, border: { display: false } },
x: { ticks: { font: { size: 8.5 } }, grid: { display: false }, border: { display: false } }
},
animation: { duration: 1000, easing: 'easeOutQuart' }
}
});
}
// 3. 용도별 서버 자원 과부족 현황 (4p)
const totalServerMismatchCtx4p = document.getElementById('chart-total-server-mismatch-by-purpose-4p') as HTMLCanvasElement;
if (totalServerMismatchCtx4p && typeof Chart !== 'undefined' && purposeServerUnders && purposeServerOvers) {
if (totalServerMismatchByPurposeChartInstance4p) {
totalServerMismatchByPurposeChartInstance4p.destroy();
totalServerMismatchByPurposeChartInstance4p = null;
}
totalServerMismatchByPurposeChartInstance4p = new Chart(totalServerMismatchCtx4p, {
type: 'bar',
data: {
labels: ['개발/테스트', '웹/WAS', 'DB/스토리지', '해석/AI', '백업/보안', '기타'],
datasets: [
{
label: '부족',
data: purposeServerUnders,
backgroundColor: '#EF4444',
borderRadius: 3
},
{
label: '과잉',
data: purposeServerOvers,
backgroundColor: '#F59E0B',
borderRadius: 3
}
]
},
options: {
responsive: true,
maintainAspectRatio: false,
plugins: {
legend: {
position: 'bottom',
labels: { padding: 4, usePointStyle: true, boxWidth: 6, font: { size: 9 } }
}
},
scales: {
x: { ticks: { font: { size: 8.5 } }, grid: { display: false }, border: { display: false } },
y: {
beginAtZero: true,
ticks: { stepSize: 1, font: { size: 8.5 } },
grid: { color: '#F1F5F9' },
border: { display: false }
}
},
animation: { duration: 1000, easing: 'easeOutQuart' }
}
});
}
// 5. 서버/공용PC 적정성 분석 (4p)
const statusCtx4p = document.getElementById('chart-server-status-4p') as HTMLCanvasElement;
if (statusCtx4p && typeof Chart !== 'undefined') {
if (serverStatusChartInstance4p) {
serverStatusChartInstance4p.destroy();
serverStatusChartInstance4p = null;
}
serverStatusChartInstance4p = new Chart(statusCtx4p, {
type: 'bar',
data: {
labels: ['적정', '부족', '과잉', '방치'],
datasets: [{
label: '수량',
data: [statusGroups.optimal, statusGroups.underSpec, statusGroups.overSpec, statusGroups.inactive],
backgroundColor: ['#10B981', '#EF4444', '#F59E0B', '#64748B'],
borderRadius: 4
}]
},
options: {
responsive: true,
maintainAspectRatio: false,
plugins: {
legend: { display: false }
},
scales: {
y: { beginAtZero: true, ticks: { stepSize: 5, font: { size: 8.5 } }, grid: { color: '#F1F5F9' }, border: { display: false } },
x: { ticks: { font: { size: 8.5 } }, grid: { display: false }, border: { display: false } }
}
}
});
}
// PC 유동 비율 도넛 차트
const flowCtx = document.getElementById('chart-pc-flow-stats') as HTMLCanvasElement;
if (flowCtx && typeof Chart !== 'undefined') {
const tCheckout = totalCheckout || 0;
const tReturn = totalReturn || 0;
const tMove = totalMove || 0;
if (pcFlowChartInstance) {
pcFlowChartInstance.destroy();
pcFlowChartInstance = null;
}
pcFlowChartInstance = new Chart(flowCtx, {
type: 'doughnut',
data: {
labels: ['불출', '입고(반납)', '이동(이관)'],
datasets: [{
data: [tCheckout, tReturn, tMove],
backgroundColor: ['#3B82F6', '#10B981', '#F59E0B'],
borderWidth: 0,
hoverOffset: 8
}]
},
options: {
responsive: true,
maintainAspectRatio: false,
plugins: {
legend: { position: 'bottom', labels: { padding: 10, usePointStyle: true, boxWidth: 10 } }
},
cutout: '75%',
animation: { animateScale: true, animateRotate: true }
}
});
}
}