6 Commits

11 changed files with 755 additions and 98 deletions

58
branch_diff_issues.md Normal file
View File

@@ -0,0 +1,58 @@
# ITAM 프로젝트 브랜치별 변경 사항 및 이슈 정리
본 문서는 `setting` 브랜치를 기준으로 `SW_Table`, `Operation_Table`, `Upload` 브랜치의 주요 변경 사항을 정리한 내용입니다. Gitea 이슈 등록 시 참고하시기 바랍니다.
---
## 1. [SW_Table] 소프트웨어 자산 관리 고도화 및 대시보드 강화
**제목:** `[SW_Table] 소프트웨어 자산 관리 고도화 및 대시보드 강화`
### 주요 변경 사항
- **SW 상세 모달 리팩토링**
- 구독형, 영구형, 클라우드 자산 유형에 따른 동적 필드 전환 기능 구현
- 자산 유형 명칭 일원화 및 필드 매핑 로직 개선
- **데이터 표준화 및 확장**
- 시작일/만료일 'yyyy-mm-dd' 형식 통일 및 데이터 일관성 확보
- 클라우드 자산 통합 관리 및 관련 스키마 확장
- **대시보드 분석 기능 강화**
- 월별 누적 비용 분석 그래프 도입
- 카테고리별 자산 보유 현황 및 비용 통계 시각화 고도화
- **사용자 관리 개선**
- SW 사용자 할당 및 이력 관리 기능 강화 (`SWUserModal` 도입)
---
## 2. [Operation_Table] 운영 서비스 도메인 관리 모듈 및 UI 최적화
**제목:** `[Operation_Table] 운영 서비스 도메인 관리 모듈 및 UI 최적화`
### 주요 변경 사항
- **도메인 관리 모듈 신규 도입**
- `ops_domain_assets` 테이블 신규 생성 및 서버 API 연동
- 유형(호스팅/SSL/도메인/네임서버), 법인, 서비스명, 관리도메인 등 상세 필드 관리
- **도메인 전용 UI 구현**
- 도메인 전용 등록/수정 모달 및 리스트 뷰 인터페이스 구축
- **사용자 경험(UX) 및 스타일 최적화**
- 상단바와 본문 사이 간격 조정 (여백 최적화)
- 전역 스타일 가이드에 맞춘 UI 레이아웃 정밀 조정
- **기능 통합**
- SW_Table의 모든 고도화 사항을 포함하여 운영 환경에 맞춰 통합 완료
---
## 3. [Upload] 엑셀 대량 업로드 워크플로우 및 자산코드 자동 생성
**제목:** `[Upload] 엑셀 대량 업로드 워크플로우 및 자산코드 자동 생성`
### 주요 변경 사항
- **통합 엑셀 업로드 시스템**
- 9개 자산 카테고리(HW, SW, 클라우드, 도메인)를 아우르는 통합 엑셀 양식 파싱 엔진 구현
- **업로드 데이터 검토 프로세스 (`UploadPreviewModal`)**
- 업로드 전 데이터를 미리 확인하고 검증할 수 있는 중간 검토 모달 도입
- 시트별 데이터 요약 및 상세 내역 확인 기능 제공
- **자산코드 일괄 생성 기능**
- 하드웨어 자산 대상, 카테고리별 접두사 및 구매연월 기반 자동 번호 부여 시스템 구축
- 서버 API와 연동된 중복 방지 및 자동 증분 로직 적용
- **안정성 및 네트워크 최적화**
- 대량 데이터 처리를 위한 배치 저장 API(Port 3000) 연동 및 절대 경로 통신 적용

View File

@@ -149,6 +149,23 @@ async function initDB() {
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
`);
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();
}

View File

@@ -93,6 +93,14 @@ 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;
`);
console.log('✅ All ITAM tables ensured.');
} finally {
@@ -405,6 +413,24 @@ app.post('/api/sw-users/batch', async (req, res) => {
} catch (err) { res.status(500).json({ error: err.message }); }
});
// 도메인 관리 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;

View File

