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:
2026-04-03 03:04:04 +00:00
parent 320c6f1bd9
commit 090f6f3154
6 changed files with 318 additions and 35 deletions

View File

@@ -19,6 +19,7 @@ import {
list_component_templates, get_component_template, set_component_template, delete_component_template,
list_pdfs, get_pdf, set_pdf, delete_pdf,
list_bins, get_bin, set_bin, delete_bin,
list_bin_types, get_bin_type, set_bin_type, delete_bin_type,
} from './lib/storage.mjs';
mkdirSync('./data/images', { recursive: true });
@@ -648,6 +649,59 @@ app.post('/api/maintenance/pdf-thumbs', (req, res) => {
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
// ---------------------------------------------------------------------------
@@ -664,30 +718,34 @@ app.get('/api/bins/:id', (req, res) => {
// Create a bin from an already-uploaded source image
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');
const src = get_source_image(source_id);
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')) {
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 my = Math.round(src.height * 0.15);
const bin = {
id: generate_id(),
name: name.trim() || src.original_name?.replace(/\.[^.]+$/, '') || 'Bin',
type_id,
source_id,
source_w: src.width,
source_h: src.height,
corners: [
{ x: mx, y: my },
{ x: src.width - mx, y: my },
{ x: mx, y: my },
{ x: src.width - mx, y: 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,
created_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)
app.post('/api/bins', upload.single('image'), async (req, res) => {
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 meta = await sharp(join('./data/images', final_name)).metadata();
// Register in unified source image gallery
const src = {
add_source_image({
id: final_name,
original_name: req.file.originalname,
width: meta.width,
height: meta.height,
uses: ['bin'],
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 my = Math.round(meta.height * 0.15);
const default_corners = [
{ x: mx, y: my },
{ x: meta.width - mx, y: my },
{ x: meta.width - mx, y: meta.height - my },
{ x: mx, y: meta.height - my },
];
const bin = {
id: generate_id(),
name: name.trim() || 'Bin',
type_id,
source_id: final_name,
source_w: meta.width,
source_h: meta.height,
corners: default_corners,
corners: [
{ x: mx, y: my },
{ x: meta.width - mx, y: my },
{ x: meta.width - mx, y: meta.height - my },
{ x: mx, y: meta.height - my },
],
phys_w: bt?.phys_w ?? null,
phys_h: bt?.phys_h ?? null,
image_filename: null,
created_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) => {
const bin = get_bin(req.params.id);
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() };
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);
ok(res, { bin: updated });
});