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 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대
사양 부족
0대
오버 스펙
0대
윈도우 11 불가 PC
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. DB 기준 사양 데이터 맵핑 (state.masterData.jobSpecs 이용) const jobSpecsMap: Record = {}; if (state.masterData.jobSpecs) { state.masterData.jobSpecs.forEach((s: any) => { jobSpecsMap[s.job_name] = s.min_score; }); } 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 실제 할당량 vs 유효 재고량 vs 사양 부족량) const isStock = (p: any) => { return p.hw_status === '재고' || p.hw_status === '대기' || !(p.user_current || '').trim(); }; const matrix = { premium: { total: 0, active: 0, stock: 0, under: 0, pcs: [] as any[], activePcs: [] as any[], stockPcs: [] as any[], underPcs: [] as any[] }, high: { total: 0, active: 0, stock: 0, under: 0, pcs: [] as any[], activePcs: [] as any[], stockPcs: [] as any[], underPcs: [] as any[] }, normal: { total: 0, active: 0, stock: 0, under: 0, pcs: [] as any[], activePcs: [] as any[], stockPcs: [] as any[], underPcs: [] as any[] }, entry: { total: 0, active: 0, stock: 0, under: 0, pcs: [] as any[], activePcs: [] as any[], stockPcs: [] as any[], underPcs: [] as any[] }, replace: { total: 0, active: 0, stock: 0, under: 0, pcs: [] as any[], activePcs: [] as any[], stockPcs: [] as any[], underPcs: [] as any[] } }; 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); const win11Incompatible = isWindows11Incompatible(p.cpu, p.ram); // 1. 현재 물리적 자산 등급 판정 let currentGradeKey: keyof typeof matrix; if (score >= 85) { currentGradeKey = 'premium'; } else if (score >= 70) { currentGradeKey = 'high'; } else if (score >= 40) { currentGradeKey = 'normal'; } else if (score >= 20 && !win11Incompatible) { currentGradeKey = 'entry'; } else { currentGradeKey = 'replace'; } const currentTarget = matrix[currentGradeKey]; currentTarget.pcs.push(p); currentTarget.total++; if (stockYn) { currentTarget.stock++; currentTarget.stockPcs.push(p); } else { currentTarget.active++; currentTarget.activePcs.push(p); // 직무 적정성 계산 (재직 중이고 실 사용자 매핑 자산만 검토 대상) const job = p[ASSET_SCHEMA.USER_POSITION.key] || '미분류'; const standardScore = jobSpecsMap[job] !== undefined ? jobSpecsMap[job] : (jobScores[job]?.avg || 0); let isUnder = false; if (standardScore > 0 && job !== '재고PC') { if (score < standardScore * 0.6) { isUnder = true; p._spec_status = '사양 부족'; } else if (score > standardScore * 1.5 && !win11Incompatible) { p._spec_status = '오버스펙'; criticalList.push(p); overSpecCount++; } else if (win11Incompatible) { isUnder = true; p._spec_status = '사양 부족'; } else { p._spec_status = '적정'; } } else { if (win11Incompatible) { isUnder = true; p._spec_status = '사양 부족'; } else { p._spec_status = '적정'; } } if (isUnder) { criticalList.push(p); underSpecCount++; // 2. 사양 부족 시 교체받아야 할 직무별 권장 목표 등급 판정 let targetGradeKey: keyof typeof matrix; if (standardScore >= 85) { targetGradeKey = 'premium'; } else if (standardScore >= 70) { targetGradeKey = 'high'; } else if (standardScore >= 40) { targetGradeKey = 'normal'; } else { targetGradeKey = 'entry'; // 교체 대상은 최소 보급형 사양으로 교체 } const targetGrade = matrix[targetGradeKey]; targetGrade.under++; targetGrade.underPcs.push(p); } } // 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 matrixTbody = document.getElementById('pc-grade-matrix-tbody')!; const getSpecStatusCounts = (activePcsList: any[]) => { let under = 0; let normal = 0; let over = 0; activePcsList.forEach(p => { if (p._spec_status === '사양 부족') under++; else if (p._spec_status === '오버스펙') over++; else normal++; }); return { under, normal, over }; }; const renderMatrixRow = (gradeKey: keyof typeof matrix, label: string, color: string, shortage: number) => { const data = matrix[gradeKey]; const totalRate = filtered.length > 0 ? Math.round((data.total / filtered.length) * 100) : 0; const cellStyle = `padding: 10px 8px; text-align: center; font-weight: 700; cursor: pointer; transition: background 0.2s; font-size: 1.1rem;`; const hoverEvents = `onmouseover="this.style.background='#F1F5F9'" onmouseout="this.style.background='none'"`; // 사양 적정성 분석 데이터 계산 (운영중인 자산만) const { under, normal, over } = getSpecStatusCounts(data.activePcs); const activeCount = data.active; const underPct = activeCount > 0 ? (under / activeCount) * 100 : 0; const normalPct = activeCount > 0 ? (normal / activeCount) * 100 : 0; const overPct = activeCount > 0 ? (over / activeCount) * 100 : 0; let barGraphHtml = ''; if (activeCount > 0) { barGraphHtml = `
${under > 0 ? `
` : ''} ${normal > 0 ? `
` : ''} ${over > 0 ? `
` : ''}
${under > 0 ? `부족 ${under}` : ''} ${normal > 0 ? `적정 ${normal}` : ''} ${over > 0 ? `오버 ${over}` : ''}
`; } else { barGraphHtml = `운영중 자산 없음`; } return ` ${label} ${data.total}대 (${totalRate}%) ${data.active}대 ${data.stock}대 ${shortage}대 ${barGraphHtml} `; }; const totalPcs = filtered.length; const totalActive = matrix.premium.active + matrix.high.active + matrix.normal.active + matrix.entry.active + matrix.replace.active; const totalStock = matrix.premium.stock + matrix.high.stock + matrix.normal.stock + matrix.entry.stock + matrix.replace.stock; const premiumShortage = Math.max(0, matrix.premium.under - matrix.premium.stock); const highShortage = Math.max(0, matrix.high.under - matrix.high.stock); const normalShortage = Math.max(0, matrix.normal.under - matrix.normal.stock); // 보급 PC 구매 필요 = 보급 under - 보급 stock const entryShortage = Math.max(0, matrix.entry.under - matrix.entry.stock); // 교체 대상 PC 자체는 새로 구매하는 기종이 아니므로 구매 필요 0대 const replaceShortage = 0; const totalShortage = premiumShortage + highShortage + normalShortage + entryShortage + replaceShortage; const totalActivePcs = filtered.filter(p => !isStock(p)); const { under: totUnder, normal: totNormal, over: totOver } = getSpecStatusCounts(totalActivePcs); const totUnderPct = totalActive > 0 ? (totUnder / totalActive) * 100 : 0; const totNormalPct = totalActive > 0 ? (totNormal / totalActive) * 100 : 0; const totOverPct = totalActive > 0 ? (totOver / totalActive) * 100 : 0; let totBarGraphHtml = ''; if (totalActive > 0) { totBarGraphHtml = `
${totUnder > 0 ? `
` : ''} ${totNormal > 0 ? `
` : ''} ${totOver > 0 ? `
` : ''}
${totUnder > 0 ? `부족 ${totUnder}` : ''} ${totNormal > 0 ? `적정 ${totNormal}` : ''} ${totOver > 0 ? `오버 ${totOver}` : ''}
`; } else { totBarGraphHtml = `운영중 자산 없음`; } const cellStyleHeader = `padding: 10px 8px; text-align: center; font-weight: 800; cursor: pointer; transition: background 0.2s; background: #F8FAFC; font-size: 1.1rem;`; const hoverEventsHeader = `onmouseover="this.style.background='#EEF2F6'" onmouseout="this.style.background='#F8FAFC'"`; matrixTbody.innerHTML = ` ${renderMatrixRow('premium', '최상급 PC (85점 이상)', '#11302B', premiumShortage)} ${renderMatrixRow('high', '상급 PC (70점 ~ 85점)', '#1E8E7C', highShortage)} ${renderMatrixRow('normal', '중급 PC (40점 ~ 70점)', '#10B981', normalShortage)} ${renderMatrixRow('entry', '보급 PC (20점 ~ 40점)', '#F59E0B', entryShortage)} ${renderMatrixRow('replace', '교체 대상 PC (20점 미만 또는 Win11 불가)', '#EF4444', replaceShortage)} 합계 (Total) ${totalPcs}대 (100%) ${totalActive}대 ${totalStock}대 ${totalShortage}대 ${totBarGraphHtml} `; // 셀별 동적 클릭 리스너 바인딩 matrixTbody.querySelectorAll('.matrix-cell').forEach(cell => { cell.addEventListener('click', () => { const grade = cell.getAttribute('data-grade')!; const type = cell.getAttribute('data-type')!; let targetList: any[] = []; let title = ''; const getGradeLabel = (g: string) => { if (g === 'premium') return '최상급 PC'; if (g === 'high') return '상급 PC'; if (g === 'normal') return '중급 PC'; if (g === 'entry') return '보급 PC'; if (g === 'replace') return '교체 대상 PC'; return '전체 PC'; }; const getTypeLabel = (t: string) => { if (t === 'total') return '보유'; if (t === 'active') return '운영중'; if (t === 'stock') return '재고'; if (t === 'under') return '구매 필요'; return ''; }; if (grade === 'all') { if (type === 'total') { targetList = filtered; } else if (type === 'active') { targetList = filtered.filter(p => !isStock(p)); } else if (type === 'stock') { targetList = filtered.filter(p => isStock(p)); } else if (type === 'under') { targetList = criticalList.filter(p => p._spec_status === '사양 부족'); } } else { const data = matrix[grade as keyof typeof matrix]; if (type === 'total') { targetList = data.pcs; } else if (type === 'active') { targetList = data.activePcs; } else if (type === 'stock') { targetList = data.stockPcs; } else if (type === 'under') { targetList = data.underPcs; } } title = `${getGradeLabel(grade)} - ${getTypeLabel(type)} 자산 목록`; showMiniListModal(title, targetList); }); }); // 바그래프 세그먼트 또는 텍스트 클릭 리스너 설정 const handleSpecClick = (e: Event) => { e.stopPropagation(); const target = e.currentTarget as HTMLElement; const grade = target.getAttribute('data-grade')!; const status = target.getAttribute('data-spec-status')!; let targetPcs: any[] = []; if (grade === 'all') { targetPcs = filtered.filter(p => !isStock(p) && p._spec_status === status); } else { const data = matrix[grade as keyof typeof matrix]; targetPcs = data.activePcs.filter(p => p._spec_status === status); } const getGradeLabel = (g: string) => { if (g === 'premium') return '최상급 PC'; if (g === 'high') return '상급 PC'; if (g === 'normal') return '중급 PC'; if (g === 'entry') return '보급 PC'; if (g === 'replace') return '교체 대상 PC'; return '전체 PC'; }; const title = `${getGradeLabel(grade)} - ${status} 자산 목록`; showMiniListModal(title, targetPcs); }; matrixTbody.querySelectorAll('.spec-segment-btn, .spec-text-btn').forEach(btn => { btn.addEventListener('click', handleSpecClick); }); // 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); }; }; // 사양 부족 / 오버 스펙 / 윈도우 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)); // 10. 도넛 차트 렌더링 호출 renderDonutChart(matrix.premium.total, matrix.high.total, matrix.normal.total, matrix.entry.total, matrix.replace.total); // 전역 상태 등록 state.activeCharts = [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 || '(재고)'; const score = pc._pc_score !== undefined ? pc._pc_score : calculatePcScoreDeductive(pc.cpu, pc.ram, pc.gpu, pc.purchase_date); const win11Incompatible = isWindows11Incompatible(pc.cpu, pc.ram); const grade = getPcGrade(score, win11Incompatible); const badgeHTML = `${grade.name}`; const scoreHTML = `${score}점`; return ` `; }).join('') }
사용자 조직 (직무) 주요 사양 등급 (점수) 자산코드
해당 등급의 자산이 없습니다.
${user} ${pc.current_dept || '-'} (${pc.user_position || '-'}) ${spec} ${badgeHTML}${scoreHTML} ${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'); } }); }); } /** * 실시간 사양 적정률 원형 도넛 그래프 (Active Spec Rate) */ function renderDonutChart(premium: number, high: number, normal: number, entry: number, replace: 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 + replace; donutChartInstance = new Chart(ctx, { type: 'doughnut', data: { labels: ['최상급', '상급', '중급', '보급', '교체 대상'], datasets: [{ data: [premium, high, normal, entry, replace], backgroundColor: [ '#11302B', // premium (Hanmac Dark Green) '#1E8E7C', // high (Hanmac Teal) '#10B981', // normal (Hanmac Mint) '#F59E0B', // entry (Yellow-Orange) '#EF4444' // replace (Red) ], 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.65rem; font-weight: 900; color: #1E5149; font-family: 'Pretendard', sans-serif; pointer-events: none; white-space: nowrap; `; parent.appendChild(textOverlay); } textOverlay.textContent = `총 ${total}대`; }