From 77563994e99c858167d465561181430b5bccfe7e Mon Sep 17 00:00:00 2001 From: JooWangi Date: Tue, 14 Apr 2026 10:13:54 +0900 Subject: [PATCH 1/6] feat: Update AssetTableView and DashboardView to improve software management UI --- src/views/AssetTableView.ts | 11 ++++++---- src/views/DashboardView.ts | 40 +++++++++++++++++++++++++++---------- 2 files changed, 37 insertions(+), 14 deletions(-) diff --git a/src/views/AssetTableView.ts b/src/views/AssetTableView.ts index 91254bf..6bb2d81 100644 --- a/src/views/AssetTableView.ts +++ b/src/views/AssetTableView.ts @@ -76,17 +76,20 @@ function renderHwTable(table: HTMLTableElement, container: HTMLElement, mainCont function renderSwTable(table: HTMLTableElement, container: HTMLElement, mainContent: HTMLElement) { const list = state.masterData.sw.filter(a => a.type === state.activeSubTab); - table.innerHTML = `No법인제품명구매일수량사용가능관리`; + const isSub = state.activeSubTab === '구독SW'; + + table.innerHTML = `No법인제품명구매일${isSub ? '구독일' : ''}수량사용가능관리`; container.appendChild(table); mainContent.appendChild(container); const tbody = document.getElementById('dynamic-tbody')!; - if (list.length === 0) { tbody.innerHTML = `정보가 없습니다.`; return; } + if (list.length === 0) { tbody.innerHTML = `정보가 없습니다.`; return; } + list.forEach((asset, idx) => { const assigned = state.masterData.swUsers.filter(u => u.swId === asset.id).length; - const avail = asset.수량 - assigned; + const avail = (typeof asset.수량 === 'number' ? asset.수량 : parseInt(asset.수량||'0', 10)) - assigned; const tr = document.createElement('tr'); tr.style.cursor = 'pointer'; - tr.innerHTML = `${idx+1}${asset.법인}${asset.제품명}${asset.구매일||''}${asset.수량}${avail}`; + tr.innerHTML = `${idx+1}${asset.법인}${asset.제품명}${asset.구매일||''}${isSub ? `${asset.구독일||''}` : ''}${asset.수량}${avail}`; tr.addEventListener('click', (e) => { if (!(e.target as HTMLElement).closest('button')) openSwModal(asset); }); tr.querySelector('.btn-edit')?.addEventListener('click', () => openSwModal(asset)); tr.querySelector('.btn-users')?.addEventListener('click', () => openSwUserModal(asset)); diff --git a/src/views/DashboardView.ts b/src/views/DashboardView.ts index 4101b34..ff72541 100644 --- a/src/views/DashboardView.ts +++ b/src/views/DashboardView.ts @@ -161,19 +161,39 @@ function renderSwDashboard(container: HTMLElement) {
-
-
- 구독 SW 만료 예정 -
${subExp}개 만료 예정
+
+
+
+ 구독 SW 만료 예정 + 30일 이내 +
+
+ 전체 ${subTotal}개 제품 중 ${subExp}개 만료 예정 +
+
${subExp}개
+
+
+
+ ${subExpPer}% +
-
-
-
- 유지보수 만료 예정 -
${permExp}개 만료 예정
+
+
+
+ 유지보수 만료 예정 + 30일 이내 +
+
+ 전체 ${permTotal}개 제품 중 ${permExp}개 만료 예정 +
+
${permExp}개
+
+
+
+ ${permExpPer}% +
-
`; From 7860edd8d06f5cde04519918cae0327cb1127d12 Mon Sep 17 00:00:00 2001 From: JooWangi Date: Tue, 14 Apr 2026 11:46:46 +0900 Subject: [PATCH 2/6] =?UTF-8?q?feat:=20S/W=20=ED=85=8C=EC=9D=B4=EB=B8=94?= =?UTF-8?q?=20=EB=B6=84=EC=95=BC/=EB=B6=80=EC=84=9C=20=EC=8A=A4=ED=82=A4?= =?UTF-8?q?=EB=A7=88=20=ED=99=95=EC=9E=A5=20=EB=B0=8F=20UI=20=EA=B0=9C?= =?UTF-8?q?=EC=84=A0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 변경 파일 세부사항: - index.html: S/W 자산 폼에 분야(select), 부서(input) 요소 추가 - src/components/Modal/SWModal.ts: 모달 오픈/저장 시 분야, 부서 데이터 Get/Set 바인딩 적용 - src/dummyDataGenerator.ts: S/W 더미 데이터 생성 시 임의의 분야 및 부서 값 할당 추가 - src/excelHandler.ts: 엑셀 파일 내 분야/부서 컬럼 정의, JSON 매핑 및 Export 로직 추가 - src/style.css: S/W 데이터 테이블(.sw-table td, th) 가운데 정렬 CSS 적용 - src/views/AssetTableView.ts: S/W 테이블 헤더 전면 개편 및 관리(아이콘) 스타일 개선 --- index.html | 15 +++++++++++++++ src/components/Modal/SWModal.ts | 6 ++++++ src/dummyDataGenerator.ts | 4 ++++ src/excelHandler.ts | 16 ++++++++++------ src/style.css | 4 ++++ src/views/AssetTableView.ts | 7 ++++--- 6 files changed, 43 insertions(+), 9 deletions(-) diff --git a/index.html b/index.html index 5bbbd7f..758464e 100644 --- a/index.html +++ b/index.html @@ -394,6 +394,16 @@ +
+ + +
+
+
+ + +
+
diff --git a/src/components/Modal/SWModal.ts b/src/components/Modal/SWModal.ts index cb2ccc0..c6d7dd8 100644 --- a/src/components/Modal/SWModal.ts +++ b/src/components/Modal/SWModal.ts @@ -19,7 +19,9 @@ export function initSWModal(renderContent: () => void, closeModals: () => void) const newAsset: SoftwareAsset = { id: id || Math.random().toString(36).substring(2, 9), type: (document.getElementById('sw-asset-type') as HTMLInputElement).value, + 분야: (document.getElementById('sw-분야') as HTMLSelectElement).value, 법인: (document.getElementById('sw-법인') as HTMLSelectElement).value, + 부서: (document.getElementById('sw-부서') as HTMLInputElement).value, 제품명: (document.getElementById('sw-제품명') as HTMLInputElement).value, 구매일: (document.getElementById('sw-구매일') as HTMLInputElement).value, 구독일: (document.getElementById('sw-구독일') as HTMLInputElement).value, @@ -82,7 +84,9 @@ export function openSwModal(asset?: SoftwareAsset) { (document.getElementById('sw-asset-id') as HTMLInputElement).value = asset.id; (document.getElementById('sw-asset-type') as HTMLInputElement).value = asset.type; + (document.getElementById('sw-분야') as HTMLSelectElement).value = asset.분야 || '업무공통'; (document.getElementById('sw-법인') as HTMLSelectElement).value = asset.법인; + (document.getElementById('sw-부서') as HTMLInputElement).value = asset.부서 || ''; (document.getElementById('sw-제품명') as HTMLInputElement).value = asset.제품명; (document.getElementById('sw-구매일') as HTMLInputElement).value = asset.구매일 || ''; (document.getElementById('sw-구독일') as HTMLInputElement).value = asset.구독일 || ''; @@ -97,6 +101,8 @@ export function openSwModal(asset?: SoftwareAsset) { deleteBtn.style.display = 'none'; (document.getElementById('sw-asset-id') as HTMLInputElement).value = ''; (document.getElementById('sw-asset-type') as HTMLInputElement).value = state.activeSubTab; + (document.getElementById('sw-분야') as HTMLSelectElement).value = '업무공통'; (document.getElementById('sw-법인') as HTMLSelectElement).value = '한맥'; + (document.getElementById('sw-부서') as HTMLInputElement).value = ''; } } diff --git a/src/dummyDataGenerator.ts b/src/dummyDataGenerator.ts index 3a292a8..5b48c9a 100644 --- a/src/dummyDataGenerator.ts +++ b/src/dummyDataGenerator.ts @@ -140,7 +140,9 @@ export function generateDummyData(): MasterAssetData { sw.push({ id: swId, type: '구독SW', + 분야: rand(['업무공통', '개발S/W', '디자인', '설계S/W']), 법인: rand(corps), + 부서: rand(depts), 제품명: rand(['Adobe CC All Apps', 'Microsoft 365', 'Slack Pro', 'Notion Team']), 구매일: '2024-01-01', 구독일: `2024.01.01 ~ ${endStr}`, @@ -182,7 +184,9 @@ export function generateDummyData(): MasterAssetData { sw.push({ id: swId, type: '영구SW', + 분야: rand(['업무공통', '개발S/W', '디자인', '설계S/W']), 법인: rand(corps), + 부서: rand(depts), 제품명: rand(['AutoCAD 2024', 'Windows 10 Pro', '한컴오피스 2022', 'Visual Studio 2022']), 구매일: '2020-05-15', 유지보수여부: true, diff --git a/src/excelHandler.ts b/src/excelHandler.ts index 36479bc..6b9bc1a 100644 --- a/src/excelHandler.ts +++ b/src/excelHandler.ts @@ -35,7 +35,9 @@ export interface HardwareAsset { export interface SoftwareAsset { id: string; type: string; // '구독SW', '영구SW' + 분야?: string; 법인: string; + 부서?: string; 제품명: string; 구매일: string; 구독일?: string; @@ -71,8 +73,8 @@ const SW_TABS = ['구독SW', '영구SW']; const HW_HEADERS = ['법인', '자산코드', '명칭', '위치', '관리자', 'IP주소', 'MACaddress', 'HW사양', 'OS', '구매일', '금액', '납품업체', '품의서명']; const PC_HEADERS = ['법인', '자산코드', '사용자', '위치', 'CPU', 'GPU', 'RAM', 'SSD1', 'SSD2', 'HDD1', 'HDD2', '구매일', '금액', '납품업체', '품의서명']; const STORAGE_HEADERS = ['법인', '유형', '자산코드', '명칭', '위치', '모델명', '용량', '담당자(정)', '담당자(부)', 'IP주소', 'MAC주소', '구매일', '금액', '납품업체', '품의서명']; -const SUB_SW_HEADERS = ['ID', '법인', '제품명', '구매일', '구독일', '금액', '수량', '계정명', '납품업체', '비고']; -const PERM_SW_HEADERS = ['ID', '법인', '제품명', '구매일', '유지보수여부', '금액', '수량', '계정명', '납품업체', '비고']; +const SUB_SW_HEADERS = ['ID', '분야', '법인', '부서', '제품명', '구매일', '구독일', '금액', '수량', '계정명', '납품업체', '비고']; +const PERM_SW_HEADERS = ['ID', '분야', '법인', '부서', '제품명', '구매일', '유지보수여부', '금액', '수량', '계정명', '납품업체', '비고']; const SW_USER_HEADERS = ['id', 'swId', '법인', '부서', '팀', '직위', '이름', '사용기간', '신청서명']; /** @@ -102,7 +104,7 @@ export function downloadTemplate() { SW_TABS.forEach(tab => { let hd = tab === '구독SW' ? SUB_SW_HEADERS : PERM_SW_HEADERS; const ws = XLSX.utils.aoa_to_sheet([hd]); - ws['!cols'] = [{wch:15}, {wch:15}, {wch:30}, {wch:15}, {wch:20}, {wch:15}, {wch:10}, {wch:20}, {wch:20}, {wch:30}]; + 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}]; XLSX.utils.book_append_sheet(wb, ws, tab); }); @@ -157,16 +159,16 @@ export function exportToExcel(masterData: MasterAssetData) { if (tab === '구독SW') { wsData = [ SUB_SW_HEADERS, - ...targetAssets.map(a => [a.id, a.법인, a.제품명, a.구매일, a.구독일, a.금액, a.수량, a.계정명, a.납품업체, a.비고]) + ...targetAssets.map(a => [a.id, a.분야||'', a.법인, a.부서||'', a.제품명, a.구매일, a.구독일, a.금액, a.수량, a.계정명, a.납품업체, a.비고]) ]; } else { wsData = [ PERM_SW_HEADERS, - ...targetAssets.map(a => [a.id, a.법인, a.제품명, a.구매일, a.유지보수여부 ? 'Y' : 'N', a.금액, a.수량, a.계정명, a.납품업체, a.비고]) + ...targetAssets.map(a => [a.id, a.분야||'', a.법인, a.부서||'', a.제품명, a.구매일, a.유지보수여부 ? 'Y' : 'N', a.금액, a.수량, a.계정명, a.납품업체, a.비고]) ]; } const ws = XLSX.utils.aoa_to_sheet(wsData); - ws['!cols'] = [{wch:15}, {wch:15}, {wch:30}, {wch:15}, {wch:20}, {wch:15}, {wch:10}, {wch:20}, {wch:20}, {wch:30}]; + 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}]; XLSX.utils.book_append_sheet(wb, ws, tab); }); @@ -281,7 +283,9 @@ export async function parseExcel(file: File): Promise { swAssets.push({ id: row['ID'] ? String(row['ID']) : Math.random().toString(36).substring(2, 9), type: sheetName, + 분야: row['분야'] || '', 법인: row['법인'] || '', + 부서: row['부서'] || '', 제품명: row['제품명'] || '', 구매일: row['구매일'] || '', 구독일: row['구독일'] || '', diff --git a/src/style.css b/src/style.css index 4b6238e..0bcbce1 100644 --- a/src/style.css +++ b/src/style.css @@ -300,6 +300,10 @@ tbody tr:last-child td { border-bottom: none; } tbody tr:hover { background-color: var(--bg-color); } .empty-row td { text-align: center; padding: 3rem; color: var(--text-muted); } +.sw-table td { + text-align: center; +} + /* Modal */ .modal-overlay { position: fixed; diff --git a/src/views/AssetTableView.ts b/src/views/AssetTableView.ts index 6bb2d81..e193d79 100644 --- a/src/views/AssetTableView.ts +++ b/src/views/AssetTableView.ts @@ -78,18 +78,19 @@ function renderSwTable(table: HTMLTableElement, container: HTMLElement, mainCont const list = state.masterData.sw.filter(a => a.type === state.activeSubTab); const isSub = state.activeSubTab === '구독SW'; - table.innerHTML = `No법인제품명구매일${isSub ? '구독일' : ''}수량사용가능관리`; + table.classList.add('sw-table'); + table.innerHTML = `No.분야법인부서제품명구매일${isSub ? '구독일' : ''}수량사용가능관리`; container.appendChild(table); mainContent.appendChild(container); const tbody = document.getElementById('dynamic-tbody')!; - if (list.length === 0) { tbody.innerHTML = `정보가 없습니다.`; return; } + if (list.length === 0) { tbody.innerHTML = `정보가 없습니다.`; return; } list.forEach((asset, idx) => { const assigned = state.masterData.swUsers.filter(u => u.swId === asset.id).length; const avail = (typeof asset.수량 === 'number' ? asset.수량 : parseInt(asset.수량||'0', 10)) - assigned; const tr = document.createElement('tr'); tr.style.cursor = 'pointer'; - tr.innerHTML = `${idx+1}${asset.법인}${asset.제품명}${asset.구매일||''}${isSub ? `${asset.구독일||''}` : ''}${asset.수량}${avail}`; + tr.innerHTML = `${idx+1}${asset.분야||''}${asset.법인}${asset.부서||''}${asset.제품명}${asset.구매일||''}${isSub ? `${asset.구독일||''}` : ''}${asset.수량}${avail}`; tr.addEventListener('click', (e) => { if (!(e.target as HTMLElement).closest('button')) openSwModal(asset); }); tr.querySelector('.btn-edit')?.addEventListener('click', () => openSwModal(asset)); tr.querySelector('.btn-users')?.addEventListener('click', () => openSwUserModal(asset)); From 3c98ce948a78e381492339683b5ef4b9cb3ea644 Mon Sep 17 00:00:00 2001 From: JooWangi Date: Tue, 14 Apr 2026 13:18:51 +0900 Subject: [PATCH 3/6] =?UTF-8?q?feat:=20=EC=86=8C=ED=94=84=ED=8A=B8?= =?UTF-8?q?=EC=9B=A8=EC=96=B4=20=EB=8C=80=EC=8B=9C=EB=B3=B4=EB=93=9C=20?= =?UTF-8?q?=EC=8B=9C=EA=B0=81=ED=99=94=20=EA=B0=9C=EC=84=A0=20=EB=B0=8F=20?= =?UTF-8?q?=EA=B8=88=EC=95=A1=20=EC=97=B4=20=EC=B6=94=EA=B0=80=20(#4)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/dummyDataGenerator.ts | 18 +++--- src/views/AssetTableView.ts | 6 +- src/views/DashboardView.ts | 110 +++++++++++++++++++++++++++++++++++- 3 files changed, 121 insertions(+), 13 deletions(-) diff --git a/src/dummyDataGenerator.ts b/src/dummyDataGenerator.ts index 5b48c9a..cfbf212 100644 --- a/src/dummyDataGenerator.ts +++ b/src/dummyDataGenerator.ts @@ -26,7 +26,7 @@ export function generateDummyData(): MasterAssetData { // 1. 개인PC 50개 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({ id: Math.random().toString(36).substring(2, 9), type: '개인PC', @@ -43,7 +43,7 @@ export function generateDummyData(): MasterAssetData { HDD1: rand(['-', '1TB', '2TB']), HDD2: '', 구매일: 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(['다나와', '컴퓨존', '오피스디포']), 품의서명: '', 관리자: '', IP주소: '', MACaddress: '', OS: '', HW사양: '' @@ -52,7 +52,7 @@ export function generateDummyData(): MasterAssetData { // 2. 서버 20개 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({ id: Math.random().toString(36).substring(2, 9), type: '서버', @@ -74,7 +74,7 @@ export function generateDummyData(): MasterAssetData { // 3. 스토리지 20개 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({ id: Math.random().toString(36).substring(2, 9), type: '스토리지', @@ -105,7 +105,7 @@ export function generateDummyData(): MasterAssetData { ]; equips.forEach((eq) => { 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({ id: Math.random().toString(36).substring(2, 9), type: '전산비품', @@ -127,6 +127,7 @@ export function generateDummyData(): MasterAssetData { // 5. 구독형 S/W 40개 for (let i = 1; i <= 40; i++) { const swId = Math.random().toString(36).substring(2, 9); + const purchaseYear = Math.random() < 0.3 ? 2026 : 2024; let isExpiring = Math.random() < 0.25; let endDt = new Date(); @@ -144,14 +145,15 @@ export function generateDummyData(): MasterAssetData { 법인: rand(corps), 부서: rand(depts), 제품명: rand(['Adobe CC All Apps', 'Microsoft 365', 'Slack Pro', 'Notion Team']), - 구매일: '2024-01-01', - 구독일: `2024.01.01 ~ ${endStr}`, - 금액: '600,000', + 구매일: `${purchaseYear}-01-01`, + 구독일: `${purchaseYear}.01.01 ~ ${endStr}`, + 금액: String(Math.floor(Math.random() * 100 + 10) * 10000).replace(/\B(?=(\d{3})+(?!\d))/g, ','), 수량: Math.floor(Math.random() * 5) + 3, // 3~7 계정명: `user${i}@hm.com`, 납품업체: '총판', 비고: '연간구독' }); + // ... rest unchanged const assignCount = Math.floor(Math.random() * 2) + 1; for (let j=0; jNo.분야법인부서제품명구매일${isSub ? '구독일' : ''}수량사용가능관리`; + table.innerHTML = `No.분야법인부서제품명구매일${isSub ? '구독일' : ''}금액수량사용가능관리`; container.appendChild(table); mainContent.appendChild(container); const tbody = document.getElementById('dynamic-tbody')!; - if (list.length === 0) { tbody.innerHTML = `정보가 없습니다.`; return; } + if (list.length === 0) { tbody.innerHTML = `정보가 없습니다.`; return; } list.forEach((asset, idx) => { const assigned = state.masterData.swUsers.filter(u => u.swId === asset.id).length; const avail = (typeof asset.수량 === 'number' ? asset.수량 : parseInt(asset.수량||'0', 10)) - assigned; const tr = document.createElement('tr'); tr.style.cursor = 'pointer'; - tr.innerHTML = `${idx+1}${asset.분야||''}${asset.법인}${asset.부서||''}${asset.제품명}${asset.구매일||''}${isSub ? `${asset.구독일||''}` : ''}${asset.수량}${avail}`; + tr.innerHTML = `${idx+1}${asset.분야||''}${asset.법인}${asset.부서||''}${asset.제품명}${asset.구매일||''}${isSub ? `${asset.구독일||''}` : ''}${asset.금액||'0'}${asset.수량}${avail}`; tr.addEventListener('click', (e) => { if (!(e.target as HTMLElement).closest('button')) openSwModal(asset); }); tr.querySelector('.btn-edit')?.addEventListener('click', () => openSwModal(asset)); tr.querySelector('.btn-users')?.addEventListener('click', () => openSwUserModal(asset)); diff --git a/src/views/DashboardView.ts b/src/views/DashboardView.ts index ff72541..82c1759 100644 --- a/src/views/DashboardView.ts +++ b/src/views/DashboardView.ts @@ -1,6 +1,8 @@ import { state } from '../state'; import { HardwareAsset, SoftwareAsset } from '../excelHandler'; +declare var Chart: any; + /** * 대시보드 렌더링 메인 함수 */ @@ -8,7 +10,9 @@ export function renderDashboard(mainContent: HTMLElement) { mainContent.innerHTML = ''; // 기존 차트 리소스 해제 - state.activeCharts.forEach(c => c.destroy()); + state.activeCharts.forEach(c => { + if (c && typeof c.destroy === 'function') c.destroy(); + }); state.activeCharts = []; if (state.activeCategory === 'hw') { @@ -120,9 +124,20 @@ function renderSwDashboard(container: HTMLElement) { let subQty = 0, subUsed = 0, subExp = 0, subTotal = 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 = { '한맥': 0, '삼안': 0, '바론': 0 }; + const costByCat: Record = {}; + categories.forEach(c => costByCat[c] = 0); + 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); + const priceStr = sw.금액 ? sw.금액.replace(/,/g, '') : '0'; + const price = parseInt(priceStr, 10) || 0; + if (sw.type === '구독SW') { subQty += qty; subUsed += assigned; subTotal++; if (isSWExpiring(sw)) subExp++; @@ -130,6 +145,12 @@ function renderSwDashboard(container: HTMLElement) { permQty += qty; permUsed += assigned; permTotal++; 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; @@ -160,7 +181,8 @@ function renderSwDashboard(container: HTMLElement) {
-
+ +
@@ -196,8 +218,90 @@ function renderSwDashboard(container: HTMLElement) {
+ +

${currentYear}년 소프트웨어 도입 비용

+
+
+

법인별 도입 금액 (원)

+ +
+
+

분야별 도입 금액 (원)

+ +
+
`; + // 차트 생성 + 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', () => { openSwUsageDetail('구독 소프트웨어 사용 목록', state.masterData.sw.filter(sw => sw.type === '구독SW')); @@ -305,3 +409,5 @@ function openSwUsageDetail(title: string, list: SoftwareAsset[]) { }); modal.classList.remove('hidden'); } + + From 818beae0dfb2dba1a84bbab8b95837f182d9434f Mon Sep 17 00:00:00 2001 From: JooWangi Date: Tue, 14 Apr 2026 14:09:28 +0900 Subject: [PATCH 4/6] =?UTF-8?q?feat:=20S/W=20=EC=9E=90=EC=82=B0=20?= =?UTF-8?q?=EC=A1=B0=ED=9A=8C=EC=9A=A9=20=EA=B2=80=EC=83=89/=ED=95=84?= =?UTF-8?q?=ED=84=B0=20=EB=B0=94=20=EC=B6=94=EA=B0=80=20=EB=B0=8F=20?= =?UTF-8?q?=ED=85=8C=EC=9D=B4=EB=B8=94=20=EB=A0=88=EC=9D=B4=EC=95=84?= =?UTF-8?q?=EC=9B=83=20=EC=B9=B4=EB=93=9C=ED=99=94?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - S/W 테이블 상단에 제품명/부서 및 분야/법인 필터링 바 추가 - 조회 바와 테이블을 각각 별도의 카드 스타일로 분리하여 시각적 완성도 높임 - 하드웨어 테이블에도 일관된 카드 레이아웃 구조 적용 --- src/style.css | 61 ++++++++++++++++ src/views/AssetTableView.ts | 140 +++++++++++++++++++++++++++++++----- 2 files changed, 182 insertions(+), 19 deletions(-) diff --git a/src/style.css b/src/style.css index 0bcbce1..7f0e5c4 100644 --- a/src/style.css +++ b/src/style.css @@ -356,3 +356,64 @@ tbody tr:hover { background-color: var(--bg-color); } display: flex; justify-content: space-between; align-items: center; } .footer-actions { display: flex; gap: 0.5rem; } + +/* Search Filter Bar */ +.search-bar { + display: flex; + flex-wrap: wrap; + gap: 1rem; + background-color: var(--white); + padding: 1.25rem; + border: 1px solid var(--border-color); + border-radius: 8px; + margin-bottom: 2rem; + align-items: flex-end; +} + +.search-item { + display: flex; + flex-direction: column; + gap: 0.5rem; + min-width: 180px; +} + +.search-item.flex-1 { + flex: 1; + min-width: 250px; +} + +.search-item label { + font-size: 0.75rem; + font-weight: 600; + color: var(--text-muted); + text-transform: uppercase; + letter-spacing: 0.05em; +} + +.search-item input, +.search-item select { + padding: 0.5rem 0.75rem; + border: 1px solid var(--border-color); + border-radius: 4px; + font-size: 0.875rem; + outline: none; + transition: all 0.2s; + background-color: var(--white); +} + +.search-item input:focus, +.search-item select:focus { + border-color: var(--primary-color); + box-shadow: 0 0 0 2px rgba(30, 81, 73, 0.1); +} + +.btn-reset { + height: 36px; + padding: 0 1rem; + display: flex; + align-items: center; + gap: 0.5rem; + font-size: 0.875rem; + color: var(--text-muted); +} + diff --git a/src/views/AssetTableView.ts b/src/views/AssetTableView.ts index 99184c0..1a0d4d4 100644 --- a/src/views/AssetTableView.ts +++ b/src/views/AssetTableView.ts @@ -11,7 +11,7 @@ import { openSwUserModal } from '../components/Modal/SWUserModal'; */ export function renderTable(mainContent: HTMLElement) { const container = document.createElement('div'); - container.className = 'table-container'; + container.className = 'view-container'; // 배경과 테두리가 없는 투명한 컨테이너 const table = document.createElement('table'); if (state.activeCategory === 'hw') { @@ -30,8 +30,11 @@ function renderHwTable(table: HTMLTableElement, container: HTMLElement, mainCont const list = state.masterData.hw.filter(a => a.type === state.activeSubTab); if (state.activeSubTab === '개인PC') { + const tableWrapper = document.createElement('div'); + tableWrapper.className = 'table-container'; table.innerHTML = `No법인자산코드사용자위치CPUGPURAMSSD1SSD2HDD1HDD2구매일금액납품업체품의서관리`; - container.appendChild(table); + tableWrapper.appendChild(table); + container.appendChild(tableWrapper); mainContent.appendChild(container); const tbody = document.getElementById('dynamic-tbody')!; if (list.length === 0) { tbody.innerHTML = `등록된 자산이 없습니다.`; return; } @@ -44,8 +47,11 @@ function renderHwTable(table: HTMLTableElement, container: HTMLElement, mainCont tbody.appendChild(tr); }); } else if (state.activeSubTab === '스토리지') { + const tableWrapper = document.createElement('div'); + tableWrapper.className = 'table-container'; table.innerHTML = `No법인유형자산코드명칭위치모델명용량담당자(정)IP주소구매일금액관리`; - container.appendChild(table); + tableWrapper.appendChild(table); + container.appendChild(tableWrapper); mainContent.appendChild(container); const tbody = document.getElementById('dynamic-tbody')!; if (list.length === 0) { tbody.innerHTML = `등록된 자산이 없습니다.`; return; } @@ -58,8 +64,11 @@ function renderHwTable(table: HTMLTableElement, container: HTMLElement, mainCont tbody.appendChild(tr); }); } else { + const tableWrapper = document.createElement('div'); + tableWrapper.className = 'table-container'; table.innerHTML = `No법인${state.activeSubTab === '전산비품' ? '유형' : ''}자산코드명칭위치관리자구매일금액관리`; - container.appendChild(table); + tableWrapper.appendChild(table); + container.appendChild(tableWrapper); mainContent.appendChild(container); const tbody = document.getElementById('dynamic-tbody')!; if (list.length === 0) { tbody.innerHTML = `등록된 자산이 없습니다.`; return; } @@ -75,25 +84,118 @@ function renderHwTable(table: HTMLTableElement, container: HTMLElement, mainCont } function renderSwTable(table: HTMLTableElement, container: HTMLElement, mainContent: HTMLElement) { - const list = state.masterData.sw.filter(a => a.type === state.activeSubTab); + const fullList = state.masterData.sw.filter(a => a.type === state.activeSubTab); const isSub = state.activeSubTab === '구독SW'; + + // 0. Container 준비 (조회 바 + 테이블) + container.innerHTML = ''; + // 1. 조회 바 (Filter Bar) 생성 + const filterBar = document.createElement('div'); + filterBar.className = 'search-bar'; + filterBar.innerHTML = ` +
+ + +
+
+ + +
+
+ + +
+ + `; + container.appendChild(filterBar); + + // 2. 테이블 기본 구조 생성 + const tableWrapper = document.createElement('div'); + tableWrapper.className = 'table-container'; table.classList.add('sw-table'); table.innerHTML = `No.분야법인부서제품명구매일${isSub ? '구독일' : ''}금액수량사용가능관리`; - container.appendChild(table); - mainContent.appendChild(container); - const tbody = document.getElementById('dynamic-tbody')!; - if (list.length === 0) { tbody.innerHTML = `정보가 없습니다.`; return; } - list.forEach((asset, idx) => { - const assigned = state.masterData.swUsers.filter(u => u.swId === asset.id).length; - const avail = (typeof asset.수량 === 'number' ? asset.수량 : parseInt(asset.수량||'0', 10)) - assigned; - const tr = document.createElement('tr'); - tr.style.cursor = 'pointer'; - tr.innerHTML = `${idx+1}${asset.분야||''}${asset.법인}${asset.부서||''}${asset.제품명}${asset.구매일||''}${isSub ? `${asset.구독일||''}` : ''}${asset.금액||'0'}${asset.수량}${avail}`; - tr.addEventListener('click', (e) => { if (!(e.target as HTMLElement).closest('button')) openSwModal(asset); }); - tr.querySelector('.btn-edit')?.addEventListener('click', () => openSwModal(asset)); - tr.querySelector('.btn-users')?.addEventListener('click', () => openSwUserModal(asset)); - tbody.appendChild(tr); + tableWrapper.appendChild(table); + container.appendChild(tableWrapper); + mainContent.appendChild(container); + + const tbody = document.getElementById('dynamic-tbody')!; + + // 3. 필터링 및 테이블 업데이트 로직 + const updateTable = () => { + const keyword = (document.getElementById('filter-keyword') as HTMLInputElement).value.toLowerCase().trim(); + const field = (document.getElementById('filter-field') as HTMLSelectElement).value; + const corp = (document.getElementById('filter-corp') as HTMLSelectElement).value; + + const filtered = fullList.filter(asset => { + const matchKeyword = !keyword || + (asset.제품명 || '').toLowerCase().includes(keyword) || + (asset.부서 || '').toLowerCase().includes(keyword); + const matchField = !field || asset.분야 === field; + const matchCorp = !corp || asset.법인 === corp; + return matchKeyword && matchField && matchCorp; + }); + + tbody.innerHTML = ''; + if (filtered.length === 0) { + tbody.innerHTML = `검색 결과가 없습니다.`; + return; + } + + filtered.forEach((asset, idx) => { + const assigned = state.masterData.swUsers.filter(u => u.swId === asset.id).length; + const avail = (typeof asset.수량 === 'number' ? asset.수량 : parseInt(asset.수량||'0', 10)) - assigned; + const tr = document.createElement('tr'); + tr.style.cursor = 'pointer'; + tr.innerHTML = `${idx+1}${asset.분야||''}${asset.법인}${asset.부서||''}${asset.제품명}${asset.구매일||''}${isSub ? `${asset.구독일||''}` : ''}${asset.금액||'0'}${asset.수량}${avail}`; + tr.addEventListener('click', (e) => { if (!(e.target as HTMLElement).closest('button')) openSwModal(asset); }); + tr.querySelector('.btn-edit')?.addEventListener('click', () => openSwModal(asset)); + tr.querySelector('.btn-users')?.addEventListener('click', () => openSwUserModal(asset)); + tbody.appendChild(tr); + }); + + // 버튼 내 아이콘 다시 그리기 + createIcons({ + icons: { Edit2, Users, RefreshCcw: CalendarClock } // RefreshCcw는 아래 버튼용 + }); + // 초기화 버튼 아이콘은 별도로 + createIcons({ + scope: filterBar + }); + }; + + // 4. 이벤트 바인딩 + const keywordInput = document.getElementById('filter-keyword') as HTMLInputElement; + const fieldSelect = document.getElementById('filter-field') as HTMLSelectElement; + const corpSelect = document.getElementById('filter-corp') as HTMLSelectElement; + const resetBtn = document.getElementById('btn-reset-filters') as HTMLButtonElement; + + keywordInput.addEventListener('input', updateTable); + fieldSelect.addEventListener('change', updateTable); + corpSelect.addEventListener('change', updateTable); + + resetBtn.addEventListener('click', () => { + keywordInput.value = ''; + fieldSelect.value = ''; + corpSelect.value = ''; + updateTable(); }); + + // 초기 실행 + updateTable(); } + From c83fa1cc5ac5e7cbe8c855ad0b50a717ea885510 Mon Sep 17 00:00:00 2001 From: JooWangi Date: Tue, 14 Apr 2026 16:13:54 +0900 Subject: [PATCH 5/6] =?UTF-8?q?PC=5FTable=20view=20=EB=B0=8F=20=EC=8A=A4?= =?UTF-8?q?=ED=83=80=EC=9D=BC=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- index.html | 3 +- src/{style.css => styles/common.css} | 54 ----------------------- src/styles/modal.css | 65 ++++++++++++++++++++++++++++ 3 files changed, 67 insertions(+), 55 deletions(-) rename src/{style.css => styles/common.css} (78%) create mode 100644 src/styles/modal.css diff --git a/index.html b/index.html index 758464e..fc8d33e 100644 --- a/index.html +++ b/index.html @@ -6,7 +6,8 @@ ITAM 자산관리 ERP - + + diff --git a/src/style.css b/src/styles/common.css similarity index 78% rename from src/style.css rename to src/styles/common.css index 7f0e5c4..f7bc4e0 100644 --- a/src/style.css +++ b/src/styles/common.css @@ -304,59 +304,6 @@ tbody tr:hover { background-color: var(--bg-color); } text-align: center; } -/* Modal */ -.modal-overlay { - position: fixed; - top: 0; left: 0; right: 0; bottom: 0; - background-color: rgba(0, 0, 0, 0.4); - display: flex; - align-items: center; - justify-content: center; - z-index: 1000; - opacity: 0; - visibility: hidden; - transition: opacity 0.2s ease, visibility 0.2s ease; -} - -.modal-overlay:not(.hidden) { opacity: 1; visibility: visible; } -.modal-content { - background-color: var(--white); - width: 100%; max-width: 600px; - border-radius: 8px; - overflow: hidden; - box-shadow: 0 10px 25px -5px rgba(0, 0, 0, 0.1); - transform: translateY(20px); - transition: transform 0.2s ease; -} -.modal-overlay:not(.hidden) .modal-content { transform: translateY(0); } -.modal-header { - background-color: var(--primary-color); - color: var(--white); - padding: 1rem 1.5rem; - display: flex; - justify-content: space-between; - align-items: center; -} -.modal-header h2 { font-size: 1.125rem; font-weight: 500; } -.modal-body { padding: 1.5rem; } -.grid-form { display: grid; grid-template-columns: 1fr 1fr; gap: 1.25rem; } -.form-group { display: flex; flex-direction: column; gap: 0.375rem; } -.form-group.full-width { grid-column: span 2; } -.form-group label { font-size: 0.875rem; font-weight: 500; } -.form-group input, .form-group textarea { - padding: 0.625rem; - border: 1px solid var(--border-color); - border-radius: 4px; - font-family: inherit; font-size: 0.875rem; - outline: none; transition: border-color 0.2s; -} -.form-group input:focus, .form-group textarea:focus { border-color: var(--primary-color); } -.modal-footer { - padding: 1rem 1.5rem; border-top: 1px solid var(--border-color); - display: flex; justify-content: space-between; align-items: center; -} -.footer-actions { display: flex; gap: 0.5rem; } - /* Search Filter Bar */ .search-bar { display: flex; @@ -416,4 +363,3 @@ tbody tr:hover { background-color: var(--bg-color); } font-size: 0.875rem; color: var(--text-muted); } - diff --git a/src/styles/modal.css b/src/styles/modal.css new file mode 100644 index 0000000..83e142c --- /dev/null +++ b/src/styles/modal.css @@ -0,0 +1,65 @@ +/* Modal */ +.modal-overlay { + position: fixed; + top: 0; left: 0; right: 0; bottom: 0; + background-color: rgba(0, 0, 0, 0.4); + display: flex; + align-items: center; + justify-content: center; + z-index: 1000; + opacity: 0; + visibility: hidden; + transition: opacity 0.2s ease, visibility 0.2s ease; +} + +.modal-overlay:not(.hidden) { opacity: 1; visibility: visible; } + +.modal-content { + background-color: var(--white); + width: 100%; max-width: 600px; + border-radius: 8px; + overflow: hidden; + box-shadow: 0 10px 25px -5px rgba(0, 0, 0, 0.1); + transform: translateY(20px); + transition: transform 0.2s ease; +} + +.modal-overlay:not(.hidden) .modal-content { transform: translateY(0); } + +.modal-header { + background-color: var(--primary-color); + color: var(--white); + padding: 1rem 1.5rem; + display: flex; + justify-content: space-between; + align-items: center; +} + +.modal-header h2 { font-size: 1.125rem; font-weight: 500; } + +.modal-body { padding: 1.5rem; } + +.grid-form { display: grid; grid-template-columns: 1fr 1fr; gap: 1.25rem; } + +.form-group { display: flex; flex-direction: column; gap: 0.375rem; } + +.form-group.full-width { grid-column: span 2; } + +.form-group label { font-size: 0.875rem; font-weight: 500; } + +.form-group input, .form-group textarea { + padding: 0.625rem; + border: 1px solid var(--border-color); + border-radius: 4px; + font-family: inherit; font-size: 0.875rem; + outline: none; transition: border-color 0.2s; +} + +.form-group input:focus, .form-group textarea:focus { border-color: var(--primary-color); } + +.modal-footer { + padding: 1rem 1.5rem; border-top: 1px solid var(--border-color); + display: flex; justify-content: space-between; align-items: center; +} + +.footer-actions { display: flex; gap: 0.5rem; } From c14eff82783d86c0056af016db05b9aee3280aac Mon Sep 17 00:00:00 2001 From: JooWangi Date: Tue, 14 Apr 2026 17:22:06 +0900 Subject: [PATCH 6/6] =?UTF-8?q?feat:=20=EA=B0=9C=EC=9D=B8=20PC=20=EC=88=98?= =?UTF-8?q?=EC=A0=95=20=EC=9D=B4=EB=A0=A5(Log)=20=EC=8B=9C=EC=8A=A4?= =?UTF-8?q?=ED=85=9C=20=EA=B5=AC=ED=98=84=20=EB=B0=8F=20UI=20=EA=B0=9C?= =?UTF-8?q?=EC=84=A0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- index.html | 28 +++++++--- src/components/Modal/PCModal.ts | 96 ++++++++++++++++++++++++++++++--- src/excelHandler.ts | 39 +++++++++++++- src/main.ts | 4 +- src/state.ts | 5 +- src/styles/modal.css | 96 +++++++++++++++++++++++++++++++++ 6 files changed, 250 insertions(+), 18 deletions(-) diff --git a/index.html b/index.html index fc8d33e..d2138f8 100644 --- a/index.html +++ b/index.html @@ -172,13 +172,15 @@