feat: QR 자산 스캔 점검, 모바일 웹뷰 및 관리자 승인 시스템 구현 (DB 기반 맵 좌표 저장 단일화 포함)

This commit is contained in:
이태훈
2026-06-23 16:39:14 +09:00
parent 9f165faf13
commit f36e8e93e2
21 changed files with 2357 additions and 46 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();
}

View File

@@ -77,7 +77,7 @@ export function createListView(container: HTMLElement, config: ListViewConfig) {
const fetchMapConfig = async () => {
try {
const res = await fetch(`http://${location.hostname}:3000/api/maps`);
const res = await fetch('/api/maps');
dynamicMapConfig = await res.json();
} catch (err) { console.error('Failed to fetch map config:', err); }
};

View File

@@ -1,5 +1,6 @@
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;
@@ -42,7 +43,7 @@ export class MapEditor {
private async loadAssets() {
try {
const res = await fetch(`http://${location.hostname}:3000/api/assets/master`);
const res = await fetch('/api/assets/master');
const masterData = await res.json();
const allHw = [
...(masterData.pc || []),
@@ -95,7 +96,7 @@ export class MapEditor {
private async loadConfig() {
try {
const res = await fetch(`http://${location.hostname}:3000/api/maps`);
const res = await fetch('/api/maps');
this.allMapConfig = await res.json();
} catch (err) {
console.error('Failed to load config:', err);
@@ -185,6 +186,30 @@ export class MapEditor {
}
});
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());
}
@@ -195,7 +220,7 @@ export class MapEditor {
this.saveBtn.disabled = true;
this.saveBtn.textContent = '저장 중...';
const res = await fetch(`http://${location.hostname}:3000/api/maps/save`, {
const res = await fetch('/api/maps/save', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ path: this.currentPath, boxes: this.boxes })
@@ -248,7 +273,10 @@ export class MapEditor {
item.innerHTML = `
<div class="box-header">
<span class="box-index">#${i+1}</span>
<button class="btn-del" onclick="removeBox(${i})">×</button>
<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">
@@ -294,5 +322,46 @@ export class MapEditor {
}
});
});
(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}`;
}