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:
131
public/app.mjs
131
public/app.mjs
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user