-
직무별 사양 적정성 분석
+
+
+
+
+ 등급별 보유 비율
-
-
+
+
+
+
+
+
+
+
+
+
+ 최상급
+
+
+
+ 상급
+
+
+
+ 중급
+
+
+
+ 보급
+
+
+
+ 교체 대상
+
+
-
-
-
-
-
-
-
-
- 등급별 보유 비율
-
-
-
-
-
-
-
-
-
-
-
- 최상급
-
-
-
- 상급
-
-
-
- 중급
-
-
-
- 보급
-
-
-
- 교체 대상
-
-
-
-
-
-
-
- 연도별 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)