2096 lines
86 KiB
JavaScript
2096 lines
86 KiB
JavaScript
// Invoice Parser desktop UI - port of invoice.js from the ASP web app.
|
||
// All parsing/prep now happens server-side; this page uploads, reviews and saves.
|
||
|
||
var poCompareData = null
|
||
var pendingId = null
|
||
var pendingCreated = null
|
||
var pendingFileInfo = null
|
||
var pendingSaveTimer = null
|
||
var pendingDefaultState = null
|
||
var lastLlmResult = null
|
||
var rowCandidates = {}
|
||
var pdfLineMap = null // [{page, top, h}] per OCR text line; fractions of page height
|
||
var isDashboard = false
|
||
var nextRowIndex = 0 // data-index is a row key, not a position - manual rows get fresh ones
|
||
var saveDirty = false // an edit is waiting on the autosave debounce
|
||
var currentDraftId = null
|
||
var draftNav = { prev: null, next: null }
|
||
var dashRefreshTimer = null
|
||
|
||
$(document).ready(function () {
|
||
var resumeID = getQueryParam('pending')
|
||
isDashboard = !resumeID
|
||
|
||
if (resumeID) {
|
||
$('body').addClass('review-page')
|
||
$('#layout').css('display', 'flex')
|
||
$('#pageMode').text('- Review')
|
||
initPreviewSplit()
|
||
initPreviewZoom()
|
||
currentDraftId = resumeID
|
||
var boot = [loadVendors(), loadDepartments(), loadPOList()]
|
||
Promise.all(boot).then(function () { resumePending(resumeID) })
|
||
loadDraftNav()
|
||
} else {
|
||
$('#dashboard').show()
|
||
$('#navHome').addClass('active')
|
||
$('#uploadZone').on('keydown', function (e) {
|
||
if (e.key !== 'Enter' && e.key !== ' ') return
|
||
e.preventDefault()
|
||
$('#fileInput').click()
|
||
})
|
||
fetch('/api/me').then(function (r) { return r.json() }).then(function (me) {
|
||
if (me.first_name) $('#dashWelcome').text('Welcome, ' + me.first_name).show()
|
||
}).catch(function () {})
|
||
loadDashboard()
|
||
setInterval(loadDashboard, 15000)
|
||
}
|
||
connectEvents()
|
||
|
||
$('#fileInput').on('change', handleFilesSelected)
|
||
$('#poSelect').on('change', handlePOChange)
|
||
$('#btnScan').on('click', handleScan)
|
||
$('#btnSave').on('click', handleSave)
|
||
$('#btnDeleteDraft').on('click', handleDeleteDraft)
|
||
$('#btnRestoreDefaults').on('click', handleRestoreDefaults)
|
||
$('#btnAcceptAll').on('click', acceptAllRows)
|
||
$('#btnAddLine').on('click', addBlankRow)
|
||
$('#btnCreateProduct').on('click', handleCreateProduct)
|
||
$('#btnCloseModal').on('click', closeNewProductModal)
|
||
$('#btnViewParsed').on('click', openParsedModal)
|
||
$('#btnCloseParsed').on('click', closeParsedModal)
|
||
$('#parsedModal').on('click', function (e) { if (e.target === this) closeParsedModal() })
|
||
$(document).on('click', '.parsed-tab', function () {
|
||
$('.parsed-tab').removeClass('active')
|
||
$(this).addClass('active')
|
||
$('.parsed-pane').hide()
|
||
$('#' + $(this).data('pane')).css('display', 'flex')
|
||
})
|
||
$(document).on('keydown', function (e) {
|
||
if (e.key === 'Escape') {
|
||
closeNewProductModal()
|
||
closeParsedModal()
|
||
return
|
||
}
|
||
if (isDashboard || $('#newProductModal').is(':visible')) return
|
||
if (e.ctrlKey && (e.key === 's' || e.key === 'S')) {
|
||
e.preventDefault()
|
||
if (!$('#btnSave').prop('disabled')) handleSave()
|
||
} else if (e.key === 'F8') {
|
||
e.preventDefault()
|
||
jumpToAttention(e.shiftKey ? -1 : 1)
|
||
}
|
||
})
|
||
$(document).on('input change', '.item-upc,.item-cert,.item-qty,.item-cost', function () {
|
||
if ($('#poSelect').val() && poCompareData) applyPoComparison()
|
||
})
|
||
$(document).on('input change', '.item-qty,.item-cost,.item-total', function () {
|
||
var row = $(this).closest('tr')
|
||
if ($(this).hasClass('item-total')) row.removeData('autoTotal')
|
||
else autoFillTotal(row)
|
||
refreshReviewSummary()
|
||
})
|
||
$(document).on('input change', '#invoiceTotal', refreshReviewSummary)
|
||
$(document).on('input', '#invoiceNumber', checkInvoiceNumber)
|
||
$(document).on('input change', '.field-invalid', function () { $(this).removeClass('field-invalid') })
|
||
$(document).on('click', '.attention-count', function () { jumpToAttention(1) })
|
||
$('#chkAttentionOnly').on('change', function () {
|
||
$('#lineItemsTable').toggleClass('attention-only', this.checked)
|
||
})
|
||
$('#btnPrevDraft').on('click', function () { if (draftNav.prev) leaveTo('index.html?pending=' + encodeURIComponent(draftNav.prev)) })
|
||
$('#btnNextDraft').on('click', function () { if (draftNav.next) leaveTo('index.html?pending=' + encodeURIComponent(draftNav.next)) })
|
||
// Leaving mid-debounce would drop the last edit; save it first.
|
||
$(document).on('click', 'a[href]', function (e) {
|
||
if (!saveDirty || !pendingId) return
|
||
e.preventDefault()
|
||
leaveTo(this.href)
|
||
})
|
||
window.addEventListener('beforeunload', function (e) {
|
||
if (!saveDirty || !pendingId) return
|
||
// keepalive requests are capped at 64KB; past that, ask before leaving.
|
||
var body = JSON.stringify(collectPendingState('editing'))
|
||
if (new Blob([body]).size < 60000) {
|
||
fetch('/api/pending/' + encodeURIComponent(pendingId) + '/state', {
|
||
method: 'POST', body: body, keepalive: true,
|
||
headers: { 'Content-Type': 'application/json; charset=utf-8' }
|
||
})
|
||
return
|
||
}
|
||
e.preventDefault()
|
||
e.returnValue = ''
|
||
})
|
||
$(document).on('mousedown', function (e) {
|
||
if ($(e.target).closest('.search-results,.product-search').length) return
|
||
$('.search-results:visible').hide()
|
||
})
|
||
$(document).on('keydown', '#newProductModal input, #newProductModal select', function (e) {
|
||
if (e.key !== 'Enter') return
|
||
e.preventDefault()
|
||
handleCreateProduct()
|
||
})
|
||
$(document).on('input change', '#reviewSection input,#reviewSection textarea,#reviewSection select,#ocrText', function () {
|
||
queuePendingStateSave('editing')
|
||
})
|
||
// scroll does not bubble, so the capture phase is the only way to catch
|
||
// the table scroller and drop the fixed-position search dropdown with it.
|
||
// Scrolling inside the dropdown itself must not close it.
|
||
document.addEventListener('scroll', function (e) {
|
||
if (e.target && e.target.nodeType === 1 && e.target.closest('.search-results')) return
|
||
$('.search-results:visible').hide()
|
||
}, true)
|
||
$(document).on('blur', '#np_upc', function () {
|
||
var v = padUpc13($(this).val())
|
||
if (v) $(this).val(v)
|
||
})
|
||
|
||
$('#uploadZone').on('dragover', function (e) {
|
||
e.preventDefault()
|
||
$(this).addClass('drag-over')
|
||
}).on('dragleave drop', function (e) {
|
||
e.preventDefault()
|
||
$(this).removeClass('drag-over')
|
||
if (e.type === 'drop') {
|
||
queueFiles(e.originalEvent.dataTransfer.files)
|
||
}
|
||
})
|
||
})
|
||
|
||
// -------------------------------------------------------
|
||
// Preview / form split - drag the divider or hide the preview
|
||
// entirely so the line-item grid gets the whole window.
|
||
// -------------------------------------------------------
|
||
function initPreviewSplit() {
|
||
var saved = null
|
||
try { saved = localStorage.getItem('ip_split_pct') } catch (e) {}
|
||
if (saved) $('#leftPanel').css('flex', '0 0 ' + saved + '%')
|
||
|
||
var collapsed = false
|
||
try { collapsed = localStorage.getItem('ip_preview_hidden') === '1' } catch (e) {}
|
||
setPreviewCollapsed(collapsed)
|
||
|
||
$('#btnTogglePreview').on('click', function () {
|
||
setPreviewCollapsed(!$('#leftPanel').hasClass('collapsed'))
|
||
})
|
||
|
||
var dragging = false
|
||
$('#splitter').on('mousedown', function (e) {
|
||
dragging = true
|
||
$(this).addClass('dragging')
|
||
$('body').css('user-select', 'none')
|
||
e.preventDefault()
|
||
})
|
||
$(document).on('mousemove', function (e) {
|
||
if (!dragging) return
|
||
var rect = document.getElementById('layout').getBoundingClientRect()
|
||
var pct = ((e.clientX - rect.left) / rect.width) * 100
|
||
pct = Math.max(15, Math.min(70, pct))
|
||
$('#leftPanel').css('flex', '0 0 ' + pct.toFixed(1) + '%')
|
||
try { localStorage.setItem('ip_split_pct', pct.toFixed(1)) } catch (err) {}
|
||
}).on('mouseup', function () {
|
||
if (!dragging) return
|
||
dragging = false
|
||
$('#splitter').removeClass('dragging')
|
||
$('body').css('user-select', '')
|
||
applyPreviewZoom()
|
||
renderLineMarkers()
|
||
})
|
||
}
|
||
|
||
// -------------------------------------------------------
|
||
// Preview zoom. 1 = fit the page to the panel width; the page
|
||
// wrapper is sized in px off the panel, so markers stay in place.
|
||
// -------------------------------------------------------
|
||
var previewZoom = 1
|
||
|
||
function initPreviewZoom() {
|
||
var saved = null
|
||
try { saved = parseFloat(localStorage.getItem('ip_preview_zoom')) } catch (e) {}
|
||
if (saved && saved > 0) previewZoom = Math.max(0.25, Math.min(5, saved))
|
||
|
||
$('#btnZoomIn').on('click', function () { setPreviewZoom(previewZoom * 1.25) })
|
||
$('#btnZoomOut').on('click', function () { setPreviewZoom(previewZoom / 1.25) })
|
||
$('#btnZoomFit').on('click', function () { setPreviewZoom(1) })
|
||
|
||
$('#canvasWrapper').on('wheel', function (e) {
|
||
var ev = e.originalEvent
|
||
if (!ev.ctrlKey) return
|
||
e.preventDefault()
|
||
setPreviewZoom(previewZoom * (ev.deltaY < 0 ? 1.1 : 1 / 1.1))
|
||
})
|
||
|
||
$(window).on('resize', applyPreviewZoom)
|
||
applyPreviewZoom()
|
||
}
|
||
|
||
function setPreviewZoom(z) {
|
||
previewZoom = Math.max(0.25, Math.min(5, z))
|
||
try { localStorage.setItem('ip_preview_zoom', previewZoom) } catch (e) {}
|
||
applyPreviewZoom()
|
||
}
|
||
|
||
function applyPreviewZoom() {
|
||
var wrap = document.getElementById('canvasWrapper')
|
||
$('#zoomLevel').text(Math.round(previewZoom * 100) + '%')
|
||
if (!wrap) return
|
||
// clientWidth already excludes the scrollbar; subtract the 8px padding pair.
|
||
var avail = wrap.clientWidth - 16
|
||
if (avail <= 0) return
|
||
$('#canvasWrapper .page-wrap').css('width', Math.round(avail * previewZoom) + 'px')
|
||
}
|
||
|
||
function setPreviewCollapsed(collapsed) {
|
||
$('#leftPanel').toggleClass('collapsed', !!collapsed)
|
||
$('#btnTogglePreview').html(collapsed ? '» Show preview' : '« Hide preview')
|
||
try { localStorage.setItem('ip_preview_hidden', collapsed ? '1' : '0') } catch (e) {}
|
||
if (!collapsed) { applyPreviewZoom(); renderLineMarkers() }
|
||
}
|
||
|
||
// -------------------------------------------------------
|
||
// Live updates from the background worker (SSE)
|
||
// -------------------------------------------------------
|
||
function connectEvents() {
|
||
try {
|
||
var es = new EventSource('/api/events')
|
||
es.onmessage = function (e) {
|
||
var msg
|
||
try { msg = JSON.parse(e.data) } catch (err) { return }
|
||
if (msg.type !== 'stage') return
|
||
if (isDashboard) {
|
||
setUploadStage(msg.id, msg.stage, msg.error)
|
||
clearTimeout(dashRefreshTimer)
|
||
dashRefreshTimer = setTimeout(loadDashboard, 400) // coalesce bursts
|
||
return
|
||
}
|
||
loadDraftNav()
|
||
if (pendingId && msg.id === pendingId) {
|
||
if (msg.stage === 'parsed') {
|
||
resumePending(pendingId)
|
||
} else if (msg.stage === 'error') {
|
||
setStatus('Background parsing failed: ' + (msg.error || 'unknown error') + '. Use Scan Invoice to retry.', 'error')
|
||
} else if (msg.stage === 'processing') {
|
||
setStatus('Parsing in the background...', 'loading')
|
||
}
|
||
}
|
||
}
|
||
es.onerror = function () { /* browser retries automatically */ }
|
||
} catch (e) {
|
||
console.warn('SSE unavailable:', e)
|
||
}
|
||
}
|
||
|
||
// -------------------------------------------------------
|
||
// Batch upload: the server stores the file, preps it and
|
||
// queues it for background parsing in one round trip.
|
||
// -------------------------------------------------------
|
||
async function handleFilesSelected(e) {
|
||
await queueFiles(e.target.files)
|
||
}
|
||
|
||
async function queueFiles(fileList) {
|
||
var all = Array.prototype.slice.call(fileList || [])
|
||
if (!all.length) return
|
||
|
||
// The accept attribute does not apply to drag-and-drop.
|
||
var rejected = []
|
||
var files = all.filter(function (f) {
|
||
var ok = /\.(pdf|jpe?g|png)$/i.test(f.name)
|
||
if (!ok) rejected.push(f.name)
|
||
return ok
|
||
})
|
||
if (!files.length) {
|
||
setStatus('Not uploaded - only PDF, JPG or PNG files are supported: ' + rejected.join(', '), 'error')
|
||
$('#fileInput').val('')
|
||
return
|
||
}
|
||
|
||
setStatus('Uploading ' + files.length + ' file(s)...', 'loading')
|
||
var fd = new FormData()
|
||
files.forEach(function (f) { fd.append('files', f, f.name) })
|
||
var items = files.map(function (f) { return addUploadItem(f.name) })
|
||
|
||
try {
|
||
var resp = await fetch('/api/upload', { method: 'POST', body: fd })
|
||
var result = await readJsonResponse(resp, 'upload')
|
||
var ok = 0, fail = 0, failMsgs = []
|
||
// Results come back in the order the files were sent.
|
||
;(result.results || []).forEach(function (r, i) {
|
||
if (r.ok) ok++
|
||
else { fail++; failMsgs.push((r.file || 'file') + ': ' + (r.error || 'failed')) }
|
||
if (!items[i]) return
|
||
items[i].attr('data-id', r.id)
|
||
var seen = uploadStages[r.id]
|
||
if (seen) setUploadStage(r.id, seen.stage, seen.error)
|
||
else setUploadStage(r.id, r.ok ? 'queued' : 'error', r.error)
|
||
})
|
||
var msg = 'Queued ' + ok + ' invoice(s)' + (fail ? (', ' + fail + ' failed (' + failMsgs.join('; ') + ')') : '') +
|
||
'. They parse automatically - check Pending.'
|
||
if (rejected.length) msg += ' Skipped (unsupported type): ' + rejected.join(', ')
|
||
setStatus(msg, (fail || rejected.length) ? 'error' : 'success')
|
||
} catch (err) {
|
||
setStatus('Upload failed: ' + err.message, 'error')
|
||
items.forEach(function (li) { showUploadStage(li, 'error', err.message) })
|
||
}
|
||
$('#fileInput').val('')
|
||
if (isDashboard) loadDashboard()
|
||
}
|
||
|
||
// id -> latest {stage, error} from SSE. Events can arrive before the upload
|
||
// response has told us which row an id belongs to.
|
||
var uploadStages = {}
|
||
|
||
function addUploadItem(name) {
|
||
var list = $('#uploadList').show()
|
||
var li = $('<li><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">×</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(' · ') + '</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, '&')
|
||
.replace(/</g, '<')
|
||
.replace(/>/g, '>')
|
||
.replace(/"/g, '"')
|
||
}
|