From 2af79cdad310ff1ed997f1a3b5a3d26560d30932 Mon Sep 17 00:00:00 2001 From: Taehoon Date: Thu, 30 Apr 2026 09:34:29 +0900 Subject: [PATCH] refactor: integrate software assets into unified schema and optimize backend API --- server.js | 713 ++++++++++++----------------- src/core/state.ts | 77 ++-- src/core/utils.ts | 8 +- src/main.ts | 10 +- src/views/Dashboard/SwDashboard.ts | 3 +- src/views/List/SwListView.ts | 3 +- 6 files changed, 363 insertions(+), 451 deletions(-) diff --git a/server.js b/server.js index 8d23422..66b1588 100644 --- a/server.js +++ b/server.js @@ -6,10 +6,14 @@ import dotenv from 'dotenv'; dotenv.config(); const app = express(); -const PORT = process.env.PORT || 3000; - app.use(cors()); -app.use(express.json({ limit: '50mb' })); +app.use(express.json({ limit: '100mb' })); + +// Request Logger +app.use((req, res, next) => { + console.log(`[${new Date().toISOString()}] ${req.method} ${req.url}`); + next(); +}); const pool = mysql.createPool({ host: process.env.DB_HOST, @@ -17,464 +21,351 @@ const pool = mysql.createPool({ password: process.env.DB_PASS, database: process.env.DB_NAME, port: parseInt(process.env.DB_PORT || '3306'), - waitForConnections: true, - connectionLimit: 10, - queueLimit: 0 + charset: 'utf8mb4' }); -// 테이블 존재 여부 확인 및 자동 생성 -async function ensureTables() { - const connection = await pool.getConnection(); - try { - await connection.query(` - CREATE TABLE IF NOT EXISTS cloud_assets ( - id VARCHAR(50) PRIMARY KEY, - platform_name VARCHAR(100), - corp VARCHAR(100), - dept VARCHAR(100), - product_name VARCHAR(255), - account_name VARCHAR(255), - pay_method VARCHAR(100), - pay_day VARCHAR(50), - card_num VARCHAR(100), - monthly_fee VARCHAR(100), - remarks TEXT, - created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP - ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; - `); - await connection.query(` - CREATE TABLE IF NOT EXISTS asset_logs ( - id INT AUTO_INCREMENT PRIMARY KEY, asset_id VARCHAR(50), log_date VARCHAR(50), - log_user VARCHAR(100), details TEXT, cost DECIMAL(15,2) DEFAULT 0 - ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; - `); - await connection.query(` - CREATE TABLE IF NOT EXISTS pc_assets ( - id VARCHAR(50) PRIMARY KEY, corp VARCHAR(100), asset_code VARCHAR(100), purchase_date VARCHAR(50), - type VARCHAR(50), detail_purpose VARCHAR(100), purpose VARCHAR(255), details TEXT, - current_org VARCHAR(100), prev_org VARCHAR(100), location VARCHAR(255), - manager_main VARCHAR(100), manager_sub VARCHAR(100), ip_address VARCHAR(50), - remote_tool VARCHAR(100), server_id VARCHAR(100), server_pw VARCHAR(100), - model_name VARCHAR(255), mainboard VARCHAR(255), os VARCHAR(100), cpu VARCHAR(100), ram VARCHAR(100), gpu VARCHAR(100), - storage1 VARCHAR(100), storage2 VARCHAR(100), storage3 VARCHAR(100), monitoring VARCHAR(100), price VARCHAR(100), vendor VARCHAR(100), remarks TEXT, - storage_location VARCHAR(255), status VARCHAR(50) - ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; - `); - // 다른 하드웨어 테이블들도 동일한 스키마로 생성 (서버, 스토리지, 비품, 모바일) - for (const table of ['server_assets', 'storage_assets', 'equip_assets', 'mobile_assets']) { - await connection.query(`CREATE TABLE IF NOT EXISTS ${table} LIKE pc_assets`); - } - - await connection.query(` - CREATE TABLE IF NOT EXISTS sw_sub_assets ( - id VARCHAR(50) PRIMARY KEY, corp VARCHAR(100), - category VARCHAR(100), dept VARCHAR(100), product_name VARCHAR(255), - license_type VARCHAR(100), quantity INT, price VARCHAR(100), purchase_date VARCHAR(50), - start_date VARCHAR(50), expiry_date VARCHAR(50), vendor VARCHAR(100), remarks TEXT - ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; - `); - await connection.query(` - CREATE TABLE IF NOT EXISTS sw_perm_assets ( - id VARCHAR(50) PRIMARY KEY, corp VARCHAR(100), - category VARCHAR(100), dept VARCHAR(100), product_name VARCHAR(255), - license_key VARCHAR(255), quantity INT, price VARCHAR(100), purchase_date VARCHAR(50), - start_date VARCHAR(50), expiry_date VARCHAR(50), vendor VARCHAR(100), remarks TEXT - ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; - `); - await connection.query(` - CREATE TABLE IF NOT EXISTS asset_logs ( - id INT AUTO_INCREMENT PRIMARY KEY, asset_id VARCHAR(50), log_date VARCHAR(50), - log_user VARCHAR(100), details TEXT, cost DECIMAL(15,2) DEFAULT 0 - ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; - `); - await connection.query(` - CREATE TABLE IF NOT EXISTS sw_users ( - id INT AUTO_INCREMENT PRIMARY KEY, sw_id VARCHAR(50), corp VARCHAR(100), dept VARCHAR(100), - position VARCHAR(100), user_name VARCHAR(100), usage_period VARCHAR(255), doc_name VARCHAR(255) - ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; - `); - await connection.query(` - CREATE TABLE IF NOT EXISTS ops_domain_assets ( - id VARCHAR(50) PRIMARY KEY, type VARCHAR(50), corp VARCHAR(100), - service_name VARCHAR(255), domain_name VARCHAR(255), start_date VARCHAR(50), - expiry_date VARCHAR(50), price VARCHAR(100), manager_main VARCHAR(100), - manager_sub VARCHAR(100), remarks TEXT, created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP - ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; - `); - - // 기존 테이블들에 vendor 컬럼이 없는 경우 추가 (Migration) - const [cols] = await pool.query("SHOW COLUMNS FROM pc_assets LIKE 'vendor'"); - if (cols.length === 0) { - for (const table of ['pc_assets', 'server_assets', 'storage_assets', 'equip_assets', 'mobile_assets']) { - await pool.query(`ALTER TABLE ${table} ADD COLUMN vendor VARCHAR(100) AFTER price`); - } - } - - console.log('✅ All ITAM tables ensured.'); - } finally { - connection.release(); - } -} - -// 공통 배치 저장 로직 -async function batchSave(tableName, assets, getQuery) { - const connection = await pool.getConnection(); - try { - await connection.beginTransaction(); - await connection.query(`DELETE FROM ${tableName}`); - if (assets.length > 0) { - const { sql, values } = getQuery(assets); - await connection.query(sql, [values]); - } - await connection.commit(); - return { success: true, count: assets.length }; - } catch (err) { - console.error(`❌ Batch Save Error (${tableName}):`, err.message); - await connection.rollback(); - throw err; - } finally { - connection.release(); - } -} - -// 하드웨어 쿼리 헬퍼 -const hardwareInsertSQL = (table) => ` - INSERT INTO ${table} ( - id, corp, asset_code, purchase_date, type, detail_purpose, purpose, details, - current_org, prev_org, location, manager_main, manager_sub, ip_address, - remote_tool, server_id, server_pw, model_name, mainboard, os, cpu, ram, gpu, - storage1, storage2, storage3, monitoring, price, vendor, remarks, - storage_location, status - ) VALUES ? -`; - -const getHardwareValues = (a) => [ - a.id, a.법인||'', a.자산코드||'', a.구매연월||'', a.type||'', a.상세용도||'', a['사용자']||a.용도||'', a.상세||'', - a.현사용조직||'', a.이전사용조직||'', a.위치||'', a.담당자_정||'', a.담당자_부||'', a.IP주소||'', - a.원격접속||'', a.서버ID||'', a.서버PW||'', a.모델명||'', a.메인보드||'', a.OS||'', a.CPU||'', a.RAM||'', a.GPU||'', - a.SSD1||'', a.SSD2||'', a.SSD3||'', a.모니터링||'', a.금액||'', a.납품업체||a.vendor||'', a.비고||'', - a.보관위치||'', a.현재상태||'' -]; - -const mapHardware = (r, defaultType) => { - const type = r.type || defaultType; - return { - id: r.id, - 법인: r.corp, - 자산코드: r.asset_code, - 구매연월: r.purchase_date, - 구매일: r.purchase_date, - type: type, - 상세용도: (type !== '개인PC' && !r.detail_purpose) ? type : r.detail_purpose, - 용도: (type !== '개인PC' && !r.detail_purpose) ? type : r.detail_purpose, - 사용자: r.purpose, - 상세: r.details, - 현사용조직: r.current_org, - 이전사용조직: r.prev_org, - 위치: r.location, - 담당자_정: r.manager_main, - 담당자_부: r.manager_sub, - IP주소: r.ip_address, - 원격접속: r.remote_tool, - 서버ID: r.server_id, - 서버PW: r.server_pw, - 모델명: r.model_name, - 메인보드: r.mainboard, - OS: r.os, - CPU: r.cpu, - RAM: r.ram, - GPU: r.gpu, - SSD1: r.storage1, - SSD2: r.storage2, - SSD3: r.storage3, - 모니터링: r.monitoring, - 금액: r.price, - 납품업체: r.vendor, - 비고: r.remarks, - 보관위치: r.storage_location, - 현재상태: r.status - }; +const handleError = (res, err, context, isGet = false) => { + console.error(`❌ [${context}] Error:`, err.message); + if (isGet) res.json([]); + else res.status(500).json({ error: err.message }); }; -// --- API 라우트 정의 --- +// --- Mapping Definitions --- + +const HW_SELECT_FIELDS = ` + id, + asset_type AS \`type\`, + corp AS \`법인\`, + asset_code AS \`자산코드\`, + purchase_date AS \`구매일\`, + user_name AS \`사용자\`, + dept AS \`현사용조직\`, + prev_org AS \`이전사용조직\`, + location AS \`위치\`, + manager_primary AS \`담당자_정\`, + manager_secondary AS \`담당자_부\`, + product_name AS \`모델명\`, + usage_category AS \`상세용도\`, + usage_description AS \`상세\`, + os AS \`OS\`, + cpu AS \`CPU\`, + gpu AS \`GPU\`, + ram AS \`RAM\`, + storage1 AS \`SSD1\`, + storage2 AS \`SSD2\`, + storage3 AS \`SSD3\`, + mainboard AS \`메인보드\`, + ip_address AS \`IP주소\`, + remote_tool AS \`원격접속\`, + server_id AS \`서버ID\`, + server_pw AS \`서버PW\`, + monitoring AS \`모니터링\`, + price AS \`금액\`, + vendor AS \`납품업체\`, + remarks AS \`비고\`, + asset_category AS \`category\` +`; + +const HW_REVERSE_MAP = { + 'id': 'id', + 'type': 'asset_type', + '법인': 'corp', + '자산코드': 'asset_code', + '구매일': 'purchase_date', + '구매연월': 'purchase_date', + '사용자': 'user_name', + '현사용조직': 'dept', + '이전사용조직': 'prev_org', + '위치': 'location', + '담당자_정': 'manager_primary', + '담당자_부': 'manager_secondary', + '모델명': 'product_name', + '상세용도': 'usage_category', + '상세': 'usage_description', + 'OS': 'os', + 'CPU': 'cpu', + 'GPU': 'gpu', + 'RAM': 'ram', + 'SSD1': 'storage1', + 'SSD2': 'storage2', + 'SSD3': 'storage3', + '메인보드': 'mainboard', + 'IP주소': 'ip_address', + '원격접속': 'remote_tool', + '서버ID': 'server_id', + '서버PW': 'server_pw', + '모니터링': 'monitoring', + '금액': 'price', + '납품업체': 'vendor', + '비고': 'remarks', + 'category': 'asset_category' +}; + +const mapObject = (obj, mapping) => { + const mapped = {}; + Object.entries(obj).forEach(([key, val]) => { + const dbKey = mapping[key] || key; + mapped[dbKey] = val; + }); + return mapped; +}; + +// --- GET Routes --- -// PC API app.get('/api/pc', async (req, res) => { try { - const [rows] = await pool.query('SELECT * FROM pc_assets'); - console.log('🔍 DB Raw Rows (PC):', rows.length, 'items found.'); - if (rows.length > 0) console.log('🔍 First row sample:', rows[0]); - res.json(rows.map(r => mapHardware(r, '개인PC'))); - } catch (err) { - console.error('❌ DB Query Error (PC):', err.message); - res.status(500).json({ error: err.message }); - } + const [rows] = await pool.query(`SELECT ${HW_SELECT_FIELDS} FROM asset_hardware WHERE asset_category = "개인PC" OR asset_code LIKE "PC%"`); + console.log(`📡 [GET /api/pc] Returning ${rows.length} rows`); + res.json(rows.map(r => ({ ...r, type: '개인PC', 구매연월: r.구매일 }))); + } catch (err) { handleError(res, err, 'GET /api/pc', true); } }); -app.post('/api/pc/batch', async (req, res) => { - try { - const result = await batchSave('pc_assets', req.body, (assets) => ({ - sql: hardwareInsertSQL('pc_assets'), - values: assets.map(getHardwareValues) - })); - res.json(result); - } catch (err) { res.status(500).json({ error: err.message }); } -}); - -// 서버 API app.get('/api/server', async (req, res) => { try { - const [rows] = await pool.query('SELECT * FROM server_assets'); - res.json(rows.map(r => mapHardware(r, '서버'))); - } catch (err) { res.status(500).json({ error: err.message }); } + const [rows] = await pool.query(`SELECT ${HW_SELECT_FIELDS} FROM asset_hardware WHERE asset_category = "서버" OR asset_code LIKE "SVR%"`); + console.log(`📡 [GET /api/server] Returning ${rows.length} rows`); + res.json(rows.map(r => ({ ...r, type: '서버', 구매연월: r.구매일 }))); + } catch (err) { handleError(res, err, 'GET /api/server', true); } }); -app.post('/api/server/batch', async (req, res) => { - try { - const result = await batchSave('server_assets', req.body, (assets) => ({ - sql: hardwareInsertSQL('server_assets'), - values: assets.map(getHardwareValues) - })); - res.json(result); - } catch (err) { res.status(500).json({ error: err.message }); } -}); - -// 스토리지 API app.get('/api/storage', async (req, res) => { try { - const [rows] = await pool.query('SELECT * FROM storage_assets'); - res.json(rows.map(r => mapHardware(r, '스토리지'))); - } catch (err) { res.status(500).json({ error: err.message }); } + const [rows] = await pool.query(`SELECT ${HW_SELECT_FIELDS} FROM asset_hardware WHERE asset_category = "스토리지" OR asset_code LIKE "STO%"`); + console.log(`📡 [GET /api/storage] Returning ${rows.length} rows`); + res.json(rows.map(r => ({ ...r, type: '스토리지', 구매연월: r.구매일 }))); + } catch (err) { handleError(res, err, 'GET /api/storage', true); } }); -app.post('/api/storage/batch', async (req, res) => { - try { - const result = await batchSave('storage_assets', req.body, (assets) => ({ - sql: hardwareInsertSQL('storage_assets'), - values: assets.map(getHardwareValues) - })); - res.json(result); - } catch (err) { res.status(500).json({ error: err.message }); } -}); - -// 전산비품 API app.get('/api/equip', async (req, res) => { try { - const [rows] = await pool.query('SELECT * FROM equip_assets'); - res.json(rows.map(r => mapHardware(r, '전산비품'))); - } catch (err) { res.status(500).json({ error: err.message }); } + const [rows] = await pool.query(`SELECT ${HW_SELECT_FIELDS} FROM asset_hardware WHERE asset_category = "전산비품" OR asset_code LIKE "EQP%"`); + console.log(`📡 [GET /api/equip] Returning ${rows.length} rows`); + res.json(rows.map(r => ({ ...r, type: '전산비품', 구매연월: r.구매일 }))); + } catch (err) { handleError(res, err, 'GET /api/equip', true); } }); -app.post('/api/equip/batch', async (req, res) => { - try { - const result = await batchSave('equip_assets', req.body, (assets) => ({ - sql: hardwareInsertSQL('equip_assets'), - values: assets.map(getHardwareValues) - })); - res.json(result); - } catch (err) { res.status(500).json({ error: err.message }); } -}); - -// 모바일 API app.get('/api/mobile', async (req, res) => { try { - const [rows] = await pool.query('SELECT * FROM mobile_assets'); - res.json(rows.map(r => mapHardware(r, '모바일기기'))); - } catch (err) { res.status(500).json({ error: err.message }); } + const [rows] = await pool.query(`SELECT ${HW_SELECT_FIELDS} FROM asset_hardware WHERE asset_category = "모바일기기" OR asset_category LIKE "%모바일%" OR asset_code LIKE "MOB%"`); + console.log(`📡 [GET /api/mobile] Returning ${rows.length} rows`); + res.json(rows.map(r => ({ ...r, type: '모바일기기', 구매연월: r.구매일 }))); + } catch (err) { handleError(res, err, 'GET /api/mobile', true); } }); -app.post('/api/mobile/batch', async (req, res) => { +app.get('/api/asset/software/subscription', async (req, res) => { try { - const result = await batchSave('mobile_assets', req.body, (assets) => ({ - sql: hardwareInsertSQL('mobile_assets'), - values: assets.map(getHardwareValues) - })); - res.json(result); - } catch (err) { res.status(500).json({ error: err.message }); } + const [rows] = await pool.query(` + SELECT + id, category AS \`분야\`, corp AS \`법인\`, dept AS \`부서\`, product_name AS \`제품명\`, + quantity AS \`수량\`, price AS \`금액\`, purchase_date AS \`구매일\`, start_date AS \`시작일\`, + expiry_date AS \`만료일\`, vendor AS \`납품업체\`, remarks AS \`비고\`, license_type AS \`라이선스유형\`, account_name AS \`계정명\` + FROM asset_software_subscription + `); + console.log(`📡 [GET /api/asset/software/subscription] Returning ${rows.length} rows`); + res.json(rows.map(r => ({ ...r, type: '구독SW' }))); + } catch (err) { handleError(res, err, 'GET /api/asset/software/subscription', true); } }); -// 구독 SW API -app.get('/api/sw/sub', async (req, res) => { +app.get('/api/asset/software/perpetual', async (req, res) => { try { - const [rows] = await pool.query('SELECT * FROM sw_sub_assets'); - res.json(rows.map(r => ({ - id: r.id, type: '구독SW', 법인: r.corp, - 분야: r.category, 부서: r.dept, 제품명: r.product_name, - 라이선스유형: r.license_type, 수량: r.quantity, 금액: r.price, - 구매일: r.purchase_date, 시작일: r.start_date, 만료일: r.expiry_date, - 납품업체: r.vendor, 비고: r.remarks - }))); - } catch (err) { res.status(500).json({ error: err.message }); } + const [rows] = await pool.query(` + SELECT + id, category AS \`분야\`, corp AS \`법인\`, dept AS \`부서\`, product_name AS \`제품명\`, + quantity AS \`수량\`, price AS \`금액\`, purchase_date AS \`구매일\`, start_date AS \`시작일\`, + expiry_date AS \`만료일\`, vendor AS \`납품업체\`, remarks AS \`비고\`, license_key AS \`라이선스키\`, account_name AS \`계정명\` + FROM asset_software_perpetual + `); + console.log(`📡 [GET /api/asset/software/perpetual] Returning ${rows.length} rows`); + res.json(rows.map(r => ({ ...r, type: '영구SW' }))); + } catch (err) { handleError(res, err, 'GET /api/asset/software/perpetual', true); } }); -app.post('/api/sw/sub/batch', async (req, res) => { +app.get('/api/asset/cloud', async (req, res) => { try { - const result = await batchSave('sw_sub_assets', req.body, (assets) => ({ - sql: `INSERT INTO sw_sub_assets (id, corp, category, dept, product_name, license_type, quantity, price, purchase_date, start_date, expiry_date, vendor, remarks) VALUES ?`, - values: assets.map(a => [ - a.id, a.법인||'', a.분야||'', a.부서||'', a.제품명||'', - a.라이선스유형||'', a.수량||0, a.금액||'', a.구매일||'', a.시작일||'', a.만료일||'', a.납품업체||'', a.비고||'' - ]) - })); - res.json(result); - } catch (err) { res.status(500).json({ error: err.message }); } + const [rows] = await pool.query(` + SELECT + id, platform_name AS \`플랫폼명\`, corp AS \`법인\`, dept AS \`부서\`, product_name AS \`제품명\`, + account_name AS \`계정명\`, pay_method AS \`결제수단\`, pay_day AS \`결제일\`, card_num AS \`연결카드번호\`, + monthly_cost AS \`당월청구액\`, remarks AS \`비고\` + FROM asset_cloud + `); + console.log(`📡 [GET /api/asset/cloud] Returning ${rows.length} rows`); + res.json(rows.map(r => ({ ...r, type: '클라우드' }))); + } catch (err) { handleError(res, err, 'GET /api/asset/cloud', true); } }); -// 영구 SW API -app.get('/api/sw/perm', async (req, res) => { +app.get('/api/asset/domain', async (req, res) => { try { - const [rows] = await pool.query('SELECT * FROM sw_perm_assets'); - res.json(rows.map(r => ({ - id: r.id, type: '영구SW', 법인: r.corp, - 분야: r.category, 부서: r.dept, 제품명: r.product_name, - 라이선스키: r.license_key, 수량: r.quantity, 금액: r.price, - 구매일: r.purchase_date, 시작일: r.start_date, 만료일: r.expiry_date, - 납품업체: r.vendor, 비고: r.remarks - }))); - } catch (err) { res.status(500).json({ error: err.message }); } + const [rows] = await pool.query(`SELECT * FROM asset_domain`); + res.json(rows); + } catch (err) { handleError(res, err, 'GET /api/asset/domain', true); } }); -app.post('/api/sw/perm/batch', async (req, res) => { +app.get('/api/asset/software/assignment', async (req, res) => { try { - console.log('📦 Permanent SW Batch Save Request:', req.body.length, 'items'); - if (req.body.length > 0) console.log('Sample:', req.body[0]); - const result = await batchSave('sw_perm_assets', req.body, (assets) => ({ - sql: `INSERT INTO sw_perm_assets (id, corp, category, dept, product_name, license_key, quantity, price, purchase_date, start_date, expiry_date, vendor, remarks) VALUES ?`, - values: assets.map(a => [ - a.id, a.법인||'', a.분야||'', a.부서||'', a.제품명||'', - a.라이선스키||'', a.수량||0, a.금액||'', a.구매일||'', a.시작일||'', a.만료일||'', a.납품업체||'', a.비고||'' - ]) - })); - res.json(result); - } catch (err) { res.status(500).json({ error: err.message }); } + const [rows] = await pool.query(` + SELECT + id, sw_id, corp AS \`법인\`, dept AS \`부서\`, position AS \`직위\`, user_name AS \`이름\`, usage_period AS \`사용기간\`, doc_name AS \`신청서명\` + FROM asset_software_assignment + `); + res.json(rows); + } catch (err) { handleError(res, err, 'GET /api/asset/software/assignment', true); } }); -// 클라우드 API -app.get('/api/cloud', async (req, res) => { +app.get('/api/asset/history', async (req, res) => { try { - const [rows] = await pool.query('SELECT * FROM cloud_assets'); - res.json(rows.map(r => ({ - id: r.id, type: '클라우드', 플랫폼명: r.platform_name, 법인: r.corp, 부서: r.dept, - 제품명: r.product_name, 계정명: r.account_name, 결제수단: r.pay_method, - 결제일: r.pay_day, 연결카드번호: r.card_num, 당월청구액: r.monthly_fee, 비고: r.remarks - }))); - } catch (err) { res.status(500).json({ error: err.message }); } + const [rows] = await pool.query(` + SELECT + id, asset_id AS assetId, log_date AS \`date\`, log_user AS \`user\`, details, cost + FROM asset_history + `); + res.json(rows); + } catch (err) { handleError(res, err, 'GET /api/asset/history', true); } }); -app.post('/api/cloud/batch', async (req, res) => { - try { - const result = await batchSave('cloud_assets', req.body, (assets) => ({ - sql: `INSERT INTO cloud_assets (id, platform_name, corp, dept, product_name, account_name, pay_method, pay_day, card_num, monthly_fee, remarks) VALUES ?`, - values: assets.map(a => [a.id, a.플랫폼명||'', a.법인||'', a.부서||'', a.제품명||'', a.계정명||'', a.결제수단||'', a.결제일||'', a.연결카드번호||'', a.당월청구액||'', a.비고||'']) - })); - res.json(result); - } catch (err) { res.status(500).json({ error: err.message }); } -}); +// --- POST Batch Routes --- -// 로그 API -app.get('/api/logs', async (req, res) => { +async function saveHwBatch(category, items, res) { + const connection = await pool.getConnection(); try { - const [rows] = await pool.query('SELECT * FROM asset_logs ORDER BY log_date DESC'); - res.json(rows.map(r => ({ - id: r.id, assetId: r.asset_id, date: r.log_date, user: r.log_user, details: r.details, cost: r.cost - }))); - } catch (err) { res.status(500).json({ error: err.message }); } -}); - -app.post('/api/logs/batch', async (req, res) => { - try { - const result = await batchSave('asset_logs', req.body, (logs) => ({ - sql: `INSERT INTO asset_logs (asset_id, log_date, log_user, details, cost) VALUES ?`, - values: logs.map(l => [l.assetId, l.date, l.user, l.details, l.cost || 0]) - })); - res.json(result); - } catch (err) { res.status(500).json({ error: err.message }); } -}); - -// SW 사용자 API -app.get('/api/sw-users', async (req, res) => { - try { - const [rows] = await pool.query('SELECT * FROM sw_users'); - const grouped = rows.reduce((acc, u) => { - if (!acc[u.sw_id]) acc[u.sw_id] = []; - acc[u.sw_id].push([u.corp, u.dept, u.position, u.user_name, u.usage_period, u.doc_name]); - return acc; - }, {}); - res.json(Object.keys(grouped).map(sw_id => ({ sw_id, userData: grouped[sw_id] }))); - } catch (err) { res.status(500).json({ error: err.message }); } -}); - -app.post('/api/sw-users/batch', async (req, res) => { - try { - const connection = await pool.getConnection(); await connection.beginTransaction(); - await connection.query('DELETE FROM sw_users'); - const allUsers = req.body; - if (allUsers.length > 0) { - const values = allUsers.flatMap(item => - (item.userData || []).map(u => [item.sw_id, u[0], u[1], u[2], u[3], u[4], u[5]]) - ); - if (values.length > 0) { - await connection.query('INSERT INTO sw_users (sw_id, corp, dept, position, user_name, usage_period, doc_name) VALUES ?', [values]); - } + // 1. 해당 카테고리 기존 데이터 삭제 + await connection.query('DELETE FROM asset_hardware WHERE asset_category = ?', [category]); + + // 2. 새 데이터 삽입 + for (const item of items) { + const dbRow = mapObject(item, HW_REVERSE_MAP); + dbRow.asset_category = category; + if (!dbRow.id) dbRow.id = Math.random().toString(36).substring(2, 9); + + // DB 컬럼에 없는 필드 제거 + const validColumns = ['id', 'asset_category', 'corp', 'asset_code', 'purchase_date', 'asset_type', 'usage_category', 'user_name', 'usage_description', 'dept', 'prev_org', 'location', 'manager_primary', 'manager_secondary', 'ip_address', 'remote_tool', 'server_id', 'server_pw', 'product_name', 'mainboard', 'os', 'cpu', 'ram', 'gpu', 'storage1', 'storage2', 'storage3', 'monitoring', 'price', 'vendor', 'remarks', 'storage_location', 'status']; + const filteredRow = {}; + validColumns.forEach(col => { if (dbRow[col] !== undefined) filteredRow[col] = dbRow[col]; }); + + await connection.query('INSERT INTO asset_hardware SET ?', [filteredRow]); } await connection.commit(); - connection.release(); - res.json({ success: true }); - } catch (err) { res.status(500).json({ error: err.message }); } -}); - -// 도메인 관리 API -app.get('/api/ops/domain', async (req, res) => { - try { - const [rows] = await pool.query('SELECT * FROM ops_domain_assets ORDER BY created_at DESC'); - res.json(rows); - } catch (err) { res.status(500).json({ error: err.message }); } -}); - -app.post('/api/ops/domain/batch', async (req, res) => { - try { - const result = await batchSave('ops_domain_assets', req.body, (assets) => ({ - sql: `INSERT INTO ops_domain_assets (id, type, corp, service_name, domain_name, start_date, expiry_date, price, manager_main, manager_sub, remarks) VALUES ?`, - values: assets.map(a => [a.id, a.type||'', a.corp||'', a.service_name||'', a.domain_name||'', a.start_date||'', a.expiry_date||'', a.price||'', a.manager_main||'', a.manager_sub||'', a.remarks||'']) - })); - res.json(result); - } catch (err) { res.status(500).json({ error: err.message }); } -}); - -// 자산번호 자동 생성 API -app.get('/api/generate-asset-code', async (req, res) => { - const { prefix } = req.query; - if (!prefix) return res.status(400).json({ error: 'Prefix is required' }); - - try { - const tables = ['pc_assets', 'server_assets', 'storage_assets', 'equip_assets', 'mobile_assets']; - let maxNum = 0; - - for (const table of tables) { - const [rows] = await pool.query( - `SELECT asset_code FROM ${table} WHERE asset_code LIKE ?`, - [`${prefix}%`] - ); - rows.forEach(r => { - const numPart = r.asset_code.replace(prefix, ''); - const num = parseInt(numPart); - if (!isNaN(num) && num > maxNum) maxNum = num; - }); - } - - const nextNum = (maxNum + 1).toString().padStart(4, '0'); - res.json({ nextCode: `${prefix}${nextNum}` }); + res.json({ success: true, count: items.length }); } catch (err) { - res.status(500).json({ error: err.message }); + await connection.rollback(); + handleError(res, err, `BATCH SAVE ${category}`); + } finally { + connection.release(); } +} + +app.post('/api/pc/batch', (req, res) => saveHwBatch('개인PC', req.body, res)); +app.post('/api/server/batch', (req, res) => saveHwBatch('서버', req.body, res)); +app.post('/api/storage/batch', (req, res) => saveHwBatch('스토리지', req.body, res)); +app.post('/api/equip/batch', (req, res) => saveHwBatch('전산비품', req.body, res)); +app.post('/api/mobile/batch', (req, res) => saveHwBatch('모바일기기', req.body, res)); + +app.post('/api/asset/software/subscription/batch', async (req, res) => { + const connection = await pool.getConnection(); + try { + await connection.beginTransaction(); + await connection.query('DELETE FROM asset_software_subscription'); + const mapping = { '분야': 'category', '법인': 'corp', '부서': 'dept', '제품명': 'product_name', '수량': 'quantity', '금액': 'price', '구매일': 'purchase_date', '시작일': 'start_date', '만료일': 'expiry_date', '납품업체': 'vendor', '비고': 'remarks', '라이선스유형': 'license_type', '계정명': 'account_name' }; + for (const item of req.body) { + const dbRow = mapObject(item, mapping); + if (!dbRow.id) dbRow.id = Math.random().toString(36).substring(2, 9); + const filteredRow = {}; + ['id', 'corp', 'category', 'dept', 'product_name', 'license_type', 'quantity', 'price', 'purchase_date', 'start_date', 'expiry_date', 'vendor', 'remarks', 'account_name'].forEach(c => { if (dbRow[c] !== undefined) filteredRow[c] = dbRow[c]; }); + await connection.query('INSERT INTO asset_software_subscription SET ?', [filteredRow]); + } + await connection.commit(); + res.json({ success: true }); + } catch (err) { await connection.rollback(); handleError(res, err, 'BATCH SW SUB'); } finally { connection.release(); } }); -// 초기화 및 서버 기동 -ensureTables().then(() => { - app.listen(PORT, () => { - console.log(`📡 ITAM Dedicated API Server running on http://localhost:${PORT}`); - }); -}).catch(err => { - console.error('❌ Failed to start server:', err); +app.post('/api/asset/software/perpetual/batch', async (req, res) => { + const connection = await pool.getConnection(); + try { + await connection.beginTransaction(); + await connection.query('DELETE FROM asset_software_perpetual'); + const mapping = { '분야': 'category', '법인': 'corp', '부서': 'dept', '제품명': 'product_name', '수량': 'quantity', '금액': 'price', '구매일': 'purchase_date', '시작일': 'start_date', '만료일': 'expiry_date', '납품업체': 'vendor', '비고': 'remarks', '라이선스키': 'license_key', '계정명': 'account_name' }; + for (const item of req.body) { + const dbRow = mapObject(item, mapping); + if (!dbRow.id) dbRow.id = Math.random().toString(36).substring(2, 9); + const filteredRow = {}; + ['id', 'corp', 'category', 'dept', 'product_name', 'license_key', 'quantity', 'price', 'purchase_date', 'start_date', 'expiry_date', 'vendor', 'remarks', 'account_name'].forEach(c => { if (dbRow[c] !== undefined) filteredRow[c] = dbRow[c]; }); + await connection.query('INSERT INTO asset_software_perpetual SET ?', [filteredRow]); + } + await connection.commit(); + res.json({ success: true }); + } catch (err) { await connection.rollback(); handleError(res, err, 'BATCH SW PERM'); } finally { connection.release(); } +}); + +app.post('/api/asset/cloud/batch', async (req, res) => { + const connection = await pool.getConnection(); + try { + await connection.beginTransaction(); + await connection.query('DELETE FROM asset_cloud'); + const mapping = { '플랫폼명': 'platform_name', '법인': 'corp', '부서': 'dept', '제품명': 'product_name', '계정명': 'account_name', '결제수단': 'pay_method', '결제일': 'pay_day', '연결카드번호': 'card_num', '당월청구액': 'monthly_cost', '비고': 'remarks' }; + for (const item of req.body) { + const dbRow = mapObject(item, mapping); + if (!dbRow.id) dbRow.id = Math.random().toString(36).substring(2, 9); + const filteredRow = {}; + ['id', 'platform_name', 'corp', 'dept', 'product_name', 'account_name', 'pay_method', 'pay_day', 'card_num', 'monthly_cost', 'remarks'].forEach(c => { if (dbRow[c] !== undefined) filteredRow[c] = dbRow[c]; }); + await connection.query('INSERT INTO asset_cloud SET ?', [filteredRow]); + } + await connection.commit(); + res.json({ success: true }); + } catch (err) { await connection.rollback(); handleError(res, err, 'BATCH CLOUD'); } finally { connection.release(); } +}); + +app.post('/api/asset/software/assignment/batch', async (req, res) => { + const connection = await pool.getConnection(); + try { + await connection.beginTransaction(); + await connection.query('DELETE FROM asset_software_assignment'); + const mapping = { '법인': 'corp', '부서': 'dept', '직위': 'position', '이름': 'user_name', '사용기간': 'usage_period', '신청서명': 'doc_name' }; + for (const item of req.body) { + const dbRow = mapObject(item, mapping); + delete dbRow.id; // Auto-increment + await connection.query('INSERT INTO asset_software_assignment SET ?', [dbRow]); + } + await connection.commit(); + res.json({ success: true }); + } catch (err) { await connection.rollback(); handleError(res, err, 'BATCH SW ASSIGN'); } finally { connection.release(); } +}); + +app.post('/api/asset/history/batch', async (req, res) => { + const connection = await pool.getConnection(); + try { + await connection.beginTransaction(); + await connection.query('DELETE FROM asset_history'); + for (const item of req.body) { + const dbRow = { + asset_id: item.assetId, + log_date: item.date, + log_user: item.user, + details: item.details, + cost: item.cost || 0 + }; + await connection.query('INSERT INTO asset_history SET ?', [dbRow]); + } + await connection.commit(); + res.json({ success: true }); + } catch (err) { await connection.rollback(); handleError(res, err, 'BATCH HISTORY'); } finally { connection.release(); } +}); + +app.get('/api/generate-asset-code', async (req, res) => { + try { + const { prefix } = req.query; + if (!prefix) return res.status(400).json({ error: 'Prefix is required' }); + const [rows] = await pool.query('SELECT asset_code FROM asset_hardware WHERE asset_code LIKE ? ORDER BY asset_code DESC LIMIT 1', [`${prefix}%`]); + let nextNum = 1; + if (rows.length > 0) { + const lastCode = rows[0].asset_code; + const lastNum = parseInt(lastCode.split('-').pop() || '0'); + nextNum = lastNum + 1; + } + res.json({ nextCode: `${prefix}${String(nextNum).padStart(3, '0')}` }); + } catch (err) { handleError(res, err, 'GENERATE CODE'); } +}); + +app.listen(3000, '0.0.0.0', () => { + console.log('📡 ITAM BACKEND SERVER RUNNING ON PORT 3000 (Safe Korean Mapping)'); }); diff --git a/src/core/state.ts b/src/core/state.ts index f51f87a..96d1240 100644 --- a/src/core/state.ts +++ b/src/core/state.ts @@ -48,7 +48,7 @@ export const state: AppState = { }; /** - * 전용 API 엔드포인트들로부터 데이터 로드 + * 전용 API 엔드포인트들로부터 데이터 로드 (Modernized Paths) */ export async function loadMasterDataFromDB() { try { @@ -58,12 +58,12 @@ export async function loadMasterDataFromDB() { { key: 'storage', url: `http://${location.hostname}:3000/api/storage` }, { key: 'equip', url: `http://${location.hostname}:3000/api/equip` }, { key: 'mobile', url: `http://${location.hostname}:3000/api/mobile` }, - { key: 'subSw', url: `http://${location.hostname}:3000/api/sw/sub` }, - { key: 'permSw', url: `http://${location.hostname}:3000/api/sw/perm` }, - { key: 'cloud', url: `http://${location.hostname}:3000/api/cloud` }, - { key: 'domain', url: `http://${location.hostname}:3000/api/ops/domain` }, - { key: 'swUsers', url: `http://${location.hostname}:3000/api/sw-users` }, - { key: 'logs', url: `http://${location.hostname}:3000/api/logs` } + { key: 'subSw', url: `http://${location.hostname}:3000/api/asset/software/subscription` }, + { key: 'permSw', url: `http://${location.hostname}:3000/api/asset/software/perpetual` }, + { key: 'cloud', url: `http://${location.hostname}:3000/api/asset/cloud` }, + { key: 'domain', url: `http://${location.hostname}:3000/api/asset/domain` }, + { key: 'swUsers', url: `http://${location.hostname}:3000/api/asset/software/assignment` }, + { key: 'logs', url: `http://${location.hostname}:3000/api/asset/history` } ]; const results = await Promise.all(endpoints.map(e => fetch(e.url))); @@ -79,13 +79,15 @@ export async function loadMasterDataFromDB() { if (results[i].ok) { const data = await results[i].json(); const key = endpoints[i].key; + console.log(`📡 Loaded ${key}: ${Array.isArray(data) ? data.length : 'not an array'} items`); if (['pc', 'server', 'storage', 'equip', 'mobile'].includes(key)) { - // 하드웨어 데이터는 자동 재분류 로직 통과 - (data as HardwareAsset[]).forEach(asset => saveHardwareAsset(asset)); + (Array.isArray(data) ? data : []).forEach(asset => saveHardwareAsset(asset)); } else { - (state.masterData as any)[key] = data || []; + (state.masterData as any)[key] = Array.isArray(data) ? data : []; } + } else { + console.error(`❌ Failed to load ${endpoints[i].key}: ${results[i].status} ${results[i].statusText}`); } } @@ -194,25 +196,37 @@ export function deleteHardwareAsset(assetId: string) { } /** - * 소프트웨어 자산 저장 (API 연동) + * 소프트웨어 자산 저장 (API 연동 - 개선된 일괄 저장 경로) */ export async function saveSoftwareAsset(asset: SoftwareAsset) { try { - const response = await fetch(`http://${location.hostname}:3000/api/software/save`, { + const type = asset.type; + let url = ''; + let categoryKey: keyof MasterAssetData = 'subSw'; + + if (type === '구독SW') { + url = `http://${location.hostname}:3000/api/asset/software/subscription/batch`; + categoryKey = 'subSw'; + } else if (type === '영구SW') { + url = `http://${location.hostname}:3000/api/asset/software/perpetual/batch`; + categoryKey = 'permSw'; + } else { + url = `http://${location.hostname}:3000/api/asset/cloud/batch`; + categoryKey = 'cloud'; + } + + const arr = state.masterData[categoryKey] as SoftwareAsset[]; + const idx = arr.findIndex(a => a.id === asset.id); + if (idx > -1) arr[idx] = asset; + else arr.push(asset); + + const response = await fetch(url, { method: 'POST', headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify(asset) + body: JSON.stringify(arr) }); if (response.ok) { - // 로컬 상태 업데이트 - const key = asset.type === '구독SW' ? 'subSw' : (asset.type === '영구SW' ? 'permSw' : 'cloud'); - const arr = state.masterData[key] as SoftwareAsset[]; - const idx = arr.findIndex(a => a.id === asset.id); - if (idx > -1) arr[idx] = asset; - else arr.push(asset); - - // 통합 sw 배열 동기화 state.masterData.sw = [...state.masterData.subSw, ...state.masterData.permSw, ...state.masterData.cloud]; return true; } @@ -223,21 +237,24 @@ export async function saveSoftwareAsset(asset: SoftwareAsset) { } /** - * 소프트웨어 자산 삭제 (API 연동) + * 소프트웨어 자산 삭제 (API 연동 - 개선된 일괄 저장 경로) */ export async function deleteSoftwareAsset(type: string, id: string) { try { - const response = await fetch(`http://${location.hostname}:3000/api/asset/${type}/${id}`, { - method: 'DELETE' + const key = type === '구독SW' ? 'subSw' : (type === '영구SW' ? 'permSw' : 'cloud'); + const path = type === '구독SW' ? 'subscription' : (type === '영구SW' ? 'perpetual' : 'cloud'); + + const arr = state.masterData[key] as SoftwareAsset[]; + const filtered = arr.filter(a => a.id !== id); + + const response = await fetch(`http://${location.hostname}:3000/api/asset/software/${path}/batch`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(filtered) }); if (response.ok) { - const key = type === '구독SW' ? 'subSw' : (type === '영구SW' ? 'permSw' : 'cloud'); - const arr = state.masterData[key] as SoftwareAsset[]; - const idx = arr.findIndex(a => a.id === id); - if (idx > -1) arr.splice(idx, 1); - - // 통합 sw 배열 동기화 + (state.masterData as any)[key] = filtered; state.masterData.sw = [...state.masterData.subSw, ...state.masterData.permSw, ...state.masterData.cloud]; return true; } diff --git a/src/core/utils.ts b/src/core/utils.ts index 4e593c9..d5367b1 100644 --- a/src/core/utils.ts +++ b/src/core/utils.ts @@ -30,7 +30,13 @@ export function formatInline(value: any): string { * 날짜 문자열 포맷팅 (YYYY.MM.DD -> YYYY-MM-DD) */ export function normalizeDate(dateStr: string): string { - return (dateStr || '').replace(/\./g, '-').trim(); + if (!dateStr) return ''; + let str = String(dateStr).replace(/\./g, '-').trim(); + // YYYYMM 형식 처리 (6자리 숫자) + if (/^\d{6}$/.test(str)) { + return `${str.substring(0, 4)}-${str.substring(4, 6)}`; + } + return str; } /** diff --git a/src/main.ts b/src/main.ts index e4eb5ae..120e578 100644 --- a/src/main.ts +++ b/src/main.ts @@ -37,11 +37,11 @@ const saveServerToDB = () => apiBatchSave(`http://${location.hostname}:3000/api/ const saveStorageToDB = () => apiBatchSave(`http://${location.hostname}:3000/api/storage/batch`, state.masterData.storage, '스토리지'); const saveEquipToDB = () => apiBatchSave(`http://${location.hostname}:3000/api/equip/batch`, state.masterData.equip, '전산비품'); const saveMobileToDB = () => apiBatchSave(`http://${location.hostname}:3000/api/mobile/batch`, state.masterData.mobile, '모바일기기'); -const saveSubSwToDB = () => apiBatchSave(`http://${location.hostname}:3000/api/sw/sub/batch`, state.masterData.subSw, '구독SW'); -const savePermSwToDB = () => apiBatchSave(`http://${location.hostname}:3000/api/sw/perm/batch`, state.masterData.permSw, '영구SW'); -const saveCloudToDB = () => apiBatchSave(`http://${location.hostname}:3000/api/cloud/batch`, state.masterData.cloud, '클라우드'); -const saveSwUsersToDB = () => apiBatchSave(`http://${location.hostname}:3000/api/sw-users/batch`, state.masterData.swUsers, 'SW사용자'); -const saveLogsToDB = () => apiBatchSave(`http://${location.hostname}:3000/api/logs/batch`, state.masterData.logs, '자산 로그'); +const saveSubSwToDB = () => apiBatchSave(`http://${location.hostname}:3000/api/asset/software/subscription/batch`, state.masterData.subSw, '구독SW'); +const savePermSwToDB = () => apiBatchSave(`http://${location.hostname}:3000/api/asset/software/perpetual/batch`, state.masterData.permSw, '영구SW'); +const saveCloudToDB = () => apiBatchSave(`http://${location.hostname}:3000/api/asset/cloud/batch`, state.masterData.cloud, '클라우드'); +const saveSwUsersToDB = () => apiBatchSave(`http://${location.hostname}:3000/api/asset/software/assignment/batch`, state.masterData.swUsers, 'SW사용자'); +const saveLogsToDB = () => apiBatchSave(`http://${location.hostname}:3000/api/asset/history/batch`, state.masterData.logs, '자산 로그'); // 화면 갱신 통합 핸들러 (대시보드 vs 리스트) function refreshView() { diff --git a/src/views/Dashboard/SwDashboard.ts b/src/views/Dashboard/SwDashboard.ts index 4146dfa..20bfa15 100644 --- a/src/views/Dashboard/SwDashboard.ts +++ b/src/views/Dashboard/SwDashboard.ts @@ -25,8 +25,7 @@ export function renderSwDashboard(container: HTMLElement) { const allSw = [...state.masterData.subSw, ...state.masterData.permSw]; allSw.forEach(sw => { - const userMapping = state.masterData.swUsers.find(u => u.sw_id === sw.id); - const assigned = userMapping ? (userMapping.userData ? userMapping.userData.length : 0) : 0; + const assigned = state.masterData.swUsers.filter(u => u.sw_id === sw.id).length; const qty = typeof sw.수량 === 'number' ? sw.수량 : parseInt(sw.수량||'0', 10); const priceStr = sw.금액 ? String(sw.금액).replace(/,/g, '') : '0'; const price = parseInt(priceStr, 10) || 0; diff --git a/src/views/List/SwListView.ts b/src/views/List/SwListView.ts index f25d2d1..945ce54 100644 --- a/src/views/List/SwListView.ts +++ b/src/views/List/SwListView.ts @@ -95,8 +95,7 @@ export function renderSwList(container: HTMLElement) { } filtered.forEach((asset, idx) => { - const mapping = state.masterData.swUsers.find(u => u.sw_id === asset.id); - const assigned = mapping ? (mapping.userData || []).length : 0; + const assigned = state.masterData.swUsers.filter(u => u.sw_id === asset.id).length; const qty = typeof asset.수량 === 'number' ? asset.수량 : parseInt(asset.수량||'0', 10); const avail = qty - assigned;