Files
Vorrania/web/src/pages/Products.jsx
Scarriffle 01c93d8b2c Web: Mindestbestaende in festen Spalten, Detail-Spalten verschiebbar
Die Eingabefelder auf der Mindestbestaende-Seite standen je Zeile woanders. Die
Zeile war ein .field-inline, und dort waechst sowohl der Name als auch das
Eingabefeld (.field-inline > input { flex: 1 1 auto }) - beide teilten sich den
Restplatz, also verschob ein laengerer Name das Feld nach rechts und machte es
schmaler. Jetzt ist die Zeile ein Raster mit festen Spalten (Name, Menge,
Einheit, Bestand, Ort, Loeschen); ohne Adminrechte bleiben die letzten beiden
Zellen leer, damit die Zeilen trotzdem fluchten. Schmale Fenster stellen den
Namen ueber die volle Breite.

Die "Details"-Spalte liess sich nicht verschieben: sie war fixed und hatte
zusaetzlich einen leeren Kopf - es gab also nichts zu greifen. Sie ist jetzt
frei und traegt einen sichtbaren Titel. Dasselbe fuer die gleichartigen
Link-Spalten in der Einzelstueck- und der Chargenliste ("Artikel",
"Aufteilen"). Sortieren bleibt dort aus, weil toggleSort ohne sortValue
ohnehin aussteigt. Bild- und Auswahlspalten bleiben fest - die haben keinen
sinnvollen Titel und gehoeren an den Anfang.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-16 10:36:48 +02:00

183 lines
8.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, 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 { gruppenInfoMap } from "../groupGraph";
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([]);
// Gruppen nur fuer die Pfad-Anzeige der Gruppen-Spalte.
const [groups, setGroups] = 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(() => {});
api.listGroups().then(setGroups).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]);
const grpInfo = useMemo(() => gruppenInfoMap(groups), [groups]);
// 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 || ""} /> },
// Gruppe: zählt Bestände mehrerer Marken zusammen. Mit ihrem Weg von oben
// dargestellt, wie die Kategorie ein Etikett verschwiege, dass „Grillwurst"
// unter „Wurst" hängt. Gefiltert wird über ALLE Wege, damit „Wurst" auch die
// Artikel aus den Untergruppen zeigt.
{ key: "gruppe", header: "Gruppe", width: 200,
filterText: (p) => grpInfo.get(p.group_id)?.path || p.group_name || "",
filterValues: (p) => grpInfo.get(p.group_id)?.tokens
|| (p.group_name ? [p.group_name] : [""]),
filterOptionLabel: (v) => (v === "" ? "(Ohne Gruppe)" : <CategoryPathLabel parts={pathParts(v)} />),
sortValue: (p) => grpInfo.get(p.group_id)?.path || p.group_name || "",
render: (p) => (p.group_id != null
? <span title={(grpInfo.get(p.group_id)?.alle || []).join(" · ")}>
<CategoryPathLabel parts={grpInfo.get(p.group_id)?.parts} fallback={p.group_name || ""} />
</span>
: <span className="muted"></span>) },
// 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> },
// Kein fixed: die Spalte soll sich verschieben lassen. Dafuer braucht sie
// einen sichtbaren Titel an einem leeren Kopf gibt es nichts zu greifen.
{ key: "details", header: "Details", label: "Details", width: 90, 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>
);
}