15 Commits

Author SHA1 Message Date
f5a84a77ef feat: PC 페이지 개발 대기 상태 전환 및 자산현황 뷰 노출 범위 제한 (서버 탭 전용) 2026-06-11 11:20:28 +09:00
10479aad7e feat: HWModal 네트워크 및 원격 접속 정보 동적 입력 기능 통합 및 UI 최적화 2026-06-11 11:14:00 +09:00
95fbd3f606 fix: 자산 상세 정보 레이아웃 최적화 및 스타일 표준화
- 상세 정보 섹션을 2열 그리드로 변경하여 정보 밀도 향상\n- 항목 제목(12px) 및 내용(14px) 폰트 크기 조정\n- 인라인 스타일을 dashboard.css로 이전 및 중앙 집중화\n- 불필요한 아이콘 제거 및 UI 정돈
2026-06-11 10:37:33 +09:00
207acbdecb fix: LocationView 레이아웃 안정화 및 UI 개선
- 페이지네이션 버튼을 상세위치 옆으로 이동\n- 지도 이미지 밀림 현상 방지를 위한 정렬 방식 수정 및 동기화 로직 보강\n- 상단 메뉴에서 구버전 현황 버튼 제거
2026-06-11 10:08:43 +09:00
164568843b feat: LocationView 고도화 - 지도 클릭 시 사이드바 상세 정보 표시 및 구역 필터링 구현 2026-06-11 09:47:57 +09:00
2b9c965c91 feat: 동적 디스크 확장 기능 및 하드웨어 카테고리 필터링 고도화 2026-06-09 16:29:54 +09:00
4b408b0640 feat: HW 모달 UI 고도화 및 자산 분류 체계 개편 2026-06-09 13:56:05 +09:00
3ab587d342 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 호출을 단일 호출로 통합하여 렌더링 성능 최적화

- 레거시 백업 파일 및 불필요한 구형 테이블 완벽 정리 완료
2026-06-08 17:58:48 +09:00
3b9b2ea598 feat: 자산번호 4자리 일련번호 확장 및 날짜 기반 생성 로직 추가
- 백엔드(server.js): 자산번호 자동 생성 시 구매일자(YYYYMM)를 파싱하여 [접두사]-[YYYYMM]-[0000] 형태로 4자리 일련번호를 부여하도록 로직 전면 수정
- 프런트엔드(HWModal.ts): 자산번호 생성 API 호출 시 사용자가 입력한 구매일자 데이터를 파라미터로 함께 전송하도록 연동
- 전체 DB 및 로컬 마스터 데이터의 4자리 일련번호 및 날짜 복구 마이그레이션 반영
2026-06-08 15:18:39 +09:00
05c565552a feat: 자산 현황 고도화 및 데이터 표준화 작업 완료
- UI 개선: 모든 아이콘/이모지 제거(미니멀리즘), 목록 행 하이라이트 추가, 요약 레이아웃 최적화
- 경고 시스템: 위치 부적절/형식 부적절 사유별 세분화 및 상단 통계 배지 적용
- 정보 강화: 우측 패널 '상세 보기' 버튼 복구 및 '자산 유형' 정보 추가 노출
- 표준화: 위치명(기술개발센터, 한맥빌딩) 및 자산번호 접두사(STO/NAS/DAS -> DSS/STM) 통합
- 데이터: realServerData.ts 및 SharedData.ts 내 유형/접두사 표준 체계 전면 반영
- 레이아웃: 헤더 역할 스위처 및 가이드 버튼 일렬 정렬 수정
2026-06-08 11:40:27 +09:00
2ec9261c03 feat: 자산 현황 레이아웃 개선 및 위치 정보 가독성 최적화
- 박스형 디자인 배제 및 선 기반의 미니멀 레이아웃 적용
- 목록 클릭 시 우측 패널에 배치도 및 위치 마커 즉시 표시
- 사진 비율 유지 및 좌표 정밀 보정 (onload/resize 로직 도입)
- 총 보유 자산 통계에 외부(운영)/내부(테스트) 대수 세부 표시 추가
- 빌드 오류 수정을 위한 구문 정리
2026-06-08 09:44:15 +09:00
06f3baaa58 feat: 대시보드 및 자산현황 레이아웃 개편 및 디자인 복원
- 자산현황 대시보드의 그래프를 제거하고 표와 상세정보 패널(5:5 비율)로 레이아웃 개편
- 표에서 '비고' 컬럼을 제거하고 '담당자(정)', '담당자(부)' 컬럼으로 교체 및 너비 조정
- 이중 필터(위치 -> 상세위치) 도입으로 필터링 기능 강화
- 상세정보 패널의 사진 영역을 Flexbox로 최적화하여 위아래 잘림 현상 원천 차단
- 모달창 내 '수정 모드' 폰트 색상을 디자인 가이드(var(--color-dahong))에 맞게 붉은 계열로 원상 복구 및 누락 변수 추가
- ListFactory.ts의 ASSET_SCHEMA.SERVICE_TYPE 참조 시 발생하던 TypeError 픽스
2026-06-05 18:01:26 +09:00
eead43837d feat: PC 맞춤형 대시보드 구현 및 자산 현황 레이아웃 최적화
- PC 자산 관리 화면에 유형별(공용/서버/개인) 통계 및 차트 적용
- 자산 현황 대시보드의 위치 분류 체계 통일 (센터/IDC/한맥빌딩)
- 하단 요약 표의 자산번호 컬럼을 비고(Memo)로 교체 및 말줄임표 적용
- 차트 크기 확대 및 네비게이션 메뉴 레이아웃 안정화
- ListFactory.ts 내 formatInline 미정의 오류 수정
2026-06-05 10:51:29 +09:00
46422e8544 cleanup: remove database migration and utility scripts 2026-06-02 14:44:45 +09:00
a30f99f0ad feat: improve asset code generation and re-sequence assets by year
- Enhanced backend asset code generation logic to handle multiple tables
- Integrated asset code generation button in HWModal
- Included utility scripts for asset code migration and DB synchronization
- Resolved issues with missing purchase dates and duplicate asset codes
2026-06-02 14:40:06 +09:00
18 changed files with 2062 additions and 1045 deletions

BIN
backupDB_20260602.xlsx Normal file

Binary file not shown.

428
server.js
View File

