feat(map): implement robust ID-based asset mapping and fix UI rendering inconsistencies

- Migrated map mapping from fuzzy coordinates to precise asset_id tracking
- Updated MapEditor to allow explicit asset assignment via dropdown
- Fixed LocationView rendering logic to search across all hardware categories
- Standardized map indicators to always render as areas (boxes) with minimum size
- Restored stable CSS max-height for detail modal photos to prevent clipping
- Synced MapEditor saves directly to database via asset_id
This commit is contained in:
2026-06-18 19:49:15 +09:00
parent e77c4854cb
commit aab1f91d3d
5 changed files with 350 additions and 355 deletions

File diff suppressed because it is too large Load Diff

View File

@@ -694,15 +694,12 @@ app.post('/api/maps/save', async (req, res) => {
// 3. Sync Database Assets (asset_location table) // 3. Sync Database Assets (asset_location table)
connection = await pool.getConnection(); connection = await pool.getConnection();
for (let i = 0; i < boxes.length; i++) { for (const box of boxes) {
const newBox = boxes[i]; if (box.asset_id) {
const oldBox = oldBoxes[i]; console.log(`Syncing asset ${box.asset_id} to new position: [${box.x}, ${box.y}]`);
if (oldBox && (String(oldBox.x) !== String(newBox.x) || String(oldBox.y) !== String(newBox.y))) {
console.log(`Syncing moved box #${i+1} on ${path}: [${oldBox.x}, ${oldBox.y}] -> [${newBox.x}, ${newBox.y}]`);
await connection.query( await connection.query(
'UPDATE asset_location SET loc_x = ?, loc_y = ? WHERE loc_x = ? AND loc_y = ? AND is_active = 1', 'UPDATE asset_location SET loc_x = ?, loc_y = ? WHERE asset_id = ? AND is_active = 1',
[newBox.x, newBox.y, oldBox.x, oldBox.y] [box.x, box.y, box.asset_id]
); );
} }
} }

View File

@@ -34,6 +34,8 @@ export const state: AppState = {
} }
}; };
(window as any).__itam_state = state;
/** /**
* 통합 V2 스키마에 맞춘 데이터 로드 * 통합 V2 스키마에 맞춘 데이터 로드
*/ */

View File

