diff --git a/app.py b/app.py index 687739c..8da426d 100644 --- a/app.py +++ b/app.py @@ -25,14 +25,14 @@ ROLE_ADMIN = "admin" VALID_ROLES = {ROLE_READ_ONLY, ROLE_READ_WRITE, ROLE_ADMIN} CONFIGURABLE_FIELDS = { "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 = { "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"], "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"], - "CD": ["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", "release_date", "media_format", "song_list", "notes"], "Display": ["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"], @@ -65,7 +65,9 @@ def initialize_db(): destination TEXT, checkout_date TEXT, purchase_date TEXT, + release_date TEXT, notes TEXT, + song_list TEXT, cpu TEXT, ram TEXT, gpu TEXT, @@ -105,6 +107,8 @@ def initialize_db(): "image_filename": "ALTER TABLE inventory_items ADD COLUMN image_filename TEXT", "destination": "ALTER TABLE inventory_items ADD COLUMN destination 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(): if column not in columns: @@ -118,6 +122,13 @@ def initialize_db(): "INSERT OR IGNORE INTO inventory_types (name, fields) VALUES (?, ?)", (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( """INSERT INTO inventory_images (item_id, filename, original_name, created_at) SELECT id, image_filename, image_filename, COALESCE(updated_at, datetime('now')) @@ -257,7 +268,9 @@ def item_payload(data): "destination": clean(data.get("destination")), "checkout_date": clean(data.get("checkout_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")), + "song_list": clean(data.get("song_list")), "cpu": clean(data.get("cpu")), "ram": clean(data.get("ram")), "gpu": clean(data.get("gpu")), @@ -540,8 +553,8 @@ def create_item(): with get_db() as connection: cursor = connection.execute( """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) - VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)""", + (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 (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)""", (*data.values(), now, now), ) 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 saved_images = save_images(new_images) 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), ) for filename, original_name in saved_images: diff --git a/data/inventory.db b/data/inventory.db index 8ba638f..4890627 100644 Binary files a/data/inventory.db and b/data/inventory.db differ diff --git a/static/app.js b/static/app.js index 584f02a..c913b2e 100644 --- a/static/app.js +++ b/static/app.js @@ -3,7 +3,7 @@ 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', 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 = {}) { 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 ensureImageControls() { if (!$('#destination')) $('[name="location"]').insertAdjacentHTML('afterend', ''); if (!$('#checkoutDate')) $('#destination').parentElement.insertAdjacentHTML('afterend', ''); if ($('#imageInput')) return; const section = document.querySelector('.form-section'); section.insertAdjacentHTML('beforebegin', ''); } +function ensureImageControls() { if (!$('#destination')) $('[name="location"]').insertAdjacentHTML('afterend', ''); if (!$('#checkoutDate')) $('#destination').parentElement.insertAdjacentHTML('afterend', ''); if (!$('[name="release_date"]')) $('[name="purchase_date"]').parentElement.insertAdjacentHTML('afterend', ''); if (!$('[name="song_list"]')) $('[name="media_format"]').parentElement.insertAdjacentHTML('afterend', ''); if ($('#imageInput')) return; const section = document.querySelector('.form-section'); section.insertAdjacentHTML('beforebegin', ''); } function updateTypeFields() { const type = $('[name="item_type"]').value; const visibleFields = new Set(state.types.find((entry) => entry.name === type)?.fields || []); diff --git a/static/style.css b/static/style.css index 7b7ea75..18eed0c 100644 --- a/static/style.css +++ b/static/style.css @@ -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)} -.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} .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)}