diff --git a/.env b/.env new file mode 100644 index 0000000..84f1df7 --- /dev/null +++ b/.env @@ -0,0 +1,6 @@ +DB_HOST=127.0.0.1 +DB_PORT=3306 +DB_USER=itam_admin +DB_PASS=itam1234 +DB_NAME=itam +PORT=3000 \ No newline at end of file diff --git a/PLAN_ASSET_HISTORY.md b/PLAN_ASSET_HISTORY.md new file mode 100644 index 0000000..a9f1065 --- /dev/null +++ b/PLAN_ASSET_HISTORY.md @@ -0,0 +1,60 @@ +# 자산 이력 누적 관리 시스템 (Cumulative Asset History System) 구현 계획 + +본 문서는 자산의 라이프사이클(조직, 사용자, 용도, 상태 변동)을 체계적으로 추적하고 누적 관리하기 위한 기술적 설계 및 단계별 구현 계획을 담고 있습니다. + +## 1. 목적 +- 자산 정보 수정 시 중요 변경 사항을 자동으로 감지하여 이력(Log)화 +- 과거부터 현재까지의 변동 사항을 타임라인 형태로 시각화하여 자산 흐름 파악 +- 데이터 정합성을 위해 서버 측에서 변경 전/후 스냅샷 비교 방식 채택 + +## 2. 관리 대상 이력 (Watch Fields) +다음 항목의 변경이 발생할 경우 이력을 자동 생성합니다. +1. **조직 변동**: `current_dept` (현 사용조직) ↔ `previous_dept` 업데이트 포함 +2. **사용자 변동**: `user_current` (현 사용자) ↔ `previous_user` 업데이트 포함 +3. **용도 변경**: `asset_type`, `current_role` (예: 개인PC -> 공용PC) +4. **상태 변경**: `hw_status` (예: 운영 -> 수리, 재고 -> 폐기 등) + +## 3. 기술 설계 (Technical Design) + +### A. 데이터베이스 (DB) +- **대상 테이블**: `asset_history` +- **컬럼 구조 활용 및 보완**: + - `asset_id`: 대상 자산 식별자 + - `event_type`: 변경 유형 (DEPT_CHANGE, USER_CHANGE, ROLE_CHANGE, STATUS_CHANGE) + - `details`: "상태 변경: 운영 -> 수리" 와 같이 읽기 쉬운 문자열 저장 + - `cost`: 관련 비용 발생 시 기록 (수리비 등) + - `log_user`: 변경을 수행한 작업자 + - `log_date`: 변경 발생 일시 + +### B. 백엔드 (Server-side Logic) +- **위치**: `server.js` 의 `POST /api/asset/:category/save` 엔드포인트 +- **동작 흐름**: + 1. **Snapshot**: 인서트/업데이트 수행 전, 기존 DB의 데이터를 `SELECT`하여 메모리에 저장. + 2. **Comparison**: 요청된 신규 데이터와 기존 데이터를 필드별로 대조. + 3. **Auto-logging**: 변경점이 발견되면 `asset_history` 테이블에 즉시 인서트. + 4. **Transaction**: 모든 로그 생성이 자산 저장과 하나의 트랜잭션으로 묶여야 함. + +### C. 프론트엔드 (UI/UX) +- **위치**: `HWModal.ts` 우측 `modal-history-area` +- **개선 사항**: + - `renderHistory()` 함수를 고도화하여 이벤트 타입별 아이콘/컬러 적용. + - "이전 값 ➔ 이후 값" 형태의 직관적인 레이아웃 도입. + - 스크롤을 통한 무제한 누적 이력 조회 지원. + +## 4. 단계별 구현 로직 + +### 1단계: 서버 로직 고도화 +- `server.js`에 비교 함수(`compareAndLog`) 구현. +- 각 자산 카테고리별 저장 로직에 비교 로직 삽입. + +### 2단계: DB 데이터 마이그레이션 (필요시) +- 기존 자산의 `current_dept` 등을 `previous_dept`로 밀어내는 로직 점검. + +### 3단계: UI 타임라인 렌더링 개선 +- `modal.css`에 이력 전용 스타일(이벤트 뱃지 등) 추가. +- `HWModal.ts`에서 최신 로그를 실시간으로 다시 불러오는 로직 확인. + +## 5. 검증 계획 +- **자동 감지 테스트**: 상태 변경 후 저장 시 우측 이력에 즉시 한 줄이 추가되는지 확인. +- **다중 변경 테스트**: 조직과 사용자를 동시에 변경했을 때 두 개의 로그가 생성되는지 확인. +- **데이터 무결성**: 수정을 취소하거나 저장 실패 시 로그가 남지 않는지(Transaction) 확인. diff --git a/backup_db.js b/backup_db.js new file mode 100644 index 0000000..2687b14 --- /dev/null +++ b/backup_db.js @@ -0,0 +1,59 @@ +import mysql from 'mysql2/promise'; +import dotenv from 'dotenv'; +import * as xlsx from 'xlsx'; +import fs from 'fs'; + +dotenv.config(); + +const { DB_HOST, DB_USER, DB_PASS, DB_NAME, DB_PORT } = process.env; + +async function backup() { + const connection = await mysql.createConnection({ + host: DB_HOST, + user: DB_USER, + password: DB_PASS, + database: DB_NAME, + port: parseInt(DB_PORT || '3306') + }); + + console.log('🚀 Starting Database Backup Process...'); + + const tables = [ + 'asset_pc', 'asset_server', 'asset_storage', 'asset_remote', + 'asset_equipment', 'asset_office_supplies', 'asset_survey', 'asset_vip' + ]; + + const wb = xlsx.utils.book_new(); + + for (const table of tables) { + try { + // 1. Create table backup + await connection.query(`DROP TABLE IF EXISTS ${table}_backup`); + await connection.query(`CREATE TABLE ${table}_backup AS SELECT * FROM ${table}`); + console.log(`✅ Table backup created: ${table} -> ${table}_backup`); + + // 2. Fetch data for Excel + const [rows] = await connection.query(`SELECT * FROM ${table}`); + if (rows.length > 0) { + const ws = xlsx.utils.json_to_sheet(rows); + // Sheet names max length is 31 chars + const sheetName = table.substring(0, 31); + xlsx.utils.book_append_sheet(wb, ws, sheetName); + } + } catch (e) { + console.warn(`⚠️ Skipped ${table}: ${e.message}`); + } + } + + // 3. Write Excel file + const fileName = 'backupDB_20260608.xlsx'; + xlsx.writeFile(wb, fileName); + console.log(`✅ Excel data exported successfully to ${fileName}`); + + await connection.end(); +} + +backup().catch(err => { + console.error('❌ Backup Failed:', err); + process.exit(1); +}); diff --git a/check_logs.js b/check_logs.js new file mode 100644 index 0000000..d14cdb7 --- /dev/null +++ b/check_logs.js @@ -0,0 +1,28 @@ +import mysql from 'mysql2/promise'; +import dotenv from 'dotenv'; + +dotenv.config(); + +const { DB_HOST, DB_USER, DB_PASS, DB_NAME, DB_PORT } = process.env; + +async function checkRecentLogs() { + const connection = await mysql.createConnection({ + host: DB_HOST, + user: DB_USER, + password: DB_PASS, + database: DB_NAME, + port: parseInt(DB_PORT || '3306') + }); + + console.log('--- Recent History Logs ---'); + const [rows] = await connection.query('SELECT * FROM asset_history ORDER BY created_at DESC LIMIT 5'); + console.log(JSON.stringify(rows, null, 2)); + + console.log('\n--- Recent Core Data (to check current_dept) ---'); + const [coreRows] = await connection.query('SELECT id, asset_code, current_dept, previous_dept FROM asset_core ORDER BY updated_at DESC LIMIT 5'); + console.log(JSON.stringify(coreRows, null, 2)); + + await connection.end(); +} + +checkRecentLogs().catch(console.error); diff --git a/check_network.js b/check_network.js new file mode 100644 index 0000000..85228d4 --- /dev/null +++ b/check_network.js @@ -0,0 +1,29 @@ +import mysql from 'mysql2/promise'; +import dotenv from 'dotenv'; + +dotenv.config(); + +const { DB_HOST, DB_USER, DB_PASS, DB_NAME, DB_PORT } = process.env; + +async function checkRemote() { + const connection = await mysql.createConnection({ + host: DB_HOST, + user: DB_USER, + password: DB_PASS, + database: DB_NAME, + port: parseInt(DB_PORT || '3306') + }); + + console.log('--- Checking asset_remote table ---'); + + const [columns] = await connection.query('DESCRIBE asset_remote'); + const cols = columns.map(c => c.Field); + console.log('Columns in asset_remote:', cols.join(', ')); + + const [count] = await connection.query('SELECT COUNT(*) as count FROM asset_remote WHERE remote_tool IS NOT NULL OR remote_id IS NOT NULL'); + console.log(`Rows with remote info (tool or id): ${count[0].count}`); + + await connection.end(); +} + +checkRemote().catch(console.error); diff --git a/drop_legacy.js b/drop_legacy.js new file mode 100644 index 0000000..c4b6ad5 --- /dev/null +++ b/drop_legacy.js @@ -0,0 +1,44 @@ +import mysql from 'mysql2/promise'; +import dotenv from 'dotenv'; + +dotenv.config(); + +const { DB_HOST, DB_USER, DB_PASS, DB_NAME, DB_PORT } = process.env; + +async function dropLegacyTables() { + const connection = await mysql.createConnection({ + host: DB_HOST, + user: DB_USER, + password: DB_PASS, + database: DB_NAME, + port: parseInt(DB_PORT || '3306') + }); + + console.log('🧹 Starting cleanup of obsolete legacy backup tables...'); + + const tablesToDrop = [ + 'asset_pc', 'asset_pc_backup', + 'asset_server', 'asset_server_backup', + 'asset_storage', 'asset_storage_backup', + 'asset_remote_backup', // IMPORTANT: DO NOT drop asset_remote! + 'asset_equipment', 'asset_equipment_backup', + 'asset_office_supplies', 'asset_office_supplies_backup', + 'asset_survey', 'asset_survey_backup', + 'asset_vip', 'asset_vip_backup', + 'asset_pc_parts' + ]; + + for (const table of tablesToDrop) { + try { + await connection.query(`DROP TABLE IF EXISTS ${table}`); + console.log(`✅ Dropped table: ${table}`); + } catch (err) { + console.warn(`⚠️ Failed to drop table ${table}: ${err.message}`); + } + } + + console.log('🎉 Cleanup complete. Database is now lean and mean.'); + await connection.end(); +} + +dropLegacyTables().catch(console.error); diff --git a/migrate_schema.js b/migrate_schema.js new file mode 100644 index 0000000..4cd295c --- /dev/null +++ b/migrate_schema.js @@ -0,0 +1,197 @@ +import mysql from 'mysql2/promise'; +import dotenv from 'dotenv'; + +dotenv.config(); + +const { DB_HOST, DB_USER, DB_PASS, DB_NAME, DB_PORT } = process.env; + +async function migrateSchema() { + const connection = await mysql.createConnection({ + host: DB_HOST, + user: DB_USER, + password: DB_PASS, + database: DB_NAME, + port: parseInt(DB_PORT || '3306') + }); + + console.log('🚀 Phase 1: Creating Normalized Tables & Migrating Data...'); + + try { + await connection.query('SET FOREIGN_KEY_CHECKS = 0'); + + // --- 1. Drop existing new tables if they exist --- + await connection.query('DROP TABLE IF EXISTS asset_core, asset_hardware, asset_location, asset_remote'); + + // --- 2. Create New Schema --- + await connection.query(` + CREATE TABLE asset_core ( + id VARCHAR(50) PRIMARY KEY, + asset_code VARCHAR(100) UNIQUE NOT NULL, + category VARCHAR(100), + asset_type VARCHAR(100), + asset_purpose VARCHAR(255), + service_type VARCHAR(50), + purchase_corp VARCHAR(100), + purchase_date VARCHAR(50), + purchase_amount VARCHAR(100), + purchase_vendor VARCHAR(255), + approval_document VARCHAR(255), + memo TEXT, + manager_primary VARCHAR(100), + manager_secondary VARCHAR(100), + current_dept VARCHAR(255), + previous_dept VARCHAR(255), + user_current VARCHAR(100), + previous_user VARCHAR(100), + emp_no VARCHAR(20), + user_position VARCHAR(50), + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP + ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; + `); + + await connection.query(` + CREATE TABLE asset_hardware ( + id INT AUTO_INCREMENT PRIMARY KEY, + asset_id VARCHAR(50) NOT NULL, + hw_status VARCHAR(50), + model_name VARCHAR(255), + mainboard VARCHAR(255), + os VARCHAR(100), + cpu VARCHAR(255), + ram VARCHAR(100), + gpu VARCHAR(100), + storage1 VARCHAR(255), + storage2 VARCHAR(255), + storage3 VARCHAR(255), + monitoring VARCHAR(100), + price VARCHAR(100), + volume VARCHAR(100), + monitor_inch VARCHAR(50), + serial_num VARCHAR(100), + FOREIGN KEY (asset_id) REFERENCES asset_core(id) ON DELETE CASCADE + ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; + `); + + await connection.query(` + CREATE TABLE asset_location ( + id INT AUTO_INCREMENT PRIMARY KEY, + asset_id VARCHAR(50) NOT NULL, + location VARCHAR(255), + location_detail VARCHAR(255), + location_photo VARCHAR(255), + loc_x VARCHAR(20), + loc_y VARCHAR(20), + is_active BOOLEAN DEFAULT TRUE, + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + FOREIGN KEY (asset_id) REFERENCES asset_core(id) ON DELETE CASCADE + ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; + `); + + await connection.query(` + CREATE TABLE asset_remote ( + id INT AUTO_INCREMENT PRIMARY KEY, + asset_id VARCHAR(50) NOT NULL, + ip_address VARCHAR(100), + mac_address VARCHAR(100), + remote_tool VARCHAR(100), + remote_id VARCHAR(100), + remote_pw VARCHAR(100), + is_active BOOLEAN DEFAULT TRUE, + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + FOREIGN KEY (asset_id) REFERENCES asset_core(id) ON DELETE CASCADE + ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; + `); + + await connection.query('SET FOREIGN_KEY_CHECKS = 1'); + console.log('✅ Normalized tables created.'); + + // --- 3. Migrate Data from Legacy Tables --- + const legacyTables = ['asset_pc', 'asset_server', 'asset_storage', 'asset_remote', 'asset_equipment', 'asset_office_supplies', 'asset_survey', 'asset_vip']; + + let totalMigrated = 0; + + for (const table of legacyTables) { + try { + const [rows] = await connection.query(`SELECT * FROM ${table}`); + + for (const row of rows) { + // 3.1 Insert into asset_core + await connection.query(` + INSERT IGNORE INTO asset_core ( + id, asset_code, category, asset_type, 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, created_at + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + `, [ + row.id, row.asset_code, row.category, row.asset_type, row.asset_purpose, row.service_type, + row.purchase_corp, row.purchase_date, row.purchase_amount, row.purchase_vendor, row.approval_document, + row.memo, row.manager_primary, row.manager_secondary, row.current_dept, row.previous_dept, + row.user_current, row.previous_user, row.emp_no, row.user_position, row.created_at + ]); + + // 3.2 Insert into asset_hardware (if hardware fields exist) + if (row.model_name || row.cpu || row.ram || row.hw_status) { + await connection.query(` + INSERT INTO asset_hardware ( + asset_id, hw_status, model_name, mainboard, os, cpu, ram, gpu, storage1, storage2, storage3, monitoring, price, volume, monitor_inch, serial_num + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + `, [ + row.id, row.hw_status, row.model_name, row.mainboard, row.os, row.cpu, row.ram, row.gpu, + row.ssd_1 || row.hdd_1, row.ssd_2 || row.hdd_2, row.hdd_3, row.monitoring, row.price, + row.volume, row.monitor_inch, row.serial_num + ]); + } + + // 3.3 Insert into asset_location (if location fields exist) + if (row.location || row.location_detail) { + await connection.query(` + INSERT INTO asset_location ( + asset_id, location, location_detail, location_photo, loc_x, loc_y + ) VALUES (?, ?, ?, ?, ?, ?) + `, [ + row.id, row.location, row.location_detail, row.location_photo, row.loc_x, row.loc_y + ]); + } + + // 3.4 Insert into asset_remote (if network fields exist) + // Handle primary network interface + if (row.ip_address || row.mac_address || row.remote_tool) { + await connection.query(` + INSERT INTO asset_remote ( + asset_id, ip_address, mac_address, remote_tool, remote_id, remote_pw + ) VALUES (?, ?, ?, ?, ?, ?) + `, [ + row.id, row.ip_address, row.mac_address, row.remote_tool, row.remote_id, row.remote_pw + ]); + } + + // Handle secondary network interface (e.g., from server table) if it exists + if (row.ip_address_2 || row.remote_tool_2) { + await connection.query(` + INSERT INTO asset_remote ( + asset_id, ip_address, remote_tool, remote_id, remote_pw + ) VALUES (?, ?, ?, ?, ?) + `, [ + row.id, row.ip_address_2, row.remote_tool_2, row.remote_id_2, row.remote_pw_2 + ]); + } + + totalMigrated++; + } + console.log(`- Migrated ${rows.length} records from ${table}`); + } catch (err) { + console.warn(`- Skipping legacy table ${table}: ${err.message}`); + } + } + + console.log(`✅ Phase 1 Data Migration Completed. Total Assets Migrated: ${totalMigrated}`); + + } catch (err) { + console.error('❌ Migration Failed:', err); + } finally { + await connection.end(); + } +} + +migrateSchema(); diff --git a/migrate_v2_final.js b/migrate_v2_final.js new file mode 100644 index 0000000..53f0c5f --- /dev/null +++ b/migrate_v2_final.js @@ -0,0 +1,212 @@ +import mysql from 'mysql2/promise'; +import dotenv from 'dotenv'; + +dotenv.config(); + +const { DB_HOST, DB_USER, DB_PASS, DB_NAME, DB_PORT } = process.env; + +async function migrateV2() { + const connection = await mysql.createConnection({ + host: DB_HOST, + user: DB_USER, + password: DB_PASS, + database: DB_NAME, + port: parseInt(DB_PORT || '3306') + }); + + console.log('🚀 Phase 2: Final Migration to Normalized V2 Schema...'); + + try { + await connection.query('SET FOREIGN_KEY_CHECKS = 0'); + + // 1. Create/Enhance Core Tables + console.log('1. Creating/Enhancing Tables...'); + + await connection.query('DROP TABLE IF EXISTS asset_core, asset_hardware, asset_location, asset_remote'); + + await connection.query(` + CREATE TABLE asset_core ( + id VARCHAR(50) PRIMARY KEY, + asset_code VARCHAR(100) UNIQUE NOT NULL, + category VARCHAR(100), + asset_type VARCHAR(100), + current_role VARCHAR(50) DEFAULT 'Normal' COMMENT 'Normal, Server, Personal, etc.', + asset_purpose VARCHAR(255), + service_type VARCHAR(50), + purchase_corp VARCHAR(100), + purchase_date VARCHAR(50), + purchase_amount VARCHAR(100), + purchase_vendor VARCHAR(255), + approval_document VARCHAR(255), + memo TEXT, + manager_primary VARCHAR(100), + manager_secondary VARCHAR(100), + current_dept VARCHAR(255), + previous_dept VARCHAR(255), + user_current VARCHAR(100), + previous_user VARCHAR(100), + emp_no VARCHAR(20), + user_position VARCHAR(50), + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP + ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; + `); + + await connection.query(` + CREATE TABLE asset_hardware ( + id INT AUTO_INCREMENT PRIMARY KEY, + asset_id VARCHAR(50) NOT NULL, + hw_status VARCHAR(50), + model_name VARCHAR(255), + mainboard VARCHAR(255), + os VARCHAR(100), + cpu VARCHAR(255), + ram VARCHAR(100), + gpu VARCHAR(100), + storage1 VARCHAR(255), + storage2 VARCHAR(255), + storage3 VARCHAR(255), + storage4 VARCHAR(255), + monitoring VARCHAR(100), + price VARCHAR(100), + volume VARCHAR(100), + monitor_inch VARCHAR(50), + serial_num VARCHAR(100), + FOREIGN KEY (asset_id) REFERENCES asset_core(id) ON DELETE CASCADE + ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; + `); + + await connection.query(` + CREATE TABLE asset_location ( + id INT AUTO_INCREMENT PRIMARY KEY, + asset_id VARCHAR(50) NOT NULL, + location VARCHAR(255), + location_detail VARCHAR(255), + location_photo VARCHAR(255), + loc_x VARCHAR(20), + loc_y VARCHAR(20), + is_active TINYINT(1) DEFAULT 1, + deactivated_at DATETIME NULL, + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + FOREIGN KEY (asset_id) REFERENCES asset_core(id) ON DELETE CASCADE + ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; + `); + + await connection.query(` + CREATE TABLE asset_remote ( + id INT AUTO_INCREMENT PRIMARY KEY, + asset_id VARCHAR(50) NOT NULL, + ip_address VARCHAR(100), + mac_address VARCHAR(100), + remote_tool VARCHAR(100), + remote_id VARCHAR(100), + remote_pw VARCHAR(100), + is_active TINYINT(1) DEFAULT 1, + deactivated_at DATETIME NULL, + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + FOREIGN KEY (asset_id) REFERENCES asset_core(id) ON DELETE CASCADE + ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; + `); + + console.log('✅ V2 Schema tables created.'); + + // 2. Migration Logic + const legacyTables = [ + { name: 'asset_pc', defaultRole: 'Personal' }, + { name: 'asset_server', defaultRole: 'Server' }, + { name: 'asset_storage', defaultRole: 'Normal' }, + { name: 'asset_equipment', defaultRole: 'Normal' }, + { name: 'asset_office_supplies', defaultRole: 'Normal' }, + { name: 'asset_survey', defaultRole: 'Normal' }, + { name: 'asset_vip', defaultRole: 'Normal' }, + { name: 'asset_pc_parts', defaultRole: 'Normal' } + ]; + + let totalMigrated = 0; + + for (const tableInfo of legacyTables) { + const table = tableInfo.name; + try { + const [rows] = await connection.query(`SELECT * FROM ${table}`); + console.log(`- Migrating ${rows.length} records from ${table}...`); + + for (const row of rows) { + // 2.1 Insert into asset_core + const role = (table === 'asset_pc' && row.asset_type === '서버PC') ? 'Server' : tableInfo.defaultRole; + + await connection.query(` + INSERT IGNORE INTO asset_core ( + 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, created_at + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + `, [ + row.id, row.asset_code, row.category, row.asset_type, role, row.asset_purpose, row.service_type, + row.purchase_corp, row.purchase_date, row.purchase_amount, row.purchase_vendor, row.approval_document, + row.memo, row.manager_primary, row.manager_secondary, row.current_dept, row.previous_dept, + row.user_current || row.current_user, row.previous_user, row.emp_no, row.user_position, row.created_at + ]); + + // 2.2 Insert into asset_hardware + await connection.query(` + INSERT INTO asset_hardware ( + asset_id, hw_status, model_name, mainboard, os, cpu, ram, gpu, storage1, storage2, storage3, storage4, monitoring, price, volume, monitor_inch, serial_num + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + `, [ + row.id, row.hw_status, row.model_name, row.mainboard, row.os, row.cpu, row.ram, row.gpu, + row.ssd_1 || row.storage1, row.ssd_2 || row.storage2, row.hdd_1 || row.storage3, row.hdd_2, row.monitoring, row.price, + row.volume, row.monitor_inch, row.serial_num + ]); + + // 2.3 Insert into asset_location + if (row.location || row.location_detail) { + await connection.query(` + INSERT INTO asset_location ( + asset_id, location, location_detail, location_photo, loc_x, loc_y, is_active + ) VALUES (?, ?, ?, ?, ?, ?, 1) + `, [ + row.id, row.location, row.location_detail, row.location_photo, row.loc_x, row.loc_y + ]); + } + + // 2.4 Insert into asset_remote + // Primary Network + if (row.ip_address || row.mac_address || row.remote_tool) { + await connection.query(` + INSERT INTO asset_remote ( + asset_id, ip_address, mac_address, remote_tool, remote_id, remote_pw, is_active + ) VALUES (?, ?, ?, ?, ?, ?, 1) + `, [ + row.id, row.ip_address, row.mac_address, row.remote_tool, row.remote_id, row.remote_pw + ]); + } + + // Secondary Network (for servers) + if (row.ip_address_2 || row.remote_tool_2) { + await connection.query(` + INSERT INTO asset_remote ( + asset_id, ip_address, remote_tool, remote_id, remote_pw, is_active + ) VALUES (?, ?, ?, ?, ?, 1) + `, [ + row.id, row.ip_address_2, row.remote_tool_2, row.remote_id_2, row.remote_pw_2 + ]); + } + + totalMigrated++; + } + } catch (err) { + console.warn(`- Skipping table ${table}: ${err.message}`); + } + } + + await connection.query('SET FOREIGN_KEY_CHECKS = 1'); + console.log(`✅ Phase 2 Data Migration Completed. Total Assets Migrated: ${totalMigrated}`); + + } catch (err) { + console.error('❌ Migration Failed:', err); + } finally { + await connection.end(); + } +} + +migrateV2(); diff --git a/migrate_v4_network.js b/migrate_v4_network.js new file mode 100644 index 0000000..e61dbb8 --- /dev/null +++ b/migrate_v4_network.js @@ -0,0 +1,73 @@ +import mysql from 'mysql2/promise'; +import dotenv from 'dotenv'; + +dotenv.config(); + +const pool = mysql.createPool({ + 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'), +}); + +async function migrate() { + const conn = await pool.getConnection(); + try { + console.log('1. Creating asset_remote_v4 table...'); + await conn.query(` + CREATE TABLE IF NOT EXISTS asset_remote_v4 ( + id INT AUTO_INCREMENT PRIMARY KEY, + asset_id VARCHAR(50) NOT NULL, + net_type VARCHAR(20) NOT NULL, /* 'IP' or 'REMOTE' */ + net_name VARCHAR(100), /* e.g., '기본망', 'AnyDesk' */ + net_value1 VARCHAR(100), /* IP or ID */ + net_value2 VARCHAR(100), /* MAC or PW */ + is_active TINYINT(1) DEFAULT 1, + deactivated_at DATETIME NULL, + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + FOREIGN KEY (asset_id) REFERENCES asset_core(id) ON DELETE CASCADE + ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; + `); + + console.log('2. Migrating data from asset_remote...'); + const [oldRows] = await conn.query('SELECT * FROM asset_remote WHERE is_active = 1'); + + let ipCount = 0; + let remoteCount = 0; + + for (const row of oldRows) { + // Migrating IP/MAC + if (row.ip_address || row.mac_address) { + await conn.query( + 'INSERT INTO asset_remote_v4 (asset_id, net_type, net_name, net_value1, net_value2, created_at) VALUES (?, ?, ?, ?, ?, ?)', + [row.asset_id, 'IP', '기본망', row.ip_address, row.mac_address, row.created_at] + ); + ipCount++; + } + // Migrating Remote + if (row.remote_tool || row.remote_id || row.remote_pw) { + await conn.query( + 'INSERT INTO asset_remote_v4 (asset_id, net_type, net_name, net_value1, net_value2, created_at) VALUES (?, ?, ?, ?, ?, ?)', + [row.asset_id, 'REMOTE', row.remote_tool, row.remote_id, row.remote_pw, row.created_at] + ); + remoteCount++; + } + } + + console.log(`Migrated ${ipCount} IP records and ${remoteCount} Remote records.`); + + console.log('3. Renaming tables...'); + await conn.query('DROP TABLE IF EXISTS asset_remote_legacy'); + await conn.query('RENAME TABLE asset_remote TO asset_remote_legacy, asset_remote_v4 TO asset_remote;'); + + console.log('✅ Migration V4 (Remote) Complete.'); + } catch (e) { + console.error('Migration failed:', e); + } finally { + conn.release(); + pool.end(); + } +} + +migrate(); \ No newline at end of file diff --git a/migrate_v5_rename_remote.js b/migrate_v5_rename_remote.js new file mode 100644 index 0000000..2902006 --- /dev/null +++ b/migrate_v5_rename_remote.js @@ -0,0 +1,28 @@ +import mysql from 'mysql2/promise'; +import dotenv from 'dotenv'; + +dotenv.config(); + +const pool = mysql.createPool({ + 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'), +}); + +async function migrate() { + const conn = await pool.getConnection(); + try { + console.log('1. Renaming asset_network to asset_remote...'); + await conn.query('RENAME TABLE asset_network TO asset_remote'); + console.log('✅ Table renamed successfully.'); + } catch (e) { + console.error('Migration failed:', e); + } finally { + conn.release(); + pool.end(); + } +} + +migrate(); diff --git a/probe_db.js b/probe_db.js new file mode 100644 index 0000000..777b08f --- /dev/null +++ b/probe_db.js @@ -0,0 +1,36 @@ +import mysql from 'mysql2/promise'; +import dotenv from 'dotenv'; + +dotenv.config(); + +const { DB_HOST, DB_USER, DB_PASS, DB_NAME, DB_PORT } = process.env; + +async function probeDB() { + const connection = await mysql.createConnection({ + host: DB_HOST, + user: DB_USER, + password: DB_PASS, + database: DB_NAME, + port: parseInt(DB_PORT || '3306') + }); + + console.log('--- Database Probe Start ---'); + + const [tables] = await connection.query('SHOW TABLES'); + const tableNames = tables.map(t => Object.values(t)[0]); + + console.log('Existing Tables:', tableNames); + + for (const table of tableNames) { + const [columns] = await connection.query(`DESCRIBE ${table}`); + console.log(`\n[Table: ${table}]`); + columns.forEach(c => { + console.log(` - ${c.Field} (${c.Type}) ${c.Comment ? '// ' + c.Comment : ''}`); + }); + } + + await connection.end(); + console.log('\n--- Database Probe End ---'); +} + +probeDB().catch(console.error); diff --git a/server.js b/server.js index eaf8c23..c19e733 100644 --- a/server.js +++ b/server.js @@ -4,7 +4,7 @@ import cors from 'cors'; import dotenv from 'dotenv'; import fs from 'fs'; -dotenv.config(); +dotenv.config({ override: true }); const app = express(); app.use(cors()); @@ -39,7 +39,7 @@ const CATEGORY_TABLE_MAP = { pc: 'asset_pc', server: 'asset_server', storage: 'asset_storage', - network: 'asset_network', + network: 'asset_remote', equipment: 'asset_equipment', officeSupplies: 'asset_office_supplies', survey: 'asset_survey', @@ -53,7 +53,7 @@ const CATEGORY_TABLE_MAP = { }; const ASSET_TABLES = [ - 'asset_pc', 'asset_server', 'asset_storage', 'asset_network', + 'asset_pc', 'asset_server', 'asset_storage', 'asset_remote', 'asset_equipment', 'asset_office_supplies', 'asset_survey', 'asset_vip' ]; @@ -116,7 +116,10 @@ app.get('/api/assets/master', async (req, res) => { 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 JSON_ARRAYAGG(JSON_OBJECT('type', net_type, 'name', net_name, 'val1', net_value1, 'val2', net_value2)) + FROM asset_remote WHERE asset_id = c.id AND is_active = 1 + ) as remotes, ( SELECT JSON_ARRAYAGG(JSON_OBJECT('type', disk_type, 'capacity', capacity, 'unit', unit, 'slot', slot_no)) FROM asset_volume WHERE asset_id = c.id @@ -128,11 +131,6 @@ app.get('/api/assets/master', async (req, res) => { 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 = { @@ -173,6 +171,86 @@ app.post('/api/asset/:category/save', async (req, res) => { connection = await pool.getConnection(); await connection.beginTransaction(); + // 3.0 History Tracking & Auto Field Update + const [oldCoreRows] = await connection.query('SELECT * FROM asset_core WHERE id = ?', [asset.id]); + const [oldSpecRows] = await connection.query('SELECT * FROM asset_spec WHERE asset_id = ?', [asset.id]); + const oldCore = oldCoreRows[0] || {}; + const oldSpec = oldSpecRows[0] || {}; + + console.log(`🔍 [History Check] ID: ${asset.id}`); + console.log(` - Dept: [${oldCore.current_dept}] -> [${asset.current_dept}]`); + console.log(` - User: [${oldCore.user_current}] -> [${asset.user_current}]`); + + const historyLogs = []; + const logDate = new Date().toISOString().split('T')[0]; // YYYY-MM-DD + const logUser = '관리자'; + + // 조직 변동 감지 (null/undefined/empty string 세이프 처리) + const oldDept = oldCore.current_dept || ''; + const newDept = asset.current_dept || ''; + if (newDept !== '' && oldDept !== newDept) { + asset.previous_dept = oldDept; + historyLogs.push({ + event_type: 'DEPT_CHANGE', + old_dept: oldDept || null, + new_dept: newDept, + details: `[조직 변동] ${oldDept || '(없음)'} -> ${newDept}` + }); + } + + // 사용자 변동 감지 + const oldUser = oldCore.user_current || ''; + const newUser = asset.user_current || ''; + if (newUser !== '' && oldUser !== newUser) { + asset.previous_user = oldUser; + historyLogs.push({ + event_type: 'USER_CHANGE', + old_user: oldUser || null, + new_user: newUser, + details: `[사용자 변동] ${oldUser || '(없음)'} -> ${newUser}` + }); + } + + // 유형/용도 변경 감지 + const oldType = oldCore.asset_type || ''; + const newType = asset.asset_type || ''; + if (newType !== '' && oldType !== newType) { + historyLogs.push({ + event_type: 'ROLE_CHANGE', + details: `[유형 변경] ${oldType || '(없음)'} -> ${newType}` + }); + } + + const oldRole = oldCore.current_role || ''; + const newRole = asset.current_role || ''; + if (newRole !== '' && oldRole !== newRole) { + historyLogs.push({ + event_type: 'ROLE_CHANGE', + details: `[용도 변경] ${oldRole || '(없음)'} -> ${newRole}` + }); + } + + // 상태 변경 감지 + const oldStatus = oldSpec.hw_status || ''; + const newStatus = asset.hw_status || ''; + if (newStatus !== '' && oldStatus !== newStatus) { + historyLogs.push({ + event_type: 'STATUS_CHANGE', + details: `[상태 변경] ${oldStatus || '(없음)'} -> ${newStatus}` + }); + } + + console.log(` - Logs Generated: ${historyLogs.length}`); + + // 로그 일괄 삽입 + for (const log of historyLogs) { + await connection.query( + `INSERT INTO asset_history (asset_id, event_type, old_dept, new_dept, old_user, new_user, details, log_date, log_user) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`, + [asset.id, log.event_type, log.old_dept || null, log.new_dept || null, log.old_user || null, log.new_user || null, log.details, logDate, logUser] + ); + } + // 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 = {}; @@ -224,14 +302,36 @@ app.post('/api/asset/:category/save', async (req, res) => { } } - // 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]); + // 3.5 asset_remote (Dynamic Array Logic) + if (asset.remotes) { + try { + let nets = typeof asset.remotes === 'string' ? JSON.parse(asset.remotes) : asset.remotes; + if (Array.isArray(nets)) { + await connection.query('UPDATE asset_remote SET is_active = 0, deactivated_at = NOW() WHERE asset_id = ? AND is_active = 1', [asset.id]); + for (const n of nets) { + if (n.type) { + await connection.query( + 'INSERT INTO asset_remote (asset_id, net_type, net_name, net_value1, net_value2, is_active) VALUES (?, ?, ?, ?, ?, 1)', + [asset.id, n.type, n.name || '', n.val1 || '', n.val2 || ''] + ); + } + } + } + } catch(e) { console.error('Remote data parse error', e); } + } else { + // Fallback for UI that hasn't sent the networks array yet + if (asset.ip_address || asset.mac_address || asset.remote_tool) { + const [netActive] = await connection.query('SELECT * FROM asset_remote WHERE asset_id = ? AND is_active = 1', [asset.id]); + const isChanged = netActive.length === 0 || netActive[0].net_value1 !== asset.ip_address || netActive[0].net_value2 !== asset.mac_address || netActive[0].net_name !== asset.remote_tool; + if (isChanged) { + await connection.query('UPDATE asset_remote SET is_active = 0, deactivated_at = NOW() WHERE asset_id = ? AND is_active = 1', [asset.id]); + if (asset.ip_address || asset.mac_address) { + await connection.query('INSERT INTO asset_remote (asset_id, net_type, net_name, net_value1, net_value2, is_active) VALUES (?, ?, ?, ?, ?, 1)', [asset.id, 'IP', '기본망', asset.ip_address, asset.mac_address]); + } + if (asset.remote_tool || asset.remote_id || asset.remote_pw) { + await connection.query('INSERT INTO asset_remote (asset_id, net_type, net_name, net_value1, net_value2, is_active) VALUES (?, ?, ?, ?, ?, 1)', [asset.id, 'REMOTE', asset.remote_tool, asset.remote_id, asset.remote_pw]); + } + } } } @@ -338,7 +438,7 @@ app.delete('/api/asset/:category/:id', async (req, res) => { try { const connection = await pool.getConnection(); - // For asset_core, ON DELETE CASCADE will handle spec, location, network, volume + // For asset_core, ON DELETE CASCADE will handle spec, location, remote, volume await connection.query(`DELETE FROM ${table} WHERE id = ?`, [id]); connection.release(); console.log(`🗑️ [ASSET DELETE] Category: ${category}, ID: ${id}`); diff --git a/src/components/Modal/BaseModal.ts b/src/components/Modal/BaseModal.ts index bbf6efb..09645ea 100644 --- a/src/components/Modal/BaseModal.ts +++ b/src/components/Modal/BaseModal.ts @@ -55,6 +55,9 @@ export abstract class BaseModal { this.currentAsset = asset; this.isEditMode = (mode === 'add' || mode === 'edit'); + // 폼 초기화 추가 + if (this.formEl) this.formEl.reset(); + this.setEditLockMode(mode); this.fillFormData(asset); diff --git a/src/components/Modal/HWModal.ts b/src/components/Modal/HWModal.ts index 9cd7916..42fb2f2 100644 --- a/src/components/Modal/HWModal.ts +++ b/src/components/Modal/HWModal.ts @@ -35,8 +35,9 @@ class HwAssetModal extends BaseModal {