Add battery to items, add Pink Floyd and Helloween albums to database + some fixes to manage types

This commit is contained in:
2026-08-29 08:16:46 +03:00
parent 3403974150
commit 91cef24f68
11 changed files with 21 additions and 13 deletions
+14 -7
View File
@@ -25,10 +25,10 @@ ROLE_ADMIN = "admin"
VALID_ROLES = {ROLE_READ_ONLY, ROLE_READ_WRITE, ROLE_ADMIN} VALID_ROLES = {ROLE_READ_ONLY, ROLE_READ_WRITE, ROLE_ADMIN}
CONFIGURABLE_FIELDS = { CONFIGURABLE_FIELDS = {
"brand", "model", "serial_number", "asset_tag", "location", "destination", "brand", "model", "serial_number", "asset_tag", "location", "destination",
"checkout_date", "purchase_date", "release_date", "notes", "song_list", "cpu", "ram", "gpu", "storage", "media_format", "checkout_date", "purchase_date", "release_date", "notes", "song_list", "cpu", "ram", "gpu", "storage", "battery", "media_format",
} }
DEFAULT_TYPES = { DEFAULT_TYPES = {
"Computer": ["brand", "model", "serial_number", "asset_tag", "location", "purchase_date", "cpu", "ram", "gpu", "storage", "notes"], "Computer": ["brand", "model", "serial_number", "asset_tag", "location", "purchase_date", "cpu", "ram", "gpu", "storage", "battery", "notes"],
"Audio": ["brand", "model", "serial_number", "asset_tag", "location", "purchase_date", "media_format", "notes"], "Audio": ["brand", "model", "serial_number", "asset_tag", "location", "purchase_date", "media_format", "notes"],
"Graphics": ["brand", "model", "serial_number", "asset_tag", "location", "purchase_date", "gpu", "storage", "notes"], "Graphics": ["brand", "model", "serial_number", "asset_tag", "location", "purchase_date", "gpu", "storage", "notes"],
"Long play disk": ["brand", "model", "asset_tag", "location", "purchase_date", "release_date", "media_format", "song_list", "notes"], "Long play disk": ["brand", "model", "asset_tag", "location", "purchase_date", "release_date", "media_format", "song_list", "notes"],
@@ -72,6 +72,7 @@ def initialize_db():
ram TEXT, ram TEXT,
gpu TEXT, gpu TEXT,
storage TEXT, storage TEXT,
battery TEXT,
media_format TEXT, media_format TEXT,
image_filename TEXT, image_filename TEXT,
created_at TEXT NOT NULL, created_at TEXT NOT NULL,
@@ -109,6 +110,7 @@ def initialize_db():
"checkout_date": "ALTER TABLE inventory_items ADD COLUMN checkout_date TEXT", "checkout_date": "ALTER TABLE inventory_items ADD COLUMN checkout_date TEXT",
"release_date": "ALTER TABLE inventory_items ADD COLUMN release_date TEXT", "release_date": "ALTER TABLE inventory_items ADD COLUMN release_date TEXT",
"song_list": "ALTER TABLE inventory_items ADD COLUMN song_list TEXT", "song_list": "ALTER TABLE inventory_items ADD COLUMN song_list TEXT",
"battery": "ALTER TABLE inventory_items ADD COLUMN battery TEXT",
} }
for column, statement in migrations.items(): for column, statement in migrations.items():
if column not in columns: if column not in columns:
@@ -122,11 +124,15 @@ def initialize_db():
"INSERT OR IGNORE INTO inventory_types (name, fields) VALUES (?, ?)", "INSERT OR IGNORE INTO inventory_types (name, fields) VALUES (?, ?)",
(name, json.dumps(fields)), (name, json.dumps(fields)),
) )
for name in ("CD", "Long play disk"): for name, additional_fields in {
"CD": ("release_date", "song_list"),
"Long play disk": ("release_date", "song_list"),
"Computer": ("battery",),
}.items():
row = connection.execute("SELECT fields FROM inventory_types WHERE name = ? COLLATE NOCASE", (name,)).fetchone() row = connection.execute("SELECT fields FROM inventory_types WHERE name = ? COLLATE NOCASE", (name,)).fetchone()
if row: if row:
fields = json.loads(row["fields"]) fields = json.loads(row["fields"])
updated_fields = fields + [field for field in ("release_date", "song_list") if field not in fields] updated_fields = fields + [field for field in additional_fields if field not in fields]
if updated_fields != fields: if updated_fields != fields:
connection.execute("UPDATE inventory_types SET fields = ? WHERE name = ? COLLATE NOCASE", (json.dumps(updated_fields), name)) connection.execute("UPDATE inventory_types SET fields = ? WHERE name = ? COLLATE NOCASE", (json.dumps(updated_fields), name))
connection.execute( connection.execute(
@@ -275,6 +281,7 @@ def item_payload(data):
"ram": clean(data.get("ram")), "ram": clean(data.get("ram")),
"gpu": clean(data.get("gpu")), "gpu": clean(data.get("gpu")),
"storage": clean(data.get("storage")), "storage": clean(data.get("storage")),
"battery": clean(data.get("battery")),
"media_format": clean(data.get("media_format")), "media_format": clean(data.get("media_format")),
} }
return payload return payload
@@ -553,8 +560,8 @@ def create_item():
with get_db() as connection: with get_db() as connection:
cursor = connection.execute( cursor = connection.execute(
"""INSERT INTO inventory_items """INSERT INTO inventory_items
(name, item_type, brand, model, serial_number, asset_tag, status, location, destination, checkout_date, purchase_date, release_date, notes, song_list, cpu, ram, gpu, storage, media_format, created_at, updated_at) (name, item_type, brand, model, serial_number, asset_tag, status, location, destination, checkout_date, purchase_date, release_date, notes, song_list, cpu, ram, gpu, storage, battery, media_format, created_at, updated_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)""", VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)""",
(*data.values(), now, now), (*data.values(), now, now),
) )
for filename, original_name in images: for filename, original_name in images:
@@ -587,7 +594,7 @@ def update_item(item_id):
return jsonify({"error": "Each inventory item can have up to 5 pictures."}), 400 return jsonify({"error": "Each inventory item can have up to 5 pictures."}), 400
saved_images = save_images(new_images) saved_images = save_images(new_images)
result = connection.execute( result = connection.execute(
"""UPDATE inventory_items SET name=?, item_type=?, brand=?, model=?, serial_number=?, asset_tag=?, status=?, location=?, destination=?, checkout_date=?, purchase_date=?, release_date=?, notes=?, song_list=?, cpu=?, ram=?, gpu=?, storage=?, media_format=?, updated_at=? WHERE id=?""", """UPDATE inventory_items SET name=?, item_type=?, brand=?, model=?, serial_number=?, asset_tag=?, status=?, location=?, destination=?, checkout_date=?, purchase_date=?, release_date=?, notes=?, song_list=?, cpu=?, ram=?, gpu=?, storage=?, battery=?, media_format=?, updated_at=? WHERE id=?""",
(*data.values(), item_id), (*data.values(), item_id),
) )
for filename, original_name in saved_images: for filename, original_name in saved_images:
BIN
View File
Binary file not shown.
Binary file not shown.

