8 Commits

23 changed files with 944 additions and 300 deletions

View File

@@ -61,6 +61,7 @@
<!-- Footer --> <!-- Footer -->
<footer class="main-footer"> <footer class="main-footer">
<div id="secret-cloud-trigger" style="width: 20px; height: 20px; cursor: pointer; opacity: 0.1; background: #000; border-radius: 4px; position: absolute; left: 1rem;"></div>
<p>Powered by BARON Consultant Co,Ltd</p> <p>Powered by BARON Consultant Co,Ltd</p>
</footer> </footer>
</div> </div>

View File

@@ -56,7 +56,7 @@ async function ensureTables() {
manager_main VARCHAR(100), manager_sub VARCHAR(100), ip_address VARCHAR(50), manager_main VARCHAR(100), manager_sub VARCHAR(100), ip_address VARCHAR(50),
remote_tool VARCHAR(100), server_id VARCHAR(100), server_pw VARCHAR(100), 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), 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) storage_location VARCHAR(255), status VARCHAR(50)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
`); `);
@@ -102,6 +102,14 @@ async function ensureTables() {
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; ) 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.'); console.log('✅ All ITAM tables ensured.');
} finally { } finally {
connection.release(); connection.release();
@@ -121,6 +129,7 @@ async function batchSave(tableName, assets, getQuery) {
await connection.commit(); await connection.commit();
return { success: true, count: assets.length }; return { success: true, count: assets.length };
} catch (err) { } catch (err) {
console.error(`❌ Batch Save Error (${tableName}):`, err.message);
await connection.rollback(); await connection.rollback();
throw err; throw err;
} finally { } finally {
@@ -134,16 +143,16 @@ const hardwareInsertSQL = (table) => `
id, corp, asset_code, purchase_date, type, detail_purpose, purpose, details, id, corp, asset_code, purchase_date, type, detail_purpose, purpose, details,
current_org, prev_org, location, manager_main, manager_sub, ip_address, current_org, prev_org, location, manager_main, manager_sub, ip_address,
remote_tool, server_id, server_pw, model_name, mainboard, os, cpu, ram, gpu, 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 storage_location, status
) VALUES ? ) VALUES ?
`; `;
const getHardwareValues = (a) => [ 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.이전사용조직||'', a.위치||'', a.담당자_정||'', a.담당자_부||'', a.IP주소||'',
a.원격접속||'', a.서버ID||'', a.서버PW||'', a.모델명||'', a.메인보드||'', a.OS||'', a.CPU||'', a.RAM||'', a.GPU||'', 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.현재상태||'' a.보관위치||'', a.현재상태||''
]; ];
@@ -157,7 +166,8 @@ const mapHardware = (r, defaultType) => {
구매일: r.purchase_date, 구매일: r.purchase_date,
type: type, type: type,
상세용도: (type !== '개인PC' && !r.detail_purpose) ? type : r.detail_purpose, 상세용도: (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.details,
현사용조직: r.current_org, 현사용조직: r.current_org,
이전사용조직: r.prev_org, 이전사용조직: r.prev_org,
@@ -176,9 +186,10 @@ const mapHardware = (r, defaultType) => {
GPU: r.gpu, GPU: r.gpu,
SSD1: r.storage1, SSD1: r.storage1,
SSD2: r.storage2, SSD2: r.storage2,
HDD1: r.storage3, SSD3: r.storage3,
모니터링: r.monitoring, 모니터링: r.monitoring,
금액: r.price, 금액: r.price,
납품업체: r.vendor,
비고: r.remarks, 비고: r.remarks,
보관위치: r.storage_location, 보관위치: r.storage_location,
현재상태: r.status 현재상태: r.status

View File

@@ -1,8 +1,9 @@
import { state } from '../../core/state'; import { state } from '../../core/state';
import { closeModals, openModal } from './BaseModal'; import { closeModals, openModal } from './BaseModal';
import { CORP_LIST } from './SharedData'; import { CORP_LIST } from './SharedData';
import { generateOptionsHTML } from './ModalUtils'; import { generateOptionsHTML, setEditLock } from './ModalUtils';
import { createIcons, X, Save, Database, CalendarClock, Edit2 } from 'lucide'; import { createIcons, X, Save, Database, CalendarClock, Edit2 } from 'lucide';
import { formatExcelDate } from '../../core/excelHandler';
let currentItem: any = null; let currentItem: any = null;
@@ -12,6 +13,7 @@ const DOMAIN_MODAL_HTML = `
<div class="modal-header"> <div class="modal-header">
<h2 id="domain-modal-title">도메인 정보</h2> <h2 id="domain-modal-title">도메인 정보</h2>
<div style="display:flex; gap:0.5rem; align-items:center;"> <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> <button id="btn-close-domain-modal" class="btn-icon"><i data-lucide="x"></i></button>
</div> </div>
</div> </div>
@@ -86,22 +88,26 @@ const DOMAIN_MODAL_HTML = `
<!-- Group 3: 기타 (Additional) --> <!-- Group 3: 기타 (Additional) -->
<div class="form-section-title" style="display:flex; align-items:center; gap:0.5rem; margin-top:1.5rem;"> <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> <i data-lucide="edit-2" style="width:16px; height:16px; color:var(--primary-color);"></i>
기타 사항 구매 정보
</div> </div>
<div class="form-group full-width"> <div class="form-group full-width">
<label>비고</label> <label>구매업체</label>
<textarea id="domain-remarks" rows="3" style="width:100%; border:1px solid var(--border-color); border-radius:4px; padding:0.625rem;"></textarea> <textarea id="domain-remarks" rows="1" style="width:100%; border:1px solid var(--border-color); border-radius:4px; padding:0.625rem;"></textarea>
</div> </div>
</form> </form>
</div> </div>
</div> </div>
<div class="modal-footer"> <div class="modal-footer">
<button id="btn-cancel-domain" class="btn btn-outline">취소</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> <button id="btn-save-domain" class="btn btn-primary"><i data-lucide="save"></i> 저장하기</button>
</div> </div>
</div> </div>
</div> </div>
</div>
`; `;
export function initDomainModal() { export function initDomainModal() {
@@ -112,15 +118,47 @@ export function initDomainModal() {
const modal = document.getElementById('domain-asset-modal')!; const modal = document.getElementById('domain-asset-modal')!;
document.getElementById('btn-close-domain-modal')?.addEventListener('click', () => closeModals()); document.getElementById('btn-close-domain-modal')?.addEventListener('click', () => closeModals());
document.getElementById('btn-cancel-domain')?.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) { export function openDomainModal(item: any = null) {
currentItem = item; currentItem = item;
const isEdit = !!item; const isEdit = !!item;
const mode = isEdit ? 'view' : 'add';
const titleEl = document.getElementById('domain-modal-title'); 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 setVal = (id: string, val: any) => {
const el = document.getElementById(id) as HTMLInputElement | HTMLSelectElement | HTMLTextAreaElement; 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-corp', item?.corp || '');
setVal('domain-service-name', item?.service_name || ''); setVal('domain-service-name', item?.service_name || '');
setVal('domain-name', item?.domain_name || ''); setVal('domain-name', item?.domain_name || '');
setVal('domain-start-date', item?.start_date || ''); setVal('domain-start-date', formatExcelDate(item?.start_date));
setVal('domain-expiry-date', item?.expiry_date || ''); setVal('domain-expiry-date', formatExcelDate(item?.expiry_date));
setVal('domain-price', item?.price || ''); setVal('domain-price', item?.price || '');
setVal('domain-manager-main', item?.manager_main || ''); setVal('domain-manager-main', item?.manager_main || '');
setVal('domain-manager-sub', item?.manager_sub || ''); setVal('domain-manager-sub', item?.manager_sub || '');
setVal('domain-remarks', item?.remarks || ''); setVal('domain-remarks', item?.remarks || '');
const deleteBtn = document.getElementById('btn-delete-domain');
if (deleteBtn) deleteBtn.style.display = isEdit ? 'block' : 'none';
openModal('domain-asset-modal'); openModal('domain-asset-modal');
createIcons({ icons: { X, Save, Database, CalendarClock, Edit2 } }); 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() { async function saveDomain() {
const getVal = (id: string) => (document.getElementById(id) as HTMLInputElement)?.value || ''; const getVal = (id: string) => (document.getElementById(id) as HTMLInputElement)?.value || '';
@@ -164,29 +225,17 @@ async function saveDomain() {
return; 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); const idx = state.masterData.domain.findIndex(d => d.id === currentItem.id);
if (idx > -1) state.masterData.domain[idx] = newDomain; if (idx > -1) state.masterData.domain[idx] = newDomain;
} else { } else {
state.masterData.domain.push(newDomain); state.masterData.domain.push(newDomain);
} }
try { await saveDomainBatch();
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

@@ -45,14 +45,17 @@ const HW_FIELD_MAP: Record<string, string> = {
'모니터링': '모니터링', '모니터링': '모니터링',
'OS': ASSET_SCHEMA.OS.key, 'OS': ASSET_SCHEMA.OS.key,
'CPU': ASSET_SCHEMA.CPU.key, 'CPU': ASSET_SCHEMA.CPU.key,
'GPU': ASSET_SCHEMA.GPU.key,
'RAM': ASSET_SCHEMA.RAM.key, 'RAM': ASSET_SCHEMA.RAM.key,
'SSD1': ASSET_SCHEMA.STORAGE1.key, 'SSD1': ASSET_SCHEMA.STORAGE1.key,
'SSD2': ASSET_SCHEMA.STORAGE2.key, 'SSD2': ASSET_SCHEMA.STORAGE2.key,
'SSD3': ASSET_SCHEMA.STORAGE3.key,
'HW사양': 'HW사양', 'HW사양': 'HW사양',
'담당자_정': ASSET_SCHEMA.MANAGER_MAIN.key, '담당자_정': ASSET_SCHEMA.MANAGER_MAIN.key,
'담당자_부': ASSET_SCHEMA.MANAGER_SUB.key, '담당자_부': ASSET_SCHEMA.MANAGER_SUB.key,
'구매일': ASSET_SCHEMA.PURCHASE_YM.key, '구매일': ASSET_SCHEMA.PURCHASE_YM.key,
'금액': ASSET_SCHEMA.PRICE.key, '금액': ASSET_SCHEMA.PRICE.key,
'납품업체': ASSET_SCHEMA.VENDOR.key,
'비고': ASSET_SCHEMA.REMARKS.key, '비고': ASSET_SCHEMA.REMARKS.key,
'사용자': ASSET_SCHEMA.USER.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 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-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-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-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-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-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 server-only" id="hw-monitoring-group"><label for="hw-모니터링">모니터링 여부</label><input type="text" id="hw-모니터링" /></div>
<div class="form-group full-width non-server" id="hw-hwspec-group"><label for="hw-HW사양">사양 상세</label><textarea id="hw-HW사양" rows="2"></textarea></div> <div class="form-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.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.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"><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 for="hw-비고">${ASSET_SCHEMA.REMARKS.ui}</label><textarea id="hw-비고" rows="2"></textarea></div>
<div class="form-group full-width"> <div class="form-group full-width">
<label>${ASSET_SCHEMA.DOC_NAME.ui} (파일 증빙)</label> <label>${ASSET_SCHEMA.DOC_NAME.ui} (파일 증빙)</label>
@@ -170,10 +176,13 @@ function applyTypeSpecificUI(type: string) {
os: document.getElementById('hw-os-group'), os: document.getElementById('hw-os-group'),
cpu: document.getElementById('hw-cpu-group'), cpu: document.getElementById('hw-cpu-group'),
ram: document.getElementById('hw-ram-group'), ram: document.getElementById('hw-ram-group'),
gpu: document.getElementById('hw-gpu-group'),
ssd1: document.getElementById('hw-ssd1-group'), ssd1: document.getElementById('hw-ssd1-group'),
ssd2: document.getElementById('hw-ssd2-group'), ssd2: document.getElementById('hw-ssd2-group'),
ssd3: document.getElementById('hw-ssd3-group'),
hwSpec: document.getElementById('hw-hwspec-group'), hwSpec: document.getElementById('hw-hwspec-group'),
monitoring: document.getElementById('hw-monitoring-group'), monitoring: document.getElementById('hw-monitoring-group'),
vendor: document.getElementById('hw-vendor-group'),
user: document.querySelector('.pc-only') as HTMLElement user: document.querySelector('.pc-only') as HTMLElement
}; };
@@ -224,16 +233,16 @@ function applyTypeSpecificUI(type: string) {
if (upperType === '노트북') { if (upperType === '노트북') {
if (groups.detailPurpose) groups.detailPurpose.style.display = 'none'; if (groups.detailPurpose) groups.detailPurpose.style.display = 'none';
nonServer.forEach(el => (el as HTMLElement).style.display = 'flex'); 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 { } else {
if (groups.detailPurpose) groups.detailPurpose.style.display = 'flex'; if (groups.detailPurpose) groups.detailPurpose.style.display = 'flex';
if (detailPurpose === '서버') { if (detailPurpose === '서버') {
serverOnly.forEach(el => (el as HTMLElement).style.display = 'flex'); serverOnly.forEach(el => (el as HTMLElement).style.display = 'flex');
if (groups.networkTitle) groups.networkTitle.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 { } else {
nonServer.forEach(el => (el as HTMLElement).style.display = 'flex'); 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'); serverOnly.forEach(el => (el as HTMLElement).style.display = 'flex');
if (groups.networkTitle) groups.networkTitle.style.display = 'flex'; if (groups.networkTitle) groups.networkTitle.style.display = 'flex';
if (groups.specTitle) groups.specTitle.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

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

View File

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

View File

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

View File

@@ -28,7 +28,7 @@ export interface AppState {
// 초기 상태 // 초기 상태
export const state: AppState = { export const state: AppState = {
activeCategory: 'dashboard', activeCategory: 'hw',
activeSubTab: '대시보드', activeSubTab: '대시보드',
masterData: { masterData: {
pc: [], 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[] { export function sortAssets<T>(list: T[]): T[] {
return [...list].sort((a: any, b: any) => { return [...list].sort((a: any, b: any) => {
// 1순위: 구매법인 (한글 가나다순) // 1순위: 법인 (가나다순)
const corpA = String(a. || '').trim(); const corpA = String(a. || a.corp || '').trim();
const corpB = String(b. || '').trim(); const corpB = String(b. || b.corp || '').trim();
if (corpA < corpB) return -1; if (corpA < corpB) return -1;
if (corpA > corpB) return 1; if (corpA > corpB) return 1;
// 2순위: 자산번호 (영문/숫자순) // 2순위: 자산번호/코드 (영문/숫자순)
const codeA = String(a. || a. || '').trim(); const codeA = String(a. || a. || a.id || '').trim();
const codeB = String(b. || b. || '').trim(); const codeB = String(b. || b. || b.id || '').trim();
if (codeA < codeB) return -1; if (codeA < codeB) return -1;
if (codeA > codeB) return 1; if (codeA > codeB) return 1;
return 0; 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

@@ -8,6 +8,7 @@ import { initHwModal, openHwModal } from './components/Modal/HWModal';
import { initSwModal, openSwModal } from './components/Modal/SWModal'; import { initSwModal, openSwModal } from './components/Modal/SWModal';
import { initSwUserModal } from './components/Modal/SWUserModal'; import { initSwUserModal } from './components/Modal/SWUserModal';
import { initDomainModal, openDomainModal } from './components/Modal/DomainModal'; import { initDomainModal, openDomainModal } from './components/Modal/DomainModal';
import { initUploadPreviewModal, openUploadPreview } from './components/Modal/UploadPreviewModal';
import { initDashboardDetailModal } from './components/Modal/DashboardDetailModal'; import { initDashboardDetailModal } from './components/Modal/DashboardDetailModal';
import { initGuide } from './components/Guide'; 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'; 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';
@@ -111,6 +112,10 @@ function initApp() {
initDashboardDetailModal(); initDashboardDetailModal();
initDomainModal(); initDomainModal();
initUploadPreviewModal(async () => {
await loadMasterDataFromDB();
refreshView();
});
initGuide(); initGuide();
// DB 데이터 로드 및 초기 화면 렌더링 // DB 데이터 로드 및 초기 화면 렌더링
@@ -121,6 +126,8 @@ function initApp() {
}); });
} catch (e) { console.error('❌ Initialization failed:', e); } } 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-download-template')?.addEventListener('click', () => downloadTemplate());
document.getElementById('btn-export-excel')?.addEventListener('click', () => exportToExcel(state.masterData)); document.getElementById('btn-export-excel')?.addEventListener('click', () => exportToExcel(state.masterData));
@@ -129,10 +136,17 @@ function initApp() {
uploadInput?.addEventListener('change', async (e) => { uploadInput?.addEventListener('change', async (e) => {
const file = (e.target as HTMLInputElement).files?.[0]; const file = (e.target as HTMLInputElement).files?.[0];
if (file) { if (file) {
console.log('📂 File selected:', file.name);
try {
const data = await parseExcel(file); const data = await parseExcel(file);
state.masterData = { ...state.masterData, ...data }; console.log('📊 Parsed data keys:', Object.keys(data));
await Promise.all([saveAllHardwareToDB(), saveAllSoftwareToDB()]); openUploadPreview(data);
refreshView(); // Clear input so same file can be selected again
uploadInput.value = '';
} catch (err) {
alert('엑셀 파일을 읽는 중 오류가 발생했습니다.');
console.error(err);
}
} }
}); });
@@ -149,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({ createIcons({
icons: { Download, Upload, FileSpreadsheet, Plus, X, LayoutDashboard, Monitor, Server, Database, Laptop, CalendarClock, Key, Cpu, Layers, Users, Paperclip, Edit2, History, RefreshCcw, BookOpen, Settings } 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); document.addEventListener('DOMContentLoaded', initApp);

View File

@@ -64,11 +64,14 @@
background-color: var(--white); background-color: var(--white);
border-top: 1px solid var(--border-color); border-top: 1px solid var(--border-color);
overflow: auto; overflow: auto;
position: relative;
-webkit-overflow-scrolling: touch;
} }
table { table {
width: 100%; width: 100%;
border-collapse: collapse; border-collapse: separate;
border-spacing: 0;
table-layout: auto; table-layout: auto;
} }
@@ -79,15 +82,21 @@ th, td {
white-space: nowrap; white-space: nowrap;
} }
thead {
position: sticky;
top: 0;
z-index: 50;
}
th { th {
background-color: #FAFAFA; background-color: #FAFAFA !important;
font-size: 13px; font-size: 13px;
font-weight: 600; font-weight: 600;
color: var(--text-muted); color: var(--text-muted);
position: sticky; position: sticky;
top: 0; top: 0;
z-index: 10; z-index: 50;
box-shadow: inset 0 -1px 0 var(--border-color); box-shadow: inset 0 1px 0 var(--border-color), inset 0 -1px 0 var(--border-color); /* 상하 테두리 보정 */
text-transform: none; text-transform: none;
} }
@@ -123,3 +132,40 @@ tbody tr:hover {
width: 16px; width: 16px;
height: 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 = ` container.innerHTML = `
<div class="view-container"> <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-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="dashboard-card" style="min-height:auto;">
<div class="stat-label">전체 평균 사용 연수</div> <span style="font-size:1rem; font-weight:700; color:var(--text-main);">전체 평균 사용 연수</span>
<div class="stat-value">${avgAge}<span class="unit">년</span></div> <div style="font-size: 0.8125rem; color:var(--text-muted); margin-bottom: 1rem;">전체 자산 기준 (권장 4.5년)</div>
<div class="stat-footer">권장 교체 주기: 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>
<div class="dashboard-card stat-card ${over5Rate >= 20 ? 'critical' : ''}"> <div class="dashboard-card" style="min-height:auto;">
<div class="stat-label">5년 이상 노후 자산 비율</div> <span style="font-size:1rem; font-weight:700; color:var(--text-main);">5년 이상 노후 자산 비율</span>
<div class="stat-value" style="${over5Rate >= 20 ? 'color:var(--danger)' : ''}">${over5Rate}<span class="unit">%</span></div> <div style="font-size: 0.8125rem; color:var(--text-muted); margin-bottom: 1rem;">총 ${over5YearsCount}대 해당</div>
<div class="stat-footer">${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>
<div class="dashboard-card stat-card"> <div class="dashboard-card" style="min-height:auto;">
<div class="stat-label">최신 도입 모델 (${latestYear}년)</div> <span style="font-size:1rem; font-weight:700; color:var(--text-main);">최신 도입 모델 (${latestYear}년)</span>
<div class="stat-value" style="font-size: 1.25rem; white-space: nowrap; overflow: hidden; text-overflow: ellipsis;" title="${(latestAsset as any)?. || '정보 없음'}"> <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)?. || '정보 없음'} ${(latestAsset as any)?. || '정보 없음'}
</div> </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>
</div> </div>

View File

@@ -11,7 +11,6 @@ export function renderSwDashboard(container: HTMLElement) {
let subCost2026 = 0; let subCost2026 = 0;
let permCost2026 = 0; let permCost2026 = 0;
let cloudCost2026 = 0;
const currentYear = new Date().getFullYear(); const currentYear = new Date().getFullYear();
@@ -22,8 +21,8 @@ export function renderSwDashboard(container: HTMLElement) {
const costByCat: Record<string, number> = {}; const costByCat: Record<string, number> = {};
categories.forEach(c => costByCat[c] = 0); categories.forEach(c => costByCat[c] = 0);
// 통합 SW 데이터 // 통합 SW 데이터 (클라우드 제외)
const allSw = [...state.masterData.subSw, ...state.masterData.permSw, ...state.masterData.cloud]; const allSw = [...state.masterData.subSw, ...state.masterData.permSw];
allSw.forEach(sw => { allSw.forEach(sw => {
const userMapping = state.masterData.swUsers.find(u => u.sw_id === sw.id); 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. && sw..startsWith('2026')) {
if (sw.type === '구독SW') subCost2026 += price; if (sw.type === '구독SW') subCost2026 += price;
else if (sw.type === '영구SW') permCost2026 += price; else if (sw.type === '영구SW') permCost2026 += price;
else if (sw.type === '클라우드') cloudCost2026 += price;
if (costByCorp[sw.] !== undefined) costByCorp[sw.] += price; if (costByCorp[sw.] !== undefined) costByCorp[sw.] += price;
if (sw. && costByCat[sw.] !== undefined) costByCat[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; const cost = Number(log.cost) || 0;
if (asset.type === '구독SW') subCost2026 += cost; if (asset.type === '구독SW') subCost2026 += cost;
else if (asset.type === '영구SW') permCost2026 += cost; else if (asset.type === '영구SW') permCost2026 += cost;
else if (asset.type === '클라우드') cloudCost2026 += cost;
if (costByCorp[asset.] !== undefined) costByCorp[asset.] += cost; if (costByCorp[asset.] !== undefined) costByCorp[asset.] += cost;
if (asset. && costByCat[asset.] !== undefined) costByCat[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> <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;"> <div class="dashboard-card" style="min-height:auto;">
<span style="font-size:1rem; font-weight:700; color:var(--text-main);">구독 SW 누적 비용 (2026)</span> <span style="font-size:1rem; font-weight:700; color:var(--text-main);">구독 SW 누적 비용 (2026)</span>
<div style="font-size: 0.8125rem; color:var(--text-muted); margin-bottom: 1rem;">갱신 및 추가 비용 합계</div> <div style="font-size: 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="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 style="width: 100%; height: 4px; background-color: #3b82f6; border-radius: 2px; margin-top: 0.5rem;"></div>
</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>
</div> </div>

View File

@@ -1,6 +1,8 @@
import { state } from '../../core/state'; import { state } from '../../core/state';
import { openSwModal } from '../../components/Modal/SWModal'; import { openSwModal } from '../../components/Modal/SWModal';
import { ASSET_SCHEMA, UI_TEXT } from '../../core/schema'; 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'; 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) { export function renderCloudList(container: HTMLElement) {
const getFullList = () => state.masterData.cloud || []; const getFullList = () => state.masterData.cloud || [];
let sortState: SortState = { key: '', direction: 'asc' };
const filterBar = document.createElement('div'); const filterBar = document.createElement('div');
filterBar.className = 'search-bar'; filterBar.className = 'search-bar';
@@ -37,15 +40,15 @@ export function renderCloudList(container: HTMLElement) {
table.innerHTML = ` table.innerHTML = `
<thead> <thead>
<tr> <tr>
<th class="text-center">No.</th> <th class="text-center" style="width:50px;">No.</th>
<th>${ASSET_SCHEMA.PLATFORM.ui}</th> <th data-sort="${ASSET_SCHEMA.PLATFORM.key}">${ASSET_SCHEMA.PLATFORM.ui}</th>
<th class="text-center">${ASSET_SCHEMA.CORP.ui}</th> <th class="text-center" data-sort="${ASSET_SCHEMA.CORP.key}">${ASSET_SCHEMA.CORP.ui}</th>
<th class="text-center">담당부서</th> <th class="text-center" data-sort="부서">담당부서</th>
<th>용도(프로젝트)</th> <th data-sort="${ASSET_SCHEMA.PRODUCT.key}">용도(프로젝트)</th>
<th>${ASSET_SCHEMA.ACCOUNT.ui}</th> <th data-sort="${ASSET_SCHEMA.ACCOUNT.key}">${ASSET_SCHEMA.ACCOUNT.ui}</th>
<th class="text-center">${ASSET_SCHEMA.PAY_METHOD.ui}</th> <th class="text-center" data-sort="${ASSET_SCHEMA.PAY_METHOD.key}">${ASSET_SCHEMA.PAY_METHOD.ui}</th>
<th class="text-center">${ASSET_SCHEMA.PAY_DAY.ui}</th> <th class="text-center" data-sort="${ASSET_SCHEMA.PAY_DAY.key}">${ASSET_SCHEMA.PAY_DAY.ui}</th>
<th class="text-center">${ASSET_SCHEMA.BILLING.ui}</th> <th class="text-center" data-sort="${ASSET_SCHEMA.BILLING.key}">${ASSET_SCHEMA.BILLING.ui}</th>
<th>${ASSET_SCHEMA.REMARKS.ui}</th> <th>${ASSET_SCHEMA.REMARKS.ui}</th>
</tr> </tr>
</thead> </thead>
@@ -63,7 +66,7 @@ export function renderCloudList(container: HTMLElement) {
const keyword = keywordInput ? keywordInput.value.toLowerCase().trim() : ''; const keyword = keywordInput ? keywordInput.value.toLowerCase().trim() : '';
const payment = paymentSelect ? paymentSelect.value : ''; const payment = paymentSelect ? paymentSelect.value : '';
const filtered = getFullList().filter(asset => { let filtered = getFullList().filter(asset => {
const kwMatch = !keyword || const kwMatch = !keyword ||
(asset[ASSET_SCHEMA.PRODUCT.key] || '').toLowerCase().includes(keyword) || (asset[ASSET_SCHEMA.PRODUCT.key] || '').toLowerCase().includes(keyword) ||
(asset. || '').toLowerCase().includes(keyword) || (asset. || '').toLowerCase().includes(keyword) ||
@@ -72,6 +75,10 @@ export function renderCloudList(container: HTMLElement) {
return kwMatch && payMatch; return kwMatch && payMatch;
}); });
if (sortState.key) {
filtered = dynamicSort(filtered, sortState.key, sortState.direction);
}
tbody.innerHTML = ''; tbody.innerHTML = '';
if (filtered.length === 0) { 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>`; 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')); tr.addEventListener('click', () => openSwModal(asset, 'view'));
tbody.appendChild(tr); tbody.appendChild(tr);
}); });
setupTableSorting(table, sortState, (key, dir) => {
sortState = { key, direction: dir };
updateTable();
});
createIcons({ icons: { Cloud, CreditCard, DollarSign, RefreshCcw } }); createIcons({ icons: { Cloud, CreditCard, DollarSign, RefreshCcw } });
}; };

