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);
|
||||
|
||||
103
public/style.css
103
public/style.css
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user