@@ -0,0 +1,192 @@
import { state } from '../../core/state';
import { closeModals, openModal } from './BaseModal';
import { CORP_LIST } from './SharedData';
import { generateOptionsHTML } from './ModalUtils';
import { createIcons, X, Save, Database, CalendarClock, Edit2 } from 'lucide';
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-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="3" 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-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>
`;
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());
document.getElementById('btn-save-domain')?.addEventListener('click', () => saveDomain());
}
export function openDomainModal(item: any = null) {
currentItem = item;
const isEdit = !!item;
const titleEl = document.getElementById('domain-modal-title');
if (titleEl) titleEl.textContent = isEdit ? '도메인 정보 수정' : '신규 도메인 등록';
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', item?.start_date || '');
setVal('domain-expiry-date', 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 || '');
openModal('domain-asset-modal');
createIcons({ icons: { X, Save, Database, CalendarClock, Edit2 } });
}
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) {
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);
}
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) {
// alert('성공적으로 저장되었습니다.');
closeModals();
window.dispatchEvent(new CustomEvent('refresh-view'));
} else {
throw new Error('DB 저장 실패');
}
} catch (err) {
console.error(err);
alert('저장 중 오류가 발생했습니다.');
}
}

View File

@@ -0,0 +1,299 @@
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) {
const response = await fetch(endpoint, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(data)
});
if (response.ok) successCount++;
}
}
if (successCount > 0) {
if (onSuccessCallback) onSuccessCallback();
closeModals();
alert(`${successCount}개 카테고리의 데이터가 성공적으로 업로드되었습니다.`);
} else {
alert('데이터 업로드에 실패했습니다.');
}
} catch (err) {
console.error(err);
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://172.16.40.100: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,74 +3,35 @@ 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;
상세용도?: string;
메인보드?: string;
보관위치?: 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;
}
export interface SWUser {
@@ -104,24 +65,24 @@ export interface MasterAssetData {
subSw: SoftwareAsset[];
permSw: SoftwareAsset[];
cloud: SoftwareAsset[];
domain?: any[];
hw: HardwareAsset[];
sw: SoftwareAsset[];
swUsers: any[]; // { sw_id, userData: [] } 형태로 처리
swUsers: SWUser[];
logs: HardwareLog[];
}
const HW_TABS = ['개인PC', '서버', '스토리지', '전산비품', '모바일기기'];
const SW_TABS = ['구독SW', '영구SW', '클라우드'];
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 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 = ['ID', '분야', '법인', '부서', '제품명', '구매연월', '만료일', '라이선스유형', '금액', '수량', '계정명', '납품업체', '비고'];
const PERM_SW_HEADERS = ['ID', '분야', '법인', '부서', '제품명', '구매연월', '만료일', '라이선스키', '금액', '수량', '계정명', '납품업체', '비고'];
const CLOUD_HEADERS = ['ID', '플랫폼명', '법인', '부서', '사용용도(제품명)', '계정명', '결제수단', '결제일', '연결카드번호', '당월청구액', '비고'];
const SUB_SW_HEADERS = ['분야', '법인', '제품명', '부서', '수량', '금액', '구매일', '납품업체', '시작일', '만료일', '라이선스유형', '계정명', '비고'];
const PERM_SW_HEADERS = ['분야', '법인', '제품명', '부서', '수량', '금액', '구매일', '납품업체', '시작일', '만료일', '라이선스키', '계정명', '비고'];
const CLOUD_HEADERS = ['플랫폼명', '법인', '제품명', '부서', '계정명', '결제수단', '결제일', '연결카드번호', '당월청구액', '비고'];
const DOMAIN_HEADERS = ['유형', '법인', '서비스명', '관리도메인', '시작일', '만료일', '금액', '담당자', '담당자(부)', '비고'];
export function downloadTemplate() {
const wb = XLSX.utils.book_new();
@@ -130,7 +91,11 @@ 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 }
];
tabConfigs.forEach(config => {
@@ -139,61 +104,67 @@ export function downloadTemplate() {
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., a.] },
{ tab: '서버', list: masterData.server, headers: SERVER_HEADERS, map: (a: any) => [a., 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., 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., 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., 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., 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., a., a.] }
{ 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., 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> {
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: [], hw: [], sw: [], swUsers: [], logs: [] };
const parsedData: any = {};
workbook.SheetNames.forEach(sheetName => {
const rows = XLSX.utils.sheet_to_json(workbook.Sheets[sheetName]) as any[];
const list: any[] = [];
rows.forEach(r => {
const common = { id: Math.random().toString(36).substring(2, 9) };
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['품의서명']||'', 비고: r['비고']||'', : '', MACaddress: '', OS: '', : '' }));
list.push({ ...common, 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['비고']||'' });
} else if (sheetName === '서버') {
rows.forEach(r => data.server.push({ id: Math.random().toString(36).substring(2, 9), type: '서버', 법인: r['구매법인']||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사양: '', : '', : '', : '' }));
list.push({ ...common, type: '서버', 법인: r['법인']||'', 자산코드: r['자산코드']||'', 구매연월: r['구매연월']||'', storage유형: r['유형']||'물리', 용도: 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']||'', HDD1: r['Storage 3']||'', 모니터링: r['모니터링']||'', 비고: r['비고']||'' });
} 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['품의서명']||'', 비고: r['비고']||'', HW사양: '', OS: '', : '' }));
list.push({ ...common, type: '스토리지', 법인: r['법인']||'', storage유형: r['유형']||'', 자산코드: r['자산코드']||'', 명칭: r['명칭']||'', 위치: r['위치']||'', 모델명: r['모델명']||'', 용량: r['용량']||'', 담당자_정: r['담당자(정)']||'', 담당자_부: r['담당자(부)']||'', IP주소: r['IP주소']||'', MACaddress: r['MAC주소']||'', 구매연월: r['구매연월']||'', 금액: r['금액']||'', 납품업체: r['납품업체']||'', 품의서명: r['품의서명']||'', 비고: r['비고']||'' });
} 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['품의서명']||'', 비고: r['비고']||'' }));
list.push({ ...common, type: '전산비품', 법인: 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['품의서명']||'', 비고: r['비고']||'', IP주소: '', MACaddress: '', HW사양: '' }));
list.push({ ...common, type: '모바일기기', 법인: r['법인']||'', 자산코드: r['자산코드']||'', 명칭: r['명칭']||'', 위치: r['위치']||'', 관리자: r['관리자']||'', 기기유형: r['기기유형']||'', OS: r['OS']||'', 구매연월: r['구매연월']||'', 금액: r['금액']||'', 납품업체: r['납품업체']||'', 품의서명: r['품의서명']||'', 비고: r['비고']||'' });
} 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['라이선스유형']||'', 금액: r['금액']||'', 수량: parseInt(r['수량']||'1'), 계정명: r['계정명']||'', 납품업체: r['납품업체']||'', 비고: r['비고']||'' }));
list.push({ ...common, type: '구독SW', 분야: r['분야']||'', 법인: 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['만료일']||'', 라이선스키: r['라이선스키']||'', 금액: r['금액']||'', 수량: parseInt(r['수량']||'1'), 계정명: r['계정명']||'', 납품업체: r['납품업체']||'', 비고: r['비고']||'' }));
list.push({ ...common, type: '영구SW', 분야: r['분야']||'', 법인: r['법인']||'', 부서: r['부서']||'', 제품명: r['제품명']||'', 구매: r['구매']||'', 시작일: r['시작일']||'', 만료일: 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: r['시작일']||'', expiry_date: r['만료일']||'', price: r['금액']||'', manager_main: r['담당자']||'', manager_sub: r['담당자(부)']||'', remarks: r['비고']||'' });
}
});
resolve(data);
if (list.length > 0) parsedData[sheetName] = list;
});
resolve(parsedData);
} catch (err) { reject(err); }
};
reader.readAsBinaryString(file);

View File

@@ -12,6 +12,7 @@ export interface MasterAssetData {
cloud: SoftwareAsset[]; // 클라우드 배열 추가
swUsers: SWUser[];
logs: HardwareLog[];
domain: any[];
// 동료 코드 호환용 통합 배열 (프론트엔드 로직용)
hw: HardwareAsset[];
@@ -41,7 +42,8 @@ export const state: AppState = {
hw: [], // 호환용
sw: [], // 호환용
swUsers: [],
logs: []
logs: [],
domain: []
}
};
@@ -59,6 +61,7 @@ export async function loadMasterDataFromDB() {
{ 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` }
];

