/* 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,
});
// A 401 anywhere else means the session lapsed and the login screen should
// take over. A 401 from the login call itself just means the password was
// wrong β bouncing to "Not authenticated" there would hide the real reason.
if (res.status === 401 && !path.startsWith("/api/login")) {
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 = '
No tags yet
';
}
for (const tag of state.tags) {
const node = document.createElement("div");
node.className = "filter" + (state.tag === tag.name ? " active" : "");
node.innerHTML = `${esc(tag.name)} ${tag.n} `;
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 = `${esc(name)} ${item.part_count} `;
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) => `${esc(s.key)}: ${esc(s.value)} `),
...part.tags.map((t) => `${esc(t)} `),
].join("");
const qtyClass = part.quantity <= 0 ? "zero" : part.low_stock ? "low" : "";
node.innerHTML = `
${esc(part.name)}
${esc(bits.join(" Β· ")) || " "}
${chips ? `
${chips}
` : ""}
β
${fmtQty(part.quantity)}${esc(part.unit)}
+
`;
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 = `${
state.q || state.category_id || state.location_id || state.tag || state.low_stock
? "Nothing matches those filters."
: "No parts yet. Hit + Add to catalog the first one."
}
`;
}
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 = `${html}
`;
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 ['β none β ']
.concat(state.categories.map((c) =>
`${esc(c.path)} `))
.join("");
}
function locationOptions(selected) {
return ['β none β ']
.concat(state.locations.map((l) =>
`${esc(l.path)} `))
.join("");
}
function specRowHTML(key = "", value = "") {
return `
β
`;
}
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(`
${isNew ? "Add part" : esc(p.name)}
β
${isNew ? "" : 'Delete '}
Cancel
${isNew ? "Add part" : "Save"}
`);
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) =>
`${h.delta >= 0 ? "+" : ""}${fmtQty(h.delta)}
${esc(h.reason || "adjusted")}
β ${fmtQty(h.quantity_after)} Β· ${esc(h.created_at)}
`).join("")
: "No changes recorded yet.
";
}).catch(() => {});
}
setTimeout(() => overlay.querySelector("#f-name").focus(), 50);
}
// --- manage categories / locations ------------------------------------------
function openManageModal(focusPassword = false) {
const overlay = openModal(`
${state.authRequired ? `
Password
${state.usingBootstrap
? 'You\'re still using the password you were handed. Setting your own here also signs out every other device.
'
: 'Changing your password signs out every other device.
'}
Change password
Sign out other devices
` : ""}
`);
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 = `${esc(item.path)}
${item.part_count}
Delete `;
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();
}
})();