Neue Seite "Chargen": alle Chargen ueber alle Artikel (Artikel, Menge, MHD, Lagerort, eingelagert). Lagerort je Zeile direkt aenderbar; alle Chargen ohne Ort per Klick zuweisbar (bulk-location) - gut, wenn der Lagerort erst spaeter angelegt wurde. DataTable bekommt einen loading-Prop: waehrend des Ladens steht "Wird geladen..." statt der Leer-Meldung. Auf ALLEN Tabellen angewendet (Produkte, Mindestbestaende, Kategorien, Gruppen, Einheiten, Gebinde, Einzelstuecke, Chargen). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
161 lines
7.3 KiB
JavaScript
161 lines
7.3 KiB
JavaScript
import { useEffect, useMemo, useState } from "react";
|
||
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 CategoryPathLabel, { pathParts } from "../components/CategoryPathLabel";
|
||
import { categoryInfoMap } from "../categoryPath";
|
||
import { fmt, gebinde, unitShort } from "../units";
|
||
|
||
// 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));
|
||
}
|
||
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;
|
||
}
|
||
// „Zuletzt geändert" als Datum + Uhrzeit (zum Sortieren/Filtern).
|
||
function fmtDateTime(iso) {
|
||
if (!iso) return "–";
|
||
return new Date(iso).toLocaleString("de-DE", {
|
||
day: "2-digit", month: "2-digit", year: "numeric", hour: "2-digit", minute: "2-digit",
|
||
});
|
||
}
|
||
// Verwaltungsart eines Gegenstands (nur für die Gegenstände-Liste sinnvoll).
|
||
function verwaltungLabel(p) {
|
||
if (p.individual) return "Einzelstücke";
|
||
if (p.bulk) return "Verbrauchsgegenstand";
|
||
return "Menge je Lagerort";
|
||
}
|
||
|
||
export default function Products({ fixedType = null }) {
|
||
const { isAdmin } = useAuth();
|
||
const [products, setProducts] = useState([]);
|
||
const [categories, setCategories] = useState([]);
|
||
// "" = alle, sonst "food"/"object": trennt Lebensmittel und Gegenstände.
|
||
const [typ, setTyp] = useState("");
|
||
const [error, setError] = useState(null);
|
||
const [loading, setLoading] = useState(true);
|
||
|
||
useEffect(() => {
|
||
// Alle Produkte einmal laden; das Filtern erledigt die Tabelle clientseitig.
|
||
setLoading(true);
|
||
api.listProducts("", "")
|
||
.then(setProducts)
|
||
.catch((err) => setError(err.message))
|
||
.finally(() => setLoading(false));
|
||
api.listCategories().then(setCategories).catch(() => {});
|
||
}, []);
|
||
|
||
// Kategorie als Pfad + Tokens (jede Ebene), damit "Klamotten" auch die
|
||
// Unterkategorien findet und die Oberkategorie im Filter wählbar ist.
|
||
const catInfo = useMemo(() => categoryInfoMap(categories), [categories]);
|
||
// Zwei getrennte Seiten (Lebensmittel/Gegenstände) fixieren den Typ; sonst der
|
||
// Umschalter oben.
|
||
const activeTyp = fixedType || typ;
|
||
const sichtbar = products.filter((p) => !activeTyp || p.tracking === activeTyp);
|
||
const titel = fixedType === "food" ? "Lebensmittel"
|
||
: fixedType === "object" ? "Gegenstände" : "Produkte";
|
||
|
||
const columns = [
|
||
{ key: "thumb", header: "", label: "Bild", 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: 210,
|
||
filterText: (p) => catInfo.get(p.category_id)?.path || p.category_name || "",
|
||
filterValues: (p) => catInfo.get(p.category_id)?.tokens || (p.category_name ? [p.category_name] : [""]),
|
||
filterOptionLabel: (v) => (v === "" ? "(Leere)" : <CategoryPathLabel parts={pathParts(v)} />),
|
||
sortValue: (p) => catInfo.get(p.category_id)?.path || p.category_name || "",
|
||
render: (p) => <CategoryPathLabel parts={catInfo.get(p.category_id)?.parts} fallback={p.category_name || "–"} /> },
|
||
// Nur in der Gegenstände-Liste: Menge je Lagerort / Einzelstücke / Verbrauchsgegenstand.
|
||
activeTyp === "object" && { key: "verwaltung", header: "Verwaltung", label: "Verwaltung", width: 190,
|
||
filterText: (p) => verwaltungLabel(p),
|
||
filterValues: (p) => [verwaltungLabel(p)],
|
||
sortValue: (p) => verwaltungLabel(p),
|
||
render: (p) => <span className="muted">{verwaltungLabel(p)}</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: "updated", header: "Zuletzt geändert", label: "Zuletzt geändert", width: 170,
|
||
sortValue: (p) => (p.updated_at ? new Date(p.updated_at).getTime() : 0),
|
||
filterText: (p) => fmtDateTime(p.updated_at),
|
||
render: (p) => <span className="muted nowrap">{fmtDateTime(p.updated_at)}</span> },
|
||
{ key: "details", header: "", label: "Details", fixed: true, width: 84, align: "num",
|
||
render: (p) => <Link to={`/products/${p.id}`}>Details</Link> },
|
||
].filter(Boolean);
|
||
|
||
return (
|
||
<div>
|
||
<div className="page-head">
|
||
<div>
|
||
<h1>{titel}</h1>
|
||
<div className="sub">
|
||
{loading
|
||
? "Wird geladen…"
|
||
: `${sichtbar.length} ${fixedType ? titel : `Produkte${activeTyp ? (activeTyp === "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>}
|
||
|
||
{!fixedType && (
|
||
<div className="segmented" style={{ marginBottom: "var(--sp-3)" }}>
|
||
<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>
|
||
)}
|
||
|
||
<div className="card">
|
||
<DataTable id={fixedType ? `products-${fixedType}` : "products"} columns={columns} rows={sichtbar}
|
||
getRowKey={(p) => p.id} loading={loading} empty="Keine Produkte gefunden." />
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|