|
|
|
|
@@ -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<string, number> = {};
|
|
|
|
|
|
|
|
|
|
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)';
|
|
|
|
|
const avgAge = countWithDate > 0 ? (totalAge / countWithDate).toFixed(1) : '0';
|
|
|
|
|
const over5Rate = allHw.length > 0 ? Math.round((over5YearsCount / allHw.length) * 100) : 0;
|
|
|
|
|
|
|
|
|
|
usageCards += `
|
|
|
|
|
<div class="dashboard-card" data-action="idle" data-type="${t}" style="padding: 1.25rem 1.5rem; cursor:pointer; min-height:auto;">
|
|
|
|
|
<span style="font-size:1rem; font-weight:700; color:var(--text-main);">${t} 사용현황</span>
|
|
|
|
|
<div style="font-size: 0.8125rem; color:var(--text-muted); margin-bottom: 1rem;">
|
|
|
|
|
${total}${units[i]} 중 ${used}${units[i]} 사용 중
|
|
|
|
|
</div>
|
|
|
|
|
<div style="font-size: 2rem; font-weight:700; color:${barColor}; line-height:1;">${per}%</div>
|
|
|
|
|
<div style="width:100%; height:4px; background-color:var(--border-color); border-radius:2px; overflow:hidden; margin-top:0.75rem;">
|
|
|
|
|
<div style="width:${per}%; height:100%; background-color:${barColor};"></div>
|
|
|
|
|
</div>
|
|
|
|
|
</div>`;
|
|
|
|
|
});
|
|
|
|
|
// 교체 시급 대상 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 = `
|
|
|
|
|
<div class="view-container">
|
|
|
|
|
<h3 class="dashboard-section-title">자산 사용현황 요약</h3>
|
|
|
|
|
<div class="dashboard-grid">${usageCards}</div>
|
|
|
|
|
<div class="dashboard-header-stats" style="display: grid; grid-template-columns: repeat(3, 1fr); gap: 1.5rem; margin-bottom: 2rem;">
|
|
|
|
|
<div class="dashboard-card stat-card">
|
|
|
|
|
<div class="stat-label">전체 평균 사용 연수</div>
|
|
|
|
|
<div class="stat-value">${avgAge}<span class="unit">년</span></div>
|
|
|
|
|
<div class="stat-footer">권장 교체 주기: 4.5년</div>
|
|
|
|
|
</div>
|
|
|
|
|
<div class="dashboard-card stat-card ${over5Rate >= 20 ? 'critical' : ''}">
|
|
|
|
|
<div class="stat-label">5년 이상 노후 자산 비율</div>
|
|
|
|
|
<div class="stat-value" style="${over5Rate >= 20 ? 'color:var(--danger)' : ''}">${over5Rate}<span class="unit">%</span></div>
|
|
|
|
|
<div class="stat-footer">${over5YearsCount}대의 자산이 교체 대상을 초과함</div>
|
|
|
|
|
</div>
|
|
|
|
|
<div class="dashboard-card stat-card">
|
|
|
|
|
<div class="stat-label">최신 도입 모델 (${latestYear}년)</div>
|
|
|
|
|
<div class="stat-value" style="font-size: 1.25rem; white-space: nowrap; overflow: hidden; text-overflow: ellipsis;" title="${latestAsset?.모델명 || '정보 없음'}">
|
|
|
|
|
${latestAsset?.모델명 || '정보 없음'}
|
|
|
|
|
</div>
|
|
|
|
|
<div class="stat-footer">가장 최근 자산번호: ${latestAsset?.자산코드 || '-'}</div>
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
<h3 class="dashboard-section-title">하드웨어 보유 통계</h3>
|
|
|
|
|
<div class="dashboard-layout-2col">
|
|
|
|
|
<div class="dashboard-layout-2col" style="margin-bottom: 2rem;">
|
|
|
|
|
<div class="dashboard-card">
|
|
|
|
|
<h4 style="margin-bottom:1rem; font-size:0.9rem; color:var(--text-muted);">자산 유형별 보유 현황</h4>
|
|
|
|
|
<canvas id="chart-hw-types"></canvas>
|
|
|
|
|
<h4 class="card-title">자산 노후도 분포</h4>
|
|
|
|
|
<div style="height: 250px;"><canvas id="chart-aging-dist"></canvas></div>
|
|
|
|
|
</div>
|
|
|
|
|
<div class="dashboard-card">
|
|
|
|
|
<h4 style="margin-bottom:1rem; font-size:0.9rem; color:var(--text-muted);">구매법인별 자산 분포</h4>
|
|
|
|
|
<canvas id="chart-hw-corps"></canvas>
|
|
|
|
|
<h4 class="card-title">연도별 자산 도입 추이</h4>
|
|
|
|
|
<div style="height: 250px;"><canvas id="chart-purchase-trend"></canvas></div>
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
<h3 class="dashboard-section-title">⚠️ 교체 검토 대상 (가장 오래된 자산 TOP 10)</h3>
|
|
|
|
|
<div class="table-container" style="background: white; border-radius: 8px; border: 1px solid var(--border-color);">
|
|
|
|
|
<table>
|
|
|
|
|
<thead>
|
|
|
|
|
<tr>
|
|
|
|
|
<th>순위</th>
|
|
|
|
|
<th>자산번호</th>
|
|
|
|
|
<th>유형</th>
|
|
|
|
|
<th>모델명</th>
|
|
|
|
|
<th>사용자/담당자</th>
|
|
|
|
|
<th>구매일</th>
|
|
|
|
|
<th>연령</th>
|
|
|
|
|
</tr>
|
|
|
|
|
</thead>
|
|
|
|
|
<tbody>
|
|
|
|
|
${criticalList.map((a, i) => `
|
|
|
|
|
<tr class="clickable-row" data-id="${a.id}">
|
|
|
|
|
<td style="text-align:center; font-weight:600; color:var(--text-muted)">${i + 1}</td>
|
|
|
|
|
<td>${a.자산코드 || '-'}</td>
|
|
|
|
|
<td><span class="badge-type">${a.type}</span></td>
|
|
|
|
|
<td>${a.모델명 || a.명칭 || '-'}</td>
|
|
|
|
|
<td>${a.사용자 || a.담당자_정 || '-'}</td>
|
|
|
|
|
<td style="text-align:center;">${a.구매일 || (a as any).purchase_date || '-'}</td>
|
|
|
|
|
<td style="text-align:center;"><strong style="color:${calculateAssetAge(a.구매일 || (a as any).purchase_date) >= 5 ? 'var(--danger)' : 'inherit'}">${calculateAssetAge(a.구매일 || (a as any).purchase_date)}년</strong></td>
|
|
|
|
|
</tr>
|
|
|
|
|
`).join('')}
|
|
|
|
|
</tbody>
|
|
|
|
|
</table>
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
`;
|
|
|
|
|
|
|
|
|
|
// 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' } } }
|
|
|
|
|
});
|
|
|
|
|
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);
|
|
|
|
|
initAgingCharts(ageGroups, yearlyCount);
|
|
|
|
|
|
|
|
|
|
container.querySelectorAll('[data-action="idle"]').forEach(card => {
|
|
|
|
|
card.addEventListener('click', () => {
|
|
|
|
|
const t = card.getAttribute('data-type')!;
|
|
|
|
|
openDashboardDetail(`[${t}] 유휴 자산 목록`, groups[t].idle);
|
|
|
|
|
// 행 클릭 이벤트 바인딩
|
|
|
|
|
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');
|
|
|
|
|
});
|
|
|
|
|
});
|
|
|
|
|
});
|
|
|
|
|
}, 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<string, number>) {
|
|
|
|
|
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 } }
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|