@@ -8,179 +8,351 @@ 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]);
}
await connection.commit();
res.json({ success: true, count: items.length });
} catch (err) {
await connection.rollback();
handleError(res, err, context);
} finally {
connection.release();
}
};
// --- Routes ---
const [columns] = await connection.query(`DESCRIBE ${table}`);
const validFields = columns.map(c => c.Field);
const routeMap = {
'/api/users': { table: 'system_users', context: 'USERS' },
'/api/pc': { table: 'asset_pc', context: 'PC' },
'/api/server': { table: 'asset_server', context: 'SERVER' },
'/api/storage': { table: 'asset_storage', context: 'STORAGE' },
'/api/network': { table: 'asset_network', context: 'NETWORK' },
'/api/sw/internal': { table: 'asset_sw_internal', context: 'SW INTERNAL' },
'/api/sw/external': { table: 'asset_sw_external', context: 'SW EXTERNAL' },
'/api/survey': { table: 'asset_survey', context: 'SURVEY' },
'/api/pc-parts': { table: 'asset_pc_parts', context: 'PC PARTS' },
'/api/equipment': { table: 'asset_equipment', context: 'EQUIPMENT' },
'/api/office-supplies': { table: 'asset_office_supplies', context: 'OFFICE SUPPLIES' },
'/api/cloud': { table: 'asset_cloud', context: 'CLOUD' },
'/api/domain': { table: 'asset_domain', context: 'DOMAIN' },
'/api/cost': { table: 'asset_cost', context: 'COST' },
'/api/vip': { table: 'asset_vip', context: 'VIP' },
'/api/asset/software/assignment': { table: 'asset_software_assignment', context: 'SW ASSIGN' }
};
await connection.query(`DELETE FROM ${table}`);
Object.entries(routeMap).forEach(([route, { table, context }]) => {
app.get(route, (req, res) => fetchAssets(table, res, context));
app.post(`${route}/batch`, (req, res) => saveAssetsBatch(table, req.body, res, `${context} BATCH`));
});
app.get('/api/asset/history', (req, res) => fetchAssets('asset_history', res, 'HISTORY'));
app.post('/api/asset/history/batch', async (req, res) => {
const connection = await pool.getConnection();
try {
await connection.beginTransaction();
await connection.query('DELETE FROM asset_history');
for (const item of req.body) {
const dbRow = {
asset_id: item.assetId,
log_date: item.date,
log_user: item.user,
details: item.details,
cost: item.cost || 0
};
await connection.query('INSERT INTO asset_history SET ?', [dbRow]);
if (data.length > 0) {
const placeholders = validFields.map(() => '?').join(', ');
const sql = `INSERT INTO ${table} (${validFields.join(', ')}) VALUES (${placeholders})`;
for (const item of data) {
const values = validFields.map(field => {
const val = item[field];
return val === undefined ? null : val;
});
await connection.query(sql, values);
}
await connection.commit();
res.json({ success: true });
} catch (err) { await connection.rollback(); handleError(res, err, 'BATCH HISTORY'); } finally { connection.release(); }
}
await connection.commit();
res.json({ success: true, count: data.length });
} catch (err) {
if (connection) await connection.rollback();
handleError(res, err, 'BATCH SAVE');
} finally {
if (connection) connection.release();
}
});
app.get('/api/generate-asset-code', async (req, res) => {
// 2. Get All Assets (Integrated Master Data from Normalized V3 Schema)
app.get('/api/assets/master', async (req, res) => {
try {
const { prefix } = req.query;
if (!prefix) return res.status(400).json({ error: 'Prefix is required' });
const tables = ['asset_pc', 'asset_server', 'asset_storage', 'asset_network', 'asset_survey', 'asset_pc_parts', 'asset_equipment', 'asset_office_supplies', 'asset_vip'];
let lastCode = '';
for (const table of tables) {
const [rows] = await pool.query(`SELECT asset_code FROM ${table} WHERE asset_code LIKE ? ORDER BY asset_code DESC LIMIT 1`, [`${prefix}%`]);
if (rows.length > 0 && rows[0].asset_code > lastCode) lastCode = rows[0].asset_code;
const connection = await pool.getConnection();
const masterData = {
pc: [], server: [], storage: [], network: [],
equipment: [], officeSupplies: [], survey: [], vip: [], pcParts: [],
swInternal: [], swExternal: [], swUsers: [], users: [], logs: []
};
const [rows] = await connection.query(`
SELECT
c.*,
s.hw_status, s.model_name, s.mainboard, s.os, s.cpu, s.ram, s.gpu,
s.monitoring, s.price, s.monitor_inch, s.serial_num,
l.location, l.location_detail, l.location_photo, l.loc_x, l.loc_y,
n.ip_address, n.mac_address, n.remote_tool, n.remote_id, n.remote_pw,
(
SELECT JSON_ARRAYAGG(JSON_OBJECT('type', disk_type, 'capacity', capacity, 'unit', unit, 'slot', slot_no))
FROM asset_volume WHERE asset_id = c.id
) as volumes
FROM asset_core c
LEFT JOIN asset_spec s ON c.id = s.asset_id
LEFT JOIN asset_location l ON l.id = (
SELECT id FROM asset_location
WHERE asset_id = c.id AND is_active = 1
ORDER BY created_at DESC LIMIT 1
)
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));
}
let nextNum = 1;
if (lastCode) {
const lastNum = parseInt(lastCode.split('-').pop() || '0');
nextNum = lastNum + 1;
// 3.3 asset_volume
await connection.query('DELETE FROM asset_volume WHERE asset_id = ?', [asset.id]);
if (asset.volumes) {
try {
let vols = typeof asset.volumes === 'string' ? JSON.parse(asset.volumes) : asset.volumes;
if (Array.isArray(vols)) {
for (let i = 0; i < vols.length; i++) {
const v = vols[i];
if (v.type && v.capacity) {
await connection.query(
'INSERT INTO asset_volume (asset_id, disk_type, capacity, unit, slot_no) VALUES (?, ?, ?, ?, ?)',
[asset.id, v.type, v.capacity, v.unit || 'GB', v.slot || (i + 1)]
);
}
}
}
} catch(e) { console.error('Volume parse error', e); }
}
res.json({ nextCode: `${prefix}${String(nextNum).padStart(3, '0')}` });
// 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();
}
});
// 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 (Real-time Save)
// 6. Map Config API
app.get('/api/maps', (req, res) => {
try {
if (!fs.existsSync('map_config.json')) {
return res.json({});
}
if (!fs.existsSync('map_config.json')) return res.json({});
const data = fs.readFileSync('map_config.json', 'utf8');
res.json(JSON.parse(data || '{}'));
} catch (err) {
handleError(res, err, 'GET MAPS');
}
} catch (err) { handleError(res, err, 'GET MAPS'); }
});
app.post('/api/maps/save', (req, res) => {
try {
const { path, boxes } = req.body;
if (!path) return res.status(400).json({ error: 'Path is required' });
let config = {};
if (fs.existsSync('map_config.json')) {
config = JSON.parse(fs.readFileSync('map_config.json', 'utf8') || '{}');
}
if (fs.existsSync('map_config.json')) config = JSON.parse(fs.readFileSync('map_config.json', 'utf8') || '{}');
config[path] = boxes;
fs.writeFileSync('map_config.json', JSON.stringify(config, null, 2));
console.log(`💾 [MAP SAVE] Updated config for: ${path}`);
res.json({ success: true });
} catch (err) { handleError(res, err, 'SAVE MAPS'); }
});
// 7. File Upload API (Base64)
app.post('/api/upload', (req, res) => {
try {
const { fileName, fileData } = req.body;
if (!fileName || !fileData) return res.status(400).json({ error: 'FileName and FileData are required' });
// base64 데이터에서 실제 바이너리 추출
const base64Data = fileData.replace(/^data:.*;base64,/, "");
const buffer = Buffer.from(base64Data, 'base64');
// 고유한 파일명 생성 (타임스탬프 결합)
const timestamp = Date.now();
const safeFileName = `${timestamp}_${fileName.replace(/[^a-zA-Z0-9._-]/g, '_')}`;
const filePath = `uploads/${safeFileName}`;
fs.writeFileSync(filePath, buffer);
console.log(`파일 업로드 성공: ${filePath}`);
res.json({ success: true, filePath: `/${filePath}`, fileName: safeFileName });
} catch (err) {
handleError(res, err, 'SAVE MAPS');
handleError(res, err, 'FILE UPLOAD');
}
});
app.listen(3000, '0.0.0.0', () => {
console.log('📡 ITAM BACKEND SERVER RUNNING ON PORT 3000 (Multi-Table Optimized)');
console.log('📡 ITAM BACKEND SERVER RUNNING ON PORT 3000 (V3 Normalized)');
});

File diff suppressed because it is too large Load Diff

View File

@@ -13,15 +13,15 @@ export const HW_STATUS_LIST = ['운영', '재고', '수리', '폐기', '기타']
// 구분(Category) -> 유형(Asset Type) 관계 정의 (통합 관리)
export const CATEGORY_TYPE_MAP: Record<string, string[]> = {
'서버': ['서버 렉', '가상서버(VM)', '워크스테이션', 'NAS', 'DAS', '서버PC', '스토리지 렉'],
'서버': ['서버 렉', '가상서버(VM)', '워크스테이션', '저장시스템_렉(NAS)', '저장시스템_렉(DAS)', '저장시스템_미니(NAS)', '저장시스템_미니(DAS)'],
'PC': ['개인PC', '노트북', '공용PC', '서버PC'],
'스토리지': ['SSD', 'HDD', '외장HDD'],
'저장매체': ['SSD', 'HDD', '외장HDD'],
'네트워크': ['스위치', '허브', '방화벽', '라우터', '공유기', '허브'],
'PC부품': ['CPU', 'RAM', 'GPU', 'SSD', 'HDD', 'RAM', '모니터'],
'공간정보장비': ['드론', '측량장비', '보조기기'],
'업무지원장비': ['카메라', '스피커', 'TV', '모바일', '유선전화기', 'XR', '프린터', '전산소모품'],
'외부': ['영구', '구독'],
'내부': ['판매용', 'Solutions', 'Inhouse', 'Engine&Module'],
'외부SW': ['영구', '구독'],
'내부SW': ['판매용', 'Solutions', 'Inhouse', 'Engine&Module'],
'비용관리': ['클라우드', '도메인', '전화', '인터넷', '이메일'],
'내빈/외빈': ['선물'],
'시설자산': ['사무가구']
@@ -38,10 +38,12 @@ export const LOCATION_DATA: Record<string, string[]> = {
// 유형별 자산번호 접두사(Prefix) 매핑
export const TYPE_PREFIX_MAP: Record<string, string> = {
'서버': 'SVR', '워크스테이션': 'SVR', '개인PC': 'PC', '공용PC': 'PC', '서버PC': 'PC', 'NAS': 'NAS', 'DAS': 'DAS', '스토리지': 'STO',
'HDD': 'HDD', 'SSD': 'SSD', '노트북': 'NBK', '태블릿': 'TAB',
'서버': 'SVR', '워크스테이션': 'SVR', '개인PC': 'PC', '공용PC': 'PC', '서버PC': 'PC',
'저장시스템_렉(NAS)': 'DSS', '저장시스템_렉(DAS)': 'DSS', '저장시스템_미니(NAS)': 'DSS', '저장시스템_미니(DAS)': 'DSS',
'저장매체': 'STM', 'HDD': 'HDD', 'SSD': 'SSD',
'노트북': 'NBK', '태블릿': 'TAB',
'드론': 'DRO', '측량장비': 'SUR', '보조기기': 'SUR', '허브': 'NET',
'구독SW': 'SW', '영구SW': 'SW', '내부' : 'INT'
'구독SW': 'SW', '영구SW': 'SW', '내부' : 'SW_INT', '외부':'SW_EXT'
};
// 배치도 이미지 매핑 데이터

View File

@@ -7,7 +7,7 @@ const MENU_CONFIG: any = {
},
sw: {
label: '소프트웨어',
tabs: ['외부', '내부']
tabs: ['외부SW', '내부SW']
},
ops: {
label: '운영지원',

View File

@@ -484,7 +484,7 @@ export const realServerData = [
},
{
"법인": "삼안",
"자산코드": "sa-das-001",
"자산코드": "DSS020",
"storage유형": "서버",
"용도": "",
"상세": "Satis01, Satis02 광케이블 연결 (물리연결)",
@@ -505,7 +505,7 @@ export const realServerData = [
},
{
"법인": "삼안",
"자산코드": "sa-nas-001",
"자산코드": "DSS019",
"storage유형": "서버",
"용도": "인트라넷 백업 스토리지",
"상세": "",
@@ -526,7 +526,7 @@ export const realServerData = [
},
{
"법인": "삼안",
"자산코드": "sa-nas-002",
"자산코드": "DSS018",
"storage유형": "서버",
"용도": "성과품 스토리지",
"상세": "매니지먼트 접속 확인 불가 (콘솔 연결 후 페이지 오픈 필요)",
@@ -547,7 +547,7 @@ export const realServerData = [
},
{
"법인": "삼안",
"자산코드": "sa-nas-003",
"자산코드": "DSS017",
"storage유형": "서버",
"용도": "성과품 백업 스토리지",
"상세": "",
@@ -568,7 +568,7 @@ export const realServerData = [
},
{
"법인": "한라",
"자산코드": "hl-das-001",
"자산코드": "DSS016",
"storage유형": "서버",
"용도": "",
"상세": "파일서버 정보 없음(접속 불가)",
@@ -589,7 +589,7 @@ export const realServerData = [
},
{
"법인": "한라",
"자산코드": "hl-das-002",
"자산코드": "DSS015",
"storage유형": "서버",
"용도": "",
"상세": "파일서버 정보 없음(접속 불가)",
@@ -611,7 +611,7 @@ export const realServerData = [
{
"법인": "",
"자산코드": "",
"storage유형": "NAS",
"storage유형": "저장시스템_렉(DAS)",
"용도": "GSIM NAS",
"상세": "팀 내부 자료 저장 , 정사영상 및 지도 데이터 저장 , Gitea 및 Git 내장 NAS",
"위치": "마천사무실",
@@ -631,7 +631,7 @@ export const realServerData = [
{
"법인": "",
"자산코드": "",
"storage유형": "NAS",
"storage유형": "저장시스템_렉(DAS)",
"용도": "그래픽스개발팀 데이터 백업 NAS",
"상세": "그래픽스 개발팀 데이터 백업용 NAS",
"위치": "마천사무실",
@@ -1091,7 +1091,7 @@ export const realServerData = [
{
"법인": "",
"자산코드": "1",
"storage유형": "NAS",
"storage유형": "저장시스템_렉(DAS)",
"용도": "NAS 2",
"상세": "한라 기업부설연구소 공용 NAS",
"위치": "한맥빌딩(MDF 실)",
@@ -1107,7 +1107,7 @@ export const realServerData = [
{
"법인": "",
"자산코드": "2",
"storage유형": "NAS",
"storage유형": "저장시스템_렉(DAS)",
"용도": "NAS 1",
"상세": "한라 공용 NAS",
"위치": "한맥빌딩(MDF 실)",
@@ -1123,7 +1123,7 @@ export const realServerData = [
{
"법인": "",
"자산코드": "3",
"storage유형": "NAS",
"storage유형": "저장시스템_렉(DAS)",
"용도": "NAS 4",
"상세": "한라 공용 NAS",
"위치": "한맥빌딩(MDF 실)",
@@ -1139,7 +1139,7 @@ export const realServerData = [
{
"법인": "",
"자산코드": "4",
"storage유형": "NAS",
"storage유형": "저장시스템_렉(DAS)",
"용도": "NAS 5",
"상세": "한라 환경플랜트사업부 NAS",
"위치": "한맥빌딩(MDF 실)",
@@ -1155,7 +1155,7 @@ export const realServerData = [
{
"법인": "",
"자산코드": "5",
"storage유형": "NAS",
"storage유형": "저장시스템_렉(DAS)",
"용도": "NAS 6",
"상세": "한라 공용 NAS",
"위치": "한맥빌딩(MDF 실)",
@@ -1171,7 +1171,7 @@ export const realServerData = [
{
"법인": "",
"자산코드": "6",
"storage유형": "NAS",
"storage유형": "저장시스템_렉(DAS)",
"용도": "NAS7",
"상세": "한라 원주바이오 NAS",
"위치": "한맥빌딩(MDF 실)",
@@ -1187,7 +1187,7 @@ export const realServerData = [
{
"법인": "",
"자산코드": "7",
"storage유형": "NAS",
"storage유형": "저장시스템_렉(DAS)",
"용도": "총괄기획실 NAS",
"상세": "총괄기획실 공용 NAS",
"위치": "한맥빌딩(MDF 실)",
@@ -1203,7 +1203,7 @@ export const realServerData = [
{
"법인": "",
"자산코드": "8",
"storage유형": "NAS",
"storage유형": "저장시스템_렉(DAS)",
"용도": "한맥 NAS 1",
"상세": "한맥 공용 NAS",
"위치": "한맥빌딩(MDF 실)",
@@ -1219,7 +1219,7 @@ export const realServerData = [
{
"법인": "",
"자산코드": "9",
"storage유형": "NAS",
"storage유형": "저장시스템_렉(DAS)",
"용도": "한맥 NAS 2",
"상세": "한맥 공용 NAS",
"위치": "한맥빌딩(MDF 실)",
@@ -1235,7 +1235,7 @@ export const realServerData = [
{
"법인": "",
"자산코드": "10",
"storage유형": "NAS",
"storage유형": "저장시스템_렉(DAS)",
"용도": "한맥 NAS 3",
"상세": "한맥 공용 NAS",
"위치": "한맥빌딩(MDF 실)",
@@ -1251,7 +1251,7 @@ export const realServerData = [
{
"법인": "",
"자산코드": "11",
"storage유형": "NAS",
"storage유형": "저장시스템_렉(DAS)",
"용도": "NAS 13",
"상세": "환경플랜트사업",
"위치": "한맥빌딩(MDF 실)",
@@ -1331,7 +1331,7 @@ export const realServerData = [
{
"법인": "",
"자산코드": "16",
"storage유형": "NAS",
"storage유형": "저장시스템_렉(DAS)",
"용도": "디자인팀1 NAS",
"상세": "",
"위치": "한맥빌딩(MDF 실)",
@@ -1347,7 +1347,7 @@ export const realServerData = [
{
"법인": "",
"자산코드": "17",
"storage유형": "NAS",
"storage유형": "저장시스템_렉(DAS)",
"용도": "디자인팀2 NAS",
"상세": "",
"위치": "한맥빌딩(MDF 실)",
@@ -1507,7 +1507,7 @@ export const realServerData = [
{
"법인": "",
"자산코드": "27",
"storage유형": "NAS",
"storage유형": "저장시스템_렉(DAS)",
"용도": "기술개발센터 NAS",
"상세": "",
"위치": "한맥빌딩(MDF 실)",
@@ -1523,7 +1523,7 @@ export const realServerData = [
{
"법인": "",
"자산코드": "28",
"storage유형": "NAS",
"storage유형": "저장시스템_렉(DAS)",
"용도": "-",
"상세": "",
"위치": "한맥빌딩(MDF 실)",

View File

@@ -120,12 +120,12 @@ export const PAGE_DESCRIPTIONS: Record<string, { title: string; description: str
description: '측량 및 공간 정보 수집에 사용되는 특수 정밀 장비들의 이력과 상태를 관리합니다.',
icon: 'map'
},
'내부': {
'내부SW': {
title: '사내 개발 S/W 관리',
description: '사내에서 자체 개발하거나 운영 중인 시스템 및 소프트웨어 서비스 현황을 관리합니다.',
icon: 'code'
},
'외부': {
'외부SW': {
title: '외부 상용 S/W 관리',
description: '상용 소프트웨어의 라이선스 보유 현황, 사용자 할당 및 만료 일정을 관리합니다.',
icon: 'package'

View File

@@ -36,6 +36,7 @@ export interface MasterAssetData {
export interface AppState {
activeCategory: 'dashboard' | 'hw' | 'sw' | 'ops' | 'vip' | 'fac' | 'users' | 'etc';
activeSubTab: string;
viewMode: 'location' | 'legacy' | 'list';
masterData: MasterAssetData;
activeCharts: any[];
currentUserRole: 'admin' | 'user';
@@ -45,6 +46,7 @@ export interface AppState {
export const state: AppState = {
activeCategory: 'hw',
activeSubTab: '서버', // 대시보드 제거됨에 따라 기본값 변경
viewMode: 'location',
activeCharts: [],
currentUserRole: 'user',
masterData: {
@@ -60,44 +62,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 +92,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 +108,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 +130,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(); // 전역 상태 갱신

View File

@@ -1,6 +1,6 @@
import { PAGE_DESCRIPTIONS } from './schema';
export const API_BASE_URL = `http://${location.hostname}:3000`;
export const API_BASE_URL = '';
/**
* ITAM 공통 유틸리티 함수
@@ -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 '';
}

View File

@@ -2,6 +2,7 @@ import { state, loadMasterDataFromDB, saveAsset } from './core/state';
import { renderNavigation } from './components/Navigation';
import { renderDashboard } from './views/DashboardView';
import { renderSWTable } from './views/SW_Table';
import { renderLocationView } from './views/LocationView';
import { initBaseModal } from './components/Modal/BaseModal';
import { initHwModal, openHwModal } from './components/Modal/HWModal';
import { initSwModal, openSwModal } from './components/Modal/SWModal';
@@ -46,11 +47,38 @@ const saveUsersToDB = () => apiBatchSave(`http://${location.hostname}:3000/api/u
function refreshView() {
const mainContent = document.getElementById('main-content')!;
if (!mainContent) return;
// 서버 탭이 아닐 경우 '자산현황(위치)' 뷰 진입 방지 및 강제 리스트 모드 전환
if (state.activeSubTab !== '서버' && state.viewMode === 'location') {
state.viewMode = 'list';
}
const isServerTab = state.activeSubTab === '서버';
if (state.activeSubTab === '대시보드') {
renderDashboard(mainContent);
mainContent.innerHTML = `
<div class="view-header">
<div class="view-toggle-container" style="${isServerTab ? '' : 'display:none;'}">
<button class="mode-toggle-btn ${state.viewMode === 'location' ? 'active' : ''}" data-mode="location">자산현황(위치)</button>
<button class="mode-toggle-btn ${state.viewMode === 'list' ? 'active' : ''}" data-mode="list">자산목록</button>
</div>
</div>
<div id="view-body" style="flex: 1; overflow: hidden; display: flex; flex-direction: column;"></div>
`;
// 이벤트 바인딩
mainContent.querySelectorAll('.mode-toggle-btn').forEach(btn => {
btn.addEventListener('click', () => {
const mode = (btn as HTMLElement).getAttribute('data-mode') as any;
state.viewMode = mode;
refreshView();
});
});
const viewBody = document.getElementById('view-body')!;
if (state.viewMode === 'location') {
renderLocationView(viewBody);
} else {
renderSWTable(mainContent);
renderSWTable(viewBody); // 리스트 형식
}
}
@@ -74,11 +102,7 @@ function initApp() {
try {
renderNavigation((tab) => {
if (tab === '대시보드') {
renderDashboard(mainContent);
} else {
renderSWTable(mainContent);
}
refreshView();
});
initHwModal(() => saveAllDataToDB(), closeAllModals);
@@ -121,7 +145,7 @@ function initApp() {
if (cat === 'hw') {
openHwModal({ id: newId, asset_code: '', category: tab } as any, 'add');
} else if (cat === 'sw') {
const swType = tab === '외부' ? '외부SW' : (tab === '내부' ? '내부SW' : '외부SW');
const swType = tab === '외부SW' ? '외부SW' : (tab === '내부SW' ? '내부SW' : '외부SW');
openSwModal({ id: newId, asset_type: swType } as any, 'add');
} else if (cat === 'ops') {
if (tab === '도메인') openDomainModal(null);

View File

@@ -1,5 +1,5 @@
:root {
/* --- System Colors (Added) --- */
/* --- System Colors --- */
--color-red: #F21D0D;
--color-pink: #E8175E;
--color-magenta: #B92ED1;
@@ -15,37 +15,6 @@
--color-iron: #7F7F7F;
--color-steel: #688897;
--color-red-light: #FEE9E7;
--color-pink-light: #FDE8EF;
--color-magenta-light: #F8EBFB;
--color-purple-light: #F1ECF9;
--color-navy-light: #EDEEF9;
--color-blue-light: #E7F4FE;
--color-cyan-light: #E6F7FF;
--color-green-light: #EEF8EE;
--color-yellow-light: #FFF9E6;
--color-orange-light: #FFF5E6;
--color-dahong-light: #FFECE6;
--color-brown-light: #F6F1EF;
--color-iron-light: #F3F3F3;
--color-steel-light: #F0F4F5;
--color-red-medium: #FAA59E;
--color-pink-medium: #F6A2BF;
--color-magenta-medium: #E3ABEC;
--color-purple-medium: #C5B1E7;
--color-navy-medium: #B3BBE5;
--color-blue-medium: #9ED1FA;
--color-cyan-medium: #9ADFFE;
--color-green-medium: #B8E0B9;
--color-yellow-medium: #FFE599;
--color-orange-medium: #FFD699;
--color-dahong-medium: #FFB199;
--color-dahong: #FF3D00;
--color-dahong-light: #FFECE6;
--color-dahong-medium: #FFB199;
--color-dahong-dark: #cc3100;
/* --- Primary Brand Levels --- */
--primary-lv-0: #E9EEED;
--primary-lv-1: #D2DCDB;
@@ -64,39 +33,30 @@
--primary-light: var(--primary-lv-0);
--edit-mode-color: var(--color-dahong);
--edit-mode-light: var(--color-dahong-light);
--edit-mode-focus: var(--color-dahong-medium);
--edit-mode-dark: var(--color-dahong-dark);
--edit-mode-light: rgba(255, 61, 0, 0.1);
--edit-mode-focus: rgba(255, 61, 0, 0.3);
--edit-mode-dark: #cc3100;
--text-main: #111827;
--text-muted: #6B7280;
--border-color: #E5E7EB;
--bg-color: #F9FAFB;
--bg-light: #FAFAFA;
--sidebar-bg: #ffffff;
--white: #FFFFFF;
--danger: var(--color-red);
--info: var(--color-blue);
--success: var(--color-green);
--warning: var(--color-orange);
--dash-primary: #6cc020;
--dash-light: #f2f9ec;
--dash-danger: #cf222e;
--header-height: 52px;
}
}
* {
box-sizing: border-box;
margin: 0;
padding: 0;
letter-spacing: -0.02em;
/* 모든 요소에 자간 규칙 일괄 적용 */
}
body {
font-family: 'Pretendard Variable', Pretendard, -apple-system, BlinkMacSystemFont, system-ui, Roboto, 'Helvetica Neue', 'Segoe UI', 'Apple SD Gothic Neo', 'Noto Sans KR', 'Malgun Gothic', sans-serif;
font-family: 'Pretendard Variable', Pretendard, sans-serif;
color: var(--text-main);
background-color: var(--bg-color);
line-height: 1.5;
@@ -111,12 +71,13 @@ body {
width: 100%;
}
/* --- Main Header & GNB/LNB --- */
/* --- Header --- */
.main-header {
background-color: var(--white);
border-bottom: 1px solid var(--border-color);
z-index: 100;
height: var(--header-height);
flex-shrink: 0;
}
.header-container {
@@ -127,239 +88,46 @@ body {
gap: 1.5rem;
}
.brand {
display: flex;
align-items: center;
gap: 0.75rem;
}
.brand { display: flex; align-items: center; gap: 0.75rem; }
.main-logo { height: 34px; width: auto; }
.brand h1 { font-size: 1.1rem; font-weight: 800; color: var(--text-main); white-space: nowrap; }
.brand h1 .sub-title { font-size: 0.85rem; color: var(--primary-color); font-weight: 600; margin-left: 0.25rem; }
.main-logo {
height: 34px;
width: auto;
}
.integrated-nav { flex: 1; height: 100%; display: flex; align-items: center; gap: 0.25rem; overflow: hidden; }
.nav-group { display: flex; align-items: center; height: 100%; position: relative; flex-shrink: 0; }
.gnb-trigger { font-size: 14px; font-weight: 700; color: var(--text-muted); padding: 0 0.75rem; cursor: pointer; height: 100%; display: flex; align-items: center; white-space: nowrap; transition: color 0.2s; }
.nav-group.active .gnb-trigger, .nav-group:hover .gnb-trigger { color: var(--text-main); }
.lnb-shelf { display: none; align-items: center; gap: 0.2rem; padding: 0 0.5rem; height: 60%; border-left: 1px solid var(--border-color); margin-left: 0.2rem; }
.brand h1 {
font-size: 1.1rem;
/* 전체적으로 살짝 축소 */
font-weight: 800;
color: var(--text-main);
white-space: nowrap;
}
/* 기본적으로 활성 탭의 서브메뉴 표시 */
.nav-group.active.is-showing-shelf .lnb-shelf { display: flex; }
.brand h1 .sub-title {
font-size: 0.85rem;
/* 영문 제목은 더 작게 */
color: var(--primary-color);
font-weight: 600;
margin-left: 0.25rem;
}
/* GNB 전체 영역에 마우스가 올라가면 활성 탭의 서브메뉴를 일단 숨김 (다른 메뉴 탐색 우선) */
.integrated-nav:hover .nav-group.active.is-showing-shelf .lnb-shelf { display: none; }
.integrated-nav {
flex: 1;
height: 100%;
display: flex;
align-items: center;
gap: 0.5rem;
}
/* 마우스가 올라간 메뉴의 서브메뉴만 표시 */
.nav-group:hover .lnb-shelf { display: flex !important; }
.nav-group {
display: flex;
align-items: center;
height: 100%;
}
.lnb-item { font-size: 13px; font-weight: 500; color: var(--text-muted); cursor: pointer; padding: 0.2rem 0.6rem; border-radius: 4px; white-space: nowrap; transition: all 0.2s; }
.lnb-item:hover { color: var(--primary-color); background-color: var(--primary-light); }
.lnb-item.active { color: var(--primary-color); background-color: var(--primary-light); font-weight: 700; }
.gnb-trigger {
font-size: 14px;
font-weight: 700;
color: var(--text-main);
padding: 0 1rem;
cursor: pointer;
height: 100%;
display: flex;
align-items: center;
white-space: nowrap;
}
.header-actions { display: flex; align-items: center; gap: 1rem; }
.role-switcher { display: flex; align-items: center; gap: 0.75rem; padding: 0 0.75rem; border-right: 1px solid var(--border-color); height: 24px; }
.role-label { font-size: 11px; font-weight: 700; color: var(--text-muted); }
.role-label.active { color: var(--primary-color); }
.switch { position: relative; display: inline-block; width: 34px; height: 18px; }
.switch input { opacity: 0; width: 0; height: 0; }
.slider { position: absolute; cursor: pointer; top: 0; left: 0; right: 0; bottom: 0; background-color: #ccc; transition: .4s; border-radius: 34px; }
.slider:before { position: absolute; content: ""; height: 12px; width: 12px; left: 3px; bottom: 3px; background-color: white; transition: .4s; border-radius: 50%; }
input:checked + .slider { background-color: var(--color-orange); }
input:checked + .slider:before { transform: translateX(16px); }
.lnb-shelf {
display: none;
align-items: center;
gap: 0.25rem;
padding: 0 0.75rem;
height: 60%;
border-left: 1px solid var(--border-color);
margin-left: 0.25rem;
animation: fadeIn 0.2s ease-out;
}
.nav-group:hover .lnb-shelf,
.nav-group.is-showing-shelf .lnb-shelf {
display: flex;
}
.lnb-item {
font-size: 13px;
font-weight: 500;
color: var(--text-muted);
cursor: pointer;
padding: 0.2rem 0.6rem;
border-radius: 4px;
white-space: nowrap;
}
.lnb-item:hover {
color: var(--primary-color);
background-color: var(--bg-color);
}
.lnb-item.active {
color: var(--primary-color);
background-color: var(--primary-light);
font-weight: 700;
}
@keyframes fadeIn {
from {
opacity: 0;
transform: translateX(-5px);
}
to {
opacity: 1;
transform: translateX(0);
}
}
/* --- Role Switcher Toggle --- */
.role-switcher {
display: flex;
align-items: center;
gap: 0.75rem;
margin-right: 0.5rem;
padding: 0 0.75rem;
border-right: 1px solid var(--border-color);
height: 24px;
}
.role-label {
font-size: 11px;
font-weight: 700;
color: var(--text-muted);
transition: color 0.2s;
}
.role-label.active {
color: var(--primary-color);
}
.role-label.admin.active {
color: var(--color-orange);
}
/* Toggle Switch Base */
.switch {
position: relative;
display: inline-block;
width: 34px;
height: 18px;
}
.switch input {
opacity: 0;
width: 0;
height: 0;
}
.slider {
position: absolute;
cursor: pointer;
top: 0; left: 0; right: 0; bottom: 0;
background-color: #ccc;
transition: .4s;
}
.slider:before {
position: absolute;
content: "";
height: 12px;
width: 12px;
left: 3px;
bottom: 3px;
background-color: white;
transition: .4s;
}
input:checked + .slider {
background-color: var(--color-orange);
}
input:focus + .slider {
box-shadow: 0 0 1px var(--color-orange);
}
input:checked + .slider:before {
transform: translateX(16px);
}
.slider.round {
border-radius: 34px;
}
.slider.round:before {
border-radius: 50%;
}
/* --- Global Actions & Buttons --- */
.header-actions {
display: flex;
gap: 0.3rem;
align-items: center;
}
.btn {
display: inline-flex;
align-items: center;
justify-content: center;
gap: 0.35rem;
padding: 0 0.8rem;
font-size: 12px;
font-weight: 600;
border-radius: 4px;
cursor: pointer;
height: 28px;
line-height: 1;
white-space: nowrap; /* 텍스트 줄바꿈 방지 */
flex-shrink: 0; /* 크기 찌그러짐 방지 */
}
.btn i,
.btn svg {
width: 12px !important;
height: 12px !important;
}
.btn-primary {
background-color: var(--primary-color);
color: var(--white);
border: 1px solid var(--primary-color);
}
.btn-outline {
background-color: transparent;
color: var(--text-muted);
border: 1px solid var(--border-color);
}
.btn-danger {
color: var(--danger) !important;
border-color: var(--danger) !important;
}
/* --- Layout Frame --- */
/* --- Layout Content --- */
.content-area {
flex: 1;
padding: 1.25rem 2rem 0; /* 상단 여백 1.25rem 추가 */
padding: 1.25rem 2rem 0;
overflow: hidden;
/* 전체 스크롤 차단 */
display: flex;
flex-direction: column;
}
@@ -370,93 +138,58 @@ input:checked + .slider:before {
display: flex;
flex-direction: column;
overflow: hidden;
/* 내부 스크롤을 유도하기 위해 설정 */
}
.view-content-wrapper {
flex: 1;
overflow-y: auto;
padding-bottom: 2rem;
}
/* --- View Toggle --- */
.view-toggle-container { margin-bottom: 1rem; display: flex; justify-content: flex-start; }
.view-toggle { display: inline-flex; background-color: var(--primary-lv-0); padding: 4px; border-radius: 8px; border: 1px solid var(--border-color); }
.toggle-btn { padding: 6px 16px; font-size: 13px; font-weight: 600; color: var(--text-muted); background: none; border: none; border-radius: 6px; cursor: pointer; }
.toggle-btn.active { background-color: var(--white); color: var(--primary-color); box-shadow: 0 2px 4px rgba(0,0,0,0.05); }
/* --- System Status List (Docker Style) --- */
.system-status-list { display: flex; flex-direction: column; gap: 0.5rem; }
.system-list-header { display: flex; align-items: center; padding: 0.75rem 1.25rem; background-color: var(--bg-light); border-bottom: 1px solid var(--border-color); font-size: 11px; font-weight: 700; color: var(--text-muted); text-transform: uppercase; }
.system-row { display: flex; align-items: center; padding: 1rem 1.25rem; background-color: var(--white); border: 1px solid var(--border-color); border-radius: 6px; transition: all 0.2s; }
.system-row:hover { border-color: var(--primary-lv-3); box-shadow: 0 4px 12px rgba(0,0,0,0.03); }
.col-status { width: 100px; display: flex; align-items: center; gap: 0.5rem; }
.col-info { flex: 1.5; }
.col-network { flex: 1; }
.col-remote { flex: 1; display: flex; align-items: center; gap: 0.5rem; }
.col-traffic { flex: 1.2; }
.col-actions { width: 120px; display: flex; justify-content: flex-end; }
.status-dot { width: 10px; height: 10px; border-radius: 50%; }
.status-dot.online { background-color: var(--success); box-shadow: 0 0 6px var(--success); }
.status-text { font-size: 11px; font-weight: 600; color: var(--success); }
.asset-primary { font-weight: 700; font-size: 14px; }
.asset-secondary { font-size: 12px; color: var(--text-muted); }
.ip-address { font-weight: 600; font-family: monospace; color: var(--primary-color); }
.traffic-mini-chart { display: flex; flex-direction: column; gap: 4px; }
.traffic-info { display: flex; justify-content: space-between; font-size: 11px; }
.progress-bg { height: 4px; background: var(--primary-lv-0); border-radius: 2px; overflow: hidden; }
.progress-fill { height: 100%; background: var(--primary-color); }
.icon-btn { width: 28px; height: 28px; display: flex; align-items: center; justify-content: center; border-radius: 4px; border: 1px solid var(--border-color); background: var(--white); color: var(--text-muted); cursor: pointer; }
.icon-btn:hover { background-color: var(--primary-light); border-color: var(--primary-color); color: var(--primary-color); }
/* --- Footer --- */
.main-footer {
height: 40px;
background-color: var(--white);
border-top: 1px solid var(--border-color);
display: flex;
align-items: center;
justify-content: flex-end;
padding: 0 1.5rem;
flex-shrink: 0;
}
.main-footer { height: 40px; background-color: var(--white); border-top: 1px solid var(--border-color); display: flex; align-items: center; justify-content: flex-end; padding: 0 1.5rem; flex-shrink: 0; }
.main-footer p { font-size: 0.75rem; color: var(--text-muted); }
.main-footer p {
font-family: 'Pretendard Variable', Pretendard, sans-serif;
font-size: 0.75rem;
font-weight: 300;
line-height: 1.25rem;
letter-spacing: -0.0175rem;
color: var(--text-muted);
user-select: none;
pointer-events: all;
-webkit-user-drag: none;
margin: 0;
padding: 0;
box-sizing: border-box;
}
.hidden {
display: none !important;
}
.text-nowrap {
white-space: nowrap;
}
/* --- Utility Styles --- */
.badge {
padding: 2px 6px;
border-radius: 4px;
font-size: 11px;
font-weight: 700;
white-space: nowrap;
}
.badge-primary {
background-color: var(--primary-color);
color: white;
}
.badge-muted {
background-color: #9CA3AF;
color: white;
}
.text-tag {
color: var(--text-muted);
font-size: 11px;
padding: 1px 5px;
border: 1px solid var(--border-color);
border-radius: 3px;
background-color: var(--bg-light);
}
.font-bold {
font-weight: 700;
}
/* --- Responsive Design (Tablet & Mobile) --- */
@media (max-width: 1200px) {
.header-container { gap: 0.75rem; padding: 0 1rem; }
.brand h1 { font-size: 1rem; }
.brand h1 .sub-title { font-size: 0.75rem; }
}
@media (max-width: 992px) {
.main-header { height: auto; padding: 0.5rem 0; }
.header-container { flex-direction: column; align-items: flex-start; gap: 0.5rem; }
.integrated-nav { width: 100%; justify-content: flex-start; border-top: 1px solid var(--border-color); padding-top: 0.5rem; }
.header-actions { width: 100%; justify-content: flex-end; padding-top: 0.5rem; }
.content-area { padding: 0 1rem; }
}
/* --- Utility --- */
.btn { display: inline-flex; align-items: center; justify-content: center; gap: 0.35rem; padding: 0 0.8rem; font-size: 12px; font-weight: 600; border-radius: 4px; cursor: pointer; height: 28px; }
.btn-primary { background-color: var(--primary-color); color: var(--white); border: none; }
.btn-outline { background-color: transparent; color: var(--text-muted); border: 1px solid var(--border-color); }
.badge { padding: 2px 6px; border-radius: 4px; font-size: 11px; font-weight: 700; }
.badge-primary { background-color: var(--primary-color); color: white; }
.badge-light { background: var(--bg-color); color: var(--text-muted); border: 1px solid var(--border-color); }
.hidden { display: none !important; }
@media (max-width: 768px) {
.brand h1 .sub-title { display: none; } /* 아주 좁은 화면에선 영문명 숨김 */
.header-actions .btn span { display: none; } /* 버튼 텍스트 숨기고 아이콘만 표시 */
.header-actions .btn { padding: 0 0.5rem; }
}
.brand h1 .sub-title { display: none; }
.header-actions .btn span { display: none; }
}

View File

@@ -57,3 +57,324 @@
width: 100% !important;
max-height: 280px;
}
/* --- Location View Styles --- */
.location-layout {
display: grid;
grid-template-columns: 1.2fr 1fr;
gap: 2rem;
height: calc(100vh - 180px);
}
.map-section, .asset-section {
display: flex;
flex-direction: column;
}
.section-title {
font-size: 1.125rem;
font-weight: 700;
margin-bottom: 1rem;
color: var(--text-main);
display: flex;
align-items: center;
}
.map-wrapper {
flex: 1;
background: #f8fafc;
box-shadow: inset 0 2px 4px 0 rgba(0, 0, 0, 0.05);
}
.location-box {
transition: all 0.2s cubic-bezier(0.4, 0, 0.2, 1);
box-shadow: 0 4px 6px -1px rgba(0, 0, 0, 0.1);
user-select: none;
}
.location-box:hover {
background: rgba(30, 81, 73, 0.2) !important;
transform: scale(1.02);
z-index: 10;
}
.location-box:active {
transform: scale(0.98);
}
.asset-section .table-container {
flex: 1;
overflow-y: auto;
}
.status-tag {
display: inline-block;
padding: 0.25rem 0.625rem;
border-radius: 9999px;
font-size: 0.75rem;
font-weight: 600;
background: #ecfdf5;
color: #059669;
border: 1px solid #d1fae5;
}
.view-toggle-btn:hover {
border-color: var(--primary-color) !important;
color: var(--primary-color) !important;
}
.view-toggle-btn.active:hover {
color: white !important;
}
/* --- View Toggle Header --- */
.view-header {
padding: 0.5rem 1.5rem;
background: var(--white);
border-bottom: 1px solid var(--border-color);
display: flex;
align-items: center;
justify-content: flex-start;
gap: 1rem;
}
.view-toggle-container {
display: flex;
background: #f1f5f9;
padding: 0.25rem;
border-radius: 8px;
gap: 0.25rem;
}
.mode-toggle-btn {
padding: 0.5rem 1rem;
border: none;
background: transparent;
border-radius: 6px;
font-size: 0.8125rem;
font-weight: 600;
color: var(--text-muted);
cursor: pointer;
transition: all 0.2s ease;
}
.mode-toggle-btn:hover {
color: var(--text-main);
}
.mode-toggle-btn.active {
background: var(--white);
color: var(--primary-color);
box-shadow: 0 1px 3px rgba(0,0,0,0.1);
}
/* --- Enhanced Location View --- */
.location-view-wrapper {
display: flex;
flex-direction: column;
height: calc(100vh - 120px);
}
.location-filter-bar {
padding: 1rem 1.5rem;
background: var(--white);
border-bottom: 1px solid var(--border-color);
display: flex;
align-items: center;
gap: 2rem;
}
.filter-group {
display: flex;
align-items: center;
gap: 0.75rem;
}
.filter-group label {
font-size: 0.8125rem;
font-weight: 700;
color: var(--text-main);
}
.filter-group select {
padding: 0.4rem 0.75rem;
border: 1px solid var(--border-color);
border-radius: 6px;
font-size: 0.8125rem;
color: var(--text-main);
background: var(--white);
min-width: 140px;
}
.map-pagination {
margin-left: auto;
display: flex;
align-items: center;
gap: 1rem;
}
.page-info {
font-size: 0.75rem;
color: var(--text-muted);
font-weight: 600;
}
.page-btns button {
padding: 0.3rem 0.75rem;
border: 1px solid var(--border-color);
background: var(--white);
border-radius: 4px;
font-size: 0.75rem;
cursor: pointer;
}
.page-btns button:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.location-main-content {
flex: 1;
display: grid;
grid-template-columns: 1.4fr 1fr;
gap: 1.5rem;
padding: 1.5rem;
overflow: hidden;
}
.map-container-section {
display: flex;
flex-direction: column;
overflow: auto;
}
.location-box-point {
display: flex;
align-items: center;
justify-content: center;
transition: all 0.2s ease;
}
.box-label-text {
font-size: 0.65rem;
font-weight: 800;
color: var(--primary-color);
pointer-events: none;
text-shadow: 0 0 2px white;
}
.asset-list-section {
background: var(--white);
border-radius: 12px;
border: 1px solid var(--border-color);
display: flex;
flex-direction: column;
overflow: hidden;
}
.asset-list-section .section-header {
padding: 1rem 1.25rem;
border-bottom: 1px solid var(--border-color);
background: #f8fafc;
}
.asset-list-section h4 {
margin: 0;
font-size: 0.9375rem;
color: var(--text-main);
}
.mini-table-wrapper {
flex: 1;
overflow-y: auto;
}
.compact-table {
width: 100%;
border-collapse: collapse;
}
.compact-table th {
position: sticky;
top: 0;
background: var(--white);
padding: 0.75rem 1rem;
text-align: left;
font-size: 0.75rem;
font-weight: 700;
color: var(--text-muted);
border-bottom: 1px solid var(--border-color);
}
.compact-table td {
padding: 0.75rem 1rem;
font-size: 0.8125rem;
border-bottom: 1px solid #f1f5f9;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
max-width: 150px;
}
.compact-table tr.clickable-row:hover {
background: #f1f5f9;
cursor: pointer;
}
/* --- Asset Detail Sidebar (LocationView) --- */
.asset-detail-sidebar {
padding-top: 1rem;
background: var(--white);
height: 100%;
overflow-y: auto;
}
.detail-section {
margin-bottom: 20px;
padding: 0 1.25rem;
}
.detail-section-title {
font-size: 13px;
font-weight: 700;
color: var(--primary-color);
border-bottom: 1px solid var(--border-color);
padding-bottom: 6px;
margin-bottom: 12px;
}
.detail-grid {
display: grid;
grid-template-columns: repeat(2, minmax(80px, auto) 1fr);
gap: 8px 16px;
}
.detail-label {
font-size: 12px;
color: var(--text-muted);
font-weight: 600;
display: flex;
align-items: center;
}
.detail-value {
font-size: 14px;
color: var(--text-main);
font-weight: 500;
word-break: break-all;
display: flex;
align-items: center;
}
.detail-header-actions {
display: flex;
align-items: center;
gap: 8px;
width: 100%;
}
.detail-header-title {
flex: 1;
font-size: 0.95rem;
font-weight: 700;
}

View File

@@ -1,8 +1,9 @@
import { ASSET_SCHEMA, UI_TEXT } from '../../core/schema';
import { dynamicSort, renderPageHeader } from '../../core/utils';
import { dynamicSort, renderPageHeader, calculateAssetAge, formatInline } from '../../core/utils';
import { setupTableSorting, SortState } from '../../core/tableHandler';
import { renderFilterBar, applyCommonFilters } from '../../core/filterHandler';
import { createIcons, RefreshCcw, Plus, Edit2, Trash2, Users, Cloud, CreditCard, DollarSign, Paperclip } from 'lucide';
import { state } from '../../core/state';
import { IMAGE_LOCATIONS } from '../../components/Modal/SharedData';
export interface ColumnDef {
header: string;
@@ -28,97 +29,553 @@ export interface ListViewConfig {
columns: ColumnDef[];
onRowClick?: (asset: any) => void;
emptyMessage?: string;
persistentSortState?: SortState; // Allow passing external sort state (like DomainListView)
persistentSortState?: SortState;
}
export function createListView(container: HTMLElement, config: ListViewConfig) {
// 1. 컨테이너 초기화 및 헤더 렌더링
container.innerHTML = '';
renderPageHeader(container, config.title);
const fullList = config.dataSource();
let sortState: SortState = config.persistentSortState || { key: '', direction: 'asc' };
// Initialize currentFilters with all possible keys to avoid undefined issues
let currentFilters: any = { keyword: '', corp: '', dept: '', loc: '', field: '', type: '' };
// 서버 탭이 아닐 경우 '자산 현황(대시보드)' 뷰 진입 방지 및 강제 'asset' 모드
const isServer = config.title === '서버';
if (!isServer) {
(state as any).currentViewMode = 'asset';
} else if (!(state as any).currentViewMode) {
(state as any).currentViewMode = 'system';
}
// 2. 뷰 전환 토글 버튼 생성 (명칭 변경)
const toggleWrapper = document.createElement('div');
toggleWrapper.className = 'view-toggle-container';
toggleWrapper.innerHTML = `
<div style="display: flex; justify-content: space-between; align-items: center; width: 100%;">
<div class="view-toggle" style="display: ${isServer ? 'flex' : 'none'}; gap: 0;">
<button class="toggle-btn ${(state as any).currentViewMode === 'system' ? 'active' : ''}" data-mode="system">자산 현황</button>
<button class="toggle-btn ${(state as any).currentViewMode === 'asset' ? 'active' : ''}" data-mode="asset">자산 목록</button>
</div>
<button id="btn-add-asset" style="padding: 6px 14px; font-size: 12px; font-weight: 700; background: #1E5149; color: white; border: none; border-radius: 4px; cursor: pointer; display: flex; align-items: center; gap: 4px;">
<span style="font-size: 16px; line-height: 1;">+</span> 자산 추가
</button>
</div>
`;
container.appendChild(toggleWrapper);
// 3. 필터 바 생성 (자산 목록에서만 사용)
const filterBar = document.createElement('div');
filterBar.className = 'search-bar';
container.appendChild(filterBar);
// 4. 컨텐츠 영역 생성
const contentWrapper = document.createElement('div');
contentWrapper.className = 'view-content-wrapper';
container.appendChild(contentWrapper);
// --- 내부 상태 ---
let selectedLocation: string | null = '기술개발센터';
let selectedDetailLocation: string | null = null;
let dynamicMapConfig: Record<string, any[]> = {};
// 맵 설정 미리 로드
const fetchMapConfig = async () => {
try {
const res = await fetch(`http://${location.hostname}:3000/api/maps`);
dynamicMapConfig = await res.json();
} catch (err) { console.error('Failed to fetch map config:', err); }
};
fetchMapConfig();
// [자산 현황] 대시보드 렌더러
const renderSystemStatus = () => {
const isPcView = config.title === 'PC';
const locationCounts: Record<string, number> = {};
const pcTypeCounts = { public: 0, server: 0, personal: 0 };
// 동적 통계 수집 객체 (Hardcoding 제거)
const extStats = {
total: 0,
locCounts: {} as Record<string, number>,
typeCounts: {} as Record<string, number>,
typeLocMap: {} as Record<string, Record<string, number>>, // 유형별 위치 분포
locWarning: 0,
typeWarning: 0
};
const intStats = {
total: 0,
locCounts: {} as Record<string, number>,
typeCounts: {} as Record<string, number>,
typeLocMap: {} as Record<string, Record<string, number>>
};
// 중앙화된 경고 감지 로직
const checkAnomaly = (serviceType: string, loc: string, type: string) => {
if (serviceType !== '외부') return { isWarning: false, isLocWarning: false, isTypeWarning: false, reason: '' };
const isLocWarning = loc !== 'IDC' && loc !== '미지정' && loc !== '';
const isTypeWarning = type.toLowerCase().replace(/\s/g, '').includes('서버pc');
const isWarning = isLocWarning || isTypeWarning;
let reason = '';
if (isLocWarning && isTypeWarning) reason = '위치/형식 부적절';
else if (isLocWarning) reason = '위치 부적절';
else if (isTypeWarning) reason = '형식 부적절';
return { isWarning, isLocWarning, isTypeWarning, reason };
};
fullList.forEach(asset => {
const loc = asset[ASSET_SCHEMA.LOCATION.key] || '미지정';
const serviceTypeKey = (ASSET_SCHEMA as any).SERVICE_TYPE?.key || 'service_type';
const serviceType = asset[serviceTypeKey] || '외부';
const type = asset[ASSET_SCHEMA.ASSET_TYPE.key] || '';
locationCounts[loc] = (locationCounts[loc] || 0) + 1;
if (isPcView) {
if (type.includes('공용')) pcTypeCounts.public++;
else if (type.includes('서버')) pcTypeCounts.server++;
else pcTypeCounts.personal++;
}
const targetStat = serviceType === '내부' ? intStats : extStats;
targetStat.total++;
if (loc) targetStat.locCounts[loc] = (targetStat.locCounts[loc] || 0) + 1;
if (type) {
targetStat.typeCounts[type] = (targetStat.typeCounts[type] || 0) + 1;
// 유형별 위치 분포 수집
if (!targetStat.typeLocMap[type]) targetStat.typeLocMap[type] = {};
targetStat.typeLocMap[type][loc] = (targetStat.typeLocMap[type][loc] || 0) + 1;
}
if (serviceType === '외부') {
const anomaly = checkAnomaly(serviceType, loc, type);
if (anomaly.isLocWarning) extStats.locWarning++;
if (anomaly.isTypeWarning) extStats.typeWarning++;
}
});
// 템플릿 제너레이터 함수 (HTML 중복 제거)
const generateDetailStatHTML = (title: string, stats: any) => `
<div style="display: flex; align-items: center; justify-content: space-between; margin-bottom: 0.5rem; gap: 0.5rem;">
<span style="font-size: 14px; font-weight: 800; color: var(--text-main); white-space: nowrap;">${title}</span>
<div style="display: flex; gap: 4px; flex-wrap: wrap; justify-content: flex-end;">
${stats.locWarning ? `<span style="background: #FFF7ED; color: #C2410C; font-size: 10px; font-weight: 800; padding: 2px 6px; border-radius: 4px; border: 1px solid #FFEDD5; white-space: nowrap;">위치부적절: ${stats.locWarning}</span>` : ''}
${stats.typeWarning ? `<span style="background: #FFF1F2; color: #E11D48; font-size: 10px; font-weight: 800; padding: 2px 6px; border-radius: 4px; border: 1px solid #FDA4AF; white-space: nowrap;">형식부적절: ${stats.typeWarning}</span>` : ''}
</div>
</div>
<div style="display: flex; flex-direction: column; gap: 0.3rem; font-size: 13px; color: var(--text-muted);">
<div style="display: flex; gap: 0.75rem; flex-wrap: wrap;">
${Object.entries(stats.locCounts as Record<string, number>).sort((a, b) => b[1] - a[1]).slice(0, 4).map(([l, c]) => `<span>${l}: <strong style="color:var(--text-main); font-size: 14px;">${c}</strong></span>`).join('')}
</div>
<div style="display: flex; gap: 0.6rem; flex-wrap: wrap; opacity: 0.9; border-top: 1px dashed var(--border-color); padding-top: 4px; margin-top: 2px;">
${Object.entries(stats.typeCounts as Record<string, number>).sort((a, b) => b[1] - a[1]).slice(0, 6).map(([t, c]) => {
const isTypeWarning = title.includes('외부') && t.toLowerCase().replace(/\s/g, '').includes('서버pc');
// 위치별 상세 정보 생성 (툴팁용)
const locDist = stats.typeLocMap[t] || {};
const locHint = Object.entries(locDist)
.sort((a: any, b: any) => b[1] - a[1])
.map(([l, count]) => `${l}: ${count}`)
.join('\n');
return `<span title="${locHint}" style="${isTypeWarning ? 'color:#E11D48; font-weight:700;' : ''}; font-size: 13px; cursor: help;">${t}: <strong style="color:var(--text-main); font-size: 14px;">${c}</strong></span>`;
}).join('')}
</div>
</div>
`;
contentWrapper.innerHTML = `
<div class="system-dashboard" style="height: calc(100vh - 240px); overflow: hidden; padding: 0.5rem 0; font-family: 'Pretendard', sans-serif; letter-spacing: -0.02em; display: flex; flex-direction: column;">
<!-- [자산 통계 그룹] -->
<div style="border-bottom: 1px solid var(--border-color); padding-bottom: 1.25rem; margin-bottom: 1rem; flex-shrink: 0; display: grid; grid-template-columns: 1fr 1.5fr 1.5fr; gap: 2rem;">
<div class="stat-group-item" style="min-width: 0;">
<div style="font-size: 11px; font-weight: 600; color: var(--text-muted); margin-bottom: 0.25rem;">총 보유 자산</div>
<div style="font-size: 28px; font-weight: 800; color: var(--text-main); line-height: 1.1;">${fullList.length}<span style="font-size: 13px; font-weight: 600; margin-left: 4px; color: var(--text-muted);">대</span></div>
<div style="display: flex; gap: 0.75rem; font-size: 14px; color: var(--text-muted); margin-top: 0.5rem;">
<span>외부: <strong style="color:#35635C; font-size: 18px;">${extStats.total}</strong></span>
<span>내부: <strong style="color:#94A3B8; font-size: 18px;">${intStats.total}</strong></span>
</div>
</div>
<div class="stat-group-item" style="border-left: 1px solid var(--border-color); padding-left: 1.5rem; min-width: 0;">
${isPcView ? `
<div style="font-size: 11px; font-weight: 600; color: var(--text-muted); margin-bottom: 0.25rem;">PC 유형별 현황</div>
<div style="display: flex; gap: 1rem; font-size: 14px; color: var(--text-muted); margin-top: 0.5rem;">
<span>공용: <strong style="color:var(--text-main); font-size: 18px;">${pcTypeCounts.public}</strong></span>
<span>서버: <strong style="color:var(--text-main); font-size: 18px;">${pcTypeCounts.server}</strong></span>
<span>개인: <strong style="color:var(--text-main); font-size: 18px;">${pcTypeCounts.personal}</strong></span>
</div>
` : generateDetailStatHTML('외부 (운영) 상세', extStats)}
</div>
<div class="stat-group-item" style="border-left: 1px solid var(--border-color); padding-left: 1.5rem; min-width: 0;">
${isPcView ? '' : generateDetailStatHTML('내부 (테스트) 상세', intStats as any)}
</div>
</div>
<div style="display: flex; flex: 1; min-height: 0; border-top: 1px solid var(--border-color);">
<!-- 좌측: 자산 현황 목록 (Border-based Separation) -->
<div class="list-section" style="flex: 1.1; display: flex; flex-direction: column; min-height: 0; padding: 1rem 1.5rem 0 0; border-right: 1px solid var(--border-color);">
<div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 1rem; flex-shrink: 0;">
<h4 id="list-section-title" style="font-size: 14px; font-weight: 700; color: var(--text-main); margin:0;">자산 현황 목록</h4>
<div style="display: flex; align-items: center; gap: 8px;">
<span style="font-size: 11px; font-weight: 600; color: var(--text-muted);">위치:</span>
<select id="select-loc" style="padding: 2px 8px; font-size: 11px; border-radius: 4px; border: 1px solid var(--border-color); outline: none; background: white; cursor:pointer; font-family: 'Pretendard';">
<option value="">전체</option>
${Array.from(new Set(fullList.map(a => a[ASSET_SCHEMA.LOCATION.key] || '미지정'))).sort().map(l => `<option value="${l}" ${l === selectedLocation ? 'selected' : ''}>${l}</option>`).join('')}
</select>
<span style="font-size: 11px; font-weight: 600; color: var(--text-muted);">상세:</span>
<select id="select-detail-loc" style="padding: 2px 8px; font-size: 11px; border-radius: 4px; border: 1px solid var(--border-color); outline: none; background: white; cursor:pointer; font-family: 'Pretendard'; max-width: 120px;"></select>
</div>
</div>
<div style="flex: 1; overflow-y: auto;">
<table style="width: 100%; border-collapse: collapse; table-layout: fixed;">
<thead style="position: sticky; top: 0; background: #fff; z-index: 10;">
<tr style="text-align: left; font-size: 11px; color: var(--text-muted);">
<th style="padding: 10px 0; font-weight: 700; border-bottom: 2px solid var(--border-color); width: 80px; text-align:center; background: #fff;">분류</th>
<th style="padding: 10px 0; font-weight: 700; border-bottom: 2px solid var(--border-color); width: 130px; background: #fff;">용도/자산명</th>
<th style="padding: 10px 0; font-weight: 700; border-bottom: 2px solid var(--border-color); text-align:center; width: 90px; background: #fff;">관리자(정)</th>
<th style="padding: 10px 0; font-weight: 700; border-bottom: 2px solid var(--border-color); text-align:center; width: 90px; background: #fff;">관리자(부)</th>
<th style="padding: 10px 0; text-align: center; font-weight: 700; border-bottom: 2px solid var(--border-color); width: 100px; background: #fff;">상세위치</th>
</tr>
</thead>
<tbody id="system-status-tbody" style="font-size: 12px;"></tbody>
</table>
</div>
</div>
<!-- 우측: 상세 정보 패널 (Box-less, Line-based) -->
<div id="system-detail-panel" style="flex: 0.9; display: flex; flex-direction: column; min-height: 0; padding: 1rem 0 0 1.5rem; overflow: hidden;">
<div id="detail-empty-state" style="height: 100%; display: flex; flex-direction: column; justify-content: center; align-items: center; color: var(--text-muted); text-align: center;">
<p style="font-size: 1.125rem; font-weight: 500; color: #94A3B8;">목록에서 자산을 선택하면<br>상세 정보와 배치도가 표시됩니다.</p>
</div>
<div id="detail-content" style="display: none; height: 100%; flex-direction: column;">
<!-- 상단 요약 정보 (Wrapping 방지 최적화) -->
<div style="display: flex; justify-content: space-between; align-items: flex-end; margin-bottom: 1.5rem; padding-bottom: 1rem; border-bottom: 1px solid var(--border-color); flex-shrink: 0; gap: 2rem;">
<div style="display: flex; gap: 2.5rem; align-items: flex-end; min-width: 0; flex: 1;">
<div style="flex-shrink: 0;">
<label style="display: block; font-size: 10px; font-weight: 700; color: var(--text-muted); text-transform: uppercase; margin-bottom: 4px;">자산번호</label>
<div id="detail-asset-code" style="font-size: 14px; font-weight: 800; color: var(--primary-color); white-space: nowrap;"></div>
</div>
<div style="flex-shrink: 0;">
<label style="display: block; font-size: 10px; font-weight: 700; color: var(--text-muted); text-transform: uppercase; margin-bottom: 4px;">유형</label>
<div id="detail-asset-type" style="font-size: 14px; font-weight: 600; color: var(--text-main); white-space: nowrap;"></div>
</div>
<div style="flex: 1; min-width: 0;">
<label style="display: block; font-size: 10px; font-weight: 700; color: var(--text-muted); text-transform: uppercase; margin-bottom: 4px;">메모 요약</label>
<div id="detail-memo" style="font-size: 14px; color: var(--text-main); font-weight: 500; white-space: nowrap; overflow: hidden; text-overflow: ellipsis;"></div>
</div>
</div>
<button id="btn-view-full-detail" style="flex-shrink: 0; padding: 6px 16px; font-size: 12px; font-weight: 700; background: var(--primary-color); color: white; border: none; border-radius: 4px; cursor: pointer; transition: opacity 0.2s;">상세 보기</button>
</div>
<!-- 메인 배치도 영역 -->
<div style="flex: 1; display: flex; flex-direction: column; min-height: 0; overflow: hidden;">
<div style="margin-bottom: 0.75rem; flex-shrink: 0; display: flex; justify-content: space-between; align-items: center;">
<label style="font-size: 11px; font-weight: 700; color: var(--text-main); text-transform: uppercase;">설치 위치 배치도</label>
</div>
<div id="detail-photo-wrapper" style="width: 100%; flex: 1; overflow: hidden; display: flex; align-items: center; justify-content: center; position: relative; border: 1px solid var(--border-color); background: #f0f0f0;">
<div class="layout-map-container readonly" style="position: relative; display: flex; align-items: center; justify-content: center; width: 100%; height: 100%;">
<img id="detail-photo" src="" style="display: block; max-width: 100%; max-height: 100%; width: auto; height: auto; object-fit: contain; pointer-events: none;" />
<div id="detail-marker" class="layout-marker pulse-marker" style="display: none; position: absolute; z-index: 20;"></div>
<div id="detail-overlay-layer" class="digital-overlay-layer" style="position: absolute; top: 0; left: 0; width: 100%; height: 100%; pointer-events: none; display: flex; align-items: center; justify-content: center;"></div>
</div>
<div id="detail-no-photo" style="display: none; height: 100%; flex-direction: column; align-items: center; justify-content: center; gap: 1rem;">
<span style="color: #94A3B8; font-size: 13px; font-weight: 500;">등록된 배치도가 없습니다.</span>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
`;
// 상세 정보 패널 업데이트 함수
const updateDetailPanel = (asset: any) => {
const emptyState = document.getElementById('detail-empty-state');
const content = document.getElementById('detail-content');
if (!emptyState || !content) return;
emptyState.style.display = 'none';
content.style.display = 'flex';
// 텍스트 정보 업데이트
const codeEl = document.getElementById('detail-asset-code');
const typeEl = document.getElementById('detail-asset-type');
const memoEl = document.getElementById('detail-memo');
const viewBtn = document.getElementById('btn-view-full-detail') as HTMLButtonElement;
if (codeEl) codeEl.textContent = asset.asset_code || '미지정';
if (typeEl) typeEl.textContent = asset.asset_type || '-';
if (memoEl) memoEl.textContent = asset.memo || '-';
if (viewBtn) {
viewBtn.onclick = () => config.onRowClick && config.onRowClick(asset);
}
// 위치 및 사진 정보 업데이트
const photo = document.getElementById('detail-photo') as HTMLImageElement;
const marker = document.getElementById('detail-marker');
const overlayLayer = document.getElementById('detail-overlay-layer');
const noPhoto = document.getElementById('detail-no-photo');
const photoWrapper = document.getElementById('detail-photo-wrapper');
const bldg = asset.location || '';
const detail = asset.location_detail || '';
// 숫자 0도 유효한 좌표이므로 정확한 체크 필요
const x = asset.loc_x;
const y = asset.loc_y;
const hasCoords = (x !== null && x !== undefined && x !== '' && x !== 'null') &&
(y !== null && y !== undefined && y !== '' && y !== 'null');
const savedImg = asset.location_photo || asset.loc_img;
const locImgs = IMAGE_LOCATIONS[bldg.trim()]?.[detail.trim()] || null;
const imgPath = (savedImg && locImgs?.includes(savedImg)) ? savedImg : (locImgs ? locImgs[0] : null);
// 좌표가 없으면 사진이 있어도 '정보 없음' 상태로 유도 (사용자 요청)
if (imgPath && hasCoords) {
photo.src = imgPath;
photo.style.display = 'block';
if (noPhoto) noPhoto.style.display = 'none';
photo.onload = () => {
const updateMarkerPos = () => {
const imgW = photo.clientWidth;
const imgH = photo.clientHeight;
if (marker) {
marker.style.left = `calc(50% - ${imgW/2}px + ${ (parseFloat(x as string) * imgW) / 100 }px)`;
marker.style.top = `calc(50% - ${imgH/2}px + ${ (parseFloat(y as string) * imgH) / 100 }px)`;
marker.style.display = 'block';
}
if (overlayLayer) {
overlayLayer.style.width = `${imgW}px`;
overlayLayer.style.height = `${imgH}px`;
overlayLayer.style.left = `calc(50% - ${imgW/2}px)`;
overlayLayer.style.top = `calc(50% - ${imgH/2}px)`;
const boxes = dynamicMapConfig[imgPath] || [];
if (boxes.length > 0) {
overlayLayer.innerHTML = `
<svg viewBox="0 0 100 100" preserveAspectRatio="none" style="width:100%; height:100%;">
<g class="seat-group">
${boxes.map((b, i) => {
const isSelected = b.x === x && b.y === y;
const fill = isSelected ? 'rgba(255, 61, 0, 0.4)' : 'rgba(30, 81, 73, 0.02)';
const stroke = isSelected ? '#FF3D00' : 'rgba(30, 81, 73, 0.15)';
const strokeWidth = isSelected ? '0.8' : '0.2';
if (isSelected && marker) {
marker.style.left = `calc(50% - ${imgW/2}px + ${ (parseFloat(b.x) + parseFloat(b.w)/2) * imgW / 100 }px)`;
marker.style.top = `calc(50% - ${imgH/2}px + ${ (parseFloat(b.y) + parseFloat(b.h)/2) * imgH / 100 }px)`;
}
return `<rect class="map-seat-obj" x="${b.x}" y="${b.y}" width="${b.w}" height="${b.h}" rx="0.5" style="fill:${fill}; stroke:${stroke}; stroke-width:${strokeWidth};" />`;
}).join('')}
</g>
</svg>
`;
} else {
overlayLayer.innerHTML = '';
}
}
};
updateMarkerPos();
window.addEventListener('resize', updateMarkerPos);
};
} else {
photo.style.display = 'none';
if (marker) marker.style.display = 'none';
if (overlayLayer) overlayLayer.innerHTML = '';
if (noPhoto) {
noPhoto.style.display = 'flex';
const msg = noPhoto.querySelector('span');
if (msg) msg.textContent = !hasCoords ? '등록된 위치 좌표 정보가 없습니다.' : '등록된 배치도가 없습니다.';
}
}
};
const updateTableOnly = () => {
let filtered = selectedLocation
? fullList.filter(a => (a[ASSET_SCHEMA.LOCATION.key] || '미지정') === selectedLocation)
: fullList;
const currentDetailLocs = Array.from(new Set(filtered.map(a => a[ASSET_SCHEMA.LOC_DETAIL.key] || '미지정'))).sort();
if (selectedDetailLocation) filtered = filtered.filter(a => (a[ASSET_SCHEMA.LOC_DETAIL.key] || '미지정') === selectedDetailLocation);
const finalDisplayList = (!selectedLocation && !selectedDetailLocation) ? filtered.slice(0, 10) : filtered;
const titleEl = document.getElementById('list-section-title');
if (titleEl) titleEl.textContent = selectedLocation ? `${selectedLocation} 자산 현황 (${finalDisplayList.length}대)` : '위치별 자산등록현황 (최근 등록)';
const selectEl = document.getElementById('select-detail-loc') as HTMLSelectElement;
if (selectEl && !selectedDetailLocation) {
selectEl.innerHTML = `<option value="">전체보기</option>` + currentDetailLocs.map(dl => `<option value="${dl}">${dl}</option>`).join('');
}
const tbody = document.getElementById('system-status-tbody');
if (tbody) {
tbody.innerHTML = finalDisplayList.length === 0
? `<tr><td colspan="4" style="padding: 3rem; text-align: center; color: var(--text-muted);">조회된 자산이 없습니다.</td></tr>`
: finalDisplayList.map(asset => {
const purpose = asset[ASSET_SCHEMA.ASSET_PURPOSE.key] || '';
const serviceTypeKey = (ASSET_SCHEMA as any).SERVICE_TYPE?.key || 'service_type';
const serviceType = asset[serviceTypeKey] || '외부';
const type = asset[ASSET_SCHEMA.ASSET_TYPE.key] || '';
const loc = asset[ASSET_SCHEMA.LOCATION.key] || '';
const labelColor = serviceType === '내부' ? '#94A3B8' : '#35635C';
const managerMain = asset[ASSET_SCHEMA.MANAGER_MAIN.key] || '-';
const managerSub = asset[ASSET_SCHEMA.MANAGER_SUB.key] || '-';
// [경고 로직] 외부 운영인데 서버PC이거나 IDC가 아닌 경우
const isLocWarning = serviceType === '외부SW' && loc !== 'IDC';
const isTypeWarning = serviceType === '외부SW' && type.toLowerCase().replace(/\s/g, '').includes('서버pc');
const isWarning = isLocWarning || isTypeWarning;
const warningStyle = isWarning ? 'background-color: #FFF1F2; border-left: 3px solid #E11D48;' : '';
let warningReason = '';
if (isLocWarning && isTypeWarning) warningReason = '위치/형식 부적절';
else if (isLocWarning) warningReason = '위치 부적절';
else if (isTypeWarning) warningReason = '형식 부적절';
return `
<tr style="border-bottom: 1px solid var(--border-color); cursor: pointer; ${warningStyle}" class="mini-row" data-id="${asset.id}">
<td style="padding: 10px 0; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; text-align:center;">
<div style="display:flex; flex-direction:column; align-items:center; gap:2px;">
<span style="color: ${isWarning ? '#E11D48' : labelColor}; font-weight: 800; font-size: 12px;">${serviceType}</span>
${isWarning ? `<span style="color: #E11D48; font-size: 9px; font-weight: 700; white-space: nowrap;">${warningReason}</span>` : ''}
</div>
</td>
<td style="padding: 10px 0; font-weight: 600; color: ${isWarning ? '#991B1B' : 'var(--text-main)'}; font-size: 13px; white-space: nowrap; overflow: hidden; text-overflow: ellipsis;" title="${purpose}">${purpose || '-'}</td>
<td style="padding: 10px 0; text-align: center; color: var(--text-main); font-size: 12px; white-space: nowrap; overflow: hidden; text-overflow: ellipsis;">${managerMain}</td>
<td style="padding: 10px 0; text-align: center; color: var(--text-main); font-size: 12px; white-space: nowrap; overflow: hidden; text-overflow: ellipsis;">${managerSub}</td>
<td style="padding: 10px 0; text-align: center; color: var(--text-main); font-size: 12px; white-space: nowrap; overflow: hidden; text-overflow: ellipsis;">${asset[ASSET_SCHEMA.LOC_DETAIL.key] || '-'}</td>
</tr>`;
}).join('');
tbody.querySelectorAll('.mini-row').forEach(row => {
row.addEventListener('click', () => {
tbody.querySelectorAll('.mini-row').forEach(r => {
const rIsWarning = (r as HTMLElement).style.borderLeftColor === 'rgb(225, 29, 72)'; // E11D48
(r as HTMLElement).style.backgroundColor = rIsWarning ? '#FFF1F2' : 'transparent';
});
(row as HTMLElement).style.backgroundColor = '#EBF2F1'; // 선택 하이라이트
const id = (row as HTMLElement).getAttribute('data-id');
const asset = fullList.find(a => a.id === id);
if (asset) updateDetailPanel(asset);
});
row.addEventListener('mouseenter', () => {
if ((row as HTMLElement).style.backgroundColor !== 'rgb(235, 242, 241)') {
(row as HTMLElement).style.backgroundColor = '#F8FAFA';
}
});
row.addEventListener('mouseleave', () => {
const isWarning = (row as HTMLElement).style.borderLeftColor === 'rgb(225, 29, 72)';
if ((row as HTMLElement).style.backgroundColor !== 'rgb(235, 242, 241)') {
(row as HTMLElement).style.backgroundColor = isWarning ? '#FFF1F2' : 'transparent';
}
});
});
}
};
(window as any).dispatchLocFilter = (loc: string) => {
if (isPcView) return;
selectedLocation = loc;
selectedDetailLocation = null;
renderSystemStatus();
};
setTimeout(() => {
const selectLoc = document.getElementById('select-loc') as HTMLSelectElement;
const selectDetailLoc = document.getElementById('select-detail-loc') as HTMLSelectElement;
selectLoc?.addEventListener('change', (e) => {
selectedLocation = (e.target as HTMLSelectElement).value || null;
selectedDetailLocation = null;
updateTableOnly();
});
selectDetailLoc?.addEventListener('change', (e) => {
selectedDetailLocation = (e.target as HTMLSelectElement).value || null;
updateTableOnly();
});
updateTableOnly();
}, 50);
};
// [자산 목록] 테이블 렌더러
const tableWrapper = document.createElement('div');
tableWrapper.className = 'table-container';
const table = document.createElement('table');
// 1. 헤더 생성
const thead = document.createElement('thead');
const trHead = document.createElement('tr');
config.columns.forEach(col => {
const th = document.createElement('th');
th.innerHTML = col.header;
if (col.sortKey) th.setAttribute('data-sort', col.sortKey);
if (col.width) th.style.width = col.width;
if (col.align) th.style.textAlign = col.align;
if (col.className) th.className = col.className;
trHead.appendChild(th);
});
thead.appendChild(trHead);
table.appendChild(thead);
// 2. 본문 생성
const tbody = document.createElement('tbody');
tbody.id = 'dynamic-tbody';
table.appendChild(thead);
table.appendChild(tbody);
tableWrapper.appendChild(table);
container.appendChild(tableWrapper);
// 3. 테이블 업데이트 로직
const updateTable = () => {
if ((state as any).currentViewMode !== 'asset') return;
let filtered = applyCommonFilters(fullList, currentFilters, config.searchKeys as any[]);
if (sortState.key) filtered = dynamicSort(filtered, sortState.key, sortState.direction);
if (sortState.key) {
filtered = dynamicSort(filtered, sortState.key, sortState.direction);
}
thead.innerHTML = `<tr>${config.columns.map(col => `
<th ${col.sortKey ? `data-sort="${col.sortKey}"` : ''}
style="${col.width ? `width:${col.width};` : ''}${col.align ? `text-align:${col.align};` : ''}"
class="${col.className || ''}">${col.header}</th>`).join('')}</tr>`;
tbody.innerHTML = '';
if (filtered.length === 0) {
const emptyMsg = config.emptyMessage || UI_TEXT.MESSAGES.NO_DATA;
tbody.innerHTML = `<tr><td colspan="${config.columns.length}" class="text-center" style="padding: 3rem; color: var(--text-muted);">${emptyMsg}</td></tr>`;
return;
}
tbody.innerHTML = filtered.length === 0
? `<tr><td colspan="${config.columns.length}" class="text-center" style="padding: 3rem; color: var(--text-muted);">${config.emptyMessage || UI_TEXT.MESSAGES.NO_DATA}</td></tr>`
: filtered.map(asset => `
<tr style="cursor:pointer;" class="asset-row" data-id="${asset.id}">
${config.columns.map(col => `<td style="${col.align ? `text-align:${col.align};` : ''}" class="${col.className || ''}">${col.render(asset)}</td>`).join('')}
</tr>`).join('');
filtered.forEach((asset) => {
const tr = document.createElement('tr');
if (config.onRowClick) {
tr.style.cursor = 'pointer';
tr.addEventListener('click', () => config.onRowClick!(asset));
}
config.columns.forEach(col => {
const td = document.createElement('td');
if (col.align) td.style.textAlign = col.align;
if (col.className) td.className = col.className;
td.innerHTML = col.render(asset);
tr.appendChild(td);
});
tbody.appendChild(tr);
tbody.querySelectorAll('.asset-row').forEach((tr, idx) => {
tr.addEventListener('click', () => config.onRowClick && config.onRowClick(filtered[idx]));
});
setupTableSorting(table, sortState, (key, dir) => {
sortState = { key, direction: dir };
// If external state was provided, sync it back
if (config.persistentSortState) {
config.persistentSortState.key = key;
config.persistentSortState.direction = dir;
config.persistentSortState.key = key;
config.persistentSortState.direction = dir;
}
updateTable();
});
// 모든 가능한 아이콘 로드 (안전하게)
createIcons({ icons: { RefreshCcw, Plus, Edit2, Trash2, Users, Cloud, CreditCard, DollarSign, Paperclip } });
// createIcons call removed as icons are no longer needed in dashboard
};
// 4. 필터 바 렌더링
// --- 뷰 전환 로직 ---
const switchView = () => {
contentWrapper.innerHTML = '';
if ((state as any).currentViewMode === 'asset') {
filterBar.style.display = 'flex';
contentWrapper.style.overflowY = 'auto';
contentWrapper.appendChild(tableWrapper);
updateTable();
} else {
filterBar.style.display = 'none';
contentWrapper.style.overflowY = 'hidden';
renderSystemStatus();
}
};
// 토글 버튼 이벤트
toggleWrapper.addEventListener('click', (e) => {
const btn = (e.target as HTMLElement).closest('.toggle-btn') as HTMLButtonElement;
if (!btn) return;
toggleWrapper.querySelectorAll('.toggle-btn').forEach(b => b.classList.remove('active'));
btn.classList.add('active');
(state as any).currentViewMode = btn.getAttribute('data-mode') as 'asset' | 'system';
switchView();
});
// 필터 바 초기화
renderFilterBar(filterBar, {
...config.filterOptions,
onFilterChange: (filters) => {
@@ -127,18 +584,11 @@ export function createListView(container: HTMLElement, config: ListViewConfig) {
}
});
// 5. 동적 Select 박스 데이터 채우기
// 셀렉트 박스 채우기
const populateSelect = (selector: string, dataKey: string) => {
const select = container.querySelector(selector) as HTMLSelectElement;
if (select) {
// Handle multiple possible keys for department names due to legacy data
const getVal = (a: any) => {
if (dataKey === ASSET_SCHEMA.CURRENT_DEPT.key) {
return a[dataKey] || a['현사용부서'] || a['현사용조직'];
}
return a[dataKey];
}
const getVal = (a: any) => dataKey === ASSET_SCHEMA.CURRENT_DEPT.key ? (a[dataKey] || a['현사용부서'] || a['현사용조직']) : a[dataKey];
const uniqueValues = Array.from(new Set(fullList.map(getVal))).filter(Boolean).sort();
uniqueValues.forEach(val => {
const opt = document.createElement('option');
@@ -154,6 +604,6 @@ export function createListView(container: HTMLElement, config: ListViewConfig) {
if (config.filterOptions.showCorp) populateSelect('#filter-corp', ASSET_SCHEMA.PURCHASE_CORP.key);
if (config.filterOptions.showType) populateSelect('#filter-type', ASSET_SCHEMA.ASSET_TYPE.key);
// 6. 초기 렌더링
updateTable();
// 초기 실행
switchView();
}

View File

@@ -5,43 +5,10 @@ import { ASSET_SCHEMA } from '../../core/schema';
import { createListView } from './ListFactory';
export function renderPcList(container: HTMLElement) {
createListView(container, {
title: 'PC',
dataSource: () => sortAssets((state.masterData.pc || []).filter((a: any) => a.asset_type !== '서버PC')),
searchKeys: ['CURRENT_DEPT', 'CURRENT_USER', 'MODEL_NAME', 'MAC_ADDR', 'MANAGER_MAIN', 'ASSET_TYPE'],
filterOptions: {
keywordLabel: `통합 검색 (${ASSET_SCHEMA.MODEL_NAME.ui}/${ASSET_SCHEMA.MANAGER_MAIN.ui}/${ASSET_SCHEMA.CURRENT_USER.ui})`,
showLoc: true,
showDept: true,
showType: true
},
onRowClick: (asset) => openHwModal(asset, 'view'),
columns: [
{ header: ASSET_SCHEMA.CURRENT_USER.ui, sortKey: ASSET_SCHEMA.CURRENT_USER.key, align: 'center', render: a => a[ASSET_SCHEMA.CURRENT_USER.key] || '-' },
{ header: ASSET_SCHEMA.ASSET_TYPE.ui, sortKey: ASSET_SCHEMA.ASSET_TYPE.key, align: 'center', width: '10%', render: a => a[ASSET_SCHEMA.ASSET_TYPE.key] || '-' },
{ header: ASSET_SCHEMA.CPU.ui, sortKey: ASSET_SCHEMA.CPU.key, align: 'center', render: a => a[ASSET_SCHEMA.CPU.key] || '' },
{ header: ASSET_SCHEMA.MAINBOARD.ui, sortKey: ASSET_SCHEMA.MAINBOARD.key, align: 'center', render: a => a[ASSET_SCHEMA.MAINBOARD.key] || '-' },
{ header: ASSET_SCHEMA.RAM.ui, sortKey: ASSET_SCHEMA.RAM.key, align: 'center', render: a => a[ASSET_SCHEMA.RAM.key] || '' },
{ header: ASSET_SCHEMA.GPU.ui, sortKey: ASSET_SCHEMA.GPU.key, align: 'center', render: a => a[ASSET_SCHEMA.GPU.key] || '-' },
{
header: 'SSD',
align: 'center',
width: '8%',
render: a => [a[ASSET_SCHEMA.SSD1.key], a[ASSET_SCHEMA.SSD2.key]].filter(Boolean).join(' / ') || '-'
},
{
header: 'HDD',
align: 'center',
width: '12%',
render: a => [a[ASSET_SCHEMA.HDD1.key], a[ASSET_SCHEMA.HDD2.key], a[ASSET_SCHEMA.HDD3.key], a[ASSET_SCHEMA.HDD4.key]].filter(Boolean).join(' / ') || '-'
},
{
header: ASSET_SCHEMA.MAC_ADDR.ui,
sortKey: ASSET_SCHEMA.MAC_ADDR.key,
align: 'center',
render: a => `<span style="font-family:monospace; font-size:11px;">${a[ASSET_SCHEMA.MAC_ADDR.key] || '-'}</span>`
},
{ header: ASSET_SCHEMA.MEMO.ui, sortKey: ASSET_SCHEMA.MEMO.key, className: 'col-memo', width: '30%', render: a => formatInline(a[ASSET_SCHEMA.MEMO.key] || '-') }
]
});
container.innerHTML = `
<div style="display: flex; flex-direction: column; align-items: center; justify-content: center; height: 100%; color: var(--text-muted);">
<div style="font-size: 1.5rem; font-weight: 600; margin-bottom: 1rem;">PC 관리</div>
<p>해당 페이지는 다른 작업자에 의해 개발 중입니다.</p>
</div>
`;
}

View File

@@ -5,10 +5,10 @@ import { ASSET_SCHEMA } from '../../core/schema';
import { createListView } from './ListFactory';
export function renderSwList(container: HTMLElement) {
const isInternal = state.activeSubTab === '내부';
const isInternal = state.activeSubTab === '내부SW';
createListView(container, {
title: isInternal ? '내부' : '외부',
title: isInternal ? '내부SW' : '외부SW',
dataSource: () => sortAssets(isInternal ? state.masterData.swInternal : state.masterData.swExternal),
searchKeys: ['PRODUCT_NAME', 'CURRENT_USER', 'CURRENT_DEPT', 'ASSET_TYPE'],
emptyMessage: '검색 결과가 없습니다.',

244
src/views/LocationView.ts Normal file
View File

@@ -0,0 +1,244 @@
import { state } from '../core/state';
import { openHwModal } from '../components/Modal/HWModal';
import { ASSET_SCHEMA } from '../core/schema';
import { LOCATION_DATA, IMAGE_LOCATIONS } from '../components/Modal/SharedData';
/**
* 위치 중심 자산 현황 뷰 (Refined)
*/
export async function renderLocationView(container: HTMLElement) {
if (!container) return;
// 로컬 상태 (UI 제어용)
let currentLoc = '기술개발센터';
let currentDetail = '서버실';
let currentPage = 0;
let mapConfig: any = {};
try {
const res = await fetch('/api/maps');
mapConfig = await res.json();
} catch (err) { console.error('Failed to load map config', err); }
const render = () => {
const locImages = (IMAGE_LOCATIONS[currentLoc] && IMAGE_LOCATIONS[currentLoc][currentDetail])
? IMAGE_LOCATIONS[currentLoc][currentDetail]
: [];
const mapPath = locImages[currentPage] || '';
// 자산이 등록된(좌표가 일치하는) 구역만 필터링하여 표시
const allBoxes = mapConfig[mapPath] || [];
const boxes = allBoxes.filter((box: any) =>
state.masterData.hw.some(a =>
a.location === currentLoc &&
a.location_detail === currentDetail &&
String(a.loc_x) === String(box.x) &&
String(a.loc_y) === String(box.y)
)
);
container.innerHTML = `
<div class="location-view-wrapper">
<!-- 2단계 필터 바 -->
<div class="location-filter-bar">
<div class="filter-group">
<label>건물/위치</label>
<select id="sel-loc-main">
${Object.keys(LOCATION_DATA).map(loc => `<option value="${loc}" ${loc === currentLoc ? 'selected' : ''}>${loc}</option>`).join('')}
</select>
</div>
<div class="filter-group">
<label>상세 위치</label>
<div style="display: flex; align-items: center; gap: 0.5rem;">
<select id="sel-loc-detail">
${(LOCATION_DATA[currentLoc] || []).map(det => `<option value="${det}" ${det === currentDetail ? 'selected' : ''}>${det}</option>`).join('')}
</select>
<!-- 페이지네이션을 상세 위치 바로 옆으로 이동 -->
${locImages.length > 1 ? `
<div class="map-pagination" style="margin-left: 0; padding-left: 0.5rem; border-left: 1px solid var(--border-color); display: flex; align-items: center; gap: 0.5rem;">
<div class="page-btns">
<button id="btn-prev-page" class="btn btn-outline btn-sm" style="height: 28px; padding: 0 8px;" ${currentPage === 0 ? 'disabled' : ''}>이전</button>
<button id="btn-next-page" class="btn btn-outline btn-sm" style="height: 28px; padding: 0 8px;" ${currentPage === locImages.length - 1 ? 'disabled' : ''}>다음</button>
</div>
<span class="page-info">(${currentPage + 1} / ${locImages.length})</span>
</div>
` : ''}
</div>
</div>
</div>
<div class="location-main-content" style="height: calc(100vh - 180px); align-items: stretch; gap: 1rem; padding: 1rem; overflow: hidden; display: grid; grid-template-columns: 1.4fr 1fr;">
<!-- 지도 섹션: 상단 고정 정렬로 밀림 방지 -->
<div class="map-container-section" style="position: relative; overflow: hidden; border-radius: 8px; border: 1px solid var(--border-color); background: #f1f5f9; display: flex; align-items: flex-start; justify-content: center;">
<div class="map-frame-wrapper" style="position: relative; width: 100%; height: 100%; display: flex; align-items: flex-start; justify-content: center;">
${mapPath ? `
<img src="${mapPath}" id="main-map-img" style="max-width: 100%; max-height: 100%; object-fit: contain; display: block;">
<div id="box-overlay" style="position: absolute; pointer-events: none; transition: none;">
${boxes.map((box: any, idx: number) => {
const name = box.name || `#${idx+1}`;
return `
<div class="location-box-point"
data-name="${name}"
data-x="${box.x}"
data-y="${box.y}"
style="position: absolute; left:${box.x}%; top:${box.y}%; width:${box.w}%; height:${box.h}%;
border: 2px solid var(--primary-color); background: rgba(30, 81, 73, 0.1); cursor:pointer; pointer-events: auto;">
</div>
`}).join('')}
</div>
` : '<div style="padding: 5rem; text-align:center; color: #999;">해당 위치의 도면이 등록되지 않았습니다.</div>'}
</div>
</div>
<!-- 상세 정보 섹션: 내부 스크롤만 허용 -->
<div class="asset-list-section" style="display: flex; flex-direction: column; height: 100%; overflow: hidden; background: #fff; border-radius: 8px; border: 1px solid var(--border-color);">
<div class="section-header" style="flex-shrink: 0; background: #f8fafc; border-bottom: 1px solid var(--border-color); padding: 1rem;">
<h4 id="loc-list-title" style="margin:0; font-size: 0.95rem; font-weight: 700;">📍 구역을 선택하세요</h4>
</div>
<div id="loc-asset-table-container" class="mini-table-wrapper" style="flex: 1; overflow-y: auto; padding: 0;">
<div class="empty-state" style="padding: 3rem 1rem;">지도에서 자산 위치를 클릭하세요.</div>
</div>
</div>
</div>
<div style="padding: 0 1.5rem 0.5rem; flex-shrink: 0;">
<p style="font-size:0.75rem; color:var(--text-muted); margin: 0;">* 지도 위의 구역을 클릭하면 자산 상세 정보가 표시됩니다.</p>
</div>
</div>
`;
// 이미지 로드 및 윈도우 리사이즈 시 오버레이 크기와 위치를 이미지에 정확히 맞춤
const syncOverlaySize = () => {
const img = container.querySelector('#main-map-img') as HTMLImageElement;
const overlay = container.querySelector('#box-overlay') as HTMLElement;
if (img && overlay && img.complete) {
overlay.style.width = img.clientWidth + 'px';
overlay.style.height = img.clientHeight + 'px';
overlay.style.left = img.offsetLeft + 'px';
overlay.style.top = img.offsetTop + 'px';
}
};
const img = container.querySelector('#main-map-img') as HTMLImageElement;
if (img) {
if (img.complete) {
syncOverlaySize();
setTimeout(syncOverlaySize, 50); // 레이아웃 안정화 대기
} else {
img.onload = syncOverlaySize;
}
}
window.removeEventListener('resize', syncOverlaySize);
window.addEventListener('resize', syncOverlaySize);
// 이벤트 바인딩
const selMain = container.querySelector('#sel-loc-main') as HTMLSelectElement;
selMain?.addEventListener('change', () => {
currentLoc = selMain.value;
currentDetail = LOCATION_DATA[currentLoc][0];
currentPage = 0;
render();
});
const selDetail = container.querySelector('#sel-loc-detail') as HTMLSelectElement;
selDetail?.addEventListener('change', () => {
currentDetail = selDetail.value;
currentPage = 0;
render();
});
container.querySelector('#btn-prev-page')?.addEventListener('click', () => { currentPage--; render(); });
container.querySelector('#btn-next-page')?.addEventListener('click', () => { currentPage++; render(); });
container.querySelectorAll('.location-box-point').forEach(box => {
box.addEventListener('click', () => {
const x = box.getAttribute('data-x');
const y = box.getAttribute('data-y');
const targetAsset = state.masterData.hw.find(a =>
a.location === currentLoc &&
a.location_detail === currentDetail &&
String(a.loc_x) === String(x) &&
String(a.loc_y) === String(y)
);
if (targetAsset) {
renderAssetDetail(targetAsset);
}
container.querySelectorAll('.location-box-point').forEach(b => (b as HTMLElement).style.background = 'rgba(30, 81, 73, 0.1)');
(box as HTMLElement).style.background = 'rgba(30, 81, 73, 0.4)';
});
});
};
const renderAssetDetail = (asset: any) => {
const title = container.querySelector('#loc-list-title')!;
const tableContainer = container.querySelector('#loc-asset-table-container')!;
title.innerHTML = `
<div class="detail-header-actions">
<button id="btn-back-to-list" class="btn-icon" style="background: none; border: none; cursor: pointer; color: var(--primary-color); font-size: 1.2rem; padding: 0 4px;">←</button>
<span class="detail-header-title">자산 상세 정보</span>
<button id="btn-edit-from-loc" class="btn btn-primary btn-sm" style="font-size: 11px; height: 28px;">수정</button>
</div>
`;
const renderSection = (title: string, fields: { label: string; value: any }[]) => `
<div class="detail-section">
<div class="detail-section-title">${title}</div>
<div class="detail-grid">
${fields.map(f => `
<div class="detail-label">${f.label}</div>
<div class="detail-value">${f.value || '-'}</div>
`).join('')}
</div>
</div>
`;
const sectionsHTML = [
renderSection('기본 관리 정보', [
{ label: ASSET_SCHEMA.ASSET_CODE.ui, value: asset.asset_code },
{ label: ASSET_SCHEMA.PURCHASE_CORP.ui, value: asset.purchase_corp },
{ label: ASSET_SCHEMA.CATEGORY.ui, value: asset.category },
{ label: ASSET_SCHEMA.ASSET_TYPE.ui, value: asset.asset_type },
{ label: ASSET_SCHEMA.HW_STATUS.ui, value: asset.hw_status }
]),
renderSection('시스템 사양', [
{ label: ASSET_SCHEMA.MODEL_NAME.ui, value: asset.model_name },
{ label: ASSET_SCHEMA.OS.ui, value: asset.os },
{ label: ASSET_SCHEMA.CPU.ui, value: asset.cpu },
{ label: ASSET_SCHEMA.RAM.ui, value: asset.ram },
{ label: ASSET_SCHEMA.GPU.ui, value: asset.gpu }
]),
renderSection('네트워크 정보', [
{ label: ASSET_SCHEMA.IP_ADDR.ui, value: asset.ip_address },
{ label: ASSET_SCHEMA.MAC_ADDR.ui, value: asset.mac_address },
{ label: ASSET_SCHEMA.REMOTE_TOOL.ui, value: asset.remote_tool }
]),
renderSection('구매 및 기타', [
{ label: ASSET_SCHEMA.PURCHASE_DATE.ui, value: asset.purchase_date },
{ label: ASSET_SCHEMA.PURCHASE_AMOUNT.ui, value: asset.purchase_amount ? `${Number(asset.purchase_amount).toLocaleString()}` : '-' },
{ label: ASSET_SCHEMA.MEMO.ui, value: asset.memo }
])
].join('');
tableContainer.innerHTML = `
<div class="asset-detail-sidebar">
${sectionsHTML}
</div>
`;
container.querySelector('#btn-back-to-list')?.addEventListener('click', () => {
title.textContent = `📍 구역을 선택하세요`;
tableContainer.innerHTML = `<div class="empty-state" style="padding: 3rem 1rem;">지도에서 자산 위치를 클릭하세요.</div>`;
});
container.querySelector('#btn-edit-from-loc')?.addEventListener('click', () => {
openHwModal(asset, 'edit');
});
};
render();
}

View File

@@ -41,7 +41,7 @@ export function renderSWTable(mainContent: HTMLElement) {
container.innerHTML = `<div style="padding:2rem; color:var(--text-muted);">"${tab}" 탭에 대한 하드웨어 리스트 뷰가 정의되지 않았습니다.</div>`;
}
} else if (state.activeCategory === 'sw') {
if (tab === '외부' || tab === '내부') {
if (tab === '외부SW' || tab === '내부SW') {
renderSwList(container);
} else {
container.innerHTML = `<div style="padding:2rem; color:var(--text-muted);">"${tab}" 탭에 대한 소프트웨어 리스트 뷰가 정의되지 않았습니다.</div>`;

View File

@@ -4,5 +4,15 @@ export default defineConfig({
server: {
port: 8080,
host: true, // Listen on all local IPs
proxy: {
'/api': {
target: 'http://localhost:3000',
changeOrigin: true,
},
'/uploads': {
target: 'http://localhost:3000',
changeOrigin: true,
}
}
},
});