Add grid cell inventory linking and component quick-create

- New 'grid' location type on inventory entries (grid_id, grid_row, grid_col)
- Clicking a grid cell shows a popup with what's stored there
- Popup has '+ Add entry' pre-filled with the cell coordinates
- Inventory dialog: 'New...' button next to component selector opens
  component creation dialog on top, returns with new component selected
- Grid entries display as e.g. 'Black Component Box R3C5' in lists
- Store original filename on source image upload

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-03-20 23:53:06 +00:00
parent cf37759893
commit 6c37912ec5
4 changed files with 267 additions and 13 deletions

View File

@@ -51,14 +51,21 @@ async function load_all() {
// Helpers
// ---------------------------------------------------------------------------
const LOCATION_TYPE_LABEL = { physical: 'Physical', bom: 'BOM', digital: 'Digital' };
const LOCATION_TYPE_LABEL = { physical: 'Physical', bom: 'BOM', digital: 'Digital', grid: 'Grid' };
function ref_label_for_type(type) {
if (type === 'physical') return 'Location (drawer, bin, shelf…)';
if (type === 'bom') return 'Document / project name';
if (type === 'grid') return 'Grid cell';
return 'Note / description';
}
function grid_cell_label(entry) {
const grid = all_grids.find(g => g.id === entry.grid_id);
if (!grid) { return `Grid cell (R${(entry.grid_row ?? 0) + 1}C${(entry.grid_col ?? 0) + 1})`; }
return `${grid.name} R${(entry.grid_row ?? 0) + 1}C${(entry.grid_col ?? 0) + 1}`;
}
function inventory_for_component(component_id) {
return all_inventory.filter(e => e.component_id === component_id);
}
@@ -297,7 +304,7 @@ function build_detail_inv_entry(entry) {
type_el.className = `type-pill type-${entry.location_type}`;
type_el.textContent = LOCATION_TYPE_LABEL[entry.location_type] ?? entry.location_type;
set_text(el, '.detail-inv-ref', entry.location_ref || '—');
set_text(el, '.detail-inv-ref', entry.location_type === 'grid' ? grid_cell_label(entry) : (entry.location_ref || '—'));
set_text(el, '.detail-inv-qty', entry.quantity ? `×${entry.quantity}` : '');
set_text(el, '.detail-inv-notes', entry.notes || '');
@@ -389,13 +396,13 @@ function build_inventory_row(entry) {
pill.textContent = LOCATION_TYPE_LABEL[entry.location_type] ?? entry.location_type;
qs(row, '.inv-type-badge').replaceChildren(pill);
set_text(row, '.inv-location-ref', entry.location_ref);
set_text(row, '.inv-location-ref', entry.location_type === 'grid' ? grid_cell_label(entry) : entry.location_ref);
set_text(row, '.inv-quantity', entry.quantity);
set_text(row, '.inv-notes', entry.notes);
qs(row, '.btn-edit').addEventListener('click', () => open_inventory_dialog(entry));
qs(row, '.btn-delete').addEventListener('click', () => confirm_delete(
`Delete this inventory entry (${LOCATION_TYPE_LABEL[entry.location_type]}: ${entry.location_ref || '—'})?`,
`Delete this inventory entry (${LOCATION_TYPE_LABEL[entry.location_type]}: ${entry.location_type === 'grid' ? grid_cell_label(entry) : (entry.location_ref || '—')})?`,
async () => {
await api.delete_inventory(entry.id);
all_inventory = all_inventory.filter(e => e.id !== entry.id);
@@ -991,16 +998,75 @@ function render_grid_viewer() {
if (filename) {
const img = qs(cell, '.grid-cell-img');
img.src = `/img/${filename}`;
img.addEventListener('click', () => window.open(`/img/${filename}`, '_blank'));
} else {
cell.classList.add('empty');
}
set_text(cell, '.grid-cell-label', `${row},${col}`);
set_text(cell, '.grid-cell-label', `R${row + 1}C${col + 1}`);
cell.addEventListener('click', (e) => open_cell_inventory(grid, row, col, e));
return cell;
});
cells_el.replaceChildren(...all_cells);
}
function open_cell_inventory(grid, row, col, e) {
// Remove any existing overlay
document.getElementById('cell-inventory-overlay')?.remove();
const frag = document.getElementById('t-cell-inventory').content.cloneNode(true);
document.body.appendChild(frag);
const overlay = document.getElementById('cell-inventory-overlay');
// Position near click
const x = Math.min(e.clientX + 8, window.innerWidth - 340);
const y = Math.min(e.clientY + 8, window.innerHeight - 200);
overlay.style.left = x + 'px';
overlay.style.top = y + 'px';
qs(overlay, '.cell-inventory-title').textContent = `${grid.name} R${row + 1}C${col + 1}`;
const entries = all_inventory.filter(inv =>
inv.location_type === 'grid' && inv.grid_id === grid.id &&
inv.grid_row === row && inv.grid_col === col
);
const list_el = qs(overlay, '.cell-inventory-list');
if (entries.length === 0) {
const empty = document.createElement('div');
empty.className = 'cell-inv-empty';
empty.textContent = 'Nothing stored here yet';
list_el.appendChild(empty);
} else {
entries.forEach(entry => {
const comp = component_by_id(entry.component_id);
const item = document.createElement('div');
item.className = 'cell-inv-item';
const name_span = document.createElement('span');
name_span.textContent = comp?.name ?? '?';
const qty_span = document.createElement('span');
qty_span.className = 'cell-inv-qty';
qty_span.textContent = entry.quantity || '';
item.append(name_span, qty_span);
list_el.appendChild(item);
});
}
qs(overlay, '#cell-inv-close').addEventListener('click', () => overlay.remove());
qs(overlay, '#cell-inv-add').addEventListener('click', () => {
overlay.remove();
open_inventory_dialog(null, null, { grid_id: grid.id, grid_row: row, grid_col: col });
});
// Close on outside click
setTimeout(() => {
document.addEventListener('click', function handler(ev) {
if (!overlay.contains(ev.target)) {
overlay.remove();
document.removeEventListener('click', handler);
}
});
}, 0);
}
// ---------------------------------------------------------------------------
// Dialog: Component
// ---------------------------------------------------------------------------
@@ -1121,7 +1187,7 @@ function open_component_dialog(comp = null) {
let inventory_dialog_callback = null;
function open_inventory_dialog(entry = null, default_component_id = null) {
function open_inventory_dialog(entry = null, default_component_id = null, default_grid_cell = null) {
const dlg = document.getElementById('dialog-inventory');
const title = qs(dlg, '.dialog-title');
const comp_sel = qs(dlg, '#i-component');
@@ -1130,6 +1196,11 @@ function open_inventory_dialog(entry = null, default_component_id = null) {
const ref_label = qs(dlg, '#i-ref-label');
const qty_input = qs(dlg, '#i-qty');
const notes_input = qs(dlg, '#i-notes');
const new_comp_btn = qs(dlg, '#i-new-component');
const grid_row_div = qs(dlg, '#i-grid-row');
const grid_sel = qs(dlg, '#i-grid-select');
const row_num_input = qs(dlg, '#i-grid-row-num');
const col_num_input = qs(dlg, '#i-grid-col-num');
title.textContent = entry ? 'Edit inventory entry' : 'Add inventory entry';
@@ -1141,13 +1212,35 @@ function open_inventory_dialog(entry = null, default_component_id = null) {
}))
);
comp_sel.value = entry?.component_id ?? default_component_id ?? '';
type_sel.value = entry?.location_type ?? 'physical';
const effective_grid_cell = default_grid_cell ?? (entry?.location_type === 'grid' ? { grid_id: entry.grid_id, grid_row: entry.grid_row, grid_col: entry.grid_col } : null);
if (effective_grid_cell) {
type_sel.value = 'grid';
} else {
type_sel.value = entry?.location_type ?? 'physical';
}
ref_input.value = entry?.location_ref ?? '';
qty_input.value = entry?.quantity ?? '';
notes_input.value = entry?.notes ?? '';
// Populate grid selector
grid_sel.replaceChildren(
Object.assign(document.createElement('option'), { value: '', textContent: '— select grid —' }),
...all_grids.map(g => Object.assign(document.createElement('option'), {
value: g.id,
textContent: g.name,
}))
);
if (effective_grid_cell?.grid_id) { grid_sel.value = effective_grid_cell.grid_id; }
row_num_input.value = effective_grid_cell?.grid_row != null ? effective_grid_cell.grid_row + 1 : 1;
col_num_input.value = effective_grid_cell?.grid_col != null ? effective_grid_cell.grid_col + 1 : 1;
function update_ref_label() {
ref_label.textContent = ref_label_for_type(type_sel.value);
const is_grid = type_sel.value === 'grid';
grid_row_div.hidden = !is_grid;
ref_input.closest('.form-row').hidden = is_grid;
}
update_ref_label();
@@ -1156,13 +1249,33 @@ function open_inventory_dialog(entry = null, default_component_id = null) {
type_sel._change_handler = update_ref_label;
type_sel.addEventListener('change', type_sel._change_handler);
new_comp_btn.onclick = () => {
const known_ids = new Set(all_components.map(c => c.id));
open_component_dialog(null);
document.getElementById('dialog-component').addEventListener('close', () => {
// Rebuild the selector and select the newly created component (if any)
comp_sel.replaceChildren(
Object.assign(document.createElement('option'), { value: '', textContent: '— select component —' }),
...all_components.map(c => Object.assign(document.createElement('option'), {
value: c.id, textContent: c.name,
}))
);
const new_comp = all_components.find(c => !known_ids.has(c.id));
if (new_comp) comp_sel.value = new_comp.id;
}, { once: true });
};
inventory_dialog_callback = async () => {
const is_grid = type_sel.value === 'grid';
const body = {
component_id: comp_sel.value,
location_type: type_sel.value,
location_ref: ref_input.value.trim(),
location_ref: is_grid ? '' : ref_input.value.trim(),
quantity: qty_input.value.trim(),
notes: notes_input.value.trim(),
grid_id: is_grid ? (grid_sel.value || null) : null,
grid_row: is_grid ? parseInt(row_num_input.value) - 1 : null,
grid_col: is_grid ? parseInt(col_num_input.value) - 1 : null,
};
if (entry) {
const result = await api.update_inventory(entry.id, body);