Files
PartsInventorySystem/static/app.js
T
thejayman77 ab82b5e9a9 Manage the password in the app; close remaining audit findings
Password management moves out of the CLI entirely. The credential is now a
salted scrypt hash in the database (so it survives rebuilds, living in the /data
volume) rather than an environment variable; PARTS_PASSWORD is demoted to a
bootstrap value that stops working the moment a password is set in the UI. Every
token carries a session epoch, so changing the password — or "sign out other
devices" — invalidates outstanding cookies while keeping the browser that made
the change signed in. A banner nags until the handed-over password is replaced.
app/admin.py remains for the one case the UI cannot cover, a forgotten password.

Audit findings:
- Taxonomy update and delete scanned affected parts before taking the write
  lock, so a concurrent rename could leave the search index matching a name the
  UI no longer showed. All four routes now lock first; removing the lock again
  makes the new test fail exactly that way.
- History of a missing part returned 200 with an empty list; now 404.
- Infinity and NaN passed ge=0 and failed at the database. They are rejected as
  422 now, and the validation error handler no longer chokes trying to echo a
  non-finite value back.

Checks go from 134 to 181.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-24 10:31:06 -04:00

753 lines
27 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/* Parts inventory frontend.
*
* Deliberately dependency-free: one file, no build step, so deploying is
* `docker compose up -d --build` and nothing else. The whole app is a search
* box over /api/parts plus a form; everything else is filters onto that.
*/
const $ = (sel) => document.querySelector(sel);
const esc = (s) => String(s ?? "").replace(/[&<>"']/g, (c) =>
({ "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;", "'": "&#39;" }[c]));
const state = {
q: "",
category_id: null,
location_id: null,
tag: "",
low_stock: false,
sort: "relevance",
offset: 0,
limit: 50,
total: 0,
categories: [],
locations: [],
tags: [],
authRequired: true,
usingBootstrap: false,
minPasswordLength: 8,
};
// --- plumbing ---------------------------------------------------------------
async function api(path, options = {}) {
const res = await fetch(path, {
headers: { "Content-Type": "application/json" },
...options,
body: options.body ? JSON.stringify(options.body) : undefined,
});
if (res.status === 401) {
showLogin();
throw new Error("Not authenticated");
}
if (!res.ok) {
let detail = res.statusText;
try { detail = (await res.json()).detail || detail; } catch (_) {}
throw new Error(typeof detail === "string" ? detail : "Request failed");
}
return res.status === 204 ? null : res.json();
}
let toastTimer;
function toast(message, bad = false) {
document.querySelectorAll(".toast").forEach((t) => t.remove());
const node = document.createElement("div");
node.className = "toast" + (bad ? " bad" : "");
node.textContent = message;
document.body.appendChild(node);
clearTimeout(toastTimer);
toastTimer = setTimeout(() => node.remove(), 2600);
}
const fmtQty = (n) => (Number.isInteger(n) ? String(n) : String(Math.round(n * 100) / 100));
// --- auth -------------------------------------------------------------------
function showLogin() {
$("#app").classList.add("hidden");
$("#login").classList.remove("hidden");
setTimeout(() => $("#password").focus(), 50);
}
function showApp() {
$("#login").classList.add("hidden");
$("#app").classList.remove("hidden");
$("#logout-btn").classList.toggle("hidden", !state.authRequired);
$("#bootstrap-banner").classList.toggle("hidden", !state.usingBootstrap);
}
$("#login-form").addEventListener("submit", async (e) => {
e.preventDefault();
const err = $("#login-error");
err.classList.add("hidden");
try {
const session = await api("/api/login", { method: "POST", body: { password: $("#password").value } });
$("#password").value = "";
state.usingBootstrap = !!session.using_bootstrap_password;
showApp();
await boot();
} catch (ex) {
err.textContent = ex.message;
err.classList.remove("hidden");
}
});
$("#logout-btn").addEventListener("click", async () => {
await api("/api/logout", { method: "POST" });
showLogin();
});
// --- sidebar ----------------------------------------------------------------
function renderSidebar() {
const cats = $("#category-list");
cats.innerHTML = "";
const roots = state.categories.filter((c) => !c.parent_id);
for (const root of roots) {
cats.appendChild(filterNode(root, "category_id", false));
for (const child of state.categories.filter((c) => c.parent_id === root.id)) {
if (child.part_count > 0 || state.category_id === child.id) {
cats.appendChild(filterNode(child, "category_id", true));
}
}
}
const locs = $("#location-list");
locs.innerHTML = "";
for (const loc of state.locations) {
locs.appendChild(filterNode(loc, "location_id", !!loc.parent_id, loc.path));
}
const tags = $("#tag-list");
tags.innerHTML = "";
if (!state.tags.length) {
tags.innerHTML = '<div class="filter" style="color:var(--dim);cursor:default">No tags yet</div>';
}
for (const tag of state.tags) {
const node = document.createElement("div");
node.className = "filter" + (state.tag === tag.name ? " active" : "");
node.innerHTML = `<span>${esc(tag.name)}</span><span class="count">${tag.n}</span>`;
node.onclick = () => {
state.tag = state.tag === tag.name ? "" : tag.name;
resetAndSearch();
};
tags.appendChild(node);
}
$("#filter-all").classList.toggle(
"active",
!state.category_id && !state.location_id && !state.tag && !state.low_stock
);
$("#filter-low").classList.toggle("active", state.low_stock);
const btn = $("#filters-btn");
const anyActive = !!(state.category_id || state.location_id || state.tag || state.low_stock);
btn.classList.toggle("active", anyActive);
if (!$("#sidebar").classList.contains("open")) btn.textContent = filtersLabel();
}
function filterNode(item, key, isChild, label) {
const node = document.createElement("div");
node.className = "filter" + (isChild ? " child" : "") + (state[key] === item.id ? " active" : "");
const name = label ? label.split(" / ").pop() : item.name;
node.innerHTML = `<span>${esc(name)}</span><span class="count">${item.part_count}</span>`;
node.onclick = () => {
state[key] = state[key] === item.id ? null : item.id;
resetAndSearch();
};
return node;
}
// --- results ----------------------------------------------------------------
function partCard(part) {
const node = document.createElement("div");
node.className = "part";
const bits = [];
if (part.location_path) bits.push("📍 " + part.location_path);
if (part.category_path) bits.push(part.category_path);
if (part.manufacturer) bits.push(part.manufacturer);
if (part.mpn) bits.push(part.mpn);
const chips = [
...part.specs.filter((s) => s.value).slice(0, 5)
.map((s) => `<span class="chip spec">${esc(s.key)}: ${esc(s.value)}</span>`),
...part.tags.map((t) => `<span class="chip tag">${esc(t)}</span>`),
].join("");
const qtyClass = part.quantity <= 0 ? "zero" : part.low_stock ? "low" : "";
node.innerHTML = `
<div class="body">
<div class="name">${esc(part.name)}</div>
<div class="meta">${esc(bits.join(" · ")) || "&nbsp;"}</div>
${chips ? `<div class="chips">${chips}</div>` : ""}
</div>
<div class="qty ${qtyClass}">
<button class="small" data-delta="-1" title="Take one"></button>
<span class="value">${fmtQty(part.quantity)}<span class="unit">${esc(part.unit)}</span></span>
<button class="small" data-delta="1" title="Add one">+</button>
</div>`;
node.querySelector(".body").onclick = () => openPartModal(part);
node.querySelectorAll("[data-delta]").forEach((btn) => {
btn.onclick = async (e) => {
e.stopPropagation();
const delta = Number(btn.dataset.delta) * (part.unit === "g" || part.unit === "ml" ? 10 : 1);
try {
const updated = await api(`/api/parts/${part.id}/adjust`, {
method: "POST",
body: { delta, reason: "quick adjust" },
});
node.replaceWith(partCard(updated));
refreshStats();
} catch (ex) {
toast(ex.message, true);
}
};
});
return node;
}
async function search(append = false) {
if (!append) state.offset = 0;
const params = new URLSearchParams({
q: state.q,
sort: state.sort,
limit: String(state.limit),
offset: String(state.offset),
});
if (state.category_id) params.set("category_id", state.category_id);
if (state.location_id) params.set("location_id", state.location_id);
if (state.tag) params.set("tag", state.tag);
if (state.low_stock) params.set("low_stock", "true");
let data;
try {
data = await api("/api/parts?" + params);
} catch (ex) {
toast(ex.message, true);
return;
}
state.total = data.total;
const box = $("#results");
if (!append) box.innerHTML = "";
for (const part of data.items) box.appendChild(partCard(part));
if (!data.total) {
box.innerHTML = `<div class="empty">${
state.q || state.category_id || state.location_id || state.tag || state.low_stock
? "Nothing matches those filters."
: "No parts yet. Hit <b>+ Add</b> to catalog the first one."
}</div>`;
}
const shown = Math.min(state.offset + data.items.length, data.total);
$("#result-summary").textContent = data.total
? `Showing ${shown} of ${data.total}`
: "";
$("#more-btn").classList.toggle("hidden", shown >= data.total);
state.offset = shown;
}
function resetAndSearch() {
renderSidebar();
search(false);
// On a phone, choosing a filter should reveal the results it produced.
if (window.matchMedia("(max-width: 820px)").matches) setSidebar(false);
}
function setSidebar(open) {
$("#sidebar").classList.toggle("open", open);
$("#filters-btn").textContent = open ? "Close" : filtersLabel();
}
function filtersLabel() {
const active = [state.category_id, state.location_id, state.tag || null, state.low_stock || null]
.filter(Boolean).length;
return active ? `Filters (${active})` : "Filters";
}
// --- part modal -------------------------------------------------------------
function closeModal() {
$("#modal-root").innerHTML = "";
document.body.style.overflow = "";
}
function openModal(html) {
const overlay = document.createElement("div");
overlay.className = "overlay";
overlay.innerHTML = `<div class="modal">${html}</div>`;
overlay.onclick = (e) => { if (e.target === overlay) closeModal(); };
$("#modal-root").innerHTML = "";
$("#modal-root").appendChild(overlay);
document.body.style.overflow = "hidden";
return overlay;
}
document.addEventListener("keydown", (e) => {
if (e.key === "Escape") closeModal();
if (e.key === "/" && document.activeElement.tagName !== "INPUT" && document.activeElement.tagName !== "TEXTAREA") {
e.preventDefault();
$("#search").focus();
}
});
function categoryOptions(selected) {
return ['<option value="">— none —</option>']
.concat(state.categories.map((c) =>
`<option value="${c.id}"${c.id === selected ? " selected" : ""}>${esc(c.path)}</option>`))
.join("");
}
function locationOptions(selected) {
return ['<option value="">— none —</option>']
.concat(state.locations.map((l) =>
`<option value="${l.id}"${l.id === selected ? " selected" : ""}>${esc(l.path)}</option>`))
.join("");
}
function specRowHTML(key = "", value = "") {
return `<div class="spec-row">
<input class="spec-key" placeholder="Property" value="${esc(key)}">
<input class="spec-value" placeholder="Value" value="${esc(value)}">
<button type="button" class="small ghost danger remove-spec" title="Remove">✕</button>
</div>`;
}
function openPartModal(part) {
const isNew = !part;
const p = part || {
name: "", description: "", category_id: null, location_id: null, manufacturer: "",
mpn: "", quantity: 0, unit: "pcs", min_quantity: null, cost_each: null,
datasheet_url: "", product_url: "", notes: "", specs: [], tags: [],
};
const overlay = openModal(`
<header>
<strong>${isNew ? "Add part" : esc(p.name)}</strong>
<button class="ghost small" data-close>✕</button>
</header>
<div class="content">
<div class="grid">
<div class="full">
<label>Name</label>
<input id="f-name" value="${esc(p.name)}" placeholder="e.g. 10k resistor, PLA black, M3×8 socket cap">
</div>
<div>
<label>Category</label>
<select id="f-category">${categoryOptions(p.category_id)}</select>
</div>
<div>
<label>Location</label>
<select id="f-location">${locationOptions(p.location_id)}</select>
</div>
<div>
<label>Quantity</label>
<input id="f-quantity" type="number" step="any" value="${p.quantity}">
</div>
<div>
<label>Unit</label>
<input id="f-unit" value="${esc(p.unit)}" placeholder="pcs, g, m, ml">
</div>
<div>
<label>Low-stock at</label>
<input id="f-min" type="number" step="any" value="${p.min_quantity ?? ""}" placeholder="optional">
</div>
<div>
<label>Cost each</label>
<input id="f-cost" type="number" step="any" value="${p.cost_each ?? ""}" placeholder="optional">
</div>
<div>
<label>Manufacturer</label>
<input id="f-manufacturer" value="${esc(p.manufacturer)}">
</div>
<div>
<label>Part number</label>
<input id="f-mpn" value="${esc(p.mpn)}">
</div>
<div class="full">
<label>Specs</label>
<div id="spec-rows">${p.specs.map((s) => specRowHTML(s.key, s.value)).join("")}</div>
<button type="button" class="small ghost" id="add-spec">+ Add property</button>
</div>
<div class="full">
<label>Tags (comma separated)</label>
<input id="f-tags" value="${esc(p.tags.join(", "))}" placeholder="smd, salvaged, project-x">
</div>
<div class="full">
<label>Notes</label>
<textarea id="f-notes" placeholder="Where it came from, what it's good for, gotchas…">${esc(p.notes)}</textarea>
</div>
<div>
<label>Datasheet URL</label>
<input id="f-datasheet" value="${esc(p.datasheet_url)}">
</div>
<div>
<label>Product URL</label>
<input id="f-product" value="${esc(p.product_url)}">
</div>
</div>
${isNew ? "" : '<div style="margin-top:18px"><label>Recent stock changes</label><div class="history" id="history">…</div></div>'}
</div>
<footer>
${isNew ? "" : '<button class="danger ghost" id="delete-btn">Delete</button>'}
<span class="grow"></span>
<button data-close>Cancel</button>
<button class="primary" id="save-btn">${isNew ? "Add part" : "Save"}</button>
</footer>`);
overlay.querySelectorAll("[data-close]").forEach((b) => (b.onclick = closeModal));
const specRows = overlay.querySelector("#spec-rows");
const bindRemove = () =>
specRows.querySelectorAll(".remove-spec").forEach((b) => (b.onclick = () => b.closest(".spec-row").remove()));
bindRemove();
overlay.querySelector("#add-spec").onclick = () => {
specRows.insertAdjacentHTML("beforeend", specRowHTML());
bindRemove();
};
// Picking a category pre-fills the properties worth recording for that kind
// of thing — the difference between a blank form and a checklist.
overlay.querySelector("#f-category").onchange = (e) => {
const cat = state.categories.find((c) => String(c.id) === e.target.value);
if (!cat) return;
const unitField = overlay.querySelector("#f-unit");
if (cat.unit && (!unitField.value || unitField.value === "pcs")) unitField.value = cat.unit;
const existing = new Set(
[...specRows.querySelectorAll(".spec-key")].map((i) => i.value.trim().toLowerCase())
);
for (const tpl of cat.spec_template || []) {
if (tpl.key && !existing.has(tpl.key.toLowerCase())) {
specRows.insertAdjacentHTML("beforeend", specRowHTML(tpl.key, ""));
}
}
bindRemove();
};
overlay.querySelector("#save-btn").onclick = async () => {
const num = (sel) => {
const raw = overlay.querySelector(sel).value.trim();
return raw === "" ? null : Number(raw);
};
const body = {
name: overlay.querySelector("#f-name").value.trim(),
category_id: Number(overlay.querySelector("#f-category").value) || null,
location_id: Number(overlay.querySelector("#f-location").value) || null,
manufacturer: overlay.querySelector("#f-manufacturer").value.trim(),
mpn: overlay.querySelector("#f-mpn").value.trim(),
quantity: num("#f-quantity") ?? 0,
unit: overlay.querySelector("#f-unit").value.trim() || "pcs",
min_quantity: num("#f-min"),
cost_each: num("#f-cost"),
datasheet_url: overlay.querySelector("#f-datasheet").value.trim(),
product_url: overlay.querySelector("#f-product").value.trim(),
notes: overlay.querySelector("#f-notes").value.trim(),
specs: [...specRows.querySelectorAll(".spec-row")]
.map((r) => ({
key: r.querySelector(".spec-key").value.trim(),
value: r.querySelector(".spec-value").value.trim(),
}))
.filter((s) => s.key),
tags: overlay.querySelector("#f-tags").value.split(",").map((t) => t.trim()).filter(Boolean),
};
if (!body.name) return toast("Give it a name", true);
try {
if (isNew) await api("/api/parts", { method: "POST", body });
else await api(`/api/parts/${p.id}`, { method: "PATCH", body });
closeModal();
toast(isNew ? "Added" : "Saved");
await loadRefData();
resetAndSearch();
} catch (ex) {
toast(ex.message, true);
}
};
if (!isNew) {
overlay.querySelector("#delete-btn").onclick = async () => {
if (!confirm(`Delete "${p.name}"? This cannot be undone.`)) return;
try {
await api(`/api/parts/${p.id}`, { method: "DELETE" });
closeModal();
toast("Deleted");
await loadRefData();
resetAndSearch();
} catch (ex) {
toast(ex.message, true);
}
};
api(`/api/parts/${p.id}/history`).then((data) => {
const box = overlay.querySelector("#history");
if (!box) return;
box.innerHTML = data.items.length
? data.items.map((h) =>
`<div><span class="${h.delta >= 0 ? "pos" : "neg"}">${h.delta >= 0 ? "+" : ""}${fmtQty(h.delta)}</span>
<span>${esc(h.reason || "adjusted")}</span>
<span>→ ${fmtQty(h.quantity_after)} · ${esc(h.created_at)}</span></div>`).join("")
: "<div>No changes recorded yet.</div>";
}).catch(() => {});
}
setTimeout(() => overlay.querySelector("#f-name").focus(), 50);
}
// --- manage categories / locations ------------------------------------------
function openManageModal(focusPassword = false) {
const overlay = openModal(`
<header>
<strong>Settings</strong>
<button class="ghost small" data-close>✕</button>
</header>
<div class="content">
${state.authRequired ? `
<section id="password-section" style="margin-bottom:24px">
<label>Password</label>
${state.usingBootstrap
? '<p style="color:var(--warn);font-size:13px;margin:0 0 10px">You\'re still using the password you were handed. Setting your own here also signs out every other device.</p>'
: '<p style="color:var(--dim);font-size:13px;margin:0 0 10px">Changing your password signs out every other device.</p>'}
<div class="grid">
<div class="full">
<input id="pw-current" type="password" autocomplete="current-password" placeholder="Current password">
</div>
<div>
<input id="pw-new" type="password" autocomplete="new-password" placeholder="New password">
</div>
<div>
<input id="pw-confirm" type="password" autocomplete="new-password" placeholder="Repeat new password">
</div>
</div>
<div style="display:flex;gap:10px;align-items:center;margin-top:10px;flex-wrap:wrap">
<button class="small primary" id="pw-save">Change password</button>
<button class="small ghost" id="pw-revoke" title="Keeps this device signed in">Sign out other devices</button>
<span id="pw-msg" style="font-size:13px"></span>
</div>
</section>` : ""}
<div class="grid">
<div>
<label>New category</label>
<input id="new-cat-name" placeholder="Name">
<select id="new-cat-parent" style="margin-top:8px">${categoryOptions(null)}</select>
<input id="new-cat-unit" placeholder="Default unit (pcs)" style="margin-top:8px">
<input id="new-cat-specs" placeholder="Suggested properties, comma separated" style="margin-top:8px">
<button class="small primary" id="add-cat" style="margin-top:8px">Add category</button>
</div>
<div>
<label>New location</label>
<input id="new-loc-name" placeholder="e.g. Bin A3">
<select id="new-loc-parent" style="margin-top:8px">${locationOptions(null)}</select>
<input id="new-loc-notes" placeholder="Notes (optional)" style="margin-top:8px">
<button class="small primary" id="add-loc" style="margin-top:8px">Add location</button>
</div>
</div>
<div style="margin-top:20px">
<label>Locations</label>
<div id="loc-rows"></div>
</div>
<div style="margin-top:20px">
<label>Categories</label>
<div id="cat-rows"></div>
</div>
</div>
<footer><span class="grow"></span><button data-close>Done</button></footer>`);
overlay.querySelectorAll("[data-close]").forEach((b) => (b.onclick = closeModal));
const pwSave = overlay.querySelector("#pw-save");
if (pwSave) {
const msg = overlay.querySelector("#pw-msg");
const say = (text, bad) => {
msg.textContent = text;
msg.style.color = bad ? "var(--bad)" : "var(--good)";
};
pwSave.onclick = async () => {
const current = overlay.querySelector("#pw-current").value;
const next = overlay.querySelector("#pw-new").value;
const confirmed = overlay.querySelector("#pw-confirm").value;
if (!current) return say("Enter your current password", true);
if (next !== confirmed) return say("New passwords don't match", true);
if (next.length < state.minPasswordLength)
return say(`At least ${state.minPasswordLength} characters`, true);
pwSave.disabled = true;
try {
await api("/api/password", {
method: "POST",
body: { current_password: current, new_password: next },
});
overlay.querySelectorAll("#pw-current, #pw-new, #pw-confirm").forEach((i) => (i.value = ""));
state.usingBootstrap = false;
$("#bootstrap-banner").classList.add("hidden");
say("Password changed. Other devices signed out.", false);
toast("Password changed");
} catch (ex) {
say(ex.message, true);
} finally {
pwSave.disabled = false;
}
};
overlay.querySelector("#pw-revoke").onclick = async () => {
if (!confirm("Sign out every other device? This one stays signed in.")) return;
try {
await api("/api/sessions/revoke", { method: "POST" });
say("Other devices signed out.", false);
} catch (ex) {
say(ex.message, true);
}
};
if (focusPassword) setTimeout(() => overlay.querySelector("#pw-current").focus(), 50);
}
const renderRows = () => {
const draw = (items, box, kind) => {
box.innerHTML = "";
for (const item of items) {
const row = document.createElement("div");
row.className = "node-row";
row.innerHTML = `<span class="path">${esc(item.path)}</span>
<span class="count">${item.part_count}</span>
<button class="small ghost danger">Delete</button>`;
row.querySelector("button").onclick = async () => {
if (!confirm(`Delete "${item.path}"? Parts filed there keep existing, just unfiled.`)) return;
try {
await api(`/api/${kind}/${item.id}`, { method: "DELETE" });
await loadRefData();
renderRows();
resetAndSearch();
} catch (ex) {
toast(ex.message, true);
}
};
box.appendChild(row);
}
};
draw(state.locations, overlay.querySelector("#loc-rows"), "locations");
draw(state.categories, overlay.querySelector("#cat-rows"), "categories");
};
renderRows();
overlay.querySelector("#add-cat").onclick = async () => {
const name = overlay.querySelector("#new-cat-name").value.trim();
if (!name) return toast("Name it first", true);
try {
await api("/api/categories", {
method: "POST",
body: {
name,
parent_id: Number(overlay.querySelector("#new-cat-parent").value) || null,
unit: overlay.querySelector("#new-cat-unit").value.trim() || "pcs",
spec_template: overlay.querySelector("#new-cat-specs").value
.split(",").map((s) => s.trim()).filter(Boolean).map((key) => ({ key, value: "" })),
},
});
overlay.querySelector("#new-cat-name").value = "";
overlay.querySelector("#new-cat-specs").value = "";
await loadRefData();
renderRows();
renderSidebar();
toast("Category added");
} catch (ex) {
toast(ex.message, true);
}
};
overlay.querySelector("#add-loc").onclick = async () => {
const name = overlay.querySelector("#new-loc-name").value.trim();
if (!name) return toast("Name it first", true);
try {
await api("/api/locations", {
method: "POST",
body: {
name,
parent_id: Number(overlay.querySelector("#new-loc-parent").value) || null,
notes: overlay.querySelector("#new-loc-notes").value.trim(),
},
});
overlay.querySelector("#new-loc-name").value = "";
overlay.querySelector("#new-loc-notes").value = "";
await loadRefData();
renderRows();
renderSidebar();
toast("Location added");
} catch (ex) {
toast(ex.message, true);
}
};
}
// --- boot -------------------------------------------------------------------
async function refreshStats() {
const s = await api("/api/stats");
$("#stats").textContent =
`${s.parts} part${s.parts === 1 ? "" : "s"}` +
(s.low_stock ? ` · ${s.low_stock} low` : "") +
(s.estimated_value ? ` · ~${s.estimated_value.toFixed(2)}` : "");
$("#count-low").textContent = s.low_stock || "";
$("#count-all").textContent = s.parts || "";
}
async function loadRefData() {
const [cats, locs, tags] = await Promise.all([
api("/api/categories"),
api("/api/locations"),
api("/api/tags"),
]);
state.categories = cats.items;
state.locations = locs.items;
state.tags = tags.items.filter((t) => t.n > 0);
await refreshStats();
}
let searchTimer;
$("#search").addEventListener("input", (e) => {
state.q = e.target.value;
clearTimeout(searchTimer);
searchTimer = setTimeout(() => search(false), 180);
});
$("#sort").addEventListener("change", (e) => {
state.sort = e.target.value;
search(false);
});
$("#filters-btn").onclick = () => setSidebar(!$("#sidebar").classList.contains("open"));
$("#add-btn").onclick = () => openPartModal(null);
$("#manage-btn").onclick = () => openManageModal(false);
$("#banner-change").onclick = () => openManageModal(true);
$("#more-btn").onclick = () => search(true);
$("#filter-all").onclick = () => {
state.category_id = state.location_id = null;
state.tag = "";
state.low_stock = false;
resetAndSearch();
};
$("#filter-low").onclick = () => {
state.low_stock = !state.low_stock;
resetAndSearch();
};
async function boot() {
await loadRefData();
renderSidebar();
await search(false);
}
(async function start() {
try {
const me = await api("/api/me");
state.authRequired = me.auth_required;
state.usingBootstrap = !!me.using_bootstrap_password;
state.minPasswordLength = me.min_password_length || state.minPasswordLength;
if (!me.authenticated) return showLogin();
showApp();
await boot();
} catch (_) {
showLogin();
}
})();