diff --git a/public/app.mjs b/public/app.mjs index 4ee9202..08b8365 100644 --- a/public/app.mjs +++ b/public/app.mjs @@ -33,7 +33,9 @@ let all_bin_types = []; let bin_tab = 'bins'; // 'bins' | 'sources' | 'types' let bin_editor_instance = null; let bin_editor_bin_id = null; +let bin_editor_get_fields = null; let bin_type_dialog_callback = null; +let bin_content_dialog_callback = null; // --------------------------------------------------------------------------- // Data loading @@ -159,6 +161,98 @@ function field_by_id(id) { return all_fields.find(f => f.id === id); } +// Attaches a reusable field editor UI to existing DOM elements. +// Returns { get_fields() } — call get_fields() to collect trimmed non-empty values. +function build_field_editor(rows_el, add_sel_el, new_btn_el, initial_fields) { + const active = new Map(Object.entries(initial_fields ?? {})); + + function rebuild_rows() { + const sorted = [...active.entries()].sort(([a], [b]) => + (field_by_id(a)?.name ?? a).localeCompare(field_by_id(b)?.name ?? b)); + rows_el.replaceChildren(...sorted.map(([fid, val]) => { + const def = field_by_id(fid); + const row = document.createElement('div'); + row.className = 'c-field-input-row'; + const lbl = document.createElement('div'); + lbl.className = 'c-field-input-label'; + lbl.textContent = def ? def.name : fid; + if (def?.unit) { + const u = document.createElement('span'); + u.className = 'c-field-unit-hint'; + u.textContent = `[${def.unit}]`; + lbl.appendChild(u); + } + const inp = document.createElement('input'); + inp.type = 'text'; + inp.className = 'c-field-value'; + inp.value = val; + inp.autocomplete = 'off'; + inp.dataset.field_id = fid; + inp.addEventListener('input', e => active.set(fid, e.target.value)); + const rm = document.createElement('button'); + rm.type = 'button'; + rm.className = 'btn-icon btn-danger'; + rm.textContent = '✕'; + rm.title = 'Remove field'; + rm.addEventListener('click', () => { active.delete(fid); rebuild_rows(); rebuild_sel(); }); + row.append(lbl, inp, rm); + return row; + })); + } + + function rebuild_sel() { + const available = all_fields.filter(f => !active.has(f.id)); + add_sel_el.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_rows(); + rebuild_sel(); + + const prev = add_sel_el._field_handler; + if (prev) add_sel_el.removeEventListener('change', prev); + add_sel_el._field_handler = (e) => { + const fid = e.target.value; + if (!fid) return; + active.set(fid, ''); + rebuild_rows(); + rebuild_sel(); + rows_el.querySelector(`[data-field_id="${fid}"]`)?.focus(); + }; + add_sel_el.addEventListener('change', add_sel_el._field_handler); + + if (new_btn_el) { + new_btn_el.onclick = () => { + const known = new Set(all_fields.map(f => f.id)); + open_field_dialog(null); + document.getElementById('dialog-field').addEventListener('close', () => { + const nf = all_fields.find(f => !known.has(f.id)); + rebuild_sel(); + if (nf) { + active.set(nf.id, ''); + rebuild_rows(); + rebuild_sel(); + rows_el.querySelector(`[data-field_id="${nf.id}"]`)?.focus(); + } + }, { once: true }); + }; + } + + return { + get_fields() { + const out = {}; + for (const [fid, val] of active.entries()) { + if (val.trim()) out[fid] = val.trim(); + } + return out; + }, + }; +} + function matches_search(component, query) { if (!query) return true; const words = query.toLowerCase().split(/\s+/).filter(Boolean); @@ -2090,6 +2184,14 @@ function open_bin_type_dialog(bt = null) { document.getElementById('bt-width').value = bt?.phys_w ?? ''; document.getElementById('bt-height').value = bt?.phys_h ?? ''; document.getElementById('bt-description').value = bt?.description ?? ''; + + const { get_fields } = build_field_editor( + document.getElementById('bt-field-rows'), + document.getElementById('bt-add-field-select'), + document.getElementById('bt-new-field'), + bt?.fields ?? {} + ); + bin_type_dialog_callback = async () => { const name = document.getElementById('bt-name').value.trim(); const phys_w = parseFloat(document.getElementById('bt-width').value); @@ -2097,11 +2199,12 @@ function open_bin_type_dialog(bt = null) { const description = document.getElementById('bt-description').value.trim(); if (!name) { alert('Name is required.'); return; } if (!(phys_w > 0) || !(phys_h > 0)) { alert('Dimensions must be positive numbers.'); return; } + const fields = get_fields(); if (bt) { - const r = await api.update_bin_type(bt.id, { name, phys_w, phys_h, description }); + const r = await api.update_bin_type(bt.id, { name, phys_w, phys_h, description, fields }); all_bin_types = all_bin_types.map(t => t.id === bt.id ? r.bin_type : t); } else { - const r = await api.create_bin_type({ name, phys_w, phys_h, description }); + const r = await api.create_bin_type({ name, phys_w, phys_h, description, fields }); all_bin_types = [...all_bin_types, r.bin_type].sort((a, b) => a.name.localeCompare(b.name)); } render_bin_types_list(); @@ -2166,6 +2269,91 @@ function render_bin_source_list() { })); } +function render_bin_contents(bin_id, container_el) { + const bin = all_bins.find(b => b.id === bin_id); + const contents = bin?.contents ?? []; + if (contents.length === 0) { + const empty = clone('t-empty-block'); + empty.textContent = 'No items yet.'; + container_el.replaceChildren(empty); + return; + } + container_el.replaceChildren(...contents.map(item => { + const row = clone('t-bin-content-row'); + let display_name = item.name ?? ''; + if (item.type === 'component') { + const comp = all_components.find(c => c.id === item.component_id); + display_name = comp ? component_display_name(comp) : `[unknown component]`; + } + qs(row, '.bin-content-name').textContent = display_name; + qs(row, '.bin-content-qty').textContent = item.quantity ? `×${item.quantity}` : ''; + qs(row, '.bin-content-notes').textContent = item.notes || ''; + qs(row, '.btn-edit').addEventListener('click', () => open_bin_content_dialog(bin_id, item)); + qs(row, '.btn-delete').addEventListener('click', async () => { + try { + await api.delete_bin_content(bin_id, item.id); + const cur = all_bins.find(b => b.id === bin_id); + const updated = { ...cur, contents: cur.contents.filter(c => c.id !== item.id) }; + all_bins = all_bins.map(b => b.id === bin_id ? updated : b); + render_bin_contents(bin_id, container_el); + } catch (err) { + alert(err.message); + } + }); + return row; + })); +} + +function open_bin_content_dialog(bin_id, item = null) { + const dlg = document.getElementById('dialog-bin-content'); + qs(dlg, '.dialog-title').textContent = item ? 'Edit item' : 'Add item'; + + const type_sel = document.getElementById('bc-type'); + const comp_row = document.getElementById('bc-component-row'); + const name_row = document.getElementById('bc-name-row'); + const comp_sel = document.getElementById('bc-component'); + const name_inp = document.getElementById('bc-name'); + const qty_inp = document.getElementById('bc-quantity'); + const notes_inp = document.getElementById('bc-notes'); + + comp_sel.replaceChildren( + new Option('— select —', ''), + ...all_components.map(c => new Option(component_display_name(c), c.id)) + ); + + type_sel.value = item?.type ?? 'component'; + comp_sel.value = item?.component_id ?? ''; + name_inp.value = item?.name ?? ''; + qty_inp.value = item?.quantity ?? ''; + notes_inp.value = item?.notes ?? ''; + type_sel.disabled = !!item; + + function sync_type() { + const is_comp = type_sel.value === 'component'; + comp_row.hidden = !is_comp; + name_row.hidden = is_comp; + } + type_sel.onchange = sync_type; + sync_type(); + + bin_content_dialog_callback = async () => { + const body = { + type: type_sel.value, + component_id: type_sel.value === 'component' ? comp_sel.value : undefined, + name: type_sel.value === 'item' ? name_inp.value.trim() : undefined, + quantity: qty_inp.value.trim(), + notes: notes_inp.value.trim(), + }; + const result = item + ? await api.update_bin_content(bin_id, item.id, body) + : await api.add_bin_content(bin_id, body); + all_bins = all_bins.map(b => b.id === bin_id ? result.bin : b); + render_bin_contents(bin_id, document.getElementById('bin-contents-list')); + }; + + dlg.showModal(); +} + function open_bin_editor(bin) { const dlg = document.getElementById('dialog-bin-editor'); if (!dlg) return; @@ -2197,8 +2385,40 @@ function open_bin_editor(bin) { type_sel.addEventListener('change', sync_dims_row); sync_dims_row(); - // Show dialog first so the canvas has correct layout dimensions before - // load_image reads parentElement.clientWidth to size itself. + // Tabs + let active_tab = 'corners'; + const tab_panels = { + corners: document.getElementById('bin-editor-tab-corners'), + fields: document.getElementById('bin-editor-tab-fields'), + contents: document.getElementById('bin-editor-tab-contents'), + }; + qs(dlg, '#bin-editor-tabs').onclick = (e) => { + const tab = e.target.dataset.tab; + if (!tab) return; + active_tab = tab; + qs(dlg, '#bin-editor-tabs').querySelectorAll('.tab-btn').forEach(btn => { + btn.classList.toggle('active', btn.dataset.tab === tab); + }); + for (const [name, el] of Object.entries(tab_panels)) { + el.hidden = name !== tab; + } + const save_btn = document.getElementById('bin-editor-save'); + save_btn.textContent = active_tab === 'corners' ? 'Save & process' : 'Save'; + }; + + // Fields tab + bin_editor_get_fields = build_field_editor( + document.getElementById('bin-field-rows'), + document.getElementById('bin-add-field-select'), + document.getElementById('bin-new-field'), + bin.fields ?? {} + ).get_fields; + + // Contents tab + render_bin_contents(bin.id, document.getElementById('bin-contents-list')); + document.getElementById('bin-add-content').onclick = () => open_bin_content_dialog(bin.id); + + // Show dialog first so the canvas has correct layout dimensions dlg.showModal(); const canvas = document.getElementById('bin-editor-canvas'); @@ -2343,7 +2563,7 @@ async function init() { const html = await fetch('/templates.html').then(r => r.text()); document.body.insertAdjacentHTML('beforeend', html); - for (const id of ['t-dialog-component', 't-dialog-inventory', 't-dialog-field', 't-dialog-confirm', 't-dialog-file-picker', 't-dialog-bin-editor', 't-dialog-bin-type']) { + for (const id of ['t-dialog-component', 't-dialog-inventory', 't-dialog-field', 't-dialog-confirm', 't-dialog-file-picker', 't-dialog-bin-editor', 't-dialog-bin-type', 't-dialog-bin-content']) { document.body.appendChild(document.getElementById(id).content.cloneNode(true)); } @@ -2453,26 +2673,46 @@ async function init() { }); document.getElementById('bin-editor-save').addEventListener('click', async () => { - const corners = bin_editor_instance?.get_corners(); + const id = bin_editor_bin_id; const name = document.getElementById('bin-editor-name').value.trim() || 'Bin'; const type_id = document.getElementById('bin-editor-type').value || null; - const phys_w = parseFloat(document.getElementById('bin-editor-width').value) || null; - const phys_h = parseFloat(document.getElementById('bin-editor-height').value) || null; - if (!corners) { alert('Load an image first.'); return; } - const id = bin_editor_bin_id; + const fields = bin_editor_get_fields?.() ?? {}; try { - await api.update_bin(id, { name, type_id }); - const r = await api.update_bin_corners(id, corners, phys_w, phys_h); - all_bins = all_bins.map(b => b.id === id ? r.bin : b); + // Always save name, type, and fields + const corners = bin_editor_instance?.get_corners(); + if (corners) { + // Corners tab active (or image loaded) — also re-process + const phys_w = parseFloat(document.getElementById('bin-editor-width').value) || null; + const phys_h = parseFloat(document.getElementById('bin-editor-height').value) || null; + await api.update_bin(id, { name, type_id, fields }); + const r = await api.update_bin_corners(id, corners, phys_w, phys_h); + all_bins = all_bins.map(b => b.id === id ? r.bin : b); + } else { + const r = await api.update_bin(id, { name, type_id, fields }); + all_bins = all_bins.map(b => b.id === id ? r.bin : b); + } document.getElementById('dialog-bin-editor').close(); bin_editor_instance = null; bin_editor_bin_id = null; + bin_editor_get_fields = null; render_bins(); } catch (err) { alert(err.message); } }); + document.getElementById('bc-cancel').addEventListener('click', () => { + document.getElementById('dialog-bin-content').close(); + }); + document.getElementById('bc-save').addEventListener('click', async () => { + try { + await bin_content_dialog_callback?.(); + document.getElementById('dialog-bin-content').close(); + } catch (err) { + alert(err.message); + } + }); + document.querySelectorAll('.nav-btn').forEach(btn => { btn.addEventListener('click', () => navigate('/' + btn.dataset.section)); }); diff --git a/public/style.css b/public/style.css index 14d0d49..1c1e279 100644 --- a/public/style.css +++ b/public/style.css @@ -2033,3 +2033,36 @@ nav { text-overflow: ellipsis; white-space: nowrap; } + +.bin-content-row { + display: flex; + align-items: center; + gap: 0.75rem; + padding: 0.4rem 0; + border-bottom: 1px solid var(--border); +} + +.bin-content-name { + flex: 1; + font-weight: 500; + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.bin-content-qty { + font-size: 0.85rem; + color: var(--text-muted); + flex-shrink: 0; +} + +.bin-content-notes { + font-size: 0.8rem; + color: var(--text-faint); + flex: 1; + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +}