InvoiceParser/wwwroot/archive.html

440 lines
18 KiB
HTML
Raw Normal View History

2026-09-16 20:09:19 +00:00
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>Invoice Archive</title>
<link rel="stylesheet" href="css/style.css">
<script src="js/jquery-3.5.1.min.js"></script>
</head>
<body class="archive-page">
<h1>
Invoice Archive
2026-09-30 18:50:04 +00:00
<a href="index.html">Home / Upload</a>
2026-09-16 20:09:19 +00:00
<a href="pending.html">Pending</a>
2026-09-30 18:50:04 +00:00
<a href="archive.html" class="active">Archive</a>
<a href="changes.html">Changes</a>
2026-09-16 20:09:19 +00:00
</h1>
<div id="filterPanel">
<form id="filterForm" onsubmit="return false">
<div class="filter-field">
<label>Vendor</label>
<select name="vendor" id="fVendor" style="width:210px">
<option value="">All Vendors</option>
</select>
</div>
<div class="filter-field">
<label>Invoice #</label>
<input name="inv" id="fInv" style="width:115px">
</div>
<div class="filter-field">
<label>PO #</label>
<input name="po" id="fPo" style="width:115px">
</div>
<div class="filter-field">
<label>From</label>
<input type="date" name="date_from" id="fDateFrom">
</div>
<div class="filter-field">
<label>To</label>
<input type="date" name="date_to" id="fDateTo">
</div>
<div class="filter-field">
<label>Min $</label>
2026-09-30 18:50:04 +00:00
<input name="amt_min" id="fAmtMin" type="number" step="0.01" min="0" style="width:80px">
2026-09-16 20:09:19 +00:00
</div>
<div class="filter-field">
<label>Max $</label>
2026-09-30 18:50:04 +00:00
<input name="amt_max" id="fAmtMax" type="number" step="0.01" min="0" style="width:80px">
2026-09-16 20:09:19 +00:00
</div>
<div class="filter-field">
<label>UPC / Cert / Product</label>
<input name="product" id="fProduct" style="width:180px">
</div>
<div class="filter-actions">
<button class="filter-btn primary" id="btnFilter" type="submit">Filter</button>
<button class="filter-btn" id="btnReset" type="button">Reset</button>
2026-09-30 18:50:04 +00:00
<span id="resultCount" class="result-count"></span>
2026-09-16 20:09:19 +00:00
</div>
</form>
</div>
2026-09-30 18:50:04 +00:00
<div id="listPanel" title="Drag the bottom edge to resize">
2026-09-16 20:09:19 +00:00
<table>
<thead>
<tr>
2026-09-30 18:50:04 +00:00
<th data-sort="invoice_date" class="sortable">Date</th>
<th data-sort="vendor_name" class="sortable">Vendor</th>
<th data-sort="invoice_number" class="sortable">Invoice #</th>
<th data-sort="vendor_total" class="sortable" style="text-align:right">Total</th>
2026-09-16 20:09:19 +00:00
</tr>
</thead>
<tbody id="archiveBody"></tbody>
</table>
</div>
<div id="detailLayout">
<div id="archiveLeft">
2026-09-30 18:50:04 +00:00
<div id="archiveToolbar" style="display:none">
<button id="btnZoomOut" type="button" title="Zoom out">&minus;</button>
<span id="zoomLevel">100%</span>
<button id="btnZoomIn" type="button" title="Zoom in">+</button>
<button id="btnZoomFit" type="button" title="Fit the page to the panel width">Fit</button>
<span class="field-note">Ctrl + scroll to zoom</span>
<a id="btnOpenOriginal" class="btn" target="_blank" style="display:none" title="Open the archived file in its own window">Open original</a>
</div>
2026-09-16 20:09:19 +00:00
<div id="pdfPlaceholder">Select an invoice above to preview.</div>
</div>
<div id="archiveRight">
<div id="detailContent" style="display:none">
<div class="detail-top">
<div class="info-card">
<h2>Invoice Header</h2>
<div class="info-row"><span class="info-label">Vendor</span><span class="info-value" id="dVendor"></span></div>
<div class="info-row"><span class="info-label">Invoice #</span><span class="info-value" id="dInvNum"></span></div>
<div class="info-row"><span class="info-label">Date</span><span class="info-value" id="dDate"></span></div>
<div class="info-row"><span class="info-label">PO #</span><span class="info-value" id="dPO"></span></div>
<div class="info-row"><span class="info-label">Total</span><span class="info-value" id="dTotal"></span></div>
</div>
<div class="info-card" id="batchCard">
<h2>Cost Batch <span id="bFlagged" class="batch-flag" style="display:none"></span></h2>
<div id="batchNone" class="batch-none">No cost changes - this invoice created no batch.</div>
<div id="batchBody" style="display:none">
<div class="info-row"><span class="info-label">Batch #</span><span class="info-value" id="bNo"></span></div>
<div class="info-row"><span class="info-label">Created</span><span class="info-value" id="bCreated"></span></div>
<div class="info-row"><span class="info-label">Items</span><span class="info-value" id="bCount"></span></div>
<div class="batch-scroll">
<table id="batchTable">
<thead>
<tr>
<th>UPC</th>
<th>Description</th>
<th title="Unit cost this batch sets">New</th>
<th title="Unit cost on the product file right now - equal to New once the batch is applied">Now</th>
<th>Retail</th>
<th title="Gross margin at the new cost">GM %</th>
</tr>
</thead>
<tbody id="batchBodyRows"></tbody>
</table>
</div>
</div>
</div>
</div>
<div class="info-card">
<h2>Line Items</h2>
<div style="overflow-x:auto">
<table id="itemsTable">
<thead>
<tr>
<th>UPC</th>
<th>Description</th>
<th>Cert</th>
<th>Qty</th>
<th>Case Cost</th>
<th>Total</th>
<th>Size</th>
<th>UOM</th>
</tr>
</thead>
<tbody id="itemsBody"></tbody>
</table>
</div>
</div>
</div>
<div id="noSelection" style="color:#777;padding:40px 20px;text-align:center">Select an invoice above.</div>
</div>
</div>
<script>
2026-09-30 18:50:04 +00:00
var archiveList = []
var sortKey = 'invoice_date', sortDir = -1 // server order: newest first
var ARCHIVE_LIMIT = 500 // TOP 500 in DataService.ArchiveList
2026-09-16 20:09:19 +00:00
$(document).ready(function () {
loadVendors()
loadList()
2026-09-30 18:50:04 +00:00
initListResize()
initZoom()
2026-09-16 20:09:19 +00:00
$('#btnFilter').on('click', loadList)
$('#btnReset').on('click', function () {
$('#filterForm')[0].reset()
loadList()
})
2026-09-30 18:50:04 +00:00
$('th.sortable').on('click', function () {
var key = $(this).data('sort')
sortDir = key === sortKey ? -sortDir : (key === 'invoice_date' || key === 'vendor_total' ? -1 : 1)
sortKey = key
renderList()
})
// Up/down walks the list while focus is not in a filter field.
$(document).on('keydown', function (e) {
if (e.key !== 'ArrowDown' && e.key !== 'ArrowUp') return
if ($(e.target).closest('input,select,textarea').length) return
var rows = $('.inv-row')
if (!rows.length) return
e.preventDefault()
var cur = rows.index(rows.filter('.selected'))
var n = cur < 0 ? 0 : Math.max(0, Math.min(rows.length - 1, cur + (e.key === 'ArrowDown' ? 1 : -1)))
if (n === cur) return
rows.eq(n).trigger('click')[0].scrollIntoView({ block: 'nearest' })
})
2026-09-16 20:09:19 +00:00
})
2026-09-30 18:50:04 +00:00
// The list panel is resizable from its bottom edge; the height is remembered.
function initListResize() {
var panel = document.getElementById('listPanel')
try {
var h = parseInt(localStorage.getItem('ip_archive_list_h'), 10)
if (h > 0) panel.style.height = h + 'px'
} catch (e) {}
if (!window.ResizeObserver) return
new ResizeObserver(function () {
try { localStorage.setItem('ip_archive_list_h', panel.offsetHeight) } catch (e) {}
}).observe(panel)
}
// Preview zoom: 1 = fit the page to the panel width.
var zoom = 1
function initZoom() {
try {
var z = parseFloat(localStorage.getItem('ip_archive_zoom'))
if (z > 0) zoom = Math.max(0.25, Math.min(5, z))
} catch (e) {}
$('#btnZoomIn').on('click', function () { setZoom(zoom * 1.25) })
$('#btnZoomOut').on('click', function () { setZoom(zoom / 1.25) })
$('#btnZoomFit').on('click', function () { setZoom(1) })
$('#archiveLeft').on('wheel', function (e) {
if (!e.originalEvent.ctrlKey) return
e.preventDefault()
setZoom(zoom * (e.originalEvent.deltaY < 0 ? 1.1 : 1 / 1.1))
})
$(window).on('resize', applyZoom)
}
function setZoom(z) {
zoom = Math.max(0.25, Math.min(5, z))
try { localStorage.setItem('ip_archive_zoom', zoom) } catch (e) {}
applyZoom()
}
function applyZoom() {
$('#zoomLevel').text(Math.round(zoom * 100) + '%')
var avail = document.getElementById('archiveLeft').clientWidth - 20 // 10px padding pair
if (avail > 0) $('#archiveLeft img').css('width', Math.round(avail * zoom) + 'px')
}
2026-09-16 20:09:19 +00:00
async function loadVendors() {
try {
var vendors = await (await fetch('/api/vendors')).json()
if (vendors.error) throw new Error(vendors.error)
var sel = $('#fVendor')
vendors.forEach(function (v) {
sel.append('<option value="' + v.id + '">' + esc(v.name) + '</option>')
})
} catch (e) { showDbError('Vendor list failed to load: ' + e.message) }
}
async function loadList() {
var params = new URLSearchParams({
vendor: $('#fVendor').val() || '',
inv: $('#fInv').val() || '',
po: $('#fPo').val() || '',
date_from: $('#fDateFrom').val() || '',
date_to: $('#fDateTo').val() || '',
amt_min: $('#fAmtMin').val() || '',
amt_max: $('#fAmtMax').val() || '',
product: $('#fProduct').val() || ''
})
2026-09-30 18:50:04 +00:00
$('#resultCount').text('Loading...')
2026-09-16 20:09:19 +00:00
try {
var list = await (await fetch('/api/archive?' + params)).json()
if (list.error) throw new Error(list.error)
2026-09-30 18:50:04 +00:00
archiveList = list
renderList()
2026-09-16 20:09:19 +00:00
} catch (e) {
2026-09-30 18:50:04 +00:00
$('#resultCount').text('')
2026-09-16 20:09:19 +00:00
showDbError('Could not load archive list: ' + e.message)
}
}
2026-09-30 18:50:04 +00:00
function renderList() {
var selected = $('.inv-row.selected')
var selKey = selected.length ? selected.data('inv') + '|' + selected.data('vendor') : null
var list = archiveList.slice().sort(function (a, b) {
var x = a[sortKey], y = b[sortKey]
if (sortKey === 'vendor_total') { x = parseFloat(x) || 0; y = parseFloat(y) || 0 }
else { x = String(x || '').toLowerCase(); y = String(y || '').toLowerCase() }
return x < y ? -sortDir : x > y ? sortDir : 0
})
$('th.sortable').removeClass('sort-asc sort-desc')
.filter('[data-sort="' + sortKey + '"]').addClass(sortDir > 0 ? 'sort-asc' : 'sort-desc')
var body = $('#archiveBody')
body.empty()
list.forEach(function (r) {
var key = r.invoice_number + '|' + r.vendor
body.append('<tr class="inv-row' + (key === selKey ? ' selected' : '') + '" data-inv="' + esc(r.invoice_number) + '" data-vendor="' + esc(r.vendor) + '">' +
'<td>' + esc(r.invoice_date) + '</td>' +
'<td>' + esc(r.vendor_name) + '</td>' +
'<td>' + esc(r.invoice_number) + '</td>' +
'<td style="text-align:right">$' + (parseFloat(r.vendor_total) || 0).toFixed(2) + '</td>' +
'</tr>')
})
if (!list.length) {
body.append('<tr><td colspan="4" style="color:#777;text-align:center;padding:12px">No invoices found.</td></tr>')
}
var total = list.reduce(function (s, r) { return s + (parseFloat(r.vendor_total) || 0) }, 0)
$('#resultCount').text(list.length >= ARCHIVE_LIMIT
? 'Showing the newest ' + ARCHIVE_LIMIT + ' - narrow the filters to see older invoices'
: list.length + ' invoice' + (list.length === 1 ? '' : 's') + (list.length ? ' - $' + total.toFixed(2) : ''))
}
2026-09-16 20:09:19 +00:00
function showDbError(msg) {
var el = $('#dbWarning')
if (!el.length) el = $('<div id="dbWarning" class="db-warning"></div>').insertAfter('h1')
el.text(msg).show()
}
$(document).on('click', '.inv-row', function () {
$('.inv-row').removeClass('selected')
$(this).addClass('selected')
loadDetail($(this).data('inv'), $(this).data('vendor'))
})
2026-09-30 18:50:04 +00:00
// Arrow-key browsing fires loads back to back; only the latest may render.
var detailSeq = 0
2026-09-16 20:09:19 +00:00
async function loadDetail(inv, vendor) {
2026-09-30 18:50:04 +00:00
var seq = ++detailSeq
2026-09-16 20:09:19 +00:00
$('#noSelection').hide()
$('#detailContent').show()
$('#itemsBody').empty()
2026-09-30 18:50:04 +00:00
$('#btnOpenOriginal').hide()
2026-09-16 20:09:19 +00:00
setPdfStatus('Loading...', true)
var q = 'inv=' + encodeURIComponent(inv) + '&v=' + encodeURIComponent(vendor)
try {
var detail = await (await fetch('/api/archive/detail?' + q)).json()
2026-09-30 18:50:04 +00:00
if (seq !== detailSeq) return
2026-09-16 20:09:19 +00:00
if (detail.error) { setPdfStatus(detail.error, false); return }
renderHeader(detail)
renderItems(detail.items || [])
renderBatch(detail.batch)
} catch (e) {
2026-09-30 18:50:04 +00:00
if (seq === detailSeq) setPdfStatus('Failed to load invoice: ' + e.message, false)
2026-09-16 20:09:19 +00:00
return
}
// Preview pages are rendered server-side; images are just <img> tags.
try {
var info = await (await fetch('/api/archive/preview?' + q)).json()
2026-09-30 18:50:04 +00:00
if (seq !== detailSeq) return
2026-09-16 20:09:19 +00:00
if (info.error) { setPdfStatus(info.error, false); return }
var left = $('#archiveLeft')
left.find('img').remove()
$('#pdfPlaceholder').hide()
2026-09-30 18:50:04 +00:00
$('#archiveToolbar').show()
$('#btnOpenOriginal').attr('href', '/api/archive/file?' + q).show()
2026-09-16 20:09:19 +00:00
// version changes only when the archived file does, so revisiting an
// invoice reuses the browser-cached pages instead of refetching them.
for (var p = 1; p <= (info.pages || 1); p++) {
left.append($('<img>').attr('src', '/api/archive/preview/' + p + '?' + q + '&ver=' + (info.version || 0)))
}
2026-09-30 18:50:04 +00:00
applyZoom()
2026-09-16 20:09:19 +00:00
} catch (e) {
2026-09-30 18:50:04 +00:00
if (seq === detailSeq) setPdfStatus('Could not load file.', false)
2026-09-16 20:09:19 +00:00
}
}
function renderHeader(d) {
$('#dVendor').text(d.vendor_name || '-')
$('#dInvNum').text(d.invoice_number || '-')
$('#dDate').text(d.invoice_date || '-')
$('#dPO').text(d.po_number || '-')
$('#dTotal').text(d.vendor_total != null ? '$' + parseFloat(d.vendor_total).toFixed(2) : '-')
}
function renderItems(items) {
var tbody = $('#itemsBody')
tbody.empty()
if (!items.length) {
tbody.append('<tr><td colspan="8" style="color:#777;text-align:center;padding:8px">No line items recorded.</td></tr>')
return
}
items.forEach(function (it) {
tbody.append('<tr>' +
'<td>' + esc(it.upc) + '</td>' +
'<td>' + esc(it.desc) + '</td>' +
'<td>' + esc(it.cert) + '</td>' +
'<td>' + (it.qty != null ? it.qty : '') + '</td>' +
'<td>' + (it.cost != null ? '$' + parseFloat(it.cost).toFixed(2) : '') + '</td>' +
'<td>' + (it.total != null ? '$' + parseFloat(it.total).toFixed(2) : '') + '</td>' +
'<td>' + esc(it.size) + '</td>' +
'<td>' + esc(it.uom) + '</td>' +
'</tr>')
})
}
// The price/cost batch this invoice produced. Null when nothing moved, which
// is the normal case for a re-order at the same cost.
function renderBatch(b) {
var rows = $('#batchBodyRows').empty()
if (!b || !b.batch_no) {
$('#batchBody').hide()
$('#bFlagged').hide()
$('#batchNone').show()
return
}
$('#batchNone').hide()
$('#batchBody').show()
$('#bNo').text(b.batch_no)
$('#bCreated').text(b.created || '-')
var items = b.items || []
var threshold = b.margin_threshold != null ? b.margin_threshold : 15
$('#bCount').text(items.length)
var flagged = 0
items.forEach(function (it) {
var low = it.margin != null && it.margin < threshold
if (low) flagged++
rows.append('<tr' + (low ? ' class="batch-low"' : '') + '>' +
'<td>' + esc(it.upc) + '</td>' +
'<td>' + esc(it.desc) + '</td>' +
'<td>' + money(it.cost) + '</td>' +
'<td>' + money(it.current_cost) + '</td>' +
'<td>' + money(it.retail) + '</td>' +
'<td>' + (it.margin != null ? it.margin.toFixed(1) + '%' : '-') + '</td>' +
'</tr>')
})
if (!items.length) {
rows.append('<tr><td colspan="6" style="color:#777;text-align:center;padding:8px">Batch has no rows.</td></tr>')
}
$('#bFlagged')
.text(flagged ? flagged + ' below ' + threshold + '% margin' : '')
.toggle(flagged > 0)
}
function money(v) {
return v != null && v !== '' ? '$' + parseFloat(v).toFixed(3).replace(/0$/, '') : '-'
}
function setPdfStatus(msg, loading) {
$('#archiveLeft').find('img').remove()
2026-09-30 18:50:04 +00:00
$('#archiveToolbar').hide()
2026-09-16 20:09:19 +00:00
$('#pdfPlaceholder').text(msg).css('color', loading ? '#ffee88' : '#888').show()
}
function esc(s) {
return String(s || '').replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;')
}
</script>
</body>
</html>