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.
+39 -11
View File
@@ -14,6 +14,7 @@ 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"}
ALLOWED_STATUSES = {"Available", "Trashed", "Sold"}
app.config["MAX_CONTENT_LENGTH"] = 250 * 1024 * 1024
@@ -40,6 +41,8 @@ def initialize_db():
asset_tag TEXT UNIQUE,
status TEXT NOT NULL DEFAULT 'Available',
location TEXT,
destination TEXT,
price REAL,
purchase_date TEXT,
notes TEXT,
cpu TEXT,
@@ -67,6 +70,10 @@ def initialize_db():
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")
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(
"""INSERT INTO inventory_images (item_id, filename, original_name, created_at)
SELECT id, image_filename, image_filename, COALESCE(updated_at, datetime('now'))
@@ -81,6 +88,19 @@ def clean(value):
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 {
"name": clean(data.get("name")),
"item_type": clean(data.get("item_type")),
@@ -88,8 +108,10 @@ def item_payload(data):
"model": clean(data.get("model")),
"serial_number": clean(data.get("serial_number")) 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")),
"destination": clean(data.get("destination")),
"price": price,
"purchase_date": clean(data.get("purchase_date")) or None,
"notes": clean(data.get("notes")),
"cpu": clean(data.get("cpu")),
@@ -212,8 +234,8 @@ def list_items():
clauses = []
params = []
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 ?)")
params.extend([f"%{query}%"] * 7)
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}%"] * 8)
if item_type:
clauses.append("item_type = ?")
params.append(item_type)
@@ -231,9 +253,9 @@ def summary():
with get_db() as connection:
total = connection.execute("SELECT COUNT(*) FROM inventory_items").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]
maintenance = connection.execute("SELECT COUNT(*) FROM inventory_items WHERE status = 'Maintenance'").fetchone()[0]
return jsonify({"total": total, "available": available, "checked_out": checked_out, "maintenance": maintenance})
trashed = connection.execute("SELECT COUNT(*) FROM inventory_items WHERE status = 'Trashed'").fetchone()[0]
sold = connection.execute("SELECT COUNT(*) FROM inventory_items WHERE status = 'Sold'").fetchone()[0]
return jsonify({"total": total, "available": available, "trashed": trashed, "sold": sold})
@app.get("/api/backup")
@@ -270,7 +292,10 @@ def restore():
@app.post("/api/items")
def create_item():
data = item_payload(request_data())
try:
data = item_payload(request_data())
except ValueError as error:
return jsonify({"error": str(error)}), 400
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]
@@ -285,8 +310,8 @@ def create_item():
with get_db() as connection:
cursor = connection.execute(
"""INSERT INTO inventory_items
(name, item_type, brand, model, serial_number, asset_tag, status, location, purchase_date, notes, cpu, ram, gpu, storage, media_format, created_at, updated_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)""",
(name, item_type, brand, model, serial_number, asset_tag, status, location, destination, price, purchase_date, notes, cpu, ram, gpu, storage, media_format, created_at, updated_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)""",
(*data.values(), now, now),
)
for filename, original_name in images:
@@ -302,7 +327,10 @@ def create_item():
@app.put("/api/items/<int:item_id>")
def update_item(item_id):
data = item_payload(request_data())
try:
data = item_payload(request_data())
except ValueError as error:
return jsonify({"error": str(error)}), 400
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()
@@ -318,7 +346,7 @@ def update_item(item_id):
return jsonify({"error": "Each inventory item can have up to 5 pictures."}), 400
saved_images = save_images(new_images)
result = connection.execute(
"""UPDATE inventory_items SET name=?, item_type=?, brand=?, model=?, serial_number=?, asset_tag=?, status=?, location=?, 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),
)
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;
$('#totalCount').textContent = summary.total;
$('#availableCount').textContent = summary.available;
$('#checkedOutCount').textContent = summary.checked_out;
$('#maintenanceCount').textContent = summary.maintenance;
$('#trashedCount').textContent = summary.trashed;
$('#soldCount').textContent = summary.sold;
$('#resultCount').textContent = `${items.length} ${items.length === 1 ? 'item' : 'items'}`;
renderItems(items);
}
@@ -24,7 +24,7 @@ async function load() {
function renderItems(items) {
const body = $('#inventoryBody');
$('#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])); }
@@ -47,7 +47,7 @@ $('#modal').addEventListener('click', (event) => { if (event.target === $('#moda
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); });
$('#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); } });
$('#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 = ''; });
+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">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<option>Trashed</option><option>Sold</option>
<title>Stockroom | Inventory</title>
<link rel="preconnect" href="https://fonts.googleapis.com">
<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 rel="stylesheet" href="{{ url_for('static', filename='style.css') }}">
</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">
<aside class="sidebar">
<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">
<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>
<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">Sold <span class="stat-symbol red">●</span></div><strong id="soldCount">0</strong><small>Transferred or sold</small></article>
</section>
<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="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>
</section>
</main>