InvoiceParser/wwwroot/js/app.js

2097 lines
86 KiB
JavaScript
Raw Permalink Normal View History

2026-09-16 20:09:19 +00:00
// 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
2026-09-30 18:50:04 +00:00
var saveDirty = false // an edit is waiting on the autosave debounce
var currentDraftId = null
var draftNav = { prev: null, next: null }
var dashRefreshTimer = null
2026-09-16 20:09:19 +00:00
$(document).ready(function () {
var resumeID = getQueryParam('pending')
isDashboard = !resumeID
if (resumeID) {
$('body').addClass('review-page')
$('#layout').css('display', 'flex')
$('#pageMode').text('- Review')
initPreviewSplit()
initPreviewZoom()
2026-09-30 18:50:04 +00:00
currentDraftId = resumeID
2026-09-16 20:09:19 +00:00
var boot = [loadVendors(), loadDepartments(), loadPOList()]
Promise.all(boot).then(function () { resumePending(resumeID) })
2026-09-30 18:50:04 +00:00
loadDraftNav()
2026-09-16 20:09:19 +00:00
} else {
$('#dashboard').show()
2026-09-30 18:50:04 +00:00
$('#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 () {})
2026-09-16 20:09:19 +00:00
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) {
2026-09-30 18:50:04 +00:00
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)
}
2026-09-16 20:09:19 +00:00
})
$(document).on('input change', '.item-upc,.item-cert,.item-qty,.item-cost', function () {
if ($('#poSelect').val() && poCompareData) applyPoComparison()
})
2026-09-30 18:50:04 +00:00
$(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()
})
2026-09-16 20:09:19 +00:00
$(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 () {
2026-09-30 18:50:04 +00:00
var v = padUpc13($(this).val())
if (v) $(this).val(v)
2026-09-16 20:09:19 +00:00
})
$('#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 ? '&raquo; Show preview' : '&laquo; 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) {
2026-09-30 18:50:04 +00:00
setUploadStage(msg.id, msg.stage, msg.error)
clearTimeout(dashRefreshTimer)
dashRefreshTimer = setTimeout(loadDashboard, 400) // coalesce bursts
2026-09-16 20:09:19 +00:00
return
}
2026-09-30 18:50:04 +00:00
loadDraftNav()
2026-09-16 20:09:19 +00:00
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) })
2026-09-30 18:50:04 +00:00
var items = files.map(function (f) { return addUploadItem(f.name) })
2026-09-16 20:09:19 +00:00
try {
var resp = await fetch('/api/upload', { method: 'POST', body: fd })
var result = await readJsonResponse(resp, 'upload')
var ok = 0, fail = 0, failMsgs = []
2026-09-30 18:50:04 +00:00
// Results come back in the order the files were sent.
;(result.results || []).forEach(function (r, i) {
2026-09-16 20:09:19 +00:00
if (r.ok) ok++
else { fail++; failMsgs.push((r.file || 'file') + ': ' + (r.error || 'failed')) }
2026-09-30 18:50:04 +00:00
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)
2026-09-16 20:09:19 +00:00
})
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')
2026-09-30 18:50:04 +00:00
items.forEach(function (li) { showUploadStage(li, 'error', err.message) })
2026-09-16 20:09:19 +00:00
}
$('#fileInput').val('')
if (isDashboard) loadDashboard()
}
2026-09-30 18:50:04 +00:00
// 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><span class="ul-name"></span><span class="stage"></span><a class="ul-open" style="display:none">Review</a></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)
}
2026-09-16 20:09:19 +00:00
// -------------------------------------------------------
// 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)
2026-09-30 18:50:04 +00:00
$('#statReady,#statQueued,#statError').each(function () {
$(this).closest('.stat-card').toggleClass('is-zero', $(this).text() === '0')
})
2026-09-16 20:09:19 +00:00
$('#dashUpdated').text('Updated ' + new Date().toLocaleTimeString())
} catch (e) {
console.warn('Dashboard stats failed:', e)
}
2026-09-30 18:50:04 +00:00
loadReviewNext()
2026-09-16 20:09:19 +00:00
}
2026-09-30 18:50:04 +00:00
// Points the main button straight at the oldest draft waiting for review.
async function loadReviewNext() {
2026-09-16 20:09:19 +00:00
try {
2026-09-30 18:50:04 +00:00
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')
2026-09-16 20:09:19 +00:00
} else {
2026-09-30 18:50:04 +00:00
btn.attr('href', 'pending.html').addClass('disabled').text('Open Pending')
$('#reviewNextNote').text('Nothing waiting for review')
2026-09-16 20:09:19 +00:00
}
} catch (e) {
2026-09-30 18:50:04 +00:00
console.warn('Review-next lookup failed:', e)
2026-09-16 20:09:19 +00:00
}
}
// -------------------------------------------------------
// 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('<option value="' + escHtml(p.po_number) + '">' + escHtml(label) + '</option>')
})
} 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+/, '')
}
2026-09-30 18:50:04 +00:00
// 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') : ''
}
2026-09-16 20:09:19 +00:00
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')
2026-09-30 18:50:04 +00:00
var note = qDiff && cDiff ? 'qty + cost differ from PO' : qDiff ? 'qty differs from PO' : 'cost differs from PO'
row.find('.match-badge').last().after('<span class="po-note">' + note + '</span>')
2026-09-16 20:09:19 +00:00
} else {
row.addClass('po-match').attr('data-po-status', 'match')
}
} else {
reviewCount++
row.addClass('po-inv-only').attr('data-po-status', 'invoice-only')
2026-09-30 18:50:04 +00:00
row.find('.match-badge').last().after('<span class="po-note">not on PO</span>')
2026-09-16 20:09:19 +00:00
}
})
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('<option value="' + v.id + '">' + escHtml(v.name) + '</option>')
})
} 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'
2026-09-30 18:50:04 +00:00
var badgeText = v.label === 'exact' ? 'matched' : v.label === 'fuzzy' ? 'check vendor' : 'no vendor match'
2026-09-16 20:09:19 +00:00
$('#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()
2026-09-30 18:50:04 +00:00
checkInvoiceNumber()
2026-09-16 20:09:19 +00:00
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
2026-09-30 18:50:04 +00:00
var lineSum = 0, anyTotal = false
2026-09-16 20:09:19 +00:00
rows.each(function () {
var row = $(this)
2026-09-30 18:50:04 +00:00
var needs = rowNeedsAttention(row)
row.toggleClass('needs-attention', needs)
if (needs) attention++
2026-09-16 20:09:19 +00:00
var s = row.attr('data-status')
if (s === 'unmatched' || s === 'fuzzy') unmatched++
2026-09-30 18:50:04 +00:00
else row.removeClass('row-invalid')
var t = numOrNull(row.find('.item-total').val())
if (t != null) { lineSum += t; anyTotal = true }
checkLineTotal(row)
2026-09-16 20:09:19 +00:00
})
var summary = rows.length + ' line' + (rows.length === 1 ? '' : 's')
if (rows.length) {
summary += attention
2026-09-30 18:50:04 +00:00
? ' - <span class="attention-count" title="Go to the next row that needs attention (F8, Shift+F8 for previous)">' + attention + ' need attention</span>'
2026-09-16 20:09:19 +00:00
: ' - <span class="attention-none">all reviewed</span>'
}
$('#lineSummary').html(summary)
2026-09-30 18:50:04 +00:00
// 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('<span class="total-ok">lines = total</span>')
else check.html('<span class="total-diff" title="Header total minus the sum of line totals. Can be tax, deposits or freight.">' +
'lines $' + lineSum.toFixed(2) + ' vs total $' + hdrTotal.toFixed(2) +
' (diff $' + (hdrTotal - lineSum).toFixed(2) + ')</span>')
2026-09-16 20:09:19 +00:00
var blockers = $('#saveBlockers')
if (unmatched) blockers.text(unmatched + ' row' + (unmatched === 1 ? '' : 's') + ' must be matched or accepted before saving').show()
else blockers.hide()
}
2026-09-30 18:50:04 +00:00
// 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')
}
2026-09-16 20:09:19 +00:00
// -------------------------------------------------------
// 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)'
$('<div class="line-marker">')
.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))
? '<small class="parsed-hint" title="Pack printed on the invoice; the box shows the product file value">inv: ' + escHtml(String(parsedPack)) + '</small>'
: ''
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'
2026-09-30 18:50:04 +00:00
var badgeText = status === 'exact' ? 'matched' : status === 'fuzzy' ? 'check match' : 'no match'
2026-09-16 20:09:19 +00:00
var warnHtml = match.inactive ? '<span class="warn-badge">INACTIVE</span>' : ''
// The search box is available on every row, matched or not - the suggested
// product is not always the right one.
var isUnmatched = (label === 'unmatched')
2026-09-30 18:50:04 +00:00
var searchHtml = '<div class="match-actions">' +
'<button type="button" class="search-toggle" data-item="' + i + '"' +
(isUnmatched ? ' style="display:none"' : '') + ' title="Search for a different product">Change</button>' +
'<button type="button" class="btn-new-item" data-item="' + i + '"' +
' title="Create this product in posbdat..Products and attach it to this row">+ New item</button>' +
'</div>' +
2026-09-16 20:09:19 +00:00
'<div class="search-inline-wrap"' + (isUnmatched ? '' : ' style="display:none"') + '>' +
2026-09-30 18:50:04 +00:00
buildSearchUI(i) + '</div>'
2026-09-16 20:09:19 +00:00
// 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')
? '<br><button class="btn-confirm" data-index="' + i + '">Accept</button>'
: ''
var matchedDesc = match.description || ''
var toggleHtml = buildDescToggle(i, parsedDesc, matchedDesc, matchedDesc ? 'matched' : 'parsed')
var parsedCodeHint = parsedCode ? '<small class="parsed-hint">parsed: ' + escHtml(parsedCode) + '</small>' : ''
var parsedUpcDiv = (parsedUpc && parsedUpc !== upc)
? '<div class="upc-parsed-val">' + escHtml(parsedUpc) + '</div>'
: ''
var cands = (match.candidates || []).filter(function (c) { return c.upc !== upc })
rowCandidates[i] = cands
var candHtml = ''
if (cands.length) {
candHtml = '<div class="cand-list">'
cands.forEach(function (c, j) {
candHtml += '<div class="cand-item" data-item="' + i + '" data-cand="' + j + '" title="' + escHtml(c.upc || '') + '">' +
escHtml(c.description || '') +
'<span class="cand-meta"> ' + Math.round((c.score || 0) * 100) + '%</span></div>'
})
candHtml += '</div>'
}
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 '<tr data-index="' + i + '" data-status="' + status + '"' +
' data-source-line="' + (isNaN(srcLine) ? '' : srcLine) + '"' +
' data-parsed-desc="' + escHtml(parsedDesc) + '"' +
' data-parsed-upc="' + escHtml(parsedUpc) + '"' +
' data-parsed-cert="' + escHtml(parsedCode) + '"' +
' data-parsed-size="' + escHtml(parsedSize) + '"' +
' data-parsed-pack="' + escHtml(String(parsedPack)) + '"' +
' data-parsed-uom="' + escHtml(parsedUom) + '"' +
' data-matched-desc="' + escHtml(matchedDesc) + '"' +
' data-matched-upc="' + escHtml(match.upc || '') + '"' +
' data-matched-cert="' + escHtml(match.cert_code || '') + '"' +
' data-matched-size="' + escHtml(match.size || '') + '"' +
' data-matched-uom="' + escHtml(match.uom || '') + '">' +
'<td><div class="upc-matched">' + escHtml(upc) + '</div>' + parsedUpcDiv +
'<input type="hidden" class="item-upc" value="' + escHtml(upc) + '"></td>' +
'<td><textarea class="item-desc" rows="2">' + escHtml(desc) + '</textarea>' + toggleHtml + '</td>' +
'<td><input class="item-cert" value="' + escHtml(cert) + '">' + parsedCodeHint + '</td>' +
'<td class="po-cases po-value cmp-na">-</td>' +
'<td class="po-pieces po-value cmp-na">-</td>' +
'<td class="po-cost po-value cmp-na">-</td>' +
'<td><input class="item-qty" type="number" step="any" value="' + escHtml(String(qty)) + '"></td>' +
'<td><input class="item-cost" type="number" step="0.01" value="' + escHtml(String(cost)) + '"></td>' +
'<td><input class="item-pack" type="number" step="1" min="0" value="' + escHtml(String(pack)) + '">' + parsedPackHint + '</td>' +
'<td class="item-unitcost calc-cell cmp-na">-</td>' +
'<td class="item-retail calc-cell cmp-na">-' +
'<input type="hidden" class="retail-val" value="' + escHtml(String(retail)) + '"></td>' +
'<td class="item-gm calc-cell gm-cell cmp-na">-</td>' +
'<td><input class="item-total" type="number" step="0.01" value="' + escHtml(String(total)) + '"></td>' +
'<td><input class="item-size" value="' + escHtml(size) + '"></td>' +
'<td><input class="item-uom" value="' + escHtml(uom) + '"></td>' +
'<td><span class="match-badge ' + badgeCls + '">' + badgeText + '</span>' + warnHtml + confirmHtml + candHtml + searchHtml + '</td>' +
'<td><button class="btn-remove-row" data-index="' + i + '" title="Remove row">&times;</button></td>' +
'</tr>'
}
// -------------------------------------------------------
// 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 '<div class="desc-toggle-row">' +
descSrcButton(i, 'matched', 'Matched', matchedDesc, active) +
descSrcButton(i, 'parsed', 'Parsed', parsedDesc, active) +
'</div>'
}
function descSrcButton(i, source, label, value, active) {
if (!value) {
return '<button type="button" class="btn-desc-src" disabled ' +
'title="No ' + source + ' description for this row">' + label + '</button>'
}
return '<button type="button" class="btn-desc-src' + (active === source ? ' active' : '') + '" ' +
'data-item="' + i + '" data-source="' + source + '" ' +
'title="' + escHtml(value) + '">' + label + '</button>'
}
function buildSearchUI(i) {
return '<input class="product-search" data-item="' + i + '" ' +
'placeholder="Search UPC or description...">' +
'<button type="button" class="btn-search-cancel" data-item="' + i + '">cancel</button>' +
'<div class="search-results" id="search_' + i + '"></div>'
}
// -------------------------------------------------------
// 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 = {}
2026-09-30 18:50:04 +00:00
$(document).on('input', '.product-search', function () {
2026-09-16 20:09:19 +00:00
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()
2026-09-30 18:50:04 +00:00
if (inp.val().trim() !== q) return // a newer search is on its way
2026-09-16 20:09:19 +00:00
if (results.error) { showDbError(results.error); div.hide().empty(); return }
div.empty()
if (results.length === 0) {
div.html(
'<div class="search-result-empty">No results</div>' +
'<div class="search-result-new" data-item="' + idx + '">[+ Create new product]</div>'
).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 + ')' : '')
$('<div class="search-result-item">')
.html('<div class="search-result-desc">' + escHtml(r.description) + '</div>' +
'<div class="search-result-meta">' + meta + '</div>')
.data('product', r)
.on('click', function () { applyProduct(idx, $(this).data('product')); div.hide().empty() })
.appendTo(div)
})
2026-09-30 18:50:04 +00:00
div.children().first().addClass('active')
2026-09-16 20:09:19 +00:00
div.show()
positionSearchResults(inp, div)
} catch (e) { console.error(e) }
}, 300)
})
2026-09-30 18:50:04 +00:00
// 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')
}
})
2026-09-16 20:09:19 +00:00
// 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() {
2026-09-30 18:50:04 +00:00
var undo = []
2026-09-16 20:09:19 +00:00
$('#lineItemsBody tr').each(function () {
2026-09-30 18:50:04 +00:00
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)
2026-09-16 20:09:19 +00:00
})
refreshReviewSummary()
queuePendingStateSave('editing')
2026-09-30 18:50:04 +00:00
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')
})
2026-09-16 20:09:19 +00:00
}
$(document).on('click', '.btn-confirm', function () {
var idx = $(this).data('index')
acceptRow($('tr[data-index="' + idx + '"]'))
})
$(document).on('click', '.btn-remove-row', function () {
2026-09-30 18:50:04 +00:00
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() {
2026-09-16 20:09:19 +00:00
if ($('#poSelect').val() && poCompareData) applyPoComparison()
renderLineMarkers()
refreshReviewSummary()
queuePendingStateSave('editing')
2026-09-30 18:50:04 +00:00
}
var toastTimer = null
function showUndo(msg, undo) {
var t = $('#toast')
if (!t.length) t = $('<div id="toast"><span class="toast-msg"></span><button type="button">Undo</button></div>').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)
}
2026-09-16 20:09:19 +00:00
$(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 += '<tr data-row="' + row.attr('data-index') + '"' + (anyDiff ? ' class="has-diff"' : '') + '>' +
'<td>' + (n + 1) + '</td>' +
'<td>' + compareCell(p, {}) + '</td>' +
'<td>' + compareCell(m, diff) + '</td>' +
'<td><span class="' + escHtml(badge.attr('class') || 'match-badge') + '">' +
escHtml(badge.text() || 'unmatched') + '</span></td>' +
'</tr>'
})
if (!shown) {
html = '<tr><td colspan="4" class="cmp-none">' +
(diffOnly ? 'Every line matched the product file exactly.' : 'No line items on this draft.') +
'</td></tr>'
}
$('#compareBody').html(html)
}
function headerCompareRow(label, parsed, current) {
var diff = !sameText(parsed, current)
return '<tr><td>' + escHtml(label) + '</td>' +
'<td>' + cmpVal(parsed, false) + '</td>' +
'<td>' + cmpVal(current, diff && parsed !== '') + '</td></tr>'
}
// 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 '<div class="cmp-desc">' + (v.desc ? span(v.desc, diff.desc) : '<span class="cmp-empty">(none)</span>') + '</div>' +
(meta.length ? '<div class="cmp-meta">' + meta.join(' &middot; ') + '</div>' : '')
}
function span(text, isDiff) {
return isDiff ? '<span class="cmp-diff">' + escHtml(text) + '</span>' : escHtml(text)
}
function cmpVal(v, isDiff) {
if (!v) return '<span class="cmp-empty">(none)</span>'
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()
2026-09-30 18:50:04 +00:00
$('#btnCreateProduct').prop('disabled', false)
2026-09-16 20:09:19 +00:00
// 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) {
2026-09-30 18:50:04 +00:00
$('#np_upc').val(padUpc13(row.attr('data-parsed-upc') || row.find('.item-upc').val()))
2026-09-16 20:09:19 +00:00
$('#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('<option value="">-- Select Department --</option>')
deptFlags = {}
depts.forEach(function (d) {
deptFlags[String(d.id)] = { tax: d.tax ? 1 : 0, fs: d.fs ? 1 : 0 }
sel.append('<option value="' + d.id + '">' + escHtml(d.name) + '</option>')
})
} 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())
2026-09-30 18:50:04 +00:00
fd.append('vendor_id', $('#vendorSelect').val() || '')
fd.append('invoice_number', $('#invoiceNumber').val() || '')
2026-09-16 20:09:19 +00:00
2026-09-30 18:50:04 +00:00
var btn = $('#btnCreateProduct')
if (btn.prop('disabled')) return
btn.prop('disabled', true)
2026-09-16 20:09:19 +00:00
$('#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
})
}
2026-09-30 18:50:04 +00:00
setTimeout(closeNewProductModal, 800) // stays disabled until the modal reopens
return
2026-09-16 20:09:19 +00:00
}
2026-09-30 18:50:04 +00:00
$('#newProductStatus').text('Error: ' + result.error).css('color', '#ff8888').show()
2026-09-16 20:09:19 +00:00
} catch (e) {
$('#newProductStatus').text('Error: ' + e.message).css('color', '#ff8888').show()
}
2026-09-30 18:50:04 +00:00
btn.prop('disabled', false)
2026-09-16 20:09:19 +00:00
}
// -------------------------------------------------------
// Pending draft state
// -------------------------------------------------------
function getQueryParam(name) {
var params = new URLSearchParams(window.location.search)
return params.get(name)
}
function queuePendingStateSave(stage) {
if (!pendingId) return
2026-09-30 18:50:04 +00:00
saveDirty = true
2026-09-16 20:09:19 +00:00
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
2026-09-30 18:50:04 +00:00
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)
}
2026-09-16 20:09:19 +00:00
}
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 = $('<div id="busyOverlay"><div class="busy-box">' +
'<div class="busy-spinner"></div><div class="busy-msg"></div>' +
'</div></div>').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) {
2026-09-30 18:50:04 +00:00
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()
2026-09-16 20:09:19 +00:00
}
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()
2026-09-30 18:50:04 +00:00
checkInvoiceNumber()
2026-09-16 20:09:19 +00:00
}
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('<option value="' + escHtml(String(value)) + '">' + escHtml(label || String(value)) + '</option>')
}
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 || '')
2026-09-30 18:50:04 +00:00
if (saved.match_badge_text && saved.match_badge_text !== 'VALIDATE DATA') row.find('.match-badge').first().text(saved.match_badge_text)
2026-09-16 20:09:19 +00:00
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() {
2026-09-30 18:50:04 +00:00
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
}
2026-09-16 20:09:19 +00:00
var vendorId = $('#vendorSelect').val()
2026-09-30 18:50:04 +00:00
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')
2026-09-16 20:09:19 +00:00
var unconfirmed = []
$('#lineItemsBody tr').each(function (idx) {
var s = $(this).attr('data-status')
2026-09-30 18:50:04 +00:00
if (s !== 'unmatched' && s !== 'fuzzy') return
$(this).addClass('row-invalid')
unconfirmed.push(idx + 1)
2026-09-16 20:09:19 +00:00
})
if (unconfirmed.length) {
2026-09-30 18:50:04 +00:00
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()
2026-09-16 20:09:19 +00:00
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())
})
2026-09-30 18:50:04 +00:00
saveBtn.prop('disabled', true).text('Saving...')
2026-09-16 20:09:19 +00:00
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'
}
2026-09-30 18:50:04 +00:00
var doneId = pendingId
2026-09-16 20:09:19 +00:00
if (pendingId) {
// Stop the debounced autosave first: firing after the delete
// would recreate the draft.
clearTimeout(pendingSaveTimer)
2026-09-30 18:50:04 +00:00
saveDirty = false
2026-09-16 20:09:19 +00:00
pendingId = null
try { await fetch('/api/pending/' + encodeURIComponent(doneId), { method: 'DELETE' }) } catch (e) {}
}
2026-09-30 18:50:04 +00:00
saveBtn.text('Saved')
$('#reviewSection').addClass('saved')
setAutosave('')
await showSaveDone(msg, doneId)
2026-09-16 20:09:19 +00:00
} else {
2026-09-30 18:50:04 +00:00
saveBtn.prop('disabled', false).text('Save Invoice')
2026-09-16 20:09:19 +00:00
setStatus('Save failed: ' + result.error, 'error')
}
} catch (e) {
2026-09-30 18:50:04 +00:00
saveBtn.prop('disabled', false).text('Save Invoice')
2026-09-16 20:09:19 +00:00
setStatus('Save failed: ' + e.message, 'error')
}
}
2026-09-30 18:50:04 +00:00
// 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 = $('<span class="status-actions"></span>')
if (nextId) actions.append($('<a>').attr('href', 'index.html?pending=' + encodeURIComponent(nextId)).text('Next invoice ›'))
actions.append($('<a href="pending.html">Pending list</a>'))
$('#statusBar').append(actions)
}
2026-09-16 20:09:19 +00:00
// -------------------------------------------------------
// 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 = $('<div id="dbWarning" class="db-warning"></div>').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, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
}