2 Commits

4 changed files with 242 additions and 101 deletions

View File

@@ -245,7 +245,7 @@ function applyTypeSpecificUI(type: string) {
opTitle: document.getElementById('hw-op-title') opTitle: document.getElementById('hw-op-title')
}; };
// 1. 초기화 // 1. 초기화 (모든 유동 섹션 숨김)
serverOnly.forEach(el => (el as HTMLElement).style.display = 'none'); serverOnly.forEach(el => (el as HTMLElement).style.display = 'none');
nonServer.forEach(el => (el as HTMLElement).style.display = 'none'); nonServer.forEach(el => (el as HTMLElement).style.display = 'none');
locationFields.forEach(el => (el as HTMLElement).style.display = 'none'); locationFields.forEach(el => (el as HTMLElement).style.display = 'none');
@@ -254,27 +254,8 @@ function applyTypeSpecificUI(type: string) {
if (groups.type) groups.type.style.display = 'flex'; if (groups.type) groups.type.style.display = 'flex';
if (groups.opTitle) groups.opTitle.style.display = 'flex'; if (groups.opTitle) groups.opTitle.style.display = 'flex';
// 2. PC 유형일 때 상세용도 선택창 노출 (복구 핵심) // 2. 유형별 정밀 규칙 적용 (사용자 정의 100% 일치)
if (type === 'PC' || type === '개인PC' || type === '노트북') { if (type === '서버') {
if (groups.detailPurpose) groups.detailPurpose.style.display = 'flex';
// 상세용도가 '서버'인 경우 서버용 필드 노출, 아니면 일반 PC용 필드 노출
if (detailPurpose === '서버') {
serverOnly.forEach(el => (el as HTMLElement).style.display = 'flex');
locationFields.forEach(el => (el as HTMLElement).style.display = 'flex');
if (groups.networkTitle) groups.networkTitle.style.display = 'flex';
['ip', 'ip2', 'remote', 'serverId', 'serverPw', 'monitoring', 'model', 'os', 'cpu', 'ram', 'ssd1', 'ssd2'].forEach(k => {
if (groups[k]) groups[k]!.style.display = 'flex';
});
} else {
nonServer.forEach(el => (el as HTMLElement).style.display = 'flex');
if (groups.specTitle) groups.specTitle.style.display = 'flex';
['model', 'os', 'cpu', 'ram', 'ssd1', 'ssd2', 'hwSpec', 'ipNonServer'].forEach(k => {
if (groups[k]) groups[k]!.style.display = 'flex';
});
}
}
else if (type === '서버') {
serverOnly.forEach(el => (el as HTMLElement).style.display = 'flex'); serverOnly.forEach(el => (el as HTMLElement).style.display = 'flex');
locationFields.forEach(el => (el as HTMLElement).style.display = 'flex'); locationFields.forEach(el => (el as HTMLElement).style.display = 'flex');
Object.values(groups).forEach(g => { if (g) g.style.display = 'flex'; }); Object.values(groups).forEach(g => { if (g) g.style.display = 'flex'; });
@@ -287,12 +268,41 @@ function applyTypeSpecificUI(type: string) {
if (groups.specTitle) groups.specTitle.style.display = 'flex'; if (groups.specTitle) groups.specTitle.style.display = 'flex';
if (groups.model) groups.model.style.display = 'flex'; if (groups.model) groups.model.style.display = 'flex';
if (groups.ssd1) groups.ssd1.style.display = 'flex'; if (groups.ssd1) groups.ssd1.style.display = 'flex';
if (groups.ssd2) groups.ssd2.style.display = 'flex';
} }
else { else if (type === 'PC' || type === '노트북') {
// 기타 유형 (CPU, RAM, 모바일 등) if (type === 'PC' && groups.detailPurpose) groups.detailPurpose.style.display = 'flex';
nonServer.forEach(el => (el as HTMLElement).style.display = 'flex');
if (groups.specTitle) groups.specTitle.style.display = 'flex';
['model', 'os', 'cpu', 'ram', 'ssd1', 'ssd2', 'hwSpec', 'ipNonServer'].forEach(k => {
if (groups[k]) groups[k]!.style.display = 'flex';
});
if (type === 'PC' && detailPurpose === '서버') {
locationFields.forEach(el => (el as HTMLElement).style.display = 'flex');
if (groups.networkTitle) groups.networkTitle.style.display = 'flex';
['ip', 'ip2', 'remote', 'serverId', 'serverPw', 'monitoring'].forEach(k => {
if (groups[k]) groups[k]!.style.display = 'flex';
});
if (groups.ipNonServer) groups.ipNonServer.style.display = 'none';
}
}
else if (['CPU', 'GPU', '모바일'].includes(type)) {
if (groups.specTitle) groups.specTitle.style.display = 'flex'; if (groups.specTitle) groups.specTitle.style.display = 'flex';
if (groups.model) groups.model.style.display = 'flex'; if (groups.model) groups.model.style.display = 'flex';
} }
else if (type === 'RAM') {
if (groups.specTitle) groups.specTitle.style.display = 'flex';
if (groups.ram) groups.ram.style.display = 'flex';
}
else if (type === 'HDD') {
if (groups.specTitle) groups.specTitle.style.display = 'flex';
if (groups.ssd1) groups.ssd1.style.display = 'flex';
}
else if (type === '태블릿') {
if (groups.specTitle) groups.specTitle.style.display = 'flex';
if (groups.model) groups.model.style.display = 'flex';
if (groups.ssd1) groups.ssd1.style.display = 'flex';
}
} }
function fillHwFormData(asset: HardwareAsset) { function fillHwFormData(asset: HardwareAsset) {

View File

@@ -14,6 +14,7 @@ export interface MasterAssetData {
logs: HardwareLog[]; logs: HardwareLog[];
// 동료 코드 호환용 통합 배열 (프론트엔드 로직용) // 동료 코드 호환용 통합 배열 (프론트엔드 로직용)
hw: HardwareAsset[];
sw: SoftwareAsset[]; sw: SoftwareAsset[];
} }
@@ -36,6 +37,7 @@ export const state: AppState = {
subSw: [], subSw: [],
permSw: [], permSw: [],
cloud: [], cloud: [],
hw: [], // 호환용
sw: [], // 호환용 sw: [], // 호환용
swUsers: [], swUsers: [],
logs: [] logs: []
@@ -90,6 +92,15 @@ export async function loadMasterDataFromDB() {
...state.masterData.cloud ...state.masterData.cloud
]; ];
// 하드웨어 통합 배열 생성 (대시보드 등에서 사용)
state.masterData.hw = [
...state.masterData.pc,
...state.masterData.server,
...state.masterData.storage,
...state.masterData.equip,
...state.masterData.mobile
];
console.log('✅ 모든 DB 데이터 로드 및 통합 완료'); console.log('✅ 모든 DB 데이터 로드 및 통합 완료');
return true; return true;
} catch (err) { } catch (err) {
@@ -137,6 +148,15 @@ export function saveHardwareAsset(updatedAsset: HardwareAsset) {
// 3. 새로운 타겟 카테고리에 추가 // 3. 새로운 타겟 카테고리에 추가
(state.masterData[targetKey] as HardwareAsset[]).push(updatedAsset); (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); 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
];
} }

