Statt der nativen "— —"-Dropdowns nutzen jetzt auch Produktformular (Kategorie + Inline-"Neue Kategorie"-Elternwahl), Einlagern-Maske und Produkte-Filter den CategorySelect-Baum. CategorySelect um optionale Zusatz-Eintraege (extraOptions) erweitert, damit der Produkte-Filter "Alle Kategorien"/"Ohne Kategorie" weiter anbietet. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
167 lines
6.7 KiB
JavaScript
167 lines
6.7 KiB
JavaScript
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 { ProduktThumb } from "../components/ProduktBild";
|
||
import { fmt, gebinde, unitShort } from "../units";
|
||
import { asTree } from "../categoryTree";
|
||
import CategorySelect from "../components/CategorySelect";
|
||
|
||
// Wie viele Basiseinheiten eine "Artikeleinheit" umfasst (Gebinde oder Produkteinheit).
|
||
function unitCount(p) {
|
||
return p.package_size && p.package_size > 0 ? p.package_size : (p.unit_factor || 1);
|
||
}
|
||
// Mengenabhaengig, damit "3 Gläser" dasteht und nicht "3 Glas".
|
||
function countLabel(p, menge) {
|
||
return p.package_size && p.package_size > 0
|
||
? gebinde(menge, p.package_label)
|
||
: (p.unit_name || unitShort(p.base_unit));
|
||
}
|
||
|
||
export default function Products() {
|
||
const { isAdmin } = useAuth();
|
||
const [products, setProducts] = useState([]);
|
||
const [q, setQ] = useState("");
|
||
const [categories, setCategories] = useState([]);
|
||
// "" = alle, "0" = ohne Kategorie, sonst die ID (Unterkategorien zaehlen mit).
|
||
const [categoryId, setCategoryId] = useState("");
|
||
// "" = alle, sonst "food"/"object": trennt Lebensmittel und Gegenstände.
|
||
const [typ, setTyp] = useState("");
|
||
const [error, setError] = useState(null);
|
||
|
||
async function load(cat = categoryId) {
|
||
try {
|
||
setProducts(await api.listProducts(q, cat));
|
||
} catch (err) {
|
||
setError(err.message);
|
||
}
|
||
}
|
||
|
||
useEffect(() => {
|
||
load();
|
||
api.listCategories().then(setCategories).catch(() => {});
|
||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||
}, []);
|
||
|
||
function onSearch(e) {
|
||
e.preventDefault();
|
||
load();
|
||
}
|
||
|
||
// Typ umschalten: passt der Kategoriefilter nicht mehr, zurücksetzen.
|
||
function changeTyp(neu) {
|
||
setTyp(neu);
|
||
if (neu && categoryId && categoryId !== "0") {
|
||
const cat = categories.find((c) => String(c.id) === String(categoryId));
|
||
if (cat && cat.tracking !== neu) { setCategoryId(""); load(""); }
|
||
}
|
||
}
|
||
|
||
const sichtbar = products.filter((p) => !typ || p.tracking === typ);
|
||
|
||
return (
|
||
<div>
|
||
<div className="page-head">
|
||
<div>
|
||
<h1>Produkte</h1>
|
||
<div className="sub">{sichtbar.length} Produkte{typ ? (typ === "food" ? " · Lebensmittel" : " · Gegenstände") : " 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"><Icon name="alert" size={16} />{error}</div>}
|
||
|
||
<div className="segmented" style={{ marginBottom: "var(--sp-3)" }}>
|
||
<button type="button" className={typ === "" ? "active" : ""} onClick={() => changeTyp("")}>Alle</button>
|
||
<button type="button" className={typ === "food" ? "active" : ""} onClick={() => changeTyp("food")}>Lebensmittel</button>
|
||
<button type="button" className={typ === "object" ? "active" : ""} onClick={() => changeTyp("object")}>Gegenstände</button>
|
||
</div>
|
||
|
||
<form className="field-inline" style={{ marginBottom: "var(--sp-4)", maxWidth: 640 }} onSubmit={onSearch}>
|
||
<label className="grow" style={{ margin: 0 }}>
|
||
<input placeholder="Produkt suchen…" value={q} onChange={(e) => setQ(e.target.value)} />
|
||
</label>
|
||
<label style={{ margin: 0, width: 230 }}>
|
||
<CategorySelect
|
||
value={categoryId}
|
||
nodes={asTree(categories).filter((c) => !typ || c.tracking === typ)}
|
||
rootLabel="Alle Kategorien"
|
||
extraOptions={[{ value: 0, label: "Ohne Kategorie" }]}
|
||
onChange={(id) => { const v = id == null ? "" : String(id); setCategoryId(v); load(v); }}
|
||
/>
|
||
</label>
|
||
<button className="btn"><Icon name="search" size={16} />Suchen</button>
|
||
</form>
|
||
|
||
<div className="card" style={{ padding: 0 }}>
|
||
<div className="table-wrap">
|
||
<table className="table">
|
||
<thead>
|
||
<tr>
|
||
<th></th>
|
||
<th>Name</th>
|
||
<th>Marke</th>
|
||
<th>Kategorie</th>
|
||
<th>Einheit</th>
|
||
<th className="num">Bestand</th>
|
||
<th className="num">Einheiten (ist / soll)</th>
|
||
<th></th>
|
||
</tr>
|
||
</thead>
|
||
<tbody>
|
||
{sichtbar.map((p) => {
|
||
const low = p.min_stock != null && p.stock < p.min_stock;
|
||
return (
|
||
<tr key={p.id}>
|
||
<td className="thumb-cell">
|
||
<ProduktThumb productId={p.id} alt={p.name} />
|
||
</td>
|
||
<td data-label="Name" className="strong">
|
||
<span className="cell-row">
|
||
<span>{p.name}</span>
|
||
{p.expired_count > 0 && (
|
||
<span className="badge danger nowrap">
|
||
<Icon name="alert" size={12} />{p.expired_count} abgelaufen
|
||
</span>
|
||
)}
|
||
</span>
|
||
</td>
|
||
<td data-label="Marke" className="muted">{p.brand || "–"}</td>
|
||
<td data-label="Kategorie" className="muted">{p.category_name || "–"}</td>
|
||
<td data-label="Einheit">
|
||
{p.unit_name || unitShort(p.base_unit)}
|
||
{p.package_size
|
||
? ` · ${p.package_label || "Packung"} ${fmt(p.package_size)} ${unitShort(p.base_unit)}`
|
||
: ""}
|
||
</td>
|
||
<td data-label="Bestand" className="num">
|
||
{fmt(p.stock / (p.unit_factor || 1))} {p.unit_name || unitShort(p.base_unit)}
|
||
</td>
|
||
<td data-label="Einheiten" className="num">
|
||
<span className="cell-row">
|
||
<span>
|
||
{fmt(p.stock / unitCount(p))}
|
||
{p.min_stock != null ? ` / ${fmt(p.min_stock / unitCount(p))}` : ""}{" "}
|
||
{countLabel(p, p.stock / unitCount(p))}
|
||
</span>
|
||
{low && <span className="badge warn">niedrig</span>}
|
||
</span>
|
||
</td>
|
||
<td className="num"><Link to={`/products/${p.id}`}>Details</Link></td>
|
||
</tr>
|
||
);
|
||
})}
|
||
{sichtbar.length === 0 && (
|
||
<tr><td colSpan={8} className="empty">Keine Produkte gefunden.</td></tr>
|
||
)}
|
||
</tbody>
|
||
</table>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|