From b2713a142d17588097aad2f4a80f4cb1e6c124c2 Mon Sep 17 00:00:00 2001 From: Taehoon Date: Tue, 26 May 2026 19:26:44 +0900 Subject: [PATCH] feat: enhance HW modal layout and Server list view columns MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 상세 모달 레이아웃 개선: 모델명과 메인보드 동일 행 배치, 중복 메인보드 필드 제거 - OS 컬럼 스키마 매핑 및 상세 모달 입력 폼 추가 - 모든 하드웨어(서버 포함)에서 HDD 1~4 노출되도록 pc-only 속성 제거 - 서버 리스트 뷰 레이아웃 개선: 자산유형(asset_type) 컬럼 추가 및 너비 조정 - 서버 리스트 모델/메인보드 통합 컬럼 노출 로직 개선 (model_name 우선 표시) - 자산코드 일괄 재부여 스크립트(batch_reformat_codes.js) 추가 및 유니크 제약조건 회피 로직 반영 --- batch_reformat_codes.js | 124 +++++++++ src/components/Modal/HWModal.ts | 19 +- src/components/Modal/UploadPreviewModal.ts | 309 --------------------- src/core/schema.ts | 1 + src/views/List/ServerListView.ts | 17 +- temp_db.xlsx | Bin 24242 -> 0 bytes 6 files changed, 149 insertions(+), 321 deletions(-) create mode 100644 batch_reformat_codes.js delete mode 100644 src/components/Modal/UploadPreviewModal.ts delete mode 100644 temp_db.xlsx diff --git a/batch_reformat_codes.js b/batch_reformat_codes.js new file mode 100644 index 0000000..d0ba02f --- /dev/null +++ b/batch_reformat_codes.js @@ -0,0 +1,124 @@ +import mysql from 'mysql2/promise'; +import dotenv from 'dotenv'; + +dotenv.config(); + +const TYPE_PREFIX_MAP = { + '서버': 'SVR', '가상서버(VM)': 'VM', '워크스테이션': 'WKS', '서버PC': 'PC', + '개인PC': 'PC', '공용PC': 'PC', '노트북': 'NBK', '태블릿': 'TAB', + 'NAS': 'NAS', 'DAS': 'DAS', '스토리지': 'STO', '스토리지 렉': 'STO', + '스위치': 'NET', '방화벽': 'NET', '공유기': 'NET', '허브': 'NET', '네트워크': 'NET', + '모니터': 'MNT', '프린터': 'PRT', '스캐너': 'SCN', '복합기': 'MFP', '빔프로젝터': 'PRJ', '화상회의장비': 'VCF', '업무지원장비': 'EQP', + 'CPU': 'CPU', 'HDD': 'HDD', 'RAM': 'RAM', 'GPU': 'GPU', 'SSD': 'SSD', '메인보드': 'MBD', '파워서플라이': 'PWR', '쿨러': 'CLR', '케이스': 'CAS', 'PC부품': 'PRT', + '드론': 'DRO', '측량장비': 'SUR', '보조기기': 'SUR', '공간정보장비': 'SUR', + '책상': 'FRN', '의자': 'FRN', '캐비닛': 'FRN', '사무가구': 'FRN', + '구독SW': 'SW', '영구SW': 'SW', '외부': 'SW', '내부': 'INT', + '선물': 'GFT', 'VIP': 'VIP' +}; + +function formatPurchaseDate(date) { + if (!date) return '000000'; + let s = String(date).replace(/[^0-9]/g, ''); + if (s.length >= 6) { + return s.substring(0, 6); + } + return '000000'; +} + +async function reformatAllCodes() { + const connection = await mysql.createConnection({ + host: process.env.DB_HOST, + user: process.env.DB_USER, + password: process.env.DB_PASS, + database: process.env.DB_NAME, + port: parseInt(process.env.DB_PORT || '3306') + }); + + try { + const tables = [ + 'asset_pc', 'asset_server', 'asset_network', 'asset_storage', + 'asset_equipment', 'asset_survey', 'asset_pc_parts', 'asset_office_supplies', + 'asset_sw_external', 'asset_sw_internal', 'asset_vip' + ]; + + let allAssets = []; + + for (const table of tables) { + try { + const [rows] = await connection.query(`SELECT * FROM ${table}`); + allAssets = allAssets.concat(rows.map(r => ({ ...r, sourceTable: table }))); + } catch (err) { + if (err.code === 'ER_NO_SUCH_TABLE') { + console.log(`Skipping missing table: ${table}`); + } else { + console.error(`Error querying ${table}:`, err.message); + } + } + } + + console.log(`Total assets loaded: ${allAssets.length}`); + + // Process each asset + const processed = allAssets.map(a => { + // 1. Determine prefix + let prefix = 'AST'; + if (a.asset_type && TYPE_PREFIX_MAP[a.asset_type]) { + prefix = TYPE_PREFIX_MAP[a.asset_type]; + } else if (a.category && TYPE_PREFIX_MAP[a.category]) { + prefix = TYPE_PREFIX_MAP[a.category]; + } else if (a.sourceTable === 'asset_sw_external') prefix = 'SW'; + else if (a.sourceTable === 'asset_sw_internal') prefix = 'INT'; + + // 2. Determine YYYYMM + const dateStr = a.purchase_date || a.start_date || ''; // start_date for SW + const yyyymm = formatPurchaseDate(dateStr); + + return { ...a, prefix, yyyymm }; + }); + + // Group by Prefix + const groups = {}; + processed.forEach(a => { + if (!groups[a.prefix]) groups[a.prefix] = []; + groups[a.prefix].push(a); + }); + + // Start renaming + for (const prefix in groups) { + const items = groups[prefix]; + + // Sort logic to maintain some order (by date then id) + items.sort((a, b) => { + if (a.yyyymm !== b.yyyymm) return a.yyyymm.localeCompare(b.yyyymm); + return String(a.id).localeCompare(String(b.id)); + }); + + console.log(`Processing group ${prefix}: ${items.length} items`); + + // Temporary rename to avoid UNIQUE constraint conflicts during sequential updates + for (const item of items) { + const tempCode = `TEMP-${Math.random().toString(36).substring(2, 10)}-${item.id}`; + await connection.query(`UPDATE ${item.sourceTable} SET asset_code = ? WHERE id = ?`, [tempCode, item.id]); + } + + for (let i = 0; i < items.length; i++) { + const item = items[i]; + const serial = String(i + 1).padStart(4, '0'); // SVR-202209-0001 + + // Some formats might want 3 or 4 digits. Defaulting to 4. + const newCode = `${prefix}-${item.yyyymm}-${serial}`; + + await connection.query(`UPDATE ${item.sourceTable} SET asset_code = ? WHERE id = ?`, [newCode, item.id]); + } + } + + console.log('✅ Asset codes reformatted successfully.'); + + } catch (err) { + console.error('❌ Reformatting failed:', err); + } finally { + await connection.end(); + } +} + +reformatAllCodes(); diff --git a/src/components/Modal/HWModal.ts b/src/components/Modal/HWModal.ts index babf962..207406f 100644 --- a/src/components/Modal/HWModal.ts +++ b/src/components/Modal/HWModal.ts @@ -107,10 +107,18 @@ const HW_MODAL_HTML = `
시스템 사양
-
+
+
+ + +
+
+ + +
@@ -139,18 +147,14 @@ const HW_MODAL_HTML = `
-
+
-
+
-
- - -
@@ -425,6 +429,7 @@ function fillHwFormData(asset: any) { setFieldValue('hw-hdd_3', asset.hdd_3 || ''); setFieldValue('hw-hdd_4', asset.hdd_4 || ''); setFieldValue('hw-mainboard', asset.mainboard || ''); + setFieldValue('hw-os', asset.os || ''); setFieldValue('hw-mac_address', asset.mac_address || ''); setFieldValue('hw-ip_address', asset.ip_address || ''); diff --git a/src/components/Modal/UploadPreviewModal.ts b/src/components/Modal/UploadPreviewModal.ts deleted file mode 100644 index d83c17a..0000000 --- a/src/components/Modal/UploadPreviewModal.ts +++ /dev/null @@ -1,309 +0,0 @@ -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 = ` - -`; - -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 = ` - ${tab} - ${parsedData[tab].length} - `; - - 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 = '
표시할 데이터가 없습니다.
'; - 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 = ` - - - - - ${headers.map(h => ``).join('')} - - - - ${data.map((row: any, idx: number) => ` - - - ${headers.map(h => ``).join('')} - - `).join('')} - -
No.${h}
${idx + 1}${row[h] || '-'}
- `; - - tableWrapper.innerHTML = tableHTML; -} - -async function confirmUpload() { - const confirmBtn = document.getElementById('btn-confirm-upload') as HTMLButtonElement; - if (confirmBtn) { - confirmBtn.disabled = true; - confirmBtn.innerHTML = ' 저장 중...'; - 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 = ' 최종 데이터 저장하기'; - 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 = ' 생성 중...'; - 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 = {}; - 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 = ' 자산코드 일괄 생성'; - createIcons({ icons: { RefreshCcw } }); - } - } -} diff --git a/src/core/schema.ts b/src/core/schema.ts index dfeac0f..4da06c3 100644 --- a/src/core/schema.ts +++ b/src/core/schema.ts @@ -44,6 +44,7 @@ export const ASSET_SCHEMA = { HDD3: { key: 'hdd_3', db: 'hdd_3', ui: 'HDD3' }, HDD4: { key: 'hdd_4', db: 'hdd_4', ui: 'HDD4' }, MAINBOARD: { key: 'mainboard', db: 'mainboard', ui: '메인보드' }, + OS: { key: 'os', db: 'os', ui: 'OS' }, IP_ADDR: { key: 'ip_address', db: 'ip_address', ui: 'IP 주소' }, IP_ADDR2: { key: 'ip_address_2', db: 'ip_address_2', ui: 'IP 주소 2' }, MAC_ADDR: { key: 'mac_address', db: 'mac_address', ui: 'MAC 주소' }, diff --git a/src/views/List/ServerListView.ts b/src/views/List/ServerListView.ts index a9126db..f90de9b 100644 --- a/src/views/List/ServerListView.ts +++ b/src/views/List/ServerListView.ts @@ -28,10 +28,11 @@ export function renderServerList(container: HTMLElement) { ${ASSET_SCHEMA.CURRENT_DEPT.ui} - ${ASSET_SCHEMA.ASSET_PURPOSE.ui} - ${ASSET_SCHEMA.MODEL_NAME.ui} + ${ASSET_SCHEMA.ASSET_PURPOSE.ui} + ${ASSET_SCHEMA.ASSET_TYPE.ui} + 모델/메인보드 ${ASSET_SCHEMA.LOCATION.ui} - ${ASSET_SCHEMA.MEMO.ui} + ${ASSET_SCHEMA.MEMO.ui} @@ -50,7 +51,7 @@ export function renderServerList(container: HTMLElement) { tbody.innerHTML = ''; if (filtered.length === 0) { - tbody.innerHTML = `${UI_TEXT.MESSAGES.NO_DATA}`; + tbody.innerHTML = `${UI_TEXT.MESSAGES.NO_DATA}`; return; } @@ -62,10 +63,16 @@ export function renderServerList(container: HTMLElement) { const detail = asset[ASSET_SCHEMA.LOC_DETAIL.key] || ''; const displayLoc = detail ? `${loc}(${detail})` : (loc || '-'); + const modelOrMainboard = asset[ASSET_SCHEMA.MODEL_NAME.key] + || asset[ASSET_SCHEMA.ASSET_NAME.key] + || asset[ASSET_SCHEMA.MAINBOARD.key] + || '-'; + tr.innerHTML = ` ${asset[ASSET_SCHEMA.CURRENT_DEPT.key]||'-'} ${formatInline(asset[ASSET_SCHEMA.ASSET_PURPOSE.key]||'-')} - ${formatInline(asset[ASSET_SCHEMA.MODEL_NAME.key]||asset[ASSET_SCHEMA.ASSET_NAME.key]||'-')} + ${asset[ASSET_SCHEMA.ASSET_TYPE.key]||'-'} + ${formatInline(modelOrMainboard)} ${displayLoc} ${formatInline(asset[ASSET_SCHEMA.MEMO.key]||'-')} `; diff --git a/temp_db.xlsx b/temp_db.xlsx deleted file mode 100644 index 11c3e1743d2c25005a15f8298fc4bf3acb62bf45..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 24242 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