// Invoice Parser desktop UI - port of invoice.js from the ASP web app. // All parsing/prep now happens server-side; this page uploads, reviews and saves. var poCompareData = null var pendingId = null var pendingCreated = null var pendingFileInfo = null var pendingSaveTimer = null var pendingDefaultState = null var lastLlmResult = null var rowCandidates = {} var pdfLineMap = null // [{page, top, h}] per OCR text line; fractions of page height var isDashboard = false var nextRowIndex = 0 // data-index is a row key, not a position - manual rows get fresh ones $(document).ready(function () { var resumeID = getQueryParam('pending') isDashboard = !resumeID if (resumeID) { $('body').addClass('review-page') $('#layout').css('display', 'flex') $('#pageMode').text('- Review') initPreviewSplit() initPreviewZoom() var boot = [loadVendors(), loadDepartments(), loadPOList()] Promise.all(boot).then(function () { resumePending(resumeID) }) } else { $('#dashboard').show() loadDashboard() setInterval(loadDashboard, 15000) } connectEvents() $('#fileInput').on('change', handleFilesSelected) $('#poSelect').on('change', handlePOChange) $('#btnScan').on('click', handleScan) $('#btnSave').on('click', handleSave) $('#btnDeleteDraft').on('click', handleDeleteDraft) $('#btnRestoreDefaults').on('click', handleRestoreDefaults) $('#btnAcceptAll').on('click', acceptAllRows) $('#btnAddLine').on('click', addBlankRow) $('#btnCreateProduct').on('click', handleCreateProduct) $('#btnCloseModal').on('click', closeNewProductModal) $('#btnParseQueued').on('click', handleParseQueued) $('#btnViewParsed').on('click', openParsedModal) $('#btnCloseParsed').on('click', closeParsedModal) $('#parsedModal').on('click', function (e) { if (e.target === this) closeParsedModal() }) $(document).on('click', '.parsed-tab', function () { $('.parsed-tab').removeClass('active') $(this).addClass('active') $('.parsed-pane').hide() $('#' + $(this).data('pane')).css('display', 'flex') }) $(document).on('keydown', function (e) { if (e.key !== 'Escape') return closeNewProductModal() closeParsedModal() }) $(document).on('input change', '.item-upc,.item-cert,.item-qty,.item-cost', function () { if ($('#poSelect').val() && poCompareData) applyPoComparison() }) $(document).on('input change', '#reviewSection input,#reviewSection textarea,#reviewSection select,#ocrText', function () { queuePendingStateSave('editing') }) // scroll does not bubble, so the capture phase is the only way to catch // the table scroller and drop the fixed-position search dropdown with it. // Scrolling inside the dropdown itself must not close it. document.addEventListener('scroll', function (e) { if (e.target && e.target.nodeType === 1 && e.target.closest('.search-results')) return $('.search-results:visible').hide() }, true) $(document).on('blur', '#np_upc', function () { var v = $(this).val().replace(/\D/g, '') if (v.length > 0) $(this).val(v.padStart(13, '0')) }) $('#uploadZone').on('dragover', function (e) { e.preventDefault() $(this).addClass('drag-over') }).on('dragleave drop', function (e) { e.preventDefault() $(this).removeClass('drag-over') if (e.type === 'drop') { queueFiles(e.originalEvent.dataTransfer.files) } }) }) // ------------------------------------------------------- // Preview / form split - drag the divider or hide the preview // entirely so the line-item grid gets the whole window. // ------------------------------------------------------- function initPreviewSplit() { var saved = null try { saved = localStorage.getItem('ip_split_pct') } catch (e) {} if (saved) $('#leftPanel').css('flex', '0 0 ' + saved + '%') var collapsed = false try { collapsed = localStorage.getItem('ip_preview_hidden') === '1' } catch (e) {} setPreviewCollapsed(collapsed) $('#btnTogglePreview').on('click', function () { setPreviewCollapsed(!$('#leftPanel').hasClass('collapsed')) }) var dragging = false $('#splitter').on('mousedown', function (e) { dragging = true $(this).addClass('dragging') $('body').css('user-select', 'none') e.preventDefault() }) $(document).on('mousemove', function (e) { if (!dragging) return var rect = document.getElementById('layout').getBoundingClientRect() var pct = ((e.clientX - rect.left) / rect.width) * 100 pct = Math.max(15, Math.min(70, pct)) $('#leftPanel').css('flex', '0 0 ' + pct.toFixed(1) + '%') try { localStorage.setItem('ip_split_pct', pct.toFixed(1)) } catch (err) {} }).on('mouseup', function () { if (!dragging) return dragging = false $('#splitter').removeClass('dragging') $('body').css('user-select', '') applyPreviewZoom() renderLineMarkers() }) } // ------------------------------------------------------- // Preview zoom. 1 = fit the page to the panel width; the page // wrapper is sized in px off the panel, so markers stay in place. // ------------------------------------------------------- var previewZoom = 1 function initPreviewZoom() { var saved = null try { saved = parseFloat(localStorage.getItem('ip_preview_zoom')) } catch (e) {} if (saved && saved > 0) previewZoom = Math.max(0.25, Math.min(5, saved)) $('#btnZoomIn').on('click', function () { setPreviewZoom(previewZoom * 1.25) }) $('#btnZoomOut').on('click', function () { setPreviewZoom(previewZoom / 1.25) }) $('#btnZoomFit').on('click', function () { setPreviewZoom(1) }) $('#canvasWrapper').on('wheel', function (e) { var ev = e.originalEvent if (!ev.ctrlKey) return e.preventDefault() setPreviewZoom(previewZoom * (ev.deltaY < 0 ? 1.1 : 1 / 1.1)) }) $(window).on('resize', applyPreviewZoom) applyPreviewZoom() } function setPreviewZoom(z) { previewZoom = Math.max(0.25, Math.min(5, z)) try { localStorage.setItem('ip_preview_zoom', previewZoom) } catch (e) {} applyPreviewZoom() } function applyPreviewZoom() { var wrap = document.getElementById('canvasWrapper') $('#zoomLevel').text(Math.round(previewZoom * 100) + '%') if (!wrap) return // clientWidth already excludes the scrollbar; subtract the 8px padding pair. var avail = wrap.clientWidth - 16 if (avail <= 0) return $('#canvasWrapper .page-wrap').css('width', Math.round(avail * previewZoom) + 'px') } function setPreviewCollapsed(collapsed) { $('#leftPanel').toggleClass('collapsed', !!collapsed) $('#btnTogglePreview').html(collapsed ? '» Show preview' : '« Hide preview') try { localStorage.setItem('ip_preview_hidden', collapsed ? '1' : '0') } catch (e) {} if (!collapsed) { applyPreviewZoom(); renderLineMarkers() } } // ------------------------------------------------------- // Live updates from the background worker (SSE) // ------------------------------------------------------- function connectEvents() { try { var es = new EventSource('/api/events') es.onmessage = function (e) { var msg try { msg = JSON.parse(e.data) } catch (err) { return } if (msg.type !== 'stage') return if (isDashboard) { loadDashboard() return } if (pendingId && msg.id === pendingId) { if (msg.stage === 'parsed') { resumePending(pendingId) } else if (msg.stage === 'error') { setStatus('Background parsing failed: ' + (msg.error || 'unknown error') + '. Use Scan Invoice to retry.', 'error') } else if (msg.stage === 'processing') { setStatus('Parsing in the background...', 'loading') } } } es.onerror = function () { /* browser retries automatically */ } } catch (e) { console.warn('SSE unavailable:', e) } } // ------------------------------------------------------- // Batch upload: the server stores the file, preps it and // queues it for background parsing in one round trip. // ------------------------------------------------------- async function handleFilesSelected(e) { await queueFiles(e.target.files) } async function queueFiles(fileList) { var all = Array.prototype.slice.call(fileList || []) if (!all.length) return // The accept attribute does not apply to drag-and-drop. var rejected = [] var files = all.filter(function (f) { var ok = /\.(pdf|jpe?g|png)$/i.test(f.name) if (!ok) rejected.push(f.name) return ok }) if (!files.length) { setStatus('Not uploaded - only PDF, JPG or PNG files are supported: ' + rejected.join(', '), 'error') $('#fileInput').val('') return } setStatus('Uploading ' + files.length + ' file(s)...', 'loading') var fd = new FormData() files.forEach(function (f) { fd.append('files', f, f.name) }) try { var resp = await fetch('/api/upload', { method: 'POST', body: fd }) var result = await readJsonResponse(resp, 'upload') var ok = 0, fail = 0, failMsgs = [] ;(result.results || []).forEach(function (r) { if (r.ok) ok++ else { fail++; failMsgs.push((r.file || 'file') + ': ' + (r.error || 'failed')) } }) var msg = 'Queued ' + ok + ' invoice(s)' + (fail ? (', ' + fail + ' failed (' + failMsgs.join('; ') + ')') : '') + '. They parse automatically - check Pending.' if (rejected.length) msg += ' Skipped (unsupported type): ' + rejected.join(', ') setStatus(msg, (fail || rejected.length) ? 'error' : 'success') } catch (err) { setStatus('Upload failed: ' + err.message, 'error') } $('#fileInput').val('') if (isDashboard) loadDashboard() } // ------------------------------------------------------- // Landing dashboard // ------------------------------------------------------- async function loadDashboard() { try { var resp = await fetch('/api/stats') var s = await resp.json() if (s.error) { showDbError(s.error); return } $('#statReady').text(s.ready) $('#statQueued').text(s.queued) $('#statProcessingSub').text(s.processing ? (s.processing + ' processing') : '') $('#statProgress').text(s.in_progress) $('#statError').text(s.error) $('#statToday').text(s.uploaded_today) $('#statArchived').text(s.archived) $('#statTotal').text(s.total) $('#statValue').text('$' + (parseFloat(s.pending_value) || 0).toFixed(2)) $('#statLines').text(s.pending_lines) $('#dashUpdated').text('Updated ' + new Date().toLocaleTimeString()) } catch (e) { console.warn('Dashboard stats failed:', e) } } async function handleParseQueued() { var btn = $('#btnParseQueued') btn.prop('disabled', true) try { var resp = await fetch('/api/parse-queued', { method: 'POST' }) var result = await readJsonResponse(resp, 'parse-queued') var msg, kind = 'success' if (result.queued) { msg = 'Queued ' + result.queued + ' invoice(s) for parsing - they process in the background.' } else if (result.already_waiting) { msg = result.already_waiting + ' invoice(s) are already in the queue and still waiting their turn.' } else { msg = 'Nothing to parse.' } setStatus(msg, kind) } catch (e) { setStatus('Parse failed: ' + e.message, 'error') } btn.prop('disabled', false) loadDashboard() } // ------------------------------------------------------- // Scan Invoice = re-parse this draft now (server-side) // ------------------------------------------------------- async function handleScan() { if (!pendingId) return if ($('#lineItemsBody tr').length && !confirm('Re-scanning replaces the header and every line item with a fresh parse. Your edits on this draft will be lost. Continue?')) return try { setStatus('Queued for parsing...', 'loading') var resp = await fetch('/api/pending/' + encodeURIComponent(pendingId) + '/parse', { method: 'POST' }) var result = await readJsonResponse(resp, 'parse') if (!result.success) throw new Error(result.error || 'parse failed') setStatus('Parsing in the background - this screen updates automatically when it finishes.', 'loading') } catch (err) { setStatus('Error: ' + err.message, 'error') } } // ------------------------------------------------------- // Matching // ------------------------------------------------------- async function callMatch(invoiceData) { var fd = new URLSearchParams() fd.append('vendor', invoiceData.vendor_name || '') var items = invoiceData.line_items || [] fd.append('item_count', items.length) items.forEach(function (item, i) { fd.append('item_' + i + '_upc', item.upc || '') fd.append('item_' + i + '_desc', item.description || '') fd.append('item_' + i + '_code', item.product_code || '') }) var resp = await fetch('/api/match', { method: 'POST', body: fd }) var result = await readJsonResponse(resp, 'match') if (result.error) throw new Error(result.error) return result } // ------------------------------------------------------- // PO dropdown + comparison // ------------------------------------------------------- async function loadPOList() { try { var resp = await fetch('/api/polist') var pos = await resp.json() if (pos.error) { showDbError(pos.error); return } var sel = $('#poSelect') pos.forEach(function (p) { var label = 'PO #' + p.po_number if (p.vendor_name) label += ' - ' + p.vendor_name if (p.po_date) label += ' (' + p.po_date + ')' sel.append('') }) } catch (e) { console.warn('Could not load PO list:', e) } } async function handlePOChange() { var poNum = $('#poSelect').val() poCompareData = null clearPoComparison() if (!poNum) return try { var resp = await fetch('/api/podetail?po=' + encodeURIComponent(poNum)) if (!resp.ok) return var detail = await resp.json() if (detail.error) { showDbError(detail.error); return } poCompareData = detail if ($('#reviewSection').is(':visible')) applyPoComparison() queuePendingStateSave('editing') } catch(e) { console.warn('PO detail load failed:', e) } } function normalizeUpc(u) { return String(u).replace(/\D/g, '').replace(/^0+/, '') } function clearPoComparison() { $('#poCompareSummary').text('') $('#lineItemsBody tr') .removeClass('po-match po-diff po-inv-only') .removeAttr('data-po-status') .each(function () { var row = $(this) row.find('.po-cases,.po-pieces,.po-cost') .text('-') .removeClass('po-cell-diff') .addClass('cmp-na') row.find('.po-note').remove() }) } function applyPoComparison() { var poNum = $('#poSelect').val() if (!poNum || !poCompareData) { clearPoComparison(); return } clearPoComparison() var poPool = poCompareData.map(function(p) { return { used: false, item: p } }) var matchedCount = 0 var reviewCount = 0 $('#lineItemsBody tr').each(function() { var row = $(this) var inv = { upc: row.find('.item-upc').val().trim(), cert: row.find('.item-cert').val().trim(), desc: row.find('.item-desc').val().trim(), qty: parseFloat(row.find('.item-qty').val()) || 0, cost: parseFloat(row.find('.item-cost').val()) || 0 } var normInv = normalizeUpc(inv.upc) var found = null for (var j = 0; j < poPool.length; j++) { if (poPool[j].used) continue if (normInv && normalizeUpc(poPool[j].item.upc) === normInv) { found = poPool[j]; break } } if (!found && inv.cert) { for (var j = 0; j < poPool.length; j++) { if (poPool[j].used) continue if (poPool[j].item.cert_code && poPool[j].item.cert_code.trim() === inv.cert) { found = poPool[j]; break } } } if (found) { found.used = true matchedCount++ var po = found.item var qDiff = Math.abs(inv.qty - (parseFloat(po.qty || 0))) > 0.005 var cDiff = Math.abs(inv.cost - (parseFloat(po.cost || 0))) > 0.005 row.find('.po-cases').text(formatPoNumber(po.qty)).removeClass('cmp-na') row.find('.po-pieces').text(formatPoNumber(po.qty_pieces)).removeClass('cmp-na') row.find('.po-cost').text(formatMoney(po.cost)).removeClass('cmp-na') if (qDiff) row.find('.po-cases').addClass('po-cell-diff') if (cDiff) row.find('.po-cost').addClass('po-cell-diff') if (qDiff || cDiff) { reviewCount++ row.addClass('po-diff').attr('data-po-status', 'review') row.find('.match-badge').last().after('VALIDATE DATA') } else { row.addClass('po-match').attr('data-po-status', 'match') } } else { reviewCount++ row.addClass('po-inv-only').attr('data-po-status', 'invoice-only') row.find('.match-badge').last().after('VALIDATE DATA') } }) var poOnlyCount = 0 poPool.forEach(function(p) { if (!p.used) poOnlyCount++ }) var summary = 'PO #' + poNum + ': ' + matchedCount + ' matched' if (reviewCount) summary += ', ' + reviewCount + ' validate' if (poOnlyCount) summary += ', ' + poOnlyCount + ' PO-only' $('#poCompareSummary').text(summary) refreshReviewSummary() } function formatPoNumber(v) { if (v == null || v === '') return '-' var n = parseFloat(v) if (isNaN(n)) return String(v) return n % 1 === 0 ? String(n) : n.toFixed(2) } function formatMoney(v) { if (v == null || v === '') return '-' var n = parseFloat(v || 0) if (isNaN(n)) return String(v) return '$' + n.toFixed(2) } // ------------------------------------------------------- // Vendor dropdown // ------------------------------------------------------- async function loadVendors() { try { var resp = await fetch('/api/vendors') var vendors = await resp.json() if (vendors.error) throw new Error(vendors.error) var sel = $('#vendorSelect') vendors.forEach(function (v) { sel.append('') }) } catch (e) { // Without the list the draft cannot be saved; do not fail silently. showDbError('Vendor list failed to load: ' + e.message) } } // ------------------------------------------------------- // Form population // ------------------------------------------------------- function populateForm(data, matchData) { $('#invoiceNumber').val(data.invoice_number || '') $('#invoiceDate').val(data.invoice_date || '') $('#invoiceTotal').val(data.total != null ? data.total : '') totalLabel = data.total_label || '' totalPage = data.total_page || '' setHint('hintVendor', data.vendor_name) setHint('hintInvoiceNumber', data.invoice_number) setHint('hintInvoiceDate', data.invoice_date) setHint('hintInvoiceTotal', data.total, totalHintSuffix()) var v = matchData.vendor || {} if (v.id != null) { $('#vendorSelect').val(v.id) } if (v.label) { var badgeClass = v.label === 'exact' ? 'badge-exact' : v.label === 'fuzzy' ? 'badge-fuzzy' : 'badge-none' var badgeText = v.label === 'exact' ? 'matched' : 'VALIDATE DATA' $('#vendorBadge').text(badgeText).attr('class', 'match-badge ' + badgeClass).show() } var tbody = $('#lineItemsBody') tbody.empty() var items = data.line_items || [] items.forEach(function (item, i) { var match = (matchData.items && matchData.items[i]) ? matchData.items[i] : {} tbody.append(buildRow(i, item, match)) }) nextRowIndex = items.length recalcAllMargins() if ($('#poSelect').val() && poCompareData) applyPoComparison() renderLineMarkers() refreshReviewSummary() pendingDefaultState = collectDraftSnapshot() } // ------------------------------------------------------- // Review sanity checks: header total vs line totals, rows that still need // a decision, and what blocks Save. Cheap enough to run on every edit. // ------------------------------------------------------- var totalLabel = '' var totalPage = '' function totalHintSuffix() { var parts = [] if (totalLabel) parts.push(totalLabel) if (totalPage) parts.push('page ' + totalPage) return parts.length ? ' (' + parts.join(', ') + ')' : '' } function rowNeedsAttention(row) { var s = row.attr('data-status') if (s === 'unmatched' || s === 'fuzzy') return true var po = row.attr('data-po-status') return po === 'review' || po === 'invoice-only' } function refreshReviewSummary() { var rows = $('#lineItemsBody tr') var attention = 0, unmatched = 0 rows.each(function () { var row = $(this) if (rowNeedsAttention(row)) attention++ var s = row.attr('data-status') if (s === 'unmatched' || s === 'fuzzy') unmatched++ }) var summary = rows.length + ' line' + (rows.length === 1 ? '' : 's') if (rows.length) { summary += attention ? ' - ' + attention + ' need attention' : ' - all reviewed' } $('#lineSummary').html(summary) var blockers = $('#saveBlockers') if (unmatched) blockers.text(unmatched + ' row' + (unmatched === 1 ? '' : 's') + ' must be matched or accepted before saving').show() else blockers.hide() } // ------------------------------------------------------- // Source-line markers on the preview (text PDFs only) // ------------------------------------------------------- function renderLineMarkers() { $('#canvasWrapper .line-marker').remove() $('#lineItemsBody tr').removeClass('has-source').removeAttr('title') if (!pdfLineMap || !pdfLineMap.length) return $('#lineItemsBody tr').each(function () { var row = $(this) var n = parseInt(row.attr('data-source-line'), 10) if (isNaN(n) || n < 1 || n > pdfLineMap.length) return var line = pdfLineMap[n - 1] var pageWrap = $('#canvasWrapper .page-wrap[data-page="' + line.page + '"]') if (!pageWrap.length) return var desc = row.find('.item-desc').val() || '(no description)' $('