Neu web/src/urlState.js mit useUrlWert/useUrlZahl/useUrlWerte. Zwei
Regeln stecken darin, beide damit die Adresszeile brauchbar bleibt:
geschrieben wird immer mit replace - sonst legt jeder Tastendruck in
einem Suchfeld einen Verlaufseintrag an und die Zurueck-Taste braeuchte
zwanzig Versuche, um die Seite zu verlassen -, und Vorgabewerte fliegen
aus der URL, sonst sammelt sich "?typ=&q=&kat=" an.
useUrlWerte gibt es, weil zwei getrennte Schreibvorgaenge im selben
Durchlauf einander ueberschreiben: Beide bekommen denselben
Ausgangsstand. Wo ein Klick zwei Dinge aendert ("Kategorie 12, Karte
Masse"), muss es ein Aufruf sein.
Damit wandern in die URL:
- Rezepte: Das Rezept steht jetzt im PFAD (/rezepte/<slug>), weil es
benennt, was man ansieht - so wie /products/:id und /i/:uid. Portionen
und Suchbegriff als Parameter. "Pfannkuchen fuer 12" ist damit ein
verschickbarer Link.
- Kategorien: Segment-Umschalter und die geoeffnete Seitenkarte
(?typ=food&kat=6&panel=masse). Gespeichert wird die Kennung, nicht das
Objekt - die Kategorien kommen erst vom Server.
- Gruppen: die geoeffnete Seitenkarte.
- Artikel: der Segment-Umschalter.
- Ein- und Auslagern: der gewaehlte Artikel als Kennung, nachgeladen beim
Aufruf. Eine halbfertige Buchung ueberlebt damit ein Neuladen.
Die Portionszahl landet NUR in der URL, wenn jemand sie selbst gestellt
hat. Die Vorbelegung aus dem Rezept bleibt draussen - sonst traegt jedes
geoeffnete Rezept ein ?portionen mit sich herum, das gar keine
Entscheidung ist.
Dazu im Backend: Ein unbekanntes Rezept ist jetzt 404 statt 502 und sagt
"Dieses Rezept gibt es in Mealie nicht (mehr)" statt "Mealie kennt
/api/recipes/x nicht". Das wurde noetig, weil ein Rezept seit diesem
Commit verlinkbar ist und ein alter Link irgendwann ins Leere zeigt -
eine gewoehnliche Auskunft, kein Gateway-Fehler.
Von Hand durchgeklickt: Rezept oeffnen und Portionen auf 12 ->
/rezepte/pfannkuchen?portionen=12, Neuladen bringt 250 g -> 750 g zurueck;
unbekannter Slug zeigt den klaren Hinweis und laesst die Liste stehen;
Kategorie-Karte und Segment ueberstehen F5, Schliessen raeumt die URL;
/checkin?artikel=1 laedt den Artikel nach, ?artikel=999 raeumt die
Adresse und zeigt die Suchmaske. 1 neuer Test, Suite bei 518.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
186 lines
8.9 KiB
React
186 lines
8.9 KiB
React
import { useEffect, useMemo, useState } from "react";
|
||
import { Link } from "react-router-dom";
|
||
import { api } from "../api";
|
||
import { useUrlWert } from "../urlState";
|
||
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.
|
||
// In der URL, damit ein Neuladen die Auswahl behält und sich ein Stand
|
||
// verschicken lässt.
|
||
const [typ, setTyp] = useUrlWert("typ");
|
||
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} version={p.image_version} 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>
|
||
);
|
||
}
|