1768 lines
87 KiB
TypeScript
1768 lines
87 KiB
TypeScript
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 & 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% 이상 미달 ▸ 클릭하여 상세보기</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% 이상 초과 ▸ 클릭하여 상세보기</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 + '원 ▸ 클릭시 목록보기</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 + '대 ▸ 클릭시 목록보기</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;">리소스 사용률 및 데이터 전송량이 극히 저조한 장비 ▸ 클릭시 목록보기</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 + '대 ▸ 클릭시 목록보기</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 }
|
||
}
|
||
});
|
||
}
|
||
}
|