Web-UI: professionelles Redesign (Icons statt Emojis) + neue Features
Design: - Neues Icon-Set (Inline-SVG, Feather-Stil), keine Emojis mehr - Kohaerentes Design-System (Sidebar-Layout, Palette, Spacing, Light/Dark) - Alle Seiten ueberarbeitet (Login, Dashboard, Produkte, Formular, Ein-/Auslagern, Lagerorte, Benutzer, Einkaufsliste) Neue Features: - Gruppen-Verwaltung (anlegen/bearbeiten/loeschen, Produktzahl + Bestand, Gruppen-Mindestbestand) inkl. Einkaufsliste - Einfache Gruppen-Auto-Zuordnung aus OFF-Kategorie im Produktformular - Verlauf-Seite (Bewegungen: wer/was/wann) via neuem /movements-Endpoint - Einstellungen-Seite (Ablauf-Warnfrist) - Lagerorte mit optional uebergeordnetem Ort (verschachtelbar) Backend: - groups: PATCH + Anreicherung (product_count, stock) - views: /shopping-list/groups, /movements - schemas: GroupUpdate, GroupShoppingItem, MovementOut Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -2,6 +2,7 @@ import { useEffect, useState } from "react";
|
||||
import { Link } from "react-router-dom";
|
||||
import { api } from "../api";
|
||||
import { useAuth } from "../auth";
|
||||
import Icon from "../components/Icon";
|
||||
import { fmt, unitOptions, unitShort } from "../units";
|
||||
|
||||
export default function CheckIn() {
|
||||
@@ -30,8 +31,7 @@ export default function CheckIn() {
|
||||
setProduct(p);
|
||||
setResults([]);
|
||||
setUnknownBarcode(null);
|
||||
const opts = unitOptions(p);
|
||||
setUnit(opts[0].value);
|
||||
setUnit(unitOptions(p)[0].value);
|
||||
}
|
||||
|
||||
async function doLookup() {
|
||||
@@ -44,7 +44,6 @@ export default function CheckIn() {
|
||||
setInfo(`Produkt erkannt: ${res.existing_product.name}`);
|
||||
} else {
|
||||
setUnknownBarcode(barcode.trim());
|
||||
setInfo(null);
|
||||
}
|
||||
} catch (err) {
|
||||
setError(err.message);
|
||||
@@ -84,36 +83,40 @@ export default function CheckIn() {
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="page-head"><h1>📥 Einlagern</h1></div>
|
||||
{error && <div className="alert error">{error}</div>}
|
||||
{info && <div className="alert info">{info}</div>}
|
||||
<div className="page-head"><h1>Einlagern</h1></div>
|
||||
{error && <div className="alert error"><Icon name="alert" size={16} />{error}</div>}
|
||||
{info && <div className="alert ok"><Icon name="check" size={16} />{info}</div>}
|
||||
|
||||
{!product && (
|
||||
<div className="grid-2">
|
||||
<div className="card">
|
||||
<h2>Per Barcode</h2>
|
||||
<div className="row">
|
||||
<input className="grow" placeholder="Barcode" value={barcode}
|
||||
onChange={(e) => setBarcode(e.target.value)}
|
||||
onKeyDown={(e) => e.key === "Enter" && doLookup()} />
|
||||
<div className="card-head"><Icon name="search" /><h2>Per Barcode</h2></div>
|
||||
<div className="field-inline">
|
||||
<label className="grow" style={{ margin: 0 }}>
|
||||
<input placeholder="Barcode eingeben oder scannen" value={barcode}
|
||||
onChange={(e) => setBarcode(e.target.value)}
|
||||
onKeyDown={(e) => e.key === "Enter" && doLookup()} autoFocus />
|
||||
</label>
|
||||
<button className="btn primary" onClick={doLookup}>Suchen</button>
|
||||
</div>
|
||||
{unknownBarcode && (
|
||||
<div className="alert warn">
|
||||
Barcode <strong>{unknownBarcode}</strong> ist unbekannt.{" "}
|
||||
{isAdmin ? (
|
||||
<Link to={`/products/new`}>Produkt anlegen</Link>
|
||||
) : (
|
||||
"Bitte einen Administrator bitten, das Produkt anzulegen."
|
||||
)}
|
||||
<div className="alert warn" style={{ marginTop: "var(--sp-3)" }}>
|
||||
<Icon name="alert" size={16} />
|
||||
<span>
|
||||
Barcode <strong>{unknownBarcode}</strong> ist unbekannt.{" "}
|
||||
{isAdmin
|
||||
? <Link to="/products/new">Produkt anlegen</Link>
|
||||
: "Bitte einen Administrator bitten, das Produkt anzulegen."}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="card">
|
||||
<h2>Aus Produktliste</h2>
|
||||
<form className="row" onSubmit={doSearch}>
|
||||
<input className="grow" placeholder="Name suchen…" value={search}
|
||||
onChange={(e) => setSearch(e.target.value)} />
|
||||
<div className="card-head"><Icon name="package" /><h2>Aus Produktliste</h2></div>
|
||||
<form className="field-inline" onSubmit={doSearch}>
|
||||
<label className="grow" style={{ margin: 0 }}>
|
||||
<input placeholder="Name suchen…" value={search} onChange={(e) => setSearch(e.target.value)} />
|
||||
</label>
|
||||
<button className="btn">Suchen</button>
|
||||
</form>
|
||||
<ul className="picklist">
|
||||
@@ -132,12 +135,12 @@ export default function CheckIn() {
|
||||
{product && (
|
||||
<form className="card form-narrow" onSubmit={submit}>
|
||||
<div className="selected-product">
|
||||
{product.image_url && <img className="thumb" src={product.image_url} alt="" />}
|
||||
<div>
|
||||
<strong>{product.name}</strong>
|
||||
<div className="muted">
|
||||
Aktueller Bestand: {fmt(product.stock)} {unitShort(product.base_unit)}
|
||||
</div>
|
||||
{product.image_url
|
||||
? <img className="thumb" src={product.image_url} alt="" />
|
||||
: <span className="thumb thumb-fallback"><Icon name="box" size={16} /></span>}
|
||||
<div className="info">
|
||||
<div className="title">{product.name}</div>
|
||||
<div className="muted small">Bestand: {fmt(product.stock)} {unitShort(product.base_unit)}</div>
|
||||
</div>
|
||||
<button type="button" className="btn ghost" onClick={() => setProduct(null)}>Ändern</button>
|
||||
</div>
|
||||
@@ -151,15 +154,13 @@ export default function CheckIn() {
|
||||
<label className="grow">
|
||||
Einheit
|
||||
<select value={unit} onChange={(e) => setUnit(e.target.value)}>
|
||||
{unitOptions(product).map((o) => (
|
||||
<option key={o.value} value={o.value}>{o.label}</option>
|
||||
))}
|
||||
{unitOptions(product).map((o) => <option key={o.value} value={o.value}>{o.label}</option>)}
|
||||
</select>
|
||||
</label>
|
||||
</div>
|
||||
<div className="row">
|
||||
<label className="grow">
|
||||
MHD (optional)
|
||||
Mindesthaltbarkeit (optional)
|
||||
<input type="date" value={bestBefore} onChange={(e) => setBestBefore(e.target.value)} />
|
||||
</label>
|
||||
<label className="grow">
|
||||
@@ -170,7 +171,7 @@ export default function CheckIn() {
|
||||
</select>
|
||||
</label>
|
||||
</div>
|
||||
<button className="btn primary" disabled={busy}>{busy ? "…" : "Einlagern"}</button>
|
||||
<button className="btn primary" disabled={busy}><Icon name="checkin" size={16} />{busy ? "…" : "Einlagern"}</button>
|
||||
</form>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { useState } from "react";
|
||||
import { api } from "../api";
|
||||
import Icon from "../components/Icon";
|
||||
import { fmt, unitOptions, unitShort } from "../units";
|
||||
|
||||
export default function CheckOut() {
|
||||
@@ -54,7 +55,7 @@ export default function CheckOut() {
|
||||
quantity: Number(quantity),
|
||||
unit,
|
||||
});
|
||||
setInfo(`Ausgelagert (${res.affected_lots.length} Charge(n) betroffen). Neuer Bestand: ${fmt(res.product_stock)} ${unitShort(product.base_unit)}`);
|
||||
setInfo(`Ausgelagert (${res.affected_lots.length} Charge(n)). Neuer Bestand: ${fmt(res.product_stock)} ${unitShort(product.base_unit)}`);
|
||||
setQuantity("");
|
||||
setProduct(await api.getProduct(product.id));
|
||||
} catch (err) {
|
||||
@@ -66,26 +67,29 @@ export default function CheckOut() {
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="page-head"><h1>📤 Auslagern</h1></div>
|
||||
{error && <div className="alert error">{error}</div>}
|
||||
{info && <div className="alert info">{info}</div>}
|
||||
<div className="page-head"><h1>Auslagern</h1></div>
|
||||
{error && <div className="alert error"><Icon name="alert" size={16} />{error}</div>}
|
||||
{info && <div className="alert ok"><Icon name="check" size={16} />{info}</div>}
|
||||
|
||||
{!product && (
|
||||
<div className="grid-2">
|
||||
<div className="card">
|
||||
<h2>Per Barcode</h2>
|
||||
<div className="row">
|
||||
<input className="grow" placeholder="Barcode" value={barcode}
|
||||
onChange={(e) => setBarcode(e.target.value)}
|
||||
onKeyDown={(e) => e.key === "Enter" && doLookup()} />
|
||||
<div className="card-head"><Icon name="search" /><h2>Per Barcode</h2></div>
|
||||
<div className="field-inline">
|
||||
<label className="grow" style={{ margin: 0 }}>
|
||||
<input placeholder="Barcode eingeben oder scannen" value={barcode}
|
||||
onChange={(e) => setBarcode(e.target.value)}
|
||||
onKeyDown={(e) => e.key === "Enter" && doLookup()} autoFocus />
|
||||
</label>
|
||||
<button className="btn primary" onClick={doLookup}>Suchen</button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="card">
|
||||
<h2>Aus Produktliste</h2>
|
||||
<form className="row" onSubmit={doSearch}>
|
||||
<input className="grow" placeholder="Name suchen…" value={search}
|
||||
onChange={(e) => setSearch(e.target.value)} />
|
||||
<div className="card-head"><Icon name="package" /><h2>Aus Produktliste</h2></div>
|
||||
<form className="field-inline" onSubmit={doSearch}>
|
||||
<label className="grow" style={{ margin: 0 }}>
|
||||
<input placeholder="Name suchen…" value={search} onChange={(e) => setSearch(e.target.value)} />
|
||||
</label>
|
||||
<button className="btn">Suchen</button>
|
||||
</form>
|
||||
<ul className="picklist">
|
||||
@@ -104,12 +108,12 @@ export default function CheckOut() {
|
||||
{product && (
|
||||
<form className="card form-narrow" onSubmit={submit}>
|
||||
<div className="selected-product">
|
||||
{product.image_url && <img className="thumb" src={product.image_url} alt="" />}
|
||||
<div>
|
||||
<strong>{product.name}</strong>
|
||||
<div className="muted">
|
||||
Verfügbar: {fmt(product.stock)} {unitShort(product.base_unit)}
|
||||
</div>
|
||||
{product.image_url
|
||||
? <img className="thumb" src={product.image_url} alt="" />
|
||||
: <span className="thumb thumb-fallback"><Icon name="box" size={16} /></span>}
|
||||
<div className="info">
|
||||
<div className="title">{product.name}</div>
|
||||
<div className="muted small">Verfügbar: {fmt(product.stock)} {unitShort(product.base_unit)}</div>
|
||||
</div>
|
||||
<button type="button" className="btn ghost" onClick={() => setProduct(null)}>Ändern</button>
|
||||
</div>
|
||||
@@ -123,16 +127,14 @@ export default function CheckOut() {
|
||||
<label className="grow">
|
||||
Einheit
|
||||
<select value={unit} onChange={(e) => setUnit(e.target.value)}>
|
||||
{unitOptions(product).map((o) => (
|
||||
<option key={o.value} value={o.value}>{o.label}</option>
|
||||
))}
|
||||
{unitOptions(product).map((o) => <option key={o.value} value={o.value}>{o.label}</option>)}
|
||||
</select>
|
||||
</label>
|
||||
</div>
|
||||
<p className="muted small">
|
||||
<p className="muted small mt-0">
|
||||
Es wird automatisch die zuerst ablaufende Charge zuerst entnommen (FEFO).
|
||||
</p>
|
||||
<button className="btn primary" disabled={busy}>{busy ? "…" : "Auslagern"}</button>
|
||||
<button className="btn primary" disabled={busy}><Icon name="checkout" size={16} />{busy ? "…" : "Auslagern"}</button>
|
||||
</form>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -1,19 +1,26 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { Link } from "react-router-dom";
|
||||
import { api } from "../api";
|
||||
import Icon from "../components/Icon";
|
||||
import { fmt, unitShort } from "../units";
|
||||
|
||||
export default function Dashboard() {
|
||||
const [expiring, setExpiring] = useState([]);
|
||||
const [shopping, setShopping] = useState([]);
|
||||
const [groupShopping, setGroupShopping] = useState([]);
|
||||
const [error, setError] = useState(null);
|
||||
|
||||
useEffect(() => {
|
||||
async function load() {
|
||||
try {
|
||||
const [e, s] = await Promise.all([api.expiring(), api.shoppingList()]);
|
||||
const [e, s, g] = await Promise.all([
|
||||
api.expiring(),
|
||||
api.shoppingList(),
|
||||
api.groupShoppingList(),
|
||||
]);
|
||||
setExpiring(e);
|
||||
setShopping(s);
|
||||
setGroupShopping(g);
|
||||
} catch (err) {
|
||||
setError(err.message);
|
||||
}
|
||||
@@ -21,61 +28,100 @@ export default function Dashboard() {
|
||||
load();
|
||||
}, []);
|
||||
|
||||
const overdue = expiring.filter((e) => e.days_left < 0).length;
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="page-head">
|
||||
<h1>Übersicht</h1>
|
||||
<div>
|
||||
<h1>Übersicht</h1>
|
||||
<div className="sub">Bestand, Ablauf und Bedarf auf einen Blick</div>
|
||||
</div>
|
||||
<div className="actions">
|
||||
<Link className="btn primary" to="/checkin">Einlagern</Link>
|
||||
<Link className="btn" to="/checkout">Auslagern</Link>
|
||||
<Link className="btn primary" to="/checkin"><Icon name="checkin" size={16} />Einlagern</Link>
|
||||
<Link className="btn" to="/checkout"><Icon name="checkout" size={16} />Auslagern</Link>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{error && <div className="alert error"><Icon name="alert" size={16} />{error}</div>}
|
||||
|
||||
<div className="stat-row">
|
||||
<div className="stat">
|
||||
<div className="stat-label"><Icon name="clock" size={14} />Bald ablaufend</div>
|
||||
<div className="stat-value">{expiring.length}</div>
|
||||
</div>
|
||||
<div className="stat">
|
||||
<div className="stat-label"><Icon name="alert" size={14} />Überfällig</div>
|
||||
<div className="stat-value">{overdue}</div>
|
||||
</div>
|
||||
<div className="stat">
|
||||
<div className="stat-label"><Icon name="cart" size={14} />Nachzukaufen</div>
|
||||
<div className="stat-value">{shopping.length + groupShopping.length}</div>
|
||||
</div>
|
||||
</div>
|
||||
{error && <div className="alert error">{error}</div>}
|
||||
|
||||
<div className="grid-2">
|
||||
<section className="card">
|
||||
<h2>⏰ Bald ablaufend</h2>
|
||||
<div className="card-head"><Icon name="clock" />
|
||||
<h2>Bald ablaufend</h2>
|
||||
</div>
|
||||
{expiring.length === 0 ? (
|
||||
<p className="muted">Nichts läuft demnächst ab. 🎉</p>
|
||||
<div className="empty">Nichts läuft demnächst ab.</div>
|
||||
) : (
|
||||
<table className="table">
|
||||
<thead>
|
||||
<tr><th>Produkt</th><th>Menge</th><th>MHD</th><th>Tage</th></tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{expiring.map((it) => (
|
||||
<tr key={it.lot_id} className={it.days_left < 0 ? "row-danger" : it.days_left <= 2 ? "row-warn" : ""}>
|
||||
<td>{it.product_name}</td>
|
||||
<td>{fmt(it.quantity)} {unitShort(it.base_unit)}</td>
|
||||
<td>{it.best_before}</td>
|
||||
<td>{it.days_left < 0 ? `${-it.days_left} überf.` : it.days_left}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
<div className="table-wrap">
|
||||
<table className="table">
|
||||
<thead>
|
||||
<tr><th>Produkt</th><th className="num">Menge</th><th>MHD</th><th className="num">Tage</th></tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{expiring.map((it) => (
|
||||
<tr key={it.lot_id} className={it.days_left < 0 ? "row-danger" : it.days_left <= 2 ? "row-warn" : ""}>
|
||||
<td>{it.product_name}</td>
|
||||
<td className="num">{fmt(it.quantity)} {unitShort(it.base_unit)}</td>
|
||||
<td>{it.best_before}</td>
|
||||
<td className="num">
|
||||
{it.days_left < 0
|
||||
? <span className="badge danger">{-it.days_left}d überf.</span>
|
||||
: it.days_left}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
|
||||
<section className="card">
|
||||
<h2>🛒 Einkaufsliste</h2>
|
||||
{shopping.length === 0 ? (
|
||||
<p className="muted">Alle Mindestbestände erreicht. 👍</p>
|
||||
<div className="card-head"><Icon name="cart" />
|
||||
<h2>Einkaufsliste</h2>
|
||||
</div>
|
||||
{shopping.length === 0 && groupShopping.length === 0 ? (
|
||||
<div className="empty">Alle Mindestbestände erreicht.</div>
|
||||
) : (
|
||||
<table className="table">
|
||||
<thead>
|
||||
<tr><th>Produkt</th><th>Bestand</th><th>Mindest</th><th>Fehlt</th></tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{shopping.map((it) => (
|
||||
<tr key={it.product_id}>
|
||||
<td>{it.name}</td>
|
||||
<td>{fmt(it.stock)} {unitShort(it.base_unit)}</td>
|
||||
<td>{fmt(it.min_stock)}</td>
|
||||
<td className="strong">{fmt(it.deficit)} {unitShort(it.base_unit)}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
<div className="table-wrap">
|
||||
<table className="table">
|
||||
<thead>
|
||||
<tr><th>Bedarf</th><th className="num">Bestand</th><th className="num">Fehlt</th></tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{groupShopping.map((it) => (
|
||||
<tr key={`g${it.group_id}`}>
|
||||
<td><span className="badge accent">Gruppe</span> {it.name}</td>
|
||||
<td className="num">{fmt(it.stock)}</td>
|
||||
<td className="num strong">{fmt(it.deficit)}</td>
|
||||
</tr>
|
||||
))}
|
||||
{shopping.map((it) => (
|
||||
<tr key={`p${it.product_id}`}>
|
||||
<td>{it.name}</td>
|
||||
<td className="num">{fmt(it.stock)} {unitShort(it.base_unit)}</td>
|
||||
<td className="num strong">{fmt(it.deficit)} {unitShort(it.base_unit)}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
</div>
|
||||
|
||||
133
web/src/pages/Groups.jsx
Normal file
133
web/src/pages/Groups.jsx
Normal file
@@ -0,0 +1,133 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { api } from "../api";
|
||||
import { useAuth } from "../auth";
|
||||
import Icon from "../components/Icon";
|
||||
import { fmt } from "../units";
|
||||
|
||||
export default function Groups() {
|
||||
const { isAdmin } = useAuth();
|
||||
const [groups, setGroups] = useState([]);
|
||||
const [name, setName] = useState("");
|
||||
const [minStock, setMinStock] = useState("");
|
||||
const [error, setError] = useState(null);
|
||||
|
||||
async function load() {
|
||||
try {
|
||||
setGroups(await api.listGroups());
|
||||
} catch (err) {
|
||||
setError(err.message);
|
||||
}
|
||||
}
|
||||
|
||||
useEffect(() => { load(); }, []);
|
||||
|
||||
async function add(e) {
|
||||
e.preventDefault();
|
||||
setError(null);
|
||||
try {
|
||||
await api.createGroup({ name, min_stock: minStock === "" ? null : Number(minStock) });
|
||||
setName("");
|
||||
setMinStock("");
|
||||
load();
|
||||
} catch (err) {
|
||||
setError(err.message);
|
||||
}
|
||||
}
|
||||
|
||||
async function saveMin(group, value) {
|
||||
try {
|
||||
await api.updateGroup(group.id, { min_stock: value === "" ? null : Number(value) });
|
||||
load();
|
||||
} catch (err) {
|
||||
setError(err.message);
|
||||
}
|
||||
}
|
||||
|
||||
async function remove(group) {
|
||||
if (!confirm(`Gruppe "${group.name}" löschen? Produkte bleiben erhalten, verlieren aber die Gruppenzuordnung.`)) return;
|
||||
try {
|
||||
await api.deleteGroup(group.id);
|
||||
load();
|
||||
} catch (err) {
|
||||
setError(err.message);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="page-head">
|
||||
<div>
|
||||
<h1>Gruppen</h1>
|
||||
<div className="sub">Fasse Produkte zusammen (z.B. Nudeln, Mehl) und setze einen Gruppen-Mindestbestand</div>
|
||||
</div>
|
||||
</div>
|
||||
{error && <div className="alert error"><Icon name="alert" size={16} />{error}</div>}
|
||||
|
||||
<div className="grid-2">
|
||||
<div className="card" style={{ padding: 0 }}>
|
||||
<div className="table-wrap">
|
||||
<table className="table">
|
||||
<thead>
|
||||
<tr><th>Gruppe</th><th className="num">Produkte</th><th className="num">Bestand</th><th>Mindestbestand</th><th></th></tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{groups.map((g) => {
|
||||
const low = g.min_stock != null && g.stock < g.min_stock;
|
||||
return (
|
||||
<tr key={g.id}>
|
||||
<td className="strong">
|
||||
{g.name}
|
||||
{low && <span className="badge warn" style={{ marginLeft: 6 }}>niedrig</span>}
|
||||
</td>
|
||||
<td className="num muted">{g.product_count}</td>
|
||||
<td className="num">{fmt(g.stock)}</td>
|
||||
<td>
|
||||
{isAdmin ? (
|
||||
<input type="number" step="any" defaultValue={g.min_stock ?? ""}
|
||||
style={{ maxWidth: 110, marginTop: 0 }}
|
||||
onBlur={(e) => {
|
||||
const v = e.target.value;
|
||||
if (v !== String(g.min_stock ?? "")) saveMin(g, v);
|
||||
}} />
|
||||
) : (g.min_stock != null ? fmt(g.min_stock) : "–")}
|
||||
</td>
|
||||
<td className="num">
|
||||
{isAdmin && (
|
||||
<button className="btn-icon danger" onClick={() => remove(g)} title="Löschen">
|
||||
<Icon name="trash" size={16} />
|
||||
</button>
|
||||
)}
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
{groups.length === 0 && <tr><td colSpan={5} className="empty">Noch keine Gruppen.</td></tr>}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{isAdmin && (
|
||||
<form className="card" onSubmit={add}>
|
||||
<div className="card-head"><Icon name="tag" /><h2>Neue Gruppe</h2></div>
|
||||
<label>
|
||||
Name
|
||||
<input placeholder="z.B. Nudeln" value={name} onChange={(e) => setName(e.target.value)} required />
|
||||
</label>
|
||||
<label>
|
||||
Mindestbestand (optional)
|
||||
<input type="number" step="any" value={minStock} onChange={(e) => setMinStock(e.target.value)}
|
||||
placeholder="Gesamtmenge über alle Produkte der Gruppe" />
|
||||
</label>
|
||||
<button className="btn primary"><Icon name="plus" size={16} />Anlegen</button>
|
||||
<p className="muted small">
|
||||
Produkte ordnest du einer Gruppe im jeweiligen Produkt-Formular zu.
|
||||
Der Gruppen-Bestand ist die Summe der Produktbestände – sinnvoll, wenn
|
||||
die Produkte dieselbe Basiseinheit haben.
|
||||
</p>
|
||||
</form>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
62
web/src/pages/History.jsx
Normal file
62
web/src/pages/History.jsx
Normal file
@@ -0,0 +1,62 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { api } from "../api";
|
||||
import Icon from "../components/Icon";
|
||||
import { fmt, unitShort } from "../units";
|
||||
|
||||
const TYPE_LABEL = { in: "Eingelagert", out: "Ausgelagert", adjust: "Korrektur" };
|
||||
|
||||
export default function History() {
|
||||
const [movements, setMovements] = useState([]);
|
||||
const [error, setError] = useState(null);
|
||||
|
||||
useEffect(() => {
|
||||
api.listMovements(150).then(setMovements).catch((e) => setError(e.message));
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="page-head">
|
||||
<div>
|
||||
<h1>Verlauf</h1>
|
||||
<div className="sub">Wer hat wann was ein- und ausgelagert</div>
|
||||
</div>
|
||||
</div>
|
||||
{error && <div className="alert error"><Icon name="alert" size={16} />{error}</div>}
|
||||
|
||||
<div className="card" style={{ padding: 0 }}>
|
||||
<div className="table-wrap">
|
||||
<table className="table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Zeitpunkt</th>
|
||||
<th>Aktion</th>
|
||||
<th>Produkt</th>
|
||||
<th className="num">Menge</th>
|
||||
<th>Benutzer</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{movements.map((m) => (
|
||||
<tr key={m.id}>
|
||||
<td className="muted">{new Date(m.created_at).toLocaleString("de-DE")}</td>
|
||||
<td>
|
||||
<span className={`badge ${m.type === "in" ? "ok" : m.type === "out" ? "warn" : ""}`}>
|
||||
<Icon name={m.type === "in" ? "checkin" : m.type === "out" ? "checkout" : "edit"} size={13} />
|
||||
{TYPE_LABEL[m.type] || m.type}
|
||||
</span>
|
||||
</td>
|
||||
<td>{m.product_name}</td>
|
||||
<td className="num">{fmt(m.quantity)} {unitShort(m.base_unit)}</td>
|
||||
<td className="muted">{m.username || "–"}</td>
|
||||
</tr>
|
||||
))}
|
||||
{movements.length === 0 && (
|
||||
<tr><td colSpan={5} className="empty">Noch keine Bewegungen.</td></tr>
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,9 +1,11 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { api } from "../api";
|
||||
import Icon from "../components/Icon";
|
||||
|
||||
export default function Locations() {
|
||||
const [locations, setLocations] = useState([]);
|
||||
const [name, setName] = useState("");
|
||||
const [parentId, setParentId] = useState("");
|
||||
const [error, setError] = useState(null);
|
||||
|
||||
async function load() {
|
||||
@@ -20,8 +22,9 @@ export default function Locations() {
|
||||
e.preventDefault();
|
||||
setError(null);
|
||||
try {
|
||||
await api.createLocation({ name });
|
||||
await api.createLocation({ name, parent_id: parentId === "" ? null : Number(parentId) });
|
||||
setName("");
|
||||
setParentId("");
|
||||
load();
|
||||
} catch (err) {
|
||||
setError(err.message);
|
||||
@@ -29,7 +32,7 @@ export default function Locations() {
|
||||
}
|
||||
|
||||
async function remove(id) {
|
||||
if (!confirm("Lagerort löschen?")) return;
|
||||
if (!confirm("Lagerort löschen? Untergeordnete Orte werden dann übergeordnet.")) return;
|
||||
try {
|
||||
await api.deleteLocation(id);
|
||||
load();
|
||||
@@ -38,29 +41,66 @@ export default function Locations() {
|
||||
}
|
||||
}
|
||||
|
||||
const nameById = Object.fromEntries(locations.map((l) => [l.id, l.name]));
|
||||
const roots = locations.filter((l) => !l.parent_id || !nameById[l.parent_id]);
|
||||
const childrenOf = (pid) => locations.filter((l) => l.parent_id === pid);
|
||||
const ordered = [];
|
||||
for (const r of roots) {
|
||||
ordered.push({ ...r, depth: 0 });
|
||||
for (const c of childrenOf(r.id)) ordered.push({ ...c, depth: 1 });
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="page-head"><h1>Lagerorte</h1></div>
|
||||
{error && <div className="alert error">{error}</div>}
|
||||
<div className="page-head">
|
||||
<div>
|
||||
<h1>Lagerorte</h1>
|
||||
<div className="sub">Orte lassen sich verschachteln (z.B. Keller → Regal 2 → Fach A)</div>
|
||||
</div>
|
||||
</div>
|
||||
{error && <div className="alert error"><Icon name="alert" size={16} />{error}</div>}
|
||||
|
||||
<div className="card form-narrow">
|
||||
<form className="row" onSubmit={add}>
|
||||
<input className="grow" placeholder="Neuer Lagerort (z.B. Speisekammer)" value={name}
|
||||
onChange={(e) => setName(e.target.value)} required />
|
||||
<button className="btn primary">Hinzufügen</button>
|
||||
<form onSubmit={add}>
|
||||
<div className="row">
|
||||
<label className="grow">
|
||||
Name
|
||||
<input placeholder="z.B. Speisekammer oder Regal 2" value={name}
|
||||
onChange={(e) => setName(e.target.value)} required />
|
||||
</label>
|
||||
<label className="grow">
|
||||
Übergeordneter Lagerort (optional)
|
||||
<select value={parentId} onChange={(e) => setParentId(e.target.value)}>
|
||||
<option value="">– keiner (oberste Ebene) –</option>
|
||||
{locations.map((l) => (
|
||||
<option key={l.id} value={l.id}>
|
||||
{l.parent_id && nameById[l.parent_id] ? `${nameById[l.parent_id]} → ` : ""}{l.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
</div>
|
||||
<button className="btn primary"><Icon name="plus" size={16} />Hinzufügen</button>
|
||||
</form>
|
||||
|
||||
<ul className="simple-list">
|
||||
{locations.map((l) => (
|
||||
{ordered.map((l) => (
|
||||
<li key={l.id}>
|
||||
<span>{l.name}</span>
|
||||
<button className="btn ghost danger" onClick={() => remove(l.id)}>Löschen</button>
|
||||
<span className={l.depth ? "tree-child" : ""} style={{ display: "flex", alignItems: "center", gap: 8 }}>
|
||||
<Icon name="location" size={15} className="muted" />
|
||||
{l.name}
|
||||
{l.depth > 0 && l.parent_id && nameById[l.parent_id] && (
|
||||
<span className="badge">in {nameById[l.parent_id]}</span>
|
||||
)}
|
||||
</span>
|
||||
<button className="btn-icon danger" onClick={() => remove(l.id)} title="Löschen">
|
||||
<Icon name="trash" size={16} />
|
||||
</button>
|
||||
</li>
|
||||
))}
|
||||
{locations.length === 0 && <li className="muted">Noch keine Lagerorte.</li>}
|
||||
{locations.length === 0 && <li className="empty">Noch keine Lagerorte.</li>}
|
||||
</ul>
|
||||
</div>
|
||||
<p className="muted small">
|
||||
Unterlagerorte (Regal / Fach) folgen in einem späteren Ausbauschritt.
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { useState } from "react";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import { useAuth } from "../auth";
|
||||
import Icon from "../components/Icon";
|
||||
|
||||
export default function Login() {
|
||||
const { login } = useAuth();
|
||||
@@ -27,9 +28,14 @@ export default function Login() {
|
||||
return (
|
||||
<div className="login-wrap">
|
||||
<form className="card login-card" onSubmit={onSubmit}>
|
||||
<div className="brand big">🥫 Project-Good</div>
|
||||
<p className="muted">Lebensmittel-Lagerverwaltung</p>
|
||||
{error && <div className="alert error">{error}</div>}
|
||||
<div className="sidebar-brand">
|
||||
<span className="mark"><Icon name="box" size={18} /></span>
|
||||
Project-Good
|
||||
</div>
|
||||
<p className="lead">Lebensmittel-Lagerverwaltung</p>
|
||||
{error && (
|
||||
<div className="alert error"><Icon name="alert" size={16} />{error}</div>
|
||||
)}
|
||||
<label>
|
||||
Benutzername
|
||||
<input value={username} onChange={(e) => setUsername(e.target.value)} autoFocus />
|
||||
@@ -38,7 +44,7 @@ export default function Login() {
|
||||
Passwort
|
||||
<input type="password" value={password} onChange={(e) => setPassword(e.target.value)} />
|
||||
</label>
|
||||
<button className="btn primary" disabled={busy}>
|
||||
<button className="btn primary" disabled={busy} style={{ width: "100%", justifyContent: "center" }}>
|
||||
{busy ? "Anmelden…" : "Anmelden"}
|
||||
</button>
|
||||
</form>
|
||||
|
||||
@@ -2,19 +2,28 @@ import { useEffect, useState } from "react";
|
||||
import { useNavigate, useParams } from "react-router-dom";
|
||||
import { api } from "../api";
|
||||
import { useAuth } from "../auth";
|
||||
import Icon from "../components/Icon";
|
||||
import { BASE_UNITS, fmt, unitShort } from "../units";
|
||||
|
||||
const EMPTY = {
|
||||
barcode: "",
|
||||
name: "",
|
||||
brand: "",
|
||||
image_url: "",
|
||||
base_unit: "piece",
|
||||
package_size: "",
|
||||
min_stock: "",
|
||||
group_id: "",
|
||||
barcode: "", name: "", brand: "", image_url: "",
|
||||
base_unit: "piece", package_size: "", min_stock: "", group_id: "",
|
||||
};
|
||||
|
||||
// Versucht, aus einem OFF-Kategorietext eine vorhandene Gruppe zu erraten.
|
||||
function guessGroup(groups, suggestion) {
|
||||
if (!suggestion) return "";
|
||||
const haystack = [
|
||||
suggestion.category_suggestion || "",
|
||||
...(suggestion.category_tags || []),
|
||||
suggestion.name || "",
|
||||
]
|
||||
.join(" ")
|
||||
.toLowerCase();
|
||||
const hit = groups.find((g) => haystack.includes(g.name.toLowerCase()));
|
||||
return hit ? String(hit.id) : "";
|
||||
}
|
||||
|
||||
export default function ProductForm() {
|
||||
const { id } = useParams();
|
||||
const isNew = !id;
|
||||
@@ -37,13 +46,9 @@ export default function ProductForm() {
|
||||
const p = await api.getProduct(id);
|
||||
setProduct(p);
|
||||
setForm({
|
||||
barcode: p.barcode || "",
|
||||
name: p.name,
|
||||
brand: p.brand || "",
|
||||
image_url: p.image_url || "",
|
||||
base_unit: p.base_unit,
|
||||
package_size: p.package_size ?? "",
|
||||
min_stock: p.min_stock ?? "",
|
||||
barcode: p.barcode || "", name: p.name, brand: p.brand || "",
|
||||
image_url: p.image_url || "", base_unit: p.base_unit,
|
||||
package_size: p.package_size ?? "", min_stock: p.min_stock ?? "",
|
||||
group_id: p.group_id ?? "",
|
||||
});
|
||||
setLots(await api.listLots(id));
|
||||
@@ -71,14 +76,19 @@ export default function ProductForm() {
|
||||
navigate(`/products/${res.existing_product.id}`);
|
||||
} else if (res.found && res.suggestion) {
|
||||
const s = res.suggestion;
|
||||
const groupGuess = guessGroup(groups, s);
|
||||
setForm((f) => ({
|
||||
...f,
|
||||
name: s.name || f.name,
|
||||
brand: s.brand || f.brand,
|
||||
image_url: s.image_url || f.image_url,
|
||||
base_unit: s.base_unit || f.base_unit,
|
||||
group_id: f.group_id || groupGuess,
|
||||
}));
|
||||
setInfo("Vorschlag von Open Food Facts übernommen.");
|
||||
setInfo(
|
||||
"Daten von Open Food Facts übernommen." +
|
||||
(groupGuess ? " Passende Gruppe vorgeschlagen." : "")
|
||||
);
|
||||
} else {
|
||||
setInfo("Barcode unbekannt – bitte Daten selbst eingeben.");
|
||||
}
|
||||
@@ -135,21 +145,26 @@ export default function ProductForm() {
|
||||
return (
|
||||
<div>
|
||||
<div className="page-head">
|
||||
<h1>{isNew ? "Neues Produkt" : form.name || "Produkt"}</h1>
|
||||
<div>
|
||||
<h1>{isNew ? "Neues Produkt" : form.name || "Produkt"}</h1>
|
||||
{!isNew && <div className="sub">Bestand: {fmt(product?.stock)} {unitShort(form.base_unit)}</div>}
|
||||
</div>
|
||||
<button className="btn ghost" onClick={() => navigate("/products")}>Zurück</button>
|
||||
</div>
|
||||
{error && <div className="alert error">{error}</div>}
|
||||
{info && <div className="alert info">{info}</div>}
|
||||
|
||||
{error && <div className="alert error"><Icon name="alert" size={16} />{error}</div>}
|
||||
{info && <div className="alert info"><Icon name="check" size={16} />{info}</div>}
|
||||
|
||||
<div className="grid-2">
|
||||
<form className="card" onSubmit={save}>
|
||||
<div className="row">
|
||||
<div className="field-inline">
|
||||
<label className="grow">
|
||||
Barcode
|
||||
<input value={form.barcode} onChange={(e) => set("barcode", e.target.value)} disabled={readOnly} />
|
||||
</label>
|
||||
{isAdmin && (
|
||||
<button type="button" className="btn" onClick={lookup} style={{ alignSelf: "flex-end" }}>
|
||||
Nachschlagen
|
||||
<button type="button" className="btn" onClick={lookup}>
|
||||
<Icon name="search" size={16} />Nachschlagen
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
@@ -165,21 +180,18 @@ export default function ProductForm() {
|
||||
<label className="grow">
|
||||
Basiseinheit
|
||||
<select value={form.base_unit} onChange={(e) => set("base_unit", e.target.value)} disabled={readOnly}>
|
||||
{BASE_UNITS.map((u) => (
|
||||
<option key={u.value} value={u.value}>{u.label}</option>
|
||||
))}
|
||||
{BASE_UNITS.map((u) => <option key={u.value} value={u.value}>{u.label}</option>)}
|
||||
</select>
|
||||
</label>
|
||||
<label className="grow">
|
||||
Packungsgröße (in Basiseinheit)
|
||||
Packungsgröße
|
||||
<input type="number" step="any" value={form.package_size}
|
||||
onChange={(e) => set("package_size", e.target.value)} disabled={readOnly}
|
||||
placeholder="z.B. 500" />
|
||||
onChange={(e) => set("package_size", e.target.value)} disabled={readOnly} placeholder="z.B. 500" />
|
||||
</label>
|
||||
</div>
|
||||
<div className="row">
|
||||
<label className="grow">
|
||||
Mindestbestand (in Basiseinheit)
|
||||
Mindestbestand
|
||||
<input type="number" step="any" value={form.min_stock}
|
||||
onChange={(e) => set("min_stock", e.target.value)} disabled={readOnly} />
|
||||
</label>
|
||||
@@ -187,39 +199,43 @@ export default function ProductForm() {
|
||||
Gruppe
|
||||
<select value={form.group_id} onChange={(e) => set("group_id", e.target.value)} disabled={readOnly}>
|
||||
<option value="">– keine –</option>
|
||||
{groups.map((g) => (
|
||||
<option key={g.id} value={g.id}>{g.name}</option>
|
||||
))}
|
||||
{groups.map((g) => <option key={g.id} value={g.id}>{g.name}</option>)}
|
||||
</select>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
{isAdmin && (
|
||||
<div className="row">
|
||||
<div className="field-inline" style={{ marginTop: "var(--sp-2)" }}>
|
||||
<button className="btn primary" disabled={busy}>{busy ? "Speichern…" : "Speichern"}</button>
|
||||
{!isNew && <button type="button" className="btn danger" onClick={remove}>Löschen</button>}
|
||||
{!isNew && (
|
||||
<button type="button" className="btn danger" onClick={remove}>
|
||||
<Icon name="trash" size={16} />Löschen
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
{readOnly && <p className="muted">Nur Administratoren können Produkte bearbeiten.</p>}
|
||||
{readOnly && <p className="muted small mt-0">Nur Administratoren können Produkte bearbeiten.</p>}
|
||||
</form>
|
||||
|
||||
{!isNew && (
|
||||
<section className="card">
|
||||
<h2>Chargen (Bestand: {fmt(product?.stock)} {unitShort(form.base_unit)})</h2>
|
||||
<div className="card-head"><Icon name="box" /><h2>Chargen im Bestand</h2></div>
|
||||
{form.image_url && <img className="product-img" src={form.image_url} alt="" />}
|
||||
<table className="table">
|
||||
<thead><tr><th>Menge</th><th>MHD</th><th>Eingelagert</th></tr></thead>
|
||||
<tbody>
|
||||
{lots.map((l) => (
|
||||
<tr key={l.id}>
|
||||
<td>{fmt(l.quantity)} {unitShort(form.base_unit)}</td>
|
||||
<td>{l.best_before || "–"}</td>
|
||||
<td className="muted">{new Date(l.created_at).toLocaleDateString("de-DE")}</td>
|
||||
</tr>
|
||||
))}
|
||||
{lots.length === 0 && <tr><td colSpan={3} className="muted">Keine Chargen im Bestand.</td></tr>}
|
||||
</tbody>
|
||||
</table>
|
||||
<div className="table-wrap">
|
||||
<table className="table">
|
||||
<thead><tr><th className="num">Menge</th><th>MHD</th><th>Eingelagert</th></tr></thead>
|
||||
<tbody>
|
||||
{lots.map((l) => (
|
||||
<tr key={l.id}>
|
||||
<td className="num">{fmt(l.quantity)} {unitShort(form.base_unit)}</td>
|
||||
<td>{l.best_before || "–"}</td>
|
||||
<td className="muted">{new Date(l.created_at).toLocaleDateString("de-DE")}</td>
|
||||
</tr>
|
||||
))}
|
||||
{lots.length === 0 && <tr><td colSpan={3} className="empty">Keine Chargen im Bestand.</td></tr>}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</section>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -2,6 +2,7 @@ import { useEffect, useState } from "react";
|
||||
import { Link } from "react-router-dom";
|
||||
import { api } from "../api";
|
||||
import { useAuth } from "../auth";
|
||||
import Icon from "../components/Icon";
|
||||
import { fmt, unitShort } from "../units";
|
||||
|
||||
export default function Products() {
|
||||
@@ -31,50 +32,66 @@ export default function Products() {
|
||||
return (
|
||||
<div>
|
||||
<div className="page-head">
|
||||
<h1>Produkte</h1>
|
||||
{isAdmin && <Link className="btn primary" to="/products/new">+ Neues Produkt</Link>}
|
||||
<div>
|
||||
<h1>Produkte</h1>
|
||||
<div className="sub">{products.length} Produkte im Katalog</div>
|
||||
</div>
|
||||
{isAdmin && (
|
||||
<Link className="btn primary" to="/products/new"><Icon name="plus" size={16} />Neues Produkt</Link>
|
||||
)}
|
||||
</div>
|
||||
{error && <div className="alert error">{error}</div>}
|
||||
|
||||
<form className="search" onSubmit={onSearch}>
|
||||
<input placeholder="Suchen…" value={q} onChange={(e) => setQ(e.target.value)} />
|
||||
<button className="btn">Suchen</button>
|
||||
{error && <div className="alert error"><Icon name="alert" size={16} />{error}</div>}
|
||||
|
||||
<form className="field-inline" style={{ marginBottom: "var(--sp-4)", maxWidth: 420 }} onSubmit={onSearch}>
|
||||
<label className="grow" style={{ margin: 0 }}>
|
||||
<input placeholder="Produkt suchen…" value={q} onChange={(e) => setQ(e.target.value)} />
|
||||
</label>
|
||||
<button className="btn"><Icon name="search" size={16} />Suchen</button>
|
||||
</form>
|
||||
|
||||
<div className="card">
|
||||
<table className="table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th></th>
|
||||
<th>Name</th>
|
||||
<th>Marke</th>
|
||||
<th>Basiseinheit</th>
|
||||
<th>Bestand</th>
|
||||
<th>Mindest</th>
|
||||
<th></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{products.map((p) => (
|
||||
<tr key={p.id}>
|
||||
<td className="thumb-cell">
|
||||
{p.image_url ? <img className="thumb" src={p.image_url} alt="" /> : "📦"}
|
||||
</td>
|
||||
<td>{p.name}</td>
|
||||
<td className="muted">{p.brand || "–"}</td>
|
||||
<td>{unitShort(p.base_unit)}{p.package_size ? ` · Pkg ${p.package_size}` : ""}</td>
|
||||
<td className={p.min_stock != null && p.stock < p.min_stock ? "strong row-warn-text" : ""}>
|
||||
{fmt(p.stock)} {unitShort(p.base_unit)}
|
||||
</td>
|
||||
<td className="muted">{p.min_stock != null ? fmt(p.min_stock) : "–"}</td>
|
||||
<td><Link to={`/products/${p.id}`}>Details</Link></td>
|
||||
<div className="card" style={{ padding: 0 }}>
|
||||
<div className="table-wrap">
|
||||
<table className="table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th></th>
|
||||
<th>Name</th>
|
||||
<th>Marke</th>
|
||||
<th>Einheit</th>
|
||||
<th className="num">Bestand</th>
|
||||
<th className="num">Mindest</th>
|
||||
<th></th>
|
||||
</tr>
|
||||
))}
|
||||
{products.length === 0 && (
|
||||
<tr><td colSpan={7} className="muted center">Keine Produkte.</td></tr>
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
</thead>
|
||||
<tbody>
|
||||
{products.map((p) => {
|
||||
const low = p.min_stock != null && p.stock < p.min_stock;
|
||||
return (
|
||||
<tr key={p.id}>
|
||||
<td className="thumb-cell">
|
||||
{p.image_url
|
||||
? <img className="thumb" src={p.image_url} alt="" />
|
||||
: <span className="thumb thumb-fallback"><Icon name="box" size={16} /></span>}
|
||||
</td>
|
||||
<td className="strong">{p.name}</td>
|
||||
<td className="muted">{p.brand || "–"}</td>
|
||||
<td>{unitShort(p.base_unit)}{p.package_size ? ` · Pkg ${fmt(p.package_size)}` : ""}</td>
|
||||
<td className="num">
|
||||
{fmt(p.stock)} {unitShort(p.base_unit)}
|
||||
{low && <span className="badge warn" style={{ marginLeft: 6 }}>niedrig</span>}
|
||||
</td>
|
||||
<td className="num muted">{p.min_stock != null ? fmt(p.min_stock) : "–"}</td>
|
||||
<td className="num"><Link to={`/products/${p.id}`}>Details</Link></td>
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
{products.length === 0 && (
|
||||
<tr><td colSpan={7} className="empty">Keine Produkte gefunden.</td></tr>
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
64
web/src/pages/Settings.jsx
Normal file
64
web/src/pages/Settings.jsx
Normal file
@@ -0,0 +1,64 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { api } from "../api";
|
||||
import Icon from "../components/Icon";
|
||||
|
||||
const EXPIRY_KEY = "expiry_warning_days";
|
||||
|
||||
export default function Settings() {
|
||||
const [days, setDays] = useState("");
|
||||
const [error, setError] = useState(null);
|
||||
const [info, setInfo] = useState(null);
|
||||
const [busy, setBusy] = useState(false);
|
||||
|
||||
async function load() {
|
||||
try {
|
||||
const settings = await api.listSettings();
|
||||
const row = settings.find((s) => s.key === EXPIRY_KEY);
|
||||
setDays(row ? row.value : "7");
|
||||
} catch (err) {
|
||||
setError(err.message);
|
||||
}
|
||||
}
|
||||
|
||||
useEffect(() => { load(); }, []);
|
||||
|
||||
async function save(e) {
|
||||
e.preventDefault();
|
||||
setError(null); setInfo(null); setBusy(true);
|
||||
try {
|
||||
await api.setSetting(EXPIRY_KEY, String(parseInt(days, 10)));
|
||||
setInfo("Gespeichert.");
|
||||
} catch (err) {
|
||||
setError(err.message);
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="page-head">
|
||||
<div>
|
||||
<h1>Einstellungen</h1>
|
||||
<div className="sub">Allgemeine Konfiguration der Anwendung</div>
|
||||
</div>
|
||||
</div>
|
||||
{error && <div className="alert error"><Icon name="alert" size={16} />{error}</div>}
|
||||
{info && <div className="alert ok"><Icon name="check" size={16} />{info}</div>}
|
||||
|
||||
<form className="card form-narrow" onSubmit={save}>
|
||||
<div className="card-head"><Icon name="clock" /><h2>Ablaufwarnung</h2></div>
|
||||
<label>
|
||||
Vorwarnzeit (Tage)
|
||||
<input type="number" min="0" step="1" value={days}
|
||||
onChange={(e) => setDays(e.target.value)} style={{ maxWidth: 160 }} />
|
||||
</label>
|
||||
<p className="muted small mt-0">
|
||||
Chargen, deren Mindesthaltbarkeitsdatum innerhalb dieser Frist liegt (oder
|
||||
bereits überschritten ist), erscheinen auf der Übersicht unter „Bald ablaufend".
|
||||
</p>
|
||||
<button className="btn primary" disabled={busy}>{busy ? "Speichern…" : "Speichern"}</button>
|
||||
</form>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,46 +1,76 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { api } from "../api";
|
||||
import Icon from "../components/Icon";
|
||||
import { fmt, unitShort } from "../units";
|
||||
|
||||
export default function ShoppingList() {
|
||||
const [items, setItems] = useState([]);
|
||||
const [groups, setGroups] = useState([]);
|
||||
const [checked, setChecked] = useState({});
|
||||
const [error, setError] = useState(null);
|
||||
|
||||
useEffect(() => {
|
||||
api.shoppingList().then(setItems).catch((e) => setError(e.message));
|
||||
Promise.all([api.shoppingList(), api.groupShoppingList()])
|
||||
.then(([p, g]) => { setItems(p); setGroups(g); })
|
||||
.catch((e) => setError(e.message));
|
||||
}, []);
|
||||
|
||||
const empty = items.length === 0 && groups.length === 0;
|
||||
|
||||
function toggle(key, val) {
|
||||
setChecked((c) => ({ ...c, [key]: val }));
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="page-head"><h1>🛒 Einkaufsliste</h1></div>
|
||||
{error && <div className="alert error">{error}</div>}
|
||||
<p className="muted">
|
||||
Produkte, deren Bestand unter dem hinterlegten Mindestbestand liegt.
|
||||
</p>
|
||||
<div className="page-head">
|
||||
<div>
|
||||
<h1>Einkaufsliste</h1>
|
||||
<div className="sub">Produkte und Gruppen unter ihrem Mindestbestand</div>
|
||||
</div>
|
||||
</div>
|
||||
{error && <div className="alert error"><Icon name="alert" size={16} />{error}</div>}
|
||||
|
||||
<div className="card">
|
||||
{items.length === 0 ? (
|
||||
<p className="muted">Alle Mindestbestände erreicht. 👍</p>
|
||||
{empty ? (
|
||||
<div className="empty">Alle Mindestbestände erreicht.</div>
|
||||
) : (
|
||||
<ul className="checklist">
|
||||
{items.map((it) => (
|
||||
<li key={it.product_id} className={checked[it.product_id] ? "done" : ""}>
|
||||
<label>
|
||||
<input type="checkbox" checked={!!checked[it.product_id]}
|
||||
onChange={(e) => setChecked({ ...checked, [it.product_id]: e.target.checked })} />
|
||||
<span className="item-name">{it.name}</span>
|
||||
</label>
|
||||
<span className="item-qty">
|
||||
fehlt <strong>{fmt(it.deficit)} {unitShort(it.base_unit)}</strong>
|
||||
<span className="muted"> (Bestand {fmt(it.stock)} / min {fmt(it.min_stock)})</span>
|
||||
</span>
|
||||
</li>
|
||||
))}
|
||||
{groups.map((it) => {
|
||||
const key = `g${it.group_id}`;
|
||||
return (
|
||||
<li key={key} className={checked[key] ? "done" : ""}>
|
||||
<label>
|
||||
<input type="checkbox" checked={!!checked[key]} onChange={(e) => toggle(key, e.target.checked)} />
|
||||
<span className="badge accent">Gruppe</span>
|
||||
<span className="item-name">{it.name}</span>
|
||||
</label>
|
||||
<span className="muted small">
|
||||
fehlt <strong>{fmt(it.deficit)}</strong> (Bestand {fmt(it.stock)} / min {fmt(it.min_stock)})
|
||||
</span>
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
{items.map((it) => {
|
||||
const key = `p${it.product_id}`;
|
||||
return (
|
||||
<li key={key} className={checked[key] ? "done" : ""}>
|
||||
<label>
|
||||
<input type="checkbox" checked={!!checked[key]} onChange={(e) => toggle(key, e.target.checked)} />
|
||||
<span className="item-name">{it.name}</span>
|
||||
</label>
|
||||
<span className="muted small">
|
||||
fehlt <strong>{fmt(it.deficit)} {unitShort(it.base_unit)}</strong>{" "}
|
||||
(Bestand {fmt(it.stock)} / min {fmt(it.min_stock)})
|
||||
</span>
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
)}
|
||||
</div>
|
||||
<p className="muted small">
|
||||
Das Abhaken dient hier nur der Übersicht beim Einkaufen und wird (noch) nicht gespeichert.
|
||||
Das Abhaken hilft beim Einkaufen und wird (noch) nicht dauerhaft gespeichert.
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { api } from "../api";
|
||||
import { useAuth } from "../auth";
|
||||
import Icon from "../components/Icon";
|
||||
|
||||
export default function Users() {
|
||||
const { user: me } = useAuth();
|
||||
@@ -53,38 +54,49 @@ export default function Users() {
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="page-head"><h1>Benutzer</h1></div>
|
||||
{error && <div className="alert error">{error}</div>}
|
||||
{info && <div className="alert info">{info}</div>}
|
||||
<div className="page-head">
|
||||
<div>
|
||||
<h1>Benutzer</h1>
|
||||
<div className="sub">Administratoren verwalten alles, Benutzer lagern nur ein und aus</div>
|
||||
</div>
|
||||
</div>
|
||||
{error && <div className="alert error"><Icon name="alert" size={16} />{error}</div>}
|
||||
{info && <div className="alert ok"><Icon name="check" size={16} />{info}</div>}
|
||||
|
||||
<div className="grid-2">
|
||||
<div className="card">
|
||||
<h2>Benutzer</h2>
|
||||
<table className="table">
|
||||
<thead><tr><th>Name</th><th>Rolle</th><th></th></tr></thead>
|
||||
<tbody>
|
||||
{users.map((u) => (
|
||||
<tr key={u.id}>
|
||||
<td>{u.username}{u.id === me.id && <span className="muted"> (du)</span>}</td>
|
||||
<td>
|
||||
<select value={u.role} onChange={(e) => changeRole(u, e.target.value)} disabled={u.id === me.id}>
|
||||
<option value="user">Nutzer</option>
|
||||
<option value="admin">Admin</option>
|
||||
</select>
|
||||
</td>
|
||||
<td>
|
||||
{u.id !== me.id && (
|
||||
<button className="btn ghost danger" onClick={() => remove(u)}>Löschen</button>
|
||||
)}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
<div className="card" style={{ padding: 0 }}>
|
||||
<div className="table-wrap">
|
||||
<table className="table">
|
||||
<thead><tr><th>Name</th><th>Rolle</th><th></th></tr></thead>
|
||||
<tbody>
|
||||
{users.map((u) => (
|
||||
<tr key={u.id}>
|
||||
<td className="strong">
|
||||
{u.username}{u.id === me.id && <span className="muted"> (du)</span>}
|
||||
</td>
|
||||
<td>
|
||||
<select value={u.role} onChange={(e) => changeRole(u, e.target.value)}
|
||||
disabled={u.id === me.id} style={{ maxWidth: 150 }}>
|
||||
<option value="user">Benutzer</option>
|
||||
<option value="admin">Administrator</option>
|
||||
</select>
|
||||
</td>
|
||||
<td className="num">
|
||||
{u.id !== me.id && (
|
||||
<button className="btn-icon danger" onClick={() => remove(u)} title="Löschen">
|
||||
<Icon name="trash" size={16} />
|
||||
</button>
|
||||
)}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<form className="card form-narrow" onSubmit={add}>
|
||||
<h2>Neuer Benutzer</h2>
|
||||
<form className="card" onSubmit={add}>
|
||||
<div className="card-head"><Icon name="users" /><h2>Neuer Benutzer</h2></div>
|
||||
<label>
|
||||
Benutzername
|
||||
<input value={form.username} onChange={(e) => setForm({ ...form, username: e.target.value })} required />
|
||||
@@ -97,11 +109,11 @@ export default function Users() {
|
||||
<label>
|
||||
Rolle
|
||||
<select value={form.role} onChange={(e) => setForm({ ...form, role: e.target.value })}>
|
||||
<option value="user">Nutzer (nur ein-/auslagern)</option>
|
||||
<option value="admin">Admin (volle Verwaltung)</option>
|
||||
<option value="user">Benutzer (nur ein-/auslagern)</option>
|
||||
<option value="admin">Administrator (volle Verwaltung)</option>
|
||||
</select>
|
||||
</label>
|
||||
<button className="btn primary">Anlegen</button>
|
||||
<button className="btn primary"><Icon name="plus" size={16} />Anlegen</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
Reference in New Issue
Block a user