// Inventory Home App - Items & Locations (No Prices) (function () { const STORAGE_KEY = 'inventory_home_v1'; const DEFAULT_LOCATIONS = ['Kitchen', 'Living Room', 'Garage', 'Bedroom', 'Bathroom', 'Office']; const DEFAULT_ITEMS = [ { name: 'Coffee Beans', location: 'Kitchen', qty: 2, notes: 'Medium roast' }, { name: 'Toothpaste', location: 'Bathroom', qty: 1, notes: '' }, { name: 'AA Batteries', location: 'Office', qty: 8, notes: '' }, { name: 'Light Bulbs', location: 'Garage', qty: 4, notes: 'LED 9W' }, ]; const $ = (sel, root = document) => root.querySelector(sel); const $$ = (sel, root = document) => [...root.querySelectorAll(sel)]; let state = { locations: [], items: [], filter: { q: '', location: 'all' }, sort: 'updated_desc', editingItemId: null, }; // DOM references const locationListEl = $('#locationList'); const locationCountEl = $('#locationCount'); const addLocationForm = $('#addLocationForm'); const newLocationName = $('#newLocationName'); const searchInput = $('#searchInput'); const filterLocation = $('#filterLocation'); const sortSelect = $('#sortSelect'); const addItemBtn = $('#addItemBtn'); const itemList = $('#itemList'); const emptyState = $('#emptyState'); const statsText = $('#statsText'); const itemModal = $('#itemModal'); const modalBackdrop = $('#modalBackdrop'); const itemForm = $('#itemForm'); const itemModalTitle = $('#itemModalTitle'); const closeItemModal = $('#closeItemModal'); const cancelItemBtn = $('#cancelItemBtn'); const itemName = $('#itemName'); const itemLocation = $('#itemLocation'); const itemQty = $('#itemQty'); const itemNotes = $('#itemNotes'); const exportBtn = $('#exportBtn'); const importFile = $('#importFile'); const resetBtn = $('#resetBtn'); // Initialize loadState(); ensureDefaults(); renderAll(); // Event listeners addLocationForm.addEventListener('submit', (e) => { e.preventDefault(); const name = newLocationName.value.trim(); if (!name) return; addLocation(name); newLocationName.value = ''; }); searchInput.addEventListener('input', (e) => { state.filter.q = e.target.value; renderItems(); }); filterLocation.addEventListener('change', (e) => { state.filter.location = e.target.value; renderItems(); }); sortSelect.addEventListener('change', (e) => { state.sort = e.target.value; renderItems(); }); addItemBtn.addEventListener('click', () => openItemModal()); [modalBackdrop, closeItemModal, cancelItemBtn].forEach((el) => el.addEventListener('click', closeItemModalHandler) ); document.addEventListener('keydown', (e) => { if (e.key === 'Escape' && !itemModal.classList.contains('hidden')) closeItemModalHandler(); }); itemForm.addEventListener('submit', (e) => { e.preventDefault(); const payload = { name: itemName.value.trim(), location: itemLocation.value, qty: Number(itemQty.value || 0), notes: itemNotes.value.trim(), }; if (!payload.name) { alert('Please enter a name.'); return; } if (state.editingItemId) { updateItem(state.editingItemId, payload); } else { addItem(payload); } closeItemModal(); }); exportBtn.addEventListener('click', () => { const data = { locations: state.locations, items: state.items, exportedAt: new Date().toISOString(), version: 1, }; const blob = new Blob([JSON.stringify(data, null, 2)], { type: 'application/json' }); const url = URL.createObjectURL(blob); const a = document.createElement('a'); a.href = url; a.download = `inventory-home-${new Date().toISOString().slice(0, 10)}.json`; document.body.appendChild(a); a.click(); a.remove(); URL.revokeObjectURL(url); }); importFile.addEventListener('change', async (e) => { const file = e.target.files?.[0]; if (!file) return; try { const text = await file.text(); const json = JSON.parse(text); if (!Array.isArray(json.items) || !Array.isArray(json.locations)) { throw new Error('Invalid file format.'); } // Basic validation for item fields const items = json.items .filter((it) => it && typeof it.name === 'string') .map((it) => ({ id: it.id || uid(), name: String(it.name).trim(), location: String(it.location || 'Unassigned'), qty: Number(it.qty || 0), notes: String(it.notes || ''), createdAt: it.createdAt || new Date().toISOString(), updatedAt: it.updatedAt || new Date().toISOString(), })); // Ensure locations include all referenced and import as provided const locSet = new Set(json.locations.map((l) => String(l))); items.forEach((it) => locSet.add(it.location)); const locations = Array.from(locSet); state.items = items; state.locations = locations; persistState(); renderAll(); alert('Import completed.'); } catch (err) { console.error(err); alert('Failed to import file.'); } finally { e.target.value = ''; } }); resetBtn.addEventListener('click', () => { if (!confirm('Reset all data to defaults? This cannot be undone.')) return; localStorage.removeItem(STORAGE_KEY); state.locations = []; state.items = []; ensureDefaults(true); renderAll(); }); // Delegated events for locations locationListEl.addEventListener('click', (e) => { const btn = e.target.closest('button[data-action]'); if (!btn) return; const id = btn.getAttribute('data-id'); const action = btn.getAttribute('data-action'); if (action === 'rename') { const newName = prompt('Rename location:', btn.getAttribute('data-name') || ''); if (newName && newName.trim()) { renameLocation(id, newName.trim()); } } else if (action === 'delete') { if (!confirm('Delete this location? Items will be moved to "Unassigned".')) return; deleteLocation(id); } }); // Delegated events for items itemList.addEventListener('click', (e) => { const btn = e.target.closest('button[data-item-id]'); if (!btn) return; const id = btn.getAttribute('data-item-id'); const action = btn.getAttribute('data-action'); if (action === 'edit') { const item = state.items.find((it) => it.id === id); if (item) openItemModal(item); } else if (action === 'delete') { if (confirm('Delete this item?')) { deleteItem(id); } } }); itemList.addEventListener('change', (e) => { const select = e.target.closest('select[data-item-id][data-field="location"]'); if (select) { const id = select.getAttribute('data-item-id'); const newLoc = select.value; updateItem(id, { location: newLoc }); } }); itemList.addEventListener('input', (e) => { const input = e.target.closest('input[data-item-id][data-field="qty"]'); if (input) { const id = input.getAttribute('data-item-id'); const val = Math.max(0, Math.floor(Number(input.value || 0))); updateItem(id, { qty: val }); } }); // Core functions function loadState() { const raw = localStorage.getItem(STORAGE_KEY); if (raw) { try { const data = JSON.parse(raw); state.locations = Array.isArray(data.locations) ? data.locations : []; state.items = Array.isArray(data.items) ? data.items : []; return; } catch { // fall through } } // empty by default state.locations = []; state.items = []; } function persistState() { localStorage.setItem( STORAGE_KEY, JSON.stringify({ locations: state.locations, items: state.items, updatedAt: new Date().toISOString(), }) ); } function ensureDefaults(force = false) { let changed = false; if (state.locations.length === 0 || force) { state.locations = DEFAULT_LOCATIONS.slice(); changed = true; } if (state.items.length === 0 || force) { const now = new Date().toISOString(); state.items = DEFAULT_ITEMS.map((it) => ({ id: uid(), name: it.name, location: it.location, qty: it.qty, notes: it.notes, createdAt: now, updatedAt: now, })); changed = true; } if (changed) persistState(); } function uid() { return 'id-' + Date.now().toString(36) + '-' + Math.random().toString(36).slice(2, 8); } function addLocation(name) { if (state.locations.includes(name)) { alert('Location already exists.'); return; } state.locations.push(name); persistState(); renderLocations(); populateLocationFilters(); } function renameLocation(oldName, newName) { if (state.locations.includes(newName)) { alert('A location with that name already exists.'); return; } const idx = state.locations.indexOf(oldName); if (idx === -1) return; state.locations[idx] = newName; // Reassign items state.items = state.items.map((it) => it.location === oldName ? { ...it, location: newName, updatedAt: new Date().toISOString() } : it ); persistState(); renderAll(); } function deleteLocation(name) { const idx = state.locations.indexOf(name); if (idx === -1) return; state.locations.splice(idx, 1); // Reassign items to Unassigned const unassigned = 'Unassigned'; if (!state.locations.includes(unassigned)) state.locations.push(unassigned); state.items = state.items.map((it) => it.location === name ? { ...it, location: unassigned, updatedAt: new Date().toISOString() } : it ); persistState(); renderAll(); } function addItem({ name, location, qty, notes }) { const now = new Date().toISOString(); const item = { id: uid(), name, location: location || 'Unassigned', qty: Number(qty || 0), notes: notes || '', createdAt: now, updatedAt: now, }; if (!state.locations.includes(item.location)) { state.locations.push(item.location); } state.items.push(item); persistState(); renderItems(); populateLocationFilters(); renderLocations(); } function updateItem(id, updates) { const idx = state.items.findIndex((it) => it.id === id); if (idx === -1) return; const prev = state.items[idx]; const next = { ...prev, ...updates, updatedAt: new Date().toISOString() }; if (next.location && !state.locations.includes(next.location)) { state.locations.push(next.location); } state.items[idx] = next; persistState(); renderItems(); populateLocationFilters(); renderLocations(); } function deleteItem(id) { const idx = state.items.findIndex((it) => it.id === id); if (idx === -1) return; state.items.splice(idx, 1); persistState(); renderItems(); } function openItemModal(item) { if (item) { state.editingItemId = item.id; itemModalTitle.textContent = 'Edit Item'; itemName.value = item.name; itemQty.value = String(item.qty); itemNotes.value = item.notes || ''; } else { state.editingItemId = null; itemModalTitle.textContent = 'Add Item'; itemName.value = ''; itemQty.value = '1'; itemNotes.value = ''; } populateLocationSelects(); if (item) itemLocation.value = item.location; itemModal.classList.remove('hidden'); itemName.focus(); } function closeItemModalHandler() { itemModal.classList.add('hidden'); state.editingItemId = null; itemForm.reset(); } // Renderers function renderAll() { renderLocations(); populateLocationFilters(); renderItems(); } function renderLocations() { locationListEl.innerHTML = ''; const totalItems = state.items.length; locationCountEl.textContent = `${totalItems} total`; const counts = countBy(state.items, (it) => it.location); const sorted = [...state.locations].sort((a, b) => a.localeCompare(b)); for (const name of sorted) { const li = document.createElement('div'); li.className = 'flex items-center justify-between rounded-md border border-gray-200 bg-gray-50 px-3 py-2'; li.innerHTML = `
${escapeHtml(it.notes)}
` : ''}Updated ${formatRelative(it.updatedAt)}