Spaces:
Running
Running
| // 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 = ` | |
| <div class="min-w-0"> | |
| <div class="text-sm font-medium text-gray-900 truncate">${escapeHtml(name)}</div> | |
| <div class="text-xs text-gray-500">${counts[name] || 0} items</div> | |
| </div> | |
| <div class="flex items-center gap-1"> | |
| <button data-action="rename" data-id="${escapeAttr(name)}" data-name="${escapeAttr(name)}" class="rounded-md border border-gray-300 bg-white px-2 py-1 text-xs text-gray-600 hover:bg-gray-100" title="Rename">Rename</button> | |
| <button data-action="delete" data-id="${escapeAttr(name)}" class="rounded-md border border-red-300 bg-white px-2 py-1 text-xs text-red-600 hover:bg-red-50" title="Delete">Delete</button> | |
| </div> | |
| `; | |
| locationListEl.appendChild(li); | |
| } | |
| } | |
| function populateLocationFilters() { | |
| const current = filterLocation.value; | |
| filterLocation.innerHTML = `<option value="all">All locations</option>`; | |
| 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 = ` | |
| <div class="min-w-0"> | |
| <div class="flex items-center gap-2 flex-wrap"> | |
| <h3 class="text-sm font-semibold text-gray-900">${escapeHtml(it.name)}</h3> | |
| <span class="inline-flex items-center rounded-full bg-gray-100 px-2 py-0.5 text-xs font-medium text-gray-700">${escapeHtml(it.location)}</span> | |
| </div> | |
| ${it.notes ? `<p class="mt-1 text-sm text-gray-600">${escapeHtml(it.notes)}</p>` : ''} | |
| <p class="mt-1 text-xs text-gray-500">Updated ${formatRelative(it.updatedAt)}</p> | |
| </div> | |
| <div class="flex items-center gap-3"> | |
| <label class="text-sm text-gray-600"> | |
| Qty | |
| <input data-field="qty" data-item-id="${it.id}" type="number" min="0" step="1" value="${it.qty}" | |
| class="ml-1 w-20 rounded-md border-gray-300 text-sm focus:border-blue-500 focus:ring-blue-500" /> | |
| </label> | |
| <label class="text-sm text-gray-600"> | |
| Location | |
| <select data-field="location" data-item-id="${it.id}" | |
| class="ml-1 w-36 rounded-md border-gray-300 text-sm focus:border-blue-500 focus:ring-blue-500"> | |
| </select> | |
| </label> | |
| <div class="flex items-center gap-2"> | |
| <button data-action="edit" data-item-id="${it.id}" class="rounded-md border border-gray-300 bg-white px-3 py-2 text-sm text-gray-700 hover:bg-gray-50">Edit</button> | |
| <button data-action="delete" data-item-id="${it.id}" class="rounded-md border border-red-300 bg-white px-3 py-2 text-sm text-red-600 hover:bg-red-50">Delete</button> | |
| </div> | |
| </div> | |
| `; | |
| 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('"', '"'); | |
| } | |
| })(); |