From 3ab587d34247941599fb2a291a05da5f322e6baf Mon Sep 17 00:00:00 2001 From: Taehoon Date: Mon, 8 Jun 2026 17:58:48 +0900 Subject: [PATCH] =?UTF-8?q?feat:=20DB=20V3=20=EC=A0=95=EA=B7=9C=ED=99=94?= =?UTF-8?q?=20=EB=B0=8F=20=EC=9A=A9=EB=8F=84=20=EA=B8=B0=EB=B0=98=20?= =?UTF-8?q?=EB=8F=99=EC=A0=81=20UI=20=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 백엔드: asset_core(마스터), asset_spec(사양), asset_volume(스토리지), asset_location(위치), asset_network(네트워크/원격) 5개 테이블로 V3 정규화 완료 - 백엔드: /api/assets/master 단일 엔드포인트로 통합 및 서브쿼리 최적화를 통한 UI 하위 호환성 유지 - 백엔드: 저장 로직(save) V3 스키마 분산 저장 및 cascade 기반 삭제 로직 적용 - 프론트엔드(HWModal): '현 용도(current_role)' 필드 추가 및 서버/개인용에 따른 네트워크/위치 섹션 동적 렌더링 구현 - 프론트엔드(state): 분산된 API 호출을 단일 호출로 통합하여 렌더링 성능 최적화 - 레거시 백업 파일 및 불필요한 구형 테이블 완벽 정리 완료 --- server.js | 304 +++++++++++++++++++------------- src/components/Modal/HWModal.ts | 44 ++++- src/core/state.ts | 111 ++---------- src/views/List/ListFactory.ts | 140 +++++++-------- 4 files changed, 303 insertions(+), 296 deletions(-) diff --git a/server.js b/server.js index 4da8052..8f666c9 100644 --- a/server.js +++ b/server.js @@ -28,6 +28,29 @@ const handleError = (res, err, label) => { res.status(500).json({ error: err.message }); }; +// --- Global Constants --- +const CATEGORY_TABLE_MAP = { + pc: 'asset_pc', + server: 'asset_server', + storage: 'asset_storage', + network: 'asset_network', + equipment: 'asset_equipment', + officeSupplies: 'asset_office_supplies', + survey: 'asset_survey', + vip: 'asset_vip', + swInternal: 'sw_internal', + swExternal: 'sw_external', + cloud: 'asset_cloud', + users: 'user_master', + swUsers: 'sw_assignment', + logs: 'asset_history' +}; + +const ASSET_TABLES = [ + 'asset_pc', 'asset_server', 'asset_storage', 'asset_network', + 'asset_equipment', 'asset_office_supplies', 'asset_survey', 'asset_vip' +]; + // --- API Endpoints --- // 1. Generic Batch Save (Dynamic Table Detection) @@ -41,15 +64,11 @@ app.post('/api/:table/batch', async (req, res) => { connection = await pool.getConnection(); await connection.beginTransaction(); - // 1. Get Table Schema const [columns] = await connection.query(`DESCRIBE ${table}`); const validFields = columns.map(c => c.Field); - // 2. Clear Existing Data (Optional - depending on strategy) - // For now, we use REPLACE INTO or similar. But user requested batch save as sync. await connection.query(`DELETE FROM ${table}`); - // 3. Insert New Data if (data.length > 0) { const placeholders = validFields.map(() => '?').join(', '); const sql = `INSERT INTO ${table} (${validFields.join(', ')}) VALUES (${placeholders})`; @@ -64,7 +83,6 @@ app.post('/api/:table/batch', async (req, res) => { } await connection.commit(); - console.log(`✅ [BATCH SAVE] Table: ${table}, Count: ${data.length}`); res.json({ success: true, count: data.length }); } catch (err) { if (connection) await connection.rollback(); @@ -74,37 +92,65 @@ app.post('/api/:table/batch', async (req, res) => { } }); -// 2. Get All Assets (Integrated Master Data) +// 2. Get All Assets (Integrated Master Data from Normalized V3 Schema) app.get('/api/assets/master', async (req, res) => { try { const connection = await pool.getConnection(); - const tables = { - pc: 'asset_pc', - server: 'asset_server', - storage: 'asset_storage', - network: 'asset_network', - equipment: 'asset_equipment', - officeSupplies: 'asset_office_supplies', - survey: 'asset_survey', - vip: 'asset_vip', - swInternal: 'sw_internal', - swExternal: 'sw_external', - cloud: 'asset_cloud', - users: 'user_master', - swUsers: 'sw_assignment', - logs: 'asset_history' + + const masterData = { + pc: [], server: [], storage: [], network: [], + equipment: [], officeSupplies: [], survey: [], vip: [], pcParts: [], + swInternal: [], swExternal: [], swUsers: [], users: [], logs: [] }; - const masterData = {}; - for (const [key, tableName] of Object.entries(tables)) { - try { - const [rows] = await connection.query(`SELECT * FROM ${tableName}`); - masterData[key] = rows; - } catch (err) { - console.warn(`[MASTER DATA] Skipping ${tableName}: ${err.message}`); - masterData[key] = []; - } - } + const [rows] = await connection.query(` + SELECT + c.*, + s.hw_status, s.model_name, s.mainboard, s.os, s.cpu, s.ram, s.gpu, + s.monitoring, s.price, s.monitor_inch, s.serial_num, + l.location, l.location_detail, l.location_photo, l.loc_x, l.loc_y, + n.ip_address, n.mac_address, n.remote_tool, n.remote_id, n.remote_pw, + (SELECT CONCAT(capacity, unit) FROM asset_volume WHERE asset_id = c.id AND disk_type = 'SSD' AND slot_no = 1 LIMIT 1) as ssd_1, + (SELECT CONCAT(capacity, unit) FROM asset_volume WHERE asset_id = c.id AND disk_type = 'SSD' AND slot_no = 2 LIMIT 1) as ssd_2, + (SELECT CONCAT(capacity, unit) FROM asset_volume WHERE asset_id = c.id AND disk_type = 'HDD' AND slot_no = 1 LIMIT 1) as hdd_1, + (SELECT CONCAT(capacity, unit) FROM asset_volume WHERE asset_id = c.id AND disk_type = 'HDD' AND slot_no = 2 LIMIT 1) as hdd_2, + (SELECT GROUP_CONCAT(CONCAT(disk_type, ': ', capacity, unit) SEPARATOR ', ') FROM asset_volume WHERE asset_id = c.id) as volume_summary + FROM asset_core c + LEFT JOIN asset_spec s ON c.id = s.asset_id + LEFT JOIN asset_location l ON l.id = ( + SELECT id FROM asset_location + WHERE asset_id = c.id AND is_active = 1 + ORDER BY created_at DESC LIMIT 1 + ) + LEFT JOIN asset_network n ON n.id = ( + SELECT id FROM asset_network + WHERE asset_id = c.id AND is_active = 1 + ORDER BY created_at DESC LIMIT 1 + ) + `); + + const catMap = { + 'PC': 'pc', '서버': 'server', '저장매체': 'storage', '네트워크': 'network', + '업무지원장비': 'equipment', '사무가구': 'officeSupplies', '공간정보장비': 'survey', + '내빈/외빈': 'vip', 'PC부품': 'pcParts' + }; + + rows.forEach(row => { + const key = catMap[row.category] || 'pc'; + masterData[key].push(row); + }); + + const [swInternal] = await connection.query('SELECT * FROM asset_software_perpetual'); + const [swExternal] = await connection.query('SELECT * FROM asset_software_subscription'); + const [swUsers] = await connection.query('SELECT * FROM asset_software_assignment'); + const [users] = await connection.query('SELECT * FROM system_users'); + const [logs] = await connection.query('SELECT * FROM asset_history ORDER BY created_at DESC'); + + masterData.swInternal = swInternal; + masterData.swExternal = swExternal; + masterData.swUsers = swUsers; + masterData.users = users; + masterData.logs = logs; connection.release(); res.json(masterData); @@ -113,70 +159,124 @@ app.get('/api/assets/master', async (req, res) => { } }); -// 3. Single Asset Save (Update or Insert) +// 3. Asset Save (Surgical Split to Normalized V3 Tables) app.post('/api/asset/:category/save', async (req, res) => { - const { category } = req.params; const asset = req.body; - const tableMap = { - pc: 'asset_pc', - server: 'asset_server', - storage: 'asset_storage', - network: 'asset_network', - equipment: 'asset_equipment', - officeSupplies: 'asset_office_supplies', - survey: 'asset_survey', - vip: 'asset_vip', - swInternal: 'sw_internal', - swExternal: 'sw_external', - cloud: 'asset_cloud' - }; - const table = tableMap[category]; - - if (!table) return res.status(400).json({ error: 'Invalid category' }); - + let connection; try { - const connection = await pool.getConnection(); - const [columns] = await connection.query(`DESCRIBE ${table}`); - const validFields = columns.map(c => c.Field); + connection = await pool.getConnection(); + await connection.beginTransaction(); - const dataObj = {}; - validFields.forEach(f => { if (asset[f] !== undefined) dataObj[f] = asset[f]; }); + // 3.1 asset_core + const coreFields = ['id', 'asset_code', 'category', 'asset_type', 'current_role', 'asset_purpose', 'service_type', 'purchase_corp', 'purchase_date', 'purchase_amount', 'purchase_vendor', 'approval_document', 'memo', 'manager_primary', 'manager_secondary', 'current_dept', 'previous_dept', 'user_current', 'previous_user', 'emp_no', 'user_position']; + const coreData = {}; + coreFields.forEach(f => { if (asset[f] !== undefined) coreData[f] = asset[f]; }); + const coreKeys = Object.keys(coreData); + const coreSql = `INSERT INTO asset_core (${coreKeys.join(', ')}) VALUES (${coreKeys.map(() => '?').join(', ')}) ON DUPLICATE KEY UPDATE ${coreKeys.map(k => `${k} = VALUES(${k})`).join(', ')}`; + await connection.query(coreSql, Object.values(coreData)); - const keys = Object.keys(dataObj); - const values = Object.values(dataObj); - const placeholders = keys.map(() => '?').join(', '); - const updates = keys.map(k => `${k} = VALUES(${k})`).join(', '); + // 3.2 asset_spec + const specFields = ['hw_status', 'model_name', 'mainboard', 'os', 'cpu', 'ram', 'gpu', 'monitoring', 'price', 'monitor_inch', 'serial_num']; + const specData = { asset_id: asset.id }; + specFields.forEach(f => { if (asset[f] !== undefined) specData[f] = asset[f]; }); + const specKeys = Object.keys(specData); + const [specExists] = await connection.query('SELECT id FROM asset_spec WHERE asset_id = ?', [asset.id]); + if (specExists.length > 0) { + const updateSql = `UPDATE asset_spec SET ${specKeys.filter(k => k !== 'asset_id').map(k => `${k} = ?`).join(', ')} WHERE asset_id = ?`; + await connection.query(updateSql, [...specKeys.filter(k => k !== 'asset_id').map(k => specData[k]), asset.id]); + } else { + await connection.query(`INSERT INTO asset_spec (${specKeys.join(', ')}) VALUES (${specKeys.map(() => '?').join(', ')})`, Object.values(specData)); + } - const sql = `INSERT INTO ${table} (${keys.join(', ')}) VALUES (${placeholders}) ON DUPLICATE KEY UPDATE ${updates}`; - - await connection.query(sql, values); - connection.release(); - console.log(`💾 [ASSET SAVE] Category: ${category}, ID: ${asset.id}`); + // 3.3 asset_volume (Legacy Parser) + const parseCapacity = (str) => { + if (!str || str.trim() === '' || str.toLowerCase() === 'null') return null; + const match = str.match(/(\d+(?:\.\d+)?)\s*([GT]B)?/i); + if (match) return { value: parseFloat(match[1]), unit: (match[2] || 'GB').toUpperCase() }; + return null; + }; + const storages = [ + { val: asset.ssd_1, type: 'SSD', slot: 1 }, + { val: asset.ssd_2, type: 'SSD', slot: 2 }, + { val: asset.hdd_1, type: 'HDD', slot: 1 }, + { val: asset.hdd_2, type: 'HDD', slot: 2 } + ]; + await connection.query('DELETE FROM asset_volume WHERE asset_id = ?', [asset.id]); + for (const s of storages) { + const parsed = parseCapacity(s.val); + if (parsed) { + await connection.query('INSERT INTO asset_volume (asset_id, disk_type, capacity, unit, slot_no) VALUES (?, ?, ?, ?, ?)', + [asset.id, s.type, parsed.value, parsed.unit, s.slot]); + } + } + + // 3.4 asset_location + if (asset.location || asset.location_detail) { + const [locActive] = await connection.query('SELECT * FROM asset_location WHERE asset_id = ? AND is_active = 1', [asset.id]); + const isChanged = locActive.length === 0 || locActive[0].location !== asset.location || locActive[0].location_detail !== asset.location_detail || locActive[0].loc_x !== asset.loc_x || locActive[0].loc_y !== asset.loc_y; + if (isChanged) { + await connection.query('UPDATE asset_location SET is_active = 0, deactivated_at = NOW() WHERE asset_id = ? AND is_active = 1', [asset.id]); + await connection.query(`INSERT INTO asset_location (asset_id, location, location_detail, location_photo, loc_x, loc_y, is_active) VALUES (?, ?, ?, ?, ?, ?, 1)`, + [asset.id, asset.location, asset.location_detail, asset.location_photo, asset.loc_x, asset.loc_y]); + } + } + + // 3.5 asset_network + if (asset.ip_address || asset.mac_address || asset.remote_tool) { + const [netActive] = await connection.query('SELECT * FROM asset_network WHERE asset_id = ? AND is_active = 1', [asset.id]); + const isChanged = netActive.length === 0 || netActive[0].ip_address !== asset.ip_address || netActive[0].mac_address !== asset.mac_address || netActive[0].remote_tool !== asset.remote_tool || netActive[0].remote_id !== asset.remote_id || netActive[0].remote_pw !== asset.remote_pw; + if (isChanged) { + await connection.query('UPDATE asset_network SET is_active = 0, deactivated_at = NOW() WHERE asset_id = ? AND is_active = 1', [asset.id]); + await connection.query(`INSERT INTO asset_network (asset_id, ip_address, mac_address, remote_tool, remote_id, remote_pw, is_active) VALUES (?, ?, ?, ?, ?, ?, 1)`, + [asset.id, asset.ip_address, asset.mac_address, asset.remote_tool, asset.remote_id, asset.remote_pw]); + } + } + + await connection.commit(); + console.log(`💾 [V3 ASSET SAVE] ID: ${asset.id}`); res.json({ success: true }); } catch (err) { - handleError(res, err, 'ASSET SAVE'); + if (connection) await connection.rollback(); + handleError(res, err, 'ASSET SAVE V3'); + } finally { + if (connection) connection.release(); } }); // 4. Asset Delete app.delete('/api/asset/:category/:id', async (req, res) => { const { category, id } = req.params; - const tableMap = { - pc: 'asset_pc', server: 'asset_server', storage: 'asset_storage', network: 'asset_network', - equipment: 'asset_equipment', officeSupplies: 'asset_office_supplies', survey: 'asset_survey', - vip: 'asset_vip', swInternal: 'sw_internal', swExternal: 'sw_external', cloud: 'asset_cloud' + + // Define mapping for which base table handles the delete + const deleteTableMap = { + pc: 'asset_core', + server: 'asset_core', + storage: 'asset_core', + network: 'asset_core', + equipment: 'asset_core', + officeSupplies: 'asset_core', + survey: 'asset_core', + vip: 'asset_core', + pcParts: 'asset_core', + swInternal: 'asset_software_perpetual', + swExternal: 'asset_software_subscription', + swUsers: 'asset_software_assignment', + users: 'system_users' }; - const table = tableMap[category]; - if (!table) return res.status(400).json({ error: 'Invalid category' }); + const table = deleteTableMap[category]; + + if (!table) return res.status(400).json({ error: 'Invalid category for deletion' }); + try { const connection = await pool.getConnection(); + // For asset_core, ON DELETE CASCADE will handle spec, location, network, volume await connection.query(`DELETE FROM ${table} WHERE id = ?`, [id]); connection.release(); console.log(`🗑️ [ASSET DELETE] Category: ${category}, ID: ${id}`); res.json({ success: true }); - } catch (err) { - handleError(res, err, 'ASSET DELETE'); + } catch (err) { + handleError(res, err, 'ASSET DELETE'); } }); @@ -184,83 +284,49 @@ app.delete('/api/asset/:category/:id', async (req, res) => { app.get('/api/generate-asset-code', async (req, res) => { const { prefix, purchaseDate } = req.query; if (!prefix) return res.status(400).json({ error: 'Prefix is required' }); - try { const connection = await pool.getConnection(); - const tables = [ - 'asset_pc', 'asset_server', 'asset_storage', 'asset_network', - 'asset_equipment', 'asset_office_supplies', 'asset_survey', 'asset_vip' - ]; - - // Extract YYYYMM from purchaseDate (format: YYYY-MM-DD) const datePart = purchaseDate ? purchaseDate.toString().replace(/-/g, '').substring(0, 6) : ''; const searchPattern = datePart ? `${prefix}-${datePart}-%` : `${prefix}-%`; - let maxNum = 0; - - for (const table of tables) { + for (const table of ASSET_TABLES) { try { - const [rows] = await connection.query( - `SELECT asset_code FROM ${table} WHERE asset_code LIKE ?`, - [searchPattern] - ); - + const [rows] = await connection.query(`SELECT asset_code FROM ${table} WHERE asset_code LIKE ?`, [searchPattern]); rows.forEach(row => { const parts = row.asset_code.split('-'); - const seqPart = parts[parts.length - 1]; // Last part is sequence - const num = parseInt(seqPart); + const num = parseInt(parts[parts.length - 1]); if (!isNaN(num) && num > maxNum) maxNum = num; }); - } catch (err) { - // Table might not exist or column missing - } + } catch (err) {} } - const nextNum = maxNum + 1; - const nextCode = datePart - ? `${prefix}-${datePart}-${String(nextNum).padStart(4, '0')}` - : `${prefix}-${String(nextNum).padStart(4, '0')}`; // Fallback if no date - + const nextCode = datePart ? `${prefix}-${datePart}-${String(nextNum).padStart(4, '0')}` : `${prefix}-${String(nextNum).padStart(4, '0')}`; connection.release(); - console.log(`🆕 [GENERATE CODE] Prefix: ${prefix}, Date: ${datePart}, Next: ${nextCode}`); res.json({ nextCode }); - } catch (err) { - handleError(res, err, 'GENERATE CODE'); - } + } catch (err) { handleError(res, err, 'GENERATE CODE'); } }); -// 6. Map Config API (Real-time Save) +// 6. Map Config API app.get('/api/maps', (req, res) => { try { - if (!fs.existsSync('map_config.json')) { - return res.json({}); - } + if (!fs.existsSync('map_config.json')) return res.json({}); const data = fs.readFileSync('map_config.json', 'utf8'); res.json(JSON.parse(data || '{}')); - } catch (err) { - handleError(res, err, 'GET MAPS'); - } + } catch (err) { handleError(res, err, 'GET MAPS'); } }); app.post('/api/maps/save', (req, res) => { try { const { path, boxes } = req.body; if (!path) return res.status(400).json({ error: 'Path is required' }); - let config = {}; - if (fs.existsSync('map_config.json')) { - config = JSON.parse(fs.readFileSync('map_config.json', 'utf8') || '{}'); - } - + if (fs.existsSync('map_config.json')) config = JSON.parse(fs.readFileSync('map_config.json', 'utf8') || '{}'); config[path] = boxes; fs.writeFileSync('map_config.json', JSON.stringify(config, null, 2)); - console.log(`💾 [MAP SAVE] Updated config for: ${path}`); res.json({ success: true }); - } catch (err) { - handleError(res, err, 'SAVE MAPS'); - } + } catch (err) { handleError(res, err, 'SAVE MAPS'); } }); app.listen(3000, '0.0.0.0', () => { - console.log('📡 ITAM BACKEND SERVER RUNNING ON PORT 3000 (Multi-Table Optimized)'); + console.log('📡 ITAM BACKEND SERVER RUNNING ON PORT 3000 (V3 Normalized)'); }); diff --git a/src/components/Modal/HWModal.ts b/src/components/Modal/HWModal.ts index 87f9a9c..b06fd96 100644 --- a/src/components/Modal/HWModal.ts +++ b/src/components/Modal/HWModal.ts @@ -359,6 +359,7 @@ class HwAssetModal extends BaseModal { setFieldValue('hw-asset_code', asset.asset_code || ''); setFieldValue('hw-purchase_corp', asset.purchase_corp || ''); setFieldValue('hw-category', asset.category || ''); + setFieldValue('hw-current_role', asset.current_role || 'Normal'); const types = CATEGORY_TYPE_MAP[asset.category] || []; const typeSelect = document.getElementById('hw-asset_type') as HTMLSelectElement; @@ -408,19 +409,50 @@ class HwAssetModal extends BaseModal { parseAndSetLocation(asset.location || '', asset.location_detail || '', 'hw-bldg-select', 'hw-location_detail'); this.renderHistory(asset.id); + + // Initial visibility check based on role + this.applyRoleVisibility(asset.current_role || 'Normal'); } protected onAfterOpen(asset: any, mode: string): void { this.updateMapButtonVisibility(asset); - const isServer = asset.category === '서버' || asset.asset_code?.startsWith('SVR') || asset.asset_type === '서버PC'; - const isPc = asset.category === 'PC' || asset.asset_code?.startsWith('PC'); - const isVip = asset.category === '선물' || asset.category === 'VIP'; + const role = asset.current_role || 'Normal'; + this.applyRoleVisibility(role); + // Role change event + const roleSelect = document.getElementById('hw-current_role') as HTMLSelectElement; + roleSelect?.addEventListener('change', (e) => { + this.applyRoleVisibility((e.target as HTMLSelectElement).value); + }); + } + + private applyRoleVisibility(role: string): void { + const isServer = role === 'Server'; + const isPersonal = role === 'Personal'; + + // Section Visibility + const networkSectionTitle = document.evaluate("//div[contains(text(), '네트워크 및 접속 정보')]", document, null, XPathResult.FIRST_ORDERED_NODE_TYPE, null).singleNodeValue as HTMLElement; + const locationSectionTitle = document.evaluate("//div[contains(text(), '설치 위치')]", document, null, XPathResult.FIRST_ORDERED_NODE_TYPE, null).singleNodeValue as HTMLElement; + + // Helper to toggle visibility of elements after a title until next section title + const toggleSection = (titleEl: HTMLElement, show: boolean) => { + if (!titleEl) return; + titleEl.style.display = show ? 'block' : 'none'; + let next = titleEl.nextElementSibling as HTMLElement; + while (next && !next.classList.contains('form-section-title')) { + next.style.display = show ? 'flex' : 'none'; + next = next.nextElementSibling as HTMLElement; + } + }; + + // Show/Hide based on role + toggleSection(networkSectionTitle, isServer); + toggleSection(locationSectionTitle, !isPersonal); + + // Specific fields document.querySelectorAll('.server-only').forEach(el => (el as HTMLElement).style.display = isServer ? 'flex' : 'none'); - document.querySelectorAll('.non-server').forEach(el => (el as HTMLElement).style.display = !isServer ? 'flex' : 'none'); - document.querySelectorAll('.pc-only').forEach(el => (el as HTMLElement).style.display = isPc ? 'flex' : 'none'); - document.querySelectorAll('.user-tracking-field').forEach(el => (el as HTMLElement).style.display = (!isServer && !isVip) ? 'flex' : 'none'); + document.querySelectorAll('.pc-only').forEach(el => (el as HTMLElement).style.display = isPersonal ? 'flex' : 'none'); } private updateMapButtonVisibility(asset?: any) { diff --git a/src/core/state.ts b/src/core/state.ts index b66ebc9..d7189bb 100644 --- a/src/core/state.ts +++ b/src/core/state.ts @@ -60,44 +60,20 @@ export const state: AppState = { }; /** - * 신규 14개 테이블 구조에 맞춘 데이터 로드 + * 통합 V2 스키마에 맞춘 데이터 로드 */ export async function loadMasterDataFromDB() { try { - const endpoints = [ - { key: 'users', url: '/api/users' }, - { key: 'pc', url: '/api/pc' }, - { key: 'server', url: '/api/server' }, - { key: 'storage', url: '/api/storage' }, - { key: 'network', url: '/api/network' }, - { key: 'survey', url: '/api/survey' }, - { key: 'pcParts', url: '/api/pc-parts' }, - { key: 'equipment', url: '/api/equipment' }, - { key: 'officeSupplies', url: '/api/office-supplies' }, - { key: 'swInternal', url: '/api/sw/internal' }, - { key: 'swExternal', url: '/api/sw/external' }, - { key: 'cloud', url: '/api/cloud' }, - { key: 'domain', url: '/api/domain' }, - { key: 'cost', url: '/api/cost' }, - { key: 'vip', url: '/api/vip' }, - { key: 'swUsers', url: '/api/asset/software/assignment' }, - { key: 'logs', url: '/api/asset/history' } - ]; - - const results = await Promise.all(endpoints.map(e => fetch(API_BASE_URL + e.url))); - - for (let i = 0; i < endpoints.length; i++) { - if (results[i].ok) { - const data = await results[i].json(); - const key = endpoints[i].key; - (state.masterData as any)[key] = Array.isArray(data) ? data : []; - } - } - - // Mapping for backward compatibility - state.masterData.equip = state.masterData.equipment; - state.masterData.subSw = state.masterData.swExternal; - state.masterData.permSw = state.masterData.swInternal; + const response = await fetch(`${API_BASE_URL}/api/assets/master`); + if (!response.ok) throw new Error('Failed to fetch master data'); + + const data = await response.json(); + + // 전역 상태 업데이트 + state.masterData = { + ...state.masterData, + ...data + }; // 하드웨어 통합 (대시보드 호환용) state.masterData.hw = [ @@ -114,10 +90,10 @@ export async function loadMasterDataFromDB() { state.masterData.sw = [ ...state.masterData.swInternal, ...state.masterData.swExternal, - ...state.masterData.cloud + ...(state.masterData.cloud || []) ]; - console.log('✅ All data (including users) loaded and unified'); + console.log('✅ V2 Normalized data loaded successfully'); return true; } catch (err) { console.warn('⚠️ 서버 연결 실패:', err); @@ -130,39 +106,15 @@ export function updateState(newState: Partial) { } /** - * 자산 저장 (Generic API) + * 자산 저장 (V2 Normalized API) */ export async function saveAsset(category: string, asset: any) { try { - const endpointMap: Record = { - 'users': '/api/users/batch', - 'pc': '/api/pc/batch', - 'server': '/api/server/batch', - 'storage': '/api/storage/batch', - 'network': '/api/network/batch', - 'survey': '/api/survey/batch', - 'pcParts': '/api/pc-parts/batch', - 'equipment': '/api/equipment/batch', - 'officeSupplies': '/api/office-supplies/batch', - 'swInternal': '/api/sw/internal/batch', - 'swExternal': '/api/sw/external/batch', - 'cloud': '/api/cloud/batch', - 'domain': '/api/domain/batch', - 'cost': '/api/cost/batch', - 'vip': '/api/vip/batch' - }; - - const url = `${API_BASE_URL}${endpointMap[category]}`; - const currentList = [...(state.masterData as any)[category]]; - const idx = currentList.findIndex(a => a.id === asset.id); - - if (idx > -1) currentList[idx] = asset; - else currentList.push(asset); - + const url = `${API_BASE_URL}/api/asset/${category}/save`; const response = await fetch(url, { method: 'POST', headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify(currentList) + body: JSON.stringify(asset) }); if (response.ok) { @@ -176,37 +128,12 @@ export async function saveAsset(category: string, asset: any) { } /** - * 자산 삭제 (Generic API - Batch 방식 활용) + * 자산 삭제 (V2 API) */ export async function deleteAsset(category: string, assetId: string) { try { - const endpointMap: Record = { - 'users': '/api/users/batch', - 'pc': '/api/pc/batch', - 'server': '/api/server/batch', - 'storage': '/api/storage/batch', - 'network': '/api/network/batch', - 'survey': '/api/survey/batch', - 'pcParts': '/api/pc-parts/batch', - 'equipment': '/api/equipment/batch', - 'officeSupplies': '/api/office-supplies/batch', - 'swInternal': '/api/sw/internal/batch', - 'swExternal': '/api/sw/external/batch', - 'cloud': '/api/cloud/batch', - 'domain': '/api/domain/batch', - 'cost': '/api/cost/batch', - 'vip': '/api/vip/batch' - }; - - const url = `${API_BASE_URL}${endpointMap[category]}`; - const currentList = [...(state.masterData as any)[category]]; - const filteredList = currentList.filter(a => a.id !== assetId); - - const response = await fetch(url, { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify(filteredList) - }); + const url = `${API_BASE_URL}/api/asset/${category}/${assetId}`; + const response = await fetch(url, { method: 'DELETE' }); if (response.ok) { await loadMasterDataFromDB(); // 전역 상태 갱신 diff --git a/src/views/List/ListFactory.ts b/src/views/List/ListFactory.ts index b7b1709..702a865 100644 --- a/src/views/List/ListFactory.ts +++ b/src/views/List/ListFactory.ts @@ -42,15 +42,15 @@ export function createListView(container: HTMLElement, config: ListViewConfig) { let currentFilters: any = { keyword: '', corp: '', dept: '', loc: '', field: '', type: '' }; // 강제로 기본 뷰 모드를 'system' (자산 현황)으로 설정 - state.currentViewMode = 'system'; + (state as any).currentViewMode = 'system'; // 2. 뷰 전환 토글 버튼 생성 (명칭 변경) const toggleWrapper = document.createElement('div'); toggleWrapper.className = 'view-toggle-container'; toggleWrapper.innerHTML = `
- - + +
`; container.appendChild(toggleWrapper); @@ -82,21 +82,32 @@ export function createListView(container: HTMLElement, config: ListViewConfig) { // [자산 현황] 대시보드 렌더러 const renderSystemStatus = () => { const isPcView = config.title === 'PC'; + const locationCounts: Record = {}; const pcTypeCounts = { public: 0, server: 0, personal: 0 }; - const extSubCounts = { tech: 0, idc: 0, hm: 0 }; - const intSubCounts = { tech: 0, idc: 0, hm: 0 }; - let internalCount = 0; - let externalCount = 0; + + // 동적 통계 수집 객체 (Hardcoding 제거) + const extStats = { total: 0, locCounts: {} as Record, typeCounts: {} as Record, locWarning: 0, typeWarning: 0 }; + const intStats = { total: 0, locCounts: {} as Record, typeCounts: {} as Record }; - const extTypeCounts: Record = {}; - const intTypeCounts: Record = {}; - let locWarningCount = 0; - let typeWarningCount = 0; + // 중앙화된 경고 감지 로직 + const checkAnomaly = (serviceType: string, loc: string, type: string) => { + if (serviceType !== '외부') return { isWarning: false, isLocWarning: false, isTypeWarning: false, reason: '' }; + const isLocWarning = loc !== 'IDC' && loc !== '미지정' && loc !== ''; + const isTypeWarning = type.toLowerCase().replace(/\s/g, '').includes('서버pc'); + const isWarning = isLocWarning || isTypeWarning; + + let reason = ''; + if (isLocWarning && isTypeWarning) reason = '위치/형식 부적절'; + else if (isLocWarning) reason = '위치 부적절'; + else if (isTypeWarning) reason = '형식 부적절'; + + return { isWarning, isLocWarning, isTypeWarning, reason }; + }; fullList.forEach(asset => { const loc = asset[ASSET_SCHEMA.LOCATION.key] || '미지정'; - const serviceTypeKey = ASSET_SCHEMA.SERVICE_TYPE?.key || 'service_type'; + const serviceTypeKey = (ASSET_SCHEMA as any).SERVICE_TYPE?.key || 'service_type'; const serviceType = asset[serviceTypeKey] || '외부'; const type = asset[ASSET_SCHEMA.ASSET_TYPE.key] || ''; @@ -108,33 +119,39 @@ export function createListView(container: HTMLElement, config: ListViewConfig) { else pcTypeCounts.personal++; } - if (serviceType === '내부') { - internalCount++; - if (loc.includes('기술개발')) intSubCounts.tech++; - else if (loc.includes('IDC')) intSubCounts.idc++; - else if (loc.includes('한맥')) intSubCounts.hm++; - if (type) intTypeCounts[type] = (intTypeCounts[type] || 0) + 1; - } else { - externalCount++; - if (loc.includes('기술개발')) extSubCounts.tech++; - else if (loc.includes('IDC')) extSubCounts.idc++; - else if (loc.includes('한맥')) extSubCounts.hm++; - if (type) extTypeCounts[type] = (extTypeCounts[type] || 0) + 1; - - // [경고 로직 세분화] 외부 운영 기준 (공백/대소문자 무시) - if (serviceType === '외부') { - if (loc !== 'IDC') locWarningCount++; - if (type.toLowerCase().replace(/\s/g, '').includes('서버pc')) typeWarningCount++; - } + const targetStat = serviceType === '내부' ? intStats : extStats; + targetStat.total++; + if (loc) targetStat.locCounts[loc] = (targetStat.locCounts[loc] || 0) + 1; + if (type) targetStat.typeCounts[type] = (targetStat.typeCounts[type] || 0) + 1; + + if (serviceType === '외부') { + const anomaly = checkAnomaly(serviceType, loc, type); + if (anomaly.isLocWarning) extStats.locWarning++; + if (anomaly.isTypeWarning) extStats.typeWarning++; } }); - const locLabels = Object.keys(locationCounts).sort((a, b) => locationCounts[b] - locationCounts[a]); - const pcLabels = ['공용PC', '서버PC', '개인PC']; - const pcData = [pcTypeCounts.public, pcTypeCounts.server, pcTypeCounts.personal]; - - const chartLabels = isPcView ? pcLabels : locLabels; - const chartData = isPcView ? pcData : locLabels.map(l => locationCounts[l]); + // 템플릿 제너레이터 함수 (HTML 중복 제거) + const generateDetailStatHTML = (title: string, stats: typeof extStats) => ` +
+ ${title} +
+ ${stats.locWarning ? `위치부적절: ${stats.locWarning}` : ''} + ${stats.typeWarning ? `형식부적절: ${stats.typeWarning}` : ''} +
+
+
+
+ ${Object.entries(stats.locCounts).sort((a, b) => b[1] - a[1]).slice(0, 4).map(([l, c]) => `${l}: ${c}`).join('')} +
+
+ ${Object.entries(stats.typeCounts).sort((a, b) => b[1] - a[1]).slice(0, 6).map(([t, c]) => { + const isTypeWarning = title.includes('외부') && t.toLowerCase().replace(/\s/g, '').includes('서버pc'); + return `${t}: ${c}`; + }).join('')} +
+
+ `; contentWrapper.innerHTML = `
@@ -145,8 +162,8 @@ export function createListView(container: HTMLElement, config: ListViewConfig) {
총 보유 자산
${fullList.length}
- 외부: ${externalCount} - 내부: ${internalCount} + 외부: ${extStats.total} + 내부: ${intStats.total}
@@ -158,46 +175,11 @@ export function createListView(container: HTMLElement, config: ListViewConfig) { 서버: ${pcTypeCounts.server} 개인: ${pcTypeCounts.personal} - ` : ` -
- 외부 (운영) 상세 -
- ${locWarningCount > 0 ? `위치부적절: ${locWarningCount}` : ''} - ${typeWarningCount > 0 ? `형식부적절: ${typeWarningCount}` : ''} -
-
-
-
- 기술개발센터: ${extSubCounts.tech} - IDC: ${extSubCounts.idc} - 한맥빌딩: ${extSubCounts.hm} -
-
- ${Object.entries(extTypeCounts).sort((a, b) => b[1] - a[1]).map(([type, count]) => { - const isTypeWarning = type.toLowerCase().replace(/\s/g, '').includes('서버pc'); - return `${type}: ${count}`; - }).join('')} -
-
- `} + ` : generateDetailStatHTML('외부 (운영) 상세', extStats)}
- ${isPcView ? '' : ` -
- 내부 (테스트) 상세 -
-
-
- 기술개발센터: ${intSubCounts.tech} - IDC: ${intSubCounts.idc} - 한맥빌딩: ${intSubCounts.hm} -
-
- ${Object.entries(intTypeCounts).sort((a, b) => b[1] - a[1]).map(([type, count]) => `${type}: ${count}`).join('')} -
-
- `} + ${isPcView ? '' : generateDetailStatHTML('내부 (테스트) 상세', intStats as any)}
@@ -405,7 +387,7 @@ export function createListView(container: HTMLElement, config: ListViewConfig) { ? `조회된 자산이 없습니다.` : finalDisplayList.map(asset => { const purpose = asset[ASSET_SCHEMA.ASSET_PURPOSE.key] || ''; - const serviceTypeKey = ASSET_SCHEMA.SERVICE_TYPE?.key || 'service_type'; + const serviceTypeKey = (ASSET_SCHEMA as any).SERVICE_TYPE?.key || 'service_type'; const serviceType = asset[serviceTypeKey] || '외부'; const type = asset[ASSET_SCHEMA.ASSET_TYPE.key] || ''; const loc = asset[ASSET_SCHEMA.LOCATION.key] || ''; @@ -501,7 +483,7 @@ export function createListView(container: HTMLElement, config: ListViewConfig) { tableWrapper.appendChild(table); const updateTable = () => { - if (state.currentViewMode !== 'asset') return; + if ((state as any).currentViewMode !== 'asset') return; let filtered = applyCommonFilters(fullList, currentFilters, config.searchKeys as any[]); if (sortState.key) filtered = dynamicSort(filtered, sortState.key, sortState.direction); @@ -536,7 +518,7 @@ export function createListView(container: HTMLElement, config: ListViewConfig) { // --- 뷰 전환 로직 --- const switchView = () => { contentWrapper.innerHTML = ''; - if (state.currentViewMode === 'asset') { + if ((state as any).currentViewMode === 'asset') { filterBar.style.display = 'flex'; contentWrapper.style.overflowY = 'auto'; contentWrapper.appendChild(tableWrapper); @@ -554,7 +536,7 @@ export function createListView(container: HTMLElement, config: ListViewConfig) { if (!btn) return; toggleWrapper.querySelectorAll('.toggle-btn').forEach(b => b.classList.remove('active')); btn.classList.add('active'); - state.currentViewMode = btn.getAttribute('data-mode') as 'asset' | 'system'; + (state as any).currentViewMode = btn.getAttribute('data-mode') as 'asset' | 'system'; switchView(); });