')
li.find('.ul-name').text(name).attr('title', name)
showUploadStage(li, 'uploading')
list.prepend(li)
list.children().slice(30).remove()
return li
}
function setUploadStage(id, stage, error) {
if (!id) return
uploadStages[id] = { stage: stage, error: error }
var li = $('#uploadList li[data-id="' + id + '"]')
if (li.length) showUploadStage(li, stage, error)
}
function showUploadStage(li, stage, error) {
li.find('.stage').attr('class', 'stage stage-' + stage).text(stage).attr('title', error || '')
var id = li.attr('data-id')
li.find('.ul-open').toggle(stage === 'parsed' && !!id)
.attr('href', id ? 'index.html?pending=' + encodeURIComponent(id) : null)
}
// -------------------------------------------------------
// Landing dashboard
// -------------------------------------------------------
async function loadDashboard() {
try {
var resp = await fetch('/api/stats')
var s = await resp.json()
if (s.error) { showDbError(s.error); return }
$('#statReady').text(s.ready)
$('#statQueued').text(s.queued)
$('#statProcessingSub').text(s.processing ? (s.processing + ' processing') : '')
$('#statProgress').text(s.in_progress)
$('#statError').text(s.error)
$('#statToday').text(s.uploaded_today)
$('#statArchived').text(s.archived)
$('#statTotal').text(s.total)
$('#statValue').text('$' + (parseFloat(s.pending_value) || 0).toFixed(2))
$('#statLines').text(s.pending_lines)
$('#statReady,#statQueued,#statError').each(function () {
$(this).closest('.stat-card').toggleClass('is-zero', $(this).text() === '0')
})
$('#dashUpdated').text('Updated ' + new Date().toLocaleTimeString())
} catch (e) {
console.warn('Dashboard stats failed:', e)
}
loadReviewNext()
}
// Points the main button straight at the oldest draft waiting for review.
async function loadReviewNext() {
try {
var ids = await fetchReviewableIds()
var btn = $('#btnReviewNext')
if (ids.length) {
btn.attr('href', 'index.html?pending=' + encodeURIComponent(ids[0]))
.removeClass('disabled').text('Review next invoice')
$('#reviewNextNote').text(ids.length + ' waiting for review')
} else {
btn.attr('href', 'pending.html').addClass('disabled').text('Open Pending')
$('#reviewNextNote').text('Nothing waiting for review')
}
} catch (e) {
console.warn('Review-next lookup failed:', e)
}
}
// -------------------------------------------------------
// Scan Invoice = re-parse this draft now (server-side)
// -------------------------------------------------------
async function handleScan() {
if (!pendingId) return
if ($('#lineItemsBody tr').length &&
!confirm('Re-scanning replaces the header and every line item with a fresh parse. Your edits on this draft will be lost. Continue?')) return
try {
setStatus('Queued for parsing...', 'loading')
var resp = await fetch('/api/pending/' + encodeURIComponent(pendingId) + '/parse', { method: 'POST' })
var result = await readJsonResponse(resp, 'parse')
if (!result.success) throw new Error(result.error || 'parse failed')
setStatus('Parsing in the background - this screen updates automatically when it finishes.', 'loading')
} catch (err) {
setStatus('Error: ' + err.message, 'error')
}
}
// -------------------------------------------------------
// Matching
// -------------------------------------------------------
async function callMatch(invoiceData) {
var fd = new URLSearchParams()
fd.append('vendor', invoiceData.vendor_name || '')
var items = invoiceData.line_items || []
fd.append('item_count', items.length)
items.forEach(function (item, i) {
fd.append('item_' + i + '_upc', item.upc || '')
fd.append('item_' + i + '_desc', item.description || '')
fd.append('item_' + i + '_code', item.product_code || '')
})
var resp = await fetch('/api/match', { method: 'POST', body: fd })
var result = await readJsonResponse(resp, 'match')
if (result.error) throw new Error(result.error)
return result
}
// -------------------------------------------------------
// PO dropdown + comparison
// -------------------------------------------------------
async function loadPOList() {
try {
var resp = await fetch('/api/polist')
var pos = await resp.json()
if (pos.error) { showDbError(pos.error); return }
var sel = $('#poSelect')
pos.forEach(function (p) {
var label = 'PO #' + p.po_number
if (p.vendor_name) label += ' - ' + p.vendor_name
if (p.po_date) label += ' (' + p.po_date + ')'
sel.append('')
})
} catch (e) {
console.warn('Could not load PO list:', e)
}
}
async function handlePOChange() {
var poNum = $('#poSelect').val()
poCompareData = null
clearPoComparison()
if (!poNum) return
try {
var resp = await fetch('/api/podetail?po=' + encodeURIComponent(poNum))
if (!resp.ok) return
var detail = await resp.json()
if (detail.error) { showDbError(detail.error); return }
poCompareData = detail
if ($('#reviewSection').is(':visible')) applyPoComparison()
queuePendingStateSave('editing')
} catch(e) {
console.warn('PO detail load failed:', e)
}
}
function normalizeUpc(u) {
return String(u).replace(/\D/g, '').replace(/^0+/, '')
}
// Database convention: 13 digits, zero padded, no separators.
function padUpc13(u) {
var v = String(u || '').replace(/\D/g, '')
return v.length > 0 ? v.padStart(13, '0') : ''
}
function clearPoComparison() {
$('#poCompareSummary').text('')
$('#lineItemsBody tr')
.removeClass('po-match po-diff po-inv-only')
.removeAttr('data-po-status')
.each(function () {
var row = $(this)
row.find('.po-cases,.po-pieces,.po-cost')
.text('-')
.removeClass('po-cell-diff')
.addClass('cmp-na')
row.find('.po-note').remove()
})
}
function applyPoComparison() {
var poNum = $('#poSelect').val()
if (!poNum || !poCompareData) { clearPoComparison(); return }
clearPoComparison()
var poPool = poCompareData.map(function(p) { return { used: false, item: p } })
var matchedCount = 0
var reviewCount = 0
$('#lineItemsBody tr').each(function() {
var row = $(this)
var inv = {
upc: row.find('.item-upc').val().trim(),
cert: row.find('.item-cert').val().trim(),
desc: row.find('.item-desc').val().trim(),
qty: parseFloat(row.find('.item-qty').val()) || 0,
cost: parseFloat(row.find('.item-cost').val()) || 0
}
var normInv = normalizeUpc(inv.upc)
var found = null
for (var j = 0; j < poPool.length; j++) {
if (poPool[j].used) continue
if (normInv && normalizeUpc(poPool[j].item.upc) === normInv) { found = poPool[j]; break }
}
if (!found && inv.cert) {
for (var j = 0; j < poPool.length; j++) {
if (poPool[j].used) continue
if (poPool[j].item.cert_code && poPool[j].item.cert_code.trim() === inv.cert) { found = poPool[j]; break }
}
}
if (found) {
found.used = true
matchedCount++
var po = found.item
var qDiff = Math.abs(inv.qty - (parseFloat(po.qty || 0))) > 0.005
var cDiff = Math.abs(inv.cost - (parseFloat(po.cost || 0))) > 0.005
row.find('.po-cases').text(formatPoNumber(po.qty)).removeClass('cmp-na')
row.find('.po-pieces').text(formatPoNumber(po.qty_pieces)).removeClass('cmp-na')
row.find('.po-cost').text(formatMoney(po.cost)).removeClass('cmp-na')
if (qDiff) row.find('.po-cases').addClass('po-cell-diff')
if (cDiff) row.find('.po-cost').addClass('po-cell-diff')
if (qDiff || cDiff) {
reviewCount++
row.addClass('po-diff').attr('data-po-status', 'review')
var note = qDiff && cDiff ? 'qty + cost differ from PO' : qDiff ? 'qty differs from PO' : 'cost differs from PO'
row.find('.match-badge').last().after('' + note + '')
} else {
row.addClass('po-match').attr('data-po-status', 'match')
}
} else {
reviewCount++
row.addClass('po-inv-only').attr('data-po-status', 'invoice-only')
row.find('.match-badge').last().after('not on PO')
}
})
var poOnlyCount = 0
poPool.forEach(function(p) { if (!p.used) poOnlyCount++ })
var summary = 'PO #' + poNum + ': ' + matchedCount + ' matched'
if (reviewCount) summary += ', ' + reviewCount + ' validate'
if (poOnlyCount) summary += ', ' + poOnlyCount + ' PO-only'
$('#poCompareSummary').text(summary)
refreshReviewSummary()
}
function formatPoNumber(v) {
if (v == null || v === '') return '-'
var n = parseFloat(v)
if (isNaN(n)) return String(v)
return n % 1 === 0 ? String(n) : n.toFixed(2)
}
function formatMoney(v) {
if (v == null || v === '') return '-'
var n = parseFloat(v || 0)
if (isNaN(n)) return String(v)
return '$' + n.toFixed(2)
}
// -------------------------------------------------------
// Vendor dropdown
// -------------------------------------------------------
async function loadVendors() {
try {
var resp = await fetch('/api/vendors')
var vendors = await resp.json()
if (vendors.error) throw new Error(vendors.error)
var sel = $('#vendorSelect')
vendors.forEach(function (v) {
sel.append('')
})
} catch (e) {
// Without the list the draft cannot be saved; do not fail silently.
showDbError('Vendor list failed to load: ' + e.message)
}
}
// -------------------------------------------------------
// Form population
// -------------------------------------------------------
function populateForm(data, matchData) {
$('#invoiceNumber').val(data.invoice_number || '')
$('#invoiceDate').val(data.invoice_date || '')
$('#invoiceTotal').val(data.total != null ? data.total : '')
totalLabel = data.total_label || ''
totalPage = data.total_page || ''
setHint('hintVendor', data.vendor_name)
setHint('hintInvoiceNumber', data.invoice_number)
setHint('hintInvoiceDate', data.invoice_date)
setHint('hintInvoiceTotal', data.total, totalHintSuffix())
var v = matchData.vendor || {}
if (v.id != null) {
$('#vendorSelect').val(v.id)
}
if (v.label) {
var badgeClass = v.label === 'exact' ? 'badge-exact' : v.label === 'fuzzy' ? 'badge-fuzzy' : 'badge-none'
var badgeText = v.label === 'exact' ? 'matched' : v.label === 'fuzzy' ? 'check vendor' : 'no vendor match'
$('#vendorBadge').text(badgeText).attr('class', 'match-badge ' + badgeClass).show()
}
var tbody = $('#lineItemsBody')
tbody.empty()
var items = data.line_items || []
items.forEach(function (item, i) {
var match = (matchData.items && matchData.items[i]) ? matchData.items[i] : {}
tbody.append(buildRow(i, item, match))
})
nextRowIndex = items.length
recalcAllMargins()
if ($('#poSelect').val() && poCompareData) applyPoComparison()
renderLineMarkers()
refreshReviewSummary()
checkInvoiceNumber()
pendingDefaultState = collectDraftSnapshot()
}
// -------------------------------------------------------
// Review sanity checks: header total vs line totals, rows that still need
// a decision, and what blocks Save. Cheap enough to run on every edit.
// -------------------------------------------------------
var totalLabel = ''
var totalPage = ''
function totalHintSuffix() {
var parts = []
if (totalLabel) parts.push(totalLabel)
if (totalPage) parts.push('page ' + totalPage)
return parts.length ? ' (' + parts.join(', ') + ')' : ''
}
function rowNeedsAttention(row) {
var s = row.attr('data-status')
if (s === 'unmatched' || s === 'fuzzy') return true
var po = row.attr('data-po-status')
return po === 'review' || po === 'invoice-only'
}
function refreshReviewSummary() {
var rows = $('#lineItemsBody tr')
var attention = 0, unmatched = 0
var lineSum = 0, anyTotal = false
rows.each(function () {
var row = $(this)
var needs = rowNeedsAttention(row)
row.toggleClass('needs-attention', needs)
if (needs) attention++
var s = row.attr('data-status')
if (s === 'unmatched' || s === 'fuzzy') unmatched++
else row.removeClass('row-invalid')
var t = numOrNull(row.find('.item-total').val())
if (t != null) { lineSum += t; anyTotal = true }
checkLineTotal(row)
})
var summary = rows.length + ' line' + (rows.length === 1 ? '' : 's')
if (rows.length) {
summary += attention
? ' - ' + attention + ' need attention'
: ' - all reviewed'
}
$('#lineSummary').html(summary)
// Informational only: the header total can legitimately include tax,
// deposits or freight that are not line items.
var check = $('#totalCheck')
var hdrTotal = numOrNull($('#invoiceTotal').val())
if (!rows.length || !anyTotal) check.html('')
else if (hdrTotal == null) check.html('lines sum $' + lineSum.toFixed(2))
else if (Math.abs(hdrTotal - lineSum) < 0.005) check.html('lines = total')
else check.html('' +
'lines $' + lineSum.toFixed(2) + ' vs total $' + hdrTotal.toFixed(2) +
' (diff $' + (hdrTotal - lineSum).toFixed(2) + ')')
var blockers = $('#saveBlockers')
if (unmatched) blockers.text(unmatched + ' row' + (unmatched === 1 ? '' : 's') + ' must be matched or accepted before saving').show()
else blockers.hide()
}
// Flags a line total that is not qty x case cost.
function checkLineTotal(row) {
var qty = numOrNull(row.find('.item-qty').val())
var cost = numOrNull(row.find('.item-cost').val())
var input = row.find('.item-total')
var total = numOrNull(input.val())
var off = qty != null && cost != null && total != null && Math.abs(qty * cost - total) > 0.01
input.toggleClass('total-mismatch', off)
.attr('title', off ? 'Qty x case cost = $' + (qty * cost).toFixed(2) : '')
}
// A blank total is filled from qty x cost and kept in step until edited by hand.
function autoFillTotal(row) {
var input = row.find('.item-total')
if (input.val() !== '' && !row.data('autoTotal')) return
var qty = numOrNull(row.find('.item-qty').val())
var cost = numOrNull(row.find('.item-cost').val())
if (qty == null || cost == null) return
input.val((qty * cost).toFixed(2))
row.data('autoTotal', true)
}
function checkInvoiceNumber() {
var len = ($('#invoiceNumber').val() || '').trim().length
$('#errInvoiceNumber').text('max 100 characters (' + len + ')').toggle(len > 100)
}
// Cycles through rows that still need a decision, from the last one visited.
var attentionCursor = null
function jumpToAttention(dir) {
var rows = $('#lineItemsBody tr.needs-attention')
if (!rows.length) return
var pos = -1
rows.each(function (n) { if ($(this).attr('data-index') === attentionCursor) pos = n })
pos = pos < 0 ? (dir > 0 ? 0 : rows.length - 1) : (pos + dir + rows.length) % rows.length
var row = rows.eq(pos)
attentionCursor = row.attr('data-index')
flashRow(row)
if (row.hasClass('has-source')) showRowOnPreview(row)
}
function flashRow(row) {
row[0].scrollIntoView({ behavior: 'smooth', block: 'center' })
row.removeClass('row-flash')
void row[0].offsetWidth // restart the flash animation
row.addClass('row-flash')
}
// -------------------------------------------------------
// Source-line markers on the preview (text PDFs only)
// -------------------------------------------------------
function renderLineMarkers() {
$('#canvasWrapper .line-marker').remove()
$('#lineItemsBody tr').removeClass('has-source').removeAttr('title')
if (!pdfLineMap || !pdfLineMap.length) return
$('#lineItemsBody tr').each(function () {
var row = $(this)
var n = parseInt(row.attr('data-source-line'), 10)
if (isNaN(n) || n < 1 || n > pdfLineMap.length) return
var line = pdfLineMap[n - 1]
var pageWrap = $('#canvasWrapper .page-wrap[data-page="' + line.page + '"]')
if (!pageWrap.length) return
var desc = row.find('.item-desc').val() || '(no description)'
$('
')
.css({ top: (line.top * 100) + '%', height: Math.max(line.h * 100, 0.3) + '%' })
.attr('data-row', row.attr('data-index'))
.attr('data-tip', 'Row ' + (row.index() + 1) + ': ' + desc)
.appendTo(pageWrap)
row.addClass('has-source').attr('title', 'Click to show this line on the invoice preview')
})
}
// Preview -> row
$(document).on('click', '.line-marker', function () {
var row = $('tr[data-index="' + $(this).attr('data-row') + '"]')
if (!row.length) return
$('#canvasWrapper .line-marker').removeClass('active')
$(this).addClass('active')
row[0].scrollIntoView({ behavior: 'smooth', block: 'center' })
row.removeClass('row-flash')
void row[0].offsetWidth // restart the flash animation
row.addClass('row-flash')
})
// Row -> preview. Clicks on the fields and buttons in the row are left alone.
$(document).on('click', '#lineItemsBody tr.has-source', function (e) {
if ($(e.target).closest('input,textarea,select,button,.cand-item,.search-results').length) return
showRowOnPreview($(this))
})
function showRowOnPreview(row) {
var idx = row.attr('data-index')
if (!$('#canvasWrapper .line-marker[data-row="' + idx + '"]').length) return
// Expanding rebuilds the markers, so look the marker up again afterwards.
if ($('#leftPanel').hasClass('collapsed')) setPreviewCollapsed(false)
var marker = $('#canvasWrapper .line-marker[data-row="' + idx + '"]')
if (!marker.length) return
$('#canvasWrapper .line-marker').removeClass('active')
marker.addClass('active')
var wrap = document.getElementById('canvasWrapper')
var wr = wrap.getBoundingClientRect()
var mr = marker[0].getBoundingClientRect()
var top = wrap.scrollTop + (mr.top - wr.top) - (wrap.clientHeight / 2) + (mr.height / 2)
wrap.scrollTo({ top: Math.max(0, top), behavior: 'smooth' })
}
function buildRow(i, item, match) {
var parsedUpc = item.upc || ''
var parsedDesc = item.description || ''
var parsedCode = item.product_code || ''
var parsedSize = item.size || ''
var parsedUom = item.unit_of_measure || 'EA'
var upc = match.upc || parsedUpc
var desc = match.description || parsedDesc
var cert = match.cert_code || parsedCode
var size = match.size || parsedSize
var uom = match.uom || parsedUom
var qty = item.quantity != null ? item.quantity : ''
var cost = item.unit_price != null ? item.unit_price : ''
var total = item.total != null ? item.total : ''
// Retail comes off the product file. Pack prefers the product file and
// falls back to the invoice's PACK/SIZE column when the product has none.
var parsedPack = (item.pack != null && item.pack !== '') ? item.pack : ''
var pack = match.pack != null ? match.pack : parsedPack
var retail = match.retail != null ? match.retail : ''
var parsedPackHint = (parsedPack !== '' && String(parsedPack) !== String(pack))
? 'inv: ' + escHtml(String(parsedPack)) + ''
: ''
var label = match.match_label || 'unmatched'
var badgeCls = label === 'exact' ? 'badge-exact'
: (label === 'fuzzy' || label === 'cert_code' || label === 'upc_norm' || label === 'upc+fuzzy') ? 'badge-fuzzy'
: 'badge-none'
var status = badgeCls === 'badge-exact' ? 'exact' : badgeCls === 'badge-fuzzy' ? 'fuzzy' : 'unmatched'
var badgeText = status === 'exact' ? 'matched' : status === 'fuzzy' ? 'check match' : 'no match'
var warnHtml = match.inactive ? 'INACTIVE' : ''
// The search box is available on every row, matched or not - the suggested
// product is not always the right one.
var isUnmatched = (label === 'unmatched')
var searchHtml = '
' +
'' +
'' +
'
' +
'
' +
buildSearchUI(i) + '
'
// Only a fuzzy row can be accepted: it already carries a real product.
// An unmatched row has nothing to accept and must be matched or created.
var confirmHtml = (status === 'fuzzy')
? ' '
: ''
var matchedDesc = match.description || ''
var toggleHtml = buildDescToggle(i, parsedDesc, matchedDesc, matchedDesc ? 'matched' : 'parsed')
var parsedCodeHint = parsedCode ? 'parsed: ' + escHtml(parsedCode) + '' : ''
var parsedUpcDiv = (parsedUpc && parsedUpc !== upc)
? '
' + escHtml(parsedUpc) + '
'
: ''
var cands = (match.candidates || []).filter(function (c) { return c.upc !== upc })
rowCandidates[i] = cands
var candHtml = ''
if (cands.length) {
candHtml = '
'
}
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 '
'
}
// -------------------------------------------------------
// 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 '
')
.data('product', r)
.on('click', function () { applyProduct(idx, $(this).data('product')); div.hide().empty() })
.appendTo(div)
})
div.children().first().addClass('active')
div.show()
positionSearchResults(inp, div)
} catch (e) { console.error(e) }
}, 300)
})
// Arrow keys move through the results, Enter picks, Escape closes.
$(document).on('keydown', '.product-search', function (e) {
var div = $('#search_' + $(this).data('item'))
if (e.key === 'Escape') {
if (div.is(':visible')) { div.hide().empty(); e.stopPropagation() }
return
}
var items = div.children('.search-result-item,.search-result-new')
if (!div.is(':visible') || !items.length) return
var cur = items.index(items.filter('.active'))
if (e.key === 'ArrowDown' || e.key === 'ArrowUp') {
e.preventDefault()
var n = e.key === 'ArrowDown' ? Math.min(items.length - 1, cur + 1) : Math.max(0, cur - 1)
items.removeClass('active')
items.eq(n).addClass('active')[0].scrollIntoView({ block: 'nearest' })
} else if (e.key === 'Enter') {
e.preventDefault()
items.eq(cur < 0 ? 0 : cur).trigger('click')
}
})
// The dropdown is position:fixed so the scrolling table cannot clip it,
// which means its coordinates have to be set against the input each time.
function positionSearchResults(inp, div) {
var r = inp[0].getBoundingClientRect()
var w = div.outerWidth()
var left = Math.max(6, Math.min(r.left, window.innerWidth - w - 10))
var top = r.bottom + 2
if (top + div.outerHeight() > window.innerHeight - 6) {
top = Math.max(6, r.top - div.outerHeight() - 2)
}
div.css({ left: left + 'px', top: top + 'px' })
}
$(document).on('click', '.search-toggle', function () {
$(this).hide()
var td = $(this).closest('td')
td.find('.search-inline-wrap').show()
td.find('.product-search').focus()
})
$(document).on('click', '.btn-search-cancel', function () {
var td = $(this).closest('td')
td.find('.search-results').hide().empty()
td.find('.product-search').val('')
td.find('.search-inline-wrap').hide()
td.find('.search-toggle').show()
})
$(document).on('click', '.cand-item', function () {
var i = $(this).data('item')
var c = (rowCandidates[i] || [])[$(this).data('cand')]
if (c) applyProduct(i, c)
})
function applyProduct(i, p) {
var row = $('tr[data-index="' + i + '"]')
row.attr('data-status', 'exact')
var matchedDesc = p.description || ''
var parsedDesc = row.attr('data-parsed-desc') || ''
row.attr('data-matched-desc', matchedDesc)
row.attr('data-matched-upc', p.upc || '')
row.attr('data-matched-cert', p.cert_code || '')
row.attr('data-matched-size', p.size || '')
row.attr('data-matched-uom', p.uom || 'EA')
row.find('.item-upc').val(p.upc || '')
row.find('.upc-matched').text(p.upc || '')
row.find('.upc-parsed-val').hide()
row.find('.item-desc').val(matchedDesc || parsedDesc)
row.find('.item-cert').val(p.cert_code || '')
row.find('.item-size').val(p.size || '')
row.find('.item-uom').val(p.uom || 'EA')
row.find('.item-pack').val(p.pack != null ? p.pack : '')
row.find('.retail-val').val(p.retail != null ? p.retail : '')
recalcMargin(row)
row.find('.match-badge').text('selected').attr('class', 'match-badge badge-exact')
row.find('.btn-confirm').remove()
row.find('.cand-list').remove()
row.find('.search-results').hide().empty()
row.find('.product-search').val('')
row.find('.search-inline-wrap').hide()
row.find('.search-toggle').show()
row.find('.desc-toggle-row').remove()
row.find('.item-desc').after(buildDescToggle(i, parsedDesc, matchedDesc, matchedDesc ? 'matched' : 'parsed'))
if ($('#poSelect').val() && poCompareData) applyPoComparison()
refreshReviewSummary()
queuePendingStateSave('editing')
}
function acceptRow(row, skipSave) {
row.attr('data-status', 'confirmed')
row.find('.match-badge').first().text('accepted').attr('class', 'match-badge badge-exact')
row.find('.btn-confirm').remove()
row.find('.cand-list').remove()
if (!skipSave) { refreshReviewSummary(); queuePendingStateSave('editing') }
}
function acceptAllRows() {
var undo = []
$('#lineItemsBody tr').each(function () {
var row = $(this)
var status = row.attr('data-status')
if (status === 'unmatched' || status === 'confirmed') return
var badge = row.find('.match-badge').first()
undo.push({
row: row, status: status, text: badge.text(), cls: badge.attr('class'),
// Detached, not removed, so Undo can put them back.
confirm: row.find('.btn-confirm').detach(),
cands: row.find('.cand-list').detach()
})
acceptRow(row, true)
})
refreshReviewSummary()
queuePendingStateSave('editing')
if (!undo.length) return
showUndo('Accepted ' + undo.length + ' row' + (undo.length === 1 ? '' : 's') + '.', function () {
undo.forEach(function (u) {
u.row.attr('data-status', u.status)
u.row.find('.match-badge').first().text(u.text).attr('class', u.cls)
u.row.find('.match-actions').before(u.confirm).before(u.cands)
})
refreshReviewSummary()
queuePendingStateSave('editing')
})
}
$(document).on('click', '.btn-confirm', function () {
var idx = $(this).data('index')
acceptRow($('tr[data-index="' + idx + '"]'))
})
$(document).on('click', '.btn-remove-row', function () {
var row = $('tr[data-index="' + $(this).data('index') + '"]')
var pos = row.index()
row.detach()
afterRowsChanged()
showUndo('Row ' + (pos + 1) + ' removed.', function () {
var body = $('#lineItemsBody')
var at = body.children().eq(pos)
if (at.length) at.before(row)
else body.append(row)
afterRowsChanged()
})
})
function afterRowsChanged() {
if ($('#poSelect').val() && poCompareData) applyPoComparison()
renderLineMarkers()
refreshReviewSummary()
queuePendingStateSave('editing')
}
var toastTimer = null
function showUndo(msg, undo) {
var t = $('#toast')
if (!t.length) t = $('
').appendTo('body')
t.find('.toast-msg').text(msg)
t.find('button').off('click').on('click', function () {
clearTimeout(toastTimer)
t.hide()
undo()
})
t.css('display', 'flex')
clearTimeout(toastTimer)
toastTimer = setTimeout(function () { t.hide() }, 8000)
}
$(document).on('click', '.btn-desc-src', function () {
var btn = $(this)
var i = btn.data('item')
var source = btn.data('source')
var row = $('tr[data-index="' + i + '"]')
var desc = source === 'parsed' ? row.attr('data-parsed-desc') : row.attr('data-matched-desc')
if (!desc) return // never blank the box by switching to a side that has nothing
row.find('.item-desc').val(desc)
row.find('.desc-toggle-row .btn-desc-src').removeClass('active')
btn.addClass('active')
queuePendingStateSave('editing')
})
$(document).on('click', '.search-result-new', function () {
var i = $(this).data('item')
$(this).closest('.search-results').hide().empty()
openNewProductModal(i)
})
$(document).on('click', '.btn-new-item', function () {
openNewProductModal($(this).data('item'))
})
// -------------------------------------------------------
// Parsed vs matched - always reachable from the title bar so you can
// check what came off the invoice against the product it was matched to.
// -------------------------------------------------------
function openParsedModal() {
renderParsedComparison()
$('#parsedModal').show()
}
function closeParsedModal() {
$('#parsedModal').hide()
}
function renderParsedComparison() {
var diffOnly = $('#chkDiffOnly').is(':checked')
$('#compareHeaderBody').html(
headerCompareRow('Vendor', stripHint($('#hintVendor').text()), $('#vendorSelect option:selected').text()) +
headerCompareRow('Invoice #', stripHint($('#hintInvoiceNumber').text()), $('#invoiceNumber').val()) +
headerCompareRow('Invoice date', stripHint($('#hintInvoiceDate').text()), $('#invoiceDate').val()) +
headerCompareRow('Total', stripHint($('#hintInvoiceTotal').text()).replace(/\s*\(.*\)$/, ''), $('#invoiceTotal').val())
)
var html = ''
var shown = 0
$('#lineItemsBody tr').each(function (n) {
var row = $(this)
var p = {
desc: row.attr('data-parsed-desc') || '',
upc: row.attr('data-parsed-upc') || '',
cert: row.attr('data-parsed-cert') || '',
size: row.attr('data-parsed-size') || '',
uom: row.attr('data-parsed-uom') || ''
}
var m = {
desc: row.attr('data-matched-desc') || '',
upc: row.attr('data-matched-upc') || '',
cert: row.attr('data-matched-cert') || '',
size: row.attr('data-matched-size') || '',
uom: row.attr('data-matched-uom') || ''
}
var diff = {
desc: !sameText(p.desc, m.desc),
upc: normalizeUpc(p.upc) !== normalizeUpc(m.upc),
cert: !sameText(p.cert, m.cert),
size: !sameText(p.size, m.size),
uom: !sameText(p.uom, m.uom)
}
var anyDiff = diff.desc || diff.upc || diff.cert || diff.size || diff.uom
if (diffOnly && !anyDiff) return
shown++
var badge = row.find('.match-badge').first()
html += '
' +
'
' + (n + 1) + '
' +
'
' + compareCell(p, {}) + '
' +
'
' + compareCell(m, diff) + '
' +
'
' +
escHtml(badge.text() || 'unmatched') + '
' +
'
'
})
if (!shown) {
html = '
' +
(diffOnly ? 'Every line matched the product file exactly.' : 'No line items on this draft.') +
'
'
}
$('#compareBody').html(html)
}
function headerCompareRow(label, parsed, current) {
var diff = !sameText(parsed, current)
return '
' + escHtml(label) + '
' +
'
' + cmpVal(parsed, false) + '
' +
'
' + cmpVal(current, diff && parsed !== '') + '
'
}
// Meta line under the description: only the pieces that exist.
function compareCell(v, diff) {
var meta = []
if (v.upc) meta.push(span(v.upc, diff.upc))
if (v.cert) meta.push('cert ' + span(v.cert, diff.cert))
if (v.size) meta.push(span(v.size, diff.size))
if (v.uom) meta.push(span(v.uom, diff.uom))
return '