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:
@@ -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>
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user