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:
2026-06-12 10:29:42 +09:00
parent 0c1977f707
commit 9186eb50ca
6 changed files with 295 additions and 201 deletions

View File

@@ -19,36 +19,6 @@
</head>
<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>&copy; 2026 BARON Consultant Co,Ltd. All rights reserved.</p>
</div>
</div>
</div>
<div class="app-layout" id="app-layout" style="display: none;">
<!-- Single-Line Integrated Header -->
<header class="main-header">

View File

@@ -36,25 +36,24 @@ const handleError = (res, err, label) => {
// --- Global Constants ---
const CATEGORY_TABLE_MAP = {
pc: 'asset_pc',
server: 'asset_server',
storage: 'asset_storage',
network: 'asset_remote',
equipment: 'asset_equipment',
officeSupplies: 'asset_office_supplies',
survey: 'asset_survey',
vip: 'asset_vip',
swInternal: 'sw_internal',
swExternal: 'sw_external',
cloud: 'asset_cloud',
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',
swUsers: 'sw_assignment',
logs: 'asset_history'
};
const ASSET_TABLES = [
'asset_pc', 'asset_server', 'asset_storage', 'asset_remote',
'asset_equipment', 'asset_office_supplies', 'asset_survey', 'asset_vip'
'asset_core'
];
// --- 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)
app.get('/api/assets/master', async (req, res) => {
let connection;
try {
const connection = await pool.getConnection();
connection = await pool.getConnection();
const masterData = {
pc: [], server: [], storage: [], network: [],
@@ -110,6 +110,7 @@ app.get('/api/assets/master', async (req, res) => {
swInternal: [], swExternal: [], swUsers: [], users: [], logs: []
};
// Load from V3 Normalized Schema
const [rows] = await connection.query(`
SELECT
c.*,
@@ -156,10 +157,11 @@ app.get('/api/assets/master', async (req, res) => {
masterData.users = users;
masterData.logs = logs;
connection.release();
res.json(masterData);
} catch (err) {
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 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 logDate = new Date().toISOString().split('T')[0]; // YYYY-MM-DD
const logUser = '관리자';
// 조직 변동 감지 (null/undefined/empty string 세이프 처리)
// 3.0.1 Core 변동 감지 (Dept, User)
const oldDept = oldCore.current_dept || '';
const newDept = asset.current_dept || '';
if (newDept !== '' && oldDept !== newDept) {
@@ -198,7 +196,6 @@ app.post('/api/asset/:category/save', async (req, res) => {
});
}
// 사용자 변동 감지
const oldUser = oldCore.user_current || '';
const newUser = asset.user_current || '';
if (newUser !== '' && oldUser !== newUser) {
@@ -211,26 +208,27 @@ app.post('/api/asset/:category/save', async (req, res) => {
});
}
// 유형/용도 감지
const oldType = oldCore.asset_type || '';
const newType = asset.asset_type || '';
if (newType !== '' && oldType !== newType) {
historyLogs.push({
event_type: 'ROLE_CHANGE',
details: `[유형 변경] ${oldType || '(없음)'} -> ${newType}`
});
}
// 3.0.2 Spec 감지 (CPU, RAM, GPU, OS, Mainboard 등)
const specFieldsToTrack = [
{ key: 'cpu', label: 'CPU' },
{ key: 'ram', label: 'RAM' },
{ key: 'gpu', label: 'GPU' },
{ key: 'os', label: 'OS' },
{ key: 'mainboard', label: '메인보드' }
];
const oldRole = oldCore.current_role || '';
const newRole = asset.current_role || '';
if (newRole !== '' && oldRole !== newRole) {
historyLogs.push({
event_type: 'ROLE_CHANGE',
details: `[용도 변경] ${oldRole || '(없음)'} -> ${newRole}`
});
}
specFieldsToTrack.forEach(field => {
const oldVal = String(oldSpec[field.key] || '').trim();
const newVal = String(asset[field.key] || '').trim();
if (newVal !== '' && oldVal !== newVal) {
historyLogs.push({
event_type: 'SPEC_CHANGE',
details: `[사양 변경] ${field.label}: ${oldVal || '(없음)'} -> ${newVal}`
});
}
});
// 상태 변경 감지
// 3.0.3 상태 변경 감지
const oldStatus = oldSpec.hw_status || '';
const newStatus = asset.hw_status || '';
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) {
await connection.query(
@@ -256,8 +252,23 @@ app.post('/api/asset/:category/save', async (req, res) => {
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));
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
const specFields = ['hw_status', 'model_name', 'mainboard', 'os', 'cpu', 'ram', 'gpu', 'monitoring', 'price', 'monitor_inch', 'serial_num'];

View File

@@ -268,10 +268,21 @@ class HwAssetModal extends BaseModal {
});
document.getElementById('btn-gen-hw-code')?.addEventListener('click', async () => {
const type = typeSelect.value;
const cat = categorySelect.value;
if (!cat) { alert('구분을 먼저 선택해주세요.'); return; }
const prefix = TYPE_PREFIX_MAP[cat] || 'ETC';
const purchaseDate = (document.getElementById('hw-purchase_date') as HTMLInputElement)?.value || '';
if (!type) { alert('유형을 먼저 선택해주세요.'); return; }
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 {
const res = await fetch(`http://${location.hostname}:3000/api/generate-asset-code?prefix=${prefix}&purchaseDate=${purchaseDate}`);
const data = await res.json();
@@ -690,29 +701,110 @@ class HwAssetModal extends BaseModal {
overlay.className = 'image-picker-overlay';
const renderContent = () => {
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 = `
<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;">&times;</button></div>
<div class="image-picker-content"><div class="layout-map-container" id="picker-container"><img src="${imgPath}" class="layout-map-img" /><div id="picker-marker" class="layout-marker hidden"></div><div class="digital-overlay-layer">${digitalMap}</div></div></div>
<div class="image-picker-header">
<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;">&times;</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>`;
let selectedX = ''; let selectedY = '';
const container = overlay.querySelector('#picker-container') as HTMLElement;
const marker = overlay.querySelector('#picker-marker') as HTMLElement;
container.addEventListener('click', (e) => {
const rect = container.getBoundingClientRect();
const x = ((e.clientX - rect.left) / rect.width) * 100;
const y = ((e.clientY - rect.top) / rect.height) * 100;
selectedX = x.toFixed(2); selectedY = y.toFixed(2);
marker.style.left = `${selectedX}%`; marker.style.top = `${selectedY}%`; marker.classList.remove('hidden');
});
overlay.querySelector('.btn-close-picker')?.addEventListener('click', () => overlay.remove());
overlay.querySelector('#btn-picker-cancel')?.addEventListener('click', () => overlay.remove());
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();
});
if (isMulti) {
overlay.querySelector('.picker-nav.prev')?.addEventListener('click', (e) => { e.stopPropagation(); if (currentIdx > 0) { currentIdx--; renderContent(); } });
overlay.querySelector('.picker-nav.next')?.addEventListener('click', (e) => { e.stopPropagation(); if (currentIdx < imagePaths.length - 1) { currentIdx++; renderContent(); } });
}
if (isHtmlMap) {
// HTML 지도 메시지 리스너
const handleMessage = (e: MessageEvent) => {
if (e.data.type === 'PICK_LOCATION') {
selectedX = e.data.x;
selectedY = e.data.y;
}
};
window.addEventListener('message', handleMessage);
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);
}
@@ -720,13 +812,26 @@ class HwAssetModal extends BaseModal {
private openImagePreview(imagePath: string, title: string, x: string, y: string) {
const overlay = document.createElement('div');
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 = `
<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;">&times;</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>`;
document.body.appendChild(overlay);
if (digitalMap) {
if (!isHtmlMap && digitalMap) {
const curX = parseFloat(x || '0'); const curY = parseFloat(y || '0');
overlay.querySelectorAll('rect').forEach(rect => {
const sx = parseFloat(rect.getAttribute('x') || '0');

View File

@@ -30,7 +30,7 @@ export const CATEGORY_TYPE_MAP: Record<string, string[]> = {
// 설치위치 종속성 데이터
export const LOCATION_DATA: Record<string, string[]> = {
'한맥빌딩': ['MDF실', '1층', '2층', '3층', '4층', '5층', '6층', '7층', '파고라'],
'기술개발센터': ['서버실', 'BLUE ZONE', 'GREEN ZONE', 'ORANGE ZONE', '회의실2', '회의실3', '회의실5', '회의실6', '회의실7', '사이니지룸'],
'기술개발센터': ['서버실', '센터내부'],
'유니온빌딩': ['4층', '5층', '6층'],
'뉴코아빌딩': ['4층', '6층', '7층'],
'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/기술개발센터/서버실/서버실_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실': [
'img/location_photo/한맥빌딩/MDF실/MDF_1.png',
'img/location_photo/한맥빌딩/MDF실/MDF_2.png',

View File

@@ -189,66 +189,40 @@ function initRoleSwitcher() {
}
/**
* 로그인 처리 로직
* 앱 초기화 (로그인 과정 없이 즉시 시작)
*/
function handleLogin() {
function initializeAppDirectly() {
const loginContainer = document.getElementById('login-container');
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 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 => {
card.addEventListener('click', () => {
const role = card.getAttribute('data-role');
const checkbox = document.getElementById('role-toggle-checkbox') as HTMLInputElement;
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 상태 동기화
if (checkbox) checkbox.checked = false;
if (userLabel) userLabel.classList.add('active');
if (adminLabel) adminLabel.classList.remove('active');
document.body.classList.remove('admin-mode');
// UI 전환
loginContainer.style.display = 'none';
appLayout.style.display = 'flex';
// 역할 스위처 및 앱 초기화 시작
initRoleSwitcher();
initApp();
// 로고 클릭 시 초기화면 복귀 로직 (한 번만 등록)
const brand = document.querySelector('.brand') as HTMLElement;
if (brand) {
brand.style.cursor = 'pointer';
brand.onclick = () => {
location.reload(); // 즉시 초기화면으로 복귀
};
}
});
});
// 화면 전환
if (loginContainer) loginContainer.style.display = 'none';
if (appLayout) appLayout.style.display = 'flex';
// 앱 초기화
initRoleSwitcher();
initApp();
// 로고 클릭 시 새로고침 (초기 화면 복귀 효과)
const brand = document.querySelector('.brand') as HTMLElement;
if (brand) {
brand.style.cursor = 'pointer';
brand.onclick = () => location.reload();
}
}
document.addEventListener('DOMContentLoaded', handleLogin);
document.addEventListener('DOMContentLoaded', initializeAppDirectly);

View File

@@ -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 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;" />
<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-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>
@@ -529,60 +530,86 @@ export function createListView(container: HTMLElement, config: ListViewConfig) {
const locImgs = IMAGE_LOCATIONS[bldg.trim()]?.[detail.trim()] || 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) {
photo.src = imgPath;
photo.style.display = 'block';
if (isHtmlMap) {
// 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';
photo.onload = () => {
const updateMarkerPos = () => {
const imgW = photo.clientWidth;
const imgH = photo.clientHeight;
if (!isHtmlMap) {
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 = '';
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);
};
updateMarkerPos();
window.addEventListener('resize', updateMarkerPos);
};
}
} else {
photo.style.display = 'none';
if (htmlMap) {
htmlMap.src = '';
htmlMap.style.display = 'none';
}
if (marker) marker.style.display = 'none';
if (overlayLayer) overlayLayer.innerHTML = '';
if (noPhoto) {