diff --git a/public/app.mjs b/public/app.mjs index 69f5a5f..49f6dcf 100644 --- a/public/app.mjs +++ b/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); diff --git a/public/style.css b/public/style.css index dcedc78..16eb71a 100644 --- a/public/style.css +++ b/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; +} diff --git a/public/templates.html b/public/templates.html index 71f5a02..46a5359 100644 --- a/public/templates.html +++ b/public/templates.html @@ -115,6 +115,7 @@ + @@ -354,9 +355,12 @@
- +
+ + +
@@ -364,12 +368,25 @@ +
+
@@ -479,3 +496,17 @@
+ + + diff --git a/server.mjs b/server.mjs index b3bdcd8..b14c01c 100644 --- a/server.mjs +++ b/server.mjs @@ -156,7 +156,8 @@ app.get('/api/inventory', (req, res) => { }); app.post('/api/inventory', (req, res) => { - const { component_id, location_type, location_ref = '', quantity = '', notes = '' } = req.body; + const { component_id, location_type, location_ref = '', quantity = '', notes = '', + grid_id = null, grid_row = null, grid_col = null } = req.body; if (!component_id) return fail(res, 'component_id is required'); if (!location_type) return fail(res, 'location_type is required'); if (!get_component(component_id)) return fail(res, 'component not found', 404); @@ -168,6 +169,9 @@ app.post('/api/inventory', (req, res) => { location_ref: String(location_ref).trim(), quantity: String(quantity).trim(), notes: String(notes).trim(), + grid_id: grid_id ?? null, + grid_row: grid_row != null ? parseInt(grid_row) : null, + grid_col: grid_col != null ? parseInt(grid_col) : null, images: [], created_at: now, updated_at: now, @@ -185,6 +189,9 @@ app.put('/api/inventory/:id', (req, res) => { if (location_ref !== undefined) updated.location_ref = String(location_ref).trim(); if (quantity !== undefined) updated.quantity = String(quantity).trim(); if (notes !== undefined) updated.notes = String(notes).trim(); + if (req.body.grid_id !== undefined) updated.grid_id = req.body.grid_id ?? null; + if (req.body.grid_row !== undefined) updated.grid_row = req.body.grid_row != null ? parseInt(req.body.grid_row) : null; + if (req.body.grid_col !== undefined) updated.grid_col = req.body.grid_col != null ? parseInt(req.body.grid_col) : null; set_inventory_entry(updated); ok(res, { entry: updated }); });