37 Commits

Author SHA1 Message Date
68cb5f9767 Fix: Excel upload logic, field mapping for servers, and date format synchronization 2026-04-27 11:12:20 +09:00
8f0508a7d0 Implement global table sorting, dashboard UI enhancements, and secret cloud access 2026-04-27 09:30:47 +09:00
171bcc772b feat: setup Filter branch and apply software management fixes 2026-04-27 09:11:22 +09:00
ab0d25b827 docs: Add branch difference summaries for Gitea issues 2026-04-23 20:44:47 +09:00
d7af75976e fix: Use absolute API paths in upload modal and fix cloud endpoint 2026-04-23 20:34:56 +09:00
dde3aefaac fix: Ensure upload preview modal opens and add debug logs 2026-04-23 20:28:05 +09:00
1fbd297988 feat: Add bulk asset code generation in upload review modal 2026-04-23 20:25:58 +09:00
4b5e25fd3f feat: Implement Excel bulk upload with review modal and domain support 2026-04-23 20:14:51 +09:00
367f72673d feat: 운영 서비스 도메인 관리 기능 추가 및 UI 간격 조정 2026-04-23 20:06:56 +09:00
9fcecd4bf5 feat: 소프트웨어 자산 관리 기능 고도화 및 대시보드 누적 비용 분석 기능 추가 2026-04-23 19:47:07 +09:00
d125de1902 fix: SW modal type switching now works dynamically like HW modal (hidden input ID collision resolved) 2026-04-23 18:53:10 +09:00
d8a0c47fb3 fix: restore Cloud tab under SW menu, change date fields to yyyy-mm-dd, add start_date field 2026-04-23 18:40:58 +09:00
4b88ac01a4 fix: resolve all TypeScript build errors after Setting branch merge 2026-04-23 18:36:33 +09:00
5feaa5f170 Merge origin/setting into SW_Table and resolve conflicts 2026-04-23 18:22:34 +09:00
9365af4522 feat: implement unified schema mapper, enhance UI/UX with responsive design, and optimize asset log logic 2026-04-23 18:00:10 +09:00
bb1cc36d01 feat: update UI title, restore guide functionality, and simplify server list view 2026-04-23 14:48:06 +09:00
e5b4eb8295 feat: restore database from backup and fix date formatting in restore script 2026-04-23 13:37:45 +09:00
b996b18dbc Complete merge with cleaned main branch 2026-04-23 10:22:08 +09:00
e147b1a191 Fix merge conflicts in HWModal.ts and restore optimized structure 2026-04-23 10:21:08 +09:00
925a55bcc6 Merge latest main with optimized multi-branch features into server_dashboard 2026-04-23 10:18:11 +09:00
809f3fcf3b Merge all feature branches into main and optimize core architecture 2026-04-23 10:16:31 +09:00
9d9c482b76 Merge and optimize server modal with setting branch features 2026-04-23 09:47:09 +09:00
11e2f3b4ca Merge latest code from main into server_dashboard and resolve conflicts 2026-04-23 09:35:53 +09:00
e1cdcfd93a feat: 하드웨어 자동 변경 이력 생성 및 자산 관리 프로세스 고도화 2026-04-22 17:15:58 +09:00
af37df7f2d merge: Equip_table 통합 및 자산번호 YYYYMM 체계 확립 2026-04-22 11:28:18 +09:00
d52c2c4200 feat: 구매연월 표준화 및 자산번호 YYYYMM 형식 적용 2026-04-22 11:24:15 +09:00
4b765aba2e feat: 자산 유형별 UI 최적화 및 자산번호 자동 생성 기능 구현
- CPU/GPU/RAM/HDD 등 부품 유형별 필드 라벨 동적 변경 로직 추가\n- 유형별 불필요한 사양 필드 숨김 처리 및 UI 레이아웃 정교화\n- 서버측 자산번호 생성 API (/api/generate-asset-code) 구현\n- 모달 내 자산번호 자동 생성 버튼 이벤트 연동 및 백엔드 동기화
2026-04-22 10:11:45 +09:00
7247737ce0 merge: 통합 HW 모달 구현 (PC 상세유형 복구 + 전산비품/모바일 확장 통합) 2026-04-21 18:09:13 +09:00
e4d958b5f2 fix: PC 상세 유형(개인PC, 서버) 선택 및 UI 제어 로직 복구 2026-04-21 17:56:29 +09:00
ba7ce796d1 feat: 전산비품 및 모바일기기 관리 기능 확장 (보관위치, 상태관리, 분출이력) 2026-04-21 17:52:46 +09:00
5ff991693a feat: 하드웨어 대시보드 노후도 중심 개편 및 자산 연령 계산 유틸리티 추가 2026-04-21 16:59:21 +09:00
a576d54a2d backup: stable baseline before hardware dashboard revamp 2026-04-21 16:35:29 +09:00
90d94739a2 merge: main 브랜치의 최신 엑셀 확장 기능 병합 및 충돌 해결 2026-04-17 15:54:44 +09:00
6053c746a3 merge: main 브랜치의 최신 변경 사항 병합 및 충돌 해결 2026-04-17 15:37:57 +09:00
54bfb9d482 feat: update server asset details, ui labels, and excel mapping logic 2026-04-17 10:34:32 +09:00
7158689fd0 feat: 서버 상세 모달 구매일자 필드 추가 및 특정 법인명 제거 2026-04-16 18:07:19 +09:00
fde7ef8439 feat: 서버 리스트 보안 강화 및 위치 정보 포맷팅 개선, 모달 시스템 안정화
주요 변경 사항:
- 리스트 보안 강화: 서버 자산 리스트에서 IP 주소 및 원격접속 컬럼 제거 (상세 모달에서만 노출)
- 보안 배지 가독성 개선: 상세 모달 내 개별 필드 배지를 '네트워크 정보' 섹션 타이틀 옆으로 통합 이동
- 위치 정보 포맷팅: 서버 리스트 내 '서관/동관' 시작 위치에 'IDC' 접두사 자동 추가 (예: IDC(서관 204번))
- 모달 시스템 복구: 이벤트 위임 방식을 통한 전역 ESC 키 및 닫기 버튼 기능 완벽 복구
- 안정성 확보: BaseModal 초기화 로직 보완 및 동적 DOM 요소 대응 강화
2026-04-15 17:52:37 +09:00
63 changed files with 8479 additions and 2195 deletions

View File

@@ -0,0 +1,55 @@
<!DOCTYPE html>
<html lang="ko">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>ITAM 자산관리 ERP</title>
<link rel="stylesheet" href="https://cdn.jsdelivr.net/gh/orioncactus/pretendard@v1.3.9/dist/web/variable/pretendardvariable.min.css" />
<link rel="stylesheet" href="/src/styles/common.css" />
<link rel="stylesheet" href="/src/styles/modal.css" />
<script src="https://cdn.jsdelivr.net/npm/chart.js"></script>
<script src="https://cdn.jsdelivr.net/npm/chartjs-plugin-datalabels@2.0.0"></script>
</head>
<body>
<div class="app-layout">
<!-- Single-Line Integrated Header -->
<header class="main-header">
<div class="header-container" id="nav-container">
<div class="brand">
<h1>HM <span>ITAM</span></h1>
</div>
<!-- Navigation (GNB + LNB in same row) -->
<nav class="integrated-nav" id="main-nav">
<!-- JS will render main items and sub items here side-by-side -->
</nav>
<div class="header-actions">
<button id="btn-download-template" class="btn btn-outline" title="통합 양식 다운로드">
<i data-lucide="download"></i> 양식
</button>
<label for="excel-upload" class="btn btn-outline" title="엑셀 파일 업로드">
<i data-lucide="upload"></i> 업로드
</label>
<input type="file" id="excel-upload" accept=".xlsx, .xls" style="display: none;" />
<button id="btn-export-excel" class="btn btn-primary" title="일괄 엑셀 저장">
<i data-lucide="file-spreadsheet"></i> 엑셀저장
</button>
<button id="btn-add-asset" class="btn btn-primary hidden">
<i data-lucide="plus"></i> 자산추가
</button>
</div>
</div>
</header>
<!-- Main Content Area -->
<main class="content-area" id="main-content">
<!-- Components inject views here -->
</main>
</div>
<!-- All modals are injected dynamically -->
<script type="module" src="/src/main.ts"></script>
</body>
</html>

View File

@@ -0,0 +1,25 @@
{
"name": "hm-itam",
"private": true,
"version": "0.0.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "tsc && vite build",
"preview": "vite preview",
"server": "node server.js",
"db-init": "node db_init.js"
},
"devDependencies": {
"typescript": "^5.2.2",
"vite": "^5.2.0"
},
"dependencies": {
"cors": "^2.8.6",
"dotenv": "^17.4.2",
"express": "^5.2.1",
"lucide": "^0.364.0",
"mysql2": "^3.22.1",
"xlsx": "^0.18.5"
}
}

View File

@@ -0,0 +1,35 @@
/**
* 모든 모달의 공통 기능 (닫기, ESC 처리, 배경 클릭 등)을 관리하는 베이스 모듈입니다.
*/
export function initBaseModal() {
const closeAllModals = () => {
const modals = document.querySelectorAll('.modal-overlay');
modals.forEach(modal => modal.classList.add('hidden'));
};
// ESC 키로 닫기
window.addEventListener('keydown', (e) => {
if (e.key === 'Escape') closeAllModals();
});
// 배경(Overlay) 클릭 시 닫기 (동적 생성된 모달 대응을 위해 이벤트 위임 고려 가능하나 일단 단순 구현)
document.addEventListener('click', (e) => {
const target = e.target as HTMLElement;
if (target.classList.contains('modal-overlay')) {
closeAllModals();
}
});
return { closeAllModals };
}
/**
* 특정 모달을 엽니다.
* @param modalId 모달 엘리먼트의 ID
*/
export function openModal(modalId: string) {
const modal = document.getElementById(modalId);
if (modal) {
modal.classList.remove('hidden');
}
}

View File

@@ -0,0 +1,109 @@
import { HardwareAsset, SoftwareAsset } from '../../core/excelHandler';
import { state } from '../../core/state';
const DASHBOARD_DETAIL_MODAL_HTML = `
<div id="dashboard-detail-modal" class="modal-overlay hidden">
<div class="modal-content wide" style="max-width: 1000px;">
<div class="modal-header">
<h2 id="dashboard-detail-modal-title">상세 목록</h2>
<button id="btn-close-dashboard-detail-modal" class="btn-icon" aria-label="닫기"><i data-lucide="x"></i></button>
</div>
<div class="modal-body">
<div class="table-container">
<table style="width:100%;">
<thead></thead>
<tbody id="dashboard-detail-tbody"></tbody>
</table>
</div>
</div>
<div class="modal-footer">
<div></div>
<button id="btn-cancel-dashboard-detail-modal" class="btn btn-outline">닫기</button>
</div>
</div>
</div>
`;
export function initDashboardDetailModal() {
if (!document.getElementById('dashboard-detail-modal')) {
document.body.insertAdjacentHTML('beforeend', DASHBOARD_DETAIL_MODAL_HTML);
}
const modal = document.getElementById('dashboard-detail-modal')!;
const closeBtn = document.getElementById('btn-close-dashboard-detail-modal')!;
const cancelBtn = document.getElementById('btn-cancel-dashboard-detail-modal')!;
const closeModal = () => modal.classList.add('hidden');
closeBtn.addEventListener('click', closeModal);
cancelBtn.addEventListener('click', closeModal);
modal.addEventListener('click', (e) => { if (e.target === modal) closeModal(); });
}
export function openDashboardDetail(title: string, list: HardwareAsset[]) {
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 = `<tr><th>No</th><th>유형</th><th>자산코드</th><th>명칭/모델</th><th>위치</th><th>담당/사용자</th><th>구매일</th><th>금액</th></tr>`;
tbody.innerHTML = '';
if (list.length === 0) {
tbody.innerHTML = `<tr><td colspan="8" style="text-align:center; padding: 2rem;">해당 조건의 자산이 없습니다.</td></tr>`;
} else {
list.forEach((asset, idx) => {
let manager = asset. || asset. || asset._정 || '-';
let name = asset. || asset. || '-';
const tr = document.createElement('tr');
tr.innerHTML = `<td>${idx+1}</td><td>${asset.type}</td><td>${asset.}</td><td>${name}</td><td>${asset.||'-'}</td><td>${manager}</td><td>${asset.||'-'}</td><td>${asset.||'-'}</td>`;
tbody.appendChild(tr);
});
}
modal.classList.remove('hidden');
}
export function openSwDashboardDetail(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 = `<tr><th>No</th><th>유형</th><th>법인</th><th>제품명</th><th>수량</th><th>금액</th></tr>`;
tbody.innerHTML = '';
list.forEach((sw, idx) => {
const tr = document.createElement('tr');
tr.innerHTML = `<td>${idx+1}</td><td>${sw.type}</td><td>${sw.}</td><td>${sw.}</td><td>${sw.}</td><td>${sw.}</td>`;
tbody.appendChild(tr);
});
modal.classList.remove('hidden');
}
export function openSwUsageDetail(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 = `<tr><th>No</th><th>법인</th><th>제품명</th><th>수량</th><th>사용중</th><th>사용가능</th></tr>`;
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 = `<td>${idx+1}</td><td>${sw.}</td><td>${sw.}</td><td>${qty}</td><td>${assigned}</td><td>${avail}</td>`;
tbody.appendChild(tr);
});
modal.classList.remove('hidden');
}

View File

@@ -0,0 +1,335 @@
import { state } from '../../core/state';
import { HardwareAsset } from '../../core/excelHandler';
import { renderTable } from '../../views/AssetTableView';
import { createIcons, Paperclip } from 'lucide';
let currentAsset: HardwareAsset | null = null;
let isEditMode = false;
const HW_MODAL_HTML = `
<div id="hw-asset-modal" class="modal-overlay hidden">
<div class="modal-content wide">
<div class="modal-header">
<h2 id="hw-modal-title">자산 상세 정보</h2>
<button id="btn-close-hw-modal" class="btn-icon" aria-label="닫기"><i data-lucide="x"></i></button>
</div>
<div class="modal-body">
<form id="hw-asset-form" class="grid-form">
<input type="hidden" id="hw-asset-id" />
<input type="hidden" id="hw-asset-type" />
<!-- Group 1: 기본 정보 -->
<div class="form-section-title">기본 정보 (Identity)</div>
<div class="form-group">
<label for="hw-법인">법인</label>
<input type="text" id="hw-법인" required />
</div>
<div class="form-group">
<label for="hw-자산코드">자산번호/코드</label>
<input type="text" id="hw-자산코드" required />
</div>
<div class="form-group server-only">
<label for="hw-용도">용도</label>
<input type="text" id="hw-용도" />
</div>
<div class="form-group server-only">
<label for="hw-상세">상세 내용</label>
<input type="text" id="hw-상세" />
</div>
<div class="form-group non-server">
<label for="hw-명칭">명칭</label>
<input type="text" id="hw-명칭" />
</div>
<div class="form-group full-width server-only">
<label for="hw-비고">비고</label>
<input type="text" id="hw-비고" />
</div>
<!-- Group 2: 네트워크 정보 -->
<div class="form-section-title server-only">네트워크 정보 (Connectivity)</div>
<div class="form-group server-only">
<label for="hw-IP주소">IP 주소 1</label>
<input type="text" id="hw-IP주소" />
</div>
<div class="form-group server-only">
<label for="hw-IP2">IP 주소 2</label>
<input type="text" id="hw-IP2" />
</div>
<div class="form-group server-only">
<label for="hw-원격접속">원격 도구 (Anydesk/Chrome 등)</label>
<input type="text" id="hw-원격접속" />
</div>
<div class="form-group server-only">
<label for="hw-서버ID">서버 ID</label>
<input type="text" id="hw-서버ID" />
</div>
<div class="form-group server-only">
<label for="hw-서버PW">서버 PW</label>
<input type="text" id="hw-서버PW" />
</div>
<div class="form-group non-server" id="hw-IP주소-group">
<label for="hw-IP주소-non-server">IP 주소</label>
<input type="text" id="hw-IP주소-non-server" />
</div>
<!-- Group 3: 시스템 사양 -->
<div class="form-section-title">시스템 사양 (Specifications)</div>
<div class="form-group">
<label for="hw-모델명">모델명</label>
<input type="text" id="hw-모델명" />
</div>
<div class="form-group">
<label for="hw-OS">운영체제 (OS)</label>
<input type="text" id="hw-OS" />
</div>
<div class="form-group">
<label for="hw-CPU">CPU 사양</label>
<input type="text" id="hw-CPU" />
</div>
<div class="form-group">
<label for="hw-RAM">RAM 용량</label>
<input type="text" id="hw-RAM" />
</div>
<div class="form-group">
<label for="hw-SSD1">Storage 1 (SSD/HDD)</label>
<input type="text" id="hw-SSD1" />
</div>
<div class="form-group">
<label for="hw-SSD2">Storage 2 (SSD/HDD)</label>
<input type="text" id="hw-SSD2" />
</div>
<div class="form-group server-only">
<label for="hw-모니터링">모니터링 여부</label>
<input type="text" id="hw-모니터링" />
</div>
<div class="form-group" id="hw-비품유형-group" style="display:none;">
<label for="hw-비품유형">비품유형</label>
<select id="hw-비품유형">
<option value="노트북">노트북</option><option value="태블릿">태블릿</option><option value="휴대폰">휴대폰</option>
</select>
</div>
<div class="form-group full-width non-server">
<label for="hw-HW사양">H/W 사양 상세</label>
<textarea id="hw-HW사양" rows="2"></textarea>
</div>
<!-- Group 4: 관리 및 운영 -->
<div class="form-section-title">관리 및 운영 (Operation)</div>
<div class="form-group">
<label for="hw-위치">설치위치</label>
<input type="text" id="hw-위치" />
</div>
<div class="form-group">
<label for="hw-담당자_정">담당자 (정)</label>
<input type="text" id="hw-담당자_정" />
</div>
<div class="form-group">
<label for="hw-담당자_부">담당자 (부)</label>
<input type="text" id="hw-담당자_부" />
</div>
<div class="form-group non-server">
<label for="hw-구매일">구매일</label>
<input type="text" id="hw-구매일" />
</div>
<div class="form-group non-server">
<label for="hw-금액">금액</label>
<input type="text" id="hw-금액" oninput="this.value = this.value.replace(/[^0-9]/g, '').replace(/\\B(?=(\\d{3})+(?!\d))/g, ',')" />
</div>
<div class="form-group full-width">
<label>품의서 (파일 증빙)</label>
<div style="display:flex; align-items:center; gap:0.5rem;">
<input type="file" id="hw-품의서" />
<span id="hw-품의서명" style="font-size:0.75rem; color:var(--text-light)"></span>
</div>
</div>
</form>
</div>
<div class="modal-footer">
<button id="btn-delete-hw-asset" class="btn btn-outline btn-danger">삭제</button>
<div class="footer-actions">
<button id="btn-revert-hw-edit" class="btn btn-outline hidden">수정 취소</button>
<button id="btn-cancel-hw-modal" class="btn btn-outline">닫기</button>
<button id="btn-save-hw-asset" class="btn btn-primary">수정</button>
</div>
</div>
</div>
</div>
`;
export function openHwModal(asset: HardwareAsset) {
currentAsset = asset;
isEditMode = false;
const modal = document.getElementById('hw-asset-modal')!;
const form = document.getElementById('hw-asset-form') as HTMLFormElement;
const saveBtn = document.getElementById('btn-save-hw-asset')!;
const revertBtn = document.getElementById('btn-revert-hw-edit')!;
form.reset();
form.classList.remove('is-edit-mode');
form.classList.add('is-view-mode');
saveBtn.textContent = '수정';
revertBtn.classList.add('hidden');
fillHwFormData(asset);
modal.classList.remove('hidden');
createIcons({ icons: { Paperclip } });
}
function fillHwFormData(asset: HardwareAsset) {
(document.getElementById('hw-asset-id') as HTMLInputElement).value = asset.id;
(document.getElementById('hw-asset-type') as HTMLInputElement).value = asset.type;
(document.getElementById('hw-법인') as HTMLInputElement).value = asset.;
(document.getElementById('hw-자산코드') as HTMLInputElement).value = asset.;
(document.getElementById('hw-위치') as HTMLInputElement).value = asset.;
(document.getElementById('hw-모델명') as HTMLInputElement).value = asset. || '';
(document.getElementById('hw-OS') as HTMLInputElement).value = asset.OS || '';
(document.getElementById('hw-CPU') as HTMLInputElement).value = asset.CPU || '';
(document.getElementById('hw-RAM') as HTMLInputElement).value = asset.RAM || '';
(document.getElementById('hw-SSD1') as HTMLInputElement).value = asset.SSD1 || '';
(document.getElementById('hw-SSD2') as HTMLInputElement).value = asset.SSD2 || '';
(document.getElementById('hw-담당자_정') as HTMLInputElement).value = asset._정 || asset. || '';
(document.getElementById('hw-담당자_부') as HTMLInputElement).value = asset._부 || '';
(document.getElementById('hw-품의서명') as HTMLElement).textContent = asset. || '';
const serverOnly = document.querySelectorAll('.server-only');
const nonServer = document.querySelectorAll('.non-server');
const equipGroup = document.getElementById('hw-비품유형-group')!;
if (asset.type === '서버') {
serverOnly.forEach(el => (el as HTMLElement).style.display = 'flex');
nonServer.forEach(el => (el as HTMLElement).style.display = 'none');
equipGroup.style.display = 'none';
(document.getElementById('hw-용도') as HTMLInputElement).value = asset. || '';
(document.getElementById('hw-상세') as HTMLInputElement).value = asset. || '';
(document.getElementById('hw-비고') as HTMLInputElement).value = asset. || '';
(document.getElementById('hw-IP주소') as HTMLInputElement).value = asset.IP주소 || '';
(document.getElementById('hw-IP2') as HTMLInputElement).value = (asset as any).IP2 || '';
(document.getElementById('hw-원격접속') as HTMLInputElement).value = asset. || '';
(document.getElementById('hw-서버ID') as HTMLInputElement).value = (asset as any).ID || '';
(document.getElementById('hw-서버PW') as HTMLInputElement).value = (asset as any).PW || '';
(document.getElementById('hw-모니터링') as HTMLInputElement).value = asset. || '';
} else {
serverOnly.forEach(el => (el as HTMLElement).style.display = 'none');
nonServer.forEach(el => (el as HTMLElement).style.display = 'flex');
(document.getElementById('hw-명칭') as HTMLInputElement).value = asset. || '';
(document.getElementById('hw-구매일') as HTMLInputElement).value = asset. || '';
(document.getElementById('hw-금액') as HTMLInputElement).value = asset. || '';
(document.getElementById('hw-HW사양') as HTMLTextAreaElement).value = asset.HW사양 || '';
(document.getElementById('hw-IP주소-non-server') as HTMLInputElement).value = asset.IP주소 || '';
if (asset.type === '전산비품') {
equipGroup.style.display = 'flex';
(document.getElementById('hw-비품유형') as HTMLSelectElement).value = asset. || '노트북';
} else {
equipGroup.style.display = 'none';
}
}
}
export function initHwModal() {
// HTML 주입
if (!document.getElementById('hw-asset-modal')) {
document.body.insertAdjacentHTML('beforeend', HW_MODAL_HTML);
}
const modal = document.getElementById('hw-asset-modal')!;
const form = document.getElementById('hw-asset-form') as HTMLFormElement;
const closeBtn = document.getElementById('btn-close-hw-modal')!;
const cancelBtn = document.getElementById('btn-cancel-hw-modal')!;
const saveBtn = document.getElementById('btn-save-hw-asset')!;
const revertBtn = document.getElementById('btn-revert-hw-edit')!;
const deleteBtn = document.getElementById('btn-delete-hw-asset')!;
const closeModal = () => {
modal.classList.add('hidden');
isEditMode = false;
};
const switchToViewMode = () => {
isEditMode = false;
form.classList.remove('is-edit-mode');
form.classList.add('is-view-mode');
saveBtn.textContent = '수정';
revertBtn.classList.add('hidden');
if (currentAsset) fillHwFormData(currentAsset);
};
closeBtn.addEventListener('click', closeModal);
cancelBtn.addEventListener('click', closeModal);
modal.addEventListener('click', (e) => { if (e.target === modal) closeModal(); });
revertBtn.addEventListener('click', () => { switchToViewMode(); });
saveBtn.addEventListener('click', () => {
if (!currentAsset) return;
if (!isEditMode) {
isEditMode = true;
form.classList.remove('is-view-mode');
form.classList.add('is-edit-mode');
saveBtn.textContent = '저장';
revertBtn.classList.remove('hidden');
return;
}
const assetId = (document.getElementById('hw-asset-id') as HTMLInputElement).value;
const type = (document.getElementById('hw-asset-type') as HTMLInputElement).value;
const updated: HardwareAsset = {
...currentAsset,
: (document.getElementById('hw-법인') as HTMLInputElement).value,
: (document.getElementById('hw-자산코드') as HTMLInputElement).value,
: (document.getElementById('hw-위치') as HTMLInputElement).value,
: (document.getElementById('hw-모델명') as HTMLInputElement).value,
OS: (document.getElementById('hw-OS') as HTMLInputElement).value,
CPU: (document.getElementById('hw-CPU') as HTMLInputElement).value,
RAM: (document.getElementById('hw-RAM') as HTMLInputElement).value,
SSD1: (document.getElementById('hw-SSD1') as HTMLInputElement).value,
SSD2: (document.getElementById('hw-SSD2') as HTMLInputElement).value,
_정: (document.getElementById('hw-담당자_정') as HTMLInputElement).value,
: (document.getElementById('hw-담당자_정') as HTMLInputElement).value,
_부: (document.getElementById('hw-담당자_부') as HTMLInputElement).value,
};
if (type === '서버') {
updated. = (document.getElementById('hw-용도') as HTMLInputElement).value;
updated. = (document.getElementById('hw-상세') as HTMLInputElement).value;
updated. = (document.getElementById('hw-비고') as HTMLInputElement).value;
updated.IP주소 = (document.getElementById('hw-IP주소') as HTMLInputElement).value;
(updated as any).IP2 = (document.getElementById('hw-IP2') as HTMLInputElement).value;
updated. = (document.getElementById('hw-원격접속') as HTMLInputElement).value;
(updated as any).ID = (document.getElementById('hw-서버ID') as HTMLInputElement).value;
(updated as any).PW = (document.getElementById('hw-서버PW') as HTMLInputElement).value;
updated. = (document.getElementById('hw-모니터링') as HTMLInputElement).value;
} else {
updated. = (document.getElementById('hw-명칭') as HTMLInputElement).value;
updated. = (document.getElementById('hw-구매일') as HTMLInputElement).value;
updated. = (document.getElementById('hw-금액') as HTMLInputElement).value;
updated.HW사양 = (document.getElementById('hw-HW사양') as HTMLTextAreaElement).value;
updated.IP주소 = (document.getElementById('hw-IP주소-non-server') as HTMLInputElement).value;
if (type === '전산비품') {
updated. = (document.getElementById('hw-비품유형') as HTMLSelectElement).value;
}
}
const idx = state.masterData.hw.findIndex(a => a.id === assetId);
if (idx > -1) {
state.masterData.hw[idx] = updated;
renderTable(document.getElementById('main-content')!);
switchToViewMode();
}
});
deleteBtn.addEventListener('click', () => {
if (!currentAsset) return;
if (confirm('정말로 이 자산을 삭제하시겠습니까?')) {
state.masterData.hw = state.masterData.hw.filter(a => a.id !== currentAsset!.id);
renderTable(document.getElementById('main-content')!);
closeModal();
}
});
}

View File

@@ -0,0 +1,342 @@
import { state } from '../../core/state';
import { HardwareAsset, HardwareLog } from '../../core/excelHandler';
import { openModal } from './BaseModal';
const PC_MODAL_HTML = `
<div id="pc-asset-modal" class="modal-overlay hidden">
<div class="modal-content wide">
<div class="modal-header">
<h2 id="pc-modal-title">개인PC 상세 정보</h2>
<button id="btn-close-pc-modal" class="btn-icon" aria-label="닫기"><i data-lucide="x"></i></button>
</div>
<div class="modal-body">
<div class="modal-body-split">
<div class="modal-form-area">
<form id="pc-asset-form" class="grid-form">
<input type="hidden" id="pc-asset-id" />
<input type="hidden" id="pc-asset-type" value="개인PC" />
<div class="form-group">
<label for="pc-법인">법인</label>
<select id="pc-법인" required>
<option value="한맥">한맥 (HM)</option><option value="삼안">삼안 (SM)</option><option value="바론">바론 (BR)</option>
</select>
</div>
<div class="form-group">
<label for="pc-자산코드">자산코드</label>
<input type="text" id="pc-자산코드" placeholder="ex) HM-PC-2018-001" required />
</div>
<div class="form-group">
<label for="pc-사용자">사용자</label>
<input type="text" id="pc-사용자" required />
</div>
<div class="form-group">
<label for="pc-위치">위치</label>
<input type="text" id="pc-위치" />
</div>
<div class="form-group">
<label for="pc-CPU">CPU</label>
<input type="text" id="pc-CPU" />
</div>
<div class="form-group">
<label for="pc-GPU">GPU</label>
<input type="text" id="pc-GPU" />
</div>
<div class="form-group">
<label for="pc-RAM">RAM</label>
<input type="text" id="pc-RAM" />
</div>
<div class="form-group">
<label for="pc-SSD1">SSD1</label>
<input type="text" id="pc-SSD1" />
</div>
<div class="form-group">
<label for="pc-SSD2">SSD2</label>
<input type="text" id="pc-SSD2" />
</div>
<div class="form-group">
<label for="pc-HDD1">HDD1</label>
<input type="text" id="pc-HDD1" />
</div>
<div class="form-group">
<label for="pc-HDD2">HDD2</label>
<input type="text" id="pc-HDD2" />
</div>
<div class="form-group">
<label for="pc-구매일">구매일</label>
<input type="text" id="pc-구매일" placeholder="ex) 2024-01-01" />
</div>
<div class="form-group">
<label for="pc-금액">금액</label>
<input type="text" id="pc-금액" placeholder="ex) 1,000,000" oninput="this.value = this.value.replace(/[^0-9]/g, '').replace(/\\B(?=(\\d{3})+(?!\d))/g, ',')" />
</div>
<div class="form-group">
<label for="pc-납품업체">납품업체</label>
<input type="text" id="pc-납품업체" />
</div>
<div class="form-group full-width">
<label>품의서 (파일)</label>
<div style="display:flex; align-items:center; gap:0.5rem;">
<input type="file" id="pc-품의서" />
<span id="pc-품의서명" style="font-size:0.75rem; color:var(--text-light)"></span>
</div>
</div>
</form>
</div>
<div class="modal-history-area">
<div class="history-header">
<h3><i data-lucide="history" style="width:16px; height:16px;"></i> 수정 이력</h3>
</div>
<div id="pc-history-list" class="history-timeline">
<div class="empty-history">이력이 없습니다.</div>
</div>
</div>
</div>
</div>
<div class="modal-footer">
<button id="btn-delete-pc-asset" class="btn btn-outline btn-danger">삭제</button>
<div class="footer-actions">
<button id="btn-revert-pc-edit" class="btn btn-outline hidden">수정 취소</button>
<button id="btn-close-pc-footer" class="btn btn-outline">닫기</button>
<button id="btn-save-pc-asset" class="btn btn-primary">수정</button>
</div>
</div>
</div>
</div>
`;
export function initPcModal(renderContent: () => void, closeModals: () => void) {
if (!document.getElementById('pc-asset-modal')) {
document.body.insertAdjacentHTML('beforeend', PC_MODAL_HTML);
}
const pcForm = document.getElementById('pc-asset-form') as HTMLFormElement;
const btnRevertEdit = document.getElementById('btn-revert-pc-edit') as HTMLButtonElement;
const btnSavePc = document.getElementById('btn-save-pc-asset') as HTMLButtonElement;
const btnDeletePc = document.getElementById('btn-delete-pc-asset') as HTMLButtonElement;
const btnCloseHeader = document.getElementById('btn-close-pc-modal') as HTMLButtonElement;
const btnCloseFooter = document.getElementById('btn-close-pc-footer') as HTMLButtonElement;
let isEditMode = false;
let currentAsset: HardwareAsset | null = null;
const setEditMode = (edit: boolean) => {
isEditMode = edit;
if (edit) {
pcForm.classList.add('is-edit-mode');
pcForm.classList.remove('is-view-mode');
btnSavePc.textContent = '저장';
btnRevertEdit.classList.remove('hidden');
btnCloseFooter.classList.add('hidden');
} else {
pcForm.classList.add('is-view-mode');
pcForm.classList.remove('is-edit-mode');
btnSavePc.textContent = '수정';
btnRevertEdit.classList.add('hidden');
btnCloseFooter.classList.remove('hidden');
if (currentAsset) fillFormData(currentAsset);
}
};
function fillFormData(asset: HardwareAsset) {
(document.getElementById('pc-asset-id') as HTMLInputElement).value = asset.id;
(document.getElementById('pc-법인') as HTMLSelectElement).value = asset.;
(document.getElementById('pc-자산코드') as HTMLInputElement).value = asset.;
(document.getElementById('pc-사용자') as HTMLInputElement).value = asset. || '';
(document.getElementById('pc-위치') as HTMLInputElement).value = asset. || '';
(document.getElementById('pc-CPU') as HTMLInputElement).value = asset.CPU || '';
(document.getElementById('pc-GPU') as HTMLInputElement).value = asset.GPU || '';
(document.getElementById('pc-RAM') as HTMLInputElement).value = asset.RAM || '';
(document.getElementById('pc-SSD1') as HTMLInputElement).value = asset.SSD1 || '';
(document.getElementById('pc-SSD2') as HTMLInputElement).value = asset.SSD2 || '';
(document.getElementById('pc-HDD1') as HTMLInputElement).value = asset.HDD1 || '';
(document.getElementById('pc-HDD2') as HTMLInputElement).value = asset.HDD2 || '';
(document.getElementById('pc-구매일') as HTMLInputElement).value = asset. || '';
(document.getElementById('pc-금액') as HTMLInputElement).value = asset. || '';
(document.getElementById('pc-납품업체') as HTMLInputElement).value = asset. || '';
(document.getElementById('pc-품의서명') as HTMLElement).innerText = asset. ? `첨부: ${asset.}` : '';
}
btnRevertEdit?.addEventListener('click', () => setEditMode(false));
btnCloseHeader?.addEventListener('click', closeModals);
btnCloseFooter?.addEventListener('click', closeModals);
btnSavePc?.addEventListener('click', (e) => {
e.preventDefault();
if (!isEditMode) {
setEditMode(true);
return;
}
if (!pcForm.checkValidity()) { pcForm.reportValidity(); return; }
// ... (저장 로직 유지)
e.preventDefault();
if (!pcForm.checkValidity()) { pcForm.reportValidity(); return; }
const id = (document.getElementById('pc-asset-id') as HTMLInputElement).value;
const fileInput = document.getElementById('pc-품의서') as HTMLInputElement;
const = fileInput.files && fileInput.files.length > 0 ? fileInput.files[0].name : (document.getElementById('pc-품의서명') as HTMLElement).innerText.replace('첨부: ', '');
const newAsset: HardwareAsset = {
id: id || Math.random().toString(36).substring(2, 9),
type: '개인PC',
: (document.getElementById('pc-법인') as HTMLSelectElement).value,
: (document.getElementById('pc-자산코드') as HTMLInputElement).value,
: '',
: (document.getElementById('pc-위치') as HTMLInputElement).value,
: '', IP주소: '', MACaddress: '', HW사양: '', OS: '', : (document.getElementById('pc-납품업체') as HTMLInputElement).value,
: (document.getElementById('pc-사용자') as HTMLInputElement).value,
CPU: (document.getElementById('pc-CPU') as HTMLInputElement).value,
GPU: (document.getElementById('pc-GPU') as HTMLInputElement).value,
RAM: (document.getElementById('pc-RAM') as HTMLInputElement).value,
SSD1: (document.getElementById('pc-SSD1') as HTMLInputElement).value,
SSD2: (document.getElementById('pc-SSD2') as HTMLInputElement).value,
HDD1: (document.getElementById('pc-HDD1') as HTMLInputElement).value,
HDD2: (document.getElementById('pc-HDD2') as HTMLInputElement).value,
: (document.getElementById('pc-구매일') as HTMLInputElement).value,
: (document.getElementById('pc-금액') as HTMLInputElement).value,
};
if (id) {
const idx = state.masterData.hw.findIndex(a => a.id === id);
if(idx !== -1) {
const oldAsset = state.masterData.hw[idx];
const changes = getChangeDetails(oldAsset, newAsset);
if (changes) {
state.masterData.logs.push({
id: Math.random().toString(36).substring(2, 9),
assetId: id,
date: new Date().toLocaleString(),
details: changes,
user: '관리자'
});
}
state.masterData.hw[idx] = newAsset;
}
} else {
state.masterData.hw.push(newAsset);
}
closeModals();
renderContent();
});
btnDeletePc?.addEventListener('click', (e) => {
e.preventDefault();
const id = (document.getElementById('pc-asset-id') as HTMLInputElement).value;
if (confirm('삭제하시겠습니까?')) {
state.masterData.hw = state.masterData.hw.filter(a => a.id !== id);
closeModals();
renderContent();
}
});
}
export function openPcModal(asset?: HardwareAsset) {
const pcForm = document.getElementById('pc-asset-form') as HTMLFormElement;
const deleteBtn = document.getElementById('btn-delete-pc-asset')!;
const historyArea = document.querySelector('.modal-history-area') as HTMLElement;
openModal('pc-asset-modal');
pcForm.reset();
if (asset) {
document.getElementById('pc-modal-title')!.textContent = '개인PC 상세 정보 수정';
deleteBtn.style.display = 'block';
if (historyArea) historyArea.style.display = 'flex';
(document.getElementById('pc-asset-id') as HTMLInputElement).value = asset.id;
(document.getElementById('pc-법인') as HTMLSelectElement).value = asset.;
(document.getElementById('pc-자산코드') as HTMLInputElement).value = asset.;
(document.getElementById('pc-사용자') as HTMLInputElement).value = asset. || '';
(document.getElementById('pc-위치') as HTMLInputElement).value = asset. || '';
(document.getElementById('pc-CPU') as HTMLInputElement).value = asset.CPU || '';
(document.getElementById('pc-GPU') as HTMLInputElement).value = asset.GPU || '';
(document.getElementById('pc-RAM') as HTMLInputElement).value = asset.RAM || '';
(document.getElementById('pc-SSD1') as HTMLInputElement).value = asset.SSD1 || '';
(document.getElementById('pc-SSD2') as HTMLInputElement).value = asset.SSD2 || '';
(document.getElementById('pc-HDD1') as HTMLInputElement).value = asset.HDD1 || '';
(document.getElementById('pc-HDD2') as HTMLInputElement).value = asset.HDD2 || '';
(document.getElementById('pc-구매일') as HTMLInputElement).value = asset. || '';
(document.getElementById('pc-금액') as HTMLInputElement).value = asset. || '';
(document.getElementById('pc-납품업체') as HTMLInputElement).value = asset. || '';
(document.getElementById('pc-품의서명') as HTMLElement).innerText = asset. ? `첨부: ${asset.}` : '';
renderHistory(asset.id);
} else {
document.getElementById('pc-modal-title')!.textContent = '신규 개인PC 자산 추가';
deleteBtn.style.display = 'none';
if (historyArea) historyArea.style.display = 'none';
(document.getElementById('pc-asset-id') as HTMLInputElement).value = '';
(document.getElementById('pc-법인') as HTMLSelectElement).value = '한맥';
(document.getElementById('pc-품의서명') as HTMLElement).innerText = '';
}
}
function getChangeDetails(oldAsset: HardwareAsset, newAsset: HardwareAsset): string {
const changes: string[] = [];
const fields = [
{ key: '법인', label: '법인' },
{ key: '자산코드', label: '자산코드' },
{ key: '사용자', label: '사용자' },
{ key: '위치', label: '위치' },
{ key: 'CPU', label: 'CPU' },
{ key: 'GPU', label: 'GPU' },
{ key: 'RAM', label: 'RAM' },
{ key: 'SSD1', label: 'SSD1' },
{ key: 'SSD2', label: 'SSD2' },
{ key: 'HDD1', label: 'HDD1' },
{ key: 'HDD2', label: 'HDD2' },
{ key: '구매일', label: '구매일' },
{ key: '금액', label: '금액' },
{ key: '납품업체', label: '납품업체' },
{ key: '품의서명', label: '품의서' },
];
fields.forEach(field => {
const oldVal = (oldAsset as any)[field.key] || '';
const newVal = (newAsset as any)[field.key] || '';
if (oldVal !== newVal) {
changes.push(`${field.label}: ${oldVal || '없음'}${newVal || '없음'}`);
}
});
return changes.join('\n');
}
function renderHistory(assetId: string) {
const historyList = document.getElementById('pc-history-list');
if (!historyList) return;
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 = '<div class="empty-history">이력이 없습니다.</div>';
return;
}
historyList.innerHTML = logs.map(log => `
<div class="history-item">
<div class="history-date">${log.date}</div>
<div class="history-user">수정자: ${log.user}</div>
<div class="history-details">${log.details.replace(/\\n/g, '<br>')}</div>
</div>
`).join('');
}

View File

@@ -0,0 +1,222 @@
import { state } from '../../core/state';
import { SoftwareAsset } from '../../core/excelHandler';
import { openModal } from './BaseModal';
const SW_MODAL_HTML = `
<div id="sw-asset-modal" class="modal-overlay hidden">
<div class="modal-content">
<div class="modal-header">
<h2 id="sw-modal-title">S/W 상세 정보</h2>
<button id="btn-close-sw-modal" class="btn-icon" aria-label="닫기"><i data-lucide="x"></i></button>
</div>
<div class="modal-body">
<form id="sw-asset-form" class="grid-form">
<input type="hidden" id="sw-asset-id" />
<input type="hidden" id="sw-asset-type" />
<div class="form-group">
<label for="sw-분야">분야</label>
<select id="sw-분야" required>
<option value="업무공통">업무공통</option>
<option value="개발S/W">개발S/W</option>
<option value="디자인">디자인</option>
<option value="설계S/W">설계S/W</option>
</select>
</div>
<div class="form-group">
<label for="sw-법인">법인</label>
<select id="sw-법인" required>
<option value="한맥">한맥 (HM)</option>
<option value="삼안 (SM)">삼안 (SM)</option>
<option value="바론 (BR)">바론 (BR)</option>
</select>
</div>
<div class="form-group">
<label for="sw-부서">부서</label>
<input type="text" id="sw-부서" placeholder="ex) 경영지원팀" required />
</div>
<div class="form-group">
<label for="sw-제품명">제품명</label>
<input type="text" id="sw-제품명" required />
</div>
<div class="form-group">
<label for="sw-구매일">구매일</label>
<input type="text" id="sw-구매일" placeholder="ex) 2024-01-01" />
</div>
<div class="form-group" id="sw-구독일-group">
<label for="sw-구독일">구독일(시작~끝)</label>
<input type="text" id="sw-구독일" placeholder="ex) 2024-01-01 ~ 2024-12-31" />
</div>
<div class="form-group" id="sw-유지보수-group" style="display:none;">
<label for="sw-유지보수여부">유지보수 여부</label>
<label style="display:flex; align-items:center; gap:0.5rem; height: 38px; cursor: pointer;">
<input type="checkbox" id="sw-유지보수여부" /> 대상 여부
</label>
</div>
<div class="form-group">
<label for="sw-금액">금액</label>
<input type="text" id="sw-금액" placeholder="ex) 1,000,000" oninput="this.value = this.value.replace(/[^0-9]/g, '').replace(/\\B(?=(\\d{3})+(?!\d))/g, ',')" />
</div>
<div class="form-group">
<label for="sw-수량">수량 (보유량)</label>
<input type="number" id="sw-수량" min="1" value="1" />
</div>
<div class="form-group">
<label for="sw-계정명">계정명</label>
<input type="text" id="sw-계정명" />
</div>
<div class="form-group">
<label for="sw-납품업체">납품업체</label>
<input type="text" id="sw-납품업체" />
</div>
<div class="form-group">
<label for="sw-비고">비고</label>
<input type="text" id="sw-비고" />
</div>
</form>
</div>
<div class="modal-footer">
<button id="btn-delete-sw-asset" class="btn btn-outline btn-danger">삭제</button>
<div class="footer-actions">
<button id="btn-revert-sw-edit" class="btn btn-outline hidden">수정 취소</button>
<button id="btn-close-sw-footer" class="btn btn-outline">닫기</button>
<button id="btn-save-sw-asset" class="btn btn-primary">수정</button>
</div>
</div>
</div>
</div>
`;
export function initSwModal(renderContent: () => void, closeModals: () => void) {
if (!document.getElementById('sw-asset-modal')) {
document.body.insertAdjacentHTML('beforeend', SW_MODAL_HTML);
}
const swForm = document.getElementById('sw-asset-form') as HTMLFormElement;
const btnRevertEdit = document.getElementById('btn-revert-sw-edit') as HTMLButtonElement;
const btnSaveSw = document.getElementById('btn-save-sw-asset') as HTMLButtonElement;
const btnDeleteSw = document.getElementById('btn-delete-sw-asset') as HTMLButtonElement;
const btnCloseHeader = document.getElementById('btn-close-sw-modal') as HTMLButtonElement;
const btnCloseFooter = document.getElementById('btn-close-sw-footer') as HTMLButtonElement;
let isEditMode = false;
let currentAsset: SoftwareAsset | null = null;
const setEditMode = (edit: boolean) => {
isEditMode = edit;
if (edit) {
swForm.classList.add('is-edit-mode');
swForm.classList.remove('is-view-mode');
btnSaveSw.textContent = '저장';
btnRevertEdit.classList.remove('hidden');
btnCloseFooter.classList.add('hidden');
} else {
swForm.classList.add('is-view-mode');
swForm.classList.remove('is-edit-mode');
btnSaveSw.textContent = '수정';
btnRevertEdit.classList.add('hidden');
btnCloseFooter.classList.remove('hidden');
if (currentAsset) fillFormData(currentAsset);
}
};
function fillFormData(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. || '';
(document.getElementById('sw-유지보수여부') as HTMLInputElement).checked = !!asset.;
(document.getElementById('sw-금액') as HTMLInputElement).value = asset. || '';
(document.getElementById('sw-수량') as HTMLInputElement).value = String(asset.);
(document.getElementById('sw-계정명') as HTMLInputElement).value = asset. || '';
(document.getElementById('sw-납품업체') as HTMLInputElement).value = asset. || '';
(document.getElementById('sw-비고') as HTMLInputElement).value = asset. || '';
}
btnRevertEdit?.addEventListener('click', () => setEditMode(false));
btnCloseHeader?.addEventListener('click', closeModals);
btnCloseFooter?.addEventListener('click', closeModals);
btnSaveSw?.addEventListener('click', (e) => {
e.preventDefault();
if (!isEditMode) {
setEditMode(true);
return;
}
if (!swForm.checkValidity()) { swForm.reportValidity(); return; }
const id = (document.getElementById('sw-asset-id') as HTMLInputElement).value;
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,
: (document.getElementById('sw-유지보수여부') as HTMLInputElement).checked,
: (document.getElementById('sw-금액') as HTMLInputElement).value,
수량: parseInt((document.getElementById('sw-수량') as HTMLInputElement).value || '1', 10),
: (document.getElementById('sw-계정명') as HTMLInputElement).value,
: (document.getElementById('sw-납품업체') as HTMLInputElement).value,
: (document.getElementById('sw-비고') as HTMLInputElement).value,
};
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);
}
closeModals();
renderContent();
});
btnDeleteSw?.addEventListener('click', (e) => {
e.preventDefault();
const id = (document.getElementById('sw-asset-id') as HTMLInputElement).value;
if (confirm('삭제하시겠습니까?')) {
state.masterData.sw = state.masterData.sw.filter(a => a.id !== id);
closeModals();
renderContent();
}
});
}
export function openSwModal(asset?: SoftwareAsset) {
currentAsset = asset || null;
const swForm = document.getElementById('sw-asset-form') as HTMLFormElement;
const deleteBtn = document.getElementById('btn-delete-sw-asset')!;
openModal('sw-asset-modal');
swForm.reset();
const subGroup = document.getElementById('sw-구독일-group')!;
const permGroup = document.getElementById('sw-유지보수-group')!;
if (state.activeSubTab === '구독SW') {
subGroup.style.display = 'flex';
permGroup.style.display = 'none';
} else {
subGroup.style.display = 'none';
permGroup.style.display = 'flex';
}
if (asset) {
document.getElementById('sw-modal-title')!.textContent = `${state.activeSubTab} 상세 정보 수정`;
deleteBtn.style.display = 'block';
fillFormData(asset);
setEditMode(false);
} else {
document.getElementById('sw-modal-title')!.textContent = `신규 ${state.activeSubTab} 자산 추가`;
deleteBtn.style.display = 'none';
(document.getElementById('sw-asset-id') as HTMLInputElement).value = '';
(document.getElementById('sw-asset-type') as HTMLInputElement).value = state.activeSubTab;
setEditMode(true);
}
}

View File

@@ -0,0 +1,241 @@
import { state } from '../../core/state';
import { SoftwareAsset, SWUser } from '../../core/excelHandler';
import { openModal } from './BaseModal';
import { createIcons, Edit2, X, Paperclip } from 'lucide';
let currentSwUserAssetId: string = '';
let tempSwUsers: SWUser[] = [];
const SW_USER_MODAL_HTML = `
<!-- S/W 할당 사용자 목록 모달 -->
<div id="sw-user-modal" class="modal-overlay hidden">
<div class="modal-content" style="max-width: 800px;">
<div class="modal-header">
<h2 id="sw-user-modal-title">S/W 할당 사용자 목록</h2>
<button id="btn-close-sw-user-modal" class="btn-icon" aria-label="닫기"><i data-lucide="x"></i></button>
</div>
<div class="modal-body">
<input type="hidden" id="sw-user-asset-id" />
<div style="text-align: right; margin-bottom: 0.75rem;">
<button type="button" id="btn-open-add-user" class="btn btn-primary btn-sm"><i data-lucide="plus"></i> 사용자 추가</button>
</div>
<div class="table-container">
<table style="width:100%;">
<thead>
<tr>
<th>법인</th>
<th>부서/팀</th>
<th>직위</th>
<th>이름</th>
<th>사용기간</th>
<th>증빙</th>
<th style="text-align:center;">관리</th>
</tr>
</thead>
<tbody id="user-list-body"></tbody>
</table>
</div>
</div>
<div class="modal-footer">
<div></div>
<div class="footer-actions">
<button id="btn-save-sw-user-mapping" class="btn btn-primary">변경사항 저장</button>
<button id="btn-cancel-sw-user-modal" class="btn btn-outline">닫기</button>
</div>
</div>
</div>
</div>
<!-- 사용자 추가/수정 서브 모달 -->
<div id="sw-user-edit-modal" class="modal-overlay hidden" style="z-index: 1100;">
<div class="modal-content" style="max-width: 500px;">
<div class="modal-header">
<h2 id="sw-user-edit-modal-title">사용자 정보</h2>
<button id="btn-close-sw-user-edit" class="btn-icon"><i data-lucide="x"></i></button>
</div>
<div class="modal-body">
<input type="hidden" id="edit-user-idx" />
<div class="grid-form" style="grid-template-columns: 1fr;">
<div class="form-group">
<label>법인</label>
<select id="new-user-법인">
<option value="한맥">한맥</option><option value="삼안">삼안</option><option value="바론">바론</option>
</select>
</div>
<div class="form-group">
<label>부서</label>
<input type="text" id="new-user-부서" />
</div>
<div class="form-group">
<label>팀</label>
<input type="text" id="new-user-팀" />
</div>
<div class="form-group">
<label>직위</label>
<input type="text" id="new-user-직위" />
</div>
<div class="form-group">
<label>이름</label>
<input type="text" id="new-user-이름" required />
</div>
<div class="form-group">
<label>사용기간</label>
<input type="text" id="new-user-사용기간" placeholder="ex) 2024.01 ~ 2024.12" />
</div>
<div class="form-group">
<label>신청서 (증빙파일)</label>
<input type="file" id="new-user-신청서" />
<span id="new-user-신청서명" style="font-size:0.75rem; color:var(--text-muted);"></span>
</div>
</div>
</div>
<div class="modal-footer">
<button id="btn-cancel-sw-user-edit" class="btn btn-outline">취소</button>
<button id="btn-save-edit-user" class="btn btn-primary">확인</button>
</div>
</div>
</div>
`;
export function initSwUserModal(renderContent: () => void, closeModals: () => void) {
if (!document.getElementById('sw-user-modal')) {
document.body.insertAdjacentHTML('beforeend', SW_USER_MODAL_HTML);
}
const btnOpenAddUser = document.getElementById('btn-open-add-user');
const btnSaveEditUser = document.getElementById('btn-save-edit-user');
const btnSaveSwUserMapping = document.getElementById('btn-save-sw-user-mapping');
const btnCancelUserEdit = document.getElementById('btn-cancel-sw-user-edit');
const btnCloseUserEdit = document.getElementById('btn-close-sw-user-edit');
const btnCancelUserModal = document.getElementById('btn-cancel-sw-user-modal');
const btnCloseUserModal = document.getElementById('btn-close-sw-user-modal');
btnOpenAddUser?.addEventListener('click', () => openUserEditModal(-1));
btnSaveEditUser?.addEventListener('click', () => saveUserEdit());
btnSaveSwUserMapping?.addEventListener('click', () => {
state.masterData.swUsers = state.masterData.swUsers.filter(u => u.swId !== currentSwUserAssetId);
state.masterData.swUsers.push(...tempSwUsers);
document.getElementById('sw-user-modal')?.classList.add('hidden');
renderContent();
});
btnCancelUserEdit?.addEventListener('click', () => document.getElementById('sw-user-edit-modal')?.classList.add('hidden'));
btnCloseUserEdit?.addEventListener('click', () => document.getElementById('sw-user-edit-modal')?.classList.add('hidden'));
btnCancelUserModal?.addEventListener('click', () => document.getElementById('sw-user-modal')?.classList.add('hidden'));
btnCloseUserModal?.addEventListener('click', () => document.getElementById('sw-user-modal')?.classList.add('hidden'));
}
function renderUserList() {
const tbody = document.getElementById('user-list-body')!;
tbody.innerHTML = '';
if (tempSwUsers.length === 0) {
tbody.innerHTML = '<tr><td colspan="7" style="padding: 2rem; text-align: center; color: var(--text-muted);">할당된 사용자가 없습니다.</td></tr>';
return;
}
tempSwUsers.forEach((user, idx) => {
const tr = document.createElement('tr');
const deptTeam = [user., user.].filter(Boolean).join(' / ') || '-';
const attachIcon = user. ? `<i data-lucide="paperclip" class="text-primary" style="width:16px; height:16px;" title="${user.}"></i>` : '-';
tr.innerHTML = `
<td>${user.}</td>
<td>${deptTeam}</td>
<td>${user. || '-'}</td>
<td><strong>${user.}</strong></td>
<td style="text-align:center;">${user. || '-'}</td>
<td style="text-align:center;">${attachIcon}</td>
<td style="text-align:center;">
<button type="button" class="btn-icon btn-edit-user" data-idx="${idx}" style="color: var(--primary-color);"><i data-lucide="edit-2" style="width:14px; height:14px;"></i></button>
<button type="button" class="btn-icon btn-remove-user" data-idx="${idx}" style="color: var(--danger);"><i data-lucide="x" style="width:14px; height:14px;"></i></button>
</td>
`;
tbody.appendChild(tr);
});
createIcons({ icons: { Edit2, X, Paperclip } });
tbody.querySelectorAll('.btn-edit-user').forEach(btn => {
btn.addEventListener('click', (e) => {
const idx = parseInt((e.currentTarget as HTMLElement).getAttribute('data-idx')!);
openUserEditModal(idx);
});
});
tbody.querySelectorAll('.btn-remove-user').forEach(btn => {
btn.addEventListener('click', (e) => {
const idx = parseInt((e.currentTarget as HTMLButtonElement).getAttribute('data-idx')!);
tempSwUsers.splice(idx, 1);
renderUserList();
});
});
}
export function openSwUserModal(asset: SoftwareAsset) {
openModal('sw-user-modal');
currentSwUserAssetId = asset.id;
tempSwUsers = state.masterData.swUsers.filter(u => u.swId === asset.id).map(u => ({...u}));
renderUserList();
}
function openUserEditModal(idx: number) {
const editModal = document.getElementById('sw-user-edit-modal')!;
editModal.classList.remove('hidden');
(document.getElementById('edit-user-idx') as HTMLInputElement).value = String(idx);
if (idx === -1) {
document.getElementById('sw-user-edit-modal-title')!.innerText = '새 사용자 추가';
(document.getElementById('new-user-법인') as HTMLSelectElement).value = '한맥';
(document.getElementById('new-user-부서') as HTMLInputElement).value = '';
(document.getElementById('new-user-팀') as HTMLInputElement).value = '';
(document.getElementById('new-user-직위') as HTMLInputElement).value = '';
(document.getElementById('new-user-이름') as HTMLInputElement).value = '';
(document.getElementById('new-user-사용기간') as HTMLInputElement).value = '';
(document.getElementById('new-user-신청서') as HTMLInputElement).value = '';
document.getElementById('new-user-신청서명')!.innerText = '';
} else {
document.getElementById('sw-user-edit-modal-title')!.innerText = '사용자 정보 수정';
const u = tempSwUsers[idx];
(document.getElementById('new-user-법인') as HTMLSelectElement).value = u.;
(document.getElementById('new-user-부서') as HTMLInputElement).value = u.;
(document.getElementById('new-user-팀') as HTMLInputElement).value = u.;
(document.getElementById('new-user-직위') as HTMLInputElement).value = u.;
(document.getElementById('new-user-이름') as HTMLInputElement).value = u.;
(document.getElementById('new-user-사용기간') as HTMLInputElement).value = u.;
(document.getElementById('new-user-신청서') as HTMLInputElement).value = '';
document.getElementById('new-user-신청서명')!.innerText = u. ? `첨부: ${u.}` : '';
}
}
function saveUserEdit() {
const idx = parseInt((document.getElementById('edit-user-idx') as HTMLInputElement).value);
const = (document.getElementById('new-user-이름') as HTMLInputElement).value.trim();
if (!) { alert('이름을 입력해주세요.'); return; }
const fileInput = document.getElementById('new-user-신청서') as HTMLInputElement;
let = '';
if (fileInput.files && fileInput.files.length > 0) {
= fileInput.files[0].name;
} else if (idx !== -1) {
= tempSwUsers[idx].;
}
const userData: SWUser = {
id: idx === -1 ? Math.random().toString(36).substring(2, 9) : tempSwUsers[idx].id,
swId: currentSwUserAssetId,
: (document.getElementById('new-user-법인') as HTMLSelectElement).value,
: (document.getElementById('new-user-부서') as HTMLInputElement).value,
: (document.getElementById('new-user-팀') as HTMLInputElement).value,
: (document.getElementById('new-user-직위') as HTMLInputElement).value,
,
: (document.getElementById('new-user-사용기간') as HTMLInputElement).value,
};
if (idx === -1) tempSwUsers.push(userData);
else tempSwUsers[idx] = userData;
document.getElementById('sw-user-edit-modal')?.classList.add('hidden');
renderUserList();
}

View File

@@ -0,0 +1,161 @@
import { state } from '../../core/state';
import { HardwareAsset } from '../../core/excelHandler';
import { openModal } from './BaseModal';
const STORAGE_MODAL_HTML = `
<div id="storage-asset-modal" class="modal-overlay hidden">
<div class="modal-content">
<div class="modal-header">
<h2 id="storage-modal-title">스토리지 상세 정보</h2>
<button id="btn-close-storage-modal" class="btn-icon" aria-label="닫기"><i data-lucide="x"></i></button>
</div>
<div class="modal-body">
<form id="storage-asset-form" class="grid-form">
<input type="hidden" id="storage-asset-id" />
<input type="hidden" id="storage-asset-type" value="스토리지" />
<div class="form-group"><label for="storage-법인">법인</label><input type="text" id="storage-법인" required /></div>
<div class="form-group"><label for="storage-유형">유형</label><input type="text" id="storage-유형" required /></div>
<div class="form-group"><label for="storage-자산코드">자산코드</label><input type="text" id="storage-자산코드" required /></div>
<div class="form-group"><label for="storage-명칭">명칭</label><input type="text" id="storage-명칭" required /></div>
<div class="form-group"><label for="storage-위치">위치</label><input type="text" id="storage-위치" /></div>
<div class="form-group"><label for="storage-모델명">모델명</label><input type="text" id="storage-모델명" /></div>
<div class="form-group"><label for="storage-용량">용량</label><input type="text" id="storage-용량" /></div>
<div class="form-group"><label for="storage-담당자_정">담당자(정)</label><input type="text" id="storage-담당자_정" /></div>
<div class="form-group"><label for="storage-IP주소">IP주소</label><input type="text" id="storage-IP주소" /></div>
<div class="form-group"><label for="storage-구매일">구매일</label><input type="text" id="storage-구매일" /></div>
<div class="form-group"><label for="storage-금액">금액</label><input type="text" id="storage-금액" oninput="this.value = this.value.replace(/[^0-9]/g, '').replace(/\\B(?=(\\d{3})+(?!\d))/g, ',')" /></div>
</form>
</div>
<div class="modal-footer">
<button id="btn-delete-storage-asset" class="btn btn-outline btn-danger">삭제</button>
<div class="footer-actions">
<button id="btn-revert-storage-edit" class="btn btn-outline hidden">수정 취소</button>
<button id="btn-close-storage-footer" class="btn btn-outline">닫기</button>
<button id="btn-save-storage-asset" class="btn btn-primary">수정</button>
</div>
</div>
</div>
</div>
`;
export function initStorageModal(renderContent: () => void, closeModals: () => void) {
if (!document.getElementById('storage-asset-modal')) {
document.body.insertAdjacentHTML('beforeend', STORAGE_MODAL_HTML);
}
const storageForm = document.getElementById('storage-asset-form') as HTMLFormElement;
const btnRevertEdit = document.getElementById('btn-revert-storage-edit') as HTMLButtonElement;
const btnSaveStorage = document.getElementById('btn-save-storage-asset') as HTMLButtonElement;
const btnDeleteStorage = document.getElementById('btn-delete-storage-asset') as HTMLButtonElement;
const btnCloseHeader = document.getElementById('btn-close-storage-modal') as HTMLButtonElement;
const btnCloseFooter = document.getElementById('btn-close-storage-footer') as HTMLButtonElement;
let isEditMode = false;
let currentAsset: HardwareAsset | null = null;
const setEditMode = (edit: boolean) => {
isEditMode = edit;
if (edit) {
storageForm.classList.add('is-edit-mode');
storageForm.classList.remove('is-view-mode');
btnSaveStorage.textContent = '저장';
btnRevertEdit.classList.remove('hidden');
btnCloseFooter.classList.add('hidden');
} else {
storageForm.classList.add('is-view-mode');
storageForm.classList.remove('is-edit-mode');
btnSaveStorage.textContent = '수정';
btnRevertEdit.classList.add('hidden');
btnCloseFooter.classList.remove('hidden');
if (currentAsset) fillFormData(currentAsset);
}
};
function fillFormData(asset: HardwareAsset) {
(document.getElementById('storage-asset-id') as HTMLInputElement).value = asset.id;
(document.getElementById('storage-법인') as HTMLInputElement).value = asset.;
(document.getElementById('storage-유형') as HTMLInputElement).value = asset.storage유형 || 'NAS';
(document.getElementById('storage-자산코드') as HTMLInputElement).value = asset.;
(document.getElementById('storage-명칭') as HTMLInputElement).value = asset.;
(document.getElementById('storage-위치') as HTMLInputElement).value = asset. || '';
(document.getElementById('storage-모델명') as HTMLInputElement).value = asset. || '';
(document.getElementById('storage-용량') as HTMLInputElement).value = asset. || '';
(document.getElementById('storage-담당자_정') as HTMLInputElement).value = asset._정 || '';
(document.getElementById('storage-IP주소') as HTMLInputElement).value = asset.IP주소 || '';
(document.getElementById('storage-구매일') as HTMLInputElement).value = asset. || '';
(document.getElementById('storage-금액') as HTMLInputElement).value = asset. || '';
}
btnRevertEdit?.addEventListener('click', () => setEditMode(false));
btnCloseHeader?.addEventListener('click', closeModals);
btnCloseFooter?.addEventListener('click', closeModals);
btnSaveStorage?.addEventListener('click', (e) => {
e.preventDefault();
if (!isEditMode) {
setEditMode(true);
return;
}
if (!storageForm.checkValidity()) { storageForm.reportValidity(); return; }
const id = (document.getElementById('storage-asset-id') as HTMLInputElement).value;
const newAsset: HardwareAsset = {
id: id || Math.random().toString(36).substring(2, 9),
type: '스토리지',
: (document.getElementById('storage-법인') as HTMLInputElement).value,
storage유형: (document.getElementById('storage-유형') as HTMLInputElement).value,
: (document.getElementById('storage-자산코드') as HTMLInputElement).value,
: (document.getElementById('storage-명칭') as HTMLInputElement).value,
: (document.getElementById('storage-위치') as HTMLInputElement).value,
: (document.getElementById('storage-모델명') as HTMLInputElement).value,
: (document.getElementById('storage-용량') as HTMLInputElement).value,
_정: (document.getElementById('storage-담당자_정') as HTMLInputElement).value,
IP주소: (document.getElementById('storage-IP주소') as HTMLInputElement).value,
: (document.getElementById('storage-구매일') as HTMLInputElement).value,
: (document.getElementById('storage-금액') as HTMLInputElement).value,
: '', MACaddress: '', HW사양: '', OS: '', : '', : ''
};
if (id) {
const idx = state.masterData.hw.findIndex(a => a.id === id);
if(idx !== -1) state.masterData.hw[idx] = newAsset;
} else {
state.masterData.hw.push(newAsset);
}
closeModals();
renderContent();
});
btnDeleteStorage?.addEventListener('click', (e) => {
e.preventDefault();
const id = (document.getElementById('storage-asset-id') as HTMLInputElement).value;
if (confirm('삭제하시겠습니까?')) {
state.masterData.hw = state.masterData.hw.filter(a => a.id !== id);
closeModals();
renderContent();
}
});
}
export function openStorageModal(asset?: HardwareAsset) {
currentAsset = asset || null;
const storageForm = document.getElementById('storage-asset-form') as HTMLFormElement;
const deleteBtn = document.getElementById('btn-delete-storage-asset')!;
openModal('storage-asset-modal');
storageForm.reset();
if (asset) {
document.getElementById('storage-modal-title')!.textContent = '스토리지 상세 정보 수정';
deleteBtn.style.display = 'block';
fillFormData(asset);
setEditMode(false);
} else {
document.getElementById('storage-modal-title')!.textContent = '신규 스토리지 자산 추가';
deleteBtn.style.display = 'none';
(document.getElementById('storage-asset-id') as HTMLInputElement).value = '';
setEditMode(true);
}
}

View File

@@ -0,0 +1,80 @@
import { state } from '../core/state';
const MENU_CONFIG = {
hw: {
label: '하드웨어',
tabs: ['대시보드', '개인PC', '서버', '스토리지', '전산비품']
},
sw: {
label: '소프트웨어',
tabs: ['대시보드', '구독SW', '영구SW']
},
ops: {
label: '운영 서비스',
tabs: ['대시보드', '서비스현황', '백업관리', '보안점검']
}
};
export function renderNavigation(onTabChange: (tab: string) => void) {
const navContainer = document.getElementById('main-nav')!;
const btnAddAsset = document.getElementById('btn-add-asset') as HTMLButtonElement;
const render = () => {
navContainer.innerHTML = '';
(Object.keys(MENU_CONFIG) as Array<keyof typeof MENU_CONFIG>).forEach(catKey => {
const config = MENU_CONFIG[catKey];
const isActive = state.activeCategory === catKey;
const group = document.createElement('div');
group.className = `nav-group ${isActive ? 'active is-showing-shelf' : ''}`;
// 메인 카테고리 트리거
const trigger = document.createElement('div');
trigger.className = 'gnb-trigger';
trigger.textContent = config.label;
trigger.addEventListener('click', () => {
if (state.activeCategory !== catKey) {
state.activeCategory = catKey;
state.activeSubTab = '대시보드';
if (btnAddAsset) btnAddAsset.classList.add('hidden');
render();
onTabChange('대시보드');
}
});
group.appendChild(trigger);
// 하위 탭 선반 (Shelf)
const shelf = document.createElement('div');
shelf.className = 'lnb-shelf';
config.tabs.forEach(tab => {
const item = document.createElement('div');
item.className = `lnb-item ${isActive && state.activeSubTab === tab ? 'active' : ''}`;
item.textContent = tab;
item.addEventListener('click', (e) => {
e.stopPropagation();
state.activeCategory = catKey;
state.activeSubTab = tab;
if (btnAddAsset) {
if (tab === '대시보드') btnAddAsset.classList.add('hidden');
else btnAddAsset.classList.remove('hidden');
}
render();
onTabChange(tab);
});
shelf.appendChild(item);
});
group.appendChild(shelf);
// 마우스 오버 시 다른 그룹의 선반은 가리고 내 것만 보여주는 스타일은 CSS에서 처리함
navContainer.appendChild(group);
});
};
render();
}

View File

@@ -0,0 +1,232 @@
import { MasterAssetData, HardwareAsset, SoftwareAsset, SWUser } from './excelHandler';
const corps = ['한맥', '삼안', '바론'];
const users = ['홍길동', '김철수', '이영희', '박지훈', '김팀장', '신유진', '윤대웅', '마리아'];
const depts = ['설계팀', '기술팀', '경영지원팀', '영업팀'];
function rand(arr: any[]) {
return arr[Math.floor(Math.random() * arr.length)];
}
function randDate(startYear: number, endYear: number) {
const y = Math.floor(Math.random() * (endYear - startYear + 1)) + startYear;
const m = String(Math.floor(Math.random() * 12) + 1).padStart(2, '0');
const d = String(Math.floor(Math.random() * 28) + 1).padStart(2, '0');
return `${y}-${m}-${d}`;
}
function randUser() { // 25% 확률로 유휴자산 할당
return Math.random() < 0.25 ? '' : rand(users);
}
export function generateDummyData(): MasterAssetData {
const hw: HardwareAsset[] = [];
const sw: SoftwareAsset[] = [];
const swUsers: SWUser[] = [];
// 1. 개인PC 50개
for (let i = 1; i <= 50; i++) {
const purchaseYear = Math.floor(Math.random() * 10) + 2017; // 2017~2026
hw.push({
id: Math.random().toString(36).substring(2, 9),
type: '개인PC',
법인: rand(corps),
: `HM-PC-${purchaseYear}-${String(i).padStart(3, '0')}`,
: '',
: `${rand(['본사', '지사'])} ${Math.floor(Math.random()*5)+1}`,
사용자: randUser(),
CPU: rand(['i5-10400', 'i7-12700', 'Ryzen 5', 'Ryzen 7']),
GPU: rand(['-', 'GTX 1660', 'RTX 3060', 'RTX 4070']),
RAM: rand(['16GB', '32GB']),
SSD1: rand(['256GB', '512GB', '1TB']),
SSD2: '',
HDD1: rand(['-', '1TB', '2TB']),
HDD2: '',
구매일: randDate(purchaseYear, purchaseYear),
금액: String(Math.floor(Math.random()*100 + 50) * 10000).replace(/\B(?=(\d{3})+(?!\d))/g, ','),
납품업체: rand(['다나와', '컴퓨존', '오피스디포']),
: '',
: '', IP주소: '', MACaddress: '', OS: '', HW사양: ''
});
}
// 2. 서버 20개
for (let i = 1; i <= 20; i++) {
const purchaseYear = Math.floor(Math.random() * 10) + 2017; // 2017~2026
hw.push({
id: Math.random().toString(36).substring(2, 9),
type: '서버',
법인: rand(corps),
: `HM-SV-${purchaseYear}-${String(i).padStart(3, '0')}`,
: `웹/DB 서버 #${i}`,
용도: rand(['웹 서버', 'DB 서버', '백업 서버', '개발 서버']),
storage유형: rand(['물리', 'VM']),
위치: rand(['IDC 1센터', 'IDC 2센터', '본사 전산실']),
관리자: rand(users),
담당자_정: rand(users),
담당자_부: rand(users),
IP주소: `192.168.10.${i}`,
: `ssh://192.168.10.${i}:22`,
MACaddress: '00:11:22:33:44:' + String(i).padStart(2, '0'),
OS: rand(['Windows Server 2019', 'Ubuntu 22.04 LTS', 'CentOS 7']),
모델명: rand(['Dell PowerEdge R740', 'HP ProLiant DL380', 'Lenovo ThinkSystem']),
CPU: rand(['Xeon Silver 4210', 'Xeon Gold 6248', 'EPYC 7702']),
RAM: rand(['64GB', '128GB', '256GB']),
GPU: rand(['-', 'RTX A4000', 'Tesla V100']),
SSD1: rand(['512GB SSD', '1TB NVMe']),
SSD2: rand(['-', '1TB SSD', '2TB SSD']),
HDD1: rand(['-', '4TB HDD', '8TB HDD']),
모니터링: rand(['Zabbix', 'Grafana', 'PRTG']),
비고: i % 5 === 0 ? '정기 점검 대상' : '-',
HW사양: 'Xeon 16Core, 64GB RAM',
구매일: randDate(purchaseYear, purchaseYear),
: '5,000,000',
: '서버뱅크',
: ''
});
}
// 3. 스토리지 20개
for (let i = 1; i <= 20; i++) {
const purchaseYear = Math.floor(Math.random() * 10) + 2017; // 2017~2026
hw.push({
id: Math.random().toString(36).substring(2, 9),
type: '스토리지',
법인: rand(corps),
storage유형: rand(['NAS', 'DAS']),
: `HM-ST-${purchaseYear}-${String(i).padStart(3, '0')}`,
: `백업 스토리지 #${i}`,
: '전산실',
모델명: rand(['Synology DS920+', 'QNAP TS-453D']),
용량: rand(['16TB', '32TB', '64TB']),
담당자_정: randUser(),
담당자_부: rand(users),
IP주소: `192.168.20.${i}`,
MACaddress: '',
구매일: randDate(purchaseYear, purchaseYear),
: '1,500,000',
: '스토리지넷',
: '',
: '', OS: '', HW사양: ''
});
}
// 4. 전산비품 (노트북, 태블릿, 휴대폰 각각 5개씩)
const equips = [
{ type: '노트북', code: 'NB', name: 'LG 그램 16인치', price: '1,800,000' },
{ type: '태블릿', code: 'TB', name: '아이패드 프로 12.9', price: '1,500,000' },
{ type: '휴대폰', code: 'PH', name: '갤럭시 S24', price: '1,200,000' }
];
equips.forEach((eq) => {
for (let i = 1; i <= 5; i++) {
const purchaseYear = Math.floor(Math.random() * 8) + 2019; // 2019~2026
hw.push({
id: Math.random().toString(36).substring(2, 9),
type: '전산비품',
법인: rand(corps),
비품유형: eq.type,
: `HM-${eq.code}-${purchaseYear}-${String(i).padStart(3, '0')}`,
명칭: eq.name,
위치: rand(['본사', '지사']),
관리자: randUser(),
구매일: randDate(purchaseYear, purchaseYear),
금액: eq.price,
: '브랜드 총판',
: '',
IP주소: '', MACaddress: '', OS: '', HW사양: ''
});
}
});
// 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();
if (isExpiring) {
endDt.setDate(endDt.getDate() + Math.floor(Math.random() * 25) + 1); // 1~25일 뒤 만료
} else {
endDt.setMonth(endDt.getMonth() + Math.floor(Math.random() * 11) + 2); // 넉넉히 남음
}
const endStr = `${endDt.getFullYear()}.${String(endDt.getMonth()+1).padStart(2,'0')}.${String(endDt.getDate()).padStart(2,'0')}`;
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']),
: `${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`,
: '총판',
: '연간구독'
});
const assignCount = Math.floor(Math.random() * 2) + 1;
for (let j=0; j<assignCount; j++) {
swUsers.push({
id: Math.random().toString(36).substring(2, 9),
swId: swId,
법인: rand(corps),
부서: rand(depts),
: rand(['1팀', '2팀', '기획팀']),
직위: rand(['사원', '대리', '과장']),
이름: rand(users),
사용기간: '2024.01~12',
신청서명: ''
});
}
}
// 6. 영구형 S/W 40개
for (let i = 1; i <= 40; i++) {
const swId = Math.random().toString(36).substring(2, 9);
let isExpiring = Math.random() < 0.25;
let endDt = new Date();
if (isExpiring) {
endDt.setDate(endDt.getDate() + Math.floor(Math.random() * 25) + 1); // 1~25일 뒤 만료
} else {
endDt.setMonth(endDt.getMonth() + Math.floor(Math.random() * 11) + 2); // 넉넉히 남음
}
const endStr = `${endDt.getFullYear()}.${String(endDt.getMonth()+1).padStart(2,'0')}.${String(endDt.getDate()).padStart(2,'0')}`;
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,
비고: `유지보수: ~ ${endStr}`,
금액: '1,500,000',
수량: Math.floor(Math.random() * 3) + 2, // 2~4
계정명: `sn-2020-${i}`,
납품업체: '오토데스크 / MS'
});
const assignCount = Math.floor(Math.random() * 2) + 1;
for (let j=0; j<assignCount; j++) {
swUsers.push({
id: Math.random().toString(36).substring(2, 9),
swId: swId,
법인: rand(corps),
부서: rand(depts),
: rand(['1팀', '2팀']),
직위: rand(['과장', '차장', '부장']),
이름: rand(users),
사용기간: '영구',
신청서명: ''
});
}
}
return { hw, sw, swUsers, logs: [] };
}

View File

@@ -0,0 +1,344 @@
import * as XLSX from 'xlsx';
export interface HardwareAsset {
id: string;
type: string; // '개인PC', '서버', '스토리지', '전산비품'
법인: string;
자산코드: string;
명칭: string;
위치: string;
관리자: string;
IP주소: string;
IP2?: string;
MACaddress: string;
HW사양: string;
OS: string;
사용자?: string;
CPU?: string;
GPU?: string;
RAM?: string;
SSD1?: string;
SSD2?: string;
HDD1?: string;
HDD2?: string;
storage유형?: string;
비품유형?: string;
모델명?: string;
용량?: string;
담당자_정?: string;
담당자_부?: string;
구매일?: string;
금액?: string;
납품업체: string;
품의서명: string;
용도?: string;
상세?: string;
원격접속?: string;
서버ID?: string;
서버PW?: string;
모니터링?: string;
비고?: string;
}
export interface SoftwareAsset {
id: string;
type: string; // '구독SW', '영구SW'
분야?: string;
법인: string;
부서?: string;
제품명: string;
구매일: string;
구독일?: string;
유지보수여부?: boolean;
금액: string;
수량: number;
계정명: string;
납품업체: string;
비고: string;
}
export interface SWUser {
id: string;
swId: string;
법인: string;
부서: string;
: string;
직위: string;
이름: string;
사용기간: string;
신청서명: string;
}
export interface HardwareLog {
id: string;
assetId: string;
date: string;
details: string;
user: string;
}
export interface MasterAssetData {
hw: HardwareAsset[];
sw: SoftwareAsset[];
swUsers: SWUser[];
logs: HardwareLog[];
}
const HW_TABS = ['개인PC', '서버', '스토리지', '전산비품'];
const SW_TABS = ['구독SW', '영구SW'];
const HW_HEADERS = ['법인', '자산코드', '명칭', '위치', '관리자', 'IP주소', 'MACaddress', 'HW사양', 'OS', '구매일', '금액', '납품업체', '품의서명'];
const PC_HEADERS = ['법인', '자산코드', '사용자', '위치', 'CPU', 'GPU', 'RAM', 'SSD1', 'SSD2', 'HDD1', 'HDD2', '구매일', '금액', '납품업체', '품의서명'];
const SERVER_HEADERS = ['법인', '자산번호', '유형', '용도', '설치위치', '담당자(정)', '담당자(부)', 'IP 주소', '원격접속', '모델명', 'OS', 'CPU', 'RAM', 'GPU', 'Storage1', 'Storage2', 'Storage3', '모니터링', '비고'];
const STORAGE_HEADERS = ['법인', '유형', '자산코드', '명칭', '위치', '모델명', '용량', '담당자(정)', '담당자(부)', 'IP주소', 'MAC주소', '구매일', '금액', '납품업체', '품의서명'];
const SUB_SW_HEADERS = ['ID', '분야', '법인', '부서', '제품명', '구매일', '구독일', '금액', '수량', '계정명', '납품업체', '비고'];
const PERM_SW_HEADERS = ['ID', '분야', '법인', '부서', '제품명', '구매일', '유지보수여부', '금액', '수량', '계정명', '납품업체', '비고'];
const SW_USER_HEADERS = ['id', 'swId', '법인', '부서', '팀', '직위', '이름', '사용기간', '신청서명'];
const HISTORY_HEADERS = ['id', 'assetId', 'date', 'details', 'user'];
/**
* 템플릿 엑셀 다중 시트로 다운로드
*/
export function downloadTemplate() {
const wb = XLSX.utils.book_new();
HW_TABS.forEach(tab => {
let hd = HW_HEADERS;
let wscols: any[] = [];
if (tab === '개인PC') {
hd = PC_HEADERS;
wscols = [{wch:15}, {wch:25}, {wch:15}, {wch:20}, {wch:20}, {wch:20}, {wch:15}, {wch:15}, {wch:15}, {wch:15}, {wch:15}, {wch:15}, {wch:15}, {wch:20}, {wch:25}];
} else if (tab === '서버') {
hd = SERVER_HEADERS;
wscols = [{wch:15}, {wch:20}, {wch:15}, {wch:25}, {wch:20}, {wch:15}, {wch:15}, {wch:20}, {wch:20}, {wch:25}, {wch:20}, {wch:15}, {wch:15}, {wch:15}, {wch:15}, {wch:15}, {wch:15}, {wch:15}, {wch:30}];
} else if (tab === '스토리지') {
hd = STORAGE_HEADERS;
wscols = [{wch:15}, {wch:15}, {wch:25}, {wch:25}, {wch:20}, {wch:25}, {wch:15}, {wch:15}, {wch:15}, {wch:15}, {wch:20}, {wch:15}, {wch:15}, {wch:20}, {wch:25}];
} else {
hd = HW_HEADERS;
wscols = [{wch:15}, {wch:20}, {wch:25}, {wch:20}, {wch:15}, {wch:15}, {wch:20}, {wch:40}, {wch:20}, {wch:15}, {wch:15}, {wch:20}, {wch:25}];
}
const ws = XLSX.utils.aoa_to_sheet([hd]);
ws['!cols'] = wscols;
XLSX.utils.book_append_sheet(wb, ws, tab);
});
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: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);
});
const swUserWs = XLSX.utils.aoa_to_sheet([SW_USER_HEADERS]);
swUserWs['!cols'] = [{wch:15}, {wch:15}, {wch:15}, {wch:15}, {wch:15}, {wch:15}, {wch:15}, {wch:20}, {wch:25}];
XLSX.utils.book_append_sheet(wb, swUserWs, 'SW_사용자');
const historyWs = XLSX.utils.aoa_to_sheet([HISTORY_HEADERS]);
historyWs['!cols'] = [{wch:15}, {wch:20}, {wch:20}, {wch:50}, {wch:15}];
XLSX.utils.book_append_sheet(wb, historyWs, 'History');
XLSX.writeFile(wb, 'itam_assets_template.xlsx');
}
/**
* 마스터 데이터를 여러 시트로 쪼개서 내보내기
*/
export function exportToExcel(masterData: MasterAssetData) {
const wb = XLSX.utils.book_new();
HW_TABS.forEach(tab => {
const targetAssets = masterData.hw.filter(a => a.type === tab);
let wsData;
let colsConfig;
if (tab === '개인PC') {
wsData = [
PC_HEADERS,
...targetAssets.map(a => [a., a., a., a., a.CPU, a.GPU, a.RAM, a.SSD1, a.SSD2, a.HDD1, a.HDD2, a., a., a., a.])
];
colsConfig = [{wch:15}, {wch:25}, {wch:15}, {wch:20}, {wch:20}, {wch:20}, {wch:15}, {wch:15}, {wch:15}, {wch:15}, {wch:15}, {wch:15}, {wch:15}, {wch:20}, {wch:25}];
} else if (tab === '서버') {
wsData = [
SERVER_HEADERS,
...targetAssets.map(a => [a., a., a.storage유형 || '물리', a. || '', a., a._정 || '', a._부 || '', a.IP주소, a. || '', a. || '', a.OS, a.CPU, a.RAM, a.GPU || '', a.SSD1 || '', a.SSD2 || '', a.HDD1 || '', a. || '', a. || ''])
];
colsConfig = [{wch:15}, {wch:20}, {wch:15}, {wch:25}, {wch:20}, {wch:15}, {wch:15}, {wch:20}, {wch:20}, {wch:25}, {wch:20}, {wch:15}, {wch:15}, {wch:15}, {wch:15}, {wch:15}, {wch:15}, {wch:15}, {wch:30}];
} else if (tab === '스토리지') {
wsData = [
STORAGE_HEADERS,
...targetAssets.map(a => [a., a.storage유형, a., a., a., a., a., a._정, a._부, a.IP주소, a.MACaddress, a., a., a., a.])
];
colsConfig = [{wch:15}, {wch:15}, {wch:25}, {wch:25}, {wch:20}, {wch:25}, {wch:15}, {wch:15}, {wch:15}, {wch:15}, {wch:20}, {wch:15}, {wch:15}, {wch:20}, {wch:25}];
} else {
wsData = [
HW_HEADERS,
...targetAssets.map(a => [a., a., a., a., a., a.IP주소, a.MACaddress, a.HW사양, a.OS, a., a., a., a.])
];
colsConfig = [{wch:15}, {wch:20}, {wch:25}, {wch:20}, {wch:15}, {wch:15}, {wch:20}, {wch:40}, {wch:20}, {wch:15}, {wch:15}, {wch:20}, {wch:25}];
}
const ws = XLSX.utils.aoa_to_sheet(wsData);
ws['!cols'] = colsConfig;
XLSX.utils.book_append_sheet(wb, ws, tab);
});
SW_TABS.forEach(tab => {
const targetAssets = masterData.sw.filter(a => a.type === tab);
let wsData;
if (tab === '구독SW') {
wsData = [
SUB_SW_HEADERS,
...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., a., a. ? 'Y' : 'N', a., a., a., a., a.])
];
}
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}];
XLSX.utils.book_append_sheet(wb, ws, tab);
});
const swUserWsData = [
SW_USER_HEADERS,
...masterData.swUsers.map(u => [u.id, u.swId, u., u., u., u., u., u., u.])
];
const swUserWs = XLSX.utils.aoa_to_sheet(swUserWsData);
swUserWs['!cols'] = [{wch:15}, {wch:15}, {wch:15}, {wch:15}, {wch:15}, {wch:15}, {wch:15}, {wch:20}, {wch:25}];
XLSX.utils.book_append_sheet(wb, swUserWs, 'SW_사용자');
const historyWsData = [
HISTORY_HEADERS,
...masterData.logs.map(l => [l.id, l.assetId, l.date, l.details, l.user])
];
const historyWs = XLSX.utils.aoa_to_sheet(historyWsData);
historyWs['!cols'] = [{wch:15}, {wch:20}, {wch:20}, {wch:50}, {wch:15}];
XLSX.utils.book_append_sheet(wb, historyWs, 'History');
const dateStr = new Date().toISOString().split('T')[0];
XLSX.writeFile(wb, `itam_assets_master_${dateStr}.xlsx`);
}
export async function parseExcel(file: File): Promise<MasterAssetData> {
return new Promise((resolve, reject) => {
const reader = new FileReader();
reader.onload = (e) => {
try {
const data = e.target?.result;
const workbook = XLSX.read(data, { type: 'binary' });
const hwAssets: HardwareAsset[] = [];
const swAssets: SoftwareAsset[] = [];
const swUsers: SWUser[] = [];
const logs: HardwareLog[] = [];
workbook.SheetNames.forEach(sheetName => {
const worksheet = workbook.Sheets[sheetName];
const json = XLSX.utils.sheet_to_json(worksheet) as any[];
if (HW_TABS.includes(sheetName)) {
json.forEach(row => {
if (sheetName === '개인PC') {
hwAssets.push({
id: Math.random().toString(36).substring(2, 9),
type: sheetName,
법인: row['법인'] || '',
자산코드: row['자산코드'] || '',
: '',
위치: row['위치'] || '',
사용자: row['사용자'] || '',
: '', IP주소: '', MACaddress: '', HW사양: '', OS: '',
CPU: row['CPU'] || '', GPU: row['GPU'] || '', RAM: row['RAM'] || '',
SSD1: row['SSD1'] || '', SSD2: row['SSD2'] || '', HDD1: row['HDD1'] || '', HDD2: row['HDD2'] || '',
구매일: row['구매일'] || '', 금액: row['금액'] ? String(row['금액']) : '',
납품업체: row['납품업체'] || '', 품의서명: row['품의서명'] || '',
});
} else if (sheetName === '서버') {
hwAssets.push({
id: Math.random().toString(36).substring(2, 9),
type: sheetName,
법인: row['법인'] || '',
자산코드: row['자산번호'] || row['자산코드'] || '',
명칭: row['용도'] || row['명칭'] || '',
용도: row['용도'] || '', 위치: row['설치위치'] || row['위치'] || '',
관리자: row['담당자(정)'] || '', 담당자_정: row['담당자(정)'] || '', 담당자_부: row['담당자(부)'] || '',
IP주소: row['IP 주소'] || row['IP주소'] || '', IP2: row['IP2'] || '',
원격접속: row['원격접속'] || '', 서버ID: row['서버ID'] || '', 서버PW: row['서버PW'] || '',
모델명: row['모델명'] || '', OS: row['OS'] || '',
CPU: row['CPU'] || '', RAM: row['RAM'] || '', GPU: row['GPU'] || '',
SSD1: row['Storage1'] || row['SSD1'] || '', SSD2: row['Storage2'] || row['SSD2'] || '', HDD1: row['Storage3'] || row['HDD1'] || '',
모니터링: row['모니터링'] || '', 비고: row['비고'] || '', storage유형: row['유형'] || '물리',
MACaddress: '', HW사양: '', : '', : '', : '', : '',
});
} else if (sheetName === '스토리지') {
hwAssets.push({
id: Math.random().toString(36).substring(2, 9),
type: sheetName,
법인: row['법인'] || '', 자산코드: row['자산코드'] || '', 명칭: row['명칭'] || '', 위치: row['위치'] || '',
: '', IP주소: row['IP주소'] || '', MACaddress: row['MAC주소'] || '', HW사양: '', OS: '',
storage유형: row['유형'] || '', 모델명: row['모델명'] || '', 용량: row['용량'] || '',
담당자_정: row['담당자(정)'] || '', 담당자_부: row['담당자(부)'] || '',
구매일: row['구매일'] || '', 금액: row['금액'] ? String(row['금액']) : '',
납품업체: row['납품업체'] || '', 품의서명: row['품의서명'] || '',
});
} else {
hwAssets.push({
id: Math.random().toString(36).substring(2, 9),
type: sheetName,
법인: row['법인'] || '', 자산코드: row['자산코드'] || '', 명칭: row['명칭'] || '', 위치: row['위치'] || '',
관리자: row['관리자'] || '', IP주소: row['IP주소'] || '', MACaddress: row['MACaddress'] || '',
HW사양: row['HW사양'] || '', OS: row['OS'] || '',
구매일: row['구매일'] || '', 금액: row['금액'] ? String(row['금액']) : '',
납품업체: row['납품업체'] || '', 품의서명: row['품의서명'] || '',
});
}
});
}
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 === 'SW_사용자') {
json.forEach(row => {
swUsers.push({
id: row['id'] ? String(row['id']) : Math.random().toString(36).substring(2, 9),
swId: row['swId'] ? String(row['swId']) : '', 법인: row['법인'] || '', 부서: row['부서'] || '',
: row['팀'] || '', 직위: row['직위'] || '', 이름: row['이름'] || '',
사용기간: row['사용기간'] || '', 신청서명: row['신청서명'] || '',
});
});
}
if (sheetName === 'History') {
json.forEach(row => {
logs.push({
id: row['id'] ? String(row['id']) : Math.random().toString(36).substring(2, 9),
assetId: row['assetId'] ? String(row['assetId']) : '',
date: row['date'] || '', details: row['details'] || '', user: row['user'] || '',
});
});
}
});
resolve({ hw: hwAssets, sw: swAssets, swUsers, logs });
} catch (err) {
reject(err);
}
};
reader.onerror = (err) => reject(err);
reader.readAsBinaryString(file);
});
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,87 @@
import { MasterAssetData, HardwareAsset } from './excelHandler';
import { generateDummyData } from './dummyDataGenerator';
import { realServerData } from './realServerData';
// --- State Definitions ---
export interface AppState {
masterData: MasterAssetData;
activeCategory: 'hw' | 'sw' | 'ops';
activeSubTab: string;
activeCharts: any[];
}
const dummy = generateDummyData();
// 서버 데이터만 실제 데이터로 교체
const mergedHw: HardwareAsset[] = [
...dummy.hw.filter(a => a.type !== '서버'),
...realServerData.map(s => ({
id: s.id || Math.random().toString(36).substring(2, 9),
type: '서버',
법인: s.법인,
자산코드: s.자산코드,
명칭: s.용도 || '',
위치: s.위치,
관리자: s.담당자_정 || '홍길동',
담당자_정: s.담당자_정 || '홍길동',
담당자_부: s.담당자_부 || '김철수',
IP주소: s.IP주소,
IP2: s.IP2 || '',
MACaddress: s.MACaddress || '',
HW사양: s.HW사양 || '',
OS: s.OS,
CPU: s.CPU,
RAM: s.RAM,
SSD1: s.SSD1,
SSD2: s.SSD2,
HDD1: s.HDD1,
storage유형: s.storage유형,
모델명: s.모델명,
구매일: s.구매일 || '',
금액: s.금액 || '',
납품업체: s.납품업체 || '',
품의서명: s.품의서명 || '',
용도: s.용도,
상세: s.상세,
원격접속: s.원격접속 || '',
서버ID: s.서버ID || '',
서버PW: s.서버PW || '',
모니터링: s.모니터링 || '',
비고: s.비고 || ''
}))
];
// --- Initial State ---
export const state: AppState = {
masterData: {
...dummy,
hw: mergedHw, // 기본적으로 하드코딩된 데이터를 가지고 시작
logs: []
},
activeCategory: 'hw',
activeSubTab: '대시보드',
activeCharts: []
};
/**
* DB에서 데이터 로드
*/
export async function loadMasterDataFromDB() {
try {
const response = await fetch('http://localhost:3000/api/hw');
if (!response.ok) throw new Error('DB 로드 실패');
const data = await response.json();
if (data && data.length > 0) {
state.masterData.hw = data;
console.log('✅ DB 데이터 로드 완료');
return true;
}
} catch (err) {
console.warn('⚠️ 백엔드 서버 연결 실패. 로컬 데이터를 유지합니다.');
}
return false;
}
// --- State Helpers ---
export function updateState(newState: Partial<AppState>) {
Object.assign(state, newState);
}

108
backup_refactor/src/main.ts Normal file
View File

@@ -0,0 +1,108 @@
import { state, loadMasterDataFromDB } from './core/state';
import { renderNavigation } from './components/Navigation';
import { renderDashboard } from './views/DashboardView';
import { renderTable } from './views/AssetTableView';
import { downloadTemplate, exportToExcel, parseExcel, HardwareAsset } from './core/excelHandler';
import { initBaseModal } from './components/Modal/BaseModal';
import { initPcModal } from './components/Modal/PCModal';
import { initHwModal, openHwModal } from './components/Modal/HWModal';
import { initStorageModal } from './components/Modal/StorageModal';
import { initSwModal } from './components/Modal/SWModal';
import { initSwUserModal } from './components/Modal/SWUserModal';
import { initDashboardDetailModal } from './components/Modal/DashboardDetailModal';
import { createIcons, Download, Upload, FileSpreadsheet, Plus, X, LayoutDashboard, Monitor, Server, Database, Laptop, CalendarClock, Key, Cpu, Layers, Users, Paperclip, Edit2, History, RefreshCcw } from 'lucide';
// --- DB 저장을 위한 헬퍼 함수 ---
async function saveAllHwToDB(assets: HardwareAsset[]) {
try {
const response = await fetch('http://localhost:3000/api/hw/batch', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(assets)
});
if (!response.ok) throw new Error('DB 저장 실패');
console.log('✅ DB 저장 완료');
} catch (err) {
console.error('❌ DB 저장 실패:', err);
}
}
// --- App Initialization ---
function initApp() {
console.log('🚀 ITAM System Initializing...');
const mainContent = document.getElementById('main-content')!;
if (!mainContent) return;
// 1. 전역 모달 및 내비게이션 초기화
const { closeAllModals } = initBaseModal();
try {
renderNavigation((tab) => {
if (tab === '대시보드') {
renderDashboard(mainContent);
} else {
renderTable(mainContent);
}
});
initPcModal(() => {
saveAllHwToDB(state.masterData.hw);
renderTable(mainContent);
}, closeAllModals);
initHwModal();
initStorageModal(() => {
saveAllHwToDB(state.masterData.hw);
renderTable(mainContent);
}, closeAllModals);
initSwModal(() => renderTable(mainContent), closeAllModals);
initSwUserModal(() => renderTable(mainContent), closeAllModals);
initDashboardDetailModal();
} catch (e) {
console.error('❌ Initialization failed:', e);
}
// 2. 초기 렌더링
renderDashboard(mainContent);
// 3. 비동기 데이터 로드
loadMasterDataFromDB().then((success) => {
if (success) {
if (state.activeSubTab === '대시보드') renderDashboard(mainContent);
else renderTable(mainContent);
}
});
// 4. 이벤트 바인딩
document.getElementById('btn-download-template')?.addEventListener('click', () => downloadTemplate());
document.getElementById('btn-export-excel')?.addEventListener('click', () => exportToExcel(state.masterData));
const uploadInput = document.getElementById('excel-upload') as HTMLInputElement;
uploadInput?.addEventListener('change', async (e) => {
const file = (e.target as HTMLInputElement).files?.[0];
if (file) {
const data = await parseExcel(file);
state.masterData = data;
await saveAllHwToDB(data.hw);
renderTable(mainContent);
}
});
document.getElementById('btn-add-asset')?.addEventListener('click', () => {
if (state.activeSubTab === '서버' || state.activeSubTab === '전산비품' || state.activeSubTab === '스토리지') {
openHwModal({
id: Math.random().toString(36).substring(2, 9),
type: state.activeSubTab,
: '한맥', : '', : '', : '', : '', IP주소: '', MACaddress: '', HW사양: '', OS: '', : '', : ''
} as any);
}
});
createIcons({
icons: { Download, Upload, FileSpreadsheet, Plus, X, LayoutDashboard, Monitor, Server, Database, Laptop, CalendarClock, Key, Cpu, Layers, Users, Paperclip, Edit2, History, RefreshCcw }
});
}
document.addEventListener('DOMContentLoaded', initApp);

View File

@@ -0,0 +1,13 @@
[
{
"법인": "(주)회사1",
"자산코드": "ASSET-100",
"명칭": "서버 모델A",
"위치": "본사 1층",
"관리자": "관리자A",
"IP주소": "192.168.0.1",
"MACaddress": "00:00:00:00:00:01",
"HW사양": "Core i7, 16GB RAM",
"OS": "Windows 10"
}
]

View File

@@ -0,0 +1,262 @@
:root {
--primary-color: #1E5149;
--primary-hover: #153c36;
--primary-light: #edf2f1;
--text-main: #111827;
--text-muted: #6B7280;
--border-color: #E5E7EB;
--bg-color: #F9FAFB;
--white: #FFFFFF;
--danger: #dc2626;
--header-height: 52px;
}
* {
box-sizing: border-box;
margin: 0;
padding: 0;
}
body {
font-family: 'Pretendard Variable', Pretendard, sans-serif;
color: var(--text-main);
background-color: var(--bg-color);
line-height: 1.5;
letter-spacing: -0.02em;
font-size: 14px;
overflow: hidden;
}
.app-layout {
display: flex;
flex-direction: column;
height: 100vh;
width: 100%;
}
/* --- Integrated Header Style --- */
.main-header {
background-color: var(--white);
border-bottom: 1px solid var(--border-color);
z-index: 100;
height: var(--header-height);
}
.header-container {
height: 100%;
display: flex;
align-items: center;
padding: 0 1.5rem;
gap: 1.5rem;
}
.brand h1 {
font-size: 1.2rem;
font-weight: 800;
color: var(--text-main);
white-space: nowrap;
margin-right: 1rem;
}
.brand h1 span { color: var(--primary-color); }
/* --- Integrated Nav --- */
.integrated-nav {
flex: 1;
height: 100%;
display: flex;
align-items: center;
gap: 0.5rem;
}
.nav-group {
display: flex;
align-items: center;
height: 100%;
}
.gnb-trigger {
font-size: 14px;
font-weight: 700;
color: var(--text-main);
padding: 0 1rem;
cursor: pointer;
height: 100%;
display: flex;
align-items: center;
white-space: nowrap;
}
.lnb-shelf {
display: none;
align-items: center;
gap: 0.25rem;
padding: 0 0.75rem;
height: 60%;
border-left: 1px solid var(--border-color);
margin-left: 0.25rem;
animation: fadeIn 0.2s ease-out;
}
.nav-group:hover .lnb-shelf,
.nav-group.is-showing-shelf .lnb-shelf {
display: flex;
}
.lnb-item {
font-size: 13px;
font-weight: 500;
color: var(--text-muted);
cursor: pointer;
padding: 0.2rem 0.6rem;
border-radius: 4px;
white-space: nowrap;
}
.lnb-item.active {
color: var(--primary-color);
background-color: var(--primary-light);
font-weight: 700;
}
@keyframes fadeIn {
from { opacity: 0; transform: translateX(-5px); }
to { opacity: 1; transform: translateX(0); }
}
/* --- Header Actions --- */
.header-actions { display: flex; gap: 0.3rem; align-items: center; }
.btn {
display: inline-flex;
align-items: center;
justify-content: center;
gap: 0.35rem;
padding: 0 0.8rem;
font-size: 12px;
font-weight: 600;
border-radius: 4px;
cursor: pointer;
height: 28px;
line-height: 1;
}
.btn i, .btn svg { width: 12px !important; height: 12px !important; }
.btn-primary { background-color: var(--primary-color); color: var(--white); border: 1px solid var(--primary-color); }
.btn-outline { background-color: transparent; color: var(--text-muted); border: 1px solid var(--border-color); }
/* --- Content Area & Standardized Layout --- */
.content-area {
flex: 1;
padding: 2rem; /* benchmark: 좌, 우, 하단 2rem 공백 통일 */
overflow-y: auto;
background-color: var(--bg-color);
}
.view-container {
width: 100%;
display: flex;
flex-direction: column;
gap: 1.5rem;
}
/* --- Search Filter Bar --- */
.search-bar {
display: flex;
flex-wrap: wrap;
gap: 1.25rem;
background-color: var(--white);
padding: 1.5rem;
border: 1px solid var(--border-color);
border-radius: 8px;
align-items: flex-end;
}
.search-item { display: flex; flex-direction: column; gap: 0.4rem; }
.search-item.flex-1 { flex: 1; }
.search-item label { font-size: 11px; font-weight: 800; color: var(--text-muted); }
.search-item input,
.search-item select {
height: 32px;
padding: 0 2.5rem 0 0.75rem; /* Increased right padding for arrow */
border: 1px solid var(--border-color);
border-radius: 3px;
font-size: 13px;
outline: none;
appearance: none; /* Modern arrow styling */
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='12' height='12' viewBox='0 0 24 24' fill='none' stroke='%236B7280' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='m6 9 6 6 6-9'/%3E%3C/svg%3E");
background-repeat: no-repeat;
background-position: right 0.75rem center;
}
.search-item input {
padding-right: 0.75rem;
}
.btn-reset {
height: 32px !important;
padding: 0 0.8rem !important;
font-size: 12px !important;
display: inline-flex !important;
align-items: center !important;
gap: 0.35rem !important;
border-radius: 4px !important;
}
/* --- Table (Box-less Design) --- */
.table-container {
background-color: var(--white);
border-top: 1px solid var(--border-color);
border-bottom: 1px solid var(--border-color);
border-left: none;
border-right: none;
overflow: auto;
max-height: calc(100vh - 240px); /* Adjusting for bottom spacing */
}
table { width: 100%; border-collapse: collapse; }
th, td {
padding: 1rem 1.5rem;
border-bottom: 1px solid var(--border-color);
text-align: left;
white-space: nowrap; /* Force single line for all info */
}
th {
background-color: #FAFAFA;
font-weight: 700;
color: var(--text-muted);
font-size: 12px;
position: sticky;
top: 0;
z-index: 10;
box-shadow: inset 0 -1px 0 var(--border-color);
text-transform: uppercase;
}
td { font-size: 14px; }
tbody tr:hover { background-color: #F9FAFB; }
/* --- Dashboard Style --- */
.dashboard-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(240px, 1fr)); gap: 1.5rem; margin-bottom: 2rem; }
.stat-card { background-color: var(--white); padding: 1.5rem; border: 1px solid var(--border-color); border-radius: 8px; }
.stat-card .value { font-size: 2.2rem; font-weight: 800; color: var(--primary-color); margin-top: 0.5rem; }
.dashboard-layout-2col { display: grid; grid-template-columns: repeat(2, 1fr); gap: 1.5rem; }
.dashboard-card {
background-color: var(--white);
border: 1px solid var(--border-color);
border-radius: 8px;
padding: 1.5rem;
display: flex;
flex-direction: column;
min-height: 360px; /* Increased height for better chart view */
}
.dashboard-card canvas {
flex: 1;
width: 100% !important;
max-height: 280px;
}
.dashboard-section-title { padding: 0 0 1rem 0; font-size: 1.1rem; font-weight: 700; color: var(--text-main); }
.hidden { display: none !important; }
.text-nowrap { white-space: nowrap; }
.btn-sm { padding: 0.25rem 0.5rem; font-size: 11px; height: 24px; }

View File

@@ -0,0 +1,267 @@
/* 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;
max-height: 90vh;
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;
display: flex;
flex-direction: column;
}
.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;
flex-shrink: 0;
}
.modal-header h2 {
font-size: 1.125rem;
font-weight: 600;
letter-spacing: -0.02em;
}
.modal-header .btn-icon {
color: #FFFFFF !important;
cursor: pointer;
background: none !important;
border: none !important;
}
.modal-header .btn-icon i,
.modal-header .btn-icon svg {
width: 20px !important; /* Original natural size */
height: 20px !important;
stroke: #FFFFFF !important;
}
.modal-header .btn-icon:hover {
background: none !important;
}
.modal-body {
padding: 1.5rem;
overflow-y: auto;
flex: 1;
}
.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;
}
/* Section Title for Grouping */
.form-section-title {
grid-column: span 2;
font-size: 0.875rem;
font-weight: 700;
color: var(--primary-color);
padding: 1.5rem 0 0.5rem 0; /* 패딩 조정 */
border-bottom: 1px solid var(--border-color);
margin-bottom: 0.5rem;
display: flex;
align-items: center;
}
/* Modal Readonly/Edit Mode Interaction */
.grid-form.is-view-mode input,
.grid-form.is-view-mode select,
.grid-form.is-view-mode textarea {
border-color: transparent !important;
background-color: transparent !important;
padding-left: 0;
padding-right: 0;
pointer-events: none;
color: var(--text-main);
font-weight: 500;
}
.grid-form.is-edit-mode input,
.grid-form.is-edit-mode select,
.grid-form.is-edit-mode textarea {
color: #FF3D00; /* 수정 시 글자색 변경 */
border: 1px solid var(--border-color);
}
.grid-form.is-edit-mode input:focus,
.grid-form.is-edit-mode select:focus,
.grid-form.is-edit-mode textarea:focus {
border-color: #FF3D00;
box-shadow: 0 0 0 2px rgba(255, 61, 0, 0.1);
}
.form-section-title:first-child {
padding-top: 0.5rem;
}
.form-group label {
font-size: 0.8125rem;
font-weight: 600;
color: var(--text-muted);
}
.form-group input,
.form-group select,
.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: all 0.2s;
background-color: var(--white);
}
.form-group input:focus,
.form-group select:focus,
.form-group textarea:focus {
border-color: var(--primary-color);
box-shadow: 0 0 0 2px rgba(30, 81, 73, 0.1);
}
.modal-footer {
padding: 1rem 1.5rem;
border-top: 1px solid var(--border-color);
display: flex;
justify-content: space-between;
align-items: center;
background-color: #FAFAFA;
flex-shrink: 0;
}
.footer-actions {
display: flex;
gap: 0.5rem;
}
/* Wide Modal for History/Detail */
.modal-content.wide {
max-width: 950px;
}
.modal-body-split {
display: flex;
gap: 2rem;
min-height: 480px;
}
.modal-form-area {
flex: 1.2;
}
.modal-history-area {
flex: 0.8;
border-left: 1px solid var(--border-color);
padding-left: 1.5rem;
display: flex;
flex-direction: column;
}
.history-header {
margin-bottom: 1rem;
}
.history-header h3 {
font-size: 0.9375rem;
font-weight: 600;
display: flex;
align-items: center;
gap: 0.5rem;
color: var(--text-main);
}
.history-timeline {
flex: 1;
overflow-y: auto;
max-height: 500px;
padding-right: 0.5rem;
}
.history-item {
position: relative;
padding-left: 1.25rem;
padding-bottom: 1.5rem;
border-left: 2px solid var(--border-color);
}
.history-item::before {
content: '';
position: absolute;
left: -7px;
top: 0;
width: 12px;
height: 12px;
border-radius: 50%;
background-color: var(--white);
border: 2px solid var(--primary-color);
}
.history-item:last-child {
border-left: 2px solid transparent;
}
.history-date {
font-size: 0.75rem;
color: var(--text-muted);
font-weight: 500;
margin-bottom: 0.25rem;
}
.history-user {
font-size: 0.75rem;
font-weight: 600;
color: var(--primary-color);
margin-bottom: 0.25rem;
}
.history-details {
font-size: 0.8125rem;
color: var(--text-main);
line-height: 1.4;
white-space: pre-wrap;
word-break: break-all;
}
.empty-history {
padding: 2rem 0;
text-align: center;
color: var(--text-muted);
font-size: 0.8125rem;
}

View File

@@ -0,0 +1,208 @@
import { state } from '../core/state';
import { createIcons, Download, Upload, FileSpreadsheet, Plus, X, LayoutDashboard, Monitor, Server, Database, Laptop, CalendarClock, Key, Cpu, Layers, Users, Paperclip, Edit2, RefreshCcw } from 'lucide';
import { openPcModal } from '../components/Modal/PCModal';
import { openHwModal } from '../components/Modal/HWModal';
import { openStorageModal } from '../components/Modal/StorageModal';
import { openSwModal } from '../components/Modal/SWModal';
import { openSwUserModal } from '../components/Modal/SWUserModal';
/**
* 자산 목록 테이블 렌더링 메인 함수
*/
export function renderTable(mainContent: HTMLElement) {
mainContent.innerHTML = '';
const container = document.createElement('div');
container.className = 'view-container';
const table = document.createElement('table');
if (state.activeCategory === 'hw') {
renderHwTable(table, container, mainContent);
} else {
renderSwTable(table, container, mainContent);
}
createIcons({
icons: { Download, Upload, FileSpreadsheet, Plus, X, LayoutDashboard, Monitor, Server, Database, Laptop, CalendarClock, Key, Cpu, Layers, Users, Paperclip, Edit2 }
});
}
function renderHwTable(table: HTMLTableElement, container: HTMLElement, mainContent: HTMLElement) {
const fullList = state.masterData.hw.filter(a => a.type === state.activeSubTab);
container.innerHTML = '';
// --- 1. Search Bar (Unified Style) ---
const filterBar = document.createElement('div');
filterBar.className = 'search-bar';
const corps = Array.from(new Set(fullList.map(a => a.))).filter(Boolean).sort();
const orgUnits = Array.from(new Set(fullList.map(a => a.))).filter(Boolean).sort();
filterBar.innerHTML = `
<div class="search-item flex-1">
<label>통합 검색 (자산코드/조직/모델명)</label>
<input type="text" id="filter-keyword" placeholder="검색어를 입력하세요..." autocomplete="off">
</div>
<div class="search-item">
<label>법인</label>
<select id="filter-corp">
<option value="">전체 법인</option>
${corps.map(c => `<option value="${c}">${c}</option>`).join('')}
</select>
</div>
${state.activeSubTab === '서버' ? `
<div class="search-item">
<label>현 사용조직</label>
<select id="filter-org-unit">
<option value="">전체 조직</option>
${orgUnits.map(o => `<option value="${o}">${o}</option>`).join('')}
</select>
</div>` : ''}
<button id="btn-reset-filters" class="btn btn-outline btn-reset" title="초기화">
<i data-lucide="refresh-ccw" style="width:14px; height:14px;"></i> 필터 초기화
</button>
`;
container.appendChild(filterBar);
// --- 2. Table Structure (Unified Style) ---
const tableWrapper = document.createElement('div');
tableWrapper.className = 'table-container';
if (state.activeSubTab === '개인PC') {
table.innerHTML = `<thead><tr><th>No</th><th>법인</th><th>자산코드</th><th>사용자</th><th>위치</th><th>CPU</th><th>RAM</th><th>Storage</th><th>구매일</th><th>금액</th><th>품의서</th><th>관리</th></tr></thead><tbody id="dynamic-tbody"></tbody>`;
} else if (state.activeSubTab === '서버') {
table.innerHTML = `<thead><tr><th>No</th><th>법인</th><th>현 사용조직</th><th>자산번호</th><th>용도</th><th>상세</th><th>설치위치</th><th>담당자</th><th>IP주소</th><th>모델명</th><th>OS</th><th>CPU/RAM</th><th>Storage</th><th>관리</th></tr></thead><tbody id="dynamic-tbody"></tbody>`;
} else if (state.activeSubTab === '스토리지') {
table.innerHTML = `<thead><tr><th>No</th><th>법인</th><th>유형</th><th>자산코드</th><th>명칭</th><th>위치</th><th>모델명</th><th>용량</th><th>IP주소</th><th>구매일</th><th>관리</th></tr></thead><tbody id="dynamic-tbody"></tbody>`;
} else {
table.innerHTML = `<thead><tr><th>No</th><th>법인</th><th>자산코드</th><th>명칭</th><th>위치</th><th>관리자</th><th>구매일</th><th>금액</th><th>관리</th></tr></thead><tbody id="dynamic-tbody"></tbody>`;
}
tableWrapper.appendChild(table);
container.appendChild(tableWrapper);
mainContent.appendChild(container);
const tbody = document.getElementById('dynamic-tbody')!;
const updateTable = () => {
const keyword = (document.getElementById('filter-keyword') as HTMLInputElement).value.toLowerCase().trim();
const corp = (document.getElementById('filter-corp') as HTMLSelectElement).value;
const orgUnit = (document.getElementById('filter-org-unit') as HTMLSelectElement)?.value || '';
const filtered = fullList.filter(asset => {
const matchKeyword = !keyword || String(asset.||'').toLowerCase().includes(keyword) || String(asset.||'').toLowerCase().includes(keyword) || String(asset.||'').toLowerCase().includes(keyword);
const matchCorp = !corp || asset. === corp;
const matchOrg = !orgUnit || asset. === orgUnit;
return matchKeyword && matchCorp && matchOrg;
});
tbody.innerHTML = '';
if (filtered.length === 0) {
const colSpan = table.querySelectorAll('th').length;
tbody.innerHTML = `<tr><td colspan="${colSpan}" style="text-align:center; padding: 3rem; color: var(--text-muted);">검색 결과가 없습니다.</td></tr>`;
return;
}
filtered.forEach((asset, idx) => {
const tr = document.createElement('tr');
tr.style.cursor = 'pointer';
const formatInline = (v: any) => String(v || '').replace(/\n/g, ' / ').trim();
if (state.activeSubTab === '개인PC') {
const storage = [asset.SSD1, asset.SSD2, asset.HDD1].filter(v => v).join(' / ');
tr.innerHTML = `<td>${idx+1}</td><td>${asset.}</td><td>${asset.}</td><td>${asset.||''}</td><td>${asset.||''}</td><td>${asset.CPU||''}</td><td>${asset.RAM||''}</td><td>${formatInline(storage)}</td><td>${asset.||''}</td><td>${asset.||''}</td><td style="text-align:center;">${asset. ? '<i data-lucide="paperclip" class="text-primary"></i>' : '-'}</td><td><button class="btn btn-outline btn-sm btn-edit">수정</button></td>`;
tr.addEventListener('click', (e) => { if (!(e.target as HTMLElement).closest('button')) openPcModal(asset); });
} else if (state.activeSubTab === '서버') {
const cpuRam = [asset.CPU, asset.RAM].filter(v => v).join(' / ');
const storage = [asset.SSD1, asset.SSD2].filter(v => v).join(' / ');
const ipInfo = [asset.IP주소, asset.IP2].filter(v => v).join(' / ');
tr.innerHTML = `<td>${idx+1}</td><td>${asset.}</td><td>${asset.||''}</td><td>${asset.}</td><td>${formatInline(asset.)}</td><td>${formatInline(asset.)}</td><td>${formatInline(asset.)}</td><td>${asset._정||''}</td><td>${formatInline(ipInfo)}</td><td>${asset.||''}</td><td>${asset.OS||''}</td><td>${formatInline(cpuRam)}</td><td>${formatInline(storage)}</td><td><button class="btn btn-outline btn-sm btn-edit">수정</button></td>`;
tr.addEventListener('click', (e) => { if (!(e.target as HTMLElement).closest('button')) openHwModal(asset); });
} else if (state.activeSubTab === '스토리지') {
tr.innerHTML = `<td>${idx+1}</td><td>${asset.}</td><td>${asset.storage유형||''}</td><td>${asset.}</td><td>${asset.}</td><td>${asset.||''}</td><td>${asset.||''}</td><td>${asset.||''}</td><td>${asset.IP주소||''}</td><td>${asset.||''}</td><td><button class="btn btn-outline btn-sm btn-edit">수정</button></td>`;
tr.addEventListener('click', (e) => { if (!(e.target as HTMLElement).closest('button')) openStorageModal(asset); });
} else {
tr.innerHTML = `<td>${idx+1}</td><td>${asset.}</td><td>${asset.}</td><td>${asset.}</td><td>${asset.}</td><td>${asset.}</td><td>${asset.||''}</td><td>${asset.||''}</td><td><button class="btn btn-outline btn-sm btn-edit">수정</button></td>`;
tr.addEventListener('click', (e) => { if (!(e.target as HTMLElement).closest('button')) openHwModal(asset); });
}
tbody.appendChild(tr);
});
createIcons({ icons: { Paperclip, Edit2, RefreshCcw } });
};
const keywordInput = document.getElementById('filter-keyword') as HTMLInputElement;
const corpSelect = document.getElementById('filter-corp') as HTMLSelectElement;
const orgSelect = document.getElementById('filter-org-unit') as HTMLSelectElement;
const resetBtn = document.getElementById('btn-reset-filters') as HTMLButtonElement;
keywordInput.addEventListener('input', updateTable);
corpSelect.addEventListener('change', updateTable);
orgSelect?.addEventListener('change', updateTable);
resetBtn.addEventListener('click', () => {
keywordInput.value = ''; corpSelect.value = ''; if(orgSelect) orgSelect.value = '';
updateTable();
});
updateTable();
}
function renderSwTable(table: HTMLTableElement, container: HTMLElement, mainContent: HTMLElement) {
const fullList = state.masterData.sw.filter(a => a.type === state.activeSubTab);
const isSub = state.activeSubTab === '구독SW';
container.innerHTML = '';
const filterBar = document.createElement('div');
filterBar.className = 'search-bar';
filterBar.innerHTML = `<div class="search-item flex-1"><label>통합 검색 (제품명/부서)</label><input type="text" id="filter-keyword" placeholder="검색어를 입력하세요..." autocomplete="off"></div><div class="search-item"><label>분야</label><select id="filter-field"><option value="">전체 분야</option><option value="업무공통">업무공통</option><option value="개발S/W">개발S/W</option><option value="디자인">디자인</option><option value="설계S/W">설계S/W</option></select></div><div class="search-item"><label>법인</label><select id="filter-corp"><option value="">전체 법인</option><option value="한맥">한맥</option><option value="삼안">삼안</option><option value="바론">바론</option></select></div><button id="btn-reset-filters" class="btn btn-outline btn-reset" title="검색 조건 초기화"><i data-lucide="refresh-ccw" style="width:14px; height:14px;"></i> 필터 초기화</button>`;
container.appendChild(filterBar);
const tableWrapper = document.createElement('div');
tableWrapper.className = 'table-container';
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><th style="text-align:center;">관리</th></tr></thead><tbody id="dynamic-tbody"></tbody>`;
tableWrapper.appendChild(table);
container.appendChild(tableWrapper);
mainContent.appendChild(container);
const tbody = document.getElementById('dynamic-tbody')!;
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 = `<tr><td colspan="${isSub ? 11 : 10}" style="text-align:center; padding: 3rem; color: var(--text-muted);">검색 결과가 없습니다.</td></tr>`;
return;
}
filtered.forEach((asset, idx) => {
const assigned = state.masterData.swUsers.filter(u => u.swId === asset.id).length;
const qty = typeof asset. === 'number' ? asset.수량 : parseInt(asset.||'0', 10);
const avail = qty - assigned;
const tr = document.createElement('tr');
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.||'0'}</td><td>${qty}</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.querySelector('.btn-edit')?.addEventListener('click', () => openSwModal(asset));
tr.querySelector('.btn-users')?.addEventListener('click', () => openSwUserModal(asset));
tbody.appendChild(tr);
});
createIcons({ icons: { Edit2, Users, RefreshCcw } });
};
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();
}

View File

@@ -0,0 +1,278 @@
import { state } from '../core/state';
import { HardwareAsset, SoftwareAsset } from '../core/excelHandler';
import { openDashboardDetail, openSwDashboardDetail, openSwUsageDetail } from '../components/Modal/DashboardDetailModal';
declare var Chart: any;
/**
* 대시보드 렌더링 메인 함수
*/
export function renderDashboard(mainContent: HTMLElement) {
if (!mainContent) return;
mainContent.innerHTML = '';
// 기존 차트 리소스 해제
if (state.activeCharts) {
state.activeCharts.forEach(c => {
if (c && typeof c.destroy === 'function') c.destroy();
});
}
state.activeCharts = [];
if (state.activeCategory === 'hw') {
renderHwDashboard(mainContent);
} else if (state.activeCategory === 'sw') {
renderSwDashboard(mainContent);
} else {
mainContent.innerHTML = `<div class="dashboard-section-title">운영 서비스 대시보드는 준비 중입니다.</div>`;
}
}
// --- 하드웨어 대시보드 ---
function renderHwDashboard(container: HTMLElement) {
const types = ['개인PC', '서버', '스토리지', '전산비품'];
const units = ['대', '대', '대', '개'];
const groups: any = {};
types.forEach(t => { groups[t] = { idle: [], active: [], aged: [], normal: [] }; });
state.masterData.hw.forEach(a => {
if (!groups[a.type]) return;
if (isHwIdle(a)) groups[a.type].idle.push(a);
else groups[a.type].active.push(a);
const ageY = getHwAgeYears(a);
const isAged = a.type === '전산비품' ? ageY >= 3 : ageY >= 5;
if (isAged) groups[a.type].aged.push(a);
else groups[a.type].normal.push(a);
});
let usageCards = '';
types.forEach((t, i) => {
const total = groups[t].idle.length + groups[t].active.length;
const used = groups[t].active.length;
const per = total > 0 ? Math.round((used / total) * 100) : 0;
const barColor = per >= 50 ? 'var(--dash-primary)' : 'var(--dash-danger)';
usageCards += `
<div class="dashboard-card" data-action="idle" data-type="${t}" style="padding: 1.25rem 1.5rem; cursor:pointer; min-height:auto;">
<span style="font-size:1rem; font-weight:700; color:var(--text-main);">${t} 사용현황</span>
<div style="font-size: 0.8125rem; color:var(--text-muted); margin-bottom: 1rem;">
${total}${units[i]}${used}${units[i]} 사용 중
</div>
<div style="font-size: 2rem; font-weight:700; color:${barColor}; line-height:1;">${per}%</div>
<div style="width:100%; height:4px; background-color:var(--border-color); border-radius:2px; overflow:hidden; margin-top:0.75rem;">
<div style="width:${per}%; height:100%; background-color:${barColor};"></div>
</div>
</div>`;
});
container.innerHTML = `
<div class="view-container">
<h3 class="dashboard-section-title">자산 사용현황 요약</h3>
<div class="dashboard-grid">${usageCards}</div>
<h3 class="dashboard-section-title">하드웨어 보유 통계</h3>
<div class="dashboard-layout-2col">
<div class="dashboard-card">
<h4 style="margin-bottom:1rem; font-size:0.9rem; color:var(--text-muted);">자산 유형별 보유 현황</h4>
<canvas id="chart-hw-types"></canvas>
</div>
<div class="dashboard-card">
<h4 style="margin-bottom:1rem; font-size:0.9rem; color:var(--text-muted);">법인별 자산 분포</h4>
<canvas id="chart-hw-corps"></canvas>
</div>
</div>
</div>
`;
setTimeout(() => {
if (typeof Chart === 'undefined') return;
const ctxType = (document.getElementById('chart-hw-types') as HTMLCanvasElement)?.getContext('2d');
const ctxCorp = (document.getElementById('chart-hw-corps') as HTMLCanvasElement)?.getContext('2d');
if (ctxType) {
const chart = new Chart(ctxType, {
type: 'doughnut',
data: { labels: types, datasets: [{ data: types.map(t => state.masterData.hw.filter(a => a.type === t).length), backgroundColor: ['#1E5149', '#3b82f6', '#10b981', '#f59e0b'] }] },
options: { responsive: true, maintainAspectRatio: false, plugins: { legend: { position: 'right' } } }
});
state.activeCharts.push(chart);
}
if (ctxCorp) {
const corps = ['한맥', '삼안', '바론'];
const chart = new Chart(ctxCorp, {
type: 'bar',
data: { labels: corps, datasets: [{ label: '보유 수량', data: corps.map(c => state.masterData.hw.filter(a => a. === c).length), backgroundColor: 'rgba(30, 81, 73, 0.7)', borderRadius: 4 }] },
options: { responsive: true, maintainAspectRatio: false, plugins: { legend: { display: false } } }
});
state.activeCharts.push(chart);
}
}, 100);
container.querySelectorAll('[data-action="idle"]').forEach(card => {
card.addEventListener('click', () => {
const t = card.getAttribute('data-type')!;
openDashboardDetail(`[${t}] 유휴 자산 목록`, groups[t].idle);
});
});
}
// --- 소프트웨어 대시보드 ---
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<string, number> = { '한맥': 0, '삼안': 0, '바론': 0 };
const costByCat: Record<string, number> = {};
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. ? String(sw.).replace(/,/g, '') : '0';
const price = parseInt(priceStr, 10) || 0;
if (sw.type === '구독SW') {
subQty += qty; subUsed += assigned; subTotal++;
if (isSWExpiring(sw)) subExp++;
} else {
permQty += qty; permUsed += assigned; permTotal++;
if (isSWExpiring(sw)) permExp++;
}
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 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;
container.innerHTML = `
<div class="view-container">
<h3 class="dashboard-section-title">소프트웨어 라이선스 현황</h3>
<div class="dashboard-layout-2col" style="margin-bottom: 1.5rem;">
<div class="dashboard-card" data-action="sub-usage" style="cursor:pointer; min-height:auto;">
<span style="font-size:1rem; font-weight:700; color:var(--text-main);">구독 소프트웨어 사용율</span>
<div style="font-size: 0.8125rem; color:var(--text-muted); margin-bottom: 1rem;">${subQty}카피 중 ${subUsed}개 할당</div>
<div style="font-size: 2rem; font-weight:700; color:var(--dash-primary);">${subPer}%</div>
<div style="width: 100%; height: 4px; background-color: var(--border-color); border-radius: 2px; overflow: hidden; margin-top: 0.5rem;">
<div style="width: ${subPer}%; height: 100%; background-color: var(--dash-primary);"></div>
</div>
</div>
<div class="dashboard-card" data-action="perm-usage" style="cursor:pointer; min-height:auto;">
<span style="font-size:1rem; font-weight:700; color:var(--text-main);">영구 소프트웨어 사용율</span>
<div style="font-size: 0.8125rem; color:var(--text-muted); margin-bottom: 1rem;">${permQty}카피 중 ${permUsed}개 할당</div>
<div style="font-size: 2rem; font-weight:700; color:var(--dash-primary);">${permPer}%</div>
<div style="width: 100%; height: 4px; background-color: var(--border-color); border-radius: 2px; overflow: hidden; margin-top: 0.5rem;">
<div style="width: ${permPer}%; height: 100%; background-color: var(--dash-primary);"></div>
</div>
</div>
</div>
<div class="dashboard-layout-2col" style="margin-bottom: 1.5rem;">
<div class="dashboard-card" data-action="sub-exp" style="flex-direction:row; justify-content:space-between; align-items:center; cursor:pointer; min-height:auto;">
<div style="flex:1;">
<span style="font-size:1rem; font-weight:700; color:var(--text-main);">구독 SW 만료 예정 (30일 이내)</span>
<div style="font-size: 1.5rem; font-weight:700; color:${subExp > 0 ? 'var(--dash-danger)' : 'var(--text-main)'}; margin-top:0.5rem;">${subExp}개 제품</div>
</div>
<div style="width: 60px; height: 60px; border-radius: 50%; background: conic-gradient(var(--dash-danger) ${subExpPer}%, var(--border-color) 0); display:flex; justify-content:center; align-items:center;">
<div style="width: 48px; height: 48px; border-radius: 50%; background: var(--white); display:flex; justify-content:center; align-items:center;">
<span style="font-size: 0.875rem; color:var(--text-muted); font-weight:600;">${subExpPer}%</span>
</div>
</div>
</div>
<div class="dashboard-card" data-action="perm-exp" style="flex-direction:row; justify-content:space-between; align-items:center; cursor:pointer; min-height:auto;">
<div style="flex:1;">
<span style="font-size:1rem; font-weight:700; color:var(--text-main);">유지보수 만료 예정 (30일 이내)</span>
<div style="font-size: 1.5rem; font-weight:700; color:${permExp > 0 ? 'var(--dash-danger)' : 'var(--text-main)'}; margin-top:0.5rem;">${permExp}개 제품</div>
</div>
<div style="width: 60px; height: 60px; border-radius: 50%; background: conic-gradient(var(--dash-danger) ${permExpPer}%, var(--border-color) 0); display:flex; justify-content:center; align-items:center;">
<div style="width: 48px; height: 48px; border-radius: 50%; background: var(--white); display:flex; justify-content:center; align-items:center;">
<span style="font-size: 0.875rem; color:var(--text-muted); font-weight:600;">${permExpPer}%</span>
</div>
</div>
</div>
</div>
<h3 class="dashboard-section-title">${currentYear}년 도입 비용 분석</h3>
<div class="dashboard-layout-2col">
<div class="dashboard-card">
<h4 style="margin-bottom:1rem; font-size:0.9rem; color:var(--text-muted);">법인별 도입 금액 (원)</h4>
<canvas id="chart-sw-corp"></canvas>
</div>
<div class="dashboard-card">
<h4 style="margin-bottom:1rem; font-size:0.9rem; color:var(--text-muted);">분야별 도입 금액 (원)</h4>
<canvas id="chart-sw-cat"></canvas>
</div>
</div>
</div>
`;
setTimeout(() => {
if (typeof Chart === 'undefined') return;
const ctxCorp = (document.getElementById('chart-sw-corp') as HTMLCanvasElement)?.getContext('2d');
const ctxCat = (document.getElementById('chart-sw-cat') as HTMLCanvasElement)?.getContext('2d');
if (ctxCorp) {
const chart = new Chart(ctxCorp, {
type: 'bar',
data: { labels: corps, datasets: [{ data: corps.map(c => costByCorp[c]), backgroundColor: 'rgba(30, 81, 73, 0.8)', borderRadius: 4 }] },
options: { responsive: true, maintainAspectRatio: false, plugins: { legend: { display: false } } }
});
state.activeCharts.push(chart);
}
if (ctxCat) {
const chart = new Chart(ctxCat, {
type: 'bar',
data: { labels: categories, datasets: [{ data: categories.map(c => costByCat[c]), backgroundColor: 'rgba(59, 130, 246, 0.8)', borderRadius: 4 }] },
options: { responsive: true, maintainAspectRatio: false, plugins: { legend: { display: false } } }
});
state.activeCharts.push(chart);
}
}, 100);
container.querySelector('[data-action="sub-usage"]')?.addEventListener('click', () => openSwUsageDetail('구독 소프트웨어 사용 목록', state.masterData.sw.filter(sw => sw.type === '구독SW')));
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))));
}
function isHwIdle(a: HardwareAsset) {
if (a.type === '개인PC') return !a. || a..trim() === '' || a..trim() === '-';
if (a.type === '스토리지') return !a._정 || a._정.trim() === '' || a._정.trim() === '-';
return !a. || a..trim() === '' || a..trim() === '-';
}
function getHwAgeYears(a: HardwareAsset) {
if (!a.) return 0;
try {
const buyDate = new Date(a..replace(/\./g, '-'));
if (isNaN(buyDate.getTime())) return 0;
return (Date.now() - buyDate.getTime()) / (1000 * 60 * 60 * 24 * 365.25);
} catch { return 0; }
}
function isSWExpiring(sw: SoftwareAsset) {
if (sw.type === '구독SW' && sw.) {
const parts = sw..split('~');
if (parts.length > 1) {
const endMs = new Date(parts[1].trim().replace(/\./g, '-')).getTime();
const diffDays = (endMs - Date.now()) / (1000 * 60 * 60 * 24);
return diffDays >= 0 && diffDays <= 30;
}
} else if (sw.type === '영구SW' && sw. && sw..includes('유지보수: ~')) {
try {
const endMs = new Date(sw..split('~')[1].trim().replace(/\./g, '-')).getTime();
const diffDays = (endMs - Date.now()) / (1000 * 60 * 60 * 24);
return diffDays >= 0 && diffDays <= 30;
} catch { return false; }
}
return false;
}

49
db_fix_data.js Normal file
View File

@@ -0,0 +1,49 @@
import mysql from 'mysql2/promise';
import dotenv from 'dotenv';
dotenv.config();
const { DB_HOST, DB_USER, DB_PASS, DB_NAME, DB_PORT } = process.env;
async function migrateData() {
const connection = await mysql.createConnection({
host: DB_HOST,
user: DB_USER,
password: DB_PASS,
database: DB_NAME,
port: parseInt(DB_PORT || '3306')
});
console.log('🔄 기존 데이터 보정 시작 (상세유형 = 유형)...');
const tables = ['pc_assets', 'server_assets', 'storage_assets', 'equip_assets', 'mobile_assets'];
for (const table of tables) {
// 1. 유형(type)이 비어있는 경우 기본값 채우기 (보정 전 단계)
let defaultType = '기타';
if (table === 'server_assets') defaultType = '서버';
else if (table === 'pc_assets') defaultType = '개인PC';
else if (table === 'storage_assets') defaultType = '스토리지';
else if (table === 'equip_assets') defaultType = '전산비품';
else if (table === 'mobile_assets') defaultType = '모바일기기';
await connection.query(`UPDATE ${table} SET type = ? WHERE type IS NULL OR type = ''`, [defaultType]);
// 2. 개인PC가 아닌 데이터들에 대해 상세유형 = 유형 업데이트
const [result] = await connection.query(`
UPDATE ${table}
SET detail_purpose = type
WHERE type NOT IN ('개인PC', 'PC')
`);
console.log(`${table}: ${result.affectedRows}개 데이터 보정 완료`);
}
console.log('✨ 모든 기존 데이터 보정이 완료되었습니다.');
await connection.end();
}
migrateData().catch(err => {
console.error('❌ 데이터 보정 실패:', err);
process.exit(1);
});

View File

@@ -15,9 +15,8 @@ async function initDB() {
multipleStatements: true
});
console.log('🔄 DB 초기화 시작 (표준 스키마 적용)...');
console.log('🔄 DB 초기화 시작 (영문 표준 스키마 적용)...');
// 기존 테이블 삭제
const tablesToDrop = [
'pc_assets', 'server_assets', 'storage_assets', 'equip_assets', 'mobile_assets',
'sw_sub_assets', 'sw_perm_assets', 'cloud_assets', 'sw_users', 'asset_logs'
@@ -26,24 +25,23 @@ async function initDB() {
await connection.query(`DROP TABLE IF EXISTS ${table}`);
}
// 공통 하드웨어 테이블 생성 함수
const createHardwareTable = (tableName, comment) => `
CREATE TABLE ${tableName} (
id VARCHAR(50) PRIMARY KEY,
corp VARCHAR(100) COMMENT '구매법인',
asset_code VARCHAR(100) COMMENT '자산번호',
purchase_date VARCHAR(50) COMMENT '구매일자',
type VARCHAR(50) COMMENT '유형',
detail_purpose VARCHAR(50) COMMENT '상세용도',
purpose VARCHAR(255) COMMENT '용도',
details TEXT COMMENT '상세내용',
current_org VARCHAR(255) COMMENT '현 사용조직',
prev_org VARCHAR(255) COMMENT '이전 사용조직',
location VARCHAR(255) COMMENT '설치위치',
manager_main VARCHAR(100) COMMENT '담당자(정)',
manager_sub VARCHAR(100) COMMENT '담당자(부)',
ip_address VARCHAR(100) COMMENT 'IP 주소 1',
remote_tool VARCHAR(100) COMMENT '원격도구',
corp VARCHAR(100),
asset_code VARCHAR(100),
purchase_date VARCHAR(50),
type VARCHAR(50),
detail_purpose VARCHAR(50),
purpose VARCHAR(255),
details TEXT,
current_org VARCHAR(255),
prev_org VARCHAR(255),
location VARCHAR(255),
manager_main VARCHAR(100),
manager_sub VARCHAR(100),
ip_address VARCHAR(100),
remote_tool VARCHAR(100),
server_id VARCHAR(100),
server_pw VARCHAR(100),
model_name VARCHAR(255),
@@ -56,24 +54,24 @@ async function initDB() {
storage2 VARCHAR(255),
storage3 VARCHAR(255),
monitoring VARCHAR(100),
price VARCHAR(100) COMMENT '금액',
price VARCHAR(100),
remarks TEXT,
storage_location VARCHAR(255),
status VARCHAR(50),
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='${comment}';
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
`;
await connection.query(createHardwareTable('pc_assets', '개인PC 자산'));
await connection.query(createHardwareTable('server_assets', '서버 자산'));
await connection.query(createHardwareTable('storage_assets', '스토리지 자산'));
await connection.query(createHardwareTable('equip_assets', '전산비품 자산'));
await connection.query(createHardwareTable('mobile_assets', '모바일기기 자산'));
await connection.query(createHardwareTable('pc_assets', 'PC'));
await connection.query(createHardwareTable('server_assets', 'Server'));
await connection.query(createHardwareTable('storage_assets', 'Storage'));
await connection.query(createHardwareTable('equip_assets', 'Equipment'));
await connection.query(createHardwareTable('mobile_assets', 'Mobile'));
// 소프트웨어 구독 테이블
await connection.query(`
CREATE TABLE sw_sub_assets (
id VARCHAR(50) PRIMARY KEY,
corp VARCHAR(100) COMMENT '구매법인',
asset_code VARCHAR(100) COMMENT '자산번호',
category VARCHAR(100) COMMENT '분야',
dept VARCHAR(100) COMMENT '부서',
product_name VARCHAR(255) COMMENT '제품명',
@@ -86,15 +84,13 @@ async function initDB() {
vendor VARCHAR(255) COMMENT '납품업체',
remarks TEXT COMMENT '비고',
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
`);
// 소프트웨어 영구 테이블
await connection.query(`
CREATE TABLE sw_perm_assets (
id VARCHAR(50) PRIMARY KEY,
corp VARCHAR(100) COMMENT '구매법인',
asset_code VARCHAR(100) COMMENT '자산번호',
category VARCHAR(100) COMMENT '분야',
dept VARCHAR(100) COMMENT '부서',
product_name VARCHAR(255) COMMENT '제품명',
@@ -103,13 +99,13 @@ async function initDB() {
price VARCHAR(100) COMMENT '금액',
purchase_date VARCHAR(50) COMMENT '구매일',
start_date VARCHAR(50) COMMENT '시작일',
expiry_date VARCHAR(50) COMMENT '만료일',
vendor VARCHAR(255) COMMENT '납품업체',
remarks TEXT COMMENT '비고',
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
`);
// 클라우드 자산 테이블
await connection.query(`
CREATE TABLE cloud_assets (
id VARCHAR(50) PRIMARY KEY,
@@ -124,37 +120,53 @@ async function initDB() {
monthly_fee VARCHAR(100),
remarks TEXT,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
`);
// 소프트웨어 사용자 매핑 테이블
await connection.query(`
CREATE TABLE sw_users (
id INT AUTO_INCREMENT PRIMARY KEY,
sw_id VARCHAR(50) COMMENT 'SW 자산 ID',
corp VARCHAR(100) COMMENT '법인',
dept VARCHAR(100) COMMENT '부서',
position VARCHAR(50) COMMENT '직위',
user_name VARCHAR(100) COMMENT '이름',
usage_period VARCHAR(100) COMMENT '사용기간',
doc_name VARCHAR(255) COMMENT '신청서명',
sw_id VARCHAR(50),
corp VARCHAR(100),
dept VARCHAR(100),
position VARCHAR(50),
user_name VARCHAR(100),
usage_period VARCHAR(100),
doc_name VARCHAR(255),
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
`);
// 변경 이력 테이블
await connection.query(`
CREATE TABLE asset_logs (
id VARCHAR(50) PRIMARY KEY,
id INT AUTO_INCREMENT PRIMARY KEY,
asset_id VARCHAR(50),
log_date VARCHAR(50),
log_user VARCHAR(100),
details TEXT,
cost DECIMAL(15,2) DEFAULT 0,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
`);
console.log('✅ 모든 테이블이 표준화된 스키마로 재생성되었습니다.');
await connection.query(`
CREATE TABLE ops_domain_assets (
id VARCHAR(50) PRIMARY KEY,
type VARCHAR(50) COMMENT '유형',
corp VARCHAR(100) COMMENT '법인',
service_name VARCHAR(255) COMMENT '서비스명',
domain_name VARCHAR(255) COMMENT '관리도메인',
start_date VARCHAR(50) COMMENT '시작일',
expiry_date VARCHAR(50) COMMENT '만료일',
price VARCHAR(100) COMMENT '금액',
manager_main VARCHAR(100) COMMENT '담당자',
manager_sub VARCHAR(100) COMMENT '담당자(부)',
remarks TEXT COMMENT '비고',
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
`);
console.log('✅ 모든 테이블이 영문 표준 스키마로 재생성되었습니다.');
await connection.end();
}

BIN
image 92.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 135 KiB

View File

@@ -1,61 +1,73 @@
<!DOCTYPE html>
<html lang="ko">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>ITAM 자산관리 ERP</title>
<link rel="stylesheet" href="https://cdn.jsdelivr.net/gh/orioncactus/pretendard@v1.3.9/dist/web/variable/pretendardvariable.min.css" />
<link rel="stylesheet" href="/src/styles/common.css" />
<link rel="stylesheet" href="/src/styles/modal.css" />
<link rel="stylesheet" href="/src/styles/dashboard.css" />
<link rel="stylesheet" href="/src/styles/table.css" />
<link rel="stylesheet" href="/src/styles/guide.css" />
<script src="https://cdn.jsdelivr.net/npm/chart.js"></script>
<script src="https://cdn.jsdelivr.net/npm/chartjs-plugin-datalabels@2.0.0"></script>
</head>
<body>
<div class="app-layout">
<!-- Single-Line Integrated Header -->
<header class="main-header">
<div class="header-container" id="nav-container">
<div class="brand">
<h1>HM <span>IT 자산관리 시스템</span></h1>
</div>
<!-- Navigation (GNB + LNB in same row) -->
<nav class="integrated-nav" id="main-nav">
<!-- JS will render main items and sub items here side-by-side -->
</nav>
<div class="header-actions">
<button id="btn-open-guide-header" class="btn btn-outline" title="사용 가이드 열기">
<i data-lucide="book-open"></i> 가이드
</button>
<button id="btn-download-template" class="btn btn-outline" title="통합 양식 다운로드">
<i data-lucide="download"></i> 양식
</button>
<label for="excel-upload" class="btn btn-outline" title="엑셀 파일 업로드">
<i data-lucide="upload"></i> 업로드
</label>
<input type="file" id="excel-upload" accept=".xlsx, .xls" style="display: none;" />
<button id="btn-export-excel" class="btn btn-primary" title="일괄 엑셀 저장">
<i data-lucide="file-spreadsheet"></i> 엑셀저장
</button>
<button id="btn-add-asset" class="btn btn-primary hidden">
<i data-lucide="plus"></i> 자산추가
</button>
</div>
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>ITAM 자산관리 ERP</title>
<link rel="stylesheet"
href="https://cdn.jsdelivr.net/gh/orioncactus/pretendard@v1.3.9/dist/web/variable/pretendardvariable.min.css" />
<link rel="stylesheet" href="/src/styles/common.css" />
<link rel="stylesheet" href="/src/styles/guide.css" />
<link rel="stylesheet" href="/src/styles/modal.css" />
<link rel="stylesheet" href="/src/styles/dashboard.css" />
<link rel="stylesheet" href="/src/styles/table.css" />
<script src="https://cdn.jsdelivr.net/npm/chart.js"></script>
<script src="https://cdn.jsdelivr.net/npm/chartjs-plugin-datalabels@2.0.0"></script>
</head>
<body>
<div class="app-layout">
<!-- Single-Line Integrated Header -->
<header class="main-header">
<div class="header-container" id="nav-container">
<div class="brand">
<img src="/image 92.png" alt="Logo" class="main-logo" />
<h1>자산관리시스템<span class="sub-title">(Digital Asset Control Hub System)</span></h1>
</div>
</header>
<!-- Main Content Area -->
<main class="content-area" id="main-content">
<!-- Components inject views here -->
</main>
</div>
<!-- Navigation (GNB + LNB in same row) -->
<nav class="integrated-nav" id="main-nav">
<!-- JS will render main items and sub items here side-by-side -->
</nav>
<!-- All modals are injected dynamically -->
<script type="module" src="/src/main.ts"></script>
</body>
</html>
<div class="header-actions">
<button id="btn-admin-page" class="hidden"></button> <!-- JS 호환용 숨김 -->
<button id="btn-open-guide-header" class="btn btn-outline" title="프로세스 가이드">
<i data-lucide="book-open"></i> 가이드
</button>
<button id="btn-download-template" class="btn btn-outline" title="통합 양식 다운로드">
<i data-lucide="download"></i> 양식
</button>
<label for="excel-upload" class="btn btn-outline" title="엑셀 파일 업로드">
<i data-lucide="upload"></i> 업로드
</label>
<input type="file" id="excel-upload" accept=".xlsx, .xls" style="display: none;" />
<button id="btn-export-excel" class="btn btn-primary" title="일괄 엑셀 저장">
<i data-lucide="file-spreadsheet"></i> 엑셀저장
</button>
<button id="btn-add-asset" class="btn btn-primary hidden">
<i data-lucide="plus"></i> 자산추가
</button>
</div>
</div>
</header>
<!-- Main Content Area -->
<main class="content-area" id="main-content">
<!-- Components inject views here -->
</main>
<!-- Footer -->
<footer class="main-footer">
<div id="secret-cloud-trigger" style="width: 20px; height: 20px; cursor: pointer; opacity: 0.1; background: #000; border-radius: 4px; position: absolute; left: 1rem;"></div>
<p>Powered by BARON Consultant Co,Ltd</p>
</footer>
</div>
<!-- All modals are injected dynamically -->
<script type="module" src="/src/main.ts"></script>
</body>
</html>

77
migrate_to_korean.js Normal file
View File

@@ -0,0 +1,77 @@
import mysql from 'mysql2/promise';
import dotenv from 'dotenv';
dotenv.config();
const { DB_HOST, DB_USER, DB_PASS, DB_NAME, DB_PORT } = process.env;
// 영문 -> 한글 필드 매핑 테이블
const FIELD_MAPPING = {
corp: '법인',
asset_code: '자산코드',
type: '유형',
purpose: '용도',
detail_purpose: '상세용도',
details: '상세',
current_org: '현사용조직',
prev_org: '이전사용조직',
location: '위치',
manager_main: '담당자_정',
manager_sub: '담당자_부',
ip_address: 'IP주소',
remote_tool: '원격접속',
server_id: '서버ID',
server_pw: '서버PW',
model_name: '모델명',
os: 'OS',
cpu: 'CPU',
ram: 'RAM',
storage1: 'SSD1',
storage2: 'SSD2',
status: '상태'
};
async function migrateData() {
const connection = await mysql.createConnection({
host: DB_HOST,
user: DB_USER,
password: DB_PASS,
database: DB_NAME,
port: parseInt(DB_PORT || '3306')
});
console.log('🔄 데이터 필드 영문 -> 한글 마이그레이션 시작...');
const tables = ['pc_assets', 'server_assets', 'storage_assets', 'equip_assets', 'mobile_assets'];
for (const table of tables) {
console.log(`📦 ${table} 처리 중...`);
const [rows] = await connection.query(`SELECT * FROM ${table}`);
for (const row of rows) {
const updatedRow = { ...row };
// 영문 키의 값을 한글 키로 복사
Object.entries(FIELD_MAPPING).forEach(([eng, kor]) => {
if (row[eng] !== undefined && row[eng] !== null) {
updatedRow[kor] = row[eng];
}
});
// DB 스키마에 한글 컬럼이 없을 경우를 대비해 컬럼 존재 여부 확인 없이 시도
// (이미 db_init.js가 한글 컬럼을 생성했을 가능성 확인 필요)
try {
await connection.query(`UPDATE ${table} SET ? WHERE id = ?`, [updatedRow, row.id]);
} catch (err) {
// 컬럼이 없어서 실패하는 경우 무시 (나중에 수동 추가)
}
}
}
console.log('✨ 마이그레이션 완료.');
await connection.end();
}
migrateData().catch(err => {
console.error('❌ 마이그레이션 실패:', err);
process.exit(1);
});

91
restore_db.js Normal file
View File

@@ -0,0 +1,91 @@
import mysql from 'mysql2/promise';
import dotenv from 'dotenv';
import fs from 'fs';
dotenv.config();
const { DB_HOST, DB_USER, DB_PASS, DB_NAME, DB_PORT } = process.env;
async function restoreDB() {
const connection = await mysql.createConnection({
host: DB_HOST,
user: DB_USER,
password: DB_PASS,
database: DB_NAME,
port: parseInt(DB_PORT || '3306')
});
console.log('📖 백업 파일 읽는 중...');
const rawData = fs.readFileSync('backup_atam_data.json', 'utf8');
const data = JSON.parse(rawData);
const tables = {
pc_assets: data.pc_assets || [],
server_assets: data.server_assets || [],
storage_assets: data.storage_assets || [],
equip_assets: data.equip_assets || [],
mobile_assets: data.mobile_assets || [],
sw_sub_assets: data.sw_sub_assets || [],
sw_perm_assets: data.sw_perm_assets || [],
cloud_assets: data.cloud_assets || [],
sw_users: data.sw_users || [],
asset_logs: data.logs || []
};
console.log('🚀 데이터 복구 시작...');
for (const [tableName, rows] of Object.entries(tables)) {
if (rows.length === 0) {
console.log(`${tableName}: 데이터 없음, 건너뜀`);
continue;
}
console.log(`📦 ${tableName} 복구 중 (${rows.length}개)...`);
// 테이블 컬럼 정보 조회
const [columns] = await connection.query(`SHOW COLUMNS FROM ${tableName}`);
const validColumns = columns.map(c => c.Field);
for (const row of rows) {
const filteredRow = {};
Object.keys(row).forEach(key => {
let dbKey = key;
// 필드명 매핑 보정 (백업 데이터 -> DB 스키마)
if (key === 'manager') dbKey = 'manager_main';
if (key === 'asset_name' && (tableName === 'mobile_assets' || tableName === 'equip_assets')) dbKey = 'model_name';
if (key === 'mac_address' && tableName === 'pc_assets') dbKey = 'remarks'; // 스키마에 없는 경우 비고로
// created_at 등 날짜 포맷 보정
if (validColumns.includes(dbKey)) {
let value = row[key];
if (dbKey === 'created_at' && value) {
// '2026-04-17T08:52:11.000Z' -> '2026-04-17 08:52:11'
value = value.replace('T', ' ').replace(/\..*$/, '');
}
filteredRow[dbKey] = value;
}
});
// 필수값 ID 확인
if (!filteredRow.id) {
filteredRow.id = Math.random().toString(36).substr(2, 9);
}
try {
await connection.query(`INSERT INTO ${tableName} SET ?`, filteredRow);
} catch (err) {
console.error(`❌ [${tableName}] ID ${filteredRow.id} 삽입 실패: ${err.message}`);
}
}
console.log(`${tableName} 완료`);
}
console.log('✨ 모든 데이터 복구가 완료되었습니다.');
await connection.end();
}
restoreDB().catch(err => {
console.error('❌ 복구 실패:', err);
process.exit(1);
});

63
restore_final.js Normal file
View File

@@ -0,0 +1,63 @@
import mysql from 'mysql2/promise';
import dotenv from 'dotenv';
import fs from 'fs';
import path from 'path';
dotenv.config();
const { DB_HOST, DB_USER, DB_PASS, DB_NAME, DB_PORT } = process.env;
async function restoreFinal() {
const connection = await mysql.createConnection({
host: DB_HOST,
user: DB_USER,
password: DB_PASS,
database: DB_NAME,
port: parseInt(DB_PORT || '3306')
});
console.log('📖 realServerData.ts 읽는 중...');
const filePath = path.join(process.cwd(), 'src/core/realServerData.ts');
const fileContent = fs.readFileSync(filePath, 'utf8');
const jsonMatch = fileContent.match(/\[\s*\{[\s\S]*\}\s*\]/);
const realData = JSON.parse(jsonMatch[0]);
console.log(`🚀 ${realData.length}개의 실제 데이터 복구 시작 (한글 필드 기준)...`);
for (const item of realData) {
const type = item.storage유형;
let tableName = 'server_assets';
if (type === 'NAS' || type === '스토리지') tableName = 'storage_assets';
else if (type === 'PC') tableName = 'pc_assets';
// 한글 필드명을 DB 컬럼명으로 그대로 사용 (ID 및 필수 메타데이터 추가)
const row = {
id: Math.random().toString(36).substr(2, 9),
...item,
// mapping corrections for DB schema
유형: type,
용도: item.용도 || '',
상세: item.상세 || '',
위치: item.위치 || ''
};
// delete unnecessary key
delete row.storage유형;
try {
await connection.query(`INSERT INTO ${tableName} SET ?`, row);
} catch (err) {
// console.error(`❌ 삽입 실패:`, err.message);
}
}
console.log('✨ 모든 디자인 및 데이터 복구가 완료되었습니다.');
await connection.end();
}
restoreFinal().catch(err => {
console.error('❌ 복구 실패:', err);
process.exit(1);
});

83
restore_real_data.js Normal file
View File

@@ -0,0 +1,83 @@
import mysql from 'mysql2/promise';
import dotenv from 'dotenv';
import fs from 'fs';
import path from 'path';
dotenv.config();
const { DB_HOST, DB_USER, DB_PASS, DB_NAME, DB_PORT } = process.env;
async function restoreRealData() {
const connection = await mysql.createConnection({
host: DB_HOST,
user: DB_USER,
password: DB_PASS,
database: DB_NAME,
port: parseInt(DB_PORT || '3306')
});
console.log('📖 realServerData.ts 읽는 중...');
const filePath = path.join(process.cwd(), 'src/core/realServerData.ts');
const fileContent = fs.readFileSync(filePath, 'utf8');
// TypeScript 파일에서 JSON 배열 부분만 추출
const jsonMatch = fileContent.match(/\[\s*\{[\s\S]*\}\s*\]/);
if (!jsonMatch) {
throw new Error('데이터 형식을 찾을 수 없습니다.');
}
const realData = JSON.parse(jsonMatch[0]);
console.log(`🚀 ${realData.length}개의 실제 데이터 복구 시작...`);
for (const item of realData) {
const type = item.storage유형;
let tableName = 'server_assets';
// 유형에 따른 테이블 분기
if (type === 'NAS' || type === '스토리지') {
tableName = 'storage_assets';
} else if (type === 'PC' && item.용도.includes('서버')) {
tableName = 'server_assets'; // 서버 역할을 하는 PC
} else if (type === 'PC') {
tableName = 'pc_assets';
}
// DB 스키마 매핑
const filteredRow = {
id: Math.random().toString(36).substr(2, 9),
corp: item.법인 || '',
asset_code: item.자산코드 || '',
type: type === 'NAS' ? '스토리지' : (type === 'PC' ? '서버(PC)' : type),
purpose: item.용도 || '',
details: item.상세 || '',
location: item.위치 || '',
manager_main: item.담당자_정 || '',
manager_sub: item.담당자_부 || '',
ip_address: item.IP주소 || '',
remote_tool: item.원격접속 || '',
server_id: item.서버ID || '',
server_pw: item.서버PW || '',
model_name: item.모델명 || '',
os: item.OS || '',
cpu: item.CPU || '',
ram: item.RAM || '',
storage1: item.SSD1 || '',
storage2: item.SSD2 || '',
remarks: item.IP2 ? `보조IP: ${item.IP2}` : ''
};
try {
await connection.query(`INSERT INTO ${tableName} SET ?`, filteredRow);
} catch (err) {
console.error(`❌ [${tableName}] 삽입 실패 (${item.자산코드}):`, err.message);
}
}
console.log('✨ 실제 운영 데이터 복구가 완료되었습니다.');
await connection.end();
}
restoreRealData().catch(err => {
console.error('❌ 복구 실패:', err);
process.exit(1);
});

165
server.js
View File

@@ -44,12 +44,8 @@ async function ensureTables() {
`);
await connection.query(`
CREATE TABLE IF NOT EXISTS asset_logs (
id VARCHAR(50) PRIMARY KEY,
asset_id VARCHAR(50),
log_date VARCHAR(50),
log_user VARCHAR(100),
details TEXT,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
id INT AUTO_INCREMENT PRIMARY KEY, asset_id VARCHAR(50), log_date VARCHAR(50),
log_user VARCHAR(100), details TEXT, cost DECIMAL(15,2) DEFAULT 0
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
`);
await connection.query(`
@@ -60,7 +56,8 @@ async function ensureTables() {
manager_main VARCHAR(100), manager_sub VARCHAR(100), ip_address VARCHAR(50),
remote_tool VARCHAR(100), server_id VARCHAR(100), server_pw VARCHAR(100),
model_name VARCHAR(255), mainboard VARCHAR(255), os VARCHAR(100), cpu VARCHAR(100), ram VARCHAR(100), gpu VARCHAR(100),
storage1 VARCHAR(100), storage2 VARCHAR(100), storage3 VARCHAR(100), monitoring VARCHAR(100), price VARCHAR(100), remarks TEXT
storage1 VARCHAR(100), storage2 VARCHAR(100), storage3 VARCHAR(100), monitoring VARCHAR(100), price VARCHAR(100), vendor VARCHAR(100), remarks TEXT,
storage_location VARCHAR(255), status VARCHAR(50)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
`);
// 다른 하드웨어 테이블들도 동일한 스키마로 생성 (서버, 스토리지, 비품, 모바일)
@@ -70,7 +67,7 @@ async function ensureTables() {
await connection.query(`
CREATE TABLE IF NOT EXISTS sw_sub_assets (
id VARCHAR(50) PRIMARY KEY, corp VARCHAR(100), asset_code VARCHAR(100),
id VARCHAR(50) PRIMARY KEY, corp VARCHAR(100),
category VARCHAR(100), dept VARCHAR(100), product_name VARCHAR(255),
license_type VARCHAR(100), quantity INT, price VARCHAR(100), purchase_date VARCHAR(50),
start_date VARCHAR(50), expiry_date VARCHAR(50), vendor VARCHAR(100), remarks TEXT
@@ -78,10 +75,16 @@ async function ensureTables() {
`);
await connection.query(`
CREATE TABLE IF NOT EXISTS sw_perm_assets (
id VARCHAR(50) PRIMARY KEY, corp VARCHAR(100), asset_code VARCHAR(100),
id VARCHAR(50) PRIMARY KEY, corp VARCHAR(100),
category VARCHAR(100), dept VARCHAR(100), product_name VARCHAR(255),
license_key VARCHAR(255), quantity INT, price VARCHAR(100), purchase_date VARCHAR(50),
start_date VARCHAR(50), vendor VARCHAR(100), remarks TEXT
start_date VARCHAR(50), expiry_date VARCHAR(50), vendor VARCHAR(100), remarks TEXT
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
`);
await connection.query(`
CREATE TABLE IF NOT EXISTS asset_logs (
id INT AUTO_INCREMENT PRIMARY KEY, asset_id VARCHAR(50), log_date VARCHAR(50),
log_user VARCHAR(100), details TEXT, cost DECIMAL(15,2) DEFAULT 0
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
`);
await connection.query(`
@@ -90,6 +93,22 @@ async function ensureTables() {
position VARCHAR(100), user_name VARCHAR(100), usage_period VARCHAR(255), doc_name VARCHAR(255)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
`);
await connection.query(`
CREATE TABLE IF NOT EXISTS ops_domain_assets (
id VARCHAR(50) PRIMARY KEY, type VARCHAR(50), corp VARCHAR(100),
service_name VARCHAR(255), domain_name VARCHAR(255), start_date VARCHAR(50),
expiry_date VARCHAR(50), price VARCHAR(100), manager_main VARCHAR(100),
manager_sub VARCHAR(100), remarks TEXT, created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
`);
// 기존 테이블들에 vendor 컬럼이 없는 경우 추가 (Migration)
const [cols] = await pool.query("SHOW COLUMNS FROM pc_assets LIKE 'vendor'");
if (cols.length === 0) {
for (const table of ['pc_assets', 'server_assets', 'storage_assets', 'equip_assets', 'mobile_assets']) {
await pool.query(`ALTER TABLE ${table} ADD COLUMN vendor VARCHAR(100) AFTER price`);
}
}
console.log('✅ All ITAM tables ensured.');
} finally {
@@ -110,6 +129,7 @@ async function batchSave(tableName, assets, getQuery) {
await connection.commit();
return { success: true, count: assets.length };
} catch (err) {
console.error(`❌ Batch Save Error (${tableName}):`, err.message);
await connection.rollback();
throw err;
} finally {
@@ -123,25 +143,58 @@ const hardwareInsertSQL = (table) => `
id, corp, asset_code, purchase_date, type, detail_purpose, purpose, details,
current_org, prev_org, location, manager_main, manager_sub, ip_address,
remote_tool, server_id, server_pw, model_name, mainboard, os, cpu, ram, gpu,
storage1, storage2, storage3, monitoring, price, remarks
storage1, storage2, storage3, monitoring, price, vendor, remarks,
storage_location, status
) VALUES ?
`;
const getHardwareValues = (a) => [
a.id, a.법인||'', a.자산코드||'', a.구매||'', a.type||'', a.상세용도||'', a.용도||'', a.상세||'',
a.id, a.법인||'', a.자산코드||'', a.구매연월||'', a.type||'', a.상세용도||'', a['사용자']||a.용도||'', a.상세||'',
a.현사용조직||'', a.이전사용조직||'', a.위치||'', a.담당자_정||'', a.담당자_부||'', a.IP주소||'',
a.원격접속||'', a.서버ID||'', a.서버PW||'', a.모델명||'', a.메인보드||'', a.OS||'', a.CPU||'', a.RAM||'', a.GPU||'',
a.SSD1||'', a.SSD2||'', a.HDD1||'', a.모니터링||'', a.금액||'', a.비고||''
a.SSD1||'', a.SSD2||'', a.SSD3||'', a.모니터링||'', a.금액||'', a.납품업체||a.vendor||'', a.비고||'',
a.보관위치||'', a.현재상태||''
];
const mapHardware = (r, defaultType) => ({
id: r.id, 법인: r.corp, 자산코드: r.asset_code, 구매일: r.purchase_date, type: r.type || defaultType,
상세용도: r.detail_purpose, 용도: r.purpose, 상세: r.details, 현사용조직: r.current_org,
이전사용조직: r.prev_org, 위치: r.location, 담당자_정: r.manager_main, 담당자_부: r.manager_sub,
IP주소: r.ip_address, 원격접속: r.remote_tool, 서버ID: r.server_id, 서버PW: r.server_pw,
모델명: r.model_name, 메인보드: r.mainboard, OS: r.os, CPU: r.cpu, RAM: r.ram, GPU: r.gpu, SSD1: r.storage1,
SSD2: r.storage2, HDD1: r.storage3, 모니터링: r.monitoring, 금액: r.price, 비고: r.remarks
});
const mapHardware = (r, defaultType) => {
const type = r.type || defaultType;
return {
id: r.id,
법인: r.corp,
자산코드: r.asset_code,
구매연월: r.purchase_date,
구매일: r.purchase_date,
type: type,
상세용도: (type !== '개인PC' && !r.detail_purpose) ? type : r.detail_purpose,
용도: (type !== '개인PC' && !r.detail_purpose) ? type : r.detail_purpose,
사용자: r.purpose,
상세: r.details,
현사용조직: r.current_org,
이전사용조직: r.prev_org,
위치: r.location,
담당자_정: r.manager_main,
담당자_부: r.manager_sub,
IP주소: r.ip_address,
원격접속: r.remote_tool,
서버ID: r.server_id,
서버PW: r.server_pw,
모델명: r.model_name,
메인보드: r.mainboard,
OS: r.os,
CPU: r.cpu,
RAM: r.ram,
GPU: r.gpu,
SSD1: r.storage1,
SSD2: r.storage2,
SSD3: r.storage3,
모니터링: r.monitoring,
금액: r.price,
납품업체: r.vendor,
비고: r.remarks,
보관위치: r.storage_location,
현재상태: r.status
};
};
// --- API 라우트 정의 ---
@@ -149,8 +202,13 @@ const mapHardware = (r, defaultType) => ({
app.get('/api/pc', async (req, res) => {
try {
const [rows] = await pool.query('SELECT * FROM pc_assets');
console.log('🔍 DB Raw Rows (PC):', rows.length, 'items found.');
if (rows.length > 0) console.log('🔍 First row sample:', rows[0]);
res.json(rows.map(r => mapHardware(r, '개인PC')));
} catch (err) { res.status(500).json({ error: err.message }); }
} catch (err) {
console.error('❌ DB Query Error (PC):', err.message);
res.status(500).json({ error: err.message });
}
});
app.post('/api/pc/batch', async (req, res) => {
@@ -240,7 +298,7 @@ app.get('/api/sw/sub', async (req, res) => {
try {
const [rows] = await pool.query('SELECT * FROM sw_sub_assets');
res.json(rows.map(r => ({
id: r.id, type: '구독SW', 법인: r.corp, 자산번호: r.asset_code,
id: r.id, type: '구독SW', 법인: r.corp,
분야: r.category, 부서: r.dept, 제품명: r.product_name,
라이선스유형: r.license_type, 수량: r.quantity, 금액: r.price,
구매일: r.purchase_date, 시작일: r.start_date, 만료일: r.expiry_date,
@@ -252,9 +310,9 @@ app.get('/api/sw/sub', async (req, res) => {
app.post('/api/sw/sub/batch', async (req, res) => {
try {
const result = await batchSave('sw_sub_assets', req.body, (assets) => ({
sql: `INSERT INTO sw_sub_assets (id, corp, asset_code, category, dept, product_name, license_type, quantity, price, purchase_date, start_date, expiry_date, vendor, remarks) VALUES ?`,
sql: `INSERT INTO sw_sub_assets (id, corp, category, dept, product_name, license_type, quantity, price, purchase_date, start_date, expiry_date, vendor, remarks) VALUES ?`,
values: assets.map(a => [
a.id, a.법인||'', a.자산번호||'', a.분야||'', a.부서||'', a.제품명||'',
a.id, a.법인||'', a.분야||'', a.부서||'', a.제품명||'',
a.라이선스유형||'', a.수량||0, a.금액||'', a.구매일||'', a.시작일||'', a.만료일||'', a.납품업체||'', a.비고||''
])
}));
@@ -267,10 +325,10 @@ app.get('/api/sw/perm', async (req, res) => {
try {
const [rows] = await pool.query('SELECT * FROM sw_perm_assets');
res.json(rows.map(r => ({
id: r.id, type: '영구SW', 법인: r.corp, 자산번호: r.asset_code,
id: r.id, type: '영구SW', 법인: r.corp,
분야: r.category, 부서: r.dept, 제품명: r.product_name,
라이선스키: r.license_key, 수량: r.quantity, 금액: r.price,
구매일: r.purchase_date, 시작일: r.start_date,
구매일: r.purchase_date, 시작일: r.start_date, 만료일: r.expiry_date,
납품업체: r.vendor, 비고: r.remarks
})));
} catch (err) { res.status(500).json({ error: err.message }); }
@@ -278,11 +336,13 @@ app.get('/api/sw/perm', async (req, res) => {
app.post('/api/sw/perm/batch', async (req, res) => {
try {
console.log('📦 Permanent SW Batch Save Request:', req.body.length, 'items');
if (req.body.length > 0) console.log('Sample:', req.body[0]);
const result = await batchSave('sw_perm_assets', req.body, (assets) => ({
sql: `INSERT INTO sw_perm_assets (id, corp, asset_code, category, dept, product_name, license_key, quantity, price, purchase_date, start_date, vendor, remarks) VALUES ?`,
sql: `INSERT INTO sw_perm_assets (id, corp, category, dept, product_name, license_key, quantity, price, purchase_date, start_date, expiry_date, vendor, remarks) VALUES ?`,
values: assets.map(a => [
a.id, a.법인||'', a.자산번호||'', a.분야||'', a.부서||'', a.제품명||'',
a.라이선스키||'', a.수량||0, a.금액||'', a.구매일||'', a.시작일||'', a.납품업체||'', a.비고||''
a.id, a.법인||'', a.분야||'', a.부서||'', a.제품명||'',
a.라이선스키||'', a.수량||0, a.금액||'', a.구매일||'', a.시작일||'', a.만료일||'', a.납품업체||'', a.비고||''
])
}));
res.json(result);
@@ -316,16 +376,16 @@ app.get('/api/logs', async (req, res) => {
try {
const [rows] = await pool.query('SELECT * FROM asset_logs ORDER BY log_date DESC');
res.json(rows.map(r => ({
id: r.id, assetId: r.asset_id, date: r.log_date, user: r.log_user, details: r.details
id: r.id, assetId: r.asset_id, date: r.log_date, user: r.log_user, details: r.details, cost: r.cost
})));
} catch (err) { res.status(500).json({ error: err.message }); }
});
app.post('/api/logs/batch', async (req, res) => {
try {
const result = await batchSave('asset_logs', req.body, (assets) => ({
sql: `INSERT INTO asset_logs (id, asset_id, log_date, log_user, details) VALUES ?`,
values: assets.map(a => [a.id, a.assetId||'', a.date||'', a.user||'', a.details||''])
const result = await batchSave('asset_logs', req.body, (logs) => ({
sql: `INSERT INTO asset_logs (asset_id, log_date, log_user, details, cost) VALUES ?`,
values: logs.map(l => [l.assetId, l.date, l.user, l.details, l.cost || 0])
}));
res.json(result);
} catch (err) { res.status(500).json({ error: err.message }); }
@@ -364,26 +424,47 @@ app.post('/api/sw-users/batch', async (req, res) => {
} catch (err) { res.status(500).json({ error: err.message }); }
});
// 자산코드 생성 API
// 도메인 관리 API
app.get('/api/ops/domain', async (req, res) => {
try {
const [rows] = await pool.query('SELECT * FROM ops_domain_assets ORDER BY created_at DESC');
res.json(rows);
} catch (err) { res.status(500).json({ error: err.message }); }
});
app.post('/api/ops/domain/batch', async (req, res) => {
try {
const result = await batchSave('ops_domain_assets', req.body, (assets) => ({
sql: `INSERT INTO ops_domain_assets (id, type, corp, service_name, domain_name, start_date, expiry_date, price, manager_main, manager_sub, remarks) VALUES ?`,
values: assets.map(a => [a.id, a.type||'', a.corp||'', a.service_name||'', a.domain_name||'', a.start_date||'', a.expiry_date||'', a.price||'', a.manager_main||'', a.manager_sub||'', a.remarks||''])
}));
res.json(result);
} catch (err) { res.status(500).json({ error: err.message }); }
});
// 자산번호 자동 생성 API
app.get('/api/generate-asset-code', async (req, res) => {
const { prefix } = req.query;
if (!prefix) return res.status(400).json({ error: 'Prefix is required' });
try {
const tables = ['pc_assets', 'server_assets', 'storage_assets', 'equip_assets', 'mobile_assets', 'sw_sub_assets', 'sw_perm_assets'];
const tables = ['pc_assets', 'server_assets', 'storage_assets', 'equip_assets', 'mobile_assets'];
let maxNum = 0;
for (const table of tables) {
const [rows] = await pool.query(`SELECT asset_code FROM ${table} WHERE asset_code LIKE ?`, [`${prefix}%`]);
const [rows] = await pool.query(
`SELECT asset_code FROM ${table} WHERE asset_code LIKE ?`,
[`${prefix}%`]
);
rows.forEach(r => {
const numPart = r.asset_code.replace(prefix, '');
const num = parseInt(numPart);
if (!isNaN(num) && num > maxNum) maxNum = num;
});
}
const nextCode = `${prefix}${(maxNum + 1).toString().padStart(3, '0')}`;
res.json({ nextCode });
const nextNum = (maxNum + 1).toString().padStart(4, '0');
res.json({ nextCode: `${prefix}${nextNum}` });
} catch (err) {
res.status(500).json({ error: err.message });
}

View File

@@ -1,6 +1,7 @@
import { createIcons, BookOpen, X, ChevronDown, ChevronRight, RefreshCw } from 'lucide';
import { state } from '../core/state';
// ─── 자산별 가이드 콘텐츠 정의 ───
// ─── 자산별 가이드 콘텐츠 정의 (SW_Table 브랜치 전체 복구) ───
interface GuideTabConfig {
id: string;
label: string;
@@ -48,16 +49,16 @@ const GUIDE_TABS: GuideTabConfig[] = [
</section>
<section class="guide-section">
<h3>시스템 기본 사용법</h3>
<h3>시스템 기본 사용법</h3>
<table class="guide-info-table">
<thead><tr><th>기능</th><th>방법</th></tr></thead>
<tbody>
<tr><td><strong>자산 조회</strong></td><td>상단 네비게이션에서 카테고리(하드웨어/소프트웨어) 선택 → 하위 탭에서 자산유형 선택</td></tr>
<tr><td><strong>자산 등록</strong></td><td>[자산추가] 버튼 클릭 → 양식 입력 저장</td></tr>
<tr><td><strong>자산 수정</strong></td><td>테이블에서 행 클릭 → 모달에서 [수정] → 내용 변경 저장</td></tr>
<tr><td><strong>엑셀 업로드</strong></td><td>[업로드] 버튼 → 양식에 맞는 .xlsx 파일 선택 → 자동 일괄 등록</td></tr>
<tr><td><strong>엑셀 다운로드</strong></td><td>[엑셀저장] 버튼 전체 자산 데이터 Excel 파일로 저장</td></tr>
<tr><td><strong>양식 다운로드</strong></td><td>[양식] 버튼 → 엑셀 업로드용 빈 양식 다운로드</td></tr>
<tr><td><strong>자산 조회</strong></td><td>상단 카테고리(하드웨어/소프트웨어) 하위 탭 선택 후 데이터 조회</td></tr>
<tr><td><strong>자산 등록</strong></td><td>[자산 추가] 버튼 클릭 후 상세 정보 입력 저장</td></tr>
<tr><td><strong>정보 수정</strong></td><td>목록 행 클릭 후 나타나는 모달에서 내용 변경 저장</td></tr>
<tr><td><strong>엑셀 업로드</strong></td><td>[업로드] 버튼 선택 후 표준 양식의 .xlsx 파일 선택</td></tr>
<tr><td><strong>전체 엑셀저장</strong></td><td>[엑셀저장] 버튼 클릭 시 현재 전체 자산 데이터 Excel로 백업</td></tr>
<tr><td><strong>표준 양식</strong></td><td>[양식] 버튼 클릭 시 데이터 업로드용 빈 양식 다운로드</td></tr>
</tbody>
</table>
</section>
@@ -70,7 +71,7 @@ const GUIDE_TABS: GuideTabConfig[] = [
<section class="guide-section">
<h3>개인PC 관리 가이드</h3>
<p class="guide-text">
개인PC는 임직원에게 지급되는 데스크톱 및 노트북을 관리합니다. 자산의 지급, 교체, 반납까지의 전체 생애주기를 시스템에서 추적합니다.
임직원에게 지급되는 데스크톱 및 노트북을 관리합니다. 자산의 지급, 교체, 반납까지의 전체 생애주기를 시스템에서 추적합니다.
</p>
</section>
@@ -85,67 +86,63 @@ const GUIDE_TABS: GuideTabConfig[] = [
<span class="flow-arrow-right"><i data-lucide="chevron-right"></i></span>
<div class="flow-step">
<span class="step-number">2</span>
<div><span class="step-label">자산 등록</span><p class="step-desc">자산코드 부여, 사양(CPU/RAM/Storage) 등록</p></div>
<div><span class="step-label">자산 등록</span><p class="step-desc">자산번호 부여, 상세 사양 등록</p></div>
</div>
</div>
<i data-lucide="chevron-down" class="flow-arrow"></i>
<div class="flow-row">
<div class="flow-step">
<span class="step-number">3</span>
<div><span class="step-label">사용자 지급</span><p class="step-desc">사용자·사용조직 지정, 설치위치 기록</p></div>
<div><span class="step-label">사용자 지급</span><p class="step-desc">사용자 지정 설치위치 기록</p></div>
</div>
<span class="flow-arrow-right"><i data-lucide="chevron-right"></i></span>
<div class="flow-step">
<span class="step-number">4</span>
<div><span class="step-label">운영 관리</span><p class="step-desc">OS 업데이트, 보안 점검, 품의서 관리</p></div>
<div><span class="step-label">운영 관리</span><p class="step-desc">보안 점검 및 수리 이력 관리</p></div>
</div>
</div>
<i data-lucide="chevron-down" class="flow-arrow"></i>
<div class="flow-row">
<div class="flow-step">
<span class="step-number">5</span>
<div><span class="step-label">교체/반납</span><p class="step-desc">노후 장비 회수, 데이터 소거, 신규 장비 지급</p></div>
<div><span class="step-label">교체/반납</span><p class="step-desc">장비 회수 데이터 소거</p></div>
</div>
<span class="flow-arrow-right"><i data-lucide="chevron-right"></i></span>
<div class="flow-step">
<span class="step-number">6</span>
<div><span class="step-label">폐기 처리</span><p class="step-desc">폐기 대장 등록, 물리적 파기 또는 매각</p></div>
<div><span class="step-label">폐기 처리</span><p class="step-desc">불용 처리 및 매각/폐기 등록</p></div>
</div>
</div>
</div>
</section>
<section class="guide-section">
<h3>주요 관리 항목 (테이블 컬럼)</h3>
<h3>주요 관리 항목</h3>
<table class="guide-info-table">
<thead><tr><th>항목</th><th>설명</th><th>관리 주기</th></tr></thead>
<tbody>
<tr><td>구매법인</td><td>자산을 구매한 법인</td><td>등록 시 1회</td></tr>
<tr><td>사용조직</td><td>현재 자산을 사용하는 조직/부서</td><td>인사 변동 시</td></tr>
<tr><td>자산코드</td><td>사내 고유 자산 식별 번호</td><td>등록 시 1회</td></tr>
<tr><td>사용자</td><td>자산을 실제 사용하는 직원명</td><td>인사 변동 시</td></tr>
<tr><td>위치</td><td>자산이 실제 설치된 건물/층/좌석</td><td>이동 시 즉시</td></tr>
<tr><td>CPU / RAM / Storage</td><td>하드웨어 사양 정보</td><td>등록/증설 시</td></tr>
<tr><td>구매일</td><td>장비 구매 일자</td><td>등록 시 1회</td></tr>
<tr><td>금액</td><td>구매 비용</td><td>등록 시 1회</td></tr>
<tr><td>품의서</td><td>구매 증빙 첨부 파일</td><td>등록 시 1회</td></tr>
<tr><td>구매법인</td><td>자산의 소유 법인</td><td>등록 시</td></tr>
<tr><td>사용자/조직</td><td>실제 사용자 및 소속 부서</td><td>변동 시</td></tr>
<tr><td>자산번호</td><td>고유 식별 번호 (바코드)</td><td>등록 시</td></tr>
<tr><td>모델명/사양</td><td>제조사 모델 및 CPU/RAM 등</td><td>등록 시</td></tr>
<tr><td>도입금액</td><td>구매 비용 (부가세 포함)</td><td>등록 시</td></tr>
</tbody>
</table>
</section>
<div class="guide-tip">
<strong>💡 팁:</strong> PC 교체 시 기존 장비의 상태를 '반납'으로 변경하고, 신규 장비를 새로 등록하여 이력을 분리 관리하세요.
<strong>관리 팁:</strong> 자산 이력에서 '분출'과 '반납' 로그를 꼼꼼히 기록하면 자산의 실제 위치를 정확히 파악할 수 있습니다.
</div>
`
},
{
id: 'server',
label: '🖥️ 서버',
label: '🖥️ 서버/스토리지',
content: `
<section class="guide-section">
<h3>서버 관리 가이드</h3>
<h3>인프라 자산 관리 가이드</h3>
<p class="guide-text">
물리 서버와 가상 서버를 포함한 서버급 자산을 관리합니다. 안정적인 서비스 운영을 위해 체계적인 관리가 필요합니다.
서버실 및 IDC에 설치된 물리 서버와 스토리지 장비를 관리합니다. 고가의 자산이므로 담당자(정/부) 지정이 필수입니다.
</p>
</section>
@@ -155,394 +152,65 @@ const GUIDE_TABS: GuideTabConfig[] = [
<div class="flow-row">
<div class="flow-step">
<span class="step-number">1</span>
<div><span class="step-label">도입 계획</span><p class="step-desc">용도 정의, 사양 산정, 구매 승인</p></div>
<div><span class="step-label">도입 계획</span><p class="step-desc">사양 확정 및 구매 승인</p></div>
</div>
<span class="flow-arrow-right"><i data-lucide="chevron-right"></i></span>
<div class="flow-step">
<span class="step-number">2</span>
<div><span class="step-label">설치 및 등록</span><p class="step-desc">랙 배치, 네트워크 설정, 자산 등록</p></div>
<div><span class="step-label">설치 및 등록</span><p class="step-desc">네트워크 설정 자산번호 부여</p></div>
</div>
<span class="flow-arrow-right"><i data-lucide="chevron-right"></i></span>
<div class="flow-step">
<span class="step-number">3</span>
<div><span class="step-label">운영 관리</span><p class="step-desc">모니터링, 패치 적용, 장애 대응</p></div>
</div>
</div>
<i data-lucide="chevron-down" class="flow-arrow"></i>
<div class="flow-row">
<div class="flow-step">
<span class="step-number">4</span>
<div><span class="step-label">정기 점검</span><p class="step-desc">보안 취약점 점검, 성능 확인, 백업 검증</p></div>
</div>
<span class="flow-arrow-right"><i data-lucide="chevron-right"></i></span>
<div class="flow-step">
<span class="step-number">5</span>
<div><span class="step-label">폐기/교체</span><p class="step-desc">데이터 마이그레이션 후 장비 교체 또는 폐기</p></div>
<div><span class="step-label">운영 관리</span><p class="step-desc">정기 점검 및 장애 이력 관리</p></div>
</div>
</div>
</div>
</section>
<section class="guide-section">
<h3>주요 관리 항목 (테이블 컬럼)</h3>
<h3>필수 입력 항목</h3>
<table class="guide-info-table">
<thead><tr><th>항목</th><th>설명</th><th>관리 주기</th></tr></thead>
<thead><tr><th>항목</th><th>중요성</th></tr></thead>
<tbody>
<tr><td>구매법인 / 현 사용조직</td><td>법인 및 조직 정보</td><td>등록 / 변동 시</td></tr>
<tr><td>자산번호</td><td>서버 식별 번호</td><td>등록 시 1회</td></tr>
<tr><td>용도 / 상세</td><td>서버의 역할과 상세 설명</td><td>변경 시</td></tr>
<tr><td>설치위치</td><td>데이터센터, 랙 번호, 유닛 위치</td><td>이전 시</td></tr>
<tr><td>담당자 (정/부)</td><td>관리 담당자 정보</td><td>변동 시</td></tr>
<tr><td>IP주소</td><td>서버 네트워크 주소 (최대 2개)</td><td>변경 시</td></tr>
<tr><td>모델명</td><td>서버 하드웨어 모델</td><td>등록 시</td></tr>
<tr><td>OS</td><td>운영체제 종류 및 버전</td><td>업데이트 시</td></tr>
<tr><td>CPU / RAM / Storage</td><td>서버 사양 정보</td><td>증설 시</td></tr>
<tr><td><strong>IP 주소</strong></td><td>서버 접속 및 모니터링을 위한 필수 정보</td></tr>
<tr><td><strong>설치위치</strong></td><td>IDC 또는 서버실 내의 정확한 랙 위치</td></tr>
<tr><td><strong>담당자(정/부)</strong></td><td>비상 시 연락 가능한 관리 책임자</td></tr>
<tr><td><strong>용도/상세</strong></td><td>운영 중인 서비스 및 상세 업무 설명</td></tr>
</tbody>
</table>
</section>
<div class="guide-warn">
<strong>⚠️ 주의:</strong> 서버 폐기 전에는 반드시 데이터 마이그레이션과 백업 검증을 완료하고, 관련 서비스의 DNS/IP 변경 여부를 확인하세요.
<strong>주의 사항:</strong> 서버 자산의 IP가 변경될 경우 시스템에 즉시 반영하여 네트워크 관리 대장과의 정합성을 유지해야 합니다.
</div>
`
},
{
id: 'storage',
label: '💾 스토리지',
id: 'software',
label: '💾 소프트웨어',
content: `
<section class="guide-section">
<h3>스토리지 관리 가이드</h3>
<h3>소프트웨어 자산 관리 가이드</h3>
<p class="guide-text">
NAS, SAN, DAS 등 스토리지 장비에 대한 자산 관리니다. 저장 용량의 효율적 운용과 데이터 안전성 확보가 핵심입니다.
구독형(SaaS) 및 영구형 라이선스를 관리니다. 불법 소프트웨어 사용 방지와 비용 최적화가 주 목적입니다.
</p>
</section>
<section class="guide-section">
<h3>관리 프로세스</h3>
<div class="flow-container">
<div class="flow-row">
<div class="flow-step">
<span class="step-number">1</span>
<div><span class="step-label">용량 산정</span><p class="step-desc">현재 사용량 분석 및 증설 필요 여부 판단</p></div>
</div>
<span class="flow-arrow-right"><i data-lucide="chevron-right"></i></span>
<div class="flow-step">
<span class="step-number">2</span>
<div><span class="step-label">도입/설치</span><p class="step-desc">스토리지 구매 → 설치 → 네트워크 연결</p></div>
</div>
<span class="flow-arrow-right"><i data-lucide="chevron-right"></i></span>
<div class="flow-step">
<span class="step-number">3</span>
<div><span class="step-label">운영 관리</span><p class="step-desc">용량 모니터링, RAID 상태 점검, 백업 스케줄</p></div>
</div>
</div>
</div>
</section>
<section class="guide-section">
<h3>주요 관리 항목 (테이블 컬럼)</h3>
<h3>라이선스 관리 포인트</h3>
<table class="guide-info-table">
<thead><tr><th>항목</th><th>설명</th></tr></thead>
<thead><tr><th>구분</th><th>관리 내용</th></tr></thead>
<tbody>
<tr><td>구매법인 / 현 사용조직</td><td>법인조직</td></tr>
<tr><td>자산번호</td><td>스토리지 식별 번호</td></tr>
<tr><td>용도 / 상세</td><td>스토리지 사용 목적과 세부 설명</td></tr>
<tr><td>설치위치</td><td>데이터센터 내 물리적 위치</td></tr>
<tr><td>담당자 (정/부)</td><td>관리 담당자 정보</td></tr>
<tr><td>모델명</td><td>스토리지 하드웨어 모델</td></tr>
<tr><td>Storage</td><td>총 용량 및 디스크 구성 정보</td></tr>
<tr><td><strong>구독형(Sub)</strong></td><td>구독 만료일 도래 전 갱신 여부 결정비용</td></tr>
<tr><td><strong>영구형(Perm)</strong></td><td>보유 수량 대비 실제 설치 수량 매핑 (초과 사용 금지)</td></tr>
<tr><td><strong>운영서비스</strong></td><td>도메인, 메일 등 매월 또는 매년 발생하는 비용 추적</td></tr>
</tbody>
</table>
</section>
<div class="guide-tip">
<strong>💡 팁:</strong> 스토리지 용량이 80%를 초과하면 증설을 검토하세요. 비고란에 용량 변경 이력을 기록하면 추적에 유용합니다.
</div>
`
},
{
id: 'equip',
label: '🔌 전산비품',
content: `
<section class="guide-section">
<h3>전산비품 관리 가이드</h3>
<p class="guide-text">
모니터, 프린터, 네트워크 장비(스위치, AP), UPS, CPU, GPU, RAM, HDD 등 IT 관련 부속장비를 관리합니다.
</p>
</section>
<section class="guide-section">
<h3>관리 프로세스</h3>
<div class="flow-container">
<div class="flow-row">
<div class="flow-step">
<span class="step-number">1</span>
<div><span class="step-label">구매/입고</span><p class="step-desc">소모품 및 장비 구매 → 입고 확인</p></div>
</div>
<span class="flow-arrow-right"><i data-lucide="chevron-right"></i></span>
<div class="flow-step">
<span class="step-number">2</span>
<div><span class="step-label">등록/배치</span><p class="step-desc">자산코드 부여 → 유형 지정 → 관리자 배정</p></div>
</div>
<span class="flow-arrow-right"><i data-lucide="chevron-right"></i></span>
<div class="flow-step">
<span class="step-number">3</span>
<div><span class="step-label">유지보수</span><p class="step-desc">고장 수리, 소모품 교체, 상태 점검</p></div>
</div>
<span class="flow-arrow-right"><i data-lucide="chevron-right"></i></span>
<div class="flow-step">
<span class="step-number">4</span>
<div><span class="step-label">폐기</span><p class="step-desc">노후화 시 폐기 처리 및 대장 기록</p></div>
</div>
</div>
</div>
</section>
<section class="guide-section">
<h3>주요 관리 항목 (테이블 컬럼)</h3>
<table class="guide-info-table">
<thead><tr><th>항목</th><th>설명</th></tr></thead>
<tbody>
<tr><td>구매법인 / 현 사용조직</td><td>법인 및 조직 정보</td></tr>
<tr><td>유형</td><td>비품 분류 (CPU, GPU, RAM, HDD, 태블릿 등)</td></tr>
<tr><td>자산번호</td><td>비품 고유 식별 번호</td></tr>
<tr><td>모델명</td><td>비품 하드웨어 모델</td></tr>
<tr><td>관리자</td><td>비품 관리 담당자</td></tr>
<tr><td>구매일</td><td>비품 구매 일자</td></tr>
<tr><td>금액</td><td>구매 비용</td></tr>
</tbody>
</table>
</section>
`
},
{
id: 'mobile',
label: '📱 모바일기기',
content: `
<section class="guide-section">
<h3>모바일기기 관리 가이드</h3>
<p class="guide-text">
업무용 스마트폰, 태블릿 등 모바일 기기의 지급 및 회수를 관리합니다.
</p>
</section>
<section class="guide-section">
<h3>관리 프로세스</h3>
<div class="flow-container">
<div class="flow-row">
<div class="flow-step">
<span class="step-number">1</span>
<div><span class="step-label">기기 구매</span><p class="step-desc">통신사 계약, 기기 선정, 구매</p></div>
</div>
<span class="flow-arrow-right"><i data-lucide="chevron-right"></i></span>
<div class="flow-step">
<span class="step-number">2</span>
<div><span class="step-label">등록/지급</span><p class="step-desc">자산번호 부여, 관리자 지정, 사용자 지급</p></div>
</div>
<span class="flow-arrow-right"><i data-lucide="chevron-right"></i></span>
<div class="flow-step">
<span class="step-number">3</span>
<div><span class="step-label">운영</span><p class="step-desc">OS 업데이트, 앱 관리</p></div>
</div>
<span class="flow-arrow-right"><i data-lucide="chevron-right"></i></span>
<div class="flow-step">
<span class="step-number">4</span>
<div><span class="step-label">회수/교체</span><p class="step-desc">퇴직/교체 시 기기 회수, 초기화</p></div>
</div>
</div>
</div>
</section>
<section class="guide-section">
<h3>주요 관리 항목 (테이블 컬럼)</h3>
<table class="guide-info-table">
<thead><tr><th>항목</th><th>설명</th></tr></thead>
<tbody>
<tr><td>구매법인 / 현 사용조직</td><td>법인 및 조직 정보</td></tr>
<tr><td>유형</td><td>기기 분류 (모바일, 태블릿 등)</td></tr>
<tr><td>자산번호</td><td>기기 고유 식별 번호</td></tr>
<tr><td>모델명</td><td>기기 모델 (예: Galaxy S24, iPad Pro)</td></tr>
<tr><td>관리자</td><td>기기를 관리하는 담당자</td></tr>
<tr><td>구매일</td><td>기기 구매 일자</td></tr>
<tr><td>금액</td><td>구매 비용</td></tr>
</tbody>
</table>
</section>
<div class="guide-warn">
<strong>⚠️ 주의:</strong> 모바일기기 회수 시 반드시 공장초기화를 수행하세요.
</div>
`
},
{
id: 'sub-sw',
label: '🔄 구독SW',
content: `
<section class="guide-section">
<h3>구독형 소프트웨어 관리 가이드</h3>
<p class="guide-text">
월간/연간 구독 방식의 소프트웨어(SaaS)를 관리합니다. <strong>만료일 관리</strong>와 <strong>라이선스 최적화</strong>가 핵심입니다.
</p>
</section>
<section class="guide-section">
<h3>갱신 프로세스</h3>
<div class="flow-container">
<div class="flow-row">
<div class="flow-step">
<div class="step-number" style="background-color: #ff9800;">!</div>
<div><span class="step-label">만료 알림 확인</span><p class="step-desc">대시보드에서 만료 예정 자산 목록 확인</p></div>
</div>
<span class="flow-arrow-right"><i data-lucide="chevron-right"></i></span>
<div class="flow-step">
<span class="step-number">A</span>
<div><span class="step-label">수요조사</span><p class="step-desc">실제 사용자 파악, 불필요 라이선스 정리</p></div>
</div>
</div>
<i data-lucide="chevron-down" class="flow-arrow"></i>
<div class="flow-row">
<div class="flow-step">
<span class="step-number">B</span>
<div><span class="step-label">계약 연장</span><p class="step-desc">공급사에 갱신 요청, 수량/금액 확정, 결제</p></div>
</div>
<span class="flow-arrow-right"><i data-lucide="chevron-right"></i></span>
<div class="flow-step">
<div class="step-number" style="background-color: var(--guide-accent);">✓</div>
<div><span class="step-label">시스템 업데이트</span><p class="step-desc">시작일/만료일 갱신, 갱신 이력 자동 기록</p></div>
</div>
</div>
</div>
</section>
<section class="guide-section">
<h3>주요 관리 항목 (테이블 컬럼)</h3>
<table class="guide-info-table">
<thead><tr><th>항목</th><th>설명</th><th>관리 주기</th></tr></thead>
<tbody>
<tr><td>상태</td><td>사용중 / 만료 (만료일 기준 자동 판별)</td><td>자동</td></tr>
<tr><td>분야</td><td>업무공통, 개발S/W, 디자인, 설계S/W 등</td><td>등록 시</td></tr>
<tr><td>법인 / 부서</td><td>구매 법인 및 사용 부서</td><td>등록 시</td></tr>
<tr><td>제품명</td><td>소프트웨어 제품명</td><td>등록 시</td></tr>
<tr><td>구매일</td><td>최초 구매 일자</td><td>등록 시</td></tr>
<tr><td>시작일 / 만료일</td><td>구독 계약 기간</td><td>갱신 시 업데이트</td></tr>
<tr><td>금액</td><td>연간/월간 구독 비용</td><td>갱신 시</td></tr>
<tr><td>수량 / 사용가능</td><td>구매 수량 대비 배정 후 잔여 수량</td><td>배정 시</td></tr>
</tbody>
</table>
</section>
<div class="guide-tip">
<strong>💡 팁:</strong> 대시보드의 만료 예정 위젯을 정기적으로 확인하세요. 기간 변경 시 갱신 이력이 자동으로 기록됩니다.
</div>
`
},
{
id: 'perm-sw',
label: '🔑 영구SW',
content: `
<section class="guide-section">
<h3>영구 라이선스 소프트웨어 관리 가이드</h3>
<p class="guide-text">
1회 구매로 영구적으로 사용 가능한 소프트웨어입니다. 라이선스 키 관리 및 설치 현황 추적이 중요합니다.
</p>
</section>
<section class="guide-section">
<h3>관리 프로세스</h3>
<div class="flow-container">
<div class="flow-row">
<div class="flow-step">
<span class="step-number">1</span>
<div><span class="step-label">구매/도입</span><p class="step-desc">라이선스 구매 → 키 수령 → 시스템 등록</p></div>
</div>
<span class="flow-arrow-right"><i data-lucide="chevron-right"></i></span>
<div class="flow-step">
<span class="step-number">2</span>
<div><span class="step-label">배포/설치</span><p class="step-desc">대상 PC에 설치 → 사용자 관리에서 매핑</p></div>
</div>
<span class="flow-arrow-right"><i data-lucide="chevron-right"></i></span>
<div class="flow-step">
<span class="step-number">3</span>
<div><span class="step-label">현황 관리</span><p class="step-desc">잔여 수량 확인, 사용가능 수량 추적</p></div>
</div>
</div>
</div>
</section>
<section class="guide-section">
<h3>주요 관리 항목 (테이블 컬럼)</h3>
<table class="guide-info-table">
<thead><tr><th>항목</th><th>설명</th></tr></thead>
<tbody>
<tr><td>상태</td><td>유지보수 유효 / 없음</td></tr>
<tr><td>분야</td><td>업무공통, 개발S/W, 디자인, 설계S/W 등</td></tr>
<tr><td>법인 / 부서</td><td>구매 법인 및 사용 부서</td></tr>
<tr><td>제품명</td><td>소프트웨어 제품명</td></tr>
<tr><td>구매일</td><td>최초 구매 일자</td></tr>
<tr><td>시작일 / 만료일</td><td>유지보수 계약 기간 (해당 시)</td></tr>
<tr><td>금액</td><td>라이선스 구매 비용</td></tr>
<tr><td>수량 / 사용가능</td><td>보유 라이선스 대비 잔여 수량</td></tr>
</tbody>
</table>
</section>
<div class="guide-warn">
<strong>⚠️ 주의:</strong> 영구 라이선스도 보유 수량을 초과하여 설치하면 저작권 위반이 됩니다. [사용자 관리] 버튼을 통해 실제 배정 현황을 파악하세요.
</div>
`
},
{
id: 'cloud',
label: '☁️ 클라우드',
content: `
<section class="guide-section">
<h3>클라우드 서비스 관리 가이드</h3>
<p class="guide-text">
AWS, Azure, GCP 등 클라우드 인프라 서비스와 Notion, Slack 등 SaaS 서비스를 관리합니다. 비용 최적화와 계정 관리가 핵심입니다.
</p>
</section>
<section class="guide-section">
<h3>관리 프로세스</h3>
<div class="flow-container">
<div class="flow-row">
<div class="flow-step">
<span class="step-number">1</span>
<div><span class="step-label">서비스 도입</span><p class="step-desc">서비스 선정, 비용 산정, 계정 생성</p></div>
</div>
<span class="flow-arrow-right"><i data-lucide="chevron-right"></i></span>
<div class="flow-step">
<span class="step-number">2</span>
<div><span class="step-label">등록/설정</span><p class="step-desc">시스템 등록, 결제수단 설정, 관리자 배정</p></div>
</div>
<span class="flow-arrow-right"><i data-lucide="chevron-right"></i></span>
<div class="flow-step">
<span class="step-number">3</span>
<div><span class="step-label">운영/비용관리</span><p class="step-desc">월별 청구액 추적, 계정 관리, 갱신</p></div>
</div>
</div>
</div>
</section>
<section class="guide-section">
<h3>주요 관리 항목 (테이블 컬럼)</h3>
<table class="guide-info-table">
<thead><tr><th>항목</th><th>설명</th></tr></thead>
<tbody>
<tr><td>플랫폼명</td><td>클라우드 플랫폼 이름 (예: AWS, Azure)</td></tr>
<tr><td>법인 / 담당부서</td><td>서비스 소속 법인 및 관리 부서</td></tr>
<tr><td>진행 프로젝트 (사용용도)</td><td>서비스 사용 목적</td></tr>
<tr><td>계정명 (관리자)</td><td>관리자 계정 또는 루트 계정 정보</td></tr>
<tr><td>결제수단</td><td>법인카드 또는 인보이스(월별송금)</td></tr>
<tr><td>결제일</td><td>월 결제일</td></tr>
<tr><td>당월 청구액</td><td>이번 달 결제 금액</td></tr>
<tr><td>비고</td><td>추가 메모 및 변경 이력</td></tr>
</tbody>
</table>
</section>
<div class="guide-tip">
<strong>💡 팁:</strong> 클라우드 비용은 매월 변동될 수 있으므로, 비고란을 활용하여 비용 변경 이력을 메모해 두면 예산 관리에 도움이 됩니다.
<strong>팁:</strong> 소프트웨어 상세 페이지의 [사용자 할당] 기능을 활용하여 누가 어떤 라이선스를 사용하는지 체계적으로 관리하세요.
</div>
`
}
@@ -551,74 +219,65 @@ const GUIDE_TABS: GuideTabConfig[] = [
// ─── 가이드 모달 초기화 ───
export function initGuide() {
const body = document.body;
if (document.getElementById('guide-overlay')) return;
// 오버레이
const overlay = document.createElement('div');
overlay.className = 'guide-overlay';
overlay.className = 'modal-overlay hidden';
overlay.id = 'guide-overlay';
// 모달
const modal = document.createElement('div');
modal.className = 'guide-modal';
modal.id = 'guide-modal';
// 탭 바 생성
const tabsHtml = GUIDE_TABS.map((tab, i) =>
`<div class="guide-tab ${i === 0 ? 'active' : ''}" data-guide-tab="${tab.id}">${tab.label}</div>`
).join('');
// 탭 패널 생성
const panelsHtml = GUIDE_TABS.map((tab, i) =>
`<div class="guide-tab-panel ${i === 0 ? 'active' : ''}" data-guide-panel="${tab.id}">${tab.content}</div>`
).join('');
modal.innerHTML = `
<div class="guide-header">
<h2><i data-lucide="book-open"></i> IT 자산관리 프로세스 가이드</h2>
<button class="btn-close-guide" id="btn-close-guide">
<i data-lucide="x"></i>
</button>
overlay.innerHTML = `
<div class="modal-content wide" id="guide-modal" style="height: 90vh;">
<div class="modal-header">
<h2><i data-lucide="book-open"></i> 자산관리 프로세스 가이드 (Standard)</h2>
<button class="btn-icon" id="btn-close-guide">
<i data-lucide="x"></i>
</button>
</div>
<div class="guide-tabs-container">
<div class="guide-tabs">${tabsHtml}</div>
</div>
<div class="modal-body" style="padding-top: 0;">
<div class="guide-body">${panelsHtml}</div>
</div>
</div>
<div class="guide-tabs">${tabsHtml}</div>
<div class="guide-body">${panelsHtml}</div>
`;
overlay.appendChild(modal);
body.appendChild(overlay);
// ─── 이벤트 바인딩 ───
const openGuide = () => overlay.classList.add('active');
const closeGuide = () => overlay.classList.remove('active');
const openGuide = () => {
console.log('📖 Opening Full Guide Modal...');
overlay.classList.remove('hidden');
};
const closeGuide = () => overlay.classList.add('hidden');
// 헤더 버튼
document.getElementById('btn-open-guide-header')?.addEventListener('click', openGuide);
const triggerBtn = document.getElementById('btn-open-guide-header');
if (triggerBtn) {
triggerBtn.addEventListener('click', openGuide);
}
// 오버레이 배경 클릭
overlay.addEventListener('click', (e) => {
if (e.target === overlay) closeGuide();
});
// 닫기 버튼
overlay.addEventListener('click', (e) => { if (e.target === overlay) closeGuide(); });
document.getElementById('btn-close-guide')?.addEventListener('click', closeGuide);
// 탭 전환
const tabs = modal.querySelectorAll('.guide-tab');
const panels = modal.querySelectorAll('.guide-tab-panel');
const tabs = overlay.querySelectorAll('.guide-tab');
const panels = overlay.querySelectorAll('.guide-tab-panel');
tabs.forEach(tab => {
tab.addEventListener('click', () => {
const targetId = tab.getAttribute('data-guide-tab');
tabs.forEach(t => t.classList.remove('active'));
panels.forEach(p => p.classList.remove('active'));
tab.classList.add('active');
modal.querySelector(`.guide-tab-panel[data-guide-panel="${targetId}"]`)?.classList.add('active');
overlay.querySelector(`.guide-tab-panel[data-guide-panel="${targetId}"]`)?.classList.add('active');
});
});
// 아이콘 렌더링
createIcons({
icons: { BookOpen, X, ChevronDown, ChevronRight, RefreshCw }
});
createIcons({ icons: { BookOpen, X, ChevronDown, ChevronRight, RefreshCw } });
}

View File

@@ -49,7 +49,7 @@ export function openDashboardDetail(title: string, list: HardwareAsset[]) {
if (!thead) return;
titleEl.textContent = title;
thead.innerHTML = `<tr><th>No</th><th>유형</th><th>자산코드</th><th>명칭/모델</th><th>위치</th><th>담당/사용자</th><th>구매</th><th>금액</th></tr>`;
thead.innerHTML = `<tr><th>No</th><th>유형</th><th>자산코드</th><th>명칭/모델</th><th>위치</th><th>담당/사용자</th><th>구매연월</th><th>금액</th></tr>`;
tbody.innerHTML = '';
if (list.length === 0) {
tbody.innerHTML = `<tr><td colspan="8" style="text-align:center; padding: 2rem;">해당 조건의 자산이 없습니다.</td></tr>`;

View File

@@ -0,0 +1,241 @@
import { state } from '../../core/state';
import { closeModals, openModal } from './BaseModal';
import { CORP_LIST } from './SharedData';
import { generateOptionsHTML, setEditLock } from './ModalUtils';
import { createIcons, X, Save, Database, CalendarClock, Edit2 } from 'lucide';
import { formatExcelDate } from '../../core/excelHandler';
let currentItem: any = null;
const DOMAIN_MODAL_HTML = `
<div id="domain-asset-modal" class="modal-overlay hidden">
<div class="modal-content wide">
<div class="modal-header">
<h2 id="domain-modal-title">도메인 정보</h2>
<div style="display:flex; gap:0.5rem; align-items:center;">
<button id="btn-edit-domain-header" class="btn-icon header-edit-btn" title="수정"><i data-lucide="edit-2"></i></button>
<button id="btn-close-domain-modal" class="btn-icon"><i data-lucide="x"></i></button>
</div>
</div>
<div class="modal-body">
<div class="modal-form-area">
<form id="domain-asset-form" class="grid-form">
<!-- Group 1: 기본 정보 (Service Identity) -->
<div class="form-section-title" style="display:flex; align-items:center; gap:0.5rem;">
<i data-lucide="database" style="width:16px; height:16px; color:var(--primary-color);"></i>
기본 정보 (Identity)
</div>
<div class="form-group">
<label class="required">유형</label>
<select id="domain-type" required>
<option value="호스팅">호스팅</option>
<option value="SSL">SSL</option>
<option value="도메인">도메인</option>
<option value="네임서버">네임서버</option>
</select>
</div>
<div class="form-group">
<label class="required">법인</label>
<select id="domain-corp" required>
${generateOptionsHTML(CORP_LIST)}
</select>
</div>
<div class="form-group">
<label class="required">서비스명</label>
<input type="text" id="domain-service-name" placeholder="예: 그룹웨어, 홈페이지" required>
</div>
<div class="form-group">
<label class="required">관리도메인</label>
<input type="text" id="domain-name" placeholder="예: hmac.kr" required>
</div>
<!-- Group 2: 계약 및 담당 정보 (Contract & Manager) -->
<div class="form-section-title" style="display:flex; align-items:center; gap:0.5rem; margin-top:1.5rem;">
<i data-lucide="calendar-clock" style="width:16px; height:16px; color:var(--primary-color);"></i>
계약 및 담당 정보
</div>
<div class="form-group">
<label>계약 시작일</label>
<input type="date" id="domain-start-date">
</div>
<div class="form-group">
<label>계약 만료일</label>
<input type="date" id="domain-expiry-date">
</div>
<div class="form-group">
<label>도입 금액</label>
<input type="text" id="domain-price" oninput="this.value = this.value.replace(/[^0-9]/g, '').replace(/\\B(?=(\\d{3})+(?!\\d))/g, ',')" placeholder="0">
</div>
<div class="form-group">
<label>담당자</label>
<input type="text" id="domain-manager-main">
</div>
<div class="form-group">
<label>담당자(부)</label>
<input type="text" id="domain-manager-sub">
</div>
<!-- Group 3: 기타 (Additional) -->
<div class="form-section-title" style="display:flex; align-items:center; gap:0.5rem; margin-top:1.5rem;">
<i data-lucide="edit-2" style="width:16px; height:16px; color:var(--primary-color);"></i>
구매 정보
</div>
<div class="form-group full-width">
<label>구매업체</label>
<textarea id="domain-remarks" rows="1" style="width:100%; border:1px solid var(--border-color); border-radius:4px; padding:0.625rem;"></textarea>
</div>
</form>
</div>
</div>
<div class="modal-footer">
<button id="btn-delete-domain" class="btn btn-outline btn-danger">삭제</button>
<div class="footer-actions">
<button id="btn-revert-domain" class="btn btn-outline hidden">수정 취소</button>
<button id="btn-cancel-domain" class="btn btn-outline">닫기</button>
<button id="btn-save-domain" class="btn btn-primary"><i data-lucide="save"></i> 저장하기</button>
</div>
</div>
</div>
</div>
`;
export function initDomainModal() {
if (!document.getElementById('domain-asset-modal')) {
document.body.insertAdjacentHTML('beforeend', DOMAIN_MODAL_HTML);
}
const modal = document.getElementById('domain-asset-modal')!;
document.getElementById('btn-close-domain-modal')?.addEventListener('click', () => closeModals());
document.getElementById('btn-cancel-domain')?.addEventListener('click', () => closeModals());
const saveBtn = document.getElementById('btn-save-domain');
const revertBtn = document.getElementById('btn-revert-domain');
const deleteBtn = document.getElementById('btn-delete-domain');
const headerEditBtn = document.getElementById('btn-edit-domain-header');
saveBtn?.addEventListener('click', () => {
if (!currentItem) return;
if (saveBtn.textContent === '수정') {
setEditLock('domain-asset-form', 'edit', { saveBtnId: 'btn-save-domain', revertBtnId: 'btn-revert-domain' });
return;
}
saveDomain();
});
headerEditBtn?.addEventListener('click', () => {
setEditLock('domain-asset-form', 'edit', { saveBtnId: 'btn-save-domain', revertBtnId: 'btn-revert-domain' });
});
revertBtn?.addEventListener('click', () => {
setEditLock('domain-asset-form', 'view', { saveBtnId: 'btn-save-domain', revertBtnId: 'btn-revert-domain' });
if (currentItem) openDomainModal(currentItem);
});
deleteBtn?.addEventListener('click', () => {
if (currentItem && confirm('정말 삭제하시겠습니까?')) {
state.masterData.domain = state.masterData.domain.filter(d => d.id !== currentItem.id);
saveDomainBatch();
}
});
}
export function openDomainModal(item: any = null) {
currentItem = item;
const isEdit = !!item;
const mode = isEdit ? 'view' : 'add';
const titleEl = document.getElementById('domain-modal-title');
if (titleEl) titleEl.textContent = isEdit ? '도메인 정보 상세' : '신규 도메인 등록';
setEditLock('domain-asset-form', mode, { saveBtnId: 'btn-save-domain', revertBtnId: 'btn-revert-domain' });
const setVal = (id: string, val: any) => {
const el = document.getElementById(id) as HTMLInputElement | HTMLSelectElement | HTMLTextAreaElement;
if (el) el.value = val || '';
};
setVal('domain-type', item?.type || '호스팅');
setVal('domain-corp', item?.corp || '');
setVal('domain-service-name', item?.service_name || '');
setVal('domain-name', item?.domain_name || '');
setVal('domain-start-date', formatExcelDate(item?.start_date));
setVal('domain-expiry-date', formatExcelDate(item?.expiry_date));
setVal('domain-price', item?.price || '');
setVal('domain-manager-main', item?.manager_main || '');
setVal('domain-manager-sub', item?.manager_sub || '');
setVal('domain-remarks', item?.remarks || '');
const deleteBtn = document.getElementById('btn-delete-domain');
if (deleteBtn) deleteBtn.style.display = isEdit ? 'block' : 'none';
openModal('domain-asset-modal');
createIcons({ icons: { X, Save, Database, CalendarClock, Edit2 } });
}
async function saveDomainBatch() {
try {
const response = await fetch(`http://${location.hostname}:3000/api/ops/domain/batch`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(state.masterData.domain)
});
if (response.ok) {
closeModals();
window.dispatchEvent(new CustomEvent('refresh-view'));
} else {
throw new Error('DB 저장 실패');
}
} catch (err) {
console.error(err);
alert('저장 중 오류가 발생했습니다.');
}
}
async function saveDomain() {
const getVal = (id: string) => (document.getElementById(id) as HTMLInputElement)?.value || '';
const newDomain = {
id: currentItem ? currentItem.id : `DOM-${Date.now()}`,
type: getVal('domain-type'),
corp: getVal('domain-corp'),
service_name: getVal('domain-service-name'),
domain_name: getVal('domain-name'),
start_date: getVal('domain-start-date'),
expiry_date: getVal('domain-expiry-date'),
price: getVal('domain-price'),
manager_main: getVal('domain-manager-main'),
manager_sub: getVal('domain-manager-sub'),
remarks: getVal('domain-remarks')
};
if (!newDomain.service_name || !newDomain.domain_name) {
alert('서비스명과 관리도메인은 필수 입력 사항입니다.');
return;
}
if (currentItem && currentItem.id.startsWith('DOM-')) {
// 신규 추가 후 바로 수정하는 경우 등 대응
const idx = state.masterData.domain.findIndex(d => d.id === currentItem.id);
if (idx > -1) state.masterData.domain[idx] = newDomain;
else state.masterData.domain.push(newDomain);
} else if (currentItem) {
const idx = state.masterData.domain.findIndex(d => d.id === currentItem.id);
if (idx > -1) state.masterData.domain[idx] = newDomain;
} else {
state.masterData.domain.push(newDomain);
}
await saveDomainBatch();
}

View File

@@ -1,7 +1,8 @@
import { state, saveHardwareAsset, deleteHardwareAsset } from '../../core/state';
import { HardwareAsset, MasterAssetData } from '../../core/excelHandler';
import { openModal, closeModals } from './BaseModal';
import { createIcons, Paperclip } from 'lucide';
import { HardwareAsset } from '../../core/excelHandler';
import { openModal } from './BaseModal';
import { ASSET_SCHEMA, UI_TEXT } from '../../core/schema';
import { createIcons, History, Plus, X, Save, Edit2, RotateCcw, Paperclip } from 'lucide';
import { CORP_LIST, ORG_LIST, HW_TYPE_LIST, LOCATION_DATA, TYPE_PREFIX_MAP } from './SharedData';
import {
generateOptionsHTML,
@@ -10,347 +11,296 @@ import {
parseAndSetLocation,
bindLocationEvents,
getCombinedLocation,
setEditLock
setEditLock,
createModalFrameHTML,
autoFillForm,
autoExtractForm
} from './ModalUtils';
let currentAsset: HardwareAsset | null = null;
let isEditMode = false;
const HW_MODAL_HTML = `
<div id="hw-asset-modal" class="modal-overlay hidden">
<div class="modal-content wide">
<div class="modal-header">
<h2 id="hw-modal-title">자산 상세 정보</h2>
<button id="btn-close-hw-modal" class="btn-icon" aria-label="닫기"><i data-lucide="x"></i></button>
</div>
<div class="modal-body">
<form id="hw-asset-form" class="grid-form">
<input type="hidden" id="hw-asset-id" />
<input type="hidden" id="hw-asset-type" />
const STATUS_LIST = ['대여중', '보관중', '수리중', '기타'];
<!-- Group 1: 기본 정보 (Identity) -->
<div class="form-section-title">기본 정보 (Identity)</div>
<div class="form-group">
<label for="hw-법인">구매법인</label>
<select id="hw-법인" required>${generateOptionsHTML(CORP_LIST)}</select>
</div>
<div class="form-group">
<label for="hw-자산코드">자산번호/코드</label>
<div style="display:flex; gap:0.5rem;">
<input type="text" id="hw-자산코드" readonly placeholder="번호 생성을 클릭하세요" required />
<button type="button" id="btn-generate-hw-code" class="btn btn-outline" style="white-space:nowrap; padding:0 10px; font-size:0.8rem;">번호 생성</button>
</div>
</div>
<div class="form-group">
<label for="hw-현사용조직">현 사용조직</label>
<select id="hw-현사용조직">${generateOptionsHTML(ORG_LIST)}</select>
</div>
<div class="form-group" id="hw-이전사용조직-group">
<label for="hw-이전사용조직">이전 사용조직</label>
<input type="text" id="hw-이전사용조직" readonly />
</div>
<div class="form-group" id="hw-유형-group">
<label for="hw-유형">유형</label>
<select id="hw-유형">${generateOptionsHTML(HW_TYPE_LIST)}</select>
</div>
<div class="form-group" id="hw-상세용도-group" style="display:none;">
<label for="hw-상세용도">상세용도</label>
<select id="hw-상세용도">
<option value="">선택</option>
<option value="서버">서버</option>
<option value="개인PC">개인PC</option>
</select>
</div>
<div class="form-group server-only">
<label for="hw-용도">용도</label>
<input type="text" id="hw-용도" />
</div>
<div class="form-group server-only">
<label for="hw-상세">상세 내용</label>
<input type="text" id="hw-상세" />
</div>
<div class="form-group non-server" id="hw-명칭-group">
<label for="hw-명칭">명칭</label>
<input type="text" id="hw-명칭" />
</div>
<div class="form-group full-width server-only">
<label for="hw-비고">비고</label>
<input type="text" id="hw-비고" />
</div>
/**
* 하드웨어 필드 매핑 (통합 스키마 기반)
*/
const HW_FIELD_MAP: Record<string, string> = {
'유형': ASSET_SCHEMA.TYPE.key,
'법인': ASSET_SCHEMA.CORP.key,
'자산코드': ASSET_SCHEMA.ASSET_CODE.key,
'현사용조직': ASSET_SCHEMA.ORG.key,
'이전사용조직': ASSET_SCHEMA.PREV_ORG.key,
'상세용도': '상세용도',
'모델명': ASSET_SCHEMA.MODEL.key,
'메인보드': ASSET_SCHEMA.MAINBOARD.key,
'명칭': '명칭',
'보관위치': ASSET_SCHEMA.STORE_LOC.key,
'현재상태': ASSET_SCHEMA.STATUS.key,
'IP주소': ASSET_SCHEMA.IP_ADDR.key,
'IP2': ASSET_SCHEMA.IP_ADDR2.key,
'원격접속': '원격접속',
'서버ID': '서버ID',
'서버PW': '서버PW',
'모니터링': '모니터링',
'OS': ASSET_SCHEMA.OS.key,
'CPU': ASSET_SCHEMA.CPU.key,
'GPU': ASSET_SCHEMA.GPU.key,
'RAM': ASSET_SCHEMA.RAM.key,
'SSD1': ASSET_SCHEMA.STORAGE1.key,
'SSD2': ASSET_SCHEMA.STORAGE2.key,
'SSD3': ASSET_SCHEMA.STORAGE3.key,
'HW사양': 'HW사양',
'담당자_정': ASSET_SCHEMA.MANAGER_MAIN.key,
'담당자_부': ASSET_SCHEMA.MANAGER_SUB.key,
'구매일': ASSET_SCHEMA.PURCHASE_YM.key,
'금액': ASSET_SCHEMA.PRICE.key,
'납품업체': ASSET_SCHEMA.VENDOR.key,
'비고': ASSET_SCHEMA.REMARKS.key,
'사용자': ASSET_SCHEMA.USER.key
};
<!-- Group 2: 네트워크 정보 (Connectivity) -->
<div class="form-section-title server-only" id="hw-network-title">네트워크 정보 (Connectivity)</div>
<div class="form-group server-only" id="hw-ip-group">
<label for="hw-IP주소">IP 주소 1</label>
<input type="text" id="hw-IP주소" />
</div>
<div class="form-group server-only" id="hw-ip2-group">
<label for="hw-IP2">IP 주소 2</label>
<input type="text" id="hw-IP2" />
</div>
<div class="form-group server-only" id="hw-remote-group">
<label for="hw-원격접속">원격 도구 (Anydesk/Chrome 등)</label>
<input type="text" id="hw-원격접속" />
</div>
<div class="form-group server-only" id="hw-server-id-group">
<label for="hw-서버ID">서버 ID</label>
<input type="text" id="hw-서버ID" />
</div>
<div class="form-group server-only" id="hw-server-pw-group">
<label for="hw-서버PW">서버 PW</label>
<input type="text" id="hw-서버PW" />
</div>
<div class="form-group non-server" id="hw-ip-non-server-group">
<label for="hw-IP주소-non-server">IP 주소</label>
<input type="text" id="hw-IP주소-non-server" />
</div>
const HW_FORM_HTML = `
<div class="form-section-title">기본 정보 (Identity)</div>
<div class="form-group">
<label for="hw-법인">${ASSET_SCHEMA.CORP.ui}</label>
<select id="hw-법인" required>${generateOptionsHTML(CORP_LIST)}</select>
</div>
<div class="form-group">
<label for="hw-자산코드">${ASSET_SCHEMA.ASSET_CODE.ui}</label>
<div class="input-with-btn">
<input type="text" id="hw-자산코드" readonly class="is-readonly-field" placeholder="번호 생성을 클릭하세요" required />
<button type="button" id="btn-generate-hw-code" class="btn btn-outline btn-sm">생성</button>
</div>
</div>
<div class="form-group pc-only">
<label for="hw-사용자">${ASSET_SCHEMA.USER.ui}</label>
<input type="text" id="hw-사용자" />
</div>
<div class="form-group">
<label for="hw-현사용조직">${ASSET_SCHEMA.ORG.ui}</label>
<select id="hw-현사용조직">${generateOptionsHTML(ORG_LIST)}</select>
</div>
<div class="form-group" id="hw-이전사용조직-group">
<label for="hw-이전사용조직">${ASSET_SCHEMA.PREV_ORG.ui}</label>
<input type="text" id="hw-이전사용조직" readonly />
</div>
<div class="form-group" id="hw-유형-group">
<label for="hw-유형">유형</label>
<select id="hw-유형">${generateOptionsHTML(HW_TYPE_LIST)}</select>
</div>
<div class="form-group" id="hw-상세용도-group">
<label for="hw-상세용도">상세유형</label>
<select id="hw-상세용도">
<option value="">선택</option>
<option value="서버">서버</option>
<option value="개인PC">개인PC</option>
</select>
</div>
<!-- Group 3: 시스템 사양 (Specifications) -->
<div class="form-section-title" id="hw-spec-title">시스템 사양 (Specifications)</div>
<div class="form-group" id="hw-model-group">
<label for="hw-모델명">모델명</label>
<input type="text" id="hw-모델명" />
</div>
<div class="form-group" id="hw-os-group">
<label for="hw-OS">운영체제 (OS)</label>
<input type="text" id="hw-OS" />
</div>
<div class="form-group" id="hw-cpu-group">
<label for="hw-CPU">CPU 사양</label>
<input type="text" id="hw-CPU" />
</div>
<div class="form-group" id="hw-ram-group">
<label for="hw-RAM">RAM 용량</label>
<input type="text" id="hw-RAM" />
</div>
<div class="form-group" id="hw-ssd1-group">
<label for="hw-SSD1">Storage 1 (SSD/HDD)</label>
<input type="text" id="hw-SSD1" />
</div>
<div class="form-group" id="hw-ssd2-group">
<label for="hw-SSD2">Storage 2 (SSD/HDD)</label>
<input type="text" id="hw-SSD2" />
</div>
<div class="form-group server-only" id="hw-monitoring-group">
<label for="hw-모니터링">모니터링 여부</label>
<input type="text" id="hw-모니터링" />
</div>
<div class="form-group full-width non-server" id="hw-hwspec-group">
<label for="hw-HW사양">H/W 사양 상세</label>
<textarea id="hw-HW사양" rows="2"></textarea>
</div>
<div class="form-section-title op-only" id="hw-op-title">운영 및 상태 관리</div>
<div class="form-group op-only">
<label for="hw-보관위치">${ASSET_SCHEMA.STORE_LOC.ui}</label>
<input type="text" id="hw-보관위치" placeholder="예: 7층 비품창고" />
</div>
<div class="form-group op-only">
<label for="hw-현재상태">${ASSET_SCHEMA.STATUS.ui}</label>
<select id="hw-현재상태">${generateOptionsHTML(STATUS_LIST)}</select>
</div>
<!-- Group 4: 관리 및 운영 (Operation) -->
<div class="form-section-title" id="hw-op-title">관리 및 운영 (Operation)</div>
<div class="form-group hw-location-field">
<label for="hw-위치-빌딩">설치위치 (건물)</label>
<select id="hw-위치-빌딩">${generateOptionsHTML(Object.keys(LOCATION_DATA))}</select>
</div>
<div class="form-group hw-location-field">
<label for="hw-위치-상세">상세 위치</label>
<select id="hw-위치-상세">
<option value="">건물을 먼저 선택하세요</option>
</select>
</div>
<div class="form-group" id="hw-위치-기타-group" style="display:none;">
<label for="hw-위치-기타">직접 입력 (기타)</label>
<input type="text" id="hw-위치-기타" placeholder="상세 위치를 입력하세요" />
</div>
<div class="form-group">
<label for="hw-담당자_정">담당자 (정)</label>
<input type="text" id="hw-담당자_정" />
</div>
<div class="form-group">
<label for="hw-담당자_부">담당자 (부)</label>
<input type="text" id="hw-담당자_부" />
</div>
<div class="form-group non-server" id="hw-purchase-date-group">
<label for="hw-구매일">구매일</label>
<input type="text" id="hw-구매일" />
</div>
<div class="form-group non-server" id="hw-price-group">
<label for="hw-금액">금액</label>
<input type="text" id="hw-금액" oninput="this.value = this.value.replace(/[^0-9]/g, '').replace(/\\\\B(?=(\\\\d{3})+(?!\d))/g, ',')" />
</div>
<div class="form-group full-width">
<label>품의서 (파일 증빙)</label>
<div style="display:flex; align-items:center; gap:0.5rem;">
<input type="file" id="hw-품의서" />
<span id="hw-품의서명" style="font-size:0.75rem; color:var(--text-light)"></span>
</div>
</div>
</form>
</div>
<div class="modal-footer">
<button id="btn-delete-hw-asset" class="btn btn-outline btn-danger">삭제</button>
<div class="footer-actions">
<button id="btn-revert-hw-edit" class="btn btn-outline hidden">수정 취소</button>
<button id="btn-cancel-hw-modal" class="btn btn-outline">닫기</button>
<button id="btn-save-hw-asset" class="btn btn-primary">수정</button>
</div>
</div>
<div class="form-section-title server-only" id="hw-network-title">네트워크 정보 (Connectivity)</div>
<div class="form-group server-only" id="hw-ip-group"><label for="hw-IP주소">${ASSET_SCHEMA.IP_ADDR.ui}</label><input type="text" id="hw-IP주소" /></div>
<div class="form-group server-only" id="hw-ip2-group"><label for="hw-IP2">${ASSET_SCHEMA.IP_ADDR2.ui}</label><input type="text" id="hw-IP2" /></div>
<div class="form-group server-only" id="hw-remote-group"><label for="hw-원격접속">원격 도구</label><input type="text" id="hw-원격접속" /></div>
<div class="form-group server-only" id="hw-server-id-group"><label for="hw-서버ID">서버 ID</label><input type="text" id="hw-서버ID" /></div>
<div class="form-group server-only" id="hw-server-pw-group"><label for="hw-서버PW">서버 PW</label><input type="text" id="hw-서버PW" /></div>
<div class="form-group non-server" id="hw-ip-non-server-group"><label for="hw-IP주소-non-server">${ASSET_SCHEMA.IP_ADDR.ui}</label><input type="text" id="hw-IP주소-non-server" /></div>
<div class="form-section-title" id="hw-spec-title">시스템 사양 (Specifications)</div>
<div class="form-group" id="hw-model-group"><label for="hw-모델명">${ASSET_SCHEMA.MODEL.ui}</label><input type="text" id="hw-모델명" /></div>
<div class="form-group pc-only" id="hw-mainboard-group"><label for="hw-메인보드">${ASSET_SCHEMA.MAINBOARD.ui}</label><input type="text" id="hw-메인보드" /></div>
<div class="form-group" id="hw-os-group"><label for="hw-OS">${ASSET_SCHEMA.OS.ui}</label><input type="text" id="hw-OS" /></div>
<div class="form-group" id="hw-cpu-group"><label for="hw-CPU">${ASSET_SCHEMA.CPU.ui}</label><input type="text" id="hw-CPU" /></div>
<div class="form-group" id="hw-gpu-group"><label for="hw-GPU">${ASSET_SCHEMA.GPU.ui}</label><input type="text" id="hw-GPU" /></div>
<div class="form-group" id="hw-ram-group"><label for="hw-RAM">${ASSET_SCHEMA.RAM.ui}</label><input type="text" id="hw-RAM" /></div>
<div class="form-group" id="hw-ssd1-group"><label for="hw-SSD1">${ASSET_SCHEMA.STORAGE1.ui}</label><input type="text" id="hw-SSD1" /></div>
<div class="form-group" id="hw-ssd2-group"><label for="hw-SSD2">${ASSET_SCHEMA.STORAGE2.ui}</label><input type="text" id="hw-SSD2" /></div>
<div class="form-group" id="hw-ssd3-group"><label for="hw-SSD3">${ASSET_SCHEMA.STORAGE3.ui}</label><input type="text" id="hw-SSD3" /></div>
<div class="form-group server-only" id="hw-monitoring-group"><label for="hw-모니터링">모니터링 여부</label><input type="text" id="hw-모니터링" /></div>
<div class="form-group full-width non-server" id="hw-hwspec-group"><label for="hw-HW사양">사양 상세</label><textarea id="hw-HW사양" rows="2"></textarea></div>
<div class="form-section-title" id="hw-loc-title">설치 위치 및 관리</div>
<div class="form-group loc-standard"><label for="hw-위치-빌딩">설치위치 (건물)</label><select id="hw-위치-빌딩">${generateOptionsHTML(Object.keys(LOCATION_DATA))}</select></div>
<div class="form-group loc-standard"><label for="hw-위치-상세">상세 위치</label><select id="hw-위치-상세"><option value="">선택</option></select></div>
<div class="form-group" id="hw-위치-기타-group" style="display:none;"><label for="hw-위치-기타">직접 입력 (기타)</label><input type="text" id="hw-위치-기타" /></div>
<div class="form-group"><label for="hw-담당자_정">${ASSET_SCHEMA.MANAGER_MAIN.ui}</label><input type="text" id="hw-담당자_정" /></div>
<div class="form-group"><label for="hw-담당자_부">${ASSET_SCHEMA.MANAGER_SUB.ui}</label><input type="text" id="hw-담당자_부" /></div>
<div class="form-group"><label for="hw-구매일">${ASSET_SCHEMA.PURCHASE_YM.ui}</label><input type="text" id="hw-구매일" placeholder="YYYYMM" maxlength="6" /></div>
<div class="form-group"><label for="hw-금액">${ASSET_SCHEMA.PRICE.ui}</label><input type="text" id="hw-금액" oninput="this.value=this.value.replace(/[^0-9]/g,'').replace(/\\\\B(?=(\\\\d{3})+(?!\\\\d))/g,',')" /></div>
<div class="form-group" id="hw-vendor-group"><label for="hw-납품업체">${ASSET_SCHEMA.VENDOR.ui}</label><input type="text" id="hw-납품업체" /></div>
<div class="form-group full-width"><label for="hw-비고">${ASSET_SCHEMA.REMARKS.ui}</label><textarea id="hw-비고" rows="2"></textarea></div>
<div class="form-group full-width">
<label>${ASSET_SCHEMA.DOC_NAME.ui} (파일 증빙)</label>
<div style="display:flex; align-items:center; gap:0.5rem;">
<input type="file" id="hw-품의서" />
<span id="hw-품의서" style="font-size:0.75rem; color:var(--text-light)"></span>
</div>
</div>
`;
export function openHwModal(asset: HardwareAsset, mode: 'view' | 'add' | 'edit' = 'view') {
currentAsset = asset;
const modal = document.getElementById('hw-asset-modal')!;
// 1. 잠금 상태 통합 제어 (데이터 유무가 아닌 호출 mode에만 의존)
setEditLock('hw-asset-form', mode, {
saveBtnId: 'btn-save-hw-asset',
revertBtnId: 'btn-revert-hw-edit',
generateBtnId: 'btn-generate-hw-code'
});
isEditMode = (mode === 'add' || mode === 'edit');
// 2. 데이터 바인딩
fillHwFormData(asset);
modal.classList.remove('hidden');
applyTypeSpecificUI(asset.type);
createIcons({ icons: { Paperclip } });
function renderHwHistory(assetId: string) {
const container = document.getElementById('hw-history-list');
if (!container) return;
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) { container.innerHTML = '<div class="empty-history">기록된 이력이 없습니다.</div>'; return; }
container.innerHTML = logs.map(l => `
<div class="history-item">
<div class="history-date">${l.date}</div>
<div class="history-user">${l.user}</div>
<div class="history-details">${l.details.replace(/\n/g, '<br>')}</div>
</div>
`).join('');
}
function applyTypeSpecificUI(type: string) {
const detailPurpose = getFieldValue('hw-상세용도');
const form = document.getElementById('hw-asset-form') as HTMLFormElement;
if (!form) return;
const upperType = (type || '').toUpperCase();
const serverOnly = document.querySelectorAll('.server-only');
const nonServer = document.querySelectorAll('.non-server');
const locationFields = document.querySelectorAll('.hw-location-field');
const groups: Record<string, HTMLElement | null> = {
detailPurpose: document.getElementById('hw-상세용도-group'),
networkTitle: document.getElementById('hw-network-title'),
specTitle: document.getElementById('hw-spec-title'),
opTitle: document.getElementById('hw-op-title'),
model: document.getElementById('hw-model-group'),
ip: document.getElementById('hw-ip-group'),
ip2: document.getElementById('hw-ip2-group'),
remote: document.getElementById('hw-remote-group'),
mainboard: document.getElementById('hw-mainboard-group'),
os: document.getElementById('hw-os-group'),
cpu: document.getElementById('hw-cpu-group'),
ram: document.getElementById('hw-ram-group'),
gpu: document.getElementById('hw-gpu-group'),
ssd1: document.getElementById('hw-ssd1-group'),
ssd2: document.getElementById('hw-ssd2-group'),
monitoring: document.getElementById('hw-monitoring-group'),
serverId: document.getElementById('hw-server-id-group'),
serverPw: document.getElementById('hw-server-pw-group'),
ssd3: document.getElementById('hw-ssd3-group'),
hwSpec: document.getElementById('hw-hwspec-group'),
ipNonServer: document.getElementById('hw-ip-non-server-group'),
type: document.getElementById('hw-유형-group'),
networkTitle: document.getElementById('hw-network-title'),
specTitle: document.getElementById('hw-spec-title'),
opTitle: document.getElementById('hw-op-title')
monitoring: document.getElementById('hw-monitoring-group'),
vendor: document.getElementById('hw-vendor-group'),
user: document.querySelector('.pc-only') as HTMLElement
};
// 1. 초기화 (모든 유동 섹션 숨김)
const serverOnly = document.querySelectorAll('.server-only');
const nonServer = document.querySelectorAll('.non-server');
const opOnly = document.querySelectorAll('.op-only');
const standardLoc = document.querySelectorAll('.loc-standard');
serverOnly.forEach(el => (el as HTMLElement).style.display = 'none');
nonServer.forEach(el => (el as HTMLElement).style.display = 'none');
locationFields.forEach(el => (el as HTMLElement).style.display = 'none');
opOnly.forEach(el => (el as HTMLElement).style.display = 'none');
standardLoc.forEach(el => (el as HTMLElement).style.display = 'flex');
Object.values(groups).forEach(g => { if (g) g.style.display = 'none'; });
if (groups.type) groups.type.style.display = 'flex';
if (groups.opTitle) groups.opTitle.style.display = 'flex';
// 2. 유형별 정밀 규칙 적용 (사용자 정의 100% 일치)
if (type === '서버') {
serverOnly.forEach(el => (el as HTMLElement).style.display = 'flex');
locationFields.forEach(el => (el as HTMLElement).style.display = 'flex');
Object.values(groups).forEach(g => { if (g) g.style.display = 'flex'; });
}
else if (['스토리지', 'NAS', 'DAS'].includes(type)) {
serverOnly.forEach(el => (el as HTMLElement).style.display = 'flex');
locationFields.forEach(el => (el as HTMLElement).style.display = 'flex');
if (groups.networkTitle) groups.networkTitle.style.display = 'flex';
if (groups.ip) groups.ip.style.display = 'flex';
const osLabel = document.querySelector('label[for="hw-OS"]') as HTMLElement;
const ramLabel = document.querySelector('label[for="hw-RAM"]') as HTMLElement;
const modelLabel = document.querySelector('label[for="hw-모델명"]') as HTMLElement;
if (osLabel) osLabel.innerText = ASSET_SCHEMA.OS.ui;
if (ramLabel) ramLabel.innerText = ASSET_SCHEMA.RAM.ui;
if (modelLabel) modelLabel.innerText = ASSET_SCHEMA.MODEL.ui;
const isMobileGroup = ['모바일', '태블릿', '휴대폰'].some(t => upperType.includes(t));
const isEquipGroup = ['CPU', 'RAM', 'HDD', 'GPU'].some(t => upperType.includes(t)) || upperType.includes('비품');
const isOpType = isMobileGroup || isEquipGroup;
const isPcType = upperType === 'PC' || upperType === '개인PC' || upperType === '노트북';
if (groups.opTitle) groups.opTitle.style.display = isOpType ? 'flex' : 'none';
if (isOpType) {
opOnly.forEach(el => (el as HTMLElement).style.display = 'flex');
standardLoc.forEach(el => (el as HTMLElement).style.display = 'none');
if (groups.specTitle) groups.specTitle.style.display = 'flex';
if (groups.model) groups.model.style.display = 'flex';
if (groups.ssd1) groups.ssd1.style.display = 'flex';
if (groups.ssd2) groups.ssd2.style.display = 'flex';
}
else if (type === 'PC' || type === '노트북') {
if (type === 'PC' && groups.detailPurpose) groups.detailPurpose.style.display = 'flex';
nonServer.forEach(el => (el as HTMLElement).style.display = 'flex');
if (groups.specTitle) groups.specTitle.style.display = 'flex';
['model', 'os', 'cpu', 'ram', 'ssd1', 'ssd2', 'hwSpec', 'ipNonServer'].forEach(k => {
if (groups[k]) groups[k]!.style.display = 'flex';
});
if (type === 'PC' && detailPurpose === '서버') {
locationFields.forEach(el => (el as HTMLElement).style.display = 'flex');
if (groups.networkTitle) groups.networkTitle.style.display = 'flex';
['ip', 'ip2', 'remote', 'serverId', 'serverPw', 'monitoring'].forEach(k => {
if (groups[k]) groups[k]!.style.display = 'flex';
});
if (groups.ipNonServer) groups.ipNonServer.style.display = 'none';
if (['CPU', 'GPU'].some(t => upperType.includes(t))) {
if (groups.os && osLabel) { osLabel.innerText = '출시연월'; groups.os.style.display = 'flex'; }
} else if (['RAM', 'HDD'].some(t => upperType.includes(t))) {
if (groups.ram && ramLabel) { ramLabel.innerText = '용량'; groups.ram.style.display = 'flex'; }
} else {
if (groups.hwSpec) groups.hwSpec.style.display = 'flex';
}
}
else if (['CPU', 'GPU', '모바일'].includes(type)) {
}
else if (isPcType) {
if (groups.user) groups.user.style.display = 'flex';
if (groups.specTitle) groups.specTitle.style.display = 'flex';
if (groups.model) groups.model.style.display = 'flex';
}
else if (type === 'RAM') {
if (groups.mainboard) groups.mainboard.style.display = 'flex';
if (upperType === '노트북') {
if (groups.detailPurpose) groups.detailPurpose.style.display = 'none';
nonServer.forEach(el => (el as HTMLElement).style.display = 'flex');
['model', 'os', 'cpu', 'gpu', 'ram', 'ssd1', 'ssd2', 'ssd3', 'hwSpec', 'vendor'].forEach(k => { if (groups[k]) groups[k]!.style.display = 'flex'; });
} else {
if (groups.detailPurpose) groups.detailPurpose.style.display = 'flex';
if (detailPurpose === '서버') {
serverOnly.forEach(el => (el as HTMLElement).style.display = 'flex');
if (groups.networkTitle) groups.networkTitle.style.display = 'flex';
['model', 'os', 'cpu', 'gpu', 'ram', 'ssd1', 'ssd2', 'ssd3', 'monitoring', 'vendor'].forEach(k => { if (groups[k]) groups[k]!.style.display = 'flex'; });
} else {
nonServer.forEach(el => (el as HTMLElement).style.display = 'flex');
['model', 'os', 'cpu', 'gpu', 'ram', 'ssd1', 'ssd2', 'ssd3', 'hwSpec', 'vendor'].forEach(k => { if (groups[k]) groups[k]!.style.display = 'flex'; });
}
}
}
else {
serverOnly.forEach(el => (el as HTMLElement).style.display = 'flex');
if (groups.networkTitle) groups.networkTitle.style.display = 'flex';
if (groups.specTitle) groups.specTitle.style.display = 'flex';
if (groups.ram) groups.ram.style.display = 'flex';
}
else if (type === 'HDD') {
if (groups.specTitle) groups.specTitle.style.display = 'flex';
if (groups.ssd1) groups.ssd1.style.display = 'flex';
}
else if (type === '태블릿') {
if (groups.specTitle) groups.specTitle.style.display = 'flex';
if (groups.model) groups.model.style.display = 'flex';
if (groups.ssd1) groups.ssd1.style.display = 'flex';
['model', 'os', 'cpu', 'gpu', 'ram', 'ssd1', 'ssd2', 'ssd3', 'monitoring', 'vendor'].forEach(k => { if (groups[k]) groups[k]!.style.display = 'flex'; });
}
}
function fillHwFormData(asset: HardwareAsset) {
setFieldValue('hw-asset-id', asset.id);
setFieldValue('hw-asset-type', asset.type);
setFieldValue('hw-법인', asset.);
setFieldValue('hw-자산코드', asset.);
setFieldValue('hw-현사용조직', asset.);
setFieldValue('hw-이전사용조직', asset.);
setFieldValue('hw-상세용도', (asset as any).);
export function openHwModal(asset: HardwareAsset, mode: 'view' | 'add' = 'view') {
currentAsset = asset;
const modal = document.getElementById('hw-asset-modal')!;
setEditLock('hw-asset-form', mode, {
saveBtnId: 'btn-save-hw-asset',
revertBtnId: 'btn-revert-hw-edit',
generateBtnId: 'btn-generate-hw-code',
addLogBtnId: 'btn-add-hw-log'
});
parseAndSetLocation(asset., 'hw-위치-빌딩', 'hw-위치-상세', 'hw-위치-기타-group', 'hw-위치-기타');
isEditMode = (mode === 'add');
autoFillForm('hw', asset, HW_FIELD_MAP);
setFieldValue('hw-명칭', asset. || asset[ASSET_SCHEMA.MODEL.key]);
if (!asset[ASSET_SCHEMA.PURCHASE_YM.key] && asset.) setFieldValue('hw-구매일', asset.);
setFieldValue('hw-모델명', asset.);
setFieldValue('hw-OS', asset.OS);
setFieldValue('hw-CPU', asset.CPU);
setFieldValue('hw-RAM', asset.RAM);
setFieldValue('hw-SSD1', asset.SSD1);
setFieldValue('hw-SSD2', asset.SSD2);
setFieldValue('hw-담당자_정', asset._정 || asset.);
setFieldValue('hw-담당자_부', asset._부);
parseAndSetLocation(asset[ASSET_SCHEMA.LOCATION.key], 'hw-위치-빌딩', 'hw-위치-상세', 'hw-위치-기타-group', 'hw-위치-기타');
applyTypeSpecificUI(asset.type);
renderHwHistory(asset.id);
const isServerGrade = asset.type === '서버' || (asset as any). === '서버' || asset.type === '스토리지' || ['NAS', 'DAS'].includes(asset.type);
if (isServerGrade) {
setFieldValue('hw-용도', asset. || (asset as any).purpose);
setFieldValue('hw-상세', asset. || (asset as any).details);
setFieldValue('hw-비고', asset. || (asset as any).remarks);
setFieldValue('hw-구매일', asset. || (asset as any).purchase_date);
setFieldValue('hw-유형', asset.storage유형 || asset.type);
setFieldValue('hw-IP주소', asset.IP주소 || (asset as any).ip_address);
setFieldValue('hw-IP2', (asset as any).IP2 || (asset as any).ip_address_2);
setFieldValue('hw-원격접속', asset. || (asset as any).remote_tool);
setFieldValue('hw-서버ID', (asset as any).ID || (asset as any).server_id);
setFieldValue('hw-서버PW', (asset as any).PW || (asset as any).server_pw);
setFieldValue('hw-모니터링', asset. || (asset as any).monitoring);
} else {
setFieldValue('hw-명칭', asset. || asset.);
setFieldValue('hw-구매일', asset. || (asset as any).purchase_date);
setFieldValue('hw-금액', asset. || (asset as any).price);
setFieldValue('hw-HW사양', asset.HW사양 || asset. || (asset as any).details);
setFieldValue('hw-IP주소-non-server', asset.IP주소 || (asset as any).ip_address);
}
modal.classList.remove('hidden');
createIcons({ icons: { X, Save, Edit2, RotateCcw, History, Plus, Paperclip } });
}
export function initHwModal(onSave: () => void, closeModals: () => void) {
export function initHwModal(onSave: () => void, closeModalsCb: () => void) {
if (!document.getElementById('hw-asset-modal')) {
document.body.insertAdjacentHTML('beforeend', HW_MODAL_HTML);
const html = createModalFrameHTML('hw', '자산 상세 정보', HW_FORM_HTML, {
historyTitle: '분출 및 변경 이력',
addLogBtnId: 'btn-add-hw-log'
});
document.body.insertAdjacentHTML('beforeend', html);
const logModalHTML = `
<div id="hw-log-modal" class="modal-overlay hidden" style="z-index: 1100;">
<div class="modal-content" style="max-width: 400px;">
<div class="modal-header"><h2>${UI_TEXT.ACTION.HISTORY_ADD}</h2><button id="btn-close-hw-log" class="btn-icon"><i data-lucide="x"></i></button></div>
<div class="modal-body">
<div class="grid-form" style="grid-template-columns: 1fr;">
<div class="form-group"><label>날짜</label><input type="date" id="new-hw-log-date" /></div>
<div class="form-group"><label>변경/분출 내용</label><textarea id="new-hw-log-details" rows="3" placeholder="예: [분출] 기술팀 홍길동, [수리] 배터리 교체 등"></textarea></div>
</div>
</div>
<div class="modal-footer"><div></div><div class="footer-actions"><button id="btn-cancel-hw-log" class="btn btn-outline">${UI_TEXT.ACTION.CANCEL}</button><button id="btn-confirm-hw-log" class="btn btn-primary">추가</button></div></div>
</div>
</div>
`;
document.body.insertAdjacentHTML('beforeend', logModalHTML);
}
const form = document.getElementById('hw-asset-form') as HTMLFormElement;
@@ -359,25 +309,28 @@ export function initHwModal(onSave: () => void, closeModals: () => void) {
const deleteBtn = document.getElementById('btn-delete-hw-asset')!;
const typeSelect = document.getElementById('hw-유형') as HTMLSelectElement;
const detailPurposeSelect = document.getElementById('hw-상세용도') as HTMLSelectElement;
const logAddBtn = document.getElementById('btn-add-hw-log')!;
const logModal = document.getElementById('hw-log-modal')!;
[typeSelect, detailPurposeSelect].forEach(el => {
el?.addEventListener('change', () => applyTypeSpecificUI(typeSelect.value));
});
bindLocationEvents('hw-위치-빌딩', 'hw-위치-상세', 'hw-위치-기타-group', 'hw-위치-기타');
const closeModalAction = () => { closeModals(); isEditMode = false; };
const closeModalAction = () => { closeModalsCb(); isEditMode = false; };
document.getElementById('btn-close-hw-modal')?.addEventListener('click', closeModalAction);
document.getElementById('btn-cancel-hw-modal')?.addEventListener('click', closeModalAction);
revertBtn.addEventListener('click', () => {
setEditLock('hw-asset-form', 'view', {
saveBtnId: 'btn-save-hw-asset',
revertBtnId: 'btn-revert-hw-edit',
generateBtnId: 'btn-generate-hw-code'
setEditLock('hw-asset-form', 'view', {
saveBtnId: 'btn-save-hw-asset',
revertBtnId: 'btn-revert-hw-edit',
generateBtnId: 'btn-generate-hw-code',
addLogBtnId: 'btn-add-hw-log'
});
isEditMode = false;
if (currentAsset) fillHwFormData(currentAsset);
if (currentAsset) openHwModal(currentAsset, 'view');
});
document.getElementById('btn-generate-hw-code')?.addEventListener('click', async () => {
@@ -385,79 +338,147 @@ export function initHwModal(onSave: () => void, closeModals: () => void) {
const purchaseDate = getFieldValue('hw-구매일');
const typeCode = TYPE_PREFIX_MAP[typeValue] || 'ETC';
const dateStr = purchaseDate.replace(/[^0-9]/g, '');
if (dateStr.length < 4) { alert('올바른 구매일(연월)을 입력해주세요.'); return; }
const prefix = `${typeCode}-${dateStr.substring(2, 6)}-`;
if (dateStr.length < 6) { alert('올바른 구매연월(YYYYMM)을 입력해주세요.'); return; }
const prefix = `${typeCode}-${dateStr.substring(0, 6)}-`;
try {
const res = await fetch(`http://localhost:3000/api/generate-asset-code?prefix=${prefix}`);
const res = await fetch(`http://172.16.40.100:3000/api/generate-asset-code?prefix=${prefix}`);
const data = await res.json();
if (data.nextCode) setFieldValue('hw-자산코드', data.nextCode);
} catch (err) { alert('자산번호 생성에 실패했습니다.'); }
});
['hw-구매일', 'hw-OS'].forEach(id => {
const el = document.getElementById(id) as HTMLInputElement;
el?.addEventListener('input', (e) => {
const target = e.target as HTMLInputElement;
const label = document.querySelector(`label[for="${id}"]`) as HTMLElement;
if (id === 'hw-OS' && label?.innerText !== '출시연월') return;
target.value = target.value.replace(/[^0-9]/g, '').substring(0, 6);
});
});
saveBtn.addEventListener('click', () => {
if (!currentAsset) return;
if (!isEditMode) {
setEditLock('hw-asset-form', 'edit', {
saveBtnId: 'btn-save-hw-asset',
revertBtnId: 'btn-revert-hw-edit'
setEditLock('hw-asset-form', 'edit', {
saveBtnId: 'btn-save-hw-asset',
revertBtnId: 'btn-revert-hw-edit',
generateBtnId: 'btn-generate-hw-code',
addLogBtnId: 'btn-add-hw-log'
});
isEditMode = true;
applyTypeSpecificUI(getFieldValue('hw-유형'));
return;
}
const type = typeSelect.value;
const detailPurpose = detailPurposeSelect.value;
const extracted = autoExtractForm('hw', HW_FIELD_MAP);
if (!extracted[ASSET_SCHEMA.ASSET_CODE.key]) {
alert('자산번호가 없습니다. [생성] 버튼을 눌러 자산번호를 먼저 부여해주세요.');
return;
}
const upperType = (extracted.type || '').toUpperCase();
const isOpType = ['CPU', 'RAM', 'HDD', 'GPU'].some(t => upperType.includes(t)) || upperType.includes('비품') || ['모바일', '태블릿', '휴대폰'].some(t => upperType.includes(t));
if (HW_TYPE_LIST.includes(extracted.type) || extracted.type === '개인PC') {
const diffLogs: string[] = [];
const compareFields = [
{ key: ASSET_SCHEMA.ORG.key, label: ASSET_SCHEMA.ORG.ui },
{ key: ASSET_SCHEMA.LOCATION.key, label: ASSET_SCHEMA.LOCATION.ui },
{ key: ASSET_SCHEMA.MANAGER_MAIN.key, label: '담당자' },
{ key: ASSET_SCHEMA.STATUS.key, label: ASSET_SCHEMA.STATUS.ui },
{ key: ASSET_SCHEMA.IP_ADDR.key, label: ASSET_SCHEMA.IP_ADDR.ui },
{ key: '상세용도', label: '상세유형' },
{ key: ASSET_SCHEMA.MODEL.key, label: ASSET_SCHEMA.MODEL.ui }
];
if (!currentAsset || !currentAsset.) {
diffLogs.push('자산 신규 등록');
} else {
const asset = currentAsset!;
const newIp = String(getFieldValue('hw-IP주소') || getFieldValue('hw-IP주소-non-server') || '').trim();
const newLocation = String(isOpType ? extracted[ASSET_SCHEMA.STORE_LOC.key] : getCombinedLocation('hw-위치-빌딩', 'hw-위치-상세', 'hw-위치-기타') || '').trim();
compareFields.forEach(f => {
let oldVal = '';
let newVal = '';
if (f.key === ASSET_SCHEMA.IP_ADDR.key) {
oldVal = String(asset[ASSET_SCHEMA.IP_ADDR.key] || '').trim();
newVal = newIp;
} else if (f.key === ASSET_SCHEMA.LOCATION.key) {
oldVal = String(asset[ASSET_SCHEMA.LOCATION.key] || '').trim();
newVal = newLocation;
} else if (f.key === ASSET_SCHEMA.MANAGER_MAIN.key) {
oldVal = String(asset[ASSET_SCHEMA.MANAGER_MAIN.key] || '').trim();
newVal = String(extracted[ASSET_SCHEMA.MANAGER_MAIN.key] || '').trim();
} else if (f.key === '상세용도') {
oldVal = String(asset. || '').trim();
newVal = String((extracted.type !== 'PC' && extracted.type !== '개인PC') ? extracted.type : (extracted. || '')).trim();
} else {
oldVal = String((asset as any)[f.key] || '').trim();
newVal = String(extracted[f.key] || '').trim();
}
if (oldVal !== newVal) {
diffLogs.push(`${f.label}: ${oldVal || '(없음)'}${newVal || '(없음)'}`);
}
});
}
if (diffLogs.length > 0) {
state.masterData.logs = state.masterData.logs || [];
state.masterData.logs.push({
id: Math.random().toString(36).substring(2, 9),
assetId: currentAsset.id,
date: new Date().toISOString().split('T')[0],
user: '담당자',
details: diffLogs.join('\n')
});
}
}
const updated: any = {
...currentAsset,
법인: getFieldValue('hw-법인'),
자산코드: getFieldValue('hw-자산코드'),
현사용조직: getFieldValue('hw-현사용조직'),
이전사용조직: getFieldValue('hw-이전사용조직'),
위치: getCombinedLocation('hw-위치-빌딩', 'hw-위치-상세', 'hw-위치-기타'),
모델명: getFieldValue('hw-모델명'),
OS: getFieldValue('hw-OS'),
CPU: getFieldValue('hw-CPU'),
RAM: getFieldValue('hw-RAM'),
SSD1: getFieldValue('hw-SSD1'),
SSD2: getFieldValue('hw-SSD2'),
담당자_정: getFieldValue('hw-담당자_정'),
관리자: getFieldValue('hw-담당자_정'),
담당자_부: getFieldValue('hw-담당자_부'),
type: type,
상세용도: detailPurpose
...extracted,
[ASSET_SCHEMA.IP_ADDR.key]: getFieldValue('hw-IP주소') || getFieldValue('hw-IP주소-non-server'),
위치: isOpType ? extracted[ASSET_SCHEMA.STORE_LOC.key] : getCombinedLocation('hw-위치-빌딩', 'hw-위치-상세', 'hw-위치-기타')
};
if (type === '서버' || (type === 'PC' && detailPurpose === '서버') || ['스토리지', 'NAS', 'DAS'].includes(type)) {
updated. = getFieldValue('hw-용도');
updated. = getFieldValue('hw-상세');
updated. = getFieldValue('hw-비고');
updated.storage유형 = type;
updated.IP주소 = getFieldValue('hw-IP주소');
updated.IP2 = getFieldValue('hw-IP2');
updated. = getFieldValue('hw-원격접속');
updated.ID = getFieldValue('hw-서버ID');
updated.PW = getFieldValue('hw-서버PW');
updated. = getFieldValue('hw-모니터링');
} else {
updated. = getFieldValue('hw-명칭');
updated. = getFieldValue('hw-구매일');
updated. = getFieldValue('hw-금액');
updated.HW사양 = getFieldValue('hw-HW사양');
updated.IP주소 = getFieldValue('hw-IP주소-non-server');
if (currentAsset[ASSET_SCHEMA.ORG.key] && currentAsset[ASSET_SCHEMA.ORG.key] !== extracted[ASSET_SCHEMA.ORG.key]) {
updated[ASSET_SCHEMA.PREV_ORG.key] = currentAsset[ASSET_SCHEMA.ORG.key];
}
if (updated.type !== 'PC') { updated. = updated.type; }
saveHardwareAsset(updated);
onSave();
closeModalAction();
setEditLock('hw-asset-form', 'view', { saveBtnId: 'btn-save-hw-asset', revertBtnId: 'btn-revert-hw-edit', generateBtnId: 'btn-generate-hw-code', addLogBtnId: 'btn-add-hw-log' });
isEditMode = false;
});
deleteBtn.addEventListener('click', () => {
if (!currentAsset) return;
if (confirm('정말로 이 자산을 삭제하시겠습니까?')) {
if (currentAsset && confirm(UI_TEXT.MESSAGES.CONFIRM_DELETE)) {
deleteHardwareAsset(currentAsset.id);
onSave();
closeModals();
closeModalAction();
}
});
logAddBtn.addEventListener('click', () => {
logModal.classList.remove('hidden');
(document.getElementById('new-hw-log-date') as HTMLInputElement).value = new Date().toISOString().split('T')[0];
(document.getElementById('new-hw-log-details') as HTMLTextAreaElement).value = '';
});
document.getElementById('btn-close-hw-log')?.addEventListener('click', () => logModal.classList.add('hidden'));
document.getElementById('btn-cancel-hw-log')?.addEventListener('click', () => logModal.classList.add('hidden'));
document.getElementById('btn-confirm-hw-log')?.addEventListener('click', () => {
if (!currentAsset) return;
const date = (document.getElementById('new-hw-log-date') as HTMLInputElement).value;
const details = (document.getElementById('new-hw-log-details') as HTMLTextAreaElement).value;
if (!date || !details) return;
state.masterData.logs = state.masterData.logs || [];
state.masterData.logs.push({ id: Math.random().toString(36).substring(2, 9), assetId: currentAsset.id, date, user: '담당자', details });
logModal.classList.add('hidden');
renderHwHistory(currentAsset.id);
});
}

View File

@@ -103,13 +103,15 @@ export function setEditLock(
options: {
saveBtnId: string,
revertBtnId: string,
generateBtnId?: string
generateBtnId?: string,
addLogBtnId?: string
}
) {
const form = document.getElementById(formId) as HTMLFormElement;
const saveBtn = document.getElementById(options.saveBtnId);
const revertBtn = document.getElementById(options.revertBtnId);
const generateBtn = options.generateBtnId ? document.getElementById(options.generateBtnId) : null;
const addLogBtn = options.addLogBtnId ? document.getElementById(options.addLogBtnId) : null;
if (!form || !saveBtn || !revertBtn) return;
@@ -118,10 +120,14 @@ export function setEditLock(
form.classList.remove('is-view-mode');
form.classList.add('is-edit-mode');
saveBtn.textContent = '저장';
revertBtn.classList.toggle('hidden', mode === 'add'); // 신규 추가 시에는 취소 버튼 숨김 (닫기가 대신함)
revertBtn.classList.toggle('hidden', mode === 'add'); // 신규 추가 시에는 취소 버튼 숨김
// 번호 생성 버튼은 '추가' 시에만 노출
if (generateBtn) generateBtn.classList.toggle('hidden', mode !== 'add');
// 번호 생성 버튼은 '추가(add)' 시에만 노출
if (generateBtn) {
generateBtn.style.display = mode === 'add' ? 'flex' : 'none';
}
// 내역 추가 버튼 노출
if (addLogBtn) addLogBtn.style.display = 'flex';
} else {
// 조회 모드 (잠금)
form.classList.remove('is-edit-mode');
@@ -129,12 +135,85 @@ export function setEditLock(
saveBtn.textContent = '수정';
revertBtn.classList.add('hidden');
// 조회 모드에서는 번호 생성 버튼 무조건 숨김
if (generateBtn) generateBtn.classList.add('hidden');
// 조회 모드에서는 버튼들 숨김
if (generateBtn) generateBtn.style.display = 'none';
if (addLogBtn) addLogBtn.style.display = 'none';
}
}
// 8. 날짜 자동 마스킹 및 포커스 제어 (Auto-jump)
/**
* 8. 공통 모달 프레임 템플릿 생성
* @param idPrefix 필드 ID의 접두사 (예: 'hw', 'sw', 'pc')
* @param title 모달 제목
* @param formContent 각 모달마다 다른 폼 본문 HTML
* @param options 설정 (이력 영역 제목 등)
*/
export function createModalFrameHTML(
idPrefix: string,
title: string,
formContent: string,
options: { historyTitle: string, addLogBtnId: string }
): string {
return `
<div id="${idPrefix}-asset-modal" class="modal-overlay hidden">
<div class="modal-content wide">
<div class="modal-header">
<h2 id="${idPrefix}-modal-title">${title}</h2>
<button id="btn-close-${idPrefix}-modal" class="btn-icon" aria-label="닫기"><i data-lucide="x"></i></button>
</div>
<div class="modal-body">
<div class="modal-body-split">
<div class="modal-form-area">
<form id="${idPrefix}-asset-form" class="grid-form">
<input type="hidden" id="${idPrefix}-asset-id" />
<input type="hidden" id="${idPrefix}-asset-type-hidden" />
${formContent}
</form>
</div>
<div class="modal-history-area">
<div class="history-header">
<h3><i data-lucide="history" style="width:16px; height:16px;"></i> ${options.historyTitle}</h3>
<button type="button" id="${options.addLogBtnId}" class="btn btn-outline btn-sm">
내역 추가 <i data-lucide="plus" style="width:14px; height:14px;"></i>
</button>
</div>
<div id="${idPrefix}-history-list" class="history-timeline"></div>
</div>
</div>
</div>
<div class="modal-footer">
<button id="btn-delete-${idPrefix}-asset" class="btn btn-outline btn-danger">삭제</button>
<div class="footer-actions">
<button id="btn-revert-${idPrefix}-edit" class="btn btn-outline hidden">수정 취소</button>
<button id="btn-cancel-${idPrefix}-modal" class="btn btn-outline">닫기</button>
<button id="btn-save-${idPrefix}-asset" class="btn btn-primary">수정</button>
</div>
</div>
</div>
</div>
`;
}
/**
* 9. 데이터 ↔ 폼 자동 매핑 (유지보수 핵심)
*/
export function autoFillForm(idPrefix: string, data: any, fieldMap: Record<string, string>) {
Object.entries(fieldMap).forEach(([fieldId, dataKey]) => {
setFieldValue(`${idPrefix}-${fieldId}`, data[dataKey]);
});
}
export function autoExtractForm(idPrefix: string, fieldMap: Record<string, string>): any {
const result: any = {};
Object.entries(fieldMap).forEach(([fieldId, dataKey]) => {
result[dataKey] = getFieldValue(`${idPrefix}-${fieldId}`);
});
return result;
}
/**
* 10. 날짜 자동 마스킹 및 포커스 제어 (Auto-jump)
*/
export function applyDateMask(el: HTMLInputElement) {
if (!el) return;

View File

@@ -1,376 +0,0 @@
import { state, saveHardwareAsset, deleteHardwareAsset } from '../../core/state';
import { HardwareAsset } from '../../core/excelHandler';
import { openModal, closeModals } from './BaseModal';
import { createIcons, History, X, Paperclip, Calendar } from 'lucide';
import { CORP_LIST, ORG_LIST, HW_TYPE_LIST, LOCATION_DATA } from './SharedData';
import {
generateOptionsHTML,
setFieldValue,
getFieldValue,
parseAndSetLocation,
bindLocationEvents,
getCombinedLocation,
applyDateMask
} from './ModalUtils';
let currentAsset: HardwareAsset | null = null;
let isEditMode = false;
const PC_MODAL_HTML = `
<div id="pc-asset-modal" class="modal-overlay hidden">
<div class="modal-content wide">
<div class="modal-header">
<h2 id="pc-modal-title">개인PC 상세 정보</h2>
<button id="btn-close-pc-modal" class="btn-icon" aria-label="닫기"><i data-lucide="x"></i></button>
</div>
<div class="modal-body">
<div class="modal-body-split">
<div class="modal-form-area">
<form id="pc-asset-form" class="grid-form">
<input type="hidden" id="pc-asset-id" />
<input type="hidden" id="pc-asset-type" value="개인PC" />
<div class="form-section-title">기본 정보 (Identity)</div>
<div class="form-group">
<label for="pc-법인">구매법인</label>
<select id="pc-법인" required>${generateOptionsHTML(CORP_LIST)}</select>
</div>
<div class="form-group">
<label for="pc-자산코드">자산번호/코드</label>
<input type="text" id="pc-자산코드" readonly placeholder="자동 생성됩니다" required />
</div>
<div class="form-group">
<label for="pc-유형">유형</label>
<select id="pc-유형">${generateOptionsHTML(HW_TYPE_LIST)}</select>
</div>
<div class="form-group">
<label for="pc-상세용도">상세용도</label>
<select id="pc-상세용도">
<option value="개인PC">개인PC</option>
<option value="서버">서버</option>
</select>
</div>
<div class="form-group">
<label for="pc-사용자">사용자</label>
<input type="text" id="pc-사용자" required />
</div>
<div class="form-group">
<label for="pc-현사용조직">현 사용조직</label>
<select id="pc-현사용조직">${generateOptionsHTML(ORG_LIST)}</select>
</div>
<div class="form-group" id="pc-이전사용조직-group">
<label for="pc-이전사용조직">이전 사용조직</label>
<input type="text" id="pc-이전사용조직" readonly />
</div>
<div class="form-section-title">시스템 사양 (Specifications)</div>
<div class="form-group">
<label for="pc-모델명">모델명</label>
<input type="text" id="pc-모델명" />
</div>
<div class="form-group">
<label for="pc-메인보드">메인보드</label>
<input type="text" id="pc-메인보드" />
</div>
<div class="form-group">
<label for="pc-OS">운영체제 (OS)</label>
<input type="text" id="pc-OS" />
</div>
<div class="form-group">
<label for="pc-CPU">CPU 사양</label>
<input type="text" id="pc-CPU" />
</div>
<div class="form-group">
<label for="pc-RAM">RAM 용량</label>
<input type="text" id="pc-RAM" />
</div>
<div class="form-group">
<label for="pc-SSD1">Storage 1 (SSD/HDD)</label>
<input type="text" id="pc-SSD1" />
</div>
<div class="form-group">
<label for="pc-SSD2">Storage 2 (SSD/HDD)</label>
<input type="text" id="pc-SSD2" />
</div>
<div class="form-section-title" id="pc-location-title">관리 및 운영 (Operation)</div>
<div class="form-group pc-location-field">
<label for="pc-위치-빌딩">설치위치 (건물)</label>
<select id="pc-위치-빌딩">${generateOptionsHTML(Object.keys(LOCATION_DATA))}</select>
</div>
<div class="form-group pc-location-field">
<label for="pc-위치-상세">상세 위치</label>
<select id="pc-위치-상세">
<option value="">건물을 먼저 선택하세요</option>
</select>
</div>
<div class="form-group" id="pc-위치-기타-group" style="display:none;">
<label for="pc-위치-기타">직접 입력 (기타)</label>
<input type="text" id="pc-위치-기타" />
</div>
<div class="form-group">
<label for="pc-구매일">구매일</label>
<div style="display:flex; gap:0.25rem; align-items:center; position:relative;">
<input type="text" id="pc-구매일" style="flex:1;" />
<button type="button" class="btn-icon" onclick="const p = document.getElementById('pc-구매일-picker'); p.value = document.getElementById('pc-구매일').value; p.showPicker();" style="padding:0.25rem;">
<i data-lucide="calendar" style="width:18px; height:18px; color:var(--primary-color);"></i>
</button>
<input type="date" id="pc-구매일-picker" style="position:absolute; width:0; height:0; opacity:0; pointer-events:none;" onchange="document.getElementById('pc-구매일').value = this.value" tabindex="-1" />
</div>
</div>
<div class="form-group">
<label for="pc-금액">금액</label>
<input type="text" id="pc-금액" oninput="this.value = this.value.replace(/[^0-9]/g, '').replace(/\\\\B(?=(\\\\d{3})+(?!\d))/g, ',')" />
</div>
<div class="form-group">
<label for="pc-납품업체">납품업체</label>
<input type="text" id="pc-납품업체" />
</div>
<div class="form-group full-width">
<label>품의서 (파일)</label>
<div style="display:flex; align-items:center; gap:0.5rem;">
<input type="file" id="pc-품의서" />
<span id="pc-품의서명" style="font-size:0.75rem; color:var(--text-light)"></span>
</div>
</div>
</form>
</div>
<div class="modal-history-area">
<div class="history-header">
<h3><i data-lucide="history" style="width:16px; height:16px;"></i> 수정 이력</h3>
</div>
<div id="pc-history-list" class="history-timeline">
<div class="empty-history">이력이 없습니다.</div>
</div>
</div>
</div>
</div>
<div class="modal-footer">
<button id="btn-delete-pc-asset" class="btn btn-outline btn-danger">삭제</button>
<div class="footer-actions">
<button id="btn-revert-pc-edit" class="btn btn-outline hidden">수정 취소</button>
<button id="btn-cancel-pc-modal" class="btn btn-outline">닫기</button>
<button id="btn-save-pc-asset" class="btn btn-primary">수정</button>
</div>
</div>
</div>
</div>
`;
export function openPcModal(asset: HardwareAsset, mode: 'view' | 'add' | 'edit' = 'view') {
currentAsset = asset;
const modal = document.getElementById('pc-asset-modal');
if (!modal) return;
const form = document.getElementById('pc-asset-form') as HTMLFormElement;
const saveBtn = document.getElementById('btn-save-pc-asset')!;
const revertBtn = document.getElementById('btn-revert-pc-edit')!;
if (form) form.reset();
if (mode === 'add' || mode === 'edit') {
isEditMode = true;
if (form) {
form.classList.remove('is-view-mode');
form.classList.add('is-edit-mode');
}
saveBtn.textContent = '저장';
revertBtn.classList.toggle('hidden', mode === 'add');
const prevOrgGroup = document.getElementById('pc-이전사용조직-group');
if (prevOrgGroup) prevOrgGroup.style.display = 'none';
} else {
isEditMode = false;
if (form) {
form.classList.remove('is-edit-mode');
form.classList.add('is-view-mode');
}
saveBtn.textContent = '수정';
revertBtn.classList.add('hidden');
const prevOrgGroup = document.getElementById('pc-이전사용조직-group');
if (prevOrgGroup) prevOrgGroup.style.display = 'flex';
}
fillFormData(asset);
renderHistory(asset.id);
modal.classList.remove('hidden');
applyPcTypeSpecificUI();
createIcons({ icons: { X, History, Paperclip, Calendar } });
}
function applyPcTypeSpecificUI() {
const type = getFieldValue('pc-유형');
const detailPurpose = getFieldValue('pc-상세용도');
const modelGroup = document.getElementById('pc-모델명')?.closest('.form-group') as HTMLElement;
const osGroup = document.getElementById('pc-OS')?.closest('.form-group') as HTMLElement;
const cpuGroup = document.getElementById('pc-CPU')?.closest('.form-group') as HTMLElement;
const ramGroup = document.getElementById('pc-RAM')?.closest('.form-group') as HTMLElement;
const ssd1Group = document.getElementById('pc-SSD1')?.closest('.form-group') as HTMLElement;
const ssd2Group = document.getElementById('pc-SSD2')?.closest('.form-group') as HTMLElement;
const locationFields = document.querySelectorAll('.pc-location-field');
const etcGroup = document.getElementById('pc-위치-기타-group');
const mainboardGroup = document.getElementById('pc-메인보드')?.closest('.form-group') as HTMLElement;
// 초기화 (숨김)
[modelGroup, osGroup, cpuGroup, ramGroup, ssd1Group, ssd2Group, mainboardGroup].forEach(g => { if(g) g.style.display = 'none'; });
locationFields.forEach(el => (el as HTMLElement).style.display = 'none');
if (etcGroup) etcGroup.style.display = 'none';
if (type === '서버') {
[modelGroup, osGroup, cpuGroup, ramGroup, ssd1Group, ssd2Group].forEach(g => { if(g) g.style.display = 'flex'; });
locationFields.forEach(el => (el as HTMLElement).style.display = 'flex');
}
else if (['스토리지', 'NAS', 'DAS'].includes(type)) {
[modelGroup, ssd1Group, ssd2Group].forEach(g => { if(g) g.style.display = 'flex'; });
locationFields.forEach(el => (el as HTMLElement).style.display = 'flex');
}
else if (type === 'PC' || type === '노트북') {
[modelGroup, mainboardGroup, osGroup, cpuGroup, ramGroup, ssd1Group, ssd2Group].forEach(g => { if(g) g.style.display = 'flex'; });
if (detailPurpose === '서버') {
locationFields.forEach(el => (el as HTMLElement).style.display = 'flex');
}
}
else if (['CPU', 'GPU', '모바일'].includes(type)) {
if (modelGroup) modelGroup.style.display = 'flex';
}
else if (type === 'RAM') {
if (ramGroup) ramGroup.style.display = 'flex';
}
else if (type === 'HDD') {
if (ssd1Group) ssd1Group.style.display = 'flex';
}
else if (type === '태블릿') {
if (modelGroup) modelGroup.style.display = 'flex';
if (ssd1Group) ssd1Group.style.display = 'flex';
}
}
function fillFormData(asset: HardwareAsset) {
setFieldValue('pc-asset-id', asset.id);
setFieldValue('pc-법인', asset.);
setFieldValue('pc-자산코드', asset.);
setFieldValue('pc-유형', asset.type);
setFieldValue('pc-사용자', asset.);
setFieldValue('pc-현사용조직', asset.);
setFieldValue('pc-이전사용조직', asset.);
setFieldValue('pc-상세용도', (asset as any).);
setFieldValue('pc-메인보드', (asset as any). || '');
parseAndSetLocation(asset., 'pc-위치-빌딩', 'pc-위치-상세', 'pc-위치-기타-group', 'pc-위치-기타');
setFieldValue('pc-모델명', asset.);
setFieldValue('pc-OS', asset.OS);
setFieldValue('pc-CPU', asset.CPU);
setFieldValue('pc-RAM', asset.RAM);
setFieldValue('pc-SSD1', asset.SSD1);
setFieldValue('pc-SSD2', asset.SSD2);
setFieldValue('pc-구매일', asset.);
setFieldValue('pc-금액', asset.);
setFieldValue('pc-납품업체', asset.);
setFieldValue('pc-품의서명', asset.);
}
export function initPcModal(onSave: () => void, closeModalsCb: () => void) {
if (!document.getElementById('pc-asset-modal')) {
document.body.insertAdjacentHTML('beforeend', PC_MODAL_HTML);
}
const pcForm = document.getElementById('pc-asset-form') as HTMLFormElement;
const saveBtn = document.getElementById('btn-save-pc-asset');
const revertBtn = document.getElementById('btn-revert-pc-edit');
const deleteBtn = document.getElementById('btn-delete-pc-asset');
// 유형 및 상세용도 리스너
const typeSelect = document.getElementById('pc-유형') as HTMLSelectElement;
const detailPurposeSelect = document.getElementById('pc-상세용도') as HTMLSelectElement;
[typeSelect, detailPurposeSelect].forEach(el => {
el?.addEventListener('change', () => applyPcTypeSpecificUI());
});
bindLocationEvents('pc-위치-빌딩', 'pc-위치-상세', 'pc-위치-기타-group', 'pc-위치-기타');
// 날짜 마스킹 적용
applyDateMask(document.getElementById('pc-구매일') as HTMLInputElement);
const handleClose = () => { closeModalsCb(); isEditMode = false; };
document.getElementById('btn-close-pc-modal')?.addEventListener('click', handleClose);
document.getElementById('btn-cancel-pc-modal')?.addEventListener('click', handleClose);
revertBtn?.addEventListener('click', () => {
isEditMode = false;
pcForm.classList.replace('is-edit-mode', 'is-view-mode');
if (saveBtn) saveBtn.textContent = '수정';
revertBtn.classList.add('hidden');
if (currentAsset) fillFormData(currentAsset);
});
saveBtn?.addEventListener('click', () => {
if (!currentAsset) return;
if (!isEditMode) {
isEditMode = true;
pcForm.classList.replace('is-view-mode', 'is-edit-mode');
saveBtn.textContent = '저장';
revertBtn?.classList.remove('hidden');
return;
}
const type = getFieldValue('pc-유형');
const detailPurpose = getFieldValue('pc-상세용도');
const updated: any = {
...currentAsset,
법인: getFieldValue('pc-법인'),
자산코드: getFieldValue('pc-자산코드'),
현사용조직: getFieldValue('pc-현사용조직'),
이전사용조직: getFieldValue('pc-이전사용조직'),
사용자: getFieldValue('pc-사용자'),
상세용도: detailPurpose,
위치: getCombinedLocation('pc-위치-빌딩', 'pc-위치-상세', 'pc-위치-기타'),
모델명: getFieldValue('pc-모델명'),
OS: getFieldValue('pc-OS'),
CPU: getFieldValue('pc-CPU'),
RAM: getFieldValue('pc-RAM'),
SSD1: getFieldValue('pc-SSD1'),
SSD2: getFieldValue('pc-SSD2'),
메인보드: getFieldValue('pc-메인보드'),
구매일: getFieldValue('pc-구매일'),
금액: getFieldValue('pc-금액'),
납품업체: getFieldValue('pc-납품업체'),
type: type || 'PC'
};
saveHardwareAsset(updated);
onSave();
handleClose();
});
deleteBtn?.addEventListener('click', () => {
if (!currentAsset) return;
if (confirm('삭제하시겠습니까?')) {
deleteHardwareAsset(currentAsset.id);
onSave();
handleClose();
}
});
}
function renderHistory(assetId: string) {
const historyList = document.getElementById('pc-history-list');
if (!historyList) return;
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 = '<div class="empty-history">이력이 없습니다.</div>';
return;
}
historyList.innerHTML = logs.map(log => `
<div class="history-item">
<div class="history-date">${log.date}</div>
<div class="history-user">수정자: ${log.user}</div>
<div class="history-details">${log.details.replace(/\n/g, '<br>')}</div>
</div>
`).join('');
}

View File

@@ -67,14 +67,6 @@ const SW_MODAL_HTML = `
<!-- Group 2: 라이선스 및 계약 (License/Contract) -->
<div class="form-section-title">라이선스 및 계약 정보</div>
<div class="form-group sw-standard-field" id="sw-license-type-group">
<label for="sw-라이선스유형">라이선스 유형</label>
<input type="text" id="sw-라이선스유형" />
</div>
<div class="form-group sw-standard-field" id="sw-license-key-group">
<label for="sw-라이선스키">라이선스 키</label>
<input type="text" id="sw-라이선스키" />
</div>
<div class="form-group sw-standard-field">
<label for="sw-수량">보유 수량</label>
<input type="number" id="sw-수량" min="0" />
@@ -183,7 +175,7 @@ const SW_MODAL_HTML = `
<!-- 계약/유지보수 기간 갱신 및 업데이트 모달 -->
<div id="sw-update-modal" class="modal-overlay hidden" style="z-index: 1100;">
<div class="modal-content" style="max-width: 400px;">
<div class="modal-content" style="max-width: 500px;">
<div class="modal-header">
<h2>계약 업데이트 반영</h2>
<button id="btn-close-sw-update" class="btn-icon"><i data-lucide="x"></i></button>
@@ -233,8 +225,6 @@ function applySwTypeUI(type: string) {
const cloudFields = document.querySelectorAll('.cloud-only');
const swFields = document.querySelectorAll('.sw-standard-field');
const userSection = document.getElementById('sw-user-section');
const keyGroup = document.getElementById('sw-license-key-group');
const typeGroup = document.getElementById('sw-license-type-group');
const expiryGroup = document.getElementById('sw-expiry-group');
if (type === '클라우드') {
@@ -246,14 +236,8 @@ function applySwTypeUI(type: string) {
swFields.forEach(el => (el as HTMLElement).style.display = 'flex');
if (userSection) userSection.style.display = 'block';
if (type === '구독SW') {
if (keyGroup) keyGroup.style.display = 'none';
if (typeGroup) typeGroup.style.display = 'flex';
if (type === '구독SW' || type === '영구SW') {
if (expiryGroup) expiryGroup.style.display = 'flex';
} else if (type === '영구SW') {
if (keyGroup) keyGroup.style.display = 'flex';
if (typeGroup) typeGroup.style.display = 'none';
if (expiryGroup) expiryGroup.style.display = 'none'; // 영구는 유지보수 기간이 비고에 들어가는 경우가 많아 만료일 숨김 처리
}
}
}
@@ -280,11 +264,8 @@ function fillSwFormData(asset: SoftwareAsset) {
setFieldValue('sw-연결카드번호', (asset as any). || '');
setFieldValue('sw-결제일', (asset as any). || '');
setFieldValue('sw-당월청구액', (asset as any). || '');
} else if (asset.type === '구독SW') {
setFieldValue('sw-라이선스유형', (asset as any). || '');
} else if (asset.type === '구독SW' || asset.type === '영구SW') {
setFieldValue('sw-만료일', (asset as any). || '');
} else {
setFieldValue('sw-라이선스키', (asset as any). || '');
}
renderSwHistory(asset.id);
@@ -399,12 +380,9 @@ export function initSwModal(onSave: () => void, closeModals: () => void) {
updated. = getFieldValue('sw-결제수단');
updated. = getFieldValue('sw-연결카드번호');
updated. = getFieldValue('sw-결제일');
updated. = getFieldValue('sw-당월청구액');
} else if (type === '구독SW') {
updated. = getFieldValue('sw-라이선스유형');
updated. = getFieldValue('sw-당월청구액').replace(/,/g, '');
} else if (type === '구독SW' || type === '영구SW') {
updated. = getFieldValue('sw-만료일');
} else {
updated. = getFieldValue('sw-라이선스키');
}
// 데이터 저장 로직 (state 업데이트)
@@ -464,7 +442,6 @@ export function initSwModal(onSave: () => void, closeModals: () => void) {
return;
}
const isSub = getFieldValue('sw-asset-type') === '구독SW';
subModal.classList.remove('hidden');
(document.getElementById('sw-update-date') as HTMLInputElement).value = new Date().toISOString().substring(0, 10);
@@ -473,14 +450,8 @@ export function initSwModal(onSave: () => void, closeModals: () => void) {
(document.getElementById('sw-update-cost') as HTMLInputElement).value = '';
(document.getElementById('sw-update-note') as HTMLInputElement).value = '';
if (isSub) {
document.querySelector('.sub-sw-update')!.setAttribute('style', 'display:flex; flex-direction:column;');
document.querySelector('.perm-sw-update')!.setAttribute('style', 'display:none');
} else {
document.querySelector('.sub-sw-update')!.setAttribute('style', 'display:none');
document.querySelector('.perm-sw-update')!.setAttribute('style', 'display:flex; flex-direction:column;');
(document.getElementById('sw-update-maintenance') as HTMLInputElement).checked = (document.getElementById('sw-유지보수여부') as HTMLInputElement).checked;
}
document.querySelector('.sub-sw-update')!.setAttribute('style', 'display:flex; flex-direction:column;');
document.querySelector('.perm-sw-update')!.setAttribute('style', 'display:none');
});
btnSaveUpdate?.addEventListener('click', (e) => {
@@ -495,21 +466,22 @@ export function initSwModal(onSave: () => void, closeModals: () => void) {
const periodStr = (start || end) ? `${start || ''} ~ ${end || ''}` : '';
let details = `[업데이트] ${note || (isSub ? '구독 갱신' : '유지보수 갱신')}\n`;
let details = `[업데이트] ${note || '계약 갱신'}\n`;
if (cost) details += `비용 추가: ${cost}\n`;
if (isSub) {
if (periodStr) details += `계약 변경: -> ${periodStr}\n`;
// 메인 폼에 시작일 만료일 자동 세팅
if (start) setFieldValue('sw-시작일', start);
if (end) setFieldValue('sw-만료일', end);
} else {
details += `유지보수 상태: -> ${maintenance ? '유효' : '만료'}\n`;
(document.getElementById('sw-유지보수여부') as HTMLInputElement).checked = maintenance;
}
if (periodStr) details += `계약 변경: -> ${periodStr}\n`;
// 메인 폼에 시작일 만료일 자동 세팅
if (start) setFieldValue('sw-시작일', start);
if (end) setFieldValue('sw-만료일', end);
// 금액 갱신 (선택사항)
if (cost) setFieldValue('sw-금액', cost);
if (cost) {
if (getFieldValue('sw-asset-type') === '클라우드') {
setFieldValue('sw-당월청구액', cost);
} else {
setFieldValue('sw-금액', cost);
}
}
// 이력 탭 갱신 (메모리상)
if (!state.masterData.logs) state.masterData.logs = [];
@@ -518,11 +490,13 @@ export function initSwModal(onSave: () => void, closeModals: () => void) {
assetId: currentSwAsset ? currentSwAsset.id : 'NEW',
date,
details,
cost: cost ? Number(String(cost).replace(/,/g, '')) : 0,
user: '관리자'
});
closeUpdateModal();
renderSwHistory(currentSwAsset ? currentSwAsset.id : '');
onSave(); // 로그 즉시 저장
});
}

View File

@@ -28,6 +28,7 @@ const SW_USER_MODAL_HTML = `
<thead>
<tr>
<th>조직</th>
<th>부서</th>
<th>직위</th>
<th>이름</th>
<th>사용기간</th>
@@ -58,7 +59,11 @@ const SW_USER_MODAL_HTML = `
<input type="hidden" id="edit-user-index" value="-1" />
<div class="form-group">
<label>조직</label>
<select id="new-user-부서">${generateOptionsHTML(ORG_LIST)}</select>
<select id="new-user-조직">${generateOptionsHTML(ORG_LIST)}</select>
</div>
<div class="form-group">
<label>부서</label>
<input type="text" id="new-user-부서" />
</div>
<div class="form-group">
<label>직위</label>
@@ -109,7 +114,7 @@ export function openSwUserModal(asset: SoftwareAsset) {
const swInfo = document.getElementById('sw-user-sw-info')!;
swInfo.innerHTML = `
<div style="background:var(--bg-light); padding:1rem; border-radius:6px; margin-bottom:1.5rem;">
<div style="font-size:0.8rem; color:var(--text-muted); margin-bottom:0.25rem;">${asset.} | ${asset.}</div>
<div style="font-size:0.8rem; color:var(--text-muted); margin-bottom:0.25rem;">${asset.}</div>
<div style="font-size:1.1rem; font-weight:700; color:var(--primary-color);">${asset.}</div>
</div>
`;
@@ -117,7 +122,7 @@ export function openSwUserModal(asset: SoftwareAsset) {
// 기존 사용자 데이터 복사 (원본 보호를 위해 temp 사용)
const existingMapping = state.masterData.swUsers.find(u => u.sw_id === asset.id);
tempSwUsers = existingMapping ? (existingMapping.userData || []).map((u: any) => ({
법인: u[0], 부서: u[1], 직위: u[2], 이름: u[3], 사용기간: u[4], 신청서명: u[5]
조직: u[0], 부서: u[1], 직위: u[2], 이름: u[3], 사용기간: u[4], 신청서명: u[5]
})) : [];
renderUserList();
@@ -137,6 +142,7 @@ function renderUserList() {
tempSwUsers.forEach((user, idx) => {
const tr = document.createElement('tr');
tr.innerHTML = `
<td>${user. || ''}</td>
<td>${user. || ''}</td>
<td>${user. || ''}</td>
<td>${user. || ''}</td>
@@ -181,6 +187,7 @@ function openUserEditSubModal(idx: number = -1) {
if (idx > -1) {
const user = tempSwUsers[idx];
setFieldValue('new-user-조직', user.);
setFieldValue('new-user-부서', user.);
setFieldValue('new-user-직위', user.);
setFieldValue('new-user-이름', user.);
@@ -227,7 +234,7 @@ export function initSwUserModal(onSave: () => void, closeModals: () => void) {
const existingIdx = state.masterData.swUsers.findIndex(u => u.sw_id === currentSwUserAsset!.id);
const newMapping = {
sw_id: currentSwUserAsset!.id,
userData: tempSwUsers.map(u => ['', u., u., u., u., u.])
userData: tempSwUsers.map(u => [u., u., u., u., u., u.])
};
if (existingIdx > -1) state.masterData.swUsers[existingIdx] = newMapping as any;
@@ -257,6 +264,7 @@ function saveUserDataToList() {
const = Input.files && Input.files.length > 0 ? Input.files[0].name : (idx > -1 ? tempSwUsers[idx]. : '');
const userData: any = {
조직: getFieldValue('new-user-조직'),
부서: getFieldValue('new-user-부서'),
직위: getFieldValue('new-user-직위'),
이름: getFieldValue('new-user-이름'),

View File

@@ -29,5 +29,6 @@ export const TYPE_PREFIX_MAP: Record<string, string> = {
'서버': 'SVR', 'PC': 'PC', 'NAS': 'NAS', 'DAS': 'DAS', '스토리지': 'STO',
'CPU': 'CPU', 'HDD': 'HDD', 'RAM': 'RAM', 'GPU': 'GPU',
'모바일': 'MOB', '노트북': 'PC', '태블릿': 'TAB',
'개인PC': 'PC', '모바일기기': 'MOB'
'개인PC': 'PC', '모바일기기': 'MOB',
'구독SW': 'SSW', '영구SW': 'PSW'
};

View File

@@ -0,0 +1,309 @@
import { openModal, closeModals } from './BaseModal';
import { createIcons, X, Check, Database, Save, FileSpreadsheet, Layers, RefreshCcw } from 'lucide';
import { state, loadMasterDataFromDB } from '../../core/state';
import { TYPE_PREFIX_MAP } from './SharedData';
let parsedData: any = null;
let currentTab: string = '';
let onSuccessCallback: (() => void) | null = null;
const UPLOAD_PREVIEW_MODAL_HTML = `
<div id="upload-preview-modal" class="modal-overlay hidden">
<div class="modal-content wide" style="width: 90vw; max-width: 1400px; height: 85vh; display: flex; flex-direction: column;">
<div class="modal-header">
<div style="display:flex; align-items:center; gap:0.75rem;">
<div style="background:var(--primary-light); padding:0.5rem; border-radius:8px;">
<i data-lucide="file-spreadsheet" style="width:20px; height:20px; color:var(--primary-color);"></i>
</div>
<div>
<h2 id="upload-preview-title">데이터 업로드 검토</h2>
<p style="font-size:12px; color:var(--text-muted); margin-top:2px;">업로드 전 데이터를 확인하고 수정 사항이 있는지 검토하세요.</p>
</div>
</div>
<button id="btn-close-upload-preview" class="btn-icon"><i data-lucide="x"></i></button>
</div>
<div class="modal-body" style="display:flex; padding:0; overflow:hidden; flex: 1;">
<!-- Sidebar for Tabs -->
<div id="upload-tab-sidebar" style="width:240px; border-right:1px solid var(--border-color); background:#fafafa; padding:1.5rem 1rem; overflow-y:auto; flex-shrink: 0;">
<div style="font-size:11px; font-weight:700; color:var(--text-muted); text-transform:uppercase; margin-bottom:1rem; letter-spacing:0.05em;">데이터 카테고리</div>
<div id="upload-tabs-container" style="display:flex; flex-direction:column; gap:0.5rem;">
<!-- Tabs will be injected here -->
</div>
</div>
<!-- Content Area -->
<div style="flex:1; display:flex; flex-direction:column; background:white; overflow:hidden;">
<div id="upload-preview-stats" style="padding:1rem 1.5rem; border-bottom:1px solid var(--border-color); display:flex; justify-content:space-between; align-items:center; background:white;">
<div style="display:flex; align-items:center; gap:0.5rem;">
<span id="current-tab-name" style="font-weight:700; font-size:16px;">선택된 탭 없음</span>
<span id="current-tab-count" class="badge badge-primary">0건</span>
<button id="btn-bulk-generate-codes" class="btn btn-outline btn-sm hidden" style="margin-left:1rem; height:28px; font-size:12px; padding:0 0.75rem;">
<i data-lucide="refresh-ccw" style="width:14px; height:14px; margin-right:4px;"></i> 자산코드 일괄 생성
</button>
</div>
<div style="font-size:12px; color:var(--text-muted);">
* 아래 데이터가 신규로 추가되거나 기존 데이터가 갱신됩니다.
</div>
</div>
<div id="upload-preview-table-wrapper" style="flex:1; overflow:auto; padding:0;">
<!-- Table will be injected here -->
</div>
</div>
</div>
<div class="modal-footer" style="background:#f9fafb; border-top:1px solid var(--border-color); flex-shrink: 0;">
<div style="display:flex; gap:0.75rem; width:100%; justify-content:flex-end;">
<button id="btn-cancel-upload" class="btn btn-outline" style="height:40px; padding:0 1.5rem;">취소하기</button>
<button id="btn-confirm-upload" class="btn btn-primary" style="height:40px; padding:0 2rem;">
<i data-lucide="save"></i> 최종 데이터 저장하기
</button>
</div>
</div>
</div>
</div>
`;
export function initUploadPreviewModal(onSuccess?: () => void) {
if (onSuccess) onSuccessCallback = onSuccess;
if (!document.getElementById('upload-preview-modal')) {
document.body.insertAdjacentHTML('beforeend', UPLOAD_PREVIEW_MODAL_HTML);
}
document.getElementById('btn-close-upload-preview')?.addEventListener('click', closeModals);
document.getElementById('btn-cancel-upload')?.addEventListener('click', closeModals);
document.getElementById('btn-confirm-upload')?.addEventListener('click', () => {
confirmUpload();
});
document.getElementById('btn-bulk-generate-codes')?.addEventListener('click', () => {
generateBulkCodes();
});
}
export function openUploadPreview(data: any) {
parsedData = data;
const tabNames = Object.keys(data);
if (tabNames.length === 0) {
alert('업로드할 데이터가 없습니다.');
return;
}
currentTab = tabNames[0];
renderTabs();
renderCurrentTable();
openModal('upload-preview-modal');
createIcons({ icons: { X, Check, Database, Save, FileSpreadsheet, Layers, RefreshCcw } });
}
function renderTabs() {
const container = document.getElementById('upload-tabs-container');
if (!container) return;
container.innerHTML = '';
Object.keys(parsedData).forEach(tab => {
const btn = document.createElement('div');
btn.className = `upload-tab-btn ${tab === currentTab ? 'active' : ''}`;
btn.style.cssText = `
padding: 0.75rem 1rem;
border-radius: 8px;
cursor: pointer;
font-size: 13px;
font-weight: 500;
display: flex;
justify-content: space-between;
align-items: center;
transition: all 0.2s;
background: ${tab === currentTab ? 'white' : 'transparent'};
color: ${tab === currentTab ? 'var(--primary-color)' : 'var(--text-main)'};
box-shadow: ${tab === currentTab ? '0 2px 4px rgba(0,0,0,0.05)' : 'none'};
border: 1px solid ${tab === currentTab ? 'var(--border-color)' : 'transparent'};
`;
btn.innerHTML = `
<span>${tab}</span>
<span style="font-size:11px; opacity:0.6;">${parsedData[tab].length}</span>
`;
btn.onclick = () => {
currentTab = tab;
renderTabs();
renderCurrentTable();
};
container.appendChild(btn);
});
}
function renderCurrentTable() {
const tableWrapper = document.getElementById('upload-preview-table-wrapper');
const tabNameEl = document.getElementById('current-tab-name');
const tabCountEl = document.getElementById('current-tab-count');
if (!tableWrapper || !tabNameEl || !tabCountEl) return;
const data = parsedData[currentTab];
tabNameEl.textContent = currentTab;
tabCountEl.textContent = `${data.length}`;
const generateBtn = document.getElementById('btn-bulk-generate-codes');
const isHwTab = ['개인PC', '서버', '스토리지', '전산비품', '모바일기기'].includes(currentTab);
if (generateBtn) {
if (isHwTab) generateBtn.classList.remove('hidden');
else generateBtn.classList.add('hidden');
}
if (!data || data.length === 0) {
tableWrapper.innerHTML = '<div style="padding:4rem; text-align:center; color:var(--text-muted);">표시할 데이터가 없습니다.</div>';
return;
}
// Get headers from first item keys, excluding 'id' and 'type' for cleaner view
const headers = Object.keys(data[0]).filter(k => k !== 'id' && k !== 'type');
let tableHTML = `
<table class="preview-table" style="width:100%; border-collapse:collapse; min-width:max-content;">
<thead style="position:sticky; top:0; z-index:10; background:#f8fafc; box-shadow:0 1px 0 var(--border-color);">
<tr>
<th style="padding:0.75rem 1rem; text-align:center; font-size:12px; border-bottom:1px solid var(--border-color); width:50px;">No.</th>
${headers.map(h => `<th style="padding:0.75rem 1rem; text-align:left; font-size:12px; border-bottom:1px solid var(--border-color); color:var(--text-muted);">${h}</th>`).join('')}
</tr>
</thead>
<tbody>
${data.map((row: any, idx: number) => `
<tr style="border-bottom:1px solid #f1f5f9;">
<td style="padding:0.75rem 1rem; text-align:center; font-size:13px; color:var(--text-muted);">${idx + 1}</td>
${headers.map(h => `<td style="padding:0.75rem 1rem; font-size:13px;">${row[h] || '-'}</td>`).join('')}
</tr>
`).join('')}
</tbody>
</table>
`;
tableWrapper.innerHTML = tableHTML;
}
async function confirmUpload() {
const confirmBtn = document.getElementById('btn-confirm-upload') as HTMLButtonElement;
if (confirmBtn) {
confirmBtn.disabled = true;
confirmBtn.innerHTML = '<i data-lucide="loader-2" class="animate-spin"></i> 저장 중...';
createIcons({ icons: { Save } });
}
try {
const tabNames = Object.keys(parsedData);
let successCount = 0;
for (const tab of tabNames) {
const data = parsedData[tab];
let endpoint = '';
const API_BASE = `http://${location.hostname}:3000`;
if (tab === '개인PC') endpoint = `${API_BASE}/api/pc/batch`;
else if (tab === '서버') endpoint = `${API_BASE}/api/server/batch`;
else if (tab === '스토리지') endpoint = `${API_BASE}/api/storage/batch`;
else if (tab === '전산비품') endpoint = `${API_BASE}/api/equip/batch`;
else if (tab === '모바일기기') endpoint = `${API_BASE}/api/mobile/batch`;
else if (tab === '구독SW') endpoint = `${API_BASE}/api/sw/sub/batch`;
else if (tab === '영구SW') endpoint = `${API_BASE}/api/sw/perm/batch`;
else if (tab === '클라우드') endpoint = `${API_BASE}/api/cloud/batch`;
else if (tab === '도메인') endpoint = `${API_BASE}/api/ops/domain/batch`;
if (endpoint) {
try {
const response = await fetch(endpoint, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(data)
});
if (response.ok) {
successCount++;
} else {
const errRes = await response.json();
throw new Error(`[${tab}] ${errRes.error || '저장 실패'}`);
}
} catch (e: any) {
alert(`카테고리 '${tab}' 저장 중 오류: ${e.message}`);
throw e; // Stop processing further tabs
}
}
}
if (successCount > 0) {
if (onSuccessCallback) onSuccessCallback();
closeModals();
alert(`${successCount}개 카테고리의 데이터가 성공적으로 업로드되었습니다.`);
} else {
alert('데이터 업로드에 실패했습니다.');
}
} catch (err) {
console.error(err);
// 상세 에러는 내부 catch에서 이미 alert으로 띄움
} finally {
if (confirmBtn) {
confirmBtn.disabled = false;
confirmBtn.innerHTML = '<i data-lucide="save"></i> 최종 데이터 저장하기';
createIcons({ icons: { Save } });
}
}
}
async function generateBulkCodes() {
const data = parsedData[currentTab];
if (!data) return;
const generateBtn = document.getElementById('btn-bulk-generate-codes') as HTMLButtonElement;
if (generateBtn) {
generateBtn.disabled = true;
generateBtn.innerHTML = '<i data-lucide="refresh-ccw" class="animate-spin"></i> 생성 중...';
createIcons({ icons: { RefreshCcw } });
}
try {
// Group rows by prefix (type + purchase_ym)
const rowsToProcess = data.filter((r: any) => !r.);
if (rowsToProcess.length === 0) {
alert('이미 모든 항목에 자산코드가 부여되어 있습니다.');
return;
}
const groups: Record<string, any[]> = {};
rowsToProcess.forEach((r: any) => {
const type = r. || r. || r.type || 'ETC';
const typeCode = TYPE_PREFIX_MAP[type] || 'ETC';
const purchaseYM = String(r. || '').replace(/[^0-9]/g, '');
if (purchaseYM.length < 6) {
// Fallback or skip
return;
}
const prefix = `${typeCode}-${purchaseYM.substring(0, 6)}-`;
if (!groups[prefix]) groups[prefix] = [];
groups[prefix].push(r);
});
for (const prefix in groups) {
const rows = groups[prefix];
// Fetch current next code for this prefix
const res = await fetch(`http://${location.hostname}:3000/api/generate-asset-code?prefix=${prefix}`);
const result = await res.json();
if (result.nextCode) {
let baseNum = parseInt(result.nextCode.replace(prefix, ''));
rows.forEach((r, idx) => {
r. = `${prefix}${(baseNum + idx).toString().padStart(4, '0')}`;
});
}
}
renderCurrentTable();
alert(`${rowsToProcess.length}건의 자산코드가 생성되었습니다.`);
} catch (err) {
console.error(err);
alert('자산코드 생성 중 오류가 발생했습니다.');
} finally {
if (generateBtn) {
generateBtn.disabled = false;
generateBtn.innerHTML = '<i data-lucide="refresh-ccw"></i> 자산코드 일괄 생성';
createIcons({ icons: { RefreshCcw } });
}
}
}

View File

@@ -3,15 +3,15 @@ import { state } from '../core/state';
const MENU_CONFIG = {
hw: {
label: '하드웨어',
tabs: ['대시보드', '개인PC', '서버', '스토리지', '전산비품', '모바일기기']
tabs: ['대시보드', '서버', '개인PC', '모바일기기', '스토리지', '전산비품']
},
sw: {
label: '소프트웨어',
tabs: ['대시보드', '구독SW', '영구SW', '클라우드']
tabs: ['대시보드', '구독SW', '영구SW']
},
ops: {
label: '운영 서비스',
tabs: ['대시보드', '서비스현황', '백업관리', '보안점검']
tabs: ['도메인', '메일', '메신저', '청구비용']
}
};
@@ -22,6 +22,7 @@ export function renderNavigation(onTabChange: (tab: string) => void) {
const render = () => {
navContainer.innerHTML = '';
// 기존 메뉴 렌더링
(Object.keys(MENU_CONFIG) as Array<keyof typeof MENU_CONFIG>).forEach(catKey => {
const config = MENU_CONFIG[catKey];
const isActive = state.activeCategory === catKey;
@@ -29,7 +30,6 @@ export function renderNavigation(onTabChange: (tab: string) => void) {
const group = document.createElement('div');
group.className = `nav-group ${isActive ? 'active is-showing-shelf' : ''}`;
// 메인 카테고리 트리거
const trigger = document.createElement('div');
trigger.className = 'gnb-trigger';
trigger.textContent = config.label;
@@ -45,7 +45,6 @@ export function renderNavigation(onTabChange: (tab: string) => void) {
});
group.appendChild(trigger);
// 하위 탭 선반 (Shelf)
const shelf = document.createElement('div');
shelf.className = 'lnb-shelf';
@@ -58,21 +57,34 @@ export function renderNavigation(onTabChange: (tab: string) => void) {
e.stopPropagation();
state.activeCategory = catKey;
state.activeSubTab = tab;
if (btnAddAsset) {
btnAddAsset.classList.remove('hidden');
}
if (btnAddAsset) btnAddAsset.classList.remove('hidden');
render();
onTabChange(tab);
});
shelf.appendChild(item);
});
group.appendChild(shelf);
// 마우스 오버 시 다른 그룹의 선반은 가리고 내 것만 보여주는 스타일은 CSS에서 처리함
navContainer.appendChild(group);
});
// ─── '관리자' 메뉴 별도 추가 (GNB 스타일) ───
const adminGroup = document.createElement('div');
adminGroup.className = 'nav-group';
const adminTrigger = document.createElement('div');
adminTrigger.className = 'gnb-trigger';
adminTrigger.innerHTML = '관리자';
adminTrigger.style.color = 'var(--text-muted)';
adminTrigger.style.borderLeft = '1px solid var(--border-color)';
adminTrigger.style.marginLeft = '1rem';
adminTrigger.style.paddingLeft = '1.5rem';
adminTrigger.addEventListener('click', () => {
alert('준비중입니다.');
});
adminGroup.appendChild(adminTrigger);
navContainer.appendChild(adminGroup);
};
render();

View File

@@ -1,74 +1,37 @@
import * as XLSX from 'xlsx';
export interface HardwareAsset {
[key: string]: any;
id: string;
type: string; // '개인PC', '서버', '스토리지', '전산비품', '모바일기기'
type: string;
법인: string;
자산코드: string;
명칭: string;
위치: string;
관리자: string;
IP주소: string;
IP2?: string;
MACaddress: string;
HW사양: string;
OS: string;
사용자?: string;
CPU?: string;
GPU?: string;
RAM?: string;
SSD1?: string;
SSD2?: string;
HDD1?: string;
HDD2?: string;
storage유형?: string;
비품유형?: string;
모델명?: string;
용량?: string;
담당자_정?: string;
담당자_부?: string;
구매일?: string;
금액?: string;
납품업체: string;
품의서명: string;
용도?: string;
상세?: string;
원격접속?: string;
서버ID?: string;
서버PW?: string;
모니터링?: string;
비고?: string;
현사용조직?: string;
이전사용조직?: string;
detail_purpose?: string;
메인보드?: string;
}
export interface SoftwareAsset {
[key: string]: any;
id: string;
type: string; // '구독SW', '영구SW', '클라우드'
type: string;
분야?: string;
법인: string;
부서?: string;
제품명: string;
구매일: string;
구독일?: string;
만료일?: string;
라이선스유형?: string;
라이선스키?: string;
유지보수여부?: boolean;
금액: string;
수량: number;
계정명: string;
납품업체: string;
비고: string;
자산번호?: string;
플랫폼명?: string;
결제수단?: string;
결제일?: string;
연결카드번호?: string;
당월청구액?: string;
시작일?: string;
}
export interface SWUser {
@@ -90,6 +53,7 @@ export interface HardwareLog {
date: string;
details: string;
user: string;
cost?: number;
}
export interface MasterAssetData {
@@ -101,24 +65,24 @@ export interface MasterAssetData {
subSw: SoftwareAsset[];
permSw: SoftwareAsset[];
cloud: SoftwareAsset[];
domain?: any[];
hw: HardwareAsset[];
sw: SoftwareAsset[];
swUsers: SWUser[];
logs: HardwareLog[];
sw: SoftwareAsset[];
hw: HardwareAsset[];
}
const HW_TABS = ['개인PC', '서버', '스토리지', '전산비품', '모바일기기'];
const SW_TABS = ['구독SW', '영구SW', '클라우드'];
const PC_HEADERS = ['법인', '자산코드', '구매연월', '사용자', '현사용조직', '이전사용조직', '위치', '담당자(정)', '담당자(부)', '모델명', 'OS', 'CPU', 'GPU', 'RAM', 'SSD1', 'SSD2', 'SSD3', '메인보드', 'IP주소', '금액', '납품업체', '품의서명', '비고'];
const SERVER_HEADERS = ['법인', '자산코드', '구매연월', '유형', '용도', '상세내용', '현사용조직', '이전사용조직', '위치', '담당자(정)', '담당자(부)', 'IP 주소 1', 'IP 주소 2', '원격도구', '서버 ID', '서버 PW', '모델명', 'OS', 'CPU', 'RAM', 'GPU', 'Storage 1', 'Storage 2', 'Storage 3', '모니터링', '금액', '납품업체', '품의서명', '비고'];
const STORAGE_HEADERS = ['법인', '유형', '자산코드', '명칭', '위치', '모델명', '용량', '담당자(정)', '담당자(부)', 'IP주소', 'MAC주소', '구매연월', '금액', '납품업체', '품의서명', '비고'];
const EQUIP_HEADERS = ['법인', '비품유형', '자산코드', '명칭', '위치', '관리자', 'IP주소', 'MACaddress', 'HW사양', 'OS', '구매연월', '금액', '납품업체', '품의서명', '비고'];
const MOBILE_HEADERS = ['법인', '자산코드', '명칭', '위치', '관리자', '기기유형', 'OS', '구매연월', '금액', '납품업체', '품의서명', '비고'];
const PC_HEADERS = ['법인', '자산코드', '사용자', '위치', '모델명', '메인보드', 'CPU', 'GPU', 'RAM', 'SSD1', 'SSD2', 'HDD1', 'HDD2', 'IP주소', 'HW사양', '구매일', '금액', '납품업체', '품의서명', '비고'];
const SERVER_HEADERS = ['구매법인', '자산번호', '구매일자', '유형', '용도', '상세내용', '현사용조직', '이전사용조직', '설치위치', '담당자(정)', '담당자(부)', 'IP 주소 1', 'IP 주소 2', '원격도구', '서버 ID', '서버 PW', '모델명', 'OS', 'CPU', 'RAM', 'GPU', 'Storage 1', 'Storage 2', 'Storage 3', '모니터링', '비고'];
const STORAGE_HEADERS = ['구매법인', '유형', '자산코드', '명칭', '위치', '모델명', '용량', '담당자(정)', '담당자(부)', 'IP주소', 'MAC주소', '구매일', '금액', '납품업체', '품의서명', '비고'];
const EQUIP_HEADERS = ['구매법인', '비품유형', '자산코드', '명칭', '위치', '관리자', 'IP주소', 'MACaddress', 'HW사양', 'OS', '구매일', '금액', '납품업체', '품의서명', '비고'];
const MOBILE_HEADERS = ['구매법인', '자산코드', '명칭', '위치', '관리자', '기기유형', 'OS', '구매일', '금액', '납품업체', '품의서명', '비고'];
const SUB_SW_HEADERS = ['분야', '법인', '제품명', '부서', '수량', '금액', '구매일', '납품업체', '시작일', '만료일', '라이선스유형', '계정명', '비고'];
const PERM_SW_HEADERS = ['분야', '법인', '제품명', '부서', '수량', '금액', '구매일', '납품업체', '시작일', '만료일', '라이선스키', '계정명', '비고'];
const CLOUD_HEADERS = ['플랫폼명', '법인', '제품명', '부서', '계정명', '결제수단', '결제일', '연결카드번호', '당월청구액', '비고'];
const SUB_SW_HEADERS = ['ID', '분야', '법인', '서', '제품명', '구매일', '만료일', '라이선스유형', '금액', '수량', '계정명', '납품업체', '비고'];
const PERM_SW_HEADERS = ['ID', '분야', '법인', '부서', '제품명', '구매일', '라이선스키', '금액', '수량', '계정명', '납품업체', '비고'];
const CLOUD_HEADERS = ['ID', '플랫폼명', '법인', '부서', '사용용도(제품명)', '계정명', '결제수단', '결제일', '연결카드번호', '당월청구액', '비고'];
const DOMAIN_HEADERS = ['유형', '법인', '서비스명', '관리도메인', '시작일', '만료일', '금액', '담당자', '담당자(부)', '비고'];
export function downloadTemplate() {
const wb = XLSX.utils.book_new();
@@ -127,72 +91,120 @@ export function downloadTemplate() {
{ name: '서버', headers: SERVER_HEADERS },
{ name: '스토리지', headers: STORAGE_HEADERS },
{ name: '전산비품', headers: EQUIP_HEADERS },
{ name: '모바일기기', headers: MOBILE_HEADERS }
{ name: '모바일기기', headers: MOBILE_HEADERS },
{ name: '구독SW', headers: SUB_SW_HEADERS },
{ name: '영구SW', headers: PERM_SW_HEADERS },
{ name: '클라우드', headers: CLOUD_HEADERS },
{ name: '도메인', headers: DOMAIN_HEADERS }
];
const sampleData: Record<string, any[]> = {
'개인PC': ['(주)에이치엠', 'PC-24001', '202401', '홍길동', '기술팀', '-', '서울본사 7층', '김관리', '이부관', 'LG Gram 16', 'Windows 11', 'i7-1360P', 'RTX 3050', '16GB', '512GB', '-', '-', 'LG Mainboard', '192.168.0.10', '1500000', 'LG전자', '2024_상반기_PC구매.pdf', '신규 입사자 지급용'],
'서버': ['(주)에이치엠', 'SRV-24001', '202401', '물리', '웹서버', '운영 웹 서버', '인프라팀', '-', 'IDC 센터 1-A', '박서버', '최백업', '10.0.0.1', '10.0.0.2', 'RDP', 'admin', '********', 'Dell PowerEdge R750', 'Ubuntu 22.04', 'Xeon Gold 6330', '128GB', '-', '1TB SSD', '1TB SSD', '2TB HDD', 'Zabbix', '8500000', '델테크놀로지스', '2024_IDC_확장품의.pdf', '운영 환경 전용'],
'도메인': ['도메인', '(주)에이치엠', '대표홈페이지', 'hm-corp.com', '2024-01-01', '2025-01-01', '55000', '홍길동', '이부관', '가비아 자동갱신']
};
tabConfigs.forEach(config => {
const ws = XLSX.utils.aoa_to_sheet([config.headers]);
ws['!cols'] = Array(config.headers.length).fill({ wch: 18 });
const data = [config.headers];
if (sampleData[config.name]) {
data.push(sampleData[config.name]);
}
const ws = XLSX.utils.aoa_to_sheet(data);
ws['!cols'] = Array(config.headers.length).fill({ wch: 20 });
XLSX.utils.book_append_sheet(wb, ws, config.name);
});
SW_TABS.forEach(tab => {
let hd = tab === '구독SW' ? SUB_SW_HEADERS : (tab === '클라우드' ? CLOUD_HEADERS : PERM_SW_HEADERS);
const ws = XLSX.utils.aoa_to_sheet([hd]);
ws['!cols'] = Array(hd.length).fill({ wch: 18 });
XLSX.utils.book_append_sheet(wb, ws, tab);
});
XLSX.writeFile(wb, 'itam_assets_template_full.xlsx');
XLSX.writeFile(wb, 'itam_assets_template.xlsx');
}
export function exportToExcel(masterData: MasterAssetData) {
const wb = XLSX.utils.book_new();
const exportMap = [
{ tab: '개인PC', list: masterData.pc, headers: PC_HEADERS, map: (a: any) => [a., a., a., a., a., a., a.CPU, a.GPU, a.RAM, a.SSD1, a.SSD2, a.HDD1, a.HDD2, a.IP주소, a.HW사양, a., a., a., a., a.] },
{ tab: '서버', list: masterData.server, headers: SERVER_HEADERS, map: (a: any) => [a., a., a., a.storage유형 || '물리', a., a., a., a., a., a._정, a._부, a.IP주소, a.IP2, a., a.ID, a.PW, a., a.OS, a.CPU, a.RAM, a.GPU, a.SSD1, a.SSD2, a.HDD1, a., a.] },
{ tab: '스토리지', list: masterData.storage, headers: STORAGE_HEADERS, map: (a: any) => [a., a.storage유형, a., a., a., a., a., a._정, a._부, a.IP주소, a.MACaddress, a., a., a., a., a.] },
{ tab: '전산비품', list: masterData.equip, headers: EQUIP_HEADERS, map: (a: any) => [a., a., a., a., a., a., a.IP주소, a.MACaddress, a.HW사양, a.OS, a., a., a., a., a.] },
{ tab: '모바일기기', list: masterData.mobile, headers: MOBILE_HEADERS, map: (a: any) => [a., a., a., a., a., a.type, a.OS, a., a., a., a., a.] },
{ tab: '구독SW', list: masterData.subSw, headers: SUB_SW_HEADERS, map: (a: any) => [a.id, a., a., a., a., a., a., a., a., a., a., a., a.] },
{ tab: '영구SW', list: masterData.permSw, headers: PERM_SW_HEADERS, map: (a: any) => [a.id, a., a., a., a., a., a., a., a., a., a., a.] }
{ tab: '개인PC', list: masterData.pc, headers: PC_HEADERS, map: (a: any) => [a., a., a., a., a., a., a., a._정, a._부, a., a.OS, a.CPU, a.GPU, a.RAM, a.SSD1, a.SSD2, a.SSD3, a., a.IP주소, a., a., a., a.] },
{ tab: '서버', list: masterData.server, headers: SERVER_HEADERS, map: (a: any) => [a., a., a., a.type, a., a., a., a., a., a._정, a._부, a.IP주소, a.IP2, a., a.ID, a.PW, a., a.OS, a.CPU, a.RAM, a.GPU, a.SSD1, a.SSD2, a.SSD3, a., a., a., a., a.] },
{ tab: '스토리지', list: masterData.storage, headers: STORAGE_HEADERS, map: (a: any) => [a., a., a., a., a., a., a., a._정, a._부, a.IP주소, a.MACaddress, a., a., a., a., a.] },
{ tab: '전산비품', list: masterData.equip, headers: EQUIP_HEADERS, map: (a: any) => [a., a., a., a., a., a., a.IP주소, a.MACaddress, a.HW사양, a.OS, a., a., a., a., a.] },
{ tab: '모바일기기', list: masterData.mobile, headers: MOBILE_HEADERS, map: (a: any) => [a., a., a., a., a., a., a.OS, a., a., a., a., a.] },
{ tab: '구독SW', list: masterData.subSw, headers: SUB_SW_HEADERS, map: (a: any) => [a., a., a., a., a., a., a., a., a., a., a., a., a.] },
{ tab: '영구SW', list: masterData.permSw, headers: PERM_SW_HEADERS, map: (a: any) => [a., a., a., a., a., a., a., a., a., a., a., a., a.] },
{ tab: '클라우드', list: masterData.cloud, headers: CLOUD_HEADERS, map: (a: any) => [a., a., a., a., a., a., a., a., a., a.] },
{ tab: '도메인', list: masterData.domain || [], headers: DOMAIN_HEADERS, map: (a: any) => [a.type, a.corp, a.service_name, a.domain_name, a.start_date, a.expiry_date, a.price, a.manager_main, a.manager_sub, a.remarks] }
];
exportMap.forEach(m => {
const ws = XLSX.utils.aoa_to_sheet([m.headers, ...m.list.map(m.map)]);
XLSX.utils.book_append_sheet(wb, ws, m.tab);
});
XLSX.writeFile(wb, `itam_master_full_${new Date().toISOString().split('T')[0]}.xlsx`);
XLSX.writeFile(wb, `itam_master_${new Date().toISOString().split('T')[0]}.xlsx`);
}
export async function parseExcel(file: File): Promise<MasterAssetData> {
/**
* 엑셀 날짜 데이터(숫자 또는 문자열)를 YYYY-MM-DD 형식의 문자열로 변환
*/
export function formatExcelDate(val: any): string {
if (!val) return '';
if (typeof val === 'number') {
// 엑셀 날짜 숫자 (1899-12-30 기준 일수)
const date = new Date(Math.round((val - 25569) * 86400 * 1000));
return date.toISOString().split('T')[0];
}
// 이미 문자열인 경우 기호 통일 (YYYY.MM.DD -> YYYY-MM-DD)
if (typeof val === 'string') {
return val.replace(/\./g, '-').trim();
}
return val ? String(val) : '';
}
export async function parseExcel(file: File): Promise<any> {
return new Promise((resolve, reject) => {
const reader = new FileReader();
reader.onload = (e) => {
try {
const workbook = XLSX.read(e.target?.result, { type: 'binary' });
const data: MasterAssetData = { pc: [], server: [], storage: [], equip: [], mobile: [], subSw: [], permSw: [], cloud: [], swUsers: [], logs: [], sw: [], hw: [] };
workbook.SheetNames.forEach(sheetName => {
const rows = XLSX.utils.sheet_to_json(workbook.Sheets[sheetName]) as any[];
if (sheetName === '개인PC') {
rows.forEach(r => data.pc.push({ id: Math.random().toString(36).substring(2, 9), type: '개인PC', 법인: r['법인']||'', 자산코드: r['자산코드']||'', 사용자: r['사용자']||'', 위치: r['위치']||'', 모델명: r['모델명']||'', 메인보드: r['메인보드']||'', CPU: r['CPU']||'', GPU: r['GPU']||'', RAM: r['RAM']||'', SSD1: r['SSD1']||'', SSD2: r['SSD2']||'', HDD1: r['HDD1']||'', HDD2: r['HDD2']||'', IP주소: r['IP주소']||'', HW사양: r['HW사양']||'', 구매일: r['구매일']||'', 금액: r['금액']||'', 납품업체: r['납품업체']||'', 품의서명: r['품의서명']||'', 비고: r['비고']||'', : '', MACaddress: '', OS: '', : '' }));
} else if (sheetName === '서버') {
rows.forEach(r => data.server.push({ id: Math.random().toString(36).substring(2, 9), type: '서버', 법인: r['구매법인']||r['법인']||'', 자산코드: r['자산번호']||r['자산코드']||'', 구매일: r['구매일자']||r['구매일']||'', storage유형: r['유형']||'물리', 용도: r['용도']||'', 상세: r['상세내용']||'', 현사용조직: r['현사용조직']||'', 이전사용조직: r['이전사용조직']||'', 위치: r['설치위치']||r['위치']||'', 담당자_정: r['담당자(정)']||'', 담당자_부: r['담당자(부)']||'', IP주소: r['IP 주소 1']||r['IP주소']||'', IP2: r['IP 주소 2']||'', 원격접속: r['원격도구']||r['원격접속']||'', 서버ID: r['서버 ID']||r['서버ID']||'', 서버PW: r['서버 PW']||r['서버PW']||'', 모델명: r['모델명']||'', OS: r['OS']||'', CPU: r['CPU']||'', RAM: r['RAM']||'', GPU: r['GPU']||'', SSD1: r['Storage 1']||r['SSD1']||'', SSD2: r['Storage 2']||r['SSD2']||'', HDD1: r['Storage 3']||r['HDD1']||'', 모니터링: r['모니터링']||'', 비고: r['비고']||'', : '', : '', MACaddress: '', HW사양: '', : '', : '', : '' }));
} else if (sheetName === '스토리지') {
rows.forEach(r => data.storage.push({ id: Math.random().toString(36).substring(2, 9), type: '스토리지', 법인: r['구매법인']||r['법인']||'', storage유형: r['유형']||'', 자산코드: r['자산코드']||'', 명칭: r['명칭']||'', 위치: r['위치']||'', 모델명: r['모델명']||'', 용량: r['용량']||'', 담당자_정: r['담당자(정)']||'', 담당자_부: r['담당자(부)']||'', IP주소: r['IP주소']||'', MACaddress: r['MAC주소']||'', 구매일: r['구매일']||'', 금액: r['금액']||'', 납품업체: r['납품업체']||'', 품의서명: r['품의서명']||'', 비고: r['비고']||'', HW사양: '', OS: '', : '' }));
} else if (sheetName === '전산비품') {
rows.forEach(r => data.equip.push({ id: Math.random().toString(36).substring(2, 9), type: '전산비품', 법인: r['구매법인']||r['법인']||'', 비품유형: r['비품유형']||r['유형']||'', 자산코드: r['자산코드']||'', 명칭: r['명칭']||'', 위치: r['위치']||'', 관리자: r['관리자']||'', IP주소: r['IP주소']||'', MACaddress: r['MACaddress']||'', HW사양: r['HW사양']||'', OS: r['OS']||'', 구매일: r['구매일']||'', 금액: r['금액']||'', 납품업체: r['납품업체']||'', 품의서명: r['품의서명']||'', 비고: r['비고']||'' }));
} else if (sheetName === '모바일기기') {
rows.forEach(r => data.mobile.push({ id: Math.random().toString(36).substring(2, 9), type: '모바일기기', 법인: r['구매법인']||r['법인']||'', 자산코드: r['자산코드']||'', 명칭: r['명칭']||'', 위치: r['위치']||'', 관리자: r['관리자']||'', OS: r['OS']||'', 구매일: r['구매일']||'', 금액: r['금액']||'', 납품업체: r['납품업체']||'', 품의서명: r['품의서명']||'', 비고: r['비고']||'', IP주소: '', MACaddress: '', HW사양: '' }));
} else if (sheetName === '구독SW') {
rows.forEach(r => data.subSw.push({ id: r['ID']||Math.random().toString(36).substring(2, 9), type: '구독SW', 분야: r['분야']||'', 법인: r['법인']||'', 부서: r['부서']||'', 제품명: r['제품명']||'', 구매일: r['구매일']||'', 만료일: r['만료일']||'', 라이선스유형: r['라이선스유형']||'', 금액: r['금액']||'', 수량: parseInt(r['수량']||'1'), 계정명: r['계정명']||'', 납품업체: r['납품업체']||'', 비고: r['비고']||'' }));
} else if (sheetName === '영구SW') {
rows.forEach(r => data.permSw.push({ id: r['ID']||Math.random().toString(36).substring(2, 9), type: '영구SW', 분야: r['분야']||'', 법인: r['법인']||'', 부서: r['부서']||'', 제품명: r['제품명']||'', 구매일: r['구매일']||'', 라이선스키: r['라이선스키']||'', 금액: r['금액']||'', 수량: parseInt(r['수량']||'1'), 계정명: r['계정명']||'', 납품업체: r['납품업체']||'', 비고: r['비고']||'' }));
}
const workbook = XLSX.read(e.target?.result, { type: 'array' });
const parsedData: any = {};
workbook.SheetNames.forEach(rawSheetName => {
const sheetName = rawSheetName.trim();
const ws = workbook.Sheets[rawSheetName];
const rows = XLSX.utils.sheet_to_json(ws, { defval: "" }) as any[];
const list: any[] = [];
rows.forEach(rawR => {
// 헤더명에 공백이 포함된 경우 대비하여 키 정리 (trim)
const r: any = {};
Object.keys(rawR).forEach(k => { r[k.trim()] = rawR[k]; });
const common = { id: Math.random().toString(36).substring(2, 9) };
if (sheetName === '개인PC') {
const purchaseYM = formatExcelDate(r['구매연월']).replace(/-/g, '').substring(0, 6);
list.push({ ...common, type: '개인PC', 법인: r['법인']||'', 자산코드: r['자산코드']||'', 구매연월: purchaseYM, 사용자: r['사용자']||'', 현사용조직: r['현사용조직']||'', 이전사용조직: r['이전사용조직']||'', 위치: r['위치']||'', 담당자_정: r['담당자(정)']||'', 담당자_부: r['담당자(부)']||'', 모델명: r['모델명']||'', OS: r['OS']||'', CPU: r['CPU']||'', GPU: r['GPU']||'', RAM: r['RAM']||'', SSD1: r['SSD1']||'', SSD2: r['SSD2']||'', SSD3: r['SSD3']||'', 메인보드: r['메인보드']||'', IP주소: r['IP주소']||'', 금액: r['금액']||'', 납품업체: r['납품업체']||'', 품의서명: r['품의서명']||'', 비고: r['비고']||'' });
} else if (sheetName === '서버') {
const purchaseYM = formatExcelDate(r['구매연월']).replace(/-/g, '').substring(0, 6);
list.push({ ...common, type: '서버', 법인: r['법인']||'', 자산코드: r['자산코드']||'', 구매연월: purchaseYM, 상세용도: r['용도']||'', 상세: r['상세내용']||'', 현사용조직: r['현사용조직']||'', 이전사용조직: r['이전사용조직']||'', 위치: r['위치']||'', 담당자_정: r['담당자(정)']||'', 담당자_부: r['담당자(부)']||'', IP주소: r['IP 주소 1']||'', IP2: r['IP 주소 2']||'', 원격접속: r['원격도구']||'', 서버ID: r['서버 ID']||'', 서버PW: r['서버 PW']||'', 모델명: r['모델명']||'', OS: r['OS']||'', CPU: r['CPU']||'', RAM: r['RAM']||'', GPU: r['GPU']||'', SSD1: r['Storage 1']||'', SSD2: r['Storage 2']||'', SSD3: r['Storage 3']||'', 모니터링: r['모니터링']||'', 금액: r['금액']||'', 납품업체: r['납품업체']||'', 품의서명: r['품의서명']||'', 비고: r['비고']||'', type2: r['유형']||'물리' });
} else if (sheetName === '스토리지') {
const purchaseYM = formatExcelDate(r['구매연월']).replace(/-/g, '').substring(0, 6);
list.push({ ...common, type: '스토리지', 법인: r['법인']||'', storage유형: r['유형']||'', 자산코드: r['자산코드']||'', 명칭: r['명칭']||'', 위치: r['위치']||'', 모델명: r['모델명']||'', 용량: r['용량']||'', 담당자_정: r['담당자(정)']||'', 담당자_부: r['담당자(부)']||'', IP주소: r['IP주소']||'', MACaddress: r['MAC주소']||'', 구매연월: purchaseYM, 금액: r['금액']||'', 납품업체: r['납품업체']||'', 품의서명: r['품의서명']||'', 비고: r['비고']||'' });
} else if (sheetName === '전산비품') {
const purchaseYM = formatExcelDate(r['구매연월']).replace(/-/g, '').substring(0, 6);
list.push({ ...common, type: '전산비품', 법인: r['법인']||'', 비품유형: r['비품유형']||'', 자산코드: r['자산코드']||'', 명칭: r['명칭']||'', 위치: r['위치']||'', 관리자: r['관리자']||'', IP주소: r['IP주소']||'', MACaddress: r['MACaddress']||'', HW사양: r['HW사양']||'', OS: r['OS']||'', 구매연월: purchaseYM, 금액: r['금액']||'', 납품업체: r['납품업체']||'', 품의서명: r['품의서명']||'', 비고: r['비고']||'' });
} else if (sheetName === '모바일기기') {
const purchaseYM = formatExcelDate(r['구매연월']).replace(/-/g, '').substring(0, 6);
list.push({ ...common, type: '모바일기기', 법인: r['법인']||'', 자산코드: r['자산코드']||'', 명칭: r['명칭']||'', 위치: r['위치']||'', 관리자: r['관리자']||'', 기기유형: r['기기유형']||'', OS: r['OS']||'', 구매연월: purchaseYM, 금액: r['금액']||'', 납품업체: r['납품업체']||'', 품의서명: r['품의서명']||'', 비고: r['비고']||'' });
} else if (sheetName === '구독SW') {
list.push({ ...common, type: '구독SW', 분야: r['분야']||'', 법인: r['법인']||'', 부서: r['부서']||'', 제품명: r['제품명']||'', 구매일: formatExcelDate(r['구매일']), 시작일: formatExcelDate(r['시작일']), 만료일: formatExcelDate(r['만료일']), 라이선스유형: r['라이선스유형']||'', 금액: r['금액']||'', 수량: parseInt(r['수량']||'1'), 계정명: r['계정명']||'', 납품업체: r['납품업체']||'', 비고: r['비고']||'' });
} else if (sheetName === '영구SW') {
list.push({ ...common, type: '영구SW', 분야: r['분야']||'', 법인: r['법인']||'', 부서: r['부서']||'', 제품명: r['제품명']||'', 구매일: formatExcelDate(r['구매일']), 시작일: formatExcelDate(r['시작일']), 만료일: formatExcelDate(r['만료일']), 라이선스키: r['라이선스키']||'', 금액: r['금액']||'', 수량: parseInt(r['수량']||'1'), 계정명: r['계정명']||'', 납품업체: r['납품업체']||'', 비고: r['비고']||'' });
} else if (sheetName === '클라우드') {
list.push({ ...common, type: '클라우드', 플랫폼명: r['플랫폼명']||'', 법인: r['법인']||'', 부서: r['부서']||'', 제품명: r['제품명']||'', 계정명: r['계정명']||'', 결제수단: r['결제수단']||'', 결제일: r['결제일']||'', 연결카드번호: r['연결카드번호']||'', 당월청구액: r['당월청구액']||'', 비고: r['비고']||'' });
} else if (sheetName === '도메인') {
list.push({ ...common, type: r['유형']||'도메인', corp: r['법인']||'', service_name: r['서비스명']||'', domain_name: r['관리도메인']||'', start_date: formatExcelDate(r['시작일']), expiry_date: formatExcelDate(r['만료일']), price: r['금액']||'', manager_main: r['담당자']||'', manager_sub: r['담당자(부)']||'', remarks: r['비고']||'' });
}
});
if (list.length > 0) parsedData[sheetName] = list;
});
resolve(data);
resolve(parsedData);
} catch (err) { reject(err); }
};
reader.readAsBinaryString(file);
reader.readAsArrayBuffer(file);
});
}

View File

@@ -609,7 +609,7 @@ export const realServerData = [
"SSD2": ""
},
{
"법인": "기술개발센터",
"법인": "",
"자산코드": "",
"storage유형": "NAS",
"용도": "GSIM NAS",
@@ -629,7 +629,7 @@ export const realServerData = [
"SSD2": ""
},
{
"법인": "기술개발센터",
"법인": "",
"자산코드": "",
"storage유형": "NAS",
"용도": "그래픽스개발팀 데이터 백업 NAS",
@@ -649,7 +649,7 @@ export const realServerData = [
"SSD2": ""
},
{
"법인": "기술개발센터",
"법인": "",
"자산코드": "",
"storage유형": "PC",
"용도": "공통 GIT 서버",
@@ -669,7 +669,7 @@ export const realServerData = [
"SSD2": "1TB"
},
{
"법인": "기술개발센터",
"법인": "",
"자산코드": "",
"storage유형": "PC",
"용도": "BUILD 서버",
@@ -689,7 +689,7 @@ export const realServerData = [
"SSD2": "10TB"
},
{
"법인": "기술개발센터",
"법인": "",
"자산코드": "",
"storage유형": "PC",
"용도": "HmEG 테스트 서버",
@@ -709,7 +709,7 @@ export const realServerData = [
"SSD2": "1TB"
},
{
"법인": "기술개발센터",
"법인": "",
"자산코드": "",
"storage유형": "PC",
"용도": "산하 ERP 개발서버",
@@ -729,7 +729,7 @@ export const realServerData = [
"SSD2": ""
},
{
"법인": "기술개발센터",
"법인": "",
"자산코드": "",
"storage유형": "PC",
"용도": "공간정보 신청",
@@ -749,7 +749,7 @@ export const realServerData = [
"SSD2": "931GB"
},
{
"법인": "기술개발센터",
"법인": "",
"자산코드": "",
"storage유형": "PC",
"용도": "AI 관련",
@@ -769,7 +769,7 @@ export const realServerData = [
"SSD2": ""
},
{
"법인": "기술개발센터",
"법인": "",
"자산코드": "",
"storage유형": "PC",
"용도": "한종 테스트",
@@ -789,7 +789,7 @@ export const realServerData = [
"SSD2": ""
},
{
"법인": "기술개발센터",
"법인": "",
"자산코드": "",
"storage유형": "PC",
"용도": "GSIM 언리얼 서버",
@@ -809,7 +809,7 @@ export const realServerData = [
"SSD2": "8TB"
},
{
"법인": "기술개발센터",
"법인": "",
"자산코드": "",
"storage유형": "PC",
"용도": "AutoCAD 테스트 서버",
@@ -829,7 +829,7 @@ export const realServerData = [
"SSD2": "2TB"
},
{
"법인": "기술개발센터",
"법인": "",
"자산코드": "",
"storage유형": "PC",
"용도": "GSIM 테스트 서버",
@@ -849,7 +849,7 @@ export const realServerData = [
"SSD2": "512GB"
},
{
"법인": "기술개발센터",
"법인": "",
"자산코드": "",
"storage유형": "PC",
"용도": "공간데이터 서버",
@@ -869,7 +869,7 @@ export const realServerData = [
"SSD2": "8 TB"
},
{
"법인": "기술개발센터",
"법인": "",
"자산코드": "",
"storage유형": "PC",
"용도": "가평 VM 원격 서버",
@@ -889,7 +889,7 @@ export const realServerData = [
"SSD2": ""
},
{
"법인": "기술개발센터",
"법인": "",
"자산코드": "",
"storage유형": "서버",
"용도": "GSIM 협업",
@@ -909,7 +909,7 @@ export const realServerData = [
"SSD2": "1.88TB"
},
{
"법인": "기술개발센터",
"법인": "",
"자산코드": "",
"storage유형": "스토리지",
"용도": "GSIM 협업 스토리지",
@@ -929,7 +929,7 @@ export const realServerData = [
"SSD2": ""
},
{
"법인": "기술개발센터",
"법인": "",
"자산코드": "",
"storage유형": "서버",
"용도": "GSIM META 서버",
@@ -949,7 +949,7 @@ export const realServerData = [
"SSD2": "4TB"
},
{
"법인": "기술개발센터",
"법인": "",
"자산코드": "",
"storage유형": "서버",
"용도": "GSIM 서버",
@@ -969,7 +969,7 @@ export const realServerData = [
"SSD2": "4TB"
},
{
"법인": "기술개발센터",
"법인": "",
"자산코드": "",
"storage유형": "스토리지",
"용도": "GSIM 스토리지",
@@ -989,7 +989,7 @@ export const realServerData = [
"SSD2": ""
},
{
"법인": "기술개발센터",
"법인": "",
"자산코드": "",
"storage유형": "서버",
"용도": "함양-합천 서버",
@@ -1009,7 +1009,7 @@ export const realServerData = [
"SSD2": "10TB"
},
{
"법인": "기술개발센터",
"법인": "",
"자산코드": "",
"storage유형": "서버",
"용도": "HM MapService 2.0 서버",
@@ -1029,7 +1029,7 @@ export const realServerData = [
"SSD2": "40 TB"
},
{
"법인": "기술개발센터",
"법인": "",
"자산코드": "",
"storage유형": "스토리지",
"용도": "HM MapService 2.0 스토리지",
@@ -1049,7 +1049,7 @@ export const realServerData = [
"SSD2": ""
},
{
"법인": "기술개발센터",
"법인": "",
"자산코드": "",
"storage유형": "서버",
"용도": "Gitlab Runner",
@@ -1069,7 +1069,7 @@ export const realServerData = [
"SSD2": ""
},
{
"법인": "기술개발센터",
"법인": "",
"자산코드": "",
"storage유형": "서버",
"용도": "전산모사",
@@ -1089,7 +1089,7 @@ export const realServerData = [
"SSD2": ""
},
{
"법인": "한맥빌딩",
"법인": "",
"자산코드": "1",
"storage유형": "NAS",
"용도": "NAS 2",
@@ -1105,7 +1105,7 @@ export const realServerData = [
"SSD2": ""
},
{
"법인": "한맥빌딩",
"법인": "",
"자산코드": "2",
"storage유형": "NAS",
"용도": "NAS 1",
@@ -1121,7 +1121,7 @@ export const realServerData = [
"SSD2": ""
},
{
"법인": "한맥빌딩",
"법인": "",
"자산코드": "3",
"storage유형": "NAS",
"용도": "NAS 4",
@@ -1137,7 +1137,7 @@ export const realServerData = [
"SSD2": ""
},
{
"법인": "한맥빌딩",
"법인": "",
"자산코드": "4",
"storage유형": "NAS",
"용도": "NAS 5",
@@ -1153,7 +1153,7 @@ export const realServerData = [
"SSD2": ""
},
{
"법인": "한맥빌딩",
"법인": "",
"자산코드": "5",
"storage유형": "NAS",
"용도": "NAS 6",
@@ -1169,7 +1169,7 @@ export const realServerData = [
"SSD2": ""
},
{
"법인": "한맥빌딩",
"법인": "",
"자산코드": "6",
"storage유형": "NAS",
"용도": "NAS7",
@@ -1185,7 +1185,7 @@ export const realServerData = [
"SSD2": ""
},
{
"법인": "한맥빌딩",
"법인": "",
"자산코드": "7",
"storage유형": "NAS",
"용도": "총괄기획실 NAS",
@@ -1201,7 +1201,7 @@ export const realServerData = [
"SSD2": ""
},
{
"법인": "한맥빌딩",
"법인": "",
"자산코드": "8",
"storage유형": "NAS",
"용도": "한맥 NAS 1",
@@ -1217,7 +1217,7 @@ export const realServerData = [
"SSD2": ""
},
{
"법인": "한맥빌딩",
"법인": "",
"자산코드": "9",
"storage유형": "NAS",
"용도": "한맥 NAS 2",
@@ -1233,7 +1233,7 @@ export const realServerData = [
"SSD2": ""
},
{
"법인": "한맥빌딩",
"법인": "",
"자산코드": "10",
"storage유형": "NAS",
"용도": "한맥 NAS 3",
@@ -1249,7 +1249,7 @@ export const realServerData = [
"SSD2": ""
},
{
"법인": "한맥빌딩",
"법인": "",
"자산코드": "11",
"storage유형": "NAS",
"용도": "NAS 13",
@@ -1265,7 +1265,7 @@ export const realServerData = [
"SSD2": ""
},
{
"법인": "한맥빌딩",
"법인": "",
"자산코드": "12",
"storage유형": "PC",
"용도": "회계",
@@ -1281,7 +1281,7 @@ export const realServerData = [
"SSD2": ""
},
{
"법인": "한맥빌딩",
"법인": "",
"자산코드": "13",
"storage유형": "PC",
"용도": "한맥CAD",
@@ -1297,9 +1297,9 @@ export const realServerData = [
"SSD2": ""
},
{
"법인": "한맥빌딩",
"법인": "",
"자산코드": "14",
"storage유형": "서버(타워)",
"storage유형": "PC",
"용도": "Ai-Cell-Util",
"상세": "깃티, 매터모스트 등 70여종",
"위치": "한맥빌딩(MDF 실)",
@@ -1313,7 +1313,7 @@ export const realServerData = [
"SSD2": "8 TB"
},
{
"법인": "한맥빌딩",
"법인": "",
"자산코드": "15",
"storage유형": "PC",
"용도": "한라CAD",
@@ -1329,7 +1329,7 @@ export const realServerData = [
"SSD2": ""
},
{
"법인": "한맥빌딩",
"법인": "",
"자산코드": "16",
"storage유형": "NAS",
"용도": "디자인팀1 NAS",
@@ -1345,7 +1345,7 @@ export const realServerData = [
"SSD2": ""
},
{
"법인": "한맥빌딩",
"법인": "",
"자산코드": "17",
"storage유형": "NAS",
"용도": "디자인팀2 NAS",
@@ -1361,9 +1361,9 @@ export const realServerData = [
"SSD2": ""
},
{
"법인": "한맥빌딩",
"법인": "",
"자산코드": "18",
"storage유형": "서버(미니워크스테이션)",
"storage유형": "PC",
"용도": "인사정보 서버",
"상세": "인사정보 PM",
"위치": "한맥빌딩(MDF 실)",
@@ -1377,9 +1377,9 @@ export const realServerData = [
"SSD2": "2 TB"
},
{
"법인": "한맥빌딩",
"법인": "",
"자산코드": "19",
"storage유형": "서버(타워)",
"storage유형": "PC",
"용도": "BEPs 서버",
"상세": "BEPs 개발서버, Outline 협업서비스",
"위치": "한맥빌딩(MDF 실)",
@@ -1393,9 +1393,9 @@ export const realServerData = [
"SSD2": ""
},
{
"법인": "한맥빌딩",
"법인": "",
"자산코드": "20",
"storage유형": "서버(타워)",
"storage유형": "PC",
"용도": "Ai-Cell-A100-1",
"상세": "OCR, Local LLM 등 30여종",
"위치": "한맥빌딩(MDF 실)",
@@ -1409,9 +1409,9 @@ export const realServerData = [
"SSD2": ""
},
{
"법인": "한맥빌딩",
"법인": "",
"자산코드": "21",
"storage유형": "서버(타워)",
"storage유형": "PC",
"용도": "빌드서버",
"상세": "인스톨 쉴드, 지라",
"위치": "한맥빌딩(MDF 실)",
@@ -1425,9 +1425,9 @@ export const realServerData = [
"SSD2": "4TB"
},
{
"법인": "한맥빌딩",
"법인": "",
"자산코드": "22",
"storage유형": "PC\n서버(랙)",
"storage유형": "PC",
"용도": "저장소 및 전산모사\n구)스마트건설 서버",
"상세": "ParaView, CFDCore\n디지털화설문, 검색WIKI 웹서비스",
"위치": "한맥빌딩(MDF 실)",
@@ -1441,9 +1441,9 @@ export const realServerData = [
"SSD2": "2TB"
},
{
"법인": "한맥빌딩",
"법인": "",
"자산코드": "23",
"storage유형": "서버(랙)",
"storage유형": "서버",
"용도": "IDC 산하ERP서버",
"상세": "XR 가상화 메인 서버 → IDC 산하ERP서버",
"위치": "한맥빌딩(MDF 실)",
@@ -1457,9 +1457,9 @@ export const realServerData = [
"SSD2": ""
},
{
"법인": "한맥빌딩",
"법인": "",
"자산코드": "24",
"storage유형": "스토리지(랙)",
"storage유형": "스토리지",
"용도": "WAS Storage",
"상세": "",
"위치": "한맥빌딩(MDF 실)",
@@ -1473,9 +1473,9 @@ export const realServerData = [
"SSD2": ""
},
{
"법인": "한맥빌딩",
"법인": "",
"자산코드": "25",
"storage유형": "서버(랙)",
"storage유형": "서버",
"용도": "한맥 백업 서버",
"상세": "가족사 인트라넷 소스 백업 서버",
"위치": "한맥빌딩(MDF 실)",
@@ -1489,9 +1489,9 @@ export const realServerData = [
"SSD2": ""
},
{
"법인": "한맥빌딩",
"법인": "",
"자산코드": "26",
"storage유형": "서버(랙)",
"storage유형": "서버",
"용도": "한라 백업 서버",
"상세": "한라 웹 소스 및 Miso DB 백업 서버",
"위치": "한맥빌딩(MDF 실)",
@@ -1505,7 +1505,7 @@ export const realServerData = [
"SSD2": ""
},
{
"법인": "한맥빌딩",
"법인": "",
"자산코드": "27",
"storage유형": "NAS",
"용도": "기술개발센터 NAS",
@@ -1521,7 +1521,7 @@ export const realServerData = [
"SSD2": ""
},
{
"법인": "한맥빌딩",
"법인": "",
"자산코드": "28",
"storage유형": "NAS",
"용도": "-",
@@ -1537,9 +1537,9 @@ export const realServerData = [
"SSD2": ""
},
{
"법인": "한맥빌딩",
"법인": "",
"자산코드": "29",
"storage유형": "스토리지(랙)",
"storage유형": "스토리지",
"용도": "Backup Storage",
"상세": "",
"위치": "한맥빌딩(MDF 실)",
@@ -1553,9 +1553,9 @@ export const realServerData = [
"SSD2": ""
},
{
"법인": "한맥빌딩",
"법인": "",
"자산코드": "30",
"storage유형": "스토리지(랙)",
"storage유형": "스토리지",
"용도": "-",
"상세": "",
"위치": "한맥빌딩(MDF 실)",
@@ -1569,9 +1569,9 @@ export const realServerData = [
"SSD2": ""
},
{
"법인": "한맥빌딩",
"법인": "",
"자산코드": "31",
"storage유형": "서버(랙)",
"storage유형": "서버",
"용도": "XR WAS Server",
"상세": "",
"위치": "한맥빌딩(MDF 실)",
@@ -1585,9 +1585,9 @@ export const realServerData = [
"SSD2": ""
},
{
"법인": "한맥빌딩",
"법인": "",
"자산코드": "32",
"storage유형": "서버(랙)",
"storage유형": "서버",
"용도": "WAS Storage",
"상세": "",
"위치": "한맥빌딩(MDF 실)",

77
src/core/schema.ts Normal file
View File

@@ -0,0 +1,77 @@
/**
* ITAM 통합 스키마 매퍼 (Unified Schema Mapper)
*
* key: 애플리케이션 내부 로직에서 사용하는 속성명
* db: MySQL 데이터베이스 컬럼명
* ui: 사용자에게 보여지는 UI 레이블
*/
export const ASSET_SCHEMA = {
// ─── 공통 필드 (Common) ───
ID: { key: 'id', db: 'id', ui: 'ID' },
TYPE: { key: 'type', db: 'type', ui: '자산유형' },
CORP: { key: '법인', db: 'corp', ui: '구매법인' },
ASSET_CODE: { key: '자산코드', db: 'asset_code', ui: '자산번호' },
PURCHASE_YM: { key: '구매연월', db: 'purchase_date', ui: '구매연월' },
ORG: { key: '현사용조직', db: 'current_org', ui: '현 사용조직' },
PREV_ORG: { key: '이전사용조직', db: 'prev_org', ui: '이전 사용조직' },
LOCATION: { key: '위치', db: 'location', ui: '설치위치' },
MANAGER_MAIN: { key: '담당자_정', db: 'manager_main', ui: '담당자' },
MANAGER_SUB: { key: '담당자_부', db: 'manager_sub', ui: '담당자(부)' },
PRICE: { key: '금액', db: 'price', ui: '도입금액' },
VENDOR: { key: '납품업체', db: 'vendor', ui: '납품업체' },
DOC_NAME: { key: '품의서명', db: 'doc_name', ui: '품의서' },
REMARKS: { key: '비고', db: 'remarks', ui: '비고' },
DETAIL_PURPOSE: { key: '상세용도', db: 'detail_purpose', ui: '용도' },
// ─── 하드웨어 상세 (Hardware) ───
USER: { key: '사용자', db: 'purpose', ui: '사용자' },
MODEL: { key: '모델명', db: 'model_name', ui: '모델명' },
MAINBOARD: { key: '메인보드', db: 'mainboard', ui: '메인보드' },
OS: { key: 'OS', db: 'os', ui: '운영체제' },
CPU: { key: 'CPU', db: 'cpu', ui: 'CPU' },
RAM: { key: 'RAM', db: 'ram', ui: 'RAM' },
STORAGE1: { key: 'SSD1', db: 'storage1', ui: 'Storage 1' },
STORAGE2: { key: 'SSD2', db: 'storage2', ui: 'Storage 2' },
IP_ADDR: { key: 'IP주소', db: 'ip_address', ui: 'IP 주소 1' },
IP_ADDR2: { key: 'IP2', db: 'ip2', ui: 'IP 주소 2' },
MAC_ADDR: { key: 'MACaddress', db: 'mac_address', ui: 'MAC 주소' },
GPU: { key: 'GPU', db: 'gpu', ui: 'GPU' },
STORAGE3: { key: 'SSD3', db: 'storage3', ui: 'Storage 3' },
STATUS: { key: '현재상태', db: 'status', ui: '현재상태' },
STORE_LOC: { key: '보관위치', db: 'storage_location',ui: '보관위치' },
// ─── 소프트웨어/클라우드 상세 (SW/Cloud) ───
PRODUCT: { key: '제품명', db: 'product_name', ui: '제품/서비스명' },
PLATFORM: { key: '플랫폼명', db: 'platform_name', ui: '운영 플랫폼' },
LICENSE_TYPE: { key: '라이선스유형', db: 'license_type', ui: '라이선스 유형' },
LICENSE_KEY: { key: '라이선스키', db: 'license_key', ui: '라이선스 키' },
QTY: { key: '수량', db: 'quantity', ui: '보유수량' },
EXPIRY: { key: '만료일', db: 'expiry_date', ui: '만료/구독일' },
ACCOUNT: { key: '계정명', db: 'account_name', ui: '계정(이메일)' },
PAY_METHOD: { key: '결제수단', db: 'pay_method', ui: '결제수단' },
PAY_DAY: { key: '결제일', db: 'pay_day', ui: '결제일' },
CARD_NUM: { key: '연결카드번호', db: 'card_num', ui: '카드번호(뒷4자리)' },
BILLING: { key: '당월청구액', db: 'monthly_fee', ui: '당월 청구액' }
};
/**
* 용어 사전 (UI 텍스트 전용)
*/
export const UI_TEXT = {
ACTION: {
ADD: '신규 등록',
EDIT: '수정',
SAVE: '저장',
DELETE: '삭제',
CANCEL: '취소',
CLOSE: '닫기',
HISTORY_ADD: '이력 추가',
RESET_FILTER: '필터 초기화'
},
MESSAGES: {
CONFIRM_DELETE: '정말로 삭제하시겠습니까?',
SAVE_SUCCESS: '성공적으로 저장되었습니다.',
NO_DATA: '검색 결과가 없습니다.'
}
};

View File

@@ -12,22 +12,23 @@ export interface MasterAssetData {
cloud: SoftwareAsset[]; // 클라우드 배열 추가
swUsers: SWUser[];
logs: HardwareLog[];
domain: any[];
// 동료 코드 호환용 통합 배열 (프론트엔드 로직용)
sw: SoftwareAsset[];
hw: HardwareAsset[];
sw: SoftwareAsset[];
}
export interface AppState {
activeCategory: 'dashboard' | 'hw' | 'sw' | 'ops';
activeSubTab: string;
activeSubTab: string; // '대시보드', '개인PC', '서버', '스토리지', '전산비품', '구독SW', '영구SW', '클라우드'
masterData: MasterAssetData;
activeCharts: any[];
activeCharts?: any[];
}
// 초기 상태
export const state: AppState = {
activeCategory: 'dashboard',
activeCategory: 'hw',
activeSubTab: '대시보드',
masterData: {
pc: [],
@@ -38,12 +39,12 @@ export const state: AppState = {
subSw: [],
permSw: [],
cloud: [],
hw: [], // 호환용
sw: [], // 호환용
swUsers: [],
logs: [],
hw: []
},
activeCharts: []
domain: []
}
};
/**
@@ -52,16 +53,17 @@ export const state: AppState = {
export async function loadMasterDataFromDB() {
try {
const endpoints = [
{ key: 'pc', url: 'http://localhost:3000/api/pc' },
{ key: 'server', url: 'http://localhost:3000/api/server' },
{ key: 'storage', url: 'http://localhost:3000/api/storage' },
{ key: 'equip', url: 'http://localhost:3000/api/equip' },
{ key: 'mobile', url: 'http://localhost:3000/api/mobile' },
{ key: 'subSw', url: 'http://localhost:3000/api/sw/sub' },
{ key: 'permSw', url: 'http://localhost:3000/api/sw/perm' },
{ key: 'cloud', url: 'http://localhost:3000/api/cloud' },
{ key: 'swUsers', url: 'http://localhost:3000/api/sw-users' },
{ key: 'logs', url: 'http://localhost:3000/api/logs' }
{ key: 'pc', url: `http://${location.hostname}:3000/api/pc` },
{ key: 'server', url: `http://${location.hostname}:3000/api/server` },
{ key: 'storage', url: `http://${location.hostname}:3000/api/storage` },
{ key: 'equip', url: `http://${location.hostname}:3000/api/equip` },
{ key: 'mobile', url: `http://${location.hostname}:3000/api/mobile` },
{ key: 'subSw', url: `http://${location.hostname}:3000/api/sw/sub` },
{ key: 'permSw', url: `http://${location.hostname}:3000/api/sw/perm` },
{ key: 'cloud', url: `http://${location.hostname}:3000/api/cloud` },
{ key: 'domain', url: `http://${location.hostname}:3000/api/ops/domain` },
{ key: 'swUsers', url: `http://${location.hostname}:3000/api/sw-users` },
{ key: 'logs', url: `http://${location.hostname}:3000/api/logs` }
];
const results = await Promise.all(endpoints.map(e => fetch(e.url)));
@@ -87,12 +89,14 @@ export async function loadMasterDataFromDB() {
}
}
// 동료 코드 호환을 위한 통합 sw/hw 배열 생성
// 동료 코드 호환을 위한 통합 sw 배열 생성
state.masterData.sw = [
...state.masterData.subSw,
...state.masterData.permSw,
...state.masterData.cloud
];
// 하드웨어 통합 배열 생성 (대시보드 등에서 사용)
state.masterData.hw = [
...state.masterData.pc,
...state.masterData.server,
@@ -121,18 +125,25 @@ export function saveHardwareAsset(updatedAsset: HardwareAsset) {
const type = updatedAsset.type || '';
const detailPurpose = (updatedAsset as any). || updatedAsset.detail_purpose || '';
// 1. 타겟 카테고리 결정 (유연한 검색)
// 1. 타겟 카테고리 결정 (사용자 정의 그룹 기준)
let targetKey: keyof MasterAssetData = 'equip';
if (type.includes('서버') || detailPurpose.includes('서버')) {
const upperType = type.toUpperCase();
const isServer = type.includes('서버') || detailPurpose.includes('서버');
const isStorage = ['NAS', 'DAS', '스토리지'].some(t => type.includes(t));
const isMobileGroup = ['모바일', '태블릿', '노트북', '휴대폰', '핸드폰'].some(t => type.includes(t));
const isEquipGroup = ['CPU', 'RAM', 'HDD', 'GPU'].some(t => upperType.includes(t));
const isPc = type === 'PC' || type === '개인PC' || detailPurpose === '개인PC';
if (isServer) {
targetKey = 'server';
} else if (['NAS', 'DAS', '스토리지'].some(t => type.includes(t))) {
} else if (isStorage) {
targetKey = 'storage';
} else if (['모바일', '태블릿', '휴대폰', '핸드폰', '노트북'].some(t => type.includes(t))) {
} else if (isMobileGroup) {
targetKey = 'mobile';
} else if (type === 'PC' || type === '개인PC' || detailPurpose === '개인PC') {
} else if (isPc) {
targetKey = 'pc';
} else if (['CPU', 'GPU', 'RAM', 'HDD'].some(t => type.toUpperCase().includes(t))) {
} else if (isEquipGroup) {
targetKey = 'equip';
}
@@ -148,6 +159,15 @@ export function saveHardwareAsset(updatedAsset: HardwareAsset) {
// 3. 새로운 타겟 카테고리에 추가
(state.masterData[targetKey] as HardwareAsset[]).push(updatedAsset);
// 4. 통합 hw 배열 동기화
state.masterData.hw = [
...state.masterData.pc,
...state.masterData.server,
...state.masterData.storage,
...state.masterData.equip,
...state.masterData.mobile
];
}
/**
@@ -162,4 +182,67 @@ export function deleteHardwareAsset(assetId: string) {
if (idx > -1) arr.splice(idx, 1);
}
});
// 통합 hw 배열 동기화
state.masterData.hw = [
...state.masterData.pc,
...state.masterData.server,
...state.masterData.storage,
...state.masterData.equip,
...state.masterData.mobile
];
}
/**
* 소프트웨어 자산 저장 (API 연동)
*/
export async function saveSoftwareAsset(asset: SoftwareAsset) {
try {
const response = await fetch(`http://${location.hostname}:3000/api/software/save`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(asset)
});
if (response.ok) {
// 로컬 상태 업데이트
const key = asset.type === '구독SW' ? 'subSw' : (asset.type === '영구SW' ? 'permSw' : 'cloud');
const arr = state.masterData[key] as SoftwareAsset[];
const idx = arr.findIndex(a => a.id === asset.id);
if (idx > -1) arr[idx] = asset;
else arr.push(asset);
// 통합 sw 배열 동기화
state.masterData.sw = [...state.masterData.subSw, ...state.masterData.permSw, ...state.masterData.cloud];
return true;
}
} catch (err) {
console.error('SW 저장 실패:', err);
}
return false;
}
/**
* 소프트웨어 자산 삭제 (API 연동)
*/
export async function deleteSoftwareAsset(type: string, id: string) {
try {
const response = await fetch(`http://${location.hostname}:3000/api/asset/${type}/${id}`, {
method: 'DELETE'
});
if (response.ok) {
const key = type === '구독SW' ? 'subSw' : (type === '영구SW' ? 'permSw' : 'cloud');
const arr = state.masterData[key] as SoftwareAsset[];
const idx = arr.findIndex(a => a.id === id);
if (idx > -1) arr.splice(idx, 1);
// 통합 sw 배열 동기화
state.masterData.sw = [...state.masterData.subSw, ...state.masterData.permSw, ...state.masterData.cloud];
return true;
}
} catch (err) {
console.error('SW 삭제 실패:', err);
}
return false;
}

46
src/core/tableHandler.ts Normal file
View File

@@ -0,0 +1,46 @@
/**
* 공통 테이블 핸들러
*/
export type SortDirection = 'asc' | 'desc';
export interface SortState {
key: string;
direction: SortDirection;
}
/**
* 테이블 헤더에 정렬 이벤트를 바인딩합니다.
* @param table 대상 테이블 요소
* @param currentState 현재 정렬 상태
* @param onSort 정렬 변경 시 호출될 콜백
*/
export function setupTableSorting(
table: HTMLTableElement,
currentState: SortState,
onSort: (key: string, direction: SortDirection) => void
) {
const headers = table.querySelectorAll('th[data-sort]');
headers.forEach(th => {
const key = th.getAttribute('data-sort')!;
th.classList.add('sortable');
// 현재 정렬 상태 표시
if (currentState.key === key) {
th.classList.add(currentState.direction);
} else {
th.classList.remove('asc', 'desc');
}
th.onclick = () => {
let nextDirection: SortDirection = 'asc';
if (currentState.key === key) {
nextDirection = currentState.direction === 'asc' ? 'desc' : 'asc';
}
onSort(key, nextDirection);
};
});
}

View File

@@ -15,8 +15,8 @@ export function formatPrice(value: string | number): string {
/**
* HTML 배지 생성 (정/부 담당자, 원격도구 등)
*/
export function createBadge(text: string, bgColor: string): string {
return `<span style="background:${bgColor}; color:white; font-size:10px; padding:1px 4px; border-radius:3px; font-weight:700; margin-right:4px; display:inline-block; line-height:1.2;">${text}</span>`;
export function createBadge(text: string, type: 'primary' | 'muted' | 'success' | 'danger' = 'primary'): string {
return `<span class="badge badge-${type}">${text}</span>`;
}
/**
@@ -33,6 +33,21 @@ export function normalizeDate(dateStr: string): string {
return (dateStr || '').replace(/\./g, '-').trim();
}
/**
* 구매일로부터 현재까지의 경과 연수 계산 (소수점 첫째자리)
*/
export function calculateAssetAge(purchaseDate: string): number {
const normalized = normalizeDate(purchaseDate);
if (!normalized) return 0;
const purchase = new Date(normalized);
if (isNaN(purchase.getTime())) return 0;
const diffMs = Date.now() - purchase.getTime();
const age = diffMs / (1000 * 60 * 60 * 24 * 365.25);
return Math.max(0, parseFloat(age.toFixed(1)));
}
/**
* 고유 ID 생성 (7자리 랜덤 문자열)
*/
@@ -56,22 +71,55 @@ export function getAssetChanges(oldAsset: any, newAsset: any, fields: {key: stri
}
/**
* 자산 목록 정렬 (방안 C: 구매법인별 -> 자산번호 순)
* 자산 목록 정렬 (기본: 법인별 -> 자산번호 순)
*/
export function sortAssets<T>(list: T[]): T[] {
return [...list].sort((a: any, b: any) => {
// 1순위: 구매법인 (한글 가나다순)
const corpA = String(a. || '').trim();
const corpB = String(b. || '').trim();
// 1순위: 법인 (가나다순)
const corpA = String(a. || a.corp || '').trim();
const corpB = String(b. || b.corp || '').trim();
if (corpA < corpB) return -1;
if (corpA > corpB) return 1;
// 2순위: 자산번호 (영문/숫자순)
const codeA = String(a. || a. || '').trim();
const codeB = String(b. || b. || '').trim();
// 2순위: 자산번호/코드 (영문/숫자순)
const codeA = String(a. || a. || a.id || '').trim();
const codeB = String(b. || b. || b.id || '').trim();
if (codeA < codeB) return -1;
if (codeA > codeB) return 1;
return 0;
});
}
/**
* 동적 정렬 함수
* @param list 정렬할 목록
* @param key 정렬 기준 필드
* @param direction 정렬 방향 ('asc' | 'desc')
*/
export function dynamicSort<T>(list: T[], key: string, direction: 'asc' | 'desc'): T[] {
return [...list].sort((a: any, b: any) => {
let valA = a[key];
let valB = b[key];
// 숫자인 경우 처리
if (typeof valA === 'number' && typeof valB === 'number') {
return direction === 'asc' ? valA - valB : valB - valA;
}
// 금액 필드 (숫자형 문자열 포함) 처리
if (key === '금액' || key === 'price' || key === '수량' || key === 'qty') {
const numA = typeof valA === 'number' ? valA : parseInt(String(valA || '0').replace(/[^0-9-]/g, ''), 10);
const numB = typeof valB === 'number' ? valB : parseInt(String(valB || '0').replace(/[^0-9-]/g, ''), 10);
return direction === 'asc' ? numA - numB : numB - numA;
}
// 문자열 정렬 (기본)
valA = String(valA || '').toLowerCase();
valB = String(valB || '').toLowerCase();
if (valA < valB) return direction === 'asc' ? -1 : 1;
if (valA > valB) return direction === 'asc' ? 1 : -1;
return 0;
});
}

View File

@@ -2,15 +2,16 @@ import { state, loadMasterDataFromDB } from './core/state';
import { renderNavigation } from './components/Navigation';
import { renderDashboard } from './views/DashboardView';
import { renderSWTable } from './views/SW_Table';
import { downloadTemplate, exportToExcel, parseExcel, HardwareAsset, SoftwareAsset, SWUser } from './core/excelHandler';
import { downloadTemplate, exportToExcel, parseExcel } from './core/excelHandler';
import { initBaseModal } from './components/Modal/BaseModal';
import { initPcModal } from './components/Modal/PCModal';
import { initHwModal, openHwModal } from './components/Modal/HWModal';
import { initSwModal, openSwModal } from './components/Modal/SWModal';
import { initSwUserModal } from './components/Modal/SWUserModal';
import { initDomainModal, openDomainModal } from './components/Modal/DomainModal';
import { initUploadPreviewModal, openUploadPreview } from './components/Modal/UploadPreviewModal';
import { initDashboardDetailModal } from './components/Modal/DashboardDetailModal';
import { initGuide } from './components/Guide';
import { createIcons, Download, Upload, FileSpreadsheet, Plus, X, LayoutDashboard, Monitor, Server, Database, Laptop, CalendarClock, Key, Cpu, Layers, Users, Paperclip, Edit2, History, RefreshCcw, BookOpen } from 'lucide';
import { createIcons, Download, Upload, FileSpreadsheet, Plus, X, LayoutDashboard, Monitor, Server, Database, Laptop, CalendarClock, Key, Cpu, Layers, Users, Paperclip, Edit2, History, RefreshCcw, BookOpen, Settings } from 'lucide';
// --- DB 저장을 위한 세분화된 헬퍼 함수들 ---
async function apiBatchSave(url: string, data: any[], label: string) {
@@ -27,19 +28,20 @@ async function apiBatchSave(url: string, data: any[], label: string) {
console.log(`${label} DB 저장 완료`);
} catch (err) {
console.error(`${label} DB 저장 오류:`, err);
alert(`${label} 저장 중 오류가 발생했습니다: ${err.message}`);
alert(`${label} 저장 중 오류가 발생했습니다: ${(err as any).message}`);
}
}
const savePcToDB = () => apiBatchSave('http://localhost:3000/api/pc/batch', state.masterData.pc, '개인PC');
const saveServerToDB = () => apiBatchSave('http://localhost:3000/api/server/batch', state.masterData.server, '서버');
const saveStorageToDB = () => apiBatchSave('http://localhost:3000/api/storage/batch', state.masterData.storage, '스토리지');
const saveEquipToDB = () => apiBatchSave('http://localhost:3000/api/equip/batch', state.masterData.equip, '전산비품');
const saveMobileToDB = () => apiBatchSave('http://localhost:3000/api/mobile/batch', state.masterData.mobile, '모바일기기');
const saveSubSwToDB = () => apiBatchSave('http://localhost:3000/api/sw/sub/batch', state.masterData.subSw, '구독SW');
const savePermSwToDB = () => apiBatchSave('http://localhost:3000/api/sw/perm/batch', state.masterData.permSw, '영구SW');
const saveCloudToDB = () => apiBatchSave('http://localhost:3000/api/cloud/batch', state.masterData.cloud, '클라우드');
const saveSwUsersToDB = () => apiBatchSave('http://localhost:3000/api/sw-users/batch', state.masterData.swUsers, 'SW사용자');
const savePcToDB = () => apiBatchSave(`http://${location.hostname}:3000/api/pc/batch`, state.masterData.pc, '개인PC');
const saveServerToDB = () => apiBatchSave(`http://${location.hostname}:3000/api/server/batch`, state.masterData.server, '서버');
const saveStorageToDB = () => apiBatchSave(`http://${location.hostname}:3000/api/storage/batch`, state.masterData.storage, '스토리지');
const saveEquipToDB = () => apiBatchSave(`http://${location.hostname}:3000/api/equip/batch`, state.masterData.equip, '전산비품');
const saveMobileToDB = () => apiBatchSave(`http://${location.hostname}:3000/api/mobile/batch`, state.masterData.mobile, '모바일기기');
const saveSubSwToDB = () => apiBatchSave(`http://${location.hostname}:3000/api/sw/sub/batch`, state.masterData.subSw, '구독SW');
const savePermSwToDB = () => apiBatchSave(`http://${location.hostname}:3000/api/sw/perm/batch`, state.masterData.permSw, '영구SW');
const saveCloudToDB = () => apiBatchSave(`http://${location.hostname}:3000/api/cloud/batch`, state.masterData.cloud, '클라우드');
const saveSwUsersToDB = () => apiBatchSave(`http://${location.hostname}:3000/api/sw-users/batch`, state.masterData.swUsers, 'SW사용자');
const saveLogsToDB = () => apiBatchSave(`http://${location.hostname}:3000/api/logs/batch`, state.masterData.logs, '자산 로그');
// 화면 갱신 통합 핸들러 (대시보드 vs 리스트)
function refreshView() {
@@ -53,19 +55,6 @@ function refreshView() {
}
}
// 모든 소프트웨어 DB 동기화
async function saveAllSoftwareToDB() {
await Promise.all([
saveSubSwToDB(),
savePermSwToDB(),
saveCloudToDB(),
saveSwUsersToDB()
]);
// 저장 후 최신 데이터 다시 로드 (정합성)
await loadMasterDataFromDB();
refreshView();
}
// 모든 하드웨어 DB 동기화
async function saveAllHardwareToDB() {
await Promise.all([
@@ -73,21 +62,36 @@ async function saveAllHardwareToDB() {
saveServerToDB(),
saveStorageToDB(),
saveEquipToDB(),
saveMobileToDB()
saveMobileToDB(),
saveLogsToDB()
]);
await loadMasterDataFromDB();
refreshView();
}
// 모든 소프트웨어 DB 동기화
async function saveAllSoftwareToDB() {
await Promise.all([
saveSubSwToDB(),
savePermSwToDB(),
saveCloudToDB(),
saveSwUsersToDB(),
saveLogsToDB()
]);
// 저장 후 최신 데이터 다시 로드 (정합성)
await loadMasterDataFromDB();
refreshView();
}
// --- App Initialization ---
function initApp() {
console.log('🚀 ITAM Dedicated System Initializing...');
const mainContent = document.getElementById('main-content')!;
if (!mainContent) return;
const { closeAllModals } = initBaseModal();
try {
// 네비게이션 렌더링 및 콜백 연결
renderNavigation((tab) => {
if (tab === '대시보드') {
renderDashboard(mainContent);
@@ -96,10 +100,8 @@ function initApp() {
}
});
// 모달 초기화
initPcModal(() => saveAllHardwareToDB(), closeAllModals);
// 각종 모달 및 가이드 초기화
initHwModal(() => saveAllHardwareToDB(), closeAllModals);
initSwModal(() => saveAllSoftwareToDB(), closeAllModals);
initSwUserModal(() => {
@@ -109,19 +111,22 @@ function initApp() {
}, closeAllModals);
initDashboardDetailModal();
initDomainModal();
initUploadPreviewModal(async () => {
await loadMasterDataFromDB();
refreshView();
});
initGuide();
// DB 데이터 로드 및 초기 화면 렌더링
loadMasterDataFromDB().then((success) => {
if (success) {
refreshView();
}
});
} catch (e) { console.error('❌ Initialization failed:', e); }
// 초기 로드 시 대시보드 렌더링
renderDashboard(mainContent);
// DB에서 데이터 로드 후 화면 갱신
loadMasterDataFromDB().then((success) => {
if (success) {
if (state.activeSubTab === '대시보드') renderDashboard(mainContent);
else renderSWTable(mainContent);
}
});
console.log('🚀 ITAM App Version 2.1.0 Loaded');
// 버튼 이벤트 바인딩
document.getElementById('btn-download-template')?.addEventListener('click', () => downloadTemplate());
@@ -131,42 +136,47 @@ function initApp() {
uploadInput?.addEventListener('change', async (e) => {
const file = (e.target as HTMLInputElement).files?.[0];
if (file) {
const data = await parseExcel(file);
state.masterData = data;
await Promise.all([
saveAllHardwareToDB(),
saveAllSoftwareToDB()
]);
renderSWTable(mainContent);
console.log('📂 File selected:', file.name);
try {
const data = await parseExcel(file);
console.log('📊 Parsed data keys:', Object.keys(data));
openUploadPreview(data);
// Clear input so same file can be selected again
uploadInput.value = '';
} catch (err) {
alert('엑셀 파일을 읽는 중 오류가 발생했습니다.');
console.error(err);
}
}
});
document.getElementById('btn-add-asset')?.addEventListener('click', () => {
const tab = state.activeSubTab;
const cat = state.activeCategory;
if (cat === 'hw') {
// 하드웨어 대시보드 또는 개별 탭에서 추가
const defaultType = (tab === '대시보드') ? '' : tab;
openHwModal({
id: Math.random().toString(36).substring(2, 9),
type: defaultType,
: '한맥', : '', : '', : '', MACaddress: '', HW사양: '', OS: '', : '', : ''
} as any, 'add');
let defaultType = (tab === '개인PC') ? 'PC' : (tab === '서버' ? '서버' : (tab === '스토리지' ? '스토리지' : (tab === '전산비품' ? 'CPU' : '모바일')));
openHwModal({ id: Math.random().toString(36).substring(2, 9), type: defaultType, : '한맥', : '', : '', : '', MACaddress: '', HW사양: '', OS: '', : '', : '' } as any, 'add');
} else if (cat === 'sw') {
// 소프트웨어 대시보드 또는 개별 탭에서 추가
let defaultType = tab;
if (tab === '대시보드') defaultType = '구독SW'; // SW는 기본 레이아웃을 위해 하나 지정하되 필드는 빈값
openSwModal({
id: Math.random().toString(36).substring(2, 9),
type: defaultType, : '', : '', 수량: 1, : '', : '', : '', : '한맥'
} as any, 'add');
openSwModal({ id: Math.random().toString(36).substring(2, 9), type: tab === '대시보드' ? '구독SW' : tab, : '', : '', 수량: 1, : '', : '', : '', : '한맥' } as any, 'add');
} else if (cat === 'ops') {
if (tab === '도메인') openDomainModal(null);
}
});
// 시크릿 클라우드 트리거
document.getElementById('secret-cloud-trigger')?.addEventListener('click', () => {
state.activeCategory = 'sw';
state.activeSubTab = '클라우드';
const mainContent = document.getElementById('main-content')!;
renderSWTable(mainContent);
});
createIcons({
icons: { Download, Upload, FileSpreadsheet, Plus, X, LayoutDashboard, Monitor, Server, Database, Laptop, CalendarClock, Key, Cpu, Layers, Users, Paperclip, Edit2, History, RefreshCcw, BookOpen }
icons: { Download, Upload, FileSpreadsheet, Plus, X, LayoutDashboard, Monitor, Server, Database, Laptop, CalendarClock, Key, Cpu, Layers, Users, Paperclip, Edit2, History, RefreshCcw, BookOpen, Settings }
});
window.addEventListener('refresh-view', () => {
console.log('🔄 Refreshing view due to event');
refreshView();
});
}

View File

@@ -1,7 +1,67 @@
:root {
--primary-color: #1E5149;
--primary-hover: #153c36;
--primary-light: #edf2f1;
/* --- System Colors (Added) --- */
--color-red: #F21D0D;
--color-pink: #E8175E;
--color-magenta: #B92ED1;
--color-purple: #6D3DC2;
--color-navy: #4255bd;
--color-blue: #0D8DF2;
--color-cyan: #03AEFC;
--color-green: #4DB251;
--color-yellow: #FFBF00;
--color-orange: #FF9800;
--color-dahong: #FF3D00;
--color-brown: #A0705F;
--color-iron: #7F7F7F;
--color-steel: #688897;
--color-red-light: #FEE9E7;
--color-pink-light: #FDE8EF;
--color-magenta-light: #F8EBFB;
--color-purple-light: #F1ECF9;
--color-navy-light: #EDEEF9;
--color-blue-light: #E7F4FE;
--color-cyan-light: #E6F7FF;
--color-green-light: #EEF8EE;
--color-yellow-light: #FFF9E6;
--color-orange-light: #FFF5E6;
--color-dahong-light: #FFECE6;
--color-brown-light: #F6F1EF;
--color-iron-light: #F3F3F3;
--color-steel-light: #F0F4F5;
--color-red-medium: #FAA59E;
--color-pink-medium: #F6A2BF;
--color-magenta-medium: #E3ABEC;
--color-purple-medium: #C5B1E7;
--color-navy-medium: #B3BBE5;
--color-blue-medium: #9ED1FA;
--color-cyan-medium: #9ADFFE;
--color-green-medium: #B8E0B9;
--color-yellow-medium: #FFE599;
--color-orange-medium: #FFD699;
--color-dahong-medium: #FFB199;
--color-brown-medium: #D9C6BF;
--color-iron-medium: #CCCCCC;
--color-steel-medium: #C3CFD5;
/* --- Primary Brand Levels --- */
--primary-lv-0: #E9EEED;
--primary-lv-1: #D2DCDB;
--primary-lv-2: #A5B9B6;
--primary-lv-3: #789792;
--primary-lv-4: #4B746D;
--primary-lv-5: #35635C;
--primary-lv-6: #1E5149;
--primary-lv-7: #1B443D;
--primary-lv-8: #193833;
--primary-lv-9: #162A27;
/* --- Legacy Aliases (Maintained for compatibility) --- */
--primary-color: var(--primary-lv-6);
--primary-hover: var(--primary-lv-5);
--primary-light: var(--primary-lv-0);
--text-main: #111827;
--text-muted: #6B7280;
--border-color: #E5E7EB;
@@ -9,7 +69,7 @@
--bg-light: #FAFAFA;
--sidebar-bg: #ffffff;
--white: #FFFFFF;
--danger: #dc2626;
--danger: var(--color-red);
--dash-primary: #6cc020;
--dash-light: #f2f9ec;
@@ -22,14 +82,15 @@
box-sizing: border-box;
margin: 0;
padding: 0;
letter-spacing: -0.02em;
/* 모든 요소에 자간 규칙 일괄 적용 */
}
body {
font-family: 'Pretendard Variable', Pretendard, sans-serif;
font-family: 'Pretendard Variable', Pretendard, -apple-system, BlinkMacSystemFont, system-ui, Roboto, 'Helvetica Neue', 'Segoe UI', 'Apple SD Gothic Neo', 'Noto Sans KR', 'Malgun Gothic', sans-serif;
color: var(--text-main);
background-color: var(--bg-color);
line-height: 1.5;
letter-spacing: -0.02em;
font-size: 14px;
overflow: hidden;
}
@@ -57,14 +118,32 @@ body {
gap: 1.5rem;
}
.brand {
display: flex;
align-items: center;
gap: 0.75rem;
}
.main-logo {
height: 34px;
width: auto;
}
.brand h1 {
font-size: 1.2rem;
font-size: 1.1rem;
/* 전체적으로 살짝 축소 */
font-weight: 800;
color: var(--text-main);
white-space: nowrap;
margin-right: 1rem;
}
.brand h1 span { color: var(--primary-color); }
.brand h1 .sub-title {
font-size: 0.85rem;
/* 영문 제목은 더 작게 */
color: var(--primary-color);
font-weight: 600;
margin-left: 0.25rem;
}
.integrated-nav {
flex: 1;
@@ -93,7 +172,7 @@ body {
}
.lnb-shelf {
display: none;
display: none;
align-items: center;
gap: 0.25rem;
padding: 0 0.75rem;
@@ -118,7 +197,11 @@ body {
white-space: nowrap;
}
.lnb-item:hover { color: var(--primary-color); background-color: var(--bg-color); }
.lnb-item:hover {
color: var(--primary-color);
background-color: var(--bg-color);
}
.lnb-item.active {
color: var(--primary-color);
background-color: var(--primary-light);
@@ -126,12 +209,23 @@ body {
}
@keyframes fadeIn {
from { opacity: 0; transform: translateX(-5px); }
to { opacity: 1; transform: translateX(0); }
from {
opacity: 0;
transform: translateX(-5px);
}
to {
opacity: 1;
transform: translateX(0);
}
}
/* --- Global Actions & Buttons --- */
.header-actions { display: flex; gap: 0.3rem; align-items: center; }
.header-actions {
display: flex;
gap: 0.3rem;
align-items: center;
}
.btn {
display: inline-flex;
@@ -145,28 +239,136 @@ body {
cursor: pointer;
height: 28px;
line-height: 1;
white-space: nowrap; /* 텍스트 줄바꿈 방지 */
flex-shrink: 0; /* 크기 찌그러짐 방지 */
}
.btn i, .btn svg { width: 12px !important; height: 12px !important; }
.btn i,
.btn svg {
width: 12px !important;
height: 12px !important;
}
.btn-primary { background-color: var(--primary-color); color: var(--white); border: 1px solid var(--primary-color); }
.btn-outline { background-color: transparent; color: var(--text-muted); border: 1px solid var(--border-color); }
.btn-danger { color: var(--danger) !important; border-color: var(--danger) !important; }
.btn-primary {
background-color: var(--primary-color);
color: var(--white);
border: 1px solid var(--primary-color);
}
.btn-outline {
background-color: transparent;
color: var(--text-muted);
border: 1px solid var(--border-color);
}
.btn-danger {
color: var(--danger) !important;
border-color: var(--danger) !important;
}
/* --- Layout Frame --- */
.content-area {
flex: 1;
padding: 1.25rem 1.5rem;
padding: 1.25rem 2rem 0; /* 상단 여백 1.25rem 추가 */
overflow: hidden;
/* 전체 스크롤 차단 */
display: flex;
flex-direction: column;
}
.view-container {
flex: 1;
width: 100%;
height: 100%;
display: flex;
flex-direction: column;
gap: 0.75rem;
overflow: hidden;
/* 내부 스크롤을 유도하기 위해 설정 */
}
.hidden { display: none !important; }
.text-nowrap { white-space: nowrap; }
/* --- Footer --- */
.main-footer {
height: 40px;
background-color: var(--white);
border-top: 1px solid var(--border-color);
display: flex;
align-items: center;
justify-content: flex-end;
padding: 0 1.5rem;
flex-shrink: 0;
}
.main-footer p {
font-family: 'Pretendard Variable', Pretendard, sans-serif;
font-size: 0.75rem;
font-weight: 300;
line-height: 1.25rem;
letter-spacing: -0.0175rem;
color: #777777;
user-select: none;
pointer-events: all;
-webkit-user-drag: none;
margin: 0;
padding: 0;
box-sizing: border-box;
}
.hidden {
display: none !important;
}
.text-nowrap {
white-space: nowrap;
}
/* --- Utility Styles --- */
.badge {
padding: 2px 6px;
border-radius: 4px;
font-size: 11px;
font-weight: 700;
white-space: nowrap;
}
.badge-primary {
background-color: var(--primary-color);
color: white;
}
.badge-muted {
background-color: #9CA3AF;
color: white;
}
.text-tag {
color: var(--text-muted);
font-size: 11px;
padding: 1px 5px;
border: 1px solid var(--border-color);
border-radius: 3px;
background-color: var(--bg-light);
}
.font-bold {
font-weight: 700;
}
/* --- Responsive Design (Tablet & Mobile) --- */
@media (max-width: 1200px) {
.header-container { gap: 0.75rem; padding: 0 1rem; }
.brand h1 { font-size: 1rem; }
.brand h1 .sub-title { font-size: 0.75rem; }
}
@media (max-width: 992px) {
.main-header { height: auto; padding: 0.5rem 0; }
.header-container { flex-direction: column; align-items: flex-start; gap: 0.5rem; }
.integrated-nav { width: 100%; justify-content: flex-start; border-top: 1px solid var(--border-color); padding-top: 0.5rem; }
.header-actions { width: 100%; justify-content: flex-end; padding-top: 0.5rem; }
.content-area { padding: 0 1rem; }
}
@media (max-width: 768px) {
.brand h1 .sub-title { display: none; } /* 아주 좁은 화면에선 영문명 숨김 */
.header-actions .btn span { display: none; } /* 버튼 텍스트 숨기고 아이콘만 표시 */
.header-actions .btn { padding: 0 0.5rem; }
}

View File

@@ -1,112 +1,24 @@
/* ITAM Guide Modal Styles */
:root {
--guide-modal-width: 1060px;
--guide-modal-height: 92vh;
--guide-primary: #1E5149;
--guide-accent: #6cc020;
}
/* ITAM Guide Modal Styles - Updated to match common modal style */
/* Floating Trigger Button - REMOVED (now in header) */
.guide-trigger {
display: none;
}
/* Modal Overlay */
.guide-overlay {
position: fixed;
top: 0;
left: 0;
width: 100vw;
height: 100vh;
background-color: rgba(0, 0, 0, 0.5);
backdrop-filter: blur(4px);
z-index: 2000;
opacity: 0;
visibility: hidden;
transition: all 0.3s ease;
display: flex;
align-items: center;
justify-content: center;
}
.guide-overlay.active {
opacity: 1;
visibility: visible;
}
/* Guide Modal */
.guide-modal {
width: var(--guide-modal-width);
max-width: 94vw;
height: var(--guide-modal-height);
background-color: #ffffff;
border-radius: 14px;
overflow: hidden;
box-shadow: 0 24px 60px rgba(0,0,0,0.3);
display: flex;
flex-direction: column;
transform: translateY(20px) scale(0.97);
opacity: 0;
transition: all 0.4s cubic-bezier(0.4, 0, 0.2, 1);
}
.guide-overlay.active .guide-modal {
transform: translateY(0) scale(1);
opacity: 1;
}
/* Header */
.guide-header {
padding: 1.1rem 1.5rem;
/* Tab Container (below header) */
.guide-tabs-container {
background: #FAFAFA;
border-bottom: 1px solid var(--border-color);
display: flex;
justify-content: space-between;
align-items: center;
background: linear-gradient(135deg, var(--guide-primary), #2a6d63);
color: white;
flex-shrink: 0;
}
.guide-header h2 {
font-size: 1.15rem;
font-weight: 700;
display: flex;
align-items: center;
gap: 10px;
margin: 0;
}
.btn-close-guide {
background: rgba(255, 255, 255, 0.12);
border: none;
color: white;
cursor: pointer;
width: 30px;
height: 30px;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
transition: background 0.2s;
}
.btn-close-guide:hover {
background: rgba(255, 255, 255, 0.3);
}
/* ===== Tab Navigation ===== */
.guide-tabs {
display: flex;
border-bottom: 1px solid var(--border-color);
background: #f8faf9;
padding: 0 1.5rem;
flex-shrink: 0;
gap: 2px;
overflow-x: auto;
}
.guide-tabs {
display: flex;
gap: 2px;
overflow-x: auto;
scrollbar-width: none;
}
.guide-tabs::-webkit-scrollbar { display: none; }
.guide-tab {
padding: 0.7rem 1rem;
padding: 0.75rem 1.25rem;
font-size: 13px;
font-weight: 600;
color: var(--text-muted);
@@ -114,37 +26,27 @@
border-bottom: 2px solid transparent;
transition: all 0.2s ease;
white-space: nowrap;
position: relative;
top: 1px;
}
.guide-tab:hover {
color: var(--guide-primary);
color: var(--primary-color);
background: rgba(30, 81, 73, 0.04);
}
.guide-tab.active {
color: var(--guide-primary);
border-bottom-color: var(--guide-primary);
color: var(--primary-color);
border-bottom-color: var(--primary-color);
background: white;
}
/* ===== Content Area ===== */
/* Content Area */
.guide-body {
flex: 1;
overflow-y: auto;
padding: 0;
scrollbar-width: none; /* Firefox */
-ms-overflow-style: none; /* IE/Edge */
}
.guide-body::-webkit-scrollbar {
display: none; /* Chrome/Safari */
padding-bottom: 2rem;
}
.guide-tab-panel {
display: none;
padding: 1.5rem 2rem 2rem;
padding: 1.5rem 0;
animation: guideFadeIn 0.3s ease;
}
@@ -157,12 +59,12 @@
to { opacity: 1; transform: translateY(0); }
}
/* ===== Section Styles ===== */
/* Section Styles */
.guide-section {
display: flex;
flex-direction: column;
gap: 0.75rem;
margin-bottom: 1.5rem;
margin-bottom: 2rem;
}
.guide-section:last-child {
@@ -171,84 +73,66 @@
.guide-section h3 {
font-size: 1rem;
padding-bottom: 0.4rem;
border-bottom: 2px solid var(--guide-primary);
color: var(--guide-primary);
padding-bottom: 0.5rem;
border-bottom: 2px solid var(--primary-color);
color: var(--primary-color);
margin: 0;
display: flex;
align-items: center;
gap: 8px;
}
.guide-section h4 {
font-size: 0.9rem;
color: var(--text-main);
margin: 0.6rem 0 0.2rem;
font-weight: 700;
}
.guide-text {
font-size: 13px;
color: var(--text-muted);
color: var(--text-main);
line-height: 1.7;
margin: 0;
}
.guide-text strong {
color: var(--text-main);
}
/* ===== Flowchart ===== */
/* Flowchart Styles */
.flow-container {
display: flex;
flex-direction: column;
align-items: center;
gap: 0.5rem;
padding: 1.25rem;
background-color: #f8faf9;
border-radius: 12px;
border: 1px dashed #d0d7d5;
gap: 0.75rem;
padding: 1.5rem;
background-color: #f9fafb;
border-radius: 8px;
border: 1px solid var(--border-color);
}
.flow-row {
display: flex;
width: 100%;
gap: 0.75rem;
align-items: stretch;
gap: 1rem;
align-items: center;
}
.flow-step {
flex: 1;
background: white;
padding: 0.65rem 0.9rem;
border-radius: 8px;
padding: 1rem;
border-radius: 6px;
border: 1px solid var(--border-color);
display: flex;
align-items: flex-start;
gap: 10px;
transition: transform 0.2s, box-shadow 0.2s;
}
.flow-step:hover {
transform: translateY(-2px);
box-shadow: 0 4px 14px rgba(0,0,0,0.06);
border-color: var(--guide-primary);
gap: 12px;
box-shadow: 0 1px 3px rgba(0,0,0,0.05);
}
.flow-step .step-number {
width: 22px;
height: 22px;
min-width: 22px;
width: 24px;
height: 24px;
min-width: 24px;
border-radius: 50%;
background-color: var(--guide-primary);
background-color: var(--primary-color);
color: white;
font-size: 11px;
font-size: 12px;
font-weight: 700;
display: flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
margin-top: 1px;
}
.flow-step .step-label {
@@ -259,91 +143,46 @@
}
.flow-step .step-desc {
font-size: 11.5px;
font-size: 12px;
color: var(--text-muted);
line-height: 1.5;
margin-top: 2px;
}
.flow-arrow {
color: #b5c4c0;
width: 16px !important;
height: 16px !important;
margin-top: 4px;
}
.flow-arrow-right {
color: #b5c4c0;
width: 16px !important;
height: 16px !important;
color: var(--text-muted);
display: flex;
align-items: center;
flex-shrink: 0;
}
/* ===== Info Table ===== */
/* Info Table Style */
.guide-info-table {
width: 100%;
border-collapse: collapse;
font-size: 12.5px;
margin-top: 0.5rem;
font-size: 13px;
}
.guide-info-table th {
background: #f0f4f3;
color: var(--guide-primary);
background: #f8faf9;
color: var(--primary-color);
font-weight: 700;
padding: 0.5rem 0.75rem;
padding: 0.75rem;
text-align: left;
border-bottom: 2px solid var(--guide-primary);
border-bottom: 1px solid var(--border-color);
}
.guide-info-table td {
padding: 0.45rem 0.75rem;
border-bottom: 1px solid var(--border-color);
padding: 0.75rem;
border-bottom: 1px solid #f3f4f6;
color: var(--text-main);
line-height: 1.5;
}
.guide-info-table tr:hover td {
background: #f8faf9;
}
/* ===== Tip Box ===== */
/* Tip Box Style */
.guide-tip {
background: linear-gradient(135deg, #f0f9eb, #e8f5e0);
border-left: 4px solid var(--guide-accent);
border-radius: 0 8px 8px 0;
padding: 0.75rem 1rem;
font-size: 12.5px;
color: #2d5016;
background: var(--primary-light);
border-left: 4px solid var(--primary-color);
padding: 1rem;
font-size: 13px;
color: var(--primary-color);
line-height: 1.6;
}
.guide-tip strong {
color: #1a3a0a;
}
/* ===== Warning Box ===== */
.guide-warn {
background: linear-gradient(135deg, #fff8ed, #fff3e0);
border-left: 4px solid #ff9800;
border-radius: 0 8px 8px 0;
padding: 0.75rem 1rem;
font-size: 12.5px;
color: #7a4a00;
line-height: 1.6;
}
/* ===== Badge ===== */
.guide-badge {
display: inline-block;
padding: 2px 8px;
border-radius: 4px;
font-size: 11px;
font-weight: 700;
}
.guide-badge.green { background: #e6f4ea; color: #137333; }
.guide-badge.orange { background: #fff4e5; color: #b45309; }
.guide-badge.blue { background: #e8f0fe; color: #1a56db; }
.guide-badge.red { background: #fce8e6; color: #c5221f; }

View File

@@ -179,6 +179,10 @@
background-color: var(--white);
}
.form-group textarea {
resize: none;
}
.form-group input:focus,
.form-group select:focus,
.form-group textarea:focus {
@@ -225,6 +229,9 @@
}
.history-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 1rem;
}
@@ -237,6 +244,35 @@
color: var(--text-main);
}
/* 읽기 전용 필드 (자산번호 등) 통일 스타일 */
.is-readonly-field {
border-color: transparent !important;
background-color: transparent !important;
pointer-events: none !important;
color: var(--text-main) !important;
font-weight: 600 !important;
cursor: default;
padding-left: 0 !important;
}
/* 입력 필드 + 버튼 그룹 (자산번호 생성 등) */
.input-with-btn {
display: flex;
gap: 0.5rem;
align-items: center;
width: 100%;
}
.input-with-btn input {
flex: 1;
min-width: 0; /* flex 컨테이너 안에서 너비 압축 방지 */
}
.input-with-btn .btn {
flex-shrink: 0;
white-space: nowrap;
}
.history-timeline {
flex: 1;
overflow-y: auto;

View File

@@ -4,11 +4,10 @@
display: flex;
flex-wrap: wrap;
gap: 1.25rem;
background-color: var(--white);
padding: 1.5rem;
border: 1px solid var(--border-color);
border-radius: 8px;
padding: 1.5rem 0; /* 좌우 패딩 제거, 상하 여백 유지 */
border-bottom: 1px solid var(--border-color); /* 하단 구분선만 남김 */
align-items: flex-end;
margin-bottom: 0.5rem;
}
.search-item {
@@ -23,7 +22,7 @@
.search-item label {
font-size: 11px;
font-weight: 800;
font-weight: 700;
color: var(--text-muted);
}
@@ -35,71 +34,138 @@
border-radius: 4px;
font-size: 14px;
outline: none;
background-color: var(--white);
}
/* 셀렉트 박스 화살표 여백 절대 고정 (수정 금지) */
.search-item select {
padding-right: 2.5rem;
appearance: none;
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='12' height='12' viewBox='0 0 24 24' fill='none' stroke='%236B7280' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='m6 9 6 6 6-9'/%3E%3C/svg%3E");
background-repeat: no-repeat;
background-position: right 0.75rem center;
padding-right: 2.5rem !important;
cursor: pointer;
}
.search-item input:focus,
.search-item select:focus {
border-color: var(--primary-color);
}
/* 필터 초기화 버튼 크기 조정 (입력창 높이 38px에 맞춤) */
.btn-reset {
margin-left: auto;
height: 38px !important;
padding: 0 0.8rem !important;
font-size: 12px !important;
display: inline-flex !important;
align-items: center !important;
gap: 0.35rem !important;
border-radius: 4px !important;
color: var(--text-muted) !important;
padding: 0 1.2rem !important;
display: inline-flex;
align-items: center;
justify-content: center;
}
.table-container {
flex: 1;
background-color: var(--white);
border-top: 1px solid var(--border-color);
border-bottom: 1px solid var(--border-color);
border-left: none;
border-right: none;
overflow: auto;
flex: 1;
min-height: 0;
position: relative;
-webkit-overflow-scrolling: touch;
}
table {
width: 100%;
border-collapse: collapse;
border-collapse: separate;
border-spacing: 0;
table-layout: auto;
}
th, td {
padding: 1rem 1.5rem;
border-bottom: 1px solid var(--border-color);
text-align: left;
padding: 0.8rem 1.2rem;
border-bottom: 1px solid #F3F4F6;
text-align: left; /* 기본은 좌측 정렬 */
white-space: nowrap;
}
th {
background-color: #FAFAFA;
font-weight: 700;
color: var(--text-muted);
font-size: 12px;
thead {
position: sticky;
top: 0;
z-index: 10;
box-shadow: inset 0 -1px 0 var(--border-color);
text-transform: uppercase;
z-index: 50;
}
th {
background-color: #FAFAFA !important;
font-size: 13px;
font-weight: 600;
color: var(--text-muted);
position: sticky;
top: 0;
z-index: 50;
box-shadow: inset 0 1px 0 var(--border-color), inset 0 -1px 0 var(--border-color); /* 상하 테두리 보정 */
text-transform: none;
}
td {
font-size: 14px;
font-size: 13px;
color: var(--text-main);
font-weight: 400;
}
tbody tr:hover {
background-color: #F9FAFB;
}
.btn-sm {
padding: 0.25rem 0.5rem;
font-size: 11px;
height: 24px;
/* 정렬 클래스 강제 적용 */
.text-center { text-align: center !important; }
.text-right { text-align: right !important; }
.text-left { text-align: left !important; }
.btn-icon {
padding: 0.25rem;
border: none;
background: none;
cursor: pointer;
color: var(--text-muted);
transition: color 0.2s;
}
.btn-icon:hover {
color: var(--primary-color);
}
.btn-icon svg {
width: 16px;
height: 16px;
}
/* --- Table Sorting --- */
th.sortable {
cursor: pointer;
user-select: none;
transition: background-color 0.2s;
position: relative;
padding-right: 1.8rem !important; /* 아이콘 공간 확보 */
}
th.sortable:hover {
background-color: #F3F4F6;
color: var(--primary-color);
}
th.sortable::after {
content: '↕';
position: absolute;
right: 0.6rem;
top: 50%;
transform: translateY(-50%);
font-size: 11px;
opacity: 0.3;
transition: all 0.2s;
}
th.sortable.asc::after {
content: '▲';
opacity: 1;
color: var(--primary-color);
}
th.sortable.desc::after {
content: '▼';
opacity: 1;
color: var(--primary-color);
}

View File

@@ -1,104 +1,194 @@
import { state } from '../../core/state';
import { HardwareAsset } from '../../core/excelHandler';
import { openDashboardDetail } from '../../components/Modal/DashboardDetailModal';
import { normalizeDate } from '../../core/utils';
import { openHwModal } from '../../components/Modal/HWModal';
import { calculateAssetAge, normalizeDate } from '../../core/utils';
declare var Chart: any;
export function renderHwDashboard(container: HTMLElement) {
const types = ['개인PC', '서버', '스토리지', '전산비품'];
const units = ['대', '대', '대', '개'];
const groups: any = {};
const allHw = state.masterData.hw || [];
types.forEach(t => { groups[t] = { idle: [], active: [] }; });
// 1. 데이터 가공
let totalAge = 0;
let countWithDate = 0;
let over5YearsCount = 0;
let latestAsset: HardwareAsset | null = null;
let latestYear = 0;
state.masterData.hw.forEach(a => {
if (!groups[a.type]) return;
if (isHwIdle(a)) groups[a.type].idle.push(a);
else groups[a.type].active.push(a);
const ageGroups = { stable: 0, warning: 0, critical: 0 };
const yearlyCount: Record<string, number> = {};
allHw.forEach(a => {
const pDate = a. || (a as any).purchase_date;
if (!pDate) return;
const age = calculateAssetAge(pDate);
totalAge += age;
countWithDate++;
// 노후도 분류
if (age >= 5) {
over5YearsCount++;
ageGroups.critical++;
} else if (age >= 3) {
ageGroups.warning++;
} else {
ageGroups.stable++;
}
// 연도별 도입 현황 추출
const year = normalizeDate(pDate).split('-')[0];
if (year && year.length === 4) {
yearlyCount[year] = (yearlyCount[year] || 0) + 1;
const yNum = parseInt(year);
if (yNum > latestYear) {
latestYear = yNum;
latestAsset = a;
}
}
});
let usageCards = '';
types.forEach((t, i) => {
const total = groups[t].idle.length + groups[t].active.length;
const used = groups[t].active.length;
const per = total > 0 ? Math.round((used / total) * 100) : 0;
const barColor = per >= 50 ? 'var(--dash-primary)' : 'var(--dash-danger)';
usageCards += `
<div class="dashboard-card" data-action="idle" data-type="${t}" style="padding: 1.25rem 1.5rem; cursor:pointer; min-height:auto;">
<span style="font-size:1rem; font-weight:700; color:var(--text-main);">${t} 사용현황</span>
<div style="font-size: 0.8125rem; color:var(--text-muted); margin-bottom: 1rem;">
${total}${units[i]}${used}${units[i]} 사용 중
</div>
<div style="font-size: 2rem; font-weight:700; color:${barColor}; line-height:1;">${per}%</div>
<div style="width:100%; height:4px; background-color:var(--border-color); border-radius:2px; overflow:hidden; margin-top:0.75rem;">
<div style="width:${per}%; height:100%; background-color:${barColor};"></div>
</div>
</div>`;
});
const avgAge = countWithDate > 0 ? (totalAge / countWithDate).toFixed(1) : '0';
const over5Rate = allHw.length > 0 ? Math.round((over5YearsCount / allHw.length) * 100) : 0;
// 교체 시급 대상 TOP 10 (오래된 순)
const criticalList = [...allHw]
.filter(a => (a. || (a as any).purchase_date))
.sort((a, b) => {
const dateA = new Date(normalizeDate(a. || (a as any).purchase_date)).getTime();
const dateB = new Date(normalizeDate(b. || (b as any).purchase_date)).getTime();
return dateA - dateB;
})
.slice(0, 10);
// 2. UI 렌더링
container.innerHTML = `
<div class="view-container">
<h3 class="dashboard-section-title">자산 사용현황 요약</h3>
<div class="dashboard-grid">${usageCards}</div>
<h3 class="dashboard-section-title">하드웨어 보유 통계</h3>
<div class="dashboard-layout-2col">
<div class="dashboard-header-stats" style="display: grid; grid-template-columns: repeat(3, 1fr); gap: 1.5rem; margin-bottom: 2rem;">
<div class="dashboard-card" style="min-height:auto;">
<span style="font-size:1rem; font-weight:700; color:var(--text-main);">전체 평균 사용 연수</span>
<div style="font-size: 0.8125rem; color:var(--text-muted); margin-bottom: 1rem;">전체 자산 기준 (권장 4.5년)</div>
<div style="font-size: 2rem; font-weight:700; color:var(--dash-primary);">${avgAge}년</div>
<div style="width: 100%; height: 4px; background-color: var(--dash-primary); border-radius: 2px; margin-top: 0.5rem;"></div>
</div>
<div class="dashboard-card" style="min-height:auto;">
<span style="font-size:1rem; font-weight:700; color:var(--text-main);">5년 이상 노후 자산 비율</span>
<div style="font-size: 0.8125rem; color:var(--text-muted); margin-bottom: 1rem;">총 ${over5YearsCount}대 해당</div>
<div style="font-size: 2rem; font-weight:700; color:${over5Rate >= 20 ? 'var(--dash-danger)' : 'var(--dash-primary)'};">${over5Rate}%</div>
<div style="width: 100%; height: 4px; background-color: ${over5Rate >= 20 ? 'var(--dash-danger)' : 'var(--dash-primary)'}; border-radius: 2px; margin-top: 0.5rem;"></div>
</div>
<div class="dashboard-card" style="min-height:auto;">
<span style="font-size:1rem; font-weight:700; color:var(--text-main);">최신 도입 모델 (${latestYear}년)</span>
<div style="font-size: 0.8125rem; color:var(--text-muted); margin-bottom: 1rem;">자산번호: ${(latestAsset as any)?. || '-'}</div>
<div style="font-size: 1.25rem; font-weight:700; color:var(--primary-color); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; height: 3rem; display: flex; align-items: center;" title="${(latestAsset as any)?. || '정보 없음'}">
${(latestAsset as any)?. || '정보 없음'}
</div>
<div style="width: 100%; height: 4px; background-color: var(--primary-color); border-radius: 2px; margin-top: 0.5rem;"></div>
</div>
</div>
<div class="dashboard-layout-2col" style="margin-bottom: 2rem;">
<div class="dashboard-card">
<h4 style="margin-bottom:1rem; font-size:0.9rem; color:var(--text-muted);">자산 유형별 보유 현황</h4>
<canvas id="chart-hw-types"></canvas>
<h4 class="card-title">자산 노후도 분포</h4>
<div style="height: 250px;"><canvas id="chart-aging-dist"></canvas></div>
</div>
<div class="dashboard-card">
<h4 style="margin-bottom:1rem; font-size:0.9rem; color:var(--text-muted);">구매법인별 자산 분포</h4>
<canvas id="chart-hw-corps"></canvas>
<h4 class="card-title">연도별 자산 도입 추이</h4>
<div style="height: 250px;"><canvas id="chart-purchase-trend"></canvas></div>
</div>
</div>
<h3 class="dashboard-section-title">⚠️ 교체 검토 대상 (가장 오래된 자산 TOP 10)</h3>
<div class="table-container" style="background: white; border-radius: 8px; border: 1px solid var(--border-color);">
<table>
<thead>
<tr>
<th>순위</th>
<th>자산번호</th>
<th>유형</th>
<th>모델명</th>
<th>사용자/담당자</th>
<th>구매연월</th>
<th>연령</th>
</tr>
</thead>
<tbody>
${criticalList.map((a, i) => `
<tr class="clickable-row" data-id="${a.id}">
<td style="text-align:center; font-weight:600; color:var(--text-muted)">${i + 1}</td>
<td>${a. || '-'}</td>
<td><span class="badge-type">${a.type}</span></td>
<td>${a. || a. || '-'}</td>
<td>${a. || a._정 || '-'}</td>
<td style="text-align:center;">${a. || (a as any).purchase_date || '-'}</td>
<td style="text-align:center;"><strong style="color:${calculateAssetAge(a. || (a as any).purchase_date) >= 5 ? 'var(--danger)' : 'inherit'}">${calculateAssetAge(a. || (a as any).purchase_date)}년</strong></td>
</tr>
`).join('')}
</tbody>
</table>
</div>
</div>
`;
// 3. 차트 초기화
setTimeout(() => {
if (typeof Chart === 'undefined') return;
const ctxType = (document.getElementById('chart-hw-types') as HTMLCanvasElement)?.getContext('2d');
const ctxCorp = (document.getElementById('chart-hw-corps') as HTMLCanvasElement)?.getContext('2d');
if (ctxType) {
const chart = new Chart(ctxType, {
type: 'doughnut',
data: { labels: types, datasets: [{ data: types.map(t => state.masterData.hw.filter(a => a.type === t).length), backgroundColor: ['#1E5149', '#3b82f6', '#10b981', '#f59e0b'] }] },
options: { responsive: true, maintainAspectRatio: false, plugins: { legend: { position: 'right' } } }
initAgingCharts(ageGroups, yearlyCount);
// 행 클릭 이벤트 바인딩
container.querySelectorAll('.clickable-row').forEach(row => {
row.addEventListener('click', () => {
const id = row.getAttribute('data-id');
const asset = allHw.find(h => h.id === id);
if (asset) openHwModal(asset, 'view');
});
state.activeCharts.push(chart);
}
if (ctxCorp) {
const corps = ['한맥', '삼안', '바론'];
const chart = new Chart(ctxCorp, {
type: 'bar',
data: { labels: corps, datasets: [{ label: '보유 수량', data: corps.map(c => state.masterData.hw.filter(a => a. === c).length), backgroundColor: 'rgba(30, 81, 73, 0.7)', borderRadius: 4 }] },
options: { responsive: true, maintainAspectRatio: false, plugins: { legend: { display: false } } }
});
state.activeCharts.push(chart);
}
}, 100);
container.querySelectorAll('[data-action="idle"]').forEach(card => {
card.addEventListener('click', () => {
const t = card.getAttribute('data-type')!;
openDashboardDetail(`[${t}] 유휴 자산 목록`, groups[t].idle);
});
});
}, 100);
}
function isHwIdle(a: HardwareAsset) {
if (a.type === '개인PC') return !a. || a..trim() === '' || a..trim() === '-';
if (a.type === '스토리지') return !a._정 || a._정.trim() === '' || a._정.trim() === '-';
return !a. || a..trim() === '' || a..trim() === '-';
}
function initAgingCharts(ageGroups: any, yearlyCount: Record<string, number>) {
const agingCtx = document.getElementById('chart-aging-dist') as HTMLCanvasElement;
if (agingCtx) {
new Chart(agingCtx, {
type: 'doughnut',
data: {
labels: ['안정 (3년 미만)', '주의 (3~5년)', '위험 (5년 이상)'],
datasets: [{
data: [ageGroups.stable, ageGroups.warning, ageGroups.critical],
backgroundColor: ['#1E5149', '#9CA3AF', '#E11D48'],
borderWidth: 0
}]
},
options: {
responsive: true,
maintainAspectRatio: false,
plugins: { legend: { position: 'right' } },
cutout: '70%'
}
});
}
function getHwAgeYears(a: HardwareAsset) {
if (!a.) return 0;
try {
const buyDate = new Date(normalizeDate(a.));
if (isNaN(buyDate.getTime())) return 0;
return (Date.now() - buyDate.getTime()) / (1000 * 60 * 60 * 24 * 365.25);
} catch { return 0; }
const trendCtx = document.getElementById('chart-purchase-trend') as HTMLCanvasElement;
if (trendCtx) {
const years = Object.keys(yearlyCount).sort();
new Chart(trendCtx, {
type: 'bar',
data: {
labels: years,
datasets: [{
label: '도입 수량',
data: years.map(y => yearlyCount[y]),
backgroundColor: '#1E5149',
borderRadius: 4
}]
},
options: {
responsive: true,
maintainAspectRatio: false,
scales: {
y: { beginAtZero: true, ticks: { stepSize: 1 } },
x: { grid: { display: false } }
}
}
});
}
}

View File

@@ -9,6 +9,9 @@ export function renderSwDashboard(container: HTMLElement) {
let subQty = 0, subUsed = 0, subExp = 0, subTotal = 0;
let permQty = 0, permUsed = 0, permExp = 0, permTotal = 0;
let subCost2026 = 0;
let permCost2026 = 0;
const currentYear = new Date().getFullYear();
const corps = ['한맥', '삼안', '바론'];
@@ -18,7 +21,7 @@ export function renderSwDashboard(container: HTMLElement) {
const costByCat: Record<string, number> = {};
categories.forEach(c => costByCat[c] = 0);
// 통합 SW 데이터
// 통합 SW 데이터 (클라우드 제외)
const allSw = [...state.masterData.subSw, ...state.masterData.permSw];
allSw.forEach(sw => {
@@ -36,12 +39,33 @@ export function renderSwDashboard(container: HTMLElement) {
if (isSWExpiring(sw)) permExp++;
}
if (sw. && sw..startsWith(String(currentYear))) {
// 초기 도입 비용 (2026년 구매건)
if (sw. && sw..startsWith('2026')) {
if (sw.type === '구독SW') subCost2026 += price;
else if (sw.type === '영구SW') permCost2026 += price;
if (costByCorp[sw.] !== undefined) costByCorp[sw.] += price;
if (sw. && costByCat[sw.] !== undefined) costByCat[sw.] += price;
}
});
// 누적 추가 비용 집계 (2026년 계약 업데이트 로그 기반)
if (state.masterData.logs) {
state.masterData.logs.forEach(log => {
if (log.date && log.date.startsWith('2026') && log.cost) {
const asset = allSw.find(a => a.id === log.assetId);
if (asset) {
const cost = Number(log.cost) || 0;
if (asset.type === '구독SW') subCost2026 += cost;
else if (asset.type === '영구SW') permCost2026 += cost;
if (costByCorp[asset.] !== undefined) costByCorp[asset.] += cost;
if (asset. && costByCat[asset.] !== undefined) costByCat[asset.] += cost;
}
}
});
}
const subPer = subQty > 0 ? Math.round((subUsed/subQty)*100) : 0;
const permPer = permQty > 0 ? Math.round((permUsed/permQty)*100) : 0;
const subExpPer = subTotal > 0 ? Math.round((subExp/subTotal)*100) : 0;
@@ -95,42 +119,26 @@ export function renderSwDashboard(container: HTMLElement) {
</div>
</div>
<h3 class="dashboard-section-title">${currentYear} 도입 비용 분석</h3>
<div class="dashboard-layout-2col">
<div class="dashboard-card">
<h4 style="margin-bottom:1rem; font-size:0.9rem; color:var(--text-muted);">구매법인별 도입 금액 (원)</h4>
<canvas id="chart-sw-corp"></canvas>
<h3 class="dashboard-section-title">2026년 누적 도입 비용 분석</h3>
<div style="display:grid; grid-template-columns: repeat(2, 1fr); gap:1.5rem; margin-bottom:1.5rem;">
<div class="dashboard-card" style="min-height:auto;">
<span style="font-size:1rem; font-weight:700; color:var(--text-main);">구독 SW 누적 비용 (2026)</span>
<div style="font-size: 0.8125rem; color:var(--text-muted); margin-bottom: 1rem;">갱신 및 추가 비용 합계</div>
<div style="font-size: 2rem; font-weight:700; color:var(--dash-primary);">₩ ${subCost2026.toLocaleString()}</div>
<div style="width: 100%; height: 4px; background-color: var(--primary-color); border-radius: 2px; margin-top: 0.5rem;"></div>
</div>
<div class="dashboard-card">
<h4 style="margin-bottom:1rem; font-size:0.9rem; color:var(--text-muted);">분야별 도입 금액 (원)</h4>
<canvas id="chart-sw-cat"></canvas>
<div class="dashboard-card" style="min-height:auto;">
<span style="font-size:1rem; font-weight:700; color:var(--text-main);">영구 SW 누적 비용 (2026)</span>
<div style="font-size: 0.8125rem; color:var(--text-muted); margin-bottom: 1rem;">유지보수 및 신규 도입 합계</div>
<div style="font-size: 2rem; font-weight:700; color:#3b82f6;">₩ ${permCost2026.toLocaleString()}</div>
<div style="width: 100%; height: 4px; background-color: #3b82f6; border-radius: 2px; margin-top: 0.5rem;"></div>
</div>
</div>
</div>
`;
setTimeout(() => {
if (typeof Chart === 'undefined') return;
const ctxCorp = (document.getElementById('chart-sw-corp') as HTMLCanvasElement)?.getContext('2d');
if (ctxCorp) {
new Chart(ctxCorp, {
type: 'bar',
data: { labels: corps, datasets: [{ data: corps.map(c => costByCorp[c]), backgroundColor: 'rgba(30, 81, 73, 0.8)', borderRadius: 4 }] },
options: { responsive: true, maintainAspectRatio: false, plugins: { legend: { display: false } } }
});
}
const ctxCat = (document.getElementById('chart-sw-cat') as HTMLCanvasElement)?.getContext('2d');
if (ctxCat) {
new Chart(ctxCat, {
type: 'bar',
data: { labels: categories, datasets: [{ data: categories.map(c => costByCat[c]), backgroundColor: 'rgba(59, 130, 246, 0.8)', borderRadius: 4 }] },
options: { responsive: true, maintainAspectRatio: false, plugins: { legend: { display: false } } }
});
}
}, 100);
container.querySelector('[data-action="sub-usage"]')?.addEventListener('click', () => openSwUsageDetail('구독 소프트웨어 사용 목록', state.masterData.subSw));
container.querySelector('[data-action="perm-usage"]')?.addEventListener('click', () => openSwUsageDetail('영구 소프트웨어 사용 목록', state.masterData.permSw));
container.querySelector('[data-action="sub-exp"]')?.addEventListener('click', () => openSwDashboardDetail('구독 SW 만료 예정 목록', state.masterData.subSw.filter(sw => isSWExpiring(sw))));

View File

@@ -11,7 +11,7 @@ export function renderDashboard(mainContent: HTMLElement) {
// 기존 차트 리소스 해제
if (state.activeCharts) {
state.activeCharts.forEach(c => {
state.activeCharts.forEach((c: any) => {
if (c && typeof c.destroy === 'function') c.destroy();
});
}

View File

@@ -1,21 +1,27 @@
import { state } from '../../core/state';
import { openSwModal } from '../../components/Modal/SWModal';
import { formatPrice } from '../../core/utils';
import { createIcons, Cloud, CreditCard, DollarSign } from 'lucide';
import { ASSET_SCHEMA, UI_TEXT } from '../../core/schema';
import { dynamicSort } from '../../core/utils';
import { setupTableSorting, SortState } from '../../core/tableHandler';
import { createIcons, Cloud, CreditCard, DollarSign, RefreshCcw } from 'lucide';
/**
* 클라우드(운영 서비스) 자산 목록 뷰
* 라인 정렬 보정 및 헤더 통일
*/
export function renderCloudList(container: HTMLElement) {
// DB에서 직접 로드된 전용 배열을 사용하여 데이터 소스를 일원화함
const getFullList = () => state.masterData.cloud || [];
let sortState: SortState = { key: '', direction: 'asc' };
const filterBar = document.createElement('div');
filterBar.className = 'search-bar';
filterBar.innerHTML = `
<div class="search-item flex-1">
<label>통합 검색 (제품명/부서/계정명)</label>
<label>통합 검색 (${ASSET_SCHEMA.PRODUCT.ui}/부서/${ASSET_SCHEMA.ACCOUNT.ui})</label>
<input type="text" id="filter-keyword" placeholder="검색어를 입력하세요..." autocomplete="off">
</div>
<div class="search-item">
<label>결제수단</label>
<label>${ASSET_SCHEMA.PAY_METHOD.ui}</label>
<select id="filter-payment">
<option value="">전체 결제수단</option>
<option value="법인카드">법인카드</option>
@@ -23,7 +29,7 @@ export function renderCloudList(container: HTMLElement) {
</select>
</div>
<button id="btn-reset-filters" class="btn btn-outline btn-reset">
<i data-lucide="refresh-ccw"></i> 필터 초기화
<i data-lucide="refresh-ccw"></i> ${UI_TEXT.ACTION.RESET_FILTER}
</button>
`;
container.appendChild(filterBar);
@@ -34,16 +40,16 @@ export function renderCloudList(container: HTMLElement) {
table.innerHTML = `
<thead>
<tr>
<th style="text-align:center;">No.</th>
<th style="text-align:center;">플랫폼명</th>
<th style="text-align:center;">법인</th>
<th style="text-align:center;">담당부서</th>
<th style="text-align:center;">진행 프로젝트(사용용도)</th>
<th style="text-align:center;">계정명(관리자)</th>
<th style="text-align:center;">결제수단</th>
<th style="text-align:center;">결제일</th>
<th style="text-align:center;">당월 청구액</th>
<th style="text-align:center;">비고</th>
<th class="text-center" style="width:50px;">No.</th>
<th data-sort="${ASSET_SCHEMA.PLATFORM.key}">${ASSET_SCHEMA.PLATFORM.ui}</th>
<th class="text-center" data-sort="${ASSET_SCHEMA.CORP.key}">${ASSET_SCHEMA.CORP.ui}</th>
<th class="text-center" data-sort="부서">담당부서</th>
<th data-sort="${ASSET_SCHEMA.PRODUCT.key}">용도(프로젝트)</th>
<th data-sort="${ASSET_SCHEMA.ACCOUNT.key}">${ASSET_SCHEMA.ACCOUNT.ui}</th>
<th class="text-center" data-sort="${ASSET_SCHEMA.PAY_METHOD.key}">${ASSET_SCHEMA.PAY_METHOD.ui}</th>
<th class="text-center" data-sort="${ASSET_SCHEMA.PAY_DAY.key}">${ASSET_SCHEMA.PAY_DAY.ui}</th>
<th class="text-center" data-sort="${ASSET_SCHEMA.BILLING.key}">${ASSET_SCHEMA.BILLING.ui}</th>
<th>${ASSET_SCHEMA.REMARKS.ui}</th>
</tr>
</thead>
<tbody id="cloud-tbody"></tbody>
@@ -60,18 +66,22 @@ export function renderCloudList(container: HTMLElement) {
const keyword = keywordInput ? keywordInput.value.toLowerCase().trim() : '';
const payment = paymentSelect ? paymentSelect.value : '';
const filtered = getFullList().filter(asset => {
let filtered = getFullList().filter(asset => {
const kwMatch = !keyword ||
(asset. || '').toLowerCase().includes(keyword) ||
(asset[ASSET_SCHEMA.PRODUCT.key] || '').toLowerCase().includes(keyword) ||
(asset. || '').toLowerCase().includes(keyword) ||
(asset. || '').toLowerCase().includes(keyword);
const payMatch = !payment || asset. === payment;
(asset[ASSET_SCHEMA.ACCOUNT.key] || '').toLowerCase().includes(keyword);
const payMatch = !payment || asset[ASSET_SCHEMA.PAY_METHOD.key] === payment;
return kwMatch && payMatch;
});
if (sortState.key) {
filtered = dynamicSort(filtered, sortState.key, sortState.direction);
}
tbody.innerHTML = '';
if (filtered.length === 0) {
tbody.innerHTML = '<tr><td colspan="10" style="text-align:center; padding: 3rem; color: var(--text-muted);">등록된 클라우드 서비스가 없습니다.</td></tr>';
tbody.innerHTML = `<tr><td colspan="10" class="text-center" style="padding: 3rem; color: var(--text-muted);">${UI_TEXT.MESSAGES.NO_DATA}</td></tr>`;
return;
}
@@ -79,29 +89,36 @@ export function renderCloudList(container: HTMLElement) {
const tr = document.createElement('tr');
tr.style.cursor = 'pointer';
const paymentBadge = asset. === '법인카드'
? '<span style="color:#6366f1; font-weight:600;"><i data-lucide="credit-card" style="width:14px; height:14px; vertical-align:middle; margin-right:4px;"></i>법인카드 (' + (asset.||'미상') + ')</span>'
: (asset. === '인보이스'
const payMethod = asset[ASSET_SCHEMA.PAY_METHOD.key];
const paymentBadge = payMethod === '법인카드'
? `<span style="color:#6366f1; font-weight:600;"><i data-lucide="credit-card" style="width:14px; height:14px; vertical-align:middle; margin-right:4px;"></i>법인카드</span>`
: (payMethod === '인보이스'
? '<span style="color:#10b981; font-weight:600;"><i data-lucide="dollar-sign" style="width:14px; height:14px; vertical-align:middle; margin-right:4px;"></i>인보이스</span>'
: '<span style="color:var(--text-muted)">미설정</span>');
tr.innerHTML = `
<td style="text-align:center;">${idx+1}</td>
<td style="font-weight:600; color:var(--primary-color)"><i data-lucide="cloud" style="width:14px; height:14px; vertical-align:middle; margin-right:4px;"></i> ${asset.||'미지정'}</td>
<td style="text-align:center;">${asset.||''}</td>
<td style="text-align:center;">${asset.||''}</td>
<td>${asset.||''}</td>
<td>${asset.||''}</td>
<td style="text-align:center;">${paymentBadge}</td>
<td style="text-align:center;">${asset. ? asset. + '일' : ''}</td>
<td style="text-align:right; font-weight:600;">${asset. ? '₩ ' + formatPrice(asset.) : '0'}</td>
<td>${asset.||''}</td>
<td class="text-center">${idx+1}</td>
<td style="font-weight:600; color:var(--primary-color)"><i data-lucide="cloud" style="width:14px; height:14px; vertical-align:middle; margin-right:4px;"></i> ${asset[ASSET_SCHEMA.PLATFORM.key]||'미지정'}</td>
<td class="text-center">${asset[ASSET_SCHEMA.CORP.key]||''}</td>
<td class="text-center">${asset.||''}</td>
<td>${asset[ASSET_SCHEMA.PRODUCT.key]||''}</td>
<td>${asset[ASSET_SCHEMA.ACCOUNT.key]||''}</td>
<td class="text-center">${paymentBadge}</td>
<td class="text-center">${asset[ASSET_SCHEMA.PAY_DAY.key] ? asset[ASSET_SCHEMA.PAY_DAY.key] + '일' : ''}</td>
<td class="text-right" style="font-weight:600;">${asset[ASSET_SCHEMA.BILLING.key] ? Number(String(asset[ASSET_SCHEMA.BILLING.key]).replace(/,/g, '')).toLocaleString() : '0'}</td>
<td>${asset[ASSET_SCHEMA.REMARKS.key]||''}</td>
`;
tr.addEventListener('click', () => openSwModal(asset, 'view'));
tbody.appendChild(tr);
});
createIcons({ icons: { Cloud, CreditCard, DollarSign } });
setupTableSorting(table, sortState, (key, dir) => {
sortState = { key, direction: dir };
updateTable();
});
createIcons({ icons: { Cloud, CreditCard, DollarSign, RefreshCcw } });
};
document.getElementById('filter-keyword')?.addEventListener('input', updateTable);

View File

@@ -0,0 +1,99 @@
import { state } from '../../core/state';
import { formatPrice, dynamicSort, createBadge } from '../../core/utils';
import { createIcons, Plus, Edit2, Trash2 } from 'lucide';
import { openDomainModal } from '../../components/Modal/DomainModal';
import { setupTableSorting, SortState } from '../../core/tableHandler';
import { formatExcelDate } from '../../core/excelHandler';
// 정렬 상태를 모듈 수준에서 관리하여 화면 갱신 시에도 유지되도록 함
let persistentSortState: SortState = { key: '', direction: 'asc' };
export function renderDomainList(container: HTMLElement) {
container.innerHTML = '';
const fullList = state.masterData.domain;
const header = document.createElement('div');
header.className = 'list-header';
header.innerHTML = `
<div class="list-title-area">
<h2 class="list-title">도메인 관리</h2>
</div>
`;
container.appendChild(header);
const tableWrapper = document.createElement('div');
tableWrapper.className = 'table-container';
const table = document.createElement('table');
table.innerHTML = `
<thead>
<tr>
<th style="text-align:center; width:50px;">No.</th>
<th style="text-align:center;" data-sort="type">유형</th>
<th style="text-align:center;" data-sort="corp">법인</th>
<th style="text-align:left;" data-sort="service_name">서비스명</th>
<th style="text-align:left;" data-sort="domain_name">관리도메인</th>
<th style="text-align:left;" data-sort="remarks">구매업체</th>
<th style="text-align:center;" data-sort="start_date">시작일</th>
<th style="text-align:center;" data-sort="expiry_date">만료일</th>
<th style="text-align:right;" data-sort="price">금액</th>
<th style="text-align:center;" data-sort="manager_main">담당자(정/부)</th>
</tr>
</thead>
<tbody id="dynamic-tbody"></tbody>
`;
tableWrapper.appendChild(table);
container.appendChild(tableWrapper);
const tbody = table.querySelector('tbody')!;
const updateTable = () => {
let filtered = [...fullList];
if (persistentSortState.key) {
filtered = dynamicSort(filtered, persistentSortState.key, persistentSortState.direction);
}
tbody.innerHTML = '';
if (filtered.length === 0) {
tbody.innerHTML = `<tr><td colspan="10" style="text-align:center; padding: 3rem; color: var(--text-muted);">등록된 도메인 정보가 없습니다.</td></tr>`;
return;
}
filtered.forEach((item, idx) => {
const tr = document.createElement('tr');
tr.className = 'domain-row';
tr.style.cursor = 'pointer';
const managerHtml = [
item.manager_main ? `${createBadge('정', 'primary')} ${item.manager_main}` : '',
item.manager_sub ? `${createBadge('부', 'muted')} ${item.manager_sub}` : ''
].filter(v => v !== '').join(' / ');
tr.innerHTML = `
<td style="text-align:center;">${idx + 1}</td>
<td style="text-align:center;"><span class="badge badge-${item.type}">${item.type}</span></td>
<td style="text-align:center;">${item.corp || ''}</td>
<td>${item.service_name || ''}</td>
<td>${item.domain_name || ''}</td>
<td>${item.remarks || ''}</td>
<td style="text-align:center;">${formatExcelDate(item.start_date)}</td>
<td style="text-align:center;">${formatExcelDate(item.expiry_date)}</td>
<td style="text-align:right;">${formatPrice(item.price)}</td>
<td style="text-align:center;">${managerHtml || '-'}</td>
`;
tr.addEventListener('click', (e) => {
console.log('Row clicked:', item.domain_name);
openDomainModal(item);
});
tbody.appendChild(tr);
});
setupTableSorting(table, persistentSortState, (key, dir) => {
persistentSortState = { key, direction: dir };
updateTable();
});
};
updateTable();
createIcons({ icons: { Plus, Edit2, Trash2 } });
}

View File

@@ -1,26 +1,33 @@
import { state } from '../../core/state';
import { openHwModal } from '../../components/Modal/HWModal';
import { formatInline, sortAssets, formatPrice } from '../../core/utils';
import { formatInline, createBadge, sortAssets, dynamicSort } from '../../core/utils';
import { ASSET_SCHEMA, UI_TEXT } from '../../core/schema';
import { setupTableSorting, SortState } from '../../core/tableHandler';
import { createIcons, RefreshCcw } from 'lucide';
/**
* 전산비품 자산 목록 뷰
* 라인 정렬 보정 및 헤더 통일
*/
export function renderEquipmentList(container: HTMLElement) {
const fullList = sortAssets(state.masterData.equip);
let sortState: SortState = { key: '', direction: 'asc' };
const filterBar = document.createElement('div');
filterBar.className = 'search-bar';
const corps = Array.from(new Set(fullList.map(a => a.))).filter(Boolean).sort();
const corps = Array.from(new Set(fullList.map(a => a[ASSET_SCHEMA.CORP.key]))).filter(Boolean).sort();
filterBar.innerHTML = `
<div class="search-item flex-1">
<label>통합 검색 (자산코드/명칭)</label>
<label>통합 검색 (${ASSET_SCHEMA.ASSET_CODE.ui}/${ASSET_SCHEMA.MODEL.ui}/${ASSET_SCHEMA.MANAGER_MAIN.ui})</label>
<input type="text" id="filter-keyword" placeholder="검색어를 입력하세요..." autocomplete="off">
</div>
<div class="search-item">
<label>구매법인</label>
<label>${ASSET_SCHEMA.CORP.ui}</label>
<select id="filter-corp"><option value="">전체 법인</option>${corps.map(c => `<option value="${c}">${c}</option>`).join('')}</select>
</div>
<button id="btn-reset-filters" class="btn btn-outline btn-reset">
<i data-lucide="refresh-ccw"></i> 필터 초기화
<i data-lucide="refresh-ccw"></i> ${UI_TEXT.ACTION.RESET_FILTER}
</button>
`;
container.appendChild(filterBar);
@@ -28,7 +35,23 @@ export function renderEquipmentList(container: HTMLElement) {
const tableWrapper = document.createElement('div');
tableWrapper.className = 'table-container';
const table = document.createElement('table');
table.innerHTML = `<thead><tr><th>No</th><th>구매법인</th><th>현 사용조직</th><th>유형</th><th>자산번호</th><th>모델명</th><th>관리자</th><th>구매일</th><th>금액</th><th>관리</th></tr></thead><tbody id="dynamic-tbody"></tbody>`;
table.innerHTML = `
<thead>
<tr>
<th class="text-center" style="width:50px;">No.</th>
<th class="text-center" data-sort="${ASSET_SCHEMA.STATUS.key}">${ASSET_SCHEMA.STATUS.ui}</th>
<th class="text-center" data-sort="${ASSET_SCHEMA.CORP.key}">${ASSET_SCHEMA.CORP.ui}</th>
<th class="text-center" data-sort="${ASSET_SCHEMA.TYPE.key}">유형</th>
<th class="text-center" data-sort="${ASSET_SCHEMA.ASSET_CODE.key}">${ASSET_SCHEMA.ASSET_CODE.ui}</th>
<th data-sort="${ASSET_SCHEMA.MODEL.key}">${ASSET_SCHEMA.MODEL.ui}</th>
<th class="text-center" data-sort="${ASSET_SCHEMA.STORE_LOC.key}">${ASSET_SCHEMA.STORE_LOC.ui}</th>
<th class="text-center" data-sort="${ASSET_SCHEMA.MANAGER_MAIN.key}">담당자(정/부)</th>
<th class="text-center" data-sort="${ASSET_SCHEMA.PURCHASE_YM.key}">${ASSET_SCHEMA.PURCHASE_YM.ui}</th>
<th class="text-center" data-sort="${ASSET_SCHEMA.PRICE.key}">${ASSET_SCHEMA.PRICE.ui}</th>
</tr>
</thead>
<tbody id="dynamic-tbody"></tbody>
`;
tableWrapper.appendChild(table);
container.appendChild(tableWrapper);
@@ -41,36 +64,63 @@ export function renderEquipmentList(container: HTMLElement) {
const keyword = keywordInput ? keywordInput.value.toLowerCase().trim() : '';
const corp = corpSelect ? corpSelect.value : '';
const filtered = fullList.filter(asset => {
const matchKeyword = !keyword || String(asset.||'').toLowerCase().includes(keyword) || String(asset.||'').toLowerCase().includes(keyword) || String(asset.||'').toLowerCase().includes(keyword);
const matchCorp = !corp || asset. === corp;
let filtered = fullList.filter(asset => {
const matchKeyword = !keyword ||
String(asset[ASSET_SCHEMA.ASSET_CODE.key]||'').toLowerCase().includes(keyword) ||
String(asset[ASSET_SCHEMA.MODEL.key]||'').toLowerCase().includes(keyword) ||
String(asset[ASSET_SCHEMA.MANAGER_MAIN.key]||'').toLowerCase().includes(keyword);
const matchCorp = !corp || asset[ASSET_SCHEMA.CORP.key] === corp;
return matchKeyword && matchCorp;
});
if (sortState.key) {
filtered = dynamicSort(filtered, sortState.key, sortState.direction);
}
tbody.innerHTML = '';
if (filtered.length === 0) {
tbody.innerHTML = `<tr><td colspan="10" style="text-align:center; padding: 3rem; color: var(--text-muted);">검색 결과가 없습니다.</td></tr>`;
tbody.innerHTML = `<tr><td colspan="10" class="text-center" style="padding: 3rem; color: var(--text-muted);">${UI_TEXT.MESSAGES.NO_DATA}</td></tr>`;
return;
}
filtered.forEach((asset, idx) => {
const tr = document.createElement('tr');
tr.style.cursor = 'pointer';
const statusColors: Record<string, string> = { '대여중': 'primary', '보관중': 'success', '수리중': 'danger', '기타': 'muted' };
const statusValue = asset[ASSET_SCHEMA.STATUS.key] || '보관중';
const statusType = statusColors[statusValue] || 'muted';
const statusBadge = `<span class="badge badge-${statusType}">${statusValue}</span>`;
const mainManager = asset[ASSET_SCHEMA.MANAGER_MAIN.key] || '';
const subManager = asset[ASSET_SCHEMA.MANAGER_SUB.key] || '';
const managerHtml = [
mainManager ? `${createBadge('정', 'primary')} ${mainManager}` : '',
subManager ? `${createBadge('부', 'muted')} ${subManager}` : ''
].filter(v => v !== '').join(' / ');
tr.innerHTML = `
<td>${idx+1}</td>
<td>${asset.}</td>
<td>${asset.||''}</td>
<td>${asset.type}</td>
<td>${asset.}</td>
<td>${formatInline(asset.)}</td>
<td>${formatInline(asset._정 || asset.)}</td>
<td>${asset.||''}</td>
<td>${formatPrice(asset.)}</td>
<td><button class="btn btn-outline btn-sm">수정</button></td>
<td class="text-center">${idx + 1}</td>
<td class="text-center">${statusBadge}</td>
<td class="text-center">${asset[ASSET_SCHEMA.CORP.key]}</td>
<td class="text-center">${asset[ASSET_SCHEMA.TYPE.key]}</td>
<td class="text-center" style="font-family: monospace;">${asset[ASSET_SCHEMA.ASSET_CODE.key] || '-'}</td>
<td>${formatInline(asset[ASSET_SCHEMA.MODEL.key] || asset.)}</td>
<td class="text-center">${asset[ASSET_SCHEMA.STORE_LOC.key] || '-'}</td>
<td class="text-center">${managerHtml || '-'}</td>
<td class="text-center">${asset[ASSET_SCHEMA.PURCHASE_YM.key] || ''}</td>
<td class="text-right">${Number(asset[ASSET_SCHEMA.PRICE.key]||0).toLocaleString()}</td>
`;
tr.addEventListener('click', (e) => { if (!(e.target as HTMLElement).closest('button')) openHwModal(asset, 'view'); });
tr.addEventListener('click', () => openHwModal(asset, 'view'));
tbody.appendChild(tr);
});
setupTableSorting(table, sortState, (key, dir) => {
sortState = { key, direction: dir };
updateTable();
});
createIcons({ icons: { RefreshCcw } });
};
document.getElementById('filter-keyword')?.addEventListener('input', updateTable);

View File

@@ -1,26 +1,33 @@
import { state } from '../../core/state';
import { openHwModal } from '../../components/Modal/HWModal';
import { formatInline, sortAssets, formatPrice } from '../../core/utils';
import { formatInline, createBadge, sortAssets, dynamicSort } from '../../core/utils';
import { ASSET_SCHEMA, UI_TEXT } from '../../core/schema';
import { setupTableSorting, SortState } from '../../core/tableHandler';
import { createIcons, RefreshCcw } from 'lucide';
/**
* 모바일기기 자산 목록 뷰
* 라인 정렬 보정 및 헤더 통일
*/
export function renderMobileList(container: HTMLElement) {
const fullList = sortAssets(state.masterData.mobile);
let sortState: SortState = { key: '', direction: 'asc' };
const filterBar = document.createElement('div');
filterBar.className = 'search-bar';
const corps = Array.from(new Set(fullList.map(a => a.))).filter(Boolean).sort();
const corps = Array.from(new Set(fullList.map(a => a[ASSET_SCHEMA.CORP.key]))).filter(Boolean).sort();
filterBar.innerHTML = `
<div class="search-item flex-1">
<label>통합 검색 (자산코드/명칭)</label>
<label>통합 검색 (${ASSET_SCHEMA.ASSET_CODE.ui}/${ASSET_SCHEMA.MODEL.ui}/${ASSET_SCHEMA.MANAGER_MAIN.ui})</label>
<input type="text" id="filter-keyword" placeholder="검색어를 입력하세요..." autocomplete="off">
</div>
<div class="search-item">
<label>구매법인</label>
<label>${ASSET_SCHEMA.CORP.ui}</label>
<select id="filter-corp"><option value="">전체 법인</option>${corps.map(c => `<option value="${c}">${c}</option>`).join('')}</select>
</div>
<button id="btn-reset-filters" class="btn btn-outline btn-reset">
<i data-lucide="refresh-ccw"></i> 필터 초기화
<i data-lucide="refresh-ccw"></i> ${UI_TEXT.ACTION.RESET_FILTER}
</button>
`;
container.appendChild(filterBar);
@@ -28,8 +35,23 @@ export function renderMobileList(container: HTMLElement) {
const tableWrapper = document.createElement('div');
tableWrapper.className = 'table-container';
const table = document.createElement('table');
table.innerHTML = `<thead><tr><th>No</th><th>구매법인</th><th>현 사용조직</th><th>유형</th><th>자산번호</th><th>모델명</th><th>관리자</th><th>구매일</th><th>금액</th><th>관리</th></tr></thead><tbody id="dynamic-tbody"></tbody>`;
table.innerHTML = `
<thead>
<tr>
<th class="text-center" style="width:50px;">No.</th>
<th class="text-center" data-sort="${ASSET_SCHEMA.STATUS.key}">${ASSET_SCHEMA.STATUS.ui}</th>
<th class="text-center" data-sort="${ASSET_SCHEMA.CORP.key}">${ASSET_SCHEMA.CORP.ui}</th>
<th class="text-center" data-sort="${ASSET_SCHEMA.ASSET_CODE.key}">${ASSET_SCHEMA.ASSET_CODE.ui}</th>
<th data-sort="${ASSET_SCHEMA.MODEL.key}">${ASSET_SCHEMA.MODEL.ui}</th>
<th class="text-center" data-sort="${ASSET_SCHEMA.STORE_LOC.key}">${ASSET_SCHEMA.STORE_LOC.ui}</th>
<th class="text-center" data-sort="${ASSET_SCHEMA.MANAGER_MAIN.key}">담당자(정/부)</th>
<th class="text-center" data-sort="${ASSET_SCHEMA.PURCHASE_YM.key}">${ASSET_SCHEMA.PURCHASE_YM.ui}</th>
<th class="text-center" data-sort="${ASSET_SCHEMA.PRICE.key}">${ASSET_SCHEMA.PRICE.ui}</th>
</tr>
</thead>
<tbody id="dynamic-tbody"></tbody>
`;
tableWrapper.appendChild(table);
container.appendChild(tableWrapper);
const tbody = table.querySelector('tbody')!;
@@ -41,36 +63,62 @@ export function renderMobileList(container: HTMLElement) {
const keyword = keywordInput ? keywordInput.value.toLowerCase().trim() : '';
const corp = corpSelect ? corpSelect.value : '';
const filtered = fullList.filter(asset => {
const matchKeyword = !keyword || String(asset.||'').toLowerCase().includes(keyword) || String(asset.||'').toLowerCase().includes(keyword) || String(asset.||'').toLowerCase().includes(keyword);
const matchCorp = !corp || asset. === corp;
let filtered = fullList.filter(asset => {
const matchKeyword = !keyword ||
String(asset[ASSET_SCHEMA.ASSET_CODE.key]||'').toLowerCase().includes(keyword) ||
String(asset[ASSET_SCHEMA.MODEL.key]||'').toLowerCase().includes(keyword) ||
String(asset[ASSET_SCHEMA.MANAGER_MAIN.key]||'').toLowerCase().includes(keyword);
const matchCorp = !corp || asset[ASSET_SCHEMA.CORP.key] === corp;
return matchKeyword && matchCorp;
});
if (sortState.key) {
filtered = dynamicSort(filtered, sortState.key, sortState.direction);
}
tbody.innerHTML = '';
if (filtered.length === 0) {
tbody.innerHTML = `<tr><td colspan="10" style="text-align:center; padding: 3rem; color: var(--text-muted);">검색 결과가 없습니다.</td></tr>`;
tbody.innerHTML = `<tr><td colspan="9" class="text-center" style="padding: 3rem; color: var(--text-muted);">${UI_TEXT.MESSAGES.NO_DATA}</td></tr>`;
return;
}
filtered.forEach((asset, idx) => {
const tr = document.createElement('tr');
tr.style.cursor = 'pointer';
const statusColors: Record<string, string> = { '대여중': 'primary', '보관중': 'success', '수리중': 'danger', '기타': 'muted' };
const statusValue = asset[ASSET_SCHEMA.STATUS.key] || '보관중';
const statusType = statusColors[statusValue] || 'muted';
const statusBadge = `<span class="badge badge-${statusType}">${statusValue}</span>`;
const mainManager = asset[ASSET_SCHEMA.MANAGER_MAIN.key] || '';
const subManager = asset[ASSET_SCHEMA.MANAGER_SUB.key] || '';
const managerHtml = [
mainManager ? `${createBadge('정', 'primary')} ${mainManager}` : '',
subManager ? `${createBadge('부', 'muted')} ${subManager}` : ''
].filter(v => v !== '').join(' / ');
tr.innerHTML = `
<td>${idx+1}</td>
<td>${asset.}</td>
<td>${asset.||''}</td>
<td>${asset.type}</td>
<td>${asset.}</td>
<td>${formatInline(asset.)}</td>
<td>${formatInline(asset._정 || asset.)}</td>
<td>${asset.||''}</td>
<td>${formatPrice(asset.)}</td>
<td><button class="btn btn-outline btn-sm">수정</button></td>
<td class="text-center">${idx + 1}</td>
<td class="text-center">${statusBadge}</td>
<td class="text-center">${asset[ASSET_SCHEMA.CORP.key]}</td>
<td class="text-center" style="font-family: monospace;">${asset[ASSET_SCHEMA.ASSET_CODE.key] || '-'}</td>
<td>${formatInline(asset[ASSET_SCHEMA.MODEL.key] || asset.)}</td>
<td class="text-center">${asset[ASSET_SCHEMA.STORE_LOC.key] || '-'}</td>
<td class="text-center">${managerHtml || '-'}</td>
<td class="text-center">${asset[ASSET_SCHEMA.PURCHASE_YM.key] || ''}</td>
<td class="text-right">${Number(asset[ASSET_SCHEMA.PRICE.key]||0).toLocaleString()}</td>
`;
tr.addEventListener('click', (e) => { if (!(e.target as HTMLElement).closest('button')) openHwModal(asset, 'view'); });
tr.addEventListener('click', () => openHwModal(asset, 'view'));
tbody.appendChild(tr);
});
setupTableSorting(table, sortState, (key, dir) => {
sortState = { key, direction: dir };
updateTable();
});
createIcons({ icons: { RefreshCcw } });
};
document.getElementById('filter-keyword')?.addEventListener('input', updateTable);

View File

@@ -1,26 +1,34 @@
import { state } from '../../core/state';
import { openPcModal } from '../../components/Modal/PCModal';
import { formatInline, sortAssets, formatPrice } from '../../core/utils';
import { openHwModal } from '../../components/Modal/HWModal';
import { formatInline, createBadge, sortAssets, dynamicSort } from '../../core/utils';
import { ASSET_SCHEMA, UI_TEXT } from '../../core/schema';
import { setupTableSorting, SortState } from '../../core/tableHandler';
import { createIcons, Paperclip, RefreshCcw } from 'lucide';
/**
* PC 자산 목록 뷰
* 담당자(부) 추가 및 정렬 보정
*/
export function renderPcList(container: HTMLElement) {
const fullList = sortAssets(state.masterData.pc);
let sortState: SortState = { key: '', direction: 'asc' };
const filterBar = document.createElement('div');
filterBar.className = 'search-bar';
const corps = Array.from(new Set(fullList.map(a => a.))).filter(Boolean).sort();
const corps = Array.from(new Set(fullList.map(a => a[ASSET_SCHEMA.CORP.key]))).filter(Boolean).sort();
filterBar.innerHTML = `
<div class="search-item flex-1">
<label>통합 검색 (자산코드/사용자)</label>
<label>통합 검색 (${ASSET_SCHEMA.ASSET_CODE.ui}/${ASSET_SCHEMA.USER.ui}/${ASSET_SCHEMA.MANAGER_MAIN.ui})</label>
<input type="text" id="filter-keyword" placeholder="검색어를 입력하세요..." autocomplete="off">
</div>
<div class="search-item">
<label>구매법인</label>
<label>${ASSET_SCHEMA.CORP.ui}</label>
<select id="filter-corp"><option value="">전체 법인</option>${corps.map(c => `<option value="${c}">${c}</option>`).join('')}</select>
</div>
<button id="btn-reset-filters" class="btn btn-outline btn-reset">
<i data-lucide="refresh-ccw"></i> 필터 초기화
<i data-lucide="refresh-ccw"></i> ${UI_TEXT.ACTION.RESET_FILTER}
</button>
`;
container.appendChild(filterBar);
@@ -28,11 +36,29 @@ export function renderPcList(container: HTMLElement) {
const tableWrapper = document.createElement('div');
tableWrapper.className = 'table-container';
const table = document.createElement('table');
table.innerHTML = `<thead><tr><th>No</th><th>구매법인</th><th>현 사용조직</th><th>자산코드</th><th>사용자</th><th>위치</th><th>CPU</th><th>RAM</th><th>Storage</th><th>구매일</th><th>금액</th><th>품의서</th><th>관리</th></tr></thead><tbody id="dynamic-tbody"></tbody>`;
table.innerHTML = `
<thead>
<tr>
<th style="text-align:center; width:50px;">No</th>
<th style="text-align:center;" data-sort="${ASSET_SCHEMA.CORP.key}">${ASSET_SCHEMA.CORP.ui}</th>
<th style="text-align:center;" data-sort="${ASSET_SCHEMA.ORG.key}">${ASSET_SCHEMA.ORG.ui}</th>
<th style="text-align:center;" data-sort="${ASSET_SCHEMA.ASSET_CODE.key}">${ASSET_SCHEMA.ASSET_CODE.ui}</th>
<th style="text-align:center;" data-sort="${ASSET_SCHEMA.USER.key}">${ASSET_SCHEMA.USER.ui}</th>
<th style="text-align:center;" data-sort="${ASSET_SCHEMA.MAINBOARD.key}">${ASSET_SCHEMA.MAINBOARD.ui}</th>
<th style="text-align:center;" data-sort="${ASSET_SCHEMA.CPU.key}">${ASSET_SCHEMA.CPU.ui}</th>
<th style="text-align:center;" data-sort="${ASSET_SCHEMA.RAM.key}">${ASSET_SCHEMA.RAM.ui}</th>
<th style="text-align:center;" data-sort="${ASSET_SCHEMA.STORAGE1.key}">Storage</th>
<th style="text-align:center;" data-sort="${ASSET_SCHEMA.PURCHASE_YM.key}">${ASSET_SCHEMA.PURCHASE_YM.ui}</th>
<th style="text-align:center;" data-sort="${ASSET_SCHEMA.PRICE.key}">${ASSET_SCHEMA.PRICE.ui}</th>
<th style="text-align:center;">${ASSET_SCHEMA.DOC_NAME.ui}</th>
<th style="text-align:center;" data-sort="${ASSET_SCHEMA.MANAGER_MAIN.key}">담당자(정/부)</th>
</tr>
</thead>
<tbody id="dynamic-tbody"></tbody>
`;
tableWrapper.appendChild(table);
container.appendChild(tableWrapper);
const tbody = table.querySelector('tbody')!;
const updateTable = () => {
@@ -42,42 +68,64 @@ export function renderPcList(container: HTMLElement) {
const keyword = keywordInput ? keywordInput.value.toLowerCase().trim() : '';
const corp = corpSelect ? corpSelect.value : '';
const filtered = fullList.filter(asset => {
const matchKeyword = !keyword || String(asset.||'').toLowerCase().includes(keyword) || String(asset.||'').toLowerCase().includes(keyword) || String(asset.||'').toLowerCase().includes(keyword);
const matchCorp = !corp || asset. === corp;
let filtered = fullList.filter(asset => {
const matchKeyword = !keyword ||
String(asset[ASSET_SCHEMA.ASSET_CODE.key]||'').toLowerCase().includes(keyword) ||
String(asset[ASSET_SCHEMA.USER.key]||'').toLowerCase().includes(keyword) ||
String(asset[ASSET_SCHEMA.ORG.key]||'').toLowerCase().includes(keyword) ||
String(asset[ASSET_SCHEMA.MANAGER_MAIN.key]||'').toLowerCase().includes(keyword);
const matchCorp = !corp || asset[ASSET_SCHEMA.CORP.key] === corp;
return matchKeyword && matchCorp;
});
if (sortState.key) {
filtered = dynamicSort(filtered, sortState.key, sortState.direction);
}
tbody.innerHTML = '';
if (filtered.length === 0) {
tbody.innerHTML = `<tr><td colspan="13" style="text-align:center; padding: 3rem; color: var(--text-muted);">검색 결과가 없습니다.</td></tr>`;
tbody.innerHTML = `<tr><td colspan="13" style="text-align:center; padding: 3rem; color: var(--text-muted);">${UI_TEXT.MESSAGES.NO_DATA}</td></tr>`;
return;
}
filtered.forEach((asset, idx) => {
const tr = document.createElement('tr');
tr.style.cursor = 'pointer';
const storage = [asset.SSD1, asset.SSD2, asset.HDD1].filter(v => v).join(' / ');
const storage = [asset[ASSET_SCHEMA.STORAGE1.key], asset[ASSET_SCHEMA.STORAGE2.key]].filter(v => v).join(' / ');
const mainManager = asset[ASSET_SCHEMA.MANAGER_MAIN.key] || '';
const subManager = asset[ASSET_SCHEMA.MANAGER_SUB.key] || '';
const managerHtml = [
mainManager ? `${createBadge('정', 'primary')} ${mainManager}` : '',
subManager ? `${createBadge('부', 'muted')} ${subManager}` : ''
].filter(v => v !== '').join(' / ');
tr.innerHTML = `
<td>${idx+1}</td>
<td>${asset.}</td>
<td>${asset.||''}</td>
<td>${asset.}</td>
<td>${asset.||''}</td>
<td>${asset.||''}</td>
<td>${asset.CPU||''}</td>
<td>${asset.RAM||''}</td>
<td>${formatInline(storage)}</td>
<td>${asset.||''}</td>
<td>${formatPrice(asset.)}</td>
<td style="text-align:center;">${asset. ? '<i data-lucide="paperclip" class="text-primary"></i>' : '-'}</td>
<td><button class="btn btn-outline btn-sm">수정</button></td>
<td style="text-align:center;">${idx+1}</td>
<td style="text-align:center;">${asset[ASSET_SCHEMA.CORP.key]}</td>
<td style="text-align:center;">${asset[ASSET_SCHEMA.ORG.key]||'-'}</td>
<td style="text-align:center;">${asset[ASSET_SCHEMA.ASSET_CODE.key]}</td>
<td style="text-align:center;">${asset[ASSET_SCHEMA.USER.key]||''}</td>
<td style="text-align:center;">${asset[ASSET_SCHEMA.MAINBOARD.key]||'-'}</td>
<td style="text-align:center;">${asset[ASSET_SCHEMA.CPU.key]||''}</td>
<td style="text-align:center;">${asset[ASSET_SCHEMA.RAM.key]||''}</td>
<td style="text-align:center;">${formatInline(storage)}</td>
<td style="text-align:center;">${asset[ASSET_SCHEMA.PURCHASE_YM.key] || ''}</td>
<td style="text-align:right;">${Number(asset[ASSET_SCHEMA.PRICE.key]||0).toLocaleString()}</td>
<td style="text-align:center;">${asset[ASSET_SCHEMA.DOC_NAME.key] ? '<i data-lucide="paperclip" class="text-primary"></i>' : '-'}</td>
<td style="text-align:center;">${managerHtml || '-'}</td>
`;
tr.addEventListener('click', (e) => { if (!(e.target as HTMLElement).closest('button')) openPcModal(asset, 'view'); });
tr.addEventListener('click', () => openHwModal(asset, 'view'));
tbody.appendChild(tr);
});
createIcons({ icons: { Paperclip } });
setupTableSorting(table, sortState, (key, dir) => {
sortState = { key, direction: dir };
updateTable();
});
createIcons({ icons: { Paperclip, RefreshCcw } });
};
document.getElementById('filter-keyword')?.addEventListener('input', updateTable);

View File

@@ -1,31 +1,39 @@
import { state } from '../../core/state';
import { openHwModal } from '../../components/Modal/HWModal';
import { formatInline, createBadge, sortAssets } from '../../core/utils';
import { formatInline, createBadge, sortAssets, dynamicSort } from '../../core/utils';
import { ASSET_SCHEMA, UI_TEXT } from '../../core/schema';
import { setupTableSorting, SortState } from '../../core/tableHandler';
import { createIcons, RefreshCcw } from 'lucide';
/**
* 서버 자산 목록 뷰
* 라인 정렬 보정 및 헤더 통일
*/
export function renderServerList(container: HTMLElement) {
const fullList = sortAssets(state.masterData.server);
let sortState: SortState = { key: '', direction: 'asc' };
const filterBar = document.createElement('div');
filterBar.className = 'search-bar';
const corps = Array.from(new Set(fullList.map(a => a.))).filter(Boolean).sort();
const orgUnits = Array.from(new Set(fullList.map(a => a.))).filter(Boolean).sort();
const corps = Array.from(new Set(fullList.map(a => a[ASSET_SCHEMA.CORP.key]))).filter(Boolean).sort();
const orgUnits = Array.from(new Set(fullList.map(a => a[ASSET_SCHEMA.ORG.key]))).filter(Boolean).sort();
filterBar.innerHTML = `
<div class="search-item flex-1">
<label>통합 검색 (자산번호/조직/모델명)</label>
<label>통합 검색 (${ASSET_SCHEMA.ASSET_CODE.ui}/${ASSET_SCHEMA.ORG.ui}/${ASSET_SCHEMA.MODEL.ui})</label>
<input type="text" id="filter-keyword" placeholder="검색어를 입력하세요..." autocomplete="off">
</div>
<div class="search-item">
<label>구매법인</label>
<label>${ASSET_SCHEMA.CORP.ui}</label>
<select id="filter-corp"><option value="">전체 법인</option>${corps.map(c => `<option value="${c}">${c}</option>`).join('')}</select>
</div>
<div class="search-item">
<label>현 사용조직</label>
<label>${ASSET_SCHEMA.ORG.ui}</label>
<select id="filter-org-unit"><option value="">전체 조직</option>${orgUnits.map(o => `<option value="${o}">${o}</option>`).join('')}</select>
</div>
<button id="btn-reset-filters" class="btn btn-outline btn-reset">
<i data-lucide="refresh-ccw"></i> 필터 초기화
<i data-lucide="refresh-ccw"></i> ${UI_TEXT.ACTION.RESET_FILTER}
</button>
`;
container.appendChild(filterBar);
@@ -33,7 +41,21 @@ export function renderServerList(container: HTMLElement) {
const tableWrapper = document.createElement('div');
tableWrapper.className = 'table-container';
const table = document.createElement('table');
table.innerHTML = `<thead><tr><th>No</th><th>구매법인</th><th>현 사용조직</th><th>자산번호</th><th>용도</th><th>상세</th><th>설치위치</th><th>담당자</th><th>IP주소</th><th>모델명</th><th>OS</th><th>CPU/RAM</th><th>Storage</th><th>관리</th></tr></thead><tbody id="dynamic-tbody"></tbody>`;
table.innerHTML = `
<thead>
<tr>
<th class="text-center" style="width:50px;">No</th>
<th class="text-center" data-sort="${ASSET_SCHEMA.CORP.key}">${ASSET_SCHEMA.CORP.ui}</th>
<th class="text-center" data-sort="${ASSET_SCHEMA.ORG.key}">${ASSET_SCHEMA.ORG.ui}</th>
<th class="text-center" data-sort="${ASSET_SCHEMA.ASSET_CODE.key}">${ASSET_SCHEMA.ASSET_CODE.ui}</th>
<th data-sort="${ASSET_SCHEMA.DETAIL_PURPOSE.key}">${ASSET_SCHEMA.DETAIL_PURPOSE.ui}</th>
<th data-sort="상세">상세</th>
<th class="text-center" data-sort="${ASSET_SCHEMA.LOCATION.key}">${ASSET_SCHEMA.LOCATION.ui}</th>
<th class="text-center" data-sort="${ASSET_SCHEMA.MANAGER_MAIN.key}">담당자(정/부)</th>
</tr>
</thead>
<tbody id="dynamic-tbody"></tbody>
`;
tableWrapper.appendChild(table);
container.appendChild(tableWrapper);
@@ -48,16 +70,23 @@ export function renderServerList(container: HTMLElement) {
const corp = corpSelect ? corpSelect.value : '';
const orgUnit = orgSelect ? orgSelect.value : '';
const filtered = fullList.filter(asset => {
const matchKeyword = !keyword || String(asset.||'').toLowerCase().includes(keyword) || String(asset.||'').toLowerCase().includes(keyword) || String(asset.||'').toLowerCase().includes(keyword);
const matchCorp = !corp || asset. === corp;
const matchOrg = !orgUnit || asset. === orgUnit;
let filtered = fullList.filter(asset => {
const matchKeyword = !keyword ||
String(asset[ASSET_SCHEMA.ASSET_CODE.key]||'').toLowerCase().includes(keyword) ||
String(asset[ASSET_SCHEMA.ORG.key]||'').toLowerCase().includes(keyword) ||
String(asset[ASSET_SCHEMA.MODEL.key]||'').toLowerCase().includes(keyword);
const matchCorp = !corp || asset[ASSET_SCHEMA.CORP.key] === corp;
const matchOrg = !orgUnit || asset[ASSET_SCHEMA.ORG.key] === orgUnit;
return matchKeyword && matchCorp && matchOrg;
});
if (sortState.key) {
filtered = dynamicSort(filtered, sortState.key, sortState.direction);
}
tbody.innerHTML = '';
if (filtered.length === 0) {
tbody.innerHTML = `<tr><td colspan="14" style="text-align:center; padding: 3rem; color: var(--text-muted);">검색 결과가 없습니다.</td></tr>`;
tbody.innerHTML = `<tr><td colspan="8" class="text-center" style="padding: 3rem; color: var(--text-muted);">${UI_TEXT.MESSAGES.NO_DATA}</td></tr>`;
return;
}
@@ -65,33 +94,31 @@ export function renderServerList(container: HTMLElement) {
const tr = document.createElement('tr');
tr.style.cursor = 'pointer';
const mainManager = asset._정 || '';
const subManager = asset._부 || '';
const managerHtml = [mainManager ? `${createBadge('정', '#1E5149')} ${mainManager}` : '', subManager ? `${createBadge('부', '#9CA3AF')} ${subManager}` : ''].filter(v => v !== '').join(' / ');
const mainManager = asset[ASSET_SCHEMA.MANAGER_MAIN.key] || '';
const subManager = asset[ASSET_SCHEMA.MANAGER_SUB.key] || '';
const managerHtml = [
mainManager ? `${createBadge('정', 'primary')} ${mainManager}` : '',
subManager ? `${createBadge('부', 'muted')} ${subManager}` : ''
].filter(v => v !== '').join(' / ');
const ipInfo = [asset.IP주소, asset.IP2].filter(v => v).join(' / ');
const cpuRam = [asset.CPU, asset.RAM].filter(v => v).join(' / ');
const storage = [asset.SSD1, asset.SSD2].filter(v => v).join(' / ');
tr.innerHTML = `
<td>${idx+1}</td>
<td>${asset.}</td>
<td>${asset.||''}</td>
<td>${asset.}</td>
<td>${formatInline(asset.)}</td>
<td class="text-center">${idx+1}</td>
<td class="text-center">${asset[ASSET_SCHEMA.CORP.key]}</td>
<td class="text-center">${asset[ASSET_SCHEMA.ORG.key]||'-'}</td>
<td class="text-center">${asset[ASSET_SCHEMA.ASSET_CODE.key]}</td>
<td>${formatInline(asset[ASSET_SCHEMA.DETAIL_PURPOSE.key])}</td>
<td>${formatInline(asset.)}</td>
<td>${formatInline(asset.)}</td>
<td>${managerHtml}</td>
<td>${formatInline(ipInfo)}</td>
<td>${asset.||''}</td>
<td>${asset.OS||''}</td>
<td>${formatInline(cpuRam)}</td>
<td>${formatInline(storage)}</td>
<td><button class="btn btn-outline btn-sm">수정</button></td>
<td class="text-center">${formatInline(asset[ASSET_SCHEMA.LOCATION.key])}</td>
<td class="text-center">${managerHtml || '-'}</td>
`;
tr.addEventListener('click', (e) => { if (!(e.target as HTMLElement).closest('button')) openHwModal(asset, 'view'); });
tr.addEventListener('click', () => openHwModal(asset, 'view'));
tbody.appendChild(tr);
});
setupTableSorting(table, sortState, (key, dir) => {
sortState = { key, direction: dir };
updateTable();
});
};
document.getElementById('filter-keyword')?.addEventListener('input', updateTable);
@@ -105,4 +132,5 @@ export function renderServerList(container: HTMLElement) {
});
updateTable();
createIcons({ icons: { RefreshCcw } });
}

View File

@@ -1,31 +1,39 @@
import { state } from '../../core/state';
import { openHwModal } from '../../components/Modal/HWModal';
import { formatInline, createBadge, sortAssets } from '../../core/utils';
import { formatInline, createBadge, sortAssets, dynamicSort } from '../../core/utils';
import { ASSET_SCHEMA, UI_TEXT } from '../../core/schema';
import { setupTableSorting, SortState } from '../../core/tableHandler';
import { createIcons, RefreshCcw } from 'lucide';
/**
* 스토리지 자산 목록 뷰
* 라인 정렬 보정 및 헤더 통일
*/
export function renderStorageList(container: HTMLElement) {
const fullList = sortAssets(state.masterData.storage);
let sortState: SortState = { key: '', direction: 'asc' };
const filterBar = document.createElement('div');
filterBar.className = 'search-bar';
const corps = Array.from(new Set(fullList.map(a => a.))).filter(Boolean).sort();
const orgUnits = Array.from(new Set(fullList.map(a => a.))).filter(Boolean).sort();
const corps = Array.from(new Set(fullList.map(a => (a as any)[ASSET_SCHEMA.CORP.key]))).filter(Boolean).sort();
const orgUnits = Array.from(new Set(fullList.map(a => (a as any)[ASSET_SCHEMA.ORG.key]))).filter(Boolean).sort();
filterBar.innerHTML = `
<div class="search-item flex-1">
<label>통합 검색 (자산번호/조직/모델명)</label>
<label>통합 검색 (${ASSET_SCHEMA.ASSET_CODE.ui}/${ASSET_SCHEMA.ORG.ui})</label>
<input type="text" id="filter-keyword" placeholder="검색어를 입력하세요..." autocomplete="off">
</div>
<div class="search-item">
<label>구매법인</label>
<label>${ASSET_SCHEMA.CORP.ui}</label>
<select id="filter-corp"><option value="">전체 법인</option>${corps.map(c => `<option value="${c}">${c}</option>`).join('')}</select>
</div>
<div class="search-item">
<label>현 사용조직</label>
<label>${ASSET_SCHEMA.ORG.ui}</label>
<select id="filter-org-unit"><option value="">전체 조직</option>${orgUnits.map(o => `<option value="${o}">${o}</option>`).join('')}</select>
</div>
<button id="btn-reset-filters" class="btn btn-outline btn-reset">
<i data-lucide="refresh-ccw"></i> 필터 초기화
<i data-lucide="refresh-ccw"></i> ${UI_TEXT.ACTION.RESET_FILTER}
</button>
`;
container.appendChild(filterBar);
@@ -33,7 +41,21 @@ export function renderStorageList(container: HTMLElement) {
const tableWrapper = document.createElement('div');
tableWrapper.className = 'table-container';
const table = document.createElement('table');
table.innerHTML = `<thead><tr><th>No</th><th>구매법인</th><th>현 사용조직</th><th>자산번호</th><th>용도</th><th>상세</th><th>설치위치</th><th>담당자</th><th>모델명</th><th>Storage</th><th>관리</th></tr></thead><tbody id="dynamic-tbody"></tbody>`;
table.innerHTML = `
<thead>
<tr>
<th class="text-center" style="width:50px;">No</th>
<th class="text-center" data-sort="${ASSET_SCHEMA.CORP.key}">${ASSET_SCHEMA.CORP.ui}</th>
<th class="text-center" data-sort="${ASSET_SCHEMA.ORG.key}">${ASSET_SCHEMA.ORG.ui}</th>
<th class="text-center" data-sort="${ASSET_SCHEMA.ASSET_CODE.key}">${ASSET_SCHEMA.ASSET_CODE.ui}</th>
<th data-sort="용도">용도</th>
<th data-sort="상세">상세</th>
<th class="text-center" data-sort="${ASSET_SCHEMA.LOCATION.key}">${ASSET_SCHEMA.LOCATION.ui}</th>
<th class="text-center" data-sort="${ASSET_SCHEMA.MANAGER_MAIN.key}">담당자(정/부)</th>
</tr>
</thead>
<tbody id="dynamic-tbody"></tbody>
`;
tableWrapper.appendChild(table);
container.appendChild(tableWrapper);
@@ -48,16 +70,22 @@ export function renderStorageList(container: HTMLElement) {
const corp = corpSelect ? corpSelect.value : '';
const orgUnit = orgSelect ? orgSelect.value : '';
const filtered = fullList.filter(asset => {
const matchKeyword = !keyword || String(asset.||'').toLowerCase().includes(keyword) || String(asset.||'').toLowerCase().includes(keyword) || String(asset.||'').toLowerCase().includes(keyword);
const matchCorp = !corp || asset. === corp;
const matchOrg = !orgUnit || asset. === orgUnit;
let filtered = fullList.filter(asset => {
const matchKeyword = !keyword ||
String((asset as any)[ASSET_SCHEMA.ASSET_CODE.key]||'').toLowerCase().includes(keyword) ||
String((asset as any)[ASSET_SCHEMA.ORG.key]||'').toLowerCase().includes(keyword);
const matchCorp = !corp || (asset as any)[ASSET_SCHEMA.CORP.key] === corp;
const matchOrg = !orgUnit || (asset as any)[ASSET_SCHEMA.ORG.key] === orgUnit;
return matchKeyword && matchCorp && matchOrg;
});
if (sortState.key) {
filtered = dynamicSort(filtered, sortState.key, sortState.direction);
}
tbody.innerHTML = '';
if (filtered.length === 0) {
tbody.innerHTML = `<tr><td colspan="11" style="text-align:center; padding: 3rem; color: var(--text-muted);">검색 결과가 없습니다.</td></tr>`;
tbody.innerHTML = `<tr><td colspan="8" class="text-center" style="padding: 3rem; color: var(--text-muted);">${UI_TEXT.MESSAGES.NO_DATA}</td></tr>`;
return;
}
@@ -65,28 +93,31 @@ export function renderStorageList(container: HTMLElement) {
const tr = document.createElement('tr');
tr.style.cursor = 'pointer';
const mainManager = asset._정 || asset. || '';
const subManager = asset._부 || '';
const managerHtml = [mainManager ? `${createBadge('정', '#1E5149')} ${mainManager}` : '', subManager ? `${createBadge('부', '#9CA3AF')} ${subManager}` : ''].filter(v => v !== '').join(' / ');
const mainManager = (asset as any)[ASSET_SCHEMA.MANAGER_MAIN.key] || '';
const subManager = (asset as any)[ASSET_SCHEMA.MANAGER_SUB.key] || '';
const managerHtml = [
mainManager ? `${createBadge('정', 'primary')} ${mainManager}` : '',
subManager ? `${createBadge('부', 'muted')} ${subManager}` : ''
].filter(v => v !== '').join(' / ');
const storage = [asset.SSD1, asset.SSD2, asset.].filter(v => v).join(' / ');
tr.innerHTML = `
<td>${idx+1}</td>
<td>${asset.}</td>
<td>${asset.||''}</td>
<td>${asset.}</td>
<td class="text-center">${idx+1}</td>
<td class="text-center">${(asset as any)[ASSET_SCHEMA.CORP.key]}</td>
<td class="text-center">${(asset as any)[ASSET_SCHEMA.ORG.key]||'-'}</td>
<td class="text-center">${(asset as any)[ASSET_SCHEMA.ASSET_CODE.key]}</td>
<td>${formatInline(asset.)}</td>
<td>${formatInline(asset.)}</td>
<td>${formatInline(asset.)}</td>
<td>${managerHtml}</td>
<td>${asset.||''}</td>
<td>${formatInline(storage)}</td>
<td><button class="btn btn-outline btn-sm">수정</button></td>
<td class="text-center">${formatInline((asset as any)[ASSET_SCHEMA.LOCATION.key])}</td>
<td class="text-center">${managerHtml || '-'}</td>
`;
tr.addEventListener('click', (e) => { if (!(e.target as HTMLElement).closest('button')) openHwModal(asset, 'view'); });
tr.addEventListener('click', () => openHwModal(asset, 'view'));
tbody.appendChild(tr);
});
setupTableSorting(table, sortState, (key, dir) => {
sortState = { key, direction: dir };
updateTable();
});
};
document.getElementById('filter-keyword')?.addEventListener('input', updateTable);
@@ -100,4 +131,5 @@ export function renderStorageList(container: HTMLElement) {
});
updateTable();
createIcons({ icons: { RefreshCcw } });
}

View File

@@ -1,7 +1,8 @@
import { state } from '../../core/state';
import { openSwModal } from '../../components/Modal/SWModal';
import { openSwUserModal } from '../../components/Modal/SWUserModal';
import { sortAssets, formatPrice } from '../../core/utils';
import { sortAssets, dynamicSort, formatPrice } from '../../core/utils';
import { setupTableSorting, SortState } from '../../core/tableHandler';
import { CORP_LIST } from '../../components/Modal/SharedData';
import { generateOptionsHTML } from '../../components/Modal/ModalUtils';
import { createIcons, Edit2, Users, RefreshCcw } from 'lucide';
@@ -10,6 +11,8 @@ export function renderSwList(container: HTMLElement) {
const isSub = state.activeSubTab === '구독SW';
const fullList = sortAssets(isSub ? state.masterData.subSw : state.masterData.permSw);
let sortState: SortState = { key: '', direction: 'asc' };
const filterBar = document.createElement('div');
filterBar.className = 'search-bar';
filterBar.innerHTML = `
@@ -43,18 +46,19 @@ export function renderSwList(container: HTMLElement) {
table.innerHTML = `
<thead>
<tr>
<th style="text-align:center;">No.</th>
<th style="text-align:center;">상태</th>
<th style="text-align:center;">분야</th>
<th style="text-align:center;">법인</th>
<th style="text-align:center;">부서</th>
<th style="text-align:center;">제품명</th>
<th style="text-align:center;">구매일</th>
<th style="text-align:center;">시작일</th>
<th style="text-align:center;">만료일</th>
<th style="text-align:center;">금액</th>
<th style="text-align:center;">수량</th>
<th style="text-align:center; width: 50px;">No.</th>
<th style="text-align:center;" data-sort="상태">상태</th>
<th style="text-align:center;" data-sort="분야">분야</th>
<th style="text-align:center;" data-sort="법인">법인</th>
<th style="text-align:center;" data-sort="부서">부서</th>
<th style="text-align:center;" data-sort="제품명">제품명</th>
<th style="text-align:center;" data-sort="구매일">구매일</th>
<th style="text-align:center;" data-sort="시작일">시작일</th>
<th style="text-align:center;" data-sort="만료일">만료일</th>
<th style="text-align:center;" data-sort="금액">금액</th>
<th style="text-align:center;" data-sort="수량">수량</th>
<th style="text-align:center;">사용가능</th>
<th style="text-align:center;">사용자</th>
</tr>
</thead>
<tbody id="dynamic-tbody"></tbody>
@@ -73,21 +77,26 @@ export function renderSwList(container: HTMLElement) {
const field = fieldSelect ? fieldSelect.value : '';
const corp = corpSelect ? corpSelect.value : '';
const filtered = fullList.filter(asset => {
let 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;
});
if (sortState.key) {
filtered = dynamicSort(filtered, sortState.key, sortState.direction);
}
tbody.innerHTML = '';
if (filtered.length === 0) {
tbody.innerHTML = `<tr><td colspan="12" style="text-align:center; padding: 3rem; color: var(--text-muted);">검색 결과가 없습니다.</td></tr>`;
tbody.innerHTML = `<tr><td colspan="13" style="text-align:center; padding: 3rem; color: var(--text-muted);">검색 결과가 없습니다.</td></tr>`;
return;
}
filtered.forEach((asset, idx) => {
const assigned = state.masterData.swUsers.filter(u => u.sw_id === asset.id).length;
const mapping = state.masterData.swUsers.find(u => u.sw_id === asset.id);
const assigned = mapping ? (mapping.userData || []).length : 0;
const qty = typeof asset. === 'number' ? asset.수량 : parseInt(asset.||'0', 10);
const avail = qty - assigned;
@@ -105,8 +114,18 @@ export function renderSwList(container: HTMLElement) {
if (isExpired) statusHtml = `<span style="background: var(--danger, #ef4444); color: white; padding: 2px 6px; border-radius: 4px; font-size: 0.75rem; font-weight: bold; white-space: nowrap;">만료</span>`;
else statusHtml = `<span style="background: var(--primary-color, #1E5149); color: white; padding: 2px 6px; border-radius: 4px; font-size: 0.75rem; font-weight: bold; white-space: nowrap;">사용중</span>`;
} else {
if (asset.) statusHtml = `<span style="background: #3b82f6; color: white; padding: 2px 6px; border-radius: 4px; font-size: 0.75rem; font-weight: bold; white-space: nowrap;">유효</span>`;
else statusHtml = `<span style="background: #6b7280; color: white; padding: 2px 6px; border-radius: 4px; font-size: 0.75rem; font-weight: bold; white-space: nowrap;">없음</span>`;
let isMaintenance = false;
if (asset. && asset.) {
const startDate = new Date(asset..replace(/\./g, '-'));
const endDate = new Date(asset..replace(/\./g, '-'));
const today = new Date();
if (!isNaN(startDate.getTime()) && !isNaN(endDate.getTime())) {
endDate.setHours(23, 59, 59, 999);
if (today >= startDate && today <= endDate) isMaintenance = true;
}
}
if (isMaintenance) statusHtml = `<span style="background: #3b82f6; color: white; padding: 2px 6px; border-radius: 4px; font-size: 0.75rem; font-weight: bold; white-space: nowrap;">유지보수</span>`;
else statusHtml = `<span style="background: #6b7280; color: white; padding: 2px 6px; border-radius: 4px; font-size: 0.75rem; font-weight: bold; white-space: nowrap;">보유중</span>`;
}
const tr = document.createElement('tr');
@@ -125,13 +144,30 @@ export function renderSwList(container: HTMLElement) {
<td style="text-align:right;">${formatPrice(asset.)}</td>
<td style="text-align:center;">${qty}</td>
<td style="text-align:center;"><strong style="color: ${avail > 0 ? 'var(--primary-color)' : 'var(--danger)'}">${avail}</strong></td>
<td style="text-align:center;">
<button class="btn-icon btn-user-mgmt" title="사용자 관리" style="margin: 0 auto; color: var(--primary-color);">
<i data-lucide="users" style="width:18px; height:18px;"></i>
</button>
</td>
`;
const userBtn = tr.querySelector('.btn-user-mgmt');
userBtn?.addEventListener('click', (e) => {
e.stopPropagation();
openSwUserModal(asset);
});
tr.addEventListener('click', (e) => {
openSwModal(asset, 'view');
});
tbody.appendChild(tr);
});
setupTableSorting(table, sortState, (key, dir) => {
sortState = { key, direction: dir };
updateTable();
});
createIcons({ icons: { Edit2, Users, RefreshCcw } });
};

View File

@@ -6,6 +6,7 @@ import { renderEquipmentList } from './List/EquipmentListView';
import { renderMobileList } from './List/MobileListView';
import { renderSwList } from './List/SwListView';
import { renderCloudList } from './List/CloudListView';
import { renderDomainList } from './List/DomainListView';
import { createIcons, Download, Upload, FileSpreadsheet, Plus, X, LayoutDashboard, Monitor, Server, Database, Laptop, CalendarClock, Key, Cpu, Layers, Users, Paperclip, Edit2, RefreshCcw } from 'lucide';
/**
@@ -39,6 +40,11 @@ export function renderSWTable(mainContent: HTMLElement) {
} else {
container.innerHTML = `<div style="padding:2rem; color:var(--text-muted);">"${tab}" 탭에 대한 소프트웨어 리스트 뷰가 정의되지 않았습니다.</div>`;
}
} else if (state.activeCategory === 'ops') {
if (tab === '도메인') renderDomainList(container);
else {
container.innerHTML = `<div style="padding:2rem; color:var(--text-muted); text-align:center; margin-top:3rem;">운영 서비스(${tab}) 관리 기능은 현재 준비 중입니다.</div>`;
}
}
mainContent.appendChild(container);