After

Width:  |  Height:  |  Size: 71 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 54 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 69 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 99 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 73 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 92 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 64 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 87 KiB

+7 -6
View File
@@ -3,7 +3,7 @@ const role = document.body.dataset.role;
const canWrite = role === 'admin' || role === 'read_write'; const canWrite = role === 'admin' || role === 'read_write';
const canDelete = role === 'admin'; const canDelete = role === 'admin';
const $ = (selector) => document.querySelector(selector); 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' }; 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 = {}) { async function request(url, options = {}) {
const headers = options.body instanceof FormData ? {} : { 'Content-Type': 'application/json' }; const headers = options.body instanceof FormData ? {} : { 'Content-Type': 'application/json' };
@@ -49,7 +49,7 @@ function updateBulkActions() {
} }
function escapeHtml(value) { return String(value).replace(/[&<>'"]/g, (char) => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', "'": '&#39;', '"': '&quot;' }[char])); } 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="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 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() { function updateTypeFields() {
const type = $('[name="item_type"]').value; const type = $('[name="item_type"]').value;
const visibleFields = new Set(state.types.find((entry) => entry.name === type)?.fields || []); const visibleFields = new Set(state.types.find((entry) => entry.name === type)?.fields || []);
@@ -77,15 +77,16 @@ function ensureTypeManager() {
$('#resetTypeForm').addEventListener('click', resetTypeForm); $('#resetTypeForm').addEventListener('click', resetTypeForm);
$('#typeManagerModal').addEventListener('click', (event) => { if (event.target === $('#typeManagerModal')) $('#typeManagerModal').classList.remove('open'); }); $('#typeManagerModal').addEventListener('click', (event) => { if (event.target === $('#typeManagerModal')) $('#typeManagerModal').classList.remove('open'); });
$('#typeForm').addEventListener('submit', saveType); $('#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 resetTypeForm() { $('#typeForm').reset(); $('#editingTypeName').value = ''; }
function renderTypeManager() { 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>`; $('#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(''); $('#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('');
} }
function openTypeManager() { ensureTypeManager(); resetTypeForm(); renderTypeManager(); $('#typeManagerModal').classList.add('open'); } async function openTypeManager() { ensureTypeManager(); await loadTypes(); 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); }); }; 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); }); }
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 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); } } 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.editItem = (id) => openModal(state.items.find((item) => item.id === id));