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 노후도 및 예측
구분 (연한) 보유 권장 조치
`; // 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 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: 14px 12px; text-align: center; font-weight: 700; cursor: pointer; transition: background 0.2s; font-size: 1.25rem;`; const hoverEvents = `onmouseover="this.style.background='#F1F5F9'" onmouseout="this.style.background='none'"`; return ` ${label} ${data.total}대 (${totalRate}%) ${data.active}대 ${data.stock}대 ${shortage}대 `; }; 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 cellStyleHeader = `padding: 14px 12px; text-align: center; font-weight: 800; cursor: pointer; transition: background 0.2s; background: #F8FAFC; font-size: 1.25rem;`; 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}대 `; // 셀별 동적 클릭 리스너 바인딩 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); }); }); // 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)); // 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(matrix.premium.total, matrix.high.total, matrix.normal.total, matrix.entry.total, matrix.replace.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 || '(재고)'; 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'); } }); }); } /** * 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 === '오버스펙' ? '오버 스펙' : clickedStatus)} 자산`, matchedPcs); } }, plugins: { legend: { position: 'top', align: 'end', labels: { font: { family: 'Pretendard', size: 16, weight: '700' }, color: '#475569', boxWidth: 12, boxHeight: 12, 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: 14, weight: '600' }, color: '#64748B' }, grid: { color: '#EEF2F6' } }, y: { stacked: true, ticks: { font: { family: 'Pretendard', size: 16, weight: '700' }, color: '#475569' }, grid: { display: false } } } } }); } /** * 실시간 사양 적정률 원형 도넛 그래프 (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}대`; }