View File

@@ -7,6 +7,8 @@ import { initBaseModal } from './components/Modal/BaseModal';
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, Settings } from 'lucide';
@@ -109,6 +111,11 @@ function initApp() {
}, closeAllModals);
initDashboardDetailModal();
initDomainModal();
initUploadPreviewModal(async () => {
await loadMasterDataFromDB();
refreshView();
});
initGuide();
// DB 데이터 로드 및 초기 화면 렌더링
@@ -119,6 +126,8 @@ function initApp() {
});
} catch (e) { console.error('❌ Initialization failed:', e); }
console.log('🚀 ITAM App Version 2.1.0 Loaded');
// 버튼 이벤트 바인딩
document.getElementById('btn-download-template')?.addEventListener('click', () => downloadTemplate());
document.getElementById('btn-export-excel')?.addEventListener('click', () => exportToExcel(state.masterData));
@@ -127,10 +136,17 @@ function initApp() {
uploadInput?.addEventListener('change', async (e) => {
const file = (e.target as HTMLInputElement).files?.[0];
if (file) {
console.log('📂 File selected:', file.name);
try {
const data = await parseExcel(file);
state.masterData = { ...state.masterData, ...data };
await Promise.all([saveAllHardwareToDB(), saveAllSoftwareToDB()]);
refreshView();
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);
}
}
});
@@ -142,6 +158,8 @@ function initApp() {
openHwModal({ id: Math.random().toString(36).substring(2, 9), type: defaultType, : '한맥', : '', : '', : '', MACaddress: '', HW사양: '', OS: '', : '', : '' } as any, 'add');
} else if (cat === 'sw') {
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);
}
});

