eSPJ/Views/Admin/Transport/SpjDriver/Submit/Struk.cshtml

598 lines
27 KiB
Plaintext

@{
Layout = "~/Views/Admin/Transport/SpjDriver/Shared/_Layout.cshtml";
ViewData["Title"] = "Submit Struk";
}
@section Styles {
<link rel="stylesheet" href="@Url.Content("~/driver/css/scanner.css")" asp-append-version="true" />
}
<div class="max-w-sm mx-auto bg-white min-h-screen">
<!-- Header with Orange Background -->
<div class="bg-orange-500 text-white px-3 py-4 rounded-b-2xl relative pb-12">
<div class="flex items-center justify-between">
<a href="@Url.Action("Index", "Home")" class="p-1 hover:bg-white/10 rounded-full transition-colors">
<i class="w-5 h-5" data-lucide="chevron-left"></i>
</a>
<h1 class="text-lg font-bold">Unggah Struk</h1>
<div class="w-8"></div>
</div>
</div>
<div class="px-8 py-4">
<!-- Camera Scanner Section -->
<div class="mb-6">
<div class="flex flex-col items-center space-y-2 mb-4">
<div class="bg-orange-100 rounded-full p-3">
<i data-lucide="camera" class="w-7 h-7 text-orange-500"></i>
</div>
<h2 class="text-xl font-bold text-orange-500">Scan Struk Otomatis</h2>
<p class="text-sm text-gray-500 text-center">Arahkan kamera ke struk untuk membaca data secara otomatis.</p>
</div>
<!-- Scanner Container -->
<div class="scanner-container mb-4" style="height: 300px;">
<div id="scanner-container" class="w-full h-full relative bg-gray-900 rounded-lg overflow-hidden">
<div id="loading-scanner" class="absolute inset-0 bg-gray-900 flex items-center justify-center z-10">
<div class="text-center text-white">
<div class="loading-spinner mx-auto mb-2"></div>
<p class="text-sm">Memuat scanner...</p>
</div>
</div>
</div>
</div>
<!-- Scanner Controls -->
<div class="space-y-3 mb-4">
<button id="start-scanner" type="button" class="w-full bg-orange-500 hover:bg-orange-600 text-white font-medium py-3 px-4 rounded-lg transition-colors btn-scanner">
<i class="w-5 h-5 inline mr-2" data-lucide="camera"></i>
Mulai Scan Struk
</button>
<button id="stop-scanner" type="button" class="w-full bg-gray-500 hover:bg-gray-600 text-white font-medium py-3 px-4 rounded-lg transition-colors btn-scanner hidden">
<i class="w-5 h-5 inline mr-2" data-lucide="camera-off"></i>
Hentikan Scan
</button>
<!-- Permission Messages -->
<div id="permission-info" class="hidden bg-blue-50 border border-blue-200 rounded-lg p-3">
<div class="flex items-start">
<i class="w-5 h-5 text-blue-600 mr-2 mt-0.5" data-lucide="info"></i>
<div class="text-blue-800 text-sm">
<p class="font-medium mb-1">📸 Meminta Akses Kamera...</p>
<p class="mb-2">Browser akan meminta izin akses kamera. Pastikan untuk:</p>
<ul class="text-xs space-y-1 list-disc list-inside">
<li>Klik tombol <strong>"Allow"</strong> atau <strong>"Izinkan"</strong></li>
<li>Jika popup tidak muncul, cek address bar browser</li>
<li>Pastikan kamera tidak sedang digunakan aplikasi lain</li>
</ul>
</div>
</div>
</div>
<div id="permission-denied" class="hidden bg-red-50 border border-red-200 rounded-lg p-3">
<div class="flex items-start">
<i class="w-5 h-5 text-red-600 mr-2 mt-0.5" data-lucide="alert-triangle"></i>
<div class="text-red-800 text-sm">
<p class="font-medium mb-1">Akses Kamera Ditolak</p>
<p class="mb-2">Untuk menggunakan scanner, aktifkan akses kamera:</p>
<ol class="list-decimal list-inside space-y-1 text-xs">
<li>Klik ikon kunci/kamera di address bar browser</li>
<li>Pilih "Allow" atau "Izinkan" untuk kamera</li>
<li>Refresh halaman dan coba lagi</li>
</ol>
</div>
</div>
</div>
<!-- OCR Processing Message -->
<div id="ocr-processing" class="hidden bg-yellow-50 border border-yellow-200 rounded-lg p-3">
<div class="flex items-center">
<div class="loading-spinner-small mr-2"></div>
<span class="text-yellow-800 text-sm">Memproses teks dari struk...</span>
</div>
</div>
<!-- OCR Result -->
<div id="ocr-result" class="hidden bg-green-50 border border-green-200 rounded-lg p-3">
<div class="flex items-center mb-2">
<i class="w-5 h-5 text-green-600 mr-2" data-lucide="check-circle"></i>
<span class="text-green-800 font-medium">Data struk berhasil dibaca!</span>
</div>
<div class="text-green-700 text-sm space-y-1">
<p>Nomor Struk: <span id="detected-receipt-number" class="font-mono font-bold">-</span></p>
<p>Berat Muatan: <span id="detected-weight" class="font-mono font-bold">-</span> kg</p>
</div>
<div class="flex gap-2 mt-3">
<button id="apply-ocr-data" type="button" class="bg-green-600 hover:bg-green-700 text-white px-3 py-2 rounded-lg transition-colors text-sm">
Gunakan Data Ini
</button>
<button id="retry-ocr" type="button" class="bg-gray-500 hover:bg-gray-600 text-white px-3 py-2 rounded-lg transition-colors text-sm">
Scan Ulang
</button>
</div>
</div>
<!-- Tips for scanning -->
<div class="bg-gray-50 border border-gray-200 rounded-lg p-3 text-sm">
<div class="flex items-start">
<i class="w-4 h-4 text-gray-600 mr-2 mt-0.5" data-lucide="lightbulb"></i>
<div class="text-gray-700">
<p class="font-medium mb-1">Tips Scan Struk:</p>
<ul class="text-xs space-y-1">
<li>• Pastikan struk dalam pencahayaan yang cukup</li>
<li>• Letakkan struk rata tanpa lipatan</li>
<li>• Jaga jarak 15-30cm dari kamera</li>
<li>• Tunggu beberapa detik untuk proses OCR</li>
<li>• Periksa data yang terdeteksi sebelum submit</li>
</ul>
</div>
</div>
</div>
</div>
</div>
<!-- Divider -->
<div class="flex items-center my-6">
<div class="flex-1 border-t border-gray-200"></div>
<span class="px-3 text-sm text-gray-500 bg-white">atau input manual</span>
<div class="flex-1 border-t border-gray-200"></div>
</div>
</div>
<form action="@Url.Action("ProcessStruk", "Submit")" method="post" class="px-8 py-4 space-y-6 bg-white">
<div class="flex flex-col items-center space-y-2">
<div class="bg-orange-100 rounded-full p-3">
<i data-lucide="edit-3" class="w-7 h-7 text-orange-500"></i>
</div>
<h2 class="text-xl font-bold text-orange-500">Input Manual</h2>
<p class="text-sm text-gray-500 text-center">Masukkan nomor struk dan berat muatan secara manual.</p>
</div>
<div>
<label for="NomorStruk" class="block text-sm font-medium text-gray-700 mb-1">Nomor Struk</label>
<input
type="text"
id="NomorStruk"
name="NomorStruk"
inputmode="numeric"
pattern="[0-9]*"
class="mt-1 block w-full rounded-lg border border-orange-300 shadow-sm focus:border-orange-500 focus:ring-2 focus:ring-orange-200 transition-all duration-150 px-4 py-2"
required
placeholder="1234567890"
/>
</div>
<div>
<label for="BeratMuatan" class="block text-sm font-medium text-gray-700 mb-1">Berat Muatan (kg)</label>
<input
type="text"
id="BeratMuatan"
name="BeratMuatan"
inputmode="decimal"
pattern="[0-9]*\.?[0-9]*"
class="mt-1 block w-full rounded-lg border border-orange-300 shadow-sm focus:border-orange-500 focus:ring-2 focus:ring-orange-200 transition-all duration-150 px-4 py-2"
required
placeholder="1500"
/>
</div>
<button type="submit" class="w-full bg-gradient-to-r from-orange-500 to-orange-400 text-white py-3 rounded-lg font-semibold shadow hover:from-orange-600 hover:to-orange-500 transition-all duration-150 flex items-center justify-center gap-2">
<i data-lucide="send" class="w-5 h-5"></i>
Submit
</button>
</form>
<!-- Bottom Navigation -->
<partial name="~/Views/Admin/Transport/SpjDriver/Shared/Components/_Navigation.cshtml" />
</div>
<register-block dynamic-section="scripts" key="jsSubmitStruk">
<script src="https://cdn.jsdelivr.net/npm/tesseract.js@4.1.1/dist/tesseract.min.js"></script>
<script>
document.addEventListener('DOMContentLoaded', function() {
const nomorStrukInput = document.getElementById('NomorStruk');
const beratMuatanInput = document.getElementById('BeratMuatan');
// Input validation for manual entry
nomorStrukInput.addEventListener('input', function() {
this.value = this.value.replace(/[^0-9]/g, '');
});
beratMuatanInput.addEventListener('input', function() {
this.value = this.value.replace(/[^0-9.]/g, '');
});
// Initialize Receipt Scanner with OCR
class ReceiptScanner {
constructor() {
this.isScanning = false;
this.stream = null;
this.video = null;
this.canvas = null;
this.ctx = null;
this.detectedData = {
receiptNumber: '',
weight: ''
};
this.initializeElements();
this.bindEvents();
this.checkBrowserSupport();
}
initializeElements() {
this.startBtn = document.getElementById('start-scanner');
this.stopBtn = document.getElementById('stop-scanner');
this.loadingDiv = document.getElementById('loading-scanner');
this.ocrProcessing = document.getElementById('ocr-processing');
this.ocrResult = document.getElementById('ocr-result');
this.permissionInfo = document.getElementById('permission-info');
this.permissionDenied = document.getElementById('permission-denied');
this.applyDataBtn = document.getElementById('apply-ocr-data');
this.retryOcrBtn = document.getElementById('retry-ocr');
this.detectedReceiptSpan = document.getElementById('detected-receipt-number');
this.detectedWeightSpan = document.getElementById('detected-weight');
this.scannerContainer = document.getElementById('scanner-container');
}
bindEvents() {
this.startBtn.addEventListener('click', () => this.startScanner());
this.stopBtn.addEventListener('click', () => this.stopScanner());
this.applyDataBtn.addEventListener('click', () => this.applyDetectedData());
this.retryOcrBtn.addEventListener('click', () => this.retryOcr());
}
checkBrowserSupport() {
if (!navigator.mediaDevices || !navigator.mediaDevices.getUserMedia) {
this.disableScanner('Browser Tidak Didukung');
return;
}
if (location.protocol !== 'https:' && location.hostname !== 'localhost' && location.hostname !== '127.0.0.1') {
this.showWarning('Scanner berfungsi optimal dengan koneksi HTTPS yang aman.');
}
}
disableScanner(message) {
this.startBtn.disabled = true;
this.startBtn.innerHTML = `<i class="w-5 h-5 inline mr-2" data-lucide="x-circle"></i>${message}`;
this.startBtn.classList.remove('bg-orange-500', 'hover:bg-orange-600');
this.startBtn.classList.add('bg-gray-400', 'cursor-not-allowed');
}
async startScanner() {
try {
this.showLoading();
this.hideMessages();
this.permissionInfo.classList.remove('hidden');
// Get camera access
this.stream = await navigator.mediaDevices.getUserMedia({
video: {
facingMode: 'environment',
width: { ideal: 1280 },
height: { ideal: 720 }
}
});
this.setupVideoElement();
this.isScanning = true;
this.startBtn.classList.add('hidden');
this.stopBtn.classList.remove('hidden');
this.hideLoading();
this.hideMessages();
// Start continuous capture for OCR
this.startContinuousCapture();
} catch (error) {
this.handleCameraError(error);
this.hideLoading();
}
}
setupVideoElement() {
// Create video element
this.video = document.createElement('video');
this.video.autoplay = true;
this.video.playsInline = true;
this.video.muted = true;
this.video.srcObject = this.stream;
// Style video element
this.video.className = 'w-full h-full object-cover rounded-lg';
// Clear container and add video
this.scannerContainer.innerHTML = '';
this.scannerContainer.appendChild(this.video);
// Create canvas for capture
this.canvas = document.createElement('canvas');
this.ctx = this.canvas.getContext('2d');
}
startContinuousCapture() {
// Capture frame every 3 seconds for OCR processing
const captureInterval = setInterval(() => {
if (!this.isScanning) {
clearInterval(captureInterval);
return;
}
this.captureAndProcessFrame();
}, 3000);
}
async captureAndProcessFrame() {
if (!this.video || !this.canvas || !this.isScanning) return;
try {
// Set canvas size to video size
this.canvas.width = this.video.videoWidth;
this.canvas.height = this.video.videoHeight;
// Draw video frame to canvas
this.ctx.drawImage(this.video, 0, 0);
// Convert to blob for OCR
this.canvas.toBlob(async (blob) => {
await this.processImageWithOCR(blob);
}, 'image/jpeg', 0.8);
} catch (error) {
console.error('Error capturing frame:', error);
}
}
async processImageWithOCR(imageBlob) {
try {
this.showOcrProcessing();
const { data: { text } } = await Tesseract.recognize(
imageBlob,
'ind+eng',
{
logger: m => {
// Optional: show OCR progress
if (m.status === 'recognizing text') {
console.log(`OCR Progress: ${Math.round(m.progress * 100)}%`);
}
}
}
);
await this.extractDataFromText(text);
this.hideOcrProcessing();
} catch (error) {
console.error('OCR Error:', error);
this.hideOcrProcessing();
}
}
async extractDataFromText(text) {
const lines = text.split('\n').map(line => line.trim()).filter(line => line.length > 0);
let receiptNumber = '';
let weight = '';
// Patterns for receipt number detection
const receiptPatterns = [
/(?:no|nomor|receipt|struk|bon)[\s.:]*([0-9]{6,})/i,
/([0-9]{8,})/g, // Long number sequences
/(?:ticket|tiket)[\s.:]*([0-9]+)/i
];
// Patterns for weight detection
const weightPatterns = [
/(?:berat|weight|kg|kilogram)[\s.:]*([0-9]+(?:\.[0-9]+)?)/i,
/([0-9]+(?:\.[0-9]+)?)[\s]*(?:kg|kilogram)/i,
/(?:muatan|load)[\s.:]*([0-9]+(?:\.[0-9]+)?)/i
];
// Search for receipt number
for (const line of lines) {
for (const pattern of receiptPatterns) {
const match = line.match(pattern);
if (match && match[1] && match[1].length >= 6) {
receiptNumber = match[1];
break;
}
}
if (receiptNumber) break;
}
// Search for weight
for (const line of lines) {
for (const pattern of weightPatterns) {
const match = line.match(pattern);
if (match && match[1]) {
const weightValue = parseFloat(match[1]);
if (weightValue > 0 && weightValue < 100000) { // Reasonable weight range
weight = match[1];
break;
}
}
}
if (weight) break;
}
// If we found either piece of data, show results
if (receiptNumber || weight) {
this.detectedData.receiptNumber = receiptNumber;
this.detectedData.weight = weight;
this.showOcrResult();
this.playSuccessSound();
this.vibrate();
// Auto-stop scanning after successful detection
setTimeout(() => {
this.stopScanner();
}, 1000);
}
}
showOcrResult() {
this.detectedReceiptSpan.textContent = this.detectedData.receiptNumber || 'Tidak terdeteksi';
this.detectedWeightSpan.textContent = this.detectedData.weight || 'Tidak terdeteksi';
this.ocrResult.classList.remove('hidden');
}
applyDetectedData() {
if (this.detectedData.receiptNumber) {
nomorStrukInput.value = this.detectedData.receiptNumber;
nomorStrukInput.classList.add('auto-filled');
setTimeout(() => nomorStrukInput.classList.remove('auto-filled'), 2000);
}
if (this.detectedData.weight) {
beratMuatanInput.value = this.detectedData.weight;
beratMuatanInput.classList.add('auto-filled');
setTimeout(() => beratMuatanInput.classList.remove('auto-filled'), 2000);
}
this.hideMessages();
// Show success message
this.showSuccess('Data dari scan telah diisi ke form!');
}
async retryOcr() {
this.hideMessages();
this.detectedData = { receiptNumber: '', weight: '' };
if (this.isScanning) {
await this.stopScanner();
}
setTimeout(() => {
this.startScanner();
}, 500);
}
async stopScanner() {
if (this.stream) {
this.stream.getTracks().forEach(track => track.stop());
this.stream = null;
}
this.isScanning = false;
this.startBtn.classList.remove('hidden');
this.stopBtn.classList.add('hidden');
this.hideLoading();
this.hideMessages();
// Reset scanner container
this.scannerContainer.innerHTML = `
<div id="loading-scanner" class="absolute inset-0 bg-gray-900 flex items-center justify-center z-10">
<div class="text-center text-white">
<div class="loading-spinner mx-auto mb-2"></div>
<p class="text-sm">Memuat scanner...</p>
</div>
</div>
`;
this.loadingDiv = document.getElementById('loading-scanner');
}
handleCameraError(error) {
if (error.name === 'NotAllowedError') {
this.permissionDenied.classList.remove('hidden');
} else if (error.name === 'NotFoundError') {
this.showError('Kamera tidak ditemukan pada perangkat ini.');
} else if (error.name === 'NotReadableError') {
this.showError('Kamera sedang digunakan aplikasi lain. Tutup aplikasi lain dan coba lagi.');
} else {
this.showError('Gagal mengakses kamera: ' + error.message);
}
}
// Helper methods
showLoading() {
this.loadingDiv.classList.remove('hidden');
}
hideLoading() {
this.loadingDiv.classList.add('hidden');
}
showOcrProcessing() {
this.ocrProcessing.classList.remove('hidden');
}
hideOcrProcessing() {
this.ocrProcessing.classList.add('hidden');
}
hideMessages() {
this.permissionInfo.classList.add('hidden');
this.permissionDenied.classList.add('hidden');
this.ocrProcessing.classList.add('hidden');
this.ocrResult.classList.add('hidden');
}
showError(message) {
// Create temporary error message
const errorDiv = document.createElement('div');
errorDiv.className = 'bg-red-50 border border-red-200 rounded-lg p-3 mt-2';
errorDiv.innerHTML = `
<div class="flex items-center">
<i class="w-5 h-5 text-red-600 mr-2" data-lucide="alert-circle"></i>
<span class="text-red-800 text-sm">${message}</span>
</div>
`;
this.scannerContainer.parentNode.insertBefore(errorDiv, this.scannerContainer.nextSibling);
setTimeout(() => {
errorDiv.remove();
}, 5000);
}
showSuccess(message) {
// Create temporary success message
const successDiv = document.createElement('div');
successDiv.className = 'bg-green-50 border border-green-200 rounded-lg p-3 mt-2';
successDiv.innerHTML = `
<div class="flex items-center">
<i class="w-5 h-5 text-green-600 mr-2" data-lucide="check-circle"></i>
<span class="text-green-800 text-sm">${message}</span>
</div>
`;
this.scannerContainer.parentNode.insertBefore(successDiv, this.scannerContainer.nextSibling);
setTimeout(() => {
successDiv.remove();
}, 3000);
}
playSuccessSound() {
try {
const audioContext = new (window.AudioContext || window.webkitAudioContext)();
const oscillator = audioContext.createOscillator();
const gainNode = audioContext.createGain();
oscillator.connect(gainNode);
gainNode.connect(audioContext.destination);
oscillator.frequency.value = 800;
oscillator.type = 'square';
gainNode.gain.setValueAtTime(0.3, audioContext.currentTime);
gainNode.gain.exponentialRampToValueAtTime(0.01, audioContext.currentTime + 0.2);
oscillator.start(audioContext.currentTime);
oscillator.stop(audioContext.currentTime + 0.2);
} catch (e) {
// Silent fail
}
}
vibrate() {
if ('vibrate' in navigator) {
navigator.vibrate([200]);
}
}
}
// Initialize the receipt scanner
new ReceiptScanner();
});
</script>
</register-block>