View File

@@ -1,11 +1,18 @@
import { state } from '../../core/state'; 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 { createIcons, Plus, Edit2, Trash2 } from 'lucide';
import { openDomainModal } from '../../components/Modal/DomainModal'; 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) { export function renderDomainList(container: HTMLElement) {
container.innerHTML = ''; container.innerHTML = '';
const fullList = state.masterData.domain;
const header = document.createElement('div'); const header = document.createElement('div');
header.className = 'list-header'; header.className = 'list-header';
header.innerHTML = ` header.innerHTML = `
@@ -17,58 +24,76 @@ export function renderDomainList(container: HTMLElement) {
const tableWrapper = document.createElement('div'); const tableWrapper = document.createElement('div');
tableWrapper.className = 'table-container'; tableWrapper.className = 'table-container';
const table = document.createElement('table'); const table = document.createElement('table');
table.innerHTML = ` table.innerHTML = `
<thead> <thead>
<tr> <tr>
<th style="text-align:center; width:50px;">No.</th> <th style="text-align:center; width:50px;">No.</th>
<th style="text-align:center;">유형</th> <th style="text-align:center;" data-sort="type">유형</th>
<th style="text-align:center;">법인</th> <th style="text-align:center;" data-sort="corp">법인</th>
<th style="text-align:left;">서비스명</th> <th style="text-align:left;" data-sort="service_name">서비스명</th>
<th style="text-align:left;">관리도메인</th> <th style="text-align:left;" data-sort="domain_name">관리도메인</th>
<th style="text-align:center;">시작일</th> <th style="text-align:left;" data-sort="remarks">구매업체</th>
<th style="text-align:center;">만료일</th> <th style="text-align:center;" data-sort="start_date">시작일</th>
<th style="text-align:right;">금액</th> <th style="text-align:center;" data-sort="expiry_date">만료일</th>
<th style="text-align:center;">담당자</th> <th style="text-align:right;" data-sort="price">금액</th>
<th style="text-align:center;">담당자(부)</th> <th style="text-align:center;" data-sort="manager_main">담당자(정/부)</th>
<th style="text-align:left;">비고</th>
</tr> </tr>
</thead> </thead>
<tbody> <tbody id="dynamic-tbody"></tbody>
${state.masterData.domain.length === 0 ? ` `;
<tr>
<td colspan="11" style="text-align:center; padding: 3rem; color: var(--text-muted);">등록된 도메인 정보가 없습니다.</td> tableWrapper.appendChild(table);
</tr> container.appendChild(tableWrapper);
` : state.masterData.domain.map((item, idx) => ` const tbody = table.querySelector('tbody')!;
<tr class="domain-row" data-id="${item.id}" style="cursor:pointer;">
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;">${idx + 1}</td>
<td style="text-align:center;"><span class="badge badge-${item.type}">${item.type}</span></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 style="text-align:center;">${item.corp || ''}</td>
<td>${item.service_name || ''}</td> <td>${item.service_name || ''}</td>
<td>${item.domain_name || ''}</td> <td>${item.domain_name || ''}</td>
<td style="text-align:center;">${item.start_date || ''}</td> <td>${item.remarks || ''}</td>
<td style="text-align:center;">${item.expiry_date || ''}</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:right;">${formatPrice(item.price)}</td>
<td style="text-align:center;">${item.manager_main || ''}</td> <td style="text-align:center;">${managerHtml || '-'}</td>
<td style="text-align:center;">${item.manager_sub || ''}</td>
<td class="text-truncate" style="max-width:200px;">${item.remarks || ''}</td>
</tr>
`).join('')}
</tbody>
`; `;
tr.addEventListener('click', (e) => {
tableWrapper.appendChild(table); console.log('Row clicked:', item.domain_name);
container.appendChild(tableWrapper); openDomainModal(item);
// 이벤트 바인딩
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);
}); });
tbody.appendChild(tr);
}); });
setupTableSorting(table, persistentSortState, (key, dir) => {
persistentSortState = { key, direction: dir };
updateTable();
});
};
updateTable();
createIcons({ icons: { Plus, Edit2, Trash2 } }); createIcons({ icons: { Plus, Edit2, Trash2 } });
} }

View File

@@ -1,7 +1,8 @@
import { state } from '../../core/state'; import { state } from '../../core/state';
import { openHwModal } from '../../components/Modal/HWModal'; 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 { ASSET_SCHEMA, UI_TEXT } from '../../core/schema';
import { setupTableSorting, SortState } from '../../core/tableHandler';
import { createIcons, RefreshCcw } from 'lucide'; import { createIcons, RefreshCcw } from 'lucide';
/** /**
@@ -10,6 +11,7 @@ import { createIcons, RefreshCcw } from 'lucide';
*/ */
export function renderEquipmentList(container: HTMLElement) { export function renderEquipmentList(container: HTMLElement) {
const fullList = sortAssets(state.masterData.equip); const fullList = sortAssets(state.masterData.equip);
let sortState: SortState = { key: '', direction: 'asc' };
const filterBar = document.createElement('div'); const filterBar = document.createElement('div');
filterBar.className = 'search-bar'; filterBar.className = 'search-bar';
@@ -36,16 +38,16 @@ export function renderEquipmentList(container: HTMLElement) {
table.innerHTML = ` table.innerHTML = `
<thead> <thead>
<tr> <tr>
<th class="text-center">No.</th> <th class="text-center" style="width:50px;">No.</th>
<th class="text-center">${ASSET_SCHEMA.STATUS.ui}</th> <th class="text-center" data-sort="${ASSET_SCHEMA.STATUS.key}">${ASSET_SCHEMA.STATUS.ui}</th>
<th class="text-center">${ASSET_SCHEMA.CORP.ui}</th> <th class="text-center" data-sort="${ASSET_SCHEMA.CORP.key}">${ASSET_SCHEMA.CORP.ui}</th>
<th class="text-center">유형</th> <th class="text-center" data-sort="${ASSET_SCHEMA.TYPE.key}">유형</th>
<th class="text-center">${ASSET_SCHEMA.ASSET_CODE.ui}</th> <th class="text-center" data-sort="${ASSET_SCHEMA.ASSET_CODE.key}">${ASSET_SCHEMA.ASSET_CODE.ui}</th>
<th>${ASSET_SCHEMA.MODEL.ui}</th> <th data-sort="${ASSET_SCHEMA.MODEL.key}">${ASSET_SCHEMA.MODEL.ui}</th>
<th class="text-center">${ASSET_SCHEMA.STORE_LOC.ui}</th> <th class="text-center" data-sort="${ASSET_SCHEMA.STORE_LOC.key}">${ASSET_SCHEMA.STORE_LOC.ui}</th>
<th class="text-center">담당자(정/부)</th> <th class="text-center" data-sort="${ASSET_SCHEMA.MANAGER_MAIN.key}">담당자(정/부)</th>
<th class="text-center">${ASSET_SCHEMA.PURCHASE_YM.ui}</th> <th class="text-center" data-sort="${ASSET_SCHEMA.PURCHASE_YM.key}">${ASSET_SCHEMA.PURCHASE_YM.ui}</th>
<th class="text-center">${ASSET_SCHEMA.PRICE.ui}</th> <th class="text-center" data-sort="${ASSET_SCHEMA.PRICE.key}">${ASSET_SCHEMA.PRICE.ui}</th>
</tr> </tr>
</thead> </thead>
<tbody id="dynamic-tbody"></tbody> <tbody id="dynamic-tbody"></tbody>
@@ -62,7 +64,7 @@ export function renderEquipmentList(container: HTMLElement) {
const keyword = keywordInput ? keywordInput.value.toLowerCase().trim() : ''; const keyword = keywordInput ? keywordInput.value.toLowerCase().trim() : '';
const corp = corpSelect ? corpSelect.value : ''; const corp = corpSelect ? corpSelect.value : '';
const filtered = fullList.filter(asset => { let filtered = fullList.filter(asset => {
const matchKeyword = !keyword || const matchKeyword = !keyword ||
String(asset[ASSET_SCHEMA.ASSET_CODE.key]||'').toLowerCase().includes(keyword) || String(asset[ASSET_SCHEMA.ASSET_CODE.key]||'').toLowerCase().includes(keyword) ||
String(asset[ASSET_SCHEMA.MODEL.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; return matchKeyword && matchCorp;
}); });
if (sortState.key) {
filtered = dynamicSort(filtered, sortState.key, sortState.direction);
}
tbody.innerHTML = ''; tbody.innerHTML = '';
if (filtered.length === 0) { 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>`; 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')); tr.addEventListener('click', () => openHwModal(asset, 'view'));
tbody.appendChild(tr); tbody.appendChild(tr);
}); });
setupTableSorting(table, sortState, (key, dir) => {
sortState = { key, direction: dir };
updateTable();
});
createIcons({ icons: { RefreshCcw } }); createIcons({ icons: { RefreshCcw } });
}; };

