Added accounting and more example laptops

This commit is contained in:
2026-08-27 01:16:16 +03:00
parent c608a8aa63
commit 7d38d03d62
10 changed files with 282 additions and 13 deletions
+14 -7
View File
@@ -1,4 +1,7 @@
const state = { items: [], editingId: null };
const role = document.body.dataset.role;
const canWrite = role === 'admin' || role === 'read_write';
const canDelete = role === 'admin';
const $ = (selector) => document.querySelector(selector);
async function request(url, options = {}) {
@@ -23,8 +26,9 @@ async function load() {
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"><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"><button class="small-action" onclick="editItem(${item.id})">Edit</button><button class="small-action delete" onclick="removeItem(${item.id})">Delete</button></div></td></tr>`).join('');
body.innerHTML = items.map((item) => `<tr class="inventory-row"><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('');
}
function escapeHtml(value) { return String(value).replace(/[&<>'"]/g, (char) => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', "'": '&#39;', '"': '&quot;' }[char])); }
@@ -38,8 +42,8 @@ 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); } };
$('#addButton').addEventListener('click', () => openModal());
$('#emptyAddButton').addEventListener('click', () => openModal());
if ($('#addButton')) $('#addButton').addEventListener('click', () => openModal());
if ($('#emptyAddButton')) $('#emptyAddButton').addEventListener('click', () => openModal());
$('#closeModal').addEventListener('click', closeModal);
$('#cancelButton').addEventListener('click', closeModal);
$('#modal').addEventListener('click', (event) => { if (event.target === $('#modal')) closeModal(); });
@@ -47,8 +51,11 @@ $('#modal').addEventListener('click', (event) => { if (event.target === $('#moda
ensureImageControls();
$('#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); } });
$('#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); });
$('#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); } });
$('#restoreButton').addEventListener('click', () => $('#restoreInput').click());
$('#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 = ''; });
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); } });
load().catch((error) => toast(error.message));
+2
View File
File diff suppressed because one or more lines are too long
+40
View File
@@ -0,0 +1,40 @@
const $ = (selector) => document.querySelector(selector);
async function request(url, options = {}) {
const response = await fetch(url, { headers: { 'Content-Type': 'application/json' }, ...options });
const data = await response.json();
if (!response.ok) throw new Error(data.error || 'Something went wrong.');
return data;
}
function escapeHtml(value) {
return String(value).replace(/[&<>'"]/g, (character) => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', "'": '&#39;', '"': '&quot;' }[character]));
}
function toast(message) {
const element = $('#toast');
element.textContent = message;
element.classList.add('show');
setTimeout(() => element.classList.remove('show'), 2500);
}
async function loadUsers() {
const users = await request('/api/users');
$('#userCount').textContent = `${users.length} ${users.length === 1 ? 'user' : 'users'}`;
$('#usersList').innerHTML = users.map((user) => `<div class="account-row"><div><strong>${escapeHtml(user.username)}</strong><span>${escapeHtml(user.role.replace('_', ' '))}</span></div><span class="account-permission">${user.can_data_tools ? 'Export, backup, restore' : 'Inventory access only'}</span></div>`).join('');
}
$('#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);
}
});
loadUsers().catch((error) => toast(error.message));