// 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 var saveDirty = false // an edit is waiting on the autosave debounce var currentDraftId = null var draftNav = { prev: null, next: null } var dashRefreshTimer = null $(document).ready(function () { var resumeID = getQueryParam('pending') isDashboard = !resumeID if (resumeID) { $('body').addClass('review-page') $('#layout').css('display', 'flex') $('#pageMode').text('- Review') initPreviewSplit() initPreviewZoom() currentDraftId = resumeID var boot = [loadVendors(), loadDepartments(), loadPOList()] Promise.all(boot).then(function () { resumePending(resumeID) }) loadDraftNav() } else { $('#dashboard').show() $('#navHome').addClass('active') $('#uploadZone').on('keydown', function (e) { if (e.key !== 'Enter' && e.key !== ' ') return e.preventDefault() $('#fileInput').click() }) fetch('/api/me').then(function (r) { return r.json() }).then(function (me) { if (me.first_name) $('#dashWelcome').text('Welcome, ' + me.first_name).show() }).catch(function () {}) 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) $('#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') { closeNewProductModal() closeParsedModal() return } if (isDashboard || $('#newProductModal').is(':visible')) return if (e.ctrlKey && (e.key === 's' || e.key === 'S')) { e.preventDefault() if (!$('#btnSave').prop('disabled')) handleSave() } else if (e.key === 'F8') { e.preventDefault() jumpToAttention(e.shiftKey ? -1 : 1) } }) $(document).on('input change', '.item-upc,.item-cert,.item-qty,.item-cost', function () { if ($('#poSelect').val() && poCompareData) applyPoComparison() }) $(document).on('input change', '.item-qty,.item-cost,.item-total', function () { var row = $(this).closest('tr') if ($(this).hasClass('item-total')) row.removeData('autoTotal') else autoFillTotal(row) refreshReviewSummary() }) $(document).on('input change', '#invoiceTotal', refreshReviewSummary) $(document).on('input', '#invoiceNumber', checkInvoiceNumber) $(document).on('input change', '.field-invalid', function () { $(this).removeClass('field-invalid') }) $(document).on('click', '.attention-count', function () { jumpToAttention(1) }) $('#chkAttentionOnly').on('change', function () { $('#lineItemsTable').toggleClass('attention-only', this.checked) }) $('#btnPrevDraft').on('click', function () { if (draftNav.prev) leaveTo('index.html?pending=' + encodeURIComponent(draftNav.prev)) }) $('#btnNextDraft').on('click', function () { if (draftNav.next) leaveTo('index.html?pending=' + encodeURIComponent(draftNav.next)) }) // Leaving mid-debounce would drop the last edit; save it first. $(document).on('click', 'a[href]', function (e) { if (!saveDirty || !pendingId) return e.preventDefault() leaveTo(this.href) }) window.addEventListener('beforeunload', function (e) { if (!saveDirty || !pendingId) return // keepalive requests are capped at 64KB; past that, ask before leaving. var body = JSON.stringify(collectPendingState('editing')) if (new Blob([body]).size < 60000) { fetch('/api/pending/' + encodeURIComponent(pendingId) + '/state', { method: 'POST', body: body, keepalive: true, headers: { 'Content-Type': 'application/json; charset=utf-8' } }) return } e.preventDefault() e.returnValue = '' }) $(document).on('mousedown', function (e) { if ($(e.target).closest('.search-results,.product-search').length) return $('.search-results:visible').hide() }) $(document).on('keydown', '#newProductModal input, #newProductModal select', function (e) { if (e.key !== 'Enter') return e.preventDefault() handleCreateProduct() }) $(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 = padUpc13($(this).val()) if (v) $(this).val(v) }) $('#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) { setUploadStage(msg.id, msg.stage, msg.error) clearTimeout(dashRefreshTimer) dashRefreshTimer = setTimeout(loadDashboard, 400) // coalesce bursts return } loadDraftNav() 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) }) var items = files.map(function (f) { return addUploadItem(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 = [] // Results come back in the order the files were sent. ;(result.results || []).forEach(function (r, i) { if (r.ok) ok++ else { fail++; failMsgs.push((r.file || 'file') + ': ' + (r.error || 'failed')) } if (!items[i]) return items[i].attr('data-id', r.id) var seen = uploadStages[r.id] if (seen) setUploadStage(r.id, seen.stage, seen.error) else setUploadStage(r.id, r.ok ? 'queued' : 'error', r.error) }) 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') items.forEach(function (li) { showUploadStage(li, 'error', err.message) }) } $('#fileInput').val('') if (isDashboard) loadDashboard() } // id -> latest {stage, error} from SSE. Events can arrive before the upload // response has told us which row an id belongs to. var uploadStages = {} function addUploadItem(name) { var list = $('#uploadList').show() var li = $('
  • ') li.find('.ul-name').text(name).attr('title', name) showUploadStage(li, 'uploading') list.prepend(li) list.children().slice(30).remove() return li } function setUploadStage(id, stage, error) { if (!id) return uploadStages[id] = { stage: stage, error: error } var li = $('#uploadList li[data-id="' + id + '"]') if (li.length) showUploadStage(li, stage, error) } function showUploadStage(li, stage, error) { li.find('.stage').attr('class', 'stage stage-' + stage).text(stage).attr('title', error || '') var id = li.attr('data-id') li.find('.ul-open').toggle(stage === 'parsed' && !!id) .attr('href', id ? 'index.html?pending=' + encodeURIComponent(id) : null) } // ------------------------------------------------------- // 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) $('#statReady,#statQueued,#statError').each(function () { $(this).closest('.stat-card').toggleClass('is-zero', $(this).text() === '0') }) $('#dashUpdated').text('Updated ' + new Date().toLocaleTimeString()) } catch (e) { console.warn('Dashboard stats failed:', e) } loadReviewNext() } // Points the main button straight at the oldest draft waiting for review. async function loadReviewNext() { try { var ids = await fetchReviewableIds() var btn = $('#btnReviewNext') if (ids.length) { btn.attr('href', 'index.html?pending=' + encodeURIComponent(ids[0])) .removeClass('disabled').text('Review next invoice') $('#reviewNextNote').text(ids.length + ' waiting for review') } else { btn.attr('href', 'pending.html').addClass('disabled').text('Open Pending') $('#reviewNextNote').text('Nothing waiting for review') } } catch (e) { console.warn('Review-next lookup failed:', e) } } // ------------------------------------------------------- // 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+/, '') } // Database convention: 13 digits, zero padded, no separators. function padUpc13(u) { var v = String(u || '').replace(/\D/g, '') return v.length > 0 ? v.padStart(13, '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') var note = qDiff && cDiff ? 'qty + cost differ from PO' : qDiff ? 'qty differs from PO' : 'cost differs from PO' row.find('.match-badge').last().after('' + note + '') } 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('not on PO') } }) 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' : v.label === 'fuzzy' ? 'check vendor' : 'no vendor match' $('#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() checkInvoiceNumber() 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 var lineSum = 0, anyTotal = false rows.each(function () { var row = $(this) var needs = rowNeedsAttention(row) row.toggleClass('needs-attention', needs) if (needs) attention++ var s = row.attr('data-status') if (s === 'unmatched' || s === 'fuzzy') unmatched++ else row.removeClass('row-invalid') var t = numOrNull(row.find('.item-total').val()) if (t != null) { lineSum += t; anyTotal = true } checkLineTotal(row) }) var summary = rows.length + ' line' + (rows.length === 1 ? '' : 's') if (rows.length) { summary += attention ? ' - ' + attention + ' need attention' : ' - all reviewed' } $('#lineSummary').html(summary) // Informational only: the header total can legitimately include tax, // deposits or freight that are not line items. var check = $('#totalCheck') var hdrTotal = numOrNull($('#invoiceTotal').val()) if (!rows.length || !anyTotal) check.html('') else if (hdrTotal == null) check.html('lines sum $' + lineSum.toFixed(2)) else if (Math.abs(hdrTotal - lineSum) < 0.005) check.html('lines = total') else check.html('' + 'lines $' + lineSum.toFixed(2) + ' vs total $' + hdrTotal.toFixed(2) + ' (diff $' + (hdrTotal - lineSum).toFixed(2) + ')') var blockers = $('#saveBlockers') if (unmatched) blockers.text(unmatched + ' row' + (unmatched === 1 ? '' : 's') + ' must be matched or accepted before saving').show() else blockers.hide() } // Flags a line total that is not qty x case cost. function checkLineTotal(row) { var qty = numOrNull(row.find('.item-qty').val()) var cost = numOrNull(row.find('.item-cost').val()) var input = row.find('.item-total') var total = numOrNull(input.val()) var off = qty != null && cost != null && total != null && Math.abs(qty * cost - total) > 0.01 input.toggleClass('total-mismatch', off) .attr('title', off ? 'Qty x case cost = $' + (qty * cost).toFixed(2) : '') } // A blank total is filled from qty x cost and kept in step until edited by hand. function autoFillTotal(row) { var input = row.find('.item-total') if (input.val() !== '' && !row.data('autoTotal')) return var qty = numOrNull(row.find('.item-qty').val()) var cost = numOrNull(row.find('.item-cost').val()) if (qty == null || cost == null) return input.val((qty * cost).toFixed(2)) row.data('autoTotal', true) } function checkInvoiceNumber() { var len = ($('#invoiceNumber').val() || '').trim().length $('#errInvoiceNumber').text('max 100 characters (' + len + ')').toggle(len > 100) } // Cycles through rows that still need a decision, from the last one visited. var attentionCursor = null function jumpToAttention(dir) { var rows = $('#lineItemsBody tr.needs-attention') if (!rows.length) return var pos = -1 rows.each(function (n) { if ($(this).attr('data-index') === attentionCursor) pos = n }) pos = pos < 0 ? (dir > 0 ? 0 : rows.length - 1) : (pos + dir + rows.length) % rows.length var row = rows.eq(pos) attentionCursor = row.attr('data-index') flashRow(row) if (row.hasClass('has-source')) showRowOnPreview(row) } function flashRow(row) { row[0].scrollIntoView({ behavior: 'smooth', block: 'center' }) row.removeClass('row-flash') void row[0].offsetWidth // restart the flash animation row.addClass('row-flash') } // ------------------------------------------------------- // 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 = status === 'exact' ? 'matched' : status === 'fuzzy' ? 'check match' : 'no match' 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('input', '.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 (inp.val().trim() !== q) return // a newer search is on its way 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.children().first().addClass('active') div.show() positionSearchResults(inp, div) } catch (e) { console.error(e) } }, 300) }) // Arrow keys move through the results, Enter picks, Escape closes. $(document).on('keydown', '.product-search', function (e) { var div = $('#search_' + $(this).data('item')) if (e.key === 'Escape') { if (div.is(':visible')) { div.hide().empty(); e.stopPropagation() } return } var items = div.children('.search-result-item,.search-result-new') if (!div.is(':visible') || !items.length) return var cur = items.index(items.filter('.active')) if (e.key === 'ArrowDown' || e.key === 'ArrowUp') { e.preventDefault() var n = e.key === 'ArrowDown' ? Math.min(items.length - 1, cur + 1) : Math.max(0, cur - 1) items.removeClass('active') items.eq(n).addClass('active')[0].scrollIntoView({ block: 'nearest' }) } else if (e.key === 'Enter') { e.preventDefault() items.eq(cur < 0 ? 0 : cur).trigger('click') } }) // 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() { var undo = [] $('#lineItemsBody tr').each(function () { var row = $(this) var status = row.attr('data-status') if (status === 'unmatched' || status === 'confirmed') return var badge = row.find('.match-badge').first() undo.push({ row: row, status: status, text: badge.text(), cls: badge.attr('class'), // Detached, not removed, so Undo can put them back. confirm: row.find('.btn-confirm').detach(), cands: row.find('.cand-list').detach() }) acceptRow(row, true) }) refreshReviewSummary() queuePendingStateSave('editing') if (!undo.length) return showUndo('Accepted ' + undo.length + ' row' + (undo.length === 1 ? '' : 's') + '.', function () { undo.forEach(function (u) { u.row.attr('data-status', u.status) u.row.find('.match-badge').first().text(u.text).attr('class', u.cls) u.row.find('.match-actions').before(u.confirm).before(u.cands) }) 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 () { var row = $('tr[data-index="' + $(this).data('index') + '"]') var pos = row.index() row.detach() afterRowsChanged() showUndo('Row ' + (pos + 1) + ' removed.', function () { var body = $('#lineItemsBody') var at = body.children().eq(pos) if (at.length) at.before(row) else body.append(row) afterRowsChanged() }) }) function afterRowsChanged() { if ($('#poSelect').val() && poCompareData) applyPoComparison() renderLineMarkers() refreshReviewSummary() queuePendingStateSave('editing') } var toastTimer = null function showUndo(msg, undo) { var t = $('#toast') if (!t.length) t = $('
    ').appendTo('body') t.find('.toast-msg').text(msg) t.find('button').off('click').on('click', function () { clearTimeout(toastTimer) t.hide() undo() }) t.css('display', 'flex') clearTimeout(toastTimer) toastTimer = setTimeout(function () { t.hide() }, 8000) } $(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() $('#btnCreateProduct').prop('disabled', false) // 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(padUpc13(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()) fd.append('vendor_id', $('#vendorSelect').val() || '') fd.append('invoice_number', $('#invoiceNumber').val() || '') var btn = $('#btnCreateProduct') if (btn.prop('disabled')) return btn.prop('disabled', true) $('#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) // stays disabled until the modal reopens return } $('#newProductStatus').text('Error: ' + result.error).css('color', '#ff8888').show() } catch (e) { $('#newProductStatus').text('Error: ' + e.message).css('color', '#ff8888').show() } btn.prop('disabled', false) } // ------------------------------------------------------- // Pending draft state // ------------------------------------------------------- function getQueryParam(name) { var params = new URLSearchParams(window.location.search) return params.get(name) } function queuePendingStateSave(stage) { if (!pendingId) return saveDirty = true 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 saveDirty = false setAutosave('Saving draft...') try { 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') setAutosave('Draft saved ' + new Date().toLocaleTimeString()) } catch (e) { saveDirty = true setAutosave('Draft NOT saved: ' + e.message, true) throw e } } function setAutosave(text, isError) { $('#autosaveStatus').text(text).toggleClass('autosave-error', !!isError) } // Navigates away once any pending autosave has landed. async function leaveTo(href) { if (saveDirty && pendingId) { clearTimeout(pendingSaveTimer) try { await savePendingState('editing') } catch (e) { if (!confirm('Your latest edits could not be saved (' + e.message + ').\n\nLeave anyway?')) return saveDirty = false } } window.location.href = href } // Drafts that can be opened for review, oldest first (ids start with the // creation timestamp). async function fetchReviewableIds() { var list = await (await fetch('/api/pending')).json() return list .filter(function (r) { return r.stage === 'parsed' || r.stage === 'review' || r.stage === 'editing' }) .map(function (r) { return r.id }) .sort() } async function loadDraftNav() { if (!currentDraftId) return try { var ids = await fetchReviewableIds() if (ids.indexOf(currentDraftId) < 0) { ids.push(currentDraftId); ids.sort() } var pos = ids.indexOf(currentDraftId) draftNav.prev = pos > 0 ? ids[pos - 1] : null draftNav.next = pos < ids.length - 1 ? ids[pos + 1] : null $('#btnPrevDraft').prop('disabled', !draftNav.prev) $('#btnNextDraft').prop('disabled', !draftNav.next) $('#draftPos').text(ids.length > 1 ? (pos + 1) + ' of ' + ids.length : '') } catch (e) { console.warn('Draft navigation failed:', e) } } 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) { var vCls = h.vendor_badge_class || 'match-badge badge-none' // Drafts saved before the specific labels carry the old generic text. var vText = h.vendor_badge_text !== 'VALIDATE DATA' ? h.vendor_badge_text : vCls.indexOf('badge-fuzzy') >= 0 ? 'check vendor' : 'no vendor match' $('#vendorBadge').text(vText).attr('class', vCls).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() checkInvoiceNumber() } 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 && saved.match_badge_text !== 'VALIDATE DATA') 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 saveBtn = $('#btnSave') if (saveBtn.prop('disabled')) return $('.field-invalid').removeClass('field-invalid') $('#lineItemsBody tr').removeClass('row-invalid') // Every problem is marked in place; the first one gets focus. var problems = [], first = null function flag(el, msg) { el.addClass('field-invalid') problems.push(msg) if (!first) first = el } var vendorId = $('#vendorSelect').val() var invNum = $('#invoiceNumber').val().trim() if (!vendorId) flag($('#vendorSelect'), 'select a vendor') if (!invNum) flag($('#invoiceNumber'), 'enter the invoice number') else if (invNum.length > 100) flag($('#invoiceNumber'), 'shorten the invoice number to 100 characters') if (!$('#invoiceDate').val()) flag($('#invoiceDate'), 'enter the invoice date') var unconfirmed = [] $('#lineItemsBody tr').each(function (idx) { var s = $(this).attr('data-status') if (s !== 'unmatched' && s !== 'fuzzy') return $(this).addClass('row-invalid') unconfirmed.push(idx + 1) }) if (unconfirmed.length) { problems.push('match or accept row' + (unconfirmed.length === 1 ? ' ' : 's ') + unconfirmed.join(', ')) if (!first) first = $('#lineItemsBody tr.row-invalid').first() } if (problems.length) { setStatus('Cannot save yet: ' + problems.join('; ') + '.', 'error') if (first.is('tr')) flashRow(first) else first.focus() 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()) }) saveBtn.prop('disabled', true).text('Saving...') 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' } var doneId = pendingId if (pendingId) { // Stop the debounced autosave first: firing after the delete // would recreate the draft. clearTimeout(pendingSaveTimer) saveDirty = false pendingId = null try { await fetch('/api/pending/' + encodeURIComponent(doneId), { method: 'DELETE' }) } catch (e) {} } saveBtn.text('Saved') $('#reviewSection').addClass('saved') setAutosave('') await showSaveDone(msg, doneId) } else { saveBtn.prop('disabled', false).text('Save Invoice') setStatus('Save failed: ' + result.error, 'error') } } catch (e) { saveBtn.prop('disabled', false).text('Save Invoice') setStatus('Save failed: ' + e.message, 'error') } } // The result stays on screen until the user moves on, with a direct jump to // the next draft waiting for review. async function showSaveDone(msg, doneId) { var nextId = null try { var ids = await fetchReviewableIds() nextId = ids.filter(function (id) { return id > (doneId || '') })[0] || ids[0] || null } catch (e) {} setStatus(msg, 'success') var actions = $('') if (nextId) actions.append($('').attr('href', 'index.html?pending=' + encodeURIComponent(nextId)).text('Next invoice ›')) actions.append($('Pending list')) $('#statusBar').append(actions) } // ------------------------------------------------------- // 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, '"') }