3 Commits

24 changed files with 593 additions and 291 deletions

View File

@@ -1,58 +0,0 @@
# 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

@@ -61,6 +61,7 @@
<!-- 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>

View File

@@ -56,7 +56,7 @@ 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;
`);
@@ -102,6 +102,14 @@ async function ensureTables() {
) 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 {
connection.release();
@@ -121,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 {
@@ -134,16 +143,16 @@ 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.현재상태||''
];
@@ -157,7 +166,8 @@ const mapHardware = (r, defaultType) => {
구매일: r.purchase_date,
type: type,
상세용도: (type !== '개인PC' && !r.detail_purpose) ? type : r.detail_purpose,
용도: r.purpose,
용도: (type !== '개인PC' && !r.detail_purpose) ? type : r.detail_purpose,
사용자: r.purpose,
상세: r.details,
현사용조직: r.current_org,
이전사용조직: r.prev_org,
@@ -176,9 +186,10 @@ const mapHardware = (r, defaultType) => {
GPU: r.gpu,
SSD1: r.storage1,
SSD2: r.storage2,
HDD1: r.storage3,
SSD3: r.storage3,
모니터링: r.monitoring,
금액: r.price,
납품업체: r.vendor,
비고: r.remarks,
보관위치: r.storage_location,
현재상태: r.status

View File

@@ -1,8 +1,9 @@
import { state } from '../../core/state';
import { closeModals, openModal } from './BaseModal';
import { CORP_LIST } from './SharedData';
import { generateOptionsHTML } from './ModalUtils';
import { generateOptionsHTML, setEditLock } from './ModalUtils';
import { createIcons, X, Save, Database, CalendarClock, Edit2 } from 'lucide';
import { formatExcelDate } from '../../core/excelHandler';
let currentItem: any = null;
@@ -12,6 +13,7 @@ const DOMAIN_MODAL_HTML = `
<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>
@@ -86,19 +88,23 @@ const DOMAIN_MODAL_HTML = `
<!-- 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>
<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-cancel-domain" class="btn btn-outline">취소</button>
<button id="btn-save-domain" class="btn btn-primary"><i data-lucide="save"></i> 저장하기</button>
<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>
@@ -112,15 +118,47 @@ export function initDomainModal() {
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());
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 ? '도메인 정보 수정' : '신규 도메인 등록';
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;
@@ -131,17 +169,40 @@ export function openDomainModal(item: any = null) {
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-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 || '';
@@ -164,29 +225,17 @@ async function saveDomain() {
return;
}
if (currentItem) {
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);
}
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('저장 중 오류가 발생했습니다.');
}
await saveDomainBatch();
}

View File

@@ -45,14 +45,17 @@ const HW_FIELD_MAP: Record<string, string> = {
'모니터링': '모니터링',
'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
};
@@ -118,9 +121,11 @@ const HW_FORM_HTML = `
<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>
@@ -132,6 +137,7 @@ const HW_FORM_HTML = `
<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>
@@ -170,10 +176,13 @@ function applyTypeSpecificUI(type: string) {
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'),
ssd3: document.getElementById('hw-ssd3-group'),
hwSpec: document.getElementById('hw-hwspec-group'),
monitoring: document.getElementById('hw-monitoring-group'),
vendor: document.getElementById('hw-vendor-group'),
user: document.querySelector('.pc-only') as HTMLElement
};
@@ -224,16 +233,16 @@ function applyTypeSpecificUI(type: string) {
if (upperType === '노트북') {
if (groups.detailPurpose) groups.detailPurpose.style.display = 'none';
nonServer.forEach(el => (el as HTMLElement).style.display = 'flex');
['model', 'os', 'cpu', 'ram', 'ssd1', 'ssd2', 'hwSpec'].forEach(k => { if (groups[k]) groups[k]!.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', 'ram', 'ssd1', 'ssd2', 'monitoring'].forEach(k => { if (groups[k]) groups[k]!.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', 'ram', 'ssd1', 'ssd2', 'hwSpec'].forEach(k => { if (groups[k]) groups[k]!.style.display = 'flex'; });
['model', 'os', 'cpu', 'gpu', 'ram', 'ssd1', 'ssd2', 'ssd3', 'hwSpec', 'vendor'].forEach(k => { if (groups[k]) groups[k]!.style.display = 'flex'; });
}
}
}
@@ -241,7 +250,7 @@ function applyTypeSpecificUI(type: string) {
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';
['model', 'os', 'cpu', 'ram', 'ssd1', 'ssd2', 'monitoring'].forEach(k => { if (groups[k]) groups[k]!.style.display = 'flex'; });
['model', 'os', 'cpu', 'gpu', 'ram', 'ssd1', 'ssd2', 'ssd3', 'monitoring', 'vendor'].forEach(k => { if (groups[k]) groups[k]!.style.display = 'flex'; });
}
}

View File

@@ -210,12 +210,22 @@ async function confirmUpload() {
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++;
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
}
}
}
@@ -228,7 +238,7 @@ async function confirmUpload() {
}
} catch (err) {
console.error(err);
alert('업로드 중 오류가 발생했습니다.');
// 상세 에러는 내부 catch에서 이미 alert으로 띄움
} finally {
if (confirmBtn) {
confirmBtn.disabled = false;
@@ -274,7 +284,7 @@ async function generateBulkCodes() {
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 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, ''));

View File

@@ -3,11 +3,11 @@ import { state } from '../core/state';
const MENU_CONFIG = {
hw: {
label: '하드웨어',
tabs: ['대시보드', '개인PC', '서버', '스토리지', '전산비품', '모바일기기']
tabs: ['대시보드', '서버', '개인PC', '모바일기기', '스토리지', '전산비품']
},
sw: {
label: '소프트웨어',
tabs: ['대시보드', '구독SW', '영구SW', '클라우드']
tabs: ['대시보드', '구독SW', '영구SW']
},
ops: {
label: '운영 서비스',

View File

@@ -72,8 +72,8 @@ export interface MasterAssetData {
logs: HardwareLog[];
}
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 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', '구매연월', '금액', '납품업체', '품의서명', '비고'];
@@ -98,9 +98,19 @@ export function downloadTemplate() {
{ 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);
});
@@ -110,11 +120,11 @@ export function downloadTemplate() {
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: '개인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.] },
@@ -128,38 +138,66 @@ export function exportToExcel(masterData: MasterAssetData) {
XLSX.writeFile(wb, `itam_master_${new Date().toISOString().split('T')[0]}.xlsx`);
}
/**
* 엑셀 날짜 데이터(숫자 또는 문자열)를 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 workbook = XLSX.read(e.target?.result, { type: 'array' });
const parsedData: any = {};
workbook.SheetNames.forEach(sheetName => {
const rows = XLSX.utils.sheet_to_json(workbook.Sheets[sheetName]) as 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(r => {
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') {
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['비고']||'' });
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 === '서버') {
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['비고']||'' });
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 === '스토리지') {
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['비고']||'' });
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 === '전산비품') {
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['비고']||'' });
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 === '모바일기기') {
list.push({ ...common, type: '모바일기기', 법인: r['법인']||'', 자산코드: r['자산코드']||'', 명칭: r['명칭']||'', 위치: r['위치']||'', 관리자: r['관리자']||'', 기기유형: r['기기유형']||'', OS: r['OS']||'', 구매연월: r['구매연월']||'', 금액: r['금액']||'', 납품업체: r['납품업체']||'', 품의서명: r['품의서명']||'', 비고: r['비고']||'' });
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['제품명']||'', 구매일: r['구매일']||'', 시작일: r['시작일']||'', 만료일: r['만료일']||'', 라이선스유형: r['라이선스유형']||'', 금액: r['금액']||'', 수량: parseInt(r['수량']||'1'), 계정명: r['계정명']||'', 납품업체: r['납품업체']||'', 비고: r['비고']||'' });
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['제품명']||'', 구매일: r['구매일']||'', 시작일: r['시작일']||'', 만료일: r['만료일']||'', 라이선스키: r['라이선스키']||'', 금액: r['금액']||'', 수량: parseInt(r['수량']||'1'), 계정명: r['계정명']||'', 납품업체: r['납품업체']||'', 비고: r['비고']||'' });
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: r['시작일']||'', expiry_date: r['만료일']||'', price: r['금액']||'', manager_main: r['담당자']||'', manager_sub: r['담당자(부)']||'', remarks: r['비고']||'' });
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;
@@ -167,6 +205,6 @@ export async function parseExcel(file: File): Promise<any> {
resolve(parsedData);
} catch (err) { reject(err); }
};
reader.readAsBinaryString(file);
reader.readAsArrayBuffer(file);
});
}

View File

@@ -22,6 +22,7 @@ export const ASSET_SCHEMA = {
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: '사용자' },
@@ -35,6 +36,8 @@ export const ASSET_SCHEMA = {
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: '보관위치' },

View File

@@ -28,7 +28,7 @@ export interface AppState {
// 초기 상태
export const state: AppState = {
activeCategory: 'dashboard',
activeCategory: 'hw',
activeSubTab: '대시보드',
masterData: {
pc: [],

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

@@ -71,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

@@ -163,9 +163,21 @@ function initApp() {
}
});
// 시크릿 클라우드 트리거
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, Settings }
});
window.addEventListener('refresh-view', () => {
console.log('🔄 Refreshing view due to event');
refreshView();
});
}
document.addEventListener('DOMContentLoaded', initApp);

View File

@@ -64,11 +64,14 @@
background-color: var(--white);
border-top: 1px solid var(--border-color);
overflow: auto;
position: relative;
-webkit-overflow-scrolling: touch;
}
table {
width: 100%;
border-collapse: collapse;
border-collapse: separate;
border-spacing: 0;
table-layout: auto;
}
@@ -79,15 +82,21 @@ th, td {
white-space: nowrap;
}
thead {
position: sticky;
top: 0;
z-index: 50;
}
th {
background-color: #FAFAFA;
background-color: #FAFAFA !important;
font-size: 13px;
font-weight: 600;
color: var(--text-muted);
position: sticky;
top: 0;
z-index: 10;
box-shadow: inset 0 -1px 0 var(--border-color);
z-index: 50;
box-shadow: inset 0 1px 0 var(--border-color), inset 0 -1px 0 var(--border-color); /* 상하 테두리 보정 */
text-transform: none;
}
@@ -123,3 +132,40 @@ tbody tr:hover {
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

@@ -65,22 +65,25 @@ export function renderHwDashboard(container: HTMLElement) {
container.innerHTML = `
<div class="view-container">
<div class="dashboard-header-stats" style="display: grid; grid-template-columns: repeat(3, 1fr); gap: 1.5rem; margin-bottom: 2rem;">
<div class="dashboard-card stat-card">
<div class="stat-label">전체 평균 사용 연수</div>
<div class="stat-value">${avgAge}<span class="unit">년</span></div>
<div class="stat-footer">권장 교체 주기: 4.5년</div>
<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 stat-card ${over5Rate >= 20 ? 'critical' : ''}">
<div class="stat-label">5년 이상 노후 자산 비율</div>
<div class="stat-value" style="${over5Rate >= 20 ? 'color:var(--danger)' : ''}">${over5Rate}<span class="unit">%</span></div>
<div class="stat-footer">${over5YearsCount}대의 자산이 교체 대상을 초과함</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 stat-card">
<div class="stat-label">최신 도입 모델 (${latestYear}년)</div>
<div class="stat-value" style="font-size: 1.25rem; white-space: nowrap; overflow: hidden; text-overflow: ellipsis;" title="${(latestAsset as any)?. || '정보 없음'}">
<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 class="stat-footer">가장 최근 자산번호: ${(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>

View File

@@ -11,7 +11,6 @@ export function renderSwDashboard(container: HTMLElement) {
let subCost2026 = 0;
let permCost2026 = 0;
let cloudCost2026 = 0;
const currentYear = new Date().getFullYear();
@@ -22,8 +21,8 @@ export function renderSwDashboard(container: HTMLElement) {
const costByCat: Record<string, number> = {};
categories.forEach(c => costByCat[c] = 0);
// 통합 SW 데이터
const allSw = [...state.masterData.subSw, ...state.masterData.permSw, ...state.masterData.cloud];
// 통합 SW 데이터 (클라우드 제외)
const allSw = [...state.masterData.subSw, ...state.masterData.permSw];
allSw.forEach(sw => {
const userMapping = state.masterData.swUsers.find(u => u.sw_id === sw.id);
@@ -44,7 +43,6 @@ export function renderSwDashboard(container: HTMLElement) {
if (sw. && sw..startsWith('2026')) {
if (sw.type === '구독SW') subCost2026 += price;
else if (sw.type === '영구SW') permCost2026 += price;
else if (sw.type === '클라우드') cloudCost2026 += price;
if (costByCorp[sw.] !== undefined) costByCorp[sw.] += price;
if (sw. && costByCat[sw.] !== undefined) costByCat[sw.] += price;
@@ -60,7 +58,6 @@ export function renderSwDashboard(container: HTMLElement) {
const cost = Number(log.cost) || 0;
if (asset.type === '구독SW') subCost2026 += cost;
else if (asset.type === '영구SW') permCost2026 += cost;
else if (asset.type === '클라우드') cloudCost2026 += cost;
if (costByCorp[asset.] !== undefined) costByCorp[asset.] += cost;
if (asset. && costByCat[asset.] !== undefined) costByCat[asset.] += cost;
@@ -124,7 +121,7 @@ export function renderSwDashboard(container: HTMLElement) {
<h3 class="dashboard-section-title">2026년 누적 도입 비용 분석</h3>
<div style="display:grid; grid-template-columns: repeat(3, 1fr); gap:1.5rem; margin-bottom:1.5rem;">
<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>
@@ -137,12 +134,6 @@ export function renderSwDashboard(container: HTMLElement) {
<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 class="dashboard-card" style="min-height:auto;">
<span style="font-size:1rem; font-weight:700; color:var(--text-main);">클라우드 누적 비용 (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:#f59e0b;">₩ ${cloudCost2026.toLocaleString()}</div>
<div style="width: 100%; height: 4px; background-color: #f59e0b; border-radius: 2px; margin-top: 0.5rem;"></div>
</div>
</div>
</div>

View File

@@ -1,6 +1,8 @@
import { state } from '../../core/state';
import { openSwModal } from '../../components/Modal/SWModal';
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';
/**
@@ -9,6 +11,7 @@ import { createIcons, Cloud, CreditCard, DollarSign, RefreshCcw } from 'lucide';
*/
export function renderCloudList(container: HTMLElement) {
const getFullList = () => state.masterData.cloud || [];
let sortState: SortState = { key: '', direction: 'asc' };
const filterBar = document.createElement('div');
filterBar.className = 'search-bar';
@@ -37,15 +40,15 @@ export function renderCloudList(container: HTMLElement) {
table.innerHTML = `
<thead>
<tr>
<th class="text-center">No.</th>
<th>${ASSET_SCHEMA.PLATFORM.ui}</th>
<th class="text-center">${ASSET_SCHEMA.CORP.ui}</th>
<th class="text-center">담당부서</th>
<th>용도(프로젝트)</th>
<th>${ASSET_SCHEMA.ACCOUNT.ui}</th>
<th class="text-center">${ASSET_SCHEMA.PAY_METHOD.ui}</th>
<th class="text-center">${ASSET_SCHEMA.PAY_DAY.ui}</th>
<th class="text-center">${ASSET_SCHEMA.BILLING.ui}</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>
@@ -63,7 +66,7 @@ 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[ASSET_SCHEMA.PRODUCT.key] || '').toLowerCase().includes(keyword) ||
(asset. || '').toLowerCase().includes(keyword) ||
@@ -72,6 +75,10 @@ export function renderCloudList(container: HTMLElement) {
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" class="text-center" style="padding: 3rem; color: var(--text-muted);">${UI_TEXT.MESSAGES.NO_DATA}</td></tr>`;
@@ -105,6 +112,12 @@ export function renderCloudList(container: HTMLElement) {
tr.addEventListener('click', () => openSwModal(asset, 'view'));
tbody.appendChild(tr);
});
setupTableSorting(table, sortState, (key, dir) => {
sortState = { key, direction: dir };
updateTable();
});
createIcons({ icons: { Cloud, CreditCard, DollarSign, RefreshCcw } });
};

View File

@@ -1,11 +1,18 @@
import { state } from '../../core/state';
import { formatPrice } from '../../core/utils';
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 = `
@@ -17,58 +24,76 @@ export function renderDomainList(container: HTMLElement) {
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>
<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>
${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>
<tbody id="dynamic-tbody"></tbody>
`;
tableWrapper.appendChild(table);
container.appendChild(tableWrapper);
const tbody = table.querySelector('tbody')!;
// 이벤트 바인딩
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);
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,7 +1,8 @@
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';
/**
@@ -10,6 +11,7 @@ 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';
@@ -36,16 +38,16 @@ export function renderEquipmentList(container: HTMLElement) {
table.innerHTML = `
<thead>
<tr>
<th class="text-center">No.</th>
<th class="text-center">${ASSET_SCHEMA.STATUS.ui}</th>
<th class="text-center">${ASSET_SCHEMA.CORP.ui}</th>
<th class="text-center">유형</th>
<th class="text-center">${ASSET_SCHEMA.ASSET_CODE.ui}</th>
<th>${ASSET_SCHEMA.MODEL.ui}</th>
<th class="text-center">${ASSET_SCHEMA.STORE_LOC.ui}</th>
<th class="text-center">담당자(정/부)</th>
<th class="text-center">${ASSET_SCHEMA.PURCHASE_YM.ui}</th>
<th class="text-center">${ASSET_SCHEMA.PRICE.ui}</th>
<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>
@@ -62,7 +64,7 @@ export function renderEquipmentList(container: HTMLElement) {
const keyword = keywordInput ? keywordInput.value.toLowerCase().trim() : '';
const corp = corpSelect ? corpSelect.value : '';
const filtered = fullList.filter(asset => {
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) ||
@@ -71,6 +73,10 @@ export function renderEquipmentList(container: HTMLElement) {
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" class="text-center" style="padding: 3rem; color: var(--text-muted);">${UI_TEXT.MESSAGES.NO_DATA}</td></tr>`;
@@ -108,6 +114,12 @@ export function renderEquipmentList(container: HTMLElement) {
tr.addEventListener('click', () => openHwModal(asset, 'view'));
tbody.appendChild(tr);
});
setupTableSorting(table, sortState, (key, dir) => {
sortState = { key, direction: dir };
updateTable();
});
createIcons({ icons: { RefreshCcw } });
};

View File

@@ -1,7 +1,8 @@
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';
/**
@@ -10,6 +11,7 @@ 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';
@@ -36,15 +38,15 @@ export function renderMobileList(container: HTMLElement) {
table.innerHTML = `
<thead>
<tr>
<th class="text-center">No.</th>
<th class="text-center">${ASSET_SCHEMA.STATUS.ui}</th>
<th class="text-center">${ASSET_SCHEMA.CORP.ui}</th>
<th class="text-center">${ASSET_SCHEMA.ASSET_CODE.ui}</th>
<th>${ASSET_SCHEMA.MODEL.ui}</th>
<th class="text-center">${ASSET_SCHEMA.STORE_LOC.ui}</th>
<th class="text-center">담당자(정/부)</th>
<th class="text-center">${ASSET_SCHEMA.PURCHASE_YM.ui}</th>
<th class="text-center">${ASSET_SCHEMA.PRICE.ui}</th>
<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>
@@ -61,7 +63,7 @@ export function renderMobileList(container: HTMLElement) {
const keyword = keywordInput ? keywordInput.value.toLowerCase().trim() : '';
const corp = corpSelect ? corpSelect.value : '';
const filtered = fullList.filter(asset => {
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) ||
@@ -70,6 +72,10 @@ export function renderMobileList(container: HTMLElement) {
return matchKeyword && matchCorp;
});
if (sortState.key) {
filtered = dynamicSort(filtered, sortState.key, sortState.direction);
}
tbody.innerHTML = '';
if (filtered.length === 0) {
tbody.innerHTML = `<tr><td colspan="9" class="text-center" style="padding: 3rem; color: var(--text-muted);">${UI_TEXT.MESSAGES.NO_DATA}</td></tr>`;
@@ -106,6 +112,12 @@ export function renderMobileList(container: HTMLElement) {
tr.addEventListener('click', () => openHwModal(asset, 'view'));
tbody.appendChild(tr);
});
setupTableSorting(table, sortState, (key, dir) => {
sortState = { key, direction: dir };
updateTable();
});
createIcons({ icons: { RefreshCcw } });
};

View File

@@ -1,7 +1,8 @@
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, Paperclip, RefreshCcw } from 'lucide';
/**
@@ -10,6 +11,7 @@ import { createIcons, Paperclip, RefreshCcw } from 'lucide';
*/
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';
@@ -37,20 +39,19 @@ export function renderPcList(container: HTMLElement) {
table.innerHTML = `
<thead>
<tr>
<th style="text-align:center;">No</th>
<th style="text-align:center;">${ASSET_SCHEMA.CORP.ui}</th>
<th style="text-align:center;">${ASSET_SCHEMA.ORG.ui}</th>
<th style="text-align:center;">${ASSET_SCHEMA.ASSET_CODE.ui}</th>
<th style="text-align:center;">${ASSET_SCHEMA.USER.ui}</th>
<th style="text-align:center;">${ASSET_SCHEMA.LOCATION.ui}</th>
<th style="text-align:center;">담당자(정/부)</th>
<th style="text-align:center;">${ASSET_SCHEMA.MAINBOARD.ui}</th>
<th style="text-align:center;">${ASSET_SCHEMA.CPU.ui}</th>
<th style="text-align:center;">${ASSET_SCHEMA.RAM.ui}</th>
<th style="text-align:center;">Storage</th>
<th style="text-align:center;">${ASSET_SCHEMA.PURCHASE_YM.ui}</th>
<th style="text-align:center;">${ASSET_SCHEMA.PRICE.ui}</th>
<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>
@@ -67,7 +68,7 @@ export function renderPcList(container: HTMLElement) {
const keyword = keywordInput ? keywordInput.value.toLowerCase().trim() : '';
const corp = corpSelect ? corpSelect.value : '';
const filtered = fullList.filter(asset => {
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) ||
@@ -77,9 +78,13 @@ export function renderPcList(container: HTMLElement) {
return matchKeyword && matchCorp;
});
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);">${UI_TEXT.MESSAGES.NO_DATA}</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;
}
@@ -102,8 +107,6 @@ export function renderPcList(container: HTMLElement) {
<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.LOCATION.key]||''}</td>
<td style="text-align:center;">${managerHtml || '-'}</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>
@@ -111,10 +114,17 @@ export function renderPcList(container: HTMLElement) {
<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', () => openHwModal(asset, 'view'));
tbody.appendChild(tr);
});
setupTableSorting(table, sortState, (key, dir) => {
sortState = { key, direction: dir };
updateTable();
});
createIcons({ icons: { Paperclip, RefreshCcw } });
};

View File

@@ -1,7 +1,8 @@
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';
/**
@@ -10,6 +11,7 @@ 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';
@@ -42,14 +44,14 @@ export function renderServerList(container: HTMLElement) {
table.innerHTML = `
<thead>
<tr>
<th class="text-center">No</th>
<th class="text-center">${ASSET_SCHEMA.CORP.ui}</th>
<th class="text-center">${ASSET_SCHEMA.ORG.ui}</th>
<th class="text-center">${ASSET_SCHEMA.ASSET_CODE.ui}</th>
<th>용도</th>
<th>상세</th>
<th class="text-center">${ASSET_SCHEMA.LOCATION.ui}</th>
<th class="text-center">담당자(정/부)</th>
<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>
@@ -68,7 +70,7 @@ export function renderServerList(container: HTMLElement) {
const corp = corpSelect ? corpSelect.value : '';
const orgUnit = orgSelect ? orgSelect.value : '';
const filtered = fullList.filter(asset => {
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) ||
@@ -78,6 +80,10 @@ export function renderServerList(container: HTMLElement) {
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="8" class="text-center" style="padding: 3rem; color: var(--text-muted);">${UI_TEXT.MESSAGES.NO_DATA}</td></tr>`;
@@ -100,7 +106,7 @@ export function renderServerList(container: HTMLElement) {
<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.)}</td>
<td>${formatInline(asset[ASSET_SCHEMA.DETAIL_PURPOSE.key])}</td>
<td>${formatInline(asset.)}</td>
<td class="text-center">${formatInline(asset[ASSET_SCHEMA.LOCATION.key])}</td>
<td class="text-center">${managerHtml || '-'}</td>
@@ -108,6 +114,11 @@ export function renderServerList(container: HTMLElement) {
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);

View File

@@ -1,7 +1,8 @@
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';
/**
@@ -10,6 +11,7 @@ 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';
@@ -42,14 +44,14 @@ export function renderStorageList(container: HTMLElement) {
table.innerHTML = `
<thead>
<tr>
<th class="text-center">No</th>
<th class="text-center">${ASSET_SCHEMA.CORP.ui}</th>
<th class="text-center">${ASSET_SCHEMA.ORG.ui}</th>
<th class="text-center">${ASSET_SCHEMA.ASSET_CODE.ui}</th>
<th>용도</th>
<th>상세</th>
<th class="text-center">${ASSET_SCHEMA.LOCATION.ui}</th>
<th class="text-center">담당자(정/부)</th>
<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>
@@ -68,7 +70,7 @@ export function renderStorageList(container: HTMLElement) {
const corp = corpSelect ? corpSelect.value : '';
const orgUnit = orgSelect ? orgSelect.value : '';
const filtered = fullList.filter(asset => {
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);
@@ -77,6 +79,10 @@ export function renderStorageList(container: HTMLElement) {
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="8" class="text-center" style="padding: 3rem; color: var(--text-muted);">${UI_TEXT.MESSAGES.NO_DATA}</td></tr>`;
@@ -107,6 +113,11 @@ export function renderStorageList(container: HTMLElement) {
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);

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,17 +46,17 @@ 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>
@@ -74,13 +77,17 @@ 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="13" style="text-align:center; padding: 3rem; color: var(--text-muted);">검색 결과가 없습니다.</td></tr>`;
@@ -88,7 +95,8 @@ export function renderSwList(container: HTMLElement) {
}
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;
@@ -154,6 +162,12 @@ export function renderSwList(container: HTMLElement) {
});
tbody.appendChild(tr);
});
setupTableSorting(table, sortState, (key, dir) => {
sortState = { key, direction: dir };
updateTable();
});
createIcons({ icons: { Edit2, Users, RefreshCcw } });
};