@@ -25,16 +25,21 @@ export async function renderLocationView(container: HTMLElement) {
: []; : [];
const mapPath = locImages[currentPage] || ''; const mapPath = locImages[currentPage] || '';
// 조회 모드: 자산이 등록된 구역만 필터링하여 노출 // 조회 모드: 설정 파일에 정의된 asset_id를 기준으로 자산 데이터 매핑
const allBoxes = mapConfig[mapPath] || []; const allBoxes = mapConfig[mapPath] || [];
const boxes = allBoxes.filter((box: any) => const boxes = allBoxes.filter((box: any) => box.asset_id != null);
state.masterData.hw.some(a =>
a.location === currentLoc && // 모든 하드웨어 카테고리에서 자산 검색
a.location_detail === currentDetail && const allHwAssets = [
String(a.loc_x) === String(box.x) && ...state.masterData.pc,
String(a.loc_y) === String(box.y) ...state.masterData.server,
) ...state.masterData.storage,
); ...state.masterData.network,
...state.masterData.equipment,
...state.masterData.survey,
...state.masterData.officeSupplies,
...state.masterData.pcParts
];
container.innerHTML = ` container.innerHTML = `
<div class="location-view-wrapper"> <div class="location-view-wrapper">
@@ -81,14 +86,17 @@ export async function renderLocationView(container: HTMLElement) {
<img src="${mapPath}" id="main-map-img" class="map-image"> <img src="${mapPath}" id="main-map-img" class="map-image">
<div id="box-overlay" class="map-overlay"> <div id="box-overlay" class="map-overlay">
${boxes.map((box: any, idx: number) => { ${boxes.map((box: any, idx: number) => {
const name = box.name || `#${idx+1}`; const asset = allHwAssets.find(a => a.id === box.asset_id);
const name = asset ? (asset.asset_purpose || asset.asset_code) : (box.name || `#${idx+1}`);
// w, h가 없거나 너무 작으면 최소 크기(3%) 보장하여 영역으로 표시
const width = Math.max(parseFloat(box.w || '3'), 3);
const height = Math.max(parseFloat(box.h || '3'), 3);
return ` return `
<div class="location-box-point" <div class="location-box-area"
data-asset-id="${box.asset_id}"
data-name="${name}" data-name="${name}"
data-x="${box.x}" style="left:${box.x}%; top:${box.y}%; width:${width}%; height:${height}%;
data-y="${box.y}" border: 2px solid var(--primary-color); background: rgba(30, 81, 73, 0.1); cursor:pointer; pointer-events: auto; position: absolute;">
style="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> </div>
`}).join('')} `}).join('')}
</div> </div>
@@ -170,20 +178,15 @@ export async function renderLocationView(container: HTMLElement) {
chkBox.addEventListener('change', handleToggle); chkBox.addEventListener('change', handleToggle);
} }
container.querySelectorAll('.location-box-point').forEach(box => { container.querySelectorAll('.location-box-area').forEach(box => {
box.addEventListener('click', () => { box.addEventListener('click', () => {
const x = box.getAttribute('data-x'); const assetId = box.getAttribute('data-asset-id');
const y = box.getAttribute('data-y'); if (!assetId) return;
const targetAsset = state.masterData.hw.find(a => const targetAsset = allHwAssets.find(a => a.id === assetId);
a.location === currentLoc &&
a.location_detail === currentDetail &&
String(a.loc_x) === String(x) &&
String(a.loc_y) === String(y)
);
if (targetAsset) renderAssetDetail(targetAsset); if (targetAsset) renderAssetDetail(targetAsset);
container.querySelectorAll('.location-box-point').forEach(b => (b as HTMLElement).style.background = 'rgba(30, 81, 73, 0.1)'); container.querySelectorAll('.location-box-area').forEach(b => (b as HTMLElement).style.background = 'rgba(30, 81, 73, 0.1)');
(box as HTMLElement).style.background = 'rgba(30, 81, 73, 0.4)'; (box as HTMLElement).style.background = 'rgba(30, 81, 73, 0.4)';
}); });
}); });

View File

@@ -18,6 +18,7 @@ export class MapEditor {
private startY: number = 0; private startY: number = 0;
private currentBox: HTMLElement | null = null; private currentBox: HTMLElement | null = null;
private currentPath: string = ''; private currentPath: string = '';
private assetOptions: {id: string, name: string}[] = [];
constructor() { constructor() {
this.container = document.getElementById('container')!; this.container = document.getElementById('container')!;
@@ -33,11 +34,35 @@ export class MapEditor {
public async init() { public async init() {
this.renderFileSidebar(); this.renderFileSidebar();
await this.loadConfig(); await this.loadConfig();
await this.loadAssets();
this.bindEvents(); this.bindEvents();
this.selectFirstFile(); this.selectFirstFile();
createIcons({ icons: { X, Save, Trash2, ChevronLeft, ChevronRight } }); createIcons({ icons: { X, Save, Trash2, ChevronLeft, ChevronRight } });
} }
private async loadAssets() {
try {
const res = await fetch(`http://${location.hostname}:3000/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() { private renderFileSidebar() {
let html = ''; let html = '';
Object.entries(IMAGE_LOCATIONS).forEach(([bldg, details]) => { Object.entries(IMAGE_LOCATIONS).forEach(([bldg, details]) => {
@@ -137,7 +162,8 @@ export class MapEditor {
x: (parseFloat(this.currentBox.style.left) / rect.width * 100).toFixed(2), x: (parseFloat(this.currentBox.style.left) / rect.width * 100).toFixed(2),
y: (parseFloat(this.currentBox.style.top) / rect.height * 100).toFixed(2), y: (parseFloat(this.currentBox.style.top) / rect.height * 100).toFixed(2),
w: (width / rect.width * 100).toFixed(2), w: (width / rect.width * 100).toFixed(2),
h: (height / rect.height * 100).toFixed(2) h: (height / rect.height * 100).toFixed(2),
asset_id: null
}; };
this.boxes.push(boxData); this.boxes.push(boxData);
this.render(); this.render();
@@ -210,6 +236,13 @@ export class MapEditor {
this.wrapper.appendChild(div); 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'); const item = document.createElement('div');
item.className = 'box-item'; item.className = 'box-item';
item.innerHTML = ` item.innerHTML = `
@@ -217,6 +250,11 @@ export class MapEditor {
<span class="box-index">#${i+1}</span> <span class="box-index">#${i+1}</span>
<button class="btn-del" onclick="removeBox(${i})">×</button> <button class="btn-del" onclick="removeBox(${i})">×</button>
</div> </div>
<div class="box-inputs" style="margin-bottom: 8px;">
<select data-index="${i}" data-prop="asset_id" style="width: 100%; padding: 4px;">
${optionsHtml}
</select>
</div>
<div class="box-inputs"> <div class="box-inputs">
<div class="input-group"> <div class="input-group">
<label>X</label> <label>X</label>
@@ -239,17 +277,20 @@ export class MapEditor {
this.boxListEl.appendChild(item); this.boxListEl.appendChild(item);
}); });
// Add events to new inputs // Add events to new inputs and selects
this.boxListEl.querySelectorAll('input').forEach(input => { this.boxListEl.querySelectorAll('input, select').forEach(input => {
input.addEventListener('change', (e) => { input.addEventListener('change', (e) => {
const target = e.target as HTMLInputElement; const target = e.target as HTMLInputElement | HTMLSelectElement;
const index = parseInt(target.dataset.index!); const index = parseInt(target.dataset.index!);
const prop = target.dataset.prop!; const prop = target.dataset.prop!;
const val = parseFloat(target.value).toFixed(2);
if (this.boxes[index]) { if (this.boxes[index]) {
this.boxes[index][prop] = val; if (prop === 'asset_id') {
this.render(); // Re-render to update the map and sync other inputs 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
}
} }
}); });
}); });