327 lines
12 KiB
HTML
327 lines
12 KiB
HTML
<!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
|
|
<a href="index.html">+ New Invoice</a>
|
|
<a href="pending.html">Pending</a>
|
|
<a href="archive.html">Archive</a>
|
|
</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>
|
|
<input name="amt_min" id="fAmtMin" style="width:80px">
|
|
</div>
|
|
<div class="filter-field">
|
|
<label>Max $</label>
|
|
<input name="amt_max" id="fAmtMax" style="width:80px">
|
|
</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>
|
|
</div>
|
|
</form>
|
|
</div>
|
|
|
|
<div id="listPanel">
|
|
<table>
|
|
<thead>
|
|
<tr>
|
|
<th>Date</th>
|
|
<th>Vendor</th>
|
|
<th>Invoice #</th>
|
|
<th style="text-align:right">Total</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody id="archiveBody"></tbody>
|
|
</table>
|
|
</div>
|
|
|
|
<div id="detailLayout">
|
|
<div id="archiveLeft">
|
|
<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>
|
|
$(document).ready(function () {
|
|
loadVendors()
|
|
loadList()
|
|
$('#btnFilter').on('click', loadList)
|
|
$('#btnReset').on('click', function () {
|
|
$('#filterForm')[0].reset()
|
|
loadList()
|
|
})
|
|
})
|
|
|
|
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() || ''
|
|
})
|
|
try {
|
|
var list = await (await fetch('/api/archive?' + params)).json()
|
|
if (list.error) throw new Error(list.error)
|
|
var body = $('#archiveBody')
|
|
body.empty()
|
|
list.forEach(function (r) {
|
|
body.append('<tr class="inv-row" data-inv="' + esc(r.invoice_number) + '" data-vendor="' + 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>')
|
|
}
|
|
} catch (e) {
|
|
showDbError('Could not load archive list: ' + e.message)
|
|
}
|
|
}
|
|
|
|
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'))
|
|
})
|
|
|
|
async function loadDetail(inv, vendor) {
|
|
$('#noSelection').hide()
|
|
$('#detailContent').show()
|
|
$('#itemsBody').empty()
|
|
setPdfStatus('Loading...', true)
|
|
|
|
var q = 'inv=' + encodeURIComponent(inv) + '&v=' + encodeURIComponent(vendor)
|
|
|
|
try {
|
|
var detail = await (await fetch('/api/archive/detail?' + q)).json()
|
|
if (detail.error) { setPdfStatus(detail.error, false); return }
|
|
renderHeader(detail)
|
|
renderItems(detail.items || [])
|
|
renderBatch(detail.batch)
|
|
} catch (e) {
|
|
setPdfStatus('Failed to load invoice: ' + e.message, false)
|
|
return
|
|
}
|
|
|
|
// Preview pages are rendered server-side; images are just <img> tags.
|
|
try {
|
|
var info = await (await fetch('/api/archive/preview?' + q)).json()
|
|
if (info.error) { setPdfStatus(info.error, false); return }
|
|
var left = $('#archiveLeft')
|
|
left.find('img').remove()
|
|
$('#pdfPlaceholder').hide()
|
|
// 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)))
|
|
}
|
|
} catch (e) {
|
|
setPdfStatus('Could not load file.', false)
|
|
}
|
|
}
|
|
|
|
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()
|
|
$('#pdfPlaceholder').text(msg).css('color', loading ? '#ffee88' : '#888').show()
|
|
}
|
|
|
|
function esc(s) {
|
|
return String(s || '').replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"')
|
|
}
|
|
</script>
|
|
|
|
</body>
|
|
</html>
|