Add possibility to upload images for inventory units
This commit is contained in:
@@ -15,11 +15,15 @@ Open http://localhost:5000. The SQLite database is stored in the persistent `inv
|
|||||||
- 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
|
||||||
- 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)
|
||||||
|
- Add more pictures or delete individual pictures while editing an item
|
||||||
- Computer fields: CPU, RAM, GPU, and disk/storage
|
- Computer fields: CPU, RAM, GPU, and disk/storage
|
||||||
- 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
|
||||||
|
|
||||||
|
Uploaded pictures are stored in `/data/uploads` alongside the SQLite database, so they persist in the `inventory_data` Docker volume. Existing databases are migrated automatically when the application starts.
|
||||||
|
|
||||||
## Local development
|
## Local development
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
|
|||||||
Binary file not shown.
@@ -1,12 +1,17 @@
|
|||||||
import os
|
import os
|
||||||
import sqlite3
|
import sqlite3
|
||||||
|
import uuid
|
||||||
from datetime import datetime, timezone
|
from datetime import datetime, timezone
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
from flask import Flask, jsonify, render_template, request
|
from flask import Flask, jsonify, render_template, request, send_from_directory
|
||||||
|
from werkzeug.utils import secure_filename
|
||||||
|
|
||||||
app = Flask(__name__)
|
app = Flask(__name__)
|
||||||
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")))
|
||||||
|
ALLOWED_IMAGE_EXTENSIONS = {"jpg", "jpeg", "png", "gif", "webp"}
|
||||||
|
app.config["MAX_CONTENT_LENGTH"] = 10 * 1024 * 1024
|
||||||
|
|
||||||
|
|
||||||
def get_db():
|
def get_db():
|
||||||
@@ -18,6 +23,7 @@ def get_db():
|
|||||||
|
|
||||||
def initialize_db():
|
def initialize_db():
|
||||||
Path(DATABASE_PATH).parent.mkdir(parents=True, exist_ok=True)
|
Path(DATABASE_PATH).parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
UPLOAD_FOLDER.mkdir(parents=True, exist_ok=True)
|
||||||
with get_db() as connection:
|
with get_db() as connection:
|
||||||
connection.executescript(
|
connection.executescript(
|
||||||
"""
|
"""
|
||||||
@@ -38,14 +44,33 @@ def initialize_db():
|
|||||||
gpu TEXT,
|
gpu TEXT,
|
||||||
storage TEXT,
|
storage TEXT,
|
||||||
media_format TEXT,
|
media_format TEXT,
|
||||||
|
image_filename TEXT,
|
||||||
created_at TEXT NOT NULL,
|
created_at TEXT NOT NULL,
|
||||||
updated_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_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_type ON inventory_items(item_type);
|
||||||
CREATE INDEX IF NOT EXISTS idx_inventory_status ON inventory_items(status);
|
CREATE INDEX IF NOT EXISTS idx_inventory_status ON inventory_items(status);
|
||||||
|
CREATE TABLE IF NOT EXISTS inventory_images (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
item_id INTEGER NOT NULL REFERENCES inventory_items(id) ON DELETE CASCADE,
|
||||||
|
filename TEXT NOT NULL,
|
||||||
|
original_name TEXT,
|
||||||
|
created_at TEXT NOT NULL
|
||||||
|
);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_inventory_images_item ON inventory_images(item_id);
|
||||||
"""
|
"""
|
||||||
)
|
)
|
||||||
|
columns = {row[1] for row in connection.execute("PRAGMA table_info(inventory_items)")}
|
||||||
|
if "image_filename" not in columns:
|
||||||
|
connection.execute("ALTER TABLE inventory_items ADD COLUMN image_filename TEXT")
|
||||||
|
connection.execute(
|
||||||
|
"""INSERT INTO inventory_images (item_id, filename, original_name, created_at)
|
||||||
|
SELECT id, image_filename, image_filename, COALESCE(updated_at, datetime('now'))
|
||||||
|
FROM inventory_items
|
||||||
|
WHERE image_filename IS NOT NULL
|
||||||
|
AND NOT EXISTS (SELECT 1 FROM inventory_images WHERE inventory_images.item_id = inventory_items.id)"""
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def clean(value):
|
def clean(value):
|
||||||
@@ -72,8 +97,52 @@ def item_payload(data):
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
def serialize(row):
|
def serialize(row, connection):
|
||||||
return dict(row)
|
item = dict(row)
|
||||||
|
item["images"] = [
|
||||||
|
{"id": image["id"], "filename": image["filename"], "original_name": image["original_name"], "url": f"/media/{image['filename']}"}
|
||||||
|
for image in connection.execute("SELECT id, filename, original_name FROM inventory_images WHERE item_id = ? ORDER BY id", (row["id"],)).fetchall()
|
||||||
|
]
|
||||||
|
item["image_url"] = item["images"][0]["url"] if item["images"] else None
|
||||||
|
return item
|
||||||
|
|
||||||
|
|
||||||
|
def request_data():
|
||||||
|
return request.form if request.form else (request.get_json(silent=True) or {})
|
||||||
|
|
||||||
|
|
||||||
|
def save_image(image):
|
||||||
|
if not image or not image.filename:
|
||||||
|
return None
|
||||||
|
original_name = secure_filename(image.filename)
|
||||||
|
extension = Path(original_name).suffix.lower().lstrip(".")
|
||||||
|
if extension not in ALLOWED_IMAGE_EXTENSIONS:
|
||||||
|
raise ValueError("Images must be JPG, PNG, GIF, or WebP files.")
|
||||||
|
filename = f"{uuid.uuid4().hex}.{extension}"
|
||||||
|
image.save(UPLOAD_FOLDER / filename)
|
||||||
|
return filename, original_name
|
||||||
|
|
||||||
|
|
||||||
|
def save_images(files):
|
||||||
|
saved = []
|
||||||
|
try:
|
||||||
|
for image in files:
|
||||||
|
if image and image.filename:
|
||||||
|
saved.append(save_image(image))
|
||||||
|
return saved
|
||||||
|
except ValueError:
|
||||||
|
for filename, _ in saved:
|
||||||
|
delete_image(filename)
|
||||||
|
raise
|
||||||
|
|
||||||
|
|
||||||
|
def delete_image(filename):
|
||||||
|
if filename:
|
||||||
|
(UPLOAD_FOLDER / filename).unlink(missing_ok=True)
|
||||||
|
|
||||||
|
|
||||||
|
def files_from_request():
|
||||||
|
return request.files.getlist("images") or request.files.getlist("image")
|
||||||
|
|
||||||
|
|
||||||
@app.route("/")
|
@app.route("/")
|
||||||
@@ -81,6 +150,11 @@ def index():
|
|||||||
return render_template("index.html")
|
return render_template("index.html")
|
||||||
|
|
||||||
|
|
||||||
|
@app.get("/media/<path:filename>")
|
||||||
|
def media(filename):
|
||||||
|
return send_from_directory(UPLOAD_FOLDER, filename)
|
||||||
|
|
||||||
|
|
||||||
@app.get("/api/items")
|
@app.get("/api/items")
|
||||||
def list_items():
|
def list_items():
|
||||||
query = clean(request.args.get("q"))
|
query = clean(request.args.get("q"))
|
||||||
@@ -100,7 +174,7 @@ def list_items():
|
|||||||
where = f"WHERE {' AND '.join(clauses)}" if clauses else ""
|
where = f"WHERE {' AND '.join(clauses)}" if clauses else ""
|
||||||
with get_db() as connection:
|
with get_db() as connection:
|
||||||
rows = connection.execute(f"SELECT * FROM inventory_items {where} ORDER BY updated_at DESC, id DESC", params).fetchall()
|
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])
|
return jsonify([serialize(row, connection) for row in rows])
|
||||||
|
|
||||||
|
|
||||||
@app.get("/api/summary")
|
@app.get("/api/summary")
|
||||||
@@ -115,9 +189,16 @@ def summary():
|
|||||||
|
|
||||||
@app.post("/api/items")
|
@app.post("/api/items")
|
||||||
def create_item():
|
def create_item():
|
||||||
data = item_payload(request.get_json(silent=True) or {})
|
data = item_payload(request_data())
|
||||||
if not data["name"] or not data["item_type"]:
|
if not data["name"] or not data["item_type"]:
|
||||||
return jsonify({"error": "Name and type are required."}), 400
|
return jsonify({"error": "Name and type are required."}), 400
|
||||||
|
selected_files = [image for image in files_from_request() if image and image.filename]
|
||||||
|
if len(selected_files) > 5:
|
||||||
|
return jsonify({"error": "Each inventory item can have up to 5 pictures."}), 400
|
||||||
|
try:
|
||||||
|
images = save_images(selected_files)
|
||||||
|
except ValueError as error:
|
||||||
|
return jsonify({"error": str(error)}), 400
|
||||||
now = datetime.now(timezone.utc).isoformat()
|
now = datetime.now(timezone.utc).isoformat()
|
||||||
try:
|
try:
|
||||||
with get_db() as connection:
|
with get_db() as connection:
|
||||||
@@ -127,41 +208,98 @@ def create_item():
|
|||||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)""",
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)""",
|
||||||
(*data.values(), now, now),
|
(*data.values(), now, now),
|
||||||
)
|
)
|
||||||
|
for filename, original_name in images:
|
||||||
|
connection.execute("INSERT INTO inventory_images (item_id, filename, original_name, created_at) VALUES (?, ?, ?, ?)", (cursor.lastrowid, filename, original_name, now))
|
||||||
row = connection.execute("SELECT * FROM inventory_items WHERE id = ?", (cursor.lastrowid,)).fetchone()
|
row = connection.execute("SELECT * FROM inventory_items WHERE id = ?", (cursor.lastrowid,)).fetchone()
|
||||||
return jsonify(serialize(row)), 201
|
result = serialize(row, connection)
|
||||||
except sqlite3.IntegrityError as error:
|
return jsonify(result), 201
|
||||||
|
except sqlite3.IntegrityError:
|
||||||
|
for filename, _ in images:
|
||||||
|
delete_image(filename)
|
||||||
return jsonify({"error": "Serial number or asset tag already exists."}), 409
|
return jsonify({"error": "Serial number or asset tag already exists."}), 409
|
||||||
|
|
||||||
|
|
||||||
@app.put("/api/items/<int:item_id>")
|
@app.put("/api/items/<int:item_id>")
|
||||||
def update_item(item_id):
|
def update_item(item_id):
|
||||||
data = item_payload(request.get_json(silent=True) or {})
|
data = item_payload(request_data())
|
||||||
if not data["name"] or not data["item_type"]:
|
if not data["name"] or not data["item_type"]:
|
||||||
return jsonify({"error": "Name and type are required."}), 400
|
return jsonify({"error": "Name and type are required."}), 400
|
||||||
data["updated_at"] = datetime.now(timezone.utc).isoformat()
|
data["updated_at"] = datetime.now(timezone.utc).isoformat()
|
||||||
|
new_images = files_from_request()
|
||||||
|
saved_images = []
|
||||||
try:
|
try:
|
||||||
with get_db() as connection:
|
with get_db() as connection:
|
||||||
|
current = connection.execute("SELECT image_filename FROM inventory_items WHERE id = ?", (item_id,)).fetchone()
|
||||||
|
if not current:
|
||||||
|
return jsonify({"error": "Item not found."}), 404
|
||||||
|
existing_count = connection.execute("SELECT COUNT(*) FROM inventory_images WHERE item_id = ?", (item_id,)).fetchone()[0]
|
||||||
|
if existing_count + len([image for image in new_images if image and image.filename]) > 5:
|
||||||
|
return jsonify({"error": "Each inventory item can have up to 5 pictures."}), 400
|
||||||
|
saved_images = save_images(new_images)
|
||||||
result = connection.execute(
|
result = connection.execute(
|
||||||
"""UPDATE inventory_items SET name=?, item_type=?, brand=?, model=?, serial_number=?, asset_tag=?, status=?, location=?, purchase_date=?, notes=?, cpu=?, ram=?, gpu=?, storage=?, media_format=?, updated_at=? WHERE id=?""",
|
"""UPDATE inventory_items SET name=?, item_type=?, brand=?, model=?, serial_number=?, asset_tag=?, status=?, location=?, purchase_date=?, notes=?, cpu=?, ram=?, gpu=?, storage=?, media_format=?, updated_at=? WHERE id=?""",
|
||||||
(*data.values(), item_id),
|
(*data.values(), item_id),
|
||||||
)
|
)
|
||||||
if result.rowcount == 0:
|
for filename, original_name in saved_images:
|
||||||
return jsonify({"error": "Item not found."}), 404
|
connection.execute("INSERT INTO inventory_images (item_id, filename, original_name, created_at) VALUES (?, ?, ?, ?)", (item_id, filename, original_name, data["updated_at"]))
|
||||||
row = connection.execute("SELECT * FROM inventory_items WHERE id = ?", (item_id,)).fetchone()
|
row = connection.execute("SELECT * FROM inventory_items WHERE id = ?", (item_id,)).fetchone()
|
||||||
return jsonify(serialize(row))
|
result = serialize(row, connection)
|
||||||
|
return jsonify(result)
|
||||||
|
except ValueError as error:
|
||||||
|
return jsonify({"error": str(error)}), 400
|
||||||
except sqlite3.IntegrityError:
|
except sqlite3.IntegrityError:
|
||||||
|
for filename, _ in saved_images:
|
||||||
|
delete_image(filename)
|
||||||
return jsonify({"error": "Serial number or asset tag already exists."}), 409
|
return jsonify({"error": "Serial number or asset tag already exists."}), 409
|
||||||
|
|
||||||
|
|
||||||
@app.delete("/api/items/<int:item_id>")
|
@app.delete("/api/items/<int:item_id>")
|
||||||
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()
|
||||||
result = connection.execute("DELETE FROM inventory_items WHERE id = ?", (item_id,))
|
result = connection.execute("DELETE FROM inventory_items WHERE id = ?", (item_id,))
|
||||||
if result.rowcount == 0:
|
if result.rowcount == 0:
|
||||||
return jsonify({"error": "Item not found."}), 404
|
return jsonify({"error": "Item not found."}), 404
|
||||||
|
for image in images:
|
||||||
|
delete_image(image["filename"])
|
||||||
return jsonify({"deleted": True})
|
return jsonify({"deleted": True})
|
||||||
|
|
||||||
|
|
||||||
|
@app.post("/api/items/<int:item_id>/images")
|
||||||
|
def add_images(item_id):
|
||||||
|
files = files_from_request()
|
||||||
|
with get_db() as connection:
|
||||||
|
if not connection.execute("SELECT id FROM inventory_items WHERE id = ?", (item_id,)).fetchone():
|
||||||
|
return jsonify({"error": "Item not found."}), 404
|
||||||
|
current_count = connection.execute("SELECT COUNT(*) FROM inventory_images WHERE item_id = ?", (item_id,)).fetchone()[0]
|
||||||
|
selected_files = [image for image in files if image and image.filename]
|
||||||
|
if current_count + len(selected_files) > 5:
|
||||||
|
return jsonify({"error": "Each inventory item can have up to 5 pictures."}), 400
|
||||||
|
try:
|
||||||
|
saved = save_images(selected_files)
|
||||||
|
except ValueError as error:
|
||||||
|
return jsonify({"error": str(error)}), 400
|
||||||
|
now = datetime.now(timezone.utc).isoformat()
|
||||||
|
with get_db() as connection:
|
||||||
|
for filename, original_name in saved:
|
||||||
|
connection.execute("INSERT INTO inventory_images (item_id, filename, original_name, created_at) VALUES (?, ?, ?, ?)", (item_id, filename, original_name, now))
|
||||||
|
row = connection.execute("SELECT * FROM inventory_items WHERE id = ?", (item_id,)).fetchone()
|
||||||
|
return jsonify(serialize(row, connection))
|
||||||
|
|
||||||
|
|
||||||
|
@app.delete("/api/items/<int:item_id>/images/<int:image_id>")
|
||||||
|
def remove_image(item_id, image_id):
|
||||||
|
with get_db() as connection:
|
||||||
|
image = connection.execute("SELECT filename FROM inventory_images WHERE id = ? AND item_id = ?", (image_id, item_id)).fetchone()
|
||||||
|
if not image:
|
||||||
|
return jsonify({"error": "Picture not found."}), 404
|
||||||
|
connection.execute("DELETE FROM inventory_images WHERE id = ?", (image_id,))
|
||||||
|
item = connection.execute("SELECT * FROM inventory_items WHERE id = ?", (item_id,)).fetchone()
|
||||||
|
result = serialize(item, connection)
|
||||||
|
delete_image(image["filename"])
|
||||||
|
return jsonify(result)
|
||||||
|
|
||||||
|
|
||||||
initialize_db()
|
initialize_db()
|
||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
|
|||||||
+10
-4
@@ -2,7 +2,8 @@ const state = { items: [], editingId: null };
|
|||||||
const $ = (selector) => document.querySelector(selector);
|
const $ = (selector) => document.querySelector(selector);
|
||||||
|
|
||||||
async function request(url, options = {}) {
|
async function request(url, options = {}) {
|
||||||
const response = await fetch(url, { headers: { 'Content-Type': 'application/json' }, ...options });
|
const headers = options.body instanceof FormData ? {} : { 'Content-Type': 'application/json' };
|
||||||
|
const response = await fetch(url, { headers, ...options });
|
||||||
const data = await response.json();
|
const data = await response.json();
|
||||||
if (!response.ok) throw new Error(data.error || 'Something went wrong.');
|
if (!response.ok) throw new Error(data.error || 'Something went wrong.');
|
||||||
return data;
|
return data;
|
||||||
@@ -23,15 +24,18 @@ async function load() {
|
|||||||
function renderItems(items) {
|
function renderItems(items) {
|
||||||
const body = $('#inventoryBody');
|
const body = $('#inventoryBody');
|
||||||
$('#emptyState').style.display = items.length ? 'none' : 'block';
|
$('#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('');
|
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('');
|
||||||
}
|
}
|
||||||
|
|
||||||
function escapeHtml(value) { return String(value).replace(/[&<>'"]/g, (char) => ({ '&': '&', '<': '<', '>': '>', "'": ''', '"': '"' }[char])); }
|
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 ensureImageControls() { 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 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 openModal(item = null) { ensureImageControls(); state.editingId = item?.id || null; $('#modalTitle').textContent = item ? 'Edit item' : 'Add item'; $('#itemForm').reset(); $('#imageName').textContent = item?.images?.length ? `${item.images.length} saved picture${item.images.length === 1 ? '' : 's'}. Add more below.` : 'Up to 5 pictures'; renderImageGallery(item?.images || []); $('#modal').classList.add('open'); $('[name="name"]').focus(); if (item) Object.entries(item).forEach(([key, value]) => { const field = $(`[name="${key}"]`); if (field && key !== 'images') field.value = value || ''; }); }
|
||||||
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); }
|
||||||
|
|
||||||
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.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());
|
$('#addButton').addEventListener('click', () => openModal());
|
||||||
@@ -40,6 +44,8 @@ $('#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(); });
|
||||||
['searchInput', 'typeFilter', 'statusFilter'].forEach((id) => $(`#${id}`).addEventListener(id === 'searchInput' ? 'input' : 'change', load));
|
['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); } });
|
ensureImageControls();
|
||||||
|
$('#imageInput').addEventListener('change', () => { const files = [...$('#imageInput').files]; $('#imageName').textContent = files.length ? `${files.length} new picture${files.length === 1 ? '' : 's'} selected` : 'Up to 5 pictures'; });
|
||||||
|
$('#itemForm').addEventListener('submit', async (event) => { event.preventDefault(); const formData = new FormData(event.target); if (!$('#imageInput').files.length) formData.delete('images'); try { await request(state.editingId ? `/api/items/${state.editingId}` : '/api/items', { method: state.editingId ? 'PUT' : 'POST', body: formData }); closeModal(); toast(state.editingId ? 'Item updated.' : 'Item added to inventory.'); await load(); } catch (error) { toast(error.message); } });
|
||||||
$('#exportButton').addEventListener('click', () => { if (!state.items.length) return toast('There are no visible items to export.'); const columns = ['name','item_type','brand','model','serial_number','asset_tag','status','location','purchase_date','cpu','ram','gpu','storage','media_format','notes']; const csv = [columns.join(','), ...state.items.map((item) => columns.map((column) => `"${String(item[column] || '').replaceAll('"', '""')}"`).join(','))].join('\n'); const link = document.createElement('a'); link.href = URL.createObjectURL(new Blob([csv], { type: 'text/csv' })); link.download = 'stockroom-inventory.csv'; link.click(); URL.revokeObjectURL(link.href); });
|
$('#exportButton').addEventListener('click', () => { if (!state.items.length) return toast('There are no visible items to export.'); const columns = ['name','item_type','brand','model','serial_number','asset_tag','status','location','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));
|
load().catch((error) => toast(error.message));
|
||||||
|
|||||||
File diff suppressed because one or more lines are too long
Reference in New Issue
Block a user