View File

@@ -33,6 +33,21 @@ export function normalizeDate(dateStr: string): string {
return (dateStr || '').replace(/\./g, '-').trim(); 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자리 랜덤 문자열) * 고유 ID 생성 (7자리 랜덤 문자열)
*/ */

View File

@@ -1,104 +1,191 @@
import { state } from '../../core/state'; import { state } from '../../core/state';
import { HardwareAsset } from '../../core/excelHandler'; import { HardwareAsset } from '../../core/excelHandler';
import { openDashboardDetail } from '../../components/Modal/DashboardDetailModal'; import { openHwModal } from '../../components/Modal/HWModal';
import { normalizeDate } from '../../core/utils'; import { calculateAssetAge, normalizeDate } from '../../core/utils';
declare var Chart: any; declare var Chart: any;
export function renderHwDashboard(container: HTMLElement) { export function renderHwDashboard(container: HTMLElement) {
const types = ['개인PC', '서버', '스토리지', '전산비품']; const allHw = state.masterData.hw || [];
const units = ['대', '대', '대', '개'];
const groups: any = {};
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 => { const ageGroups = { stable: 0, warning: 0, critical: 0 };
if (!groups[a.type]) return; const yearlyCount: Record<string, number> = {};
if (isHwIdle(a)) groups[a.type].idle.push(a);
else groups[a.type].active.push(a); 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 = ''; const avgAge = countWithDate > 0 ? (totalAge / countWithDate).toFixed(1) : '0';
types.forEach((t, i) => { const over5Rate = allHw.length > 0 ? Math.round((over5YearsCount / allHw.length) * 100) : 0;
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 += ` // 교체 시급 대상 TOP 10 (오래된 순)
<div class="dashboard-card" data-action="idle" data-type="${t}" style="padding: 1.25rem 1.5rem; cursor:pointer; min-height:auto;"> const criticalList = [...allHw]
<span style="font-size:1rem; font-weight:700; color:var(--text-main);">${t} 사용현황</span> .filter(a => (a. || (a as any).purchase_date))
<div style="font-size: 0.8125rem; color:var(--text-muted); margin-bottom: 1rem;"> .sort((a, b) => {
${total}${units[i]}${used}${units[i]} 사용 중 const dateA = new Date(normalizeDate(a. || (a as any).purchase_date)).getTime();
</div> const dateB = new Date(normalizeDate(b. || (b as any).purchase_date)).getTime();
<div style="font-size: 2rem; font-weight:700; color:${barColor}; line-height:1;">${per}%</div> return dateA - dateB;
<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> .slice(0, 10);
</div>
</div>`;
});
// 2. UI 렌더링
container.innerHTML = ` container.innerHTML = `
<div class="view-container"> <div class="view-container">
<h3 class="dashboard-section-title">자산 사용현황 요약</h3> <div class="dashboard-header-stats" style="display: grid; grid-template-columns: repeat(3, 1fr); gap: 1.5rem; margin-bottom: 2rem;">
<div class="dashboard-grid">${usageCards}</div> <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" style="margin-bottom: 2rem;">
<div class="dashboard-layout-2col">
<div class="dashboard-card"> <div class="dashboard-card">
<h4 style="margin-bottom:1rem; font-size:0.9rem; color:var(--text-muted);">자산 유형별 보유 현황</h4> <h4 class="card-title">자산 노후도 분포</h4>
<canvas id="chart-hw-types"></canvas> <div style="height: 250px;"><canvas id="chart-aging-dist"></canvas></div>
</div> </div>
<div class="dashboard-card"> <div class="dashboard-card">
<h4 style="margin-bottom:1rem; font-size:0.9rem; color:var(--text-muted);">구매법인별 자산 분포</h4> <h4 class="card-title">연도별 자산 도입 추이</h4>
<canvas id="chart-hw-corps"></canvas> <div style="height: 250px;"><canvas id="chart-purchase-trend"></canvas></div>
</div> </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> </div>
`; `;
// 3. 차트 초기화
setTimeout(() => { setTimeout(() => {
if (typeof Chart === 'undefined') return; initAgingCharts(ageGroups, yearlyCount);
const ctxType = (document.getElementById('chart-hw-types') as HTMLCanvasElement)?.getContext('2d');
const ctxCorp = (document.getElementById('chart-hw-corps') as HTMLCanvasElement)?.getContext('2d'); // 행 클릭 이벤트 바인딩
if (ctxType) { container.querySelectorAll('.clickable-row').forEach(row => {
const chart = new Chart(ctxType, { row.addEventListener('click', () => {
type: 'doughnut', const id = row.getAttribute('data-id');
data: { labels: types, datasets: [{ data: types.map(t => state.masterData.hw.filter(a => a.type === t).length), backgroundColor: ['#1E5149', '#3b82f6', '#10b981', '#f59e0b'] }] }, const asset = allHw.find(h => h.id === id);
options: { responsive: true, maintainAspectRatio: false, plugins: { legend: { position: 'right' } } } 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); }, 100);
}
container.querySelectorAll('[data-action="idle"]').forEach(card => { function initAgingCharts(ageGroups: any, yearlyCount: Record<string, number>) {
card.addEventListener('click', () => { const agingCtx = document.getElementById('chart-aging-dist') as HTMLCanvasElement;
const t = card.getAttribute('data-type')!; if (agingCtx) {
openDashboardDetail(`[${t}] 유휴 자산 목록`, groups[t].idle); 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 isHwIdle(a: HardwareAsset) { const trendCtx = document.getElementById('chart-purchase-trend') as HTMLCanvasElement;
if (a.type === '개인PC') return !a. || a..trim() === '' || a..trim() === '-'; if (trendCtx) {
if (a.type === '스토리지') return !a._정 || a._정.trim() === '' || a._정.trim() === '-'; const years = Object.keys(yearlyCount).sort();
return !a. || a..trim() === '' || a..trim() === '-'; 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 } }
}
}
});
} }
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; }
} }