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_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 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_h = parseFloat(document.getElementById('bin-editor-height').value) || null;
|
||||
if (!corners) { alert('Load an image first.'); return; }
|
||||
const id = bin_editor_bin_id;
|
||||
try {
|
||||
await api.update_bin(id, { name, type_id });
|
||||
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));
|
||||
});
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user