InvoiceParser/wwwroot/js/app.js
2026-09-30 11:50:04 -07:00

2096 lines
86 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

// 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 ? '&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) {
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><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)
}
// -------------------------------------------------------
// 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('<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+/, '')
}
// 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('<span class="po-note">' + note + '</span>')
} 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('<span class="po-note">not on PO</span>')
}
})
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'
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
? ' - <span class="attention-count" title="Go to the next row that needs attention (F8, Shift+F8 for previous)">' + attention + ' need attention</span>'
: ' - <span class="attention-none">all reviewed</span>'
}
$('#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('<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>')
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)'
$('<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'
var badgeText = status === 'exact' ? 'matched' : status === 'fuzzy' ? 'check match' : 'no match'
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')
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>' +
'<div class="search-inline-wrap"' + (isUnmatched ? '' : ' style="display:none"') + '>' +
buildSearchUI(i) + '</div>'
// 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 = {}
$(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(
'<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)
})
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 = $('<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)
}
$(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()
$('#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('<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())
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 = $('<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) {
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('<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 || '')
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 = $('<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)
}
// -------------------------------------------------------
// 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;')
}