Compare commits
3 Commits
05c565552a
...
4b408b0640
| Author | SHA1 | Date | |
|---|---|---|---|
| 4b408b0640 | |||
| 3ab587d342 | |||
| 3b9b2ea598 |
457
server.js
457
server.js
@@ -8,208 +8,355 @@ dotenv.config();
|
||||
|
||||
const app = express();
|
||||
app.use(cors());
|
||||
app.use(express.json({ limit: '100mb' }));
|
||||
app.use(express.json({ limit: '50mb' }));
|
||||
app.use('/uploads', express.static('uploads')); // 업로드 파일 정적 서빙
|
||||
|
||||
// Request Logger
|
||||
app.use((req, res, next) => {
|
||||
console.log(`[${new Date().toISOString()}] ${req.method} ${req.url}`);
|
||||
next();
|
||||
});
|
||||
// uploads 폴더가 없으면 생성
|
||||
if (!fs.existsSync('uploads')) {
|
||||
fs.mkdirSync('uploads');
|
||||
}
|
||||
|
||||
// MySQL Pool Configuration
|
||||
const pool = mysql.createPool({
|
||||
host: process.env.DB_HOST,
|
||||
user: process.env.DB_USER,
|
||||
password: process.env.DB_PASS,
|
||||
database: process.env.DB_NAME,
|
||||
port: parseInt(process.env.DB_PORT || '3306'),
|
||||
charset: 'utf8mb4'
|
||||
waitForConnections: true,
|
||||
connectionLimit: 10,
|
||||
queueLimit: 0
|
||||
});
|
||||
|
||||
const handleError = (res, err, context, isGet = false) => {
|
||||
console.error(`❌ [${context}] Error:`, err.message);
|
||||
if (isGet) res.json([]);
|
||||
else res.status(500).json({ error: err.message });
|
||||
// Error Handler
|
||||
const handleError = (res, err, label) => {
|
||||
console.error(`❌ [${label}] Error:`, err);
|
||||
res.status(500).json({ error: err.message });
|
||||
};
|
||||
|
||||
// --- API Implementation ---
|
||||
|
||||
/**
|
||||
* Generic Fetcher for Asset Tables
|
||||
*/
|
||||
const fetchAssets = async (tableName, res, context) => {
|
||||
try {
|
||||
const [rows] = await pool.query(`SELECT * FROM ${tableName}`);
|
||||
console.log(`📡 [GET ${context}] Returning ${rows.length} rows from ${tableName}`);
|
||||
res.json(rows);
|
||||
} catch (err) {
|
||||
handleError(res, err, context, true);
|
||||
}
|
||||
// --- Global Constants ---
|
||||
const CATEGORY_TABLE_MAP = {
|
||||
pc: 'asset_pc',
|
||||
server: 'asset_server',
|
||||
storage: 'asset_storage',
|
||||
network: 'asset_network',
|
||||
equipment: 'asset_equipment',
|
||||
officeSupplies: 'asset_office_supplies',
|
||||
survey: 'asset_survey',
|
||||
vip: 'asset_vip',
|
||||
swInternal: 'sw_internal',
|
||||
swExternal: 'sw_external',
|
||||
cloud: 'asset_cloud',
|
||||
users: 'user_master',
|
||||
swUsers: 'sw_assignment',
|
||||
logs: 'asset_history'
|
||||
};
|
||||
|
||||
/**
|
||||
* Generic Batch Saver for Asset Tables
|
||||
*/
|
||||
const saveAssetsBatch = async (tableName, items, res, context) => {
|
||||
const connection = await pool.getConnection();
|
||||
const ASSET_TABLES = [
|
||||
'asset_pc', 'asset_server', 'asset_storage', 'asset_network',
|
||||
'asset_equipment', 'asset_office_supplies', 'asset_survey', 'asset_vip'
|
||||
];
|
||||
|
||||
// --- API Endpoints ---
|
||||
|
||||
// 1. Generic Batch Save (Dynamic Table Detection)
|
||||
app.post('/api/:table/batch', async (req, res) => {
|
||||
const { table } = req.params;
|
||||
const data = req.body;
|
||||
if (!Array.isArray(data)) return res.status(400).json({ error: 'Data must be an array' });
|
||||
|
||||
let connection;
|
||||
try {
|
||||
connection = await pool.getConnection();
|
||||
await connection.beginTransaction();
|
||||
|
||||
// Get valid columns for this table
|
||||
const [cols] = await connection.query(`DESCRIBE ${tableName}`);
|
||||
const validColumns = cols.map(c => c.Field);
|
||||
|
||||
// 1. Clear existing
|
||||
await connection.query(`DELETE FROM ${tableName}`);
|
||||
|
||||
// 2. Insert new items
|
||||
for (const item of items) {
|
||||
const filteredRow = {};
|
||||
validColumns.forEach(col => {
|
||||
if (col === 'created_at' || col === 'updated_at') return;
|
||||
if (item[col] !== undefined) filteredRow[col] = item[col];
|
||||
});
|
||||
|
||||
if (!filteredRow.id) filteredRow.id = Math.random().toString(36).substring(2, 9);
|
||||
await connection.query(`INSERT INTO ${tableName} SET ?`, [filteredRow]);
|
||||
|
||||
const [columns] = await connection.query(`DESCRIBE ${table}`);
|
||||
const validFields = columns.map(c => c.Field);
|
||||
|
||||
await connection.query(`DELETE FROM ${table}`);
|
||||
|
||||
if (data.length > 0) {
|
||||
const placeholders = validFields.map(() => '?').join(', ');
|
||||
const sql = `INSERT INTO ${table} (${validFields.join(', ')}) VALUES (${placeholders})`;
|
||||
|
||||
for (const item of data) {
|
||||
const values = validFields.map(field => {
|
||||
const val = item[field];
|
||||
return val === undefined ? null : val;
|
||||
});
|
||||
await connection.query(sql, values);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
await connection.commit();
|
||||
res.json({ success: true, count: items.length });
|
||||
res.json({ success: true, count: data.length });
|
||||
} catch (err) {
|
||||
await connection.rollback();
|
||||
handleError(res, err, context);
|
||||
if (connection) await connection.rollback();
|
||||
handleError(res, err, 'BATCH SAVE');
|
||||
} finally {
|
||||
connection.release();
|
||||
if (connection) connection.release();
|
||||
}
|
||||
};
|
||||
|
||||
// --- Routes ---
|
||||
|
||||
const routeMap = {
|
||||
'/api/users': { table: 'system_users', context: 'USERS' },
|
||||
'/api/pc': { table: 'asset_pc', context: 'PC' },
|
||||
'/api/server': { table: 'asset_server', context: 'SERVER' },
|
||||
'/api/storage': { table: 'asset_storage', context: 'STORAGE' },
|
||||
'/api/network': { table: 'asset_network', context: 'NETWORK' },
|
||||
'/api/sw/internal': { table: 'asset_sw_internal', context: 'SW INTERNAL' },
|
||||
'/api/sw/external': { table: 'asset_sw_external', context: 'SW EXTERNAL' },
|
||||
'/api/survey': { table: 'asset_survey', context: 'SURVEY' },
|
||||
'/api/pc-parts': { table: 'asset_pc_parts', context: 'PC PARTS' },
|
||||
'/api/equipment': { table: 'asset_equipment', context: 'EQUIPMENT' },
|
||||
'/api/office-supplies': { table: 'asset_office_supplies', context: 'OFFICE SUPPLIES' },
|
||||
'/api/cloud': { table: 'asset_cloud', context: 'CLOUD' },
|
||||
'/api/domain': { table: 'asset_domain', context: 'DOMAIN' },
|
||||
'/api/cost': { table: 'asset_cost', context: 'COST' },
|
||||
'/api/vip': { table: 'asset_vip', context: 'VIP' },
|
||||
'/api/asset/software/assignment': { table: 'asset_software_assignment', context: 'SW ASSIGN' }
|
||||
};
|
||||
|
||||
Object.entries(routeMap).forEach(([route, { table, context }]) => {
|
||||
app.get(route, (req, res) => fetchAssets(table, res, context));
|
||||
app.post(`${route}/batch`, (req, res) => saveAssetsBatch(table, req.body, res, `${context} BATCH`));
|
||||
});
|
||||
|
||||
app.get('/api/asset/history', (req, res) => fetchAssets('asset_history', res, 'HISTORY'));
|
||||
app.post('/api/asset/history/batch', async (req, res) => {
|
||||
const connection = await pool.getConnection();
|
||||
try {
|
||||
await connection.beginTransaction();
|
||||
await connection.query('DELETE FROM asset_history');
|
||||
for (const item of req.body) {
|
||||
const dbRow = {
|
||||
asset_id: item.assetId,
|
||||
log_date: item.date,
|
||||
log_user: item.user,
|
||||
details: item.details,
|
||||
cost: item.cost || 0
|
||||
};
|
||||
await connection.query('INSERT INTO asset_history SET ?', [dbRow]);
|
||||
}
|
||||
await connection.commit();
|
||||
res.json({ success: true });
|
||||
} catch (err) { await connection.rollback(); handleError(res, err, 'BATCH HISTORY'); } finally { connection.release(); }
|
||||
});
|
||||
|
||||
app.get('/api/generate-asset-code', async (req, res) => {
|
||||
// 2. Get All Assets (Integrated Master Data from Normalized V3 Schema)
|
||||
app.get('/api/assets/master', async (req, res) => {
|
||||
try {
|
||||
const { prefix } = req.query;
|
||||
if (!prefix) return res.status(400).json({ error: 'Prefix is required' });
|
||||
|
||||
// asset_code 컬럼이 있는 것으로 확인된 테이블 목록 (DESCRIBE 결과 기반)
|
||||
const tables = [
|
||||
'asset_pc', 'asset_server', 'asset_storage', 'asset_network',
|
||||
'asset_equipment', 'asset_office_supplies', 'asset_survey', 'asset_vip'
|
||||
];
|
||||
|
||||
let lastCode = '';
|
||||
let maxNum = 0;
|
||||
const connection = await pool.getConnection();
|
||||
|
||||
for (const table of tables) {
|
||||
try {
|
||||
const [rows] = await pool.query(
|
||||
`SELECT asset_code FROM ${table} WHERE asset_code LIKE ? ORDER BY asset_code DESC LIMIT 1`,
|
||||
[`${prefix}%`]
|
||||
);
|
||||
|
||||
if (rows.length > 0) {
|
||||
const code = rows[0].asset_code;
|
||||
// 숫자 부분 추출 (예: SVR048 -> 48)
|
||||
const numMatch = code.match(/\d+/);
|
||||
if (numMatch) {
|
||||
const num = parseInt(numMatch[0]);
|
||||
if (num > maxNum) {
|
||||
maxNum = num;
|
||||
lastCode = code;
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
console.warn(`[GENERATE CODE] Skipping ${table}: ${err.message}`);
|
||||
const masterData = {
|
||||
pc: [], server: [], storage: [], network: [],
|
||||
equipment: [], officeSupplies: [], survey: [], vip: [], pcParts: [],
|
||||
swInternal: [], swExternal: [], swUsers: [], users: [], logs: []
|
||||
};
|
||||
|
||||
const [rows] = await connection.query(`
|
||||
SELECT
|
||||
c.*,
|
||||
s.hw_status, s.model_name, s.mainboard, s.os, s.cpu, s.ram, s.gpu,
|
||||
s.monitoring, s.price, s.monitor_inch, s.serial_num,
|
||||
l.location, l.location_detail, l.location_photo, l.loc_x, l.loc_y,
|
||||
n.ip_address, n.mac_address, n.remote_tool, n.remote_id, n.remote_pw,
|
||||
(SELECT CONCAT(capacity, unit) FROM asset_volume WHERE asset_id = c.id AND disk_type = 'SSD' AND slot_no = 1 LIMIT 1) as ssd_1,
|
||||
(SELECT CONCAT(capacity, unit) FROM asset_volume WHERE asset_id = c.id AND disk_type = 'SSD' AND slot_no = 2 LIMIT 1) as ssd_2,
|
||||
(SELECT CONCAT(capacity, unit) FROM asset_volume WHERE asset_id = c.id AND disk_type = 'HDD' AND slot_no = 1 LIMIT 1) as hdd_1,
|
||||
(SELECT CONCAT(capacity, unit) FROM asset_volume WHERE asset_id = c.id AND disk_type = 'HDD' AND slot_no = 2 LIMIT 1) as hdd_2,
|
||||
(SELECT GROUP_CONCAT(CONCAT(disk_type, ': ', capacity, unit) SEPARATOR ', ') FROM asset_volume WHERE asset_id = c.id) as volume_summary
|
||||
FROM asset_core c
|
||||
LEFT JOIN asset_spec s ON c.id = s.asset_id
|
||||
LEFT JOIN asset_location l ON l.id = (
|
||||
SELECT id FROM asset_location
|
||||
WHERE asset_id = c.id AND is_active = 1
|
||||
ORDER BY created_at DESC LIMIT 1
|
||||
)
|
||||
LEFT JOIN asset_network n ON n.id = (
|
||||
SELECT id FROM asset_network
|
||||
WHERE asset_id = c.id AND is_active = 1
|
||||
ORDER BY created_at DESC LIMIT 1
|
||||
)
|
||||
`);
|
||||
|
||||
const catMap = {
|
||||
'PC': 'pc', '서버': 'server', '저장매체': 'storage', '네트워크': 'network',
|
||||
'업무지원장비': 'equipment', '사무가구': 'officeSupplies', '공간정보장비': 'survey',
|
||||
'내빈/외빈': 'vip', 'PC부품': 'pcParts'
|
||||
};
|
||||
|
||||
rows.forEach(row => {
|
||||
const key = catMap[row.category] || 'pc';
|
||||
masterData[key].push(row);
|
||||
});
|
||||
|
||||
const [swInternal] = await connection.query('SELECT * FROM asset_software_perpetual');
|
||||
const [swExternal] = await connection.query('SELECT * FROM asset_software_subscription');
|
||||
const [swUsers] = await connection.query('SELECT * FROM asset_software_assignment');
|
||||
const [users] = await connection.query('SELECT * FROM system_users');
|
||||
const [logs] = await connection.query('SELECT * FROM asset_history ORDER BY created_at DESC');
|
||||
|
||||
masterData.swInternal = swInternal;
|
||||
masterData.swExternal = swExternal;
|
||||
masterData.swUsers = swUsers;
|
||||
masterData.users = users;
|
||||
masterData.logs = logs;
|
||||
|
||||
connection.release();
|
||||
res.json(masterData);
|
||||
} catch (err) {
|
||||
handleError(res, err, 'MASTER DATA');
|
||||
}
|
||||
});
|
||||
|
||||
// 3. Asset Save (Surgical Split to Normalized V3 Tables)
|
||||
app.post('/api/asset/:category/save', async (req, res) => {
|
||||
const asset = req.body;
|
||||
let connection;
|
||||
try {
|
||||
connection = await pool.getConnection();
|
||||
await connection.beginTransaction();
|
||||
|
||||
// 3.1 asset_core
|
||||
const coreFields = ['id', 'asset_code', 'category', 'asset_type', 'current_role', 'asset_purpose', 'service_type', 'purchase_corp', 'purchase_date', 'purchase_amount', 'purchase_vendor', 'approval_document', 'memo', 'manager_primary', 'manager_secondary', 'current_dept', 'previous_dept', 'user_current', 'previous_user', 'emp_no', 'user_position'];
|
||||
const coreData = {};
|
||||
coreFields.forEach(f => { if (asset[f] !== undefined) coreData[f] = asset[f]; });
|
||||
const coreKeys = Object.keys(coreData);
|
||||
const coreSql = `INSERT INTO asset_core (${coreKeys.join(', ')}) VALUES (${coreKeys.map(() => '?').join(', ')}) ON DUPLICATE KEY UPDATE ${coreKeys.map(k => `${k} = VALUES(${k})`).join(', ')}`;
|
||||
await connection.query(coreSql, Object.values(coreData));
|
||||
|
||||
// 3.2 asset_spec
|
||||
const specFields = ['hw_status', 'model_name', 'mainboard', 'os', 'cpu', 'ram', 'gpu', 'monitoring', 'price', 'monitor_inch', 'serial_num'];
|
||||
const specData = { asset_id: asset.id };
|
||||
specFields.forEach(f => { if (asset[f] !== undefined) specData[f] = asset[f]; });
|
||||
const specKeys = Object.keys(specData);
|
||||
const [specExists] = await connection.query('SELECT id FROM asset_spec WHERE asset_id = ?', [asset.id]);
|
||||
if (specExists.length > 0) {
|
||||
const updateSql = `UPDATE asset_spec SET ${specKeys.filter(k => k !== 'asset_id').map(k => `${k} = ?`).join(', ')} WHERE asset_id = ?`;
|
||||
await connection.query(updateSql, [...specKeys.filter(k => k !== 'asset_id').map(k => specData[k]), asset.id]);
|
||||
} else {
|
||||
await connection.query(`INSERT INTO asset_spec (${specKeys.join(', ')}) VALUES (${specKeys.map(() => '?').join(', ')})`, Object.values(specData));
|
||||
}
|
||||
|
||||
// 3.3 asset_volume (Legacy Parser)
|
||||
const parseCapacity = (str) => {
|
||||
if (!str || str.trim() === '' || str.toLowerCase() === 'null') return null;
|
||||
const match = str.match(/(\d+(?:\.\d+)?)\s*([GT]B)?/i);
|
||||
if (match) return { value: parseFloat(match[1]), unit: (match[2] || 'GB').toUpperCase() };
|
||||
return null;
|
||||
};
|
||||
const storages = [
|
||||
{ val: asset.ssd_1, type: 'SSD', slot: 1 },
|
||||
{ val: asset.ssd_2, type: 'SSD', slot: 2 },
|
||||
{ val: asset.hdd_1, type: 'HDD', slot: 1 },
|
||||
{ val: asset.hdd_2, type: 'HDD', slot: 2 }
|
||||
];
|
||||
await connection.query('DELETE FROM asset_volume WHERE asset_id = ?', [asset.id]);
|
||||
for (const s of storages) {
|
||||
const parsed = parseCapacity(s.val);
|
||||
if (parsed) {
|
||||
await connection.query('INSERT INTO asset_volume (asset_id, disk_type, capacity, unit, slot_no) VALUES (?, ?, ?, ?, ?)',
|
||||
[asset.id, s.type, parsed.value, parsed.unit, s.slot]);
|
||||
}
|
||||
}
|
||||
|
||||
const nextNum = maxNum + 1;
|
||||
const nextCode = `${prefix}${String(nextNum).padStart(3, '0')}`;
|
||||
|
||||
console.log(`🆕 [GENERATE CODE] Prefix: ${prefix}, Last: ${lastCode}, Next: ${nextCode}`);
|
||||
res.json({ nextCode });
|
||||
} catch (err) {
|
||||
handleError(res, err, 'GENERATE CODE');
|
||||
|
||||
// 3.4 asset_location
|
||||
if (asset.location || asset.location_detail) {
|
||||
const [locActive] = await connection.query('SELECT * FROM asset_location WHERE asset_id = ? AND is_active = 1', [asset.id]);
|
||||
const isChanged = locActive.length === 0 || locActive[0].location !== asset.location || locActive[0].location_detail !== asset.location_detail || locActive[0].loc_x !== asset.loc_x || locActive[0].loc_y !== asset.loc_y;
|
||||
if (isChanged) {
|
||||
await connection.query('UPDATE asset_location SET is_active = 0, deactivated_at = NOW() WHERE asset_id = ? AND is_active = 1', [asset.id]);
|
||||
await connection.query(`INSERT INTO asset_location (asset_id, location, location_detail, location_photo, loc_x, loc_y, is_active) VALUES (?, ?, ?, ?, ?, ?, 1)`,
|
||||
[asset.id, asset.location, asset.location_detail, asset.location_photo, asset.loc_x, asset.loc_y]);
|
||||
}
|
||||
}
|
||||
|
||||
// 3.5 asset_network
|
||||
if (asset.ip_address || asset.mac_address || asset.remote_tool) {
|
||||
const [netActive] = await connection.query('SELECT * FROM asset_network WHERE asset_id = ? AND is_active = 1', [asset.id]);
|
||||
const isChanged = netActive.length === 0 || netActive[0].ip_address !== asset.ip_address || netActive[0].mac_address !== asset.mac_address || netActive[0].remote_tool !== asset.remote_tool || netActive[0].remote_id !== asset.remote_id || netActive[0].remote_pw !== asset.remote_pw;
|
||||
if (isChanged) {
|
||||
await connection.query('UPDATE asset_network SET is_active = 0, deactivated_at = NOW() WHERE asset_id = ? AND is_active = 1', [asset.id]);
|
||||
await connection.query(`INSERT INTO asset_network (asset_id, ip_address, mac_address, remote_tool, remote_id, remote_pw, is_active) VALUES (?, ?, ?, ?, ?, ?, 1)`,
|
||||
[asset.id, asset.ip_address, asset.mac_address, asset.remote_tool, asset.remote_id, asset.remote_pw]);
|
||||
}
|
||||
}
|
||||
|
||||
await connection.commit();
|
||||
console.log(`💾 [V3 ASSET SAVE] ID: ${asset.id}`);
|
||||
res.json({ success: true });
|
||||
} catch (err) {
|
||||
if (connection) await connection.rollback();
|
||||
handleError(res, err, 'ASSET SAVE V3');
|
||||
} finally {
|
||||
if (connection) connection.release();
|
||||
}
|
||||
});
|
||||
|
||||
// 6. Map Config API (Real-time Save)
|
||||
// 4. Asset Delete
|
||||
app.delete('/api/asset/:category/:id', async (req, res) => {
|
||||
const { category, id } = req.params;
|
||||
|
||||
// Define mapping for which base table handles the delete
|
||||
const deleteTableMap = {
|
||||
pc: 'asset_core',
|
||||
server: 'asset_core',
|
||||
storage: 'asset_core',
|
||||
network: 'asset_core',
|
||||
equipment: 'asset_core',
|
||||
officeSupplies: 'asset_core',
|
||||
survey: 'asset_core',
|
||||
vip: 'asset_core',
|
||||
pcParts: 'asset_core',
|
||||
swInternal: 'asset_software_perpetual',
|
||||
swExternal: 'asset_software_subscription',
|
||||
swUsers: 'asset_software_assignment',
|
||||
users: 'system_users'
|
||||
};
|
||||
|
||||
const table = deleteTableMap[category];
|
||||
|
||||
if (!table) return res.status(400).json({ error: 'Invalid category for deletion' });
|
||||
|
||||
try {
|
||||
const connection = await pool.getConnection();
|
||||
// For asset_core, ON DELETE CASCADE will handle spec, location, network, volume
|
||||
await connection.query(`DELETE FROM ${table} WHERE id = ?`, [id]);
|
||||
connection.release();
|
||||
console.log(`🗑️ [ASSET DELETE] Category: ${category}, ID: ${id}`);
|
||||
res.json({ success: true });
|
||||
} catch (err) {
|
||||
handleError(res, err, 'ASSET DELETE');
|
||||
}
|
||||
});
|
||||
|
||||
// 5. Generate Next Asset Code
|
||||
app.get('/api/generate-asset-code', async (req, res) => {
|
||||
const { prefix, purchaseDate } = req.query;
|
||||
if (!prefix) return res.status(400).json({ error: 'Prefix is required' });
|
||||
try {
|
||||
const connection = await pool.getConnection();
|
||||
const datePart = purchaseDate ? purchaseDate.toString().replace(/-/g, '').substring(0, 6) : '';
|
||||
const searchPattern = datePart ? `${prefix}-${datePart}-%` : `${prefix}-%`;
|
||||
let maxNum = 0;
|
||||
for (const table of ASSET_TABLES) {
|
||||
try {
|
||||
const [rows] = await connection.query(`SELECT asset_code FROM ${table} WHERE asset_code LIKE ?`, [searchPattern]);
|
||||
rows.forEach(row => {
|
||||
const parts = row.asset_code.split('-');
|
||||
const num = parseInt(parts[parts.length - 1]);
|
||||
if (!isNaN(num) && num > maxNum) maxNum = num;
|
||||
});
|
||||
} catch (err) {}
|
||||
}
|
||||
const nextNum = maxNum + 1;
|
||||
const nextCode = datePart ? `${prefix}-${datePart}-${String(nextNum).padStart(4, '0')}` : `${prefix}-${String(nextNum).padStart(4, '0')}`;
|
||||
connection.release();
|
||||
res.json({ nextCode });
|
||||
} catch (err) { handleError(res, err, 'GENERATE CODE'); }
|
||||
});
|
||||
|
||||
// 6. Map Config API
|
||||
app.get('/api/maps', (req, res) => {
|
||||
try {
|
||||
if (!fs.existsSync('map_config.json')) {
|
||||
return res.json({});
|
||||
}
|
||||
if (!fs.existsSync('map_config.json')) return res.json({});
|
||||
const data = fs.readFileSync('map_config.json', 'utf8');
|
||||
res.json(JSON.parse(data || '{}'));
|
||||
} catch (err) {
|
||||
handleError(res, err, 'GET MAPS');
|
||||
}
|
||||
} catch (err) { handleError(res, err, 'GET MAPS'); }
|
||||
});
|
||||
|
||||
app.post('/api/maps/save', (req, res) => {
|
||||
try {
|
||||
const { path, boxes } = req.body;
|
||||
if (!path) return res.status(400).json({ error: 'Path is required' });
|
||||
|
||||
let config = {};
|
||||
if (fs.existsSync('map_config.json')) {
|
||||
config = JSON.parse(fs.readFileSync('map_config.json', 'utf8') || '{}');
|
||||
}
|
||||
|
||||
if (fs.existsSync('map_config.json')) config = JSON.parse(fs.readFileSync('map_config.json', 'utf8') || '{}');
|
||||
config[path] = boxes;
|
||||
fs.writeFileSync('map_config.json', JSON.stringify(config, null, 2));
|
||||
console.log(`💾 [MAP SAVE] Updated config for: ${path}`);
|
||||
res.json({ success: true });
|
||||
} catch (err) { handleError(res, err, 'SAVE MAPS'); }
|
||||
});
|
||||
|
||||
// 7. File Upload API (Base64)
|
||||
app.post('/api/upload', (req, res) => {
|
||||
try {
|
||||
const { fileName, fileData } = req.body;
|
||||
if (!fileName || !fileData) return res.status(400).json({ error: 'FileName and FileData are required' });
|
||||
|
||||
// base64 데이터에서 실제 바이너리 추출
|
||||
const base64Data = fileData.replace(/^data:.*;base64,/, "");
|
||||
const buffer = Buffer.from(base64Data, 'base64');
|
||||
|
||||
// 고유한 파일명 생성 (타임스탬프 결합)
|
||||
const timestamp = Date.now();
|
||||
const safeFileName = `${timestamp}_${fileName.replace(/[^a-zA-Z0-9._-]/g, '_')}`;
|
||||
const filePath = `uploads/${safeFileName}`;
|
||||
|
||||
fs.writeFileSync(filePath, buffer);
|
||||
|
||||
console.log(`파일 업로드 성공: ${filePath}`);
|
||||
res.json({ success: true, filePath: `/${filePath}`, fileName: safeFileName });
|
||||
} catch (err) {
|
||||
handleError(res, err, 'SAVE MAPS');
|
||||
handleError(res, err, 'FILE UPLOAD');
|
||||
}
|
||||
});
|
||||
|
||||
app.listen(3000, '0.0.0.0', () => {
|
||||
console.log('📡 ITAM BACKEND SERVER RUNNING ON PORT 3000 (Multi-Table Optimized)');
|
||||
console.log('📡 ITAM BACKEND SERVER RUNNING ON PORT 3000 (V3 Normalized)');
|
||||
});
|
||||
|
||||
@@ -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,12 +19,15 @@ class HwAssetModal extends BaseModal {
|
||||
}
|
||||
|
||||
protected renderFrameHTML(): string {
|
||||
// CSS 명세(modal.css)의 input 패딩(0.625rem)과 일치시켜 정렬을 완벽하게 잡는 스타일
|
||||
const standardBtnStyle = 'height: auto !important; padding: 0.625rem 1.25rem; font-size: 0.875rem; line-height: 1.2; display: inline-flex; align-items: center; justify-content: center;';
|
||||
|
||||
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>
|
||||
<button id="btn-close-hw-modal" class="btn-icon" aria-label="닫기" style="font-size: 24px; color: white; background: none; border: none; cursor: pointer;">×</button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<div class="modal-body-split">
|
||||
@@ -33,12 +35,12 @@ class HwAssetModal extends BaseModal {
|
||||
<form id="hw-asset-form" class="grid-form">
|
||||
<input type="hidden" id="hw-id" name="id" />
|
||||
|
||||
<div class="form-section-title">기본 및 관리 정보</div>
|
||||
<div class="form-section-title" style="padding-top: 0;">기본 정보</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>
|
||||
<button type="button" id="btn-gen-hw-code" class="btn btn-outline" style="${standardBtnStyle}">생성</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
@@ -62,71 +64,45 @@ class HwAssetModal extends BaseModal {
|
||||
<label>${ASSET_SCHEMA.HW_STATUS.ui}</label>
|
||||
<select id="hw-hw_status" name="hw_status">${generateOptionsHTML(HW_STATUS_LIST)}</select>
|
||||
</div>
|
||||
<div class="form-group dept-field">
|
||||
<div class="form-group server-only">
|
||||
<label>${ASSET_SCHEMA.MONITORING.ui}</label>
|
||||
<select id="hw-monitoring" name="monitoring">
|
||||
<option value="비대상">비대상</option>
|
||||
<option value="대상">대상</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div class="form-section-title user-tracking-field">담당 및 조직</div>
|
||||
<div class="form-group">
|
||||
<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 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">
|
||||
<label>${ASSET_SCHEMA.USER_POSITION.ui}</label>
|
||||
<input type="text" id="hw-user_position" name="user_position" />
|
||||
</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-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" />
|
||||
@@ -139,40 +115,6 @@ class HwAssetModal extends BaseModal {
|
||||
<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" />
|
||||
@@ -181,9 +123,13 @@ class HwAssetModal extends BaseModal {
|
||||
<label>${ASSET_SCHEMA.IP_ADDR2.ui}</label>
|
||||
<input type="text" id="hw-ip_address_2" name="ip_address_2" />
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>${ASSET_SCHEMA.MAC_ADDR.ui}</label>
|
||||
<input type="text" id="hw-mac_address" name="mac_address" />
|
||||
</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 등" />
|
||||
<input type="text" id="hw-remote_tool" name="remote_tool" />
|
||||
</div>
|
||||
<div class="form-group server-only">
|
||||
<label>${ASSET_SCHEMA.REMOTE_ID.ui}</label>
|
||||
@@ -193,24 +139,26 @@ class HwAssetModal extends BaseModal {
|
||||
<label>${ASSET_SCHEMA.REMOTE_PW.ui}</label>
|
||||
<input type="text" id="hw-remote_pw" name="remote_pw" />
|
||||
</div>
|
||||
<div class="form-group server-only">
|
||||
<label>${ASSET_SCHEMA.MONITORING.ui}</label>
|
||||
<select id="hw-monitoring" name="monitoring">
|
||||
<option value="대상">대상</option>
|
||||
<option value="비대상">비대상</option>
|
||||
</select>
|
||||
|
||||
<div class="form-section-title infra-only">설치 위치</div>
|
||||
<div class="form-group infra-only">
|
||||
<label>건물/위치</label>
|
||||
<select id="hw-bldg-select" name="location">${generateOptionsHTML(Object.keys(LOCATION_DATA))}</select>
|
||||
</div>
|
||||
<div class="form-group infra-only">
|
||||
<label>${ASSET_SCHEMA.LOC_DETAIL.ui}</label>
|
||||
<div class="input-with-btn">
|
||||
<select id="hw-location_detail" name="location_detail" style="flex: 1;"><option value="">선택</option></select>
|
||||
<button type="button" id="btn-reg-loc-map" class="btn btn-primary" style="${standardBtnStyle} display: none;">위치등록</button>
|
||||
<button type="button" id="btn-view-loc-map" class="btn btn-primary btn-loc-action" style="${standardBtnStyle} display: none; pointer-events: auto !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>
|
||||
|
||||
<div class="form-section-title">구매 및 증빙</div>
|
||||
<div class="form-section-title">구매 및 증빙 정보</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" />
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>${ASSET_SCHEMA.PURCHASE_VENDOR.ui}</label>
|
||||
@@ -218,29 +166,32 @@ class HwAssetModal extends BaseModal {
|
||||
</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" 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">
|
||||
<button type="button" id="btn-file-select" onclick="document.getElementById('hw-approval_document_file').click()" class="btn btn-outline btn-loc-action" style="${standardBtnStyle} flex: 1; justify-content: flex-start; pointer-events: auto !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"></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>
|
||||
<h3>자산 변동 이력</h3>
|
||||
<button type="button" id="btn-add-hw-log" class="btn btn-outline btn-sm">이력 추가</button>
|
||||
</div>
|
||||
<div id="hw-history-list" class="history-timeline"></div>
|
||||
</div>
|
||||
@@ -251,7 +202,7 @@ class HwAssetModal extends BaseModal {
|
||||
<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-save-hw-asset" class="btn btn-primary">저장</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -273,47 +224,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}`);
|
||||
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);
|
||||
}
|
||||
});
|
||||
|
||||
@@ -330,27 +282,52 @@ class HwAssetModal extends BaseModal {
|
||||
this.updateMapButtonVisibility();
|
||||
});
|
||||
|
||||
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 () => {
|
||||
if (!this.currentAsset) return;
|
||||
if (!this.isEditMode) {
|
||||
this.setEditLockMode('edit');
|
||||
this.isEditMode = true;
|
||||
this.updateMapButtonVisibility();
|
||||
this.toggleFileUploadUI(true);
|
||||
return;
|
||||
}
|
||||
|
||||
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 } });
|
||||
}
|
||||
|
||||
protected fillFormData(asset: any): void {
|
||||
@@ -358,68 +335,72 @@ class HwAssetModal extends BaseModal {
|
||||
setFieldValue('hw-asset_code', asset.asset_code || '');
|
||||
setFieldValue('hw-purchase_corp', asset.purchase_corp || '');
|
||||
setFieldValue('hw-category', asset.category || '');
|
||||
|
||||
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-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 || '');
|
||||
setFieldValue('hw-monitoring', asset.monitoring || '비대상');
|
||||
setFieldValue('hw-purchase_date', asset.purchase_date || '');
|
||||
setFieldValue('hw-purchase_vendor', asset.purchase_vendor || '');
|
||||
setFieldValue('hw-purchase_amount', asset.purchase_amount || '');
|
||||
|
||||
const docName = document.getElementById('hw-approval_document_name');
|
||||
if (docName) docName.textContent = asset.approval_document || '';
|
||||
|
||||
setFieldValue('hw-approval_document', 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);
|
||||
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.updateMapButtonVisibility(asset);
|
||||
this.applyRoleVisibility();
|
||||
}
|
||||
|
||||
const isServer = asset.category === '서버' || asset.asset_code?.startsWith('SVR') || asset.asset_type === '서버PC';
|
||||
const isPc = asset.category === 'PC' || asset.asset_code?.startsWith('PC');
|
||||
const isVip = asset.category === '선물' || asset.category === 'VIP';
|
||||
private toggleFileUploadUI(showUpload: boolean) {
|
||||
const fileBtn = document.getElementById('btn-file-select') as HTMLElement;
|
||||
if (fileBtn) fileBtn.style.display = showUpload ? 'inline-flex' : 'none';
|
||||
}
|
||||
|
||||
document.querySelectorAll('.server-only').forEach(el => (el as HTMLElement).style.display = isServer ? 'flex' : 'none');
|
||||
document.querySelectorAll('.non-server').forEach(el => (el as HTMLElement).style.display = !isServer ? 'flex' : 'none');
|
||||
document.querySelectorAll('.pc-only').forEach(el => (el as HTMLElement).style.display = isPc ? 'flex' : 'none');
|
||||
document.querySelectorAll('.user-tracking-field').forEach(el => (el as HTMLElement).style.display = (!isServer && !isVip) ? 'flex' : 'none');
|
||||
private applyRoleVisibility(): void {
|
||||
const category = (document.getElementById('hw-category') as HTMLSelectElement)?.value || '';
|
||||
const type = (document.getElementById('hw-asset_type') as HTMLSelectElement)?.value || '';
|
||||
|
||||
// 인프라 장비 (서버, 서버PC, 저장시스템 등)
|
||||
const isInfra = ['서버', '저장매체', '네트워크', '공간정보장비'].includes(category) || type.includes('서버') || type.includes('저장시스템');
|
||||
// 개인 장비 (PC, 노트북) - '서버PC'는 제외
|
||||
const isPersonal = (['PC', '노트북'].includes(category) || type.includes('개인PC') || type.includes('노트북')) && !type.includes('서버PC');
|
||||
|
||||
// JS에서 display: block을 강제하지 않고, 빈 문자열로 설정하여 CSS의 flex가 작동하게 함
|
||||
document.querySelectorAll('.server-only').forEach(el => (el as HTMLElement).style.display = isInfra ? '' : 'none');
|
||||
document.querySelectorAll('.infra-only').forEach(el => (el as HTMLElement).style.display = isInfra ? '' : 'none');
|
||||
document.querySelectorAll('.user-tracking-field').forEach(el => (el as HTMLElement).style.display = isPersonal ? '' : 'none');
|
||||
}
|
||||
|
||||
private updateMapButtonVisibility(asset?: any) {
|
||||
@@ -427,18 +408,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 {
|
||||
@@ -456,78 +438,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);
|
||||
@@ -542,33 +478,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}%`; }
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -598,14 +523,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,7 +13,7 @@ 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'],
|
||||
'네트워크': ['스위치', '허브', '방화벽', '라우터', '공유기', '허브'],
|
||||
|
||||
@@ -60,44 +60,20 @@ export const state: AppState = {
|
||||
};
|
||||
|
||||
/**
|
||||
* 신규 14개 테이블 구조에 맞춘 데이터 로드
|
||||
* 통합 V2 스키마에 맞춘 데이터 로드
|
||||
*/
|
||||
export async function loadMasterDataFromDB() {
|
||||
try {
|
||||
const endpoints = [
|
||||
{ key: 'users', url: '/api/users' },
|
||||
{ key: 'pc', url: '/api/pc' },
|
||||
{ key: 'server', url: '/api/server' },
|
||||
{ key: 'storage', url: '/api/storage' },
|
||||
{ key: 'network', url: '/api/network' },
|
||||
{ key: 'survey', url: '/api/survey' },
|
||||
{ key: 'pcParts', url: '/api/pc-parts' },
|
||||
{ key: 'equipment', url: '/api/equipment' },
|
||||
{ key: 'officeSupplies', url: '/api/office-supplies' },
|
||||
{ key: 'swInternal', url: '/api/sw/internal' },
|
||||
{ key: 'swExternal', url: '/api/sw/external' },
|
||||
{ key: 'cloud', url: '/api/cloud' },
|
||||
{ key: 'domain', url: '/api/domain' },
|
||||
{ key: 'cost', url: '/api/cost' },
|
||||
{ key: 'vip', url: '/api/vip' },
|
||||
{ key: 'swUsers', url: '/api/asset/software/assignment' },
|
||||
{ key: 'logs', url: '/api/asset/history' }
|
||||
];
|
||||
|
||||
const results = await Promise.all(endpoints.map(e => fetch(API_BASE_URL + e.url)));
|
||||
|
||||
for (let i = 0; i < endpoints.length; i++) {
|
||||
if (results[i].ok) {
|
||||
const data = await results[i].json();
|
||||
const key = endpoints[i].key;
|
||||
(state.masterData as any)[key] = Array.isArray(data) ? data : [];
|
||||
}
|
||||
}
|
||||
|
||||
// Mapping for backward compatibility
|
||||
state.masterData.equip = state.masterData.equipment;
|
||||
state.masterData.subSw = state.masterData.swExternal;
|
||||
state.masterData.permSw = state.masterData.swInternal;
|
||||
const response = await fetch(`${API_BASE_URL}/api/assets/master`);
|
||||
if (!response.ok) throw new Error('Failed to fetch master data');
|
||||
|
||||
const data = await response.json();
|
||||
|
||||
// 전역 상태 업데이트
|
||||
state.masterData = {
|
||||
...state.masterData,
|
||||
...data
|
||||
};
|
||||
|
||||
// 하드웨어 통합 (대시보드 호환용)
|
||||
state.masterData.hw = [
|
||||
@@ -114,10 +90,10 @@ export async function loadMasterDataFromDB() {
|
||||
state.masterData.sw = [
|
||||
...state.masterData.swInternal,
|
||||
...state.masterData.swExternal,
|
||||
...state.masterData.cloud
|
||||
...(state.masterData.cloud || [])
|
||||
];
|
||||
|
||||
console.log('✅ All data (including users) loaded and unified');
|
||||
console.log('✅ V2 Normalized data loaded successfully');
|
||||
return true;
|
||||
} catch (err) {
|
||||
console.warn('⚠️ 서버 연결 실패:', err);
|
||||
@@ -130,39 +106,15 @@ export function updateState(newState: Partial<AppState>) {
|
||||
}
|
||||
|
||||
/**
|
||||
* 자산 저장 (Generic API)
|
||||
* 자산 저장 (V2 Normalized API)
|
||||
*/
|
||||
export async function saveAsset(category: string, asset: any) {
|
||||
try {
|
||||
const endpointMap: Record<string, string> = {
|
||||
'users': '/api/users/batch',
|
||||
'pc': '/api/pc/batch',
|
||||
'server': '/api/server/batch',
|
||||
'storage': '/api/storage/batch',
|
||||
'network': '/api/network/batch',
|
||||
'survey': '/api/survey/batch',
|
||||
'pcParts': '/api/pc-parts/batch',
|
||||
'equipment': '/api/equipment/batch',
|
||||
'officeSupplies': '/api/office-supplies/batch',
|
||||
'swInternal': '/api/sw/internal/batch',
|
||||
'swExternal': '/api/sw/external/batch',
|
||||
'cloud': '/api/cloud/batch',
|
||||
'domain': '/api/domain/batch',
|
||||
'cost': '/api/cost/batch',
|
||||
'vip': '/api/vip/batch'
|
||||
};
|
||||
|
||||
const url = `${API_BASE_URL}${endpointMap[category]}`;
|
||||
const currentList = [...(state.masterData as any)[category]];
|
||||
const idx = currentList.findIndex(a => a.id === asset.id);
|
||||
|
||||
if (idx > -1) currentList[idx] = asset;
|
||||
else currentList.push(asset);
|
||||
|
||||
const url = `${API_BASE_URL}/api/asset/${category}/save`;
|
||||
const response = await fetch(url, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(currentList)
|
||||
body: JSON.stringify(asset)
|
||||
});
|
||||
|
||||
if (response.ok) {
|
||||
@@ -176,37 +128,12 @@ export async function saveAsset(category: string, asset: any) {
|
||||
}
|
||||
|
||||
/**
|
||||
* 자산 삭제 (Generic API - Batch 방식 활용)
|
||||
* 자산 삭제 (V2 API)
|
||||
*/
|
||||
export async function deleteAsset(category: string, assetId: string) {
|
||||
try {
|
||||
const endpointMap: Record<string, string> = {
|
||||
'users': '/api/users/batch',
|
||||
'pc': '/api/pc/batch',
|
||||
'server': '/api/server/batch',
|
||||
'storage': '/api/storage/batch',
|
||||
'network': '/api/network/batch',
|
||||
'survey': '/api/survey/batch',
|
||||
'pcParts': '/api/pc-parts/batch',
|
||||
'equipment': '/api/equipment/batch',
|
||||
'officeSupplies': '/api/office-supplies/batch',
|
||||
'swInternal': '/api/sw/internal/batch',
|
||||
'swExternal': '/api/sw/external/batch',
|
||||
'cloud': '/api/cloud/batch',
|
||||
'domain': '/api/domain/batch',
|
||||
'cost': '/api/cost/batch',
|
||||
'vip': '/api/vip/batch'
|
||||
};
|
||||
|
||||
const url = `${API_BASE_URL}${endpointMap[category]}`;
|
||||
const currentList = [...(state.masterData as any)[category]];
|
||||
const filteredList = currentList.filter(a => a.id !== assetId);
|
||||
|
||||
const response = await fetch(url, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(filteredList)
|
||||
});
|
||||
const url = `${API_BASE_URL}/api/asset/${category}/${assetId}`;
|
||||
const response = await fetch(url, { method: 'DELETE' });
|
||||
|
||||
if (response.ok) {
|
||||
await loadMasterDataFromDB(); // 전역 상태 갱신
|
||||
|
||||
@@ -153,14 +153,8 @@ export function dynamicSort<T>(list: T[], key: string, direction: 'asc' | 'desc'
|
||||
}
|
||||
|
||||
/**
|
||||
* 목록 뷰용 액션 버튼 HTML 생성 (자산추가)
|
||||
* 목록 뷰용 액션 버튼 HTML 생성 (중복 제거를 위해 비워둠)
|
||||
*/
|
||||
export function getActionButtonsHTML(): string {
|
||||
return `
|
||||
<div class="search-actions">
|
||||
<button id="btn-add-asset" class="btn btn-primary">
|
||||
<i data-lucide="plus"></i> 자산추가
|
||||
</button>
|
||||
</div>
|
||||
`;
|
||||
return '';
|
||||
}
|
||||
|
||||
@@ -42,15 +42,20 @@ export function createListView(container: HTMLElement, config: ListViewConfig) {
|
||||
let currentFilters: any = { keyword: '', corp: '', dept: '', loc: '', field: '', type: '' };
|
||||
|
||||
// 강제로 기본 뷰 모드를 'system' (자산 현황)으로 설정
|
||||
state.currentViewMode = 'system';
|
||||
(state as any).currentViewMode = 'system';
|
||||
|
||||
// 2. 뷰 전환 토글 버튼 생성 (명칭 변경)
|
||||
const toggleWrapper = document.createElement('div');
|
||||
toggleWrapper.className = 'view-toggle-container';
|
||||
toggleWrapper.innerHTML = `
|
||||
<div class="view-toggle">
|
||||
<button class="toggle-btn ${state.currentViewMode === 'system' ? 'active' : ''}" data-mode="system">자산 현황</button>
|
||||
<button class="toggle-btn ${state.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);
|
||||
@@ -82,21 +87,44 @@ export function createListView(container: HTMLElement, config: ListViewConfig) {
|
||||
// [자산 현황] 대시보드 렌더러
|
||||
const renderSystemStatus = () => {
|
||||
const isPcView = config.title === 'PC';
|
||||
|
||||
const locationCounts: Record<string, number> = {};
|
||||
const pcTypeCounts = { public: 0, server: 0, personal: 0 };
|
||||
const extSubCounts = { tech: 0, idc: 0, hm: 0 };
|
||||
const intSubCounts = { tech: 0, idc: 0, hm: 0 };
|
||||
let internalCount = 0;
|
||||
let externalCount = 0;
|
||||
|
||||
// 동적 통계 수집 객체 (Hardcoding 제거)
|
||||
const extStats = {
|
||||
total: 0,
|
||||
locCounts: {} as Record<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 extTypeCounts: Record<string, number> = {};
|
||||
const intTypeCounts: Record<string, number> = {};
|
||||
let locWarningCount = 0;
|
||||
let typeWarningCount = 0;
|
||||
// 중앙화된 경고 감지 로직
|
||||
const checkAnomaly = (serviceType: string, loc: string, type: string) => {
|
||||
if (serviceType !== '외부') return { isWarning: false, isLocWarning: false, isTypeWarning: false, reason: '' };
|
||||
const isLocWarning = loc !== 'IDC' && loc !== '미지정' && loc !== '';
|
||||
const isTypeWarning = type.toLowerCase().replace(/\s/g, '').includes('서버pc');
|
||||
const isWarning = isLocWarning || isTypeWarning;
|
||||
|
||||
let reason = '';
|
||||
if (isLocWarning && isTypeWarning) reason = '위치/형식 부적절';
|
||||
else if (isLocWarning) reason = '위치 부적절';
|
||||
else if (isTypeWarning) reason = '형식 부적절';
|
||||
|
||||
return { isWarning, isLocWarning, isTypeWarning, reason };
|
||||
};
|
||||
|
||||
fullList.forEach(asset => {
|
||||
const loc = asset[ASSET_SCHEMA.LOCATION.key] || '미지정';
|
||||
const serviceTypeKey = ASSET_SCHEMA.SERVICE_TYPE?.key || 'service_type';
|
||||
const serviceTypeKey = (ASSET_SCHEMA as any).SERVICE_TYPE?.key || 'service_type';
|
||||
const serviceType = asset[serviceTypeKey] || '외부';
|
||||
const type = asset[ASSET_SCHEMA.ASSET_TYPE.key] || '';
|
||||
|
||||
@@ -108,33 +136,52 @@ export function createListView(container: HTMLElement, config: ListViewConfig) {
|
||||
else pcTypeCounts.personal++;
|
||||
}
|
||||
|
||||
if (serviceType === '내부') {
|
||||
internalCount++;
|
||||
if (loc.includes('기술개발')) intSubCounts.tech++;
|
||||
else if (loc.includes('IDC')) intSubCounts.idc++;
|
||||
else if (loc.includes('한맥')) intSubCounts.hm++;
|
||||
if (type) intTypeCounts[type] = (intTypeCounts[type] || 0) + 1;
|
||||
} else {
|
||||
externalCount++;
|
||||
if (loc.includes('기술개발')) extSubCounts.tech++;
|
||||
else if (loc.includes('IDC')) extSubCounts.idc++;
|
||||
else if (loc.includes('한맥')) extSubCounts.hm++;
|
||||
if (type) extTypeCounts[type] = (extTypeCounts[type] || 0) + 1;
|
||||
|
||||
// [경고 로직 세분화] 외부 운영 기준 (공백/대소문자 무시)
|
||||
if (serviceType === '외부') {
|
||||
if (loc !== 'IDC') locWarningCount++;
|
||||
if (type.toLowerCase().replace(/\s/g, '').includes('서버pc')) typeWarningCount++;
|
||||
}
|
||||
const targetStat = serviceType === '내부' ? intStats : extStats;
|
||||
targetStat.total++;
|
||||
if (loc) targetStat.locCounts[loc] = (targetStat.locCounts[loc] || 0) + 1;
|
||||
if (type) {
|
||||
targetStat.typeCounts[type] = (targetStat.typeCounts[type] || 0) + 1;
|
||||
// 유형별 위치 분포 수집
|
||||
if (!targetStat.typeLocMap[type]) targetStat.typeLocMap[type] = {};
|
||||
targetStat.typeLocMap[type][loc] = (targetStat.typeLocMap[type][loc] || 0) + 1;
|
||||
}
|
||||
|
||||
if (serviceType === '외부') {
|
||||
const anomaly = checkAnomaly(serviceType, loc, type);
|
||||
if (anomaly.isLocWarning) extStats.locWarning++;
|
||||
if (anomaly.isTypeWarning) extStats.typeWarning++;
|
||||
}
|
||||
});
|
||||
|
||||
const locLabels = Object.keys(locationCounts).sort((a, b) => locationCounts[b] - locationCounts[a]);
|
||||
const pcLabels = ['공용PC', '서버PC', '개인PC'];
|
||||
const pcData = [pcTypeCounts.public, pcTypeCounts.server, pcTypeCounts.personal];
|
||||
// 템플릿 제너레이터 함수 (HTML 중복 제거)
|
||||
const generateDetailStatHTML = (title: string, stats: any) => `
|
||||
<div style="display: flex; align-items: center; justify-content: space-between; margin-bottom: 0.5rem; gap: 0.5rem;">
|
||||
<span style="font-size: 14px; font-weight: 800; color: var(--text-main); white-space: nowrap;">${title}</span>
|
||||
<div style="display: flex; gap: 4px; flex-wrap: wrap; justify-content: flex-end;">
|
||||
${stats.locWarning ? `<span style="background: #FFF7ED; color: #C2410C; font-size: 10px; font-weight: 800; padding: 2px 6px; border-radius: 4px; border: 1px solid #FFEDD5; white-space: nowrap;">위치부적절: ${stats.locWarning}</span>` : ''}
|
||||
${stats.typeWarning ? `<span style="background: #FFF1F2; color: #E11D48; font-size: 10px; font-weight: 800; padding: 2px 6px; border-radius: 4px; border: 1px solid #FDA4AF; white-space: nowrap;">형식부적절: ${stats.typeWarning}</span>` : ''}
|
||||
</div>
|
||||
</div>
|
||||
<div style="display: flex; flex-direction: column; gap: 0.3rem; font-size: 13px; color: var(--text-muted);">
|
||||
<div style="display: flex; gap: 0.75rem; flex-wrap: wrap;">
|
||||
${Object.entries(stats.locCounts as Record<string, number>).sort((a, b) => b[1] - a[1]).slice(0, 4).map(([l, c]) => `<span>${l}: <strong style="color:var(--text-main); font-size: 14px;">${c}</strong></span>`).join('')}
|
||||
</div>
|
||||
<div style="display: flex; gap: 0.6rem; flex-wrap: wrap; opacity: 0.9; border-top: 1px dashed var(--border-color); padding-top: 4px; margin-top: 2px;">
|
||||
${Object.entries(stats.typeCounts as Record<string, number>).sort((a, b) => b[1] - a[1]).slice(0, 6).map(([t, c]) => {
|
||||
const isTypeWarning = title.includes('외부') && t.toLowerCase().replace(/\s/g, '').includes('서버pc');
|
||||
|
||||
// 위치별 상세 정보 생성 (툴팁용)
|
||||
const locDist = stats.typeLocMap[t] || {};
|
||||
const locHint = Object.entries(locDist)
|
||||
.sort((a: any, b: any) => b[1] - a[1])
|
||||
.map(([l, count]) => `${l}: ${count}대`)
|
||||
.join('\n');
|
||||
|
||||
const chartLabels = isPcView ? pcLabels : locLabels;
|
||||
const chartData = isPcView ? pcData : locLabels.map(l => locationCounts[l]);
|
||||
return `<span title="${locHint}" style="${isTypeWarning ? 'color:#E11D48; font-weight:700;' : ''}; font-size: 13px; cursor: help;">${t}: <strong style="color:var(--text-main); font-size: 14px;">${c}</strong></span>`;
|
||||
}).join('')}
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
|
||||
contentWrapper.innerHTML = `
|
||||
<div class="system-dashboard" style="height: calc(100vh - 240px); overflow: hidden; padding: 0.5rem 0; font-family: 'Pretendard', sans-serif; letter-spacing: -0.02em; display: flex; flex-direction: column;">
|
||||
@@ -145,8 +192,8 @@ export function createListView(container: HTMLElement, config: ListViewConfig) {
|
||||
<div style="font-size: 11px; font-weight: 600; color: var(--text-muted); margin-bottom: 0.25rem;">총 보유 자산</div>
|
||||
<div style="font-size: 28px; font-weight: 800; color: var(--text-main); line-height: 1.1;">${fullList.length}<span style="font-size: 13px; font-weight: 600; margin-left: 4px; color: var(--text-muted);">대</span></div>
|
||||
<div style="display: flex; gap: 0.75rem; font-size: 14px; color: var(--text-muted); margin-top: 0.5rem;">
|
||||
<span>외부: <strong style="color:#35635C; font-size: 18px;">${externalCount}</strong></span>
|
||||
<span>내부: <strong style="color:#94A3B8; font-size: 18px;">${internalCount}</strong></span>
|
||||
<span>외부: <strong style="color:#35635C; font-size: 18px;">${extStats.total}</strong></span>
|
||||
<span>내부: <strong style="color:#94A3B8; font-size: 18px;">${intStats.total}</strong></span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -158,46 +205,11 @@ export function createListView(container: HTMLElement, config: ListViewConfig) {
|
||||
<span>서버: <strong style="color:var(--text-main); font-size: 18px;">${pcTypeCounts.server}</strong></span>
|
||||
<span>개인: <strong style="color:var(--text-main); font-size: 18px;">${pcTypeCounts.personal}</strong></span>
|
||||
</div>
|
||||
` : `
|
||||
<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;">외부 (운영) 상세</span>
|
||||
<div style="display: flex; gap: 4px; flex-wrap: wrap; justify-content: flex-end;">
|
||||
${locWarningCount > 0 ? `<span style="background: #FFF7ED; color: #C2410C; font-size: 10px; font-weight: 800; padding: 2px 6px; border-radius: 4px; border: 1px solid #FFEDD5; white-space: nowrap;">위치부적절: ${locWarningCount}</span>` : ''}
|
||||
${typeWarningCount > 0 ? `<span style="background: #FFF1F2; color: #E11D48; font-size: 10px; font-weight: 800; padding: 2px 6px; border-radius: 4px; border: 1px solid #FDA4AF; white-space: nowrap;">형식부적절: ${typeWarningCount}</span>` : ''}
|
||||
</div>
|
||||
</div>
|
||||
<div style="display: flex; flex-direction: column; gap: 0.3rem; font-size: 13px; color: var(--text-muted);">
|
||||
<div style="display: flex; gap: 0.75rem; flex-wrap: wrap;">
|
||||
<span>기술개발센터: <strong style="color:var(--text-main); font-size: 14px;">${extSubCounts.tech}</strong></span>
|
||||
<span>IDC: <strong style="color:var(--text-main); font-size: 14px;">${extSubCounts.idc}</strong></span>
|
||||
<span>한맥빌딩: <strong style="color:var(--text-main); font-size: 14px;">${extSubCounts.hm}</strong></span>
|
||||
</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(extTypeCounts).sort((a, b) => b[1] - a[1]).map(([type, count]) => {
|
||||
const isTypeWarning = type.toLowerCase().replace(/\s/g, '').includes('서버pc');
|
||||
return `<span style="${isTypeWarning ? 'color:#E11D48; font-weight:700;' : ''}; font-size: 13px;">${type}: <strong style="color:var(--text-main); font-size: 14px;">${count}</strong></span>`;
|
||||
}).join('')}
|
||||
</div>
|
||||
</div>
|
||||
`}
|
||||
` : generateDetailStatHTML('외부 (운영) 상세', extStats)}
|
||||
</div>
|
||||
|
||||
<div class="stat-group-item" style="border-left: 1px solid var(--border-color); padding-left: 1.5rem; min-width: 0;">
|
||||
${isPcView ? '' : `
|
||||
<div style="display: flex; align-items: flex-end; gap: 0.75rem; margin-bottom: 0.5rem;">
|
||||
<span style="font-size: 14px; font-weight: 800; color: var(--text-main);">내부 (테스트) 상세</span>
|
||||
</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;">
|
||||
<span>기술개발센터: <strong style="color:var(--text-main); font-size: 14px;">${intSubCounts.tech}</strong></span>
|
||||
<span>IDC: <strong style="color:var(--text-main); font-size: 14px;">${intSubCounts.idc}</strong></span>
|
||||
<span>한맥빌딩: <strong style="color:var(--text-main); font-size: 14px;">${intSubCounts.hm}</strong></span>
|
||||
</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(intTypeCounts).sort((a, b) => b[1] - a[1]).map(([type, count]) => `<span style="font-size: 13px;">${type}: <strong style="color:var(--text-main); font-size: 14px;">${count}</strong></span>`).join('')}
|
||||
</div>
|
||||
</div>
|
||||
`}
|
||||
${isPcView ? '' : generateDetailStatHTML('내부 (테스트) 상세', intStats as any)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -405,7 +417,7 @@ export function createListView(container: HTMLElement, config: ListViewConfig) {
|
||||
? `<tr><td colspan="4" style="padding: 3rem; text-align: center; color: var(--text-muted);">조회된 자산이 없습니다.</td></tr>`
|
||||
: finalDisplayList.map(asset => {
|
||||
const purpose = asset[ASSET_SCHEMA.ASSET_PURPOSE.key] || '';
|
||||
const serviceTypeKey = ASSET_SCHEMA.SERVICE_TYPE?.key || 'service_type';
|
||||
const serviceTypeKey = (ASSET_SCHEMA as any).SERVICE_TYPE?.key || 'service_type';
|
||||
const serviceType = asset[serviceTypeKey] || '외부';
|
||||
const type = asset[ASSET_SCHEMA.ASSET_TYPE.key] || '';
|
||||
const loc = asset[ASSET_SCHEMA.LOCATION.key] || '';
|
||||
@@ -501,7 +513,7 @@ export function createListView(container: HTMLElement, config: ListViewConfig) {
|
||||
tableWrapper.appendChild(table);
|
||||
|
||||
const updateTable = () => {
|
||||
if (state.currentViewMode !== 'asset') return;
|
||||
if ((state as any).currentViewMode !== 'asset') return;
|
||||
let filtered = applyCommonFilters(fullList, currentFilters, config.searchKeys as any[]);
|
||||
if (sortState.key) filtered = dynamicSort(filtered, sortState.key, sortState.direction);
|
||||
|
||||
@@ -536,7 +548,7 @@ export function createListView(container: HTMLElement, config: ListViewConfig) {
|
||||
// --- 뷰 전환 로직 ---
|
||||
const switchView = () => {
|
||||
contentWrapper.innerHTML = '';
|
||||
if (state.currentViewMode === 'asset') {
|
||||
if ((state as any).currentViewMode === 'asset') {
|
||||
filterBar.style.display = 'flex';
|
||||
contentWrapper.style.overflowY = 'auto';
|
||||
contentWrapper.appendChild(tableWrapper);
|
||||
@@ -554,7 +566,7 @@ export function createListView(container: HTMLElement, config: ListViewConfig) {
|
||||
if (!btn) return;
|
||||
toggleWrapper.querySelectorAll('.toggle-btn').forEach(b => b.classList.remove('active'));
|
||||
btn.classList.add('active');
|
||||
state.currentViewMode = btn.getAttribute('data-mode') as 'asset' | 'system';
|
||||
(state as any).currentViewMode = btn.getAttribute('data-mode') as 'asset' | 'system';
|
||||
switchView();
|
||||
});
|
||||
|
||||
|
||||
Reference in New Issue
Block a user