feat: 자산 관리 시스템 고도화 및 DB 정규화 대응 수정
1. 자산 저장 시 500 에러 해결: V3 정규화 스키마에 맞춰 테이블 매핑 최신화 및 저장 로직 안정화 2. 자산 번호 체계 개편: 구매일자(YYYYMM)와 유형을 기반으로 PREFIX-YYYYMM-NNNN 규칙 적용 (코드 로직 수정 및 기존 데이터 전량 갱신) 3. 구매일자 표준화: 모든 purchase_date를 YYYY-MM-DD 형식으로 통일 4. HWModal 기능 복원: 위치 등록 시 다중 사진 페이지네이션(좌우 버튼) 기능 복구 5. 위치 지도 고도화: HTML 인터랙티브 지도 지원 및 이미지 지도 내 좌석 스내핑 로직 추가
This commit is contained in:
30
index.html
30
index.html
@@ -19,36 +19,6 @@
|
|||||||
</head>
|
</head>
|
||||||
|
|
||||||
<body>
|
<body>
|
||||||
<!-- Login Screen -->
|
|
||||||
<div id="login-container" class="login-layout">
|
|
||||||
<div class="login-card">
|
|
||||||
<div class="login-header">
|
|
||||||
<img src="/image 92.png" alt="Logo" class="login-logo" />
|
|
||||||
<h2>ITAM 시스템</h2>
|
|
||||||
<p>자산 관리 포털에 오신 것을 환영합니다</p>
|
|
||||||
</div>
|
|
||||||
<div id="login-selection" class="login-selection">
|
|
||||||
<div class="role-card" data-role="admin">
|
|
||||||
<div class="role-icon">
|
|
||||||
<i data-lucide="settings"></i>
|
|
||||||
</div>
|
|
||||||
<h3>관리자</h3>
|
|
||||||
<p>시스템 설정 및 자산 마스터 관리</p>
|
|
||||||
</div>
|
|
||||||
<div class="role-card" data-role="user">
|
|
||||||
<div class="role-icon">
|
|
||||||
<i data-lucide="monitor"></i>
|
|
||||||
</div>
|
|
||||||
<h3>실무자</h3>
|
|
||||||
<p>자산 조회 및 현황 확인</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="login-footer">
|
|
||||||
<p>© 2026 BARON Consultant Co,Ltd. All rights reserved.</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="app-layout" id="app-layout" style="display: none;">
|
<div class="app-layout" id="app-layout" style="display: none;">
|
||||||
<!-- Single-Line Integrated Header -->
|
<!-- Single-Line Integrated Header -->
|
||||||
<header class="main-header">
|
<header class="main-header">
|
||||||
|
|||||||
99
server.js
99
server.js
@@ -36,25 +36,24 @@ const handleError = (res, err, label) => {
|
|||||||
|
|
||||||
// --- Global Constants ---
|
// --- Global Constants ---
|
||||||
const CATEGORY_TABLE_MAP = {
|
const CATEGORY_TABLE_MAP = {
|
||||||
pc: 'asset_pc',
|
pc: 'asset_core',
|
||||||
server: 'asset_server',
|
server: 'asset_core',
|
||||||
storage: 'asset_storage',
|
storage: 'asset_core',
|
||||||
network: 'asset_remote',
|
network: 'asset_core',
|
||||||
equipment: 'asset_equipment',
|
equipment: 'asset_core',
|
||||||
officeSupplies: 'asset_office_supplies',
|
officeSupplies: 'asset_core',
|
||||||
survey: 'asset_survey',
|
survey: 'asset_core',
|
||||||
vip: 'asset_vip',
|
vip: 'asset_core',
|
||||||
swInternal: 'sw_internal',
|
pcParts: 'asset_core',
|
||||||
swExternal: 'sw_external',
|
swInternal: 'asset_software_perpetual',
|
||||||
cloud: 'asset_cloud',
|
swExternal: 'asset_software_subscription',
|
||||||
|
swUsers: 'asset_software_assignment',
|
||||||
users: 'system_users',
|
users: 'system_users',
|
||||||
swUsers: 'sw_assignment',
|
|
||||||
logs: 'asset_history'
|
logs: 'asset_history'
|
||||||
};
|
};
|
||||||
|
|
||||||
const ASSET_TABLES = [
|
const ASSET_TABLES = [
|
||||||
'asset_pc', 'asset_server', 'asset_storage', 'asset_remote',
|
'asset_core'
|
||||||
'asset_equipment', 'asset_office_supplies', 'asset_survey', 'asset_vip'
|
|
||||||
];
|
];
|
||||||
|
|
||||||
// --- API Endpoints ---
|
// --- API Endpoints ---
|
||||||
@@ -101,8 +100,9 @@ app.post('/api/:table/batch', async (req, res) => {
|
|||||||
|
|
||||||
// 2. Get All Assets (Integrated Master Data from Normalized V3 Schema)
|
// 2. Get All Assets (Integrated Master Data from Normalized V3 Schema)
|
||||||
app.get('/api/assets/master', async (req, res) => {
|
app.get('/api/assets/master', async (req, res) => {
|
||||||
|
let connection;
|
||||||
try {
|
try {
|
||||||
const connection = await pool.getConnection();
|
connection = await pool.getConnection();
|
||||||
|
|
||||||
const masterData = {
|
const masterData = {
|
||||||
pc: [], server: [], storage: [], network: [],
|
pc: [], server: [], storage: [], network: [],
|
||||||
@@ -110,6 +110,7 @@ app.get('/api/assets/master', async (req, res) => {
|
|||||||
swInternal: [], swExternal: [], swUsers: [], users: [], logs: []
|
swInternal: [], swExternal: [], swUsers: [], users: [], logs: []
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Load from V3 Normalized Schema
|
||||||
const [rows] = await connection.query(`
|
const [rows] = await connection.query(`
|
||||||
SELECT
|
SELECT
|
||||||
c.*,
|
c.*,
|
||||||
@@ -156,10 +157,11 @@ app.get('/api/assets/master', async (req, res) => {
|
|||||||
masterData.users = users;
|
masterData.users = users;
|
||||||
masterData.logs = logs;
|
masterData.logs = logs;
|
||||||
|
|
||||||
connection.release();
|
|
||||||
res.json(masterData);
|
res.json(masterData);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
handleError(res, err, 'MASTER DATA');
|
handleError(res, err, 'MASTER DATA');
|
||||||
|
} finally {
|
||||||
|
if (connection) connection.release();
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -177,15 +179,11 @@ app.post('/api/asset/:category/save', async (req, res) => {
|
|||||||
const oldCore = oldCoreRows[0] || {};
|
const oldCore = oldCoreRows[0] || {};
|
||||||
const oldSpec = oldSpecRows[0] || {};
|
const oldSpec = oldSpecRows[0] || {};
|
||||||
|
|
||||||
console.log(`🔍 [History Check] ID: ${asset.id}`);
|
|
||||||
console.log(` - Dept: [${oldCore.current_dept}] -> [${asset.current_dept}]`);
|
|
||||||
console.log(` - User: [${oldCore.user_current}] -> [${asset.user_current}]`);
|
|
||||||
|
|
||||||
const historyLogs = [];
|
const historyLogs = [];
|
||||||
const logDate = new Date().toISOString().split('T')[0]; // YYYY-MM-DD
|
const logDate = new Date().toISOString().split('T')[0]; // YYYY-MM-DD
|
||||||
const logUser = '관리자';
|
const logUser = '관리자';
|
||||||
|
|
||||||
// 조직 변동 감지 (null/undefined/empty string 세이프 처리)
|
// 3.0.1 Core 변동 감지 (Dept, User)
|
||||||
const oldDept = oldCore.current_dept || '';
|
const oldDept = oldCore.current_dept || '';
|
||||||
const newDept = asset.current_dept || '';
|
const newDept = asset.current_dept || '';
|
||||||
if (newDept !== '' && oldDept !== newDept) {
|
if (newDept !== '' && oldDept !== newDept) {
|
||||||
@@ -198,7 +196,6 @@ app.post('/api/asset/:category/save', async (req, res) => {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// 사용자 변동 감지
|
|
||||||
const oldUser = oldCore.user_current || '';
|
const oldUser = oldCore.user_current || '';
|
||||||
const newUser = asset.user_current || '';
|
const newUser = asset.user_current || '';
|
||||||
if (newUser !== '' && oldUser !== newUser) {
|
if (newUser !== '' && oldUser !== newUser) {
|
||||||
@@ -211,26 +208,27 @@ app.post('/api/asset/:category/save', async (req, res) => {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// 유형/용도 변경 감지
|
// 3.0.2 Spec 변동 감지 (CPU, RAM, GPU, OS, Mainboard 등)
|
||||||
const oldType = oldCore.asset_type || '';
|
const specFieldsToTrack = [
|
||||||
const newType = asset.asset_type || '';
|
{ key: 'cpu', label: 'CPU' },
|
||||||
if (newType !== '' && oldType !== newType) {
|
{ key: 'ram', label: 'RAM' },
|
||||||
historyLogs.push({
|
{ key: 'gpu', label: 'GPU' },
|
||||||
event_type: 'ROLE_CHANGE',
|
{ key: 'os', label: 'OS' },
|
||||||
details: `[유형 변경] ${oldType || '(없음)'} -> ${newType}`
|
{ key: 'mainboard', label: '메인보드' }
|
||||||
});
|
];
|
||||||
}
|
|
||||||
|
|
||||||
const oldRole = oldCore.current_role || '';
|
specFieldsToTrack.forEach(field => {
|
||||||
const newRole = asset.current_role || '';
|
const oldVal = String(oldSpec[field.key] || '').trim();
|
||||||
if (newRole !== '' && oldRole !== newRole) {
|
const newVal = String(asset[field.key] || '').trim();
|
||||||
historyLogs.push({
|
if (newVal !== '' && oldVal !== newVal) {
|
||||||
event_type: 'ROLE_CHANGE',
|
historyLogs.push({
|
||||||
details: `[용도 변경] ${oldRole || '(없음)'} -> ${newRole}`
|
event_type: 'SPEC_CHANGE',
|
||||||
});
|
details: `[사양 변경] ${field.label}: ${oldVal || '(없음)'} -> ${newVal}`
|
||||||
}
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
// 상태 변경 감지
|
// 3.0.3 상태 변경 감지
|
||||||
const oldStatus = oldSpec.hw_status || '';
|
const oldStatus = oldSpec.hw_status || '';
|
||||||
const newStatus = asset.hw_status || '';
|
const newStatus = asset.hw_status || '';
|
||||||
if (newStatus !== '' && oldStatus !== newStatus) {
|
if (newStatus !== '' && oldStatus !== newStatus) {
|
||||||
@@ -240,8 +238,6 @@ app.post('/api/asset/:category/save', async (req, res) => {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
console.log(` - Logs Generated: ${historyLogs.length}`);
|
|
||||||
|
|
||||||
// 로그 일괄 삽입
|
// 로그 일괄 삽입
|
||||||
for (const log of historyLogs) {
|
for (const log of historyLogs) {
|
||||||
await connection.query(
|
await connection.query(
|
||||||
@@ -256,8 +252,23 @@ app.post('/api/asset/:category/save', async (req, res) => {
|
|||||||
const coreData = {};
|
const coreData = {};
|
||||||
coreFields.forEach(f => { if (asset[f] !== undefined) coreData[f] = asset[f]; });
|
coreFields.forEach(f => { if (asset[f] !== undefined) coreData[f] = asset[f]; });
|
||||||
const coreKeys = Object.keys(coreData);
|
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));
|
console.log(`[DEBUG] Saving Asset ID: ${asset.id}, Code: ${asset.asset_code}`);
|
||||||
|
const [existingCore] = await connection.query('SELECT id FROM asset_core WHERE id = ?', [asset.id]);
|
||||||
|
console.log(`[DEBUG] Existing Core Check for ${asset.id}: Found ${existingCore.length}`);
|
||||||
|
|
||||||
|
if (existingCore.length > 0) {
|
||||||
|
// UPDATE
|
||||||
|
const updateKeys = coreKeys.filter(k => k !== 'id');
|
||||||
|
const coreSql = `UPDATE asset_core SET ${updateKeys.map(k => `${k} = ?`).join(', ')} WHERE id = ?`;
|
||||||
|
const [updRes] = await connection.query(coreSql, [...updateKeys.map(k => coreData[k]), asset.id]);
|
||||||
|
console.log(`[DEBUG] Core UPDATE result: affectedRows=${updRes.affectedRows}`);
|
||||||
|
} else {
|
||||||
|
// INSERT
|
||||||
|
const coreSql = `INSERT INTO asset_core (${coreKeys.join(', ')}) VALUES (${coreKeys.map(() => '?').join(', ')})`;
|
||||||
|
const [insRes] = await connection.query(coreSql, Object.values(coreData));
|
||||||
|
console.log(`[DEBUG] Core INSERT result: affectedRows=${insRes.affectedRows}`);
|
||||||
|
}
|
||||||
|
|
||||||
// 3.2 asset_spec
|
// 3.2 asset_spec
|
||||||
const specFields = ['hw_status', 'model_name', 'mainboard', 'os', 'cpu', 'ram', 'gpu', 'monitoring', 'price', 'monitor_inch', 'serial_num'];
|
const specFields = ['hw_status', 'model_name', 'mainboard', 'os', 'cpu', 'ram', 'gpu', 'monitoring', 'price', 'monitor_inch', 'serial_num'];
|
||||||
|
|||||||
@@ -268,10 +268,21 @@ class HwAssetModal extends BaseModal {
|
|||||||
});
|
});
|
||||||
|
|
||||||
document.getElementById('btn-gen-hw-code')?.addEventListener('click', async () => {
|
document.getElementById('btn-gen-hw-code')?.addEventListener('click', async () => {
|
||||||
|
const type = typeSelect.value;
|
||||||
const cat = categorySelect.value;
|
const cat = categorySelect.value;
|
||||||
if (!cat) { alert('구분을 먼저 선택해주세요.'); return; }
|
if (!type) { alert('유형을 먼저 선택해주세요.'); return; }
|
||||||
const prefix = TYPE_PREFIX_MAP[cat] || 'ETC';
|
|
||||||
const purchaseDate = (document.getElementById('hw-purchase_date') as HTMLInputElement)?.value || '';
|
const purchaseDateEl = document.getElementById('hw-purchase_date') as HTMLInputElement;
|
||||||
|
const purchaseDate = purchaseDateEl?.value || '';
|
||||||
|
|
||||||
|
if (!purchaseDate) {
|
||||||
|
alert('구매일자를 먼저 입력해야 자산번호 생성이 가능합니다.');
|
||||||
|
purchaseDateEl?.focus();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 유형 기반 매핑 우선, 없으면 구분 기반, 그래도 없으면 ETC
|
||||||
|
const prefix = TYPE_PREFIX_MAP[type] || TYPE_PREFIX_MAP[cat] || 'ETC';
|
||||||
try {
|
try {
|
||||||
const res = await fetch(`http://${location.hostname}:3000/api/generate-asset-code?prefix=${prefix}&purchaseDate=${purchaseDate}`);
|
const res = await fetch(`http://${location.hostname}:3000/api/generate-asset-code?prefix=${prefix}&purchaseDate=${purchaseDate}`);
|
||||||
const data = await res.json();
|
const data = await res.json();
|
||||||
@@ -690,29 +701,110 @@ class HwAssetModal extends BaseModal {
|
|||||||
overlay.className = 'image-picker-overlay';
|
overlay.className = 'image-picker-overlay';
|
||||||
const renderContent = () => {
|
const renderContent = () => {
|
||||||
const imgPath = imagePaths[currentIdx];
|
const imgPath = imagePaths[currentIdx];
|
||||||
const digitalMap = this.generateDynamicSVG(imgPath);
|
const isMulti = imagePaths.length > 1;
|
||||||
|
const isHtmlMap = imgPath.toLowerCase().endsWith('.html');
|
||||||
|
const digitalMap = isHtmlMap ? '' : this.generateDynamicSVG(imgPath);
|
||||||
|
|
||||||
overlay.innerHTML = `
|
overlay.innerHTML = `
|
||||||
<div class="image-picker-header"><h3>${title}</h3><button class="btn-close-picker" style="background:none; border:none; color:white; font-size:24px; cursor:pointer;">×</button></div>
|
<div class="image-picker-header">
|
||||||
<div class="image-picker-content"><div class="layout-map-container" id="picker-container"><img src="${imgPath}" class="layout-map-img" /><div id="picker-marker" class="layout-marker hidden"></div><div class="digital-overlay-layer">${digitalMap}</div></div></div>
|
<h3>${title} ${isMulti ? `(${currentIdx + 1}/${imagePaths.length})` : ''}</h3>
|
||||||
|
<button class="btn-close-picker" style="background:none; border:none; color:white; font-size:24px; cursor:pointer;">×</button>
|
||||||
|
</div>
|
||||||
|
<div class="image-picker-content">
|
||||||
|
${isMulti ? `
|
||||||
|
<div class="picker-nav prev ${currentIdx === 0 ? 'disabled' : ''}" style="position: absolute; left: 10px; top: 50%; transform: translateY(-50%); z-index: 100; cursor: pointer; background: rgba(0,0,0,0.5); color: white; padding: 20px 10px; border-radius: 5px; font-size: 24px; user-select: none;">◀</div>
|
||||||
|
<div class="picker-nav next ${currentIdx === imagePaths.length - 1 ? 'disabled' : ''}" style="position: absolute; right: 10px; top: 50%; transform: translateY(-50%); z-index: 100; cursor: pointer; background: rgba(0,0,0,0.5); color: white; padding: 20px 10px; border-radius: 5px; font-size: 24px; user-select: none;">▶</div>
|
||||||
|
` : ''}
|
||||||
|
<div class="layout-map-container" id="picker-container">
|
||||||
|
${isHtmlMap
|
||||||
|
? `<iframe src="${imgPath}" style="width:100%; height:100%; border:none; display:block;"></iframe>`
|
||||||
|
: `<img src="${imgPath}" class="layout-map-img" /><div id="picker-marker" class="layout-marker hidden"></div><div class="digital-overlay-layer">${digitalMap}</div>`
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
<div class="image-picker-footer"><button id="btn-picker-cancel" class="btn btn-outline" style="color:white; border-color:white;">취소</button><button id="btn-picker-save" class="btn btn-primary">위치 확정</button></div>`;
|
<div class="image-picker-footer"><button id="btn-picker-cancel" class="btn btn-outline" style="color:white; border-color:white;">취소</button><button id="btn-picker-save" class="btn btn-primary">위치 확정</button></div>`;
|
||||||
|
|
||||||
let selectedX = ''; let selectedY = '';
|
let selectedX = ''; let selectedY = '';
|
||||||
const container = overlay.querySelector('#picker-container') as HTMLElement;
|
|
||||||
const marker = overlay.querySelector('#picker-marker') as HTMLElement;
|
if (isMulti) {
|
||||||
container.addEventListener('click', (e) => {
|
overlay.querySelector('.picker-nav.prev')?.addEventListener('click', (e) => { e.stopPropagation(); if (currentIdx > 0) { currentIdx--; renderContent(); } });
|
||||||
const rect = container.getBoundingClientRect();
|
overlay.querySelector('.picker-nav.next')?.addEventListener('click', (e) => { e.stopPropagation(); if (currentIdx < imagePaths.length - 1) { currentIdx++; renderContent(); } });
|
||||||
const x = ((e.clientX - rect.left) / rect.width) * 100;
|
}
|
||||||
const y = ((e.clientY - rect.top) / rect.height) * 100;
|
|
||||||
selectedX = x.toFixed(2); selectedY = y.toFixed(2);
|
if (isHtmlMap) {
|
||||||
marker.style.left = `${selectedX}%`; marker.style.top = `${selectedY}%`; marker.classList.remove('hidden');
|
// HTML 지도 메시지 리스너
|
||||||
});
|
const handleMessage = (e: MessageEvent) => {
|
||||||
overlay.querySelector('.btn-close-picker')?.addEventListener('click', () => overlay.remove());
|
if (e.data.type === 'PICK_LOCATION') {
|
||||||
overlay.querySelector('#btn-picker-cancel')?.addEventListener('click', () => overlay.remove());
|
selectedX = e.data.x;
|
||||||
overlay.querySelector('#btn-picker-save')?.addEventListener('click', () => {
|
selectedY = e.data.y;
|
||||||
if (!selectedX || !selectedY) { alert('위치를 선택해주세요.'); return; }
|
}
|
||||||
setFieldValue('hw-loc_x', selectedX); setFieldValue('hw-loc_y', selectedY);
|
};
|
||||||
setFieldValue('hw-location_photo', imagePaths[currentIdx]);
|
window.addEventListener('message', handleMessage);
|
||||||
this.updateMapButtonVisibility(); overlay.remove();
|
overlay.querySelector('.btn-close-picker')?.addEventListener('click', () => { window.removeEventListener('message', handleMessage); overlay.remove(); });
|
||||||
});
|
overlay.querySelector('#btn-picker-cancel')?.addEventListener('click', () => { window.removeEventListener('message', handleMessage); overlay.remove(); });
|
||||||
|
overlay.querySelector('#btn-picker-save')?.addEventListener('click', () => {
|
||||||
|
if (!selectedX || !selectedY) { alert('위치를 선택해주세요.'); return; }
|
||||||
|
setFieldValue('hw-loc_x', selectedX); setFieldValue('hw-loc_y', selectedY);
|
||||||
|
setFieldValue('hw-location_photo', imagePaths[currentIdx]);
|
||||||
|
this.updateMapButtonVisibility();
|
||||||
|
window.removeEventListener('message', handleMessage);
|
||||||
|
overlay.remove();
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
const container = overlay.querySelector('#picker-container') as HTMLElement;
|
||||||
|
const marker = overlay.querySelector('#picker-marker') as HTMLElement;
|
||||||
|
container.addEventListener('click', (e) => {
|
||||||
|
const rectBound = container.getBoundingClientRect();
|
||||||
|
const clickX = ((e.clientX - rectBound.left) / rectBound.width) * 100;
|
||||||
|
const clickY = ((e.clientY - rectBound.top) / rectBound.height) * 100;
|
||||||
|
|
||||||
|
let snapped = false;
|
||||||
|
overlay.querySelectorAll('rect').forEach(rect => {
|
||||||
|
const rx = parseFloat(rect.getAttribute('x') || '0');
|
||||||
|
const ry = parseFloat(rect.getAttribute('y') || '0');
|
||||||
|
const rw = parseFloat(rect.getAttribute('width') || '0');
|
||||||
|
const rh = parseFloat(rect.getAttribute('height') || '0');
|
||||||
|
|
||||||
|
if (clickX >= rx && clickX <= rx + rw && clickY >= ry && clickY <= ry + rh) {
|
||||||
|
overlay.querySelectorAll('rect').forEach(r => {
|
||||||
|
r.style.fill = 'rgba(30,81,73,0.05)';
|
||||||
|
r.style.stroke = 'rgba(30,81,73,0.2)';
|
||||||
|
r.style.strokeWidth = '0.2';
|
||||||
|
});
|
||||||
|
rect.style.fill = 'rgba(255, 61, 0, 0.4)';
|
||||||
|
rect.style.stroke = '#FF3D00';
|
||||||
|
rect.style.strokeWidth = '0.8';
|
||||||
|
|
||||||
|
selectedX = rx.toFixed(2);
|
||||||
|
selectedY = ry.toFixed(2);
|
||||||
|
|
||||||
|
marker.style.left = `${rx + rw/2}%`;
|
||||||
|
marker.style.top = `${ry + rh/2}%`;
|
||||||
|
marker.classList.remove('hidden');
|
||||||
|
snapped = true;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!snapped) {
|
||||||
|
selectedX = '';
|
||||||
|
selectedY = '';
|
||||||
|
marker.classList.add('hidden');
|
||||||
|
overlay.querySelectorAll('rect').forEach(r => {
|
||||||
|
r.style.fill = 'rgba(30,81,73,0.05)';
|
||||||
|
r.style.stroke = 'rgba(30,81,73,0.2)';
|
||||||
|
r.style.strokeWidth = '0.2';
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
overlay.querySelector('.btn-close-picker')?.addEventListener('click', () => overlay.remove());
|
||||||
|
overlay.querySelector('#btn-picker-cancel')?.addEventListener('click', () => overlay.remove());
|
||||||
|
overlay.querySelector('#btn-picker-save')?.addEventListener('click', () => {
|
||||||
|
if (!selectedX || !selectedY) { alert('위치를 선택해주세요.'); return; }
|
||||||
|
setFieldValue('hw-loc_x', selectedX); setFieldValue('hw-loc_y', selectedY);
|
||||||
|
setFieldValue('hw-location_photo', imagePaths[currentIdx]);
|
||||||
|
this.updateMapButtonVisibility(); overlay.remove();
|
||||||
|
});
|
||||||
|
}
|
||||||
};
|
};
|
||||||
renderContent(); document.body.appendChild(overlay);
|
renderContent(); document.body.appendChild(overlay);
|
||||||
}
|
}
|
||||||
@@ -720,13 +812,26 @@ class HwAssetModal extends BaseModal {
|
|||||||
private openImagePreview(imagePath: string, title: string, x: string, y: string) {
|
private openImagePreview(imagePath: string, title: string, x: string, y: string) {
|
||||||
const overlay = document.createElement('div');
|
const overlay = document.createElement('div');
|
||||||
overlay.className = 'image-picker-overlay';
|
overlay.className = 'image-picker-overlay';
|
||||||
const digitalMap = this.generateDynamicSVG(imagePath);
|
const isHtmlMap = imagePath.toLowerCase().endsWith('.html');
|
||||||
|
const digitalMap = isHtmlMap ? '' : this.generateDynamicSVG(imagePath);
|
||||||
|
|
||||||
|
// HTML 지도인 경우 좌표를 쿼리 파라미터로 전달
|
||||||
|
const finalPath = isHtmlMap ? `${imagePath}?markerX=${x}&markerY=${y}` : imagePath;
|
||||||
|
|
||||||
overlay.innerHTML = `
|
overlay.innerHTML = `
|
||||||
<div class="image-picker-header"><h3>${title}</h3><button class="btn-close-picker" style="background:none; border:none; color:white; font-size:24px; cursor:pointer;">×</button></div>
|
<div class="image-picker-header"><h3>${title}</h3><button class="btn-close-picker" style="background:none; border:none; color:white; font-size:24px; cursor:pointer;">×</button></div>
|
||||||
<div class="image-picker-content"><div class="layout-map-container readonly"><img src="${imagePath}" class="layout-map-img" /><div id="preview-marker" class="layout-marker pulse-marker" style="left:${x}%; top:${y}%;"></div><div class="digital-overlay-layer">${digitalMap}</div></div></div>
|
<div class="image-picker-content">
|
||||||
|
<div class="layout-map-container readonly">
|
||||||
|
${isHtmlMap
|
||||||
|
? `<iframe src="${finalPath}" style="width:100%; height:100%; border:none; display:block;"></iframe>`
|
||||||
|
: `<img src="${imagePath}" class="layout-map-img" /><div id="preview-marker" class="layout-marker pulse-marker" style="left:${x}%; top:${y}%;"></div><div class="digital-overlay-layer">${digitalMap}</div>`
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
<div class="image-picker-footer"><button id="btn-preview-close" class="btn btn-primary">확인</button></div>`;
|
<div class="image-picker-footer"><button id="btn-preview-close" class="btn btn-primary">확인</button></div>`;
|
||||||
|
|
||||||
document.body.appendChild(overlay);
|
document.body.appendChild(overlay);
|
||||||
if (digitalMap) {
|
if (!isHtmlMap && digitalMap) {
|
||||||
const curX = parseFloat(x || '0'); const curY = parseFloat(y || '0');
|
const curX = parseFloat(x || '0'); const curY = parseFloat(y || '0');
|
||||||
overlay.querySelectorAll('rect').forEach(rect => {
|
overlay.querySelectorAll('rect').forEach(rect => {
|
||||||
const sx = parseFloat(rect.getAttribute('x') || '0');
|
const sx = parseFloat(rect.getAttribute('x') || '0');
|
||||||
|
|||||||
@@ -30,7 +30,7 @@ export const CATEGORY_TYPE_MAP: Record<string, string[]> = {
|
|||||||
// 설치위치 종속성 데이터
|
// 설치위치 종속성 데이터
|
||||||
export const LOCATION_DATA: Record<string, string[]> = {
|
export const LOCATION_DATA: Record<string, string[]> = {
|
||||||
'한맥빌딩': ['MDF실', '1층', '2층', '3층', '4층', '5층', '6층', '7층', '파고라'],
|
'한맥빌딩': ['MDF실', '1층', '2층', '3층', '4층', '5층', '6층', '7층', '파고라'],
|
||||||
'기술개발센터': ['서버실', 'BLUE ZONE', 'GREEN ZONE', 'ORANGE ZONE', '회의실2', '회의실3', '회의실5', '회의실6', '회의실7', '사이니지룸'],
|
'기술개발센터': ['서버실', '센터내부'],
|
||||||
'유니온빌딩': ['4층', '5층', '6층'],
|
'유니온빌딩': ['4층', '5층', '6층'],
|
||||||
'뉴코아빌딩': ['4층', '6층', '7층'],
|
'뉴코아빌딩': ['4층', '6층', '7층'],
|
||||||
'IDC': ['서관202', '서관203', '서관204', '서관205', '동관53', '동관54']
|
'IDC': ['서관202', '서관203', '서관204', '서관205', '동관53', '동관54']
|
||||||
@@ -60,10 +60,17 @@ export const IMAGE_LOCATIONS: Record<string, Record<string, string[]>> = {
|
|||||||
'서버실': [
|
'서버실': [
|
||||||
'img/location_photo/기술개발센터/서버실/서버실_1.png',
|
'img/location_photo/기술개발센터/서버실/서버실_1.png',
|
||||||
'img/location_photo/기술개발센터/서버실/서버실_2.png'
|
'img/location_photo/기술개발센터/서버실/서버실_2.png'
|
||||||
]
|
],
|
||||||
|
'센터내부': ['img/location_photo/기술개발센터/센터내부/센터내부.png']
|
||||||
},
|
},
|
||||||
'한맥빌딩': {
|
'한맥빌딩': {
|
||||||
'7층': ['img/location_photo/한맥빌딩/7층_로비.png'],
|
'1층': ['img/location_photo/한맥빌딩/1층.png'],
|
||||||
|
'2층': ['img/location_photo/한맥빌딩/2층.png'],
|
||||||
|
'3층': ['img/location_photo/한맥빌딩/3층.png'],
|
||||||
|
'4층': ['img/location_photo/한맥빌딩/4층.png'],
|
||||||
|
'5층': ['img/location_photo/한맥빌딩/5층.png'],
|
||||||
|
'6층': ['img/location_photo/한맥빌딩/6층.png'],
|
||||||
|
'7층': ['img/location_photo/한맥빌딩/7층.png'],
|
||||||
'MDF실': [
|
'MDF실': [
|
||||||
'img/location_photo/한맥빌딩/MDF실/MDF_1.png',
|
'img/location_photo/한맥빌딩/MDF실/MDF_1.png',
|
||||||
'img/location_photo/한맥빌딩/MDF실/MDF_2.png',
|
'img/location_photo/한맥빌딩/MDF실/MDF_2.png',
|
||||||
|
|||||||
80
src/main.ts
80
src/main.ts
@@ -189,66 +189,40 @@ function initRoleSwitcher() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 로그인 처리 로직
|
* 앱 초기화 (로그인 과정 없이 즉시 시작)
|
||||||
*/
|
*/
|
||||||
function handleLogin() {
|
function initializeAppDirectly() {
|
||||||
const loginContainer = document.getElementById('login-container');
|
const loginContainer = document.getElementById('login-container');
|
||||||
const appLayout = document.getElementById('app-layout');
|
const appLayout = document.getElementById('app-layout');
|
||||||
const roleCards = document.querySelectorAll('.role-card');
|
const checkbox = document.getElementById('role-toggle-checkbox') as HTMLInputElement;
|
||||||
const userLabel = document.querySelector('.role-label.user');
|
const userLabel = document.querySelector('.role-label.user');
|
||||||
const adminLabel = document.querySelector('.role-label.admin');
|
const adminLabel = document.querySelector('.role-label.admin');
|
||||||
|
|
||||||
if (!loginContainer || !appLayout || roleCards.length === 0) return;
|
// 기본 권한 설정: 실무자 (User)
|
||||||
|
state.currentUserRole = 'user';
|
||||||
|
state.activeCategory = 'hw';
|
||||||
|
state.activeSubTab = '서버'; // 실무자 기본 탭
|
||||||
|
|
||||||
roleCards.forEach(card => {
|
// UI 상태 동기화
|
||||||
card.addEventListener('click', () => {
|
if (checkbox) checkbox.checked = false;
|
||||||
const role = card.getAttribute('data-role');
|
if (userLabel) userLabel.classList.add('active');
|
||||||
const checkbox = document.getElementById('role-toggle-checkbox') as HTMLInputElement;
|
if (adminLabel) adminLabel.classList.remove('active');
|
||||||
|
document.body.classList.remove('admin-mode');
|
||||||
if (role === 'admin') {
|
|
||||||
console.log('🔓 Entering as Admin');
|
|
||||||
|
|
||||||
state.currentUserRole = 'admin';
|
|
||||||
state.activeCategory = 'hw';
|
|
||||||
state.activeSubTab = '대시보드'; // 관리자는 대시보드로 진입
|
|
||||||
|
|
||||||
if (checkbox) checkbox.checked = true;
|
|
||||||
if (userLabel) userLabel.classList.remove('active');
|
|
||||||
if (adminLabel) adminLabel.classList.add('active');
|
|
||||||
document.body.classList.add('admin-mode');
|
|
||||||
} else if (role === 'user') {
|
|
||||||
console.log('🔓 Entering as Practitioner');
|
|
||||||
|
|
||||||
state.currentUserRole = 'user';
|
|
||||||
state.activeCategory = 'hw';
|
|
||||||
state.activeSubTab = '서버'; // 실무자는 서버 목록으로 진입
|
|
||||||
|
|
||||||
if (checkbox) checkbox.checked = false;
|
|
||||||
if (userLabel) userLabel.classList.add('active');
|
|
||||||
if (adminLabel) adminLabel.classList.remove('active');
|
|
||||||
document.body.classList.remove('admin-mode');
|
|
||||||
} else {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// UI 전환
|
// 화면 전환
|
||||||
loginContainer.style.display = 'none';
|
if (loginContainer) loginContainer.style.display = 'none';
|
||||||
appLayout.style.display = 'flex';
|
if (appLayout) appLayout.style.display = 'flex';
|
||||||
|
|
||||||
// 역할 스위처 및 앱 초기화 시작
|
// 앱 초기화
|
||||||
initRoleSwitcher();
|
initRoleSwitcher();
|
||||||
initApp();
|
initApp();
|
||||||
|
|
||||||
// 로고 클릭 시 초기화면 복귀 로직 (한 번만 등록)
|
// 로고 클릭 시 새로고침 (초기 화면 복귀 효과)
|
||||||
const brand = document.querySelector('.brand') as HTMLElement;
|
const brand = document.querySelector('.brand') as HTMLElement;
|
||||||
if (brand) {
|
if (brand) {
|
||||||
brand.style.cursor = 'pointer';
|
brand.style.cursor = 'pointer';
|
||||||
brand.onclick = () => {
|
brand.onclick = () => location.reload();
|
||||||
location.reload(); // 즉시 초기화면으로 복귀
|
}
|
||||||
};
|
|
||||||
}
|
|
||||||
});
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
document.addEventListener('DOMContentLoaded', handleLogin);
|
document.addEventListener('DOMContentLoaded', initializeAppDirectly);
|
||||||
|
|||||||
@@ -474,6 +474,7 @@ export function createListView(container: HTMLElement, config: ListViewConfig) {
|
|||||||
<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 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%;">
|
<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;" />
|
<img id="detail-photo" src="" style="display: block; max-width: 100%; max-height: 100%; width: auto; height: auto; object-fit: contain; pointer-events: none;" />
|
||||||
|
<iframe id="detail-html-map" src="" style="display: none; width: 100%; height: 100%; border: none;"></iframe>
|
||||||
<div id="detail-marker" class="layout-marker pulse-marker" style="display: none; position: absolute; z-index: 20;"></div>
|
<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 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>
|
||||||
@@ -529,60 +530,86 @@ export function createListView(container: HTMLElement, config: ListViewConfig) {
|
|||||||
const locImgs = IMAGE_LOCATIONS[bldg.trim()]?.[detail.trim()] || null;
|
const locImgs = IMAGE_LOCATIONS[bldg.trim()]?.[detail.trim()] || null;
|
||||||
const imgPath = (savedImg && locImgs?.includes(savedImg)) ? savedImg : (locImgs ? locImgs[0] : null);
|
const imgPath = (savedImg && locImgs?.includes(savedImg)) ? savedImg : (locImgs ? locImgs[0] : null);
|
||||||
|
|
||||||
|
const htmlMap = document.getElementById('detail-html-map') as HTMLIFrameElement;
|
||||||
|
const isHtmlMap = imgPath?.toLowerCase().endsWith('.html');
|
||||||
|
|
||||||
// 좌표가 없으면 사진이 있어도 '정보 없음' 상태로 유도 (사용자 요청)
|
// 좌표가 없으면 사진이 있어도 '정보 없음' 상태로 유도 (사용자 요청)
|
||||||
if (imgPath && hasCoords) {
|
if (imgPath && hasCoords) {
|
||||||
photo.src = imgPath;
|
if (isHtmlMap) {
|
||||||
photo.style.display = 'block';
|
// HTML 지도 처리
|
||||||
|
photo.style.display = 'none';
|
||||||
|
if (marker) marker.style.display = 'none';
|
||||||
|
if (overlayLayer) overlayLayer.innerHTML = '';
|
||||||
|
if (htmlMap) {
|
||||||
|
htmlMap.src = `${imgPath}?markerX=${x}&markerY=${y}`;
|
||||||
|
htmlMap.style.display = 'block';
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// 일반 이미지 지도 처리
|
||||||
|
if (htmlMap) {
|
||||||
|
htmlMap.src = '';
|
||||||
|
htmlMap.style.display = 'none';
|
||||||
|
}
|
||||||
|
photo.src = imgPath;
|
||||||
|
photo.style.display = 'block';
|
||||||
|
}
|
||||||
|
|
||||||
if (noPhoto) noPhoto.style.display = 'none';
|
if (noPhoto) noPhoto.style.display = 'none';
|
||||||
|
|
||||||
photo.onload = () => {
|
if (!isHtmlMap) {
|
||||||
const updateMarkerPos = () => {
|
photo.onload = () => {
|
||||||
const imgW = photo.clientWidth;
|
const updateMarkerPos = () => {
|
||||||
const imgH = photo.clientHeight;
|
const imgW = photo.clientWidth;
|
||||||
|
const imgH = photo.clientHeight;
|
||||||
|
|
||||||
if (marker) {
|
if (marker) {
|
||||||
marker.style.left = `calc(50% - ${imgW/2}px + ${ (parseFloat(x as string) * imgW) / 100 }px)`;
|
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.top = `calc(50% - ${imgH/2}px + ${ (parseFloat(y as string) * imgH) / 100 }px)`;
|
||||||
marker.style.display = 'block';
|
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 = '';
|
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
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);
|
||||||
};
|
};
|
||||||
updateMarkerPos();
|
}
|
||||||
window.addEventListener('resize', updateMarkerPos);
|
|
||||||
};
|
|
||||||
} else {
|
} else {
|
||||||
photo.style.display = 'none';
|
photo.style.display = 'none';
|
||||||
|
if (htmlMap) {
|
||||||
|
htmlMap.src = '';
|
||||||
|
htmlMap.style.display = 'none';
|
||||||
|
}
|
||||||
if (marker) marker.style.display = 'none';
|
if (marker) marker.style.display = 'none';
|
||||||
if (overlayLayer) overlayLayer.innerHTML = '';
|
if (overlayLayer) overlayLayer.innerHTML = '';
|
||||||
if (noPhoto) {
|
if (noPhoto) {
|
||||||
|
|||||||
Reference in New Issue
Block a user