View File

@@ -269,8 +269,7 @@ body {
/* --- Layout Frame --- */
.content-area {
flex: 1;
padding: 0 2rem;
/* 좌우 여백만 유지 */
padding: 1.25rem 2rem 0; /* 상단 여백 1.25rem 추가 */
overflow: hidden;
/* 전체 스크롤 차단 */
display: flex;

View File

@@ -0,0 +1,74 @@
import { state } from '../../core/state';
import { formatPrice } from '../../core/utils';
import { createIcons, Plus, Edit2, Trash2 } from 'lucide';
import { openDomainModal } from '../../components/Modal/DomainModal';
export function renderDomainList(container: HTMLElement) {
container.innerHTML = '';
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;">유형</th>
<th style="text-align:center;">법인</th>
<th style="text-align:left;">서비스명</th>
<th style="text-align:left;">관리도메인</th>
<th style="text-align:center;">시작일</th>
<th style="text-align:center;">만료일</th>
<th style="text-align:right;">금액</th>
<th style="text-align:center;">담당자</th>
<th style="text-align:center;">담당자(부)</th>
<th style="text-align:left;">비고</th>
</tr>
</thead>
<tbody>
${state.masterData.domain.length === 0 ? `
<tr>
<td colspan="11" style="text-align:center; padding: 3rem; color: var(--text-muted);">등록된 도메인 정보가 없습니다.</td>
</tr>
` : state.masterData.domain.map((item, idx) => `
<tr class="domain-row" data-id="${item.id}" style="cursor:pointer;">
<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 style="text-align:center;">${item.start_date || ''}</td>
<td style="text-align:center;">${item.expiry_date || ''}</td>
<td style="text-align:right;">${formatPrice(item.price)}</td>
<td style="text-align:center;">${item.manager_main || ''}</td>
<td style="text-align:center;">${item.manager_sub || ''}</td>
<td class="text-truncate" style="max-width:200px;">${item.remarks || ''}</td>
</tr>
`).join('')}
</tbody>
`;
tableWrapper.appendChild(table);
container.appendChild(tableWrapper);
// 이벤트 바인딩
table.querySelectorAll('.domain-row').forEach(row => {
row.addEventListener('click', () => {
const id = row.getAttribute('data-id');
const item = state.masterData.domain.find(d => d.id === id);
if (item) openDomainModal(item);
});
});
createIcons({ icons: { Plus, Edit2, Trash2 } });
}

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';
/**
@@ -40,10 +41,9 @@ export function renderSWTable(mainContent: HTMLElement) {
container.innerHTML = `<div style="padding:2rem; color:var(--text-muted);">"${tab}" 탭에 대한 소프트웨어 리스트 뷰가 정의되지 않았습니다.</div>`;
}
} else if (state.activeCategory === 'ops') {
if (['도메인', '메일', '메신저', '청구비용'].includes(tab)) {
renderCloudList(container);
} else {
container.innerHTML = `<div style="padding:2rem; color:var(--text-muted);">"${tab}" 탭에 대한 운영 서비스 뷰가 정의되지 않았습니다.</div>`;
if (tab === '도메인') renderDomainList(container);
else {
container.innerHTML = `<div style="padding:2rem; color:var(--text-muted); text-align:center; margin-top:3rem;">운영 서비스(${tab}) 관리 기능은 현재 준비 중입니다.</div>`;
}
}