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:
@@ -174,6 +174,28 @@ export function find_pdf_references(pdf_id) {
|
|||||||
return list_components().filter(c => c.file_ids?.includes(pdf_id));
|
return list_components().filter(c => c.file_ids?.includes(pdf_id));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// --- Bin types ---
|
||||||
|
|
||||||
|
export function list_bin_types() {
|
||||||
|
const result = [];
|
||||||
|
for (const [key] of store.data.entries()) {
|
||||||
|
if (key.startsWith('bt:')) result.push(store.get(key));
|
||||||
|
}
|
||||||
|
return result.sort((a, b) => a.name.localeCompare(b.name));
|
||||||
|
}
|
||||||
|
|
||||||
|
export function get_bin_type(id) {
|
||||||
|
return store.get(`bt:${id}`) ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function set_bin_type(bt) {
|
||||||
|
store.set(`bt:${bt.id}`, bt);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function delete_bin_type(id) {
|
||||||
|
return store.delete(`bt:${id}`);
|
||||||
|
}
|
||||||
|
|
||||||
// --- Bins ---
|
// --- Bins ---
|
||||||
|
|
||||||
export function list_bins() {
|
export function list_bins() {
|
||||||
|
|||||||
122
public/app.mjs
122
public/app.mjs
@@ -29,16 +29,18 @@ let all_drafts = [];
|
|||||||
let all_templates = [];
|
let all_templates = [];
|
||||||
let all_pdfs = [];
|
let all_pdfs = [];
|
||||||
let all_bins = [];
|
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_instance = null;
|
||||||
let bin_editor_bin_id = null;
|
let bin_editor_bin_id = null;
|
||||||
|
let bin_type_dialog_callback = null;
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// Data loading
|
// Data loading
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
async function load_all() {
|
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_fields(),
|
||||||
api.get_inventory(),
|
api.get_inventory(),
|
||||||
api.get_components(),
|
api.get_components(),
|
||||||
@@ -48,6 +50,7 @@ async function load_all() {
|
|||||||
api.get_component_templates(),
|
api.get_component_templates(),
|
||||||
api.get_pdfs(),
|
api.get_pdfs(),
|
||||||
api.get_bins(),
|
api.get_bins(),
|
||||||
|
api.get_bin_types(),
|
||||||
]);
|
]);
|
||||||
all_fields = cf.fields;
|
all_fields = cf.fields;
|
||||||
all_inventory = ci.entries;
|
all_inventory = ci.entries;
|
||||||
@@ -58,6 +61,7 @@ async function load_all() {
|
|||||||
all_templates = ct.templates;
|
all_templates = ct.templates;
|
||||||
all_pdfs = pd.pdfs;
|
all_pdfs = pd.pdfs;
|
||||||
all_bins = bn.bins;
|
all_bins = bn.bins;
|
||||||
|
all_bin_types = bt.bin_types;
|
||||||
compile_templates();
|
compile_templates();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -2009,6 +2013,12 @@ function render_bins() {
|
|||||||
history.replaceState(null, '', '/bins/sources');
|
history.replaceState(null, '', '/bins/sources');
|
||||||
update_bin_tabs(sec);
|
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) => {
|
qs(sec, '#bin-source-upload-input').addEventListener('change', async (e) => {
|
||||||
const files = [...e.target.files];
|
const files = [...e.target.files];
|
||||||
if (!files.length) return;
|
if (!files.length) return;
|
||||||
@@ -2033,14 +2043,70 @@ function render_bins() {
|
|||||||
update_bin_tabs(sec);
|
update_bin_tabs(sec);
|
||||||
render_bin_list();
|
render_bin_list();
|
||||||
render_bin_source_list();
|
render_bin_source_list();
|
||||||
|
render_bin_types_list();
|
||||||
}
|
}
|
||||||
|
|
||||||
function update_bin_tabs(sec) {
|
function update_bin_tabs(sec) {
|
||||||
qs(sec, '#btn-tab-bins').classList.toggle('active', bin_tab === 'bins');
|
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-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-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-bins-content').hidden = (bin_tab !== 'bins');
|
||||||
qs(sec, '#tab-bin-sources-content').hidden = (bin_tab !== 'sources');
|
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() {
|
function render_bin_list() {
|
||||||
@@ -2106,8 +2172,30 @@ function open_bin_editor(bin) {
|
|||||||
|
|
||||||
bin_editor_bin_id = bin.id;
|
bin_editor_bin_id = bin.id;
|
||||||
document.getElementById('bin-editor-name').value = bin.name;
|
document.getElementById('bin-editor-name').value = bin.name;
|
||||||
|
|
||||||
|
// 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-width').value = bin.phys_w ?? '';
|
||||||
document.getElementById('bin-editor-height').value = bin.phys_h ?? '';
|
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
|
// Show dialog first so the canvas has correct layout dimensions before
|
||||||
// load_image reads parentElement.clientWidth to size itself.
|
// load_image reads parentElement.clientWidth to size itself.
|
||||||
@@ -2162,7 +2250,7 @@ function parse_url() {
|
|||||||
section = 'images';
|
section = 'images';
|
||||||
} else if (p0 === 'bins') {
|
} else if (p0 === 'bins') {
|
||||||
section = 'bins';
|
section = 'bins';
|
||||||
bin_tab = p1 === 'sources' ? 'sources' : 'bins';
|
bin_tab = p1 === 'sources' ? 'sources' : p1 === 'types' ? 'types' : 'bins';
|
||||||
} else if (p0 === 'grids') {
|
} else if (p0 === 'grids') {
|
||||||
section = 'grids';
|
section = 'grids';
|
||||||
if (p1 === 'sources') {
|
if (p1 === 'sources') {
|
||||||
@@ -2255,7 +2343,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']) {
|
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));
|
document.body.appendChild(document.getElementById(id).content.cloneNode(true));
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -2352,22 +2440,30 @@ async function init() {
|
|||||||
bin_editor_bin_id = null;
|
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 () => {
|
document.getElementById('bin-editor-save').addEventListener('click', async () => {
|
||||||
const corners = bin_editor_instance?.get_corners();
|
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_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; }
|
if (!corners) { alert('Load an image first.'); return; }
|
||||||
const id = bin_editor_bin_id;
|
const id = bin_editor_bin_id;
|
||||||
try {
|
try {
|
||||||
let updated;
|
await api.update_bin(id, { name, type_id });
|
||||||
if (name) {
|
const r = await api.update_bin_corners(id, corners, phys_w, phys_h);
|
||||||
const r = await api.rename_bin(id, name);
|
all_bins = all_bins.map(b => b.id === id ? r.bin : b);
|
||||||
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);
|
|
||||||
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;
|
||||||
|
|||||||
@@ -61,11 +61,17 @@ export async function upload_pdf(file, display_name, filename) {
|
|||||||
return data;
|
return data;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Bin types
|
||||||
|
export const get_bin_types = () => req('GET', '/api/bin-types');
|
||||||
|
export const create_bin_type = (body) => req('POST', '/api/bin-types', body);
|
||||||
|
export const update_bin_type = (id, body) => req('PUT', `/api/bin-types/${id}`, body);
|
||||||
|
export const delete_bin_type = (id) => req('DELETE', `/api/bin-types/${id}`);
|
||||||
|
|
||||||
// Bins
|
// Bins
|
||||||
export const get_bins = () => req('GET', '/api/bins');
|
export const get_bins = () => req('GET', '/api/bins');
|
||||||
export const create_bin_from_source = (source_id, name) => req('POST', '/api/bins/from-source', { source_id, name });
|
export const create_bin_from_source = (source_id, name) => req('POST', '/api/bins/from-source', { source_id, name });
|
||||||
export const get_bin = (id) => req('GET', `/api/bins/${id}`);
|
export const get_bin = (id) => req('GET', `/api/bins/${id}`);
|
||||||
export const rename_bin = (id, name) => req('PUT', `/api/bins/${id}`, { name });
|
export const update_bin = (id, body) => req('PUT', `/api/bins/${id}`, body);
|
||||||
export const update_bin_corners = (id, corners, phys_w, phys_h) => req('PUT', `/api/bins/${id}/corners`, { corners, phys_w, phys_h });
|
export const update_bin_corners = (id, corners, phys_w, phys_h) => req('PUT', `/api/bins/${id}/corners`, { corners, phys_w, phys_h });
|
||||||
export const delete_bin = (id) => req('DELETE', `/api/bins/${id}`);
|
export const delete_bin = (id) => req('DELETE', `/api/bins/${id}`);
|
||||||
|
|
||||||
|
|||||||
@@ -1993,3 +1993,43 @@ nav {
|
|||||||
border-radius: 4px;
|
border-radius: 4px;
|
||||||
max-width: 100%;
|
max-width: 100%;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.bin-types-list {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bin-type-row {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
padding: 0.5rem 0;
|
||||||
|
border-bottom: 1px solid var(--border);
|
||||||
|
gap: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bin-type-info {
|
||||||
|
display: flex;
|
||||||
|
align-items: baseline;
|
||||||
|
gap: 0.75rem;
|
||||||
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bin-type-name {
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bin-type-dims {
|
||||||
|
font-size: 0.8rem;
|
||||||
|
color: var(--text-muted);
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bin-type-desc {
|
||||||
|
font-size: 0.8rem;
|
||||||
|
color: var(--text-faint);
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|||||||
@@ -617,11 +617,13 @@
|
|||||||
<div class="tab-bar">
|
<div class="tab-bar">
|
||||||
<button class="tab-btn" id="btn-tab-bins">Bins</button>
|
<button class="tab-btn" id="btn-tab-bins">Bins</button>
|
||||||
<button class="tab-btn" id="btn-tab-bin-sources">Source images</button>
|
<button class="tab-btn" id="btn-tab-bin-sources">Source images</button>
|
||||||
|
<button class="tab-btn" id="btn-tab-bin-types">Types</button>
|
||||||
</div>
|
</div>
|
||||||
<label class="btn btn-secondary" id="btn-upload-bin-sources" hidden>
|
<label class="btn btn-secondary" id="btn-upload-bin-sources" hidden>
|
||||||
+ Upload
|
+ Upload
|
||||||
<input type="file" accept="image/*" multiple hidden id="bin-source-upload-input">
|
<input type="file" accept="image/*" multiple hidden id="bin-source-upload-input">
|
||||||
</label>
|
</label>
|
||||||
|
<button class="btn btn-primary" id="btn-add-bin-type" hidden>+ Add type</button>
|
||||||
</div>
|
</div>
|
||||||
<div id="tab-bins-content">
|
<div id="tab-bins-content">
|
||||||
<div class="bin-gallery" id="bin-gallery"></div>
|
<div class="bin-gallery" id="bin-gallery"></div>
|
||||||
@@ -629,9 +631,26 @@
|
|||||||
<div id="tab-bin-sources-content" hidden>
|
<div id="tab-bin-sources-content" hidden>
|
||||||
<div id="bin-source-image-list" class="source-gallery"></div>
|
<div id="bin-source-image-list" class="source-gallery"></div>
|
||||||
</div>
|
</div>
|
||||||
|
<div id="tab-bin-types-content" hidden>
|
||||||
|
<div id="bin-types-list" class="bin-types-list"></div>
|
||||||
|
</div>
|
||||||
</section>
|
</section>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
|
<template id="t-bin-type-row">
|
||||||
|
<div class="bin-type-row">
|
||||||
|
<div class="bin-type-info">
|
||||||
|
<span class="bin-type-name"></span>
|
||||||
|
<span class="bin-type-dims"></span>
|
||||||
|
<span class="bin-type-desc"></span>
|
||||||
|
</div>
|
||||||
|
<span class="row-actions">
|
||||||
|
<button class="btn-icon btn-edit" title="Edit">✎</button>
|
||||||
|
<button class="btn-icon btn-danger btn-delete" title="Delete">✕</button>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
<template id="t-bin-card">
|
<template id="t-bin-card">
|
||||||
<div class="bin-card">
|
<div class="bin-card">
|
||||||
<div class="bin-card-img-wrap">
|
<div class="bin-card-img-wrap">
|
||||||
@@ -657,6 +676,12 @@
|
|||||||
<input type="text" id="bin-editor-name" autocomplete="off">
|
<input type="text" id="bin-editor-name" autocomplete="off">
|
||||||
</div>
|
</div>
|
||||||
<div class="form-row">
|
<div class="form-row">
|
||||||
|
<label>Type</label>
|
||||||
|
<select id="bin-editor-type">
|
||||||
|
<option value="">— Custom —</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div class="form-row" id="bin-editor-dims-row">
|
||||||
<label>Dimensions (mm)</label>
|
<label>Dimensions (mm)</label>
|
||||||
<div class="bin-editor-dims">
|
<div class="bin-editor-dims">
|
||||||
<input type="number" id="bin-editor-width" placeholder="W" min="1" step="1">
|
<input type="number" id="bin-editor-width" placeholder="W" min="1" step="1">
|
||||||
@@ -673,6 +698,32 @@
|
|||||||
</dialog>
|
</dialog>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
|
<template id="t-dialog-bin-type">
|
||||||
|
<dialog id="dialog-bin-type" class="app-dialog">
|
||||||
|
<h2 class="dialog-title"></h2>
|
||||||
|
<div class="form-row">
|
||||||
|
<label>Name</label>
|
||||||
|
<input type="text" id="bt-name" autocomplete="off" placeholder="e.g. Sortimo L-Boxx small">
|
||||||
|
</div>
|
||||||
|
<div class="form-row">
|
||||||
|
<label>Dimensions (mm)</label>
|
||||||
|
<div class="bin-editor-dims">
|
||||||
|
<input type="number" id="bt-width" placeholder="W" min="1" step="1">
|
||||||
|
<span>×</span>
|
||||||
|
<input type="number" id="bt-height" placeholder="H" min="1" step="1">
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="form-row">
|
||||||
|
<label>Description</label>
|
||||||
|
<input type="text" id="bt-description" autocomplete="off" placeholder="Optional">
|
||||||
|
</div>
|
||||||
|
<div class="dialog-actions">
|
||||||
|
<button type="button" class="btn btn-secondary" id="bt-cancel">Cancel</button>
|
||||||
|
<button type="button" class="btn btn-primary" id="bt-save">Save</button>
|
||||||
|
</div>
|
||||||
|
</dialog>
|
||||||
|
</template>
|
||||||
|
|
||||||
<!-- ===== CELL INVENTORY OVERLAY ===== -->
|
<!-- ===== CELL INVENTORY OVERLAY ===== -->
|
||||||
<template id="t-cell-inventory">
|
<template id="t-cell-inventory">
|
||||||
<div class="cell-inventory-overlay" id="cell-inventory-overlay">
|
<div class="cell-inventory-overlay" id="cell-inventory-overlay">
|
||||||
|
|||||||
104
server.mjs
104
server.mjs
@@ -19,6 +19,7 @@ import {
|
|||||||
list_component_templates, get_component_template, set_component_template, delete_component_template,
|
list_component_templates, get_component_template, set_component_template, delete_component_template,
|
||||||
list_pdfs, get_pdf, set_pdf, delete_pdf,
|
list_pdfs, get_pdf, set_pdf, delete_pdf,
|
||||||
list_bins, get_bin, set_bin, delete_bin,
|
list_bins, get_bin, set_bin, delete_bin,
|
||||||
|
list_bin_types, get_bin_type, set_bin_type, delete_bin_type,
|
||||||
} from './lib/storage.mjs';
|
} from './lib/storage.mjs';
|
||||||
|
|
||||||
mkdirSync('./data/images', { recursive: true });
|
mkdirSync('./data/images', { recursive: true });
|
||||||
@@ -648,6 +649,59 @@ app.post('/api/maintenance/pdf-thumbs', (req, res) => {
|
|||||||
ok(res, { generated, total: pdfs.length });
|
ok(res, { generated, total: pdfs.length });
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Bin types
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
app.get('/api/bin-types', (req, res) => {
|
||||||
|
ok(res, { bin_types: list_bin_types() });
|
||||||
|
});
|
||||||
|
|
||||||
|
app.post('/api/bin-types', (req, res) => {
|
||||||
|
const { name, phys_w, phys_h, description = '' } = req.body;
|
||||||
|
if (!name?.trim()) return fail(res, 'name is required');
|
||||||
|
if (!(phys_w > 0)) return fail(res, 'phys_w must be a positive number');
|
||||||
|
if (!(phys_h > 0)) return fail(res, 'phys_h must be a positive number');
|
||||||
|
const bt = {
|
||||||
|
id: generate_id(),
|
||||||
|
name: name.trim(),
|
||||||
|
phys_w: Number(phys_w),
|
||||||
|
phys_h: Number(phys_h),
|
||||||
|
description: description.trim(),
|
||||||
|
created_at: Date.now(),
|
||||||
|
updated_at: Date.now(),
|
||||||
|
};
|
||||||
|
set_bin_type(bt);
|
||||||
|
ok(res, { bin_type: bt });
|
||||||
|
});
|
||||||
|
|
||||||
|
app.put('/api/bin-types/:id', (req, res) => {
|
||||||
|
const existing = get_bin_type(req.params.id);
|
||||||
|
if (!existing) return fail(res, 'not found', 404);
|
||||||
|
const { name, phys_w, phys_h, description } = req.body;
|
||||||
|
const updated = { ...existing, updated_at: Date.now() };
|
||||||
|
if (name !== undefined) updated.name = name.trim();
|
||||||
|
if (phys_w !== undefined) {
|
||||||
|
if (!(Number(phys_w) > 0)) return fail(res, 'phys_w must be a positive number');
|
||||||
|
updated.phys_w = Number(phys_w);
|
||||||
|
}
|
||||||
|
if (phys_h !== undefined) {
|
||||||
|
if (!(Number(phys_h) > 0)) return fail(res, 'phys_h must be a positive number');
|
||||||
|
updated.phys_h = Number(phys_h);
|
||||||
|
}
|
||||||
|
if (description !== undefined) updated.description = description.trim();
|
||||||
|
set_bin_type(updated);
|
||||||
|
ok(res, { bin_type: updated });
|
||||||
|
});
|
||||||
|
|
||||||
|
app.delete('/api/bin-types/:id', (req, res) => {
|
||||||
|
if (!get_bin_type(req.params.id)) return fail(res, 'not found', 404);
|
||||||
|
const in_use = list_bins().find(b => b.type_id === req.params.id);
|
||||||
|
if (in_use) return fail(res, `In use by bin "${in_use.name}"`, 409);
|
||||||
|
delete_bin_type(req.params.id);
|
||||||
|
ok(res);
|
||||||
|
});
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// Bins
|
// Bins
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
@@ -664,21 +718,23 @@ app.get('/api/bins/:id', (req, res) => {
|
|||||||
|
|
||||||
// Create a bin from an already-uploaded source image
|
// Create a bin from an already-uploaded source image
|
||||||
app.post('/api/bins/from-source', (req, res) => {
|
app.post('/api/bins/from-source', (req, res) => {
|
||||||
const { source_id, name = '' } = req.body;
|
const { source_id, name = '', type_id = null } = req.body;
|
||||||
if (!source_id) return fail(res, 'source_id is required');
|
if (!source_id) return fail(res, 'source_id is required');
|
||||||
const src = get_source_image(source_id);
|
const src = get_source_image(source_id);
|
||||||
if (!src) return fail(res, 'source image not found', 404);
|
if (!src) return fail(res, 'source image not found', 404);
|
||||||
|
if (type_id && !get_bin_type(type_id)) return fail(res, 'bin type not found', 404);
|
||||||
|
|
||||||
// Add 'bin' to uses if not already there
|
|
||||||
if (!src.uses?.includes('bin')) {
|
if (!src.uses?.includes('bin')) {
|
||||||
add_source_image({ ...src, uses: [...(src.uses ?? []), 'bin'] });
|
add_source_image({ ...src, uses: [...(src.uses ?? []), 'bin'] });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const bt = type_id ? get_bin_type(type_id) : null;
|
||||||
const mx = Math.round(src.width * 0.15);
|
const mx = Math.round(src.width * 0.15);
|
||||||
const my = Math.round(src.height * 0.15);
|
const my = Math.round(src.height * 0.15);
|
||||||
const bin = {
|
const bin = {
|
||||||
id: generate_id(),
|
id: generate_id(),
|
||||||
name: name.trim() || src.original_name?.replace(/\.[^.]+$/, '') || 'Bin',
|
name: name.trim() || src.original_name?.replace(/\.[^.]+$/, '') || 'Bin',
|
||||||
|
type_id,
|
||||||
source_id,
|
source_id,
|
||||||
source_w: src.width,
|
source_w: src.width,
|
||||||
source_h: src.height,
|
source_h: src.height,
|
||||||
@@ -688,6 +744,8 @@ app.post('/api/bins/from-source', (req, res) => {
|
|||||||
{ x: src.width - mx, y: src.height - my },
|
{ x: src.width - mx, y: src.height - my },
|
||||||
{ x: mx, y: src.height - my },
|
{ x: mx, y: src.height - my },
|
||||||
],
|
],
|
||||||
|
phys_w: bt?.phys_w ?? null,
|
||||||
|
phys_h: bt?.phys_h ?? null,
|
||||||
image_filename: null,
|
image_filename: null,
|
||||||
created_at: Date.now(),
|
created_at: Date.now(),
|
||||||
updated_at: Date.now(),
|
updated_at: Date.now(),
|
||||||
@@ -699,36 +757,38 @@ app.post('/api/bins/from-source', (req, res) => {
|
|||||||
// Upload a source image for a bin and create the bin record (no processing yet)
|
// Upload a source image for a bin and create the bin record (no processing yet)
|
||||||
app.post('/api/bins', upload.single('image'), async (req, res) => {
|
app.post('/api/bins', upload.single('image'), async (req, res) => {
|
||||||
if (!req.file) return fail(res, 'no image uploaded');
|
if (!req.file) return fail(res, 'no image uploaded');
|
||||||
const { name = '' } = req.body;
|
const { name = '', type_id = null } = req.body;
|
||||||
|
if (type_id && !get_bin_type(type_id)) return fail(res, 'bin type not found', 404);
|
||||||
const final_name = settle_image_filename(req.file.filename, req.file.originalname);
|
const final_name = settle_image_filename(req.file.filename, req.file.originalname);
|
||||||
const meta = await sharp(join('./data/images', final_name)).metadata();
|
const meta = await sharp(join('./data/images', final_name)).metadata();
|
||||||
|
|
||||||
// Register in unified source image gallery
|
add_source_image({
|
||||||
const src = {
|
|
||||||
id: final_name,
|
id: final_name,
|
||||||
original_name: req.file.originalname,
|
original_name: req.file.originalname,
|
||||||
width: meta.width,
|
width: meta.width,
|
||||||
height: meta.height,
|
height: meta.height,
|
||||||
uses: ['bin'],
|
uses: ['bin'],
|
||||||
created_at: Date.now(),
|
created_at: Date.now(),
|
||||||
};
|
});
|
||||||
add_source_image(src);
|
|
||||||
|
|
||||||
|
const bt = type_id ? get_bin_type(type_id) : null;
|
||||||
const mx = Math.round(meta.width * 0.15);
|
const mx = Math.round(meta.width * 0.15);
|
||||||
const my = Math.round(meta.height * 0.15);
|
const my = Math.round(meta.height * 0.15);
|
||||||
const default_corners = [
|
const bin = {
|
||||||
|
id: generate_id(),
|
||||||
|
name: name.trim() || 'Bin',
|
||||||
|
type_id,
|
||||||
|
source_id: final_name,
|
||||||
|
source_w: meta.width,
|
||||||
|
source_h: meta.height,
|
||||||
|
corners: [
|
||||||
{ x: mx, y: my },
|
{ x: mx, y: my },
|
||||||
{ x: meta.width - mx, y: my },
|
{ x: meta.width - mx, y: my },
|
||||||
{ x: meta.width - mx, y: meta.height - my },
|
{ x: meta.width - mx, y: meta.height - my },
|
||||||
{ x: mx, y: meta.height - my },
|
{ x: mx, y: meta.height - my },
|
||||||
];
|
],
|
||||||
const bin = {
|
phys_w: bt?.phys_w ?? null,
|
||||||
id: generate_id(),
|
phys_h: bt?.phys_h ?? null,
|
||||||
name: name.trim() || 'Bin',
|
|
||||||
source_id: final_name,
|
|
||||||
source_w: meta.width,
|
|
||||||
source_h: meta.height,
|
|
||||||
corners: default_corners,
|
|
||||||
image_filename: null,
|
image_filename: null,
|
||||||
created_at: Date.now(),
|
created_at: Date.now(),
|
||||||
updated_at: Date.now(),
|
updated_at: Date.now(),
|
||||||
@@ -777,13 +837,21 @@ app.put('/api/bins/:id/corners', async (req, res) => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
// Update name only
|
// Update name / type
|
||||||
app.put('/api/bins/:id', (req, res) => {
|
app.put('/api/bins/:id', (req, res) => {
|
||||||
const bin = get_bin(req.params.id);
|
const bin = get_bin(req.params.id);
|
||||||
if (!bin) return fail(res, 'not found', 404);
|
if (!bin) return fail(res, 'not found', 404);
|
||||||
const { name } = req.body;
|
const { name, type_id } = req.body;
|
||||||
|
if (type_id !== undefined && type_id !== null && !get_bin_type(type_id)) {
|
||||||
|
return fail(res, 'bin type not found', 404);
|
||||||
|
}
|
||||||
const updated = { ...bin, updated_at: Date.now() };
|
const updated = { ...bin, updated_at: Date.now() };
|
||||||
if (name !== undefined) updated.name = name.trim() || 'Bin';
|
if (name !== undefined) updated.name = name.trim() || 'Bin';
|
||||||
|
if (type_id !== undefined) {
|
||||||
|
updated.type_id = type_id;
|
||||||
|
const bt = type_id ? get_bin_type(type_id) : null;
|
||||||
|
if (bt) { updated.phys_w = bt.phys_w; updated.phys_h = bt.phys_h; }
|
||||||
|
}
|
||||||
set_bin(updated);
|
set_bin(updated);
|
||||||
ok(res, { bin: updated });
|
ok(res, { bin: updated });
|
||||||
});
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user