Merge branch 'origin/QR_setting' into thoon

This commit is contained in:
이태훈
2026-06-25 11:39:01 +09:00
66 changed files with 7789 additions and 5427 deletions

View File

@@ -0,0 +1,427 @@
import { state, loadMasterDataFromDB } from '../core/state';
import { openHwModal } from '../components/Modal/HWModal';
/**
* 실사 점검 승인 대시보드 뷰 (Vercel Style Clean layout)
*/
export async function renderAuditApprovalView(container: HTMLElement) {
if (!container) return;
// 1. CSS Stylesheet Injection
const styleId = 'audit-approval-view-style';
if (!document.getElementById(styleId)) {
const style = document.createElement('style');
style.id = styleId;
style.innerHTML = `
.audit-container {
display: flex;
flex-direction: column;
height: calc(100vh - var(--header-height) - 48px);
background-color: var(--canvas);
color: var(--text-main);
padding: 1.5rem;
box-sizing: border-box;
}
.audit-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 1rem;
flex-shrink: 0;
}
.audit-title-area {
display: flex;
align-items: center;
gap: 0.75rem;
}
.audit-title {
font-size: 1.25rem;
font-weight: 700;
}
.audit-badge {
background-color: var(--primary-soft);
color: var(--primary);
font-size: 0.75rem;
font-weight: 700;
padding: 0.15rem 0.5rem;
border-radius: 9999px;
border: 1px solid rgba(59, 130, 246, 0.2);
}
.audit-actions {
display: flex;
gap: 0.5rem;
}
.audit-btn {
display: inline-flex;
align-items: center;
justify-content: center;
padding: 0.4rem 0.8rem;
font-size: 0.8rem;
font-weight: 600;
border-radius: 8px;
cursor: pointer;
transition: all 0.2s;
border: 1px solid var(--hairline);
background-color: var(--canvas-soft);
color: var(--text-main);
}
.audit-btn:hover {
background-color: var(--canvas-soft-2);
}
.audit-btn-primary {
background-color: var(--primary);
color: #fff;
border-color: var(--primary);
}
.audit-btn-primary:hover {
background-color: var(--primary-hover);
}
.audit-btn-danger {
background-color: rgba(239, 68, 68, 0.1);
color: var(--danger);
border-color: rgba(239, 68, 68, 0.2);
}
.audit-btn-danger:hover {
background-color: rgba(239, 68, 68, 0.2);
}
/* Data Table Custom Vercel layout */
.audit-table-wrapper {
flex: 1;
overflow: auto;
border: 1px solid var(--hairline);
border-radius: 12px;
background-color: var(--canvas-soft);
}
.audit-table {
width: 100%;
border-collapse: collapse;
text-align: left;
font-size: 0.825rem;
}
.audit-table th {
background-color: var(--canvas-soft-2);
color: var(--text-muted);
font-weight: 600;
padding: 0.6rem 0.8rem;
border-bottom: 1px solid var(--hairline);
position: sticky;
top: 0;
z-index: 10;
font-size: 0.75rem;
text-transform: uppercase;
letter-spacing: 0.05em;
}
.audit-table td {
padding: 0.75rem 0.8rem;
border-bottom: 1px solid var(--hairline);
vertical-align: middle;
}
.audit-table tr:last-child td {
border-bottom: none;
}
.audit-table tr:hover td {
background-color: var(--canvas-soft-2);
}
.audit-checkbox {
width: 15px;
height: 15px;
cursor: pointer;
accent-color: var(--primary);
}
.link-asset-code {
color: var(--primary);
text-decoration: underline;
font-weight: 700;
cursor: pointer;
}
.link-asset-code:hover {
color: var(--primary-hover);
}
.location-badge-diff {
background-color: rgba(245, 158, 11, 0.12);
color: #d97706;
border: 1px solid rgba(245, 158, 11, 0.25);
padding: 0.15rem 0.4rem;
border-radius: 4px;
font-size: 0.75rem;
font-weight: 600;
display: inline-block;
}
.location-badge-same {
background-color: rgba(16, 185, 129, 0.08);
color: #059669;
border: 1px solid rgba(16, 185, 129, 0.18);
padding: 0.15rem 0.4rem;
border-radius: 4px;
font-size: 0.75rem;
font-weight: 600;
display: inline-block;
}
/* Empty State Illustration Layout */
.audit-empty-state {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: 3rem;
gap: 1rem;
height: 100%;
color: var(--text-muted);
}
.audit-empty-icon {
font-size: 3rem;
color: var(--hairline);
}
`;
document.head.appendChild(style);
}
let pendingData: any[] = [];
// Function to load data and render layout
async function loadAndRender() {
try {
container.innerHTML = `
<div class="audit-container">
<div class="audit-header">
<div class="audit-title-area">
<span class="audit-title">실사 점검 승인 관리</span>
<span id="audit-count-badge" class="audit-badge">조회 중...</span>
</div>
<div class="audit-actions">
<button id="btn-audit-refresh" class="audit-btn"><i data-lucide="refresh-ccw" style="width:14px; height:14px; margin-right:4px;"></i> 새로고침</button>
<button id="btn-audit-reject" class="audit-btn audit-btn-danger" disabled>선택 반려</button>
<button id="btn-audit-approve" class="audit-btn audit-btn-primary" disabled>선택 승인</button>
</div>
</div>
<div id="audit-content-area" class="audit-table-wrapper">
<div style="padding: 2rem; text-align: center; color: var(--text-muted);">실사 내역을 불러오고 있습니다...</div>
</div>
</div>
`;
bindHeaderEvents();
await fetchPendingList();
} catch (err) {
console.error('Failed to init audit view:', err);
}
}
async function fetchPendingList() {
try {
const res = await fetch('/api/audit/pending');
pendingData = await res.json();
renderTable();
} catch (err) {
console.error('Failed to fetch pending audits:', err);
const contentArea = document.getElementById('audit-content-area')!;
contentArea.innerHTML = `<div style="padding: 3rem; text-align: center; color: var(--danger); font-weight: 600;">데이터를 불러오는 중 네트워크 에러가 발생했습니다.</div>`;
}
}
function renderTable() {
const badge = document.getElementById('audit-count-badge')!;
badge.textContent = `대기 ${pendingData.length}`;
const contentArea = document.getElementById('audit-content-area')!;
if (pendingData.length === 0) {
contentArea.innerHTML = `
<div class="audit-empty-state">
<div class="audit-empty-icon">✓</div>
<div style="font-size: 1.05rem; font-weight: 700; color: var(--text-main);">대기 중인 실사 내역이 없습니다</div>
<div style="font-size: 0.8rem;">현장에서 스캐너로 자산을 스캔하면 실시간으로 여기에 등록됩니다.</div>
</div>
`;
updateActionButtons();
return;
}
let tbodyRows = '';
pendingData.forEach((row, i) => {
// Format scanned date
const dateStr = new Date(row.scanned_at).toLocaleString('ko-KR');
// Check if location actually changed
const oldLocFull = row.old_location ? `${row.old_location} ${row.old_location_detail || ''}`.trim() : '미배치';
const newLocFull = `${row.location_name} ${row.location_detail || ''}`.trim();
const isDiff = oldLocFull !== newLocFull;
tbodyRows += `
<tr>
<td style="width: 40px; text-align: center;">
<input type="checkbox" class="audit-checkbox row-select" data-id="${row.id}" />
</td>
<td>
<span class="link-asset-code" data-index="${i}">${row.asset_code}</span>
</td>
<td>${row.asset_purpose || '-'}</td>
<td><span class="badge" style="font-size: 11px;">${row.asset_type || 'IT자산'}</span></td>
<td><span style="color: var(--text-muted);">${oldLocFull}</span></td>
<td>
<span class="${isDiff ? 'location-badge-diff' : 'location-badge-same'}">${newLocFull}</span>
</td>
<td style="color: var(--text-muted); font-size: 11px;">${dateStr}</td>
</tr>
`;
});
contentArea.innerHTML = `
<table class="audit-table">
<thead>
<tr>
<th style="width: 40px; text-align: center;">
<input type="checkbox" class="audit-checkbox" id="chk-audit-all" />
</th>
<th>자산번호</th>
<th>자산용도</th>
<th>자산유형</th>
<th>기존 위치</th>
<th>실사 위치</th>
<th>스캔 일시</th>
</tr>
</thead>
<tbody>
${tbodyRows}
</tbody>
</table>
`;
bindTableEvents();
updateActionButtons();
}
function bindHeaderEvents() {
document.getElementById('btn-audit-refresh')?.addEventListener('click', () => fetchPendingList());
document.getElementById('btn-audit-approve')?.addEventListener('click', () => handleAction('approve'));
document.getElementById('btn-audit-reject')?.addEventListener('click', () => handleAction('reject'));
}
function bindTableEvents() {
// Select All Checkbox
const selectAllChk = document.getElementById('chk-audit-all') as HTMLInputElement;
const rowCheckboxes = document.querySelectorAll('.row-select') as NodeListOf<HTMLInputElement>;
selectAllChk?.addEventListener('change', () => {
rowCheckboxes.forEach(chk => {
chk.checked = selectAllChk.checked;
});
updateActionButtons();
});
rowCheckboxes.forEach(chk => {
chk.addEventListener('change', () => {
updateActionButtons();
// Sync selectAll checkbox state
const allChecked = Array.from(rowCheckboxes).every(c => c.checked);
if (selectAllChk) selectAllChk.checked = allChecked;
});
});
// Asset Detail Modal linkage
const assetLinks = document.querySelectorAll('.link-asset-code');
assetLinks.forEach(link => {
link.addEventListener('click', (e) => {
const idx = parseInt((e.target as HTMLElement).dataset.index!);
const row = pendingData[idx];
if (!row) return;
// Compile master array from state data to find full asset object
const allHwAssets = [
...(state.masterData.pc || []),
...(state.masterData.server || []),
...(state.masterData.storage || []),
...(state.masterData.network || []),
...(state.masterData.equipment || []),
...(state.masterData.survey || []),
...(state.masterData.officeSupplies || []),
...(state.masterData.pcParts || [])
];
const targetAsset = allHwAssets.find(a => a.asset_code === row.asset_code);
if (targetAsset) {
openHwModal(targetAsset, 'view');
} else {
alert(`자산 코드 [${row.asset_code}] 에 매칭되는 마스터 데이터가 존재하지 않습니다.`);
}
});
});
}
function updateActionButtons() {
const selected = document.querySelectorAll('.row-select:checked');
const approveBtn = document.getElementById('btn-audit-approve') as HTMLButtonElement;
const rejectBtn = document.getElementById('btn-audit-reject') as HTMLButtonElement;
if (approveBtn && rejectBtn) {
const isDisabled = selected.length === 0;
approveBtn.disabled = isDisabled;
rejectBtn.disabled = isDisabled;
approveBtn.textContent = `선택 승인 (${selected.length})`;
rejectBtn.textContent = `선택 반려 (${selected.length})`;
}
}
async function handleAction(actionType: 'approve' | 'reject') {
const selected = document.querySelectorAll('.row-select:checked') as NodeListOf<HTMLInputElement>;
const ids = Array.from(selected).map(chk => parseInt(chk.dataset.id!));
if (ids.length === 0) return;
const actionText = actionType === 'approve' ? '승인' : '반려';
if (!confirm(`선택한 ${ids.length}건의 실사 내역을 최종 ${actionText} 처리할까요?`)) return;
const endpoint = actionType === 'approve' ? '/api/audit/approve' : '/api/audit/reject';
try {
const res = await fetch(endpoint, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
pending_ids: ids,
processed_by: 'ADMIN'
})
});
const data = await res.json();
if (res.ok && data.success) {
alert(`성공적으로 ${actionText} 완료되었습니다.`);
// Reload dashboard state to sync map_config/db coordinates changes
await loadMasterDataFromDB();
await fetchPendingList();
} else {
alert(`${actionText} 실패: ${data.error || '알 수 없는 서버 오류'}`);
}
} catch (err) {
console.error(`Failed to trigger audit ${actionType}:`, err);
alert(`네트워크 통신 오류로 ${actionText} 처리가 실패했습니다.`);
}
}
// Run initial loading
await loadAndRender();
}

