Add songlist and release date

This commit is contained in:
2026-08-29 07:43:12 +03:00
parent 634a25b2c6
commit 3403974150
4 changed files with 22 additions and 9 deletions
+19 -6
View File
@@ -25,14 +25,14 @@ 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", "notes", "cpu", "ram", "gpu", "storage", "media_format", "checkout_date", "purchase_date", "release_date", "notes", "song_list", "cpu", "ram", "gpu", "storage", "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", "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", "media_format", "notes"], "Long play disk": ["brand", "model", "asset_tag", "location", "purchase_date", "release_date", "media_format", "song_list", "notes"],
"CD": ["brand", "model", "asset_tag", "location", "purchase_date", "media_format", "notes"], "CD": ["brand", "model", "asset_tag", "location", "purchase_date", "release_date", "media_format", "song_list", "notes"],
"Display": ["brand", "model", "serial_number", "asset_tag", "location", "purchase_date", "notes"], "Display": ["brand", "model", "serial_number", "asset_tag", "location", "purchase_date", "notes"],
"Peripheral": ["brand", "model", "serial_number", "asset_tag", "location", "purchase_date", "notes"], "Peripheral": ["brand", "model", "serial_number", "asset_tag", "location", "purchase_date", "notes"],
"Other": ["brand", "model", "serial_number", "asset_tag", "location", "purchase_date", "notes"], "Other": ["brand", "model", "serial_number", "asset_tag", "location", "purchase_date", "notes"],
@@ -65,7 +65,9 @@ def initialize_db():
destination TEXT, destination TEXT,
checkout_date TEXT, checkout_date TEXT,
purchase_date TEXT, purchase_date TEXT,
release_date TEXT,
notes TEXT, notes TEXT,
song_list TEXT,
cpu TEXT, cpu TEXT,
ram TEXT, ram TEXT,
gpu TEXT, gpu TEXT,
@@ -105,6 +107,8 @@ def initialize_db():
"image_filename": "ALTER TABLE inventory_items ADD COLUMN image_filename TEXT", "image_filename": "ALTER TABLE inventory_items ADD COLUMN image_filename TEXT",
"destination": "ALTER TABLE inventory_items ADD COLUMN destination TEXT", "destination": "ALTER TABLE inventory_items ADD COLUMN destination TEXT",
"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",
"song_list": "ALTER TABLE inventory_items ADD COLUMN song_list TEXT",
} }
for column, statement in migrations.items(): for column, statement in migrations.items():
if column not in columns: if column not in columns:
@@ -118,6 +122,13 @@ 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"):
row = connection.execute("SELECT fields FROM inventory_types WHERE name = ? COLLATE NOCASE", (name,)).fetchone()
if row:
fields = json.loads(row["fields"])
updated_fields = fields + [field for field in ("release_date", "song_list") if field not in fields]
if updated_fields != fields:
connection.execute("UPDATE inventory_types SET fields = ? WHERE name = ? COLLATE NOCASE", (json.dumps(updated_fields), name))
connection.execute( connection.execute(
"""INSERT INTO inventory_images (item_id, filename, original_name, created_at) """INSERT INTO inventory_images (item_id, filename, original_name, created_at)
SELECT id, image_filename, image_filename, COALESCE(updated_at, datetime('now')) SELECT id, image_filename, image_filename, COALESCE(updated_at, datetime('now'))
@@ -257,7 +268,9 @@ def item_payload(data):
"destination": clean(data.get("destination")), "destination": clean(data.get("destination")),
"checkout_date": clean(data.get("checkout_date")) or None, "checkout_date": clean(data.get("checkout_date")) or None,
"purchase_date": clean(data.get("purchase_date")) or None, "purchase_date": clean(data.get("purchase_date")) or None,
"release_date": clean(data.get("release_date")) or None,
"notes": clean(data.get("notes")), "notes": clean(data.get("notes")),
"song_list": clean(data.get("song_list")),
"cpu": clean(data.get("cpu")), "cpu": clean(data.get("cpu")),
"ram": clean(data.get("ram")), "ram": clean(data.get("ram")),
"gpu": clean(data.get("gpu")), "gpu": clean(data.get("gpu")),
@@ -540,8 +553,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, notes, 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, 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:
@@ -574,7 +587,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=?, notes=?, 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=?, 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.
+2 -2
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', notes: 'Notes', 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', 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 ($('#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="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 || []);
+1 -1
View File
@@ -1,5 +1,5 @@
.bulk-actions{display:flex;align-items:center;gap:9px;margin-left:auto;color:var(--muted);font-size:12px;font-weight:700}.selection-column{width:34px;text-align:center}.selection-column input{accent-color:var(--teal)} .bulk-actions{display:flex;align-items:center;gap:9px;margin-left:auto;color:var(--muted);font-size:12px;font-weight:700}.selection-column{width:34px;text-align:center}.selection-column input{accent-color:var(--teal)}
.type-manager{max-width:720px}.type-fields>span{display:block;color:var(--muted);font-size:12px;font-weight:700;margin-bottom:9px}.field-checklist{display:grid;grid-template-columns:repeat(3,minmax(0,1fr));gap:8px 14px}.field-checklist label{display:flex;align-items:center;gap:7px;font-size:12px;color:var(--ink)}.field-checklist input{width:auto}.type-list{border-top:1px solid var(--line);margin-top:22px;padding-top:4px}.type-row{display:flex;align-items:center;justify-content:space-between;gap:16px;border-bottom:1px solid var(--line);padding:13px 0}.type-row strong,.type-row span{display:block}.type-row span{color:var(--muted);font-size:11px;margin-top:4px;max-width:460px}.type-row .row-actions{display:flex;gap:7px;flex:none}@media(max-width:640px){.field-checklist{grid-template-columns:repeat(2,minmax(0,1fr))}.type-row{align-items:flex-start}.type-row .row-actions{flex-direction:column}.type-row span{max-width:230px}.bulk-actions{width:100%;margin-left:0;justify-content:space-between}} .type-manager{max-width:720px}.type-fields>span{display:block;color:var(--muted);font-size:12px;font-weight:700;margin-bottom:9px}.field-checklist{display:grid;grid-template-columns:repeat(3,minmax(0,1fr));gap:8px 14px}.field-checklist label{display:grid;grid-template-columns:16px minmax(0,1fr);align-items:center;gap:7px;min-height:28px;font-size:12px;color:var(--ink);white-space:normal}.field-checklist input{width:16px;height:16px;margin:0}.type-list{border-top:1px solid var(--line);margin-top:22px;padding-top:4px}.type-row{display:flex;align-items:center;justify-content:space-between;gap:16px;border-bottom:1px solid var(--line);padding:13px 0}.type-row strong,.type-row span{display:block}.type-row span{color:var(--muted);font-size:11px;margin-top:4px;max-width:460px}.type-row .row-actions{display:flex;gap:7px;flex:none}@media(max-width:640px){.field-checklist{grid-template-columns:repeat(2,minmax(0,1fr))}.type-row{align-items:flex-start}.type-row .row-actions{flex-direction:column}.type-row span{max-width:230px}.bulk-actions{width:100%;margin-left:0;justify-content:space-between}}
.item-cell{display:flex;align-items:center;gap:11px}.item-thumb{width:38px;height:38px;object-fit:cover;border-radius:6px;background:#e8efeb;flex:none}.item-thumb.placeholder{display:grid;place-items:center;color:#8aa39b;font-size:20px}.image-count{background:#e7f3f0;color:var(--teal);font-size:10px;font-weight:700;padding:4px 5px;border-radius:4px;margin-left:-7px}.image-field input{padding:7px 0!important;border:0!important}.image-field span{display:block;color:#8c9994;font-size:10px;font-weight:400;margin-top:4px}.image-gallery{display:flex;flex-wrap:wrap;gap:9px;margin-top:11px}.gallery-item{position:relative;width:82px;height:64px}.gallery-item img{width:100%;height:100%;object-fit:cover;border-radius:6px;border:1px solid var(--line)}.gallery-delete{position:absolute;top:-7px;right:-7px;width:20px;height:20px;padding:0;border:0;border-radius:50%;background:#193f3b;color:#fff;cursor:pointer;line-height:18px}.hidden{display:none!important} .item-cell{display:flex;align-items:center;gap:11px}.item-thumb{width:38px;height:38px;object-fit:cover;border-radius:6px;background:#e8efeb;flex:none}.item-thumb.placeholder{display:grid;place-items:center;color:#8aa39b;font-size:20px}.image-count{background:#e7f3f0;color:var(--teal);font-size:10px;font-weight:700;padding:4px 5px;border-radius:4px;margin-left:-7px}.image-field input{padding:7px 0!important;border:0!important}.image-field span{display:block;color:#8c9994;font-size:10px;font-weight:400;margin-top:4px}.image-gallery{display:flex;flex-wrap:wrap;gap:9px;margin-top:11px}.gallery-item{position:relative;width:82px;height:64px}.gallery-item img{width:100%;height:100%;object-fit:cover;border-radius:6px;border:1px solid var(--line)}.gallery-delete{position:absolute;top:-7px;right:-7px;width:20px;height:20px;padding:0;border:0;border-radius:50%;background:#193f3b;color:#fff;cursor:pointer;line-height:18px}.hidden{display:none!important}
.row-actions{opacity:1!important} .row-actions{opacity:1!important}
.login-page{min-height:100vh;display:grid;place-items:center;background:linear-gradient(135deg,#f6f8f5,#dcece5)}.login-card{width:min(390px,calc(100% - 32px));padding:34px;background:#fff;border:1px solid var(--line);border-radius:12px;box-shadow:var(--shadow)}.login-card .brand-mark{margin-bottom:22px}.login-card h1{font-family:'Space Grotesk';margin:0 0 24px}.login-card form{display:grid;gap:16px}.login-card label,.user-form label{display:grid;gap:6px;color:var(--muted);font-size:12px;font-weight:700}.login-card input,.user-form input,.user-form select{width:100%;padding:11px;border:1px solid var(--line);border-radius:6px;background:#fff}.login-card .primary-button{margin-top:8px}.login-error{padding:10px;background:#fff0ed;color:#a33e2b;border-radius:6px;font-size:12px}.logout-button{margin-top:12px;border:0;background:transparent;color:#90afa9;padding:0;cursor:pointer;font-size:11px}.users-panel{margin-top:24px;padding:24px;background:#fff;border:1px solid var(--line);border-radius:8px}.user-form{display:flex;align-items:end;gap:12px;flex-wrap:wrap}.user-form label{min-width:150px}.user-form .checkbox-label{display:flex;align-items:center;gap:7px;min-width:auto}.user-form .checkbox-label input{width:auto}.user-row{display:flex;justify-content:space-between;border-top:1px solid var(--line);padding:10px 0;margin:18px 0 0;font-size:12px}.user-row span{color:var(--muted)} .login-page{min-height:100vh;display:grid;place-items:center;background:linear-gradient(135deg,#f6f8f5,#dcece5)}.login-card{width:min(390px,calc(100% - 32px));padding:34px;background:#fff;border:1px solid var(--line);border-radius:12px;box-shadow:var(--shadow)}.login-card .brand-mark{margin-bottom:22px}.login-card h1{font-family:'Space Grotesk';margin:0 0 24px}.login-card form{display:grid;gap:16px}.login-card label,.user-form label{display:grid;gap:6px;color:var(--muted);font-size:12px;font-weight:700}.login-card input,.user-form input,.user-form select{width:100%;padding:11px;border:1px solid var(--line);border-radius:6px;background:#fff}.login-card .primary-button{margin-top:8px}.login-error{padding:10px;background:#fff0ed;color:#a33e2b;border-radius:6px;font-size:12px}.logout-button{margin-top:12px;border:0;background:transparent;color:#90afa9;padding:0;cursor:pointer;font-size:11px}.users-panel{margin-top:24px;padding:24px;background:#fff;border:1px solid var(--line);border-radius:8px}.user-form{display:flex;align-items:end;gap:12px;flex-wrap:wrap}.user-form label{min-width:150px}.user-form .checkbox-label{display:flex;align-items:center;gap:7px;min-width:auto}.user-form .checkbox-label input{width:auto}.user-row{display:flex;justify-content:space-between;border-top:1px solid var(--line);padding:10px 0;margin:18px 0 0;font-size:12px}.user-row span{color:var(--muted)}