From 709c645af2de0a1c6854cbc595d93e52fdbf96e1 Mon Sep 17 00:00:00 2001 From: Tero Huttunen Date: Wed, 29 Jul 2026 21:12:28 +0300 Subject: [PATCH] Add possibility to upload images for inventory units --- README.md | 4 + __pycache__/app.cpython-314.pyc | Bin 11530 -> 21585 bytes app.py | 160 +++++++++++++++++++++++++++++--- static/app.js | 14 ++- static/style.css | 2 + 5 files changed, 165 insertions(+), 15 deletions(-) diff --git a/README.md b/README.md index 1b8b224..12e9be3 100644 --- a/README.md +++ b/README.md @@ -15,11 +15,15 @@ Open http://localhost:5000. The SQLite database is stored in the persistent `inv - Search by name, brand, model, serial number, asset tag, or location - Filter by item type and status - Add, edit, and delete inventory records +- Upload and display up to five device pictures per item (JPG, PNG, GIF, or WebP; up to 10 MB per request) +- Add more pictures or delete individual pictures while editing an item - Computer fields: CPU, RAM, GPU, and disk/storage - Media field: format - CSV export of the currently visible inventory - Responsive web UI for desktop and mobile +Uploaded pictures are stored in `/data/uploads` alongside the SQLite database, so they persist in the `inventory_data` Docker volume. Existing databases are migrated automatically when the application starts. + ## Local development ```bash diff --git a/__pycache__/app.cpython-314.pyc b/__pycache__/app.cpython-314.pyc index ac9bf2983fb88d170ce6be9c40b8a9ddbadbf4c8..18b0e2617251fba8d431be066fadd4062d1ddcf7 100644 GIT binary patch literal 21585 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 delta 3914 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 aXO-!CzzR$!Wp}UB-UB!3PImBgBmV{thE%2i diff --git a/app.py b/app.py index 56bc8d8..1a468b5 100644 --- a/app.py +++ b/app.py @@ -1,12 +1,17 @@ import os import sqlite3 +import uuid from datetime import datetime, timezone from pathlib import Path -from flask import Flask, jsonify, render_template, request +from flask import Flask, jsonify, render_template, request, send_from_directory +from werkzeug.utils import secure_filename app = Flask(__name__) DATABASE_PATH = os.environ.get("DATABASE_PATH", str(Path(__file__).with_name("inventory.db"))) +UPLOAD_FOLDER = Path(os.environ.get("UPLOAD_FOLDER", str(Path(DATABASE_PATH).parent / "uploads"))) +ALLOWED_IMAGE_EXTENSIONS = {"jpg", "jpeg", "png", "gif", "webp"} +app.config["MAX_CONTENT_LENGTH"] = 10 * 1024 * 1024 def get_db(): @@ -18,6 +23,7 @@ def get_db(): def initialize_db(): Path(DATABASE_PATH).parent.mkdir(parents=True, exist_ok=True) + UPLOAD_FOLDER.mkdir(parents=True, exist_ok=True) with get_db() as connection: connection.executescript( """ @@ -38,14 +44,33 @@ def initialize_db(): gpu TEXT, storage TEXT, media_format TEXT, + image_filename TEXT, created_at TEXT NOT NULL, updated_at TEXT NOT NULL ); CREATE INDEX IF NOT EXISTS idx_inventory_search ON inventory_items(name, brand, model, serial_number, asset_tag); CREATE INDEX IF NOT EXISTS idx_inventory_type ON inventory_items(item_type); CREATE INDEX IF NOT EXISTS idx_inventory_status ON inventory_items(status); + CREATE TABLE IF NOT EXISTS inventory_images ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + item_id INTEGER NOT NULL REFERENCES inventory_items(id) ON DELETE CASCADE, + filename TEXT NOT NULL, + original_name TEXT, + created_at TEXT NOT NULL + ); + CREATE INDEX IF NOT EXISTS idx_inventory_images_item ON inventory_images(item_id); """ ) + columns = {row[1] for row in connection.execute("PRAGMA table_info(inventory_items)")} + if "image_filename" not in columns: + connection.execute("ALTER TABLE inventory_items ADD COLUMN image_filename TEXT") + connection.execute( + """INSERT INTO inventory_images (item_id, filename, original_name, created_at) + SELECT id, image_filename, image_filename, COALESCE(updated_at, datetime('now')) + FROM inventory_items + WHERE image_filename IS NOT NULL + AND NOT EXISTS (SELECT 1 FROM inventory_images WHERE inventory_images.item_id = inventory_items.id)""" + ) def clean(value): @@ -72,8 +97,52 @@ def item_payload(data): } -def serialize(row): - return dict(row) +def serialize(row, connection): + item = dict(row) + item["images"] = [ + {"id": image["id"], "filename": image["filename"], "original_name": image["original_name"], "url": f"/media/{image['filename']}"} + for image in connection.execute("SELECT id, filename, original_name FROM inventory_images WHERE item_id = ? ORDER BY id", (row["id"],)).fetchall() + ] + item["image_url"] = item["images"][0]["url"] if item["images"] else None + return item + + +def request_data(): + return request.form if request.form else (request.get_json(silent=True) or {}) + + +def save_image(image): + if not image or not image.filename: + return None + original_name = secure_filename(image.filename) + extension = Path(original_name).suffix.lower().lstrip(".") + if extension not in ALLOWED_IMAGE_EXTENSIONS: + raise ValueError("Images must be JPG, PNG, GIF, or WebP files.") + filename = f"{uuid.uuid4().hex}.{extension}" + image.save(UPLOAD_FOLDER / filename) + return filename, original_name + + +def save_images(files): + saved = [] + try: + for image in files: + if image and image.filename: + saved.append(save_image(image)) + return saved + except ValueError: + for filename, _ in saved: + delete_image(filename) + raise + + +def delete_image(filename): + if filename: + (UPLOAD_FOLDER / filename).unlink(missing_ok=True) + + +def files_from_request(): + return request.files.getlist("images") or request.files.getlist("image") @app.route("/") @@ -81,6 +150,11 @@ def index(): return render_template("index.html") +@app.get("/media/") +def media(filename): + return send_from_directory(UPLOAD_FOLDER, filename) + + @app.get("/api/items") def list_items(): query = clean(request.args.get("q")) @@ -100,7 +174,7 @@ def list_items(): where = f"WHERE {' AND '.join(clauses)}" if clauses else "" with get_db() as connection: rows = connection.execute(f"SELECT * FROM inventory_items {where} ORDER BY updated_at DESC, id DESC", params).fetchall() - return jsonify([serialize(row) for row in rows]) + return jsonify([serialize(row, connection) for row in rows]) @app.get("/api/summary") @@ -115,9 +189,16 @@ def summary(): @app.post("/api/items") def create_item(): - data = item_payload(request.get_json(silent=True) or {}) + data = item_payload(request_data()) if not data["name"] or not data["item_type"]: return jsonify({"error": "Name and type are required."}), 400 + selected_files = [image for image in files_from_request() if image and image.filename] + if len(selected_files) > 5: + return jsonify({"error": "Each inventory item can have up to 5 pictures."}), 400 + try: + images = save_images(selected_files) + except ValueError as error: + return jsonify({"error": str(error)}), 400 now = datetime.now(timezone.utc).isoformat() try: with get_db() as connection: @@ -127,41 +208,98 @@ def create_item(): VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)""", (*data.values(), now, now), ) + for filename, original_name in images: + connection.execute("INSERT INTO inventory_images (item_id, filename, original_name, created_at) VALUES (?, ?, ?, ?)", (cursor.lastrowid, filename, original_name, now)) row = connection.execute("SELECT * FROM inventory_items WHERE id = ?", (cursor.lastrowid,)).fetchone() - return jsonify(serialize(row)), 201 - except sqlite3.IntegrityError as error: + result = serialize(row, connection) + return jsonify(result), 201 + except sqlite3.IntegrityError: + for filename, _ in images: + delete_image(filename) return jsonify({"error": "Serial number or asset tag already exists."}), 409 @app.put("/api/items/") def update_item(item_id): - data = item_payload(request.get_json(silent=True) or {}) + data = item_payload(request_data()) if not data["name"] or not data["item_type"]: return jsonify({"error": "Name and type are required."}), 400 data["updated_at"] = datetime.now(timezone.utc).isoformat() + new_images = files_from_request() + saved_images = [] try: with get_db() as connection: + current = connection.execute("SELECT image_filename FROM inventory_items WHERE id = ?", (item_id,)).fetchone() + if not current: + return jsonify({"error": "Item not found."}), 404 + existing_count = connection.execute("SELECT COUNT(*) FROM inventory_images WHERE item_id = ?", (item_id,)).fetchone()[0] + if existing_count + len([image for image in new_images if image and image.filename]) > 5: + 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=?, purchase_date=?, notes=?, cpu=?, ram=?, gpu=?, storage=?, media_format=?, updated_at=? WHERE id=?""", (*data.values(), item_id), ) - if result.rowcount == 0: - return jsonify({"error": "Item not found."}), 404 + for filename, original_name in saved_images: + connection.execute("INSERT INTO inventory_images (item_id, filename, original_name, created_at) VALUES (?, ?, ?, ?)", (item_id, filename, original_name, data["updated_at"])) row = connection.execute("SELECT * FROM inventory_items WHERE id = ?", (item_id,)).fetchone() - return jsonify(serialize(row)) + result = serialize(row, connection) + return jsonify(result) + except ValueError as error: + return jsonify({"error": str(error)}), 400 except sqlite3.IntegrityError: + for filename, _ in saved_images: + delete_image(filename) return jsonify({"error": "Serial number or asset tag already exists."}), 409 @app.delete("/api/items/") def delete_item(item_id): with get_db() as connection: + images = connection.execute("SELECT filename FROM inventory_images WHERE item_id = ?", (item_id,)).fetchall() result = connection.execute("DELETE FROM inventory_items WHERE id = ?", (item_id,)) if result.rowcount == 0: return jsonify({"error": "Item not found."}), 404 + for image in images: + delete_image(image["filename"]) return jsonify({"deleted": True}) +@app.post("/api/items//images") +def add_images(item_id): + files = files_from_request() + with get_db() as connection: + if not connection.execute("SELECT id FROM inventory_items WHERE id = ?", (item_id,)).fetchone(): + return jsonify({"error": "Item not found."}), 404 + current_count = connection.execute("SELECT COUNT(*) FROM inventory_images WHERE item_id = ?", (item_id,)).fetchone()[0] + selected_files = [image for image in files if image and image.filename] + if current_count + len(selected_files) > 5: + return jsonify({"error": "Each inventory item can have up to 5 pictures."}), 400 + try: + saved = save_images(selected_files) + except ValueError as error: + return jsonify({"error": str(error)}), 400 + now = datetime.now(timezone.utc).isoformat() + with get_db() as connection: + for filename, original_name in saved: + connection.execute("INSERT INTO inventory_images (item_id, filename, original_name, created_at) VALUES (?, ?, ?, ?)", (item_id, filename, original_name, now)) + row = connection.execute("SELECT * FROM inventory_items WHERE id = ?", (item_id,)).fetchone() + return jsonify(serialize(row, connection)) + + +@app.delete("/api/items//images/") +def remove_image(item_id, image_id): + with get_db() as connection: + image = connection.execute("SELECT filename FROM inventory_images WHERE id = ? AND item_id = ?", (image_id, item_id)).fetchone() + if not image: + return jsonify({"error": "Picture not found."}), 404 + connection.execute("DELETE FROM inventory_images WHERE id = ?", (image_id,)) + item = connection.execute("SELECT * FROM inventory_items WHERE id = ?", (item_id,)).fetchone() + result = serialize(item, connection) + delete_image(image["filename"]) + return jsonify(result) + + initialize_db() if __name__ == "__main__": diff --git a/static/app.js b/static/app.js index 8f0bf8c..6ff31da 100644 --- a/static/app.js +++ b/static/app.js @@ -2,7 +2,8 @@ const state = { items: [], editingId: null }; const $ = (selector) => document.querySelector(selector); async function request(url, options = {}) { - const response = await fetch(url, { headers: { 'Content-Type': 'application/json' }, ...options }); + const headers = options.body instanceof FormData ? {} : { 'Content-Type': 'application/json' }; + const response = await fetch(url, { headers, ...options }); const data = await response.json(); if (!response.ok) throw new Error(data.error || 'Something went wrong.'); return data; @@ -23,15 +24,18 @@ async function load() { function renderItems(items) { const body = $('#inventoryBody'); $('#emptyState').style.display = items.length ? 'none' : 'block'; - body.innerHTML = items.map((item) => `${escapeHtml(item.name)}${escapeHtml([item.brand, item.model].filter(Boolean).join(' · ') || 'No manufacturer details')}${escapeHtml(item.item_type)}${escapeHtml(item.serial_number || 'No serial')}${escapeHtml(item.asset_tag || 'No asset tag')}${escapeHtml(item.location || 'Unassigned')}${escapeHtml(item.status)}
`).join(''); + body.innerHTML = items.map((item) => `
${item.images?.length ? `${item.images.length > 1 ? `+${item.images.length - 1}` : ''}` : '
◌
'}
${escapeHtml(item.name)}${escapeHtml([item.brand, item.model].filter(Boolean).join(' · ') || 'No manufacturer details')}
${escapeHtml(item.item_type)}${escapeHtml(item.serial_number || 'No serial')}${escapeHtml(item.asset_tag || 'No asset tag')}${escapeHtml(item.location || 'Unassigned')}${escapeHtml(item.status)}
`).join(''); } function escapeHtml(value) { return String(value).replace(/[&<>'"]/g, (char) => ({ '&': '&', '<': '<', '>': '>', "'": ''', '"': '"' }[char])); } -function openModal(item = null) { state.editingId = item?.id || null; $('#modalTitle').textContent = item ? 'Edit item' : 'Add item'; $('#itemForm').reset(); if (item) Object.entries(item).forEach(([key, value]) => { const field = $(`[name="${key}"]`); if (field) field.value = value || ''; }); $('#modal').classList.add('open'); $('[name="name"]').focus(); } +function ensureImageControls() { if ($('#imageInput')) return; const section = document.querySelector('.form-section'); section.insertAdjacentHTML('beforebegin', ''); } +function renderImageGallery(images = []) { $('#imageGallery').innerHTML = images.map((image) => ``).join(''); } +function openModal(item = null) { ensureImageControls(); state.editingId = item?.id || null; $('#modalTitle').textContent = item ? 'Edit item' : 'Add item'; $('#itemForm').reset(); $('#imageName').textContent = item?.images?.length ? `${item.images.length} saved picture${item.images.length === 1 ? '' : 's'}. Add more below.` : 'Up to 5 pictures'; renderImageGallery(item?.images || []); $('#modal').classList.add('open'); $('[name="name"]').focus(); if (item) Object.entries(item).forEach(([key, value]) => { const field = $(`[name="${key}"]`); if (field && key !== 'images') field.value = value || ''; }); } function closeModal() { $('#modal').classList.remove('open'); } function toast(message) { const element = $('#toast'); element.textContent = message; element.classList.add('show'); setTimeout(() => element.classList.remove('show'), 2500); } 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()); @@ -40,6 +44,8 @@ $('#closeModal').addEventListener('click', closeModal); $('#cancelButton').addEventListener('click', closeModal); $('#modal').addEventListener('click', (event) => { if (event.target === $('#modal')) closeModal(); }); ['searchInput', 'typeFilter', 'statusFilter'].forEach((id) => $(`#${id}`).addEventListener(id === 'searchInput' ? 'input' : 'change', load)); -$('#itemForm').addEventListener('submit', async (event) => { event.preventDefault(); const formData = new FormData(event.target); const payload = Object.fromEntries(formData.entries()); try { await request(state.editingId ? `/api/items/${state.editingId}` : '/api/items', { method: state.editingId ? 'PUT' : 'POST', body: JSON.stringify(payload) }); closeModal(); toast(state.editingId ? 'Item updated.' : 'Item added to inventory.'); await load(); } catch (error) { toast(error.message); } }); +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','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); }); load().catch((error) => toast(error.message)); diff --git a/static/style.css b/static/style.css index d599f0e..35d5e82 100644 --- a/static/style.css +++ b/static/style.css @@ -1 +1,3 @@ +.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} *{box-sizing:border-box} :root{--ink:#17211f;--muted:#76817d;--line:#e5e9e5;--paper:#f6f8f5;--white:#fff;--teal:#0e7770;--teal-dark:#075c57;--orange:#e9823d;--shadow:0 14px 40px rgba(31,52,45,.07)} body{margin:0;background:var(--paper);color:var(--ink);font-family:'DM Sans',sans-serif}button,input,select,textarea{font:inherit}.app-shell{display:flex;min-height:100vh}.sidebar{width:242px;background:#193f3b;color:#d9e8e3;padding:26px 18px;display:flex;flex-direction:column}.brand{display:flex;align-items:center;gap:11px;margin:0 10px 58px}.brand-mark{width:35px;height:35px;border-radius:10px;background:#f0a15d;color:#193f3b;display:grid;place-items:center;font-family:'Space Grotesk';font-weight:700;font-size:20px}.brand strong{display:block;font-family:'Space Grotesk';font-size:17px;color:#fff}.brand span{display:block;font-size:10px;color:#92b1aa;margin-top:2px}.nav-item{width:100%;padding:12px 13px;border:0;border-radius:8px;background:transparent;color:#9dbab4;text-align:left;cursor:pointer;font-weight:600;display:flex;gap:12px;align-items:center;margin-bottom:5px}.nav-item.active,.nav-item:hover{background:#275852;color:#fff}.nav-icon{font-size:20px;width:18px;text-align:center}.sidebar-footer{margin-top:auto;border-top:1px solid #2a5b55;padding:18px 10px 2px;color:#90afa9;font-size:11px}.sidebar-footer small{display:block;color:#6f958d;margin-top:8px}.status-dot{display:inline-block;width:7px;height:7px;background:#75d19a;border-radius:50%;margin-right:7px}.main-content{flex:1;padding:42px 48px;max-width:1500px}.topbar{display:flex;align-items:flex-end;justify-content:space-between;margin-bottom:32px}.eyebrow{color:var(--teal);text-transform:uppercase;letter-spacing:.13em;font-size:10px;font-weight:700;margin:0 0 8px}.topbar h1,.panel-heading h2,.modal h2{font-family:'Space Grotesk';margin:0;letter-spacing:-.03em}.topbar h1{font-size:34px}.primary-button,.secondary-button{border:0;border-radius:7px;padding:11px 16px;font-weight:700;cursor:pointer}.primary-button{background:var(--orange);color:#fff;box-shadow:0 5px 12px #e9823d35}.primary-button:hover{background:#d96d2c}.primary-button span{font-size:18px;margin-right:5px}.secondary-button{background:#eef2ee;color:var(--teal-dark)}.stats-grid{display:grid;grid-template-columns:repeat(4,1fr);gap:13px;margin-bottom:30px}.stat-card{background:#fff;border:1px solid var(--line);border-radius:10px;padding:19px 20px;box-shadow:var(--shadow)}.stat-card.accent{border-top:3px solid var(--orange);padding-top:17px}.stat-label{font-size:12px;color:var(--muted);font-weight:600}.stat-symbol{float:right;color:#aeb9b5}.stat-symbol.green{color:#5bbd84}.stat-symbol.amber{color:#e6a14d}.stat-symbol.red{color:#d86c62}.stat-card strong{font-family:'Space Grotesk';display:block;font-size:31px;margin:11px 0 3px}.stat-card small{font-size:11px;color:#a2aaa7}.inventory-panel{background:#fff;border:1px solid var(--line);border-radius:10px;box-shadow:var(--shadow);overflow:hidden}.panel-heading{display:flex;align-items:center;justify-content:space-between;padding:24px 25px 19px}.panel-heading h2{font-size:21px}.panel-heading p{margin:5px 0 0;color:var(--muted);font-size:12px}.item-count{font-size:12px;color:var(--muted);background:#f1f4f1;border-radius:20px;padding:6px 11px}.toolbar{display:flex;gap:10px;padding:0 25px 19px}.search-box{border:1px solid var(--line);border-radius:6px;display:flex;align-items:center;flex:1;max-width:390px;padding:0 11px;color:#a1aca8}.search-box span{font-size:22px;margin-right:7px}.search-box input{border:0;outline:0;width:100%;padding:10px 0;font-size:12px}.toolbar select{border:1px solid var(--line);border-radius:6px;color:#697571;background:#fff;padding:0 10px;font-size:12px;min-width:125px;outline:none}.table-wrap{border-top:1px solid var(--line);overflow-x:auto}table{border-collapse:collapse;width:100%;min-width:750px}th{text-align:left;color:#96a19d;font-size:10px;text-transform:uppercase;letter-spacing:.09em;font-weight:700;padding:13px 25px}td{padding:15px 25px;border-top:1px solid #eef1ee;font-size:12px;vertical-align:middle}.item-title{font-weight:700;display:block;color:#23322e}.item-sub{display:block;color:#9aa49f;font-size:11px;margin-top:4px}.type-pill{display:inline-block;color:var(--teal);background:#e7f3f0;border-radius:4px;padding:5px 8px;font-size:10px;font-weight:700}.identifier{color:#5d6c67;font-family:monospace;font-size:11px}.identifier em{color:#a4ada9;font-style:normal;display:block;margin-top:4px}.location{color:#586762}.status{font-size:11px;font-weight:700;white-space:nowrap}.status:before{content:'';display:inline-block;width:6px;height:6px;border-radius:50%;background:#65bd84;margin:0 7px 1px 0}.status.checked-out:before{background:#e4a04d}.status.maintenance:before{background:#da756b}.status.retired:before{background:#9ca7a3}.row-actions{opacity:0;display:flex;justify-content:flex-end;gap:4px}.inventory-row:hover .row-actions{opacity:1}.small-action{border:0;background:#f1f4f1;color:#52716b;border-radius:5px;padding:6px 8px;font-size:11px;cursor:pointer}.small-action.delete{color:#b65c55}.empty-state{text-align:center;padding:60px 20px;color:var(--muted)}.empty-icon{font-size:38px;color:#bdd0c9}.empty-state h3{font-family:'Space Grotesk';color:var(--ink);margin:7px 0}.empty-state p{font-size:12px;margin:0 0 17px}.modal-backdrop{display:none;position:fixed;inset:0;background:#183d3b66;z-index:5;align-items:center;justify-content:center;padding:20px}.modal-backdrop.open{display:flex}.modal{background:#fff;width:min(680px,100%);max-height:92vh;overflow-y:auto;border-radius:11px;box-shadow:0 25px 70px #133f3b35}.modal-header{display:flex;justify-content:space-between;align-items:flex-start;padding:24px 28px 18px;border-bottom:1px solid var(--line)}.modal h2{font-size:24px}.icon-button{border:0;background:transparent;font-size:26px;color:#8c9994;cursor:pointer}.modal form{padding:23px 28px}.form-grid{display:grid;grid-template-columns:1fr 1fr;gap:15px}.form-grid label{font-size:11px;font-weight:700;color:#5d6d67}.form-grid input,.form-grid select,.form-grid textarea{display:block;width:100%;border:1px solid #dfe6e1;border-radius:6px;padding:9px 10px;margin-top:6px;outline:none;font-size:12px;background:#fff;color:var(--ink)}.form-grid input:focus,.form-grid select:focus,.form-grid textarea:focus{border-color:var(--teal)}.form-grid textarea{resize:vertical}.full{grid-column:1/-1}.form-section{border-top:1px solid var(--line);padding-top:17px;margin-top:3px;text-transform:uppercase;letter-spacing:.1em;color:var(--teal)!important}.form-section small{color:#9ba7a2;text-transform:none;letter-spacing:0;margin-left:5px;font-weight:400}.form-actions{display:flex;justify-content:flex-end;gap:9px;border-top:1px solid var(--line);margin-top:22px;padding-top:18px}.toast{position:fixed;right:25px;bottom:25px;background:#193f3b;color:white;padding:13px 17px;border-radius:7px;font-size:12px;box-shadow:var(--shadow);transform:translateY(20px);opacity:0;transition:.2s;z-index:10}.toast.show{transform:translateY(0);opacity:1}.sr-only{position:absolute;width:1px;height:1px;padding:0;margin:-1px;overflow:hidden;clip:rect(0,0,0,0);white-space:nowrap;border:0}@media(max-width:800px){.sidebar{width:68px;padding:20px 10px}.brand{margin:0 7px 48px}.brand>div:last-child,.nav-item:not(.active)::after,.nav-item{font-size:0}.nav-item.active{font-size:0}.nav-icon{font-size:20px}.sidebar-footer{font-size:0;padding-left:4px}.sidebar-footer small{display:none}.main-content{padding:28px 18px}.stats-grid{grid-template-columns:1fr 1fr}.topbar h1{font-size:28px}.toolbar{flex-wrap:wrap}.search-box{max-width:none;flex-basis:100%}.toolbar select{flex:1}.panel-heading{padding-left:18px;padding-right:18px}.toolbar{padding-left:18px;padding-right:18px}th,td{padding-left:18px;padding-right:18px}.modal form,.modal-header{padding-left:20px;padding-right:20px}}@media(max-width:480px){.form-grid{grid-template-columns:1fr}.full{grid-column:auto}.stats-grid{gap:8px}.stat-card{padding:13px}.stat-card strong{font-size:24px}.stat-card small{font-size:9px}.topbar{align-items:flex-start;gap:10px;flex-direction:column}}