File diff suppressed because it is too large Load Diff

View File

@@ -1,291 +1,294 @@
import { state } from '../core/state';
import { openHwModal } from '../components/Modal/HWModal';
import { ASSET_SCHEMA } from '../core/schema';
import { LOCATION_DATA, IMAGE_LOCATIONS } from '../components/Modal/SharedData';
/**
* 위치 중심 자산 현황 뷰 (Vercel Integrated)
*/
export async function renderLocationView(container: HTMLElement) {
if (!container) return;
let currentLoc = '기술개발센터';
let currentDetail = '서버실';
let currentPage = 0;
let mapConfig: any = {};
try {
const res = await fetch('/api/maps');
mapConfig = await res.json();
} catch (err) { console.error('Failed to load map config', err); }
const render = () => {
const locImages = (IMAGE_LOCATIONS[currentLoc] && IMAGE_LOCATIONS[currentLoc][currentDetail])
? IMAGE_LOCATIONS[currentLoc][currentDetail]
: [];
const mapPath = locImages[currentPage] || '';
// 모든 하드웨어 카테고리에서 자산 검색
const allHwAssets = [
...state.masterData.pc,
...state.masterData.server,
...state.masterData.storage,
...state.masterData.network,
...state.masterData.equipment,
...state.masterData.survey,
...state.masterData.officeSupplies,
...state.masterData.pcParts
];
// map_config.json에 설정된 모든 박스를 복사해서 작업용으로 사용
const tempBoxes = (mapConfig[mapPath] || []).map((b: any) => ({ ...b }));
// DB 데이터에서 현재 지도(mapPath) 및 위치와 좌표 정보(loc_x, loc_y)가 일치하는 자산 추출
allHwAssets.forEach((asset: any) => {
const photoPath = asset.location_photo || asset.loc_img || '';
const hasCoords = asset.loc_x != null && asset.loc_y != null && asset.loc_x !== '' && asset.loc_y !== '' && asset.loc_x !== 'null' && asset.loc_y !== 'null';
if (hasCoords && photoPath.trim() === mapPath.trim()) {
const ax = parseFloat(asset.loc_x);
const ay = parseFloat(asset.loc_y);
// map_config.json에서 읽어온 박스들 중 x, y 좌표가 일치하는 빈 박스가 있는지 찾음 (오차범위 0.1 고려)
const matchedBox = tempBoxes.find((b: any) => {
const bx = parseFloat(b.x);
const by = parseFloat(b.y);
return Math.abs(bx - ax) < 0.1 && Math.abs(by - ay) < 0.1;
});
if (matchedBox) {
// 이미 매칭된 박스가 존재하고 asset_id가 비어있다면 해당 박스에 asset_id를 주입
if (matchedBox.asset_id == null) {
matchedBox.asset_id = asset.id;
}
} else {
// 일치하는 기존 박스가 없을 때만 4x4 크기의 임시 박스로 동적 생성
const alreadyMatched = tempBoxes.some((b: any) => b.asset_id === asset.id);
if (!alreadyMatched) {
tempBoxes.push({
asset_id: asset.id,
x: asset.loc_x,
y: asset.loc_y,
w: '4',
h: '4',
name: asset.asset_purpose || asset.asset_code || '미지정 자산'
});
}
}
}
});
// 최종적으로 asset_id가 null이 아닌(자산이 정상 매핑되거나 갱신된) 박스들만 남겨서 렌더링
const boxes = tempBoxes.filter((b: any) => b.asset_id != null);
container.innerHTML = `
<div class="location-view-wrapper">
<!-- 상단 통합 바 (Unified Search Bar) -->
<div class="location-filter-bar search-bar">
<div class="search-item">
<label class="list-view-toggle-label">
<input type="checkbox" id="chk-list-view-loc" />
목록보기
</label>
</div>
<div class="search-item">
<label>건물/위치</label>
<select id="sel-loc-main">
${Object.keys(LOCATION_DATA).map(loc => `<option value="${loc}" ${loc === currentLoc ? 'selected' : ''}>${loc}</option>`).join('')}
</select>
</div>
<div class="search-item">
<label>상세 위치</label>
<div class="flex items-center gap-2">
<select id="sel-loc-detail">
${(LOCATION_DATA[currentLoc] || []).map(det => `<option value="${det}" ${det === currentDetail ? 'selected' : ''}>${det}</option>`).join('')}
</select>
<!-- 페이지네이션 -->
${locImages.length > 1 ? `
<div class="map-pagination-group">
<div class="page-btns flex gap-1">
<button id="btn-prev-page" class="btn btn-outline btn-sm" ${currentPage === 0 ? 'disabled' : ''}>이전</button>
<button id="btn-next-page" class="btn btn-outline btn-sm" ${currentPage === locImages.length - 1 ? 'disabled' : ''}>다음</button>
</div>
<span class="page-info">(${currentPage + 1} / ${locImages.length})</span>
</div>
` : ''}
</div>
</div>
</div>
<div class="location-main-content">
<!-- 지도 섹션 -->
<div class="map-container-section">
<div class="map-frame-wrapper">
${mapPath ? `
<img src="${mapPath}" id="main-map-img" class="map-image">
<div id="box-overlay" class="map-overlay">
${boxes.map((box: any, idx: number) => {
const asset = allHwAssets.find(a => a.id === box.asset_id);
const name = asset ? ((asset as any).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 `
<div class="location-box-area"
data-asset-id="${box.asset_id}"
data-name="${name}"
style="left:${box.x}%; top:${box.y}%; width:${width}%; height:${height}%;
border: 2px solid var(--primary-color); background: rgba(30, 81, 73, 0.1); cursor:pointer; pointer-events: auto; position: absolute;">
</div>
`}).join('')}
</div>
` : '<div class="no-map-message">해당 위치의 도면이 등록되지 않았습니다.</div>'}
</div>
</div>
<!-- 상세 정보 섹션 -->
<div class="asset-list-section">
<div class="section-header">
<h4 id="loc-list-title" class="sidebar-title">구역을 선택하세요</h4>
</div>
<div id="loc-asset-table-container" class="mini-table-wrapper">
<div class="empty-state">지도에서 자산 위치를 클릭하세요.</div>
</div>
</div>
</div>
</div>
`;
const syncOverlaySize = () => {
const img = container.querySelector('#main-map-img') as HTMLImageElement;
const overlay = container.querySelector('#box-overlay') as HTMLElement;
if (img && overlay && img.complete) {
overlay.style.width = img.clientWidth + 'px';
overlay.style.height = img.clientHeight + 'px';
overlay.style.left = img.offsetLeft + 'px';
overlay.style.top = img.offsetTop + 'px';
}
};
const img = container.querySelector('#main-map-img') as HTMLImageElement;
if (img) {
if (img.complete) {
syncOverlaySize();
setTimeout(syncOverlaySize, 50);
} else {
img.onload = syncOverlaySize;
}
}
window.removeEventListener('resize', syncOverlaySize);
window.addEventListener('resize', syncOverlaySize);
const selMain = container.querySelector('#sel-loc-main') as HTMLSelectElement;
selMain?.addEventListener('change', () => {
currentLoc = selMain.value;
currentDetail = LOCATION_DATA[currentLoc][0];
currentPage = 0;
render();
});
const selDetail = container.querySelector('#sel-loc-detail') as HTMLSelectElement;
selDetail?.addEventListener('change', () => {
currentDetail = selDetail.value;
currentPage = 0;
render();
});
container.querySelector('#btn-prev-page')?.addEventListener('click', () => { currentPage--; render(); });
container.querySelector('#btn-next-page')?.addEventListener('click', () => { currentPage++; render(); });
const chkBox = container.querySelector('#chk-list-view-loc') as HTMLInputElement;
if (chkBox) {
chkBox.checked = state.viewMode === 'list';
const handleToggle = () => {
const isListMode = chkBox.checked;
if (isListMode) {
state.viewMode = 'list';
} else {
state.viewMode = 'location';
}
window.dispatchEvent(new Event('refresh-view'));
};
chkBox.addEventListener('change', handleToggle);
}
container.querySelectorAll('.location-box-area').forEach(box => {
box.addEventListener('click', () => {
const assetId = box.getAttribute('data-asset-id');
if (!assetId) return;
const targetAsset = allHwAssets.find(a => a.id === assetId);
if (targetAsset) renderAssetDetail(targetAsset);
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)';
});
});
};
const renderAssetDetail = (asset: any) => {
const title = container.querySelector('#loc-list-title')!;
const tableContainer = container.querySelector('#loc-asset-table-container')!;
title.innerHTML = `
<div class="detail-header-actions">
<div class="header-identity">
<span class="asset-code-title">${asset.asset_code || '미부여'}</span>
<span class="service-type-badge">${asset.service_type || '운영'}</span>
<span class="asset-type-label">${asset.asset_type || 'PC'}</span>
</div>
<button id="btn-view-from-loc" class="btn btn-primary btn-sm">조회</button>
</div>
`;
const fields = [
{ label: ASSET_SCHEMA.CURRENT_DEPT.ui, value: asset.current_dept },
{ label: ASSET_SCHEMA.HW_STATUS.ui, value: asset.hw_status },
{ label: ASSET_SCHEMA.MANAGER_MAIN.ui, value: asset.manager_primary },
{ label: ASSET_SCHEMA.MANAGER_SUB.ui, value: asset.manager_secondary },
{ label: ASSET_SCHEMA.ASSET_PURPOSE.ui, value: asset.asset_purpose, fullWidth: true },
{ label: ASSET_SCHEMA.MODEL_NAME.ui, value: asset.model_name },
{ label: ASSET_SCHEMA.OS.ui, value: asset.os },
{ label: ASSET_SCHEMA.CPU.ui, value: asset.cpu },
{ label: ASSET_SCHEMA.RAM.ui, value: asset.ram },
{ label: ASSET_SCHEMA.GPU.ui, value: asset.gpu, fullWidth: true },
{ label: ASSET_SCHEMA.IP_ADDR.ui, value: asset.ip_address },
{ label: ASSET_SCHEMA.MAC_ADDR.ui, value: asset.mac_address },
{ label: ASSET_SCHEMA.REMOTE_TOOL.ui, value: asset.remote_tool },
{ label: ASSET_SCHEMA.MONITORING.ui, value: asset.monitoring },
{ label: ASSET_SCHEMA.MEMO.ui, value: asset.memo, fullWidth: true }
];
const sectionsHTML = `
<div class="detail-section" style="margin-bottom: 0;">
<div class="detail-grid-2col" style="gap: 0.75rem 1rem;">
${fields.map(f => `
<div class="detail-item ${f.fullWidth ? 'full-width' : ''}">
<div class="detail-label-sm">${f.label}</div>
<div class="detail-value-lg">${f.value || '-'}</div>
</div>
`).join('')}
</div>
</div>
`;
tableContainer.innerHTML = `
<div class="asset-detail-sidebar">
${sectionsHTML}
</div>
`;
container.querySelector('#btn-view-from-loc')?.addEventListener('click', () => {
openHwModal(asset, 'view');
});
};
render();
}
import { state } from '../core/state';
import { openHwModal } from '../components/Modal/HWModal';
import { ASSET_SCHEMA } from '../core/schema';
import { LOCATION_DATA, IMAGE_LOCATIONS } from '../components/Modal/SharedData';
/**
* 위치 중심 자산 현황 뷰 (Vercel Integrated)
*/
export async function renderLocationView(container: HTMLElement) {
if (!container) return;
let currentLoc = '기술개발센터';
let currentDetail = '서버실';
let currentPage = 0;
let mapConfig: any = {};
try {
const res = await fetch('/api/maps');
mapConfig = await res.json();
} catch (err) { console.error('Failed to load map config', err); }
const render = () => {
const locImages = (IMAGE_LOCATIONS[currentLoc] && IMAGE_LOCATIONS[currentLoc][currentDetail])
? IMAGE_LOCATIONS[currentLoc][currentDetail]
: [];
const mapPath = locImages[currentPage] || '';
// 모든 하드웨어 카테고리에서 자산 검색
const allHwAssets = [
...state.masterData.pc,
...state.masterData.server,
...state.masterData.storage,
...state.masterData.network,
...state.masterData.equipment,
...state.masterData.survey,
...state.masterData.officeSupplies,
...state.masterData.pcParts
];
// map_config.json에 설정된 모든 박스를 복사해서 작업용으로 사용
const tempBoxes = (mapConfig[mapPath] || []).map((b: any) => ({ ...b }));
// DB 데이터에서 현재 지도(mapPath) 및 위치와 좌표 정보(loc_x, loc_y)가 일치하는 자산 추출
allHwAssets.forEach((asset: any) => {
const photoPath = asset.location_photo || asset.loc_img || '';
const hasCoords = asset.loc_x != null && asset.loc_y != null && asset.loc_x !== '' && asset.loc_y !== '' && asset.loc_x !== 'null' && asset.loc_y !== 'null';
if (hasCoords && photoPath.trim() === mapPath.trim()) {
const ax = parseFloat(asset.loc_x);
const ay = parseFloat(asset.loc_y);
// map_config.json에서 읽어온 박스들 중 x, y 좌표가 일치하는 빈 박스가 있는지 찾음 (오차범위 0.1 고려)
const matchedBox = tempBoxes.find((b: any) => {
const bx = parseFloat(b.x);
const by = parseFloat(b.y);
return Math.abs(bx - ax) < 0.1 && Math.abs(by - ay) < 0.1;
});
if (matchedBox) {
// 이미 매칭된 박스가 존재하고 asset_id가 비어있다면 해당 박스에 asset_id를 주입
if (matchedBox.asset_id == null) {
matchedBox.asset_id = asset.id;
}
} else {
// 일치하는 기존 박스가 없을 때만 4x4 크기의 임시 박스로 동적 생성
const alreadyMatched = tempBoxes.some((b: any) => b.asset_id === asset.id);
if (!alreadyMatched) {
tempBoxes.push({
asset_id: asset.id,
x: asset.loc_x,
y: asset.loc_y,
w: '4',
h: '4',
name: asset.asset_purpose || asset.asset_code || '미지정 자산'
});
}
}
}
});
// 최종적으로 asset_id가 null이 아닌(자산이 정상 매핑되거나 갱신된) 박스들만 남겨서 렌더링
const boxes = tempBoxes.filter((b: any) => b.asset_id != null);
container.innerHTML = `
<div class="location-view-wrapper">
<!-- 상단 통합 바 (Unified Search Bar) -->
<div class="location-filter-bar search-bar">
<div class="search-item">
<label class="list-view-toggle-label">
<input type="checkbox" id="chk-list-view-loc" />
목록보기
</label>
</div>
<div class="search-item">
<label>건물/위치</label>
<select id="sel-loc-main">
${Object.keys(LOCATION_DATA).map(loc => `<option value="${loc}" ${loc === currentLoc ? 'selected' : ''}>${loc}</option>`).join('')}
</select>
</div>
<div class="search-item">
<label>상세 위치</label>
<div class="flex items-center gap-2">
<select id="sel-loc-detail">
${(LOCATION_DATA[currentLoc] || []).map(det => `<option value="${det}" ${det === currentDetail ? 'selected' : ''}>${det}</option>`).join('')}
</select>
<!-- 페이지네이션 -->
${locImages.length > 1 ? `
<div class="map-pagination-group">
<div class="page-btns flex gap-1">
<button id="btn-prev-page" class="btn btn-outline btn-sm" ${currentPage === 0 ? 'disabled' : ''}>이전</button>
<button id="btn-next-page" class="btn btn-outline btn-sm" ${currentPage === locImages.length - 1 ? 'disabled' : ''}>다음</button>
</div>
<span class="page-info">(${currentPage + 1} / ${locImages.length})</span>
</div>
` : ''}
</div>
</div>
</div>
<div class="location-main-content">
<!-- 지도 섹션 -->
<div class="map-container-section">
<div class="map-frame-wrapper">
${mapPath ? `
<img src="${mapPath}" id="main-map-img" class="map-image">
<div id="box-overlay" class="map-overlay">
${boxes.map((box: any, idx: number) => {
const asset = allHwAssets.find(a => a.id === box.asset_id);
const name = asset ? ((asset as any).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 `
<div class="location-box-area"
data-asset-id="${box.asset_id}"
data-name="${name}"
style="left:${box.x}%; top:${box.y}%; width:${width}%; height:${height}%;
border: 2px solid var(--primary-color); background: rgba(30, 81, 73, 0.1); cursor:pointer; pointer-events: auto; position: absolute;">
</div>
`}).join('')}
</div>
` : '<div class="no-map-message">해당 위치의 도면이 등록되지 않았습니다.</div>'}
</div>
</div>
<!-- 상세 정보 섹션 -->
<div class="asset-list-section">
<div class="section-header">
<h4 id="loc-list-title" class="sidebar-title">구역을 선택하세요</h4>
</div>
<div id="loc-asset-table-container" class="mini-table-wrapper">
<div class="empty-state">지도에서 자산 위치를 클릭하세요.</div>
</div>
</div>
</div>
</div>
`;
const syncOverlaySize = () => {
const img = container.querySelector('#main-map-img') as HTMLImageElement;
const overlay = container.querySelector('#box-overlay') as HTMLElement;
if (img && overlay && img.complete) {
overlay.style.width = img.clientWidth + 'px';
overlay.style.height = img.clientHeight + 'px';
overlay.style.left = img.offsetLeft + 'px';
overlay.style.top = img.offsetTop + 'px';
}
};
const img = container.querySelector('#main-map-img') as HTMLImageElement;
if (img) {
if (img.complete) {
syncOverlaySize();
setTimeout(syncOverlaySize, 50);
} else {
img.onload = syncOverlaySize;
}
}
window.removeEventListener('resize', syncOverlaySize);
window.addEventListener('resize', syncOverlaySize);
const selMain = container.querySelector('#sel-loc-main') as HTMLSelectElement;
selMain?.addEventListener('change', () => {
currentLoc = selMain.value;
currentDetail = LOCATION_DATA[currentLoc][0];
currentPage = 0;
render();
});
const selDetail = container.querySelector('#sel-loc-detail') as HTMLSelectElement;
selDetail?.addEventListener('change', () => {
currentDetail = selDetail.value;
currentPage = 0;
render();
});
container.querySelector('#btn-prev-page')?.addEventListener('click', () => { currentPage--; render(); });
container.querySelector('#btn-next-page')?.addEventListener('click', () => { currentPage++; render(); });
const chkBox = container.querySelector('#chk-list-view-loc') as HTMLInputElement;
if (chkBox) {
chkBox.checked = state.viewMode === 'list';
const handleToggle = () => {
const isListMode = chkBox.checked;
if (isListMode) {
state.viewMode = 'list';
} else {
state.viewMode = 'location';
}
window.dispatchEvent(new Event('refresh-view'));
};
chkBox.addEventListener('change', handleToggle);
}
container.querySelectorAll('.location-box-area').forEach(box => {
box.addEventListener('click', () => {
const assetId = box.getAttribute('data-asset-id');
if (!assetId) return;
const targetAsset = allHwAssets.find(a => a.id === assetId);
if (targetAsset) renderAssetDetail(targetAsset);
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)';
});
});
};
const renderAssetDetail = (asset: any) => {
const title = container.querySelector('#loc-list-title')!;
const tableContainer = container.querySelector('#loc-asset-table-container')!;
title.innerHTML = `
<div class="detail-header-actions">
<div class="header-identity">
<span class="asset-code-title">${asset.asset_code || '미부여'}</span>
<span class="service-type-badge">${asset.service_type || '운영'}</span>
<span class="asset-type-label">${asset.asset_type || 'PC'}</span>
</div>
<div style="display: inline-flex; align-items: center; gap: 0.5rem;">
${asset.is_audit_approved ? `<span style="display: inline-flex; align-items: center; background-color: rgba(16, 185, 129, 0.08); color: #059669; border: 1px solid rgba(16, 185, 129, 0.18); padding: 2px 6px; border-radius: 4px; font-size: 10px; font-weight: 600; height: 18px; line-height: 1; white-space: nowrap; vertical-align: middle;">승인완료</span>` : ''}
<button id="btn-view-from-loc" class="btn btn-primary btn-sm">조회</button>
</div>
</div>
`;
const fields = [
{ label: ASSET_SCHEMA.CURRENT_DEPT.ui, value: asset.current_dept },
{ label: ASSET_SCHEMA.HW_STATUS.ui, value: asset.hw_status },
{ label: ASSET_SCHEMA.MANAGER_MAIN.ui, value: asset.manager_primary },
{ label: ASSET_SCHEMA.MANAGER_SUB.ui, value: asset.manager_secondary },
{ label: ASSET_SCHEMA.ASSET_PURPOSE.ui, value: asset.asset_purpose, fullWidth: true },
{ label: ASSET_SCHEMA.MODEL_NAME.ui, value: asset.model_name },
{ label: ASSET_SCHEMA.OS.ui, value: asset.os },
{ label: ASSET_SCHEMA.CPU.ui, value: asset.cpu },
{ label: ASSET_SCHEMA.RAM.ui, value: asset.ram },
{ label: ASSET_SCHEMA.GPU.ui, value: asset.gpu, fullWidth: true },
{ label: ASSET_SCHEMA.IP_ADDR.ui, value: asset.ip_address },
{ label: ASSET_SCHEMA.MAC_ADDR.ui, value: asset.mac_address },
{ label: ASSET_SCHEMA.REMOTE_TOOL.ui, value: asset.remote_tool },
{ label: ASSET_SCHEMA.MONITORING.ui, value: asset.monitoring },
{ label: ASSET_SCHEMA.MEMO.ui, value: asset.memo, fullWidth: true }
];
const sectionsHTML = `
<div class="detail-section" style="margin-bottom: 0;">
<div class="detail-grid-2col" style="gap: 0.75rem 1rem;">
${fields.map(f => `
<div class="detail-item ${f.fullWidth ? 'full-width' : ''}">
<div class="detail-label-sm">${f.label}</div>
<div class="detail-value-lg">${f.value || '-'}</div>
</div>
`).join('')}
</div>
</div>
`;
tableContainer.innerHTML = `
<div class="asset-detail-sidebar">
${sectionsHTML}
</div>
`;
container.querySelector('#btn-view-from-loc')?.addEventListener('click', () => {
openHwModal(asset, 'view');
});
};
render();
}

View File

@@ -1,299 +1,367 @@
import { IMAGE_LOCATIONS } from '../components/Modal/SharedData';
import { API_BASE_URL } from '../core/utils';
import { createIcons, X, Save, Trash2, ChevronLeft, ChevronRight } from 'lucide';
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_BASE_URL}/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-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_BASE_URL}/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>
<button class="btn-del" onclick="removeBox(${i})">×</button>
</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
}
}
});
});
}
}
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}`;
}