feat: 대시보드 및 모달 컴포넌트 최적화, 클라우드 자산 뷰 추가

This commit is contained in:
2026-04-21 09:11:56 +09:00
parent c5d7f4cf67
commit 1ace678c09
10 changed files with 769 additions and 39 deletions

View File

@@ -1,6 +1,6 @@
import { state } from '../../core/state';
import { SoftwareAsset } from '../../core/excelHandler';
import { openSwDashboardDetail, openSwUsageDetail } from '../../components/Modal/DashboardDetailModal';
import { openSwDashboardDetail, openSwUsageDetail, openCloudDashboardDetail } from '../../components/Modal/DashboardDetailModal';
import { normalizeDate } from '../../core/utils';
declare var Chart: any;
@@ -8,8 +8,13 @@ declare var Chart: any;
export function renderSwDashboard(container: HTMLElement) {
let subQty = 0, subUsed = 0, subExp = 0, subTotal = 0;
let permQty = 0, permUsed = 0, permExp = 0, permTotal = 0;
let thisMonthCloudCost = 0;
let lastMonthCloudCost = 0;
let cloudExp = 0;
const currentYear = new Date().getFullYear().toString();
const currentYear = new Date().getFullYear();
const corps = ['한맥', '삼안', '바론'];
const categories = ['업무공통', '개발S/W', '디자인', '설계S/W'];
@@ -17,6 +22,8 @@ export function renderSwDashboard(container: HTMLElement) {
const costByCat: Record<string, number> = {};
categories.forEach(c => costByCat[c] = 0);
const today = new Date();
state.masterData.sw.forEach(sw => {
const assigned = state.masterData.swUsers.filter(u => u.swId === sw.id).length;
const qty = typeof sw. === 'number' ? sw.수량 : parseInt(sw.||'0', 10);
@@ -26,12 +33,28 @@ export function renderSwDashboard(container: HTMLElement) {
if (sw.type === '구독SW') {
subQty += qty; subUsed += assigned; subTotal++;
if (isSWExpiring(sw)) subExp++;
} else {
} else if (sw.type === '영구SW') {
permQty += qty; permUsed += assigned; permTotal++;
if (isSWExpiring(sw)) permExp++;
} else if (sw.type === '클라우드') {
if (sw.) {
thisMonthCloudCost += parseInt(String(sw.).replace(/,/g, ''), 10) || 0;
}
if (sw.) {
const payDay = parseInt(sw., 10);
if (!isNaN(payDay)) {
const nextPayMs = new Date(today.getFullYear(), today.getMonth(), payDay).getTime();
let diff = (nextPayMs - today.getTime()) / (1000 * 60 * 60 * 24);
if (diff < 0) {
const nextMonthMs = new Date(today.getFullYear(), today.getMonth() + 1, payDay).getTime();
diff = (nextMonthMs - today.getTime()) / (1000 * 60 * 60 * 24);
}
if (diff <= 14) cloudExp++;
}
}
}
if (sw. && sw..startsWith(currentYear)) {
if (sw. && sw..startsWith(String(currentYear))) {
if (costByCorp[sw.] !== undefined) costByCorp[sw.] += price;
if (sw. && costByCat[sw.] !== undefined) costByCat[sw.] += price;
}
@@ -41,10 +64,41 @@ export function renderSwDashboard(container: HTMLElement) {
const permPer = permQty > 0 ? Math.round((permUsed/permQty)*100) : 0;
const subExpPer = subTotal > 0 ? Math.round((subExp/subTotal)*100) : 0;
const permExpPer = permTotal > 0 ? Math.round((permExp/permTotal)*100) : 0;
const cloudExpTotal = state.masterData.sw.filter(s => s.type === '클라우드').length;
const cloudExpPer = cloudExpTotal > 0 ? Math.round((cloudExp/cloudExpTotal)*100) : 0;
// Cloud trend & Last month cost logic
const cloudCostTrend = [0, 0, 0, 0];
const trendLabels: string[] = [];
for(let i=3; i>=0; i--) {
const d = new Date();
d.setMonth(d.getMonth() - i);
trendLabels.push(`${d.getMonth()+1}`);
}
if (state.masterData.logs) {
state.masterData.logs.forEach(log => {
const match = log.details.match(/[^\d]*([\d,]+)/);
if (match && (log.details.includes('청구액') || log.details.includes('비용'))) {
const cost = parseInt(match[1].replace(/,/g, ''), 10);
const logDate = new Date(log.date);
const monthDiff = (today.getFullYear() - logDate.getFullYear())*12 + (today.getMonth() - logDate.getMonth());
if (monthDiff === 1) lastMonthCloudCost += cost;
if (monthDiff >= 0 && monthDiff < 4) cloudCostTrend[3 - monthDiff] += cost;
}
});
}
const costDiff = thisMonthCloudCost - lastMonthCloudCost;
const costDiffText = lastMonthCloudCost > 0
? `${costDiff >= 0 ? '+' : ''}${((costDiff/lastMonthCloudCost)*100).toFixed(1)}%`
: 'New';
container.innerHTML = `
<div class="view-container">
<h3 class="dashboard-section-title">소프트웨어 라이선스 현황</h3>
<div class="dashboard-layout-2col" style="margin-bottom: 1.5rem;">
<div class="dashboard-card" data-action="sub-usage" style="cursor:pointer; min-height:auto;">
<span style="font-size:1rem; font-weight:700; color:var(--text-main);">구독 소프트웨어 사용율</span>
@@ -67,46 +121,83 @@ export function renderSwDashboard(container: HTMLElement) {
<div class="dashboard-layout-2col" style="margin-bottom: 1.5rem;">
<div class="dashboard-card" data-action="sub-exp" style="flex-direction:row; justify-content:space-between; align-items:center; cursor:pointer; min-height:auto;">
<div style="flex:1;">
<span style="font-size:1rem; font-weight:700; color:var(--text-main);">구독 SW 만료 예정 (30일 이내)</span>
<span style="font-size:1rem; font-weight:700; color:var(--text-main);">구독 SW 만료 예정<br><span style="font-size:0.8rem;font-weight:400;color:var(--text-muted);">(30일 이내)</span></span>
<div style="font-size: 1.5rem; font-weight:700; color:${subExp > 0 ? 'var(--dash-danger)' : 'var(--text-main)'}; margin-top:0.5rem;">${subExp}개 제품</div>
</div>
<div style="width: 60px; height: 60px; border-radius: 50%; background: conic-gradient(var(--dash-danger) ${subExpPer}%, var(--border-color) 0); display:flex; justify-content:center; align-items:center;">
<div style="width: 48px; height: 48px; border-radius: 50%; background: var(--white); display:flex; justify-content:center; align-items:center;">
<span style="font-size: 0.875rem; color:var(--text-muted); font-weight:600;">${subExpPer}%</span>
<div style="width: 50px; height: 50px; border-radius: 50%; background: conic-gradient(var(--dash-danger) ${subExpPer}%, var(--border-color) 0); display:flex; justify-content:center; align-items:center;">
<div style="width: 40px; height: 40px; border-radius: 50%; background: var(--white); display:flex; justify-content:center; align-items:center;">
<span style="font-size: 0.75rem; color:var(--text-muted); font-weight:600;">${subExpPer}%</span>
</div>
</div>
</div>
<div class="dashboard-card" data-action="perm-exp" style="flex-direction:row; justify-content:space-between; align-items:center; cursor:pointer; min-height:auto;">
<div style="flex:1;">
<span style="font-size:1rem; font-weight:700; color:var(--text-main);">유지보수 만료 예정 (30일 이내)</span>
<span style="font-size:1rem; font-weight:700; color:var(--text-main);">유지보수 만료 예정<br><span style="font-size:0.8rem;font-weight:400;color:var(--text-muted);">(30일 이내)</span></span>
<div style="font-size: 1.5rem; font-weight:700; color:${permExp > 0 ? 'var(--dash-danger)' : 'var(--text-main)'}; margin-top:0.5rem;">${permExp}개 제품</div>
</div>
<div style="width: 60px; height: 60px; border-radius: 50%; background: conic-gradient(var(--dash-danger) ${permExpPer}%, var(--border-color) 0); display:flex; justify-content:center; align-items:center;">
<div style="width: 48px; height: 48px; border-radius: 50%; background: var(--white); display:flex; justify-content:center; align-items:center;">
<span style="font-size: 0.875rem; color:var(--text-muted); font-weight:600;">${permExpPer}%</span>
<div style="width: 50px; height: 50px; border-radius: 50%; background: conic-gradient(var(--dash-danger) ${permExpPer}%, var(--border-color) 0); display:flex; justify-content:center; align-items:center;">
<div style="width: 40px; height: 40px; border-radius: 50%; background: var(--white); display:flex; justify-content:center; align-items:center;">
<span style="font-size: 0.75rem; color:var(--text-muted); font-weight:600;">${permExpPer}%</span>
</div>
</div>
</div>
</div>
<h3 class="dashboard-section-title">${currentYear}년 도입 비용 분석</h3>
<div class="dashboard-layout-2col">
<div class="dashboard-card">
<h4 style="margin-bottom:1rem; font-size:0.9rem; color:var(--text-muted);">법인별 도입 금액 (원)</h4>
<!-- 클라우드 전월/당월 통합 및 결제 임박 (1:1 비율) -->
<div class="dashboard-layout-2col" style="margin-bottom: 2.5rem;">
<div class="dashboard-card" data-action="cloud-billing" style="flex-direction:row; cursor:pointer; min-height:auto; align-items:center; gap: 1.5rem;">
<div style="flex:1;">
<span style="font-size:1rem; font-weight:700; color:var(--text-main);">☁️ 클라우드 청구 현황 (통합)</span>
<div style="margin-top: 0.5rem; display:flex; align-items:baseline; gap: 0.75rem;">
<span style="font-size: 2rem; font-weight:800; color:var(--primary-color);">₩ ${thisMonthCloudCost.toLocaleString()}</span>
<span style="font-size: 0.85rem; font-weight:600; color:${costDiff >= 0 ? 'var(--dash-danger)' : 'var(--dash-primary)'};">
${costDiff >= 0 ? '▲' : '▼'} ${costDiffText}
</span>
</div>
<div style="font-size: 0.8125rem; color:var(--text-muted); margin-top:0.25rem;">전월 실적: ₩ ${lastMonthCloudCost.toLocaleString()}</div>
</div>
<div style="width: 50px; height: 50px; border-radius: 50%; background: conic-gradient(var(--primary-color) 100%, var(--border-color) 0); display:flex; justify-content:center; align-items:center; opacity:0.8;">
<div style="width: 40px; height: 40px; border-radius: 50%; background: var(--white); display:flex; justify-content:center; align-items:center;">
<span style="font-size: 0.75rem; color:var(--text-muted); font-weight:600;">₩</span>
</div>
</div>
</div>
<div class="dashboard-card" data-action="cloud-exp" style="flex-direction:row; justify-content:space-between; align-items:center; cursor:pointer; min-height:auto;">
<div style="flex:1;">
<span style="font-size:1rem; font-weight:700; color:var(--text-main);">클라우드 결제 임박<br><span style="font-size:0.8rem;font-weight:400;color:var(--text-muted);">(14일 이내)</span></span>
<div style="font-size: 1.5rem; font-weight:700; color:${cloudExp > 0 ? 'var(--dash-danger)' : 'var(--text-main)'}; margin-top:0.5rem;">${cloudExp}건 청구</div>
</div>
<div style="width: 50px; height: 50px; border-radius: 50%; background: conic-gradient(var(--dash-danger) ${cloudExpPer}%, var(--border-color) 0); display:flex; justify-content:center; align-items:center;">
<div style="width: 40px; height: 40px; border-radius: 50%; background: var(--white); display:flex; justify-content:center; align-items:center;">
<span style="font-size: 0.75rem; color:var(--text-muted); font-weight:600;">${cloudExpPer}%</span>
</div>
</div>
</div>
</div>
<h3 class="dashboard-section-title">비용 분석 현황</h3>
<div style="display:grid; grid-template-columns: repeat(3, 1fr); gap: 1rem; margin-bottom: 2rem;">
<div class="dashboard-card" style="grid-column: span 1;">
<h4 style="margin-bottom:1rem; font-size:0.9rem; color:var(--text-muted);">${currentYear}년 법인별 도입 금액 (원)</h4>
<canvas id="chart-sw-corp"></canvas>
</div>
<div class="dashboard-card">
<h4 style="margin-bottom:1rem; font-size:0.9rem; color:var(--text-muted);">분야별 도입 금액 (원)</h4>
<div class="dashboard-card" style="grid-column: span 1;">
<h4 style="margin-bottom:1rem; font-size:0.9rem; color:var(--text-muted);">${currentYear}분야별 도입 금액 (원)</h4>
<canvas id="chart-sw-cat"></canvas>
</div>
<div class="dashboard-card" style="grid-column: span 1;">
<h4 style="margin-bottom:1rem; font-size:0.9rem; color:var(--text-muted);">직전 4개월 클라우드 결제 추이 (원)</h4>
<canvas id="chart-cloud-trend"></canvas>
</div>
</div>
</div>
`;
setTimeout(() => {
if (typeof Chart === 'undefined') return;
const ctxCorp = (document.getElementById('chart-sw-corp') as HTMLCanvasElement)?.getContext('2d');
const ctxCat = (document.getElementById('chart-sw-cat') as HTMLCanvasElement)?.getContext('2d');
if (ctxCorp) {
const chart = new Chart(ctxCorp, {
type: 'bar',
@@ -115,6 +206,33 @@ export function renderSwDashboard(container: HTMLElement) {
});
state.activeCharts.push(chart);
}
const ctxTrend = (document.getElementById('chart-cloud-trend') as HTMLCanvasElement)?.getContext('2d');
if (ctxTrend) {
const chart = new Chart(ctxTrend, {
type: 'line',
data: {
labels: trendLabels,
datasets: [{
data: cloudCostTrend,
borderColor: '#6366f1',
backgroundColor: 'rgba(99, 102, 241, 0.05)',
borderWidth: 2,
fill: true,
tension: 0.4
}]
},
options: {
responsive: true,
maintainAspectRatio: false,
plugins: { legend: { display: false } },
scales: { y: { beginAtZero: true } }
}
});
state.activeCharts.push(chart);
}
const ctxCat = (document.getElementById('chart-sw-cat') as HTMLCanvasElement)?.getContext('2d');
if (ctxCat) {
const chart = new Chart(ctxCat, {
type: 'bar',
@@ -129,6 +247,23 @@ export function renderSwDashboard(container: HTMLElement) {
container.querySelector('[data-action="perm-usage"]')?.addEventListener('click', () => openSwUsageDetail('영구 소프트웨어 사용 목록', state.masterData.sw.filter(sw => sw.type === '영구SW')));
container.querySelector('[data-action="sub-exp"]')?.addEventListener('click', () => openSwDashboardDetail('구독 SW 만료 예정 목록', state.masterData.sw.filter(sw => sw.type === '구독SW' && isSWExpiring(sw))));
container.querySelector('[data-action="perm-exp"]')?.addEventListener('click', () => openSwDashboardDetail('유지보수 만료 예정 목록', state.masterData.sw.filter(sw => sw.type === '영구SW' && isSWExpiring(sw))));
container.querySelector('[data-action="cloud-billing"]')?.addEventListener('click', () => openCloudDashboardDetail('클라우드 청구 현황 (전체)', state.masterData.sw.filter(sw => sw.type === '클라우드')));
container.querySelector('[data-action="cloud-exp"]')?.addEventListener('click', () => {
const expiringClouds = state.masterData.sw.filter(sw => {
if (sw.type !== '클라우드' || !sw.) return false;
const payDay = parseInt(sw., 10);
if (isNaN(payDay)) return false;
const nextPayMs = new Date(today.getFullYear(), today.getMonth(), payDay).getTime();
let diff = (nextPayMs - today.getTime()) / (1000 * 60 * 60 * 24);
if (diff < 0) {
const nextMonthMs = new Date(today.getFullYear(), today.getMonth() + 1, payDay).getTime();
diff = (nextMonthMs - today.getTime()) / (1000 * 60 * 60 * 24);
}
return diff <= 14;
});
openCloudDashboardDetail('결제 임박 클라우드 목록 (14일 이내)', expiringClouds);
});
}
function isSWExpiring(sw: SoftwareAsset) {

View File

@@ -0,0 +1,114 @@
import { state } from '../../core/state';
import { openCloudModal } from '../../components/Modal/CloudModal';
import { createIcons, Cloud, CreditCard, DollarSign } from 'lucide';
export function renderCloudList(container: HTMLElement) {
const fullList = state.masterData.sw.filter(a => a.type === '클라우드');
const filterBar = document.createElement('div');
filterBar.className = 'search-bar';
filterBar.innerHTML = `
<div class="search-item flex-1">
<label>통합 검색 (제품명/부서/계정명)</label>
<input type="text" id="filter-keyword" placeholder="검색어를 입력하세요..." autocomplete="off">
</div>
<div class="search-item">
<label>결제수단</label>
<select id="filter-payment">
<option value="">전체 결제수단</option>
<option value="법인카드">법인카드</option>
<option value="인보이스">인보이스 (월별송금)</option>
</select>
</div>
<button id="btn-reset-filters" class="btn btn-outline btn-reset">
<i data-lucide="refresh-ccw"></i> 필터 초기화
</button>
`;
container.appendChild(filterBar);
const tableWrapper = document.createElement('div');
tableWrapper.className = 'table-container';
const table = document.createElement('table');
table.innerHTML = `
<thead>
<tr>
<th style="text-align:center;">No.</th>
<th style="text-align:center;">플랫폼명</th>
<th style="text-align:center;">법인</th>
<th style="text-align:center;">담당부서</th>
<th style="text-align:center;">진행 프로젝트(사용용도)</th>
<th style="text-align:center;">계정명(관리자)</th>
<th style="text-align:center;">결제수단</th>
<th style="text-align:center;">결제일</th>
<th style="text-align:center;">당월 청구액</th>
<th style="text-align:center;">비고</th>
</tr>
</thead>
<tbody id="cloud-tbody"></tbody>
`;
tableWrapper.appendChild(table);
container.appendChild(tableWrapper);
const tbody = table.querySelector('tbody')!;
const updateTable = () => {
const keywordInput = document.getElementById('filter-keyword') as HTMLInputElement;
const paymentSelect = document.getElementById('filter-payment') as HTMLSelectElement;
const keyword = keywordInput ? keywordInput.value.toLowerCase().trim() : '';
const payment = paymentSelect ? paymentSelect.value : '';
const filtered = fullList.filter(asset => {
const kwMatch = !keyword ||
(asset. || '').toLowerCase().includes(keyword) ||
(asset. || '').toLowerCase().includes(keyword) ||
(asset. || '').toLowerCase().includes(keyword);
const payMatch = !payment || asset. === payment;
return kwMatch && payMatch;
});
tbody.innerHTML = '';
if (filtered.length === 0) {
tbody.innerHTML = '<tr><td colspan="10" style="text-align:center; padding: 3rem; color: var(--text-muted);">등록된 클라우드 서비스가 없습니다.</td></tr>';
return;
}
filtered.forEach((asset, idx) => {
const tr = document.createElement('tr');
tr.style.cursor = 'pointer';
const paymentBadge = asset. === '법인카드'
? '<span style="color:#6366f1; font-weight:600;"><i data-lucide="credit-card" style="width:14px; height:14px; vertical-align:middle; margin-right:4px;"></i>법인카드 (' + (asset.||'미상') + ')</span>'
: (asset. === '인보이스'
? '<span style="color:#10b981; font-weight:600;"><i data-lucide="dollar-sign" style="width:14px; height:14px; vertical-align:middle; margin-right:4px;"></i>인보이스</span>'
: '<span style="color:var(--text-muted)">미설정</span>');
tr.innerHTML = `
<td style="text-align:center;">${idx+1}</td>
<td style="font-weight:600; color:var(--primary-color)"><i data-lucide="cloud" style="width:14px; height:14px; vertical-align:middle; margin-right:4px;"></i> ${asset.||'미지정'}</td>
<td style="text-align:center;">${asset.||''}</td>
<td style="text-align:center;">${asset.||''}</td>
<td>${asset.||''}</td>
<td>${asset.||''}</td>
<td style="text-align:center;">${paymentBadge}</td>
<td style="text-align:center;">${asset. ? asset. + '일' : ''}</td>
<td style="text-align:right; font-weight:600;">₩ ${asset. ? Number(asset.).toLocaleString() : '0'}</td>
<td>${asset.||''}</td>
`;
tr.addEventListener('click', () => openCloudModal(asset));
tbody.appendChild(tr);
});
createIcons({ icons: { Cloud, CreditCard, DollarSign } });
};
document.getElementById('filter-keyword')?.addEventListener('input', updateTable);
document.getElementById('filter-payment')?.addEventListener('change', updateTable);
document.getElementById('btn-reset-filters')?.addEventListener('click', () => {
if (document.getElementById('filter-keyword')) (document.getElementById('filter-keyword') as HTMLInputElement).value = '';
if (document.getElementById('filter-payment')) (document.getElementById('filter-payment') as HTMLSelectElement).value = '';
updateTable();
});
updateTable();
}