Add examples to inventory
This commit is contained in:
Binary file not shown.
@@ -14,7 +14,6 @@ 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")))
|
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"}
|
||||||
ALLOWED_STATUSES = {"Available", "Trashed", "Sold"}
|
|
||||||
app.config["MAX_CONTENT_LENGTH"] = 250 * 1024 * 1024
|
app.config["MAX_CONTENT_LENGTH"] = 250 * 1024 * 1024
|
||||||
|
|
||||||
|
|
||||||
@@ -42,7 +41,7 @@ def initialize_db():
|
|||||||
status TEXT NOT NULL DEFAULT 'Available',
|
status TEXT NOT NULL DEFAULT 'Available',
|
||||||
location TEXT,
|
location TEXT,
|
||||||
destination TEXT,
|
destination TEXT,
|
||||||
price REAL,
|
checkout_date TEXT,
|
||||||
purchase_date TEXT,
|
purchase_date TEXT,
|
||||||
notes TEXT,
|
notes TEXT,
|
||||||
cpu TEXT,
|
cpu TEXT,
|
||||||
@@ -68,12 +67,18 @@ def initialize_db():
|
|||||||
"""
|
"""
|
||||||
)
|
)
|
||||||
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)")}
|
||||||
if "image_filename" not in columns:
|
migrations = {
|
||||||
connection.execute("ALTER TABLE inventory_items ADD COLUMN image_filename TEXT")
|
"image_filename": "ALTER TABLE inventory_items ADD COLUMN image_filename TEXT",
|
||||||
if "price" not in columns:
|
"destination": "ALTER TABLE inventory_items ADD COLUMN destination TEXT",
|
||||||
connection.execute("ALTER TABLE inventory_items ADD COLUMN price REAL")
|
"checkout_date": "ALTER TABLE inventory_items ADD COLUMN checkout_date TEXT",
|
||||||
if "destination" not in columns:
|
}
|
||||||
connection.execute("ALTER TABLE inventory_items ADD COLUMN destination TEXT")
|
for column, statement in migrations.items():
|
||||||
|
if column not in columns:
|
||||||
|
try:
|
||||||
|
connection.execute(statement)
|
||||||
|
except sqlite3.OperationalError as error:
|
||||||
|
if "duplicate column name" not in str(error):
|
||||||
|
raise
|
||||||
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'))
|
||||||
@@ -88,19 +93,6 @@ def clean(value):
|
|||||||
|
|
||||||
|
|
||||||
def item_payload(data):
|
def item_payload(data):
|
||||||
price_value = clean(data.get("price"))
|
|
||||||
if price_value:
|
|
||||||
try:
|
|
||||||
price = round(float(price_value), 2)
|
|
||||||
except ValueError:
|
|
||||||
raise ValueError("Price must be a valid number.")
|
|
||||||
if price < 0:
|
|
||||||
raise ValueError("Price cannot be negative.")
|
|
||||||
else:
|
|
||||||
price = None
|
|
||||||
status = clean(data.get("status")) or "Available"
|
|
||||||
if status not in ALLOWED_STATUSES:
|
|
||||||
raise ValueError("Status must be Available, Trashed, or Sold.")
|
|
||||||
return {
|
return {
|
||||||
"name": clean(data.get("name")),
|
"name": clean(data.get("name")),
|
||||||
"item_type": clean(data.get("item_type")),
|
"item_type": clean(data.get("item_type")),
|
||||||
@@ -108,10 +100,10 @@ def item_payload(data):
|
|||||||
"model": clean(data.get("model")),
|
"model": clean(data.get("model")),
|
||||||
"serial_number": clean(data.get("serial_number")) or None,
|
"serial_number": clean(data.get("serial_number")) or None,
|
||||||
"asset_tag": clean(data.get("asset_tag")) or None,
|
"asset_tag": clean(data.get("asset_tag")) or None,
|
||||||
"status": status,
|
"status": clean(data.get("status")) or "Available",
|
||||||
"location": clean(data.get("location")),
|
"location": clean(data.get("location")),
|
||||||
"destination": clean(data.get("destination")),
|
"destination": clean(data.get("destination")),
|
||||||
"price": price,
|
"checkout_date": clean(data.get("checkout_date")) or None,
|
||||||
"purchase_date": clean(data.get("purchase_date")) or None,
|
"purchase_date": clean(data.get("purchase_date")) or None,
|
||||||
"notes": clean(data.get("notes")),
|
"notes": clean(data.get("notes")),
|
||||||
"cpu": clean(data.get("cpu")),
|
"cpu": clean(data.get("cpu")),
|
||||||
@@ -253,9 +245,9 @@ 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]
|
||||||
available = connection.execute("SELECT COUNT(*) FROM inventory_items WHERE status = 'Available'").fetchone()[0]
|
available = connection.execute("SELECT COUNT(*) FROM inventory_items WHERE status = 'Available'").fetchone()[0]
|
||||||
trashed = connection.execute("SELECT COUNT(*) FROM inventory_items WHERE status = 'Trashed'").fetchone()[0]
|
checked_out = connection.execute("SELECT COUNT(*) FROM inventory_items WHERE status = 'Checked out'").fetchone()[0]
|
||||||
sold = connection.execute("SELECT COUNT(*) FROM inventory_items WHERE status = 'Sold'").fetchone()[0]
|
maintenance = connection.execute("SELECT COUNT(*) FROM inventory_items WHERE status = 'Maintenance'").fetchone()[0]
|
||||||
return jsonify({"total": total, "available": available, "trashed": trashed, "sold": sold})
|
return jsonify({"total": total, "available": available, "checked_out": checked_out, "maintenance": maintenance})
|
||||||
|
|
||||||
|
|
||||||
@app.get("/api/backup")
|
@app.get("/api/backup")
|
||||||
@@ -292,10 +284,7 @@ def restore():
|
|||||||
|
|
||||||
@app.post("/api/items")
|
@app.post("/api/items")
|
||||||
def create_item():
|
def create_item():
|
||||||
try:
|
|
||||||
data = item_payload(request_data())
|
data = item_payload(request_data())
|
||||||
except ValueError as error:
|
|
||||||
return jsonify({"error": str(error)}), 400
|
|
||||||
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]
|
selected_files = [image for image in files_from_request() if image and image.filename]
|
||||||
@@ -310,7 +299,7 @@ def create_item():
|
|||||||
with get_db() as connection:
|
with get_db() as connection:
|
||||||
cursor = connection.execute(
|
cursor = connection.execute(
|
||||||
"""INSERT INTO inventory_items
|
"""INSERT INTO inventory_items
|
||||||
(name, item_type, brand, model, serial_number, asset_tag, status, location, destination, price, purchase_date, notes, cpu, ram, gpu, storage, media_format, created_at, updated_at)
|
(name, item_type, brand, model, serial_number, asset_tag, status, location, destination, checkout_date, purchase_date, notes, cpu, ram, gpu, storage, media_format, created_at, updated_at)
|
||||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)""",
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)""",
|
||||||
(*data.values(), now, now),
|
(*data.values(), now, now),
|
||||||
)
|
)
|
||||||
@@ -327,10 +316,7 @@ def create_item():
|
|||||||
|
|
||||||
@app.put("/api/items/<int:item_id>")
|
@app.put("/api/items/<int:item_id>")
|
||||||
def update_item(item_id):
|
def update_item(item_id):
|
||||||
try:
|
|
||||||
data = item_payload(request_data())
|
data = item_payload(request_data())
|
||||||
except ValueError as error:
|
|
||||||
return jsonify({"error": str(error)}), 400
|
|
||||||
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()
|
||||||
@@ -346,7 +332,7 @@ def update_item(item_id):
|
|||||||
return jsonify({"error": "Each inventory item can have up to 5 pictures."}), 400
|
return jsonify({"error": "Each inventory item can have up to 5 pictures."}), 400
|
||||||
saved_images = save_images(new_images)
|
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=?, destination=?, price=?, purchase_date=?, notes=?, cpu=?, ram=?, gpu=?, storage=?, media_format=?, updated_at=? WHERE id=?""",
|
"""UPDATE inventory_items SET name=?, item_type=?, brand=?, model=?, serial_number=?, asset_tag=?, status=?, location=?, destination=?, checkout_date=?, purchase_date=?, notes=?, cpu=?, ram=?, gpu=?, storage=?, media_format=?, updated_at=? WHERE id=?""",
|
||||||
(*data.values(), item_id),
|
(*data.values(), item_id),
|
||||||
)
|
)
|
||||||
for filename, original_name in saved_images:
|
for filename, original_name in saved_images:
|
||||||
|
|||||||
Binary file not shown.
+8
-5
@@ -10,10 +10,13 @@ async function request(url, options = {}) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function load() {
|
async function load() {
|
||||||
const query = new URLSearchParams({ q: $('#searchInput').value, type: $('#typeFilter').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')]);
|
||||||
state.items = items;
|
state.items = items;
|
||||||
$('#totalCount').textContent = summary.total;
|
$('#totalCount').textContent = summary.total;
|
||||||
|
$('#availableCount').textContent = summary.available;
|
||||||
|
$('#checkedOutCount').textContent = summary.checked_out;
|
||||||
|
$('#maintenanceCount').textContent = summary.maintenance;
|
||||||
$('#resultCount').textContent = `${items.length} ${items.length === 1 ? 'item' : 'items'}`;
|
$('#resultCount').textContent = `${items.length} ${items.length === 1 ? 'item' : 'items'}`;
|
||||||
renderItems(items);
|
renderItems(items);
|
||||||
}
|
}
|
||||||
@@ -21,11 +24,11 @@ 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><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><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 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 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 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(''); }
|
||||||
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 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'); }
|
||||||
@@ -40,11 +43,11 @@ $('#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(); });
|
||||||
['searchInput', 'typeFilter'].forEach((id) => $(`#${id}`).addEventListener(id === 'searchInput' ? 'input' : 'change', load));
|
['searchInput', 'typeFilter', 'statusFilter'].forEach((id) => $(`#${id}`).addEventListener(id === 'searchInput' ? 'input' : 'change', load));
|
||||||
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','price','purchase_date','cpu','ram','gpu','storage','media_format','notes']; const csv = [columns.join(','), ...state.items.map((item) => columns.map((column) => `"${String(item[column] || '').replaceAll('"', '""')}"`).join(','))].join('\n'); const link = document.createElement('a'); link.href = URL.createObjectURL(new Blob([csv], { type: 'text/csv' })); link.download = 'stockroom-inventory.csv'; link.click(); URL.revokeObjectURL(link.href); });
|
$('#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); } });
|
$('#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());
|
$('#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 = ''; });
|
$('#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 = ''; });
|
||||||
|
|||||||
File diff suppressed because one or more lines are too long
@@ -2,14 +2,12 @@
|
|||||||
<html lang="en">
|
<html lang="en">
|
||||||
<head>
|
<head>
|
||||||
<meta charset="utf-8">
|
<meta charset="utf-8">
|
||||||
<option>Trashed</option><option>Sold</option>
|
|
||||||
<title>Stockroom | Inventory</title>
|
<title>Stockroom | Inventory</title>
|
||||||
<link rel="preconnect" href="https://fonts.googleapis.com">
|
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
<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 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>
|
||||||
<label>Where it went<input name="destination" placeholder="e.g. Customer, recycling, or employee"></label><label>Price<input name="price" type="number" min="0" step="0.01" placeholder="0.00"></label><label>Purchase date<input name="purchase_date" type="date"></label>
|
|
||||||
<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>
|
||||||
@@ -20,11 +18,14 @@
|
|||||||
<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><button class="primary-button" id="addButton"><span>+</span> Add item</button></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">Checked out <span class="stat-symbol amber">●</span></div><strong id="checkedOutCount">0</strong><small>Currently assigned</small></article>
|
||||||
|
<article class="stat-card"><div class="stat-label">Maintenance <span class="stat-symbol red">●</span></div><strong id="maintenanceCount">0</strong><small>Needs attention</small></article>
|
||||||
</section>
|
</section>
|
||||||
<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></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><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><button class="secondary-button" id="emptyAddButton">Add first item</button></div></div>
|
||||||
</section>
|
</section>
|
||||||
</main>
|
</main>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
Reference in New Issue
Block a user