Added accounting and more example laptops
This commit is contained in:
Vendored
+18
@@ -0,0 +1,18 @@
|
|||||||
|
{
|
||||||
|
"version": "2.0.0",
|
||||||
|
"tasks": [
|
||||||
|
{
|
||||||
|
"label": "Rebuild Stockroom Docker app",
|
||||||
|
"type": "shell",
|
||||||
|
"command": "docker",
|
||||||
|
"args": [
|
||||||
|
"compose",
|
||||||
|
"up",
|
||||||
|
"--build",
|
||||||
|
"-d"
|
||||||
|
],
|
||||||
|
"isBackground": false,
|
||||||
|
"problemMatcher": []
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -21,9 +21,14 @@ Open http://localhost:5000. The SQLite database is stored in the project-local `
|
|||||||
- Media field: format
|
- Media field: format
|
||||||
- CSV export of the currently visible inventory
|
- CSV export of the currently visible inventory
|
||||||
- Responsive web UI for desktop and mobile
|
- Responsive web UI for desktop and mobile
|
||||||
|
- Account login with read-only, read-and-write, and admin access levels
|
||||||
|
- Amin and admins can export inventory and backup or restore data
|
||||||
|
- Admins can add users and manage access levels
|
||||||
|
|
||||||
The project-local `data` folder is mounted at `/data`. The SQLite database is stored at `data/inventory.db`, and uploaded pictures are stored in `data/uploads`. Keep this folder with the project when moving it to another computer. Existing databases are migrated automatically when the application starts.
|
The project-local `data` folder is mounted at `/data`. The SQLite database is stored at `data/inventory.db`, and uploaded pictures are stored in `data/uploads`. Keep this folder with the project when moving it to another computer. Existing databases are migrated automatically when the application starts.
|
||||||
|
|
||||||
|
On first startup, the app creates an `admin` account with password `admin` and an `Amin` account with password `amin`. Set `ADMIN_USERNAME`, `ADMIN_PASSWORD`, `AMIN_PASSWORD`, and `SECRET_KEY` in the environment before starting the app, then change these credentials for any shared deployment. Admins can add further users from **Manage users**. Read-only users can view inventory, read-and-write users can add and edit items, and admins can add, edit, and delete items as well as manage users. Export, backup, and restore are visible to admins and Amin.
|
||||||
|
|
||||||
Use **Backup data** in the sidebar to download a ZIP containing the database and uploaded pictures. On another computer, start the application and use **Restore data** to upload that ZIP. Restoring replaces the current inventory and pictures.
|
Use **Backup data** in the sidebar to download a ZIP containing the database and uploaded pictures. On another computer, start the application and use **Restore data** to upload that ZIP. Restoring replaces the current inventory and pictures.
|
||||||
|
|
||||||
## Local development
|
## Local development
|
||||||
|
|||||||
@@ -5,16 +5,23 @@ import tempfile
|
|||||||
import uuid
|
import uuid
|
||||||
import zipfile
|
import zipfile
|
||||||
from datetime import datetime, timezone
|
from datetime import datetime, timezone
|
||||||
|
from functools import wraps
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
from flask import Flask, jsonify, render_template, request, send_file, send_from_directory
|
from flask import Flask, jsonify, redirect, render_template, request, send_file, send_from_directory, session, url_for
|
||||||
|
from werkzeug.security import check_password_hash, generate_password_hash
|
||||||
from werkzeug.utils import secure_filename
|
from werkzeug.utils import secure_filename
|
||||||
|
|
||||||
app = Flask(__name__)
|
app = Flask(__name__)
|
||||||
|
app.secret_key = os.environ.get("SECRET_KEY", "change-this-secret-key")
|
||||||
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"}
|
||||||
app.config["MAX_CONTENT_LENGTH"] = 250 * 1024 * 1024
|
app.config["MAX_CONTENT_LENGTH"] = 250 * 1024 * 1024
|
||||||
|
ROLE_READ_ONLY = "read_only"
|
||||||
|
ROLE_READ_WRITE = "read_write"
|
||||||
|
ROLE_ADMIN = "admin"
|
||||||
|
VALID_ROLES = {ROLE_READ_ONLY, ROLE_READ_WRITE, ROLE_ADMIN}
|
||||||
|
|
||||||
|
|
||||||
def get_db():
|
def get_db():
|
||||||
@@ -64,6 +71,14 @@ def initialize_db():
|
|||||||
created_at TEXT NOT NULL
|
created_at TEXT NOT NULL
|
||||||
);
|
);
|
||||||
CREATE INDEX IF NOT EXISTS idx_inventory_images_item ON inventory_images(item_id);
|
CREATE INDEX IF NOT EXISTS idx_inventory_images_item ON inventory_images(item_id);
|
||||||
|
CREATE TABLE IF NOT EXISTS users (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
username TEXT NOT NULL UNIQUE COLLATE NOCASE,
|
||||||
|
password_hash TEXT NOT NULL,
|
||||||
|
role TEXT NOT NULL DEFAULT 'read_only',
|
||||||
|
can_data_tools INTEGER NOT NULL DEFAULT 0,
|
||||||
|
created_at TEXT NOT NULL
|
||||||
|
);
|
||||||
"""
|
"""
|
||||||
)
|
)
|
||||||
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)")}
|
||||||
@@ -86,6 +101,119 @@ def initialize_db():
|
|||||||
WHERE image_filename IS NOT NULL
|
WHERE image_filename IS NOT NULL
|
||||||
AND NOT EXISTS (SELECT 1 FROM inventory_images WHERE inventory_images.item_id = inventory_items.id)"""
|
AND NOT EXISTS (SELECT 1 FROM inventory_images WHERE inventory_images.item_id = inventory_items.id)"""
|
||||||
)
|
)
|
||||||
|
user_count = connection.execute("SELECT COUNT(*) FROM users").fetchone()[0]
|
||||||
|
if user_count == 0:
|
||||||
|
now = datetime.now(timezone.utc).isoformat()
|
||||||
|
connection.execute(
|
||||||
|
"INSERT INTO users (username, password_hash, role, can_data_tools, created_at) VALUES (?, ?, ?, ?, ?)",
|
||||||
|
(os.environ.get("ADMIN_USERNAME", "admin"), generate_password_hash(os.environ.get("ADMIN_PASSWORD", "admin")), ROLE_ADMIN, 1, now),
|
||||||
|
)
|
||||||
|
connection.execute(
|
||||||
|
"INSERT INTO users (username, password_hash, role, can_data_tools, created_at) VALUES (?, ?, ?, ?, ?)",
|
||||||
|
("Amin", generate_password_hash(os.environ.get("AMIN_PASSWORD", "amin")), ROLE_READ_ONLY, 1, now),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def current_user():
|
||||||
|
user_id = session.get("user_id")
|
||||||
|
if not user_id:
|
||||||
|
return None
|
||||||
|
with get_db() as connection:
|
||||||
|
return connection.execute("SELECT id, username, role, can_data_tools FROM users WHERE id = ?", (user_id,)).fetchone()
|
||||||
|
|
||||||
|
|
||||||
|
def json_auth_error(message, status):
|
||||||
|
return jsonify({"error": message}), status
|
||||||
|
|
||||||
|
|
||||||
|
def login_required(view):
|
||||||
|
@wraps(view)
|
||||||
|
def wrapped(*args, **kwargs):
|
||||||
|
if not current_user():
|
||||||
|
if request.path.startswith("/api/"):
|
||||||
|
return json_auth_error("Login required.", 401)
|
||||||
|
return redirect(url_for("login"))
|
||||||
|
return view(*args, **kwargs)
|
||||||
|
return wrapped
|
||||||
|
|
||||||
|
|
||||||
|
def permission_required(permission):
|
||||||
|
def decorator(view):
|
||||||
|
@wraps(view)
|
||||||
|
def wrapped(*args, **kwargs):
|
||||||
|
user = current_user()
|
||||||
|
if not user:
|
||||||
|
return json_auth_error("Login required.", 401)
|
||||||
|
allowed = {
|
||||||
|
"write": user["role"] in {ROLE_READ_WRITE, ROLE_ADMIN},
|
||||||
|
"delete": user["role"] == ROLE_ADMIN,
|
||||||
|
"admin": user["role"] == ROLE_ADMIN,
|
||||||
|
"data_tools": user["role"] == ROLE_ADMIN or bool(user["can_data_tools"]),
|
||||||
|
}.get(permission, False)
|
||||||
|
if not allowed:
|
||||||
|
return json_auth_error("You do not have permission for this action.", 403)
|
||||||
|
return view(*args, **kwargs)
|
||||||
|
return wrapped
|
||||||
|
return decorator
|
||||||
|
|
||||||
|
|
||||||
|
@app.get("/login")
|
||||||
|
def login():
|
||||||
|
if current_user():
|
||||||
|
return redirect(url_for("index"))
|
||||||
|
return render_template("login.html")
|
||||||
|
|
||||||
|
|
||||||
|
@app.post("/login")
|
||||||
|
def login_submit():
|
||||||
|
username = clean(request.form.get("username"))
|
||||||
|
password = request.form.get("password", "")
|
||||||
|
with get_db() as connection:
|
||||||
|
user = connection.execute("SELECT * FROM users WHERE username = ? COLLATE NOCASE", (username,)).fetchone()
|
||||||
|
if not user or not check_password_hash(user["password_hash"], password):
|
||||||
|
return render_template("login.html", error="Invalid username or password."), 401
|
||||||
|
session.clear()
|
||||||
|
session["user_id"] = user["id"]
|
||||||
|
return redirect(url_for("index"))
|
||||||
|
|
||||||
|
|
||||||
|
@app.post("/logout")
|
||||||
|
def logout():
|
||||||
|
session.clear()
|
||||||
|
return redirect(url_for("login"))
|
||||||
|
|
||||||
|
|
||||||
|
@app.get("/api/me")
|
||||||
|
@login_required
|
||||||
|
def me():
|
||||||
|
user = current_user()
|
||||||
|
return jsonify({"id": user["id"], "username": user["username"], "role": user["role"], "can_data_tools": bool(user["can_data_tools"])})
|
||||||
|
|
||||||
|
|
||||||
|
@app.get("/api/users")
|
||||||
|
@permission_required("admin")
|
||||||
|
def list_users():
|
||||||
|
with get_db() as connection:
|
||||||
|
users = connection.execute("SELECT id, username, role, can_data_tools, created_at FROM users ORDER BY username COLLATE NOCASE").fetchall()
|
||||||
|
return jsonify([{**dict(user), "can_data_tools": bool(user["can_data_tools"])} for user in users])
|
||||||
|
|
||||||
|
|
||||||
|
@app.post("/api/users")
|
||||||
|
@permission_required("admin")
|
||||||
|
def create_user():
|
||||||
|
data = request.get_json(silent=True) or {}
|
||||||
|
username = clean(data.get("username"))
|
||||||
|
password = data.get("password", "")
|
||||||
|
role = clean(data.get("role")) or ROLE_READ_ONLY
|
||||||
|
can_data_tools = bool(data.get("can_data_tools"))
|
||||||
|
if not username or len(password) < 6 or role not in VALID_ROLES:
|
||||||
|
return jsonify({"error": "Username, a password of at least 6 characters, and a valid level are required."}), 400
|
||||||
|
try:
|
||||||
|
with get_db() as connection:
|
||||||
|
cursor = connection.execute("INSERT INTO users (username, password_hash, role, can_data_tools, created_at) VALUES (?, ?, ?, ?, ?)", (username, generate_password_hash(password), role, int(can_data_tools), datetime.now(timezone.utc).isoformat()))
|
||||||
|
return jsonify({"id": cursor.lastrowid, "username": username, "role": role, "can_data_tools": can_data_tools}), 201
|
||||||
|
except sqlite3.IntegrityError:
|
||||||
|
return jsonify({"error": "That username already exists."}), 409
|
||||||
|
|
||||||
|
|
||||||
def clean(value):
|
def clean(value):
|
||||||
@@ -209,16 +337,26 @@ def restore_backup(backup):
|
|||||||
|
|
||||||
|
|
||||||
@app.route("/")
|
@app.route("/")
|
||||||
|
@login_required
|
||||||
def index():
|
def index():
|
||||||
return render_template("index.html")
|
user = current_user()
|
||||||
|
return render_template("index.html", user=dict(user))
|
||||||
|
|
||||||
|
|
||||||
|
@app.get("/users")
|
||||||
|
@permission_required("admin")
|
||||||
|
def users_page():
|
||||||
|
return render_template("users.html", user=dict(current_user()))
|
||||||
|
|
||||||
|
|
||||||
@app.get("/media/<path:filename>")
|
@app.get("/media/<path:filename>")
|
||||||
|
@login_required
|
||||||
def media(filename):
|
def media(filename):
|
||||||
return send_from_directory(UPLOAD_FOLDER, filename)
|
return send_from_directory(UPLOAD_FOLDER, filename)
|
||||||
|
|
||||||
|
|
||||||
@app.get("/api/items")
|
@app.get("/api/items")
|
||||||
|
@login_required
|
||||||
def list_items():
|
def list_items():
|
||||||
query = clean(request.args.get("q"))
|
query = clean(request.args.get("q"))
|
||||||
item_type = clean(request.args.get("type"))
|
item_type = clean(request.args.get("type"))
|
||||||
@@ -241,6 +379,7 @@ def list_items():
|
|||||||
|
|
||||||
|
|
||||||
@app.get("/api/summary")
|
@app.get("/api/summary")
|
||||||
|
@login_required
|
||||||
def summary():
|
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]
|
||||||
@@ -251,6 +390,7 @@ def summary():
|
|||||||
|
|
||||||
|
|
||||||
@app.get("/api/backup")
|
@app.get("/api/backup")
|
||||||
|
@permission_required("data_tools")
|
||||||
def backup():
|
def backup():
|
||||||
with tempfile.NamedTemporaryFile(suffix=".db", delete=False) as temporary_database:
|
with tempfile.NamedTemporaryFile(suffix=".db", delete=False) as temporary_database:
|
||||||
temporary_database_path = Path(temporary_database.name)
|
temporary_database_path = Path(temporary_database.name)
|
||||||
@@ -271,6 +411,7 @@ def backup():
|
|||||||
|
|
||||||
|
|
||||||
@app.post("/api/restore")
|
@app.post("/api/restore")
|
||||||
|
@permission_required("data_tools")
|
||||||
def restore():
|
def restore():
|
||||||
backup_file = request.files.get("backup")
|
backup_file = request.files.get("backup")
|
||||||
if not backup_file or not backup_file.filename:
|
if not backup_file or not backup_file.filename:
|
||||||
@@ -283,6 +424,7 @@ def restore():
|
|||||||
|
|
||||||
|
|
||||||
@app.post("/api/items")
|
@app.post("/api/items")
|
||||||
|
@permission_required("write")
|
||||||
def create_item():
|
def create_item():
|
||||||
data = item_payload(request_data())
|
data = item_payload(request_data())
|
||||||
if not data["name"] or not data["item_type"]:
|
if not data["name"] or not data["item_type"]:
|
||||||
@@ -315,6 +457,7 @@ def create_item():
|
|||||||
|
|
||||||
|
|
||||||
@app.put("/api/items/<int:item_id>")
|
@app.put("/api/items/<int:item_id>")
|
||||||
|
@permission_required("write")
|
||||||
def update_item(item_id):
|
def update_item(item_id):
|
||||||
data = item_payload(request_data())
|
data = item_payload(request_data())
|
||||||
if not data["name"] or not data["item_type"]:
|
if not data["name"] or not data["item_type"]:
|
||||||
@@ -349,6 +492,7 @@ def update_item(item_id):
|
|||||||
|
|
||||||
|
|
||||||
@app.delete("/api/items/<int:item_id>")
|
@app.delete("/api/items/<int:item_id>")
|
||||||
|
@permission_required("delete")
|
||||||
def delete_item(item_id):
|
def delete_item(item_id):
|
||||||
with get_db() as connection:
|
with get_db() as connection:
|
||||||
images = connection.execute("SELECT filename FROM inventory_images WHERE item_id = ?", (item_id,)).fetchall()
|
images = connection.execute("SELECT filename FROM inventory_images WHERE item_id = ?", (item_id,)).fetchall()
|
||||||
@@ -361,6 +505,7 @@ def delete_item(item_id):
|
|||||||
|
|
||||||
|
|
||||||
@app.post("/api/items/<int:item_id>/images")
|
@app.post("/api/items/<int:item_id>/images")
|
||||||
|
@permission_required("write")
|
||||||
def add_images(item_id):
|
def add_images(item_id):
|
||||||
files = files_from_request()
|
files = files_from_request()
|
||||||
with get_db() as connection:
|
with get_db() as connection:
|
||||||
@@ -383,6 +528,7 @@ def add_images(item_id):
|
|||||||
|
|
||||||
|
|
||||||
@app.delete("/api/items/<int:item_id>/images/<int:image_id>")
|
@app.delete("/api/items/<int:item_id>/images/<int:image_id>")
|
||||||
|
@permission_required("write")
|
||||||
def remove_image(item_id, image_id):
|
def remove_image(item_id, image_id):
|
||||||
with get_db() as connection:
|
with get_db() as connection:
|
||||||
image = connection.execute("SELECT filename FROM inventory_images WHERE id = ? AND item_id = ?", (image_id, item_id)).fetchone()
|
image = connection.execute("SELECT filename FROM inventory_images WHERE id = ? AND item_id = ?", (image_id, item_id)).fetchone()
|
||||||
|
|||||||
Binary file not shown.
+14
-7
@@ -1,4 +1,7 @@
|
|||||||
const state = { items: [], editingId: null };
|
const state = { items: [], editingId: null };
|
||||||
|
const role = document.body.dataset.role;
|
||||||
|
const canWrite = role === 'admin' || role === 'read_write';
|
||||||
|
const canDelete = role === 'admin';
|
||||||
const $ = (selector) => document.querySelector(selector);
|
const $ = (selector) => document.querySelector(selector);
|
||||||
|
|
||||||
async function request(url, options = {}) {
|
async function request(url, options = {}) {
|
||||||
@@ -23,8 +26,9 @@ async function load() {
|
|||||||
|
|
||||||
function renderItems(items) {
|
function renderItems(items) {
|
||||||
const body = $('#inventoryBody');
|
const body = $('#inventoryBody');
|
||||||
|
const actions = (itemId) => `${canWrite ? `<button class="small-action" onclick="editItem(${itemId})">Edit</button>` : ''}${canDelete ? `<button class="small-action delete" onclick="removeItem(${itemId})">Delete</button>` : ''}`;
|
||||||
$('#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')}</span></td><td><span class="status ${item.status.toLowerCase().replace(' ', '-')}">${escapeHtml(item.status)}</span></td><td><div class="row-actions">${actions(item.id)}</div></td></tr>`).join('');
|
||||||
}
|
}
|
||||||
|
|
||||||
function escapeHtml(value) { return String(value).replace(/[&<>'"]/g, (char) => ({ '&': '&', '<': '<', '>': '>', "'": ''', '"': '"' }[char])); }
|
function escapeHtml(value) { return String(value).replace(/[&<>'"]/g, (char) => ({ '&': '&', '<': '<', '>': '>', "'": ''', '"': '"' }[char])); }
|
||||||
@@ -38,8 +42,8 @@ window.editItem = (id) => openModal(state.items.find((item) => item.id === id));
|
|||||||
window.deleteImage = async (imageId) => { if (!state.editingId || !confirm('Delete this picture?')) return; try { const item = await request(`/api/items/${state.editingId}/images/${imageId}`, { method: 'DELETE' }); renderImageGallery(item.images); $('#imageName').textContent = `${item.images.length} saved picture${item.images.length === 1 ? '' : 's'}. Add more below.`; state.items = state.items.map((entry) => entry.id === item.id ? item : entry); renderItems(state.items); toast('Picture deleted.'); } catch (error) { toast(error.message); } };
|
window.deleteImage = async (imageId) => { if (!state.editingId || !confirm('Delete this picture?')) return; try { const item = await request(`/api/items/${state.editingId}/images/${imageId}`, { method: 'DELETE' }); renderImageGallery(item.images); $('#imageName').textContent = `${item.images.length} saved picture${item.images.length === 1 ? '' : 's'}. Add more below.`; state.items = state.items.map((entry) => entry.id === item.id ? item : entry); renderItems(state.items); toast('Picture deleted.'); } catch (error) { toast(error.message); } };
|
||||||
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); } };
|
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());
|
if ($('#addButton')) $('#addButton').addEventListener('click', () => openModal());
|
||||||
$('#emptyAddButton').addEventListener('click', () => openModal());
|
if ($('#emptyAddButton')) $('#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(); });
|
||||||
@@ -47,8 +51,11 @@ $('#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','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); });
|
if ($('#exportButton')) $('#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); } });
|
if ($('#backupButton')) $('#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());
|
if ($('#restoreButton')) $('#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 = ''; });
|
if ($('#restoreInput')) $('#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 = ''; });
|
||||||
|
async function loadUsers() { const users = await request('/api/users'); $('#usersList').innerHTML = users.map((user) => `<p class="user-row"><strong>${escapeHtml(user.username)}</strong><span>${escapeHtml(user.role.replace('_', ' '))}${user.can_data_tools ? ' · data tools' : ''}</span></p>`).join(''); }
|
||||||
|
if ($('#usersButton')) $('#usersButton').addEventListener('click', async () => { $('#usersPanel').classList.toggle('hidden'); if (!$('#usersPanel').classList.contains('hidden')) { try { await loadUsers(); } catch (error) { toast(error.message); } } });
|
||||||
|
if ($('#userForm')) $('#userForm').addEventListener('submit', async (event) => { event.preventDefault(); const form = new FormData(event.target); try { await request('/api/users', { method: 'POST', body: JSON.stringify({ username: form.get('username'), password: form.get('password'), role: form.get('role'), can_data_tools: form.get('can_data_tools') === 'on' }) }); event.target.reset(); await loadUsers(); toast('User added.'); } catch (error) { toast(error.message); } });
|
||||||
load().catch((error) => toast(error.message));
|
load().catch((error) => toast(error.message));
|
||||||
|
|||||||
File diff suppressed because one or more lines are too long
@@ -0,0 +1,40 @@
|
|||||||
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
|
function escapeHtml(value) {
|
||||||
|
return String(value).replace(/[&<>'"]/g, (character) => ({ '&': '&', '<': '<', '>': '>', "'": ''', '"': '"' }[character]));
|
||||||
|
}
|
||||||
|
|
||||||
|
function toast(message) {
|
||||||
|
const element = $('#toast');
|
||||||
|
element.textContent = message;
|
||||||
|
element.classList.add('show');
|
||||||
|
setTimeout(() => element.classList.remove('show'), 2500);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadUsers() {
|
||||||
|
const users = await request('/api/users');
|
||||||
|
$('#userCount').textContent = `${users.length} ${users.length === 1 ? 'user' : 'users'}`;
|
||||||
|
$('#usersList').innerHTML = users.map((user) => `<div class="account-row"><div><strong>${escapeHtml(user.username)}</strong><span>${escapeHtml(user.role.replace('_', ' '))}</span></div><span class="account-permission">${user.can_data_tools ? 'Export, backup, restore' : 'Inventory access only'}</span></div>`).join('');
|
||||||
|
}
|
||||||
|
|
||||||
|
$('#userForm').addEventListener('submit', async (event) => {
|
||||||
|
event.preventDefault();
|
||||||
|
const form = new FormData(event.target);
|
||||||
|
try {
|
||||||
|
await request('/api/users', { method: 'POST', body: JSON.stringify({ username: form.get('username'), password: form.get('password'), role: form.get('role'), can_data_tools: form.get('can_data_tools') === 'on' }) });
|
||||||
|
event.target.reset();
|
||||||
|
await loadUsers();
|
||||||
|
toast('User added.');
|
||||||
|
} catch (error) {
|
||||||
|
toast(error.message);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
loadUsers().catch((error) => toast(error.message));
|
||||||
@@ -8,14 +8,15 @@
|
|||||||
<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 data-role="{{ user.role }}" data-tools="{{ 1 if user.can_data_tools or user.role == 'admin' else 0 }}">
|
||||||
<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>
|
||||||
<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><button class="nav-item" id="backupButton"><span class="nav-icon">⇩</span>Backup data</button><button class="nav-item" id="restoreButton"><span class="nav-icon">⇧</span>Restore data</button><input id="restoreInput" class="hidden" type="file" accept=".zip,application/zip"></nav>
|
<nav><button class="nav-item active"><span class="nav-icon">▦</span>Inventory</button>{% if user.role == 'admin' or user.can_data_tools %}<button class="nav-item" id="exportButton"><span class="nav-icon">↓</span>Export CSV</button><button class="nav-item" id="backupButton"><span class="nav-icon">⇩</span>Backup data</button><button class="nav-item" id="restoreButton"><span class="nav-icon">⇧</span>Restore data</button><input id="restoreInput" class="hidden" type="file" accept=".zip,application/zip">{% endif %}{% if user.role == 'admin' %}<a class="nav-item" href="{{ url_for('users_page') }}"><span class="nav-icon">♙</span>Manage users</a>{% endif %}</nav>
|
||||||
<div class="sidebar-footer"><span class="status-dot"></span><span>Database online</span><small>SQLite · Project data</small></div>
|
<div class="sidebar-footer"><span class="status-dot"></span><span>{{ user.username }} · {{ user.role|replace('_', ' ')|title }}</span><small>SQLite · Project data</small><form method="post" action="{{ url_for('logout') }}"><button class="logout-button" type="submit">Sign out</button></form></div>
|
||||||
</aside>
|
</aside>
|
||||||
<main class="main-content">
|
<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>
|
<header class="topbar"><div><p class="eyebrow">Operations / Stockroom</p><h1>Inventory</h1></div>{% if user.role in ['admin', 'read_write'] %}<button class="primary-button" id="addButton"><span>+</span> Add item</button>{% endif %}</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">Available <span class="stat-symbol green">●</span></div><strong id="availableCount">0</strong><small>Ready to use</small></article>
|
||||||
@@ -25,7 +26,7 @@
|
|||||||
<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>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>
|
<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>{% if user.role in ['admin', 'read_write'] %}<button class="secondary-button" id="emptyAddButton">Add first item</button>{% endif %}</div></div>
|
||||||
</section>
|
</section>
|
||||||
</main>
|
</main>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -0,0 +1,22 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||||
|
<title>Stockroom | Sign in</title>
|
||||||
|
<link rel="stylesheet" href="{{ url_for('static', filename='style.css') }}">
|
||||||
|
</head>
|
||||||
|
<body class="login-page">
|
||||||
|
<main class="login-card">
|
||||||
|
<div class="brand-mark">S</div>
|
||||||
|
<p class="eyebrow">Stockroom</p>
|
||||||
|
<h1>Sign in</h1>
|
||||||
|
{% if error %}<p class="login-error">{{ error }}</p>{% endif %}
|
||||||
|
<form method="post">
|
||||||
|
<label>Username<input name="username" autocomplete="username" required autofocus></label>
|
||||||
|
<label>Password<input name="password" type="password" autocomplete="current-password" required></label>
|
||||||
|
<button class="primary-button" type="submit">Sign in</button>
|
||||||
|
</form>
|
||||||
|
</main>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||||
|
<title>Stockroom | User accounts</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 class="account-page">
|
||||||
|
<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><a class="nav-item" href="{{ url_for('index') }}"><span class="nav-icon">▦</span>Inventory</a><a class="nav-item active" href="{{ url_for('users_page') }}"><span class="nav-icon">♙</span>Manage users</a></nav>
|
||||||
|
<div class="sidebar-footer"><span class="status-dot"></span><span>{{ user.username }} · Admin</span><small>SQLite · Project data</small><form method="post" action="{{ url_for('logout') }}"><button class="logout-button" type="submit">Sign out</button></form></div>
|
||||||
|
</aside>
|
||||||
|
<main class="main-content">
|
||||||
|
<header class="topbar"><div><p class="eyebrow">Administration / Access</p><h1>User accounts</h1></div></header>
|
||||||
|
<section class="users-panel account-form-panel"><div class="panel-heading"><div><h2>Add a user</h2><p>Create an account and choose what it can do.</p></div></div><form id="userForm" class="user-form"><label>Username<input name="username" autocomplete="off" required></label><label>Temporary password<input name="password" type="password" minlength="6" required></label><label>Access level<select name="role"><option value="read_only">Read only</option><option value="read_write">Read and write</option><option value="admin">Admin</option></select></label><label class="checkbox-label"><input name="can_data_tools" type="checkbox"> Allow export, backup, and restore</label><button class="primary-button" type="submit">Add user</button></form></section>
|
||||||
|
<section class="users-panel"><div class="panel-heading"><div><h2>Accounts</h2><p>Current users and their access.</p></div><span class="item-count" id="userCount">0 users</span></div><div id="usersList" class="accounts-list"></div></section>
|
||||||
|
</main>
|
||||||
|
</div>
|
||||||
|
<div class="toast" id="toast"></div>
|
||||||
|
<script src="{{ url_for('static', filename='users.js') }}"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
Reference in New Issue
Block a user