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">−</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, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"')
|
|
|
|
|
}
|
|
|
|
|
</script>
|
|
|
|
|
|
|
|
|
|
</body>
|
|
|
|
|
</html>
|