ab82b5e9a9
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>
753 lines
27 KiB
JavaScript
753 lines
27 KiB
JavaScript
/* 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) =>
|
||
({ "&": "&", "<": "<", ">": ">", '"': """, "'": "'" }[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(" · ")) || " "}</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();
|
||
}
|
||
})();
|