import { IMAGE_LOCATIONS } from '../components/Modal/SharedData'; import { createIcons, X, Save, Trash2, ChevronLeft, ChevronRight } from 'lucide'; import { QRPrinter } from '../core/qr_print'; export class MapEditor { private container: HTMLElement; private wrapper: HTMLElement; private img: HTMLImageElement; private boxListEl: HTMLElement; private pathLabel: HTMLElement; private statusEl: HTMLElement; private saveBtn: HTMLButtonElement; private fileSidebar: HTMLElement; private allMapConfig: Record = {}; private boxes: any[] = []; private isDrawing: boolean = false; private startX: number = 0; private startY: number = 0; private currentBox: HTMLElement | null = null; private currentPath: string = ''; private assetOptions: {id: string, name: string}[] = []; constructor() { this.container = document.getElementById('container')!; this.wrapper = document.getElementById('wrapper')!; this.img = document.getElementById('target-img') as HTMLImageElement; this.boxListEl = document.getElementById('box-list')!; this.pathLabel = document.getElementById('current-path')!; this.statusEl = document.getElementById('save-status')!; this.saveBtn = document.getElementById('btn-save-server') as HTMLButtonElement; this.fileSidebar = document.getElementById('file-sidebar')!; } public async init() { this.renderFileSidebar(); await this.loadConfig(); await this.loadAssets(); this.bindEvents(); this.selectFirstFile(); createIcons({ icons: { X, Save, Trash2, ChevronLeft, ChevronRight } }); } private async loadAssets() { try { const res = await fetch('/api/assets/master'); const masterData = await res.json(); const allHw = [ ...(masterData.pc || []), ...(masterData.server || []), ...(masterData.storage || []), ...(masterData.network || []), ...(masterData.equipment || []), ...(masterData.survey || []), ...(masterData.officeSupplies || []), ...(masterData.pcParts || []) ]; this.assetOptions = allHw.map(a => ({ id: a.id, name: `[${a.asset_code}] ${a.asset_purpose || a.model_name || a.category}` })); } catch (err) { console.error('Failed to load assets for mapping', err); } } private renderFileSidebar() { let html = ''; Object.entries(IMAGE_LOCATIONS).forEach(([bldg, details]) => { html += `
${bldg}
`; Object.entries(details).forEach(([detail, paths]) => { paths.forEach(path => { const fileName = path.split('/').pop() || path; html += `
${fileName}
`; }); }); }); this.fileSidebar.innerHTML = html; this.fileSidebar.querySelectorAll('.file-item').forEach(item => { item.addEventListener('click', () => { this.fileSidebar.querySelectorAll('.file-item').forEach(i => i.classList.remove('active')); item.classList.add('active'); this.renderCurrentFile(); }); }); } private selectFirstFile() { const firstItem = this.fileSidebar.querySelector('.file-item') as HTMLElement; if (firstItem) { firstItem.classList.add('active'); this.renderCurrentFile(); } } private async loadConfig() { try { const res = await fetch('/api/maps'); this.allMapConfig = await res.json(); } catch (err) { console.error('Failed to load config:', err); } } private renderCurrentFile() { const activeItem = this.fileSidebar.querySelector('.file-item.active') as HTMLElement; if (!activeItem) return; this.currentPath = activeItem.dataset.path || ''; this.boxes = this.allMapConfig[this.currentPath] || []; this.pathLabel.textContent = this.currentPath; this.img.src = this.currentPath; this.render(); } private bindEvents() { this.wrapper.addEventListener('mousedown', (e) => { if (e.button !== 0) return; this.isDrawing = true; const rect = this.wrapper.getBoundingClientRect(); this.startX = e.clientX - rect.left; this.startY = e.clientY - rect.top; this.currentBox = document.createElement('div'); this.currentBox.className = 'draw-box'; this.currentBox.style.left = this.startX + 'px'; this.currentBox.style.top = this.startY + 'px'; const label = document.createElement('div'); label.className = 'box-label'; label.textContent = '#' + (this.boxes.length + 1); this.currentBox.appendChild(label); this.wrapper.appendChild(this.currentBox); }); window.addEventListener('mousemove', (e) => { if (!this.isDrawing || !this.currentBox) return; const rect = this.wrapper.getBoundingClientRect(); const currentX = Math.max(0, Math.min(e.clientX - rect.left, rect.width)); const currentY = Math.max(0, Math.min(e.clientY - rect.top, rect.height)); const width = currentX - this.startX; const height = currentY - this.startY; this.currentBox.style.width = Math.abs(width) + 'px'; this.currentBox.style.height = Math.abs(height) + 'px'; this.currentBox.style.left = (width > 0 ? this.startX : currentX) + 'px'; this.currentBox.style.top = (height > 0 ? this.startY : currentY) + 'px'; }); window.addEventListener('mouseup', () => { if (!this.isDrawing || !this.currentBox) return; this.isDrawing = false; const width = parseFloat(this.currentBox.style.width); const height = parseFloat(this.currentBox.style.height); if (width > 3 && height > 3) { const rect = this.wrapper.getBoundingClientRect(); const boxData = { x: (parseFloat(this.currentBox.style.left) / rect.width * 100).toFixed(2), y: (parseFloat(this.currentBox.style.top) / rect.height * 100).toFixed(2), w: (width / rect.width * 100).toFixed(2), h: (height / rect.height * 100).toFixed(2), asset_id: null }; this.boxes.push(boxData); this.render(); } this.currentBox.remove(); this.currentBox = null; }); (window as any).removeBox = (index: number) => { this.boxes.splice(index, 1); this.render(); }; document.getElementById('btn-clear-all')?.addEventListener('click', () => { if(confirm('모든 박스를 삭제할까요?')) { this.boxes = []; this.render(); } }); document.getElementById('btn-print-map-qrs')?.addEventListener('click', () => { if (this.boxes.length === 0) { alert('인쇄할 구역이 없습니다.'); return; } const cleanKey = getCleanMapKey(this.currentPath); const locName = getLocationName(this.currentPath); const items = this.boxes.map((box, index) => { const padIdx = String(index + 1).padStart(3, '0'); const locCode = `LOC-${cleanKey}-${padIdx}`; const locDetail = getLocationDetail(this.currentPath, index); return { type: 'location' as const, code: locCode, title: '[ HM LOCATION ]', subtitle: locName, dept: locDetail }; }); QRPrinter.print(items); }); document.getElementById('btn-save-server')?.addEventListener('click', () => this.saveToServer()); } private async saveToServer() { if (!this.currentPath) return; try { this.saveBtn.disabled = true; this.saveBtn.textContent = '저장 중...'; const res = await fetch('/api/maps/save', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ path: this.currentPath, boxes: this.boxes }) }); if (res.ok) { this.allMapConfig[this.currentPath] = [...this.boxes]; this.statusEl.textContent = '✅ 서버 저장 완료 (' + new Date().toLocaleTimeString() + ')'; setTimeout(() => this.statusEl.textContent = '', 3000); } else { alert('저장 실패!'); } } catch (err) { alert('서버 연결 오류!'); } finally { this.saveBtn.disabled = false; this.saveBtn.textContent = '서버에 즉시 저장'; } } private render() { this.boxListEl.innerHTML = ''; const oldBoxes = this.wrapper.querySelectorAll('.placed-box'); oldBoxes.forEach(b => b.remove()); this.boxes.forEach((box, i) => { const div = document.createElement('div'); div.className = 'placed-box'; div.style.left = box.x + '%'; div.style.top = box.y + '%'; div.style.width = box.w + '%'; div.style.height = box.h + '%'; const label = document.createElement('div'); label.className = 'box-label'; label.textContent = '#' + (i + 1); div.appendChild(label); this.wrapper.appendChild(div); // Create asset options dropdown let optionsHtml = ''; this.assetOptions.forEach(opt => { const selected = box.asset_id === opt.id ? 'selected' : ''; optionsHtml += ``; }); const item = document.createElement('div'); item.className = 'box-item'; item.innerHTML = `
#${i+1}
`; this.boxListEl.appendChild(item); }); // Add events to new inputs and selects this.boxListEl.querySelectorAll('input, select').forEach(input => { input.addEventListener('change', (e) => { const target = e.target as HTMLInputElement | HTMLSelectElement; const index = parseInt(target.dataset.index!); const prop = target.dataset.prop!; if (this.boxes[index]) { if (prop === 'asset_id') { this.boxes[index][prop] = target.value || null; } else { this.boxes[index][prop] = parseFloat(target.value).toFixed(2); this.render(); // Re-render to update the map visual size } } }); }); (window as any).printBoxQR = (index: number) => { const box = this.boxes[index]; if (!box) return; const cleanKey = getCleanMapKey(this.currentPath); const padIdx = String(index + 1).padStart(3, '0'); const locCode = `LOC-${cleanKey}-${padIdx}`; const locDetail = getLocationDetail(this.currentPath, index); const locName = getLocationName(this.currentPath); QRPrinter.print([{ type: 'location', code: locCode, title: '[ HM LOCATION ]', subtitle: locName, dept: locDetail }]); }; } } function getCleanMapKey(path: string) { let clean = path.replace('img/location_photo/', '').replace('.png', ''); clean = clean.replace('서관', 'W').replace('동관', 'E'); clean = clean.replace('한맥빌딩/MDF실/MDF_', 'HAN-MDF-'); clean = clean.replace('기술개발센터/서버실/서버실_', 'DEV-SVR-'); clean = clean.replace(/\//g, '-'); return clean; } function getLocationName(path: string) { if (path.includes('IDC')) return 'IDC'; if (path.includes('한맥빌딩')) return '한맥빌딩'; if (path.includes('기술개발센터')) return '기술개발센터'; return '기타'; } function getLocationDetail(path: string, idx: number) { let clean = path.replace('img/location_photo/', '').replace('.png', ''); let parts = clean.split('/'); let lastPart = parts[parts.length - 1]; return `${lastPart} 구역 자리 #${idx + 1}`; }