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

180
src/mobile-main.ts Normal file
View File

@@ -0,0 +1,180 @@
// ITAM Mobile Audit Scanner Main Business Logic
const SESSION_LOC_KEY = 'itam_audit_locked_location';
document.addEventListener('DOMContentLoaded', () => {
const locDisplay = document.getElementById('loc-display')!;
const unlockBtn = document.getElementById('btn-unlock-loc') as HTMLButtonElement;
const feedbackEl = document.getElementById('scan-feedback')!;
const manualToggleBtn = document.getElementById('btn-toggle-manual')!;
const manualForm = document.getElementById('manual-form')!;
const manualInput = document.getElementById('manual-code-input') as HTMLInputElement;
const manualSubmitBtn = document.getElementById('btn-submit-manual') as HTMLButtonElement;
let html5QrcodeScanner: any = null;
// Initialize UI based on current session lock
updateLocationUI();
// Parse URL parameters for immediate processing (convenience for direct QR scans)
parseUrlParams();
// Initialize HTML5 QR Code Scanner
initScanner();
// Bind UI Events
unlockBtn.addEventListener('click', () => {
sessionStorage.removeItem(SESSION_LOC_KEY);
showFeedback('위치 잠금이 해제되었습니다.', 'success');
updateLocationUI();
});
manualToggleBtn.addEventListener('click', () => {
const isHidden = window.getComputedStyle(manualForm).display === 'none';
manualForm.style.display = isHidden ? 'flex' : 'none';
manualToggleBtn.textContent = isHidden ? '스캐너 카메라로 스캔하기' : '카메라가 안 되나요? 수동 코드로 입력';
});
manualSubmitBtn.addEventListener('click', () => {
const code = manualInput.value.trim();
if (!code) return;
processScannedCode(code);
manualInput.value = '';
});
// --- Core Scanner Functions ---
function initScanner() {
try {
// Create Html5Qrcode instance
// Using Html5Qrcode directly instead of Html5QrcodeScanner for customized viewport control
const html5QrCode = new (window as any).Html5Qrcode("reader");
const config = {
fps: 10,
qrbox: (width: number, height: number) => {
const size = Math.min(width, height) * 0.7;
return { width: size, height: size };
},
aspectRatio: 1.0
};
// Start scanning using the rear camera
html5QrCode.start(
{ facingMode: "environment" },
config,
(decodedText: string) => {
processScannedCode(decodedText);
},
(errorMessage: string) => {
// Silent failure during continuous scanning to avoid log flooding
}
).catch((err: any) => {
console.error("Camera startup failed:", err);
showFeedback("카메라 시작 실패: 권한을 허용했는지 확인하세요.", "error");
});
} catch (e) {
console.error("Failed to initialize html5-qrcode:", e);
showFeedback("QR 라이브러리 로드 오류", "error");
}
}
function processScannedCode(code: string) {
// 1. Check if the code is a physical location code
if (code.startsWith('LOC-')) {
sessionStorage.setItem(SESSION_LOC_KEY, code);
showFeedback(`위치 [${code}] 잠금 설정 완료!`, 'success');
updateLocationUI();
vibrateDevice(100);
return;
}
// 2. Otherwise treat it as an asset code
const lockedLoc = sessionStorage.getItem(SESSION_LOC_KEY);
if (!lockedLoc) {
showFeedback('위치 QR 코드를 먼저 스캔하여 잠금을 설정해야 자산을 스캔할 수 있습니다.', 'error');
vibrateDevice([100, 50, 100]);
return;
}
// Submit matching info to server
submitAssetAudit(code, lockedLoc);
}
async function submitAssetAudit(assetCode: string, locationCode: string) {
showFeedback(`자산 ${assetCode} 전송 중...`, 'success');
try {
// Request is sent relative to host, which resolves dynamically through server proxy
const res = await fetch('/api/audit/scan', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
asset_code: assetCode,
physical_location_code: locationCode
})
});
const data = await res.json();
if (res.ok && data.success) {
showFeedback(`자산 [${assetCode}] 실사 전송 성공! (관리자 승인 대기)`, 'success');
vibrateDevice([200]);
} else {
showFeedback(`전송 실패: ${data.error || '알 수 없는 서버 오류'}`, 'error');
vibrateDevice([100, 100, 100]);
}
} catch (err) {
console.error("Failed to submit scan:", err);
showFeedback('서버 전송 중 통신 네트워크 오류가 발생했습니다.', 'error');
vibrateDevice([100, 100, 100]);
}
}
function updateLocationUI() {
const lockedLoc = sessionStorage.getItem(SESSION_LOC_KEY);
if (lockedLoc) {
locDisplay.textContent = lockedLoc;
locDisplay.className = 'badge-lock';
unlockBtn.style.display = 'inline-block';
} else {
locDisplay.textContent = '위치 QR 코드를 먼저 스캔하세요.';
locDisplay.className = 'badge-empty';
unlockBtn.style.display = 'none';
}
}
function parseUrlParams() {
const params = new URLSearchParams(window.location.search);
const loc = params.get('loc');
const asset = params.get('asset');
if (loc) {
processScannedCode(loc);
// Clean query parameters to avoid re-triggering on page refresh
window.history.replaceState({}, document.title, window.location.pathname);
} else if (asset) {
processScannedCode(asset);
window.history.replaceState({}, document.title, window.location.pathname);
}
}
function showFeedback(msg: string, type: 'success' | 'error') {
feedbackEl.textContent = msg;
feedbackEl.style.display = 'block';
feedbackEl.className = `feedback-message ${type === 'success' ? 'feedback-success' : 'feedback-error'}`;
// Auto-hide feedback after 4 seconds
setTimeout(() => {
if (feedbackEl.textContent === msg) {
feedbackEl.style.display = 'none';
}
}, 4000);
}
function vibrateDevice(pattern: number | number[]) {
if ('vibrate' in navigator) {
navigator.vibrate(pattern);
}
}
});