spread

https://git.tonybtw.com/spread.git git://git.tonybtw.com/spread.git
5,290 bytes raw
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
      ' &nbsp;<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
})();