368 lines
13 KiB
TypeScript
368 lines
13 KiB
TypeScript
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<string, any[]> = {};
|
||
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 += `<div class="folder-item">${bldg}</div>`;
|
||
Object.entries(details).forEach(([detail, paths]) => {
|
||
paths.forEach(path => {
|
||
const fileName = path.split('/').pop() || path;
|
||
html += `<div class="file-item" data-path="${path}">${fileName}</div>`;
|
||
});
|
||
});
|
||
});
|
||
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 = '<option value="">-- 자산 매핑 안 됨 --</option>';
|
||
this.assetOptions.forEach(opt => {
|
||
const selected = box.asset_id === opt.id ? 'selected' : '';
|
||
optionsHtml += `<option value="${opt.id}" ${selected}>${opt.name}</option>`;
|
||
});
|
||
|
||
const item = document.createElement('div');
|
||
item.className = 'box-item';
|
||
item.innerHTML = `
|
||
<div class="box-header">
|
||
<span class="box-index">#${i+1}</span>
|
||
<div style="display: flex; gap: 0.5rem; align-items: center;">
|
||
<button class="btn btn-outline btn-sm" onclick="printBoxQR(${i})" style="padding: 2px 6px; font-size: 11px; margin: 0; cursor: pointer;">QR</button>
|
||
<button class="btn-del" onclick="removeBox(${i})">×</button>
|
||
</div>
|
||
</div>
|
||
<div class="box-inputs margin-bottom">
|
||
<select data-index="${i}" data-prop="asset_id">
|
||
${optionsHtml}
|
||
</select>
|
||
</div>
|
||
<div class="box-inputs">
|
||
<div class="input-group">
|
||
<label>X</label>
|
||
<input type="number" step="0.01" value="${box.x}" data-index="${i}" data-prop="x">
|
||
</div>
|
||
<div class="input-group">
|
||
<label>Y</label>
|
||
<input type="number" step="0.01" value="${box.y}" data-index="${i}" data-prop="y">
|
||
</div>
|
||
<div class="input-group">
|
||
<label>W</label>
|
||
<input type="number" step="0.01" value="${box.w}" data-index="${i}" data-prop="w">
|
||
</div>
|
||
<div class="input-group">
|
||
<label>H</label>
|
||
<input type="number" step="0.01" value="${box.h}" data-index="${i}" data-prop="h">
|
||
</div>
|
||
</div>
|
||
`;
|
||
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}`;
|
||
}
|