Add battery to items, add Pink Floyd and Helloween albums to database + some fixes to manage types
@@ -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:
|
||||||
|
|||||||
|
After Width: | Height: | Size: 71 KiB |
|
After Width: | Height: | Size: 54 KiB |
|
After Width: | Height: | Size: 69 KiB |
|
After Width: | Height: | Size: 99 KiB |
|
After Width: | Height: | Size: 73 KiB |
|
After Width: | Height: | Size: 92 KiB |
|
After Width: | Height: | Size: 64 KiB |
|
After Width: | Height: | Size: 87 KiB |
@@ -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) => ({ '&': '&', '<': '<', '>': '>', "'": ''', '"': '"' }[char])); }
|
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 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));
|
||||||
|
|||||||