// 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)' $('
') .css({ top: (line.top * 100) + '%', height: Math.max(line.h * 100, 0.3) + '%' }) .attr('data-row', row.attr('data-index')) .attr('data-tip', 'Row ' + (row.index() + 1) + ': ' + desc) .appendTo(pageWrap) row.addClass('has-source').attr('title', 'Click to show this line on the invoice preview') }) } // Preview -> row $(document).on('click', '.line-marker', function () { var row = $('tr[data-index="' + $(this).attr('data-row') + '"]') if (!row.length) return $('#canvasWrapper .line-marker').removeClass('active') $(this).addClass('active') row[0].scrollIntoView({ behavior: 'smooth', block: 'center' }) row.removeClass('row-flash') void row[0].offsetWidth // restart the flash animation row.addClass('row-flash') }) // Row -> preview. Clicks on the fields and buttons in the row are left alone. $(document).on('click', '#lineItemsBody tr.has-source', function (e) { if ($(e.target).closest('input,textarea,select,button,.cand-item,.search-results').length) return showRowOnPreview($(this)) }) function showRowOnPreview(row) { var idx = row.attr('data-index') if (!$('#canvasWrapper .line-marker[data-row="' + idx + '"]').length) return // Expanding rebuilds the markers, so look the marker up again afterwards. if ($('#leftPanel').hasClass('collapsed')) setPreviewCollapsed(false) var marker = $('#canvasWrapper .line-marker[data-row="' + idx + '"]') if (!marker.length) return $('#canvasWrapper .line-marker').removeClass('active') marker.addClass('active') var wrap = document.getElementById('canvasWrapper') var wr = wrap.getBoundingClientRect() var mr = marker[0].getBoundingClientRect() var top = wrap.scrollTop + (mr.top - wr.top) - (wrap.clientHeight / 2) + (mr.height / 2) wrap.scrollTo({ top: Math.max(0, top), behavior: 'smooth' }) } function buildRow(i, item, match) { var parsedUpc = item.upc || '' var parsedDesc = item.description || '' var parsedCode = item.product_code || '' var parsedSize = item.size || '' var parsedUom = item.unit_of_measure || 'EA' var upc = match.upc || parsedUpc var desc = match.description || parsedDesc var cert = match.cert_code || parsedCode var size = match.size || parsedSize var uom = match.uom || parsedUom var qty = item.quantity != null ? item.quantity : '' var cost = item.unit_price != null ? item.unit_price : '' var total = item.total != null ? item.total : '' // Retail comes off the product file. Pack prefers the product file and // falls back to the invoice's PACK/SIZE column when the product has none. var parsedPack = (item.pack != null && item.pack !== '') ? item.pack : '' var pack = match.pack != null ? match.pack : parsedPack var retail = match.retail != null ? match.retail : '' var parsedPackHint = (parsedPack !== '' && String(parsedPack) !== String(pack)) ? 'inv: ' + escHtml(String(parsedPack)) + '' : '' var label = match.match_label || 'unmatched' var badgeCls = label === 'exact' ? 'badge-exact' : (label === 'fuzzy' || label === 'cert_code' || label === 'upc_norm' || label === 'upc+fuzzy') ? 'badge-fuzzy' : 'badge-none' var status = badgeCls === 'badge-exact' ? 'exact' : badgeCls === 'badge-fuzzy' ? 'fuzzy' : 'unmatched' var badgeText = label === 'exact' ? 'matched' : 'VALIDATE DATA' var warnHtml = match.inactive ? 'INACTIVE' : '' // The search box is available on every row, matched or not - the suggested // product is not always the right one. var isUnmatched = (label === 'unmatched') var searchHtml = '
' + '' + '
' // Only a fuzzy row can be accepted: it already carries a real product. // An unmatched row has nothing to accept and must be matched or created. var confirmHtml = (status === 'fuzzy') ? '
' : '' var matchedDesc = match.description || '' var toggleHtml = buildDescToggle(i, parsedDesc, matchedDesc, matchedDesc ? 'matched' : 'parsed') var parsedCodeHint = parsedCode ? 'parsed: ' + escHtml(parsedCode) + '' : '' var parsedUpcDiv = (parsedUpc && parsedUpc !== upc) ? '
' + escHtml(parsedUpc) + '
' : '' var cands = (match.candidates || []).filter(function (c) { return c.upc !== upc }) rowCandidates[i] = cands var candHtml = '' if (cands.length) { candHtml = '
' cands.forEach(function (c, j) { candHtml += '
' + escHtml(c.description || '') + ' ' + Math.round((c.score || 0) * 100) + '%
' }) candHtml += '
' } var srcLine = parseInt(item.source_line, 10) // Both sides are kept on the row so the parsed-vs-matched view can show // what came off the invoice even after the fields have been edited. return '' + '
' + escHtml(upc) + '
' + parsedUpcDiv + '' + '' + toggleHtml + '' + '' + parsedCodeHint + '' + '-' + '-' + '-' + '' + '' + '' + parsedPackHint + '' + '-' + '-' + '' + '-' + '' + '' + '' + '' + badgeText + '' + warnHtml + confirmHtml + candHtml + searchHtml + '' + '' + '' } // ------------------------------------------------------- // Gross margin. The invoice bills by the case; the product file prices by the // unit, so the two only compare after the case cost is divided by the pack. // A blank or zero pack is treated as 1 (the product file leaves it empty for // singles) - fix the pack cell when a case row looks impossibly profitable. // ------------------------------------------------------- function numOrNull(v) { var n = parseFloat(v) return isNaN(n) ? null : n } function recalcMargin(row) { var caseCost = numOrNull(row.find('.item-cost').val()) var pack = numOrNull(row.find('.item-pack').val()) var retail = numOrNull(row.find('.retail-val').val()) if (!pack || pack <= 0) pack = 1 var unit = caseCost != null ? caseCost / pack : null var unitCell = row.find('.item-unitcost') unitCell.text(unit != null ? '$' + unit.toFixed(3) : '-') .toggleClass('cmp-na', unit == null) var retailCell = row.find('.item-retail') retailCell.contents().filter(function () { return this.nodeType === 3 }).remove() retailCell.prepend(document.createTextNode(retail != null ? '$' + retail.toFixed(2) : '-')) retailCell.toggleClass('cmp-na', retail == null) var gmCell = row.find('.item-gm').removeClass('gm-good gm-thin gm-loss') if (unit == null || retail == null || retail <= 0) { gmCell.text('-').addClass('cmp-na') return } var gm = ((retail - unit) / retail) * 100 gmCell.text(gm.toFixed(1) + '%').removeClass('cmp-na') .addClass(gm < 0 ? 'gm-loss' : gm < 15 ? 'gm-thin' : 'gm-good') } function recalcAllMargins() { $('#lineItemsBody tr').each(function () { recalcMargin($(this)) }) } $(document).on('input change', '.item-cost,.item-pack', function () { recalcMargin($(this).closest('tr')) }) // Always rendered, on every row - you should never have to guess whether the // description in the box came off the invoice or out of the product file. function buildDescToggle(i, parsedDesc, matchedDesc, active) { return '
' + descSrcButton(i, 'matched', 'Matched', matchedDesc, active) + descSrcButton(i, 'parsed', 'Parsed', parsedDesc, active) + '
' } function descSrcButton(i, source, label, value, active) { if (!value) { return '' } return '' } function buildSearchUI(i) { return '' + '' + '
' } // ------------------------------------------------------- // Manually added line (parser missed a row) // ------------------------------------------------------- function addBlankRow() { var i = nextRowIndex++ $('#lineItemsBody').append(buildRow(i, { unit_of_measure: 'EA' }, { match_label: 'unmatched' })) var row = $('#lineItemsBody tr[data-index="' + i + '"]').addClass('row-added') recalcMargin(row) refreshReviewSummary() row[0].scrollIntoView({ block: 'center' }) row.find('.product-search').focus() if ($('#poSelect').val() && poCompareData) applyPoComparison() queuePendingStateSave('editing') } // ------------------------------------------------------- // Inline product search // ------------------------------------------------------- var searchTimers = {} $(document).on('keyup', '.product-search', function () { var inp = $(this) var idx = inp.data('item') clearTimeout(searchTimers[idx]) searchTimers[idx] = setTimeout(async function () { var q = inp.val().trim() var div = $('#search_' + idx) if (q.length < 2) { div.hide().empty(); return } try { var resp = await fetch('/api/products/search?q=' + encodeURIComponent(q)) var results = await resp.json() if (results.error) { showDbError(results.error); div.hide().empty(); return } div.empty() if (results.length === 0) { div.html( '
No results
' + '
[+ Create new product]
' ).show() positionSearchResults(inp, div) return } results.forEach(function (r) { var meta = escHtml(r.upc) + (r.cert_code ? ' / ' + escHtml(r.cert_code) : '') + (r.size ? ' - ' + escHtml(r.size) : '') + (r.cost ? ' ($' + r.cost + ')' : '') $('
') .html('
' + escHtml(r.description) + '
' + '
' + meta + '
') .data('product', r) .on('click', function () { applyProduct(idx, $(this).data('product')); div.hide().empty() }) .appendTo(div) }) div.show() positionSearchResults(inp, div) } catch (e) { console.error(e) } }, 300) }) // The dropdown is position:fixed so the scrolling table cannot clip it, // which means its coordinates have to be set against the input each time. function positionSearchResults(inp, div) { var r = inp[0].getBoundingClientRect() var w = div.outerWidth() var left = Math.max(6, Math.min(r.left, window.innerWidth - w - 10)) var top = r.bottom + 2 if (top + div.outerHeight() > window.innerHeight - 6) { top = Math.max(6, r.top - div.outerHeight() - 2) } div.css({ left: left + 'px', top: top + 'px' }) } $(document).on('click', '.search-toggle', function () { $(this).hide() var td = $(this).closest('td') td.find('.search-inline-wrap').show() td.find('.product-search').focus() }) $(document).on('click', '.btn-search-cancel', function () { var td = $(this).closest('td') td.find('.search-results').hide().empty() td.find('.product-search').val('') td.find('.search-inline-wrap').hide() td.find('.search-toggle').show() }) $(document).on('click', '.cand-item', function () { var i = $(this).data('item') var c = (rowCandidates[i] || [])[$(this).data('cand')] if (c) applyProduct(i, c) }) function applyProduct(i, p) { var row = $('tr[data-index="' + i + '"]') row.attr('data-status', 'exact') var matchedDesc = p.description || '' var parsedDesc = row.attr('data-parsed-desc') || '' row.attr('data-matched-desc', matchedDesc) row.attr('data-matched-upc', p.upc || '') row.attr('data-matched-cert', p.cert_code || '') row.attr('data-matched-size', p.size || '') row.attr('data-matched-uom', p.uom || 'EA') row.find('.item-upc').val(p.upc || '') row.find('.upc-matched').text(p.upc || '') row.find('.upc-parsed-val').hide() row.find('.item-desc').val(matchedDesc || parsedDesc) row.find('.item-cert').val(p.cert_code || '') row.find('.item-size').val(p.size || '') row.find('.item-uom').val(p.uom || 'EA') row.find('.item-pack').val(p.pack != null ? p.pack : '') row.find('.retail-val').val(p.retail != null ? p.retail : '') recalcMargin(row) row.find('.match-badge').text('selected').attr('class', 'match-badge badge-exact') row.find('.btn-confirm').remove() row.find('.cand-list').remove() row.find('.search-results').hide().empty() row.find('.product-search').val('') row.find('.search-inline-wrap').hide() row.find('.search-toggle').show() row.find('.desc-toggle-row').remove() row.find('.item-desc').after(buildDescToggle(i, parsedDesc, matchedDesc, matchedDesc ? 'matched' : 'parsed')) if ($('#poSelect').val() && poCompareData) applyPoComparison() refreshReviewSummary() queuePendingStateSave('editing') } function acceptRow(row, skipSave) { row.attr('data-status', 'confirmed') row.find('.match-badge').first().text('accepted').attr('class', 'match-badge badge-exact') row.find('.btn-confirm').remove() row.find('.cand-list').remove() if (!skipSave) { refreshReviewSummary(); queuePendingStateSave('editing') } } function acceptAllRows() { $('#lineItemsBody tr').each(function () { if ($(this).attr('data-status') === 'unmatched') return acceptRow($(this), true) }) refreshReviewSummary() queuePendingStateSave('editing') } $(document).on('click', '.btn-confirm', function () { var idx = $(this).data('index') acceptRow($('tr[data-index="' + idx + '"]')) }) $(document).on('click', '.btn-remove-row', function () { $('tr[data-index="' + $(this).data('index') + '"]').remove() if ($('#poSelect').val() && poCompareData) applyPoComparison() renderLineMarkers() refreshReviewSummary() queuePendingStateSave('editing') }) $(document).on('click', '.btn-desc-src', function () { var btn = $(this) var i = btn.data('item') var source = btn.data('source') var row = $('tr[data-index="' + i + '"]') var desc = source === 'parsed' ? row.attr('data-parsed-desc') : row.attr('data-matched-desc') if (!desc) return // never blank the box by switching to a side that has nothing row.find('.item-desc').val(desc) row.find('.desc-toggle-row .btn-desc-src').removeClass('active') btn.addClass('active') queuePendingStateSave('editing') }) $(document).on('click', '.search-result-new', function () { var i = $(this).data('item') $(this).closest('.search-results').hide().empty() openNewProductModal(i) }) $(document).on('click', '.btn-new-item', function () { openNewProductModal($(this).data('item')) }) // ------------------------------------------------------- // Parsed vs matched - always reachable from the title bar so you can // check what came off the invoice against the product it was matched to. // ------------------------------------------------------- function openParsedModal() { renderParsedComparison() $('#parsedModal').show() } function closeParsedModal() { $('#parsedModal').hide() } function renderParsedComparison() { var diffOnly = $('#chkDiffOnly').is(':checked') $('#compareHeaderBody').html( headerCompareRow('Vendor', stripHint($('#hintVendor').text()), $('#vendorSelect option:selected').text()) + headerCompareRow('Invoice #', stripHint($('#hintInvoiceNumber').text()), $('#invoiceNumber').val()) + headerCompareRow('Invoice date', stripHint($('#hintInvoiceDate').text()), $('#invoiceDate').val()) + headerCompareRow('Total', stripHint($('#hintInvoiceTotal').text()).replace(/\s*\(.*\)$/, ''), $('#invoiceTotal').val()) ) var html = '' var shown = 0 $('#lineItemsBody tr').each(function (n) { var row = $(this) var p = { desc: row.attr('data-parsed-desc') || '', upc: row.attr('data-parsed-upc') || '', cert: row.attr('data-parsed-cert') || '', size: row.attr('data-parsed-size') || '', uom: row.attr('data-parsed-uom') || '' } var m = { desc: row.attr('data-matched-desc') || '', upc: row.attr('data-matched-upc') || '', cert: row.attr('data-matched-cert') || '', size: row.attr('data-matched-size') || '', uom: row.attr('data-matched-uom') || '' } var diff = { desc: !sameText(p.desc, m.desc), upc: normalizeUpc(p.upc) !== normalizeUpc(m.upc), cert: !sameText(p.cert, m.cert), size: !sameText(p.size, m.size), uom: !sameText(p.uom, m.uom) } var anyDiff = diff.desc || diff.upc || diff.cert || diff.size || diff.uom if (diffOnly && !anyDiff) return shown++ var badge = row.find('.match-badge').first() html += '' + '' + (n + 1) + '' + '' + compareCell(p, {}) + '' + '' + compareCell(m, diff) + '' + '' + escHtml(badge.text() || 'unmatched') + '' + '' }) if (!shown) { html = '' + (diffOnly ? 'Every line matched the product file exactly.' : 'No line items on this draft.') + '' } $('#compareBody').html(html) } function headerCompareRow(label, parsed, current) { var diff = !sameText(parsed, current) return '' + escHtml(label) + '' + '' + cmpVal(parsed, false) + '' + '' + cmpVal(current, diff && parsed !== '') + '' } // Meta line under the description: only the pieces that exist. function compareCell(v, diff) { var meta = [] if (v.upc) meta.push(span(v.upc, diff.upc)) if (v.cert) meta.push('cert ' + span(v.cert, diff.cert)) if (v.size) meta.push(span(v.size, diff.size)) if (v.uom) meta.push(span(v.uom, diff.uom)) return '
' + (v.desc ? span(v.desc, diff.desc) : '(none)') + '
' + (meta.length ? '
' + meta.join(' · ') + '
' : '') } function span(text, isDiff) { return isDiff ? '' + escHtml(text) + '' : escHtml(text) } function cmpVal(v, isDiff) { if (!v) return '(none)' return span(v, isDiff) } function sameText(a, b) { return String(a || '').trim().toLowerCase() === String(b || '').trim().toLowerCase() } // Hints render as "scanned: X" on a fresh parse and bare on a restored draft. function stripHint(s) { return String(s || '').replace(/^scanned:\s*/i, '').trim() } $(document).on('change', '#chkDiffOnly', renderParsedComparison) $(document).on('click', '#compareBody tr[data-row]', function () { var row = $('#lineItemsBody tr[data-index="' + $(this).attr('data-row') + '"]') if (!row.length) return closeParsedModal() row[0].scrollIntoView({ behavior: 'smooth', block: 'center' }) row.removeClass('row-flash') void row[0].offsetWidth row.addClass('row-flash') }) // ------------------------------------------------------- // New Product modal // ------------------------------------------------------- var _newProductTargetRow = null function openNewProductModal(rowIndex) { _newProductTargetRow = (rowIndex !== undefined && rowIndex !== null) ? rowIndex : null $('#np_upc').val('') $('#np_desc').val('') $('#np_price').val('') $('#np_dept').val('') $('#np_tax').val('0') $('#np_fs').val('0') $('#np_flagsNote').text('from department') $('#np_cost').val('') $('#np_pack').val('') $('#np_size').val('') $('#np_code').val('') $('#newProductStatus').text('').hide() // Seed from the row that asked for it - the invoice already read most of this. if (_newProductTargetRow !== null) { var row = $('tr[data-index="' + _newProductTargetRow + '"]') if (row.length) { $('#np_upc').val(row.attr('data-parsed-upc') || row.find('.item-upc').val() || '') $('#np_desc').val((row.find('.item-desc').val() || '').slice(0, 30)) $('#np_code').val(row.find('.item-cert').val() || '') $('#np_size').val((row.find('.item-size').val() || '').slice(0, 9)) $('#np_pack').val(row.find('.item-pack').val() || '') var caseCost = parseFloat(row.find('.item-cost').val()) var pack = parseFloat(row.find('.item-pack').val()) if (!isNaN(caseCost)) { if (isNaN(pack) || pack <= 0) pack = 1 $('#np_cost').val((caseCost / pack).toFixed(2)) } } } $('#newProductModal').show() $('#np_upc').focus() } function closeNewProductModal() { $('#newProductModal').hide() _newProductTargetRow = null } // dept_no -> { tax, fs } from posbdat..Departments; picking a department // prefills the item's tax and food stamp flags the same way the POS does. var deptFlags = {} async function loadDepartments() { try { var resp = await fetch('/api/departments') var depts = await resp.json() if (depts.error) throw new Error(depts.error) var sel = $('#np_dept') sel.empty().append('') deptFlags = {} depts.forEach(function (d) { deptFlags[String(d.id)] = { tax: d.tax ? 1 : 0, fs: d.fs ? 1 : 0 } sel.append('') }) } catch (e) { showDbError('Department list failed to load: ' + e.message) } } $(document).on('change', '#np_dept', function () { var f = deptFlags[String($(this).val())] if (!f) { $('#np_flagsNote').text('from department'); return } $('#np_tax').val(String(f.tax)) $('#np_fs').val(String(f.fs)) $('#np_flagsNote').text('prefilled from department') }) async function handleCreateProduct() { var upc = $('#np_upc').val().trim() var desc = $('#np_desc').val().trim() var price = $('#np_price').val().trim() if (!upc || !desc || !price) { $('#newProductStatus').text('UPC, Description, and Price are required.').css('color', '#ff8888').show() return } if (!$('#np_dept').val()) { $('#newProductStatus').text('Department is required - it sets tax, food stamp and age checking.').css('color', '#ff8888').show() return } // "2/5.00" = 2 for $5.00; anything else must be a plain amount. if (!/^\d+\s*\/\s*\d+(\.\d+)?$/.test(price) && !/^\d+(\.\d+)?$/.test(price)) { $('#newProductStatus').text('Price must be an amount like 2.99, or a group price like 2/5.00.').css('color', '#ff8888').show() return } var fd = new URLSearchParams() fd.append('upc', upc) fd.append('description', desc) fd.append('normal_price', price) fd.append('dept_id', $('#np_dept').val()) fd.append('cost', $('#np_cost').val()) fd.append('item_code', $('#np_code').val()) fd.append('tax', $('#np_tax').val()) fd.append('foodstamp', $('#np_fs').val()) fd.append('pack', $('#np_pack').val()) fd.append('size', $('#np_size').val()) $('#newProductStatus').text('Creating...').css('color', '#ffee88').show() try { var resp = await fetch('/api/newproduct', { method: 'POST', body: fd }) var result = await resp.json() if (result.success) { $('#newProductStatus').text('Created: ' + result.description + ' (' + result.upc + ')').css('color', '#88ff88').show() if (_newProductTargetRow !== null) { applyProduct(_newProductTargetRow, { upc: result.upc, description: result.description, cert_code: $('#np_code').val() || '', size: $('#np_size').val() || '', uom: 'EA', pack: $('#np_pack').val() !== '' ? parseFloat($('#np_pack').val()) : null, retail: result.retail != null ? result.retail : null }) setTimeout(closeNewProductModal, 800) } } else { $('#newProductStatus').text('Error: ' + result.error).css('color', '#ff8888').show() } } catch (e) { $('#newProductStatus').text('Error: ' + e.message).css('color', '#ff8888').show() } } // ------------------------------------------------------- // Pending draft state // ------------------------------------------------------- function getQueryParam(name) { var params = new URLSearchParams(window.location.search) return params.get(name) } function queuePendingStateSave(stage) { if (!pendingId) return clearTimeout(pendingSaveTimer) pendingSaveTimer = setTimeout(function () { savePendingState(stage || 'editing').catch(function (e) { console.warn('Pending state save failed:', e) }) }, 700) } async function savePendingState(stage) { if (!pendingId) return var state = collectPendingState(stage || 'editing') var resp = await fetch('/api/pending/' + encodeURIComponent(pendingId) + '/state', { method: 'POST', body: JSON.stringify(state), headers: { 'Content-Type': 'application/json; charset=utf-8' } }) var result = await resp.json() if (!result.success) throw new Error(result.error || 'state save failed') } function collectPendingState(stage) { var snap = collectDraftSnapshot() return { version: 1, id: pendingId, stage: stage || 'editing', created_at: pendingCreated || new Date().toISOString(), updated_at: new Date().toISOString(), source: pendingFileInfo || {}, parse_mode: _resumedParseMode || '', ocr: { text: $('#ocrText').val() || '', method: $('#ocrMethod').text() || '', line_map: pdfLineMap || null }, header: snap.header, po_compare_data: snap.po_compare_data, line_items: snap.line_items, llm: lastLlmResult || null, defaults: pendingDefaultState || null } } var _resumedParseMode = '' function collectDraftSnapshot() { var selectedVendor = $('#vendorSelect option:selected') return { header: { vendor_id: $('#vendorSelect').val() || '', vendor_name: selectedVendor.length ? selectedVendor.text() : '', vendor_hint: $('#hintVendor').text() || '', vendor_badge_text: $('#vendorBadge').is(':visible') ? $('#vendorBadge').text() : '', vendor_badge_class: $('#vendorBadge').is(':visible') ? $('#vendorBadge').attr('class') : '', invoice_number: $('#invoiceNumber').val() || '', invoice_date: $('#invoiceDate').val() || '', po_number: $('#poSelect').val() || '', invoice_total: $('#invoiceTotal').val() || '', total_label: totalLabel || '', total_page: totalPage || '' }, po_compare_data: poCompareData || null, line_items: collectPendingLineItems() } } function collectPendingLineItems() { var items = [] $('#lineItemsBody tr').each(function () { var row = $(this) var badge = row.find('.match-badge').first() items.push({ upc: row.find('.item-upc').val() || '', desc: row.find('.item-desc').val() || '', cert: row.find('.item-cert').val() || '', qty: row.find('.item-qty').val() || '', cost: row.find('.item-cost').val() || '', pack: row.find('.item-pack').val() || '', retail: row.find('.retail-val').val() || '', total: row.find('.item-total').val() || '', size: row.find('.item-size').val() || '', uom: row.find('.item-uom').val() || '', source_line: row.attr('data-source-line') || '', status: row.attr('data-status') || '', parsed_desc: row.attr('data-parsed-desc') || '', parsed_upc: row.attr('data-parsed-upc') || '', parsed_cert: row.attr('data-parsed-cert') || '', parsed_size: row.attr('data-parsed-size') || '', parsed_pack: row.attr('data-parsed-pack') || '', parsed_uom: row.attr('data-parsed-uom') || '', matched_desc: row.attr('data-matched-desc') || '', matched_upc: row.attr('data-matched-upc') || '', matched_cert: row.attr('data-matched-cert') || '', matched_size: row.attr('data-matched-size') || '', matched_uom: row.attr('data-matched-uom') || '', match_badge_text: badge.text() || '', match_badge_class: badge.attr('class') || '', po_status: row.attr('data-po-status') || '' }) }) return items } // ------------------------------------------------------- // Resume a pending draft // ------------------------------------------------------- async function resumePending(id) { try { showBusy('Loading invoice...') setStatus('Loading pending invoice...', 'loading') var resp = await fetch('/api/pending/' + encodeURIComponent(id)) if (!resp.ok) throw new Error('pending load returned ' + resp.status) var state = await resp.json() if (state.error && !state.stage) throw new Error(state.error) pendingId = state.id || id pendingCreated = state.created_at || new Date().toISOString() pendingFileInfo = state.source || {} pendingDefaultState = state.defaults || null lastLlmResult = state.llm || null _resumedParseMode = state.parse_mode || '' try { await renderPendingSourcePreview(pendingId) } catch (previewErr) { console.warn('Pending source preview failed:', previewErr) $('#uploadPrompt').text('Pending source file could not be previewed.').show() } restorePendingState(state) if (state.stage === 'parsed' && state.llm && state.llm.parsed) { // Background worker did the slow LLM step; finish with the fast match pass. await matchAndPopulateFromParsed(state.llm.parsed) await savePendingState('review') setStatus('Parsed invoice loaded.', 'success') } else if (state.stage === 'queued' || state.stage === 'processing') { setStatus('This invoice is ' + state.stage + ' for background parsing - this screen updates automatically when it finishes.', 'loading') } else if (state.stage === 'error') { setStatus('Background parsing failed: ' + (state.error || 'unknown error') + '. Use Scan Invoice to retry.', 'error') } else { setStatus('Pending invoice loaded.', 'success') } } catch (e) { setStatus('Could not load pending invoice: ' + e.message, 'error') console.error(e) } finally { hideBusy() } } async function matchAndPopulateFromParsed(invoiceData) { var count = (invoiceData.line_items || []).length showBusy('Matching ' + count + ' line item' + (count === 1 ? '' : 's') + ' against the product file...') try { var matchData = await callMatch(invoiceData) populateForm(invoiceData, matchData) $('#reviewSection').css('display', 'flex') } finally { hideBusy() } } // Full-panel busy overlay. Opening a parsed invoice does real work (matching // every line item), and without this the review pane just sits blank. function showBusy(msg) { var el = $('#busyOverlay') if (!el.length) { el = $('
' + '
' + '
').appendTo('body') } el.find('.busy-msg').text(msg || 'Working...') el.css('display', 'flex') } function hideBusy() { $('#busyOverlay').hide() } // Preview pages come pre-rendered from the server (PDFium) - no PDF.js. async function renderPendingSourcePreview(id) { var resp = await fetch('/api/pending/' + encodeURIComponent(id) + '/preview') var info = await resp.json() if (info.error) throw new Error(info.error) var wrapper = document.getElementById('canvasWrapper') $(wrapper).find('.page-wrap').remove() $('#uploadPrompt').hide() for (var p = 1; p <= (info.pages || 1); p++) { var pageWrap = document.createElement('div') pageWrap.className = 'page-wrap' pageWrap.setAttribute('data-page', p) var img = document.createElement('img') img.src = '/api/pending/' + encodeURIComponent(id) + '/preview/' + p + '?ts=' + Date.now() pageWrap.appendChild(img) wrapper.appendChild(pageWrap) } applyPreviewZoom() $('#scanSection').css('display', 'flex') } function restorePendingState(state) { var h = state.header || {} pdfLineMap = (state.ocr && state.ocr.line_map && state.ocr.line_map.length) ? state.ocr.line_map : null $('#ocrText').val('') $('#ocrMethod').text('') $('#vendorBadge').hide().text('') $('#hintVendor,#hintInvoiceNumber,#hintInvoiceDate,#hintInvoiceTotal').hide().text('') // A vendor or PO missing from the dropdown (list failed to load, PO // outside the TOP 500) would otherwise reset to blank and the next // autosave would write that blank back into the draft. ensureOption($('#vendorSelect'), h.vendor_id, h.vendor_name) ensureOption($('#poSelect'), h.po_number, 'PO #' + h.po_number) $('#vendorSelect').val(h.vendor_id || '') $('#invoiceNumber').val(h.invoice_number || '') $('#invoiceDate').val(h.invoice_date || '') $('#invoiceTotal').val(h.invoice_total || '') $('#poSelect').val(h.po_number || '') totalLabel = h.total_label || '' totalPage = h.total_page || '' if (h.vendor_badge_text) { $('#vendorBadge').text(h.vendor_badge_text).attr('class', h.vendor_badge_class || 'match-badge badge-none').show() } if (h.vendor_hint) $('#hintVendor').text(h.vendor_hint).show() if (h.invoice_number) $('#hintInvoiceNumber').text(h.invoice_number).show() if (h.invoice_date) $('#hintInvoiceDate').text(h.invoice_date).show() if (h.invoice_total) $('#hintInvoiceTotal').text(h.invoice_total + totalHintSuffix()).show() if (state.ocr && state.ocr.text) { $('#ocrText').val(state.ocr.text) $('#ocrMethod').text(state.ocr.method || 'from pending') } restorePendingRows(state.line_items || []) recalcAllMargins() poCompareData = state.po_compare_data || null if ((state.line_items || []).length) { $('#reviewSection').css('display', 'flex') if ($('#poSelect').val() && poCompareData) applyPoComparison() renderLineMarkers() } refreshReviewSummary() } function ensureOption(sel, value, label) { if (!value) return var exists = sel.find('option').filter(function () { return this.value === String(value) }).length if (!exists) sel.append('') } function restorePendingRows(items) { var tbody = $('#lineItemsBody') tbody.empty() items.forEach(function (saved, i) { var status = saved.status || 'unmatched' var matchLabel = (status === 'unmatched' || status === 'fuzzy') ? status : 'exact' var item = { upc: saved.upc || '', description: saved.desc || '', product_code: saved.cert || '', quantity: saved.qty || '', unit_price: saved.cost || '', total: saved.total || '', size: saved.size || '', unit_of_measure: saved.uom || 'EA', pack: saved.parsed_pack || '', source_line: saved.source_line || '' } var match = { upc: saved.upc || '', description: saved.desc || '', cert_code: saved.cert || '', size: saved.size || '', uom: saved.uom || 'EA', pack: saved.pack !== '' && saved.pack != null ? saved.pack : null, retail: saved.retail !== '' && saved.retail != null ? saved.retail : null, match_label: matchLabel } tbody.append(buildRow(i, item, match)) var row = tbody.find('tr[data-index="' + i + '"]') row.attr('data-status', status) row.attr('data-parsed-desc', saved.parsed_desc || saved.desc || '') row.attr('data-parsed-upc', saved.parsed_upc || saved.upc || '') row.attr('data-parsed-cert', saved.parsed_cert || saved.cert || '') row.attr('data-parsed-size', saved.parsed_size || saved.size || '') row.attr('data-parsed-uom', saved.parsed_uom || saved.uom || '') row.attr('data-matched-desc', saved.matched_desc || saved.desc || '') row.attr('data-matched-upc', saved.matched_upc || saved.upc || '') row.attr('data-matched-cert', saved.matched_cert || saved.cert || '') row.attr('data-matched-size', saved.matched_size || saved.size || '') row.attr('data-matched-uom', saved.matched_uom || saved.uom || '') if (saved.match_badge_text) row.find('.match-badge').first().text(saved.match_badge_text) if (saved.match_badge_class) row.find('.match-badge').first().attr('class', saved.match_badge_class) if (status !== 'unmatched' && status !== 'fuzzy') row.find('.btn-confirm').remove() }) nextRowIndex = items.length } async function handleDeleteDraft() { if (!pendingId) return if (!confirm('Delete this pending draft? This removes the saved draft and source file.')) return var id = pendingId clearTimeout(pendingSaveTimer) pendingId = null try { setStatus('Deleting draft...', 'loading') var resp = await fetch('/api/pending/' + encodeURIComponent(id), { method: 'DELETE' }) var result = await resp.json() if (!result.success) throw new Error(result.error || 'delete failed') window.location.href = 'pending.html' } catch (e) { pendingId = id setStatus('Delete failed: ' + e.message, 'error') } } async function handleRestoreDefaults() { if (!pendingDefaultState) { alert('No saved defaults are available for this draft yet.') return } if (!confirm('Restore the original parsed and matched values for this draft?')) return var restored = { header: pendingDefaultState.header || {}, line_items: pendingDefaultState.line_items || [], po_compare_data: pendingDefaultState.po_compare_data || null, ocr: { text: $('#ocrText').val() || '', method: $('#ocrMethod').text() || '', line_map: pdfLineMap } } restorePendingState(restored) if (pendingId) { try { await savePendingState('editing') } catch (e) { console.warn('Pending state save failed after restore:', e) } } setStatus('Defaults restored.', 'success') } // ------------------------------------------------------- // Save // ------------------------------------------------------- async function handleSave() { var vendorId = $('#vendorSelect').val() if (!vendorId) { alert('Please select a vendor before saving.'); return } if (!$('#invoiceNumber').val().trim()) { alert('Please enter an invoice number before saving.'); return } if (!$('#invoiceDate').val()) { alert('Please enter an invoice date before saving.'); return } var unconfirmed = [] $('#lineItemsBody tr').each(function (idx) { var s = $(this).attr('data-status') if (s === 'unmatched' || s === 'fuzzy') unconfirmed.push(idx + 1) }) if (unconfirmed.length) { alert('Row(s) ' + unconfirmed.join(', ') + ' must be matched to an existing product before saving.') return } var fd = new URLSearchParams() fd.append('vendor_id', vendorId) fd.append('invoice_number', $('#invoiceNumber').val()) fd.append('invoice_date', $('#invoiceDate').val()) fd.append('po_number', $('#poSelect').val()) fd.append('vendor_total', $('#invoiceTotal').val()) fd.append('pending_id', pendingId || '') var rows = $('#lineItemsBody tr') fd.append('item_count', rows.length) rows.each(function (i) { var row = $(this) fd.append('item_' + i + '_upc', row.find('.item-upc').val()) fd.append('item_' + i + '_desc', row.find('.item-desc').val()) fd.append('item_' + i + '_cert', row.find('.item-cert').val()) fd.append('item_' + i + '_qty', row.find('.item-qty').val()) fd.append('item_' + i + '_cost', row.find('.item-cost').val()) fd.append('item_' + i + '_total', row.find('.item-total').val()) fd.append('item_' + i + '_size', row.find('.item-size').val()) fd.append('item_' + i + '_uom', row.find('.item-uom').val()) // The server divides the case cost by this pack to get the unit cost // it compares against the product file, so send the pack on screen. fd.append('item_' + i + '_pack', row.find('.item-pack').val()) }) setStatus('Saving...', 'loading') try { // The server archives the source file inside the save transaction. var resp = await fetch('/api/save', { method: 'POST', body: fd }) var result = await resp.json() if (result.success) { var msg = 'Saved successfully' + (result.po_number ? '. PO: ' + result.po_number : '') if (result.batch_no) { msg += '. Cost batch ' + result.batch_no + ': ' + result.batch_items + ' item' + (result.batch_items === 1 ? '' : 's') if (result.batch_flagged) msg += ', ' + result.batch_flagged + ' below target margin' } setStatus(msg, 'success') if (pendingId) { // Stop the debounced autosave first: firing after the delete // would recreate the draft. clearTimeout(pendingSaveTimer) var doneId = pendingId pendingId = null try { await fetch('/api/pending/' + encodeURIComponent(doneId), { method: 'DELETE' }) } catch (e) {} } // Long enough to read the batch number before the page moves on. setTimeout(function () { window.location.href = 'pending.html' }, result.batch_no ? 3000 : 900) } else { setStatus('Save failed: ' + result.error, 'error') } } catch (e) { setStatus('Save failed: ' + e.message, 'error') } } // ------------------------------------------------------- // Utilities // ------------------------------------------------------- function setStatus(msg, type) { var el = isDashboard ? $('#dashStatusBar') : $('#statusBar') el.text(msg).removeClass('status-loading status-error status-success') if (type && msg) el.addClass('status-' + type).show() else el.hide() } // Persistent banner for database failures. Separate from the status bar, // which later steps overwrite, so a dead SQL connection stays visible. function showDbError(msg) { var el = $('#dbWarning') if (!el.length) el = $('
').insertAfter('h1') el.text(msg).show() } async function readJsonResponse(resp, label) { var raw = await resp.text() if (!resp.ok) { throw new Error(label + ' returned ' + resp.status + (raw ? ': ' + raw.slice(0, 300) : '')) } try { return JSON.parse(raw) } catch (e) { throw new Error(label + ' returned non-JSON: ' + raw.slice(0, 300)) } } function setHint(id, val, suffix) { var el = $('#' + id) if (val != null && val !== '') el.text('scanned: ' + val + (suffix || '')).show() else el.hide() } function escHtml(s) { return String(s) .replace(/&/g, '&') .replace(//g, '>') .replace(/"/g, '"') }