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 = `
+
업데이트 내역이 없습니다.
';
+ return;
+ }
+
+ historyList.innerHTML = logs.map(log => `
+
상세 내용 (메모)
@@ -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}개 제품
-
-
-
유지보수 만료 예정 (30일 이내)
+
유지보수 만료 예정(30일 이내)
${permExp}개 제품
-
-
${currentYear}년 도입 비용 분석
-
-
-
법인별 도입 금액 (원)
+
+
+
+
+
☁️ 클라우드 청구 현황 (통합)
+
+ ₩ ${thisMonthCloudCost.toLocaleString()}
+
+ ${costDiff >= 0 ? '▲' : '▼'} ${costDiffText}
+
+
+
전월 실적: ₩ ${lastMonthCloudCost.toLocaleString()}
+
+
+
+
+
+
+
클라우드 결제 임박(14일 이내)
+
${cloudExp}건 청구
+
+
+
+
+
+
비용 분석 현황
+
+
+
${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();
+}