Add bin types: reusable named dimension presets for bins
Bin types store a name, physical W×H in mm, and optional description. When editing a bin, a type can be selected from a dropdown; this pre-fills and locks the dimension inputs. Custom dimensions remain available when no type is selected. - lib/storage.mjs: bin type CRUD with bt: prefix - server.mjs: /api/bin-types CRUD routes; type_id accepted on bin create/update routes; DELETE protected if any bin references the type; type dims copied onto bin when type_id is set - public/lib/api.mjs: bin type wrappers; rename_bin → update_bin (accepts any fields) - public/templates.html: Types tab in bins section; t-bin-type-row; t-dialog-bin-type; type selector in bin editor dialog - public/app.mjs: all_bin_types state loaded at startup; render_bin_types_list(); open_bin_type_dialog(); type selector in open_bin_editor(); /bins/types routing - public/style.css: bin types list styles Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
126
public/app.mjs
126
public/app.mjs
@@ -29,16 +29,18 @@ let all_drafts = [];
|
||||
let all_templates = [];
|
||||
let all_pdfs = [];
|
||||
let all_bins = [];
|
||||
let bin_tab = 'bins'; // 'bins' | 'sources'
|
||||
let all_bin_types = [];
|
||||
let bin_tab = 'bins'; // 'bins' | 'sources' | 'types'
|
||||
let bin_editor_instance = null;
|
||||
let bin_editor_bin_id = null;
|
||||
let bin_type_dialog_callback = null;
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Data loading
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
async function load_all() {
|
||||
const [cf, ci, cmp, gr, dr, sr, ct, pd, bn] = await Promise.all([
|
||||
const [cf, ci, cmp, gr, dr, sr, ct, pd, bn, bt] = await Promise.all([
|
||||
api.get_fields(),
|
||||
api.get_inventory(),
|
||||
api.get_components(),
|
||||
@@ -48,6 +50,7 @@ async function load_all() {
|
||||
api.get_component_templates(),
|
||||
api.get_pdfs(),
|
||||
api.get_bins(),
|
||||
api.get_bin_types(),
|
||||
]);
|
||||
all_fields = cf.fields;
|
||||
all_inventory = ci.entries;
|
||||
@@ -58,6 +61,7 @@ async function load_all() {
|
||||
all_templates = ct.templates;
|
||||
all_pdfs = pd.pdfs;
|
||||
all_bins = bn.bins;
|
||||
all_bin_types = bt.bin_types;
|
||||
compile_templates();
|
||||
}
|
||||
|
||||
@@ -2009,6 +2013,12 @@ function render_bins() {
|
||||
history.replaceState(null, '', '/bins/sources');
|
||||
update_bin_tabs(sec);
|
||||
});
|
||||
qs(sec, '#btn-tab-bin-types').addEventListener('click', () => {
|
||||
bin_tab = 'types';
|
||||
history.replaceState(null, '', '/bins/types');
|
||||
update_bin_tabs(sec);
|
||||
});
|
||||
qs(sec, '#btn-add-bin-type').addEventListener('click', () => open_bin_type_dialog());
|
||||
qs(sec, '#bin-source-upload-input').addEventListener('change', async (e) => {
|
||||
const files = [...e.target.files];
|
||||
if (!files.length) return;
|
||||
@@ -2033,14 +2043,70 @@ function render_bins() {
|
||||
update_bin_tabs(sec);
|
||||
render_bin_list();
|
||||
render_bin_source_list();
|
||||
render_bin_types_list();
|
||||
}
|
||||
|
||||
function update_bin_tabs(sec) {
|
||||
qs(sec, '#btn-tab-bins').classList.toggle('active', bin_tab === 'bins');
|
||||
qs(sec, '#btn-tab-bin-sources').classList.toggle('active', bin_tab === 'sources');
|
||||
qs(sec, '#btn-tab-bin-types').classList.toggle('active', bin_tab === 'types');
|
||||
qs(sec, '#btn-upload-bin-sources').hidden = (bin_tab !== 'sources');
|
||||
qs(sec, '#btn-add-bin-type').hidden = (bin_tab !== 'types');
|
||||
qs(sec, '#tab-bins-content').hidden = (bin_tab !== 'bins');
|
||||
qs(sec, '#tab-bin-sources-content').hidden = (bin_tab !== 'sources');
|
||||
qs(sec, '#tab-bin-types-content').hidden = (bin_tab !== 'types');
|
||||
}
|
||||
|
||||
function render_bin_types_list() {
|
||||
const list_el = document.getElementById('bin-types-list');
|
||||
if (!list_el) return;
|
||||
if (all_bin_types.length === 0) {
|
||||
const el = clone('t-empty-block');
|
||||
el.textContent = 'No bin types yet. Click "+ Add type" to define one.';
|
||||
list_el.replaceChildren(el);
|
||||
return;
|
||||
}
|
||||
list_el.replaceChildren(...all_bin_types.map(bt => {
|
||||
const row = clone('t-bin-type-row');
|
||||
qs(row, '.bin-type-name').textContent = bt.name;
|
||||
qs(row, '.bin-type-dims').textContent = `${bt.phys_w} × ${bt.phys_h} mm`;
|
||||
qs(row, '.bin-type-desc').textContent = bt.description || '';
|
||||
qs(row, '.btn-edit').addEventListener('click', () => open_bin_type_dialog(bt));
|
||||
qs(row, '.btn-delete').addEventListener('click', () => {
|
||||
confirm_delete(`Delete bin type "${bt.name}"?`, async () => {
|
||||
await api.delete_bin_type(bt.id);
|
||||
all_bin_types = all_bin_types.filter(t => t.id !== bt.id);
|
||||
render_bin_types_list();
|
||||
});
|
||||
});
|
||||
return row;
|
||||
}));
|
||||
}
|
||||
|
||||
function open_bin_type_dialog(bt = null) {
|
||||
const dlg = document.getElementById('dialog-bin-type');
|
||||
dlg.querySelector('.dialog-title').textContent = bt ? 'Edit bin type' : 'Add bin type';
|
||||
document.getElementById('bt-name').value = bt?.name ?? '';
|
||||
document.getElementById('bt-width').value = bt?.phys_w ?? '';
|
||||
document.getElementById('bt-height').value = bt?.phys_h ?? '';
|
||||
document.getElementById('bt-description').value = bt?.description ?? '';
|
||||
bin_type_dialog_callback = async () => {
|
||||
const name = document.getElementById('bt-name').value.trim();
|
||||
const phys_w = parseFloat(document.getElementById('bt-width').value);
|
||||
const phys_h = parseFloat(document.getElementById('bt-height').value);
|
||||
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; }
|
||||
if (bt) {
|
||||
const r = await api.update_bin_type(bt.id, { name, phys_w, phys_h, description });
|
||||
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 });
|
||||
all_bin_types = [...all_bin_types, r.bin_type].sort((a, b) => a.name.localeCompare(b.name));
|
||||
}
|
||||
render_bin_types_list();
|
||||
};
|
||||
dlg.showModal();
|
||||
}
|
||||
|
||||
function render_bin_list() {
|
||||
@@ -2106,8 +2172,30 @@ function open_bin_editor(bin) {
|
||||
|
||||
bin_editor_bin_id = bin.id;
|
||||
document.getElementById('bin-editor-name').value = bin.name;
|
||||
document.getElementById('bin-editor-width').value = bin.phys_w ?? '';
|
||||
document.getElementById('bin-editor-height').value = bin.phys_h ?? '';
|
||||
|
||||
// Populate type selector
|
||||
const type_sel = document.getElementById('bin-editor-type');
|
||||
type_sel.replaceChildren(new Option('— Custom —', ''));
|
||||
for (const bt of all_bin_types) {
|
||||
type_sel.appendChild(new Option(`${bt.name} (${bt.phys_w}×${bt.phys_h}mm)`, bt.id));
|
||||
}
|
||||
type_sel.value = bin.type_id ?? '';
|
||||
|
||||
const dims_row = document.getElementById('bin-editor-dims-row');
|
||||
function sync_dims_row() {
|
||||
const bt = all_bin_types.find(t => t.id === type_sel.value);
|
||||
if (bt) {
|
||||
document.getElementById('bin-editor-width').value = bt.phys_w;
|
||||
document.getElementById('bin-editor-height').value = bt.phys_h;
|
||||
dims_row.hidden = true;
|
||||
} else {
|
||||
document.getElementById('bin-editor-width').value = bin.phys_w ?? '';
|
||||
document.getElementById('bin-editor-height').value = bin.phys_h ?? '';
|
||||
dims_row.hidden = false;
|
||||
}
|
||||
}
|
||||
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.
|
||||
@@ -2162,7 +2250,7 @@ function parse_url() {
|
||||
section = 'images';
|
||||
} else if (p0 === 'bins') {
|
||||
section = 'bins';
|
||||
bin_tab = p1 === 'sources' ? 'sources' : 'bins';
|
||||
bin_tab = p1 === 'sources' ? 'sources' : p1 === 'types' ? 'types' : 'bins';
|
||||
} else if (p0 === 'grids') {
|
||||
section = 'grids';
|
||||
if (p1 === 'sources') {
|
||||
@@ -2255,7 +2343,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']) {
|
||||
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']) {
|
||||
document.body.appendChild(document.getElementById(id).content.cloneNode(true));
|
||||
}
|
||||
|
||||
@@ -2352,22 +2440,30 @@ async function init() {
|
||||
bin_editor_bin_id = null;
|
||||
});
|
||||
|
||||
document.getElementById('bt-cancel').addEventListener('click', () => {
|
||||
document.getElementById('dialog-bin-type').close();
|
||||
});
|
||||
document.getElementById('bt-save').addEventListener('click', async () => {
|
||||
try {
|
||||
await bin_type_dialog_callback?.();
|
||||
document.getElementById('dialog-bin-type').close();
|
||||
} catch (err) {
|
||||
alert(err.message);
|
||||
}
|
||||
});
|
||||
|
||||
document.getElementById('bin-editor-save').addEventListener('click', async () => {
|
||||
const corners = bin_editor_instance?.get_corners();
|
||||
const name = document.getElementById('bin-editor-name').value.trim();
|
||||
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;
|
||||
try {
|
||||
let updated;
|
||||
if (name) {
|
||||
const r = await api.rename_bin(id, name);
|
||||
updated = r.bin;
|
||||
}
|
||||
const r2 = await api.update_bin_corners(id, corners, phys_w, phys_h);
|
||||
updated = r2.bin;
|
||||
all_bins = all_bins.map(b => b.id === id ? updated : b);
|
||||
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);
|
||||
document.getElementById('dialog-bin-editor').close();
|
||||
bin_editor_instance = null;
|
||||
bin_editor_bin_id = null;
|
||||
|
||||
Reference in New Issue
Block a user