Parts inventory: catalog, search, and stock tracking

FastAPI + SQLite behind a single-page frontend, deployed as a container on
mainserver behind Caddy.

One flexible parts table plus key/value specs so components, filament,
fasteners and tooling share a schema. Categories and locations are nestable
trees whose filters and sidebar counts both roll up through descendants.
Category spec templates pre-fill the properties worth recording for each kind
of part, which is what makes manual entry tolerable. Every quantity change is
logged. Search is FTS5 with prefix matching across names, MPNs, spec values,
tags, category and location.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Jay
2026-08-24 09:19:58 -04:00
commit ffe508b7f3
15 changed files with 2330 additions and 0 deletions
+239
View File
@@ -0,0 +1,239 @@
:root {
--bg: #12141a;
--panel: #1a1d26;
--panel-2: #222634;
--line: #2e3342;
--text: #e6e8ef;
--dim: #949bb0;
--accent: #5b9dff;
--good: #4ec98a;
--warn: #f0a848;
--bad: #f2685f;
--radius: 10px;
}
* { box-sizing: border-box; }
body {
margin: 0;
background: var(--bg);
color: var(--text);
font: 15px/1.5 ui-sans-serif, -apple-system, "Segoe UI", Roboto, sans-serif;
-webkit-text-size-adjust: 100%;
}
button, input, select, textarea { font: inherit; color: inherit; }
button {
background: var(--panel-2);
border: 1px solid var(--line);
color: var(--text);
border-radius: var(--radius);
padding: 8px 12px;
cursor: pointer;
min-height: 38px;
}
button:hover { border-color: #414861; }
button.primary { background: var(--accent); border-color: var(--accent); color: #08101f; font-weight: 600; }
button.danger { color: var(--bad); }
button.ghost { background: transparent; }
button.small { padding: 4px 9px; min-height: 30px; font-size: 13px; }
input, select, textarea {
background: var(--panel-2);
border: 1px solid var(--line);
border-radius: var(--radius);
padding: 9px 11px;
width: 100%;
min-height: 38px;
}
input:focus, select:focus, textarea:focus { outline: 2px solid var(--accent); outline-offset: -1px; }
textarea { resize: vertical; min-height: 70px; }
label { display: block; font-size: 12px; color: var(--dim); margin-bottom: 4px; text-transform: uppercase; letter-spacing: .04em; }
.hidden { display: none !important; }
.mobile-only { display: none; }
/* --- login --- */
#login {
min-height: 100dvh;
display: grid;
place-items: center;
padding: 24px;
}
#login form {
background: var(--panel);
border: 1px solid var(--line);
border-radius: 14px;
padding: 28px;
width: min(360px, 100%);
}
#login h1 { margin: 0 0 6px; font-size: 20px; }
#login p { margin: 0 0 18px; color: var(--dim); font-size: 13px; }
/* --- shell --- */
header {
position: sticky;
top: 0;
z-index: 20;
background: rgba(18,20,26,.94);
backdrop-filter: blur(8px);
border-bottom: 1px solid var(--line);
padding: 10px 16px;
display: flex;
gap: 10px;
align-items: center;
flex-wrap: wrap;
}
header .brand { font-weight: 700; letter-spacing: -.01em; white-space: nowrap; }
header .search { flex: 1 1 240px; min-width: 160px; }
header .stat { color: var(--dim); font-size: 13px; white-space: nowrap; }
.layout { display: flex; align-items: flex-start; gap: 18px; padding: 16px; max-width: 1400px; margin: 0 auto; }
aside {
width: 250px;
flex: 0 0 250px;
background: var(--panel);
border: 1px solid var(--line);
border-radius: 12px;
padding: 12px;
position: sticky;
top: 68px;
max-height: calc(100dvh - 84px);
overflow-y: auto;
}
aside h3 { font-size: 11px; text-transform: uppercase; letter-spacing: .07em; color: var(--dim); margin: 14px 0 6px; }
aside h3:first-child { margin-top: 0; }
.filter {
display: flex;
justify-content: space-between;
gap: 8px;
padding: 5px 8px;
border-radius: 7px;
cursor: pointer;
font-size: 14px;
}
.filter:hover { background: var(--panel-2); }
.filter.active { background: var(--accent); color: #08101f; font-weight: 600; }
.filter .count { color: var(--dim); font-size: 12px; }
.filter.active .count { color: #08101f; }
.filter.child { padding-left: 20px; font-size: 13px; }
main { flex: 1; min-width: 0; }
.toolbar { display: flex; gap: 10px; align-items: center; margin-bottom: 12px; flex-wrap: wrap; }
.toolbar .grow { flex: 1; }
.toolbar select { width: auto; min-width: 150px; }
/* --- part rows --- */
.part {
background: var(--panel);
border: 1px solid var(--line);
border-radius: 12px;
padding: 12px 14px;
margin-bottom: 9px;
display: flex;
gap: 14px;
align-items: center;
flex-wrap: wrap;
}
.part:hover { border-color: #3c445e; }
.part .body { flex: 1 1 260px; min-width: 0; cursor: pointer; }
.part .name { font-weight: 600; }
.part .meta { color: var(--dim); font-size: 13px; margin-top: 2px; word-break: break-word; }
.part .chips { margin-top: 6px; display: flex; gap: 5px; flex-wrap: wrap; }
.chip {
background: var(--panel-2);
border: 1px solid var(--line);
border-radius: 999px;
padding: 1px 9px;
font-size: 12px;
color: var(--dim);
}
.chip.spec { color: var(--text); }
.chip.tag { color: var(--accent); border-color: #2c4470; }
.qty { display: flex; align-items: center; gap: 6px; }
.qty .value { min-width: 84px; text-align: right; font-variant-numeric: tabular-nums; font-weight: 600; }
.qty .value .unit { color: var(--dim); font-weight: 400; font-size: 13px; margin-left: 3px; }
.qty.low .value { color: var(--warn); }
.qty.zero .value { color: var(--bad); }
.empty { text-align: center; color: var(--dim); padding: 60px 20px; }
/* --- modal --- */
.overlay {
position: fixed;
inset: 0;
background: rgba(6,8,13,.72);
display: grid;
place-items: center;
padding: 16px;
z-index: 50;
overflow-y: auto;
}
.modal {
background: var(--panel);
border: 1px solid var(--line);
border-radius: 14px;
width: min(720px, 100%);
max-height: calc(100dvh - 32px);
display: flex;
flex-direction: column;
margin: auto;
}
.modal header { position: static; background: none; backdrop-filter: none; justify-content: space-between; }
.modal .content { padding: 16px; overflow-y: auto; }
.modal footer {
padding: 12px 16px;
border-top: 1px solid var(--line);
display: flex;
gap: 10px;
justify-content: flex-end;
flex-wrap: wrap;
}
.modal footer .grow { flex: 1; }
.grid { display: grid; grid-template-columns: 1fr 1fr; gap: 12px; }
.grid .full { grid-column: 1 / -1; }
.spec-row { display: flex; gap: 8px; margin-bottom: 7px; align-items: center; }
.spec-row input:first-child { flex: 0 0 38%; }
.history { font-size: 13px; color: var(--dim); }
.history div { padding: 4px 0; border-bottom: 1px solid var(--line); display: flex; justify-content: space-between; gap: 10px; }
.pos { color: var(--good); }
.neg { color: var(--bad); }
.node-row { display: flex; gap: 8px; align-items: center; padding: 6px 0; border-bottom: 1px solid var(--line); }
.node-row .path { flex: 1; min-width: 0; word-break: break-word; }
.node-row .count { color: var(--dim); font-size: 12px; }
.toast {
position: fixed;
bottom: 18px;
left: 50%;
transform: translateX(-50%);
background: var(--panel-2);
border: 1px solid var(--line);
border-radius: 10px;
padding: 10px 16px;
z-index: 100;
box-shadow: 0 8px 30px rgba(0,0,0,.5);
}
.toast.bad { border-color: var(--bad); color: var(--bad); }
@media (max-width: 820px) {
.layout { flex-direction: column; padding: 12px; gap: 12px; }
/* On a phone the filter list is longer than the screen, so it starts closed:
search and results are what you came for, filters are a tap away. */
aside { width: 100%; flex: none; position: static; max-height: none; display: none; }
aside.open { display: block; }
.mobile-only { display: inline-flex; align-items: center; }
#filters-btn.active { background: var(--accent); border-color: var(--accent); color: #08101f; }
.grid { grid-template-columns: 1fr; }
header { padding: 8px 12px; }
header .stat { display: none; }
}
+678
View File
@@ -0,0 +1,678 @@
/* 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,
};
// --- 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);
}
$("#login-form").addEventListener("submit", async (e) => {
e.preventDefault();
const err = $("#login-error");
err.classList.add("hidden");
try {
await api("/api/login", { method: "POST", body: { password: $("#password").value } });
$("#password").value = "";
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() {
const overlay = openModal(`
<header>
<strong>Categories &amp; locations</strong>
<button class="ghost small" data-close>✕</button>
</header>
<div class="content">
<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 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;
$("#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;
if (!me.authenticated) return showLogin();
showApp();
await boot();
} catch (_) {
showLogin();
}
})();
+70
View File
@@ -0,0 +1,70 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover">
<meta name="color-scheme" content="dark">
<title>Parts Inventory</title>
<link rel="stylesheet" href="/static/app.css">
<link rel="icon" href="data:image/svg+xml,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 16 16'><text y='13' font-size='13'>🧰</text></svg>">
</head>
<body>
<section id="login" class="hidden">
<form id="login-form">
<h1>Parts Inventory</h1>
<p>Enter the password to continue.</p>
<input type="password" id="password" autocomplete="current-password" placeholder="Password" required>
<p id="login-error" class="hidden" style="color:var(--bad);margin:10px 0 0"></p>
<button class="primary" type="submit" style="width:100%;margin-top:14px">Unlock</button>
</form>
</section>
<section id="app" class="hidden">
<header>
<span class="brand">🧰 Parts</span>
<input class="search" id="search" type="search" placeholder="Search parts, values, MPN, tags…" autocomplete="off">
<span class="stat" id="stats"></span>
<button class="ghost small mobile-only" id="filters-btn">Filters</button>
<button class="primary" id="add-btn">+ Add</button>
<button class="ghost small" id="manage-btn" title="Categories &amp; locations"></button>
<button class="ghost small hidden" id="logout-btn" title="Log out"></button>
</header>
<div class="layout">
<aside id="sidebar">
<h3>Filters</h3>
<div class="filter" id="filter-all">All parts <span class="count" id="count-all"></span></div>
<div class="filter" id="filter-low">Low stock <span class="count" id="count-low"></span></div>
<h3>Categories</h3>
<div id="category-list"></div>
<h3>Locations</h3>
<div id="location-list"></div>
<h3>Tags</h3>
<div id="tag-list"></div>
</aside>
<main>
<div class="toolbar">
<span class="grow" id="result-summary"></span>
<select id="sort">
<option value="relevance">Best match</option>
<option value="name">Name AZ</option>
<option value="quantity">Quantity, lowest</option>
<option value="updated">Recently updated</option>
<option value="created">Recently added</option>
<option value="location">Location</option>
</select>
</div>
<div id="results"></div>
<div style="text-align:center;margin:14px 0">
<button id="more-btn" class="hidden">Load more</button>
</div>
</main>
</div>
</section>
<div id="modal-root"></div>
<script src="/static/app.js"></script>
</body>
</html>