// 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(name)}
${counts[name] || 0} items
`; locationListEl.appendChild(li); } } function populateLocationFilters() { const current = filterLocation.value; filterLocation.innerHTML = ``; const sorted = [...state.locations].sort((a, b) => a.localeCompare(b)); for (const name of sorted) { const opt = document.createElement('option'); opt.value = name; opt.textContent = name; filterLocation.appendChild(opt); } filterLocation.value = state.filter.location || current || 'all'; populateLocationSelects(); } function populateLocationSelects() { itemLocation.innerHTML = ''; const sorted = [...state.locations].sort((a, b) => a.localeCompare(b)); for (const name of sorted) { const opt = document.createElement('option'); opt.value = name; opt.textContent = name; itemLocation.appendChild(opt); } } function renderItems() { const q = (state.filter.q || '').toLowerCase(); const loc = state.filter.location || 'all'; let items = state.items.slice(); if (q) { items = items.filter((it) => it.name.toLowerCase().includes(q) || (it.notes || '').toLowerCase().includes(q)); } if (loc !== 'all') { items = items.filter((it) => it.location === loc); } items = sortItems(items, state.sort); itemList.innerHTML = ''; if (items.length === 0) { emptyState.classList.remove('hidden'); } else { emptyState.classList.add('hidden'); } for (const it of items) { const li = document.createElement('li'); li.className = 'p-4 sm:p-5 flex flex-col sm:flex-row sm:items-center sm:justify-between gap-3'; li.innerHTML = `

${escapeHtml(it.name)}

${escapeHtml(it.location)}
${it.notes ? `

${escapeHtml(it.notes)}

` : ''}

Updated ${formatRelative(it.updatedAt)}

`; itemList.appendChild(li); } // Populate each item's location select $$('select[data-field="location"][data-item-id]', itemList).forEach((sel) => { const id = sel.getAttribute('data-item-id'); const item = state.items.find((x) => x.id === id); if (!item) return; sel.innerHTML = ''; const sorted = [...state.locations].sort((a, b) => a.localeCompare(b)); for (const name of sorted) { const opt = document.createElement('option'); opt.value = name; opt.textContent = name; if (name === item.location) opt.selected = true; sel.appendChild(opt); } }); statsText.textContent = `${items.length} item${items.length === 1 ? '' : 's'}`; } function sortItems(items, sortKey) { const byName = (a, b) => a.name.localeCompare(b.name); const byQty = (a, b) => a.qty - b.qty; const byCreated = (a, b) => new Date(b.createdAt) - new Date(a.createdAt); const byUpdated = (a, b) => new Date(b.updatedAt) - new Date(a.updatedAt); switch (sortKey) { case 'name_asc': return items.sort(byName); case 'name_desc': return items.sort((a, b) => byName(b, a)); case 'qty_asc': return items.sort(byQty); case 'qty_desc': return items.sort((a, b) => byQty(b, a)); case 'created_desc': return items.sort(byCreated); case 'updated_desc': default: return items.sort(byUpdated); } } // Utils function countBy(arr, fn) { return arr.reduce((acc, cur) => { const key = fn(cur); acc[key] = (acc[key] || 0) + 1; return acc; }, {}); } function formatRelative(iso) { try { const d = new Date(iso); const diff = Date.now() - d.getTime(); const sec = Math.floor(diff / 1000); if (sec < 10) return 'just now'; if (sec < 60) return `${sec}s ago`; const min = Math.floor(sec / 60); if (min < 60) return `${min}m ago`; const hr = Math.floor(min / 60); if (hr < 24) return `${hr}h ago`; const day = Math.floor(hr / 24); if (day < 7) return `${day}d ago`; return d.toLocaleDateString(); } catch { return ''; } } function escapeHtml(s) { return String(s) .replaceAll('&', '&') .replaceAll('<', '<') .replaceAll('>', '>') .replaceAll('"', '"') .replaceAll("'", '''); } function escapeAttr(s) { return escapeHtml(s).replaceAll('"', '"'); } })();