diff --git a/src/views/Dashboard/HwDashboard.ts b/src/views/Dashboard/HwDashboard.ts index 83c6956..069b612 100644 --- a/src/views/Dashboard/HwDashboard.ts +++ b/src/views/Dashboard/HwDashboard.ts @@ -2,170 +2,840 @@ 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; + +// ─── 네트워크 트래픽 문자열을 숫자(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 || []; - - // 1. 데이터 가공 - const pcIds = new Set((state.masterData.pc || []).map((p: any) => p.id)); - const serverIds = new Set((state.masterData.server || []).map((s: any) => s.id)); - let totalAge = 0; - let countWithDate = 0; - let over5YearsCount = 0; - let agingPcCount = 0; - let agingServerCount = 0; - let latestAsset: any | null = null; - let latestYear = 0; + // --- 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] || ''; + return (cat === 'PC' || type === '개인PC' || type === '노트북' || type === '공용PC') && job !== '재고PC'; + }); - const ageGroups = { stable: 0, warning: 0, critical: 0 }; - const yearlyCount: Record = {}; + 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; + }); - allHw.forEach(a => { - const pDate = a[ASSET_SCHEMA.PURCHASE_DATE.key]; - if (!pDate) return; + 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 age = calculateAssetAge(pDate); - totalAge += age; - countWithDate++; + const jobsList = Object.keys(jobScores).sort((a, b) => jobScores[b].avg - jobScores[a].avg); + const recommendedScores = getRecommendedScores(jobsList); - // 노후도 분류 - if (age >= 5) { - over5YearsCount++; - ageGroups.critical++; - if (pcIds.has(a.id)) { - agingPcCount++; - } else if (serverIds.has(a.id)) { - agingServerCount++; - } - } else if (age >= 3) { - ageGroups.warning++; + 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 { - ageGroups.stable++; + 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 year = normalizeDate(pDate).split('-')[0]; - if (year && year.length === 4) { - yearlyCount[year] = (yearlyCount[year] || 0) + 1; - const yNum = parseInt(year); - if (yNum > latestYear) { - latestYear = yNum; - latestAsset = a; + // 서비스 유형 판단 및 집계 + 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 avgAge = countWithDate > 0 ? (totalAge / countWithDate).toFixed(1) : '0'; - const over5Rate = allHw.length > 0 ? Math.round((over5YearsCount / allHw.length) * 100) : 0; - - // 교체 시급 대상 TOP 10 (오래된 순) - const criticalList = [...allHw] - .filter(a => a[ASSET_SCHEMA.PURCHASE_DATE.key]) - .sort((a, b) => { - const dateA = new Date(normalizeDate(a[ASSET_SCHEMA.PURCHASE_DATE.key])).getTime(); - const dateB = new Date(normalizeDate(b[ASSET_SCHEMA.PURCHASE_DATE.key])).getTime(); - return dateA - dateB; - }) - .slice(0, 10); + 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); - // 2. UI 렌더링 - container.innerHTML = ` -
-
-
- 전체 평균 사용 연수 -
전체 자산 기준 (권장 4.5년)
-
${avgAge}년
-
-
-
-
-
- 교체 대상 장비 (5년 노후) -
총 ${over5YearsCount}대 해당
- -
-
- 개인/공용 PC - ${agingPcCount}대 -
-
-
- 서버 장비 - ${agingServerCount}대 -
-
- -
-
-
-
-
-
- 최신 도입 모델 (${latestYear}년) -
자산번호: ${latestAsset ? latestAsset[ASSET_SCHEMA.ASSET_CODE.key] : '-'}
-
- ${latestAsset ? (latestAsset[ASSET_SCHEMA.MODEL_NAME.key] || latestAsset[ASSET_SCHEMA.ASSET_NAME.key] || '정보 없음') : '정보 없음'} -
-
-
-
+ // 리스트: 자원 과잉 장비 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 10)

