Add examples to inventory

This commit is contained in:
2026-08-27 01:00:19 +03:00
parent 9ca4d8b72c
commit c608a8aa63
6 changed files with 35 additions and 46 deletions
Binary file not shown.
+20 -34
View File
@@ -14,7 +14,6 @@ app = Flask(__name__)
DATABASE_PATH = os.environ.get("DATABASE_PATH", str(Path(__file__).with_name("inventory.db"))) 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"))) UPLOAD_FOLDER = Path(os.environ.get("UPLOAD_FOLDER", str(Path(DATABASE_PATH).parent / "uploads")))
ALLOWED_IMAGE_EXTENSIONS = {"jpg", "jpeg", "png", "gif", "webp"} ALLOWED_IMAGE_EXTENSIONS = {"jpg", "jpeg", "png", "gif", "webp"}
ALLOWED_STATUSES = {"Available", "Trashed", "Sold"}
app.config["MAX_CONTENT_LENGTH"] = 250 * 1024 * 1024 app.config["MAX_CONTENT_LENGTH"] = 250 * 1024 * 1024
@@ -42,7 +41,7 @@ def initialize_db():
status TEXT NOT NULL DEFAULT 'Available', status TEXT NOT NULL DEFAULT 'Available',
location TEXT, location TEXT,
destination TEXT, destination TEXT,
price REAL, checkout_date TEXT,
purchase_date TEXT, purchase_date TEXT,
notes TEXT, notes TEXT,
cpu TEXT, cpu TEXT,
@@ -68,12 +67,18 @@ def initialize_db():
""" """
) )
columns = {row[1] for row in connection.execute("PRAGMA table_info(inventory_items)")} columns = {row[1] for row in connection.execute("PRAGMA table_info(inventory_items)")}
if "image_filename" not in columns: migrations = {
connection.execute("ALTER TABLE inventory_items ADD COLUMN image_filename TEXT") "image_filename": "ALTER TABLE inventory_items ADD COLUMN image_filename TEXT",
if "price" not in columns: "destination": "ALTER TABLE inventory_items ADD COLUMN destination TEXT",
connection.execute("ALTER TABLE inventory_items ADD COLUMN price REAL") "checkout_date": "ALTER TABLE inventory_items ADD COLUMN checkout_date TEXT",
if "destination" not in columns: }
connection.execute("ALTER TABLE inventory_items ADD COLUMN destination TEXT") for column, statement in migrations.items():
if column not in columns:
try:
connection.execute(statement)
except sqlite3.OperationalError as error:
if "duplicate column name" not in str(error):
raise
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'))
@@ -88,19 +93,6 @@ def clean(value):
def item_payload(data): def item_payload(data):
price_value = clean(data.get("price"))
if price_value:
try:
price = round(float(price_value), 2)
except ValueError:
raise ValueError("Price must be a valid number.")
if price < 0:
raise ValueError("Price cannot be negative.")
else:
price = None
status = clean(data.get("status")) or "Available"
if status not in ALLOWED_STATUSES:
raise ValueError("Status must be Available, Trashed, or Sold.")
return { return {
"name": clean(data.get("name")), "name": clean(data.get("name")),
"item_type": clean(data.get("item_type")), "item_type": clean(data.get("item_type")),
@@ -108,10 +100,10 @@ def item_payload(data):
"model": clean(data.get("model")), "model": clean(data.get("model")),
"serial_number": clean(data.get("serial_number")) or None, "serial_number": clean(data.get("serial_number")) or None,
"asset_tag": clean(data.get("asset_tag")) or None, "asset_tag": clean(data.get("asset_tag")) or None,
"status": status, "status": clean(data.get("status")) or "Available",
"location": clean(data.get("location")), "location": clean(data.get("location")),
"destination": clean(data.get("destination")), "destination": clean(data.get("destination")),
"price": price, "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,
"notes": clean(data.get("notes")), "notes": clean(data.get("notes")),
"cpu": clean(data.get("cpu")), "cpu": clean(data.get("cpu")),
@@ -253,9 +245,9 @@ def summary():
with get_db() as connection: with get_db() as connection:
total = connection.execute("SELECT COUNT(*) FROM inventory_items").fetchone()[0] total = connection.execute("SELECT COUNT(*) FROM inventory_items").fetchone()[0]
available = connection.execute("SELECT COUNT(*) FROM inventory_items WHERE status = 'Available'").fetchone()[0] available = connection.execute("SELECT COUNT(*) FROM inventory_items WHERE status = 'Available'").fetchone()[0]
trashed = connection.execute("SELECT COUNT(*) FROM inventory_items WHERE status = 'Trashed'").fetchone()[0] checked_out = connection.execute("SELECT COUNT(*) FROM inventory_items WHERE status = 'Checked out'").fetchone()[0]
sold = connection.execute("SELECT COUNT(*) FROM inventory_items WHERE status = 'Sold'").fetchone()[0] maintenance = connection.execute("SELECT COUNT(*) FROM inventory_items WHERE status = 'Maintenance'").fetchone()[0]
return jsonify({"total": total, "available": available, "trashed": trashed, "sold": sold}) return jsonify({"total": total, "available": available, "checked_out": checked_out, "maintenance": maintenance})
@app.get("/api/backup") @app.get("/api/backup")
@@ -292,10 +284,7 @@ def restore():
@app.post("/api/items") @app.post("/api/items")
def create_item(): def create_item():
try:
data = item_payload(request_data()) data = item_payload(request_data())
except ValueError as error:
return jsonify({"error": str(error)}), 400
if not data["name"] or not data["item_type"]: if not data["name"] or not data["item_type"]:
return jsonify({"error": "Name and type are required."}), 400 return jsonify({"error": "Name and type are required."}), 400
selected_files = [image for image in files_from_request() if image and image.filename] selected_files = [image for image in files_from_request() if image and image.filename]
@@ -310,7 +299,7 @@ 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, price, 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, notes, cpu, ram, gpu, storage, media_format, created_at, updated_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)""", VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)""",
(*data.values(), now, now), (*data.values(), now, now),
) )
@@ -327,10 +316,7 @@ def create_item():
@app.put("/api/items/<int:item_id>") @app.put("/api/items/<int:item_id>")
def update_item(item_id): def update_item(item_id):
try:
data = item_payload(request_data()) data = item_payload(request_data())
except ValueError as error:
return jsonify({"error": str(error)}), 400
if not data["name"] or not data["item_type"]: if not data["name"] or not data["item_type"]:
return jsonify({"error": "Name and type are required."}), 400 return jsonify({"error": "Name and type are required."}), 400
data["updated_at"] = datetime.now(timezone.utc).isoformat() data["updated_at"] = datetime.now(timezone.utc).isoformat()
@@ -346,7 +332,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=?, price=?, 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=?, notes=?, 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.
+8 -5
View File
@@ -10,10 +10,13 @@ async function request(url, options = {}) {
} }
async function load() { async function load() {
const query = new URLSearchParams({ q: $('#searchInput').value, type: $('#typeFilter').value }); const query = new URLSearchParams({ q: $('#searchInput').value, type: $('#typeFilter').value, status: $('#statusFilter').value });
const [items, summary] = await Promise.all([request(`/api/items?${query}`), request('/api/summary')]); const [items, summary] = await Promise.all([request(`/api/items?${query}`), request('/api/summary')]);
state.items = items; state.items = items;
$('#totalCount').textContent = summary.total; $('#totalCount').textContent = summary.total;
$('#availableCount').textContent = summary.available;
$('#checkedOutCount').textContent = summary.checked_out;
$('#maintenanceCount').textContent = summary.maintenance;
$('#resultCount').textContent = `${items.length} ${items.length === 1 ? 'item' : 'items'}`; $('#resultCount').textContent = `${items.length} ${items.length === 1 ? 'item' : 'items'}`;
renderItems(items); renderItems(items);
} }
@@ -21,11 +24,11 @@ async function load() {
function renderItems(items) { function renderItems(items) {
const body = $('#inventoryBody'); const body = $('#inventoryBody');
$('#emptyState').style.display = items.length ? 'none' : 'block'; $('#emptyState').style.display = items.length ? 'none' : 'block';
body.innerHTML = items.map((item) => `<tr class="inventory-row"><td><div class="item-cell">${item.images?.length ? `<img class="item-thumb" src="${escapeHtml(item.images[0].url)}" alt="">${item.images.length > 1 ? `<span class="image-count">+${item.images.length - 1}</span>` : ''}` : '<div class="item-thumb placeholder">◌</div>'}<div><span class="item-title">${escapeHtml(item.name)}</span><span class="item-sub">${escapeHtml([item.brand, item.model].filter(Boolean).join(' · ') || 'No manufacturer details')}</span></div></div></td><td><span class="type-pill">${escapeHtml(item.item_type)}</span></td><td><span class="identifier">${escapeHtml(item.serial_number || 'No serial')}<em>${escapeHtml(item.asset_tag || 'No asset tag')}</em></span></td><td><span class="location">${escapeHtml(item.location || 'Unassigned')}</span></td><td><div class="row-actions"><button class="small-action" onclick="editItem(${item.id})">Edit</button><button class="small-action delete" onclick="removeItem(${item.id})">Delete</button></div></td></tr>`).join(''); body.innerHTML = items.map((item) => `<tr class="inventory-row"><td><div class="item-cell">${item.images?.length ? `<img class="item-thumb" src="${escapeHtml(item.images[0].url)}" alt="">${item.images.length > 1 ? `<span class="image-count">+${item.images.length - 1}</span>` : ''}` : '<div class="item-thumb placeholder">◌</div>'}<div><span class="item-title">${escapeHtml(item.name)}</span><span class="item-sub">${escapeHtml([item.brand, item.model].filter(Boolean).join(' · ') || 'No manufacturer details')}</span></div></div></td><td><span class="type-pill">${escapeHtml(item.item_type)}</span></td><td><span class="identifier">${escapeHtml(item.serial_number || 'No serial')}<em>${escapeHtml(item.asset_tag || 'No asset tag')}</em></span></td><td><span class="location">${escapeHtml(item.location || 'Unassigned')}</span></td><td><span class="status ${item.status.toLowerCase().replace(' ', '-')}">${escapeHtml(item.status)}</span></td><td><div class="row-actions"><button class="small-action" onclick="editItem(${item.id})">Edit</button><button class="small-action delete" onclick="removeItem(${item.id})">Delete</button></div></td></tr>`).join('');
} }
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 ($('#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>Destination / assigned to<input id="destination" name="destination" placeholder="e.g. Employee, buyer, recycling"></label>'); if (!$('#checkoutDate')) $('#destination').parentElement.insertAdjacentHTML('afterend', '<label>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 renderImageGallery(images = []) { $('#imageGallery').innerHTML = images.map((image) => `<div class="gallery-item"><img src="${escapeHtml(image.url)}" alt="${escapeHtml(image.original_name || 'Device picture')}"><button type="button" class="gallery-delete" onclick="deleteImage(${image.id})" aria-label="Delete picture">×</button></div>`).join(''); } function renderImageGallery(images = []) { $('#imageGallery').innerHTML = images.map((image) => `<div class="gallery-item"><img src="${escapeHtml(image.url)}" alt="${escapeHtml(image.original_name || 'Device picture')}"><button type="button" class="gallery-delete" onclick="deleteImage(${image.id})" aria-label="Delete picture">×</button></div>`).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 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 closeModal() { $('#modal').classList.remove('open'); }
@@ -40,11 +43,11 @@ $('#emptyAddButton').addEventListener('click', () => openModal());
$('#closeModal').addEventListener('click', closeModal); $('#closeModal').addEventListener('click', closeModal);
$('#cancelButton').addEventListener('click', closeModal); $('#cancelButton').addEventListener('click', closeModal);
$('#modal').addEventListener('click', (event) => { if (event.target === $('#modal')) closeModal(); }); $('#modal').addEventListener('click', (event) => { if (event.target === $('#modal')) closeModal(); });
['searchInput', 'typeFilter'].forEach((id) => $(`#${id}`).addEventListener(id === 'searchInput' ? 'input' : 'change', load)); ['searchInput', 'typeFilter', 'statusFilter'].forEach((id) => $(`#${id}`).addEventListener(id === 'searchInput' ? 'input' : 'change', load));
ensureImageControls(); 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'; }); $('#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); } }); $('#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','destination','price','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); }); $('#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','destination','checkout_date','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); });
$('#backupButton').addEventListener('click', async () => { try { const response = await fetch('/api/backup'); if (!response.ok) throw new Error('Backup could not be created.'); const link = document.createElement('a'); link.href = URL.createObjectURL(await response.blob()); link.download = 'stockroom-backup.zip'; link.click(); URL.revokeObjectURL(link.href); toast('Backup downloaded.'); } catch (error) { toast(error.message); } }); $('#backupButton').addEventListener('click', async () => { try { const response = await fetch('/api/backup'); if (!response.ok) throw new Error('Backup could not be created.'); const link = document.createElement('a'); link.href = URL.createObjectURL(await response.blob()); link.download = 'stockroom-backup.zip'; link.click(); URL.revokeObjectURL(link.href); toast('Backup downloaded.'); } catch (error) { toast(error.message); } });
$('#restoreButton').addEventListener('click', () => $('#restoreInput').click()); $('#restoreButton').addEventListener('click', () => $('#restoreInput').click());
$('#restoreInput').addEventListener('change', async () => { const file = $('#restoreInput').files[0]; if (!file || !confirm('Restore this backup? Current inventory and pictures will be replaced.')) return; const formData = new FormData(); formData.append('backup', file); try { await request('/api/restore', { method: 'POST', body: formData }); toast('Backup restored. Reloading...'); setTimeout(() => window.location.reload(), 700); } catch (error) { toast(error.message); } $('#restoreInput').value = ''; }); $('#restoreInput').addEventListener('change', async () => { const file = $('#restoreInput').files[0]; if (!file || !confirm('Restore this backup? Current inventory and pictures will be replaced.')) return; const formData = new FormData(); formData.append('backup', file); try { await request('/api/restore', { method: 'POST', body: formData }); toast('Backup restored. Reloading...'); setTimeout(() => window.location.reload(), 700); } catch (error) { toast(error.message); } $('#restoreInput').value = ''; });
-1
View File
File diff suppressed because one or more lines are too long
+5 -4
View File
@@ -2,14 +2,12 @@
<html lang="en"> <html lang="en">
<head> <head>
<meta charset="utf-8"> <meta charset="utf-8">
<option>Trashed</option><option>Sold</option>
<title>Stockroom | Inventory</title> <title>Stockroom | Inventory</title>
<link rel="preconnect" href="https://fonts.googleapis.com"> <link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin> <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=DM+Sans:wght@400;500;600;700&family=Space+Grotesk:wght@500;600;700&display=swap" rel="stylesheet"> <link href="https://fonts.googleapis.com/css2?family=DM+Sans:wght@400;500;600;700&family=Space+Grotesk:wght@500;600;700&display=swap" rel="stylesheet">
<link rel="stylesheet" href="{{ url_for('static', filename='style.css') }}"> <link rel="stylesheet" href="{{ url_for('static', filename='style.css') }}">
</head> </head>
<label>Where it went<input name="destination" placeholder="e.g. Customer, recycling, or employee"></label><label>Price<input name="price" type="number" min="0" step="0.01" placeholder="0.00"></label><label>Purchase date<input name="purchase_date" type="date"></label>
<div class="app-shell"> <div class="app-shell">
<aside class="sidebar"> <aside class="sidebar">
<div class="brand"><div class="brand-mark">S</div><div><strong>Stockroom</strong><span>Asset intelligence</span></div></div> <div class="brand"><div class="brand-mark">S</div><div><strong>Stockroom</strong><span>Asset intelligence</span></div></div>
@@ -20,11 +18,14 @@
<header class="topbar"><div><p class="eyebrow">Operations / Stockroom</p><h1>Inventory</h1></div><button class="primary-button" id="addButton"><span>+</span> Add item</button></header> <header class="topbar"><div><p class="eyebrow">Operations / Stockroom</p><h1>Inventory</h1></div><button class="primary-button" id="addButton"><span>+</span> Add item</button></header>
<section class="stats-grid"> <section class="stats-grid">
<article class="stat-card accent"><div class="stat-label">Total assets <span class="stat-symbol">◉</span></div><strong id="totalCount">0</strong><small>Across all categories</small></article> <article class="stat-card accent"><div class="stat-label">Total assets <span class="stat-symbol">◉</span></div><strong id="totalCount">0</strong><small>Across all categories</small></article>
<article class="stat-card"><div class="stat-label">Available <span class="stat-symbol green">●</span></div><strong id="availableCount">0</strong><small>Ready to use</small></article>
<article class="stat-card"><div class="stat-label">Checked out <span class="stat-symbol amber">●</span></div><strong id="checkedOutCount">0</strong><small>Currently assigned</small></article>
<article class="stat-card"><div class="stat-label">Maintenance <span class="stat-symbol red">●</span></div><strong id="maintenanceCount">0</strong><small>Needs attention</small></article>
</section> </section>
<section class="inventory-panel"> <section class="inventory-panel">
<div class="panel-heading"><div><h2>All inventory</h2><p>Search, filter, and manage every item in your stockroom.</p></div><span class="item-count" id="resultCount">0 items</span></div> <div class="panel-heading"><div><h2>All inventory</h2><p>Search, filter, and manage every item in your stockroom.</p></div><span class="item-count" id="resultCount">0 items</span></div>
<div class="toolbar"><label class="search-box"><span>⌕</span><input id="searchInput" type="search" placeholder="Search name, serial, model..." autocomplete="off"></label><select id="typeFilter"><option value="">All types</option><option>Computer</option><option>Audio</option><option>Graphics</option><option>Long play disk</option><option>CD</option><option>Display</option><option>Peripheral</option><option>Other</option></select></div> <div class="toolbar"><label class="search-box"><span>⌕</span><input id="searchInput" type="search" placeholder="Search name, serial, model..." autocomplete="off"></label><select id="typeFilter"><option value="">All types</option><option>Computer</option><option>Audio</option><option>Graphics</option><option>Long play disk</option><option>CD</option><option>Display</option><option>Peripheral</option><option>Other</option></select><select id="statusFilter"><option value="">All status</option><option>Available</option><option>Checked out</option><option>Maintenance</option><option>Retired</option></select></div>
<div class="table-wrap"><table><thead><tr><th>Item</th><th>Type</th><th>Identifiers</th><th>Location</th><th><span class="sr-only">Actions</span></th></tr></thead><tbody id="inventoryBody"></tbody></table><div class="empty-state" id="emptyState"><div class="empty-icon">⌁</div><h3>No items found</h3><p>Adjust your search or add the first item to your inventory.</p><button class="secondary-button" id="emptyAddButton">Add first item</button></div></div> <div class="table-wrap"><table><thead><tr><th>Item</th><th>Type</th><th>Identifiers</th><th>Location</th><th>Status</th><th><span class="sr-only">Actions</span></th></tr></thead><tbody id="inventoryBody"></tbody></table><div class="empty-state" id="emptyState"><div class="empty-icon">⌁</div><h3>No items found</h3><p>Adjust your search or add the first item to your inventory.</p><button class="secondary-button" id="emptyAddButton">Add first item</button></div></div>
</section> </section>
</main> </main>
</div> </div>