From 5ff991693a49ec54cc2dd9643ce02f690e43182f Mon Sep 17 00:00:00 2001 From: Taehoon Date: Tue, 21 Apr 2026 16:59:21 +0900 Subject: [PATCH] =?UTF-8?q?feat:=20=ED=95=98=EB=93=9C=EC=9B=A8=EC=96=B4=20?= =?UTF-8?q?=EB=8C=80=EC=8B=9C=EB=B3=B4=EB=93=9C=20=EB=85=B8=ED=9B=84?= =?UTF-8?q?=EB=8F=84=20=EC=A4=91=EC=8B=AC=20=EA=B0=9C=ED=8E=B8=20=EB=B0=8F?= =?UTF-8?q?=20=EC=9E=90=EC=82=B0=20=EC=97=B0=EB=A0=B9=20=EA=B3=84=EC=82=B0?= =?UTF-8?q?=20=EC=9C=A0=ED=8B=B8=EB=A6=AC=ED=8B=B0=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/core/utils.ts | 15 ++ src/views/Dashboard/HwDashboard.ts | 239 ++++++++++++++++++++--------- 2 files changed, 178 insertions(+), 76 deletions(-) diff --git a/src/core/utils.ts b/src/core/utils.ts index aefcd6f..bc999ee 100644 --- a/src/core/utils.ts +++ b/src/core/utils.ts @@ -33,6 +33,21 @@ export function normalizeDate(dateStr: string): string { return (dateStr || '').replace(/\./g, '-').trim(); } +/** + * 구매일로부터 현재까지의 경과 연수 계산 (소수점 첫째자리) + */ +export function calculateAssetAge(purchaseDate: string): number { + const normalized = normalizeDate(purchaseDate); + if (!normalized) return 0; + + const purchase = new Date(normalized); + if (isNaN(purchase.getTime())) return 0; + + const diffMs = Date.now() - purchase.getTime(); + const age = diffMs / (1000 * 60 * 60 * 24 * 365.25); + return Math.max(0, parseFloat(age.toFixed(1))); +} + /** * 고유 ID 생성 (7자리 랜덤 문자열) */ diff --git a/src/views/Dashboard/HwDashboard.ts b/src/views/Dashboard/HwDashboard.ts index f669e2e..c124dcf 100644 --- a/src/views/Dashboard/HwDashboard.ts +++ b/src/views/Dashboard/HwDashboard.ts @@ -1,104 +1,191 @@ import { state } from '../../core/state'; import { HardwareAsset } from '../../core/excelHandler'; -import { openDashboardDetail } from '../../components/Modal/DashboardDetailModal'; -import { normalizeDate } from '../../core/utils'; +import { openHwModal } from '../../components/Modal/HWModal'; +import { calculateAssetAge, normalizeDate } from '../../core/utils'; declare var Chart: any; export function renderHwDashboard(container: HTMLElement) { - const types = ['개인PC', '서버', '스토리지', '전산비품']; - const units = ['대', '대', '대', '개']; - const groups: any = {}; + const allHw = state.masterData.hw || []; - types.forEach(t => { groups[t] = { idle: [], active: [] }; }); + // 1. 데이터 가공 + let totalAge = 0; + let countWithDate = 0; + let over5YearsCount = 0; + let latestAsset: HardwareAsset | null = null; + let latestYear = 0; - state.masterData.hw.forEach(a => { - if (!groups[a.type]) return; - if (isHwIdle(a)) groups[a.type].idle.push(a); - else groups[a.type].active.push(a); + const ageGroups = { stable: 0, warning: 0, critical: 0 }; + const yearlyCount: Record = {}; + + allHw.forEach(a => { + const pDate = a.구매일 || (a as any).purchase_date; + if (!pDate) return; + + const age = calculateAssetAge(pDate); + totalAge += age; + countWithDate++; + + // 노후도 분류 + if (age >= 5) { + over5YearsCount++; + ageGroups.critical++; + } else if (age >= 3) { + ageGroups.warning++; + } else { + ageGroups.stable++; + } + + // 연도별 도입 현황 추출 + const year = normalizeDate(pDate).split('-')[0]; + if (year && year.length === 4) { + yearlyCount[year] = (yearlyCount[year] || 0) + 1; + const yNum = parseInt(year); + if (yNum > latestYear) { + latestYear = yNum; + latestAsset = a; + } + } }); - let usageCards = ''; - types.forEach((t, i) => { - const total = groups[t].idle.length + groups[t].active.length; - const used = groups[t].active.length; - const per = total > 0 ? Math.round((used / total) * 100) : 0; - const barColor = per >= 50 ? 'var(--dash-primary)' : 'var(--dash-danger)'; - - usageCards += ` -
- ${t} 사용현황 -
- ${total}${units[i]} 중 ${used}${units[i]} 사용 중 -
-
${per}%
-
-
-
-
`; - }); + const avgAge = countWithDate > 0 ? (totalAge / countWithDate).toFixed(1) : '0'; + const over5Rate = allHw.length > 0 ? Math.round((over5YearsCount / allHw.length) * 100) : 0; + + // 교체 시급 대상 TOP 10 (오래된 순) + const criticalList = [...allHw] + .filter(a => (a.구매일 || (a as any).purchase_date)) + .sort((a, b) => { + const dateA = new Date(normalizeDate(a.구매일 || (a as any).purchase_date)).getTime(); + const dateB = new Date(normalizeDate(b.구매일 || (b as any).purchase_date)).getTime(); + return dateA - dateB; + }) + .slice(0, 10); + // 2. UI 렌더링 container.innerHTML = `
-

자산 사용현황 요약

-
${usageCards}
- -

하드웨어 보유 통계

-
+
+
+
전체 평균 사용 연수
+
${avgAge}
+ +
+
+
5년 이상 노후 자산 비율
+
${over5Rate}%
+ +
+
+
최신 도입 모델 (${latestYear}년)
+
+ ${latestAsset?.모델명 || '정보 없음'} +
+ +
+
+ +
-

자산 유형별 보유 현황

- +

자산 노후도 분포

+
-

구매법인별 자산 분포

- +

연도별 자산 도입 추이

+
+ +

⚠️ 교체 검토 대상 (가장 오래된 자산 TOP 10)

+
+ + + + + + + + + + + + + + ${criticalList.map((a, i) => ` + + + + + + + + + + `).join('')} + +
순위자산번호유형모델명사용자/담당자구매일연령
${i + 1}${a.자산코드 || '-'}${a.type}${a.모델명 || a.명칭 || '-'}${a.사용자 || a.담당자_정 || '-'}${a.구매일 || (a as any).purchase_date || '-'}${calculateAssetAge(a.구매일 || (a as any).purchase_date)}년
+
`; + // 3. 차트 초기화 setTimeout(() => { - if (typeof Chart === 'undefined') return; - const ctxType = (document.getElementById('chart-hw-types') as HTMLCanvasElement)?.getContext('2d'); - const ctxCorp = (document.getElementById('chart-hw-corps') as HTMLCanvasElement)?.getContext('2d'); - if (ctxType) { - const chart = new Chart(ctxType, { - type: 'doughnut', - data: { labels: types, datasets: [{ data: types.map(t => state.masterData.hw.filter(a => a.type === t).length), backgroundColor: ['#1E5149', '#3b82f6', '#10b981', '#f59e0b'] }] }, - options: { responsive: true, maintainAspectRatio: false, plugins: { legend: { position: 'right' } } } + initAgingCharts(ageGroups, yearlyCount); + + // 행 클릭 이벤트 바인딩 + 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'); }); - state.activeCharts.push(chart); - } - if (ctxCorp) { - const corps = ['한맥', '삼안', '바론']; - const chart = new Chart(ctxCorp, { - type: 'bar', - data: { labels: corps, datasets: [{ label: '보유 수량', data: corps.map(c => state.masterData.hw.filter(a => a.법인 === c).length), backgroundColor: 'rgba(30, 81, 73, 0.7)', borderRadius: 4 }] }, - options: { responsive: true, maintainAspectRatio: false, plugins: { legend: { display: false } } } - }); - state.activeCharts.push(chart); - } - }, 100); - - container.querySelectorAll('[data-action="idle"]').forEach(card => { - card.addEventListener('click', () => { - const t = card.getAttribute('data-type')!; - openDashboardDetail(`[${t}] 유휴 자산 목록`, groups[t].idle); }); - }); + }, 100); } -function isHwIdle(a: HardwareAsset) { - if (a.type === '개인PC') return !a.사용자 || a.사용자.trim() === '' || a.사용자.trim() === '-'; - if (a.type === '스토리지') return !a.담당자_정 || a.담당자_정.trim() === '' || a.담당자_정.trim() === '-'; - return !a.관리자 || a.관리자.trim() === '' || a.관리자.trim() === '-'; -} +function initAgingCharts(ageGroups: any, yearlyCount: Record) { + const agingCtx = document.getElementById('chart-aging-dist') as HTMLCanvasElement; + if (agingCtx) { + new Chart(agingCtx, { + type: 'doughnut', + data: { + labels: ['안정 (3년 미만)', '주의 (3~5년)', '위험 (5년 이상)'], + datasets: [{ + data: [ageGroups.stable, ageGroups.warning, ageGroups.critical], + backgroundColor: ['#1E5149', '#9CA3AF', '#E11D48'], + borderWidth: 0 + }] + }, + options: { + responsive: true, + maintainAspectRatio: false, + plugins: { legend: { position: 'right' } }, + cutout: '70%' + } + }); + } -function getHwAgeYears(a: HardwareAsset) { - if (!a.구매일) return 0; - try { - const buyDate = new Date(normalizeDate(a.구매일)); - if (isNaN(buyDate.getTime())) return 0; - return (Date.now() - buyDate.getTime()) / (1000 * 60 * 60 * 24 * 365.25); - } catch { return 0; } + const trendCtx = document.getElementById('chart-purchase-trend') as HTMLCanvasElement; + if (trendCtx) { + const years = Object.keys(yearlyCount).sort(); + new Chart(trendCtx, { + type: 'bar', + data: { + labels: years, + datasets: [{ + label: '도입 수량', + data: years.map(y => yearlyCount[y]), + backgroundColor: '#1E5149', + borderRadius: 4 + }] + }, + options: { + responsive: true, + maintainAspectRatio: false, + scales: { + y: { beginAtZero: true, ticks: { stepSize: 1 } }, + x: { grid: { display: false } } + } + } + }); + } }