Initial commit
This commit is contained in:
+15
@@ -0,0 +1,15 @@
|
|||||||
|
FROM python:3.12-slim
|
||||||
|
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
|
COPY requirements.txt .
|
||||||
|
RUN pip install --no-cache-dir -r requirements.txt
|
||||||
|
|
||||||
|
COPY . .
|
||||||
|
RUN mkdir -p /data
|
||||||
|
|
||||||
|
ENV DATABASE_PATH=/data/inventory.db
|
||||||
|
ENV FLASK_APP=app.py
|
||||||
|
|
||||||
|
EXPOSE 5000
|
||||||
|
CMD ["python", "app.py"]
|
||||||
@@ -0,0 +1,30 @@
|
|||||||
|
# Stockroom Inventory
|
||||||
|
|
||||||
|
A Dockerized inventory system for computers, audio equipment, graphics hardware, long-play records, CDs, displays, peripherals, and other assets.
|
||||||
|
|
||||||
|
## Run with Docker
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker compose up --build
|
||||||
|
```
|
||||||
|
|
||||||
|
Open http://localhost:5000. The SQLite database is stored in the persistent `inventory_data` Docker volume.
|
||||||
|
|
||||||
|
## Features
|
||||||
|
|
||||||
|
- Search by name, brand, model, serial number, asset tag, or location
|
||||||
|
- Filter by item type and status
|
||||||
|
- Add, edit, and delete inventory records
|
||||||
|
- 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
|
||||||
|
|
||||||
|
## Local development
|
||||||
|
|
||||||
|
```bash
|
||||||
|
python -m venv .venv
|
||||||
|
.venv\\Scripts\\activate
|
||||||
|
pip install -r requirements.txt
|
||||||
|
python app.py
|
||||||
|
```
|
||||||
Binary file not shown.
@@ -0,0 +1,168 @@
|
|||||||
|
import os
|
||||||
|
import sqlite3
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
from flask import Flask, jsonify, render_template, request
|
||||||
|
|
||||||
|
app = Flask(__name__)
|
||||||
|
DATABASE_PATH = os.environ.get("DATABASE_PATH", str(Path(__file__).with_name("inventory.db")))
|
||||||
|
|
||||||
|
|
||||||
|
def get_db():
|
||||||
|
connection = sqlite3.connect(DATABASE_PATH)
|
||||||
|
connection.row_factory = sqlite3.Row
|
||||||
|
connection.execute("PRAGMA foreign_keys = ON")
|
||||||
|
return connection
|
||||||
|
|
||||||
|
|
||||||
|
def initialize_db():
|
||||||
|
Path(DATABASE_PATH).parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
with get_db() as connection:
|
||||||
|
connection.executescript(
|
||||||
|
"""
|
||||||
|
CREATE TABLE IF NOT EXISTS inventory_items (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
name TEXT NOT NULL,
|
||||||
|
item_type TEXT NOT NULL,
|
||||||
|
brand TEXT,
|
||||||
|
model TEXT,
|
||||||
|
serial_number TEXT UNIQUE,
|
||||||
|
asset_tag TEXT UNIQUE,
|
||||||
|
status TEXT NOT NULL DEFAULT 'Available',
|
||||||
|
location TEXT,
|
||||||
|
purchase_date TEXT,
|
||||||
|
notes TEXT,
|
||||||
|
cpu TEXT,
|
||||||
|
ram TEXT,
|
||||||
|
gpu TEXT,
|
||||||
|
storage TEXT,
|
||||||
|
media_format 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);
|
||||||
|
"""
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def clean(value):
|
||||||
|
return (value or "").strip()
|
||||||
|
|
||||||
|
|
||||||
|
def item_payload(data):
|
||||||
|
return {
|
||||||
|
"name": clean(data.get("name")),
|
||||||
|
"item_type": clean(data.get("item_type")),
|
||||||
|
"brand": clean(data.get("brand")),
|
||||||
|
"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",
|
||||||
|
"location": clean(data.get("location")),
|
||||||
|
"purchase_date": clean(data.get("purchase_date")) or None,
|
||||||
|
"notes": clean(data.get("notes")),
|
||||||
|
"cpu": clean(data.get("cpu")),
|
||||||
|
"ram": clean(data.get("ram")),
|
||||||
|
"gpu": clean(data.get("gpu")),
|
||||||
|
"storage": clean(data.get("storage")),
|
||||||
|
"media_format": clean(data.get("media_format")),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def serialize(row):
|
||||||
|
return dict(row)
|
||||||
|
|
||||||
|
|
||||||
|
@app.route("/")
|
||||||
|
def index():
|
||||||
|
return render_template("index.html")
|
||||||
|
|
||||||
|
|
||||||
|
@app.get("/api/items")
|
||||||
|
def list_items():
|
||||||
|
query = clean(request.args.get("q"))
|
||||||
|
item_type = clean(request.args.get("type"))
|
||||||
|
status = clean(request.args.get("status"))
|
||||||
|
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)
|
||||||
|
if item_type:
|
||||||
|
clauses.append("item_type = ?")
|
||||||
|
params.append(item_type)
|
||||||
|
if status:
|
||||||
|
clauses.append("status = ?")
|
||||||
|
params.append(status)
|
||||||
|
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])
|
||||||
|
|
||||||
|
|
||||||
|
@app.get("/api/summary")
|
||||||
|
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})
|
||||||
|
|
||||||
|
|
||||||
|
@app.post("/api/items")
|
||||||
|
def create_item():
|
||||||
|
data = item_payload(request.get_json(silent=True) or {})
|
||||||
|
if not data["name"] or not data["item_type"]:
|
||||||
|
return jsonify({"error": "Name and type are required."}), 400
|
||||||
|
now = datetime.now(timezone.utc).isoformat()
|
||||||
|
try:
|
||||||
|
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 (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)""",
|
||||||
|
(*data.values(), now, now),
|
||||||
|
)
|
||||||
|
row = connection.execute("SELECT * FROM inventory_items WHERE id = ?", (cursor.lastrowid,)).fetchone()
|
||||||
|
return jsonify(serialize(row)), 201
|
||||||
|
except sqlite3.IntegrityError as error:
|
||||||
|
return jsonify({"error": "Serial number or asset tag already exists."}), 409
|
||||||
|
|
||||||
|
|
||||||
|
@app.put("/api/items/<int:item_id>")
|
||||||
|
def update_item(item_id):
|
||||||
|
data = item_payload(request.get_json(silent=True) or {})
|
||||||
|
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()
|
||||||
|
try:
|
||||||
|
with get_db() as connection:
|
||||||
|
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
|
||||||
|
row = connection.execute("SELECT * FROM inventory_items WHERE id = ?", (item_id,)).fetchone()
|
||||||
|
return jsonify(serialize(row))
|
||||||
|
except sqlite3.IntegrityError:
|
||||||
|
return jsonify({"error": "Serial number or asset tag already exists."}), 409
|
||||||
|
|
||||||
|
|
||||||
|
@app.delete("/api/items/<int:item_id>")
|
||||||
|
def delete_item(item_id):
|
||||||
|
with get_db() as connection:
|
||||||
|
result = connection.execute("DELETE FROM inventory_items WHERE id = ?", (item_id,))
|
||||||
|
if result.rowcount == 0:
|
||||||
|
return jsonify({"error": "Item not found."}), 404
|
||||||
|
return jsonify({"deleted": True})
|
||||||
|
|
||||||
|
|
||||||
|
initialize_db()
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
app.run(host="0.0.0.0", port=5000, debug=False)
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
services:
|
||||||
|
inventory:
|
||||||
|
build: .
|
||||||
|
ports:
|
||||||
|
- "5000:5000"
|
||||||
|
environment:
|
||||||
|
DATABASE_PATH: /data/inventory.db
|
||||||
|
volumes:
|
||||||
|
- inventory_data:/data
|
||||||
|
restart: unless-stopped
|
||||||
|
|
||||||
|
volumes:
|
||||||
|
inventory_data:
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
Flask==3.1.1
|
||||||
@@ -0,0 +1,45 @@
|
|||||||
|
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 data = await response.json();
|
||||||
|
if (!response.ok) throw new Error(data.error || 'Something went wrong.');
|
||||||
|
return data;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function load() {
|
||||||
|
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')]);
|
||||||
|
state.items = items;
|
||||||
|
$('#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'}`;
|
||||||
|
renderItems(items);
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderItems(items) {
|
||||||
|
const body = $('#inventoryBody');
|
||||||
|
$('#emptyState').style.display = items.length ? 'none' : 'block';
|
||||||
|
body.innerHTML = items.map((item) => `<tr class="inventory-row"><td><span class="item-title">${escapeHtml(item.name)}</span><span class="item-sub">${escapeHtml([item.brand, item.model].filter(Boolean).join(' · ') || 'No manufacturer details')}</span></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) => ({ '&': '&', '<': '<', '>': '>', "'": ''', '"': '"' }[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 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.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());
|
||||||
|
$('#emptyAddButton').addEventListener('click', () => openModal());
|
||||||
|
$('#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); } });
|
||||||
|
$('#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));
|
||||||
File diff suppressed because one or more lines are too long
@@ -0,0 +1,38 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||||
|
<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>
|
||||||
|
<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>
|
||||||
|
<nav><button class="nav-item active"><span class="nav-icon">▦</span>Inventory</button><button class="nav-item" id="exportButton"><span class="nav-icon">↓</span>Export CSV</button></nav>
|
||||||
|
<div class="sidebar-footer"><span class="status-dot"></span><span>Database online</span><small>SQLite · Docker volume</small></div>
|
||||||
|
</aside>
|
||||||
|
<main class="main-content">
|
||||||
|
<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">
|
||||||
|
<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 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="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>
|
||||||
|
</div>
|
||||||
|
<div class="modal-backdrop" id="modal"><section class="modal"><div class="modal-header"><div><p class="eyebrow">Inventory record</p><h2 id="modalTitle">Add item</h2></div><button class="icon-button" id="closeModal" aria-label="Close">×</button></div><form id="itemForm"><div class="form-grid"><label class="full">Item name *<input name="name" required placeholder="e.g. ThinkPad T14 Gen 3"></label><label>Type *<select name="item_type" required><option value="">Select type</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></label><label>Status<select name="status"><option>Available</option><option>Checked out</option><option>Maintenance</option><option>Retired</option></select></label><label>Brand<input name="brand" placeholder="e.g. Lenovo"></label><label>Model<input name="model" placeholder="e.g. T14 Gen 3"></label><label>Serial number<input name="serial_number" placeholder="Serial or barcode"></label><label>Asset tag<input name="asset_tag" placeholder="e.g. AST-00142"></label><label>Location<input name="location" placeholder="e.g. Shelf A3"></label><label>Purchase date<input name="purchase_date" type="date"></label><div class="form-section full"><span>Technical details <small>optional</small></span></div><label>CPU<input name="cpu" placeholder="e.g. Intel Core i7-1260P"></label><label>RAM<input name="ram" placeholder="e.g. 16 GB"></label><label>GPU<input name="gpu" placeholder="e.g. NVIDIA RTX 3060"></label><label>Disk / storage<input name="storage" placeholder="e.g. 512 GB SSD"></label><label>Media format<input name="media_format" placeholder="e.g. 12-inch, 33 RPM"></label><label class="full">Notes<textarea name="notes" rows="3" placeholder="Condition, accessories, history..."></textarea></label></div><div class="form-actions"><button type="button" class="secondary-button" id="cancelButton">Cancel</button><button type="submit" class="primary-button" id="saveButton">Save item</button></div></form></section></div>
|
||||||
|
<div class="toast" id="toast"></div>
|
||||||
|
<script src="{{ url_for('static', filename='app.js') }}"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
Reference in New Issue
Block a user