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 ac9bf29..18b0e26 100644 Binary files a/__pycache__/app.cpython-314.pyc and b/__pycache__/app.cpython-314.pyc differ 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}}