Initial commit
This commit is contained in:
Binary file not shown.
@@ -0,0 +1,80 @@
|
||||
from fastapi import FastAPI, HTTPException, Form
|
||||
from fastapi.responses import HTMLResponse
|
||||
import os, json, uuid
|
||||
from datetime import datetime
|
||||
|
||||
DATA = "/data/inventory.json"
|
||||
app = FastAPI(redirect_slashes=False)
|
||||
|
||||
def load():
|
||||
if os.path.exists(DATA):
|
||||
with open(DATA) as f: return json.load(f)
|
||||
return {}
|
||||
|
||||
def save(d):
|
||||
with open(DATA, "w") as f: json.dump(d, f, indent=2)
|
||||
|
||||
@app.get("/api/list")
|
||||
def list_items():
|
||||
items = load()
|
||||
return sorted(
|
||||
[{"id": k, **v} for k, v in items.items()],
|
||||
key=lambda x: (x.get("category", ""), x.get("name", "").lower())
|
||||
)
|
||||
|
||||
@app.post("/api/add")
|
||||
def add(name: str = Form(""), category: str = Form(""), quantity: str = Form("1"),
|
||||
unit: str = Form(""), location: str = Form(""), expires: str = Form(""),
|
||||
notes: str = Form("")):
|
||||
items = load()
|
||||
fid = uuid.uuid4().hex[:12]
|
||||
try: qty = float(quantity or 1)
|
||||
except: qty = 1
|
||||
items[fid] = {
|
||||
"name": name, "category": category, "quantity": qty,
|
||||
"unit": unit, "location": location, "expires": expires,
|
||||
"notes": notes, "added": datetime.utcnow().isoformat()
|
||||
}
|
||||
save(items)
|
||||
return {"id": fid}
|
||||
|
||||
@app.patch("/api/update/{fid}")
|
||||
def update(fid: str, name: str = Form(None), category: str = Form(None),
|
||||
quantity: str = Form(None), unit: str = Form(None),
|
||||
location: str = Form(None), expires: str = Form(None),
|
||||
notes: str = Form(None)):
|
||||
items = load()
|
||||
if fid not in items: raise HTTPException(404)
|
||||
for k, v in {"name": name, "category": category, "unit": unit,
|
||||
"location": location, "expires": expires, "notes": notes}.items():
|
||||
if v is not None: items[fid][k] = v
|
||||
if quantity is not None:
|
||||
try: items[fid]["quantity"] = float(quantity)
|
||||
except: pass
|
||||
save(items)
|
||||
return {"ok": True}
|
||||
|
||||
@app.post("/api/adjust/{fid}")
|
||||
def adjust(fid: str, delta: str = Form(...)):
|
||||
items = load()
|
||||
if fid not in items: raise HTTPException(404)
|
||||
try:
|
||||
cur = float(items[fid].get("quantity", 0))
|
||||
items[fid]["quantity"] = max(0, cur + float(delta))
|
||||
except:
|
||||
raise HTTPException(400, "Invalid delta")
|
||||
save(items)
|
||||
return {"ok": True, "quantity": items[fid]["quantity"]}
|
||||
|
||||
@app.delete("/api/delete/{fid}")
|
||||
def delete(fid: str):
|
||||
items = load()
|
||||
if fid not in items: raise HTTPException(404)
|
||||
del items[fid]
|
||||
save(items)
|
||||
return {"ok": True}
|
||||
|
||||
@app.get("/", response_class=HTMLResponse)
|
||||
def root():
|
||||
with open("/static/index.html") as f:
|
||||
return f.read()
|
||||
@@ -0,0 +1 @@
|
||||
{}
|
||||
@@ -0,0 +1,226 @@
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<title>Inventory - The Dark Elite</title>
|
||||
<meta name="viewport" content="width=device-width,initial-scale=1">
|
||||
<style>
|
||||
:root { --bg:#060708; --border:rgba(80,90,100,0.2); --accent:#22d3ee; --text:#e5e7eb; --muted:#9ca3af; --danger:#dc2626; --warn:#f59e0b; --ok:#22c55e; }
|
||||
*{box-sizing:border-box}
|
||||
body{margin:0;background:var(--bg);color:var(--text);font-family:system-ui,sans-serif;min-height:100vh}
|
||||
.container{max-width:1300px;margin:0 auto;padding:2rem}
|
||||
header{display:flex;justify-content:space-between;align-items:center;padding-bottom:1.5rem;border-bottom:1px solid var(--border);margin-bottom:2rem}
|
||||
header h1{margin:0;font-size:1.5rem;letter-spacing:0.2em;text-transform:uppercase;color:var(--accent);text-shadow:0 0 12px rgba(34,211,238,0.4)}
|
||||
header a{color:var(--muted);text-decoration:none;font-size:0.85rem}
|
||||
header a:hover{color:var(--accent)}
|
||||
.section-title{font-size:0.75rem;letter-spacing:0.25em;text-transform:uppercase;color:var(--accent);border-left:3px solid var(--accent);padding-left:0.75rem;margin:2rem 0 1rem 0;display:flex;justify-content:space-between;align-items:center;flex-wrap:wrap;gap:0.5rem}
|
||||
.controls{display:flex;gap:0.5rem;flex-wrap:wrap}
|
||||
.controls input,.controls select{background:rgba(10,11,13,0.6);border:1px solid var(--border);color:var(--text);padding:0.4rem 0.7rem;border-radius:4px;font-size:0.8rem;font-family:ui-monospace,monospace}
|
||||
.controls input:focus,.controls select:focus{outline:none;border-color:var(--accent)}
|
||||
.add-form{background:linear-gradient(135deg,rgba(18,20,24,0.7),rgba(10,11,13,0.85));border:1px solid var(--border);border-radius:8px;padding:1.25rem;display:grid;grid-template-columns:repeat(auto-fit,minmax(150px,1fr));gap:0.75rem;margin-bottom:2rem}
|
||||
.add-form input,.add-form select{background:rgba(10,11,13,0.6);border:1px solid var(--border);color:var(--text);padding:0.55rem 0.7rem;border-radius:4px;font-size:0.85rem;font-family:ui-monospace,monospace}
|
||||
.add-form input:focus,.add-form select:focus{outline:none;border-color:var(--accent)}
|
||||
.add-form .full{grid-column:1/-1}
|
||||
.add-form button{background:rgba(34,211,238,0.15);border:1px solid var(--accent);color:var(--accent);padding:0.55rem;border-radius:4px;font-size:0.8rem;cursor:pointer;letter-spacing:0.1em;text-transform:uppercase;font-family:ui-monospace,monospace}
|
||||
.add-form button:hover{background:rgba(34,211,238,0.25)}
|
||||
table{width:100%;border-collapse:collapse;font-size:0.85rem;font-family:ui-monospace,monospace}
|
||||
th{text-align:left;color:var(--muted);font-weight:400;text-transform:uppercase;letter-spacing:0.1em;font-size:0.7rem;padding:0.7rem 0.5rem;border-bottom:1px solid var(--border);position:sticky;top:0;background:var(--bg);cursor:pointer;user-select:none}
|
||||
th:hover{color:var(--accent)}
|
||||
td{padding:0.5rem 0.5rem;border-bottom:1px solid rgba(80,90,100,0.1);vertical-align:middle}
|
||||
tr:hover td{background:rgba(34,211,238,0.04)}
|
||||
td.qty{color:var(--accent);font-weight:600;text-align:center;white-space:nowrap}
|
||||
td.editable{cursor:text}
|
||||
td.editable:hover{background:rgba(34,211,238,0.08)}
|
||||
td input{background:rgba(10,11,13,0.9);border:1px solid var(--accent);color:var(--accent);padding:0.2rem 0.4rem;border-radius:3px;font:inherit;width:100%;outline:none}
|
||||
.qty-btn{background:rgba(18,20,24,0.9);border:1px solid var(--border);color:var(--text);width:22px;height:22px;border-radius:3px;font-size:0.85rem;cursor:pointer;font-family:ui-monospace,monospace;line-height:1;padding:0}
|
||||
.qty-btn:hover{border-color:var(--accent);color:var(--accent)}
|
||||
.qty-btn.minus:hover{border-color:var(--warn);color:var(--warn)}
|
||||
.qty-val{display:inline-block;min-width:2.5rem;text-align:center;padding:0 0.4rem}
|
||||
.del{color:var(--muted);cursor:pointer;font-size:0.7rem;text-transform:uppercase;letter-spacing:0.1em}
|
||||
.del:hover{color:var(--danger)}
|
||||
.empty{color:var(--muted);text-align:center;padding:2rem;font-style:italic}
|
||||
.toast{position:fixed;bottom:2rem;right:2rem;background:rgba(18,20,24,0.95);border:1px solid var(--accent);color:var(--accent);padding:0.8rem 1.2rem;border-radius:4px;font-family:ui-monospace,monospace;font-size:0.85rem;z-index:100;display:none}
|
||||
.cat-pill{display:inline-block;background:rgba(34,211,238,0.12);border:1px solid rgba(34,211,238,0.3);color:rgb(165,235,247);font-size:0.65rem;padding:0.1rem 0.4rem;border-radius:3px;text-transform:uppercase;letter-spacing:0.1em}
|
||||
.expired{color:var(--danger)}
|
||||
.expiring{color:var(--warn)}
|
||||
.count{font-size:0.75rem;color:var(--muted);font-family:ui-monospace,monospace}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="container">
|
||||
<header>
|
||||
<div><h1>Inventory</h1><div class="count" id="count">Loading...</div></div>
|
||||
<a href="/" style="color:#9ca3af;text-decoration:none;font-size:0.85rem">← Hub</a>
|
||||
</header>
|
||||
|
||||
<div class="section-title">Add Item</div>
|
||||
<form class="add-form" id="addForm">
|
||||
<input name="name" placeholder="Item name" required>
|
||||
<select name="category" id="catInput">
|
||||
<option value="">Category</option>
|
||||
<option>Food - Canned</option><option>Food - Dry Goods</option><option>Food - Frozen</option><option>Food - Fresh</option>
|
||||
<option>Water</option><option>Medical</option><option>Hygiene</option><option>Cleaning</option>
|
||||
<option>Tools</option><option>Hardware</option><option>Batteries</option><option>Fuel</option>
|
||||
<option>Ammunition</option><option>Clothing</option><option>Camping</option><option>Electronics</option>
|
||||
<option>Office</option><option>Pet/Livestock</option><option>Seeds</option><option>Other</option>
|
||||
</select>
|
||||
<input name="quantity" type="number" step="0.01" value="1" placeholder="Qty">
|
||||
<input name="unit" placeholder="Unit (cans, lbs, ea)">
|
||||
<input name="location" placeholder="Location (pantry, garage)">
|
||||
<input name="expires" type="date" placeholder="Expires">
|
||||
<input name="notes" class="full" placeholder="Notes">
|
||||
<button type="submit" class="full">+ Add Item</button>
|
||||
</form>
|
||||
|
||||
<div class="section-title">
|
||||
Inventory
|
||||
<div class="controls">
|
||||
<select id="filterCat"><option value="">All Categories</option></select>
|
||||
<select id="filterExp">
|
||||
<option value="">All Items</option>
|
||||
<option value="expired">Expired</option>
|
||||
<option value="soon">Expiring < 30d</option>
|
||||
<option value="no-exp">No Expiration</option>
|
||||
</select>
|
||||
<input type="text" id="search" placeholder="Filter...">
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th data-sort="name">Item</th>
|
||||
<th data-sort="category">Category</th>
|
||||
<th data-sort="quantity">Qty</th>
|
||||
<th data-sort="unit">Unit</th>
|
||||
<th data-sort="location">Location</th>
|
||||
<th data-sort="expires">Expires</th>
|
||||
<th data-sort="notes">Notes</th>
|
||||
<th></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="rows"><tr><td colspan="8" class="empty">Loading...</td></tr></tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<div id="toast" class="toast"></div>
|
||||
|
||||
<script>
|
||||
let all=[]; let sortKey="category"; let sortDir=1;
|
||||
const FIELDS=["name","category","quantity","unit","location","expires","notes"];
|
||||
|
||||
function toast(m){const t=document.getElementById("toast");t.textContent=m;t.style.display="block";setTimeout(()=>t.style.display="none",1800);}
|
||||
function dayDiff(d){if(!d)return null;const ms=new Date(d).getTime()-Date.now();return Math.floor(ms/86400000);}
|
||||
function expClass(d){const dd=dayDiff(d);if(dd===null)return"";if(dd<0)return"expired";if(dd<30)return"expiring";return"";}
|
||||
|
||||
async function load(){
|
||||
const r=await fetch("/inventory/api/list");
|
||||
all=await r.json();
|
||||
populateCats();
|
||||
render();
|
||||
}
|
||||
|
||||
function populateCats(){
|
||||
const cats=[...new Set(all.map(x=>x.category).filter(Boolean))].sort();
|
||||
const sel=document.getElementById("filterCat");
|
||||
const cur=sel.value;
|
||||
sel.innerHTML='<option value="">All Categories</option>'+cats.map(c=>'<option>'+c+'</option>').join("");
|
||||
sel.value=cur;
|
||||
}
|
||||
|
||||
function render(){
|
||||
const q=document.getElementById("search").value.toLowerCase();
|
||||
const cat=document.getElementById("filterCat").value;
|
||||
const expF=document.getElementById("filterExp").value;
|
||||
let items=all.filter(x=>{
|
||||
if(cat && x.category!==cat)return false;
|
||||
if(expF==="expired"){const dd=dayDiff(x.expires);if(dd===null||dd>=0)return false;}
|
||||
if(expF==="soon"){const dd=dayDiff(x.expires);if(dd===null||dd<0||dd>=30)return false;}
|
||||
if(expF==="no-exp" && x.expires)return false;
|
||||
if(q){const s=FIELDS.map(f=>(x[f]||"")).join(" ").toLowerCase();if(!s.includes(q))return false;}
|
||||
return true;
|
||||
});
|
||||
items.sort((a,b)=>{
|
||||
let va=a[sortKey], vb=b[sortKey];
|
||||
if(sortKey==="quantity"){return ((parseFloat(va)||0)-(parseFloat(vb)||0))*sortDir;}
|
||||
return String(va||"").localeCompare(String(vb||""))*sortDir;
|
||||
});
|
||||
document.getElementById("count").textContent=items.length+" of "+all.length+" items";
|
||||
const tbody=document.getElementById("rows");
|
||||
if(!items.length){tbody.innerHTML='<tr><td colspan="8" class="empty">No items. Add one above.</td></tr>';return;}
|
||||
tbody.innerHTML=items.map(x=>{
|
||||
const ec=expClass(x.expires);
|
||||
return '<tr data-id="'+x.id+'">'+
|
||||
'<td class="editable" data-f="name">'+(x.name||"")+'</td>'+
|
||||
'<td class="editable" data-f="category">'+(x.category?'<span class="cat-pill">'+x.category+'</span>':"")+'</td>'+
|
||||
'<td class="qty"><button class="qty-btn minus" data-d="-1">−</button><span class="qty-val">'+(x.quantity||0)+'</span><button class="qty-btn plus" data-d="1">+</button></td>'+
|
||||
'<td class="editable" data-f="unit">'+(x.unit||"")+'</td>'+
|
||||
'<td class="editable" data-f="location">'+(x.location||"")+'</td>'+
|
||||
'<td class="editable '+ec+'" data-f="expires">'+(x.expires||"")+'</td>'+
|
||||
'<td class="editable" data-f="notes" style="max-width:280px">'+(x.notes||"")+'</td>'+
|
||||
'<td><span class="del">Del</span></td>'+
|
||||
'</tr>';
|
||||
}).join("");
|
||||
}
|
||||
|
||||
document.getElementById("addForm").addEventListener("submit",async e=>{
|
||||
e.preventDefault();
|
||||
const fd=new FormData(e.target);
|
||||
const r=await fetch("/inventory/api/add",{method:"POST",body:fd});
|
||||
if(r.ok){toast("Added");e.target.reset();e.target.quantity.value=1;load();}
|
||||
else toast("Failed");
|
||||
});
|
||||
|
||||
document.querySelectorAll("th[data-sort]").forEach(th=>{
|
||||
th.addEventListener("click",()=>{
|
||||
const k=th.dataset.sort;
|
||||
if(sortKey===k)sortDir*=-1; else {sortKey=k;sortDir=1;}
|
||||
render();
|
||||
});
|
||||
});
|
||||
|
||||
document.getElementById("search").addEventListener("input",render);
|
||||
document.getElementById("filterCat").addEventListener("change",render);
|
||||
document.getElementById("filterExp").addEventListener("change",render);
|
||||
|
||||
document.getElementById("rows").addEventListener("click",async e=>{
|
||||
const tr=e.target.closest("tr"); if(!tr)return;
|
||||
const id=tr.dataset.id;
|
||||
if(e.target.classList.contains("del")){
|
||||
if(!confirm("Delete this item?"))return;
|
||||
await fetch("/inventory/api/delete/"+id,{method:"DELETE"});
|
||||
toast("Deleted");load();
|
||||
return;
|
||||
}
|
||||
if(e.target.classList.contains("qty-btn")){
|
||||
const delta=e.target.dataset.d;
|
||||
const fd=new FormData(); fd.append("delta",delta);
|
||||
const r=await fetch("/inventory/api/adjust/"+id,{method:"POST",body:fd});
|
||||
if(r.ok){
|
||||
const d=await r.json();
|
||||
tr.querySelector(".qty-val").textContent=d.quantity;
|
||||
const it=all.find(x=>x.id===id); if(it) it.quantity=d.quantity;
|
||||
}
|
||||
return;
|
||||
}
|
||||
const td=e.target.closest("td.editable");
|
||||
if(!td || td.querySelector("input"))return;
|
||||
const field=td.dataset.f;
|
||||
const it=all.find(x=>x.id===id);
|
||||
const cur=field==="category"?(it.category||""):(it[field]||"");
|
||||
td.innerHTML='<input type="text" value="'+String(cur).replace(/"/g,""")+'">';
|
||||
const inp=td.querySelector("input"); inp.focus(); inp.select();
|
||||
const save=async()=>{
|
||||
const val=inp.value;
|
||||
const fd=new FormData(); fd.append(field,val);
|
||||
await fetch("/inventory/api/update/"+id,{method:"PATCH",body:fd});
|
||||
toast("Updated");load();
|
||||
};
|
||||
inp.addEventListener("blur",save);
|
||||
inp.addEventListener("keydown",ev=>{if(ev.key==="Enter")inp.blur();if(ev.key==="Escape"){td.textContent=cur;}});
|
||||
});
|
||||
|
||||
load();
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
Reference in New Issue
Block a user