From a576d54a2d4b57b5f6dbcaf2ddf333b9c6e81caa Mon Sep 17 00:00:00 2001 From: Taehoon Date: Tue, 21 Apr 2026 16:35:29 +0900 Subject: [PATCH 1/3] backup: stable baseline before hardware dashboard revamp --- src/core/state.ts | 29 +++++++++++++++++++++++++++++ 1 file changed, 29 insertions(+) diff --git a/src/core/state.ts b/src/core/state.ts index fd5d181..6751938 100644 --- a/src/core/state.ts +++ b/src/core/state.ts @@ -14,6 +14,7 @@ export interface MasterAssetData { logs: HardwareLog[]; // 동료 코드 호환용 통합 배열 (프론트엔드 로직용) + hw: HardwareAsset[]; sw: SoftwareAsset[]; } @@ -36,6 +37,7 @@ export const state: AppState = { subSw: [], permSw: [], cloud: [], + hw: [], // 호환용 sw: [], // 호환용 swUsers: [], logs: [] @@ -90,6 +92,15 @@ export async function loadMasterDataFromDB() { ...state.masterData.cloud ]; + // 하드웨어 통합 배열 생성 (대시보드 등에서 사용) + state.masterData.hw = [ + ...state.masterData.pc, + ...state.masterData.server, + ...state.masterData.storage, + ...state.masterData.equip, + ...state.masterData.mobile + ]; + console.log('✅ 모든 DB 데이터 로드 및 통합 완료'); return true; } catch (err) { @@ -137,6 +148,15 @@ export function saveHardwareAsset(updatedAsset: HardwareAsset) { // 3. 새로운 타겟 카테고리에 추가 (state.masterData[targetKey] as HardwareAsset[]).push(updatedAsset); + + // 4. 통합 hw 배열 동기화 + state.masterData.hw = [ + ...state.masterData.pc, + ...state.masterData.server, + ...state.masterData.storage, + ...state.masterData.equip, + ...state.masterData.mobile + ]; } /** @@ -151,4 +171,13 @@ export function deleteHardwareAsset(assetId: string) { if (idx > -1) arr.splice(idx, 1); } }); + + // 통합 hw 배열 동기화 + state.masterData.hw = [ + ...state.masterData.pc, + ...state.masterData.server, + ...state.masterData.storage, + ...state.masterData.equip, + ...state.masterData.mobile + ]; } From 5ff991693a49ec54cc2dd9643ce02f690e43182f Mon Sep 17 00:00:00 2001 From: Taehoon Date: Tue, 21 Apr 2026 16:59:21 +0900 Subject: [PATCH 2/3] =?UTF-8?q?feat:=20=ED=95=98=EB=93=9C=EC=9B=A8?= =?UTF-8?q?=EC=96=B4=20=EB=8C=80=EC=8B=9C=EB=B3=B4=EB=93=9C=20=EB=85=B8?= =?UTF-8?q?=ED=9B=84=EB=8F=84=20=EC=A4=91=EC=8B=AC=20=EA=B0=9C=ED=8E=B8=20?= =?UTF-8?q?=EB=B0=8F=20=EC=9E=90=EC=82=B0=20=EC=97=B0=EB=A0=B9=20=EA=B3=84?= =?UTF-8?q?=EC=82=B0=20=EC=9C=A0=ED=8B=B8=EB=A6=AC=ED=8B=B0=20=EC=B6=94?= =?UTF-8?q?=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 } } + } + } + }); + } } From ba7ce796d1fca9e5fcc2b1dee10347c1e128e32f Mon Sep 17 00:00:00 2001 From: Taehoon Date: Tue, 21 Apr 2026 17:52:46 +0900 Subject: [PATCH 3/3] =?UTF-8?q?feat:=20=EC=A0=84=EC=82=B0=EB=B9=84?= =?UTF-8?q?=ED=92=88=20=EB=B0=8F=20=EB=AA=A8=EB=B0=94=EC=9D=BC=EA=B8=B0?= =?UTF-8?q?=EA=B8=B0=20=EA=B4=80=EB=A6=AC=20=EA=B8=B0=EB=8A=A5=20=ED=99=95?= =?UTF-8?q?=EC=9E=A5=20(=EB=B3=B4=EA=B4=80=EC=9C=84=EC=B9=98,=20=EC=83=81?= =?UTF-8?q?=ED=83=9C=EA=B4=80=EB=A6=AC,=20=EB=B6=84=EC=B6=9C=EC=9D=B4?= =?UTF-8?q?=EB=A0=A5)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- server.js | 9 +- src/components/Modal/HWModal.ts | 590 ++++++++++++---------------- src/core/excelHandler.ts | 2 + src/core/state.ts | 19 +- src/views/List/EquipmentListView.ts | 52 ++- src/views/List/MobileListView.ts | 51 ++- 6 files changed, 342 insertions(+), 381 deletions(-) diff --git a/server.js b/server.js index 103bafb..22cd962 100644 --- a/server.js +++ b/server.js @@ -84,7 +84,8 @@ const hardwareInsertSQL = (table) => ` id, corp, asset_code, purchase_date, type, detail_purpose, purpose, details, current_org, prev_org, location, manager_main, manager_sub, ip_address, remote_tool, server_id, server_pw, model_name, os, cpu, ram, gpu, - storage1, storage2, storage3, monitoring, price, remarks + storage1, storage2, storage3, monitoring, price, remarks, + storage_location, status ) VALUES ? `; @@ -92,7 +93,8 @@ const getHardwareValues = (a) => [ a.id, a.법인||'', a.자산코드||'', a.구매일||'', a.type||'', a.상세용도||'', a.용도||'', a.상세||'', a.현사용조직||'', a.이전사용조직||'', a.위치||'', a.담당자_정||'', a.담당자_부||'', a.IP주소||'', a.원격접속||'', a.서버ID||'', a.서버PW||'', a.모델명||'', a.OS||'', a.CPU||'', a.RAM||'', a.GPU||'', - a.SSD1||'', a.SSD2||'', a.HDD1||'', a.모니터링||'', a.금액||'', a.비고||'' + a.SSD1||'', a.SSD2||'', a.HDD1||'', a.모니터링||'', a.금액||'', a.비고||'', + a.보관위치||'', a.현재상태||'' ]; const mapHardware = (r, defaultType) => ({ @@ -101,7 +103,8 @@ const mapHardware = (r, defaultType) => ({ 이전사용조직: r.prev_org, 위치: r.location, 담당자_정: r.manager_main, 담당자_부: r.manager_sub, IP주소: r.ip_address, 원격접속: r.remote_tool, 서버ID: r.server_id, 서버PW: r.server_pw, 모델명: r.model_name, OS: r.os, CPU: r.cpu, RAM: r.ram, GPU: r.gpu, SSD1: r.storage1, - SSD2: r.storage2, HDD1: r.storage3, 모니터링: r.monitoring, 금액: r.price, 비고: r.remarks + SSD2: r.storage2, HDD1: r.storage3, 모니터링: r.monitoring, 금액: r.price, 비고: r.remarks, + 보관위치: r.storage_location, 현재상태: r.status }); // --- API 라우트 정의 --- diff --git a/src/components/Modal/HWModal.ts b/src/components/Modal/HWModal.ts index f43b102..2e4ff9b 100644 --- a/src/components/Modal/HWModal.ts +++ b/src/components/Modal/HWModal.ts @@ -1,7 +1,7 @@ import { state, saveHardwareAsset, deleteHardwareAsset } from '../../core/state'; -import { HardwareAsset, MasterAssetData } from '../../core/excelHandler'; +import { HardwareAsset, MasterAssetData, HardwareLog } from '../../core/excelHandler'; import { openModal, closeModals } from './BaseModal'; -import { createIcons, Paperclip } from 'lucide'; +import { createIcons, Paperclip, History, Plus, X, Save, Edit2, RotateCcw } from 'lucide'; import { CORP_LIST, ORG_LIST, HW_TYPE_LIST, LOCATION_DATA, TYPE_PREFIX_MAP } from './SharedData'; import { generateOptionsHTML, @@ -16,6 +16,8 @@ import { let currentAsset: HardwareAsset | null = null; let isEditMode = false; +const STATUS_LIST = ['대여중', '보관중', '수리중', '기타']; + const HW_MODAL_HTML = `