Compare commits
3 Commits
3ab587d342
...
25ebaf4685
| Author | SHA1 | Date | |
|---|---|---|---|
| 25ebaf4685 | |||
| 2b9c965c91 | |||
| 4b408b0640 |
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);
|
||||
});
|
||||
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();
|
||||
128
server.js
128
server.js
@@ -9,6 +9,12 @@ dotenv.config();
|
||||
const app = express();
|
||||
app.use(cors());
|
||||
app.use(express.json({ limit: '50mb' }));
|
||||
app.use('/uploads', express.static('uploads')); // 업로드 파일 정적 서빙
|
||||
|
||||
// uploads 폴더가 없으면 생성
|
||||
if (!fs.existsSync('uploads')) {
|
||||
fs.mkdirSync('uploads');
|
||||
}
|
||||
|
||||
// MySQL Pool Configuration
|
||||
const pool = mysql.createPool({
|
||||
@@ -33,7 +39,7 @@ const CATEGORY_TABLE_MAP = {
|
||||
pc: 'asset_pc',
|
||||
server: 'asset_server',
|
||||
storage: 'asset_storage',
|
||||
network: 'asset_network',
|
||||
network: 'asset_remote',
|
||||
equipment: 'asset_equipment',
|
||||
officeSupplies: 'asset_office_supplies',
|
||||
survey: 'asset_survey',
|
||||
@@ -47,7 +53,7 @@ const CATEGORY_TABLE_MAP = {
|
||||
};
|
||||
|
||||
const ASSET_TABLES = [
|
||||
'asset_pc', 'asset_server', 'asset_storage', 'asset_network',
|
||||
'asset_pc', 'asset_server', 'asset_storage', 'asset_remote',
|
||||
'asset_equipment', 'asset_office_supplies', 'asset_survey', 'asset_vip'
|
||||
];
|
||||
|
||||
@@ -109,12 +115,14 @@ app.get('/api/assets/master', async (req, res) => {
|
||||
s.hw_status, s.model_name, s.mainboard, s.os, s.cpu, s.ram, s.gpu,
|
||||
s.monitoring, s.price, s.monitor_inch, s.serial_num,
|
||||
l.location, l.location_detail, l.location_photo, l.loc_x, l.loc_y,
|
||||
n.ip_address, n.mac_address, n.remote_tool, n.remote_id, n.remote_pw,
|
||||
(SELECT CONCAT(capacity, unit) FROM asset_volume WHERE asset_id = c.id AND disk_type = 'SSD' AND slot_no = 1 LIMIT 1) as ssd_1,
|
||||
(SELECT CONCAT(capacity, unit) FROM asset_volume WHERE asset_id = c.id AND disk_type = 'SSD' AND slot_no = 2 LIMIT 1) as ssd_2,
|
||||
(SELECT CONCAT(capacity, unit) FROM asset_volume WHERE asset_id = c.id AND disk_type = 'HDD' AND slot_no = 1 LIMIT 1) as hdd_1,
|
||||
(SELECT CONCAT(capacity, unit) FROM asset_volume WHERE asset_id = c.id AND disk_type = 'HDD' AND slot_no = 2 LIMIT 1) as hdd_2,
|
||||
(SELECT GROUP_CONCAT(CONCAT(disk_type, ': ', capacity, unit) SEPARATOR ', ') FROM asset_volume WHERE asset_id = c.id) as volume_summary
|
||||
(
|
||||
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 = (
|
||||
@@ -122,11 +130,6 @@ app.get('/api/assets/master', async (req, res) => {
|
||||
WHERE asset_id = c.id AND is_active = 1
|
||||
ORDER BY created_at DESC LIMIT 1
|
||||
)
|
||||
LEFT JOIN asset_network n ON n.id = (
|
||||
SELECT id FROM asset_network
|
||||
WHERE asset_id = c.id AND is_active = 1
|
||||
ORDER BY created_at DESC LIMIT 1
|
||||
)
|
||||
`);
|
||||
|
||||
const catMap = {
|
||||
@@ -188,26 +191,23 @@ app.post('/api/asset/:category/save', async (req, res) => {
|
||||
await connection.query(`INSERT INTO asset_spec (${specKeys.join(', ')}) VALUES (${specKeys.map(() => '?').join(', ')})`, Object.values(specData));
|
||||
}
|
||||
|
||||
// 3.3 asset_volume (Legacy Parser)
|
||||
const parseCapacity = (str) => {
|
||||
if (!str || str.trim() === '' || str.toLowerCase() === 'null') return null;
|
||||
const match = str.match(/(\d+(?:\.\d+)?)\s*([GT]B)?/i);
|
||||
if (match) return { value: parseFloat(match[1]), unit: (match[2] || 'GB').toUpperCase() };
|
||||
return null;
|
||||
};
|
||||
const storages = [
|
||||
{ val: asset.ssd_1, type: 'SSD', slot: 1 },
|
||||
{ val: asset.ssd_2, type: 'SSD', slot: 2 },
|
||||
{ val: asset.hdd_1, type: 'HDD', slot: 1 },
|
||||
{ val: asset.hdd_2, type: 'HDD', slot: 2 }
|
||||
];
|
||||
// 3.3 asset_volume
|
||||
await connection.query('DELETE FROM asset_volume WHERE asset_id = ?', [asset.id]);
|
||||
for (const s of storages) {
|
||||
const parsed = parseCapacity(s.val);
|
||||
if (parsed) {
|
||||
await connection.query('INSERT INTO asset_volume (asset_id, disk_type, capacity, unit, slot_no) VALUES (?, ?, ?, ?, ?)',
|
||||
[asset.id, s.type, parsed.value, parsed.unit, s.slot]);
|
||||
}
|
||||
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
|
||||
@@ -221,14 +221,36 @@ app.post('/api/asset/:category/save', async (req, res) => {
|
||||
}
|
||||
}
|
||||
|
||||
// 3.5 asset_network
|
||||
if (asset.ip_address || asset.mac_address || asset.remote_tool) {
|
||||
const [netActive] = await connection.query('SELECT * FROM asset_network WHERE asset_id = ? AND is_active = 1', [asset.id]);
|
||||
const isChanged = netActive.length === 0 || netActive[0].ip_address !== asset.ip_address || netActive[0].mac_address !== asset.mac_address || netActive[0].remote_tool !== asset.remote_tool || netActive[0].remote_id !== asset.remote_id || netActive[0].remote_pw !== asset.remote_pw;
|
||||
if (isChanged) {
|
||||
await connection.query('UPDATE asset_network SET is_active = 0, deactivated_at = NOW() WHERE asset_id = ? AND is_active = 1', [asset.id]);
|
||||
await connection.query(`INSERT INTO asset_network (asset_id, ip_address, mac_address, remote_tool, remote_id, remote_pw, is_active) VALUES (?, ?, ?, ?, ?, ?, 1)`,
|
||||
[asset.id, asset.ip_address, asset.mac_address, asset.remote_tool, asset.remote_id, asset.remote_pw]);
|
||||
// 3.5 asset_remote (Dynamic Array Logic)
|
||||
if (asset.remotes) {
|
||||
try {
|
||||
let nets = typeof asset.remotes === 'string' ? JSON.parse(asset.remotes) : asset.remotes;
|
||||
if (Array.isArray(nets)) {
|
||||
await connection.query('UPDATE asset_remote SET is_active = 0, deactivated_at = NOW() WHERE asset_id = ? AND is_active = 1', [asset.id]);
|
||||
for (const n of nets) {
|
||||
if (n.type) {
|
||||
await connection.query(
|
||||
'INSERT INTO asset_remote (asset_id, net_type, net_name, net_value1, net_value2, is_active) VALUES (?, ?, ?, ?, ?, 1)',
|
||||
[asset.id, n.type, n.name || '', n.val1 || '', n.val2 || '']
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch(e) { console.error('Remote data parse error', e); }
|
||||
} else {
|
||||
// Fallback for UI that hasn't sent the networks array yet
|
||||
if (asset.ip_address || asset.mac_address || asset.remote_tool) {
|
||||
const [netActive] = await connection.query('SELECT * FROM asset_remote WHERE asset_id = ? AND is_active = 1', [asset.id]);
|
||||
const isChanged = netActive.length === 0 || netActive[0].net_value1 !== asset.ip_address || netActive[0].net_value2 !== asset.mac_address || netActive[0].net_name !== asset.remote_tool;
|
||||
if (isChanged) {
|
||||
await connection.query('UPDATE asset_remote SET is_active = 0, deactivated_at = NOW() WHERE asset_id = ? AND is_active = 1', [asset.id]);
|
||||
if (asset.ip_address || asset.mac_address) {
|
||||
await connection.query('INSERT INTO asset_remote (asset_id, net_type, net_name, net_value1, net_value2, is_active) VALUES (?, ?, ?, ?, ?, 1)', [asset.id, 'IP', '기본망', asset.ip_address, asset.mac_address]);
|
||||
}
|
||||
if (asset.remote_tool || asset.remote_id || asset.remote_pw) {
|
||||
await connection.query('INSERT INTO asset_remote (asset_id, net_type, net_name, net_value1, net_value2, is_active) VALUES (?, ?, ?, ?, ?, 1)', [asset.id, 'REMOTE', asset.remote_tool, asset.remote_id, asset.remote_pw]);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -270,7 +292,7 @@ app.delete('/api/asset/:category/:id', async (req, res) => {
|
||||
|
||||
try {
|
||||
const connection = await pool.getConnection();
|
||||
// For asset_core, ON DELETE CASCADE will handle spec, location, network, volume
|
||||
// For asset_core, ON DELETE CASCADE will handle spec, location, remote, volume
|
||||
await connection.query(`DELETE FROM ${table} WHERE id = ?`, [id]);
|
||||
connection.release();
|
||||
console.log(`🗑️ [ASSET DELETE] Category: ${category}, ID: ${id}`);
|
||||
@@ -327,6 +349,30 @@ app.post('/api/maps/save', (req, res) => {
|
||||
} 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, 'FILE UPLOAD');
|
||||
}
|
||||
});
|
||||
|
||||
app.listen(3000, '0.0.0.0', () => {
|
||||
console.log('📡 ITAM BACKEND SERVER RUNNING ON PORT 3000 (V3 Normalized)');
|
||||
});
|
||||
|
||||
@@ -10,7 +10,6 @@ import {
|
||||
} from './ModalUtils';
|
||||
import { CORP_LIST, LOCATION_DATA, CATEGORY_TYPE_MAP, HW_STATUS_LIST, ORG_LIST, IMAGE_LOCATIONS, TYPE_PREFIX_MAP } from './SharedData';
|
||||
import { BaseModal } from './BaseModal';
|
||||
import { createIcons, X, History, Plus, Save, Paperclip, Calendar, Monitor, Cpu, Network, ShieldCheck } from 'lucide';
|
||||
|
||||
class HwAssetModal extends BaseModal {
|
||||
private dynamicMapConfig: Record<string, any[]> = {};
|
||||
@@ -20,238 +19,231 @@ class HwAssetModal extends BaseModal {
|
||||
}
|
||||
|
||||
protected renderFrameHTML(): string {
|
||||
const sharedStyle = 'height: 38px !important; box-sizing: border-box !important; font-size: 13px; margin: 0;';
|
||||
const inputStyle = sharedStyle;
|
||||
const btnStyle = `padding: 0 16px; display: inline-flex; align-items: center; justify-content: center; font-weight: 600; white-space: nowrap; cursor: pointer; ${sharedStyle}`;
|
||||
|
||||
return `
|
||||
<div id="hw-asset-modal" class="modal-overlay hidden">
|
||||
<div class="modal-content wide">
|
||||
<div class="modal-header">
|
||||
<h2 id="hw-modal-title">${this.title}</h2>
|
||||
<button id="btn-close-hw-modal" class="btn-icon" aria-label="닫기"><i data-lucide="x"></i></button>
|
||||
<h2 id="hw-modal-title" style="margin: 0; font-size: 18px; font-weight: 800; color: white;">${this.title}</h2>
|
||||
<button id="btn-close-hw-modal" class="btn-icon" aria-label="닫기" style="font-size: 28px; color: white; background: none; border: none; cursor: pointer; line-height: 1;">×</button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<div class="modal-body" style="padding: 24px; overflow-y: auto;">
|
||||
<div class="modal-body-split">
|
||||
<div class="modal-form-area">
|
||||
<form id="hw-asset-form" class="grid-form">
|
||||
<input type="hidden" id="hw-id" name="id" />
|
||||
<input type="hidden" id="hw-remotes-data" name="remotes" />
|
||||
|
||||
<div class="form-section-title">기본 및 관리 정보</div>
|
||||
<!-- [SECTION 1] 기본 관리 정보 -->
|
||||
<div class="form-section-title" style="padding-top: 0; margin-bottom: 12px;">기본 관리 정보</div>
|
||||
<div class="form-group">
|
||||
<label>${ASSET_SCHEMA.ASSET_CODE.ui}</label>
|
||||
<div class="input-with-btn">
|
||||
<input type="text" id="hw-asset_code" name="asset_code" placeholder="자동 생성" readonly />
|
||||
<button type="button" id="btn-gen-hw-code" class="btn btn-outline btn-sm btn-helper">생성</button>
|
||||
<div class="input-with-btn" style="display: flex; gap: 8px; align-items: stretch;">
|
||||
<input type="text" id="hw-asset_code" name="asset_code" placeholder="자동 생성" readonly style="flex: 1; ${inputStyle}" />
|
||||
<button type="button" id="btn-gen-hw-code" class="btn btn-outline" style="${btnStyle}">생성</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>${ASSET_SCHEMA.PURCHASE_CORP.ui}</label>
|
||||
<select id="hw-purchase_corp" name="purchase_corp">${generateOptionsHTML(CORP_LIST)}</select>
|
||||
<select id="hw-purchase_corp" name="purchase_corp" style="${inputStyle}">${generateOptionsHTML(CORP_LIST)}</select>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>${ASSET_SCHEMA.CATEGORY.ui}</label>
|
||||
<select id="hw-category" name="category">
|
||||
<select id="hw-category" name="category" style="${inputStyle}">
|
||||
<option value="">선택</option>
|
||||
${generateOptionsHTML(Object.keys(CATEGORY_TYPE_MAP), '', false)}
|
||||
</select>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>${ASSET_SCHEMA.ASSET_TYPE.ui}</label>
|
||||
<select id="hw-asset_type" name="asset_type">
|
||||
<select id="hw-asset_type" name="asset_type" style="${inputStyle}">
|
||||
<option value="">구분을 먼저 선택하세요</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>${ASSET_SCHEMA.HW_STATUS.ui}</label>
|
||||
<select id="hw-hw_status" name="hw_status">${generateOptionsHTML(HW_STATUS_LIST)}</select>
|
||||
<select id="hw-hw_status" name="hw_status" style="${inputStyle}">${generateOptionsHTML(HW_STATUS_LIST)}</select>
|
||||
</div>
|
||||
<div class="form-group dept-field">
|
||||
<label>${ASSET_SCHEMA.CURRENT_DEPT.ui}</label>
|
||||
<select id="hw-current_dept" name="current_dept">${generateOptionsHTML(ORG_LIST)}</select>
|
||||
</div>
|
||||
<div class="form-group dept-field">
|
||||
<label>${ASSET_SCHEMA.PREV_DEPT.ui}</label>
|
||||
<select id="hw-previous_dept" name="previous_dept">${generateOptionsHTML(ORG_LIST)}</select>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>${ASSET_SCHEMA.MANAGER_MAIN.ui}</label>
|
||||
<input type="text" id="hw-manager_primary" name="manager_primary" />
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>${ASSET_SCHEMA.MANAGER_SUB.ui}</label>
|
||||
<input type="text" id="hw-manager_secondary" name="manager_secondary" />
|
||||
</div>
|
||||
<div class="form-group user-tracking-field">
|
||||
<label>${ASSET_SCHEMA.CURRENT_USER.ui}</label>
|
||||
<input type="text" id="hw-user_current" name="user_current" />
|
||||
</div>
|
||||
<div class="form-group user-tracking-field pc-only">
|
||||
<label>${ASSET_SCHEMA.USER_POSITION.ui}</label>
|
||||
<input type="text" id="hw-user_position" name="user_position" />
|
||||
</div>
|
||||
<div class="form-group user-tracking-field">
|
||||
<label>${ASSET_SCHEMA.PREV_USER.ui}</label>
|
||||
<input type="text" id="hw-previous_user" name="previous_user" />
|
||||
</div>
|
||||
<div class="form-group full-width server-only">
|
||||
<label>${ASSET_SCHEMA.ASSET_PURPOSE.ui}</label>
|
||||
<input type="text" id="hw-asset_purpose" name="asset_purpose" placeholder="예: DB서버, 웹서버, 백업용 등" />
|
||||
</div>
|
||||
|
||||
<div class="form-section-title">설치 위치</div>
|
||||
<div class="form-group">
|
||||
<label>건물/위치</label>
|
||||
<select id="hw-bldg-select" name="location">${generateOptionsHTML(Object.keys(LOCATION_DATA))}</select>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>${ASSET_SCHEMA.LOC_DETAIL.ui}</label>
|
||||
<div class="location-detail-container">
|
||||
<select id="hw-location_detail" name="location_detail">
|
||||
<option value="">선택</option>
|
||||
</select>
|
||||
<button type="button" id="btn-reg-loc-map" class="btn-loc-action btn-loc-view hidden" style="background-color: var(--primary-color);">
|
||||
위치등록
|
||||
</button>
|
||||
<button type="button" id="btn-view-loc-map" class="btn-loc-action btn-loc-view hidden">
|
||||
위치보기
|
||||
</button>
|
||||
<input type="hidden" id="hw-loc_x" name="loc_x" />
|
||||
<input type="hidden" id="hw-loc_y" name="loc_y" />
|
||||
<input type="hidden" id="hw-location_photo" name="location_photo" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="form-section-title">시스템 사양</div>
|
||||
<div class="form-group">
|
||||
<label>${ASSET_SCHEMA.MODEL_NAME.ui}</label>
|
||||
<input type="text" id="hw-model_name" name="model_name" />
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>${ASSET_SCHEMA.MAINBOARD.ui}</label>
|
||||
<input type="text" id="hw-mainboard" name="mainboard" />
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>${ASSET_SCHEMA.OS.ui}</label>
|
||||
<input type="text" id="hw-os" name="os" />
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>${ASSET_SCHEMA.CPU.ui}</label>
|
||||
<input type="text" id="hw-cpu" name="cpu" />
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>${ASSET_SCHEMA.RAM.ui}</label>
|
||||
<input type="text" id="hw-ram" name="ram" />
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>${ASSET_SCHEMA.GPU.ui}</label>
|
||||
<input type="text" id="hw-gpu" name="gpu" />
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>SSD 1</label>
|
||||
<input type="text" id="hw-ssd_1" name="ssd_1" />
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>SSD 2</label>
|
||||
<input type="text" id="hw-ssd_2" name="ssd_2" />
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>HDD 1</label>
|
||||
<input type="text" id="hw-hdd_1" name="hdd_1" />
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>HDD 2</label>
|
||||
<input type="text" id="hw-hdd_2" name="hdd_2" />
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>HDD 3</label>
|
||||
<input type="text" id="hw-hdd_3" name="hdd_3" />
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>HDD 4</label>
|
||||
<input type="text" id="hw-hdd_4" name="hdd_4" />
|
||||
</div>
|
||||
<div class="form-group pc-only">
|
||||
<label>${ASSET_SCHEMA.MAC_ADDR.ui}</label>
|
||||
<input type="text" id="hw-mac_address" name="mac_address" />
|
||||
</div>
|
||||
|
||||
<div class="form-section-title">네트워크 및 접속 정보</div>
|
||||
<div class="form-group">
|
||||
<label>${ASSET_SCHEMA.IP_ADDR.ui}</label>
|
||||
<input type="text" id="hw-ip_address" name="ip_address" />
|
||||
</div>
|
||||
<div class="form-group server-only">
|
||||
<label>${ASSET_SCHEMA.IP_ADDR2.ui}</label>
|
||||
<input type="text" id="hw-ip_address_2" name="ip_address_2" />
|
||||
</div>
|
||||
<div class="form-group server-only">
|
||||
<label>${ASSET_SCHEMA.REMOTE_TOOL.ui}</label>
|
||||
<input type="text" id="hw-remote_tool" name="remote_tool" placeholder="Anydesk, Chrome 등" />
|
||||
</div>
|
||||
<div class="form-group server-only">
|
||||
<label>${ASSET_SCHEMA.REMOTE_ID.ui}</label>
|
||||
<input type="text" id="hw-remote_id" name="remote_id" />
|
||||
</div>
|
||||
<div class="form-group server-only">
|
||||
<label>${ASSET_SCHEMA.REMOTE_PW.ui}</label>
|
||||
<input type="text" id="hw-remote_pw" name="remote_pw" />
|
||||
</div>
|
||||
<div class="form-group server-only">
|
||||
<div class="form-group infra-only monitoring-field">
|
||||
<label>${ASSET_SCHEMA.MONITORING.ui}</label>
|
||||
<select id="hw-monitoring" name="monitoring">
|
||||
<option value="대상">대상</option>
|
||||
<select id="hw-monitoring" name="monitoring" style="${inputStyle}">
|
||||
<option value="비대상">비대상</option>
|
||||
<option value="대상">대상</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div class="form-section-title">구매 및 증빙</div>
|
||||
<!-- [SECTION 2] 조직 및 사용자 정보 -->
|
||||
<div class="form-section-title org-user-section" style="margin-top: 24px; margin-bottom: 12px;">사용자 및 조직 정보</div>
|
||||
<div class="form-group org-user-field">
|
||||
<label>${ASSET_SCHEMA.CURRENT_DEPT.ui}</label>
|
||||
<select id="hw-current_dept" name="current_dept" style="${inputStyle}">${generateOptionsHTML(ORG_LIST)}</select>
|
||||
</div>
|
||||
<div class="form-group org-user-field">
|
||||
<label>${ASSET_SCHEMA.MANAGER_MAIN.ui}</label>
|
||||
<input type="text" id="hw-manager_primary" name="manager_primary" style="${inputStyle}" />
|
||||
</div>
|
||||
<div class="form-group personal-only">
|
||||
<label>${ASSET_SCHEMA.CURRENT_USER.ui}</label>
|
||||
<input type="text" id="hw-user_current" name="user_current" style="${inputStyle}" />
|
||||
</div>
|
||||
<div class="form-group personal-only">
|
||||
<label>${ASSET_SCHEMA.USER_POSITION.ui}</label>
|
||||
<input type="text" id="hw-user_position" name="user_position" style="${inputStyle}" />
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>${ASSET_SCHEMA.MANAGER_SUB.ui}</label>
|
||||
<input type="text" id="hw-manager_secondary" name="manager_secondary" style="${inputStyle}" />
|
||||
</div>
|
||||
<div class="form-group personal-only">
|
||||
<label>${ASSET_SCHEMA.PREV_USER.ui}</label>
|
||||
<input type="text" id="hw-previous_user" name="previous_user" style="${inputStyle}" />
|
||||
</div>
|
||||
|
||||
<!-- [SECTION 3] 하드웨어 사양 -->
|
||||
<div class="form-section-title hardware-section" style="margin-top: 24px; margin-bottom: 12px;">시스템 사양 정보</div>
|
||||
<div class="form-group">
|
||||
<label>${ASSET_SCHEMA.MODEL_NAME.ui}</label>
|
||||
<input type="text" id="hw-model_name" name="model_name" style="${inputStyle}" />
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>${ASSET_SCHEMA.ASSET_MFR.ui}</label>
|
||||
<input type="text" id="hw-asset_mfr" name="asset_mfr" style="${inputStyle}" />
|
||||
</div>
|
||||
<div class="form-group sn-only">
|
||||
<label>${ASSET_SCHEMA.SERIAL_NUM.ui}</label>
|
||||
<input type="text" id="hw-serial_num" name="serial_num" style="${inputStyle}" />
|
||||
</div>
|
||||
<div class="form-group spec-only">
|
||||
<label>${ASSET_SCHEMA.OS.ui}</label>
|
||||
<input type="text" id="hw-os" name="os" style="${inputStyle}" />
|
||||
</div>
|
||||
<div class="form-group spec-only">
|
||||
<label>${ASSET_SCHEMA.CPU.ui}</label>
|
||||
<input type="text" id="hw-cpu" name="cpu" style="${inputStyle}" />
|
||||
</div>
|
||||
<div class="form-group spec-only">
|
||||
<label>${ASSET_SCHEMA.RAM.ui}</label>
|
||||
<input type="text" id="hw-ram" name="ram" style="${inputStyle}" />
|
||||
</div>
|
||||
<div class="form-group spec-only">
|
||||
<label>${ASSET_SCHEMA.GPU.ui}</label>
|
||||
<input type="text" id="hw-gpu" name="gpu" style="${inputStyle}" />
|
||||
</div>
|
||||
<div class="form-group spec-only">
|
||||
<label>${ASSET_SCHEMA.MAINBOARD.ui}</label>
|
||||
<input type="text" id="hw-mainboard" name="mainboard" style="${inputStyle}" />
|
||||
</div>
|
||||
<div class="form-group monitor-only">
|
||||
<label>${ASSET_SCHEMA.MONITOR_INCH.ui}</label>
|
||||
<input type="text" id="hw-monitor_inch" name="monitor_inch" style="${inputStyle}" />
|
||||
</div>
|
||||
<div class="form-group parts-only">
|
||||
<label>${ASSET_SCHEMA.VOLUME.ui}</label>
|
||||
<input type="text" id="hw-volume" name="volume" style="${inputStyle}" />
|
||||
</div>
|
||||
<div class="form-group parts-only">
|
||||
<label>${ASSET_SCHEMA.ASSET_COUNT.ui}</label>
|
||||
<input type="text" id="hw-asset_count" name="asset_count" style="${inputStyle}" />
|
||||
</div>
|
||||
|
||||
<!-- 동적 디스크 할당 영역 -->
|
||||
<div class="form-group spec-only full-width" style="grid-column: span 2;">
|
||||
<div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 8px;">
|
||||
<label style="margin: 0; font-size: 11px; font-weight: 700; color: var(--text-muted);">저장장치 (디스크)</label>
|
||||
<button type="button" id="btn-add-volume" class="btn btn-outline" style="height: 26px !important; padding: 0 10px; font-size: 11px; display: none;">+ 디스크 추가</button>
|
||||
</div>
|
||||
<div id="hw-volume-container" style="display: flex; flex-direction: column; gap: 8px;"></div>
|
||||
<input type="hidden" id="hw-volumes-data" name="volumes" />
|
||||
</div>
|
||||
|
||||
<!-- [SECTION 4] 동적 네트워크 (IP/MAC) 정보 -->
|
||||
<div class="form-section-title net-only" style="margin-top: 24px; margin-bottom: 12px;">네트워크 인터페이스</div>
|
||||
<div class="form-group net-only full-width" style="grid-column: span 2;">
|
||||
<div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 8px;">
|
||||
<label style="margin: 0; font-size: 11px; font-weight: 700; color: var(--text-muted);">IP / MAC 정보</label>
|
||||
<button type="button" id="btn-add-network" class="btn btn-outline" style="height: 26px !important; padding: 0 10px; font-size: 11px; display: none;">+ 인터페이스 추가</button>
|
||||
</div>
|
||||
<div id="hw-network-container" style="display: flex; flex-direction: column; gap: 8px;"></div>
|
||||
</div>
|
||||
|
||||
<!-- [SECTION 5] 동적 원격 접속 정보 -->
|
||||
<div class="form-section-title remote-section" style="margin-top: 24px; margin-bottom: 12px;">원격 접속 정보</div>
|
||||
<div class="form-group remote-field full-width" style="grid-column: span 2;">
|
||||
<div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 8px;">
|
||||
<label style="margin: 0; font-size: 11px; font-weight: 700; color: var(--text-muted);">접속 도구 및 인증 정보</label>
|
||||
<button type="button" id="btn-add-remote" class="btn btn-outline" style="height: 26px !important; padding: 0 10px; font-size: 11px; display: none;">+ 접속 도구 추가</button>
|
||||
</div>
|
||||
<div id="hw-remote-container" style="display: flex; flex-direction: column; gap: 8px;"></div>
|
||||
</div>
|
||||
|
||||
<!-- [SECTION 6] 설치 위치 -->
|
||||
<div class="form-section-title location-section" style="margin-top: 24px; margin-bottom: 12px;">설치 위치</div>
|
||||
<div class="form-group location-field">
|
||||
<label>건물/위치</label>
|
||||
<select id="hw-bldg-select" name="location" style="${inputStyle}">${generateOptionsHTML(Object.keys(LOCATION_DATA))}</select>
|
||||
</div>
|
||||
<div class="form-group location-field">
|
||||
<label>${ASSET_SCHEMA.LOC_DETAIL.ui}</label>
|
||||
<div class="input-with-btn" style="display: flex; gap: 8px; align-items: stretch;">
|
||||
<select id="hw-location_detail" name="location_detail" style="flex: 1; ${inputStyle}"><option value="">선택</option></select>
|
||||
<button type="button" id="btn-reg-loc-map" class="btn btn-primary" style="${btnStyle} display: none;">위치등록</button>
|
||||
<button type="button" id="btn-view-loc-map" class="btn btn-primary btn-loc-action btn-loc-view" style="${btnStyle} display: none; pointer-events: auto !important; cursor: pointer !important;">위치보기</button>
|
||||
</div>
|
||||
<input type="hidden" id="hw-loc_x" name="loc_x" /><input type="hidden" id="hw-loc_y" name="loc_y" /><input type="hidden" id="hw-location_photo" name="location_photo" />
|
||||
</div>
|
||||
|
||||
<!-- [SECTION 7] 구매 및 증빙 -->
|
||||
<div class="form-section-title" style="margin-top: 24px; margin-bottom: 12px;">구매 및 증빙 정보</div>
|
||||
<div class="form-group">
|
||||
<label>${ASSET_SCHEMA.PURCHASE_DATE.ui}</label>
|
||||
<div style="display:flex; gap:0.25rem; align-items:center; position:relative;">
|
||||
<input type="text" id="hw-purchase_date" name="purchase_date" style="flex:1;" />
|
||||
<button type="button" class="btn-icon btn-helper" onclick="const p = document.getElementById('hw-purchase_date-picker'); p.value = document.getElementById('hw-purchase_date').value; p.showPicker();" style="padding:0.25rem;">
|
||||
<i data-lucide="calendar" style="width:18px; height:18px; color:var(--primary-color);"></i>
|
||||
</button>
|
||||
<input type="date" id="hw-purchase_date-picker" style="position:absolute; width:0; height:0; opacity:0; pointer-events:none;" onchange="document.getElementById('hw-purchase_date').value = this.value" tabindex="-1" />
|
||||
</div>
|
||||
<input type="text" id="hw-purchase_date" name="purchase_date" placeholder="YYYY-MM-DD" style="${inputStyle}" />
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>${ASSET_SCHEMA.PURCHASE_VENDOR.ui}</label>
|
||||
<input type="text" id="hw-purchase_vendor" name="purchase_vendor" />
|
||||
<input type="text" id="hw-purchase_vendor" name="purchase_vendor" style="${inputStyle}" />
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>${ASSET_SCHEMA.PURCHASE_AMOUNT.ui}</label>
|
||||
<input type="text" id="hw-purchase_amount" name="purchase_amount" oninput="this.value = this.value.replace(/[^0-9]/g, '').replace(/\\B(?=(\\d{3})+(?!\\d))/g, ',')" />
|
||||
<input type="text" id="hw-purchase_amount" name="purchase_amount" placeholder="0" style="${inputStyle}" oninput="this.value = this.value.replace(/[^0-9]/g, '').replace(/\\B(?=(\\d{3})+(?!\\d))/g, ',')" />
|
||||
</div>
|
||||
<div class="form-group full-width">
|
||||
<label>${ASSET_SCHEMA.APPROVAL_DOC.ui}</label>
|
||||
<div style="display:flex; align-items:center; gap:0.5rem;">
|
||||
<input type="file" id="hw-approval_document_file" style="font-size:12px;" />
|
||||
<span id="hw-approval_document_name" style="font-size:12px; color:var(--text-muted);"></span>
|
||||
<div class="form-group">
|
||||
<label>${ASSET_SCHEMA.APPROVAL_DOC.ui} (첨부파일)</label>
|
||||
<div class="file-upload-wrapper">
|
||||
<input type="file" id="hw-approval_document_file" style="display:none;" />
|
||||
<div class="input-with-btn" style="display: flex; gap: 8px; align-items: stretch;">
|
||||
<button type="button" id="btn-file-select" onclick="document.getElementById('hw-approval_document_file').click()" class="btn btn-outline btn-loc-action" style="${btnStyle} flex: 1; justify-content: flex-start; pointer-events: auto !important; cursor: pointer !important;">
|
||||
<span id="hw-file-name-display">파일 선택...</span>
|
||||
</button>
|
||||
</div>
|
||||
<input type="hidden" id="hw-approval_document" name="approval_document" />
|
||||
<div id="hw-file-link-container" style="margin-top: 4px;"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="form-group full-width">
|
||||
<label>${ASSET_SCHEMA.MEMO.ui}</label>
|
||||
<textarea id="hw-memo" name="memo" rows="2"></textarea>
|
||||
<textarea id="hw-memo" name="memo" rows="3" style="width: 100%; padding: 10px; border: 1px solid var(--border-color); border-radius: 4px; font-family: inherit; font-size: 13px; resize: none !important; box-sizing: border-box;"></textarea>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<div class="modal-history-area">
|
||||
<div class="history-header">
|
||||
<h3><i data-lucide="history" style="width:16px; height:16px;"></i> 자산 변동 이력</h3>
|
||||
<button type="button" id="btn-add-hw-log" class="btn btn-outline btn-sm">
|
||||
이력 추가 <i data-lucide="plus" style="width:14px; height:14px;"></i>
|
||||
</button>
|
||||
<div class="history-header" style="border-bottom: 1px solid var(--border-color); padding-bottom: 12px; margin-bottom: 16px;">
|
||||
<h3 style="margin: 0; font-size: 14px; font-weight: 800;">자산 변동 이력</h3>
|
||||
<button type="button" id="btn-add-hw-log" class="btn btn-outline btn-sm" style="height: 30px; font-size: 11px;">이력 추가</button>
|
||||
</div>
|
||||
<div id="hw-history-list" class="history-timeline"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<button id="btn-delete-hw-asset" class="btn btn-outline btn-danger">삭제</button>
|
||||
<button id="btn-delete-hw-asset" class="btn btn-outline btn-danger" style="height: 42px;">삭제</button>
|
||||
<div class="footer-actions">
|
||||
<button id="btn-revert-hw-edit" class="btn btn-outline hidden">수정 취소</button>
|
||||
<button id="btn-cancel-hw-modal" class="btn btn-outline">닫기</button>
|
||||
<button id="btn-save-hw-asset" class="btn btn-primary">수정</button>
|
||||
<button id="btn-revert-hw-edit" class="btn btn-outline hidden" style="height: 42px;">수정 취소</button>
|
||||
<button id="btn-cancel-hw-modal" class="btn btn-outline" style="height: 42px;">닫기</button>
|
||||
<button id="btn-save-hw-asset" class="btn btn-primary" style="height: 42px;">저장</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -273,48 +265,48 @@ class HwAssetModal extends BaseModal {
|
||||
|
||||
categorySelect.addEventListener('change', () => {
|
||||
const types = CATEGORY_TYPE_MAP[categorySelect.value] || [];
|
||||
typeSelect.innerHTML = types.length > 0
|
||||
? generateOptionsHTML(types, '', true)
|
||||
: '<option value="">구분을 먼저 선택하세요</option>';
|
||||
typeSelect.innerHTML = types.length > 0 ? generateOptionsHTML(types, '', true) : '<option value="">구분을 먼저 선택하세요</option>';
|
||||
this.applyRoleVisibility();
|
||||
});
|
||||
|
||||
typeSelect.addEventListener('change', () => {
|
||||
this.applyRoleVisibility();
|
||||
});
|
||||
|
||||
document.getElementById('btn-gen-hw-code')?.addEventListener('click', async () => {
|
||||
const cat = categorySelect.value;
|
||||
if (!cat) { alert('구분을 먼저 선택해주세요.'); return; }
|
||||
|
||||
const prefix = TYPE_PREFIX_MAP[cat] || 'ETC';
|
||||
const purchaseDate = (document.getElementById('hw-purchase_date') as HTMLInputElement)?.value || '';
|
||||
|
||||
try {
|
||||
const res = await fetch(`http://${location.hostname}:3000/api/generate-asset-code?prefix=${prefix}&purchaseDate=${purchaseDate}`);
|
||||
const data = await res.json();
|
||||
if (data.nextCode) {
|
||||
setFieldValue('hw-asset_code', data.nextCode);
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('코드 생성 실패:', err);
|
||||
}
|
||||
if (data.nextCode) setFieldValue('hw-asset_code', data.nextCode);
|
||||
} catch (err) { console.error('코드 생성 실패:', err); }
|
||||
});
|
||||
|
||||
bldgSelect.addEventListener('change', () => setTimeout(() => this.updateMapButtonVisibility(), 100));
|
||||
detailSelect.addEventListener('change', () => this.updateMapButtonVisibility());
|
||||
|
||||
document.getElementById('btn-reg-loc-map')?.addEventListener('click', async () => {
|
||||
document.getElementById('btn-reg-loc-map')?.addEventListener('click', async (e) => {
|
||||
e.preventDefault(); e.stopPropagation();
|
||||
await this.fetchMapConfig();
|
||||
const images = this.getImagesForLocation(bldgSelect.value, detailSelect.value);
|
||||
if (images) this.openImagePicker(images, `${detailSelect.value} 위치 등록`);
|
||||
});
|
||||
|
||||
document.getElementById('btn-view-loc-map')?.addEventListener('click', async () => {
|
||||
document.getElementById('btn-view-loc-map')?.addEventListener('click', async (e) => {
|
||||
e.preventDefault(); e.stopPropagation();
|
||||
await this.fetchMapConfig();
|
||||
const images = this.getImagesForLocation(bldgSelect.value, detailSelect.value);
|
||||
const x = getFieldValue('hw-loc_x');
|
||||
const y = getFieldValue('hw-loc_y');
|
||||
const savedImg = getFieldValue('hw-location_photo');
|
||||
|
||||
const bldg = getFieldValue('hw-bldg-select');
|
||||
const detail = getFieldValue('hw-location_detail');
|
||||
const images = this.getImagesForLocation(bldg, detail);
|
||||
if (images) {
|
||||
const imgPath = savedImg && images.includes(savedImg) ? savedImg : images[0];
|
||||
this.openImagePreview(imgPath, `${detailSelect.value} 위치 확인`, x, y);
|
||||
this.openImagePreview(imgPath, `${detail} 위치 확인`, x, y);
|
||||
}
|
||||
});
|
||||
|
||||
@@ -329,6 +321,40 @@ class HwAssetModal extends BaseModal {
|
||||
this.setEditLockMode('view');
|
||||
if (this.currentAsset) this.fillFormData(this.currentAsset);
|
||||
this.updateMapButtonVisibility();
|
||||
this.toggleEditOnlyBtns(false);
|
||||
});
|
||||
|
||||
// 동적 기능 이벤트 연결
|
||||
document.getElementById('btn-add-volume')?.addEventListener('click', () => this.addVolumeRow());
|
||||
document.getElementById('btn-add-network')?.addEventListener('click', () => this.addNetworkRow());
|
||||
document.getElementById('btn-add-remote')?.addEventListener('click', () => this.addRemoteRow());
|
||||
|
||||
const fileInput = document.getElementById('hw-approval_document_file') as HTMLInputElement;
|
||||
const fileNameDisplay = document.getElementById('hw-file-name-display');
|
||||
const fileLinkContainer = document.getElementById('hw-file-link-container');
|
||||
|
||||
fileInput?.addEventListener('change', async (e) => {
|
||||
const file = (e.target as HTMLInputElement).files?.[0];
|
||||
if (!file) return;
|
||||
if (fileNameDisplay) fileNameDisplay.textContent = file.name;
|
||||
const reader = new FileReader();
|
||||
reader.onload = async () => {
|
||||
try {
|
||||
const res = await fetch(`http://${location.hostname}:3000/api/upload`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ fileName: file.name, fileData: reader.result })
|
||||
});
|
||||
const data = await res.json();
|
||||
if (data.success) {
|
||||
setFieldValue('hw-approval_document', data.filePath);
|
||||
if (fileLinkContainer) {
|
||||
fileLinkContainer.innerHTML = `<a href="http://${location.hostname}:3000${data.filePath}" target="_blank" class="btn-loc-action" style="color:var(--primary-color); font-size:12px; text-decoration:underline; pointer-events: auto !important;">[업로드 완료: 파일 보기]</a>`;
|
||||
}
|
||||
}
|
||||
} catch (err) { console.error('파일 업로드 실패:', err); alert('파일 업로드 중 오류가 발생했습니다.'); }
|
||||
};
|
||||
reader.readAsDataURL(file);
|
||||
});
|
||||
|
||||
saveBtn.addEventListener('click', async () => {
|
||||
@@ -337,21 +363,115 @@ class HwAssetModal extends BaseModal {
|
||||
this.setEditLockMode('edit');
|
||||
this.isEditMode = true;
|
||||
this.updateMapButtonVisibility();
|
||||
this.toggleFileUploadUI(true);
|
||||
this.toggleEditOnlyBtns(true);
|
||||
return;
|
||||
}
|
||||
|
||||
|
||||
// 동적 볼륨 데이터 수집
|
||||
const vols: any[] = [];
|
||||
document.querySelectorAll('#hw-volume-container .volume-row').forEach((row, idx) => {
|
||||
const type = (row.querySelector('.vol-type') as HTMLSelectElement).value;
|
||||
const cap = (row.querySelector('.vol-cap') as HTMLInputElement).value;
|
||||
const unit = (row.querySelector('.vol-unit') as HTMLSelectElement).value;
|
||||
if (cap) vols.push({ type, capacity: parseFloat(cap), unit, slot: idx + 1 });
|
||||
});
|
||||
setFieldValue('hw-volumes-data', JSON.stringify(vols));
|
||||
|
||||
// 동적 네트워크/원격 데이터 수집
|
||||
const nets: any[] = [];
|
||||
document.querySelectorAll('#hw-network-container .net-row').forEach(row => {
|
||||
const name = (row.querySelector('.net-name') as HTMLInputElement).value;
|
||||
const ip = (row.querySelector('.net-ip') as HTMLInputElement).value;
|
||||
const mac = (row.querySelector('.net-mac') as HTMLInputElement).value;
|
||||
if (ip || mac) nets.push({ type: 'IP', name, val1: ip, val2: mac });
|
||||
});
|
||||
document.querySelectorAll('#hw-remote-container .remote-row').forEach(row => {
|
||||
const name = (row.querySelector('.rem-name') as HTMLInputElement).value;
|
||||
const id = (row.querySelector('.rem-id') as HTMLInputElement).value;
|
||||
const pw = (row.querySelector('.rem-pw') as HTMLInputElement).value;
|
||||
if (name || id) nets.push({ type: 'REMOTE', name, val1: id, val2: pw });
|
||||
});
|
||||
setFieldValue('hw-remotes-data', JSON.stringify(nets));
|
||||
|
||||
const formData = new FormData(this.formEl!);
|
||||
const updated = { ...this.currentAsset };
|
||||
formData.forEach((value, key) => { if (key !== 'id') updated[key] = value; });
|
||||
updated.location = getFieldValue('hw-bldg-select');
|
||||
|
||||
|
||||
if (await saveAsset(this.getCategoryKey(updated), updated)) {
|
||||
alert(UI_TEXT.MESSAGES.SAVE_SUCCESS);
|
||||
onSave(); this.close(); closeModals();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
createIcons({ icons: { History, Plus, Save, Paperclip, Calendar, Monitor, Cpu, Network, ShieldCheck } });
|
||||
private addVolumeRow(vol: any = { type: 'SSD', capacity: '', unit: 'GB' }) {
|
||||
const container = document.getElementById('hw-volume-container');
|
||||
if (!container) return;
|
||||
const row = document.createElement('div');
|
||||
row.className = 'volume-row';
|
||||
row.style.display = 'flex'; row.style.gap = '8px'; row.style.alignItems = 'center';
|
||||
const inputStyle = 'height: 38px !important; box-sizing: border-box !important; font-size: 13px; margin: 0; padding: 0 8px;';
|
||||
row.innerHTML = `
|
||||
<select class="vol-type" style="${inputStyle} width: 80px;" ${!this.isEditMode ? 'disabled' : ''}>
|
||||
<option value="SSD" ${vol.type === 'SSD' ? 'selected' : ''}>SSD</option>
|
||||
<option value="HDD" ${vol.type === 'HDD' ? 'selected' : ''}>HDD</option>
|
||||
<option value="NVMe" ${vol.type === 'NVMe' ? 'selected' : ''}>NVMe</option>
|
||||
</select>
|
||||
<input type="number" class="vol-cap" value="${vol.capacity || ''}" placeholder="용량" style="${inputStyle} flex: 1;" ${!this.isEditMode ? 'readonly' : ''} />
|
||||
<select class="vol-unit" style="${inputStyle} width: 70px;" ${!this.isEditMode ? 'disabled' : ''}>
|
||||
<option value="GB" ${vol.unit === 'GB' ? 'selected' : ''}>GB</option>
|
||||
<option value="TB" ${vol.unit === 'TB' ? 'selected' : ''}>TB</option>
|
||||
</select>
|
||||
<button type="button" class="btn btn-outline btn-remove-row edit-only-btn" style="height: 38px !important; padding: 0 12px; color: #E11D48; border-color: #E11D48; display: ${this.isEditMode ? 'inline-flex' : 'none'};">×</button>
|
||||
`;
|
||||
row.querySelector('.btn-remove-row')?.addEventListener('click', () => row.remove());
|
||||
container.appendChild(row);
|
||||
}
|
||||
|
||||
private addNetworkRow(net: any = { name: '기본망', val1: '', val2: '' }) {
|
||||
const container = document.getElementById('hw-network-container');
|
||||
if (!container) return;
|
||||
const row = document.createElement('div');
|
||||
row.className = 'net-row';
|
||||
row.style.display = 'flex'; row.style.gap = '8px'; row.style.alignItems = 'center';
|
||||
const inputStyle = 'height: 38px !important; box-sizing: border-box !important; font-size: 13px; margin: 0; padding: 0 8px;';
|
||||
row.innerHTML = `
|
||||
<input type="text" class="net-name" value="${net.name || ''}" placeholder="망구분(내부망)" style="${inputStyle} width: 100px;" ${!this.isEditMode ? 'readonly' : ''} />
|
||||
<input type="text" class="net-ip" value="${net.val1 || ''}" placeholder="IP 주소" style="${inputStyle} flex: 1;" ${!this.isEditMode ? 'readonly' : ''} />
|
||||
<input type="text" class="net-mac" value="${net.val2 || ''}" placeholder="MAC 주소" style="${inputStyle} flex: 1;" ${!this.isEditMode ? 'readonly' : ''} />
|
||||
<button type="button" class="btn btn-outline btn-remove-row edit-only-btn" style="height: 38px !important; padding: 0 12px; color: #E11D48; border-color: #E11D48; display: ${this.isEditMode ? 'inline-flex' : 'none'};">×</button>
|
||||
`;
|
||||
row.querySelector('.btn-remove-row')?.addEventListener('click', () => row.remove());
|
||||
container.appendChild(row);
|
||||
}
|
||||
|
||||
private addRemoteRow(rem: any = { name: '', val1: '', val2: '' }) {
|
||||
const container = document.getElementById('hw-remote-container');
|
||||
if (!container) return;
|
||||
const row = document.createElement('div');
|
||||
row.className = 'remote-row';
|
||||
row.style.display = 'flex'; row.style.gap = '8px'; row.style.alignItems = 'center';
|
||||
const inputStyle = 'height: 38px !important; box-sizing: border-box !important; font-size: 13px; margin: 0; padding: 0 8px;';
|
||||
row.innerHTML = `
|
||||
<input type="text" class="rem-name" value="${rem.name || ''}" placeholder="접속 도구(AnyDesk)" style="${inputStyle} flex: 1;" ${!this.isEditMode ? 'readonly' : ''} />
|
||||
<input type="text" class="rem-id" value="${rem.val1 || ''}" placeholder="접속 ID" style="${inputStyle} flex: 1;" ${!this.isEditMode ? 'readonly' : ''} />
|
||||
<input type="text" class="rem-pw" value="${rem.val2 || ''}" placeholder="접속 PW" style="${inputStyle} flex: 1;" ${!this.isEditMode ? 'readonly' : ''} />
|
||||
<button type="button" class="btn btn-outline btn-remove-row edit-only-btn" style="height: 38px !important; padding: 0 12px; color: #E11D48; border-color: #E11D48; display: ${this.isEditMode ? 'inline-flex' : 'none'};">×</button>
|
||||
`;
|
||||
row.querySelector('.btn-remove-row')?.addEventListener('click', () => row.remove());
|
||||
container.appendChild(row);
|
||||
}
|
||||
|
||||
private toggleEditOnlyBtns(isEdit: boolean) {
|
||||
['btn-add-volume', 'btn-add-network', 'btn-add-remote'].forEach(id => {
|
||||
const btn = document.getElementById(id);
|
||||
if (btn) btn.style.display = isEdit ? 'inline-flex' : 'none';
|
||||
});
|
||||
document.querySelectorAll('.edit-only-btn').forEach(btn => {
|
||||
(btn as HTMLElement).style.display = isEdit ? 'inline-flex' : 'none';
|
||||
});
|
||||
}
|
||||
|
||||
protected fillFormData(asset: any): void {
|
||||
@@ -359,100 +479,121 @@ class HwAssetModal extends BaseModal {
|
||||
setFieldValue('hw-asset_code', asset.asset_code || '');
|
||||
setFieldValue('hw-purchase_corp', asset.purchase_corp || '');
|
||||
setFieldValue('hw-category', asset.category || '');
|
||||
setFieldValue('hw-current_role', asset.current_role || 'Normal');
|
||||
|
||||
const types = CATEGORY_TYPE_MAP[asset.category] || [];
|
||||
const typeSelect = document.getElementById('hw-asset_type') as HTMLSelectElement;
|
||||
if (typeSelect) typeSelect.innerHTML = types.length > 0 ? generateOptionsHTML(types, asset.asset_type, true) : '<option value="">구분을 먼저 선택하세요</option>';
|
||||
|
||||
setFieldValue('hw-asset_type', asset.asset_type || '');
|
||||
setFieldValue('hw-hw_status', asset.hw_status || '운영');
|
||||
setFieldValue('hw-current_dept', asset.current_dept || '');
|
||||
setFieldValue('hw-previous_dept', asset.previous_dept || '');
|
||||
setFieldValue('hw-manager_primary', asset.manager_primary || '');
|
||||
setFieldValue('hw-manager_secondary', asset.manager_secondary || '');
|
||||
setFieldValue('hw-user_current', asset.user_current || '');
|
||||
setFieldValue('hw-user_position', asset.user_position || '');
|
||||
setFieldValue('hw-previous_user', asset.previous_user || '');
|
||||
setFieldValue('hw-asset_purpose', asset.asset_purpose || '');
|
||||
setFieldValue('hw-model_name', asset.model_name || '');
|
||||
setFieldValue('hw-asset_mfr', asset.asset_mfr || '');
|
||||
setFieldValue('hw-os', asset.os || '');
|
||||
setFieldValue('hw-cpu', asset.cpu || '');
|
||||
setFieldValue('hw-ram', asset.ram || '');
|
||||
setFieldValue('hw-gpu', asset.gpu || '');
|
||||
setFieldValue('hw-ssd_1', asset.ssd_1 || '');
|
||||
setFieldValue('hw-ssd_2', asset.ssd_2 || '');
|
||||
setFieldValue('hw-hdd_1', asset.hdd_1 || '');
|
||||
setFieldValue('hw-hdd_2', asset.hdd_2 || '');
|
||||
setFieldValue('hw-hdd_3', asset.hdd_3 || '');
|
||||
setFieldValue('hw-hdd_4', asset.hdd_4 || '');
|
||||
setFieldValue('hw-mainboard', asset.mainboard || '');
|
||||
setFieldValue('hw-os', asset.os || '');
|
||||
setFieldValue('hw-mac_address', asset.mac_address || '');
|
||||
setFieldValue('hw-ip_address', asset.ip_address || '');
|
||||
setFieldValue('hw-ip_address_2', asset.ip_address_2 || '');
|
||||
setFieldValue('hw-remote_tool', asset.remote_tool || '');
|
||||
setFieldValue('hw-remote_id', asset.remote_id || '');
|
||||
setFieldValue('hw-remote_pw', asset.remote_pw || '');
|
||||
|
||||
// 동적 볼륨 렌더링
|
||||
const volumeContainer = document.getElementById('hw-volume-container');
|
||||
if (volumeContainer) {
|
||||
volumeContainer.innerHTML = '';
|
||||
let vols = [];
|
||||
try { vols = asset.volumes ? (typeof asset.volumes === 'string' ? JSON.parse(asset.volumes) : asset.volumes) : []; } catch(e) {}
|
||||
vols.forEach((v: any) => this.addVolumeRow(v));
|
||||
}
|
||||
|
||||
// 동적 네트워크 및 원격 렌더링
|
||||
const netContainer = document.getElementById('hw-network-container');
|
||||
const remContainer = document.getElementById('hw-remote-container');
|
||||
if (netContainer) netContainer.innerHTML = '';
|
||||
if (remContainer) remContainer.innerHTML = '';
|
||||
let nets = [];
|
||||
try { nets = asset.remotes ? (typeof asset.remotes === 'string' ? JSON.parse(asset.remotes) : asset.remotes) : []; } catch(e) {}
|
||||
|
||||
// Fallback: 서버에서 배열을 안 줬지만 기존 평탄화 데이터가 있는 경우
|
||||
if (nets.length === 0 && (asset.ip_address || asset.remote_tool)) {
|
||||
if (asset.ip_address || asset.mac_address) nets.push({ type: 'IP', name: '기본망', val1: asset.ip_address || '', val2: asset.mac_address || '' });
|
||||
if (asset.remote_tool || asset.remote_id) nets.push({ type: 'REMOTE', name: asset.remote_tool || '', val1: asset.remote_id || '', val2: asset.remote_pw || '' });
|
||||
}
|
||||
|
||||
nets.forEach((n: any) => {
|
||||
if (n.type === 'IP') this.addNetworkRow(n);
|
||||
else if (n.type === 'REMOTE') this.addRemoteRow(n);
|
||||
});
|
||||
|
||||
setFieldValue('hw-monitoring', asset.monitoring || '비대상');
|
||||
setFieldValue('hw-serial_num', asset.serial_num || '');
|
||||
setFieldValue('hw-monitor_inch', asset.monitor_inch || '');
|
||||
setFieldValue('hw-volume', asset.volume || '');
|
||||
setFieldValue('hw-asset_count', asset.asset_count || '');
|
||||
setFieldValue('hw-purchase_date', asset.purchase_date || '');
|
||||
setFieldValue('hw-purchase_vendor', asset.purchase_vendor || '');
|
||||
setFieldValue('hw-purchase_amount', asset.purchase_amount || '');
|
||||
setFieldValue('hw-approval_document', asset.approval_document || '');
|
||||
|
||||
const docName = document.getElementById('hw-approval_document_name');
|
||||
if (docName) docName.textContent = asset.approval_document || '';
|
||||
const docName = document.getElementById('hw-file-name-display');
|
||||
if (docName) docName.textContent = asset.approval_document ? asset.approval_document.split('/').pop() : '파일 선택...';
|
||||
const fileLinkContainer = document.getElementById('hw-file-link-container');
|
||||
if (fileLinkContainer && asset.approval_document) {
|
||||
fileLinkContainer.innerHTML = `<a href="http://${location.hostname}:3000${asset.approval_document}" target="_blank" class="btn-loc-action" style="color:var(--primary-color); font-size:12px; text-decoration:underline; pointer-events: auto !important;">[파일 보기]</a>`;
|
||||
} else if (fileLinkContainer) {
|
||||
fileLinkContainer.innerHTML = '';
|
||||
}
|
||||
|
||||
setFieldValue('hw-memo', asset.memo || '');
|
||||
setFieldValue('hw-location_detail', asset.location_detail || '');
|
||||
setFieldValue('hw-loc_x', asset.loc_x || '');
|
||||
setFieldValue('hw-loc_y', asset.loc_y || '');
|
||||
setFieldValue('hw-location_photo', asset.location_photo || asset.loc_img || '');
|
||||
|
||||
parseAndSetLocation(asset.location || '', asset.location_detail || '', 'hw-bldg-select', 'hw-location_detail');
|
||||
this.renderHistory(asset.id);
|
||||
|
||||
// Initial visibility check based on role
|
||||
this.applyRoleVisibility(asset.current_role || 'Normal');
|
||||
this.applyRoleVisibility();
|
||||
}
|
||||
|
||||
protected onAfterOpen(asset: any, mode: string): void {
|
||||
const genBtn = document.getElementById('btn-gen-hw-code');
|
||||
if (genBtn) genBtn.style.display = (mode === 'add') ? 'inline-flex' : 'none';
|
||||
this.toggleFileUploadUI(mode !== 'view');
|
||||
this.toggleEditOnlyBtns(mode !== 'view');
|
||||
this.updateMapButtonVisibility(asset);
|
||||
|
||||
const role = asset.current_role || 'Normal';
|
||||
this.applyRoleVisibility(role);
|
||||
|
||||
// Role change event
|
||||
const roleSelect = document.getElementById('hw-current_role') as HTMLSelectElement;
|
||||
roleSelect?.addEventListener('change', (e) => {
|
||||
this.applyRoleVisibility((e.target as HTMLSelectElement).value);
|
||||
});
|
||||
this.applyRoleVisibility();
|
||||
}
|
||||
|
||||
private applyRoleVisibility(role: string): void {
|
||||
const isServer = role === 'Server';
|
||||
const isPersonal = role === 'Personal';
|
||||
private toggleFileUploadUI(showUpload: boolean) {
|
||||
const fileBtn = document.getElementById('btn-file-select') as HTMLElement;
|
||||
if (fileBtn) fileBtn.style.display = showUpload ? 'inline-flex' : 'none';
|
||||
}
|
||||
|
||||
// Section Visibility
|
||||
const networkSectionTitle = document.evaluate("//div[contains(text(), '네트워크 및 접속 정보')]", document, null, XPathResult.FIRST_ORDERED_NODE_TYPE, null).singleNodeValue as HTMLElement;
|
||||
const locationSectionTitle = document.evaluate("//div[contains(text(), '설치 위치')]", document, null, XPathResult.FIRST_ORDERED_NODE_TYPE, null).singleNodeValue as HTMLElement;
|
||||
private applyRoleVisibility(): void {
|
||||
const category = (document.getElementById('hw-category') as HTMLSelectElement)?.value || '';
|
||||
const type = (document.getElementById('hw-asset_type') as HTMLSelectElement)?.value || '';
|
||||
|
||||
// Helper to toggle visibility of elements after a title until next section title
|
||||
const toggleSection = (titleEl: HTMLElement, show: boolean) => {
|
||||
if (!titleEl) return;
|
||||
titleEl.style.display = show ? 'block' : 'none';
|
||||
let next = titleEl.nextElementSibling as HTMLElement;
|
||||
while (next && !next.classList.contains('form-section-title')) {
|
||||
next.style.display = show ? 'flex' : 'none';
|
||||
next = next.nextElementSibling as HTMLElement;
|
||||
}
|
||||
};
|
||||
const infraCategories = ['서버', '저장매체', '네트워크', '보안장비', '공간정보장비'];
|
||||
const isInfra = infraCategories.includes(category) || type.includes('서버') || type.includes('저장시스템');
|
||||
const personalCategories = ['PC', '노트북', '모바일', '태블릿'];
|
||||
const isPersonal = (personalCategories.includes(category) || type.includes('개인PC') || type.includes('노트북')) && !type.includes('서버PC');
|
||||
const specCategories = ['PC', '서버', '노트북', '스토리지', '워크스테이션'];
|
||||
const hasSpec = specCategories.includes(category) || type.includes('서버PC');
|
||||
const noNetCategories = ['저장매체', '네트워크', '공간정보장비', 'PC부품', '사무가구'];
|
||||
const showNet = (isInfra || isPersonal) && !noNetCategories.includes(category);
|
||||
const hasSN = !['사무가구', 'PC부품'].includes(category);
|
||||
const isParts = ['PC부품', '사무가구'].includes(category);
|
||||
const showRemote = category === '서버' || type.includes('서버');
|
||||
|
||||
// Show/Hide based on role
|
||||
toggleSection(networkSectionTitle, isServer);
|
||||
toggleSection(locationSectionTitle, !isPersonal);
|
||||
|
||||
// Specific fields
|
||||
document.querySelectorAll('.server-only').forEach(el => (el as HTMLElement).style.display = isServer ? 'flex' : 'none');
|
||||
document.querySelectorAll('.pc-only').forEach(el => (el as HTMLElement).style.display = isPersonal ? 'flex' : 'none');
|
||||
document.querySelectorAll('.remote-section, .remote-field, .monitoring-field').forEach(el => (el as HTMLElement).style.display = showRemote ? '' : 'none');
|
||||
document.querySelectorAll('.net-only').forEach(el => (el as HTMLElement).style.display = showNet ? '' : 'none');
|
||||
document.querySelectorAll('.spec-only').forEach(el => (el as HTMLElement).style.display = hasSpec ? '' : 'none');
|
||||
document.querySelectorAll('.location-section, .location-field').forEach(el => (el as HTMLElement).style.display = (isInfra || category === '공간정보장비') ? '' : 'none');
|
||||
document.querySelectorAll('.org-user-section, .org-user-field').forEach(el => (el as HTMLElement).style.display = (isPersonal || isParts || category === '업무지원장비') ? '' : 'none');
|
||||
document.querySelectorAll('.personal-only').forEach(el => (el as HTMLElement).style.display = isPersonal ? '' : 'none');
|
||||
document.querySelectorAll('.sn-only').forEach(el => (el as HTMLElement).style.display = hasSN ? '' : 'none');
|
||||
document.querySelectorAll('.monitor-only').forEach(el => (el as HTMLElement).style.display = type.includes('모니터') ? '' : 'none');
|
||||
document.querySelectorAll('.parts-only').forEach(el => (el as HTMLElement).style.display = isParts ? '' : 'none');
|
||||
}
|
||||
|
||||
private updateMapButtonVisibility(asset?: any) {
|
||||
@@ -460,18 +601,19 @@ class HwAssetModal extends BaseModal {
|
||||
const detail = asset ? (asset.location_detail || '') : getFieldValue('hw-location_detail');
|
||||
const x = asset ? (asset.loc_x || '') : getFieldValue('hw-loc_x');
|
||||
const y = asset ? (asset.loc_y || '') : getFieldValue('hw-loc_y');
|
||||
|
||||
const hasCoords = (x !== '' && y !== '' && x !== 'null' && y !== 'null');
|
||||
const hasImage = !!this.getImagesForLocation(bldg, detail);
|
||||
|
||||
const regLocBtn = document.getElementById('btn-reg-loc-map')!;
|
||||
const viewLocBtn = document.getElementById('btn-view-loc-map')!;
|
||||
|
||||
if (hasImage && this.isEditMode) regLocBtn.classList.remove('hidden');
|
||||
else regLocBtn.classList.add('hidden');
|
||||
|
||||
if (hasImage && hasCoords) viewLocBtn.classList.remove('hidden');
|
||||
else viewLocBtn.classList.add('hidden');
|
||||
if (hasImage && this.isEditMode) regLocBtn.style.display = 'inline-flex'; else regLocBtn.style.display = 'none';
|
||||
if (hasImage && hasCoords) {
|
||||
viewLocBtn.style.display = 'inline-flex';
|
||||
viewLocBtn.style.pointerEvents = 'auto';
|
||||
viewLocBtn.style.opacity = '1';
|
||||
} else {
|
||||
viewLocBtn.style.display = 'none';
|
||||
}
|
||||
}
|
||||
|
||||
private getImagesForLocation(bldg: string, detail: string): string[] | null {
|
||||
@@ -489,78 +631,32 @@ class HwAssetModal extends BaseModal {
|
||||
private generateDynamicSVG(imagePath: string): string {
|
||||
const boxes = this.dynamicMapConfig[imagePath] || [];
|
||||
if (boxes.length === 0) return '';
|
||||
return `
|
||||
<svg viewBox="0 0 100 100" preserveAspectRatio="none" class="digital-map-svg">
|
||||
<g class="seat-group">
|
||||
${boxes.map((b, i) => `<rect class="map-seat-obj" data-id="seat-${i+1}" x="${b.x}" y="${b.y}" width="${b.w}" height="${b.h}" rx="0.5" />`).join('')}
|
||||
</g>
|
||||
</svg>
|
||||
`;
|
||||
return `<svg viewBox="0 0 100 100" preserveAspectRatio="none" style="width:100%; height:100%; position:absolute; top:0; left:0; pointer-events:none;"><g>${boxes.map((b) => `<rect x="${b.x}" y="${b.y}" width="${b.w}" height="${b.h}" rx="0.5" style="fill:rgba(30,81,73,0.05); stroke:rgba(30,81,73,0.2); stroke-width:0.2;" />`).join('')}</g></svg>`;
|
||||
}
|
||||
|
||||
private openImagePicker(imagePaths: string[], title: string) {
|
||||
let currentIdx = 0;
|
||||
const overlay = document.createElement('div');
|
||||
overlay.className = 'image-picker-overlay';
|
||||
|
||||
const renderContent = () => {
|
||||
const imgPath = imagePaths[currentIdx];
|
||||
const isMulti = imagePaths.length > 1;
|
||||
const digitalMap = this.generateDynamicSVG(imgPath);
|
||||
overlay.innerHTML = `
|
||||
<div class="image-picker-header">
|
||||
<h3>${title} ${isMulti ? `(${currentIdx + 1}/${imagePaths.length})` : ''}</h3>
|
||||
<button class="btn-icon btn-close-picker" style="color:white !important;"><i data-lucide="x"></i></button>
|
||||
</div>
|
||||
<div class="image-picker-content">
|
||||
${isMulti ? `<div class=\"picker-nav prev ${currentIdx === 0 ? 'disabled' : ''}\">◀</div><div class=\"picker-nav next ${currentIdx === imagePaths.length - 1 ? 'disabled' : ''}\">▶</div>` : ''}
|
||||
<div class="layout-map-container" id="picker-container">
|
||||
<img src="${imgPath}" class="layout-map-img" />
|
||||
<div id="picker-marker" class="layout-marker hidden"></div>
|
||||
<div class="digital-overlay-layer">${digitalMap}</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="image-picker-footer">
|
||||
<p style="color:#ddd; font-size:12px; margin:0; flex:1;">배치도의 네모 칸을 클릭하면 위치가 자동으로 지정됩니다.</p>
|
||||
<button id="btn-picker-cancel" class="btn btn-outline" style="color:white; border-color:white;">취소</button>
|
||||
<button id="btn-picker-save" class="btn btn-primary">위치 확정</button>
|
||||
</div>
|
||||
`;
|
||||
createIcons({ icons: { X } });
|
||||
|
||||
<div class="image-picker-header"><h3>${title}</h3><button class="btn-close-picker" style="background:none; border:none; color:white; font-size:24px; cursor:pointer;">×</button></div>
|
||||
<div class="image-picker-content"><div class="layout-map-container" id="picker-container"><img src="${imgPath}" class="layout-map-img" /><div id="picker-marker" class="layout-marker hidden"></div><div class="digital-overlay-layer">${digitalMap}</div></div></div>
|
||||
<div class="image-picker-footer"><button id="btn-picker-cancel" class="btn btn-outline" style="color:white; border-color:white;">취소</button><button id="btn-picker-save" class="btn btn-primary">위치 확정</button></div>`;
|
||||
let selectedX = ''; let selectedY = '';
|
||||
const container = overlay.querySelector('#picker-container') as HTMLElement;
|
||||
const marker = overlay.querySelector('#picker-marker') as HTMLElement;
|
||||
|
||||
overlay.querySelectorAll('.map-seat-obj').forEach(seat => {
|
||||
seat.addEventListener('click', (e) => {
|
||||
e.stopPropagation();
|
||||
const target = e.currentTarget as SVGRectElement;
|
||||
selectedX = target.getAttribute('x') || ''; selectedY = target.getAttribute('y') || '';
|
||||
const w = target.getAttribute('width') || '0'; const h = target.getAttribute('height') || '0';
|
||||
marker.style.left = `${parseFloat(selectedX) + parseFloat(w)/2}%`;
|
||||
marker.style.top = `${parseFloat(selectedY) + parseFloat(h)/2}%`;
|
||||
marker.classList.remove('hidden');
|
||||
});
|
||||
container.addEventListener('click', (e) => {
|
||||
const rect = container.getBoundingClientRect();
|
||||
const x = ((e.clientX - rect.left) / rect.width) * 100;
|
||||
const y = ((e.clientY - rect.top) / rect.height) * 100;
|
||||
selectedX = x.toFixed(2); selectedY = y.toFixed(2);
|
||||
marker.style.left = `${selectedX}%`; marker.style.top = `${selectedY}%`; marker.classList.remove('hidden');
|
||||
});
|
||||
|
||||
if (!digitalMap) {
|
||||
container.addEventListener('click', (e) => {
|
||||
const rect = container.getBoundingClientRect();
|
||||
const x = ((e.clientX - rect.left) / rect.width) * 100;
|
||||
const y = ((e.clientY - rect.top) / rect.height) * 100;
|
||||
selectedX = x.toFixed(2); selectedY = y.toFixed(2);
|
||||
marker.style.left = `${selectedX}%`; marker.style.top = `${selectedY}%`;
|
||||
marker.classList.remove('hidden');
|
||||
});
|
||||
}
|
||||
|
||||
overlay.querySelector('.btn-close-picker')?.addEventListener('click', () => overlay.remove());
|
||||
overlay.querySelector('#btn-picker-cancel')?.addEventListener('click', () => overlay.remove());
|
||||
if (isMulti) {
|
||||
overlay.querySelector('.picker-nav.prev')?.addEventListener('click', (e) => { if (currentIdx > 0) { currentIdx--; renderContent(); } });
|
||||
overlay.querySelector('.picker-nav.next')?.addEventListener('click', (e) => { if (currentIdx < imagePaths.length - 1) { currentIdx++; renderContent(); } });
|
||||
}
|
||||
overlay.querySelector('#btn-picker-save')?.addEventListener('click', () => {
|
||||
if (!selectedX || !selectedY) { alert('위치를 선택해주세요.'); return; }
|
||||
setFieldValue('hw-loc_x', selectedX); setFieldValue('hw-loc_y', selectedY);
|
||||
@@ -575,33 +671,22 @@ class HwAssetModal extends BaseModal {
|
||||
const overlay = document.createElement('div');
|
||||
overlay.className = 'image-picker-overlay';
|
||||
const digitalMap = this.generateDynamicSVG(imagePath);
|
||||
|
||||
overlay.innerHTML = `
|
||||
<div class="image-picker-header">
|
||||
<h3>${title}</h3>
|
||||
<button class="btn-icon btn-close-picker" style="color:white !important;"><i data-lucide="x"></i></button>
|
||||
</div>
|
||||
<div class="image-picker-content">
|
||||
<div class="layout-map-container readonly">
|
||||
<img src="${imagePath}" class="layout-map-img" />
|
||||
<div id="preview-marker" class="layout-marker pulse-marker" style="left:${x}%; top:${y}%;"></div>
|
||||
<div class="digital-overlay-layer">${digitalMap}</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="image-picker-footer"><button id="btn-preview-close" class="btn btn-primary">확인</button></div>
|
||||
`;
|
||||
<div class="image-picker-header"><h3>${title}</h3><button class="btn-close-picker" style="background:none; border:none; color:white; font-size:24px; cursor:pointer;">×</button></div>
|
||||
<div class="image-picker-content"><div class="layout-map-container readonly"><img src="${imagePath}" class="layout-map-img" /><div id="preview-marker" class="layout-marker pulse-marker" style="left:${x}%; top:${y}%;"></div><div class="digital-overlay-layer">${digitalMap}</div></div></div>
|
||||
<div class="image-picker-footer"><button id="btn-preview-close" class="btn btn-primary">확인</button></div>`;
|
||||
document.body.appendChild(overlay);
|
||||
createIcons({ icons: { X } });
|
||||
|
||||
if (digitalMap) {
|
||||
overlay.querySelectorAll('.map-seat-obj').forEach(seat => {
|
||||
const sx = seat.getAttribute('x'); const sy = seat.getAttribute('y');
|
||||
if (sx === x && sy === y) {
|
||||
(seat as SVGRectElement).style.fill = 'rgba(255, 61, 0, 0.4)';
|
||||
(seat as SVGRectElement).style.stroke = '#FF3D00'; (seat as SVGRectElement).style.strokeWidth = '0.8';
|
||||
const curX = parseFloat(x || '0'); const curY = parseFloat(y || '0');
|
||||
overlay.querySelectorAll('rect').forEach(rect => {
|
||||
const sx = parseFloat(rect.getAttribute('x') || '0');
|
||||
const sy = parseFloat(rect.getAttribute('y') || '0');
|
||||
if (Math.abs(sx - curX) < 0.01 && Math.abs(sy - curY) < 0.01) {
|
||||
rect.style.fill = 'rgba(255, 61, 0, 0.4)'; rect.style.stroke = '#FF3D00'; rect.style.strokeWidth = '0.8';
|
||||
const w = parseFloat(rect.getAttribute('width') || '0');
|
||||
const h = parseFloat(rect.getAttribute('height') || '0');
|
||||
const marker = overlay.querySelector('#preview-marker') as HTMLElement;
|
||||
const w = seat.getAttribute('width') || '0'; const h = seat.getAttribute('height') || '0';
|
||||
marker.style.left = `${parseFloat(sx!) + parseFloat(w)/2}%`; marker.style.top = `${parseFloat(sy!) + parseFloat(h)/2}%`;
|
||||
if (marker) { marker.style.left = `${sx + w/2}%`; marker.style.top = `${sy + h/2}%`; }
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -631,14 +716,6 @@ class HwAssetModal extends BaseModal {
|
||||
}
|
||||
}
|
||||
|
||||
// 싱글톤 인스턴스 생성 및 익스포트
|
||||
export const hwModal = new HwAssetModal();
|
||||
|
||||
// 레거시 호환성을 위한 함수 래퍼
|
||||
export function initHwModal(onSave: () => void, closeModals: () => void) {
|
||||
hwModal.init(onSave, closeModals);
|
||||
}
|
||||
|
||||
export function openHwModal(asset: any, mode: 'view' | 'edit' | 'add' = 'view') {
|
||||
hwModal.open(asset, mode);
|
||||
}
|
||||
export function initHwModal(onSave: () => void, closeModals: () => void) { hwModal.init(onSave, closeModals); }
|
||||
export function openHwModal(asset: any, mode: 'view' | 'edit' | 'add' = 'view') { hwModal.open(asset, mode); }
|
||||
|
||||
@@ -13,15 +13,15 @@ export const HW_STATUS_LIST = ['운영', '재고', '수리', '폐기', '기타']
|
||||
|
||||
// 구분(Category) -> 유형(Asset Type) 관계 정의 (통합 관리)
|
||||
export const CATEGORY_TYPE_MAP: Record<string, string[]> = {
|
||||
'서버': ['서버 렉', '가상서버(VM)', '워크스테이션', '서버PC', '저장시스템_렉(NAS)', '저장시스템_렉(DAS)', '저장시스템_미니(NAS)', '저장시스템_미니(DAS)'],
|
||||
'서버': ['서버 렉', '가상서버(VM)', '워크스테이션', '저장시스템_렉(NAS)', '저장시스템_렉(DAS)', '저장시스템_미니(NAS)', '저장시스템_미니(DAS)'],
|
||||
'PC': ['개인PC', '노트북', '공용PC', '서버PC'],
|
||||
'저장매체': ['SSD', 'HDD', '외장HDD'],
|
||||
'네트워크': ['스위치', '허브', '방화벽', '라우터', '공유기', '허브'],
|
||||
'PC부품': ['CPU', 'RAM', 'GPU', 'SSD', 'HDD', 'RAM', '모니터'],
|
||||
'공간정보장비': ['드론', '측량장비', '보조기기'],
|
||||
'업무지원장비': ['카메라', '스피커', 'TV', '모바일', '유선전화기', 'XR', '프린터', '전산소모품'],
|
||||
'외부': ['영구', '구독'],
|
||||
'내부': ['판매용', 'Solutions', 'Inhouse', 'Engine&Module'],
|
||||
'외부SW': ['영구', '구독'],
|
||||
'내부SW': ['판매용', 'Solutions', 'Inhouse', 'Engine&Module'],
|
||||
'비용관리': ['클라우드', '도메인', '전화', '인터넷', '이메일'],
|
||||
'내빈/외빈': ['선물'],
|
||||
'시설자산': ['사무가구']
|
||||
|
||||
@@ -7,7 +7,7 @@ const MENU_CONFIG: any = {
|
||||
},
|
||||
sw: {
|
||||
label: '소프트웨어',
|
||||
tabs: ['외부', '내부']
|
||||
tabs: ['외부SW', '내부SW']
|
||||
},
|
||||
ops: {
|
||||
label: '운영지원',
|
||||
|
||||
@@ -120,12 +120,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'
|
||||
|
||||
@@ -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 '';
|
||||
}
|
||||
|
||||
@@ -121,7 +121,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);
|
||||
|
||||
@@ -48,9 +48,14 @@ export function createListView(container: HTMLElement, config: ListViewConfig) {
|
||||
const toggleWrapper = document.createElement('div');
|
||||
toggleWrapper.className = 'view-toggle-container';
|
||||
toggleWrapper.innerHTML = `
|
||||
<div class="view-toggle">
|
||||
<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 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);
|
||||
@@ -87,8 +92,20 @@ export function createListView(container: HTMLElement, config: ListViewConfig) {
|
||||
const pcTypeCounts = { public: 0, server: 0, personal: 0 };
|
||||
|
||||
// 동적 통계 수집 객체 (Hardcoding 제거)
|
||||
const extStats = { total: 0, locCounts: {} as Record<string, number>, typeCounts: {} as Record<string, number>, locWarning: 0, typeWarning: 0 };
|
||||
const intStats = { total: 0, locCounts: {} as Record<string, number>, typeCounts: {} as Record<string, number> };
|
||||
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) => {
|
||||
@@ -122,7 +139,12 @@ export function createListView(container: HTMLElement, config: ListViewConfig) {
|
||||
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 (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);
|
||||
@@ -132,7 +154,7 @@ export function createListView(container: HTMLElement, config: ListViewConfig) {
|
||||
});
|
||||
|
||||
// 템플릿 제너레이터 함수 (HTML 중복 제거)
|
||||
const generateDetailStatHTML = (title: string, stats: typeof extStats) => `
|
||||
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;">
|
||||
@@ -142,12 +164,20 @@ export function createListView(container: HTMLElement, config: ListViewConfig) {
|
||||
</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).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('')}
|
||||
${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).sort((a, b) => b[1] - a[1]).slice(0, 6).map(([t, c]) => {
|
||||
${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');
|
||||
return `<span style="${isTypeWarning ? 'color:#E11D48; font-weight:700;' : ''}; font-size: 13px;">${t}: <strong style="color:var(--text-main); font-size: 14px;">${c}</strong></span>`;
|
||||
|
||||
// 위치별 상세 정보 생성 (툴팁용)
|
||||
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>
|
||||
@@ -397,8 +427,8 @@ export function createListView(container: HTMLElement, config: ListViewConfig) {
|
||||
const managerSub = asset[ASSET_SCHEMA.MANAGER_SUB.key] || '-';
|
||||
|
||||
// [경고 로직] 외부 운영인데 서버PC이거나 IDC가 아닌 경우
|
||||
const isLocWarning = serviceType === '외부' && loc !== 'IDC';
|
||||
const isTypeWarning = serviceType === '외부' && type.toLowerCase().replace(/\s/g, '').includes('서버pc');
|
||||
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;' : '';
|
||||
|
||||
|
||||
@@ -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>`;
|
||||
|
||||
Reference in New Issue
Block a user