Web: Excel-artige DataTable + volle Breite; Produkte migriert

Neue wiederverwendbare DataTable: feste Filterzeile je Spalte (Freitext + Dropdown der vorhandenen Werte, ueber Spalten kombinierbar), Spalten per Drag verschieben und in der Breite ziehen, Klick-Sortierung; Reihenfolge/Breiten merkt der Browser (localStorage). Produkte-Seite darauf umgestellt. Tabellenseiten (Produkte/Kategorien/Gruppen/Einheiten/Gebinde) nutzen jetzt die volle Fensterbreite (content--wide); Kategorien-Seitenspalte etwas breiter und die Feldliste bricht um, statt ueber den Container zu laufen. Farbschema unveraendert.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Scarriffle
2026-07-27 14:53:05 +02:00
parent 4887596b43
commit 76acf57edf
4 changed files with 403 additions and 122 deletions

View File

@@ -3,10 +3,9 @@ import { Link } from "react-router-dom";
import { api } from "../api";
import { useAuth } from "../auth";
import Icon from "../components/Icon";
import DataTable from "../components/DataTable";
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) {
@@ -18,54 +17,78 @@ function countLabel(p, menge) {
? gebinde(menge, p.package_label)
: (p.unit_name || unitShort(p.base_unit));
}
function einheitText(p) {
const basis = p.unit_name || unitShort(p.base_unit);
return p.package_size
? `${basis} · ${p.package_label || "Packung"} ${fmt(p.package_size)} ${unitShort(p.base_unit)}`
: basis;
}
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
// Alle Produkte einmal laden; das Filtern erledigt die Tabelle clientseitig.
api.listProducts("", "").then(setProducts).catch((err) => setError(err.message));
}, []);
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);
const columns = [
{ key: "thumb", header: "", fixed: true, width: 56,
render: (p) => <ProduktThumb productId={p.id} alt={p.name} /> },
{ key: "name", header: "Name", grow: true, min: 200,
filterText: (p) => p.name, sortValue: (p) => p.name,
render: (p) => (
<span className="cell-row">
<span className="strong">{p.name}</span>
{p.expired_count > 0 && (
<span className="badge danger nowrap">
<Icon name="alert" size={12} />{p.expired_count} abgelaufen
</span>
)}
</span>
) },
{ key: "brand", header: "Marke", width: 150, filterText: (p) => p.brand || "",
render: (p) => <span className="muted">{p.brand || ""}</span> },
{ key: "category", header: "Kategorie", width: 180, filterText: (p) => p.category_name || "",
render: (p) => <span className="muted">{p.category_name || ""}</span> },
{ key: "unit", header: "Einheit", width: 200, filterText: einheitText,
render: einheitText },
{ key: "stock", header: "Bestand", width: 130, align: "num",
sortValue: (p) => p.stock / (p.unit_factor || 1),
render: (p) => `${fmt(p.stock / (p.unit_factor || 1))} ${p.unit_name || unitShort(p.base_unit)}` },
{ key: "units", header: "Einheiten (ist / soll)", width: 190, align: "num",
sortValue: (p) => p.stock / unitCount(p),
render: (p) => {
const low = p.min_stock != null && p.stock < p.min_stock;
return (
<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>
);
} },
{ key: "details", header: "", fixed: true, width: 84, align: "num",
render: (p) => <Link to={`/products/${p.id}`}>Details</Link> },
];
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 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>
@@ -75,91 +98,14 @@ export default function Products() {
{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>
<button type="button" className={typ === "" ? "active" : ""} onClick={() => setTyp("")}>Alle</button>
<button type="button" className={typ === "food" ? "active" : ""} onClick={() => setTyp("food")}>Lebensmittel</button>
<button type="button" className={typ === "object" ? "active" : ""} onClick={() => setTyp("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 className="card">
<DataTable id="products" columns={columns} rows={sichtbar}
getRowKey={(p) => p.id} empty="Keine Produkte gefunden." />
</div>
</div>
);