View File

@@ -1,7 +1,8 @@
import { state } from '../../core/state'; import { state } from '../../core/state';
import { openHwModal } from '../../components/Modal/HWModal'; 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 { ASSET_SCHEMA, UI_TEXT } from '../../core/schema';
import { setupTableSorting, SortState } from '../../core/tableHandler';
import { createIcons, RefreshCcw } from 'lucide'; import { createIcons, RefreshCcw } from 'lucide';
/** /**
@@ -10,6 +11,7 @@ import { createIcons, RefreshCcw } from 'lucide';
*/ */
export function renderMobileList(container: HTMLElement) { export function renderMobileList(container: HTMLElement) {
const fullList = sortAssets(state.masterData.mobile); const fullList = sortAssets(state.masterData.mobile);
let sortState: SortState = { key: '', direction: 'asc' };
const filterBar = document.createElement('div'); const filterBar = document.createElement('div');
filterBar.className = 'search-bar'; filterBar.className = 'search-bar';
@@ -36,15 +38,15 @@ export function renderMobileList(container: HTMLElement) {
table.innerHTML = ` table.innerHTML = `
<thead> <thead>
<tr> <tr>
<th class="text-center">No.</th> <th class="text-center" style="width:50px;">No.</th>
<th class="text-center">${ASSET_SCHEMA.STATUS.ui}</th> <th class="text-center" data-sort="${ASSET_SCHEMA.STATUS.key}">${ASSET_SCHEMA.STATUS.ui}</th>
<th class="text-center">${ASSET_SCHEMA.CORP.ui}</th> <th class="text-center" data-sort="${ASSET_SCHEMA.CORP.key}">${ASSET_SCHEMA.CORP.ui}</th>
<th class="text-center">${ASSET_SCHEMA.ASSET_CODE.ui}</th> <th class="text-center" data-sort="${ASSET_SCHEMA.ASSET_CODE.key}">${ASSET_SCHEMA.ASSET_CODE.ui}</th>
<th>${ASSET_SCHEMA.MODEL.ui}</th> <th data-sort="${ASSET_SCHEMA.MODEL.key}">${ASSET_SCHEMA.MODEL.ui}</th>
<th class="text-center">${ASSET_SCHEMA.STORE_LOC.ui}</th> <th class="text-center" data-sort="${ASSET_SCHEMA.STORE_LOC.key}">${ASSET_SCHEMA.STORE_LOC.ui}</th>
<th class="text-center">담당자(정/부)</th> <th class="text-center" data-sort="${ASSET_SCHEMA.MANAGER_MAIN.key}">담당자(정/부)</th>
<th class="text-center">${ASSET_SCHEMA.PURCHASE_YM.ui}</th> <th class="text-center" data-sort="${ASSET_SCHEMA.PURCHASE_YM.key}">${ASSET_SCHEMA.PURCHASE_YM.ui}</th>
<th class="text-center">${ASSET_SCHEMA.PRICE.ui}</th> <th class="text-center" data-sort="${ASSET_SCHEMA.PRICE.key}">${ASSET_SCHEMA.PRICE.ui}</th>
</tr> </tr>
</thead> </thead>
<tbody id="dynamic-tbody"></tbody> <tbody id="dynamic-tbody"></tbody>
@@ -61,7 +63,7 @@ export function renderMobileList(container: HTMLElement) {
const keyword = keywordInput ? keywordInput.value.toLowerCase().trim() : ''; const keyword = keywordInput ? keywordInput.value.toLowerCase().trim() : '';
const corp = corpSelect ? corpSelect.value : ''; const corp = corpSelect ? corpSelect.value : '';
const filtered = fullList.filter(asset => { let filtered = fullList.filter(asset => {
const matchKeyword = !keyword || const matchKeyword = !keyword ||
String(asset[ASSET_SCHEMA.ASSET_CODE.key]||'').toLowerCase().includes(keyword) || String(asset[ASSET_SCHEMA.ASSET_CODE.key]||'').toLowerCase().includes(keyword) ||
String(asset[ASSET_SCHEMA.MODEL.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; return matchKeyword && matchCorp;
}); });
if (sortState.key) {
filtered = dynamicSort(filtered, sortState.key, sortState.direction);
}
tbody.innerHTML = ''; tbody.innerHTML = '';
if (filtered.length === 0) { 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>`; 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')); tr.addEventListener('click', () => openHwModal(asset, 'view'));
tbody.appendChild(tr); tbody.appendChild(tr);
}); });
setupTableSorting(table, sortState, (key, dir) => {
sortState = { key, direction: dir };
updateTable();
});
createIcons({ icons: { RefreshCcw } }); createIcons({ icons: { RefreshCcw } });
}; };

View File

@@ -1,7 +1,8 @@
import { state } from '../../core/state'; import { state } from '../../core/state';
import { openHwModal } from '../../components/Modal/HWModal'; 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 { ASSET_SCHEMA, UI_TEXT } from '../../core/schema';
import { setupTableSorting, SortState } from '../../core/tableHandler';
import { createIcons, Paperclip, RefreshCcw } from 'lucide'; import { createIcons, Paperclip, RefreshCcw } from 'lucide';
/** /**
@@ -10,6 +11,7 @@ import { createIcons, Paperclip, RefreshCcw } from 'lucide';
*/ */
export function renderPcList(container: HTMLElement) { export function renderPcList(container: HTMLElement) {
const fullList = sortAssets(state.masterData.pc); const fullList = sortAssets(state.masterData.pc);
let sortState: SortState = { key: '', direction: 'asc' };
const filterBar = document.createElement('div'); const filterBar = document.createElement('div');
filterBar.className = 'search-bar'; filterBar.className = 'search-bar';
@@ -37,20 +39,19 @@ export function renderPcList(container: HTMLElement) {
table.innerHTML = ` table.innerHTML = `
<thead> <thead>
<tr> <tr>
<th style="text-align:center;">No</th> <th style="text-align:center; width:50px;">No</th>
<th style="text-align:center;">${ASSET_SCHEMA.CORP.ui}</th> <th style="text-align:center;" data-sort="${ASSET_SCHEMA.CORP.key}">${ASSET_SCHEMA.CORP.ui}</th>
<th style="text-align:center;">${ASSET_SCHEMA.ORG.ui}</th> <th style="text-align:center;" data-sort="${ASSET_SCHEMA.ORG.key}">${ASSET_SCHEMA.ORG.ui}</th>
<th style="text-align:center;">${ASSET_SCHEMA.ASSET_CODE.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;">${ASSET_SCHEMA.USER.ui}</th> <th style="text-align:center;" data-sort="${ASSET_SCHEMA.USER.key}">${ASSET_SCHEMA.USER.ui}</th>
<th style="text-align:center;">${ASSET_SCHEMA.LOCATION.ui}</th> <th style="text-align:center;" data-sort="${ASSET_SCHEMA.MAINBOARD.key}">${ASSET_SCHEMA.MAINBOARD.ui}</th>
<th style="text-align:center;">담당자(정/부)</th> <th style="text-align:center;" data-sort="${ASSET_SCHEMA.CPU.key}">${ASSET_SCHEMA.CPU.ui}</th>
<th style="text-align:center;">${ASSET_SCHEMA.MAINBOARD.ui}</th> <th style="text-align:center;" data-sort="${ASSET_SCHEMA.RAM.key}">${ASSET_SCHEMA.RAM.ui}</th>
<th style="text-align:center;">${ASSET_SCHEMA.CPU.ui}</th> <th style="text-align:center;" data-sort="${ASSET_SCHEMA.STORAGE1.key}">Storage</th>
<th style="text-align:center;">${ASSET_SCHEMA.RAM.ui}</th> <th style="text-align:center;" data-sort="${ASSET_SCHEMA.PURCHASE_YM.key}">${ASSET_SCHEMA.PURCHASE_YM.ui}</th>
<th style="text-align:center;">Storage</th> <th style="text-align:center;" data-sort="${ASSET_SCHEMA.PRICE.key}">${ASSET_SCHEMA.PRICE.ui}</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;">${ASSET_SCHEMA.DOC_NAME.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> </tr>
</thead> </thead>
<tbody id="dynamic-tbody"></tbody> <tbody id="dynamic-tbody"></tbody>
@@ -67,7 +68,7 @@ export function renderPcList(container: HTMLElement) {
const keyword = keywordInput ? keywordInput.value.toLowerCase().trim() : ''; const keyword = keywordInput ? keywordInput.value.toLowerCase().trim() : '';
const corp = corpSelect ? corpSelect.value : ''; const corp = corpSelect ? corpSelect.value : '';
const filtered = fullList.filter(asset => { let filtered = fullList.filter(asset => {
const matchKeyword = !keyword || const matchKeyword = !keyword ||
String(asset[ASSET_SCHEMA.ASSET_CODE.key]||'').toLowerCase().includes(keyword) || String(asset[ASSET_SCHEMA.ASSET_CODE.key]||'').toLowerCase().includes(keyword) ||
String(asset[ASSET_SCHEMA.USER.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; return matchKeyword && matchCorp;
}); });
if (sortState.key) {
filtered = dynamicSort(filtered, sortState.key, sortState.direction);
}
tbody.innerHTML = ''; tbody.innerHTML = '';
if (filtered.length === 0) { 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; 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.ORG.key]||'-'}</td>
<td style="text-align:center;">${asset[ASSET_SCHEMA.ASSET_CODE.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.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.MAINBOARD.key]||'-'}</td>
<td style="text-align:center;">${asset[ASSET_SCHEMA.CPU.key]||''}</td> <td style="text-align:center;">${asset[ASSET_SCHEMA.CPU.key]||''}</td>
<td style="text-align:center;">${asset[ASSET_SCHEMA.RAM.key]||''}</td> <td style="text-align:center;">${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: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: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;">${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')); tr.addEventListener('click', () => openHwModal(asset, 'view'));
tbody.appendChild(tr); tbody.appendChild(tr);
}); });
setupTableSorting(table, sortState, (key, dir) => {
sortState = { key, direction: dir };
updateTable();
});
createIcons({ icons: { Paperclip, RefreshCcw } }); createIcons({ icons: { Paperclip, RefreshCcw } });
}; };

View File

@@ -1,7 +1,8 @@
import { state } from '../../core/state'; import { state } from '../../core/state';
import { openHwModal } from '../../components/Modal/HWModal'; 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 { ASSET_SCHEMA, UI_TEXT } from '../../core/schema';
import { setupTableSorting, SortState } from '../../core/tableHandler';
import { createIcons, RefreshCcw } from 'lucide'; import { createIcons, RefreshCcw } from 'lucide';
/** /**
@@ -10,6 +11,7 @@ import { createIcons, RefreshCcw } from 'lucide';
*/ */
export function renderServerList(container: HTMLElement) { export function renderServerList(container: HTMLElement) {
const fullList = sortAssets(state.masterData.server); const fullList = sortAssets(state.masterData.server);
let sortState: SortState = { key: '', direction: 'asc' };
const filterBar = document.createElement('div'); const filterBar = document.createElement('div');
filterBar.className = 'search-bar'; filterBar.className = 'search-bar';
@@ -42,14 +44,14 @@ export function renderServerList(container: HTMLElement) {
table.innerHTML = ` table.innerHTML = `
<thead> <thead>
<tr> <tr>
<th class="text-center">No</th> <th class="text-center" style="width:50px;">No</th>
<th class="text-center">${ASSET_SCHEMA.CORP.ui}</th> <th class="text-center" data-sort="${ASSET_SCHEMA.CORP.key}">${ASSET_SCHEMA.CORP.ui}</th>
<th class="text-center">${ASSET_SCHEMA.ORG.ui}</th> <th class="text-center" data-sort="${ASSET_SCHEMA.ORG.key}">${ASSET_SCHEMA.ORG.ui}</th>
<th class="text-center">${ASSET_SCHEMA.ASSET_CODE.ui}</th> <th class="text-center" data-sort="${ASSET_SCHEMA.ASSET_CODE.key}">${ASSET_SCHEMA.ASSET_CODE.ui}</th>
<th>용도</th> <th data-sort="${ASSET_SCHEMA.DETAIL_PURPOSE.key}">${ASSET_SCHEMA.DETAIL_PURPOSE.ui}</th>
<th>상세</th> <th data-sort="상세">상세</th>
<th class="text-center">${ASSET_SCHEMA.LOCATION.ui}</th> <th class="text-center" data-sort="${ASSET_SCHEMA.LOCATION.key}">${ASSET_SCHEMA.LOCATION.ui}</th>
<th class="text-center">담당자(정/부)</th> <th class="text-center" data-sort="${ASSET_SCHEMA.MANAGER_MAIN.key}">담당자(정/부)</th>
</tr> </tr>
</thead> </thead>
<tbody id="dynamic-tbody"></tbody> <tbody id="dynamic-tbody"></tbody>
@@ -68,7 +70,7 @@ export function renderServerList(container: HTMLElement) {
const corp = corpSelect ? corpSelect.value : ''; const corp = corpSelect ? corpSelect.value : '';
const orgUnit = orgSelect ? orgSelect.value : ''; const orgUnit = orgSelect ? orgSelect.value : '';
const filtered = fullList.filter(asset => { let filtered = fullList.filter(asset => {
const matchKeyword = !keyword || const matchKeyword = !keyword ||
String(asset[ASSET_SCHEMA.ASSET_CODE.key]||'').toLowerCase().includes(keyword) || String(asset[ASSET_SCHEMA.ASSET_CODE.key]||'').toLowerCase().includes(keyword) ||
String(asset[ASSET_SCHEMA.ORG.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; return matchKeyword && matchCorp && matchOrg;
}); });
if (sortState.key) {
filtered = dynamicSort(filtered, sortState.key, sortState.direction);
}
tbody.innerHTML = ''; tbody.innerHTML = '';
if (filtered.length === 0) { 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>`; 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.CORP.key]}</td>
<td class="text-center">${asset[ASSET_SCHEMA.ORG.key]||'-'}</td> <td class="text-center">${asset[ASSET_SCHEMA.ORG.key]||'-'}</td>
<td class="text-center">${asset[ASSET_SCHEMA.ASSET_CODE.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>${formatInline(asset.)}</td>
<td class="text-center">${formatInline(asset[ASSET_SCHEMA.LOCATION.key])}</td> <td class="text-center">${formatInline(asset[ASSET_SCHEMA.LOCATION.key])}</td>
<td class="text-center">${managerHtml || '-'}</td> <td class="text-center">${managerHtml || '-'}</td>
@@ -108,6 +114,11 @@ export function renderServerList(container: HTMLElement) {
tr.addEventListener('click', () => openHwModal(asset, 'view')); tr.addEventListener('click', () => openHwModal(asset, 'view'));
tbody.appendChild(tr); tbody.appendChild(tr);
}); });
setupTableSorting(table, sortState, (key, dir) => {
sortState = { key, direction: dir };
updateTable();
});
}; };
document.getElementById('filter-keyword')?.addEventListener('input', updateTable); document.getElementById('filter-keyword')?.addEventListener('input', updateTable);

View File

@@ -1,7 +1,8 @@
import { state } from '../../core/state'; import { state } from '../../core/state';
import { openHwModal } from '../../components/Modal/HWModal'; 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 { ASSET_SCHEMA, UI_TEXT } from '../../core/schema';
import { setupTableSorting, SortState } from '../../core/tableHandler';
import { createIcons, RefreshCcw } from 'lucide'; import { createIcons, RefreshCcw } from 'lucide';
/** /**
@@ -10,6 +11,7 @@ import { createIcons, RefreshCcw } from 'lucide';
*/ */
export function renderStorageList(container: HTMLElement) { export function renderStorageList(container: HTMLElement) {
const fullList = sortAssets(state.masterData.storage); const fullList = sortAssets(state.masterData.storage);
let sortState: SortState = { key: '', direction: 'asc' };
const filterBar = document.createElement('div'); const filterBar = document.createElement('div');
filterBar.className = 'search-bar'; filterBar.className = 'search-bar';
@@ -42,14 +44,14 @@ export function renderStorageList(container: HTMLElement) {
table.innerHTML = ` table.innerHTML = `
<thead> <thead>
<tr> <tr>
<th class="text-center">No</th> <th class="text-center" style="width:50px;">No</th>
<th class="text-center">${ASSET_SCHEMA.CORP.ui}</th> <th class="text-center" data-sort="${ASSET_SCHEMA.CORP.key}">${ASSET_SCHEMA.CORP.ui}</th>
<th class="text-center">${ASSET_SCHEMA.ORG.ui}</th> <th class="text-center" data-sort="${ASSET_SCHEMA.ORG.key}">${ASSET_SCHEMA.ORG.ui}</th>
<th class="text-center">${ASSET_SCHEMA.ASSET_CODE.ui}</th> <th class="text-center" data-sort="${ASSET_SCHEMA.ASSET_CODE.key}">${ASSET_SCHEMA.ASSET_CODE.ui}</th>
<th>용도</th> <th data-sort="용도">용도</th>
<th>상세</th> <th data-sort="상세">상세</th>
<th class="text-center">${ASSET_SCHEMA.LOCATION.ui}</th> <th class="text-center" data-sort="${ASSET_SCHEMA.LOCATION.key}">${ASSET_SCHEMA.LOCATION.ui}</th>
<th class="text-center">담당자(정/부)</th> <th class="text-center" data-sort="${ASSET_SCHEMA.MANAGER_MAIN.key}">담당자(정/부)</th>
</tr> </tr>
</thead> </thead>
<tbody id="dynamic-tbody"></tbody> <tbody id="dynamic-tbody"></tbody>
@@ -68,7 +70,7 @@ export function renderStorageList(container: HTMLElement) {
const corp = corpSelect ? corpSelect.value : ''; const corp = corpSelect ? corpSelect.value : '';
const orgUnit = orgSelect ? orgSelect.value : ''; const orgUnit = orgSelect ? orgSelect.value : '';
const filtered = fullList.filter(asset => { let filtered = fullList.filter(asset => {
const matchKeyword = !keyword || const matchKeyword = !keyword ||
String((asset as any)[ASSET_SCHEMA.ASSET_CODE.key]||'').toLowerCase().includes(keyword) || String((asset as any)[ASSET_SCHEMA.ASSET_CODE.key]||'').toLowerCase().includes(keyword) ||
String((asset as any)[ASSET_SCHEMA.ORG.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; return matchKeyword && matchCorp && matchOrg;
}); });
if (sortState.key) {
filtered = dynamicSort(filtered, sortState.key, sortState.direction);
}
tbody.innerHTML = ''; tbody.innerHTML = '';
if (filtered.length === 0) { 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>`; 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')); tr.addEventListener('click', () => openHwModal(asset, 'view'));
tbody.appendChild(tr); tbody.appendChild(tr);
}); });
setupTableSorting(table, sortState, (key, dir) => {
sortState = { key, direction: dir };
updateTable();
});
}; };
document.getElementById('filter-keyword')?.addEventListener('input', updateTable); document.getElementById('filter-keyword')?.addEventListener('input', updateTable);

View File

@@ -1,7 +1,8 @@
import { state } from '../../core/state'; import { state } from '../../core/state';
import { openSwModal } from '../../components/Modal/SWModal'; import { openSwModal } from '../../components/Modal/SWModal';
import { openSwUserModal } from '../../components/Modal/SWUserModal'; 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 { CORP_LIST } from '../../components/Modal/SharedData';
import { generateOptionsHTML } from '../../components/Modal/ModalUtils'; import { generateOptionsHTML } from '../../components/Modal/ModalUtils';
import { createIcons, Edit2, Users, RefreshCcw } from 'lucide'; import { createIcons, Edit2, Users, RefreshCcw } from 'lucide';
@@ -10,6 +11,8 @@ export function renderSwList(container: HTMLElement) {
const isSub = state.activeSubTab === '구독SW'; const isSub = state.activeSubTab === '구독SW';
const fullList = sortAssets(isSub ? state.masterData.subSw : state.masterData.permSw); const fullList = sortAssets(isSub ? state.masterData.subSw : state.masterData.permSw);
let sortState: SortState = { key: '', direction: 'asc' };
const filterBar = document.createElement('div'); const filterBar = document.createElement('div');
filterBar.className = 'search-bar'; filterBar.className = 'search-bar';
filterBar.innerHTML = ` filterBar.innerHTML = `
@@ -43,17 +46,17 @@ export function renderSwList(container: HTMLElement) {
table.innerHTML = ` table.innerHTML = `
<thead> <thead>
<tr> <tr>
<th style="text-align:center;">No.</th> <th style="text-align:center; width: 50px;">No.</th>
<th style="text-align:center;">상태</th> <th style="text-align:center;" data-sort="상태">상태</th>
<th style="text-align:center;">분야</th> <th style="text-align:center;" data-sort="분야">분야</th>
<th style="text-align:center;">법인</th> <th style="text-align:center;" data-sort="법인">법인</th>
<th style="text-align:center;">부서</th> <th style="text-align:center;" data-sort="부서">부서</th>
<th style="text-align:center;">제품명</th> <th style="text-align:center;" data-sort="제품명">제품명</th>
<th style="text-align:center;">구매일</th> <th style="text-align:center;" data-sort="구매일">구매일</th>
<th style="text-align:center;">시작일</th> <th style="text-align:center;" data-sort="시작일">시작일</th>
<th style="text-align:center;">만료일</th> <th style="text-align:center;" data-sort="만료일">만료일</th>
<th style="text-align:center;">금액</th> <th style="text-align:center;" data-sort="금액">금액</th>
<th style="text-align:center;">수량</th> <th style="text-align:center;" data-sort="수량">수량</th>
<th style="text-align:center;">사용가능</th> <th style="text-align:center;">사용가능</th>
<th style="text-align:center;">사용자</th> <th style="text-align:center;">사용자</th>
</tr> </tr>
@@ -74,13 +77,17 @@ export function renderSwList(container: HTMLElement) {
const field = fieldSelect ? fieldSelect.value : ''; const field = fieldSelect ? fieldSelect.value : '';
const corp = corpSelect ? corpSelect.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 matchKeyword = !keyword || (asset. || '').toLowerCase().includes(keyword) || (asset. || '').toLowerCase().includes(keyword);
const matchField = !field || asset. === field; const matchField = !field || asset. === field;
const matchCorp = !corp || asset. === corp; const matchCorp = !corp || asset. === corp;
return matchKeyword && matchField && matchCorp; return matchKeyword && matchField && matchCorp;
}); });
if (sortState.key) {
filtered = dynamicSort(filtered, sortState.key, sortState.direction);
}
tbody.innerHTML = ''; tbody.innerHTML = '';
if (filtered.length === 0) { if (filtered.length === 0) {
tbody.innerHTML = `<tr><td colspan="13" style="text-align:center; padding: 3rem; color: var(--text-muted);">검색 결과가 없습니다.</td></tr>`; tbody.innerHTML = `<tr><td colspan="13" style="text-align:center; padding: 3rem; color: var(--text-muted);">검색 결과가 없습니다.</td></tr>`;
@@ -88,7 +95,8 @@ export function renderSwList(container: HTMLElement) {
} }
filtered.forEach((asset, idx) => { 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 qty = typeof asset. === 'number' ? asset.수량 : parseInt(asset.||'0', 10);
const avail = qty - assigned; const avail = qty - assigned;
@@ -154,6 +162,12 @@ export function renderSwList(container: HTMLElement) {
}); });
tbody.appendChild(tr); tbody.appendChild(tr);
}); });
setupTableSorting(table, sortState, (key, dir) => {
sortState = { key, direction: dir };
updateTable();
});
createIcons({ icons: { Edit2, Users, RefreshCcw } }); createIcons({ icons: { Edit2, Users, RefreshCcw } });
}; };