From 3e69e74bc96791c4dabbbd26f9a1c088c9cd91cc Mon Sep 17 00:00:00 2001 From: JooWangi Date: Wed, 17 Jun 2026 09:22:31 +0900 Subject: [PATCH 1/5] =?UTF-8?q?Feat:=20=ED=86=B5=ED=95=A9=20=EC=82=AC?= =?UTF-8?q?=EC=96=91=20=EC=A0=81=EC=A0=95=EC=84=B1=20=EC=9D=B8=EB=9D=BC?= =?UTF-8?q?=EC=9D=B8=20=EB=B0=94=20=EA=B7=B8=EB=9E=98=ED=94=84=20=EB=B0=8F?= =?UTF-8?q?=20=EB=8C=80=EC=8B=9C=EB=B3=B4=EB=93=9C=20=EB=A0=88=EC=9D=B4?= =?UTF-8?q?=EC=95=84=EC=9B=83=20=EA=B0=9C=ED=8E=B8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/views/Dashboard/HwDashboard.ts | 576 ++++++++++++----------------- 1 file changed, 238 insertions(+), 338 deletions(-) diff --git a/src/views/Dashboard/HwDashboard.ts b/src/views/Dashboard/HwDashboard.ts index 2c91be7..72c2317 100644 --- a/src/views/Dashboard/HwDashboard.ts +++ b/src/views/Dashboard/HwDashboard.ts @@ -6,7 +6,6 @@ import { createIcons, Laptop, Cpu, Shield, Zap, Monitor, AlertTriangle, ChevronR declare var Chart: any; -let jobChartInstance: any = null; let donutChartInstance: any = null; export function renderHwDashboard(container: HTMLElement) { @@ -43,181 +42,149 @@ export function renderHwDashboard(container: HTMLElement) { - -
+ +
- -
+ +
- -
- - -
-
- 보유 자산 수량 -
-
-
-
0대
- 전사 보유 개인용 PC -
-
+ +
+
+ 보유 자산 수량
- - -
-
- 사양 부족 -
-
-
-
0대
- 사양 교체 권고 자산 -
-
+
+
0대
- - -
-
- 오버 스펙 -
-
-
-
0대
- 사양 회수 권고 자산 -
-
-
- - -
-
- 윈도우 11 불가 PC -
-
-
-
0대
- 업데이트 미지원 하드웨어 -
-
-
-
- - -
- -
- -
- 등급별 자산 종합 현황 -
- - -
- - - - - - - - - - - - - -
구분 (등급)보유량운영중재고구매 필요
-
+ +
+
+ 사양 부족 +
+
+
0대
-
- + + +
+
+ 오버 스펙 +
+
+
0대
+
+
+ + +
+
+ 윈도우 11 불가 PC +
+
+
0대
+
+
+
- -
+ +
- -
-
- 직무별 사양 적정성 분석 + +
+ +
+ 등급별 보유 비율
-
- + + +
+
+ +
+ +
+
+ + 최상급 +
+
+ + 상급 +
+
+ + 중급 +
+
+ + 보급 +
+
+ + 교체 대상 +
+
- - -
- - -
- -
- 등급별 보유 비율 -
- - -
-
- -
- -
-
- - 최상급 -
-
- - 상급 -
-
- - 중급 -
-
- - 보급 -
-
- - 교체 대상 -
-
-
-
- -
-
- 연도별 PC 노후도 및 예측 -
-
- - - - - - - - - - - -
구분 (연한)보유권장 조치
-
+ +
+
+ 연도별 PC 노후도 및 예측 +
+
+ + + + + + + + + + + +
구분 (연한)보유권장 조치
+ + +
+
+ +
+ 등급별 자산 종합 현황 및 사양 적정성 분석 +
+ + +
+ + + + + + + + + + + + + + +
구분 (등급)보유량운영중재고구매 필요사양 적정성 분석 (직무 기준)
+
+
+
+
`; @@ -402,21 +369,64 @@ function updateDashboardData(pcs: any[], selectedDept: string) { // 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: 14px 12px; text-align: center; font-weight: 700; cursor: pointer; transition: background 0.2s; font-size: 1.25rem;`; + 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}%) + ${label} + ${data.total}대 (${totalRate}%) ${data.active}대 ${data.stock}대 ${shortage}대 + + ${barGraphHtml} + `; }; @@ -437,7 +447,33 @@ function updateDashboardData(pcs: any[], selectedDept: string) { 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 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 = ` @@ -447,11 +483,14 @@ function updateDashboardData(pcs: any[], selectedDept: string) { ${renderMatrixRow('entry', '보급 PC (20점 ~ 40점)', '#F59E0B', entryShortage)} ${renderMatrixRow('replace', '교체 대상 PC (20점 미만 또는 Win11 불가)', '#EF4444', replaceShortage)} - 합계 (Total) - ${totalPcs}대 (100%) + 합계 (Total) + ${totalPcs}대 (100%) ${totalActive}대 ${totalStock}대 ${totalShortage}대 + + ${totBarGraphHtml} + `; @@ -509,6 +548,38 @@ function updateDashboardData(pcs: any[], selectedDept: string) { }); }); + // 바그래프 세그먼트 또는 텍스트 클릭 리스너 설정 + 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년 이상 @@ -535,10 +606,10 @@ function updateDashboardData(pcs: any[], selectedDept: string) { const renderAgingRow = (label: string, list: any[], badgeText: string, badgeStyle: string, ageGroupKey: string) => { return ` - ${label} - ${list.length}대 - - ${badgeText} + ${label} + ${list.length}대 + + ${badgeText} `; @@ -586,50 +657,11 @@ function updateDashboardData(pcs: any[], selectedDept: string) { 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); + // 10. 도넛 차트 렌더링 호출 renderDonutChart(matrix.premium.total, matrix.high.total, matrix.normal.total, matrix.entry.total, matrix.replace.total); // 전역 상태 등록 - state.activeCharts = [jobChartInstance, donutChartInstance]; + state.activeCharts = [donutChartInstance]; } /** @@ -746,139 +778,7 @@ function showMiniListModal(title: string, list: any[]) { }); } -/** - * 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) From 84511013251bae52718cea4a59966d6b63fb1fd5 Mon Sep 17 00:00:00 2001 From: JooWangi Date: Wed, 17 Jun 2026 09:25:16 +0900 Subject: [PATCH 2/5] =?UTF-8?q?Style:=20=EB=8C=80=EC=8B=9C=EB=B3=B4?= =?UTF-8?q?=EB=93=9C=20UI=20=ED=94=84=EB=A6=AC=EB=AF=B8=EC=97=84=20?= =?UTF-8?q?=EB=A6=AC=EC=8A=A4=ED=83=80=EC=9D=BC=EB=A7=81=20=EB=B0=8F=20?= =?UTF-8?q?=EC=B9=B4=EB=93=9C=20=EA=B5=AC=EC=A1=B0=20=EB=8F=84=EC=9E=85?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/views/Dashboard/HwDashboard.ts | 183 ++++++++++++++++------------- 1 file changed, 101 insertions(+), 82 deletions(-) diff --git a/src/views/Dashboard/HwDashboard.ts b/src/views/Dashboard/HwDashboard.ts index 72c2317..dc451ae 100644 --- a/src/views/Dashboard/HwDashboard.ts +++ b/src/views/Dashboard/HwDashboard.ts @@ -43,70 +43,87 @@ export function renderHwDashboard(container: HTMLElement) {
-
+
- -
+ +
-
-
- 보유 자산 수량 +
+
+
-
-
0대
+
+ + 보유 자산 수량
+
0대
-
-
- 사양 부족 +
+
+
-
-
0대
+
+ + 사양 부족
+
0대
-
-
- 오버 스펙 +
+
+
-
-
0대
+
+ + 오버 스펙
+
0대
-
-
- 윈도우 11 불가 PC +
+
+
-
-
0대
+
+ + 윈도우 11 불가 PC
+
0대
- -
+ +
- -
+ +
-
- 등급별 보유 비율 +
+ 등급별 보유 비율
-
+
-
+
최상급 @@ -125,24 +142,25 @@ export function renderHwDashboard(container: HTMLElement) {
- 교체 대상 + 교체
- -
-
- 연도별 PC 노후도 및 예측 + +
+
+ 연도별 PC 노후도 및 예측
-
+
- - - + + + @@ -156,25 +174,26 @@ export function renderHwDashboard(container: HTMLElement) { - -
-
+ +
+
-
+
등급별 자산 종합 현황 및 사양 적정성 분석
-
-
구분 (연한)보유권장 조치구분 (연한)보유권장 조치
+
+
- - - - - - - + + + + + + + @@ -386,7 +405,7 @@ function updateDashboardData(pcs: any[], selectedDept: string) { 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 cellStyle = `padding: 10px 8px; text-align: center; font-weight: 700; cursor: pointer; transition: background 0.2s; font-size: 1.05rem;`; const hoverEvents = `onmouseover="this.style.background='#F1F5F9'" onmouseout="this.style.background='none'"`; // 사양 적정성 분석 데이터 계산 (운영중인 자산만) @@ -400,31 +419,31 @@ function updateDashboardData(pcs: any[], selectedDept: string) { let barGraphHtml = ''; if (activeCount > 0) { barGraphHtml = ` -
-
- ${under > 0 ? `
` : ''} - ${normal > 0 ? `
` : ''} - ${over > 0 ? `
` : ''} +
+
+ ${under > 0 ? `
` : ''} + ${normal > 0 ? `
` : ''} + ${over > 0 ? `
` : ''}
-
- ${under > 0 ? `부족 ${under}` : ''} - ${normal > 0 ? `적정 ${normal}` : ''} - ${over > 0 ? `오버 ${over}` : ''} +
+ ${under > 0 ? `부족 ${under}` : ''} + ${normal > 0 ? `적정 ${normal}` : ''} + ${over > 0 ? `오버 ${over}` : ''}
`; } else { - barGraphHtml = `운영중 자산 없음`; + barGraphHtml = `운영중 자산 없음`; } return ` -
- - + + + - @@ -456,24 +475,24 @@ function updateDashboardData(pcs: any[], selectedDept: string) { let totBarGraphHtml = ''; if (totalActive > 0) { totBarGraphHtml = ` -
-
- ${totUnder > 0 ? `
` : ''} - ${totNormal > 0 ? `
` : ''} - ${totOver > 0 ? `
` : ''} +
+
+ ${totUnder > 0 ? `
` : ''} + ${totNormal > 0 ? `
` : ''} + ${totOver > 0 ? `
` : ''}
-
- ${totUnder > 0 ? `부족 ${totUnder}` : ''} - ${totNormal > 0 ? `적정 ${totNormal}` : ''} - ${totOver > 0 ? `오버 ${totOver}` : ''} +
+ ${totUnder > 0 ? `부족 ${totUnder}` : ''} + ${totNormal > 0 ? `적정 ${totNormal}` : ''} + ${totOver > 0 ? `오버 ${totOver}` : ''}
`; } else { - totBarGraphHtml = `운영중 자산 없음`; + 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 cellStyleHeader = `padding: 12px 10px; text-align: center; font-weight: 800; cursor: pointer; transition: background 0.2s; background: #F8FAFC; font-size: 1.05rem;`; const hoverEventsHeader = `onmouseover="this.style.background='#EEF2F6'" onmouseout="this.style.background='#F8FAFC'"`; matrixTbody.innerHTML = ` @@ -483,12 +502,12 @@ function updateDashboardData(pcs: any[], selectedDept: string) { ${renderMatrixRow('entry', '보급 PC (20점 ~ 40점)', '#F59E0B', entryShortage)} ${renderMatrixRow('replace', '교체 대상 PC (20점 미만 또는 Win11 불가)', '#EF4444', replaceShortage)}
- - + + - From abc531a41e29bfb32ecd0ba088fa218eee772d83 Mon Sep 17 00:00:00 2001 From: JooWangi Date: Wed, 17 Jun 2026 09:28:06 +0900 Subject: [PATCH 3/5] =?UTF-8?q?Design:=20=EB=8C=80=EC=8B=9C=EB=B3=B4?= =?UTF-8?q?=EB=93=9C=20=ED=95=98=EB=8B=A8=20=ED=91=9C=20=EC=84=B8=EB=A1=9C?= =?UTF-8?q?=EB=B9=84=EC=9C=A8=20=ED=99=95=EC=9E=A5=20=EB=B0=8F=20=EC=8A=A4?= =?UTF-8?q?=ED=81=AC=EB=A1=A4=EB=B0=94=20=EC=A0=9C=EA=B1=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/views/Dashboard/HwDashboard.ts | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/views/Dashboard/HwDashboard.ts b/src/views/Dashboard/HwDashboard.ts index dc451ae..cb5ab72 100644 --- a/src/views/Dashboard/HwDashboard.ts +++ b/src/views/Dashboard/HwDashboard.ts @@ -42,8 +42,8 @@ export function renderHwDashboard(container: HTMLElement) { - -
+ +
@@ -175,7 +175,7 @@ export function renderHwDashboard(container: HTMLElement) {
-
From 1d32a0350bef9a873f205ef1530f8864113fa0de Mon Sep 17 00:00:00 2001 From: JooWangi Date: Thu, 18 Jun 2026 15:56:51 +0900 Subject: [PATCH 4/5] =?UTF-8?q?feat:=20=EB=93=B1=EA=B8=89=EB=B3=84=20?= =?UTF-8?q?=EC=9E=90=EC=82=B0=20=EC=A2=85=ED=95=A9=20=ED=98=84=ED=99=A9=20?= =?UTF-8?q?=EB=B0=8F=20=EC=82=AC=EC=96=91=20=EC=A0=81=EC=A0=95=EC=84=B1=20?= =?UTF-8?q?=EB=B6=84=EC=84=9D=20=EB=A0=88=EC=9D=B4=EC=95=84=EC=9B=83=205:5?= =?UTF-8?q?=20=EC=BD=A4=ED=8C=A9=ED=8A=B8=20=EC=B5=9C=EC=A0=81=ED=99=94?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/views/Dashboard/HwDashboard.ts | 66 +++++++++++++++--------------- 1 file changed, 33 insertions(+), 33 deletions(-) diff --git a/src/views/Dashboard/HwDashboard.ts b/src/views/Dashboard/HwDashboard.ts index cb5ab72..3dcff22 100644 --- a/src/views/Dashboard/HwDashboard.ts +++ b/src/views/Dashboard/HwDashboard.ts @@ -42,8 +42,8 @@ export function renderHwDashboard(container: HTMLElement) {
- -
+ +
@@ -175,25 +175,25 @@ export function renderHwDashboard(container: HTMLElement) {
-
-
+
-
- 등급별 자산 종합 현황 및 사양 적정성 분석 +
+ 등급별 자산 종합 현황 및 사양 적정성 분석
-
구분 (등급)보유량운영중재고구매 필요사양 적정성 분석 (직무 기준)
구분 (등급)보유량운영중재고구매 필요사양 적정성 분석 (직무 기준)
${label}${data.total}대 (${totalRate}%)
${label}${data.total}대 (${totalRate}%) ${data.active}대 ${data.stock}대 ${shortage}대 + ${barGraphHtml}
합계 (Total)${totalPcs}대 (100%)합계 (Total)${totalPcs}대 (100%) ${totalActive}대 ${totalStock}대 ${totalShortage}대 + ${totBarGraphHtml}
+
- - - - - - + + + + + + @@ -405,7 +405,7 @@ function updateDashboardData(pcs: any[], selectedDept: string) { 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.05rem;`; + const cellStyle = `padding: 6px 8px; text-align: center; font-weight: 700; cursor: pointer; transition: background 0.2s; font-size: 0.95rem;`; const hoverEvents = `onmouseover="this.style.background='#F1F5F9'" onmouseout="this.style.background='none'"`; // 사양 적정성 분석 데이터 계산 (운영중인 자산만) @@ -419,31 +419,31 @@ function updateDashboardData(pcs: any[], selectedDept: string) { let barGraphHtml = ''; if (activeCount > 0) { barGraphHtml = ` -
-
+
+
${under > 0 ? `
` : ''} ${normal > 0 ? `
` : ''} ${over > 0 ? `
` : ''}
-
- ${under > 0 ? `부족 ${under}` : ''} - ${normal > 0 ? `적정 ${normal}` : ''} - ${over > 0 ? `오버 ${over}` : ''} +
+ ${under > 0 ? `부족 ${under}` : ''} + ${normal > 0 ? `적정 ${normal}` : ''} + ${over > 0 ? `오버 ${over}` : ''}
`; } else { - barGraphHtml = `운영중 자산 없음`; + barGraphHtml = `운영중 자산 없음`; } return `
- - + + - @@ -475,24 +475,24 @@ function updateDashboardData(pcs: any[], selectedDept: string) { let totBarGraphHtml = ''; if (totalActive > 0) { totBarGraphHtml = ` -
-
+
+
${totUnder > 0 ? `
` : ''} ${totNormal > 0 ? `
` : ''} - ${totOver > 0 ? `
` : ''} + ${totOver > 0 ? `
` : ''}
-
- ${totUnder > 0 ? `부족 ${totUnder}` : ''} - ${totNormal > 0 ? `적정 ${totNormal}` : ''} - ${totOver > 0 ? `오버 ${totOver}` : ''} +
+ ${totUnder > 0 ? `부족 ${totUnder}` : ''} + ${totNormal > 0 ? `적정 ${totNormal}` : ''} + ${totOver > 0 ? `오버 ${totOver}` : ''}
`; } else { - totBarGraphHtml = `운영중 자산 없음`; + totBarGraphHtml = `운영중 자산 없음`; } - const cellStyleHeader = `padding: 12px 10px; text-align: center; font-weight: 800; cursor: pointer; transition: background 0.2s; background: #F8FAFC; font-size: 1.05rem;`; + const cellStyleHeader = `padding: 6px 8px; text-align: center; font-weight: 800; cursor: pointer; transition: background 0.2s; background: #F8FAFC; font-size: 0.95rem;`; const hoverEventsHeader = `onmouseover="this.style.background='#EEF2F6'" onmouseout="this.style.background='#F8FAFC'"`; matrixTbody.innerHTML = ` From f656f0a43979b53e9a887ada3b3dda7295874f39 Mon Sep 17 00:00:00 2001 From: JooWangi Date: Thu, 18 Jun 2026 19:48:23 +0900 Subject: [PATCH 5/5] =?UTF-8?q?fix:=20=EB=8C=80=EC=8B=9C=EB=B3=B4=EB=93=9C?= =?UTF-8?q?=20=EC=82=AC=EC=96=91=20=EC=A0=81=EC=A0=95=EC=84=B1=20=EC=A7=81?= =?UTF-8?q?=EB=AC=B4=20=EB=A7=A4=ED=95=91=20=EC=88=98=EC=A0=95=20(system?= =?UTF-8?q?=5Fusers.position=20=EC=9A=B0=EC=84=A0=20=EC=B0=B8=EC=A1=B0)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - HwDashboard: asset_core.user_position 대신 system_users.user_name -> position 으로 세부 직무 조회 - ListFactory: 동일하게 세부 직무명 우선 참조 - 미니 모달 조직(직무) 컬럼: _resolved_position 사용으로 정확한 직무명 표시 - 수정된 필드명: u.name -> u.user_name (system_users 실제 컬럼명 반영) - 예) 디자이너(3D, 영상) 직군이 최상급 기준으로 올바르게 판정됨 --- src/views/Dashboard/HwDashboard.ts | 480 +++++++----- src/views/List/ListFactory.ts | 1114 +++++++++++----------------- 2 files changed, 708 insertions(+), 886 deletions(-) diff --git a/src/views/Dashboard/HwDashboard.ts b/src/views/Dashboard/HwDashboard.ts index 3dcff22..be7ac87 100644 --- a/src/views/Dashboard/HwDashboard.ts +++ b/src/views/Dashboard/HwDashboard.ts @@ -9,6 +9,55 @@ declare var Chart: any; let donutChartInstance: any = null; export function renderHwDashboard(container: HTMLElement) { + // 전역 툴팁 헬퍼 함수 등록 + (window as any).showSpecTooltip = function(event: MouseEvent, element: HTMLElement, type: string, count: number) { + const container = element.closest('.spec-bar-container'); + if (!container) return; + const tooltip = container.querySelector('.spec-tooltip') as HTMLElement; + if (!tooltip) return; + const textSpan = tooltip.querySelector('.tooltip-text') as HTMLElement; + if (textSpan) { + let color = ''; + let label = ''; + if (type === 'under') { + color = '#EF4444'; + label = '부족'; + } else if (type === 'normal') { + color = '#10B981'; + label = '적정'; + } else if (type === 'over') { + color = '#F59E0B'; + label = '오버'; + } else if (type === 'win11') { + color = '#7928ca'; + label = '윈도우 11 불가'; + } + textSpan.innerHTML = `${label} ${count}대`; + } + tooltip.style.left = event.clientX + 'px'; + tooltip.style.top = event.clientY + 'px'; + tooltip.style.opacity = '1'; + }; + + (window as any).updateSpecTooltipPos = function(event: MouseEvent, element: HTMLElement) { + const container = element.closest('.spec-bar-container'); + if (!container) return; + const tooltip = container.querySelector('.spec-tooltip') as HTMLElement; + if (tooltip) { + tooltip.style.left = event.clientX + 'px'; + tooltip.style.top = event.clientY + 'px'; + } + }; + + (window as any).hideSpecTooltip = function(element: HTMLElement) { + const container = element.closest('.spec-bar-container'); + if (!container) return; + const tooltip = container.querySelector('.spec-tooltip') as HTMLElement; + if (tooltip) { + tooltip.style.opacity = '0'; + } + }; + // 1. 개인용 PC 데이터 추출 (유형이 '개인PC'이거나 상태가 '재고' 또는 '대기' 상태인 PC 집계) const pcs = (state.masterData.pc || []).filter((a: any) => a.asset_type === '개인PC' || @@ -17,19 +66,11 @@ export function renderHwDashboard(container: HTMLElement) { // 2. 1페이지 매거진 리포트(제목바 제거, '| 제목' 미니멀리즘 스타일) HTML 빌드 container.innerHTML = ` -
+
- -
-
-

- 개인 PC 자산 대시보드 -

-
- - -
- 조직 필터: + +
+
@@ -42,125 +83,108 @@ export function renderHwDashboard(container: HTMLElement) {
- -
+ +
- -
+ +
-
-
- +
+
+ 보유 자산 수량
-
- - 보유 자산 수량 -
-
0대
+
0대
-
-
- +
+
+ 사양 부족
-
- - 사양 부족 -
-
0대
+
0대
-
-
- +
+
+ 오버 스펙
-
- - 오버 스펙 -
-
0대
+
0대
-
-
- +
+
+ 윈도우 11 불가
-
- - 윈도우 11 불가 PC -
-
0대
+
0대
- -
+ +
- -
+ +
- 등급별 보유 비율 + 조직별 사용 비율
- +
-
+
- - 최상급 + + 한맥
- - 상급 + + 삼안 +
+
+ + 장헌 +
+
+ + 한라
- 중급 + 기술개발센터
- - 보급 + + 총괄기획실
- - 교체 + + 기타
- -
+ +
- 연도별 PC 노후도 및 예측 + PC 노후도
-
구분 (등급)보유량운영중재고구매 필요사양 적정성 분석 (직무 기준)구분 (등급)보유량운영중재고구매 필요사양 적정성 분석 (직무 기준)
${label}${data.total}대 (${totalRate}%)${label}${data.total}대 (${totalRate}%) ${data.active}대 ${data.stock}대 ${shortage}대 + ${barGraphHtml}
+
- - - + + @@ -174,26 +198,25 @@ export function renderHwDashboard(container: HTMLElement) { - -
-
+ +
+
-
- 등급별 자산 종합 현황 및 사양 적정성 분석 +
+ 등급별 자산 종합현황
-
-
구분 (연한)보유권장 조치구분 (연한)보유
+
+
- - - - - - + + + + + + @@ -226,7 +249,16 @@ export function renderHwDashboard(container: HTMLElement) { }); btn.classList.add('active'); - btn.style.background = '#1E5149'; + const dept = btn.getAttribute('data-dept') || ''; + let bgColor = '#1E5149'; + if (dept === '한맥') bgColor = '#D02121'; + else if (dept === '삼안') bgColor = '#F58120'; + else if (dept === '장헌') bgColor = '#3889C7'; + else if (dept === '한라') bgColor = '#79B2D9'; + else if (dept === '기술개발센터') bgColor = '#10B981'; + else if (dept === '총괄기획실') bgColor = '#133D84'; + + btn.style.background = bgColor; btn.style.color = 'white'; const selectedDept = btn.getAttribute('data-dept') || ''; @@ -252,13 +284,31 @@ function updateDashboardData(pcs: any[], selectedDept: string) { }); // 3. DB 기준 사양 데이터 맵핑 (state.masterData.jobSpecs 이용) - const jobSpecsMap: Record = {}; + const jobSpecsMap: Record = {}; if (state.masterData.jobSpecs) { state.masterData.jobSpecs.forEach((s: any) => { - jobSpecsMap[s.job_name] = s.min_score; + jobSpecsMap[s.job_name] = s.required_grade || '중급'; }); } + // 사용자 이름 → 세부 직무 맵 생성 (system_users.position 기준, 더 정확한 직무 구분) + const userPositionMap: Record = {}; + if (state.masterData.users) { + state.masterData.users.forEach((u: any) => { + if (u.user_name && u.position) { + userPositionMap[u.user_name.trim()] = u.position.trim(); + } + }); + } + + const GRADE_RANK: Record = { + 'premium': 4, '최상급': 4, + 'high': 3, '상급': 3, + 'normal': 2, '중급': 2, + 'entry': 1, '보급': 1, + 'replace': 0, '교체 대상': 0 + }; + const jobScores: Record = {}; pcs.forEach((p: any) => { const score = calculatePcScoreDeductive(p.cpu, p.ram, p.gpu, p.purchase_date); @@ -306,7 +356,7 @@ function updateDashboardData(pcs: any[], selectedDept: string) { currentGradeKey = 'high'; } else if (score >= 40) { currentGradeKey = 'normal'; - } else if (score >= 20 && !win11Incompatible) { + } else if (score >= 20) { currentGradeKey = 'entry'; } else { currentGradeKey = 'replace'; @@ -323,23 +373,32 @@ function updateDashboardData(pcs: any[], selectedDept: string) { 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); + // 직무 적정성 계산: system_users.position 우선 조회 → asset_core.user_position fallback + const userName = (p[ASSET_SCHEMA.CURRENT_USER.key] || '').trim(); + const job = userPositionMap[userName] || p[ASSET_SCHEMA.USER_POSITION.key] || '미분류'; + const requiredGrade = jobSpecsMap[job] || jobSpecsMap[p[ASSET_SCHEMA.USER_POSITION.key]] || '중급'; // 세부 직무 우선, 없으면 일반 직무, 없으면 기본 중급 + + // 미니 모달 표시용으로 해석된 세부 직무명 저장 + p._resolved_position = job; + + const actualGrade = currentGradeKey; // premium, high, normal, entry, replace 중 하나 + + const reqRank = GRADE_RANK[requiredGrade] !== undefined ? GRADE_RANK[requiredGrade] : 2; // '중급' rank + const actRank = GRADE_RANK[actualGrade] !== undefined ? GRADE_RANK[actualGrade] : 0; let isUnder = false; - if (standardScore > 0 && job !== '재고PC') { - if (score < standardScore * 0.6) { + if (job !== '재고PC') { + if (win11Incompatible) { isUnder = true; p._spec_status = '사양 부족'; - } else if (score > standardScore * 1.5 && !win11Incompatible) { + } else if (actRank < reqRank) { + isUnder = true; + p._spec_status = '사양 부족'; + } else if (actRank > reqRank) { p._spec_status = '오버스펙'; criticalList.push(p); overSpecCount++; - } else if (win11Incompatible) { - isUnder = true; - p._spec_status = '사양 부족'; } else { p._spec_status = '적정'; } @@ -357,16 +416,11 @@ function updateDashboardData(pcs: any[], selectedDept: string) { 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'; // 교체 대상은 최소 보급형 사양으로 교체 - } + let targetGradeKey: keyof typeof matrix = 'normal'; + if (requiredGrade === '최상급') targetGradeKey = 'premium'; + else if (requiredGrade === '상급') targetGradeKey = 'high'; + else if (requiredGrade === '중급') targetGradeKey = 'normal'; + else if (requiredGrade === '보급') targetGradeKey = 'entry'; const targetGrade = matrix[targetGradeKey]; targetGrade.under++; @@ -390,60 +444,76 @@ function updateDashboardData(pcs: any[], selectedDept: string) { const matrixTbody = document.getElementById('pc-grade-matrix-tbody')!; const getSpecStatusCounts = (activePcsList: any[]) => { + let win11 = 0; let under = 0; let normal = 0; let over = 0; activePcsList.forEach(p => { - if (p._spec_status === '사양 부족') under++; + if (isWindows11Incompatible(p.cpu, p.ram)) win11++; + else if (p._spec_status === '사양 부족') under++; else if (p._spec_status === '오버스펙') over++; else normal++; }); - return { under, normal, over }; + return { win11, under, normal, over }; }; + const maxTotal = Math.max( + matrix.premium.total, + matrix.high.total, + matrix.normal.total, + matrix.entry.total, + matrix.replace.total + ); + 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: 6px 8px; text-align: center; font-weight: 700; cursor: pointer; transition: background 0.2s; font-size: 0.95rem;`; + const cellStyle = `padding: 22px 8px; text-align: center; font-weight: 700; cursor: pointer; transition: background 0.2s; font-size: 1.05rem;`; const hoverEvents = `onmouseover="this.style.background='#F1F5F9'" onmouseout="this.style.background='none'"`; // 사양 적정성 분석 데이터 계산 (운영중인 자산만) - const { under, normal, over } = getSpecStatusCounts(data.activePcs); + const { win11, under, normal, over } = getSpecStatusCounts(data.activePcs); const activeCount = data.active; + const win11Pct = activeCount > 0 ? (win11 / activeCount) * 100 : 0; const underPct = activeCount > 0 ? (under / activeCount) * 100 : 0; const normalPct = activeCount > 0 ? (normal / activeCount) * 100 : 0; const overPct = activeCount > 0 ? (over / activeCount) * 100 : 0; + const rowTotal = data.total; + const barWidthPct = maxTotal > 0 ? (rowTotal / maxTotal) * 100 : 0; + let barGraphHtml = ''; if (activeCount > 0) { barGraphHtml = ` -
-
- ${under > 0 ? `
` : ''} - ${normal > 0 ? `
` : ''} - ${over > 0 ? `
` : ''} +
+ +
+ ${win11 > 0 ? `
` : ''} + ${under > 0 ? `
` : ''} + ${normal > 0 ? `
` : ''} + ${over > 0 ? `
` : ''}
-
- ${under > 0 ? `부족 ${under}` : ''} - ${normal > 0 ? `적정 ${normal}` : ''} - ${over > 0 ? `오버 ${over}` : ''} + +
+ +
`; } else { - barGraphHtml = `운영중 자산 없음`; + barGraphHtml = `운영중 자산 없음`; } return `
- - + + - @@ -467,7 +537,7 @@ function updateDashboardData(pcs: any[], selectedDept: string) { const totalShortage = premiumShortage + highShortage + normalShortage + entryShortage + replaceShortage; const totalActivePcs = filtered.filter(p => !isStock(p)); - const { under: totUnder, normal: totNormal, over: totOver } = getSpecStatusCounts(totalActivePcs); + const { win11: totWin11, 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; @@ -475,24 +545,25 @@ function updateDashboardData(pcs: any[], selectedDept: string) { let totBarGraphHtml = ''; if (totalActive > 0) { totBarGraphHtml = ` -
-
- ${totUnder > 0 ? `
` : ''} - ${totNormal > 0 ? `
` : ''} - ${totOver > 0 ? `
` : ''} +
+ +
+ ${totUnder > 0 ? `
` : ''} + ${totNormal > 0 ? `
` : ''} + ${totOver > 0 ? `
` : ''}
-
- ${totUnder > 0 ? `부족 ${totUnder}` : ''} - ${totNormal > 0 ? `적정 ${totNormal}` : ''} - ${totOver > 0 ? `오버 ${totOver}` : ''} + +
+ +
`; } else { - totBarGraphHtml = `운영중 자산 없음`; + totBarGraphHtml = `운영중 자산 없음`; } - const cellStyleHeader = `padding: 6px 8px; text-align: center; font-weight: 800; cursor: pointer; transition: background 0.2s; background: #F8FAFC; font-size: 0.95rem;`; + const cellStyleHeader = `padding: 12px 10px; text-align: center; font-weight: 800; cursor: pointer; transition: background 0.2s; background: #F8FAFC; font-size: 1.05rem;`; const hoverEventsHeader = `onmouseover="this.style.background='#EEF2F6'" onmouseout="this.style.background='#F8FAFC'"`; matrixTbody.innerHTML = ` @@ -500,17 +571,7 @@ function updateDashboardData(pcs: any[], selectedDept: string) { ${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)} -
- - - - - - - + ${renderMatrixRow('replace', '교체 대상 PC (20점 미만)', '#EF4444', replaceShortage)} `; // 셀별 동적 클릭 리스너 바인딩 @@ -535,7 +596,7 @@ function updateDashboardData(pcs: any[], selectedDept: string) { if (t === 'total') return '보유'; if (t === 'active') return '운영중'; if (t === 'stock') return '재고'; - if (t === 'under') return '구매 필요'; + if (t === 'under') return '부족분'; return ''; }; @@ -575,11 +636,21 @@ function updateDashboardData(pcs: any[], selectedDept: string) { const status = target.getAttribute('data-spec-status')!; let targetPcs: any[] = []; + const filterFn = (p: any) => { + if (status === '윈도우 11 불가') { + return isWindows11Incompatible(p.cpu, p.ram); + } else if (status === '사양 부족') { + return !isWindows11Incompatible(p.cpu, p.ram) && p._spec_status === '사양 부족'; + } else { + return p._spec_status === status; + } + }; + if (grade === 'all') { - targetPcs = filtered.filter(p => !isStock(p) && p._spec_status === status); + targetPcs = filtered.filter(p => !isStock(p) && filterFn(p)); } else { const data = matrix[grade as keyof typeof matrix]; - targetPcs = data.activePcs.filter(p => p._spec_status === status); + targetPcs = data.activePcs.filter(filterFn); } const getGradeLabel = (g: string) => { @@ -622,23 +693,20 @@ function updateDashboardData(pcs: any[], selectedDept: string) { const agingTbody = document.getElementById('pc-aging-tbody')!; - const renderAgingRow = (label: string, list: any[], badgeText: string, badgeStyle: string, ageGroupKey: string) => { + const renderAgingRow = (label: string, list: any[], ageGroupKey: string) => { return ` - - - + + `; }; 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')} + ${renderAgingRow('즉시 교체 (7년 이상)', agingCounts.immediate, 'immediate')} + ${renderAgingRow('교체 검토 (3년 ~ 7년)', agingCounts.review, 'review')} + ${renderAgingRow('정상 운용 (1년 ~ 3년)', agingCounts.normal, 'normal')} + ${renderAgingRow('최신 도입 (1년 미만)', agingCounts.fresh, 'fresh')} `; agingTbody.querySelectorAll('.aging-row').forEach(row => { @@ -656,14 +724,14 @@ function updateDashboardData(pcs: any[], selectedDept: string) { }); // 8. 요약 지표 카드 클릭 리스너 설정 - const bindCardClick = (id: string, gradeTitle: string, filterFn: (p: any) => boolean) => { + const bindCardClick = (id: string, gradeTitle: string, filterFn: (p: any) => boolean, hoverBgColor: string) => { const card = document.getElementById(id)!; if (!card) return; card.style.cursor = 'pointer'; - card.style.transition = 'opacity 0.2s'; + card.style.transition = 'background-color 0.15s ease'; - card.onmouseover = () => { card.style.opacity = '0.7'; }; - card.onmouseout = () => { card.style.opacity = '1'; }; + card.onmouseover = () => { card.style.backgroundColor = hoverBgColor; }; + card.onmouseout = () => { card.style.backgroundColor = '#ffffff'; }; card.onclick = () => { const pcsInGrade = filtered.filter(filterFn); @@ -672,12 +740,48 @@ function updateDashboardData(pcs: any[], selectedDept: string) { }; // 사양 부족 / 오버 스펙 / 윈도우 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)); + bindCardClick('card-under-spec', '사양 부족 대상', p => p._spec_status === '사양 부족', '#FEF2F2'); + bindCardClick('card-over-spec', '오버 스펙 대상', p => p._spec_status === '오버스펙', '#FFFBEB'); + bindCardClick('card-win11-incompatible', '윈도우 11 업그레이드 불가 PC', p => isWindows11Incompatible(p.cpu, p.ram), '#F5F3FF'); + + // 9. 조직별 사용 비율 집계 (전체 개인용 PC 기준) + const deptCounts: Record = { + '한맥': 0, + '삼안': 0, + '장헌': 0, + '한라': 0, + '기술개발센터': 0, + '총괄기획실': 0, + '기타': 0 + }; + + pcs.forEach((p: any) => { + const dept = String(p[ASSET_SCHEMA.CURRENT_DEPT.key] || '').trim(); + let matched = false; + for (const key of Object.keys(deptCounts)) { + if (key !== '기타' && dept.includes(key)) { + deptCounts[key]++; + matched = true; + break; + } + } + if (!matched) { + deptCounts['기타']++; + } + }); + + const deptChartData = [ + { label: '한맥', count: deptCounts['한맥'], color: '#D02121' }, + { label: '삼안', count: deptCounts['삼안'], color: '#F58120' }, + { label: '장헌', count: deptCounts['장헌'], color: '#3889C7' }, + { label: '한라', count: deptCounts['한라'], color: '#79B2D9' }, + { label: '기술개발센터', count: deptCounts['기술개발센터'], color: '#10B981' }, + { label: '총괄기획실', count: deptCounts['총괄기획실'], color: '#133D84' }, + { label: '기타', count: deptCounts['기타'], color: '#94A3B8' } + ]; // 10. 도넛 차트 렌더링 호출 - renderDonutChart(matrix.premium.total, matrix.high.total, matrix.normal.total, matrix.entry.total, matrix.replace.total); + renderDonutChart(deptChartData); // 전역 상태 등록 state.activeCharts = [donutChartInstance]; @@ -746,7 +850,7 @@ function showMiniListModal(title: string, list: any[]) { return ` - + @@ -802,7 +906,7 @@ function showMiniListModal(title: string, list: any[]) { /** * 실시간 사양 적정률 원형 도넛 그래프 (Active Spec Rate) */ -function renderDonutChart(premium: number, high: number, normal: number, entry: number, replace: number) { +function renderDonutChart(deptData: { label: string; count: number; color: string }[]) { const ctx = document.getElementById('chart-overall-donut') as HTMLCanvasElement; if (!ctx || typeof Chart === 'undefined') return; @@ -811,21 +915,15 @@ function renderDonutChart(premium: number, high: number, normal: number, entry: donutChartInstance = null; } - const total = premium + high + normal + entry + replace; + const total = deptData.reduce((sum, d) => sum + d.count, 0); donutChartInstance = new Chart(ctx, { type: 'doughnut', data: { - labels: ['최상급', '상급', '중급', '보급', '교체 대상'], + labels: deptData.map(d => d.label), 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) - ], + data: deptData.map(d => d.count), + backgroundColor: deptData.map(d => d.color), borderColor: '#ffffff', borderWidth: 2 }] diff --git a/src/views/List/ListFactory.ts b/src/views/List/ListFactory.ts index ae7a26b..28bcd40 100644 --- a/src/views/List/ListFactory.ts +++ b/src/views/List/ListFactory.ts @@ -153,6 +153,7 @@ export interface ListViewConfig { showField?: boolean; showType?: boolean; showStatus?: boolean; + showPosition?: boolean; }; columns: ColumnDef[]; onRowClick?: (asset: any) => void; @@ -161,9 +162,8 @@ export interface ListViewConfig { } export function createListView(container: HTMLElement, config: ListViewConfig) { - // 1. 컨테이너 초기화 및 헤더 렌더링 + // 1. 컨테이너 초기화 container.innerHTML = ''; - renderPageHeader(container, config.title); const fullList = config.dataSource(); let sortState: SortState = config.persistentSortState || { key: '', direction: 'asc' }; @@ -181,46 +181,20 @@ export function createListView(container: HTMLElement, config: ListViewConfig) { const isServer = config.title === '서버'; if (!isServer) { (state as any).currentViewMode = 'asset'; - } else if (!(state as any).currentViewMode) { - (state as any).currentViewMode = 'system'; } - // 2. 뷰 전환 토글 버튼 생성 (명칭 변경) - const toggleWrapper = document.createElement('div'); - toggleWrapper.className = 'view-toggle-container'; - - const showPcFlowBtn = config.title === 'PC'; - toggleWrapper.innerHTML = ` -
-
- - -
-
- ${showPcFlowBtn ? ` - - - ` : ''} - -
-
- `; - container.appendChild(toggleWrapper); - - // 3. 필터 바 생성 (자산 목록에서만 사용) - const filterBar = document.createElement('div'); - filterBar.className = 'search-bar'; - container.appendChild(filterBar); - - // 4. 컨텐츠 영역 생성 + // 1. 컨텐츠 영역 생성 (먼저 생성하여 참조 가능하게 함) const contentWrapper = document.createElement('div'); contentWrapper.className = 'view-content-wrapper'; + + // 2. 필터 바 생성 (자산 목록에서만 사용) + const filterBar = document.createElement('div'); + filterBar.className = 'search-bar'; + + // 자산 추가 버튼 및 목록 보기 체크박스 추가 로직 + const showPcFlowBtn = config.title === 'PC'; + + container.appendChild(filterBar); container.appendChild(contentWrapper); // --- 내부 상태 --- @@ -228,7 +202,6 @@ export function createListView(container: HTMLElement, config: ListViewConfig) { let selectedDetailLocation: string | null = null; let dynamicMapConfig: Record = {}; - // 맵 설정 미리 로드 const fetchMapConfig = async () => { try { const res = await fetch(`http://${location.hostname}:3000/api/maps`); @@ -254,15 +227,12 @@ export function createListView(container: HTMLElement, config: ListViewConfig) { selectedLocation = validLocations[0] || ''; } - const locationCounts: Record = {}; - const pcTypeCounts = { public: 0, server: 0, personal: 0 }; - - // 동적 통계 수집 객체 (Hardcoding 제거) + // 동적 통계 수집 객체 const extStats = { total: 0, locCounts: {} as Record, typeCounts: {} as Record, - typeLocMap: {} as Record>, // 유형별 위치 분포 + typeLocMap: {} as Record>, locWarning: 0, typeWarning: 0 }; @@ -273,41 +243,23 @@ export function createListView(container: HTMLElement, config: ListViewConfig) { typeLocMap: {} as Record> }; - // 중앙화된 경고 감지 로직 const checkAnomaly = (serviceType: string, loc: string, type: string) => { - if (serviceType !== '외부') return { isWarning: false, isLocWarning: false, isTypeWarning: false, reason: '' }; + if (serviceType !== '외부') return { isWarning: false, isLocWarning: false, isTypeWarning: false }; const isLocWarning = loc !== 'IDC' && loc !== '미지정' && loc !== ''; const isTypeWarning = type.toLowerCase().replace(/\s/g, '').includes('서버pc'); - const isWarning = isLocWarning || isTypeWarning; - - let reason = ''; - if (isLocWarning && isTypeWarning) reason = '위치/형식 부적절'; - else if (isLocWarning) reason = '위치 부적절'; - else if (isTypeWarning) reason = '형식 부적절'; - - return { isWarning, isLocWarning, isTypeWarning, reason }; + return { isWarning: isLocWarning || isTypeWarning, isLocWarning, isTypeWarning }; }; fullList.forEach(asset => { const loc = asset[ASSET_SCHEMA.LOCATION.key] || '미지정'; - const serviceTypeKey = (ASSET_SCHEMA as any).SERVICE_TYPE?.key || 'service_type'; - const serviceType = asset[serviceTypeKey] || '외부'; + const serviceType = asset.service_type || '외부'; const type = asset[ASSET_SCHEMA.ASSET_TYPE.key] || ''; - locationCounts[loc] = (locationCounts[loc] || 0) + 1; - - if (isPcView) { - if (type.includes('공용')) pcTypeCounts.public++; - else if (type.includes('서버')) pcTypeCounts.server++; - else pcTypeCounts.personal++; - } - const targetStat = serviceType === '내부' ? intStats : extStats; targetStat.total++; if (loc) targetStat.locCounts[loc] = (targetStat.locCounts[loc] || 0) + 1; if (type) { targetStat.typeCounts[type] = (targetStat.typeCounts[type] || 0) + 1; - // 유형별 위치 분포 수집 if (!targetStat.typeLocMap[type]) targetStat.typeLocMap[type] = {}; targetStat.typeLocMap[type][loc] = (targetStat.typeLocMap[type][loc] || 0) + 1; } @@ -319,180 +271,139 @@ export function createListView(container: HTMLElement, config: ListViewConfig) { } }); - // 템플릿 제너레이터 함수 (HTML 중복 제거) const generateDetailStatHTML = (title: string, stats: any) => ` -
- ${title} -
- ${stats.locWarning ? `위치부적절: ${stats.locWarning}` : ''} - ${stats.typeWarning ? `형식부적절: ${stats.typeWarning}` : ''} +
+ ${title} +
+ ${stats.locWarning ? `위치부적절: ${stats.locWarning}` : ''} + ${stats.typeWarning ? `형식부적절: ${stats.typeWarning}` : ''}
-
-
- ${Object.entries(stats.locCounts as Record).sort((a, b) => b[1] - a[1]).slice(0, 4).map(([l, c]) => `${l}: ${c}`).join('')} +
+
+ ${Object.entries(stats.locCounts as Record).sort((a, b) => b[1] - a[1]).slice(0, 4).map(([l, c]) => `${l}: ${c}`).join('')}
-
+
${Object.entries(stats.typeCounts as Record).sort((a, b) => b[1] - a[1]).slice(0, 6).map(([t, c]) => { - const isTypeWarning = title.includes('외부') && t.toLowerCase().replace(/\s/g, '').includes('서버pc'); - - // 위치별 상세 정보 생성 (툴팁용) const locDist = stats.typeLocMap[t] || {}; - const locHint = Object.entries(locDist) - .sort((a: any, b: any) => b[1] - a[1]) - .map(([l, count]) => `${l}: ${count}대`) - .join('\n'); - - return `${t}: ${c}`; + const locHint = Object.entries(locDist).sort((a: any, b: any) => b[1] - a[1]).map(([l, count]) => `${l}: ${count}대`).join('\n'); + return `${t}: ${c}`; }).join('')}
`; contentWrapper.innerHTML = ` -
- - -
-
-
총 보유 자산
-
${fullList.length}
-
- 외부: ${extStats.total} - 내부: ${intStats.total} +
+
+
+
총 보유 자산
+
${fullList.length}
+
+ 외부: ${extStats.total} + 내부: ${intStats.total}
- -
- ${isPcView ? ` -
PC 유형별 현황
-
- 공용: ${pcTypeCounts.public} - 서버: ${pcTypeCounts.server} - 개인: ${pcTypeCounts.personal} -
- ` : generateDetailStatHTML('외부 (운영) 상세', extStats)} -
- -
- ${isPcView ? '' : generateDetailStatHTML('내부 (테스트) 상세', intStats as any)} -
+
${generateDetailStatHTML('외부 (운영) 상세', extStats)}
+
${generateDetailStatHTML('내부 (테스트) 상세', intStats)}
-
- -
-
-

+
+ +
+
+ ${!isPcView ? ` -
- 위치: - ${validLocations.map(l => ``).join('')} - 상세: - +
` : ''}
-
-

구분 (등급)보유량운영중재고구매 필요사양 적정성 분석 (직무 기준)구분 (등급)보유량운영중재고부족분사양 적정성
${label}${data.total}대 (${totalRate}%)${label}${data.total}대 (${totalRate}%) ${data.active}대 ${data.stock}대 ${shortage}대 + ${barGraphHtml}
합계 (Total)${totalPcs}대 (100%)${totalActive}대${totalStock}대${totalShortage}대 - ${totBarGraphHtml} -
${label}${list.length}대 - ${badgeText} - ${label}${list.length}대
${user}${pc.current_dept || '-'} (${pc.user_position || '-'})${pc.current_dept || '-'} (${pc._resolved_position || pc.user_position || '-'}) ${spec} ${badgeHTML}${scoreHTML} ${pc.asset_code || '-'}
- +
+
+ ${isPcView ? ` - - - - - - - - + + + + + + + + ` : ` - - - - - - + + + + + + `} - +
일자담당자구분사용자인수자자산번호상세
일자담당자구분사용자인수자자산번호상세
분류용도/자산명관리자(정)관리자(부)상세위치
분류용도/자산명관리자(정)관리자(부)상세위치
- -
-
+ +
+
${isPcView ? ` -
-
-

+
+
+
-
- - - - - - - +
+
사용자부서 (직무)상태자산코드
+ + + + + + - - + +
사용자부서 (직무)상태자산코드
사양 주의 자산이 없습니다.
사양 주의 자산이 없습니다.
` : ` -

목록에서 자산을 선택하면
상세 정보와 배치도가 표시됩니다.

+

목록에서 자산을 선택하면
상세 정보와 배치도가 표시됩니다.

`}

-