Files
Vorrania/web/src/pages/Products.jsx
Scarriffle 826f4df4bd Web: Chargenliste (mit Sammel-Lagerort) + Ladezustand auf allen Tabellen
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>
2026-07-29 13:58:07 +02:00

161 lines
7.3 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, 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>
);
}