Merge branch 'origin/QR_setting' into thoon
This commit is contained in:
427
src/views/AuditApprovalView.ts
Normal file
427
src/views/AuditApprovalView.ts
Normal 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
@@ -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();
|
||||
}
|
||||
|
||||
@@ -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}`;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user