inventory-home-app / script.js
ionel's picture
create inventory home app no prices just items and places
5eacc3d verified
Raw
History Blame Contribute Delete
19 kB
// 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('&', '&amp;')
.replaceAll('<', '&lt;')
.replaceAll('>', '&gt;')
.replaceAll('"', '&quot;')
.replaceAll("'", '&#039;');
}
function escapeAttr(s) {
return escapeHtml(s).replaceAll('"', '&quot;');
}
})();