756 lines
38 KiB
Plaintext
756 lines
38 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">
|
|
<div class="bg-upst text-white px-6 pt-10 pb-16 rounded-b-[40px] shadow-lg relative overflow-hidden">
|
|
<div class="absolute top-0 right-0 w-32 h-32 bg-white/5 rounded-full -mr-16 -mt-16"></div>
|
|
<div class="flex items-center justify-between relative z-10">
|
|
<a href="@Url.Action("Index", "Home")" class="w-10 h-10 flex items-center justify-center bg-white/20 hover:bg-white/30 rounded-xl transition-colors">
|
|
<i class="w-5 h-5" data-lucide="arrow-left"></i>
|
|
</a>
|
|
<div class="text-center">
|
|
<h1 class="text-lg font-bold tracking-wide uppercase">Unggah Struk</h1>
|
|
<p class="text-[10px] text-white/70 font-medium">Upload Struk SPJ</p>
|
|
</div>
|
|
<img src="@Url.Content("~/driver/upst_white.svg")" alt="UPST Logo" class="absolute top-6 left-8 w-20 h-auto opacity-20">
|
|
<div class="w-10"></div>
|
|
</div>
|
|
</div>
|
|
|
|
<div class="px-8 py-4">
|
|
<div class="mb-6">
|
|
<div class="flex flex-col items-center space-y-2 mb-4">
|
|
<div class="bg-upst rounded-full p-3">
|
|
<i data-lucide="camera" class="w-7 h-7 text-white"></i>
|
|
</div>
|
|
<h2 class="text-xl font-bold text-gray-700">Scan Struk Otomatis</h2>
|
|
<p class="text-sm text-gray-500 text-center">Arahkan kamera ke struk atau upload foto struk untuk membaca data secara otomatis.</p>
|
|
</div>
|
|
|
|
<div id="ocr-processing" class="hidden bg-yellow-50 border border-yellow-200 rounded-lg p-3 mb-4">
|
|
<div class="flex items-center">
|
|
<div class="loading-spinner-small mr-2"></div>
|
|
<span class="text-yellow-800 text-sm">AI sedang menganalisis foto struk...</span>
|
|
</div>
|
|
</div>
|
|
|
|
|
|
<div id="scan-success" class="hidden bg-green-50 border border-green-200 rounded-lg p-3 mb-4">
|
|
<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 font-medium">AI berhasil membaca data struk!</span>
|
|
</div>
|
|
<p class="text-green-700 text-sm mt-1">Data telah diisi otomatis. Periksa dan lengkapi jika diperlukan.</p>
|
|
</div>
|
|
|
|
|
|
<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>
|
|
|
|
<div class="space-y-3 mb-4">
|
|
<button id="start-scanner" type="button" class="w-full bg-upst hover:bg-gray-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>
|
|
|
|
<div id="scanner-active-btns" class="hidden grid-cols-2 gap-2" style="display:none">
|
|
<button id="capture-now" type="button" class="bg-green-600 hover:bg-green-700 text-white font-medium py-3 px-4 rounded-lg transition-colors flex items-center justify-center gap-2">
|
|
<i class="w-5 h-5" data-lucide="zap"></i>
|
|
Capture
|
|
</button>
|
|
<button id="stop-scanner" type="button" class="bg-gray-500 hover:bg-gray-600 text-white font-medium py-3 px-4 rounded-lg transition-colors flex items-center justify-center gap-2">
|
|
<i class="w-5 h-5" data-lucide="camera-off"></i>
|
|
Stop
|
|
</button>
|
|
</div>
|
|
|
|
|
|
@* <div class="flex items-center">
|
|
<div class="flex-1 border-t border-gray-200"></div>
|
|
<span class="px-3 text-sm text-gray-500 bg-white">atau</span>
|
|
<div class="flex-1 border-t border-gray-200"></div>
|
|
</div> *@
|
|
<label for="file-upload" class="w-full bg-upst text-white font-medium py-3 px-4 rounded-lg transition-colors cursor-pointer flex items-center justify-center gap-2 upload-label">
|
|
<i class="w-5 h-5" data-lucide="upload"></i>
|
|
Upload Foto Struk
|
|
</label>
|
|
<input
|
|
type="file"
|
|
id="file-upload"
|
|
accept="image/*"
|
|
class="hidden"
|
|
/>
|
|
<p class="text-xs text-gray-500 mt-2 text-center">
|
|
Pilih foto struk dari galeri atau ambil foto baru
|
|
</p>
|
|
|
|
|
|
<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>
|
|
|
|
|
|
</div>
|
|
</div>
|
|
|
|
<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">Hasil Scan</span>
|
|
<div class="flex-1 border-t border-gray-200"></div>
|
|
</div>
|
|
</div>
|
|
|
|
<form id="struk-form" action="@Url.Action("ProcessStruk", "Submit")" method="post" enctype="multipart/form-data" class="px-8 py-4 space-y-6 bg-white">
|
|
<input type="file" id="FotoStruk" name="FotoStruk" accept="image/*" class="hidden">
|
|
|
|
<div id="foto-struk-preview" class="hidden">
|
|
<div class="flex items-center justify-between mb-1">
|
|
<label class="block text-sm font-medium text-gray-700">Foto Struk <span class="text-red-500">*</span></label>
|
|
<span class="text-xs text-green-600 font-medium flex items-center gap-1">
|
|
<i data-lucide="check-circle" class="w-3 h-3"></i> Tersimpan
|
|
</span>
|
|
</div>
|
|
<img id="foto-struk-img" class="w-full rounded-lg border border-gray-200 max-h-48 object-contain bg-gray-50" alt="Foto Struk" />
|
|
</div>
|
|
<div id="foto-struk-empty" class="bg-yellow-50 border border-yellow-200 rounded-lg p-3">
|
|
<div class="flex items-center gap-2">
|
|
<i data-lucide="image" class="w-4 h-4 text-yellow-600 shrink-0"></i>
|
|
<span class="text-yellow-800 text-sm font-medium">Foto struk belum diambil</span>
|
|
</div>
|
|
<p class="text-yellow-700 text-xs mt-1">Gunakan kamera atau upload foto struk terlebih dahulu sebelum submit.</p>
|
|
<p id="err-foto" class="hidden text-xs text-red-500 mt-1 font-medium"></p>
|
|
</div>
|
|
|
|
<div class="flex flex-col items-center space-y-2">
|
|
<div class="bg-upst rounded-full p-3">
|
|
<i data-lucide="edit-3" class="w-7 h-7 text-white"></i>
|
|
</div>
|
|
<h2 class="text-xl font-bold text-gray-700">Data Struk</h2>
|
|
<p class="text-sm text-gray-500 text-center">Periksa dan lengkapi data struk sebelum submit.</p>
|
|
</div>
|
|
|
|
<div>
|
|
<label class="block text-sm font-medium text-gray-700 mb-2">Timbang</label>
|
|
<div id="timbang-options" class="grid grid-cols-2 gap-3">
|
|
<label class="timbang-opt flex items-center gap-2 p-3 border-2 rounded-lg cursor-pointer transition-all border-upst bg-upst/5" data-value="TPA">
|
|
<input type="radio" name="Timbang" value="TPA" checked class="hidden">
|
|
<div class="w-4 h-4 rounded-full border-2 border-upst flex items-center justify-center shrink-0">
|
|
<div class="w-2 h-2 rounded-full bg-upst timbang-dot"></div>
|
|
</div>
|
|
<span class="text-sm font-medium text-gray-700">Timbangan TPA</span>
|
|
</label>
|
|
<label class="timbang-opt flex items-center gap-2 p-3 border-2 rounded-lg cursor-pointer transition-all border-gray-200" data-value="RDF">
|
|
<input type="radio" name="Timbang" value="RDF" class="hidden">
|
|
<div class="w-4 h-4 rounded-full border-2 border-gray-300 flex items-center justify-center shrink-0">
|
|
<div class="w-2 h-2 rounded-full bg-transparent timbang-dot"></div>
|
|
</div>
|
|
<span class="text-sm font-medium text-gray-700">Timbangan RDF</span>
|
|
</label>
|
|
</div>
|
|
</div>
|
|
|
|
<div class="grid grid-cols-1 gap-4">
|
|
<div>
|
|
<label for="NomorStruk" class="block text-sm font-medium text-gray-700 mb-1">Nomor Struk <span class="text-red-500">*</span></label>
|
|
<input
|
|
type="text"
|
|
id="NomorStruk"
|
|
name="NomorStruk"
|
|
class="field-input mt-1 block w-full rounded-lg border border-gray-300 shadow-sm focus:border-gray-500 focus:ring-2 focus:ring-gray-200 transition-all duration-150 px-4 py-2"
|
|
placeholder="8001441"
|
|
/>
|
|
<p id="err-NomorStruk" class="hidden text-xs text-red-500 mt-1"></p>
|
|
<p class="text-xs text-gray-500 mt-1">Nomor tanpa prefix (contoh: 8001441)</p>
|
|
</div>
|
|
|
|
<div>
|
|
<label for="NomorPolisi" class="block text-sm font-medium text-gray-700 mb-1">Nomor Polisi <span class="text-red-500">*</span></label>
|
|
<input
|
|
type="text"
|
|
id="NomorPolisi"
|
|
name="NomorPolisi"
|
|
class="field-input mt-1 block w-full rounded-lg border border-gray-300 shadow-sm focus:border-gray-500 focus:ring-2 focus:ring-gray-200 transition-all duration-150 px-4 py-2"
|
|
placeholder="B 9125 PJA"
|
|
/>
|
|
<p id="err-NomorPolisi" class="hidden text-xs text-red-500 mt-1"></p>
|
|
</div>
|
|
|
|
<div>
|
|
<label for="Penugasan" class="block text-sm font-medium text-gray-700 mb-1">Penugasan <span class="text-red-500">*</span></label>
|
|
<input
|
|
type="text"
|
|
id="Penugasan"
|
|
name="Penugasan"
|
|
class="field-input mt-1 block w-full rounded-lg border border-gray-300 shadow-sm focus:border-gray-500 focus:ring-2 focus:ring-gray-200 transition-all duration-150 px-4 py-2"
|
|
placeholder="JAKARTA BARAT"
|
|
/>
|
|
<p id="err-Penugasan" class="hidden text-xs text-red-500 mt-1"></p>
|
|
</div>
|
|
|
|
<div class="grid grid-cols-1 gap-3">
|
|
<div>
|
|
<label for="WaktuMasuk" class="block text-sm font-medium text-gray-700 mb-1">Masuk <span class="text-red-500">*</span></label>
|
|
<input
|
|
type="text"
|
|
id="WaktuMasuk"
|
|
name="WaktuMasuk"
|
|
class="field-input mt-1 block w-full rounded-lg border border-gray-300 shadow-sm focus:border-gray-500 focus:ring-2 focus:ring-gray-200 transition-all duration-150 px-4 py-2"
|
|
placeholder="2025-08-04, 08:13:51"
|
|
/>
|
|
<p id="err-WaktuMasuk" class="hidden text-xs text-red-500 mt-1"></p>
|
|
</div>
|
|
<div>
|
|
<label for="WaktuKeluar" class="block text-sm font-medium text-gray-700 mb-1">Keluar <span class="text-red-500">*</span></label>
|
|
<input
|
|
type="text"
|
|
id="WaktuKeluar"
|
|
name="WaktuKeluar"
|
|
class="field-input mt-1 block w-full rounded-lg border border-gray-300 shadow-sm focus:border-gray-500 focus:ring-2 focus:ring-gray-200 transition-all duration-150 px-4 py-2"
|
|
placeholder="2025-08-04, 14:35:10"
|
|
/>
|
|
<p id="err-WaktuKeluar" class="hidden text-xs text-red-500 mt-1"></p>
|
|
</div>
|
|
</div>
|
|
|
|
<div class="grid grid-cols-2 gap-3">
|
|
<div>
|
|
<label for="BeratMasuk" class="block text-sm font-medium text-gray-700 mb-1">Berat Masuk (kg) <span class="text-red-500">*</span></label>
|
|
<input
|
|
type="number"
|
|
id="BeratMasuk"
|
|
name="BeratMasuk"
|
|
class="field-input mt-1 block w-full rounded-lg border border-gray-300 shadow-sm focus:border-gray-500 focus:ring-2 focus:ring-gray-200 transition-all duration-150 px-4 py-2"
|
|
placeholder="23280"
|
|
/>
|
|
<p id="err-BeratMasuk" class="hidden text-xs text-red-500 mt-1"></p>
|
|
</div>
|
|
<div>
|
|
<label for="BeratKeluar" class="block text-sm font-medium text-gray-700 mb-1">Berat Keluar (kg) <span class="text-red-500">*</span></label>
|
|
<input
|
|
type="number"
|
|
id="BeratKeluar"
|
|
name="BeratKeluar"
|
|
class="field-input mt-1 block w-full rounded-lg border border-gray-300 shadow-sm focus:border-gray-500 focus:ring-2 focus:ring-gray-200 transition-all duration-150 px-4 py-2"
|
|
placeholder="13540"
|
|
/>
|
|
<p id="err-BeratKeluar" class="hidden text-xs text-red-500 mt-1"></p>
|
|
</div>
|
|
</div>
|
|
|
|
<div>
|
|
<label for="BeratNett" class="block text-sm font-medium text-gray-700 mb-1">Berat Nett (kg) <span class="text-red-500">*</span></label>
|
|
<input
|
|
type="number"
|
|
id="BeratNett"
|
|
name="BeratNett"
|
|
class="field-input mt-1 block w-full rounded-lg border border-gray-300 shadow-sm focus:border-gray-500 focus:ring-2 focus:ring-gray-200 transition-all duration-150 px-4 py-2"
|
|
placeholder="9740"
|
|
/>
|
|
<p id="err-BeratNett" class="hidden text-xs text-red-500 mt-1"></p>
|
|
</div>
|
|
</div>
|
|
|
|
<button type="submit" class="w-full bg-upst text-white py-3 rounded-lg font-semibold shadow hover:from-gray-600 hover:to-gray-500 transition-all duration-150 flex items-center justify-center gap-2">
|
|
<i data-lucide="send" class="w-5 h-5"></i>
|
|
Submit Data Struk
|
|
</button>
|
|
</form>
|
|
|
|
<partial name="~/Views/Admin/Transport/SpjDriverUpst/Shared/Components/_Navigation.cshtml" />
|
|
|
|
</div>
|
|
|
|
<register-block dynamic-section="scripts" key="jsSubmitStruk">
|
|
<script>
|
|
document.addEventListener('DOMContentLoaded', function() {
|
|
// Numeric-only inputs
|
|
['NomorStruk', 'BeratMasuk', 'BeratKeluar', 'BeratNett'].forEach(id => {
|
|
const el = document.getElementById(id);
|
|
if (el) el.addEventListener('input', function() { this.value = this.value.replace(/[^0-9]/g, ''); });
|
|
});
|
|
|
|
// Clear field error on input
|
|
['NomorStruk', 'NomorPolisi', 'Penugasan', 'WaktuMasuk', 'WaktuKeluar', 'BeratMasuk', 'BeratKeluar', 'BeratNett'].forEach(id => {
|
|
const el = document.getElementById(id);
|
|
el?.addEventListener('input', function() {
|
|
this.classList.remove('border-red-400', 'ring-2', 'ring-red-100');
|
|
const errEl = document.getElementById('err-' + id);
|
|
if (errEl) { errEl.classList.add('hidden'); errEl.textContent = ''; }
|
|
});
|
|
});
|
|
|
|
// Timbang radio visual styling
|
|
document.querySelectorAll('.timbang-opt').forEach(label => {
|
|
label.addEventListener('click', function() {
|
|
document.querySelectorAll('.timbang-opt').forEach(l => {
|
|
l.classList.remove('border-upst', 'bg-upst/5');
|
|
l.classList.add('border-gray-200');
|
|
const dot = l.querySelector('.timbang-dot');
|
|
if (dot) { dot.classList.remove('bg-upst'); dot.classList.add('bg-transparent'); }
|
|
const ring = l.querySelector('.rounded-full.border-2');
|
|
if (ring) { ring.classList.remove('border-upst'); ring.classList.add('border-gray-300'); }
|
|
});
|
|
this.classList.add('border-upst', 'bg-upst/5');
|
|
this.classList.remove('border-gray-200');
|
|
const dot = this.querySelector('.timbang-dot');
|
|
if (dot) { dot.classList.add('bg-upst'); dot.classList.remove('bg-transparent'); }
|
|
const ring = this.querySelector('.rounded-full.border-2');
|
|
if (ring) { ring.classList.add('border-upst'); ring.classList.remove('border-gray-300'); }
|
|
});
|
|
});
|
|
|
|
// ===== AI Receipt Scanner =====
|
|
class ReceiptScanner {
|
|
constructor() {
|
|
this.isScanning = false;
|
|
this.stream = null;
|
|
this.video = null;
|
|
this.canvas = null;
|
|
this.ctx = null;
|
|
this.capturedFile = null;
|
|
this.aiProcessing = false;
|
|
this.captureInterval = null;
|
|
|
|
this.startBtn = document.getElementById('start-scanner');
|
|
this.scannerActiveBtns = document.getElementById('scanner-active-btns');
|
|
this.stopBtn = document.getElementById('stop-scanner');
|
|
this.captureNowBtn = document.getElementById('capture-now');
|
|
this.loadingDiv = document.getElementById('loading-scanner');
|
|
this.aiProcessingDiv = document.getElementById('ocr-processing');
|
|
this.permissionInfo = document.getElementById('permission-info');
|
|
this.permissionDenied = document.getElementById('permission-denied');
|
|
this.scanSuccess = document.getElementById('scan-success');
|
|
this.scannerContainer = document.getElementById('scanner-container');
|
|
this.fileUpload = document.getElementById('file-upload');
|
|
|
|
this.bindEvents();
|
|
this.checkBrowserSupport();
|
|
}
|
|
|
|
bindEvents() {
|
|
this.startBtn?.addEventListener('click', () => this.startScanner());
|
|
this.stopBtn?.addEventListener('click', () => this.stopScanner());
|
|
this.captureNowBtn?.addEventListener('click', () => {
|
|
if (!this.aiProcessing) this.captureAndProcess();
|
|
});
|
|
this.fileUpload?.addEventListener('change', (e) => this.handleFileUpload(e));
|
|
}
|
|
|
|
checkBrowserSupport() {
|
|
if (!navigator.mediaDevices?.getUserMedia) {
|
|
if (this.startBtn) {
|
|
this.startBtn.disabled = true;
|
|
this.startBtn.textContent = 'Kamera tidak didukung di browser ini';
|
|
this.startBtn.classList.add('opacity-60', 'cursor-not-allowed');
|
|
}
|
|
}
|
|
}
|
|
|
|
async startScanner() {
|
|
try {
|
|
this.hideMessages();
|
|
this.permissionInfo?.classList.remove('hidden');
|
|
|
|
this.stream = await navigator.mediaDevices.getUserMedia({
|
|
video: { facingMode: 'environment', width: { ideal: 1280 }, height: { ideal: 720 } }
|
|
});
|
|
|
|
this.video = document.createElement('video');
|
|
this.video.autoplay = true;
|
|
this.video.playsInline = true;
|
|
this.video.muted = true;
|
|
this.video.srcObject = this.stream;
|
|
this.video.className = 'w-full h-full object-cover rounded-lg';
|
|
|
|
this.scannerContainer.innerHTML = '';
|
|
this.scannerContainer.appendChild(this.video);
|
|
|
|
this.canvas = document.createElement('canvas');
|
|
this.ctx = this.canvas.getContext('2d');
|
|
|
|
this.isScanning = true;
|
|
this.startBtn?.classList.add('hidden');
|
|
if (this.scannerActiveBtns) this.scannerActiveBtns.style.display = 'grid';
|
|
this.permissionInfo?.classList.add('hidden');
|
|
|
|
// Auto-capture: first after 2.5s, then every 6s
|
|
setTimeout(() => { if (this.isScanning && !this.aiProcessing) this.captureAndProcess(); }, 2500);
|
|
this.captureInterval = setInterval(() => {
|
|
if (this.isScanning && !this.aiProcessing) this.captureAndProcess();
|
|
}, 6000);
|
|
|
|
} catch (error) {
|
|
this.permissionInfo?.classList.add('hidden');
|
|
if (error.name === 'NotAllowedError') {
|
|
this.permissionDenied?.classList.remove('hidden');
|
|
} else if (error.name === 'NotFoundError') {
|
|
this.showError('Kamera tidak ditemukan pada perangkat ini.');
|
|
} else {
|
|
this.showError('Gagal mengakses kamera: ' + error.message);
|
|
}
|
|
}
|
|
}
|
|
|
|
async captureAndProcess() {
|
|
if (!this.video || !this.canvas || !this.isScanning || this.aiProcessing) return;
|
|
if (!this.video.videoWidth) return;
|
|
|
|
this.canvas.width = this.video.videoWidth;
|
|
this.canvas.height = this.video.videoHeight;
|
|
this.ctx.drawImage(this.video, 0, 0);
|
|
|
|
this.canvas.toBlob(async (blob) => {
|
|
if (blob) await this.processImageWithAI(blob);
|
|
}, 'image/jpeg', 0.85);
|
|
}
|
|
|
|
stopScanner() {
|
|
if (this.captureInterval) { clearInterval(this.captureInterval); this.captureInterval = null; }
|
|
if (this.stream) { this.stream.getTracks().forEach(t => t.stop()); this.stream = null; }
|
|
|
|
this.isScanning = false;
|
|
this.startBtn?.classList.remove('hidden');
|
|
if (this.scannerActiveBtns) this.scannerActiveBtns.style.display = 'none';
|
|
this.permissionInfo?.classList.add('hidden');
|
|
this.permissionDenied?.classList.add('hidden');
|
|
|
|
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">Kamera dihentikan</p>
|
|
</div>
|
|
</div>`;
|
|
this.loadingDiv = document.getElementById('loading-scanner');
|
|
}
|
|
|
|
async handleFileUpload(event) {
|
|
const file = event.target.files[0];
|
|
if (!file) return;
|
|
|
|
if (!file.type.startsWith('image/')) {
|
|
this.showError('Harap pilih file gambar (JPG, PNG, dll.).');
|
|
event.target.value = ''; return;
|
|
}
|
|
if (file.size > 10 * 1024 * 1024) {
|
|
this.showError('Ukuran file terlalu besar. Maksimal 10MB.');
|
|
event.target.value = ''; return;
|
|
}
|
|
|
|
// Display image in scanner container
|
|
const reader = new FileReader();
|
|
reader.onload = (e) => {
|
|
const img = document.createElement('img');
|
|
img.src = e.target.result;
|
|
img.className = 'w-full h-full object-contain rounded-lg';
|
|
this.scannerContainer.innerHTML = '';
|
|
this.scannerContainer.appendChild(img);
|
|
};
|
|
reader.readAsDataURL(file);
|
|
|
|
if (this.isScanning) this.stopScanner();
|
|
this.hideMessages();
|
|
await this.processImageWithAI(file);
|
|
event.target.value = '';
|
|
}
|
|
|
|
async processImageWithAI(imageInput) {
|
|
if (this.aiProcessing) return;
|
|
this.aiProcessing = true;
|
|
this.aiProcessingDiv?.classList.remove('hidden');
|
|
|
|
try {
|
|
const formData = new FormData();
|
|
const fileName = imageInput instanceof File ? imageInput.name : 'struk.jpg';
|
|
formData.append('Foto', imageInput, fileName);
|
|
|
|
const response = await fetch('/upst/submit/ocr-struk', {
|
|
method: 'POST',
|
|
body: formData
|
|
});
|
|
|
|
const result = await response.json();
|
|
|
|
if (result.success && result.data) {
|
|
this.applyAIData(result.data);
|
|
|
|
// Store file for form submission
|
|
this.capturedFile = imageInput instanceof File
|
|
? imageInput
|
|
: new File([imageInput], 'struk.jpg', { type: 'image/jpeg' });
|
|
this.updatePhotoInput();
|
|
this.showFotoPreview();
|
|
this.showScanSuccess();
|
|
this.vibrate();
|
|
|
|
if (this.isScanning) this.stopScanner();
|
|
|
|
setTimeout(() => {
|
|
document.getElementById('struk-form')?.scrollIntoView({ behavior: 'smooth', block: 'start' });
|
|
}, 600);
|
|
} else {
|
|
this.showError(result.message || 'AI tidak dapat membaca data struk. Coba upload foto yang lebih jelas.');
|
|
}
|
|
} catch (err) {
|
|
this.showError('Gagal menghubungi AI. Periksa koneksi dan coba lagi.');
|
|
} finally {
|
|
this.aiProcessing = false;
|
|
this.aiProcessingDiv?.classList.add('hidden');
|
|
}
|
|
}
|
|
|
|
applyAIData(data) {
|
|
const fields = [
|
|
{ id: 'NomorStruk', value: data.nomorStruk },
|
|
{ id: 'NomorPolisi', value: data.nomorPolisi },
|
|
{ id: 'Penugasan', value: data.penugasan },
|
|
{ id: 'WaktuMasuk', value: data.waktuMasuk },
|
|
{ id: 'WaktuKeluar', value: data.waktuKeluar },
|
|
{ id: 'BeratMasuk', value: data.beratMasuk },
|
|
{ id: 'BeratKeluar', value: data.beratKeluar },
|
|
{ id: 'BeratNett', value: data.beratNett },
|
|
];
|
|
fields.forEach(({ id, value }) => {
|
|
if (value !== null && value !== undefined && value !== '') {
|
|
const el = document.getElementById(id);
|
|
if (el) {
|
|
el.value = value;
|
|
el.classList.add('auto-filled');
|
|
el.dispatchEvent(new Event('input', { bubbles: true }));
|
|
setTimeout(() => el.classList.remove('auto-filled'), 3000);
|
|
}
|
|
}
|
|
});
|
|
}
|
|
|
|
updatePhotoInput() {
|
|
if (!this.capturedFile) return;
|
|
const input = document.getElementById('FotoStruk');
|
|
if (!input) return;
|
|
try {
|
|
const dt = new DataTransfer();
|
|
dt.items.add(this.capturedFile);
|
|
input.files = dt.files;
|
|
} catch (e) { /* fallback: manually appended on form submit */ }
|
|
}
|
|
|
|
showFotoPreview() {
|
|
if (!this.capturedFile) return;
|
|
const preview = document.getElementById('foto-struk-preview');
|
|
const img = document.getElementById('foto-struk-img');
|
|
const empty = document.getElementById('foto-struk-empty');
|
|
if (preview && img) {
|
|
img.src = URL.createObjectURL(this.capturedFile);
|
|
preview.classList.remove('hidden');
|
|
if (window.lucide) lucide.createIcons();
|
|
}
|
|
if (empty) empty.classList.add('hidden');
|
|
}
|
|
|
|
showScanSuccess() {
|
|
this.scanSuccess?.classList.remove('hidden');
|
|
setTimeout(() => this.scanSuccess?.classList.add('hidden'), 6000);
|
|
}
|
|
|
|
showError(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 gap-2"><i class="w-4 h-4 text-red-600 shrink-0" data-lucide="alert-circle"></i><span class="text-red-800 text-sm">${message}</span></div>`;
|
|
this.scannerContainer?.parentNode?.insertBefore(errorDiv, this.scannerContainer.nextSibling);
|
|
if (window.lucide) lucide.createIcons();
|
|
setTimeout(() => errorDiv.remove(), 6000);
|
|
}
|
|
|
|
hideMessages() {
|
|
this.permissionInfo?.classList.add('hidden');
|
|
this.permissionDenied?.classList.add('hidden');
|
|
this.aiProcessingDiv?.classList.add('hidden');
|
|
this.scanSuccess?.classList.add('hidden');
|
|
}
|
|
|
|
vibrate() {
|
|
if ('vibrate' in navigator) navigator.vibrate([200]);
|
|
}
|
|
}
|
|
|
|
const scanner = new ReceiptScanner();
|
|
|
|
// ===== Client-side validation =====
|
|
function clearErrors() {
|
|
document.querySelectorAll('.field-input').forEach(el => {
|
|
el.classList.remove('border-red-400', 'ring-2', 'ring-red-100');
|
|
});
|
|
document.querySelectorAll('[id^="err-"]').forEach(el => {
|
|
el.classList.add('hidden');
|
|
el.textContent = '';
|
|
});
|
|
}
|
|
|
|
function fieldError(id, message) {
|
|
const input = document.getElementById(id);
|
|
const errEl = document.getElementById('err-' + id);
|
|
if (input) {
|
|
input.classList.add('border-red-400', 'ring-2', 'ring-red-100');
|
|
input.classList.remove('border-gray-300');
|
|
}
|
|
if (errEl) {
|
|
errEl.textContent = message;
|
|
errEl.classList.remove('hidden');
|
|
}
|
|
}
|
|
|
|
function validateForm() {
|
|
clearErrors();
|
|
let firstErrorId = null;
|
|
|
|
const rules = [
|
|
{ id: 'NomorStruk', label: 'Nomor Struk', type: 'digits', minLen: 3 },
|
|
{ id: 'NomorPolisi', label: 'Nomor Polisi', type: 'text' },
|
|
{ id: 'Penugasan', label: 'Penugasan', type: 'text' },
|
|
{ id: 'WaktuMasuk', label: 'Waktu Masuk', type: 'text' },
|
|
{ id: 'WaktuKeluar', label: 'Waktu Keluar', type: 'text' },
|
|
{ id: 'BeratMasuk', label: 'Berat Masuk', type: 'number', min: 1 },
|
|
{ id: 'BeratKeluar', label: 'Berat Keluar', type: 'number', min: 1 },
|
|
{ id: 'BeratNett', label: 'Berat Nett', type: 'number', min: 100, max: 50000 },
|
|
];
|
|
|
|
rules.forEach(rule => {
|
|
const el = document.getElementById(rule.id);
|
|
if (!el) return;
|
|
const val = el.value.trim();
|
|
|
|
if (!val) {
|
|
fieldError(rule.id, `${rule.label} wajib diisi.`);
|
|
if (!firstErrorId) firstErrorId = rule.id;
|
|
return;
|
|
}
|
|
|
|
if (rule.type === 'digits' && !/^\d+$/.test(val)) {
|
|
fieldError(rule.id, `${rule.label} harus berupa angka.`);
|
|
if (!firstErrorId) firstErrorId = rule.id;
|
|
return;
|
|
}
|
|
|
|
if (rule.type === 'digits' && rule.minLen && val.length < rule.minLen) {
|
|
fieldError(rule.id, `${rule.label} minimal ${rule.minLen} digit.`);
|
|
if (!firstErrorId) firstErrorId = rule.id;
|
|
return;
|
|
}
|
|
|
|
if (rule.type === 'number') {
|
|
const num = Number(val);
|
|
if (isNaN(num) || num <= 0) {
|
|
fieldError(rule.id, `${rule.label} harus berupa angka positif.`);
|
|
if (!firstErrorId) firstErrorId = rule.id;
|
|
return;
|
|
}
|
|
if (rule.min !== undefined && num < rule.min) {
|
|
fieldError(rule.id, `${rule.label} minimal ${rule.min} kg.`);
|
|
if (!firstErrorId) firstErrorId = rule.id;
|
|
return;
|
|
}
|
|
if (rule.max !== undefined && num > rule.max) {
|
|
fieldError(rule.id, `${rule.label} maksimal ${rule.max.toLocaleString('id-ID')} kg.`);
|
|
if (!firstErrorId) firstErrorId = rule.id;
|
|
}
|
|
}
|
|
});
|
|
|
|
// Foto struk wajib
|
|
const fotoInput = document.getElementById('FotoStruk');
|
|
const hasFoto = scanner.capturedFile || fotoInput?.files?.length;
|
|
if (!hasFoto) {
|
|
const errEl = document.getElementById('err-foto');
|
|
if (errEl) { errEl.textContent = 'Foto struk wajib diupload atau di-scan.'; errEl.classList.remove('hidden'); }
|
|
if (!firstErrorId) firstErrorId = 'foto-struk-preview';
|
|
}
|
|
|
|
if (firstErrorId) {
|
|
const scrollTarget = document.getElementById(firstErrorId) || document.getElementById('foto-struk-preview');
|
|
scrollTarget?.scrollIntoView({ behavior: 'smooth', block: 'center' });
|
|
return false;
|
|
}
|
|
return true;
|
|
}
|
|
|
|
// Form submit: validate then submit
|
|
document.getElementById('struk-form')?.addEventListener('submit', async function(e) {
|
|
e.preventDefault();
|
|
|
|
if (!validateForm()) return;
|
|
|
|
const formData = new FormData(this);
|
|
|
|
// Fallback: manually append if DataTransfer API did not set the file input
|
|
const fotoInput = document.getElementById('FotoStruk');
|
|
if (scanner.capturedFile && (!fotoInput?.files?.length)) {
|
|
formData.append('FotoStruk', scanner.capturedFile, 'struk.jpg');
|
|
}
|
|
|
|
const submitBtn = this.querySelector('[type="submit"]');
|
|
const originalHtml = submitBtn?.innerHTML;
|
|
if (submitBtn) {
|
|
submitBtn.disabled = true;
|
|
submitBtn.innerHTML = '<div class="loading-spinner-small inline-block mr-2"></div>Menyimpan...';
|
|
}
|
|
|
|
try {
|
|
const response = await fetch(this.action, { method: 'POST', body: formData });
|
|
if (response.redirected) {
|
|
window.location.href = response.url;
|
|
} else {
|
|
window.location.reload();
|
|
}
|
|
} catch (err) {
|
|
if (submitBtn) {
|
|
submitBtn.disabled = false;
|
|
submitBtn.innerHTML = originalHtml;
|
|
if (window.lucide) lucide.createIcons();
|
|
}
|
|
alert('Gagal submit. Silakan coba lagi.');
|
|
}
|
|
});
|
|
});
|
|
</script>
|
|
</register-block>
|