feat: DB V3 정규화 및 용도 기반 동적 UI 구현
- 백엔드: asset_core(마스터), asset_spec(사양), asset_volume(스토리지), asset_location(위치), asset_network(네트워크/원격) 5개 테이블로 V3 정규화 완료 - 백엔드: /api/assets/master 단일 엔드포인트로 통합 및 서브쿼리 최적화를 통한 UI 하위 호환성 유지 - 백엔드: 저장 로직(save) V3 스키마 분산 저장 및 cascade 기반 삭제 로직 적용 - 프론트엔드(HWModal): '현 용도(current_role)' 필드 추가 및 서버/개인용에 따른 네트워크/위치 섹션 동적 렌더링 구현 - 프론트엔드(state): 분산된 API 호출을 단일 호출로 통합하여 렌더링 성능 최적화 - 레거시 백업 파일 및 불필요한 구형 테이블 완벽 정리 완료
This commit is contained in:
358
server.js
358
server.js
@@ -28,57 +28,8 @@ const handleError = (res, err, label) => {
|
||||
res.status(500).json({ error: err.message });
|
||||
};
|
||||
|
||||
// --- 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();
|
||||
|
||||
// 1. Get Table Schema
|
||||
const [columns] = await connection.query(`DESCRIBE ${table}`);
|
||||
const validFields = columns.map(c => c.Field);
|
||||
|
||||
// 2. Clear Existing Data (Optional - depending on strategy)
|
||||
// For now, we use REPLACE INTO or similar. But user requested batch save as sync.
|
||||
await connection.query(`DELETE FROM ${table}`);
|
||||
|
||||
// 3. Insert New Data
|
||||
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();
|
||||
console.log(`✅ [BATCH SAVE] Table: ${table}, Count: ${data.length}`);
|
||||
res.json({ success: true, count: data.length });
|
||||
} catch (err) {
|
||||
if (connection) await connection.rollback();
|
||||
handleError(res, err, 'BATCH SAVE');
|
||||
} finally {
|
||||
if (connection) connection.release();
|
||||
}
|
||||
});
|
||||
|
||||
// 2. Get All Assets (Integrated Master Data)
|
||||
app.get('/api/assets/master', async (req, res) => {
|
||||
try {
|
||||
const connection = await pool.getConnection();
|
||||
const tables = {
|
||||
// --- Global Constants ---
|
||||
const CATEGORY_TABLE_MAP = {
|
||||
pc: 'asset_pc',
|
||||
server: 'asset_server',
|
||||
storage: 'asset_storage',
|
||||
@@ -93,18 +44,113 @@ app.get('/api/assets/master', async (req, res) => {
|
||||
users: 'user_master',
|
||||
swUsers: 'sw_assignment',
|
||||
logs: 'asset_history'
|
||||
};
|
||||
|
||||
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();
|
||||
|
||||
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: data.length });
|
||||
} catch (err) {
|
||||
if (connection) await connection.rollback();
|
||||
handleError(res, err, 'BATCH SAVE');
|
||||
} finally {
|
||||
if (connection) connection.release();
|
||||
}
|
||||
});
|
||||
|
||||
// 2. Get All Assets (Integrated Master Data from Normalized V3 Schema)
|
||||
app.get('/api/assets/master', async (req, res) => {
|
||||
try {
|
||||
const connection = await pool.getConnection();
|
||||
|
||||
const masterData = {
|
||||
pc: [], server: [], storage: [], network: [],
|
||||
equipment: [], officeSupplies: [], survey: [], vip: [], pcParts: [],
|
||||
swInternal: [], swExternal: [], swUsers: [], users: [], logs: []
|
||||
};
|
||||
|
||||
const masterData = {};
|
||||
for (const [key, tableName] of Object.entries(tables)) {
|
||||
try {
|
||||
const [rows] = await connection.query(`SELECT * FROM ${tableName}`);
|
||||
masterData[key] = rows;
|
||||
} catch (err) {
|
||||
console.warn(`[MASTER DATA] Skipping ${tableName}: ${err.message}`);
|
||||
masterData[key] = [];
|
||||
}
|
||||
}
|
||||
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);
|
||||
@@ -113,64 +159,118 @@ app.get('/api/assets/master', async (req, res) => {
|
||||
}
|
||||
});
|
||||
|
||||
// 3. Single Asset Save (Update or Insert)
|
||||
// 3. Asset Save (Surgical Split to Normalized V3 Tables)
|
||||
app.post('/api/asset/:category/save', async (req, res) => {
|
||||
const { category } = req.params;
|
||||
const asset = req.body;
|
||||
const tableMap = {
|
||||
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'
|
||||
};
|
||||
const table = tableMap[category];
|
||||
|
||||
if (!table) return res.status(400).json({ error: 'Invalid category' });
|
||||
|
||||
let connection;
|
||||
try {
|
||||
const connection = await pool.getConnection();
|
||||
const [columns] = await connection.query(`DESCRIBE ${table}`);
|
||||
const validFields = columns.map(c => c.Field);
|
||||
connection = await pool.getConnection();
|
||||
await connection.beginTransaction();
|
||||
|
||||
const dataObj = {};
|
||||
validFields.forEach(f => { if (asset[f] !== undefined) dataObj[f] = asset[f]; });
|
||||
// 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));
|
||||
|
||||
const keys = Object.keys(dataObj);
|
||||
const values = Object.values(dataObj);
|
||||
const placeholders = keys.map(() => '?').join(', ');
|
||||
const updates = keys.map(k => `${k} = VALUES(${k})`).join(', ');
|
||||
// 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));
|
||||
}
|
||||
|
||||
const sql = `INSERT INTO ${table} (${keys.join(', ')}) VALUES (${placeholders}) ON DUPLICATE KEY UPDATE ${updates}`;
|
||||
// 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]);
|
||||
}
|
||||
}
|
||||
|
||||
await connection.query(sql, values);
|
||||
connection.release();
|
||||
console.log(`💾 [ASSET SAVE] Category: ${category}, ID: ${asset.id}`);
|
||||
// 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) {
|
||||
handleError(res, err, 'ASSET SAVE');
|
||||
if (connection) await connection.rollback();
|
||||
handleError(res, err, 'ASSET SAVE V3');
|
||||
} finally {
|
||||
if (connection) connection.release();
|
||||
}
|
||||
});
|
||||
|
||||
// 4. Asset Delete
|
||||
app.delete('/api/asset/:category/:id', async (req, res) => {
|
||||
const { category, id } = req.params;
|
||||
const tableMap = {
|
||||
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'
|
||||
|
||||
// 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 = tableMap[category];
|
||||
if (!table) return res.status(400).json({ error: 'Invalid category' });
|
||||
|
||||
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}`);
|
||||
@@ -184,83 +284,49 @@ app.delete('/api/asset/:category/:id', async (req, res) => {
|
||||
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 tables = [
|
||||
'asset_pc', 'asset_server', 'asset_storage', 'asset_network',
|
||||
'asset_equipment', 'asset_office_supplies', 'asset_survey', 'asset_vip'
|
||||
];
|
||||
|
||||
// Extract YYYYMM from purchaseDate (format: YYYY-MM-DD)
|
||||
const datePart = purchaseDate ? purchaseDate.toString().replace(/-/g, '').substring(0, 6) : '';
|
||||
const searchPattern = datePart ? `${prefix}-${datePart}-%` : `${prefix}-%`;
|
||||
|
||||
let maxNum = 0;
|
||||
|
||||
for (const table of tables) {
|
||||
for (const table of ASSET_TABLES) {
|
||||
try {
|
||||
const [rows] = await connection.query(
|
||||
`SELECT asset_code FROM ${table} WHERE asset_code LIKE ?`,
|
||||
[searchPattern]
|
||||
);
|
||||
|
||||
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 seqPart = parts[parts.length - 1]; // Last part is sequence
|
||||
const num = parseInt(seqPart);
|
||||
const num = parseInt(parts[parts.length - 1]);
|
||||
if (!isNaN(num) && num > maxNum) maxNum = num;
|
||||
});
|
||||
} catch (err) {
|
||||
// Table might not exist or column missing
|
||||
} catch (err) {}
|
||||
}
|
||||
}
|
||||
|
||||
const nextNum = maxNum + 1;
|
||||
const nextCode = datePart
|
||||
? `${prefix}-${datePart}-${String(nextNum).padStart(4, '0')}`
|
||||
: `${prefix}-${String(nextNum).padStart(4, '0')}`; // Fallback if no date
|
||||
|
||||
const nextCode = datePart ? `${prefix}-${datePart}-${String(nextNum).padStart(4, '0')}` : `${prefix}-${String(nextNum).padStart(4, '0')}`;
|
||||
connection.release();
|
||||
console.log(`🆕 [GENERATE CODE] Prefix: ${prefix}, Date: ${datePart}, Next: ${nextCode}`);
|
||||
res.json({ nextCode });
|
||||
} catch (err) {
|
||||
handleError(res, err, 'GENERATE CODE');
|
||||
}
|
||||
} catch (err) { handleError(res, err, 'GENERATE CODE'); }
|
||||
});
|
||||
|
||||
// 6. Map Config API (Real-time Save)
|
||||
// 6. Map Config API
|
||||
app.get('/api/maps', (req, res) => {
|
||||
try {
|
||||
if (!fs.existsSync('map_config.json')) {
|
||||
return res.json({});
|
||||
}
|
||||
if (!fs.existsSync('map_config.json')) return res.json({});
|
||||
const data = fs.readFileSync('map_config.json', 'utf8');
|
||||
res.json(JSON.parse(data || '{}'));
|
||||
} catch (err) {
|
||||
handleError(res, err, 'GET MAPS');
|
||||
}
|
||||
} catch (err) { handleError(res, err, 'GET MAPS'); }
|
||||
});
|
||||
|
||||
app.post('/api/maps/save', (req, res) => {
|
||||
try {
|
||||
const { path, boxes } = req.body;
|
||||
if (!path) return res.status(400).json({ error: 'Path is required' });
|
||||
|
||||
let config = {};
|
||||
if (fs.existsSync('map_config.json')) {
|
||||
config = JSON.parse(fs.readFileSync('map_config.json', 'utf8') || '{}');
|
||||
}
|
||||
|
||||
if (fs.existsSync('map_config.json')) config = JSON.parse(fs.readFileSync('map_config.json', 'utf8') || '{}');
|
||||
config[path] = boxes;
|
||||
fs.writeFileSync('map_config.json', JSON.stringify(config, null, 2));
|
||||
console.log(`💾 [MAP SAVE] Updated config for: ${path}`);
|
||||
res.json({ success: true });
|
||||
} catch (err) {
|
||||
handleError(res, err, 'SAVE MAPS');
|
||||
}
|
||||
} catch (err) { handleError(res, err, 'SAVE MAPS'); }
|
||||
});
|
||||
|
||||
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)');
|
||||
});
|
||||
|
||||
@@ -359,6 +359,7 @@ 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;
|
||||
@@ -408,19 +409,50 @@ class HwAssetModal extends BaseModal {
|
||||
|
||||
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');
|
||||
}
|
||||
|
||||
protected onAfterOpen(asset: any, mode: string): void {
|
||||
this.updateMapButtonVisibility(asset);
|
||||
|
||||
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';
|
||||
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);
|
||||
});
|
||||
}
|
||||
|
||||
private applyRoleVisibility(role: string): void {
|
||||
const isServer = role === 'Server';
|
||||
const isPersonal = role === 'Personal';
|
||||
|
||||
// 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;
|
||||
|
||||
// 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;
|
||||
}
|
||||
};
|
||||
|
||||
// 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('.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');
|
||||
document.querySelectorAll('.pc-only').forEach(el => (el as HTMLElement).style.display = isPersonal ? 'flex' : 'none');
|
||||
}
|
||||
|
||||
private updateMapButtonVisibility(asset?: any) {
|
||||
|
||||
@@ -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 response = await fetch(`${API_BASE_URL}/api/assets/master`);
|
||||
if (!response.ok) throw new Error('Failed to fetch master data');
|
||||
|
||||
const results = await Promise.all(endpoints.map(e => fetch(API_BASE_URL + e.url)));
|
||||
const data = await response.json();
|
||||
|
||||
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;
|
||||
// 전역 상태 업데이트
|
||||
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(); // 전역 상태 갱신
|
||||
|
||||
@@ -42,15 +42,15 @@ 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>
|
||||
<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>
|
||||
`;
|
||||
container.appendChild(toggleWrapper);
|
||||
@@ -82,21 +82,32 @@ 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;
|
||||
|
||||
const extTypeCounts: Record<string, number> = {};
|
||||
const intTypeCounts: Record<string, number> = {};
|
||||
let locWarningCount = 0;
|
||||
let typeWarningCount = 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 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 +119,39 @@ 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;
|
||||
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 (serviceType === '외부') {
|
||||
if (loc !== 'IDC') locWarningCount++;
|
||||
if (type.toLowerCase().replace(/\s/g, '').includes('서버pc')) typeWarningCount++;
|
||||
}
|
||||
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];
|
||||
|
||||
const chartLabels = isPcView ? pcLabels : locLabels;
|
||||
const chartData = isPcView ? pcData : locLabels.map(l => locationCounts[l]);
|
||||
// 템플릿 제너레이터 함수 (HTML 중복 제거)
|
||||
const generateDetailStatHTML = (title: string, stats: typeof extStats) => `
|
||||
<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).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]) => {
|
||||
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>`;
|
||||
}).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 +162,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 +175,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 +387,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 +483,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 +518,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 +536,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