Wire up fields and contents UI for bins and bin types

- build_field_editor() helper: reusable field row editor shared by
  bin editor, bin type dialog (open_component_dialog still uses its own)
- open_bin_editor: tabs (Corners|Fields|Contents), field editor on
  Fields tab, content list on Contents tab, save always persists fields
- open_bin_type_dialog: field editor appended below existing form fields
- render_bin_contents / open_bin_content_dialog: content item CRUD
  (component ref or free-text name, quantity, notes); add/edit/delete
  update all_bins immediately without closing the editor
- bc-cancel / bc-save handlers registered in init()
- bin-content-row CSS

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-04-03 03:37:32 +00:00
parent 33c8ff274e
commit 46ce10289e
2 changed files with 286 additions and 13 deletions

View File

@@ -33,7 +33,9 @@ let all_bin_types = [];
let bin_tab = 'bins'; // 'bins' | 'sources' | 'types' let bin_tab = 'bins'; // 'bins' | 'sources' | 'types'
let bin_editor_instance = null; let bin_editor_instance = null;
let bin_editor_bin_id = null; let bin_editor_bin_id = null;
let bin_editor_get_fields = null;
let bin_type_dialog_callback = null; let bin_type_dialog_callback = null;
let bin_content_dialog_callback = null;
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
// Data loading // Data loading
@@ -159,6 +161,98 @@ function field_by_id(id) {
return all_fields.find(f => f.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) { function matches_search(component, query) {
if (!query) return true; if (!query) return true;
const words = query.toLowerCase().split(/\s+/).filter(Boolean); 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-width').value = bt?.phys_w ?? '';
document.getElementById('bt-height').value = bt?.phys_h ?? ''; document.getElementById('bt-height').value = bt?.phys_h ?? '';
document.getElementById('bt-description').value = bt?.description ?? ''; 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 () => { bin_type_dialog_callback = async () => {
const name = document.getElementById('bt-name').value.trim(); const name = document.getElementById('bt-name').value.trim();
const phys_w = parseFloat(document.getElementById('bt-width').value); 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(); const description = document.getElementById('bt-description').value.trim();
if (!name) { alert('Name is required.'); return; } if (!name) { alert('Name is required.'); return; }
if (!(phys_w > 0) || !(phys_h > 0)) { alert('Dimensions must be positive numbers.'); return; } if (!(phys_w > 0) || !(phys_h > 0)) { alert('Dimensions must be positive numbers.'); return; }
const fields = get_fields();
if (bt) { 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); all_bin_types = all_bin_types.map(t => t.id === bt.id ? r.bin_type : t);
} else { } 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)); all_bin_types = [...all_bin_types, r.bin_type].sort((a, b) => a.name.localeCompare(b.name));
} }
render_bin_types_list(); 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) { function open_bin_editor(bin) {
const dlg = document.getElementById('dialog-bin-editor'); const dlg = document.getElementById('dialog-bin-editor');
if (!dlg) return; if (!dlg) return;
@@ -2197,8 +2385,40 @@ function open_bin_editor(bin) {
type_sel.addEventListener('change', sync_dims_row); type_sel.addEventListener('change', sync_dims_row);
sync_dims_row(); sync_dims_row();
// Show dialog first so the canvas has correct layout dimensions before // Tabs
// load_image reads parentElement.clientWidth to size itself. 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(); dlg.showModal();
const canvas = document.getElementById('bin-editor-canvas'); const canvas = document.getElementById('bin-editor-canvas');
@@ -2343,7 +2563,7 @@ async function init() {
const html = await fetch('/templates.html').then(r => r.text()); const html = await fetch('/templates.html').then(r => r.text());
document.body.insertAdjacentHTML('beforeend', html); 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)); document.body.appendChild(document.getElementById(id).content.cloneNode(true));
} }
@@ -2453,26 +2673,46 @@ async function init() {
}); });
document.getElementById('bin-editor-save').addEventListener('click', async () => { 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 name = document.getElementById('bin-editor-name').value.trim() || 'Bin';
const type_id = document.getElementById('bin-editor-type').value || null; const type_id = document.getElementById('bin-editor-type').value || null;
const phys_w = parseFloat(document.getElementById('bin-editor-width').value) || null; const fields = bin_editor_get_fields?.() ?? {};
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;
try { try {
await api.update_bin(id, { name, type_id }); // Always save name, type, and fields
const r = await api.update_bin_corners(id, corners, phys_w, phys_h); const corners = bin_editor_instance?.get_corners();
all_bins = all_bins.map(b => b.id === id ? r.bin : b); 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(); document.getElementById('dialog-bin-editor').close();
bin_editor_instance = null; bin_editor_instance = null;
bin_editor_bin_id = null; bin_editor_bin_id = null;
bin_editor_get_fields = null;
render_bins(); render_bins();
} catch (err) { } catch (err) {
alert(err.message); 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 => { document.querySelectorAll('.nav-btn').forEach(btn => {
btn.addEventListener('click', () => navigate('/' + btn.dataset.section)); btn.addEventListener('click', () => navigate('/' + btn.dataset.section));
}); });

View File

@@ -2033,3 +2033,36 @@ nav {
text-overflow: ellipsis; text-overflow: ellipsis;
white-space: nowrap; 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;
}