-
- - - - - - - - - - - - - - ${criticalList.map((a, i) => { - const pDate = a[ASSET_SCHEMA.PURCHASE_DATE.key]; - const age = calculateAssetAge(pDate); - return ` - - - - - - - - - - `; - }).join('')} - -
순위자산번호유형모델명담당자구매일자연령
${i + 1}${a[ASSET_SCHEMA.ASSET_CODE.key] || '-'}${a[ASSET_SCHEMA.ASSET_TYPE.key] || a.category || ''}${a[ASSET_SCHEMA.MODEL_NAME.key] || a[ASSET_SCHEMA.ASSET_NAME.key] || '-'}${a[ASSET_SCHEMA.MANAGER_MAIN.key] || '-'}${pDate || '-'}${age}년
-
-
- `; + // 리스트: 방치 의심 장비 (회수/재배치 필요) TOP 5 + const inactiveList = servers + .filter(a => a['_server_status'] === '방치 의심') + .slice(0, 5); - // 3. 차트 초기화 + // --- 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 / 2' + + '' + + '
' + + '
' + + + '
' + + '
' + + + // ── SLIDE 1: 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열) + '
' + + '
' + + '

서비스 유형 분포

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

서버/공용PC 적정성 분석

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

서버/스토리지 노후도 분포

' + + '
' + + '
' + + '
' + + + // 테이블 영역 (2열 레이아웃) + '
' + + '
' + + '

' + + '⚠️ 자원 과잉 장비 (TOP 5)' + + '

' + + '
' + + '' + + '' + + '' + buildServerStatusTableRows(overSpecList) + '' + + '
순위장비명서비스사양 요약사용 리소스 (CPU/RAM)일일 전송량점수상태
' + + '
' + + '
' + + '
' + + '

' + + '🔻 자원 부족 장비 (TOP 5)' + + '

' + + '
' + + '' + + '' + + '' + buildServerStatusTableRows(underSpecList) + '' + + '
순위장비명서비스사양 요약사용 리소스 (CPU/RAM)일일 전송량점수상태
' + + '
' + + '
' + + '
' + + + // 방치 장비 목록 (Full-width) + '
' + + '

' + + '🔍 미사용 방치 의심 장비 (회수/철수 권장)' + + '

' + + '
' + + '' + + '' + + '' + buildServerStatusTableRows(inactiveList) + '' + + '
순위장비명서비스사양 요약사용 리소스 (CPU/RAM)일일 전송량점수상태
' + + '
' + + '
' + + + '
' + + + '
' + + '
' + + '
'; + + // --- INIT --- setTimeout(() => { - initAgingCharts(ageGroups, yearlyCount); - - // 행 클릭 이벤트 바인딩 + 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); + + // 기획서 보기 버튼 클릭 이벤트 바인딩 + 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'); @@ -173,51 +843,207 @@ export function renderHwDashboard(container: HTMLElement) { 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 = 2; + + const updateSlider = () => { + track.style.transform = 'translateX(-' + (currentSlide * 50) + '%)'; + 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(); } }); }, 100); } -function initAgingCharts(ageGroups: any, yearlyCount: Record) { - const agingCtx = document.getElementById('chart-aging-dist') as HTMLCanvasElement; + +// ─── CHART INIT ─── +function initCharts( + jobScores: any, + recommendedScores: any, + corpScores: any, + ageGroups: any, + serviceGroups: any, + statusGroups: any +) { + // 직무별 점수 + 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: ['#1E5149', '#9CA3AF', '#E11D48'], - borderWidth: 0 - }] + datasets: [{ data: [ageGroups.stable, ageGroups.warning, ageGroups.critical], backgroundColor: ['#10B981', '#F59E0B', '#E11D48'], borderWidth: 0, hoverOffset: 8 }] }, options: { - responsive: true, - maintainAspectRatio: false, - plugins: { legend: { position: 'right' } }, - cutout: '70%' + responsive: true, maintainAspectRatio: false, + plugins: { legend: { position: 'bottom', labels: { padding: 10, usePointStyle: true, boxWidth: 10 } } }, + cutout: '75%', animation: { animateScale: true, animateRotate: true } } }); } - const trendCtx = document.getElementById('chart-purchase-trend') as HTMLCanvasElement; - if (trendCtx && typeof Chart !== 'undefined') { - const years = Object.keys(yearlyCount).sort(); - new Chart(trendCtx, { - type: 'bar', + // 서비스 유형 분포 (Doughnut) + const serviceCtx = document.getElementById('chart-server-service') as HTMLCanvasElement; + if (serviceCtx && typeof Chart !== 'undefined') { + new Chart(serviceCtx, { + type: 'doughnut', data: { - labels: years, + labels: ['내부인프라/백업', '외부서비스/운영', '회의용/공용'], datasets: [{ - label: '도입 수량', - data: years.map(y => yearlyCount[y]), - backgroundColor: '#1E5149', - borderRadius: 4 + 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: 1 } }, - x: { grid: { display: false } } + y: { beginAtZero: true, ticks: { stepSize: 5 }, grid: { color: '#F1F5F9' }, border: { display: false } }, + x: { grid: { display: false }, border: { display: false } } } } });