| 1 |
// Spread — frontend glue. Sends an uploaded BOM to /api/analyze and renders
|
| 2 |
// the returned line-by-line re-quote into the example table.
|
| 3 |
(function () {
|
| 4 |
"use strict";
|
| 5 |
|
| 6 |
var fileInput = document.getElementById("file-input");
|
| 7 |
var dropzone = document.getElementById("dropzone");
|
| 8 |
var wrapper = document.getElementById("bom-wrapper");
|
| 9 |
var body = document.getElementById("bom-body");
|
| 10 |
var title = document.getElementById("bom-title");
|
| 11 |
var meta = document.getElementById("bom-meta");
|
| 12 |
var errorBanner = document.getElementById("error-banner");
|
| 13 |
var els = {
|
| 14 |
savings: document.getElementById("t-savings"),
|
| 15 |
cut: document.getElementById("t-cut"),
|
| 16 |
net: document.getElementById("t-net"),
|
| 17 |
};
|
| 18 |
|
| 19 |
function money(n, digits) {
|
| 20 |
return "$" + Number(n).toLocaleString("en-US", {
|
| 21 |
minimumFractionDigits: digits,
|
| 22 |
maximumFractionDigits: digits,
|
| 23 |
});
|
| 24 |
}
|
| 25 |
function qty(n) {
|
| 26 |
return Number(n).toLocaleString("en-US");
|
| 27 |
}
|
| 28 |
function esc(s) {
|
| 29 |
var d = document.createElement("div");
|
| 30 |
d.textContent = s == null ? "" : String(s);
|
| 31 |
return d.innerHTML;
|
| 32 |
}
|
| 33 |
|
| 34 |
function showError(msg) {
|
| 35 |
errorBanner.textContent = msg;
|
| 36 |
errorBanner.classList.add("error");
|
| 37 |
}
|
| 38 |
function clearError() {
|
| 39 |
errorBanner.textContent = "";
|
| 40 |
errorBanner.classList.remove("error");
|
| 41 |
}
|
| 42 |
|
| 43 |
function render(report) {
|
| 44 |
var lines = report.lines || [];
|
| 45 |
var rows = lines.map(function (l) {
|
| 46 |
var pn = l.part_number || "";
|
| 47 |
var estTag = l.matched ? "" : ' <span class="est">· est.</span>';
|
| 48 |
var saveClass = l.line_saving > 0 ? "save" : "save zero";
|
| 49 |
return (
|
| 50 |
"<tr>" +
|
| 51 |
'<td class="part">' + esc(l.description) +
|
| 52 |
'<span class="pn">' + esc(pn) + estTag + "</span></td>" +
|
| 53 |
'<td class="num">' + qty(l.quantity) + "</td>" +
|
| 54 |
'<td class="num market">' + money(l.unit_price, 4) + "</td>" +
|
| 55 |
'<td class="num ours">' + money(l.spread_price, 4) + "</td>" +
|
| 56 |
'<td class="num ' + saveClass + '">' + money(l.line_saving, 2) + "</td>" +
|
| 57 |
"</tr>"
|
| 58 |
);
|
| 59 |
});
|
| 60 |
body.innerHTML = rows.join("") ||
|
| 61 |
'<tr><td class="part" colspan="5" style="color:var(--text-tertiary)">No line items found.</td></tr>';
|
| 62 |
|
| 63 |
var t = report.totals || {};
|
| 64 |
els.savings.textContent = money(t.savings || 0, 2);
|
| 65 |
els.cut.textContent = money(t.our_cut || 0, 2);
|
| 66 |
els.net.textContent = money(t.net_savings || 0, 2);
|
| 67 |
|
| 68 |
var partWord = (t.lines === 1) ? "part" : "parts";
|
| 69 |
title.innerHTML = esc(report.bom_ref || "BOM") +
|
| 70 |
' <span class="dim">· ' + (t.lines || 0) + " " + partWord + "</span>";
|
| 71 |
meta.textContent = "analysis complete";
|
| 72 |
}
|
| 73 |
|
| 74 |
function analyze(file) {
|
| 75 |
clearError();
|
| 76 |
wrapper.classList.add("busy");
|
| 77 |
meta.textContent = "re-quoting…";
|
| 78 |
|
| 79 |
var fd = new FormData();
|
| 80 |
fd.append("bom", file, file.name);
|
| 81 |
|
| 82 |
fetch("/api/analyze", { method: "POST", body: fd })
|
| 83 |
.then(function (res) {
|
| 84 |
return res.json().then(function (data) {
|
| 85 |
if (!res.ok) throw new Error(data.error || "Analysis failed (" + res.status + ")");
|
| 86 |
return data;
|
| 87 |
});
|
| 88 |
})
|
| 89 |
.then(function (report) {
|
| 90 |
render(report);
|
| 91 |
})
|
| 92 |
.catch(function (err) {
|
| 93 |
showError(err.message || "Something went wrong parsing that file.");
|
| 94 |
meta.textContent = "upload failed";
|
| 95 |
})
|
| 96 |
.finally(function () {
|
| 97 |
wrapper.classList.remove("busy");
|
| 98 |
});
|
| 99 |
}
|
| 100 |
|
| 101 |
function loadExample() {
|
| 102 |
clearError();
|
| 103 |
wrapper.classList.add("busy");
|
| 104 |
meta.textContent = "re-quoting…";
|
| 105 |
fetch("/example-bom.csv")
|
| 106 |
.then(function (res) {
|
| 107 |
if (!res.ok) throw new Error("could not load example");
|
| 108 |
return res.blob();
|
| 109 |
})
|
| 110 |
.then(function (blob) {
|
| 111 |
analyze(new File([blob], "example-bom.csv", { type: "text/csv" }));
|
| 112 |
})
|
| 113 |
.catch(function (err) {
|
| 114 |
showError(err.message);
|
| 115 |
wrapper.classList.remove("busy");
|
| 116 |
});
|
| 117 |
}
|
| 118 |
|
| 119 |
// Wire the upload buttons.
|
| 120 |
Array.prototype.forEach.call(document.querySelectorAll("[data-upload]"), function (btn) {
|
| 121 |
btn.addEventListener("click", function () { fileInput.click(); });
|
| 122 |
});
|
| 123 |
Array.prototype.forEach.call(document.querySelectorAll("[data-example]"), function (btn) {
|
| 124 |
btn.addEventListener("click", function (e) {
|
| 125 |
e.preventDefault();
|
| 126 |
document.getElementById("example").scrollIntoView({ behavior: "smooth" });
|
| 127 |
loadExample();
|
| 128 |
});
|
| 129 |
});
|
| 130 |
|
| 131 |
fileInput.addEventListener("change", function () {
|
| 132 |
if (fileInput.files && fileInput.files[0]) analyze(fileInput.files[0]);
|
| 133 |
fileInput.value = "";
|
| 134 |
});
|
| 135 |
|
| 136 |
// Drag and drop.
|
| 137 |
["dragenter", "dragover"].forEach(function (ev) {
|
| 138 |
dropzone.addEventListener(ev, function (e) {
|
| 139 |
e.preventDefault();
|
| 140 |
dropzone.classList.add("drag");
|
| 141 |
});
|
| 142 |
});
|
| 143 |
["dragleave", "drop"].forEach(function (ev) {
|
| 144 |
dropzone.addEventListener(ev, function (e) {
|
| 145 |
e.preventDefault();
|
| 146 |
if (ev === "dragleave" && dropzone.contains(e.relatedTarget)) return;
|
| 147 |
dropzone.classList.remove("drag");
|
| 148 |
});
|
| 149 |
});
|
| 150 |
dropzone.addEventListener("drop", function (e) {
|
| 151 |
if (e.dataTransfer && e.dataTransfer.files && e.dataTransfer.files[0]) {
|
| 152 |
analyze(e.dataTransfer.files[0]);
|
| 153 |
}
|
| 154 |
});
|
| 155 |
|
| 156 |
// Load the example on first paint so the section is never empty.
|
| 157 |
loadExample();
|
| 158 |
})();
|