const state = { items: [], types: [], editingId: null }; const role = document.body.dataset.role; const canWrite = role === 'admin' || role === 'read_write'; const canDelete = role === 'admin'; const $ = (selector) => document.querySelector(selector); const fieldLabels = { brand: 'Brand', model: 'Model', serial_number: 'Serial number', asset_tag: 'Asset tag', location: 'Location', destination: 'Destination / assigned to', checkout_date: 'Checkout date', purchase_date: 'Purchase date', notes: 'Notes', cpu: 'CPU', ram: 'RAM', gpu: 'GPU', storage: 'Disk / storage', media_format: 'Media format' }; async function request(url, options = {}) { const headers = options.body instanceof FormData ? {} : { 'Content-Type': 'application/json' }; const response = await fetch(url, { headers, ...options }); const data = await response.json(); if (!response.ok) throw new Error(data.error || 'Something went wrong.'); return data; } function typeOptions(selected = '') { return `${state.types.map((type) => ``).join('')}`; } function renderTypeSelectors() { const formType = $('[name="item_type"]'); const selectedFormType = formType.value; formType.innerHTML = typeOptions(selectedFormType); const filter = $('#typeFilter'); const selectedFilter = filter.value; filter.innerHTML = `${state.types.map((type) => ``).join('')}`; } async function loadTypes() { state.types = await request('/api/types'); renderTypeSelectors(); updateTypeFields(); } async function load() { const query = new URLSearchParams({ q: $('#searchInput').value, type: $('#typeFilter').value, status: $('#statusFilter').value }); const [items, summary] = await Promise.all([request(`/api/items?${query}`), request('/api/summary')]); state.items = items; $('#totalCount').textContent = summary.total; $('#availableCount').textContent = summary.available; $('#checkedOutCount').textContent = summary.checked_out; $('#maintenanceCount').textContent = summary.maintenance; $('#resultCount').textContent = `${items.length} ${items.length === 1 ? 'item' : 'items'}`; renderItems(items); } function renderItems(items) { const body = $('#inventoryBody'); const actions = (itemId) => `${canWrite ? `` : ''}${canDelete ? `` : ''}`; $('#emptyState').style.display = items.length ? 'none' : 'block'; body.innerHTML = items.map((item) => `
${item.images?.length ? `${item.images.length > 1 ? `+${item.images.length - 1}` : ''}` : '
◌
'}
${escapeHtml(item.name)}${escapeHtml([item.brand, item.model].filter(Boolean).join(' · ') || 'No manufacturer details')}
${escapeHtml(item.item_type)}${escapeHtml(item.serial_number || 'No serial')}${escapeHtml(item.asset_tag || 'No asset tag')}${escapeHtml(item.location || 'Unassigned')}${escapeHtml(item.status)}
${actions(item.id)}
`).join(''); } function escapeHtml(value) { return String(value).replace(/[&<>'"]/g, (char) => ({ '&': '&', '<': '<', '>': '>', "'": ''', '"': '"' }[char])); } function ensureImageControls() { if (!$('#destination')) $('[name="location"]').insertAdjacentHTML('afterend', ''); if (!$('#checkoutDate')) $('#destination').parentElement.insertAdjacentHTML('afterend', ''); if ($('#imageInput')) return; const section = document.querySelector('.form-section'); section.insertAdjacentHTML('beforebegin', ''); } function updateTypeFields() { const type = $('[name="item_type"]').value; const visibleFields = new Set(state.types.find((entry) => entry.name === type)?.fields || []); document.querySelectorAll('#itemForm [name]').forEach((field) => { if (!Object.hasOwn(fieldLabels, field.name)) return; const visible = visibleFields.has(field.name); field.parentElement.hidden = !visible; }); const section = document.querySelector('#itemForm .form-section'); if (section) { section.hidden = !visibleFields.size; const heading = section.querySelector('span'); if (heading) heading.firstChild.textContent = 'Type details '; } } function renderImageGallery(images = []) { $('#imageGallery').innerHTML = images.map((image) => ``).join(''); } function openModal(item = null) { ensureImageControls(); state.editingId = item?.id || null; $('#modalTitle').textContent = item ? 'Edit item' : 'Add item'; $('#itemForm').reset(); $('#imageName').textContent = item?.images?.length ? `${item.images.length} saved picture${item.images.length === 1 ? '' : 's'}. Add more below.` : 'Up to 5 pictures'; renderImageGallery(item?.images || []); if (item) Object.entries(item).forEach(([key, value]) => { const field = $(`[name="${key}"]`); if (field && key !== 'images') field.value = value || ''; }); updateTypeFields(); $('#modal').classList.add('open'); $('[name="name"]').focus(); } function closeModal() { $('#modal').classList.remove('open'); } function toast(message) { const element = $('#toast'); element.textContent = message; element.classList.add('show'); setTimeout(() => element.classList.remove('show'), 2500); } function ensureTypeManager() { if ($('#typeManagerModal')) return; document.body.insertAdjacentHTML('beforeend', ''); $('#closeTypeManager').addEventListener('click', () => $('#typeManagerModal').classList.remove('open')); $('#resetTypeForm').addEventListener('click', resetTypeForm); $('#typeManagerModal').addEventListener('click', (event) => { if (event.target === $('#typeManagerModal')) $('#typeManagerModal').classList.remove('open'); }); $('#typeForm').addEventListener('submit', saveType); } function resetTypeForm() { $('#typeForm').reset(); $('#editingTypeName').value = ''; } function renderTypeManager() { $('#typeFields').innerHTML = `Fields shown for this type
${Object.entries(fieldLabels).map(([key, label]) => ``).join('')}
`; $('#typeList').innerHTML = state.types.map((type) => `
${escapeHtml(type.name)}${type.fields.map((field) => escapeHtml(fieldLabels[field])).join(', ') || 'Name, type, and status only'}
`).join(''); } function openTypeManager() { ensureTypeManager(); resetTypeForm(); renderTypeManager(); $('#typeManagerModal').classList.add('open'); } window.editType = (name) => { const type = state.types.find((entry) => entry.name === name); if (!type) return; $('#editingTypeName').value = type.name; $('#typeName').value = type.name; document.querySelectorAll('[name="type_field"]').forEach((field) => { field.checked = type.fields.includes(field.value); }); }; window.removeType = async (name) => { if (!confirm(`Delete the "${name}" inventory type?`)) return; try { await request(`/api/types/${encodeURIComponent(name)}`, { method: 'DELETE' }); await loadTypes(); renderTypeManager(); toast('Inventory type deleted.'); } catch (error) { toast(error.message); } }; async function saveType(event) { event.preventDefault(); const previousName = $('#editingTypeName').value; const fields = [...document.querySelectorAll('[name="type_field"]:checked')].map((field) => field.value); try { await request(previousName ? `/api/types/${encodeURIComponent(previousName)}` : '/api/types', { method: previousName ? 'PUT' : 'POST', body: JSON.stringify({ name: $('#typeName').value, fields }) }); await loadTypes(); renderTypeManager(); resetTypeForm(); toast('Inventory type saved.'); } catch (error) { toast(error.message); } } window.editItem = (id) => openModal(state.items.find((item) => item.id === id)); window.deleteImage = async (imageId) => { if (!state.editingId || !confirm('Delete this picture?')) return; try { const item = await request(`/api/items/${state.editingId}/images/${imageId}`, { method: 'DELETE' }); renderImageGallery(item.images); $('#imageName').textContent = `${item.images.length} saved picture${item.images.length === 1 ? '' : 's'}. Add more below.`; state.items = state.items.map((entry) => entry.id === item.id ? item : entry); renderItems(state.items); toast('Picture deleted.'); } catch (error) { toast(error.message); } }; window.removeItem = async (id) => { const item = state.items.find((entry) => entry.id === id); if (!confirm(`Delete "${item.name}"?`)) return; try { await request(`/api/items/${id}`, { method: 'DELETE' }); toast('Item removed from inventory.'); await load(); } catch (error) { toast(error.message); } }; if ($('#addButton')) $('#addButton').addEventListener('click', () => openModal()); if ($('#emptyAddButton')) $('#emptyAddButton').addEventListener('click', () => openModal()); if ($('#typeManagerButton')) $('#typeManagerButton').addEventListener('click', openTypeManager); $('#closeModal').addEventListener('click', closeModal); $('#cancelButton').addEventListener('click', closeModal); $('#modal').addEventListener('click', (event) => { if (event.target === $('#modal')) closeModal(); }); ['searchInput', 'typeFilter', 'statusFilter'].forEach((id) => $(`#${id}`).addEventListener(id === 'searchInput' ? 'input' : 'change', load)); ensureImageControls(); $('#itemForm [name="item_type"]').addEventListener('change', updateTypeFields); updateTypeFields(); $('#imageInput').addEventListener('change', () => { const files = [...$('#imageInput').files]; $('#imageName').textContent = files.length ? `${files.length} new picture${files.length === 1 ? '' : 's'} selected` : 'Up to 5 pictures'; }); $('#itemForm').addEventListener('submit', async (event) => { event.preventDefault(); const formData = new FormData(event.target); if (!$('#imageInput').files.length) formData.delete('images'); try { await request(state.editingId ? `/api/items/${state.editingId}` : '/api/items', { method: state.editingId ? 'PUT' : 'POST', body: formData }); closeModal(); toast(state.editingId ? 'Item updated.' : 'Item added to inventory.'); await load(); } catch (error) { toast(error.message); } }); if ($('#exportButton')) $('#exportButton').addEventListener('click', () => { if (!state.items.length) return toast('There are no visible items to export.'); const columns = ['name','item_type','brand','model','serial_number','asset_tag','status','location','destination','checkout_date','purchase_date','cpu','ram','gpu','storage','media_format','notes']; const csv = [columns.join(','), ...state.items.map((item) => columns.map((column) => `"${String(item[column] || '').replaceAll('"', '""')}"`).join(','))].join('\n'); const link = document.createElement('a'); link.href = URL.createObjectURL(new Blob([csv], { type: 'text/csv' })); link.download = 'stockroom-inventory.csv'; link.click(); URL.revokeObjectURL(link.href); }); if ($('#backupButton')) $('#backupButton').addEventListener('click', async () => { try { const response = await fetch('/api/backup'); if (!response.ok) throw new Error('Backup could not be created.'); const link = document.createElement('a'); link.href = URL.createObjectURL(await response.blob()); link.download = 'stockroom-backup.zip'; link.click(); URL.revokeObjectURL(link.href); toast('Backup downloaded.'); } catch (error) { toast(error.message); } }); if ($('#restoreButton')) $('#restoreButton').addEventListener('click', () => $('#restoreInput').click()); if ($('#restoreInput')) $('#restoreInput').addEventListener('change', async () => { const file = $('#restoreInput').files[0]; if (!file || !confirm('Restore this backup? Current inventory and pictures will be replaced.')) return; const formData = new FormData(); formData.append('backup', file); try { await request('/api/restore', { method: 'POST', body: formData }); toast('Backup restored. Reloading...'); setTimeout(() => window.location.reload(), 700); } catch (error) { toast(error.message); } $('#restoreInput').value = ''; }); async function loadUsers() { const users = await request('/api/users'); $('#usersList').innerHTML = users.map((user) => `

${escapeHtml(user.username)}${escapeHtml(user.role.replace('_', ' '))}${user.can_data_tools ? ' · data tools' : ''}

`).join(''); } if ($('#usersButton')) $('#usersButton').addEventListener('click', async () => { $('#usersPanel').classList.toggle('hidden'); if (!$('#usersPanel').classList.contains('hidden')) { try { await loadUsers(); } catch (error) { toast(error.message); } } }); if ($('#userForm')) $('#userForm').addEventListener('submit', async (event) => { event.preventDefault(); const form = new FormData(event.target); try { await request('/api/users', { method: 'POST', body: JSON.stringify({ username: form.get('username'), password: form.get('password'), role: form.get('role'), can_data_tools: form.get('can_data_tools') === 'on' }) }); event.target.reset(); await loadUsers(); toast('User added.'); } catch (error) { toast(error.message); } }); loadTypes().then(load).catch((error) => toast(error.message));