Files
Docker-Inventory/static/app.js
T

120 lines
18 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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', battery: 'Battery', 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) => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', "'": '&#39;', '"': '&quot;' }[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="battery"]')) $('[name="storage"]').parentElement.insertAdjacentHTML('afterend', '<label data-configurable-field="battery">Battery<input name="battery" placeholder="e.g. 68 Wh, health 92%"></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);
$('#typeList').addEventListener('click', (event) => { const button = event.target.closest('[data-type-action]'); if (!button) return; const name = button.dataset.typeName; if (button.dataset.typeAction === 'edit') editType(name); if (button.dataset.typeAction === 'delete') removeType(name); });
}
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" data-type-action="edit" data-type-name="${escapeHtml(type.name)}">Edit</button><button class="small-action delete" type="button" data-type-action="delete" data-type-name="${escapeHtml(type.name)}">Delete</button></div></div>`).join('');
}
async function openTypeManager() { ensureTypeManager(); await loadTypes(); resetTypeForm(); renderTypeManager(); $('#typeManagerModal').classList.add('open'); }
function 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); }); }
async function removeType(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));