Files
Vorrania/web/src/pages/Products.jsx
Scarriffle dc66a6d7ff Web: alle Kategorie-Auswahlen als aufklappbarer Baum
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>
2026-07-26 13:07:36 +02:00

167 lines
6.7 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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>
);
}