// /public/js/receipts.js
document.addEventListener('DOMContentLoaded', function() {
const dropZone = document.getElementById('dropZone');
const fileInput = document.getElementById('file');
const fileNameDisplay = document.getElementById('fileName');
if (dropZone && fileInput) {
['dragenter', 'dragover', 'dragleave', 'drop'].forEach(eventName => {
dropZone.addEventListener(eventName, e => {
e.preventDefault();
e.stopPropagation();
}, false);
});
['dragenter', 'dragover'].forEach(eventName => {
dropZone.addEventListener(eventName, () => dropZone.classList.add('dragover'), false);
});
['dragleave', 'drop'].forEach(eventName => {
dropZone.addEventListener(eventName, () => dropZone.classList.remove('dragover'), false);
});
dropZone.addEventListener('drop', e => {
const files = e.dataTransfer.files;
if (files.length > 0) {
fileInput.files = files;
updateFileName(files[0].name);
}
}, false);
fileInput.addEventListener('change', function() {
if (this.files.length > 0) {
const file = this.files[0];
updateFileName(file.name);
const lowerName = file.name.toLowerCase();
// Offer cropping for images, including HEIC/HEIF which browsers may not
// flag with an image/ MIME type
if (file.type.startsWith('image/') || lowerName.endsWith('.heic') || lowerName.endsWith('.heif')) {
initPreUploadCrop(file);
}
}
});
// Intercept form submission to show a blocking progress overlay.
// Without this, the user sees nothing for 5-20s while the server runs
// the ImageMagick pre-processing and Tesseract OCR pipeline (OCR.pm).
// We locate the form via the file input rather than requiring a fixed ID.
const uploadForm = fileInput.closest('form');
if (uploadForm) {
uploadForm.addEventListener('submit', function() {
const submitBtn = uploadForm.querySelector('[type="submit"]');
if (submitBtn) {
submitBtn.disabled = true;
submitBtn.textContent = 'Processing...';
}
showUploadProgress();
});
}
}
// --- Upload Page Functions ---
// Converts HEIC/HEIF to JPEG for browser display, then opens the crop
// modal so the user can refine the image before it is uploaded.
async function initPreUploadCrop(file) {
let displayFile = file;
const lowerName = file.name.toLowerCase();
if (lowerName.endsWith('.heic') || lowerName.endsWith('.heif')) {
showToast('Converting HEIC for preview...', 'info');
try {
const blob = await heic2any({ blob: file, toType: 'image/jpeg', quality: 0.8 });
// heic2any returns an array when the HEIC contains multiple frames
displayFile = Array.isArray(blob) ? blob[0] : blob;
} catch (err) {
console.error('HEIC conversion failed:', err);
showToast('Could not convert HEIC for preview.', 'error');
return;
}
}
const reader = new FileReader();
reader.onload = function(e) {
const modal = document.getElementById('cropModal');
const img = document.getElementById('cropImg');
if (modal && img) {
img.src = e.target.result;
modal.style.display = 'flex';
// Delay Cropper.js init to ensure the image is rendered and has dimensions
setTimeout(() => {
if (cropper) cropper.destroy();
cropper = new Cropper(img, { viewMode: 1, autoCropArea: 1, responsive: true });
}, 100);
}
};
reader.readAsDataURL(displayFile);
}
// Reads the cropped canvas back into the file input via DataTransfer so
// the cropped version â not the original â is sent with the form POST.
window.applyPreUploadCrop = function() {
if (!cropper) return;
const canvas = cropper.getCroppedCanvas();
canvas.toBlob((blob) => {
const croppedFile = new File([blob], fileInput.files[0].name, {
type: 'image/png',
lastModified: new Date().getTime()
});
const dt = new DataTransfer();
dt.items.add(croppedFile);
fileInput.files = dt.files;
showToast('Image refined successfully!', 'success');
closeCropModal();
}, 'image/png');
};
// Updates the filename label beneath the drop zone after a file is selected
// or a pre-upload crop is applied.
function updateFileName(name) {
if (fileNameDisplay) {
fileNameDisplay.textContent = name;
fileNameDisplay.style.display = 'block';
}
}
// Builds and injects a full-page loading overlay with a spinner and cycling
// status messages. Message timings are tuned to the OCR.pm pipeline:
// 0s â multipart upload transmitting to server
// 3s â ImageMagick: grayscale / unsharp / deskew / threshold
// 6s â Tesseract: primary pass (psm 6 / oem 1)
// 11s â Tesseract: fallback pass (200% resize, triggered when date or total absent)
// 16s â DB write and redirect
// The overlay stays alive until the browser navigates away on server response.
function showUploadProgress() {
const overlay = document.createElement('div');
overlay.className = 'upload-progress-overlay';
overlay.innerHTML = `
Uploading receipt...
Please wait while we scan and extract details.
`;
document.body.appendChild(overlay);
const stages = [
[0, 'Uploading receipt...'],
[3000, 'Processing image...'],
[6000, 'Scanning for text...'],
[11000, 'Extracting details...'],
[16000, 'Almost done...'],
];
stages.forEach(([delay, text]) => {
setTimeout(() => {
const label = document.getElementById('uploadProgressLabel');
if (label) label.textContent = text;
}, delay);
});
}
// --- Receipt Viewer Modal ---
window.openReceiptModal = function(id) {
const modalImg = document.getElementById('modalImg');
const modal = document.getElementById('receiptModal');
if (modalImg && modal) {
modalImg.src = '/receipts/serve/' + id;
modal.style.display = 'flex';
}
};
window.closeReceiptModal = function() {
const modal = document.getElementById('receiptModal');
if (modal) modal.style.display = 'none';
};
// --- Edit Modal ---
window.openEditModal = function(receipt) {
const modal = document.getElementById('editModal');
const form = document.getElementById('editForm');
if (modal && form) {
form.action = '/receipts/update/' + receipt.id;
document.getElementById('editStoreName').value = receipt.store_name || '';
document.getElementById('editDate').value = receipt.receipt_date || '';
document.getElementById('editAmount').value = receipt.total_amount || '';
document.getElementById('editDescription').value = receipt.description || '';
modal.style.display = 'flex';
}
};
window.closeEditModal = function() {
const modal = document.getElementById('editModal');
if (modal) modal.style.display = 'none';
};
// --- Crop Modal (post-upload, applied to existing records) ---
let cropper = null;
let currentCropId = null;
window.openCropModal = async function(id) {
currentCropId = id;
const modal = document.getElementById('cropModal');
const img = document.getElementById('cropImg');
if (!(modal && img)) return;
modal.style.display = 'flex';
showToast('Loading image...', 'info');
try {
const response = await fetch('/receipts/serve/' + id);
const blob = await response.blob();
let displayBlob = blob;
// Server may store HEIC originals; convert before passing to Cropper.js
if (blob.type === 'image/heic' || blob.type === 'image/heif') {
showToast('Converting HEIC for preview...', 'info');
const convertedBlob = await heic2any({ blob: blob, toType: 'image/jpeg', quality: 0.8 });
displayBlob = Array.isArray(convertedBlob) ? convertedBlob[0] : convertedBlob;
}
const reader = new FileReader();
reader.onload = function(e) {
img.src = e.target.result;
// Defer Cropper.js init until the img element has painted and has dimensions
img.onload = function() {
if (cropper) cropper.destroy();
cropper = new Cropper(img, { viewMode: 1, autoCropArea: 1, responsive: true });
showToast('Ready to crop.', 'success');
};
};
reader.readAsDataURL(displayBlob);
} catch (err) {
console.error('Failed to load image for cropping:', err);
showToast('Error loading image.', 'error');
closeCropModal();
}
};
window.closeCropModal = function() {
if (cropper) {
cropper.destroy();
cropper = null;
}
const cropModal = document.getElementById('cropModal');
if (cropModal) cropModal.style.display = 'none';
// Restore the filename label if we were in pre-upload crop mode and the
// user cancelled; the original file selection is still intact
if (fileInput && fileInput.files.length > 0) {
updateFileName(fileInput.files[0].name);
} else if (fileNameDisplay) {
fileNameDisplay.textContent = '';
fileNameDisplay.style.display = 'none';
}
};
// POSTs the cropped canvas blob to the server to replace the stored binary.
// Uses fetch so the page does not reload mid-crop.
window.saveCrop = async function() {
if (!cropper || !currentCropId) return;
const canvas = cropper.getCroppedCanvas();
canvas.toBlob(async (blob) => {
const formData = new FormData();
formData.append('cropped_image', blob, 'receipt_cropped.png');
try {
const response = await fetch('/receipts/crop/' + currentCropId, {
method: 'POST',
body: formData
});
const result = await response.json();
if (result.success) {
showToast('Receipt cropped successfully!', 'success');
closeCropModal();
setTimeout(() => window.location.reload(), 1000);
} else {
showToast('Failed to save crop: ' + (result.error || 'Unknown error'), 'error');
}
} catch (err) {
showToast('Request failed', 'error');
}
}, 'image/png');
};
// --- OCR Trigger ---
// Sends an AJAX request to re-run OCR on an already-stored image, then
// pre-fills the edit modal with extracted data for user review before saving.
window.triggerOCR = function(id) {
const btn = document.getElementById('ocr-btn-' + id);
const originalContent = btn.innerHTML;
btn.disabled = true;
btn.innerHTML = '...';
showToast('Scanning receipt... please wait.', 'info');
fetch('/receipts/ocr/' + id, { method: 'POST' })
.then(r => r.json())
.then(data => {
btn.disabled = false;
btn.innerHTML = originalContent;
if (data.success) {
showToast('OCR complete! Reviewing details.', 'success');
openEditModal({
id: id,
store_name: data.store_name,
receipt_date: data.receipt_date,
total_amount: data.total_amount,
description: data.raw_text
});
} else {
showToast('OCR failed: ' + (data.error || 'Unknown error'), 'error');
}
})
.catch(() => {
btn.disabled = false;
btn.innerHTML = originalContent;
showToast('Server error during scan.', 'error');
});
};
// Close any open modal when the user clicks the darkened overlay behind it
window.addEventListener('click', function(e) {
const receiptModal = document.getElementById('receiptModal');
const editModal = document.getElementById('editModal');
const cropModal = document.getElementById('cropModal');
if (e.target === receiptModal) closeReceiptModal();
if (e.target === editModal) closeEditModal();
if (e.target === cropModal) closeCropModal();
});
});