Added price status and information

This commit is contained in:
2026-08-27 00:41:28 +03:00
parent e532335b1b
commit 6df56c3ffb
6 changed files with 49 additions and 20 deletions
Binary file not shown.
+37 -9
View File
@@ -14,6 +14,7 @@ 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
@@ -40,6 +41,8 @@ def initialize_db():
asset_tag TEXT UNIQUE, asset_tag TEXT UNIQUE,
status TEXT NOT NULL DEFAULT 'Available', status TEXT NOT NULL DEFAULT 'Available',
location TEXT, location TEXT,
destination TEXT,
price REAL,
purchase_date TEXT, purchase_date TEXT,
notes TEXT, notes TEXT,
cpu TEXT, cpu TEXT,
@@ -67,6 +70,10 @@ 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: if "image_filename" not in columns:
connection.execute("ALTER TABLE inventory_items ADD COLUMN image_filename TEXT") connection.execute("ALTER TABLE inventory_items ADD COLUMN image_filename TEXT")
if "price" not in columns:
connection.execute("ALTER TABLE inventory_items ADD COLUMN price REAL")
if "destination" not in columns:
connection.execute("ALTER TABLE inventory_items ADD COLUMN destination TEXT")
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'))
@@ -81,6 +88,19 @@ 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")),
@@ -88,8 +108,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": clean(data.get("status")) or "Available", "status": status,
"location": clean(data.get("location")), "location": clean(data.get("location")),
"destination": clean(data.get("destination")),
"price": price,
"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")),
@@ -212,8 +234,8 @@ def list_items():
clauses = [] clauses = []
params = [] params = []
if query: if query:
clauses.append("(name LIKE ? OR item_type LIKE ? OR brand LIKE ? OR model LIKE ? OR serial_number LIKE ? OR asset_tag LIKE ? OR location LIKE ?)") clauses.append("(name LIKE ? OR item_type LIKE ? OR brand LIKE ? OR model LIKE ? OR serial_number LIKE ? OR asset_tag LIKE ? OR location LIKE ? OR destination LIKE ?)")
params.extend([f"%{query}%"] * 7) params.extend([f"%{query}%"] * 8)
if item_type: if item_type:
clauses.append("item_type = ?") clauses.append("item_type = ?")
params.append(item_type) params.append(item_type)
@@ -231,9 +253,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]
checked_out = connection.execute("SELECT COUNT(*) FROM inventory_items WHERE status = 'Checked out'").fetchone()[0] trashed = connection.execute("SELECT COUNT(*) FROM inventory_items WHERE status = 'Trashed'").fetchone()[0]
maintenance = connection.execute("SELECT COUNT(*) FROM inventory_items WHERE status = 'Maintenance'").fetchone()[0] sold = connection.execute("SELECT COUNT(*) FROM inventory_items WHERE status = 'Sold'").fetchone()[0]
return jsonify({"total": total, "available": available, "checked_out": checked_out, "maintenance": maintenance}) return jsonify({"total": total, "available": available, "trashed": trashed, "sold": sold})
@app.get("/api/backup") @app.get("/api/backup")
@@ -270,7 +292,10 @@ 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]
@@ -285,8 +310,8 @@ def create_item():
with get_db() as connection: with get_db() as connection:
cursor = connection.execute( cursor = connection.execute(
"""INSERT INTO inventory_items """INSERT INTO inventory_items
(name, item_type, brand, model, serial_number, asset_tag, status, location, 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, price, purchase_date, notes, cpu, ram, gpu, storage, media_format, created_at, updated_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)""", VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)""",
(*data.values(), now, now), (*data.values(), now, now),
) )
for filename, original_name in images: for filename, original_name in images:
@@ -302,7 +327,10 @@ 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()
@@ -318,7 +346,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=?, 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=?, price=?, 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.
+4 -4
View File
@@ -15,8 +15,8 @@ async function load() {
state.items = items; state.items = items;
$('#totalCount').textContent = summary.total; $('#totalCount').textContent = summary.total;
$('#availableCount').textContent = summary.available; $('#availableCount').textContent = summary.available;
$('#checkedOutCount').textContent = summary.checked_out; $('#trashedCount').textContent = summary.trashed;
$('#maintenanceCount').textContent = summary.maintenance; $('#soldCount').textContent = summary.sold;
$('#resultCount').textContent = `${items.length} ${items.length === 1 ? 'item' : 'items'}`; $('#resultCount').textContent = `${items.length} ${items.length === 1 ? 'item' : 'items'}`;
renderItems(items); renderItems(items);
} }
@@ -24,7 +24,7 @@ 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><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(''); 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')}<em>${escapeHtml(item.destination ? `Went to: ${item.destination}` : '')}</em></span></td><td><span class="status ${item.status.toLowerCase().replace(' ', '-')}">${escapeHtml(item.status)}</span><em class="price">${item.price != null ? escapeHtml(Number(item.price).toFixed(2)) : ''}</em></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])); }
@@ -47,7 +47,7 @@ $('#modal').addEventListener('click', (event) => { if (event.target === $('#moda
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','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','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); });
$('#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 -5
View File
@@ -2,14 +2,14 @@
<html lang="en"> <html lang="en">
<head> <head>
<meta charset="utf-8"> <meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1"> <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>
<body> <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>
@@ -21,12 +21,12 @@
<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">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">Trashed <span class="stat-symbol amber">●</span></div><strong id="trashedCount">0</strong><small>Removed from stock</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> <article class="stat-card"><div class="stat-label">Sold <span class="stat-symbol red">●</span></div><strong id="soldCount">0</strong><small>Transferred or sold</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><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="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>Trashed</option><option>Sold</option></select></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> <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>