Files
ITAM/src/views/LocationView.ts
이태훈 0bfff08af6 fix(audit): 실사 승인 대기 목록 필터링 오류 수정 및 승인완료 배지 표시 추가
- GET /api/audit/pending API에서 PENDING 상태인 내역만 반환하도록 SQL 쿼리 수정
- 실사 승인 및 맵 저장 시 asset_location.location_detail에 표준 상세 위치가 저장되도록 개선
- 실사 승인 완료된 자산에 대해 상세 모달, 위치 보기, 목록 보기에 '승인완료' 배지 노출 처리
- 목록 보기에서 기존 위치 표시 형식 및 툴팁을 훼손하지 않도록 배지를 분리하여 렌더링
2026-06-24 17:56:41 +09:00

295 lines
12 KiB
TypeScript

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();
}