import { state } from '../../core/state'; import { openHwModal } from '../../components/Modal/HWModal'; import { calculatePcScoreDeductive, getPcGrade, calculateAssetAge, isWindows11Incompatible } from '../../core/utils'; import { ASSET_SCHEMA } from '../../core/schema'; import { createIcons, Laptop, Cpu, Shield, Zap, Monitor, AlertTriangle, ChevronRight, HelpCircle } from 'lucide'; declare var Chart: any; let jobChartInstance: any = null; let donutChartInstance: any = null; export function renderHwDashboard(container: HTMLElement) { // 1. 개인용 PC 데이터 추출 (유형이 '개인PC'이거나 상태가 '재고' 또는 '대기' 상태인 PC 집계) const pcs = (state.masterData.pc || []).filter((a: any) => a.asset_type === '개인PC' || ((a.hw_status === '재고' || a.hw_status === '대기') && a.category === 'PC') ); // 2. 1페이지 매거진 리포트(제목바 제거, '| 제목' 미니멀리즘 스타일) HTML 빌드 container.innerHTML = `

개인 PC 자산 대시보드

조직 필터:
보유 자산 수량
0대
전사 보유 개인용 PC
사양 부족 검토
0명
사양 교체 권고 자산
오버스펙 검토
0명
사양 회수 권고 자산
윈도우 11 불가 PC
0대
업데이트 미지원 하드웨어
등급별 보유 현황
최상급 PC (85점 이상) 0대(0%)
상급 PC (70점 ~ 85점) 0대(0%)
중급 PC (40점 ~ 70점) 0대(0%)
보급 PC (40점 미만) 0대(0%)
등급별 보유 비율
최상급
상급
중급
보급
유효 재고 현황
0대
최상급 재고
0대
중급 재고
0대
상급 재고
0대
보급 재고
직무별 사양 적정성 분석
연도별 PC 노후도 및 교체 주기 예측
구분 (사용 연한) 보유 대수 권장 조치
`; // 3. Lucide 아이콘 초기화 createIcons({ icons: { Laptop, Cpu, Shield, Zap, Monitor, AlertTriangle, ChevronRight, HelpCircle } }); // 4. 사용조직 버튼 그룹 필터 이벤트 연동 const btnGroup = container.querySelector('#dashboard-dept-buttons') as HTMLElement; btnGroup.addEventListener('click', (e) => { const btn = (e.target as HTMLElement).closest('.dept-filter-btn') as HTMLButtonElement; if (!btn) return; btnGroup.querySelectorAll('.dept-filter-btn').forEach(b => { const button = b as HTMLButtonElement; button.classList.remove('active'); button.style.background = 'transparent'; button.style.color = '#475569'; }); btn.classList.add('active'); btn.style.background = '#1E5149'; btn.style.color = 'white'; const selectedDept = btn.getAttribute('data-dept') || ''; updateDashboardData(pcs, selectedDept); }); // 5. 첫 로딩 시 전체 부서 대상 통계 로드 updateDashboardData(pcs, ''); } /** * 대시보드 데이터 수치 및 차트, 테이블 실시간 갱신 */ function updateDashboardData(pcs: any[], selectedDept: string) { // 1. 선택 부서 필터 적용 const filtered = selectedDept ? pcs.filter((p: any) => String(p[ASSET_SCHEMA.CURRENT_DEPT.key] || '').trim().includes(selectedDept)) : pcs; // 2. 개별 PC의 성능 감점식 점수 실시간 재연산 filtered.forEach((p: any) => { p._pc_score = calculatePcScoreDeductive(p.cpu, p.ram, p.gpu, p.purchase_date); }); // 3. 전사 직무군별 평균 점수 산출 const jobScores: Record = {}; pcs.forEach((p: any) => { const score = calculatePcScoreDeductive(p.cpu, p.ram, p.gpu, p.purchase_date); const job = p[ASSET_SCHEMA.USER_POSITION.key] || '미분류'; if (!jobScores[job]) jobScores[job] = { totalScore: 0, count: 0, avg: 0 }; jobScores[job].totalScore += score; jobScores[job].count += 1; }); Object.keys(jobScores).forEach(job => { jobScores[job].avg = jobScores[job].count > 0 ? jobScores[job].totalScore / jobScores[job].count : 0; }); // 4. 등급 집계 (보유량 vs 유효 재고량) const isStock = (p: any) => { return p.hw_status === '재고' || p.hw_status === '대기' || !(p.user_current || '').trim(); }; const gradeCounts = { premium: { total: 0, stock: 0 }, high: { total: 0, stock: 0 }, normal: { total: 0, stock: 0 }, entry: { total: 0, stock: 0 } }; let scoreSum = 0; let underSpecCount = 0; let overSpecCount = 0; let win11IncompatibleCount = 0; const criticalList: any[] = []; filtered.forEach((p: any) => { const score = p._pc_score; scoreSum += score; const stockYn = isStock(p); if (score >= 85) { gradeCounts.premium.total++; if (stockYn) gradeCounts.premium.stock++; } else if (score >= 70) { gradeCounts.high.total++; if (stockYn) gradeCounts.high.stock++; } else if (score >= 40) { gradeCounts.normal.total++; if (stockYn) gradeCounts.normal.stock++; } else { gradeCounts.entry.total++; if (stockYn) gradeCounts.entry.stock++; } // 직무 적정성 계산 (재직 중이고 실 사용자 매핑 자산만 검토 대상) const job = p[ASSET_SCHEMA.USER_POSITION.key] || '미분류'; const avg = jobScores[job]?.avg || 0; if (avg > 0 && job !== '재고PC' && !stockYn) { if (score < avg * 0.6) { p._spec_status = '사양 부족'; criticalList.push(p); underSpecCount++; } else if (score > avg * 1.5) { p._spec_status = '오버스펙'; criticalList.push(p); overSpecCount++; } else { p._spec_status = '적정'; } } // Windows 11 업그레이드 지원 불가 검사 if (isWindows11Incompatible(p.cpu, p.ram)) { win11IncompatibleCount++; } }); // 5. 핵심 텍스트형 지표 갱신 document.getElementById('metric-total-pcs')!.textContent = `${filtered.length}대`; document.getElementById('metric-under-spec')!.textContent = `${underSpecCount}명`; document.getElementById('metric-over-spec')!.textContent = `${overSpecCount}명`; document.getElementById('metric-win11-incompatible')!.textContent = `${win11IncompatibleCount}대`; // 6. 등급별 리스트 데이터 바 업데이트 const total = filtered.length || 1; const updateCard = (id: string, counts: { total: number; stock: number }) => { const card = document.getElementById(id)!; const rate = Math.round((counts.total / total) * 100); card.querySelector('.grade-count')!.textContent = `${counts.total}대`; card.querySelector('.grade-rate')!.textContent = `(${rate}%)`; }; updateCard('grade-premium', gradeCounts.premium); updateCard('grade-high', gradeCounts.high); updateCard('grade-normal', gradeCounts.normal); updateCard('grade-entry', gradeCounts.entry); // 6.2 Inventory Summary 수치 업데이트 (골드/민트 텍스트 영역) const container = document.getElementById('view-body')?.parentElement || document.body; const setStockVal = (cls: string, val: number) => { const el = container.querySelector(`.${cls}`); if (el) el.textContent = `${val}대`; }; setStockVal('summary-grade-stock-premium', gradeCounts.premium.stock); setStockVal('summary-grade-stock-high', gradeCounts.high.stock); setStockVal('summary-grade-stock-normal', gradeCounts.normal.stock); setStockVal('summary-grade-stock-entry', gradeCounts.entry.stock); // 7. 연도별 PC 노후도 집계 및 렌더링 const agingCounts = { immediate: [] as any[], // 7년 이상 review: [] as any[], // 3년 이상 7년 미만 normal: [] as any[], // 1년 이상 3년 미만 fresh: [] as any[] // 1년 미만 }; filtered.forEach((p: any) => { const age = calculateAssetAge(p.purchase_date); if (age >= 7.0) { agingCounts.immediate.push(p); } else if (age >= 3.0) { agingCounts.review.push(p); } else if (age >= 1.0) { agingCounts.normal.push(p); } else { agingCounts.fresh.push(p); } }); const agingTbody = document.getElementById('pc-aging-tbody')!; const renderAgingRow = (label: string, list: any[], badgeText: string, badgeStyle: string, ageGroupKey: string) => { return ` ${label} ${list.length}대 ${badgeText} `; }; agingTbody.innerHTML = ` ${renderAgingRow('즉시 교체 (7년 이상)', agingCounts.immediate, '즉시 교체', 'background:#FFF1F2; color:#EF4444; border:1px solid #FCA5A5;', 'immediate')} ${renderAgingRow('교체 검토 (3년 ~ 7년)', agingCounts.review, '교체 검토', 'background:#FFF7ED; color:#D97706; border:1px solid #FCD34D;', 'review')} ${renderAgingRow('정상 운용 (1년 ~ 3년)', agingCounts.normal, '정상 운용', 'background:#ECFDF5; color:#059669; border:1px solid #A7F3D0;', 'normal')} ${renderAgingRow('최신 도입 (1년 미만)', agingCounts.fresh, '최신 도입', 'background:#F0FDF4; color:#16A34A; border:1px solid #BBF7D0;', 'fresh')} `; agingTbody.querySelectorAll('.aging-row').forEach(row => { row.addEventListener('click', () => { const groupKey = row.getAttribute('data-group') as any; const groupList = agingCounts[groupKey as keyof typeof agingCounts]; const groupLabels = { immediate: '즉시 교체 대상 (7년 이상)', review: '교체 검토 대상 (3년 ~ 7년)', normal: '정상 운용 장비 (1년 ~ 3년)', fresh: '최신 도입 장비 (1년 미만)' }; showMiniListModal(groupLabels[groupKey as keyof typeof groupLabels], groupList); }); }); // 8. 각 등급 행 클릭 리스너 설정 const bindCardClick = (id: string, gradeTitle: string, filterFn: (p: any) => boolean) => { const card = document.getElementById(id)!; if (!card) return; card.style.cursor = 'pointer'; card.style.transition = 'opacity 0.2s'; card.onmouseover = () => { card.style.opacity = '0.7'; }; card.onmouseout = () => { card.style.opacity = '1'; }; card.onclick = () => { const pcsInGrade = filtered.filter(filterFn); showMiniListModal(gradeTitle, pcsInGrade); }; }; bindCardClick('grade-premium', '최상급 PC', p => p._pc_score >= 85); bindCardClick('grade-high', '상급 PC', p => p._pc_score >= 70 && p._pc_score < 85); bindCardClick('grade-normal', '중급 PC', p => p._pc_score >= 40 && p._pc_score < 70); bindCardClick('grade-entry', '보급 PC', p => p._pc_score < 40); // 사양 부족 / 오버스펙 / 윈도우 11 불가 클릭 리스너 설정 bindCardClick('card-under-spec', '사양 부족 검토 대상', p => p._spec_status === '사양 부족'); bindCardClick('card-over-spec', '오버스펙 검토 대상', p => p._spec_status === '오버스펙'); bindCardClick('card-win11-incompatible', '윈도우 11 업그레이드 불가 PC', p => isWindows11Incompatible(p.cpu, p.ram)); // 8.2 유효 재고 현황 클릭 리스너 설정 bindCardClick('stock-premium-card', '최상급 유효 재고', p => p._pc_score >= 85 && isStock(p)); bindCardClick('stock-high-card', '상급 유효 재고', p => p._pc_score >= 70 && p._pc_score < 85 && isStock(p)); bindCardClick('stock-normal-card', '중급 유효 재고', p => p._pc_score >= 40 && p._pc_score < 70 && isStock(p)); bindCardClick('stock-entry-card', '보급 유효 재고', p => p._pc_score < 40 && isStock(p)); // 9. 직무별 사양 적정성 대수 연산 및 차트 데이터 셋 구성 (누적 막대 그래프화) const activeJobs = Array.from( new Set(filtered.map((p: any) => p[ASSET_SCHEMA.USER_POSITION.key] || '미분류').filter(j => j !== '재고PC')) ).sort(); const underData: number[] = []; const normalData: number[] = []; const overData: number[] = []; activeJobs.forEach(job => { const jobPcs = filtered.filter((p: any) => (p[ASSET_SCHEMA.USER_POSITION.key] || '미분류') === job); const totalCount = jobPcs.length; if (totalCount === 0) { underData.push(0); normalData.push(0); overData.push(0); return; } let under = 0; let normal = 0; let over = 0; jobPcs.forEach(p => { const stockYn = isStock(p); if (!stockYn) { if (p._spec_status === '사양 부족') { under++; } else if (p._spec_status === '오버스펙') { over++; } else { normal++; } } else { normal++; // 예외 폴백 } }); underData.push(under); normalData.push(normal); overData.push(over); }); // 10. 차트들 렌더링 호출 renderChart(activeJobs, underData, normalData, overData, filtered); renderDonutChart(gradeCounts.premium.total, gradeCounts.high.total, gradeCounts.normal.total, gradeCounts.entry.total); // 전역 상태 등록 state.activeCharts = [jobChartInstance, donutChartInstance]; } /** * 등급 클릭 시 열리는 심플 미니 리스트 모달 (라이트 글래스 헤더 적용) */ function showMiniListModal(title: string, list: any[]) { const oldModal = document.getElementById('dashboard-mini-modal'); if (oldModal) oldModal.remove(); const modal = document.createElement('div'); modal.id = 'dashboard-mini-modal'; modal.style.cssText = ` position: fixed; top: 0; left: 0; width: 100vw; height: 100vh; background: rgba(15, 23, 42, 0.4); backdrop-filter: blur(4px); z-index: 1000; display: flex; align-items: center; justify-content: center; font-family: 'Pretendard', sans-serif; color: #1E293B; `; modal.innerHTML = `

${title} 자산 목록 ${list.length}대

${list.length === 0 ? `` : list.map(pc => { const spec = `${pc.cpu || ''} / ${pc.ram || ''} / ${pc.gpu || '-'}`; const user = pc.user_current || '(재고)'; return ` `; }).join('') }
사용자 조직 (직무) 주요 사양 자산코드
해당 등급의 자산이 없습니다.
${user} ${pc.current_dept || '-'} (${pc.user_position || '-'}) ${spec} ${pc.asset_code || '-'}
`; const style = document.createElement('style'); style.innerHTML = ` @keyframes modalFadeIn { from { transform: scale(0.96); opacity: 0; } to { transform: scale(1); opacity: 1; } } `; modal.appendChild(style); document.body.appendChild(modal); const closeModal = () => { modal.remove(); }; modal.addEventListener('click', (e) => { if (e.target === modal) closeModal(); }); document.getElementById('btn-close-mini-modal')?.addEventListener('click', closeModal); document.getElementById('btn-confirm-mini-modal')?.addEventListener('click', closeModal); modal.querySelectorAll('.mini-modal-row').forEach(row => { row.addEventListener('click', () => { const id = row.getAttribute('data-id'); const asset = list.find(p => String(p.id) === String(id)); if (asset) { closeModal(); openHwModal(asset, 'view'); } }); }); } /** * Chart.js 가로형 100% 스택 막대 차트 (라이트 테마 튜닝) */ function renderChart(labels: string[], underData: number[], normalData: number[], overData: number[], currentFiltered: any[]) { const ctx = document.getElementById('chart-job-scores') as HTMLCanvasElement; if (!ctx || typeof Chart === 'undefined') return; if (jobChartInstance) { jobChartInstance.destroy(); jobChartInstance = null; } jobChartInstance = new Chart(ctx, { type: 'bar', data: { labels: labels, datasets: [ { label: '사양 부족', data: underData, backgroundColor: 'rgba(239, 68, 68, 0.85)', // Rose Red borderColor: 'rgb(239, 68, 68)', borderWidth: 1, borderRadius: 4, barPercentage: 0.45, categoryPercentage: 0.8 }, { label: '적정 사양', data: normalData, backgroundColor: 'rgba(30, 81, 73, 0.85)', // Hanmac Green borderColor: 'rgb(30, 81, 73)', borderWidth: 1, borderRadius: 4, barPercentage: 0.45, categoryPercentage: 0.8 }, { label: '오버스펙', data: overData, backgroundColor: 'rgba(217, 119, 6, 0.85)', // Amber Orange borderColor: 'rgb(217, 119, 6)', borderWidth: 1, borderRadius: 4, barPercentage: 0.45, categoryPercentage: 0.8 } ] }, options: { indexAxis: 'y', responsive: true, maintainAspectRatio: false, onHover: (event: any, activeElements: any[]) => { event.chart.canvas.style.cursor = activeElements.length ? 'pointer' : 'default'; }, onClick: (event: any, activeElements: any[]) => { if (activeElements && activeElements.length > 0) { const activeElement = activeElements[0]; const datasetIndex = activeElement.datasetIndex; // 0: 사양 부족, 1: 적정 사양, 2: 오버스펙 const index = activeElement.index; // 직무군 인덱스 const clickedJob = labels[index]; const statusLabels = ['사양 부족', '적정', '오버스펙']; const clickedStatus = statusLabels[datasetIndex] || '적정'; // 해당 직무군과 사양 상태가 매칭되는 자산 목록 필터링 const matchedPcs = currentFiltered.filter((p: any) => { const job = p[ASSET_SCHEMA.USER_POSITION.key] || '미분류'; if (job !== clickedJob) return false; const stockYn = p.hw_status === '재고' || p.hw_status === '대기' || !(p.user_current || '').trim(); let specStatus = '적정'; if (!stockYn) { specStatus = p._spec_status || '적정'; } return specStatus === clickedStatus; }); showMiniListModal(`${clickedJob} - ${clickedStatus === '적정' ? '적정 사양' : clickedStatus} 자산`, matchedPcs); } }, plugins: { legend: { position: 'top', align: 'end', labels: { font: { family: 'Pretendard', size: 11, weight: '700' }, color: '#475569', boxWidth: 8, boxHeight: 8, usePointStyle: true } }, tooltip: { titleFont: { family: 'Pretendard', size: 12, weight: '700' }, bodyFont: { family: 'Pretendard', size: 12 }, callbacks: { label: function (context: any) { const datasetLabel = context.dataset.label; const value = context.raw; // 실제 대수 const total = context.chart.data.datasets.reduce((sum: number, dataset: any) => sum + dataset.data[context.dataIndex], 0); const percentage = total > 0 ? Math.round((value / total) * 100) : 0; return `${datasetLabel}: ${value}대 (${percentage}%)`; } } } }, scales: { x: { stacked: true, ticks: { callback: (val: any) => `${val}대`, font: { family: 'Pretendard', size: 10, weight: '600' }, color: '#64748B' }, grid: { color: '#EEF2F6' } }, y: { stacked: true, ticks: { font: { family: 'Pretendard', size: 11, weight: '700' }, color: '#475569' }, grid: { display: false } } } } }); } /** * 실시간 사양 적정률 원형 도넛 그래프 (Active Spec Rate) */ function renderDonutChart(premium: number, high: number, normal: number, entry: number) { const ctx = document.getElementById('chart-overall-donut') as HTMLCanvasElement; if (!ctx || typeof Chart === 'undefined') return; if (donutChartInstance) { donutChartInstance.destroy(); donutChartInstance = null; } const total = premium + high + normal + entry; donutChartInstance = new Chart(ctx, { type: 'doughnut', data: { labels: ['최상급', '상급', '중급', '보급'], datasets: [{ data: [premium, high, normal, entry], backgroundColor: [ '#11302B', // premium (Hanmac Dark Green) '#1E8E7C', // high (Hanmac Teal) '#10B981', // normal (Hanmac Mint) '#94A3B8' // entry (Slate Gray) ], borderColor: '#ffffff', borderWidth: 2 }] }, options: { responsive: true, maintainAspectRatio: false, cutout: '70%', plugins: { legend: { display: false }, tooltip: { titleFont: { family: 'Pretendard', size: 12 }, bodyFont: { family: 'Pretendard', size: 12 }, callbacks: { label: (context: any) => `${context.label}: ${context.raw}대` } } } } }); // 도넛 차트 중앙에 총 자산 대수 텍스트 오버레이 배치 const parent = ctx.parentElement!; let textOverlay = parent.querySelector('.donut-text-overlay') as HTMLElement; if (!textOverlay) { textOverlay = document.createElement('div'); textOverlay.className = 'donut-text-overlay'; textOverlay.style.cssText = ` position: absolute; top: 50%; left: 50%; transform: translate(-50%, -46%); font-size: 1.56rem; font-weight: 900; color: #1E5149; font-family: 'Pretendard', sans-serif; pointer-events: none; white-space: nowrap; `; parent.appendChild(textOverlay); } textOverlay.textContent = `총 ${total}대`; }