Files
electronics-inventory/public/app.mjs
mikael-lovqvists-claude-agent ef2e53ea18 Initial electronics inventory webapp
KV-store backed Express 5 app for tracking electronic components,
their arbitrary fields, and inventory locations (physical, BOM, digital).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-20 19:11:13 +00:00

622 lines
20 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.
import { qs, clone, set_text } from './lib/dom.mjs';
import * as api from './lib/api.mjs';
// ---------------------------------------------------------------------------
// State
// ---------------------------------------------------------------------------
let section = 'components';
let all_components = [];
let all_fields = [];
let all_inventory = [];
let component_search = '';
let inventory_search = '';
let inventory_type_filter = '';
// ---------------------------------------------------------------------------
// Data loading
// ---------------------------------------------------------------------------
async function load_all() {
const [cf, ci, cmp] = await Promise.all([
api.get_fields(),
api.get_inventory(),
api.get_components(),
]);
all_fields = cf.fields;
all_inventory = ci.entries;
all_components = cmp.components;
}
// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------
const LOCATION_TYPE_LABEL = { physical: 'Physical', bom: 'BOM', digital: 'Digital' };
const LOCATION_TYPE_ICON = { physical: '📦', bom: '📋', digital: '💡' };
function ref_label_for_type(type) {
if (type === 'physical') return 'Location (drawer, bin, shelf…)';
if (type === 'bom') return 'Document / project name';
return 'Note / description';
}
function inventory_for_component(component_id) {
return all_inventory.filter(e => e.component_id === component_id);
}
function component_by_id(id) {
return all_components.find(c => c.id === id);
}
function field_by_id(id) {
return all_fields.find(f => f.id === id);
}
function matches_search(component, query) {
if (!query) return true;
const q = query.toLowerCase();
if (component.name.toLowerCase().includes(q)) return true;
if (component.description.toLowerCase().includes(q)) return true;
for (const val of Object.values(component.fields ?? {})) {
if (String(val).toLowerCase().includes(q)) return true;
}
// Also search inventory locations for this component
for (const entry of inventory_for_component(component.id)) {
if (entry.location_ref.toLowerCase().includes(q)) return true;
}
return false;
}
function matches_inventory_search(entry, query, type_filter) {
if (type_filter && entry.location_type !== type_filter) return false;
if (!query) return true;
const q = query.toLowerCase();
const comp = component_by_id(entry.component_id);
if (comp && comp.name.toLowerCase().includes(q)) return true;
if (entry.location_ref.toLowerCase().includes(q)) return true;
if (entry.notes.toLowerCase().includes(q)) return true;
return false;
}
// ---------------------------------------------------------------------------
// Render: Components section
// ---------------------------------------------------------------------------
function render_components() {
const main = document.getElementById('main');
let section_el = document.getElementById('section-components');
if (!section_el) {
const frag = document.getElementById('t-section-components').content.cloneNode(true);
main.replaceChildren(frag);
section_el = document.getElementById('section-components');
qs(section_el, '#component-search').addEventListener('input', (e) => {
component_search = e.target.value;
render_component_list();
});
qs(section_el, '#btn-add-component').addEventListener('click', () => open_component_dialog());
}
qs(section_el, '#component-search').value = component_search;
render_component_list();
}
function render_component_list() {
const list_el = document.getElementById('component-list');
const query = component_search.trim();
const visible = all_components.filter(c => matches_search(c, query));
if (visible.length === 0) {
const empty = document.createElement('div');
empty.className = 'empty-state';
empty.textContent = query ? 'No components match your search.' : 'No components yet. Add one!';
list_el.replaceChildren(empty);
return;
}
list_el.replaceChildren(...visible.map(build_component_row));
}
function build_component_row(comp) {
const row = clone('t-component-row');
set_text(row, '.component-name', comp.name);
set_text(row, '.component-description', comp.description || '');
// Field tags
const fields_el = qs(row, '.component-fields');
const field_entries = Object.entries(comp.fields ?? {});
if (field_entries.length > 0) {
fields_el.replaceChildren(...field_entries.map(([fid, val]) => {
const tag = clone('t-field-tag');
const def = field_by_id(fid);
const label = def ? def.name : fid;
const display_val = def?.unit ? `${val} ${def.unit}` : String(val);
set_text(tag, '.tag-name', label);
set_text(tag, '.tag-value', display_val);
return tag;
}));
}
// Location badges
const locs_el = qs(row, '.component-locations');
const entries = inventory_for_component(comp.id);
if (entries.length > 0) {
locs_el.replaceChildren(...entries.map(entry => {
const badge = clone('t-location-badge');
badge.classList.add(`type-${entry.location_type}`);
set_text(badge, '.badge-icon', LOCATION_TYPE_ICON[entry.location_type] ?? '');
const ref_text = entry.location_ref || LOCATION_TYPE_LABEL[entry.location_type];
const qty_text = entry.quantity ? ` ×${entry.quantity}` : '';
set_text(badge, '.badge-text', ref_text + qty_text);
return badge;
}));
}
qs(row, '.btn-edit').addEventListener('click', () => open_component_dialog(comp));
qs(row, '.btn-delete').addEventListener('click', () => confirm_delete(
`Delete component "${comp.name}"? Inventory entries for it will remain but become orphaned.`,
async () => {
await api.delete_component(comp.id);
all_components = all_components.filter(c => c.id !== comp.id);
render_component_list();
}
));
return row;
}
// ---------------------------------------------------------------------------
// Render: Inventory section
// ---------------------------------------------------------------------------
function render_inventory() {
const main = document.getElementById('main');
let section_el = document.getElementById('section-inventory');
if (!section_el) {
const frag = document.getElementById('t-section-inventory').content.cloneNode(true);
main.replaceChildren(frag);
section_el = document.getElementById('section-inventory');
qs(section_el, '#inventory-search').addEventListener('input', (e) => {
inventory_search = e.target.value;
render_inventory_list();
});
qs(section_el, '#inventory-type-filter').addEventListener('change', (e) => {
inventory_type_filter = e.target.value;
render_inventory_list();
});
qs(section_el, '#btn-add-inventory').addEventListener('click', () => open_inventory_dialog());
}
qs(section_el, '#inventory-search').value = inventory_search;
qs(section_el, '#inventory-type-filter').value = inventory_type_filter;
render_inventory_list();
}
function render_inventory_list() {
const list_el = document.getElementById('inventory-list');
const visible = all_inventory.filter(e => matches_inventory_search(e, inventory_search.trim(), inventory_type_filter));
if (visible.length === 0) {
const empty = document.createElement('div');
empty.className = 'empty-state';
empty.textContent = 'No inventory entries match your filter.';
list_el.replaceChildren(empty);
return;
}
list_el.replaceChildren(...visible.map(build_inventory_row));
}
function build_inventory_row(entry) {
const row = clone('t-inventory-row');
const comp = component_by_id(entry.component_id);
set_text(row, '.inv-component-name', comp ? comp.name : '(deleted component)');
const pill = document.createElement('span');
pill.className = `type-pill type-${entry.location_type}`;
pill.textContent = LOCATION_TYPE_LABEL[entry.location_type] ?? entry.location_type;
qs(row, '.inv-type-badge').replaceChildren(pill);
set_text(row, '.inv-location-ref', entry.location_ref);
set_text(row, '.inv-quantity', entry.quantity);
set_text(row, '.inv-notes', entry.notes);
qs(row, '.btn-edit').addEventListener('click', () => open_inventory_dialog(entry));
qs(row, '.btn-delete').addEventListener('click', () => confirm_delete(
`Delete this inventory entry (${LOCATION_TYPE_LABEL[entry.location_type]}: ${entry.location_ref || '—'})?`,
async () => {
await api.delete_inventory(entry.id);
all_inventory = all_inventory.filter(e => e.id !== entry.id);
render_inventory_list();
}
));
return row;
}
// ---------------------------------------------------------------------------
// Render: Fields section
// ---------------------------------------------------------------------------
function render_fields() {
const main = document.getElementById('main');
let section_el = document.getElementById('section-fields');
if (!section_el) {
const frag = document.getElementById('t-section-fields').content.cloneNode(true);
main.replaceChildren(frag);
section_el = document.getElementById('section-fields');
qs(section_el, '#btn-add-field').addEventListener('click', () => open_field_dialog());
}
render_field_list();
}
function render_field_list() {
const list_el = document.getElementById('field-list');
if (all_fields.length === 0) {
const empty = document.createElement('div');
empty.className = 'empty-state';
empty.textContent = 'No field definitions yet. Add some!';
list_el.replaceChildren(empty);
return;
}
list_el.replaceChildren(...all_fields.map(build_field_row));
}
function build_field_row(fdef) {
const row = clone('t-field-row');
set_text(row, '.fdef-name', fdef.name);
set_text(row, '.fdef-unit', fdef.unit || '—');
set_text(row, '.fdef-description', fdef.description || '');
qs(row, '.btn-edit').addEventListener('click', () => open_field_dialog(fdef));
qs(row, '.btn-delete').addEventListener('click', () => confirm_delete(
`Delete field definition "${fdef.name}"? This will not remove values already stored on components.`,
async () => {
await api.delete_field(fdef.id);
all_fields = all_fields.filter(f => f.id !== fdef.id);
render_field_list();
}
));
return row;
}
// ---------------------------------------------------------------------------
// Dialog: Component
// ---------------------------------------------------------------------------
let component_dialog_callback = null;
function open_component_dialog(comp = null) {
const dlg = document.getElementById('dialog-component');
const title = qs(dlg, '.dialog-title');
const name_input = qs(dlg, '#c-name');
const desc_input = qs(dlg, '#c-description');
const field_rows_el = qs(dlg, '#c-field-rows');
const add_field_sel = qs(dlg, '#c-add-field-select');
title.textContent = comp ? `Edit component` : 'Add component';
name_input.value = comp?.name ?? '';
desc_input.value = comp?.description ?? '';
// Build field rows from component's existing values
const active_fields = new Map(Object.entries(comp?.fields ?? {}));
function rebuild_field_rows() {
field_rows_el.replaceChildren(...[...active_fields.entries()].map(([fid, val]) => {
const def = field_by_id(fid);
const label_text = def ? def.name : fid;
const unit_text = def?.unit ? ` [${def.unit}]` : '';
const row_el = document.createElement('div');
row_el.className = 'c-field-input-row';
const label_el = document.createElement('div');
label_el.className = 'c-field-input-label';
label_el.textContent = label_text;
if (unit_text) {
const unit_span = document.createElement('span');
unit_span.className = 'c-field-unit-hint';
unit_span.textContent = unit_text;
label_el.appendChild(unit_span);
}
const input_el = document.createElement('input');
input_el.type = 'text';
input_el.className = 'c-field-value';
input_el.value = val;
input_el.autocomplete = 'off';
input_el.dataset.field_id = fid;
input_el.addEventListener('input', (e) => {
active_fields.set(fid, e.target.value);
});
const remove_btn = document.createElement('button');
remove_btn.type = 'button';
remove_btn.className = 'btn-icon btn-danger';
remove_btn.textContent = '✕';
remove_btn.title = 'Remove field value';
remove_btn.addEventListener('click', () => {
active_fields.delete(fid);
rebuild_field_rows();
rebuild_add_select();
});
row_el.appendChild(label_el);
row_el.appendChild(input_el);
row_el.appendChild(remove_btn);
return row_el;
}));
}
function rebuild_add_select() {
const available = all_fields.filter(f => !active_fields.has(f.id));
add_field_sel.replaceChildren(
Object.assign(document.createElement('option'), { value: '', textContent: '— add a field —' }),
...available.map(f => Object.assign(document.createElement('option'), {
value: f.id,
textContent: f.name + (f.unit ? ` [${f.unit}]` : ''),
}))
);
}
rebuild_field_rows();
rebuild_add_select();
// Wire add-field select
const old_handler = add_field_sel._change_handler;
if (old_handler) add_field_sel.removeEventListener('change', old_handler);
add_field_sel._change_handler = (e) => {
const fid = e.target.value;
if (!fid) return;
active_fields.set(fid, '');
rebuild_field_rows();
rebuild_add_select();
// Focus the new input
const inputs = field_rows_el.querySelectorAll(`[data-field_id="${fid}"]`);
if (inputs.length) inputs[inputs.length - 1].focus();
};
add_field_sel.addEventListener('change', add_field_sel._change_handler);
component_dialog_callback = async () => {
const name = name_input.value.trim();
if (!name) return;
const fields = {};
for (const [fid, val] of active_fields.entries()) {
if (val.trim()) fields[fid] = val.trim();
}
const body = { name, description: desc_input.value.trim(), fields };
if (comp) {
const result = await api.update_component(comp.id, body);
const idx = all_components.findIndex(c => c.id === comp.id);
if (idx !== -1) all_components[idx] = result.component;
} else {
const result = await api.create_component(body);
all_components.push(result.component);
all_components.sort((a, b) => a.name.localeCompare(b.name));
}
};
dlg.showModal();
name_input.focus();
}
// ---------------------------------------------------------------------------
// Dialog: Inventory entry
// ---------------------------------------------------------------------------
let inventory_dialog_callback = null;
function open_inventory_dialog(entry = null) {
const dlg = document.getElementById('dialog-inventory');
const title = qs(dlg, '.dialog-title');
const comp_sel = qs(dlg, '#i-component');
const type_sel = qs(dlg, '#i-type');
const ref_input = qs(dlg, '#i-ref');
const ref_label = qs(dlg, '#i-ref-label');
const qty_input = qs(dlg, '#i-qty');
const notes_input = qs(dlg, '#i-notes');
title.textContent = entry ? 'Edit inventory entry' : 'Add inventory entry';
// Populate component dropdown
comp_sel.replaceChildren(
Object.assign(document.createElement('option'), { value: '', textContent: '— select component —' }),
...all_components.map(c => Object.assign(document.createElement('option'), {
value: c.id,
textContent: c.name,
}))
);
comp_sel.value = entry?.component_id ?? '';
type_sel.value = entry?.location_type ?? 'physical';
ref_input.value = entry?.location_ref ?? '';
qty_input.value = entry?.quantity ?? '';
notes_input.value = entry?.notes ?? '';
function update_ref_label() {
ref_label.textContent = ref_label_for_type(type_sel.value);
}
update_ref_label();
const old_type_handler = type_sel._change_handler;
if (old_type_handler) type_sel.removeEventListener('change', old_type_handler);
type_sel._change_handler = update_ref_label;
type_sel.addEventListener('change', type_sel._change_handler);
inventory_dialog_callback = async () => {
const body = {
component_id: comp_sel.value,
location_type: type_sel.value,
location_ref: ref_input.value.trim(),
quantity: qty_input.value.trim(),
notes: notes_input.value.trim(),
};
if (entry) {
const result = await api.update_inventory(entry.id, body);
const idx = all_inventory.findIndex(e => e.id === entry.id);
if (idx !== -1) all_inventory[idx] = result.entry;
} else {
const result = await api.create_inventory(body);
all_inventory.push(result.entry);
}
};
dlg.showModal();
comp_sel.focus();
}
// ---------------------------------------------------------------------------
// Dialog: Field definition
// ---------------------------------------------------------------------------
let field_dialog_callback = null;
function open_field_dialog(fdef = null) {
const dlg = document.getElementById('dialog-field');
const title = qs(dlg, '.dialog-title');
const name_input = qs(dlg, '#f-name');
const unit_input = qs(dlg, '#f-unit');
const desc_input = qs(dlg, '#f-description');
title.textContent = fdef ? 'Edit field' : 'Add field';
name_input.value = fdef?.name ?? '';
unit_input.value = fdef?.unit ?? '';
desc_input.value = fdef?.description ?? '';
field_dialog_callback = async () => {
const body = {
name: name_input.value.trim(),
unit: unit_input.value.trim(),
description: desc_input.value.trim(),
};
if (fdef) {
const result = await api.update_field(fdef.id, body);
const idx = all_fields.findIndex(f => f.id === fdef.id);
if (idx !== -1) all_fields[idx] = result.field;
all_fields.sort((a, b) => a.name.localeCompare(b.name));
} else {
const result = await api.create_field(body);
all_fields.push(result.field);
all_fields.sort((a, b) => a.name.localeCompare(b.name));
}
};
dlg.showModal();
name_input.focus();
}
// ---------------------------------------------------------------------------
// Dialog: Confirm delete
// ---------------------------------------------------------------------------
let confirm_callback = null;
function confirm_delete(message, on_confirm) {
const dlg = document.getElementById('dialog-confirm');
document.getElementById('confirm-message').textContent = message;
confirm_callback = on_confirm;
dlg.showModal();
}
// ---------------------------------------------------------------------------
// Render dispatcher
// ---------------------------------------------------------------------------
function render() {
if (section === 'components') render_components();
else if (section === 'inventory') render_inventory();
else if (section === 'fields') render_fields();
}
// ---------------------------------------------------------------------------
// Init
// ---------------------------------------------------------------------------
async function init() {
// Load templates
const html = await fetch('/templates.html').then(r => r.text());
document.body.insertAdjacentHTML('beforeend', html);
// Clone and mount dialogs
for (const id of ['t-dialog-component', 't-dialog-inventory', 't-dialog-field', 't-dialog-confirm']) {
const frag = document.getElementById(id).content.cloneNode(true);
document.body.appendChild(frag);
}
// Wire dialog form submissions
document.getElementById('form-component').addEventListener('submit', async (e) => {
e.preventDefault();
try {
await component_dialog_callback?.();
document.getElementById('dialog-component').close();
render();
} catch (err) {
alert(`Error: ${err.message}`);
}
});
document.getElementById('c-cancel').addEventListener('click', () => {
document.getElementById('dialog-component').close();
});
document.getElementById('form-inventory').addEventListener('submit', async (e) => {
e.preventDefault();
try {
await inventory_dialog_callback?.();
document.getElementById('dialog-inventory').close();
render();
} catch (err) {
alert(`Error: ${err.message}`);
}
});
document.getElementById('i-cancel').addEventListener('click', () => {
document.getElementById('dialog-inventory').close();
});
document.getElementById('form-field').addEventListener('submit', async (e) => {
e.preventDefault();
try {
await field_dialog_callback?.();
document.getElementById('dialog-field').close();
render();
} catch (err) {
alert(`Error: ${err.message}`);
}
});
document.getElementById('f-cancel').addEventListener('click', () => {
document.getElementById('dialog-field').close();
});
document.getElementById('confirm-ok').addEventListener('click', async () => {
try {
await confirm_callback?.();
document.getElementById('dialog-confirm').close();
render();
} catch (err) {
alert(`Error: ${err.message}`);
}
});
document.getElementById('confirm-cancel').addEventListener('click', () => {
document.getElementById('dialog-confirm').close();
});
// Nav wiring
document.querySelectorAll('.nav-btn').forEach(btn => {
btn.addEventListener('click', () => {
document.querySelectorAll('.nav-btn').forEach(b => b.classList.remove('active'));
btn.classList.add('active');
section = btn.dataset.section;
render();
});
});
// Load data
await load_all();
render();
}
init();