From 6df56c3ffbfe7a1f61810ace3095aff783e78c23 Mon Sep 17 00:00:00 2001 From: Tero Huttunen Date: Thu, 27 Aug 2026 00:41:28 +0300 Subject: [PATCH] Added price status and information --- __pycache__/app.cpython-314.pyc | Bin 29198 -> 30773 bytes app.py | 50 +++++++++++++++++++++++++------- data/inventory.db | Bin 40960 -> 40960 bytes static/app.js | 8 ++--- static/style.css | 1 + templates/index.html | 10 +++---- 6 files changed, 49 insertions(+), 20 deletions(-) diff --git a/__pycache__/app.cpython-314.pyc b/__pycache__/app.cpython-314.pyc index 0f670221d52f49530e2969ed3f88f94dfab0cbd5..c8bce08310946887284f7833c435d7fd3b26f6c8 100644 GIT binary patch delta 6250 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 delta 5209 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 LVjKIx(Z~M^lkVBZ diff --git a/app.py b/app.py index 19b9762..991aec0 100644 --- a/app.py +++ b/app.py @@ -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/") 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: diff --git a/data/inventory.db b/data/inventory.db index 300ed61bcb587d87c6fe7958ad116ee182fd315f..22306ee93439d5ef78b68ef7fdd5359058f4cafa 100644 GIT binary patch delta 264 zcmZoTz|?SnX@ayMD+2=q7ZAe$^F$qEepUv(szP3#84R542N~E8Zp;g2=V}vTWEa=g zX6%mM{F40*lcbJ9K~ZLMszQ*fqmPb4N@{UQW?o`RW`5r03tU#LOnjM}1r;9iZGJB& z&B(<6d9$FwbNaPsoMVPN7v$iRP){~A!oR(@|`MrLis z%)GMHypsH)%J|HZ)ZAhw5hi9`WWn6T^weS&prb`#4u{bo&oDv^TnRRC^1*mdCjM7I VTVL=`{*o^SVL#{J{JEZ20RXo4NS^=z delta 203 zcmZoTz|?SnX@ayMBLf2i7ZAe$<3t@}entkpszP3#84R54_ZislZ_Ep3pEyB|tF?rY zU0hq6v0HxgY4$fvo3C=2vThbsc*?i=y__`TWfFvh`Cz;!(Ck=602F9DdH?_b diff --git a/static/app.js b/static/app.js index 37b835c..24c4322 100644 --- a/static/app.js +++ b/static/app.js @@ -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) => `
${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(''); + 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.destination ? `Went to: ${item.destination}` : '')}${escapeHtml(item.status)}${item.price != null ? escapeHtml(Number(item.price).toFixed(2)) : ''}
`).join(''); } function escapeHtml(value) { return String(value).replace(/[&<>'"]/g, (char) => ({ '&': '&', '<': '<', '>': '>', "'": ''', '"': '"' }[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 = ''; }); diff --git a/static/style.css b/static/style.css index 35d5e82..64260e2 100644 --- a/static/style.css +++ b/static/style.css @@ -1,3 +1,4 @@ .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} +.location em,.price{display:block;color:var(--muted);font-size:10px;font-style:normal;margin-top:3px}.price{color:var(--teal);font-weight:700} *{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}} diff --git a/templates/index.html b/templates/index.html index 8bea57d..ac116ac 100644 --- a/templates/index.html +++ b/templates/index.html @@ -2,14 +2,14 @@ - + Stockroom | Inventory - +