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:
260
public/app.mjs
260
public/app.mjs
@@ -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 fields = bin_editor_get_fields?.() ?? {};
|
||||||
|
try {
|
||||||
|
// 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_w = parseFloat(document.getElementById('bin-editor-width').value) || null;
|
||||||
const phys_h = parseFloat(document.getElementById('bin-editor-height').value) || null;
|
const phys_h = parseFloat(document.getElementById('bin-editor-height').value) || null;
|
||||||
if (!corners) { alert('Load an image first.'); return; }
|
await api.update_bin(id, { name, type_id, fields });
|
||||||
const id = bin_editor_bin_id;
|
|
||||||
try {
|
|
||||||
await api.update_bin(id, { name, type_id });
|
|
||||||
const r = await api.update_bin_corners(id, corners, phys_w, phys_h);
|
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);
|
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));
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user