diff --git a/docs/issues/issue_dashboard_and_modal_optimization.md b/docs/issues/issue_dashboard_and_modal_optimization.md new file mode 100644 index 0000000..4e383eb --- /dev/null +++ b/docs/issues/issue_dashboard_and_modal_optimization.md @@ -0,0 +1,45 @@ +# [Issue] 소프트웨어 자산 관리 체계 개편 및 클라우드(Cloud) 서비스 관리 신설 + +## 1. 개요 +기존의 단일 소프트웨어(SW) 분류 체계를 비즈니스 모델에 맞춰 **구독형, 영구형, 클라우드형**으로 삼원화하고, 특히 비용 변동이 잦은 클라우드 서비스를 독립적으로 관리할 수 있는 전용 시스템을 신설함. + +--- + +## 2. 주요 작업 내용 + +### 📂 소프트웨어 관리 프레임워크 재구조화 +- **분류 체계 개편**: 소프트웨어를 아래 세 가지 유형으로 재정의하여 관리 효율성을 높임. + 1. **구독형 (Subscription)**: 연/월 정액제로 운영되는 SW + 2. **영구형 (Perpetual)**: 구매 후 영구 소유하는 SW (유지보수 중심 관리) + 3. **클라우드형 (Cloud)**: 플랫폼 기반 종량제(AWS, Azure 등) 서비스 +- **내비게이션 통합**: 상단 탭을 유형별로 분리하여 각 자산 특성에 맞는 리스트 뷰를 제공함. + +### ☁️ 클라우드(Cloud) 서비스 관리 페이지 신설 +- **전용 리스트 뷰 (`CloudListView.ts`)**: + - 플랫폼명, 담당 부서, 프로젝트(사용용도), 결제 수단, 결제일 등 클라우드 특화 항목 중심의 테이블 구성함. + - **결제수단별 필터링 기능** (법인카드, 인보이스) 및 통합 검색 기능을 추가함. +- **클라우드 전문 모달 (`CloudModal.ts`)**: + - 클라우드 요금 및 결제 정보 입력을 위한 2분할 레이아웃 배치함. + - **업데이트 이력(History Logs)** 시스템을 도입하여 매월 변동되는 비용을 히스토리 형식으로 기록/추적 가능하게 함. + +### 📊 대시보드(Dashboard) 리팩토링 및 고도화 +- **카드 레이아웃 최적화**: 사용율, 만료 예정, 클라우드 현황(전월/당월 비교) 정보를 2열 그리드로 정돈함. +- **데이터 시각화**: + - **클라우드 결제 규모 추이**: 최근 4개월간의 비용 변동을 꺾은선 그래프로 구현함. + - **실시간 데이터 연동**: 자산 업데이트 이력(Logs)에 기록된 비용이 대시보드 차트에 실시간 합산 반영되도록 로그 분석 엔진을 구축함. +- **상세 팝업 연동**: 대시보드 요약 카드를 클릭하면 해당하는 자산의 상세 목록이 뜨는 모달 연동 기능을 추가함. + +### 🪟 UX 및 데이터 정합성 강화 +- **수정 저장 워크플로우 (Edit-to-Save)**: 실수로 인한 데이터 변경을 막기 위해 모든 상세 모달에 '조회 모드'를 기본으로 하고, [수정] 버튼 클릭 시에만 입력이 활성화되도록 제어함. +- **금액 자동 포맷팅**: 콤마 표시 오류를 해결하고 천 단위 포맷팅을 표준화함. +- **결제 임박 알림**: 각 서비스의 결제일을 계산하여 14일 이내 결제가 필요한 항목을 대시보드에서 즉시 파악할 수 있게 함. + +--- + +## 3. 향후 과제 +- 클라우드 플랫폼 간 비용 비교 통계 기능 확장 검토 +- 결제 수단(법인카드) 만료일에 기초한 알림 서비스 추가 검토 + +--- +**작업자**: Antigravity (AI Assistant) +**상태**: 완료 (2026-04-17) diff --git a/src/components/Modal/CloudModal.ts b/src/components/Modal/CloudModal.ts new file mode 100644 index 0000000..eba98e1 --- /dev/null +++ b/src/components/Modal/CloudModal.ts @@ -0,0 +1,317 @@ +import { state } from '../../core/state'; +import { SoftwareAsset } from '../../core/excelHandler'; +import { openModal } from './BaseModal'; +import { createIcons, Save, X, Edit2, RotateCcw, History, Plus } from 'lucide'; + +const CLOUD_MODAL_HTML = ` + + + +`; + +export let currentCloudAsset: SoftwareAsset | null = null; +export let isCloudEditMode = false; + +export function setCloudEditMode(edit: boolean) { + isCloudEditMode = edit; + const form = document.getElementById('cloud-asset-form') as HTMLFormElement; + const btnSave = document.getElementById('btn-save-cloud-asset') as HTMLButtonElement; + const btnRevert = document.getElementById('btn-revert-cloud-edit') as HTMLButtonElement; + const btnClose = document.getElementById('btn-close-cloud-footer') as HTMLButtonElement; + + if (edit) { + form.classList.add('is-edit-mode'); + form.classList.remove('is-view-mode'); + btnSave.textContent = '저장'; + btnRevert.classList.remove('hidden'); + btnClose.classList.add('hidden'); + Array.from(form.elements).forEach((el: any) => el.disabled = false); + } else { + form.classList.add('is-view-mode'); + form.classList.remove('is-edit-mode'); + btnSave.textContent = '수정'; + btnRevert.classList.add('hidden'); + btnClose.classList.remove('hidden'); + Array.from(form.elements).forEach((el: any) => el.disabled = true); + if (currentCloudAsset) fillCloudFormData(currentCloudAsset); + } +} + +export function fillCloudFormData(asset: SoftwareAsset) { + (document.getElementById('cloud-asset-id') as HTMLInputElement).value = asset.id; + (document.getElementById('cloud-플랫폼명') as HTMLInputElement).value = asset.플랫폼명 || ''; + (document.getElementById('cloud-법인') as HTMLSelectElement).value = asset.법인 || '한맥'; + (document.getElementById('cloud-제품명') as HTMLInputElement).value = asset.제품명 || ''; + (document.getElementById('cloud-부서') as HTMLInputElement).value = asset.부서 || ''; + (document.getElementById('cloud-계정명') as HTMLInputElement).value = asset.계정명 || ''; + (document.getElementById('cloud-결제수단') as HTMLSelectElement).value = asset.결제수단 || ''; + (document.getElementById('cloud-연결카드번호') as HTMLInputElement).value = asset.연결카드번호 || ''; + (document.getElementById('cloud-결제일') as HTMLInputElement).value = asset.결제일 || ''; + + const billing = asset.당월청구액 ? asset.당월청구액.replace(/[^0-9]/g, '') : ''; + (document.getElementById('cloud-당월청구액') as HTMLInputElement).value = billing ? Number(billing).toLocaleString() : ''; + (document.getElementById('cloud-비고') as HTMLInputElement).value = asset.비고 || ''; + + document.getElementById('btn-open-cloud-update')!.style.display = 'flex'; + renderCloudHistory(asset.id); +} + +function renderCloudHistory(assetId: string) { + const historyList = document.getElementById('cloud-history-list'); + if (!historyList) return; + if (!state.masterData.logs) state.masterData.logs = []; + + const logs = state.masterData.logs + .filter(l => l.assetId === assetId) + .sort((a, b) => new Date(b.date).getTime() - new Date(a.date).getTime()); + + if (logs.length === 0) { + historyList.innerHTML = '
업데이트 내역이 없습니다.
'; + return; + } + + historyList.innerHTML = logs.map(log => ` +
+
${log.date}
+
작업자: ${log.user}
+
${log.details.replace(/\n/g, '
')}
+
+ `).join(''); + createIcons({ icons: { X, History, Plus } }); +} + +export function initCloudModal(renderContent: () => void, closeModals: () => void) { + if (!document.getElementById('cloud-asset-modal')) { + document.body.insertAdjacentHTML('beforeend', CLOUD_MODAL_HTML); + } + + const form = document.getElementById('cloud-asset-form') as HTMLFormElement; + const btnRevert = document.getElementById('btn-revert-cloud-edit'); + const btnSave = document.getElementById('btn-save-cloud-asset'); + const btnDelete = document.getElementById('btn-delete-cloud-asset'); + + document.getElementById('btn-close-cloud-modal')?.addEventListener('click', closeModals); + document.getElementById('btn-close-cloud-footer')?.addEventListener('click', closeModals); + + btnRevert?.addEventListener('click', (e) => { + e.preventDefault(); + setCloudEditMode(false); + }); + + btnSave?.addEventListener('click', (e) => { + e.preventDefault(); + if (!isCloudEditMode) { + setCloudEditMode(true); + return; + } + if (!form.checkValidity()) { form.reportValidity(); return; } + + const id = (document.getElementById('cloud-asset-id') as HTMLInputElement).value; + const billingRaw = (document.getElementById('cloud-당월청구액') as HTMLInputElement).value.replace(/[^0-9]/g, ''); + + const newAsset: SoftwareAsset = { + id: id || Math.random().toString(36).substring(2, 9), + type: '클라우드', + 플랫폼명: (document.getElementById('cloud-플랫폼명') as HTMLInputElement).value, + 법인: (document.getElementById('cloud-법인') as HTMLSelectElement).value, + 제품명: (document.getElementById('cloud-제품명') as HTMLInputElement).value, + 부서: (document.getElementById('cloud-부서') as HTMLInputElement).value, + 계정명: (document.getElementById('cloud-계정명') as HTMLInputElement).value, + 결제수단: (document.getElementById('cloud-결제수단') as HTMLSelectElement).value, + 연결카드번호: (document.getElementById('cloud-연결카드번호') as HTMLInputElement).value, + 결제일: (document.getElementById('cloud-결제일') as HTMLInputElement).value, + 당월청구액: billingRaw, + 비고: (document.getElementById('cloud-비고') as HTMLInputElement).value, + 구매일: '', 금액: '', 수량: 1, 납품업체: '' + }; + + if (id) { + const idx = state.masterData.sw.findIndex(a => a.id === id); + if (idx !== -1) state.masterData.sw[idx] = newAsset; + } else { + state.masterData.sw.push(newAsset); + const now = new Date(); + state.masterData.logs = state.masterData.logs || []; + state.masterData.logs.push({ + id: Math.random().toString(36).substring(2, 9), + assetId: newAsset.id, + date: `${now.getFullYear()}-${String(now.getMonth()+1).padStart(2,'0')}-${String(now.getDate()).padStart(2,'0')}`, + user: '관리자', + details: '신규 등록' + }); + } + closeModals(); + renderContent(); + }); + + btnDelete?.addEventListener('click', (e) => { + e.preventDefault(); + const id = (document.getElementById('cloud-asset-id') as HTMLInputElement).value; + if (confirm('클라우드 자산을 삭제하시겠습니까?')) { + state.masterData.sw = state.masterData.sw.filter(a => a.id !== id); + closeModals(); + renderContent(); + } + }); + + // 클라우드 업데이트 (이력) 모달 로직 + const updateModal = document.getElementById('cloud-update-modal')!; + document.getElementById('btn-open-cloud-update')?.addEventListener('click', () => { + updateModal.classList.remove('hidden'); + (document.getElementById('cloud-update-date') as HTMLInputElement).value = new Date().toISOString().split('T')[0]; + (document.getElementById('cloud-update-cost') as HTMLInputElement).value = ''; + (document.getElementById('cloud-update-note') as HTMLInputElement).value = ''; + }); + + const closeUpdateModal = () => updateModal.classList.add('hidden'); + document.getElementById('btn-close-cloud-update')?.addEventListener('click', closeUpdateModal); + document.getElementById('btn-cancel-cloud-update')?.addEventListener('click', closeUpdateModal); + + document.getElementById('btn-save-cloud-update')?.addEventListener('click', () => { + const id = (document.getElementById('cloud-asset-id') as HTMLInputElement).value; + if (!id) return; + + const date = (document.getElementById('cloud-update-date') as HTMLInputElement).value; + const costRaw = (document.getElementById('cloud-update-cost') as HTMLInputElement).value.replace(/[^0-9]/g, ''); + const note = (document.getElementById('cloud-update-note') as HTMLInputElement).value; + + if (!date) return alert('업데이트 일자를 입력하세요.'); + + let details = '결제/상태 업데이트'; + if (costRaw) details += ` (비용: ₩ ${Number(costRaw).toLocaleString()})`; + if (note) details += `\n메모: ${note}`; + + state.masterData.logs = state.masterData.logs || []; + state.masterData.logs.push({ + id: Math.random().toString(36).substring(2, 9), + assetId: id, + date, + user: '관리자', + details + }); + + // 금액 업데이트 반영 + if (costRaw) { + const idx = state.masterData.sw.findIndex(a => a.id === id); + if (idx !== -1) { + state.masterData.sw[idx].당월청구액 = costRaw; + (document.getElementById('cloud-당월청구액') as HTMLInputElement).value = Number(costRaw).toLocaleString(); + } + } + + closeUpdateModal(); + renderCloudHistory(id); + renderContent(); + }); + + createIcons({ icons: { Save, X, Edit2, RotateCcw, History, Plus } }); +} + +export function openCloudModal(asset?: SoftwareAsset) { + currentCloudAsset = asset || null; + const form = document.getElementById('cloud-asset-form') as HTMLFormElement; + const deleteBtn = document.getElementById('btn-delete-cloud-asset')!; + + openModal('cloud-asset-modal'); + form.reset(); + + if (asset) { + document.getElementById('cloud-modal-title')!.textContent = '클라우드 서비스 상세'; + deleteBtn.style.display = 'block'; + fillCloudFormData(asset); + setCloudEditMode(false); + } else { + document.getElementById('cloud-modal-title')!.textContent = '신규 클라우드 서비스 등록'; + deleteBtn.style.display = 'none'; + (document.getElementById('cloud-asset-id') as HTMLInputElement).value = ''; + document.getElementById('btn-open-cloud-update')!.style.display = 'none'; + renderCloudHistory(''); + setCloudEditMode(true); + } + createIcons({ icons: { History, Plus } }); +} diff --git a/src/components/Modal/DashboardDetailModal.ts b/src/components/Modal/DashboardDetailModal.ts index 16c1273..4ea6f96 100644 --- a/src/components/Modal/DashboardDetailModal.ts +++ b/src/components/Modal/DashboardDetailModal.ts @@ -99,11 +99,34 @@ export function openSwUsageDetail(title: string, list: SoftwareAsset[]) { tbody.innerHTML = ''; list.forEach((sw, idx) => { const assigned = state.masterData.swUsers.filter(u => u.swId === sw.id).length; - const qty = typeof sw.수량 === 'number' ? sw.수량 : parseInt(sw.수량||'0', 10); - const avail = qty - assigned; const tr = document.createElement('tr'); - tr.innerHTML = `${idx+1}${sw.법인}${sw.제품명}${qty}${assigned}${avail}`; + tr.innerHTML = `${idx+1}${sw.법인}${sw.제품명}${sw.수량}${assigned}${Number(sw.수량) - assigned}`; tbody.appendChild(tr); }); modal.classList.remove('hidden'); } + +export function openCloudDashboardDetail(title: string, list: SoftwareAsset[]) { + const modal = document.getElementById('dashboard-detail-modal'); + if (!modal) return; + const titleEl = document.getElementById('dashboard-detail-modal-title'); + const tbody = document.getElementById('dashboard-detail-tbody'); + if (!titleEl || !tbody) return; + const thead = tbody.closest('table')?.querySelector('thead'); + if (!thead) return; + + titleEl.textContent = title; + thead.innerHTML = `No플랫폼명법인제품명결제일당월청구액(원)`; + tbody.innerHTML = ''; + if (list.length === 0) { + tbody.innerHTML = `해당 내역이 없습니다.`; + } else { + list.forEach((sw, idx) => { + const priceStr = sw.당월청구액 ? Number(sw.당월청구액.replace(/[^0-9]/g, '')).toLocaleString() : '0'; + const tr = document.createElement('tr'); + tr.innerHTML = `${idx+1}${sw.플랫폼명||'-'}${sw.법인||'-'}${sw.제품명||'-'}${sw.결제일 ? sw.결제일 + '일' : '-'}₩ ${priceStr}`; + tbody.appendChild(tr); + }); + } + modal.classList.remove('hidden'); +} diff --git a/src/components/Modal/SWModal.ts b/src/components/Modal/SWModal.ts index 8c858db..b1a0985 100644 --- a/src/components/Modal/SWModal.ts +++ b/src/components/Modal/SWModal.ts @@ -25,7 +25,7 @@ const SW_MODAL_HTML = `
@@ -52,7 +52,7 @@ const SW_MODAL_HTML = `
- +
@@ -122,7 +122,7 @@ const SW_MODAL_HTML = `
- +
@@ -157,12 +157,14 @@ export function setEditMode(edit: boolean) { btnSaveSw.textContent = '저장'; btnRevertEdit.classList.remove('hidden'); btnCloseFooter.classList.add('hidden'); + Array.from(swForm.elements).forEach((el: any) => el.disabled = false); } else { swForm.classList.add('is-view-mode'); swForm.classList.remove('is-edit-mode'); btnSaveSw.textContent = '수정'; btnRevertEdit.classList.add('hidden'); btnCloseFooter.classList.remove('hidden'); + Array.from(swForm.elements).forEach((el: any) => el.disabled = true); if (currentAsset) fillFormData(currentAsset); } } @@ -248,6 +250,16 @@ export function initSwModal(renderContent: () => void, closeModals: () => void) if(idx !== -1) state.masterData.sw[idx] = newAsset; } else { state.masterData.sw.push(newAsset); + + const now = new Date(); + state.masterData.logs = state.masterData.logs || []; + state.masterData.logs.push({ + id: Math.random().toString(36).substring(2, 9), + assetId: newAsset.id, + date: `${now.getFullYear()}-${String(now.getMonth()+1).padStart(2,'0')}-${String(now.getDate()).padStart(2,'0')}`, + user: '관리자', + details: '신규 등록' + }); } closeModals(); @@ -348,6 +360,7 @@ export function initSwModal(renderContent: () => void, closeModals: () => void) function renderSwHistory(assetId: string) { const historyList = document.getElementById('sw-history-list'); if (!historyList) return; + if (!state.masterData.logs) state.masterData.logs = []; const logs = state.masterData.logs .filter(l => l.assetId === assetId) diff --git a/src/components/Navigation.ts b/src/components/Navigation.ts index 8ed0688..87984c5 100644 --- a/src/components/Navigation.ts +++ b/src/components/Navigation.ts @@ -7,7 +7,7 @@ const MENU_CONFIG = { }, sw: { label: '소프트웨어', - tabs: ['대시보드', '구독SW', '영구SW'] + tabs: ['대시보드', '구독SW', '영구SW', '클라우드'] }, ops: { label: '운영 서비스', diff --git a/src/core/dummyDataGenerator.ts b/src/core/dummyDataGenerator.ts index 55f3b43..cb163d2 100644 --- a/src/core/dummyDataGenerator.ts +++ b/src/core/dummyDataGenerator.ts @@ -23,6 +23,7 @@ export function generateDummyData(): MasterAssetData { const hw: HardwareAsset[] = []; const sw: SoftwareAsset[] = []; const swUsers: SWUser[] = []; + const logs: any[] = []; // 1. 개인PC 50개 for (let i = 1; i <= 50; i++) { @@ -228,5 +229,51 @@ export function generateDummyData(): MasterAssetData { } } - return { hw, sw, swUsers, logs: [] }; + // 7. 클라우드 서비스 15개 + for (let i = 1; i <= 15; i++) { + const swId = Math.random().toString(36).substring(2, 9); + const platforms = ['AWS', 'Microsoft Azure', 'Google Cloud', 'Naver Cloud', 'Cafe24']; + const pfmt = rand(platforms); + + const billing = (Math.floor(Math.random() * 500) + 10) * 10000; + const paymentDay = String(Math.floor(Math.random() * 28) + 1); + + sw.push({ + id: swId, + type: '클라우드', + 플랫폼명: pfmt, + 법인: rand(corps), + 부서: rand(depts), + 제품명: rand(['본사 홈페이지 운영', 'AI 분석 프로젝트', '인트라넷 백업용', '현장 모니터링 시스템']), + 계정명: `admin_${i}@hm.com`, + 결제수단: Math.random() > 0.5 ? '법인카드' : '인보이스', + 결제일: paymentDay, + 연결카드번호: String(Math.floor(Math.random() * 8999) + 1000), // 1000~9999 + 당월청구액: String(billing), + 비고: Math.random() > 0.8 ? '비용 한도 초과 경고' : '', + + // 더미 필수값 + 구매일: '', + 금액: '', + 수량: 1, + 납품업체: '' + }); + + // 4개월치 모의 결제 이력 생성 + for (let m = 0; m < 4; m++) { + const logDate = new Date(); + logDate.setMonth(logDate.getMonth() - m); + logDate.setDate(parseInt(paymentDay, 10)); + const historyBilling = Math.floor(billing * (1 + (Math.random() * 0.2 - 0.1))); + logs.push({ + id: Math.random().toString(36).substring(2, 9), + assetId: swId, + date: `${logDate.getFullYear()}-${String(logDate.getMonth()+1).padStart(2,'0')}-${String(logDate.getDate()).padStart(2,'0')}`, + user: `admin_${i}@hm.com`, + details: `정기 결제 완료 (비용: ₩ ${historyBilling.toLocaleString()})` + }); + } + } + + return { hw, sw, swUsers, logs }; } diff --git a/src/core/excelHandler.ts b/src/core/excelHandler.ts index 618a77a..0301db8 100644 --- a/src/core/excelHandler.ts +++ b/src/core/excelHandler.ts @@ -57,6 +57,11 @@ export interface SoftwareAsset { 계정명: string; 납품업체: string; 비고: string; + 플랫폼명?: string; + 결제수단?: string; + 결제일?: string; + 연결카드번호?: string; + 당월청구액?: string; } export interface SWUser { @@ -87,7 +92,7 @@ export interface MasterAssetData { } const HW_TABS = ['개인PC', '서버', '스토리지', '전산비품']; -const SW_TABS = ['구독SW', '영구SW']; +const SW_TABS = ['구독SW', '영구SW', '클라우드']; const HW_HEADERS = ['법인', '자산코드', '명칭', '위치', '관리자', 'IP주소', 'MACaddress', 'HW사양', 'OS', '구매일', '금액', '납품업체', '품의서명']; const PC_HEADERS = ['법인', '자산코드', '사용자', '위치', 'CPU', 'GPU', 'RAM', 'SSD1', 'SSD2', 'HDD1', 'HDD2', '구매일', '금액', '납품업체', '품의서명']; @@ -95,6 +100,7 @@ const SERVER_HEADERS = ['법인', '자산번호', '유형', '용도', '설치위 const STORAGE_HEADERS = ['법인', '유형', '자산코드', '명칭', '위치', '모델명', '용량', '담당자(정)', '담당자(부)', 'IP주소', 'MAC주소', '구매일', '금액', '납품업체', '품의서명']; const SUB_SW_HEADERS = ['ID', '분야', '법인', '부서', '제품명', '구매일', '구독일', '금액', '수량', '계정명', '납품업체', '비고']; const PERM_SW_HEADERS = ['ID', '분야', '법인', '부서', '제품명', '구매일', '유지보수여부', '금액', '수량', '계정명', '납품업체', '비고']; +const CLOUD_HEADERS = ['ID', '플랫폼명', '법인', '부서', '사용용도(제품명)', '계정명', '결제수단', '결제일', '연결카드번호', '당월청구액', '비고']; const SW_USER_HEADERS = ['id', 'swId', '법인', '부서', '팀', '직위', '이름', '사용기간', '신청서명']; const HISTORY_HEADERS = ['id', 'assetId', 'date', 'details', 'user']; @@ -128,9 +134,11 @@ export function downloadTemplate() { }); SW_TABS.forEach(tab => { - let hd = tab === '구독SW' ? SUB_SW_HEADERS : PERM_SW_HEADERS; + let hd = tab === '구독SW' ? SUB_SW_HEADERS : (tab === '클라우드' ? CLOUD_HEADERS : PERM_SW_HEADERS); const ws = XLSX.utils.aoa_to_sheet([hd]); - ws['!cols'] = [{wch:15}, {wch:15}, {wch:15}, {wch:20}, {wch:30}, {wch:15}, {wch:20}, {wch:15}, {wch:10}, {wch:20}, {wch:20}, {wch:30}]; + ws['!cols'] = tab === '클라우드' + ? [{wch:15}, {wch:20}, {wch:15}, {wch:20}, {wch:30}, {wch:25}, {wch:15}, {wch:10}, {wch:15}, {wch:15}, {wch:30}] + : [{wch:15}, {wch:15}, {wch:15}, {wch:20}, {wch:30}, {wch:15}, {wch:20}, {wch:15}, {wch:10}, {wch:20}, {wch:20}, {wch:30}]; XLSX.utils.book_append_sheet(wb, ws, tab); }); @@ -195,6 +203,11 @@ export function exportToExcel(masterData: MasterAssetData) { SUB_SW_HEADERS, ...targetAssets.map(a => [a.id, a.분야||'', a.법인, a.부서||'', a.제품명, a.구매일, a.구독일, a.금액, a.수량, a.계정명, a.납품업체, a.비고]) ]; + } else if (tab === '클라우드') { + wsData = [ + CLOUD_HEADERS, + ...targetAssets.map(a => [a.id, a.플랫폼명||'', a.법인, a.부서||'', a.제품명, a.계정명, a.결제수단||'', a.결제일||'', a.연결카드번호||'', a.당월청구액||'', a.비고]) + ]; } else { wsData = [ PERM_SW_HEADERS, @@ -202,7 +215,9 @@ export function exportToExcel(masterData: MasterAssetData) { ]; } const ws = XLSX.utils.aoa_to_sheet(wsData); - ws['!cols'] = [{wch:15}, {wch:15}, {wch:15}, {wch:20}, {wch:30}, {wch:15}, {wch:20}, {wch:15}, {wch:10}, {wch:20}, {wch:20}, {wch:30}]; + ws['!cols'] = tab === '클라우드' + ? [{wch:15}, {wch:20}, {wch:15}, {wch:20}, {wch:30}, {wch:25}, {wch:15}, {wch:10}, {wch:15}, {wch:15}, {wch:30}] + : [{wch:15}, {wch:15}, {wch:15}, {wch:20}, {wch:30}, {wch:15}, {wch:20}, {wch:15}, {wch:10}, {wch:20}, {wch:20}, {wch:30}]; XLSX.utils.book_append_sheet(wb, ws, tab); }); @@ -303,13 +318,34 @@ export async function parseExcel(file: File): Promise { if (SW_TABS.includes(sheetName)) { json.forEach(row => { - swAssets.push({ - id: row['ID'] ? String(row['ID']) : Math.random().toString(36).substring(2, 9), - type: sheetName, 분야: row['분야'] || '', 법인: row['법인'] || '', 부서: row['부서'] || '', 제품명: row['제품명'] || '', - 구매일: row['구매일'] || '', 구독일: row['구독일'] || '', 유지보수여부: row['유지보수여부'] === 'Y' || row['유지보수여부'] === true, - 금액: row['금액'] ? String(row['금액']) : '', 수량: parseInt(row['수량'] || '1', 10), - 계정명: row['계정명'] || '', 납품업체: row['납품업체'] || '', 비고: row['비고'] || '', - }); + if (sheetName === '클라우드') { + swAssets.push({ + id: row['ID'] ? String(row['ID']) : Math.random().toString(36).substring(2, 9), + type: sheetName, + 플랫폼명: row['플랫폼명'] || '', + 법인: row['법인'] || '', + 부서: row['부서'] || '', + 제품명: row['사용용도(제품명)'] || '', + 구매일: '', + 금액: '', + 수량: 1, + 계정명: row['계정명'] || '', + 결제수단: row['결제수단'] || '', + 결제일: row['결제일'] ? String(row['결제일']) : '', + 연결카드번호: row['연결카드번호'] ? String(row['연결카드번호']) : '', + 당월청구액: row['당월청구액'] ? String(row['당월청구액']) : '', + 납품업체: '', + 비고: row['비고'] || '', + }); + } else { + swAssets.push({ + id: row['ID'] ? String(row['ID']) : Math.random().toString(36).substring(2, 9), + type: sheetName, 분야: row['분야'] || '', 법인: row['법인'] || '', 부서: row['부서'] || '', 제품명: row['제품명'] || '', + 구매일: row['구매일'] || '', 구독일: row['구독일'] || '', 유지보수여부: row['유지보수여부'] === 'Y' || row['유지보수여부'] === true, + 금액: row['금액'] ? String(row['금액']) : '', 수량: parseInt(row['수량'] || '1', 10), + 계정명: row['계정명'] || '', 납품업체: row['납품업체'] || '', 비고: row['비고'] || '', + }); + } }); } diff --git a/src/core/state.ts b/src/core/state.ts index 2e5294c..6585e30 100644 --- a/src/core/state.ts +++ b/src/core/state.ts @@ -57,7 +57,7 @@ export const state: AppState = { masterData: { ...dummy, hw: mergedHw, // 기본적으로 하드코딩된 데이터를 가지고 시작 - logs: [] + logs: dummy.logs || [] }, activeCategory: 'hw', activeSubTab: '대시보드', diff --git a/src/views/Dashboard/SwDashboard.ts b/src/views/Dashboard/SwDashboard.ts index a782e7f..0214c09 100644 --- a/src/views/Dashboard/SwDashboard.ts +++ b/src/views/Dashboard/SwDashboard.ts @@ -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 = {}; 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 = `

소프트웨어 라이선스 현황

+
구독 소프트웨어 사용율 @@ -67,46 +121,83 @@ export function renderSwDashboard(container: HTMLElement) {
- 구독 SW 만료 예정 (30일 이내) + 구독 SW 만료 예정
(30일 이내)
${subExp}개 제품
-
-
- ${subExpPer}% +
+
+ ${subExpPer}%
- 유지보수 만료 예정 (30일 이내) + 유지보수 만료 예정
(30일 이내)
${permExp}개 제품
-
-
- ${permExpPer}% +
+
+ ${permExpPer}%
-

${currentYear}년 도입 비용 분석

-
-
-

법인별 도입 금액 (원)

+ +
+
+
+ ☁️ 클라우드 청구 현황 (통합) +
+ ₩ ${thisMonthCloudCost.toLocaleString()} + + ${costDiff >= 0 ? '▲' : '▼'} ${costDiffText} + +
+
전월 실적: ₩ ${lastMonthCloudCost.toLocaleString()}
+
+
+
+ +
+
+
+ +
+
+ 클라우드 결제 임박
(14일 이내)
+
${cloudExp}건 청구
+
+
+
+ ${cloudExpPer}% +
+
+
+
+ +

비용 분석 현황

+
+
+

${currentYear}년 법인별 도입 금액 (원)

-
-

분야별 도입 금액 (원)

+
+

${currentYear}년 분야별 도입 금액 (원)

+
+

직전 4개월 클라우드 결제 추이 (원)

+ +
`; 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) { diff --git a/src/views/List/CloudListView.ts b/src/views/List/CloudListView.ts new file mode 100644 index 0000000..3af4d1c --- /dev/null +++ b/src/views/List/CloudListView.ts @@ -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 = ` +
+ + +
+
+ + +
+ + `; + container.appendChild(filterBar); + + const tableWrapper = document.createElement('div'); + tableWrapper.className = 'table-container'; + const table = document.createElement('table'); + table.innerHTML = ` + + + No. + 플랫폼명 + 법인 + 담당부서 + 진행 프로젝트(사용용도) + 계정명(관리자) + 결제수단 + 결제일 + 당월 청구액 + 비고 + + + + `; + + 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 = '등록된 클라우드 서비스가 없습니다.'; + return; + } + + filtered.forEach((asset, idx) => { + const tr = document.createElement('tr'); + tr.style.cursor = 'pointer'; + + const paymentBadge = asset.결제수단 === '법인카드' + ? '법인카드 (' + (asset.연결카드번호||'미상') + ')' + : (asset.결제수단 === '인보이스' + ? '인보이스' + : '미설정'); + + tr.innerHTML = ` + ${idx+1} + ${asset.플랫폼명||'미지정'} + ${asset.법인||''} + ${asset.부서||''} + ${asset.제품명||''} + ${asset.계정명||''} + ${paymentBadge} + ${asset.결제일 ? asset.결제일 + '일' : ''} + ₩ ${asset.당월청구액 ? Number(asset.당월청구액).toLocaleString() : '0'} + ${asset.비고||''} + `; + + 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(); +}