215 lines
7.9 KiB
HTML
215 lines
7.9 KiB
HTML
<!DOCTYPE html>
|
|
<html lang="en">
|
|
<head>
|
|
<meta charset="UTF-8">
|
|
<title>Pending Invoices</title>
|
|
<link rel="stylesheet" href="css/style.css">
|
|
<script src="js/jquery-3.5.1.min.js"></script>
|
|
</head>
|
|
<body>
|
|
|
|
<h1>
|
|
Pending Invoices
|
|
<a href="index.html">Home / Upload</a>
|
|
<a href="pending.html" class="active">Pending</a>
|
|
<a href="archive.html">Archive</a>
|
|
<a href="changes.html">Changes</a>
|
|
</h1>
|
|
|
|
<div id="content">
|
|
<div id="statusBar"></div>
|
|
<div id="emptyState">No pending invoices.</div>
|
|
<table id="pendingTable" class="list" style="display:none">
|
|
<thead>
|
|
<tr>
|
|
<th>Updated</th>
|
|
<th>Vendor</th>
|
|
<th>Invoice #</th>
|
|
<th>Date</th>
|
|
<th>PO #</th>
|
|
<th>Total</th>
|
|
<th>Lines</th>
|
|
<th>Status</th>
|
|
<th>Source</th>
|
|
<th></th>
|
|
</tr>
|
|
</thead>
|
|
<tbody id="pendingBody"></tbody>
|
|
</table>
|
|
</div>
|
|
|
|
<script>
|
|
$(document).ready(function () {
|
|
loadPending()
|
|
connectEvents()
|
|
})
|
|
|
|
// Live refresh whenever the background worker changes any item's stage.
|
|
function connectEvents() {
|
|
try {
|
|
var es = new EventSource('/api/events')
|
|
var refreshTimer = null
|
|
es.onmessage = function (e) {
|
|
var msg
|
|
try { msg = JSON.parse(e.data) } catch (err) { return }
|
|
if (msg.type !== 'stage') return
|
|
clearTimeout(refreshTimer)
|
|
refreshTimer = setTimeout(loadPending, 400) // coalesce bursts
|
|
}
|
|
} catch (e) { console.warn('SSE unavailable:', e) }
|
|
}
|
|
|
|
async function loadPending() {
|
|
try {
|
|
var resp = await fetch('/api/pending')
|
|
var list = await resp.json()
|
|
list.sort(function (a, b) { return String(b.updated_at || '').localeCompare(String(a.updated_at || '')) })
|
|
var body = $('#pendingBody')
|
|
|
|
if (!list.length) {
|
|
body.empty()
|
|
$('#pendingTable').hide()
|
|
$('#emptyState').show()
|
|
setStatus('', '')
|
|
return
|
|
}
|
|
|
|
var states = await Promise.all(list.map(function (rec) {
|
|
return fetch('/api/pending/' + encodeURIComponent(rec.id))
|
|
.then(function (r) { return r.json() })
|
|
.catch(function () { return { error: true } })
|
|
}))
|
|
body.empty()
|
|
list.forEach(function (rec, i) {
|
|
if (!states[i] || (states[i].error && !states[i].stage)) return
|
|
body.append(buildRow(rec, states[i]))
|
|
})
|
|
|
|
$('#emptyState').toggle(body.children().length === 0)
|
|
$('#pendingTable').toggle(body.children().length > 0)
|
|
var active = $('#pendingBody tr').filter(function () {
|
|
var s = $(this).data('stage'); return s === 'queued' || s === 'queue' || s === 'processing'
|
|
}).length
|
|
if (active) setStatus(active + ' invoice(s) queued/processing in the background - this list refreshes automatically. You can leave this page.', 'loading')
|
|
else setStatus('', '')
|
|
} catch (e) {
|
|
setStatus('Could not load pending invoices: ' + e.message, 'error')
|
|
}
|
|
}
|
|
|
|
function buildRow(rec, state) {
|
|
var h = state.header || {}
|
|
var src = state.source || {}
|
|
var vendor = h.vendor_name || h.vendor_hint || ''
|
|
if (vendor === '-- Select Vendor --') vendor = ''
|
|
var stage = state.stage || 'pending'
|
|
var stageLabel = stage.replace(/_/g, ' ')
|
|
if (stage === 'error' && state.error) stageLabel += ': ' + state.error
|
|
|
|
var resumeLabel = (stage === 'parsed') ? 'Review'
|
|
: (stage === 'queued' || stage === 'processing') ? 'Open'
|
|
: 'Resume'
|
|
var parseBtn = (stage === 'queued' || stage === 'error')
|
|
? '<button class="btn btn-process btn-parsenow" data-id="' + esc(rec.id) + '">Parse now</button>'
|
|
: ''
|
|
|
|
return '<tr data-id="' + esc(rec.id) + '" data-stage="' + esc(stage) + '">' +
|
|
'<td>' + esc(formatDate(rec.updated_at || state.updated_at)) + '</td>' +
|
|
'<td>' + esc(vendor || '-') + '</td>' +
|
|
'<td>' + esc(h.invoice_number || '-') + '</td>' +
|
|
'<td>' + esc(h.invoice_date || '-') + '</td>' +
|
|
'<td>' + esc(h.po_number || '-') + '</td>' +
|
|
'<td>' + esc(h.invoice_total ? '$' + parseFloat(h.invoice_total).toFixed(2) : '-') + '</td>' +
|
|
'<td>' + ((state.line_items || []).length) + '</td>' +
|
|
'<td><span class="stage stage-' + esc(stage) + '">' + esc(stageLabel) + '</span></td>' +
|
|
'<td class="muted">' + esc(src.file_name || ('pending_' + rec.id)) + '</td>' +
|
|
'<td style="white-space:nowrap">' +
|
|
'<a class="btn" href="index.html?pending=' + encodeURIComponent(rec.id) + '">' + resumeLabel + '</a>' +
|
|
parseBtn +
|
|
'<button class="btn btn-restore" data-id="' + esc(rec.id) + '">Restore Defaults</button>' +
|
|
'<button class="btn btn-delete" data-id="' + esc(rec.id) + '">Delete</button>' +
|
|
'</td>' +
|
|
'</tr>'
|
|
}
|
|
|
|
$(document).on('click', '.btn-parsenow', async function () {
|
|
var btn = $(this)
|
|
var id = btn.data('id')
|
|
btn.text('Queued...').prop('disabled', true)
|
|
try {
|
|
var resp = await fetch('/api/pending/' + encodeURIComponent(id) + '/parse', { method: 'POST' })
|
|
var result = await resp.json()
|
|
if (!result.success) throw new Error(result.error || 'parse failed')
|
|
setStatus('Parsing in the background - this list updates automatically.', 'loading')
|
|
} catch (e) {
|
|
setStatus('Parse failed: ' + e.message, 'error')
|
|
}
|
|
loadPending()
|
|
})
|
|
|
|
$(document).on('click', '.btn-delete', async function () {
|
|
var id = $(this).data('id')
|
|
if (!confirm('Delete this pending invoice?')) return
|
|
try {
|
|
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')
|
|
$('tr[data-id="' + id + '"]').remove()
|
|
$('#emptyState').toggle($('#pendingBody').children().length === 0)
|
|
$('#pendingTable').toggle($('#pendingBody').children().length > 0)
|
|
} catch (e) {
|
|
setStatus('Delete failed: ' + e.message, 'error')
|
|
}
|
|
})
|
|
|
|
$(document).on('click', '.btn-restore', async function () {
|
|
var id = $(this).data('id')
|
|
if (!confirm('Restore this pending invoice to its original parsed defaults?')) return
|
|
try {
|
|
var stateResp = await fetch('/api/pending/' + encodeURIComponent(id))
|
|
var state = await stateResp.json()
|
|
if (state.error && !state.stage) throw new Error(state.error)
|
|
if (!state.defaults || !state.defaults.header || !state.defaults.line_items) {
|
|
throw new Error('This draft does not have saved defaults.')
|
|
}
|
|
|
|
state.header = state.defaults.header
|
|
state.line_items = state.defaults.line_items
|
|
state.po_compare_data = state.defaults.po_compare_data || null
|
|
state.stage = 'review'
|
|
state.updated_at = new Date().toISOString()
|
|
|
|
var saveResp = await fetch('/api/pending/' + encodeURIComponent(id) + '/state', {
|
|
method: 'POST',
|
|
body: JSON.stringify(state),
|
|
headers: { 'Content-Type': 'application/json; charset=utf-8' }
|
|
})
|
|
var result = await saveResp.json()
|
|
if (!result.success) throw new Error(result.error || 'restore failed')
|
|
await loadPending()
|
|
} catch (e) {
|
|
setStatus('Restore failed: ' + e.message, 'error')
|
|
}
|
|
})
|
|
|
|
function setStatus(msg, type) {
|
|
var el = $('#statusBar')
|
|
el.text(msg).removeClass('status-loading status-error status-success')
|
|
if (msg) el.addClass('status-' + type).show()
|
|
else el.hide()
|
|
}
|
|
|
|
function formatDate(s) {
|
|
if (!s) return ''
|
|
var d = new Date(s)
|
|
if (!isNaN(d.getTime())) return d.toLocaleString()
|
|
return String(s).replace('T', ' ')
|
|
}
|
|
|
|
function esc(s) {
|
|
return String(s || '').replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"')
|
|
}
|
|
</script>
|
|
|
|
</body>
|
|
</html>
|