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 { const stored = localStorage.getItem('recommended_pc_scores'); let scores: Record = {}; if (stored) { try { scores = JSON.parse(stored); } catch (e) { console.error(e); } } const defaultScores: Record = { '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) { 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 = '부적합 대상자가 없습니다.'; } 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 += ''; rows += '' + userName + ''; rows += '' + job + ''; rows += '' + corp + ''; rows += '' + assetCode + ''; rows += '' + cpuStr + ' / ' + ramStr + ' / ' + gpuStr + ''; rows += '' + score + ''; rows += '' + avg + ''; rows += '' + status + ''; rows += ''; } } 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 = '
' + '

' + ' ' + titleText + '

' + '' + '
' + '
' + '
' + '
' + '🔻' + '
사양 부족
' + criticalPcList.filter(p => p['_spec_status'] === '사양 부족').length + '명
' + '
' + '
' + '🔺' + '
오버스펙
' + criticalPcList.filter(p => p['_spec_status'] === '오버스펙').length + '명
' + '
' + '
' + '
' + '' + '' + '' + '' + '' + rows + '' + '
사용자직무가족사자산번호사양(CPU/RAM/GPU)점수직무평균상태
' + '
' + '
'; 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 = '해당하는 장비가 없습니다.'; } 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 = `CPU ${cpuUsage}% / RAM ${ramUsage}%`; } else { resourceHtml = `CPU ${cpuUsage}% / RAM ${ramUsage}%`; } trafficHtml = a.network_traffic || '-'; } else { resourceHtml = `-`; trafficHtml = `0 GB (N/A)`; } rows += ``; rows += `${idx + 1}`; rows += `${a.asset_name || a[ASSET_SCHEMA.ASSET_CODE.key]}`; rows += `${service}`; rows += `${a.current_dept || '-'}`; rows += `${a.location || '-'}`; rows += `${resourceHtml}`; rows += `${trafficHtml}`; rows += `${score}점`; rows += `${status}`; rows += ''; }); } 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 = '
' + '

' + ' ' + titleText + '

' + '' + '
' + '
' + '
' + '' + '' + '' + '' + '' + rows + '' + '
순위장비명서비스 유형소속 부서설치 위치월 평균 리소스 사용량 (CPU/RAM)월 데이터 전송량점수상태
' + '
' + '
'; 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 '대상 장비가 없습니다.'; } 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 = `CPU ${cpuUsage}% / RAM ${ramUsage}%`; } else { resourceHtml = `CPU ${cpuUsage}% / RAM ${ramUsage}%`; } trafficHtml = a.network_traffic || '-'; } else { resourceHtml = `-`; trafficHtml = `0 GB (N/A)`; } rows += ``; rows += `${idx + 1}`; rows += `${a.asset_name || a[ASSET_SCHEMA.ASSET_CODE.key]}`; rows += `${service}`; rows += `${resourceHtml}`; rows += `${trafficHtml}`; rows += `${score}점`; rows += `${status}`; rows += ''; }); 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 = '최근 유동 이력이 없습니다.'; } else { recentFlowLogs.forEach((log: any) => { const details = log.details || ''; let badgeHtml = ''; if (details.includes('[불출]')) { badgeHtml = '불출'; } else if (details.includes('[반납]') || details.includes('[입고]')) { badgeHtml = '입고'; } else if (details.includes('[이동]') || details.includes('[이관]')) { badgeHtml = '이동'; } const cleanDetails = details.replace(/^\[(불출|반납|입고|이동|이관)\]\s*/, ''); recentFlowLogsHtml += ` ${log.log_date || '-'} ${badgeHtml} ${log.log_user || '시스템'} ${cleanDetails} `; }); } // --- 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 = {}; 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 += '
'; corpAvgListHtml += '' + corpScores.labels[ci] + ''; corpAvgListHtml += '' + corpScores.avgs[ci] + '점'; corpAvgListHtml += '
'; } // --- RENDER --- container.innerHTML = '
' + '
' + '
' + '

Executive Dashboard (PC & Server)

' + '' + '
' + '
' + '' + '1 / 5' + '' + '
' + '
' + '
' + '
' + // ── SLIDE 1: TOTAL EXECUTIVE DASHBOARD ── '
' + '

📊 전사 IT 자산 및 자원 최적화 요약

' + // KPI Row '
' + '
' + '
' + 'PC 사양 부족 장비' + '
' + underSpecCount + '
' + '
개인용 PC 대비 ' + (pcs.length > 0 ? Math.round((underSpecCount / pcs.length) * 100) : 0) + '% 비율
' + '
' + '
' + '
' + 'PC 오버스펙 장비' + '
' + overSpecCount + '
' + '
개인용 PC 대비 ' + (pcs.length > 0 ? Math.round((overSpecCount / pcs.length) * 100) : 0) + '% 비율
' + '
' + '
' + '
' + '서버 자원 부족 장비' + '
' + serverStatusGroups.underSpec + '
' + '
서버/NAS 대비 ' + (servers.length > 0 ? Math.round((serverStatusGroups.underSpec / servers.length) * 100) : 0) + '% 비율
' + '
' + '
' + '
' + '서버 자원 과잉 장비' + '
' + serverStatusGroups.overSpec + '
' + '
서버/NAS 대비 ' + (servers.length > 0 ? Math.round((serverStatusGroups.overSpec / servers.length) * 100) : 0) + '% 비율
' + '
' + '
' + // Charts '
' + '
' + '

가족사별 PC 사양 과부족 현황

' + '
' + '
' + '
' + '

용도별 서버 자원 과부족 현황

' + '
' + '
' + '
' + '
' + // ── SLIDE 2: PC DASHBOARD ── '
' + '

💻 PC 사양 적정성 분석

' + // KPI Row '
' + '
' + '
' + '전사 평균 PC 사양 점수' + '
' + overallPcAvg + '
' + '
운영 중인 PC 총 ' + pcs.length + '대 기준
' + '
' + '
' + '
' + '사양 부족 인원 (교체 검토)' + '
' + underSpecCount + '
' + '
직무 평균 대비 20% 이상 미달  ▸ 클릭하여 상세보기
' + '
' + '
' + '
' + '오버스펙 인원 (회수 검토)' + '
' + overSpecCount + '
' + '
직무 평균 대비 30% 이상 초과  ▸ 클릭하여 상세보기
' + '
' + '
' + '
' + '교체/회수 대상 비율' + '
' + (pcs.length > 0 ? Math.round(((underSpecCount + overSpecCount) / pcs.length) * 100) : 0) + '%
' + '
' + '
' + '
' + '
* 산출식: (사양 부족 + 오버스펙 인원) / 전체 PC 수량 × 100
' + '
' + '
' + // Charts + Corp Stats '
' + '
' + '

직무별 평균 PC 사양 점수

' + '
' + '
' + '
' + '

' + ' 가족사별 PC 사양 현황' + '

' + '
' + '
' + '
평균 점수
' + '
' + corpAvgListHtml + '
' + '
' + '
' + '
' + '
' + '
' + '
' + // ── SLIDE 2: SERVER DASHBOARD ── '
' + '

🖥️ 서버 및 공용 인프라 분석

' + '
' + '
' + '
' + '총 운영 서버/NAS 수량' + '
' + servers.length + '
' + '
실제 도입 가치: ' + serverFormattedValue + '원  ▸ 클릭시 목록보기
' + '
' + '
' + '
' + '외부 운영 서비스 비율' + '
' + serverServiceGroups.external + '대 (' + Math.round((serverServiceGroups.external / servers.length) * 100) + '%)
' + '
사내용 인프라 ' + serverServiceGroups.internal + '대 / 회의실 ' + serverServiceGroups.public + '대  ▸ 클릭시 목록보기
' + '
' + '
' + '
' + '자원 과잉 장비' + '
' + serverStatusGroups.overSpec + '
' + '
리소스 사용률 및 데이터 전송량이 극히 저조한 장비  ▸ 클릭시 목록보기
' + '
' + '
' + '
' + '자원 부족 장비' + '
' + (serverStatusGroups.underSpec + serverStatusGroups.inactive) + '
' + '
자원 부족 ' + serverStatusGroups.underSpec + '대 / 방치 장비 ' + serverStatusGroups.inactive + '대  ▸ 클릭시 목록보기
' + '
' + '
' + // 차트 영역 (3열) '
' + '
' + '

서비스 유형 분포

' + '
' + '
' + '
' + '

용도별 서버 자원 과부족 현황

' + '
' + '
' + '
' + '

서버 적정성 분석

' + '
' + '
' + '
' + '
' + // ── SLIDE 4: ALL DETAILS EXECUTIVE CARDS ── '
' + '

📋 전사 PC 및 서버 상세 현황 (종합 상황판)

' + // 세로로 분할된 2열 구조 컨테이너 (140% 내용 확대) '
' + // 왼쪽 열: PC 현황 (height: 100% 적용) '
' + '

' + ' PC 현황 요약' + '

' + // 1행: PC KPI 카드 3개 가로 배치 '
' + '
' + '
' + '
' + '평균 PC 점수' + '
' + overallPcAvg + '
' + '
' + '
' + '
' + '
' + '
' + '사양 부족(교체)' + '
' + underSpecCount + '
' + '
' + '
' + '
' + '
' + '
' + '오버스펙(회수)' + '
' + overSpecCount + '
' + '
' + '
' + '
' + // 2행: PC 그래프 2개 가로 배치 '
' + '
' + '
직무별 평균 PC 사양 점수
' + '
' + '
' + '
' + '
가족사별 PC 사양 현황
' + '
' + '
' + '
' + '
' + // 오른쪽 열: 서버 현황 (height: 100% 적용) '
' + '

' + ' 서버 및 인프라 현황 요약' + '

' + // 1행: 서버 KPI 카드 4개 가로 배치 '
' + '
' + '
' + '
' + '총 서버 수량' + '
' + servers.length + '
' + '
' + '
' + '
' + '
' + '
' + '외부 서비스' + '
' + Math.round((serverServiceGroups.external / servers.length) * 100) + '%
' + '
' + '
' + '
' + '
' + '
' + '자원 과잉' + '
' + serverStatusGroups.overSpec + '
' + '
' + '
' + '
' + '
' + '
' + '자원 부족' + '
' + (serverStatusGroups.underSpec + serverStatusGroups.inactive) + '
' + '
' + '
' + '
' + // 2행: 서버 그래프 2개 가로 배치 '
' + '
' + '
용도별 서버 자원 과부족 현황
' + '
' + '
' + '
' + '
서버 적정성 분석
' + '
' + '
' + '
' + '
' + '
' + '
' + '
' + '
'; // --- 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 } } }); } }