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);

View File

@@ -1019,6 +1019,17 @@ nav {
/* ===== FORM ROW PAIR ===== */
.input-with-action {
display: flex;
gap: 0.4rem;
align-items: center;
}
.input-with-action .filter-select {
flex: 1;
min-width: 0;
}
.form-row-pair {
display: grid;
grid-template-columns: 1fr 1fr;
@@ -1311,3 +1322,95 @@ nav {
text-align: center;
font-family: var(--font-mono);
}
/* ===== GRID TYPE PILL ===== */
.type-pill.type-grid {
background: #2e7d4f;
color: #fff;
}
/* ===== GRID CELL PICKER (inventory dialog) ===== */
.i-grid-cell-picker {
display: flex;
flex-direction: column;
gap: 0.4rem;
}
.i-grid-coords {
display: flex;
align-items: center;
gap: 0.4rem;
font-size: 0.9rem;
}
.input-narrow {
width: 4rem;
}
/* ===== CELL INVENTORY OVERLAY ===== */
.grid-cell {
cursor: pointer;
}
.cell-inventory-overlay {
position: fixed;
background: var(--surface);
border: 1px solid var(--border);
border-radius: 6px;
padding: 0.75rem;
min-width: 220px;
max-width: 320px;
box-shadow: 0 4px 16px rgba(0,0,0,0.4);
z-index: 100;
}
.cell-inventory-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 0.5rem;
}
.cell-inventory-title {
font-weight: 600;
font-size: 0.9rem;
}
.cell-inventory-list {
display: flex;
flex-direction: column;
gap: 0.25rem;
margin-bottom: 0.5rem;
min-height: 1rem;
}
.cell-inv-item {
font-size: 0.85rem;
display: flex;
justify-content: space-between;
gap: 0.5rem;
}
.cell-inv-qty {
color: var(--text-dim);
white-space: nowrap;
}
.cell-inv-empty {
font-size: 0.85rem;
color: var(--text-faint);
font-style: italic;
}
.cell-inventory-actions {
display: flex;
justify-content: flex-end;
}
.btn-sm {
padding: 0.25rem 0.6rem;
font-size: 0.82rem;
}

View File

@@ -115,6 +115,7 @@
<option value="physical">Physical</option>
<option value="bom">BOM / Drawing</option>
<option value="digital">Digital / Note</option>
<option value="grid">Grid cell</option>
</select>
<button class="btn btn-primary" id="btn-add-inventory">+ Add entry</button>
</div>
@@ -354,9 +355,12 @@
<form method="dialog" id="form-inventory">
<div class="form-row">
<label for="i-component">Component</label>
<select id="i-component" required class="filter-select wide">
<option value="">— select component —</option>
</select>
<div class="input-with-action">
<select id="i-component" required class="filter-select">
<option value="">— select component —</option>
</select>
<button type="button" class="btn btn-secondary btn-sm" id="i-new-component">New…</button>
</div>
</div>
<div class="form-row">
<label for="i-type">Location type</label>
@@ -364,12 +368,25 @@
<option value="physical">Physical location</option>
<option value="bom">BOM / Drawing</option>
<option value="digital">Digital / Note</option>
<option value="grid">Grid cell</option>
</select>
</div>
<div class="form-row">
<label for="i-ref" id="i-ref-label">Location reference</label>
<input type="text" id="i-ref" autocomplete="off">
</div>
<div class="form-row" id="i-grid-row" hidden>
<label>Grid cell</label>
<div class="i-grid-cell-picker">
<select id="i-grid-select" class="filter-select"></select>
<div class="i-grid-coords">
<label for="i-grid-row-num">Row</label>
<input type="number" id="i-grid-row-num" min="1" class="input-narrow">
<label for="i-grid-col-num">Col</label>
<input type="number" id="i-grid-col-num" min="1" class="input-narrow">
</div>
</div>
</div>
<div class="form-row">
<label for="i-qty">Quantity</label>
<input type="text" id="i-qty" autocomplete="off" placeholder="e.g. 10, ~50, see BOM">
@@ -479,3 +496,17 @@
</div>
</dialog>
</template>
<!-- ===== CELL INVENTORY OVERLAY ===== -->
<template id="t-cell-inventory">
<div class="cell-inventory-overlay" id="cell-inventory-overlay">
<div class="cell-inventory-header">
<span class="cell-inventory-title"></span>
<button type="button" class="btn-icon" id="cell-inv-close"></button>
</div>
<div class="cell-inventory-list" id="cell-inventory-list"></div>
<div class="cell-inventory-actions">
<button type="button" class="btn btn-primary btn-sm" id="cell-inv-add">+ Add entry</button>
</div>
</div>
</template>