feat: 소프트웨어 대시보드 시각화 개선 및 금액 열 추가 (#4)
This commit is contained in:
@@ -26,7 +26,7 @@ export function generateDummyData(): MasterAssetData {
|
|||||||
|
|
||||||
// 1. 개인PC 50개
|
// 1. 개인PC 50개
|
||||||
for (let i = 1; i <= 50; i++) {
|
for (let i = 1; i <= 50; i++) {
|
||||||
const purchaseYear = Math.floor(Math.random() * 8) + 2017; // 2017~2024
|
const purchaseYear = Math.floor(Math.random() * 10) + 2017; // 2017~2026
|
||||||
hw.push({
|
hw.push({
|
||||||
id: Math.random().toString(36).substring(2, 9),
|
id: Math.random().toString(36).substring(2, 9),
|
||||||
type: '개인PC',
|
type: '개인PC',
|
||||||
@@ -43,7 +43,7 @@ export function generateDummyData(): MasterAssetData {
|
|||||||
HDD1: rand(['-', '1TB', '2TB']),
|
HDD1: rand(['-', '1TB', '2TB']),
|
||||||
HDD2: '',
|
HDD2: '',
|
||||||
구매일: randDate(purchaseYear, purchaseYear),
|
구매일: randDate(purchaseYear, purchaseYear),
|
||||||
금액: String(Math.floor(Math.random()*100 + 50) * 10000).replace(/\\B(?=(\\d{3})+(?!\\d))/g, ','),
|
금액: String(Math.floor(Math.random()*100 + 50) * 10000).replace(/\B(?=(\d{3})+(?!\d))/g, ','),
|
||||||
납품업체: rand(['다나와', '컴퓨존', '오피스디포']),
|
납품업체: rand(['다나와', '컴퓨존', '오피스디포']),
|
||||||
품의서명: '',
|
품의서명: '',
|
||||||
관리자: '', IP주소: '', MACaddress: '', OS: '', HW사양: ''
|
관리자: '', IP주소: '', MACaddress: '', OS: '', HW사양: ''
|
||||||
@@ -52,7 +52,7 @@ export function generateDummyData(): MasterAssetData {
|
|||||||
|
|
||||||
// 2. 서버 20개
|
// 2. 서버 20개
|
||||||
for (let i = 1; i <= 20; i++) {
|
for (let i = 1; i <= 20; i++) {
|
||||||
const purchaseYear = Math.floor(Math.random() * 8) + 2017; // 2017~2024
|
const purchaseYear = Math.floor(Math.random() * 10) + 2017; // 2017~2026
|
||||||
hw.push({
|
hw.push({
|
||||||
id: Math.random().toString(36).substring(2, 9),
|
id: Math.random().toString(36).substring(2, 9),
|
||||||
type: '서버',
|
type: '서버',
|
||||||
@@ -74,7 +74,7 @@ export function generateDummyData(): MasterAssetData {
|
|||||||
|
|
||||||
// 3. 스토리지 20개
|
// 3. 스토리지 20개
|
||||||
for (let i = 1; i <= 20; i++) {
|
for (let i = 1; i <= 20; i++) {
|
||||||
const purchaseYear = Math.floor(Math.random() * 8) + 2017; // 2017~2024
|
const purchaseYear = Math.floor(Math.random() * 10) + 2017; // 2017~2026
|
||||||
hw.push({
|
hw.push({
|
||||||
id: Math.random().toString(36).substring(2, 9),
|
id: Math.random().toString(36).substring(2, 9),
|
||||||
type: '스토리지',
|
type: '스토리지',
|
||||||
@@ -105,7 +105,7 @@ export function generateDummyData(): MasterAssetData {
|
|||||||
];
|
];
|
||||||
equips.forEach((eq) => {
|
equips.forEach((eq) => {
|
||||||
for (let i = 1; i <= 5; i++) {
|
for (let i = 1; i <= 5; i++) {
|
||||||
const purchaseYear = Math.floor(Math.random() * 6) + 2019; // 2019~2024
|
const purchaseYear = Math.floor(Math.random() * 8) + 2019; // 2019~2026
|
||||||
hw.push({
|
hw.push({
|
||||||
id: Math.random().toString(36).substring(2, 9),
|
id: Math.random().toString(36).substring(2, 9),
|
||||||
type: '전산비품',
|
type: '전산비품',
|
||||||
@@ -127,6 +127,7 @@ export function generateDummyData(): MasterAssetData {
|
|||||||
// 5. 구독형 S/W 40개
|
// 5. 구독형 S/W 40개
|
||||||
for (let i = 1; i <= 40; i++) {
|
for (let i = 1; i <= 40; i++) {
|
||||||
const swId = Math.random().toString(36).substring(2, 9);
|
const swId = Math.random().toString(36).substring(2, 9);
|
||||||
|
const purchaseYear = Math.random() < 0.3 ? 2026 : 2024;
|
||||||
|
|
||||||
let isExpiring = Math.random() < 0.25;
|
let isExpiring = Math.random() < 0.25;
|
||||||
let endDt = new Date();
|
let endDt = new Date();
|
||||||
@@ -144,14 +145,15 @@ export function generateDummyData(): MasterAssetData {
|
|||||||
법인: rand(corps),
|
법인: rand(corps),
|
||||||
부서: rand(depts),
|
부서: rand(depts),
|
||||||
제품명: rand(['Adobe CC All Apps', 'Microsoft 365', 'Slack Pro', 'Notion Team']),
|
제품명: rand(['Adobe CC All Apps', 'Microsoft 365', 'Slack Pro', 'Notion Team']),
|
||||||
구매일: '2024-01-01',
|
구매일: `${purchaseYear}-01-01`,
|
||||||
구독일: `2024.01.01 ~ ${endStr}`,
|
구독일: `${purchaseYear}.01.01 ~ ${endStr}`,
|
||||||
금액: '600,000',
|
금액: String(Math.floor(Math.random() * 100 + 10) * 10000).replace(/\B(?=(\d{3})+(?!\d))/g, ','),
|
||||||
수량: Math.floor(Math.random() * 5) + 3, // 3~7
|
수량: Math.floor(Math.random() * 5) + 3, // 3~7
|
||||||
계정명: `user${i}@hm.com`,
|
계정명: `user${i}@hm.com`,
|
||||||
납품업체: '총판',
|
납품업체: '총판',
|
||||||
비고: '연간구독'
|
비고: '연간구독'
|
||||||
});
|
});
|
||||||
|
// ... rest unchanged
|
||||||
const assignCount = Math.floor(Math.random() * 2) + 1;
|
const assignCount = Math.floor(Math.random() * 2) + 1;
|
||||||
for (let j=0; j<assignCount; j++) {
|
for (let j=0; j<assignCount; j++) {
|
||||||
swUsers.push({
|
swUsers.push({
|
||||||
|
|||||||
@@ -79,18 +79,18 @@ function renderSwTable(table: HTMLTableElement, container: HTMLElement, mainCont
|
|||||||
const isSub = state.activeSubTab === '구독SW';
|
const isSub = state.activeSubTab === '구독SW';
|
||||||
|
|
||||||
table.classList.add('sw-table');
|
table.classList.add('sw-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>${isSub ? '<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="dynamic-tbody"></tbody>`;
|
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>${isSub ? '<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="dynamic-tbody"></tbody>`;
|
||||||
container.appendChild(table);
|
container.appendChild(table);
|
||||||
mainContent.appendChild(container);
|
mainContent.appendChild(container);
|
||||||
const tbody = document.getElementById('dynamic-tbody')!;
|
const tbody = document.getElementById('dynamic-tbody')!;
|
||||||
if (list.length === 0) { tbody.innerHTML = `<tr><td colspan="${isSub ? 10 : 9}" style="text-align:center;">정보가 없습니다.</td></tr>`; return; }
|
if (list.length === 0) { tbody.innerHTML = `<tr><td colspan="${isSub ? 11 : 10}" style="text-align:center;">정보가 없습니다.</td></tr>`; return; }
|
||||||
|
|
||||||
list.forEach((asset, idx) => {
|
list.forEach((asset, idx) => {
|
||||||
const assigned = state.masterData.swUsers.filter(u => u.swId === asset.id).length;
|
const assigned = state.masterData.swUsers.filter(u => u.swId === asset.id).length;
|
||||||
const avail = (typeof asset.수량 === 'number' ? asset.수량 : parseInt(asset.수량||'0', 10)) - assigned;
|
const avail = (typeof asset.수량 === 'number' ? asset.수량 : parseInt(asset.수량||'0', 10)) - assigned;
|
||||||
const tr = document.createElement('tr');
|
const tr = document.createElement('tr');
|
||||||
tr.style.cursor = 'pointer';
|
tr.style.cursor = 'pointer';
|
||||||
tr.innerHTML = `<td>${idx+1}</td><td>${asset.분야||''}</td><td>${asset.법인}</td><td>${asset.부서||''}</td><td>${asset.제품명}</td><td>${asset.구매일||''}</td>${isSub ? `<td>${asset.구독일||''}</td>` : ''}<td>${asset.수량}</td><td><strong style="color: ${avail > 0 ? 'var(--primary-color)' : 'var(--danger)'}">${avail}</strong></td><td style="display:flex; justify-content:center; align-items:center; gap:0.5rem;"><button type="button" class="btn-icon btn-edit" title="수정" style="color: var(--text-muted);"><i data-lucide="edit-2" style="width:18px; height:18px;"></i></button><button type="button" class="btn-icon btn-users" title="사용자 관리" style="color: var(--primary-color);"><i data-lucide="users" style="width:18px; height:18px;"></i></button></td>`;
|
tr.innerHTML = `<td>${idx+1}</td><td>${asset.분야||''}</td><td>${asset.법인}</td><td>${asset.부서||''}</td><td>${asset.제품명}</td><td>${asset.구매일||''}</td>${isSub ? `<td>${asset.구독일||''}</td>` : ''}<td>${asset.금액||'0'}</td><td>${asset.수량}</td><td><strong style="color: ${avail > 0 ? 'var(--primary-color)' : 'var(--danger)'}">${avail}</strong></td><td style="display:flex; justify-content:center; align-items:center; gap:0.5rem;"><button type="button" class="btn-icon btn-edit" title="수정" style="color: var(--text-muted);"><i data-lucide="edit-2" style="width:18px; height:18px;"></i></button><button type="button" class="btn-icon btn-users" title="사용자 관리" style="color: var(--primary-color);"><i data-lucide="users" style="width:18px; height:18px;"></i></button></td>`;
|
||||||
tr.addEventListener('click', (e) => { if (!(e.target as HTMLElement).closest('button')) openSwModal(asset); });
|
tr.addEventListener('click', (e) => { if (!(e.target as HTMLElement).closest('button')) openSwModal(asset); });
|
||||||
tr.querySelector('.btn-edit')?.addEventListener('click', () => openSwModal(asset));
|
tr.querySelector('.btn-edit')?.addEventListener('click', () => openSwModal(asset));
|
||||||
tr.querySelector('.btn-users')?.addEventListener('click', () => openSwUserModal(asset));
|
tr.querySelector('.btn-users')?.addEventListener('click', () => openSwUserModal(asset));
|
||||||
|
|||||||
@@ -1,6 +1,8 @@
|
|||||||
import { state } from '../state';
|
import { state } from '../state';
|
||||||
import { HardwareAsset, SoftwareAsset } from '../excelHandler';
|
import { HardwareAsset, SoftwareAsset } from '../excelHandler';
|
||||||
|
|
||||||
|
declare var Chart: any;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 대시보드 렌더링 메인 함수
|
* 대시보드 렌더링 메인 함수
|
||||||
*/
|
*/
|
||||||
@@ -8,7 +10,9 @@ export function renderDashboard(mainContent: HTMLElement) {
|
|||||||
mainContent.innerHTML = '';
|
mainContent.innerHTML = '';
|
||||||
|
|
||||||
// 기존 차트 리소스 해제
|
// 기존 차트 리소스 해제
|
||||||
state.activeCharts.forEach(c => c.destroy());
|
state.activeCharts.forEach(c => {
|
||||||
|
if (c && typeof c.destroy === 'function') c.destroy();
|
||||||
|
});
|
||||||
state.activeCharts = [];
|
state.activeCharts = [];
|
||||||
|
|
||||||
if (state.activeCategory === 'hw') {
|
if (state.activeCategory === 'hw') {
|
||||||
@@ -120,9 +124,20 @@ function renderSwDashboard(container: HTMLElement) {
|
|||||||
let subQty = 0, subUsed = 0, subExp = 0, subTotal = 0;
|
let subQty = 0, subUsed = 0, subExp = 0, subTotal = 0;
|
||||||
let permQty = 0, permUsed = 0, permExp = 0, permTotal = 0;
|
let permQty = 0, permUsed = 0, permExp = 0, permTotal = 0;
|
||||||
|
|
||||||
|
const currentYear = new Date().getFullYear().toString();
|
||||||
|
const corps = ['한맥', '삼안', '바론'];
|
||||||
|
const categories = ['업무공통', '개발S/W', '디자인', '설계S/W'];
|
||||||
|
|
||||||
|
const costByCorp: Record<string, number> = { '한맥': 0, '삼안': 0, '바론': 0 };
|
||||||
|
const costByCat: Record<string, number> = {};
|
||||||
|
categories.forEach(c => costByCat[c] = 0);
|
||||||
|
|
||||||
state.masterData.sw.forEach(sw => {
|
state.masterData.sw.forEach(sw => {
|
||||||
const assigned = state.masterData.swUsers.filter(u => u.swId === sw.id).length;
|
const assigned = state.masterData.swUsers.filter(u => u.swId === sw.id).length;
|
||||||
const qty = typeof sw.수량 === 'number' ? sw.수량 : parseInt(sw.수량||'0', 10);
|
const qty = typeof sw.수량 === 'number' ? sw.수량 : parseInt(sw.수량||'0', 10);
|
||||||
|
const priceStr = sw.금액 ? sw.금액.replace(/,/g, '') : '0';
|
||||||
|
const price = parseInt(priceStr, 10) || 0;
|
||||||
|
|
||||||
if (sw.type === '구독SW') {
|
if (sw.type === '구독SW') {
|
||||||
subQty += qty; subUsed += assigned; subTotal++;
|
subQty += qty; subUsed += assigned; subTotal++;
|
||||||
if (isSWExpiring(sw)) subExp++;
|
if (isSWExpiring(sw)) subExp++;
|
||||||
@@ -130,6 +145,12 @@ function renderSwDashboard(container: HTMLElement) {
|
|||||||
permQty += qty; permUsed += assigned; permTotal++;
|
permQty += qty; permUsed += assigned; permTotal++;
|
||||||
if (isSWExpiring(sw)) permExp++;
|
if (isSWExpiring(sw)) permExp++;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 오늘이 속해있는 년도(2026)의 사용 금액 합계
|
||||||
|
if (sw.구매일 && sw.구매일.startsWith(currentYear)) {
|
||||||
|
if (costByCorp[sw.법인] !== undefined) costByCorp[sw.법인] += price;
|
||||||
|
if (sw.분야 && costByCat[sw.분야] !== undefined) costByCat[sw.분야] += price;
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
const subPer = subQty > 0 ? Math.round((subUsed/subQty)*100) : 0;
|
const subPer = subQty > 0 ? Math.round((subUsed/subQty)*100) : 0;
|
||||||
@@ -160,7 +181,8 @@ function renderSwDashboard(container: HTMLElement) {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="dashboard-layout-2col">
|
|
||||||
|
<div class="dashboard-layout-2col" style="margin-bottom: 1.5rem;">
|
||||||
<div class="dashboard-card" data-action="sub-exp" style="padding: 1.25rem 1.5rem; flex-direction:row; justify-content:space-between; align-items:center; cursor:pointer;">
|
<div class="dashboard-card" data-action="sub-exp" style="padding: 1.25rem 1.5rem; flex-direction:row; justify-content:space-between; align-items:center; cursor:pointer;">
|
||||||
<div style="flex:1;">
|
<div style="flex:1;">
|
||||||
<div style="display:flex; align-items:center; gap: 0.5rem; margin-bottom: 0.5rem;">
|
<div style="display:flex; align-items:center; gap: 0.5rem; margin-bottom: 0.5rem;">
|
||||||
@@ -196,8 +218,90 @@ function renderSwDashboard(container: HTMLElement) {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<h3 style="margin: 0 0 1rem 0; font-size: 1.125rem; color: var(--text-main);">${currentYear}년 소프트웨어 도입 비용</h3>
|
||||||
|
<div class="dashboard-layout-2col">
|
||||||
|
<div class="dashboard-card" style="padding: 1.5rem;">
|
||||||
|
<h4 style="margin: 0 0 1rem 0; font-size: 0.9375rem; color: var(--text-main);">법인별 도입 금액 (원)</h4>
|
||||||
|
<canvas id="chart-cost-corp" style="max-height: 250px;"></canvas>
|
||||||
|
</div>
|
||||||
|
<div class="dashboard-card" style="padding: 1.5rem;">
|
||||||
|
<h4 style="margin: 0 0 1rem 0; font-size: 0.9375rem; color: var(--text-main);">분야별 도입 금액 (원)</h4>
|
||||||
|
<canvas id="chart-cost-cat" style="max-height: 250px;"></canvas>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
`;
|
`;
|
||||||
|
|
||||||
|
// 차트 생성
|
||||||
|
setTimeout(() => {
|
||||||
|
const ctxCorp = (document.getElementById('chart-cost-corp') as HTMLCanvasElement)?.getContext('2d');
|
||||||
|
const ctxCat = (document.getElementById('chart-cost-cat') as HTMLCanvasElement)?.getContext('2d');
|
||||||
|
|
||||||
|
if (ctxCorp && typeof Chart !== 'undefined') {
|
||||||
|
const chartCorp = new Chart(ctxCorp, {
|
||||||
|
type: 'bar',
|
||||||
|
data: {
|
||||||
|
labels: corps,
|
||||||
|
datasets: [{
|
||||||
|
label: '도입 금액',
|
||||||
|
data: corps.map(c => costByCorp[c]),
|
||||||
|
backgroundColor: '#3b82f6',
|
||||||
|
borderRadius: 4,
|
||||||
|
barThickness: 20 // 막대 두께 줄임
|
||||||
|
}]
|
||||||
|
},
|
||||||
|
options: {
|
||||||
|
responsive: true,
|
||||||
|
maintainAspectRatio: false,
|
||||||
|
plugins: { legend: { display: false } },
|
||||||
|
scales: {
|
||||||
|
y: {
|
||||||
|
beginAtZero: true,
|
||||||
|
ticks: { callback: (v: any) => v.toLocaleString() },
|
||||||
|
grid: { display: false } // 가로줄 삭제
|
||||||
|
},
|
||||||
|
x: {
|
||||||
|
grid: { display: false }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
state.activeCharts.push(chartCorp);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (ctxCat && typeof Chart !== 'undefined') {
|
||||||
|
const chartCat = new Chart(ctxCat, {
|
||||||
|
type: 'bar',
|
||||||
|
data: {
|
||||||
|
labels: categories,
|
||||||
|
datasets: [{
|
||||||
|
label: '도입 금액',
|
||||||
|
data: categories.map(c => costByCat[c]),
|
||||||
|
backgroundColor: '#10b981',
|
||||||
|
borderRadius: 4,
|
||||||
|
barThickness: 20 // 막대 두께 줄임
|
||||||
|
}]
|
||||||
|
},
|
||||||
|
options: {
|
||||||
|
responsive: true,
|
||||||
|
maintainAspectRatio: false,
|
||||||
|
plugins: { legend: { display: false } },
|
||||||
|
scales: {
|
||||||
|
y: {
|
||||||
|
beginAtZero: true,
|
||||||
|
ticks: { callback: (v: any) => v.toLocaleString() },
|
||||||
|
grid: { display: false } // 가로줄 삭제
|
||||||
|
},
|
||||||
|
x: {
|
||||||
|
grid: { display: false }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
state.activeCharts.push(chartCat);
|
||||||
|
}
|
||||||
|
}, 0);
|
||||||
|
|
||||||
// 클릭 이벤트 바인딩
|
// 클릭 이벤트 바인딩
|
||||||
container.querySelector('[data-action="sub-usage"]')?.addEventListener('click', () => {
|
container.querySelector('[data-action="sub-usage"]')?.addEventListener('click', () => {
|
||||||
openSwUsageDetail('구독 소프트웨어 사용 목록', state.masterData.sw.filter(sw => sw.type === '구독SW'));
|
openSwUsageDetail('구독 소프트웨어 사용 목록', state.masterData.sw.filter(sw => sw.type === '구독SW'));
|
||||||
@@ -305,3 +409,5 @@ function openSwUsageDetail(title: string, list: SoftwareAsset[]) {
|
|||||||
});
|
});
|
||||||
modal.classList.remove('hidden');
|
modal.classList.remove('hidden');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user