119 lines
18 KiB
JavaScript
119 lines
18 KiB
JavaScript
const state = { items: [], types: [], editingId: null, selectedIds: new Set() };
|
||
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', release_date: 'Release date', notes: 'Notes', song_list: 'Song list', 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 `<option value="">Select type</option>${state.types.map((type) => `<option value="${escapeHtml(type.name)}"${type.name === selected ? ' selected' : ''}>${escapeHtml(type.name)}</option>`).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 = `<option value="">All types</option>${state.types.map((type) => `<option value="${escapeHtml(type.name)}"${type.name === selectedFilter ? ' selected' : ''}>${escapeHtml(type.name)}</option>`).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;
|
||
state.selectedIds = new Set([...state.selectedIds].filter((itemId) => items.some((item) => item.id === itemId)));
|
||
$('#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 ? `<button class="small-action" onclick="editItem(${itemId})">Edit</button>` : ''}${canDelete ? `<button class="small-action delete" onclick="removeItem(${itemId})">Delete</button>` : ''}`;
|
||
$('#emptyState').style.display = items.length ? 'none' : 'block';
|
||
body.innerHTML = items.map((item) => `<tr class="inventory-row">${canDelete ? `<td class="selection-column"><input class="item-selector" type="checkbox" value="${item.id}" aria-label="Select ${escapeHtml(item.name)}"${state.selectedIds.has(item.id) ? ' checked' : ''}></td>` : ''}<td><div class="item-cell">${item.images?.length ? `<img class="item-thumb" src="${escapeHtml(item.images[0].url)}" alt="">${item.images.length > 1 ? `<span class="image-count">+${item.images.length - 1}</span>` : ''}` : '<div class="item-thumb placeholder">◌</div>'}<div><span class="item-title">${escapeHtml(item.name)}</span><span class="item-sub">${escapeHtml([item.brand, item.model].filter(Boolean).join(' · ') || 'No manufacturer details')}</span></div></div></td><td><span class="type-pill">${escapeHtml(item.item_type)}</span></td><td><span class="identifier">${escapeHtml(item.serial_number || 'No serial')}<em>${escapeHtml(item.asset_tag || 'No asset tag')}</em></span></td><td><span class="location">${escapeHtml(item.location || 'Unassigned')}</span></td><td><span class="status ${item.status.toLowerCase().replace(' ', '-')}">${escapeHtml(item.status)}</span></td><td><div class="row-actions">${actions(item.id)}</div></td></tr>`).join('');
|
||
updateBulkActions();
|
||
}
|
||
|
||
function updateBulkActions() {
|
||
if (!canDelete) return;
|
||
const count = state.selectedIds.size;
|
||
$('#selectedCount').textContent = `${count} ${count === 1 ? 'item' : 'items'} selected`;
|
||
$('#bulkActions').classList.toggle('hidden', count === 0);
|
||
$('#bulkDeleteButton').disabled = count === 0;
|
||
$('#selectAllItems').checked = Boolean(state.items.length) && state.items.every((item) => state.selectedIds.has(item.id));
|
||
$('#selectAllItems').indeterminate = count > 0 && !$('#selectAllItems').checked;
|
||
}
|
||
|
||
function escapeHtml(value) { return String(value).replace(/[&<>'"]/g, (char) => ({ '&': '&', '<': '<', '>': '>', "'": ''', '"': '"' }[char])); }
|
||
function ensureImageControls() { if (!$('#destination')) $('[name="location"]').insertAdjacentHTML('afterend', '<label data-configurable-field="destination">Destination / assigned to<input id="destination" name="destination" placeholder="e.g. Employee, buyer, recycling"></label>'); if (!$('#checkoutDate')) $('#destination').parentElement.insertAdjacentHTML('afterend', '<label data-configurable-field="checkout_date">Checkout date<input id="checkoutDate" name="checkout_date" type="date"></label>'); if (!$('[name="release_date"]')) $('[name="purchase_date"]').parentElement.insertAdjacentHTML('afterend', '<label data-configurable-field="release_date">Release date<input name="release_date" type="date"></label>'); if (!$('[name="song_list"]')) $('[name="media_format"]').parentElement.insertAdjacentHTML('afterend', '<label class="full" data-configurable-field="song_list">Song list<textarea name="song_list" rows="4" placeholder="One song per line"></textarea></label>'); if ($('#imageInput')) return; const section = document.querySelector('.form-section'); section.insertAdjacentHTML('beforebegin', '<label class="full image-field">Device pictures<input id="imageInput" name="images" type="file" accept="image/jpeg,image/png,image/gif,image/webp" multiple><span id="imageName">Up to 5 pictures</span><div id="imageGallery" class="image-gallery"></div></label>'); }
|
||
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) => `<div class="gallery-item"><img src="${escapeHtml(image.url)}" alt="${escapeHtml(image.original_name || 'Device picture')}"><button type="button" class="gallery-delete" onclick="deleteImage(${image.id})" aria-label="Delete picture">×</button></div>`).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', '<div class="modal-backdrop" id="typeManagerModal"><section class="modal type-manager"><div class="modal-header"><div><p class="eyebrow">Administration</p><h2>Inventory types</h2></div><button class="icon-button" id="closeTypeManager" aria-label="Close">×</button></div><form id="typeForm"><input id="editingTypeName" type="hidden"><div class="form-grid"><label class="full">Type name *<input id="typeName" required maxlength="80" placeholder="e.g. Camera"></label><div class="type-fields full" id="typeFields"></div></div><div class="form-actions"><button type="button" class="secondary-button" id="resetTypeForm">New type</button><button class="primary-button" type="submit">Save type</button></div></form><div class="type-list" id="typeList"></div></section></div>');
|
||
$('#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 = `<span>Fields shown for this type</span><div class="field-checklist">${Object.entries(fieldLabels).map(([key, label]) => `<label><input type="checkbox" name="type_field" value="${key}">${label}</label>`).join('')}</div>`;
|
||
$('#typeList').innerHTML = state.types.map((type) => `<div class="type-row"><div><strong>${escapeHtml(type.name)}</strong><span>${type.fields.map((field) => escapeHtml(fieldLabels[field])).join(', ') || 'Name, type, and status only'}</span></div><div class="row-actions"><button class="small-action" type="button" onclick="editType(${JSON.stringify(type.name)})">Edit</button><button class="small-action delete" type="button" onclick="removeType(${JSON.stringify(type.name)})">Delete</button></div></div>`).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); } };
|
||
async function removeSelectedItems() { const count = state.selectedIds.size; if (!count || !confirm(`Remove ${count} selected ${count === 1 ? 'item' : 'items'} from inventory?`)) return; try { const result = await request('/api/items/bulk-delete', { method: 'POST', body: JSON.stringify({ item_ids: [...state.selectedIds] }) }); state.selectedIds.clear(); toast(`${result.deleted} ${result.deleted === 1 ? 'item' : 'items'} 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);
|
||
if ($('#selectAllItems')) $('#selectAllItems').addEventListener('change', (event) => { state.items.forEach((item) => { if (event.target.checked) state.selectedIds.add(item.id); else state.selectedIds.delete(item.id); }); renderItems(state.items); });
|
||
if ($('#inventoryBody')) $('#inventoryBody').addEventListener('change', (event) => { if (!event.target.matches('.item-selector')) return; const itemId = Number(event.target.value); if (event.target.checked) state.selectedIds.add(itemId); else state.selectedIds.delete(itemId); updateBulkActions(); });
|
||
if ($('#bulkDeleteButton')) $('#bulkDeleteButton').addEventListener('click', removeSelectedItems);
|
||
$('#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) => `<p class="user-row"><strong>${escapeHtml(user.username)}</strong><span>${escapeHtml(user.role.replace('_', ' '))}${user.can_data_tools ? ' · data tools' : ''}</span></p>`).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));
|