Add dynamic inventory type creation
This commit is contained in:
@@ -14,6 +14,7 @@ Open http://localhost:5000. The SQLite database is stored in the project-local `
|
|||||||
|
|
||||||
- Search by name, brand, model, serial number, asset tag, or location
|
- Search by name, brand, model, serial number, asset tag, or location
|
||||||
- Filter by item type and status
|
- Filter by item type and status
|
||||||
|
- Admin-managed inventory types with a selectable set of fields for each type
|
||||||
- Add, edit, and delete inventory records
|
- Add, edit, and delete inventory records
|
||||||
- Upload and display up to five device pictures per item (JPG, PNG, GIF, or WebP; up to 10 MB per request)
|
- Upload and display up to five device pictures per item (JPG, PNG, GIF, or WebP; up to 10 MB per request)
|
||||||
- Add more pictures or delete individual pictures while editing an item
|
- Add more pictures or delete individual pictures while editing an item
|
||||||
@@ -27,7 +28,7 @@ Open http://localhost:5000. The SQLite database is stored in the project-local `
|
|||||||
|
|
||||||
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.
|
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** and manage inventory types from **Manage types**. 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 and types. 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.
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import os
|
import os
|
||||||
|
import json
|
||||||
import shutil
|
import shutil
|
||||||
import sqlite3
|
import sqlite3
|
||||||
import tempfile
|
import tempfile
|
||||||
@@ -22,6 +23,20 @@ ROLE_READ_ONLY = "read_only"
|
|||||||
ROLE_READ_WRITE = "read_write"
|
ROLE_READ_WRITE = "read_write"
|
||||||
ROLE_ADMIN = "admin"
|
ROLE_ADMIN = "admin"
|
||||||
VALID_ROLES = {ROLE_READ_ONLY, ROLE_READ_WRITE, ROLE_ADMIN}
|
VALID_ROLES = {ROLE_READ_ONLY, ROLE_READ_WRITE, ROLE_ADMIN}
|
||||||
|
CONFIGURABLE_FIELDS = {
|
||||||
|
"brand", "model", "serial_number", "asset_tag", "location", "destination",
|
||||||
|
"checkout_date", "purchase_date", "notes", "cpu", "ram", "gpu", "storage", "media_format",
|
||||||
|
}
|
||||||
|
DEFAULT_TYPES = {
|
||||||
|
"Computer": ["brand", "model", "serial_number", "asset_tag", "location", "purchase_date", "cpu", "ram", "gpu", "storage", "notes"],
|
||||||
|
"Audio": ["brand", "model", "serial_number", "asset_tag", "location", "purchase_date", "media_format", "notes"],
|
||||||
|
"Graphics": ["brand", "model", "serial_number", "asset_tag", "location", "purchase_date", "gpu", "storage", "notes"],
|
||||||
|
"Long play disk": ["brand", "model", "asset_tag", "location", "purchase_date", "media_format", "notes"],
|
||||||
|
"CD": ["brand", "model", "asset_tag", "location", "purchase_date", "media_format", "notes"],
|
||||||
|
"Display": ["brand", "model", "serial_number", "asset_tag", "location", "purchase_date", "notes"],
|
||||||
|
"Peripheral": ["brand", "model", "serial_number", "asset_tag", "location", "purchase_date", "notes"],
|
||||||
|
"Other": ["brand", "model", "serial_number", "asset_tag", "location", "purchase_date", "notes"],
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
def get_db():
|
def get_db():
|
||||||
@@ -79,6 +94,10 @@ def initialize_db():
|
|||||||
can_data_tools INTEGER NOT NULL DEFAULT 0,
|
can_data_tools INTEGER NOT NULL DEFAULT 0,
|
||||||
created_at TEXT NOT NULL
|
created_at TEXT NOT NULL
|
||||||
);
|
);
|
||||||
|
CREATE TABLE IF NOT EXISTS inventory_types (
|
||||||
|
name TEXT PRIMARY KEY COLLATE NOCASE,
|
||||||
|
fields 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)")}
|
||||||
@@ -94,6 +113,11 @@ def initialize_db():
|
|||||||
except sqlite3.OperationalError as error:
|
except sqlite3.OperationalError as error:
|
||||||
if "duplicate column name" not in str(error):
|
if "duplicate column name" not in str(error):
|
||||||
raise
|
raise
|
||||||
|
for name, fields in DEFAULT_TYPES.items():
|
||||||
|
connection.execute(
|
||||||
|
"INSERT OR IGNORE INTO inventory_types (name, fields) VALUES (?, ?)",
|
||||||
|
(name, json.dumps(fields)),
|
||||||
|
)
|
||||||
connection.execute(
|
connection.execute(
|
||||||
"""INSERT INTO inventory_images (item_id, filename, original_name, created_at)
|
"""INSERT INTO inventory_images (item_id, filename, original_name, created_at)
|
||||||
SELECT id, image_filename, image_filename, COALESCE(updated_at, datetime('now'))
|
SELECT id, image_filename, image_filename, COALESCE(updated_at, datetime('now'))
|
||||||
@@ -240,17 +264,28 @@ def item_payload(data):
|
|||||||
"storage": clean(data.get("storage")),
|
"storage": clean(data.get("storage")),
|
||||||
"media_format": clean(data.get("media_format")),
|
"media_format": clean(data.get("media_format")),
|
||||||
}
|
}
|
||||||
if payload["item_type"] != "Computer":
|
|
||||||
payload["cpu"] = ""
|
|
||||||
payload["ram"] = ""
|
|
||||||
if payload["item_type"] not in {"Computer", "Graphics"}:
|
|
||||||
payload["gpu"] = ""
|
|
||||||
payload["storage"] = ""
|
|
||||||
if payload["item_type"] not in {"CD", "Long play disk", "Audio"}:
|
|
||||||
payload["media_format"] = ""
|
|
||||||
return payload
|
return payload
|
||||||
|
|
||||||
|
|
||||||
|
def type_payload(data):
|
||||||
|
name = clean(data.get("name"))
|
||||||
|
fields = data.get("fields", [])
|
||||||
|
if not isinstance(fields, list):
|
||||||
|
raise ValueError("Choose the fields to show for this inventory type.")
|
||||||
|
fields = [field for field in fields if field in CONFIGURABLE_FIELDS]
|
||||||
|
if not name or len(name) > 80:
|
||||||
|
raise ValueError("A type name of up to 80 characters is required.")
|
||||||
|
return name, fields
|
||||||
|
|
||||||
|
|
||||||
|
def serialize_type(row):
|
||||||
|
try:
|
||||||
|
fields = json.loads(row["fields"])
|
||||||
|
except json.JSONDecodeError:
|
||||||
|
fields = []
|
||||||
|
return {"name": row["name"], "fields": [field for field in fields if field in CONFIGURABLE_FIELDS]}
|
||||||
|
|
||||||
|
|
||||||
def serialize(row, connection):
|
def serialize(row, connection):
|
||||||
item = dict(row)
|
item = dict(row)
|
||||||
item["images"] = [
|
item["images"] = [
|
||||||
@@ -343,6 +378,7 @@ def restore_backup(backup):
|
|||||||
os.replace(staged_uploads_path, UPLOAD_FOLDER)
|
os.replace(staged_uploads_path, UPLOAD_FOLDER)
|
||||||
else:
|
else:
|
||||||
UPLOAD_FOLDER.mkdir(parents=True, exist_ok=True)
|
UPLOAD_FOLDER.mkdir(parents=True, exist_ok=True)
|
||||||
|
initialize_db()
|
||||||
|
|
||||||
|
|
||||||
@app.route("/")
|
@app.route("/")
|
||||||
@@ -387,6 +423,60 @@ def list_items():
|
|||||||
return jsonify([serialize(row, connection) for row in rows])
|
return jsonify([serialize(row, connection) for row in rows])
|
||||||
|
|
||||||
|
|
||||||
|
@app.get("/api/types")
|
||||||
|
@login_required
|
||||||
|
def list_types():
|
||||||
|
with get_db() as connection:
|
||||||
|
rows = connection.execute("SELECT name, fields FROM inventory_types ORDER BY name COLLATE NOCASE").fetchall()
|
||||||
|
return jsonify([serialize_type(row) for row in rows])
|
||||||
|
|
||||||
|
|
||||||
|
@app.post("/api/types")
|
||||||
|
@permission_required("admin")
|
||||||
|
def create_type():
|
||||||
|
try:
|
||||||
|
name, fields = type_payload(request.get_json(silent=True) or {})
|
||||||
|
except ValueError as error:
|
||||||
|
return jsonify({"error": str(error)}), 400
|
||||||
|
try:
|
||||||
|
with get_db() as connection:
|
||||||
|
connection.execute("INSERT INTO inventory_types (name, fields) VALUES (?, ?)", (name, json.dumps(fields)))
|
||||||
|
return jsonify({"name": name, "fields": fields}), 201
|
||||||
|
except sqlite3.IntegrityError:
|
||||||
|
return jsonify({"error": "That inventory type already exists."}), 409
|
||||||
|
|
||||||
|
|
||||||
|
@app.put("/api/types/<path:type_name>")
|
||||||
|
@permission_required("admin")
|
||||||
|
def update_type(type_name):
|
||||||
|
try:
|
||||||
|
name, fields = type_payload(request.get_json(silent=True) or {})
|
||||||
|
except ValueError as error:
|
||||||
|
return jsonify({"error": str(error)}), 400
|
||||||
|
with get_db() as connection:
|
||||||
|
if name.casefold() != type_name.casefold() and connection.execute("SELECT 1 FROM inventory_types WHERE name = ? COLLATE NOCASE", (name,)).fetchone():
|
||||||
|
return jsonify({"error": "That inventory type already exists."}), 409
|
||||||
|
result = connection.execute("UPDATE inventory_types SET name = ?, fields = ? WHERE name = ? COLLATE NOCASE", (name, json.dumps(fields), type_name))
|
||||||
|
if result.rowcount:
|
||||||
|
connection.execute("UPDATE inventory_items SET item_type = ? WHERE item_type = ? COLLATE NOCASE", (name, type_name))
|
||||||
|
if result.rowcount == 0:
|
||||||
|
return jsonify({"error": "Inventory type not found."}), 404
|
||||||
|
return jsonify({"name": name, "fields": fields})
|
||||||
|
|
||||||
|
|
||||||
|
@app.delete("/api/types/<path:type_name>")
|
||||||
|
@permission_required("admin")
|
||||||
|
def delete_type(type_name):
|
||||||
|
with get_db() as connection:
|
||||||
|
count = connection.execute("SELECT COUNT(*) FROM inventory_items WHERE item_type = ? COLLATE NOCASE", (type_name,)).fetchone()[0]
|
||||||
|
if count:
|
||||||
|
return jsonify({"error": "This type is used by existing inventory and cannot be removed."}), 400
|
||||||
|
result = connection.execute("DELETE FROM inventory_types WHERE name = ? COLLATE NOCASE", (type_name,))
|
||||||
|
if result.rowcount == 0:
|
||||||
|
return jsonify({"error": "Inventory type not found."}), 404
|
||||||
|
return jsonify({"deleted": True})
|
||||||
|
|
||||||
|
|
||||||
@app.get("/api/summary")
|
@app.get("/api/summary")
|
||||||
@login_required
|
@login_required
|
||||||
def summary():
|
def summary():
|
||||||
|
|||||||
Binary file not shown.
+32
-12
@@ -1,8 +1,9 @@
|
|||||||
const state = { items: [], editingId: null };
|
const state = { items: [], types: [], editingId: null };
|
||||||
const role = document.body.dataset.role;
|
const role = document.body.dataset.role;
|
||||||
const canWrite = role === 'admin' || role === 'read_write';
|
const canWrite = role === 'admin' || role === 'read_write';
|
||||||
const canDelete = role === 'admin';
|
const canDelete = role === 'admin';
|
||||||
const $ = (selector) => document.querySelector(selector);
|
const $ = (selector) => document.querySelector(selector);
|
||||||
|
const fieldLabels = { brand: 'Brand', model: 'Model', serial_number: 'Serial number', asset_tag: 'Asset tag', location: 'Location', destination: 'Destination / assigned to', checkout_date: 'Checkout date', purchase_date: 'Purchase date', notes: 'Notes', cpu: 'CPU', ram: 'RAM', gpu: 'GPU', storage: 'Disk / storage', media_format: 'Media format' };
|
||||||
|
|
||||||
async function request(url, options = {}) {
|
async function request(url, options = {}) {
|
||||||
const headers = options.body instanceof FormData ? {} : { 'Content-Type': 'application/json' };
|
const headers = options.body instanceof FormData ? {} : { 'Content-Type': 'application/json' };
|
||||||
@@ -12,6 +13,10 @@ async function request(url, options = {}) {
|
|||||||
return data;
|
return data;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function typeOptions(selected = '') { return `<option value="">Select type</option>${state.types.map((type) => `<option value="${escapeHtml(type.name)}"${type.name === selected ? ' selected' : ''}>${escapeHtml(type.name)}</option>`).join('')}`; }
|
||||||
|
function renderTypeSelectors() { const formType = $('[name="item_type"]'); const selectedFormType = formType.value; formType.innerHTML = typeOptions(selectedFormType); const filter = $('#typeFilter'); const selectedFilter = filter.value; filter.innerHTML = `<option value="">All types</option>${state.types.map((type) => `<option value="${escapeHtml(type.name)}"${type.name === selectedFilter ? ' selected' : ''}>${escapeHtml(type.name)}</option>`).join('')}`; }
|
||||||
|
async function loadTypes() { state.types = await request('/api/types'); renderTypeSelectors(); updateTypeFields(); }
|
||||||
|
|
||||||
async function load() {
|
async function load() {
|
||||||
const query = new URLSearchParams({ q: $('#searchInput').value, type: $('#typeFilter').value, status: $('#statusFilter').value });
|
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')]);
|
const [items, summary] = await Promise.all([request(`/api/items?${query}`), request('/api/summary')]);
|
||||||
@@ -32,24 +37,20 @@ function renderItems(items) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function escapeHtml(value) { return String(value).replace(/[&<>'"]/g, (char) => ({ '&': '&', '<': '<', '>': '>', "'": ''', '"': '"' }[char])); }
|
function escapeHtml(value) { return String(value).replace(/[&<>'"]/g, (char) => ({ '&': '&', '<': '<', '>': '>', "'": ''', '"': '"' }[char])); }
|
||||||
function ensureImageControls() { if (!$('#destination')) $('[name="location"]').insertAdjacentHTML('afterend', '<label>Destination / assigned to<input id="destination" name="destination" placeholder="e.g. Employee, buyer, recycling"></label>'); if (!$('#checkoutDate')) $('#destination').parentElement.insertAdjacentHTML('afterend', '<label>Checkout date<input id="checkoutDate" name="checkout_date" type="date"></label>'); if ($('#imageInput')) return; const section = document.querySelector('.form-section'); section.insertAdjacentHTML('beforebegin', '<label class="full image-field">Device pictures<input id="imageInput" name="images" type="file" accept="image/jpeg,image/png,image/gif,image/webp" multiple><span id="imageName">Up to 5 pictures</span><div id="imageGallery" class="image-gallery"></div></label>'); }
|
function ensureImageControls() { if (!$('#destination')) $('[name="location"]').insertAdjacentHTML('afterend', '<label data-configurable-field="destination">Destination / assigned to<input id="destination" name="destination" placeholder="e.g. Employee, buyer, recycling"></label>'); if (!$('#checkoutDate')) $('#destination').parentElement.insertAdjacentHTML('afterend', '<label data-configurable-field="checkout_date">Checkout date<input id="checkoutDate" name="checkout_date" type="date"></label>'); if ($('#imageInput')) return; const section = document.querySelector('.form-section'); section.insertAdjacentHTML('beforebegin', '<label class="full image-field">Device pictures<input id="imageInput" name="images" type="file" accept="image/jpeg,image/png,image/gif,image/webp" multiple><span id="imageName">Up to 5 pictures</span><div id="imageGallery" class="image-gallery"></div></label>'); }
|
||||||
function updateTypeFields() {
|
function updateTypeFields() {
|
||||||
const type = $('[name="item_type"]').value;
|
const type = $('[name="item_type"]').value;
|
||||||
const technicalFields = new Set(['cpu', 'ram', 'gpu', 'storage']);
|
const visibleFields = new Set(state.types.find((entry) => entry.name === type)?.fields || []);
|
||||||
const computerFields = new Set(['cpu', 'ram', 'gpu', 'storage']);
|
|
||||||
const graphicsFields = new Set(['gpu']);
|
|
||||||
const mediaFields = new Set(['media_format']);
|
|
||||||
document.querySelectorAll('#itemForm [name]').forEach((field) => {
|
document.querySelectorAll('#itemForm [name]').forEach((field) => {
|
||||||
if (!technicalFields.has(field.name) && field.name !== 'media_format') return;
|
if (!Object.hasOwn(fieldLabels, field.name)) return;
|
||||||
const visible = type === 'Computer' ? computerFields.has(field.name) : type === 'Graphics' ? graphicsFields.has(field.name) : mediaFields.has(field.name) && ['CD', 'Long play disk', 'Audio'].includes(type);
|
const visible = visibleFields.has(field.name);
|
||||||
field.parentElement.hidden = !visible;
|
field.parentElement.hidden = !visible;
|
||||||
if (!visible) field.value = '';
|
|
||||||
});
|
});
|
||||||
const section = document.querySelector('#itemForm .form-section');
|
const section = document.querySelector('#itemForm .form-section');
|
||||||
if (section) {
|
if (section) {
|
||||||
section.hidden = !['Computer', 'Graphics', 'CD', 'Long play disk', 'Audio'].includes(type);
|
section.hidden = !visibleFields.size;
|
||||||
const heading = section.querySelector('span');
|
const heading = section.querySelector('span');
|
||||||
if (heading) heading.firstChild.textContent = ['CD', 'Long play disk', 'Audio'].includes(type) ? 'Format details ' : 'Technical details ';
|
if (heading) heading.firstChild.textContent = 'Type details ';
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
function renderImageGallery(images = []) { $('#imageGallery').innerHTML = images.map((image) => `<div class="gallery-item"><img src="${escapeHtml(image.url)}" alt="${escapeHtml(image.original_name || 'Device picture')}"><button type="button" class="gallery-delete" onclick="deleteImage(${image.id})" aria-label="Delete picture">×</button></div>`).join(''); }
|
function renderImageGallery(images = []) { $('#imageGallery').innerHTML = images.map((image) => `<div class="gallery-item"><img src="${escapeHtml(image.url)}" alt="${escapeHtml(image.original_name || 'Device picture')}"><button type="button" class="gallery-delete" onclick="deleteImage(${image.id})" aria-label="Delete picture">×</button></div>`).join(''); }
|
||||||
@@ -57,12 +58,31 @@ function openModal(item = null) { ensureImageControls(); state.editingId = item?
|
|||||||
function closeModal() { $('#modal').classList.remove('open'); }
|
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); }
|
function toast(message) { const element = $('#toast'); element.textContent = message; element.classList.add('show'); setTimeout(() => element.classList.remove('show'), 2500); }
|
||||||
|
|
||||||
|
function ensureTypeManager() {
|
||||||
|
if ($('#typeManagerModal')) return;
|
||||||
|
document.body.insertAdjacentHTML('beforeend', '<div class="modal-backdrop" id="typeManagerModal"><section class="modal type-manager"><div class="modal-header"><div><p class="eyebrow">Administration</p><h2>Inventory types</h2></div><button class="icon-button" id="closeTypeManager" aria-label="Close">×</button></div><form id="typeForm"><input id="editingTypeName" type="hidden"><div class="form-grid"><label class="full">Type name *<input id="typeName" required maxlength="80" placeholder="e.g. Camera"></label><div class="type-fields full" id="typeFields"></div></div><div class="form-actions"><button type="button" class="secondary-button" id="resetTypeForm">New type</button><button class="primary-button" type="submit">Save type</button></div></form><div class="type-list" id="typeList"></div></section></div>');
|
||||||
|
$('#closeTypeManager').addEventListener('click', () => $('#typeManagerModal').classList.remove('open'));
|
||||||
|
$('#resetTypeForm').addEventListener('click', resetTypeForm);
|
||||||
|
$('#typeManagerModal').addEventListener('click', (event) => { if (event.target === $('#typeManagerModal')) $('#typeManagerModal').classList.remove('open'); });
|
||||||
|
$('#typeForm').addEventListener('submit', saveType);
|
||||||
|
}
|
||||||
|
function resetTypeForm() { $('#typeForm').reset(); $('#editingTypeName').value = ''; }
|
||||||
|
function renderTypeManager() {
|
||||||
|
$('#typeFields').innerHTML = `<span>Fields shown for this type</span><div class="field-checklist">${Object.entries(fieldLabels).map(([key, label]) => `<label><input type="checkbox" name="type_field" value="${key}">${label}</label>`).join('')}</div>`;
|
||||||
|
$('#typeList').innerHTML = state.types.map((type) => `<div class="type-row"><div><strong>${escapeHtml(type.name)}</strong><span>${type.fields.map((field) => escapeHtml(fieldLabels[field])).join(', ') || 'Name, type, and status only'}</span></div><div class="row-actions"><button class="small-action" type="button" onclick="editType(${JSON.stringify(type.name)})">Edit</button><button class="small-action delete" type="button" onclick="removeType(${JSON.stringify(type.name)})">Delete</button></div></div>`).join('');
|
||||||
|
}
|
||||||
|
function openTypeManager() { ensureTypeManager(); resetTypeForm(); renderTypeManager(); $('#typeManagerModal').classList.add('open'); }
|
||||||
|
window.editType = (name) => { const type = state.types.find((entry) => entry.name === name); if (!type) return; $('#editingTypeName').value = type.name; $('#typeName').value = type.name; document.querySelectorAll('[name="type_field"]').forEach((field) => { field.checked = type.fields.includes(field.value); }); };
|
||||||
|
window.removeType = async (name) => { if (!confirm(`Delete the "${name}" inventory type?`)) return; try { await request(`/api/types/${encodeURIComponent(name)}`, { method: 'DELETE' }); await loadTypes(); renderTypeManager(); toast('Inventory type deleted.'); } catch (error) { toast(error.message); } };
|
||||||
|
async function saveType(event) { event.preventDefault(); const previousName = $('#editingTypeName').value; const fields = [...document.querySelectorAll('[name="type_field"]:checked')].map((field) => field.value); try { await request(previousName ? `/api/types/${encodeURIComponent(previousName)}` : '/api/types', { method: previousName ? 'PUT' : 'POST', body: JSON.stringify({ name: $('#typeName').value, fields }) }); await loadTypes(); renderTypeManager(); resetTypeForm(); toast('Inventory type saved.'); } catch (error) { toast(error.message); } }
|
||||||
|
|
||||||
window.editItem = (id) => openModal(state.items.find((item) => item.id === id));
|
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); } };
|
||||||
|
|
||||||
if ($('#addButton')) $('#addButton').addEventListener('click', () => openModal());
|
if ($('#addButton')) $('#addButton').addEventListener('click', () => openModal());
|
||||||
if ($('#emptyAddButton')) $('#emptyAddButton').addEventListener('click', () => openModal());
|
if ($('#emptyAddButton')) $('#emptyAddButton').addEventListener('click', () => openModal());
|
||||||
|
if ($('#typeManagerButton')) $('#typeManagerButton').addEventListener('click', openTypeManager);
|
||||||
$('#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(); });
|
||||||
@@ -79,4 +99,4 @@ if ($('#restoreInput')) $('#restoreInput').addEventListener('change', async () =
|
|||||||
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(''); }
|
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 ($('#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); } });
|
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));
|
loadTypes().then(load).catch((error) => toast(error.message));
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
.type-manager{max-width:720px}.type-fields>span{display:block;color:var(--muted);font-size:12px;font-weight:700;margin-bottom:9px}.field-checklist{display:grid;grid-template-columns:repeat(3,minmax(0,1fr));gap:8px 14px}.field-checklist label{display:flex;align-items:center;gap:7px;font-size:12px;color:var(--ink)}.field-checklist input{width:auto}.type-list{border-top:1px solid var(--line);margin-top:22px;padding-top:4px}.type-row{display:flex;align-items:center;justify-content:space-between;gap:16px;border-bottom:1px solid var(--line);padding:13px 0}.type-row strong,.type-row span{display:block}.type-row span{color:var(--muted);font-size:11px;margin-top:4px;max-width:460px}.type-row .row-actions{display:flex;gap:7px;flex:none}@media(max-width:640px){.field-checklist{grid-template-columns:repeat(2,minmax(0,1fr))}.type-row{align-items:flex-start}.type-row .row-actions{flex-direction:column}.type-row span{max-width:230px}}
|
||||||
.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}
|
.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}
|
.row-actions{opacity:1!important}
|
||||||
.login-page{min-height:100vh;display:grid;place-items:center;background:linear-gradient(135deg,#f6f8f5,#dcece5)}.login-card{width:min(390px,calc(100% - 32px));padding:34px;background:#fff;border:1px solid var(--line);border-radius:12px;box-shadow:var(--shadow)}.login-card .brand-mark{margin-bottom:22px}.login-card h1{font-family:'Space Grotesk';margin:0 0 24px}.login-card form{display:grid;gap:16px}.login-card label,.user-form label{display:grid;gap:6px;color:var(--muted);font-size:12px;font-weight:700}.login-card input,.user-form input,.user-form select{width:100%;padding:11px;border:1px solid var(--line);border-radius:6px;background:#fff}.login-card .primary-button{margin-top:8px}.login-error{padding:10px;background:#fff0ed;color:#a33e2b;border-radius:6px;font-size:12px}.logout-button{margin-top:12px;border:0;background:transparent;color:#90afa9;padding:0;cursor:pointer;font-size:11px}.users-panel{margin-top:24px;padding:24px;background:#fff;border:1px solid var(--line);border-radius:8px}.user-form{display:flex;align-items:end;gap:12px;flex-wrap:wrap}.user-form label{min-width:150px}.user-form .checkbox-label{display:flex;align-items:center;gap:7px;min-width:auto}.user-form .checkbox-label input{width:auto}.user-row{display:flex;justify-content:space-between;border-top:1px solid var(--line);padding:10px 0;margin:18px 0 0;font-size:12px}.user-row span{color:var(--muted)}
|
.login-page{min-height:100vh;display:grid;place-items:center;background:linear-gradient(135deg,#f6f8f5,#dcece5)}.login-card{width:min(390px,calc(100% - 32px));padding:34px;background:#fff;border:1px solid var(--line);border-radius:12px;box-shadow:var(--shadow)}.login-card .brand-mark{margin-bottom:22px}.login-card h1{font-family:'Space Grotesk';margin:0 0 24px}.login-card form{display:grid;gap:16px}.login-card label,.user-form label{display:grid;gap:6px;color:var(--muted);font-size:12px;font-weight:700}.login-card input,.user-form input,.user-form select{width:100%;padding:11px;border:1px solid var(--line);border-radius:6px;background:#fff}.login-card .primary-button{margin-top:8px}.login-error{padding:10px;background:#fff0ed;color:#a33e2b;border-radius:6px;font-size:12px}.logout-button{margin-top:12px;border:0;background:transparent;color:#90afa9;padding:0;cursor:pointer;font-size:11px}.users-panel{margin-top:24px;padding:24px;background:#fff;border:1px solid var(--line);border-radius:8px}.user-form{display:flex;align-items:end;gap:12px;flex-wrap:wrap}.user-form label{min-width:150px}.user-form .checkbox-label{display:flex;align-items:center;gap:7px;min-width:auto}.user-form .checkbox-label input{width:auto}.user-row{display:flex;justify-content:space-between;border-top:1px solid var(--line);padding:10px 0;margin:18px 0 0;font-size:12px}.user-row span{color:var(--muted)}
|
||||||
|
|||||||
@@ -12,7 +12,7 @@
|
|||||||
<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>{% 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>
|
<nav><button class="nav-item active"><span class="nav-icon">▦</span>Inventory</button>{% if user.role == 'admin' %}<button class="nav-item" id="typeManagerButton"><span class="nav-icon">≡</span>Manage types</button>{% endif %}{% 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>{{ 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>
|
<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">
|
||||||
@@ -25,7 +25,7 @@
|
|||||||
</section>
|
</section>
|
||||||
<section class="inventory-panel">
|
<section class="inventory-panel">
|
||||||
<div class="panel-heading"><div><h2>All inventory</h2><p>Search, filter, and manage every item in your stockroom.</p></div><span class="item-count" id="resultCount">0 items</span></div>
|
<div class="panel-heading"><div><h2>All inventory</h2><p>Search, filter, and manage every item in your stockroom.</p></div><span class="item-count" id="resultCount">0 items</span></div>
|
||||||
<div class="toolbar"><label class="search-box"><span>⌕</span><input id="searchInput" type="search" placeholder="Search name, serial, model..." autocomplete="off"></label><select id="typeFilter"><option value="">All types</option><option>Computer</option><option>Audio</option><option>Graphics</option><option>Long play disk</option><option>CD</option><option>Display</option><option>Peripheral</option><option>Other</option></select><select id="statusFilter"><option value="">All status</option><option>Available</option><option>Checked out</option><option>Maintenance</option><option>Retired</option></select></div>
|
<div class="toolbar"><label class="search-box"><span>⌕</span><input id="searchInput" type="search" placeholder="Search name, serial, model..." autocomplete="off"></label><select id="typeFilter"><option value="">All types</option></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>{% if user.role in ['admin', 'read_write'] %}<button class="secondary-button" id="emptyAddButton">Add first item</button>{% endif %}</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>
|
||||||
|
|||||||
Reference in New Issue
Block a user