Compare commits
13 Commits
login
...
29c7d5f3d8
| Author | SHA1 | Date | |
|---|---|---|---|
| 29c7d5f3d8 | |||
| ce1ed40561 | |||
| 25ebaf4685 | |||
| 2b9c965c91 | |||
| 4b408b0640 | |||
| 3ab587d342 | |||
| 3b9b2ea598 | |||
| 05c565552a | |||
| 2ec9261c03 | |||
| 06f3baaa58 | |||
| eead43837d | |||
| 46422e8544 | |||
| a30f99f0ad |
6
.env
Normal file
6
.env
Normal file
@@ -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
|
||||
60
PLAN_ASSET_HISTORY.md
Normal file
60
PLAN_ASSET_HISTORY.md
Normal file
@@ -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) 확인.
|
||||
BIN
backupDB_20260602.xlsx
Normal file
BIN
backupDB_20260602.xlsx
Normal file
Binary file not shown.
59
backup_db.js
Normal file
59
backup_db.js
Normal file
@@ -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);
|
||||
});
|
||||
28
check_logs.js
Normal file
28
check_logs.js
Normal file
@@ -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);
|
||||
29
check_network.js
Normal file
29
check_network.js
Normal file
@@ -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);
|
||||
44
drop_legacy.js
Normal file
44
drop_legacy.js
Normal file
@@ -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);
|
||||
197
migrate_schema.js
Normal file
197
migrate_schema.js
Normal file
@@ -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();
|
||||
212
migrate_v2_final.js
Normal file
212
migrate_v2_final.js
Normal file
@@ -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();
|
||||
73
migrate_v4_network.js
Normal file
73
migrate_v4_network.js
Normal file
@@ -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();
|
||||
28
migrate_v5_rename_remote.js
Normal file
28
migrate_v5_rename_remote.js
Normal file
@@ -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();
|
||||
36
probe_db.js
Normal file
36
probe_db.js
Normal file
@@ -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);
|
||||
530
server.js
530
server.js
@@ -4,183 +4,455 @@ import cors from 'cors';
|
||||
import dotenv from 'dotenv';
|
||||
import fs from 'fs';
|
||||
|
||||
dotenv.config();
|
||||
dotenv.config({ override: true });
|
||||
|
||||
const app = express();
|
||||
app.use(cors());
|
||||
app.use(express.json({ limit: '100mb' }));
|
||||
app.use(express.json({ limit: '50mb' }));
|
||||
app.use('/uploads', express.static('uploads')); // 업로드 파일 정적 서빙
|
||||
|
||||
// Request Logger
|
||||
app.use((req, res, next) => {
|
||||
console.log(`[${new Date().toISOString()}] ${req.method} ${req.url}`);
|
||||
next();
|
||||
});
|
||||
// uploads 폴더가 없으면 생성
|
||||
if (!fs.existsSync('uploads')) {
|
||||
fs.mkdirSync('uploads');
|
||||
}
|
||||
|
||||
// MySQL Pool Configuration
|
||||
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'),
|
||||
charset: 'utf8mb4'
|
||||
waitForConnections: true,
|
||||
connectionLimit: 10,
|
||||
queueLimit: 0
|
||||
});
|
||||
|
||||
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 });
|
||||
// Error Handler
|
||||
const handleError = (res, err, label) => {
|
||||
console.error(`❌ [${label}] Error:`, err);
|
||||
res.status(500).json({ error: err.message });
|
||||
};
|
||||
|
||||
// --- API Implementation ---
|
||||
|
||||
/**
|
||||
* Generic Fetcher for Asset Tables
|
||||
*/
|
||||
const fetchAssets = async (tableName, res, context) => {
|
||||
try {
|
||||
const [rows] = await pool.query(`SELECT * FROM ${tableName}`);
|
||||
console.log(`📡 [GET ${context}] Returning ${rows.length} rows from ${tableName}`);
|
||||
res.json(rows);
|
||||
} catch (err) {
|
||||
handleError(res, err, context, true);
|
||||
}
|
||||
// --- Global Constants ---
|
||||
const CATEGORY_TABLE_MAP = {
|
||||
pc: 'asset_pc',
|
||||
server: 'asset_server',
|
||||
storage: 'asset_storage',
|
||||
network: 'asset_remote',
|
||||
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'
|
||||
};
|
||||
|
||||
/**
|
||||
* Generic Batch Saver for Asset Tables
|
||||
*/
|
||||
const saveAssetsBatch = async (tableName, items, res, context) => {
|
||||
const connection = await pool.getConnection();
|
||||
const ASSET_TABLES = [
|
||||
'asset_pc', 'asset_server', 'asset_storage', 'asset_remote',
|
||||
'asset_equipment', 'asset_office_supplies', 'asset_survey', 'asset_vip'
|
||||
];
|
||||
|
||||
// --- API Endpoints ---
|
||||
|
||||
// 1. Generic Batch Save (Dynamic Table Detection)
|
||||
app.post('/api/:table/batch', async (req, res) => {
|
||||
const { table } = req.params;
|
||||
const data = req.body;
|
||||
if (!Array.isArray(data)) return res.status(400).json({ error: 'Data must be an array' });
|
||||
|
||||
let connection;
|
||||
try {
|
||||
connection = await pool.getConnection();
|
||||
await connection.beginTransaction();
|
||||
|
||||
// Get valid columns for this table
|
||||
const [cols] = await connection.query(`DESCRIBE ${tableName}`);
|
||||
const validColumns = cols.map(c => c.Field);
|
||||
|
||||
// 1. Clear existing
|
||||
await connection.query(`DELETE FROM ${tableName}`);
|
||||
|
||||
// 2. Insert new items
|
||||
for (const item of items) {
|
||||
const filteredRow = {};
|
||||
validColumns.forEach(col => {
|
||||
if (col === 'created_at' || col === 'updated_at') return;
|
||||
if (item[col] !== undefined) filteredRow[col] = item[col];
|
||||
});
|
||||
|
||||
if (!filteredRow.id) filteredRow.id = Math.random().toString(36).substring(2, 9);
|
||||
await connection.query(`INSERT INTO ${tableName} SET ?`, [filteredRow]);
|
||||
}
|
||||
|
||||
await connection.commit();
|
||||
res.json({ success: true, count: items.length });
|
||||
} catch (err) {
|
||||
await connection.rollback();
|
||||
handleError(res, err, context);
|
||||
} finally {
|
||||
connection.release();
|
||||
}
|
||||
};
|
||||
|
||||
// --- Routes ---
|
||||
const [columns] = await connection.query(`DESCRIBE ${table}`);
|
||||
const validFields = columns.map(c => c.Field);
|
||||
|
||||
const routeMap = {
|
||||
'/api/users': { table: 'system_users', context: 'USERS' },
|
||||
'/api/pc': { table: 'asset_pc', context: 'PC' },
|
||||
'/api/server': { table: 'asset_server', context: 'SERVER' },
|
||||
'/api/storage': { table: 'asset_storage', context: 'STORAGE' },
|
||||
'/api/network': { table: 'asset_network', context: 'NETWORK' },
|
||||
'/api/sw/internal': { table: 'asset_sw_internal', context: 'SW INTERNAL' },
|
||||
'/api/sw/external': { table: 'asset_sw_external', context: 'SW EXTERNAL' },
|
||||
'/api/survey': { table: 'asset_survey', context: 'SURVEY' },
|
||||
'/api/pc-parts': { table: 'asset_pc_parts', context: 'PC PARTS' },
|
||||
'/api/equipment': { table: 'asset_equipment', context: 'EQUIPMENT' },
|
||||
'/api/office-supplies': { table: 'asset_office_supplies', context: 'OFFICE SUPPLIES' },
|
||||
'/api/cloud': { table: 'asset_cloud', context: 'CLOUD' },
|
||||
'/api/domain': { table: 'asset_domain', context: 'DOMAIN' },
|
||||
'/api/cost': { table: 'asset_cost', context: 'COST' },
|
||||
'/api/vip': { table: 'asset_vip', context: 'VIP' },
|
||||
'/api/asset/software/assignment': { table: 'asset_software_assignment', context: 'SW ASSIGN' }
|
||||
};
|
||||
await connection.query(`DELETE FROM ${table}`);
|
||||
|
||||
Object.entries(routeMap).forEach(([route, { table, context }]) => {
|
||||
app.get(route, (req, res) => fetchAssets(table, res, context));
|
||||
app.post(`${route}/batch`, (req, res) => saveAssetsBatch(table, req.body, res, `${context} BATCH`));
|
||||
});
|
||||
|
||||
app.get('/api/asset/history', (req, res) => fetchAssets('asset_history', res, 'HISTORY'));
|
||||
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]);
|
||||
if (data.length > 0) {
|
||||
const placeholders = validFields.map(() => '?').join(', ');
|
||||
const sql = `INSERT INTO ${table} (${validFields.join(', ')}) VALUES (${placeholders})`;
|
||||
|
||||
for (const item of data) {
|
||||
const values = validFields.map(field => {
|
||||
const val = item[field];
|
||||
return val === undefined ? null : val;
|
||||
});
|
||||
await connection.query(sql, values);
|
||||
}
|
||||
await connection.commit();
|
||||
res.json({ success: true });
|
||||
} catch (err) { await connection.rollback(); handleError(res, err, 'BATCH HISTORY'); } finally { connection.release(); }
|
||||
}
|
||||
|
||||
await connection.commit();
|
||||
res.json({ success: true, count: data.length });
|
||||
} catch (err) {
|
||||
if (connection) await connection.rollback();
|
||||
handleError(res, err, 'BATCH SAVE');
|
||||
} finally {
|
||||
if (connection) connection.release();
|
||||
}
|
||||
});
|
||||
|
||||
app.get('/api/generate-asset-code', async (req, res) => {
|
||||
// 2. Get All Assets (Integrated Master Data from Normalized V3 Schema)
|
||||
app.get('/api/assets/master', async (req, res) => {
|
||||
try {
|
||||
const { prefix } = req.query;
|
||||
if (!prefix) return res.status(400).json({ error: 'Prefix is required' });
|
||||
const tables = ['asset_pc', 'asset_server', 'asset_storage', 'asset_network', 'asset_survey', 'asset_pc_parts', 'asset_equipment', 'asset_office_supplies', 'asset_vip'];
|
||||
let lastCode = '';
|
||||
for (const table of tables) {
|
||||
const [rows] = await pool.query(`SELECT asset_code FROM ${table} WHERE asset_code LIKE ? ORDER BY asset_code DESC LIMIT 1`, [`${prefix}%`]);
|
||||
if (rows.length > 0 && rows[0].asset_code > lastCode) lastCode = rows[0].asset_code;
|
||||
const connection = await pool.getConnection();
|
||||
|
||||
const masterData = {
|
||||
pc: [], server: [], storage: [], network: [],
|
||||
equipment: [], officeSupplies: [], survey: [], vip: [], pcParts: [],
|
||||
swInternal: [], swExternal: [], swUsers: [], users: [], logs: []
|
||||
};
|
||||
|
||||
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,
|
||||
(
|
||||
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
|
||||
) as volumes
|
||||
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
|
||||
)
|
||||
`);
|
||||
|
||||
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);
|
||||
} catch (err) {
|
||||
handleError(res, err, 'MASTER DATA');
|
||||
}
|
||||
});
|
||||
|
||||
// 3. Asset Save (Surgical Split to Normalized V3 Tables)
|
||||
app.post('/api/asset/:category/save', async (req, res) => {
|
||||
const asset = req.body;
|
||||
let connection;
|
||||
try {
|
||||
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}`
|
||||
});
|
||||
}
|
||||
let nextNum = 1;
|
||||
if (lastCode) {
|
||||
const lastNum = parseInt(lastCode.split('-').pop() || '0');
|
||||
nextNum = lastNum + 1;
|
||||
|
||||
// 사용자 변동 감지
|
||||
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}`
|
||||
});
|
||||
}
|
||||
res.json({ nextCode: `${prefix}${String(nextNum).padStart(3, '0')}` });
|
||||
|
||||
// 유형/용도 변경 감지
|
||||
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 = {};
|
||||
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));
|
||||
|
||||
// 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));
|
||||
}
|
||||
|
||||
// 3.3 asset_volume
|
||||
await connection.query('DELETE FROM asset_volume WHERE asset_id = ?', [asset.id]);
|
||||
if (asset.volumes) {
|
||||
try {
|
||||
let vols = typeof asset.volumes === 'string' ? JSON.parse(asset.volumes) : asset.volumes;
|
||||
if (Array.isArray(vols)) {
|
||||
for (let i = 0; i < vols.length; i++) {
|
||||
const v = vols[i];
|
||||
if (v.type && v.capacity) {
|
||||
await connection.query(
|
||||
'INSERT INTO asset_volume (asset_id, disk_type, capacity, unit, slot_no) VALUES (?, ?, ?, ?, ?)',
|
||||
[asset.id, v.type, v.capacity, v.unit || 'GB', v.slot || (i + 1)]
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch(e) { console.error('Volume parse error', e); }
|
||||
}
|
||||
|
||||
// 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_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]);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
await connection.commit();
|
||||
console.log(`💾 [V3 ASSET SAVE] ID: ${asset.id}`);
|
||||
res.json({ success: true });
|
||||
} catch (err) {
|
||||
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;
|
||||
|
||||
// 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 = 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, remote, 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');
|
||||
}
|
||||
});
|
||||
|
||||
// 5. Generate Next Asset Code
|
||||
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 datePart = purchaseDate ? purchaseDate.toString().replace(/-/g, '').substring(0, 6) : '';
|
||||
const searchPattern = datePart ? `${prefix}-${datePart}-%` : `${prefix}-%`;
|
||||
let maxNum = 0;
|
||||
for (const table of ASSET_TABLES) {
|
||||
try {
|
||||
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 num = parseInt(parts[parts.length - 1]);
|
||||
if (!isNaN(num) && num > maxNum) maxNum = num;
|
||||
});
|
||||
} catch (err) {}
|
||||
}
|
||||
const nextNum = maxNum + 1;
|
||||
const nextCode = datePart ? `${prefix}-${datePart}-${String(nextNum).padStart(4, '0')}` : `${prefix}-${String(nextNum).padStart(4, '0')}`;
|
||||
connection.release();
|
||||
res.json({ nextCode });
|
||||
} 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'); }
|
||||
});
|
||||
|
||||
// 7. File Upload API (Base64)
|
||||
app.post('/api/upload', (req, res) => {
|
||||
try {
|
||||
const { fileName, fileData } = req.body;
|
||||
if (!fileName || !fileData) return res.status(400).json({ error: 'FileName and FileData are required' });
|
||||
|
||||
// base64 데이터에서 실제 바이너리 추출
|
||||
const base64Data = fileData.replace(/^data:.*;base64,/, "");
|
||||
const buffer = Buffer.from(base64Data, 'base64');
|
||||
|
||||
// 고유한 파일명 생성 (타임스탬프 결합)
|
||||
const timestamp = Date.now();
|
||||
const safeFileName = `${timestamp}_${fileName.replace(/[^a-zA-Z0-9._-]/g, '_')}`;
|
||||
const filePath = `uploads/${safeFileName}`;
|
||||
|
||||
fs.writeFileSync(filePath, buffer);
|
||||
|
||||
console.log(`파일 업로드 성공: ${filePath}`);
|
||||
res.json({ success: true, filePath: `/${filePath}`, fileName: safeFileName });
|
||||
} catch (err) {
|
||||
handleError(res, err, 'SAVE MAPS');
|
||||
handleError(res, err, 'FILE UPLOAD');
|
||||
}
|
||||
});
|
||||
|
||||
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)');
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -13,15 +13,15 @@ export const HW_STATUS_LIST = ['운영', '재고', '수리', '폐기', '기타']
|
||||
|
||||
// 구분(Category) -> 유형(Asset Type) 관계 정의 (통합 관리)
|
||||
export const CATEGORY_TYPE_MAP: Record<string, string[]> = {
|
||||
'서버': ['서버 렉', '가상서버(VM)', '워크스테이션', 'NAS', 'DAS', '서버PC', '스토리지 렉'],
|
||||
'서버': ['서버 렉', '가상서버(VM)', '워크스테이션', '저장시스템_렉(NAS)', '저장시스템_렉(DAS)', '저장시스템_미니(NAS)', '저장시스템_미니(DAS)'],
|
||||
'PC': ['개인PC', '노트북', '공용PC', '서버PC'],
|
||||
'스토리지': ['SSD', 'HDD', '외장HDD'],
|
||||
'저장매체': ['SSD', 'HDD', '외장HDD'],
|
||||
'네트워크': ['스위치', '허브', '방화벽', '라우터', '공유기', '허브'],
|
||||
'PC부품': ['CPU', 'RAM', 'GPU', 'SSD', 'HDD', 'RAM', '모니터'],
|
||||
'공간정보장비': ['드론', '측량장비', '보조기기'],
|
||||
'업무지원장비': ['카메라', '스피커', 'TV', '모바일', '유선전화기', 'XR', '프린터', '전산소모품'],
|
||||
'외부': ['영구', '구독'],
|
||||
'내부': ['판매용', 'Solutions', 'Inhouse', 'Engine&Module'],
|
||||
'외부SW': ['영구', '구독'],
|
||||
'내부SW': ['판매용', 'Solutions', 'Inhouse', 'Engine&Module'],
|
||||
'비용관리': ['클라우드', '도메인', '전화', '인터넷', '이메일'],
|
||||
'내빈/외빈': ['선물'],
|
||||
'시설자산': ['사무가구']
|
||||
@@ -38,10 +38,12 @@ export const LOCATION_DATA: Record<string, string[]> = {
|
||||
|
||||
// 유형별 자산번호 접두사(Prefix) 매핑
|
||||
export const TYPE_PREFIX_MAP: Record<string, string> = {
|
||||
'서버': 'SVR', '워크스테이션': 'SVR', '개인PC': 'PC', '공용PC': 'PC', '서버PC': 'PC', 'NAS': 'NAS', 'DAS': 'DAS', '스토리지': 'STO',
|
||||
'HDD': 'HDD', 'SSD': 'SSD', '노트북': 'NBK', '태블릿': 'TAB',
|
||||
'서버': 'SVR', '워크스테이션': 'SVR', '개인PC': 'PC', '공용PC': 'PC', '서버PC': 'PC',
|
||||
'저장시스템_렉(NAS)': 'DSS', '저장시스템_렉(DAS)': 'DSS', '저장시스템_미니(NAS)': 'DSS', '저장시스템_미니(DAS)': 'DSS',
|
||||
'저장매체': 'STM', 'HDD': 'HDD', 'SSD': 'SSD',
|
||||
'노트북': 'NBK', '태블릿': 'TAB',
|
||||
'드론': 'DRO', '측량장비': 'SUR', '보조기기': 'SUR', '허브': 'NET',
|
||||
'구독SW': 'SW', '영구SW': 'SW', '내부' : 'INT'
|
||||
'구독SW': 'SW', '영구SW': 'SW', '내부' : 'SW_INT', '외부':'SW_EXT'
|
||||
};
|
||||
|
||||
// 배치도 이미지 매핑 데이터
|
||||
|
||||
@@ -7,7 +7,7 @@ const MENU_CONFIG: any = {
|
||||
},
|
||||
sw: {
|
||||
label: '소프트웨어',
|
||||
tabs: ['외부', '내부']
|
||||
tabs: ['외부SW', '내부SW']
|
||||
},
|
||||
ops: {
|
||||
label: '운영지원',
|
||||
|
||||
@@ -484,7 +484,7 @@ export const realServerData = [
|
||||
},
|
||||
{
|
||||
"법인": "삼안",
|
||||
"자산코드": "sa-das-001",
|
||||
"자산코드": "DSS020",
|
||||
"storage유형": "서버",
|
||||
"용도": "",
|
||||
"상세": "Satis01, Satis02 광케이블 연결 (물리연결)",
|
||||
@@ -505,7 +505,7 @@ export const realServerData = [
|
||||
},
|
||||
{
|
||||
"법인": "삼안",
|
||||
"자산코드": "sa-nas-001",
|
||||
"자산코드": "DSS019",
|
||||
"storage유형": "서버",
|
||||
"용도": "인트라넷 백업 스토리지",
|
||||
"상세": "",
|
||||
@@ -526,7 +526,7 @@ export const realServerData = [
|
||||
},
|
||||
{
|
||||
"법인": "삼안",
|
||||
"자산코드": "sa-nas-002",
|
||||
"자산코드": "DSS018",
|
||||
"storage유형": "서버",
|
||||
"용도": "성과품 스토리지",
|
||||
"상세": "매니지먼트 접속 확인 불가 (콘솔 연결 후 페이지 오픈 필요)",
|
||||
@@ -547,7 +547,7 @@ export const realServerData = [
|
||||
},
|
||||
{
|
||||
"법인": "삼안",
|
||||
"자산코드": "sa-nas-003",
|
||||
"자산코드": "DSS017",
|
||||
"storage유형": "서버",
|
||||
"용도": "성과품 백업 스토리지",
|
||||
"상세": "",
|
||||
@@ -568,7 +568,7 @@ export const realServerData = [
|
||||
},
|
||||
{
|
||||
"법인": "한라",
|
||||
"자산코드": "hl-das-001",
|
||||
"자산코드": "DSS016",
|
||||
"storage유형": "서버",
|
||||
"용도": "",
|
||||
"상세": "파일서버 정보 없음(접속 불가)",
|
||||
@@ -589,7 +589,7 @@ export const realServerData = [
|
||||
},
|
||||
{
|
||||
"법인": "한라",
|
||||
"자산코드": "hl-das-002",
|
||||
"자산코드": "DSS015",
|
||||
"storage유형": "서버",
|
||||
"용도": "",
|
||||
"상세": "파일서버 정보 없음(접속 불가)",
|
||||
@@ -611,7 +611,7 @@ export const realServerData = [
|
||||
{
|
||||
"법인": "",
|
||||
"자산코드": "",
|
||||
"storage유형": "NAS",
|
||||
"storage유형": "저장시스템_렉(DAS)",
|
||||
"용도": "GSIM NAS",
|
||||
"상세": "팀 내부 자료 저장 , 정사영상 및 지도 데이터 저장 , Gitea 및 Git 내장 NAS",
|
||||
"위치": "마천사무실",
|
||||
@@ -631,7 +631,7 @@ export const realServerData = [
|
||||
{
|
||||
"법인": "",
|
||||
"자산코드": "",
|
||||
"storage유형": "NAS",
|
||||
"storage유형": "저장시스템_렉(DAS)",
|
||||
"용도": "그래픽스개발팀 데이터 백업 NAS",
|
||||
"상세": "그래픽스 개발팀 데이터 백업용 NAS",
|
||||
"위치": "마천사무실",
|
||||
@@ -1091,7 +1091,7 @@ export const realServerData = [
|
||||
{
|
||||
"법인": "",
|
||||
"자산코드": "1",
|
||||
"storage유형": "NAS",
|
||||
"storage유형": "저장시스템_렉(DAS)",
|
||||
"용도": "NAS 2",
|
||||
"상세": "한라 기업부설연구소 공용 NAS",
|
||||
"위치": "한맥빌딩(MDF 실)",
|
||||
@@ -1107,7 +1107,7 @@ export const realServerData = [
|
||||
{
|
||||
"법인": "",
|
||||
"자산코드": "2",
|
||||
"storage유형": "NAS",
|
||||
"storage유형": "저장시스템_렉(DAS)",
|
||||
"용도": "NAS 1",
|
||||
"상세": "한라 공용 NAS",
|
||||
"위치": "한맥빌딩(MDF 실)",
|
||||
@@ -1123,7 +1123,7 @@ export const realServerData = [
|
||||
{
|
||||
"법인": "",
|
||||
"자산코드": "3",
|
||||
"storage유형": "NAS",
|
||||
"storage유형": "저장시스템_렉(DAS)",
|
||||
"용도": "NAS 4",
|
||||
"상세": "한라 공용 NAS",
|
||||
"위치": "한맥빌딩(MDF 실)",
|
||||
@@ -1139,7 +1139,7 @@ export const realServerData = [
|
||||
{
|
||||
"법인": "",
|
||||
"자산코드": "4",
|
||||
"storage유형": "NAS",
|
||||
"storage유형": "저장시스템_렉(DAS)",
|
||||
"용도": "NAS 5",
|
||||
"상세": "한라 환경플랜트사업부 NAS",
|
||||
"위치": "한맥빌딩(MDF 실)",
|
||||
@@ -1155,7 +1155,7 @@ export const realServerData = [
|
||||
{
|
||||
"법인": "",
|
||||
"자산코드": "5",
|
||||
"storage유형": "NAS",
|
||||
"storage유형": "저장시스템_렉(DAS)",
|
||||
"용도": "NAS 6",
|
||||
"상세": "한라 공용 NAS",
|
||||
"위치": "한맥빌딩(MDF 실)",
|
||||
@@ -1171,7 +1171,7 @@ export const realServerData = [
|
||||
{
|
||||
"법인": "",
|
||||
"자산코드": "6",
|
||||
"storage유형": "NAS",
|
||||
"storage유형": "저장시스템_렉(DAS)",
|
||||
"용도": "NAS7",
|
||||
"상세": "한라 원주바이오 NAS",
|
||||
"위치": "한맥빌딩(MDF 실)",
|
||||
@@ -1187,7 +1187,7 @@ export const realServerData = [
|
||||
{
|
||||
"법인": "",
|
||||
"자산코드": "7",
|
||||
"storage유형": "NAS",
|
||||
"storage유형": "저장시스템_렉(DAS)",
|
||||
"용도": "총괄기획실 NAS",
|
||||
"상세": "총괄기획실 공용 NAS",
|
||||
"위치": "한맥빌딩(MDF 실)",
|
||||
@@ -1203,7 +1203,7 @@ export const realServerData = [
|
||||
{
|
||||
"법인": "",
|
||||
"자산코드": "8",
|
||||
"storage유형": "NAS",
|
||||
"storage유형": "저장시스템_렉(DAS)",
|
||||
"용도": "한맥 NAS 1",
|
||||
"상세": "한맥 공용 NAS",
|
||||
"위치": "한맥빌딩(MDF 실)",
|
||||
@@ -1219,7 +1219,7 @@ export const realServerData = [
|
||||
{
|
||||
"법인": "",
|
||||
"자산코드": "9",
|
||||
"storage유형": "NAS",
|
||||
"storage유형": "저장시스템_렉(DAS)",
|
||||
"용도": "한맥 NAS 2",
|
||||
"상세": "한맥 공용 NAS",
|
||||
"위치": "한맥빌딩(MDF 실)",
|
||||
@@ -1235,7 +1235,7 @@ export const realServerData = [
|
||||
{
|
||||
"법인": "",
|
||||
"자산코드": "10",
|
||||
"storage유형": "NAS",
|
||||
"storage유형": "저장시스템_렉(DAS)",
|
||||
"용도": "한맥 NAS 3",
|
||||
"상세": "한맥 공용 NAS",
|
||||
"위치": "한맥빌딩(MDF 실)",
|
||||
@@ -1251,7 +1251,7 @@ export const realServerData = [
|
||||
{
|
||||
"법인": "",
|
||||
"자산코드": "11",
|
||||
"storage유형": "NAS",
|
||||
"storage유형": "저장시스템_렉(DAS)",
|
||||
"용도": "NAS 13",
|
||||
"상세": "환경플랜트사업",
|
||||
"위치": "한맥빌딩(MDF 실)",
|
||||
@@ -1331,7 +1331,7 @@ export const realServerData = [
|
||||
{
|
||||
"법인": "",
|
||||
"자산코드": "16",
|
||||
"storage유형": "NAS",
|
||||
"storage유형": "저장시스템_렉(DAS)",
|
||||
"용도": "디자인팀1 NAS",
|
||||
"상세": "",
|
||||
"위치": "한맥빌딩(MDF 실)",
|
||||
@@ -1347,7 +1347,7 @@ export const realServerData = [
|
||||
{
|
||||
"법인": "",
|
||||
"자산코드": "17",
|
||||
"storage유형": "NAS",
|
||||
"storage유형": "저장시스템_렉(DAS)",
|
||||
"용도": "디자인팀2 NAS",
|
||||
"상세": "",
|
||||
"위치": "한맥빌딩(MDF 실)",
|
||||
@@ -1507,7 +1507,7 @@ export const realServerData = [
|
||||
{
|
||||
"법인": "",
|
||||
"자산코드": "27",
|
||||
"storage유형": "NAS",
|
||||
"storage유형": "저장시스템_렉(DAS)",
|
||||
"용도": "기술개발센터 NAS",
|
||||
"상세": "",
|
||||
"위치": "한맥빌딩(MDF 실)",
|
||||
@@ -1523,7 +1523,7 @@ export const realServerData = [
|
||||
{
|
||||
"법인": "",
|
||||
"자산코드": "28",
|
||||
"storage유형": "NAS",
|
||||
"storage유형": "저장시스템_렉(DAS)",
|
||||
"용도": "-",
|
||||
"상세": "",
|
||||
"위치": "한맥빌딩(MDF 실)",
|
||||
|
||||
@@ -17,6 +17,7 @@ export const ASSET_SCHEMA = {
|
||||
PURCHASE_AMOUNT:{ key: 'purchase_amount', db: 'purchase_amount', ui: '구매금액' },
|
||||
PURCHASE_VENDOR:{ key: 'purchase_vendor', db: 'purchase_vendor', ui: '구매업체' },
|
||||
APPROVAL_DOC: { key: 'approval_document', db: 'approval_document', ui: '품의서' },
|
||||
SERVICE_TYPE: { key: 'service_type', db: 'service_type', ui: '서비스 구분' },
|
||||
MANAGER_MAIN: { key: 'manager_primary', db: 'manager_primary', ui: '담당자(정)' },
|
||||
MANAGER_SUB: { key: 'manager_secondary', db: 'manager_secondary', ui: '담당자(부)' },
|
||||
LOCATION: { key: 'location', db: 'location', ui: '자산위치' },
|
||||
@@ -120,12 +121,12 @@ export const PAGE_DESCRIPTIONS: Record<string, { title: string; description: str
|
||||
description: '측량 및 공간 정보 수집에 사용되는 특수 정밀 장비들의 이력과 상태를 관리합니다.',
|
||||
icon: 'map'
|
||||
},
|
||||
'내부': {
|
||||
'내부SW': {
|
||||
title: '사내 개발 S/W 관리',
|
||||
description: '사내에서 자체 개발하거나 운영 중인 시스템 및 소프트웨어 서비스 현황을 관리합니다.',
|
||||
icon: 'code'
|
||||
},
|
||||
'외부': {
|
||||
'외부SW': {
|
||||
title: '외부 상용 S/W 관리',
|
||||
description: '상용 소프트웨어의 라이선스 보유 현황, 사용자 할당 및 만료 일정을 관리합니다.',
|
||||
icon: 'package'
|
||||
|
||||
@@ -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<AppState>) {
|
||||
}
|
||||
|
||||
/**
|
||||
* 자산 저장 (Generic API)
|
||||
* 자산 저장 (V2 Normalized API)
|
||||
*/
|
||||
export async function saveAsset(category: string, asset: any) {
|
||||
try {
|
||||
const endpointMap: Record<string, string> = {
|
||||
'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<string, string> = {
|
||||
'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(); // 전역 상태 갱신
|
||||
|
||||
@@ -153,14 +153,8 @@ export function dynamicSort<T>(list: T[], key: string, direction: 'asc' | 'desc'
|
||||
}
|
||||
|
||||
/**
|
||||
* 목록 뷰용 액션 버튼 HTML 생성 (자산추가)
|
||||
* 목록 뷰용 액션 버튼 HTML 생성 (중복 제거를 위해 비워둠)
|
||||
*/
|
||||
export function getActionButtonsHTML(): string {
|
||||
return `
|
||||
<div class="search-actions">
|
||||
<button id="btn-add-asset" class="btn btn-primary">
|
||||
<i data-lucide="plus"></i> 자산추가
|
||||
</button>
|
||||
</div>
|
||||
`;
|
||||
return '';
|
||||
}
|
||||
|
||||
50
src/main.ts
50
src/main.ts
@@ -11,37 +11,6 @@ import { initDashboardDetailModal } from './components/Modal/DashboardDetailModa
|
||||
import { initGuide } from './components/Guide';
|
||||
import { createIcons, Plus, X, LayoutDashboard, Monitor, Server, Database, Laptop, CalendarClock, Key, Cpu, Layers, Users, Paperclip, Edit2, History, RefreshCcw, BookOpen, Settings } from 'lucide';
|
||||
|
||||
// --- DB 저장을 위한 세분화된 헬퍼 함수들 ---
|
||||
async function apiBatchSave(url: string, data: any[], label: string) {
|
||||
try {
|
||||
const response = await fetch(url, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(data)
|
||||
});
|
||||
if (!response.ok) {
|
||||
const errorData = await response.json().catch(() => ({}));
|
||||
throw new Error(`${label} DB 저장 실패: ${errorData.error || response.statusText}`);
|
||||
}
|
||||
console.log(`✅ ${label} DB 저장 완료`);
|
||||
} catch (err) {
|
||||
console.error(`❌ ${label} DB 저장 오류:`, err);
|
||||
alert(`${label} 저장 중 오류가 발생했습니다: ${(err as any).message}`);
|
||||
}
|
||||
}
|
||||
|
||||
const savePcToDB = () => apiBatchSave(`http://${location.hostname}:3000/api/pc/batch`, state.masterData.pc, '개인PC');
|
||||
const saveServerToDB = () => apiBatchSave(`http://${location.hostname}:3000/api/server/batch`, state.masterData.server, '서버');
|
||||
const saveStorageToDB = () => apiBatchSave(`http://${location.hostname}:3000/api/storage/batch`, state.masterData.storage, '스토리지');
|
||||
const saveNetworkToDB = () => apiBatchSave(`http://${location.hostname}:3000/api/network/batch`, state.masterData.network, '네트워크');
|
||||
const saveEquipToDB = () => apiBatchSave(`http://${location.hostname}:3000/api/equipment/batch`, state.masterData.equipment, '업무지원장비');
|
||||
const saveSwInternalToDB = () => apiBatchSave(`http://${location.hostname}:3000/api/sw/internal/batch`, state.masterData.swInternal, '내부SW');
|
||||
const saveSwExternalToDB = () => apiBatchSave(`http://${location.hostname}:3000/api/sw/external/batch`, state.masterData.swExternal, '외부SW');
|
||||
const saveCloudToDB = () => apiBatchSave(`http://${location.hostname}:3000/api/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, '자산 로그');
|
||||
const saveUsersToDB = () => apiBatchSave(`http://${location.hostname}:3000/api/users/batch`, state.masterData.users, '사용자마스터');
|
||||
|
||||
// 화면 갱신 통합 핸들러
|
||||
function refreshView() {
|
||||
const mainContent = document.getElementById('main-content')!;
|
||||
@@ -54,13 +23,8 @@ function refreshView() {
|
||||
}
|
||||
}
|
||||
|
||||
// 통합 저장 및 갱신
|
||||
async function saveAllDataToDB() {
|
||||
await Promise.all([
|
||||
savePcToDB(), saveServerToDB(), saveStorageToDB(), saveNetworkToDB(),
|
||||
saveEquipToDB(), saveSwInternalToDB(), saveSwExternalToDB(),
|
||||
saveCloudToDB(), saveSwUsersToDB(), saveLogsToDB(), saveUsersToDB()
|
||||
]);
|
||||
// 통합 갱신 (저장은 이미 개별 모달에서 처리됨)
|
||||
async function refreshAllData() {
|
||||
await loadMasterDataFromDB();
|
||||
refreshView();
|
||||
}
|
||||
@@ -81,14 +45,12 @@ function initApp() {
|
||||
}
|
||||
});
|
||||
|
||||
initHwModal(() => saveAllDataToDB(), closeAllModals);
|
||||
initSwModal(() => saveAllDataToDB(), closeAllModals);
|
||||
initHwModal(() => refreshAllData(), closeAllModals);
|
||||
initSwModal(() => refreshAllData(), closeAllModals);
|
||||
initSwUserModal(() => {
|
||||
saveSwUsersToDB().then(() => {
|
||||
loadMasterDataFromDB().then(() => refreshView());
|
||||
});
|
||||
}, closeAllModals);
|
||||
initDomainModal(() => saveAllDataToDB(), closeAllModals);
|
||||
initDomainModal(() => refreshAllData(), closeAllModals);
|
||||
|
||||
initDashboardDetailModal();
|
||||
initGuide();
|
||||
@@ -121,7 +83,7 @@ function initApp() {
|
||||
if (cat === 'hw') {
|
||||
openHwModal({ id: newId, asset_code: '', category: tab } as any, 'add');
|
||||
} else if (cat === 'sw') {
|
||||
const swType = tab === '외부' ? '외부SW' : (tab === '내부' ? '내부SW' : '외부SW');
|
||||
const swType = tab === '외부SW' ? '외부SW' : (tab === '내부SW' ? '내부SW' : '외부SW');
|
||||
openSwModal({ id: newId, asset_type: swType } as any, 'add');
|
||||
} else if (cat === 'ops') {
|
||||
if (tab === '도메인') openDomainModal(null);
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
:root {
|
||||
/* --- System Colors (Added) --- */
|
||||
/* --- System Colors --- */
|
||||
--color-red: #F21D0D;
|
||||
--color-pink: #E8175E;
|
||||
--color-magenta: #B92ED1;
|
||||
@@ -15,37 +15,6 @@
|
||||
--color-iron: #7F7F7F;
|
||||
--color-steel: #688897;
|
||||
|
||||
--color-red-light: #FEE9E7;
|
||||
--color-pink-light: #FDE8EF;
|
||||
--color-magenta-light: #F8EBFB;
|
||||
--color-purple-light: #F1ECF9;
|
||||
--color-navy-light: #EDEEF9;
|
||||
--color-blue-light: #E7F4FE;
|
||||
--color-cyan-light: #E6F7FF;
|
||||
--color-green-light: #EEF8EE;
|
||||
--color-yellow-light: #FFF9E6;
|
||||
--color-orange-light: #FFF5E6;
|
||||
--color-dahong-light: #FFECE6;
|
||||
--color-brown-light: #F6F1EF;
|
||||
--color-iron-light: #F3F3F3;
|
||||
--color-steel-light: #F0F4F5;
|
||||
|
||||
--color-red-medium: #FAA59E;
|
||||
--color-pink-medium: #F6A2BF;
|
||||
--color-magenta-medium: #E3ABEC;
|
||||
--color-purple-medium: #C5B1E7;
|
||||
--color-navy-medium: #B3BBE5;
|
||||
--color-blue-medium: #9ED1FA;
|
||||
--color-cyan-medium: #9ADFFE;
|
||||
--color-green-medium: #B8E0B9;
|
||||
--color-yellow-medium: #FFE599;
|
||||
--color-orange-medium: #FFD699;
|
||||
--color-dahong-medium: #FFB199;
|
||||
--color-dahong: #FF3D00;
|
||||
--color-dahong-light: #FFECE6;
|
||||
--color-dahong-medium: #FFB199;
|
||||
--color-dahong-dark: #cc3100;
|
||||
|
||||
/* --- Primary Brand Levels --- */
|
||||
--primary-lv-0: #E9EEED;
|
||||
--primary-lv-1: #D2DCDB;
|
||||
@@ -64,39 +33,30 @@
|
||||
--primary-light: var(--primary-lv-0);
|
||||
|
||||
--edit-mode-color: var(--color-dahong);
|
||||
--edit-mode-light: var(--color-dahong-light);
|
||||
--edit-mode-focus: var(--color-dahong-medium);
|
||||
--edit-mode-dark: var(--color-dahong-dark);
|
||||
--edit-mode-light: rgba(255, 61, 0, 0.1);
|
||||
--edit-mode-focus: rgba(255, 61, 0, 0.3);
|
||||
--edit-mode-dark: #cc3100;
|
||||
|
||||
--text-main: #111827;
|
||||
--text-muted: #6B7280;
|
||||
--border-color: #E5E7EB;
|
||||
--bg-color: #F9FAFB;
|
||||
--bg-light: #FAFAFA;
|
||||
--sidebar-bg: #ffffff;
|
||||
--white: #FFFFFF;
|
||||
--danger: var(--color-red);
|
||||
--info: var(--color-blue);
|
||||
--success: var(--color-green);
|
||||
--warning: var(--color-orange);
|
||||
|
||||
--dash-primary: #6cc020;
|
||||
--dash-light: #f2f9ec;
|
||||
--dash-danger: #cf222e;
|
||||
|
||||
--header-height: 52px;
|
||||
}
|
||||
}
|
||||
|
||||
* {
|
||||
box-sizing: border-box;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
letter-spacing: -0.02em;
|
||||
/* 모든 요소에 자간 규칙 일괄 적용 */
|
||||
}
|
||||
|
||||
body {
|
||||
font-family: 'Pretendard Variable', Pretendard, -apple-system, BlinkMacSystemFont, system-ui, Roboto, 'Helvetica Neue', 'Segoe UI', 'Apple SD Gothic Neo', 'Noto Sans KR', 'Malgun Gothic', sans-serif;
|
||||
font-family: 'Pretendard Variable', Pretendard, sans-serif;
|
||||
color: var(--text-main);
|
||||
background-color: var(--bg-color);
|
||||
line-height: 1.5;
|
||||
@@ -111,12 +71,13 @@ body {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
/* --- Main Header & GNB/LNB --- */
|
||||
/* --- Header --- */
|
||||
.main-header {
|
||||
background-color: var(--white);
|
||||
border-bottom: 1px solid var(--border-color);
|
||||
z-index: 100;
|
||||
height: var(--header-height);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.header-container {
|
||||
@@ -127,239 +88,46 @@ body {
|
||||
gap: 1.5rem;
|
||||
}
|
||||
|
||||
.brand {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.75rem;
|
||||
}
|
||||
.brand { display: flex; align-items: center; gap: 0.75rem; }
|
||||
.main-logo { height: 34px; width: auto; }
|
||||
.brand h1 { font-size: 1.1rem; font-weight: 800; color: var(--text-main); white-space: nowrap; }
|
||||
.brand h1 .sub-title { font-size: 0.85rem; color: var(--primary-color); font-weight: 600; margin-left: 0.25rem; }
|
||||
|
||||
.main-logo {
|
||||
height: 34px;
|
||||
width: auto;
|
||||
}
|
||||
.integrated-nav { flex: 1; height: 100%; display: flex; align-items: center; gap: 0.25rem; overflow: hidden; }
|
||||
.nav-group { display: flex; align-items: center; height: 100%; position: relative; flex-shrink: 0; }
|
||||
.gnb-trigger { font-size: 14px; font-weight: 700; color: var(--text-muted); padding: 0 0.75rem; cursor: pointer; height: 100%; display: flex; align-items: center; white-space: nowrap; transition: color 0.2s; }
|
||||
.nav-group.active .gnb-trigger, .nav-group:hover .gnb-trigger { color: var(--text-main); }
|
||||
.lnb-shelf { display: none; align-items: center; gap: 0.2rem; padding: 0 0.5rem; height: 60%; border-left: 1px solid var(--border-color); margin-left: 0.2rem; }
|
||||
|
||||
.brand h1 {
|
||||
font-size: 1.1rem;
|
||||
/* 전체적으로 살짝 축소 */
|
||||
font-weight: 800;
|
||||
color: var(--text-main);
|
||||
white-space: nowrap;
|
||||
}
|
||||
/* 기본적으로 활성 탭의 서브메뉴 표시 */
|
||||
.nav-group.active.is-showing-shelf .lnb-shelf { display: flex; }
|
||||
|
||||
.brand h1 .sub-title {
|
||||
font-size: 0.85rem;
|
||||
/* 영문 제목은 더 작게 */
|
||||
color: var(--primary-color);
|
||||
font-weight: 600;
|
||||
margin-left: 0.25rem;
|
||||
}
|
||||
/* GNB 전체 영역에 마우스가 올라가면 활성 탭의 서브메뉴를 일단 숨김 (다른 메뉴 탐색 우선) */
|
||||
.integrated-nav:hover .nav-group.active.is-showing-shelf .lnb-shelf { display: none; }
|
||||
|
||||
.integrated-nav {
|
||||
flex: 1;
|
||||
height: 100%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
/* 마우스가 올라간 메뉴의 서브메뉴만 표시 */
|
||||
.nav-group:hover .lnb-shelf { display: flex !important; }
|
||||
|
||||
.nav-group {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
height: 100%;
|
||||
}
|
||||
.lnb-item { font-size: 13px; font-weight: 500; color: var(--text-muted); cursor: pointer; padding: 0.2rem 0.6rem; border-radius: 4px; white-space: nowrap; transition: all 0.2s; }
|
||||
.lnb-item:hover { color: var(--primary-color); background-color: var(--primary-light); }
|
||||
.lnb-item.active { color: var(--primary-color); background-color: var(--primary-light); font-weight: 700; }
|
||||
|
||||
.gnb-trigger {
|
||||
font-size: 14px;
|
||||
font-weight: 700;
|
||||
color: var(--text-main);
|
||||
padding: 0 1rem;
|
||||
cursor: pointer;
|
||||
height: 100%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.header-actions { display: flex; align-items: center; gap: 1rem; }
|
||||
.role-switcher { display: flex; align-items: center; gap: 0.75rem; padding: 0 0.75rem; border-right: 1px solid var(--border-color); height: 24px; }
|
||||
.role-label { font-size: 11px; font-weight: 700; color: var(--text-muted); }
|
||||
.role-label.active { color: var(--primary-color); }
|
||||
.switch { position: relative; display: inline-block; width: 34px; height: 18px; }
|
||||
.switch input { opacity: 0; width: 0; height: 0; }
|
||||
.slider { position: absolute; cursor: pointer; top: 0; left: 0; right: 0; bottom: 0; background-color: #ccc; transition: .4s; border-radius: 34px; }
|
||||
.slider:before { position: absolute; content: ""; height: 12px; width: 12px; left: 3px; bottom: 3px; background-color: white; transition: .4s; border-radius: 50%; }
|
||||
input:checked + .slider { background-color: var(--color-orange); }
|
||||
input:checked + .slider:before { transform: translateX(16px); }
|
||||
|
||||
.lnb-shelf {
|
||||
display: none;
|
||||
align-items: center;
|
||||
gap: 0.25rem;
|
||||
padding: 0 0.75rem;
|
||||
height: 60%;
|
||||
border-left: 1px solid var(--border-color);
|
||||
margin-left: 0.25rem;
|
||||
animation: fadeIn 0.2s ease-out;
|
||||
}
|
||||
|
||||
.nav-group:hover .lnb-shelf,
|
||||
.nav-group.is-showing-shelf .lnb-shelf {
|
||||
display: flex;
|
||||
}
|
||||
|
||||
.lnb-item {
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
color: var(--text-muted);
|
||||
cursor: pointer;
|
||||
padding: 0.2rem 0.6rem;
|
||||
border-radius: 4px;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.lnb-item:hover {
|
||||
color: var(--primary-color);
|
||||
background-color: var(--bg-color);
|
||||
}
|
||||
|
||||
.lnb-item.active {
|
||||
color: var(--primary-color);
|
||||
background-color: var(--primary-light);
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
@keyframes fadeIn {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateX(-5px);
|
||||
}
|
||||
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateX(0);
|
||||
}
|
||||
}
|
||||
|
||||
/* --- Role Switcher Toggle --- */
|
||||
.role-switcher {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.75rem;
|
||||
margin-right: 0.5rem;
|
||||
padding: 0 0.75rem;
|
||||
border-right: 1px solid var(--border-color);
|
||||
height: 24px;
|
||||
}
|
||||
|
||||
.role-label {
|
||||
font-size: 11px;
|
||||
font-weight: 700;
|
||||
color: var(--text-muted);
|
||||
transition: color 0.2s;
|
||||
}
|
||||
|
||||
.role-label.active {
|
||||
color: var(--primary-color);
|
||||
}
|
||||
|
||||
.role-label.admin.active {
|
||||
color: var(--color-orange);
|
||||
}
|
||||
|
||||
/* Toggle Switch Base */
|
||||
.switch {
|
||||
position: relative;
|
||||
display: inline-block;
|
||||
width: 34px;
|
||||
height: 18px;
|
||||
}
|
||||
|
||||
.switch input {
|
||||
opacity: 0;
|
||||
width: 0;
|
||||
height: 0;
|
||||
}
|
||||
|
||||
.slider {
|
||||
position: absolute;
|
||||
cursor: pointer;
|
||||
top: 0; left: 0; right: 0; bottom: 0;
|
||||
background-color: #ccc;
|
||||
transition: .4s;
|
||||
}
|
||||
|
||||
.slider:before {
|
||||
position: absolute;
|
||||
content: "";
|
||||
height: 12px;
|
||||
width: 12px;
|
||||
left: 3px;
|
||||
bottom: 3px;
|
||||
background-color: white;
|
||||
transition: .4s;
|
||||
}
|
||||
|
||||
input:checked + .slider {
|
||||
background-color: var(--color-orange);
|
||||
}
|
||||
|
||||
input:focus + .slider {
|
||||
box-shadow: 0 0 1px var(--color-orange);
|
||||
}
|
||||
|
||||
input:checked + .slider:before {
|
||||
transform: translateX(16px);
|
||||
}
|
||||
|
||||
.slider.round {
|
||||
border-radius: 34px;
|
||||
}
|
||||
|
||||
.slider.round:before {
|
||||
border-radius: 50%;
|
||||
}
|
||||
|
||||
/* --- Global Actions & Buttons --- */
|
||||
.header-actions {
|
||||
display: flex;
|
||||
gap: 0.3rem;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.btn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 0.35rem;
|
||||
padding: 0 0.8rem;
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
border-radius: 4px;
|
||||
cursor: pointer;
|
||||
height: 28px;
|
||||
line-height: 1;
|
||||
white-space: nowrap; /* 텍스트 줄바꿈 방지 */
|
||||
flex-shrink: 0; /* 크기 찌그러짐 방지 */
|
||||
}
|
||||
|
||||
.btn i,
|
||||
.btn svg {
|
||||
width: 12px !important;
|
||||
height: 12px !important;
|
||||
}
|
||||
|
||||
.btn-primary {
|
||||
background-color: var(--primary-color);
|
||||
color: var(--white);
|
||||
border: 1px solid var(--primary-color);
|
||||
}
|
||||
|
||||
.btn-outline {
|
||||
background-color: transparent;
|
||||
color: var(--text-muted);
|
||||
border: 1px solid var(--border-color);
|
||||
}
|
||||
|
||||
.btn-danger {
|
||||
color: var(--danger) !important;
|
||||
border-color: var(--danger) !important;
|
||||
}
|
||||
|
||||
/* --- Layout Frame --- */
|
||||
/* --- Layout Content --- */
|
||||
.content-area {
|
||||
flex: 1;
|
||||
padding: 1.25rem 2rem 0; /* 상단 여백 1.25rem 추가 */
|
||||
padding: 1.25rem 2rem 0;
|
||||
overflow: hidden;
|
||||
/* 전체 스크롤 차단 */
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
@@ -370,93 +138,58 @@ input:checked + .slider:before {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
/* 내부 스크롤을 유도하기 위해 설정 */
|
||||
}
|
||||
|
||||
.view-content-wrapper {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
padding-bottom: 2rem;
|
||||
}
|
||||
|
||||
/* --- View Toggle --- */
|
||||
.view-toggle-container { margin-bottom: 1rem; display: flex; justify-content: flex-start; }
|
||||
.view-toggle { display: inline-flex; background-color: var(--primary-lv-0); padding: 4px; border-radius: 8px; border: 1px solid var(--border-color); }
|
||||
.toggle-btn { padding: 6px 16px; font-size: 13px; font-weight: 600; color: var(--text-muted); background: none; border: none; border-radius: 6px; cursor: pointer; }
|
||||
.toggle-btn.active { background-color: var(--white); color: var(--primary-color); box-shadow: 0 2px 4px rgba(0,0,0,0.05); }
|
||||
|
||||
/* --- System Status List (Docker Style) --- */
|
||||
.system-status-list { display: flex; flex-direction: column; gap: 0.5rem; }
|
||||
.system-list-header { display: flex; align-items: center; padding: 0.75rem 1.25rem; background-color: var(--bg-light); border-bottom: 1px solid var(--border-color); font-size: 11px; font-weight: 700; color: var(--text-muted); text-transform: uppercase; }
|
||||
.system-row { display: flex; align-items: center; padding: 1rem 1.25rem; background-color: var(--white); border: 1px solid var(--border-color); border-radius: 6px; transition: all 0.2s; }
|
||||
.system-row:hover { border-color: var(--primary-lv-3); box-shadow: 0 4px 12px rgba(0,0,0,0.03); }
|
||||
.col-status { width: 100px; display: flex; align-items: center; gap: 0.5rem; }
|
||||
.col-info { flex: 1.5; }
|
||||
.col-network { flex: 1; }
|
||||
.col-remote { flex: 1; display: flex; align-items: center; gap: 0.5rem; }
|
||||
.col-traffic { flex: 1.2; }
|
||||
.col-actions { width: 120px; display: flex; justify-content: flex-end; }
|
||||
.status-dot { width: 10px; height: 10px; border-radius: 50%; }
|
||||
.status-dot.online { background-color: var(--success); box-shadow: 0 0 6px var(--success); }
|
||||
.status-text { font-size: 11px; font-weight: 600; color: var(--success); }
|
||||
.asset-primary { font-weight: 700; font-size: 14px; }
|
||||
.asset-secondary { font-size: 12px; color: var(--text-muted); }
|
||||
.ip-address { font-weight: 600; font-family: monospace; color: var(--primary-color); }
|
||||
.traffic-mini-chart { display: flex; flex-direction: column; gap: 4px; }
|
||||
.traffic-info { display: flex; justify-content: space-between; font-size: 11px; }
|
||||
.progress-bg { height: 4px; background: var(--primary-lv-0); border-radius: 2px; overflow: hidden; }
|
||||
.progress-fill { height: 100%; background: var(--primary-color); }
|
||||
.icon-btn { width: 28px; height: 28px; display: flex; align-items: center; justify-content: center; border-radius: 4px; border: 1px solid var(--border-color); background: var(--white); color: var(--text-muted); cursor: pointer; }
|
||||
.icon-btn:hover { background-color: var(--primary-light); border-color: var(--primary-color); color: var(--primary-color); }
|
||||
|
||||
/* --- Footer --- */
|
||||
.main-footer {
|
||||
height: 40px;
|
||||
background-color: var(--white);
|
||||
border-top: 1px solid var(--border-color);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: flex-end;
|
||||
padding: 0 1.5rem;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.main-footer { height: 40px; background-color: var(--white); border-top: 1px solid var(--border-color); display: flex; align-items: center; justify-content: flex-end; padding: 0 1.5rem; flex-shrink: 0; }
|
||||
.main-footer p { font-size: 0.75rem; color: var(--text-muted); }
|
||||
|
||||
.main-footer p {
|
||||
font-family: 'Pretendard Variable', Pretendard, sans-serif;
|
||||
font-size: 0.75rem;
|
||||
font-weight: 300;
|
||||
line-height: 1.25rem;
|
||||
letter-spacing: -0.0175rem;
|
||||
color: var(--text-muted);
|
||||
user-select: none;
|
||||
pointer-events: all;
|
||||
-webkit-user-drag: none;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.hidden {
|
||||
display: none !important;
|
||||
}
|
||||
|
||||
.text-nowrap {
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
/* --- Utility Styles --- */
|
||||
.badge {
|
||||
padding: 2px 6px;
|
||||
border-radius: 4px;
|
||||
font-size: 11px;
|
||||
font-weight: 700;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.badge-primary {
|
||||
background-color: var(--primary-color);
|
||||
color: white;
|
||||
}
|
||||
|
||||
.badge-muted {
|
||||
background-color: #9CA3AF;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.text-tag {
|
||||
color: var(--text-muted);
|
||||
font-size: 11px;
|
||||
padding: 1px 5px;
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: 3px;
|
||||
background-color: var(--bg-light);
|
||||
}
|
||||
|
||||
.font-bold {
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
/* --- Responsive Design (Tablet & Mobile) --- */
|
||||
@media (max-width: 1200px) {
|
||||
.header-container { gap: 0.75rem; padding: 0 1rem; }
|
||||
.brand h1 { font-size: 1rem; }
|
||||
.brand h1 .sub-title { font-size: 0.75rem; }
|
||||
}
|
||||
|
||||
@media (max-width: 992px) {
|
||||
.main-header { height: auto; padding: 0.5rem 0; }
|
||||
.header-container { flex-direction: column; align-items: flex-start; gap: 0.5rem; }
|
||||
.integrated-nav { width: 100%; justify-content: flex-start; border-top: 1px solid var(--border-color); padding-top: 0.5rem; }
|
||||
.header-actions { width: 100%; justify-content: flex-end; padding-top: 0.5rem; }
|
||||
.content-area { padding: 0 1rem; }
|
||||
}
|
||||
/* --- Utility --- */
|
||||
.btn { display: inline-flex; align-items: center; justify-content: center; gap: 0.35rem; padding: 0 0.8rem; font-size: 12px; font-weight: 600; border-radius: 4px; cursor: pointer; height: 28px; }
|
||||
.btn-primary { background-color: var(--primary-color); color: var(--white); border: none; }
|
||||
.btn-outline { background-color: transparent; color: var(--text-muted); border: 1px solid var(--border-color); }
|
||||
.badge { padding: 2px 6px; border-radius: 4px; font-size: 11px; font-weight: 700; }
|
||||
.badge-primary { background-color: var(--primary-color); color: white; }
|
||||
.badge-light { background: var(--bg-color); color: var(--text-muted); border: 1px solid var(--border-color); }
|
||||
.hidden { display: none !important; }
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.brand h1 .sub-title { display: none; } /* 아주 좁은 화면에선 영문명 숨김 */
|
||||
.header-actions .btn span { display: none; } /* 버튼 텍스트 숨기고 아이콘만 표시 */
|
||||
.header-actions .btn { padding: 0 0.5rem; }
|
||||
}
|
||||
.brand h1 .sub-title { display: none; }
|
||||
.header-actions .btn span { display: none; }
|
||||
}
|
||||
|
||||
@@ -379,16 +379,21 @@
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
max-height: 500px;
|
||||
padding-right: 0.5rem;
|
||||
padding-right: 8px;
|
||||
margin-top: 8px;
|
||||
}
|
||||
|
||||
.history-item {
|
||||
position: relative;
|
||||
padding-left: 1.25rem;
|
||||
padding-bottom: 1.5rem;
|
||||
padding-left: 20px;
|
||||
padding-bottom: 20px;
|
||||
border-left: 2px solid var(--border-color);
|
||||
}
|
||||
|
||||
.history-item:last-child {
|
||||
border-left: 2px solid transparent;
|
||||
}
|
||||
|
||||
.history-item::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
@@ -399,34 +404,68 @@
|
||||
border-radius: 50%;
|
||||
background-color: var(--white);
|
||||
border: 2px solid var(--primary-color);
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
.history-item:last-child {
|
||||
border-left: 2px solid transparent;
|
||||
/* Event Specific Markers */
|
||||
.history-item.evt-dept::before { border-color: #3b82f6; }
|
||||
.history-item.evt-user::before { border-color: #8b5cf6; }
|
||||
.history-item.evt-role::before { border-color: #10b981; }
|
||||
.history-item.evt-status::before { border-color: #f59e0b; }
|
||||
|
||||
.history-header-row {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.history-date {
|
||||
font-size: 0.75rem;
|
||||
font-size: 11px;
|
||||
color: var(--text-muted);
|
||||
font-weight: 500;
|
||||
margin-bottom: 0.25rem;
|
||||
}
|
||||
|
||||
.history-tag {
|
||||
font-size: 10px;
|
||||
font-weight: 700;
|
||||
padding: 2px 6px;
|
||||
border-radius: 10px;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.tag-dept { background: #eff6ff; color: #3b82f6; }
|
||||
.tag-user { background: #f5f3ff; color: #8b5cf6; }
|
||||
.tag-role { background: #ecfdf5; color: #10b981; }
|
||||
.tag-status { background: #fffbeb; color: #f59e0b; }
|
||||
.tag-default { background: #f3f4f6; color: #6b7280; }
|
||||
|
||||
.history-user {
|
||||
font-size: 0.75rem;
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
color: var(--primary-color);
|
||||
margin-bottom: 0.25rem;
|
||||
color: var(--text-main);
|
||||
margin-bottom: 6px;
|
||||
display: block;
|
||||
}
|
||||
|
||||
.history-details {
|
||||
font-size: 0.8125rem;
|
||||
font-size: 12.5px;
|
||||
color: var(--text-main);
|
||||
line-height: 1.4;
|
||||
white-space: pre-wrap;
|
||||
line-height: 1.5;
|
||||
background: #f8fafc;
|
||||
padding: 8px 10px;
|
||||
border-radius: 6px;
|
||||
border: 1px solid #f1f5f9;
|
||||
word-break: break-all;
|
||||
}
|
||||
|
||||
.history-arrow {
|
||||
display: inline-block;
|
||||
margin: 0 4px;
|
||||
color: var(--text-muted);
|
||||
font-weight: 400;
|
||||
}
|
||||
|
||||
.empty-history {
|
||||
padding: 2rem 0;
|
||||
text-align: center;
|
||||
@@ -691,3 +730,100 @@
|
||||
.location-detail-container select {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
/* Dynamic Remote Info Row */
|
||||
.remote-info-row {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
padding: 8px 0;
|
||||
border-bottom: 1px dashed var(--border-color);
|
||||
}
|
||||
|
||||
.remote-info-row:last-child {
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
.ri-line {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.ri-line select,
|
||||
.ri-line input {
|
||||
height: 38px;
|
||||
box-sizing: border-box;
|
||||
font-size: 13px;
|
||||
padding: 0 10px;
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: 4px;
|
||||
outline: none;
|
||||
background-color: var(--white);
|
||||
color: var(--text-main);
|
||||
transition: border-color 0.2s;
|
||||
}
|
||||
|
||||
.ri-line select:disabled,
|
||||
.ri-line input[readonly] {
|
||||
background-color: var(--bg-muted);
|
||||
border-color: transparent;
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
.ri-line select:focus,
|
||||
.ri-line input:focus {
|
||||
border-color: var(--primary-color);
|
||||
box-shadow: 0 0 0 2px rgba(30, 81, 73, 0.1);
|
||||
}
|
||||
|
||||
.ri-type, .ri-tool {
|
||||
width: 110px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.ri-val1, .ri-id, .ri-pw {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.ri-remove-btn {
|
||||
height: 38px;
|
||||
width: 38px;
|
||||
padding: 0;
|
||||
color: #E11D48;
|
||||
border: 1px solid #E11D48;
|
||||
background: transparent;
|
||||
border-radius: 4px;
|
||||
cursor: pointer;
|
||||
flex-shrink: 0;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 18px;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
.ri-remove-btn:hover {
|
||||
background-color: #FFF1F2;
|
||||
}
|
||||
|
||||
.ri-spacer {
|
||||
width: 46px; /* 38px btn + 8px gap */
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.ri-connector {
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
border-left: 1.5px solid #94a3b8;
|
||||
border-bottom: 1.5px solid #94a3b8;
|
||||
margin-top: -24px;
|
||||
margin-left: 12px;
|
||||
border-bottom-left-radius: 6px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.ri-cred-line {
|
||||
margin-top: -4px;
|
||||
}
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
import { ASSET_SCHEMA, UI_TEXT } from '../../core/schema';
|
||||
import { dynamicSort, renderPageHeader } from '../../core/utils';
|
||||
import { dynamicSort, renderPageHeader, calculateAssetAge, formatInline } from '../../core/utils';
|
||||
import { setupTableSorting, SortState } from '../../core/tableHandler';
|
||||
import { renderFilterBar, applyCommonFilters } from '../../core/filterHandler';
|
||||
import { createIcons, RefreshCcw, Plus, Edit2, Trash2, Users, Cloud, CreditCard, DollarSign, Paperclip } from 'lucide';
|
||||
import { state } from '../../core/state';
|
||||
import { IMAGE_LOCATIONS } from '../../components/Modal/SharedData';
|
||||
|
||||
export interface ColumnDef {
|
||||
header: string;
|
||||
@@ -28,97 +29,548 @@ export interface ListViewConfig {
|
||||
columns: ColumnDef[];
|
||||
onRowClick?: (asset: any) => void;
|
||||
emptyMessage?: string;
|
||||
persistentSortState?: SortState; // Allow passing external sort state (like DomainListView)
|
||||
persistentSortState?: SortState;
|
||||
}
|
||||
|
||||
export function createListView(container: HTMLElement, config: ListViewConfig) {
|
||||
// 1. 컨테이너 초기화 및 헤더 렌더링
|
||||
container.innerHTML = '';
|
||||
renderPageHeader(container, config.title);
|
||||
|
||||
const fullList = config.dataSource();
|
||||
let sortState: SortState = config.persistentSortState || { key: '', direction: 'asc' };
|
||||
|
||||
// Initialize currentFilters with all possible keys to avoid undefined issues
|
||||
let currentFilters: any = { keyword: '', corp: '', dept: '', loc: '', field: '', type: '' };
|
||||
|
||||
// 강제로 기본 뷰 모드를 'system' (자산 현황)으로 설정
|
||||
(state as any).currentViewMode = 'system';
|
||||
|
||||
// 2. 뷰 전환 토글 버튼 생성 (명칭 변경)
|
||||
const toggleWrapper = document.createElement('div');
|
||||
toggleWrapper.className = 'view-toggle-container';
|
||||
toggleWrapper.innerHTML = `
|
||||
<div style="display: flex; justify-content: space-between; align-items: center; width: 100%;">
|
||||
<div class="view-toggle" style="display: flex; gap: 0;">
|
||||
<button class="toggle-btn ${(state as any).currentViewMode === 'system' ? 'active' : ''}" data-mode="system">자산 현황</button>
|
||||
<button class="toggle-btn ${(state as any).currentViewMode === 'asset' ? 'active' : ''}" data-mode="asset">자산 목록</button>
|
||||
</div>
|
||||
<button id="btn-add-asset" style="padding: 6px 14px; font-size: 12px; font-weight: 700; background: #1E5149; color: white; border: none; border-radius: 4px; cursor: pointer; display: flex; align-items: center; gap: 4px;">
|
||||
<span style="font-size: 16px; line-height: 1;">+</span> 자산 추가
|
||||
</button>
|
||||
</div>
|
||||
`;
|
||||
container.appendChild(toggleWrapper);
|
||||
|
||||
// 3. 필터 바 생성 (자산 목록에서만 사용)
|
||||
const filterBar = document.createElement('div');
|
||||
filterBar.className = 'search-bar';
|
||||
container.appendChild(filterBar);
|
||||
|
||||
// 4. 컨텐츠 영역 생성
|
||||
const contentWrapper = document.createElement('div');
|
||||
contentWrapper.className = 'view-content-wrapper';
|
||||
container.appendChild(contentWrapper);
|
||||
|
||||
// --- 내부 상태 ---
|
||||
let selectedLocation: string | null = '기술개발센터';
|
||||
let selectedDetailLocation: string | null = null;
|
||||
let dynamicMapConfig: Record<string, any[]> = {};
|
||||
|
||||
// 맵 설정 미리 로드
|
||||
const fetchMapConfig = async () => {
|
||||
try {
|
||||
const res = await fetch(`http://${location.hostname}:3000/api/maps`);
|
||||
dynamicMapConfig = await res.json();
|
||||
} catch (err) { console.error('Failed to fetch map config:', err); }
|
||||
};
|
||||
fetchMapConfig();
|
||||
|
||||
// [자산 현황] 대시보드 렌더러
|
||||
const renderSystemStatus = () => {
|
||||
const isPcView = config.title === 'PC';
|
||||
|
||||
const locationCounts: Record<string, number> = {};
|
||||
const pcTypeCounts = { public: 0, server: 0, personal: 0 };
|
||||
|
||||
// 동적 통계 수집 객체 (Hardcoding 제거)
|
||||
const extStats = {
|
||||
total: 0,
|
||||
locCounts: {} as Record<string, number>,
|
||||
typeCounts: {} as Record<string, number>,
|
||||
typeLocMap: {} as Record<string, Record<string, number>>, // 유형별 위치 분포
|
||||
locWarning: 0,
|
||||
typeWarning: 0
|
||||
};
|
||||
const intStats = {
|
||||
total: 0,
|
||||
locCounts: {} as Record<string, number>,
|
||||
typeCounts: {} as Record<string, number>,
|
||||
typeLocMap: {} as Record<string, Record<string, number>>
|
||||
};
|
||||
|
||||
// 중앙화된 경고 감지 로직
|
||||
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 as any).SERVICE_TYPE?.key || 'service_type';
|
||||
const serviceType = asset[serviceTypeKey] || '외부';
|
||||
const type = asset[ASSET_SCHEMA.ASSET_TYPE.key] || '';
|
||||
|
||||
locationCounts[loc] = (locationCounts[loc] || 0) + 1;
|
||||
|
||||
if (isPcView) {
|
||||
if (type.includes('공용')) pcTypeCounts.public++;
|
||||
else if (type.includes('서버')) pcTypeCounts.server++;
|
||||
else pcTypeCounts.personal++;
|
||||
}
|
||||
|
||||
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 (!targetStat.typeLocMap[type]) targetStat.typeLocMap[type] = {};
|
||||
targetStat.typeLocMap[type][loc] = (targetStat.typeLocMap[type][loc] || 0) + 1;
|
||||
}
|
||||
|
||||
if (serviceType === '외부') {
|
||||
const anomaly = checkAnomaly(serviceType, loc, type);
|
||||
if (anomaly.isLocWarning) extStats.locWarning++;
|
||||
if (anomaly.isTypeWarning) extStats.typeWarning++;
|
||||
}
|
||||
});
|
||||
|
||||
// 템플릿 제너레이터 함수 (HTML 중복 제거)
|
||||
const generateDetailStatHTML = (title: string, stats: any) => `
|
||||
<div style="display: flex; align-items: center; justify-content: space-between; margin-bottom: 0.5rem; gap: 0.5rem;">
|
||||
<span style="font-size: 14px; font-weight: 800; color: var(--text-main); white-space: nowrap;">${title}</span>
|
||||
<div style="display: flex; gap: 4px; flex-wrap: wrap; justify-content: flex-end;">
|
||||
${stats.locWarning ? `<span style="background: #FFF7ED; color: #C2410C; font-size: 10px; font-weight: 800; padding: 2px 6px; border-radius: 4px; border: 1px solid #FFEDD5; white-space: nowrap;">위치부적절: ${stats.locWarning}</span>` : ''}
|
||||
${stats.typeWarning ? `<span style="background: #FFF1F2; color: #E11D48; font-size: 10px; font-weight: 800; padding: 2px 6px; border-radius: 4px; border: 1px solid #FDA4AF; white-space: nowrap;">형식부적절: ${stats.typeWarning}</span>` : ''}
|
||||
</div>
|
||||
</div>
|
||||
<div style="display: flex; flex-direction: column; gap: 0.3rem; font-size: 13px; color: var(--text-muted);">
|
||||
<div style="display: flex; gap: 0.75rem; flex-wrap: wrap;">
|
||||
${Object.entries(stats.locCounts as Record<string, number>).sort((a, b) => b[1] - a[1]).slice(0, 4).map(([l, c]) => `<span>${l}: <strong style="color:var(--text-main); font-size: 14px;">${c}</strong></span>`).join('')}
|
||||
</div>
|
||||
<div style="display: flex; gap: 0.6rem; flex-wrap: wrap; opacity: 0.9; border-top: 1px dashed var(--border-color); padding-top: 4px; margin-top: 2px;">
|
||||
${Object.entries(stats.typeCounts as Record<string, number>).sort((a, b) => b[1] - a[1]).slice(0, 6).map(([t, c]) => {
|
||||
const isTypeWarning = title.includes('외부') && t.toLowerCase().replace(/\s/g, '').includes('서버pc');
|
||||
|
||||
// 위치별 상세 정보 생성 (툴팁용)
|
||||
const locDist = stats.typeLocMap[t] || {};
|
||||
const locHint = Object.entries(locDist)
|
||||
.sort((a: any, b: any) => b[1] - a[1])
|
||||
.map(([l, count]) => `${l}: ${count}대`)
|
||||
.join('\n');
|
||||
|
||||
return `<span title="${locHint}" style="${isTypeWarning ? 'color:#E11D48; font-weight:700;' : ''}; font-size: 13px; cursor: help;">${t}: <strong style="color:var(--text-main); font-size: 14px;">${c}</strong></span>`;
|
||||
}).join('')}
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
|
||||
contentWrapper.innerHTML = `
|
||||
<div class="system-dashboard" style="height: calc(100vh - 240px); overflow: hidden; padding: 0.5rem 0; font-family: 'Pretendard', sans-serif; letter-spacing: -0.02em; display: flex; flex-direction: column;">
|
||||
|
||||
<!-- [자산 통계 그룹] -->
|
||||
<div style="border-bottom: 1px solid var(--border-color); padding-bottom: 1.25rem; margin-bottom: 1rem; flex-shrink: 0; display: grid; grid-template-columns: 1fr 1.5fr 1.5fr; gap: 2rem;">
|
||||
<div class="stat-group-item" style="min-width: 0;">
|
||||
<div style="font-size: 11px; font-weight: 600; color: var(--text-muted); margin-bottom: 0.25rem;">총 보유 자산</div>
|
||||
<div style="font-size: 28px; font-weight: 800; color: var(--text-main); line-height: 1.1;">${fullList.length}<span style="font-size: 13px; font-weight: 600; margin-left: 4px; color: var(--text-muted);">대</span></div>
|
||||
<div style="display: flex; gap: 0.75rem; font-size: 14px; color: var(--text-muted); margin-top: 0.5rem;">
|
||||
<span>외부: <strong style="color:#35635C; font-size: 18px;">${extStats.total}</strong></span>
|
||||
<span>내부: <strong style="color:#94A3B8; font-size: 18px;">${intStats.total}</strong></span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="stat-group-item" style="border-left: 1px solid var(--border-color); padding-left: 1.5rem; min-width: 0;">
|
||||
${isPcView ? `
|
||||
<div style="font-size: 11px; font-weight: 600; color: var(--text-muted); margin-bottom: 0.25rem;">PC 유형별 현황</div>
|
||||
<div style="display: flex; gap: 1rem; font-size: 14px; color: var(--text-muted); margin-top: 0.5rem;">
|
||||
<span>공용: <strong style="color:var(--text-main); font-size: 18px;">${pcTypeCounts.public}</strong></span>
|
||||
<span>서버: <strong style="color:var(--text-main); font-size: 18px;">${pcTypeCounts.server}</strong></span>
|
||||
<span>개인: <strong style="color:var(--text-main); font-size: 18px;">${pcTypeCounts.personal}</strong></span>
|
||||
</div>
|
||||
` : generateDetailStatHTML('외부 (운영) 상세', extStats)}
|
||||
</div>
|
||||
|
||||
<div class="stat-group-item" style="border-left: 1px solid var(--border-color); padding-left: 1.5rem; min-width: 0;">
|
||||
${isPcView ? '' : generateDetailStatHTML('내부 (테스트) 상세', intStats as any)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div style="display: flex; flex: 1; min-height: 0; border-top: 1px solid var(--border-color);">
|
||||
<!-- 좌측: 자산 현황 목록 (Border-based Separation) -->
|
||||
<div class="list-section" style="flex: 1.1; display: flex; flex-direction: column; min-height: 0; padding: 1rem 1.5rem 0 0; border-right: 1px solid var(--border-color);">
|
||||
<div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 1rem; flex-shrink: 0;">
|
||||
<h4 id="list-section-title" style="font-size: 14px; font-weight: 700; color: var(--text-main); margin:0;">자산 현황 목록</h4>
|
||||
<div style="display: flex; align-items: center; gap: 8px;">
|
||||
<span style="font-size: 11px; font-weight: 600; color: var(--text-muted);">위치:</span>
|
||||
<select id="select-loc" style="padding: 2px 8px; font-size: 11px; border-radius: 4px; border: 1px solid var(--border-color); outline: none; background: white; cursor:pointer; font-family: 'Pretendard';">
|
||||
<option value="">전체</option>
|
||||
${Array.from(new Set(fullList.map(a => a[ASSET_SCHEMA.LOCATION.key] || '미지정'))).sort().map(l => `<option value="${l}" ${l === selectedLocation ? 'selected' : ''}>${l}</option>`).join('')}
|
||||
</select>
|
||||
<span style="font-size: 11px; font-weight: 600; color: var(--text-muted);">상세:</span>
|
||||
<select id="select-detail-loc" style="padding: 2px 8px; font-size: 11px; border-radius: 4px; border: 1px solid var(--border-color); outline: none; background: white; cursor:pointer; font-family: 'Pretendard'; max-width: 120px;"></select>
|
||||
</div>
|
||||
</div>
|
||||
<div style="flex: 1; overflow-y: auto;">
|
||||
<table style="width: 100%; border-collapse: collapse; table-layout: fixed;">
|
||||
<thead style="position: sticky; top: 0; background: #fff; z-index: 10;">
|
||||
<tr style="text-align: left; font-size: 11px; color: var(--text-muted);">
|
||||
<th style="padding: 10px 0; font-weight: 700; border-bottom: 2px solid var(--border-color); width: 80px; text-align:center; background: #fff;">분류</th>
|
||||
<th style="padding: 10px 0; font-weight: 700; border-bottom: 2px solid var(--border-color); width: 130px; background: #fff;">용도/자산명</th>
|
||||
<th style="padding: 10px 0; font-weight: 700; border-bottom: 2px solid var(--border-color); text-align:center; width: 90px; background: #fff;">관리자(정)</th>
|
||||
<th style="padding: 10px 0; font-weight: 700; border-bottom: 2px solid var(--border-color); text-align:center; width: 90px; background: #fff;">관리자(부)</th>
|
||||
<th style="padding: 10px 0; text-align: center; font-weight: 700; border-bottom: 2px solid var(--border-color); width: 100px; background: #fff;">상세위치</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="system-status-tbody" style="font-size: 12px;"></tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 우측: 상세 정보 패널 (Box-less, Line-based) -->
|
||||
<div id="system-detail-panel" style="flex: 0.9; display: flex; flex-direction: column; min-height: 0; padding: 1rem 0 0 1.5rem; overflow: hidden;">
|
||||
<div id="detail-empty-state" style="height: 100%; display: flex; flex-direction: column; justify-content: center; align-items: center; color: var(--text-muted); text-align: center;">
|
||||
<p style="font-size: 1.125rem; font-weight: 500; color: #94A3B8;">목록에서 자산을 선택하면<br>상세 정보와 배치도가 표시됩니다.</p>
|
||||
</div>
|
||||
<div id="detail-content" style="display: none; height: 100%; flex-direction: column;">
|
||||
<!-- 상단 요약 정보 (Wrapping 방지 최적화) -->
|
||||
<div style="display: flex; justify-content: space-between; align-items: flex-end; margin-bottom: 1.5rem; padding-bottom: 1rem; border-bottom: 1px solid var(--border-color); flex-shrink: 0; gap: 2rem;">
|
||||
<div style="display: flex; gap: 2.5rem; align-items: flex-end; min-width: 0; flex: 1;">
|
||||
<div style="flex-shrink: 0;">
|
||||
<label style="display: block; font-size: 10px; font-weight: 700; color: var(--text-muted); text-transform: uppercase; margin-bottom: 4px;">자산번호</label>
|
||||
<div id="detail-asset-code" style="font-size: 14px; font-weight: 800; color: var(--primary-color); white-space: nowrap;"></div>
|
||||
</div>
|
||||
<div style="flex-shrink: 0;">
|
||||
<label style="display: block; font-size: 10px; font-weight: 700; color: var(--text-muted); text-transform: uppercase; margin-bottom: 4px;">유형</label>
|
||||
<div id="detail-asset-type" style="font-size: 14px; font-weight: 600; color: var(--text-main); white-space: nowrap;"></div>
|
||||
</div>
|
||||
<div style="flex: 1; min-width: 0;">
|
||||
<label style="display: block; font-size: 10px; font-weight: 700; color: var(--text-muted); text-transform: uppercase; margin-bottom: 4px;">메모 요약</label>
|
||||
<div id="detail-memo" style="font-size: 14px; color: var(--text-main); font-weight: 500; white-space: nowrap; overflow: hidden; text-overflow: ellipsis;"></div>
|
||||
</div>
|
||||
</div>
|
||||
<button id="btn-view-full-detail" style="flex-shrink: 0; padding: 6px 16px; font-size: 12px; font-weight: 700; background: var(--primary-color); color: white; border: none; border-radius: 4px; cursor: pointer; transition: opacity 0.2s;">상세 보기</button>
|
||||
</div>
|
||||
|
||||
<!-- 메인 배치도 영역 -->
|
||||
<div style="flex: 1; display: flex; flex-direction: column; min-height: 0; overflow: hidden;">
|
||||
<div style="margin-bottom: 0.75rem; flex-shrink: 0; display: flex; justify-content: space-between; align-items: center;">
|
||||
<label style="font-size: 11px; font-weight: 700; color: var(--text-main); text-transform: uppercase;">설치 위치 배치도</label>
|
||||
</div>
|
||||
<div id="detail-photo-wrapper" style="width: 100%; flex: 1; overflow: hidden; display: flex; align-items: center; justify-content: center; position: relative; border: 1px solid var(--border-color); background: #f0f0f0;">
|
||||
<div class="layout-map-container readonly" style="position: relative; display: flex; align-items: center; justify-content: center; width: 100%; height: 100%;">
|
||||
<img id="detail-photo" src="" style="display: block; max-width: 100%; max-height: 100%; width: auto; height: auto; object-fit: contain; pointer-events: none;" />
|
||||
<div id="detail-marker" class="layout-marker pulse-marker" style="display: none; position: absolute; z-index: 20;"></div>
|
||||
<div id="detail-overlay-layer" class="digital-overlay-layer" style="position: absolute; top: 0; left: 0; width: 100%; height: 100%; pointer-events: none; display: flex; align-items: center; justify-content: center;"></div>
|
||||
</div>
|
||||
<div id="detail-no-photo" style="display: none; height: 100%; flex-direction: column; align-items: center; justify-content: center; gap: 1rem;">
|
||||
<span style="color: #94A3B8; font-size: 13px; font-weight: 500;">등록된 배치도가 없습니다.</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
|
||||
// 상세 정보 패널 업데이트 함수
|
||||
const updateDetailPanel = (asset: any) => {
|
||||
const emptyState = document.getElementById('detail-empty-state');
|
||||
const content = document.getElementById('detail-content');
|
||||
if (!emptyState || !content) return;
|
||||
|
||||
emptyState.style.display = 'none';
|
||||
content.style.display = 'flex';
|
||||
|
||||
// 텍스트 정보 업데이트
|
||||
const codeEl = document.getElementById('detail-asset-code');
|
||||
const typeEl = document.getElementById('detail-asset-type');
|
||||
const memoEl = document.getElementById('detail-memo');
|
||||
const viewBtn = document.getElementById('btn-view-full-detail') as HTMLButtonElement;
|
||||
|
||||
if (codeEl) codeEl.textContent = asset.asset_code || '미지정';
|
||||
if (typeEl) typeEl.textContent = asset.asset_type || '-';
|
||||
if (memoEl) memoEl.textContent = asset.memo || '-';
|
||||
if (viewBtn) {
|
||||
viewBtn.onclick = () => config.onRowClick && config.onRowClick(asset);
|
||||
}
|
||||
|
||||
// 위치 및 사진 정보 업데이트
|
||||
const photo = document.getElementById('detail-photo') as HTMLImageElement;
|
||||
const marker = document.getElementById('detail-marker');
|
||||
const overlayLayer = document.getElementById('detail-overlay-layer');
|
||||
const noPhoto = document.getElementById('detail-no-photo');
|
||||
const photoWrapper = document.getElementById('detail-photo-wrapper');
|
||||
|
||||
const bldg = asset.location || '';
|
||||
const detail = asset.location_detail || '';
|
||||
// 숫자 0도 유효한 좌표이므로 정확한 체크 필요
|
||||
const x = asset.loc_x;
|
||||
const y = asset.loc_y;
|
||||
const hasCoords = (x !== null && x !== undefined && x !== '' && x !== 'null') &&
|
||||
(y !== null && y !== undefined && y !== '' && y !== 'null');
|
||||
|
||||
const savedImg = asset.location_photo || asset.loc_img;
|
||||
const locImgs = IMAGE_LOCATIONS[bldg.trim()]?.[detail.trim()] || null;
|
||||
const imgPath = (savedImg && locImgs?.includes(savedImg)) ? savedImg : (locImgs ? locImgs[0] : null);
|
||||
|
||||
// 좌표가 없으면 사진이 있어도 '정보 없음' 상태로 유도 (사용자 요청)
|
||||
if (imgPath && hasCoords) {
|
||||
photo.src = imgPath;
|
||||
photo.style.display = 'block';
|
||||
if (noPhoto) noPhoto.style.display = 'none';
|
||||
|
||||
photo.onload = () => {
|
||||
const updateMarkerPos = () => {
|
||||
const imgW = photo.clientWidth;
|
||||
const imgH = photo.clientHeight;
|
||||
|
||||
if (marker) {
|
||||
marker.style.left = `calc(50% - ${imgW/2}px + ${ (parseFloat(x as string) * imgW) / 100 }px)`;
|
||||
marker.style.top = `calc(50% - ${imgH/2}px + ${ (parseFloat(y as string) * imgH) / 100 }px)`;
|
||||
marker.style.display = 'block';
|
||||
}
|
||||
|
||||
if (overlayLayer) {
|
||||
overlayLayer.style.width = `${imgW}px`;
|
||||
overlayLayer.style.height = `${imgH}px`;
|
||||
overlayLayer.style.left = `calc(50% - ${imgW/2}px)`;
|
||||
overlayLayer.style.top = `calc(50% - ${imgH/2}px)`;
|
||||
|
||||
const boxes = dynamicMapConfig[imgPath] || [];
|
||||
if (boxes.length > 0) {
|
||||
overlayLayer.innerHTML = `
|
||||
<svg viewBox="0 0 100 100" preserveAspectRatio="none" style="width:100%; height:100%;">
|
||||
<g class="seat-group">
|
||||
${boxes.map((b, i) => {
|
||||
const isSelected = b.x === x && b.y === y;
|
||||
const fill = isSelected ? 'rgba(255, 61, 0, 0.4)' : 'rgba(30, 81, 73, 0.02)';
|
||||
const stroke = isSelected ? '#FF3D00' : 'rgba(30, 81, 73, 0.15)';
|
||||
const strokeWidth = isSelected ? '0.8' : '0.2';
|
||||
|
||||
if (isSelected && marker) {
|
||||
marker.style.left = `calc(50% - ${imgW/2}px + ${ (parseFloat(b.x) + parseFloat(b.w)/2) * imgW / 100 }px)`;
|
||||
marker.style.top = `calc(50% - ${imgH/2}px + ${ (parseFloat(b.y) + parseFloat(b.h)/2) * imgH / 100 }px)`;
|
||||
}
|
||||
|
||||
return `<rect class="map-seat-obj" x="${b.x}" y="${b.y}" width="${b.w}" height="${b.h}" rx="0.5" style="fill:${fill}; stroke:${stroke}; stroke-width:${strokeWidth};" />`;
|
||||
}).join('')}
|
||||
</g>
|
||||
</svg>
|
||||
`;
|
||||
} else {
|
||||
overlayLayer.innerHTML = '';
|
||||
}
|
||||
}
|
||||
};
|
||||
updateMarkerPos();
|
||||
window.addEventListener('resize', updateMarkerPos);
|
||||
};
|
||||
} else {
|
||||
photo.style.display = 'none';
|
||||
if (marker) marker.style.display = 'none';
|
||||
if (overlayLayer) overlayLayer.innerHTML = '';
|
||||
if (noPhoto) {
|
||||
noPhoto.style.display = 'flex';
|
||||
const msg = noPhoto.querySelector('span');
|
||||
if (msg) msg.textContent = !hasCoords ? '등록된 위치 좌표 정보가 없습니다.' : '등록된 배치도가 없습니다.';
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const updateTableOnly = () => {
|
||||
let filtered = selectedLocation
|
||||
? fullList.filter(a => (a[ASSET_SCHEMA.LOCATION.key] || '미지정') === selectedLocation)
|
||||
: fullList;
|
||||
const currentDetailLocs = Array.from(new Set(filtered.map(a => a[ASSET_SCHEMA.LOC_DETAIL.key] || '미지정'))).sort();
|
||||
if (selectedDetailLocation) filtered = filtered.filter(a => (a[ASSET_SCHEMA.LOC_DETAIL.key] || '미지정') === selectedDetailLocation);
|
||||
const finalDisplayList = (!selectedLocation && !selectedDetailLocation) ? filtered.slice(0, 10) : filtered;
|
||||
|
||||
const titleEl = document.getElementById('list-section-title');
|
||||
if (titleEl) titleEl.textContent = selectedLocation ? `${selectedLocation} 자산 현황 (${finalDisplayList.length}대)` : '위치별 자산등록현황 (최근 등록)';
|
||||
const selectEl = document.getElementById('select-detail-loc') as HTMLSelectElement;
|
||||
if (selectEl && !selectedDetailLocation) {
|
||||
selectEl.innerHTML = `<option value="">전체보기</option>` + currentDetailLocs.map(dl => `<option value="${dl}">${dl}</option>`).join('');
|
||||
}
|
||||
|
||||
const tbody = document.getElementById('system-status-tbody');
|
||||
if (tbody) {
|
||||
tbody.innerHTML = finalDisplayList.length === 0
|
||||
? `<tr><td colspan="4" style="padding: 3rem; text-align: center; color: var(--text-muted);">조회된 자산이 없습니다.</td></tr>`
|
||||
: finalDisplayList.map(asset => {
|
||||
const purpose = asset[ASSET_SCHEMA.ASSET_PURPOSE.key] || '';
|
||||
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] || '';
|
||||
|
||||
const labelColor = serviceType === '내부' ? '#94A3B8' : '#35635C';
|
||||
const managerMain = asset[ASSET_SCHEMA.MANAGER_MAIN.key] || '-';
|
||||
const managerSub = asset[ASSET_SCHEMA.MANAGER_SUB.key] || '-';
|
||||
|
||||
// [경고 로직] 외부 운영인데 서버PC이거나 IDC가 아닌 경우
|
||||
const isLocWarning = serviceType === '외부SW' && loc !== 'IDC';
|
||||
const isTypeWarning = serviceType === '외부SW' && type.toLowerCase().replace(/\s/g, '').includes('서버pc');
|
||||
const isWarning = isLocWarning || isTypeWarning;
|
||||
const warningStyle = isWarning ? 'background-color: #FFF1F2; border-left: 3px solid #E11D48;' : '';
|
||||
|
||||
let warningReason = '';
|
||||
if (isLocWarning && isTypeWarning) warningReason = '위치/형식 부적절';
|
||||
else if (isLocWarning) warningReason = '위치 부적절';
|
||||
else if (isTypeWarning) warningReason = '형식 부적절';
|
||||
|
||||
return `
|
||||
<tr style="border-bottom: 1px solid var(--border-color); cursor: pointer; ${warningStyle}" class="mini-row" data-id="${asset.id}">
|
||||
<td style="padding: 10px 0; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; text-align:center;">
|
||||
<div style="display:flex; flex-direction:column; align-items:center; gap:2px;">
|
||||
<span style="color: ${isWarning ? '#E11D48' : labelColor}; font-weight: 800; font-size: 12px;">${serviceType}</span>
|
||||
${isWarning ? `<span style="color: #E11D48; font-size: 9px; font-weight: 700; white-space: nowrap;">${warningReason}</span>` : ''}
|
||||
</div>
|
||||
</td>
|
||||
<td style="padding: 10px 0; font-weight: 600; color: ${isWarning ? '#991B1B' : 'var(--text-main)'}; font-size: 13px; white-space: nowrap; overflow: hidden; text-overflow: ellipsis;" title="${purpose}">${purpose || '-'}</td>
|
||||
<td style="padding: 10px 0; text-align: center; color: var(--text-main); font-size: 12px; white-space: nowrap; overflow: hidden; text-overflow: ellipsis;">${managerMain}</td>
|
||||
<td style="padding: 10px 0; text-align: center; color: var(--text-main); font-size: 12px; white-space: nowrap; overflow: hidden; text-overflow: ellipsis;">${managerSub}</td>
|
||||
<td style="padding: 10px 0; text-align: center; color: var(--text-main); font-size: 12px; white-space: nowrap; overflow: hidden; text-overflow: ellipsis;">${asset[ASSET_SCHEMA.LOC_DETAIL.key] || '-'}</td>
|
||||
</tr>`;
|
||||
}).join('');
|
||||
tbody.querySelectorAll('.mini-row').forEach(row => {
|
||||
row.addEventListener('click', () => {
|
||||
tbody.querySelectorAll('.mini-row').forEach(r => {
|
||||
const rIsWarning = (r as HTMLElement).style.borderLeftColor === 'rgb(225, 29, 72)'; // E11D48
|
||||
(r as HTMLElement).style.backgroundColor = rIsWarning ? '#FFF1F2' : 'transparent';
|
||||
});
|
||||
(row as HTMLElement).style.backgroundColor = '#EBF2F1'; // 선택 하이라이트
|
||||
const id = (row as HTMLElement).getAttribute('data-id');
|
||||
const asset = fullList.find(a => a.id === id);
|
||||
if (asset) updateDetailPanel(asset);
|
||||
});
|
||||
row.addEventListener('mouseenter', () => {
|
||||
if ((row as HTMLElement).style.backgroundColor !== 'rgb(235, 242, 241)') {
|
||||
(row as HTMLElement).style.backgroundColor = '#F8FAFA';
|
||||
}
|
||||
});
|
||||
row.addEventListener('mouseleave', () => {
|
||||
const isWarning = (row as HTMLElement).style.borderLeftColor === 'rgb(225, 29, 72)';
|
||||
if ((row as HTMLElement).style.backgroundColor !== 'rgb(235, 242, 241)') {
|
||||
(row as HTMLElement).style.backgroundColor = isWarning ? '#FFF1F2' : 'transparent';
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
(window as any).dispatchLocFilter = (loc: string) => {
|
||||
if (isPcView) return;
|
||||
selectedLocation = loc;
|
||||
selectedDetailLocation = null;
|
||||
renderSystemStatus();
|
||||
};
|
||||
|
||||
setTimeout(() => {
|
||||
const selectLoc = document.getElementById('select-loc') as HTMLSelectElement;
|
||||
const selectDetailLoc = document.getElementById('select-detail-loc') as HTMLSelectElement;
|
||||
|
||||
selectLoc?.addEventListener('change', (e) => {
|
||||
selectedLocation = (e.target as HTMLSelectElement).value || null;
|
||||
selectedDetailLocation = null;
|
||||
updateTableOnly();
|
||||
});
|
||||
selectDetailLoc?.addEventListener('change', (e) => {
|
||||
selectedDetailLocation = (e.target as HTMLSelectElement).value || null;
|
||||
updateTableOnly();
|
||||
});
|
||||
updateTableOnly();
|
||||
}, 50);
|
||||
};
|
||||
|
||||
// [자산 목록] 테이블 렌더러
|
||||
const tableWrapper = document.createElement('div');
|
||||
tableWrapper.className = 'table-container';
|
||||
const table = document.createElement('table');
|
||||
|
||||
// 1. 헤더 생성
|
||||
const thead = document.createElement('thead');
|
||||
const trHead = document.createElement('tr');
|
||||
config.columns.forEach(col => {
|
||||
const th = document.createElement('th');
|
||||
th.innerHTML = col.header;
|
||||
if (col.sortKey) th.setAttribute('data-sort', col.sortKey);
|
||||
if (col.width) th.style.width = col.width;
|
||||
if (col.align) th.style.textAlign = col.align;
|
||||
if (col.className) th.className = col.className;
|
||||
trHead.appendChild(th);
|
||||
});
|
||||
thead.appendChild(trHead);
|
||||
table.appendChild(thead);
|
||||
|
||||
// 2. 본문 생성
|
||||
const tbody = document.createElement('tbody');
|
||||
tbody.id = 'dynamic-tbody';
|
||||
table.appendChild(thead);
|
||||
table.appendChild(tbody);
|
||||
|
||||
tableWrapper.appendChild(table);
|
||||
container.appendChild(tableWrapper);
|
||||
|
||||
// 3. 테이블 업데이트 로직
|
||||
const updateTable = () => {
|
||||
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);
|
||||
|
||||
if (sortState.key) {
|
||||
filtered = dynamicSort(filtered, sortState.key, sortState.direction);
|
||||
}
|
||||
thead.innerHTML = `<tr>${config.columns.map(col => `
|
||||
<th ${col.sortKey ? `data-sort="${col.sortKey}"` : ''}
|
||||
style="${col.width ? `width:${col.width};` : ''}${col.align ? `text-align:${col.align};` : ''}"
|
||||
class="${col.className || ''}">${col.header}</th>`).join('')}</tr>`;
|
||||
|
||||
tbody.innerHTML = '';
|
||||
if (filtered.length === 0) {
|
||||
const emptyMsg = config.emptyMessage || UI_TEXT.MESSAGES.NO_DATA;
|
||||
tbody.innerHTML = `<tr><td colspan="${config.columns.length}" class="text-center" style="padding: 3rem; color: var(--text-muted);">${emptyMsg}</td></tr>`;
|
||||
return;
|
||||
}
|
||||
tbody.innerHTML = filtered.length === 0
|
||||
? `<tr><td colspan="${config.columns.length}" class="text-center" style="padding: 3rem; color: var(--text-muted);">${config.emptyMessage || UI_TEXT.MESSAGES.NO_DATA}</td></tr>`
|
||||
: filtered.map(asset => `
|
||||
<tr style="cursor:pointer;" class="asset-row" data-id="${asset.id}">
|
||||
${config.columns.map(col => `<td style="${col.align ? `text-align:${col.align};` : ''}" class="${col.className || ''}">${col.render(asset)}</td>`).join('')}
|
||||
</tr>`).join('');
|
||||
|
||||
filtered.forEach((asset) => {
|
||||
const tr = document.createElement('tr');
|
||||
if (config.onRowClick) {
|
||||
tr.style.cursor = 'pointer';
|
||||
tr.addEventListener('click', () => config.onRowClick!(asset));
|
||||
}
|
||||
|
||||
config.columns.forEach(col => {
|
||||
const td = document.createElement('td');
|
||||
if (col.align) td.style.textAlign = col.align;
|
||||
if (col.className) td.className = col.className;
|
||||
td.innerHTML = col.render(asset);
|
||||
tr.appendChild(td);
|
||||
});
|
||||
|
||||
tbody.appendChild(tr);
|
||||
tbody.querySelectorAll('.asset-row').forEach((tr, idx) => {
|
||||
tr.addEventListener('click', () => config.onRowClick && config.onRowClick(filtered[idx]));
|
||||
});
|
||||
|
||||
setupTableSorting(table, sortState, (key, dir) => {
|
||||
sortState = { key, direction: dir };
|
||||
// If external state was provided, sync it back
|
||||
if (config.persistentSortState) {
|
||||
config.persistentSortState.key = key;
|
||||
config.persistentSortState.direction = dir;
|
||||
config.persistentSortState.key = key;
|
||||
config.persistentSortState.direction = dir;
|
||||
}
|
||||
updateTable();
|
||||
});
|
||||
|
||||
// 모든 가능한 아이콘 로드 (안전하게)
|
||||
createIcons({ icons: { RefreshCcw, Plus, Edit2, Trash2, Users, Cloud, CreditCard, DollarSign, Paperclip } });
|
||||
// createIcons call removed as icons are no longer needed in dashboard
|
||||
};
|
||||
|
||||
// 4. 필터 바 렌더링
|
||||
// --- 뷰 전환 로직 ---
|
||||
const switchView = () => {
|
||||
contentWrapper.innerHTML = '';
|
||||
if ((state as any).currentViewMode === 'asset') {
|
||||
filterBar.style.display = 'flex';
|
||||
contentWrapper.style.overflowY = 'auto';
|
||||
contentWrapper.appendChild(tableWrapper);
|
||||
updateTable();
|
||||
} else {
|
||||
filterBar.style.display = 'none';
|
||||
contentWrapper.style.overflowY = 'hidden';
|
||||
renderSystemStatus();
|
||||
}
|
||||
};
|
||||
|
||||
// 토글 버튼 이벤트
|
||||
toggleWrapper.addEventListener('click', (e) => {
|
||||
const btn = (e.target as HTMLElement).closest('.toggle-btn') as HTMLButtonElement;
|
||||
if (!btn) return;
|
||||
toggleWrapper.querySelectorAll('.toggle-btn').forEach(b => b.classList.remove('active'));
|
||||
btn.classList.add('active');
|
||||
(state as any).currentViewMode = btn.getAttribute('data-mode') as 'asset' | 'system';
|
||||
switchView();
|
||||
});
|
||||
|
||||
// 필터 바 초기화
|
||||
renderFilterBar(filterBar, {
|
||||
...config.filterOptions,
|
||||
onFilterChange: (filters) => {
|
||||
@@ -127,18 +579,11 @@ export function createListView(container: HTMLElement, config: ListViewConfig) {
|
||||
}
|
||||
});
|
||||
|
||||
// 5. 동적 Select 박스 데이터 채우기
|
||||
// 셀렉트 박스 채우기
|
||||
const populateSelect = (selector: string, dataKey: string) => {
|
||||
const select = container.querySelector(selector) as HTMLSelectElement;
|
||||
if (select) {
|
||||
// Handle multiple possible keys for department names due to legacy data
|
||||
const getVal = (a: any) => {
|
||||
if (dataKey === ASSET_SCHEMA.CURRENT_DEPT.key) {
|
||||
return a[dataKey] || a['현사용부서'] || a['현사용조직'];
|
||||
}
|
||||
return a[dataKey];
|
||||
}
|
||||
|
||||
const getVal = (a: any) => dataKey === ASSET_SCHEMA.CURRENT_DEPT.key ? (a[dataKey] || a['현사용부서'] || a['현사용조직']) : a[dataKey];
|
||||
const uniqueValues = Array.from(new Set(fullList.map(getVal))).filter(Boolean).sort();
|
||||
uniqueValues.forEach(val => {
|
||||
const opt = document.createElement('option');
|
||||
@@ -154,6 +599,6 @@ export function createListView(container: HTMLElement, config: ListViewConfig) {
|
||||
if (config.filterOptions.showCorp) populateSelect('#filter-corp', ASSET_SCHEMA.PURCHASE_CORP.key);
|
||||
if (config.filterOptions.showType) populateSelect('#filter-type', ASSET_SCHEMA.ASSET_TYPE.key);
|
||||
|
||||
// 6. 초기 렌더링
|
||||
updateTable();
|
||||
// 초기 실행
|
||||
switchView();
|
||||
}
|
||||
|
||||
@@ -5,10 +5,10 @@ import { ASSET_SCHEMA } from '../../core/schema';
|
||||
import { createListView } from './ListFactory';
|
||||
|
||||
export function renderSwList(container: HTMLElement) {
|
||||
const isInternal = state.activeSubTab === '내부';
|
||||
|
||||
const isInternal = state.activeSubTab === '내부SW';
|
||||
|
||||
createListView(container, {
|
||||
title: isInternal ? '내부' : '외부',
|
||||
title: isInternal ? '내부SW' : '외부SW',
|
||||
dataSource: () => sortAssets(isInternal ? state.masterData.swInternal : state.masterData.swExternal),
|
||||
searchKeys: ['PRODUCT_NAME', 'CURRENT_USER', 'CURRENT_DEPT', 'ASSET_TYPE'],
|
||||
emptyMessage: '검색 결과가 없습니다.',
|
||||
|
||||
@@ -41,7 +41,7 @@ export function renderSWTable(mainContent: HTMLElement) {
|
||||
container.innerHTML = `<div style="padding:2rem; color:var(--text-muted);">"${tab}" 탭에 대한 하드웨어 리스트 뷰가 정의되지 않았습니다.</div>`;
|
||||
}
|
||||
} else if (state.activeCategory === 'sw') {
|
||||
if (tab === '외부' || tab === '내부') {
|
||||
if (tab === '외부SW' || tab === '내부SW') {
|
||||
renderSwList(container);
|
||||
} else {
|
||||
container.innerHTML = `<div style="padding:2rem; color:var(--text-muted);">"${tab}" 탭에 대한 소프트웨어 리스트 뷰가 정의되지 않았습니다.</div>`;
|
||||
|
||||
71
test_data_generator.js
Normal file
71
test_data_generator.js
Normal file
@@ -0,0 +1,71 @@
|
||||
import mysql from 'mysql2/promise';
|
||||
import dotenv from 'dotenv';
|
||||
import crypto from 'crypto';
|
||||
|
||||
dotenv.config();
|
||||
|
||||
const { DB_HOST, DB_USER, DB_PASS, DB_NAME, DB_PORT } = process.env;
|
||||
|
||||
const CATEGORIES = ['PC', '서버', '노트북', '모니터', '업무지원장비'];
|
||||
const DEPTS = ['기술개발센터', '총괄기획실', '한맥', '삼안', '장헌', '한라'];
|
||||
const USERS = ['홍길동', '김철수', '이영희', '박지성', '손흥민', '봉준호', '싸이'];
|
||||
const STATUSES = ['운영', '재고', '수리', '폐기', '기타'];
|
||||
const CORPS = ['한맥', '삼안', '장헌', '한라', 'PTC', '바론'];
|
||||
|
||||
async function generateTestData() {
|
||||
const connection = await mysql.createConnection({
|
||||
host: DB_HOST,
|
||||
user: DB_USER,
|
||||
password: DB_PASS,
|
||||
database: DB_NAME,
|
||||
port: parseInt(DB_PORT || '3306')
|
||||
});
|
||||
|
||||
console.log('🚀 무작위 테스트 데이터 생성을 시작합니다 (Crypto UUID 방식)...');
|
||||
|
||||
for (let i = 1; i <= 20; i++) {
|
||||
const category = CATEGORIES[Math.floor(Math.random() * CATEGORIES.length)];
|
||||
const dept = DEPTS[Math.floor(Math.random() * DEPTS.length)];
|
||||
const user = USERS[Math.floor(Math.random() * USERS.length)];
|
||||
const status = STATUSES[Math.floor(Math.random() * STATUSES.length)];
|
||||
const corp = CORPS[Math.floor(Math.random() * CORPS.length)];
|
||||
|
||||
// Crypto UUID 생성
|
||||
const id = crypto.randomUUID();
|
||||
const assetCode = `TEST-${Date.now().toString().slice(-6)}-${String(i).padStart(3, '0')}`;
|
||||
|
||||
try {
|
||||
// 1. asset_core 삽입 (id 수동 지정)
|
||||
await connection.query(
|
||||
`INSERT INTO asset_core
|
||||
(id, asset_code, category, asset_type, purchase_corp, current_dept, user_current, purchase_date, service_type)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
||||
[id, assetCode, category, category, corp, dept, user, '2026-06-10', '내부']
|
||||
);
|
||||
|
||||
// 2. asset_spec 삽입
|
||||
await connection.query(
|
||||
`INSERT INTO asset_spec
|
||||
(asset_id, hw_status, model_name, cpu, ram)
|
||||
VALUES (?, ?, ?, ?, ?)`,
|
||||
[id, status, `${category} Model ${i}`, 'Intel i7', '16GB']
|
||||
);
|
||||
|
||||
// 3. 초기 이력 삽입
|
||||
await connection.query(
|
||||
`INSERT INTO asset_history (asset_id, event_type, details, log_date, log_user)
|
||||
VALUES (?, ?, ?, ?, ?)`,
|
||||
[id, 'STATUS_CHANGE', `[최초 등록] 테스트 데이터 생성 (${status})`, '2026-06-10', '시스템']
|
||||
);
|
||||
|
||||
console.log(`✅ 생성 완료: ${assetCode} (${category} / ${dept} / ${user})`);
|
||||
} catch (err) {
|
||||
console.error(`❌ 생성 실패 (${i}):`, err.message);
|
||||
}
|
||||
}
|
||||
|
||||
await connection.end();
|
||||
console.log('\n✨ 20개의 테스트 데이터 생성이 완료되었습니다.');
|
||||
}
|
||||
|
||||
generateTestData().catch(console.error);
|
||||
Reference in New Issue
Block a user