Web: Kategorie-Filter - Oberkategorie subtil + als eigener Eintrag waehlbar
Die DataTable kann jetzt je Spalte mehrere Filter-Werte pro Zeile (filterValues/Tokens) und eine eigene Option-Darstellung (filterOptionLabel). Die Kategorie-Spalte liefert als Tokens jede Ebene kumuliert (Klamotten, Klamotten -> kurze Hosen); so erscheint die Oberkategorie als eigener, anklickbarer Eintrag und selektiert alle Unterkategorien. Ober-Ebenen werden in Zelle und Dropdown kleiner/gedaempft dargestellt (CategoryPathLabel), das Blatt normal. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -1,23 +1,33 @@
|
||||
// Kategorien sind verschachtelbar (Klamotten → kurze Hosen). Produkte hängen an
|
||||
// der Blatt-Kategorie; um „alle Klamotten" filtern zu können, arbeiten die
|
||||
// Listen mit dem vollen Pfad statt nur dem Blattnamen.
|
||||
// der Blatt-Kategorie; um „alle Klamotten" filtern zu können, arbeiten die Listen
|
||||
// mit dem Pfad. `tokens` enthält jede Ebene als eigenen Wert (Ober- und eigene
|
||||
// Kategorie), damit im Filter-Dropdown auch die Oberkategorie wählbar ist.
|
||||
|
||||
/** Map Kategorie-ID → voller Pfad "Klamotten → kurze Hosen". */
|
||||
export function categoryPathMap(categories) {
|
||||
/**
|
||||
* Map Kategorie-ID → { path, parts, tokens }.
|
||||
* path = "Klamotten → kurze Hosen"
|
||||
* parts = ["Klamotten", "kurze Hosen"]
|
||||
* tokens = ["Klamotten", "Klamotten → kurze Hosen"] (jede Ebene kumuliert)
|
||||
*/
|
||||
export function categoryInfoMap(categories) {
|
||||
const byId = new Map((categories || []).map((c) => [c.id, c]));
|
||||
const cache = new Map();
|
||||
const pfad = (id, seen) => {
|
||||
if (cache.has(id)) return cache.get(id);
|
||||
const partsCache = new Map();
|
||||
const parts = (id, seen) => {
|
||||
if (partsCache.has(id)) return partsCache.get(id);
|
||||
const c = byId.get(id);
|
||||
if (!c) return "";
|
||||
if (seen.has(id)) return c.name; // Ringschutz
|
||||
if (!c) return [];
|
||||
if (seen.has(id)) return [c.name]; // Ringschutz
|
||||
seen.add(id);
|
||||
const oben = c.parent_id != null ? pfad(c.parent_id, seen) : "";
|
||||
const p = oben ? `${oben} → ${c.name}` : c.name;
|
||||
cache.set(id, p);
|
||||
return p;
|
||||
const oben = c.parent_id != null ? parts(c.parent_id, seen) : [];
|
||||
const arr = [...oben, c.name];
|
||||
partsCache.set(id, arr);
|
||||
return arr;
|
||||
};
|
||||
const out = new Map();
|
||||
for (const c of categories || []) out.set(c.id, pfad(c.id, new Set()));
|
||||
for (const c of categories || []) {
|
||||
const arr = parts(c.id, new Set());
|
||||
const tokens = arr.map((_, i) => arr.slice(0, i + 1).join(" → "));
|
||||
out.set(c.id, { path: arr.join(" → "), parts: arr, tokens });
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
23
web/src/components/CategoryPathLabel.jsx
Normal file
23
web/src/components/CategoryPathLabel.jsx
Normal file
@@ -0,0 +1,23 @@
|
||||
/**
|
||||
* Kategorie-Pfad mit subtil dargestellten Oberkategorien und normalem Blatt,
|
||||
* z.B. „Klamotten →" klein/gedämpft und „kurze Hosen" normal. Wird in der
|
||||
* Tabellenzelle und im Filter-Dropdown verwendet.
|
||||
*/
|
||||
export default function CategoryPathLabel({ parts, fallback = "–" }) {
|
||||
if (!parts || parts.length === 0) return <span className="muted">{fallback}</span>;
|
||||
const ancestors = parts.slice(0, -1);
|
||||
const leaf = parts[parts.length - 1];
|
||||
return (
|
||||
<span className="cat-path">
|
||||
{ancestors.length > 0 && (
|
||||
<span className="cat-ancestors">{ancestors.join(" → ")} → </span>
|
||||
)}
|
||||
<span className="cat-leaf">{leaf}</span>
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
/** Aus einem Token-/Pfad-String die Teile gewinnen (Trenner " → "). */
|
||||
export function pathParts(value) {
|
||||
return String(value ?? "").split(" → ");
|
||||
}
|
||||
@@ -71,15 +71,20 @@ export default function DataTable({
|
||||
: fixedWidth(c));
|
||||
const totalWidth = Math.round(orderedCols.reduce((s, c) => s + widthOf(c), 0));
|
||||
|
||||
// Mögliche Filterwerte je Zeile: `filterValues` (mehrere Tokens, z.B. jede
|
||||
// Kategorie-Ebene) hat Vorrang, sonst der einzelne `filterText`.
|
||||
const rowValues = (c, r) => (c.filterValues ? c.filterValues(r) : [String(c.filterText(r) ?? "")]);
|
||||
|
||||
const distinctByKey = useMemo(() => {
|
||||
const m = {};
|
||||
for (const c of columns) {
|
||||
if (!c.filterText) continue;
|
||||
if (!c.filterText && !c.filterValues) continue;
|
||||
const set = new Set();
|
||||
for (const r of rows) set.add(String(c.filterText(r) ?? ""));
|
||||
for (const r of rows) for (const v of rowValues(c, r)) set.add(String(v));
|
||||
m[c.key] = [...set].sort((a, b) => a.localeCompare(b, "de"));
|
||||
}
|
||||
return m;
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [columns, rows]);
|
||||
|
||||
const anyFilter = orderedCols.some((c) => {
|
||||
@@ -91,10 +96,10 @@ export default function DataTable({
|
||||
const flat = useMemo(() => {
|
||||
let out = rows.filter((r) => orderedCols.every((c) => {
|
||||
const f = filters[c.key];
|
||||
if (!f || !c.filterText) return true;
|
||||
const val = String(c.filterText(r) ?? "");
|
||||
if (f.values && !f.values.has(val)) return false;
|
||||
if (f.text && !val.toLowerCase().includes(f.text.toLowerCase())) return false;
|
||||
if (!f || (!c.filterText && !c.filterValues)) return true;
|
||||
if (f.values && !rowValues(c, r).some((v) => f.values.has(String(v)))) return false;
|
||||
if (f.text && c.filterText
|
||||
&& !String(c.filterText(r) ?? "").toLowerCase().includes(f.text.toLowerCase())) return false;
|
||||
return true;
|
||||
}));
|
||||
if (sort) {
|
||||
@@ -259,11 +264,13 @@ export default function DataTable({
|
||||
<tr className="dt-filter">
|
||||
{orderedCols.map((c) => (
|
||||
<td key={c.key}>
|
||||
{c.filterText ? (
|
||||
{(c.filterText || c.filterValues) ? (
|
||||
<FilterCell
|
||||
col={c}
|
||||
filter={filters[c.key]}
|
||||
distinct={distinctByKey[c.key] || []}
|
||||
optionLabel={c.filterOptionLabel}
|
||||
hasText={Boolean(c.filterText)}
|
||||
open={openFilter === c.key}
|
||||
onToggleOpen={setOpenFilter}
|
||||
onChange={(f) => setFilters((old) => ({ ...old, [c.key]: f }))}
|
||||
@@ -307,7 +314,7 @@ export default function DataTable({
|
||||
}
|
||||
|
||||
/** Freitext-Feld + Dropdown der vorhandenen Werte (eine Spalte). */
|
||||
function FilterCell({ col, filter, distinct, onChange, open, onToggleOpen }) {
|
||||
function FilterCell({ col, filter, distinct, onChange, open, onToggleOpen, optionLabel, hasText = true }) {
|
||||
const btnRef = useRef(null);
|
||||
const popRef = useRef(null);
|
||||
const [pos, setPos] = useState(null);
|
||||
@@ -345,8 +352,10 @@ function FilterCell({ col, filter, distinct, onChange, open, onToggleOpen }) {
|
||||
|
||||
return (
|
||||
<div className="dt-filter-cell">
|
||||
{hasText && (
|
||||
<input className="dt-filter-input" placeholder="Filter…" value={text}
|
||||
onChange={(e) => onChange({ text: e.target.value, values })} />
|
||||
)}
|
||||
<button type="button" ref={btnRef} className={`dt-filter-btn ${active ? "active" : ""}`}
|
||||
title="Werte auswählen" onClick={() => onToggleOpen(open ? null : col.key)}>▾</button>
|
||||
{open && pos && createPortal(
|
||||
@@ -361,7 +370,7 @@ function FilterCell({ col, filter, distinct, onChange, open, onToggleOpen }) {
|
||||
{shown.map((v) => (
|
||||
<label key={v} className="dt-pop-opt">
|
||||
<input type="checkbox" checked={isChecked(v)} onChange={() => toggleValue(v)} />
|
||||
<span>{v === "" ? "(Leere)" : v}</span>
|
||||
<span>{optionLabel ? optionLabel(v) : (v === "" ? "(Leere)" : v)}</span>
|
||||
</label>
|
||||
))}
|
||||
{shown.length === 0 && <div className="muted small">Keine Werte.</div>}
|
||||
|
||||
@@ -6,7 +6,8 @@ import Icon from "../components/Icon";
|
||||
import DataTable from "../components/DataTable";
|
||||
import { ProduktThumb } from "../components/ProduktBild";
|
||||
import { locationOptions, locationPathById } from "../locationPath";
|
||||
import { categoryPathMap } from "../categoryPath";
|
||||
import { categoryInfoMap } from "../categoryPath";
|
||||
import CategoryPathLabel, { pathParts } from "../components/CategoryPathLabel";
|
||||
|
||||
/**
|
||||
* Liste aller Einzelstücke (physische Exemplare, jedes mit eigener UID/Lagerort) –
|
||||
@@ -31,8 +32,9 @@ export default function ItemList() {
|
||||
}
|
||||
useEffect(() => { load(); }, []);
|
||||
|
||||
// Kategorie als voller Pfad, damit "Klamotten" auch die Unterkategorien findet.
|
||||
const catPfad = useMemo(() => categoryPathMap(categories), [categories]);
|
||||
// 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]);
|
||||
|
||||
async function patch(it, body) {
|
||||
try { await api.updateItem(it.id, body); await load(); }
|
||||
@@ -53,8 +55,11 @@ export default function ItemList() {
|
||||
{ key: "brand", header: "Marke", width: 140, filterText: (it) => it.product_brand || "",
|
||||
render: (it) => <span className="muted">{it.product_brand || "–"}</span> },
|
||||
{ key: "category", header: "Kategorie", width: 200,
|
||||
filterText: (it) => catPfad.get(it.category_id) || it.category_name || "",
|
||||
render: (it) => <span className="muted">{catPfad.get(it.category_id) || it.category_name || "–"}</span> },
|
||||
filterText: (it) => catInfo.get(it.category_id)?.path || it.category_name || "",
|
||||
filterValues: (it) => catInfo.get(it.category_id)?.tokens || (it.category_name ? [it.category_name] : [""]),
|
||||
filterOptionLabel: (v) => (v === "" ? "(Leere)" : <CategoryPathLabel parts={pathParts(v)} />),
|
||||
sortValue: (it) => catInfo.get(it.category_id)?.path || it.category_name || "",
|
||||
render: (it) => <CategoryPathLabel parts={catInfo.get(it.category_id)?.parts} fallback={it.category_name || "–"} /> },
|
||||
{ key: "location", header: "Lagerort", width: 220,
|
||||
filterText: (it) => locationPathById(it.location_id, locations),
|
||||
render: (it) => (isAdmin ? (
|
||||
|
||||
@@ -5,7 +5,8 @@ import { useAuth } from "../auth";
|
||||
import Icon from "../components/Icon";
|
||||
import DataTable from "../components/DataTable";
|
||||
import { ProduktThumb } from "../components/ProduktBild";
|
||||
import { categoryPathMap } from "../categoryPath";
|
||||
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).
|
||||
@@ -39,8 +40,9 @@ export default function Products() {
|
||||
api.listCategories().then(setCategories).catch(() => {});
|
||||
}, []);
|
||||
|
||||
// Kategorie als voller Pfad, damit "Klamotten" auch die Unterkategorien findet.
|
||||
const catPfad = useMemo(() => categoryPathMap(categories), [categories]);
|
||||
// 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 sichtbar = products.filter((p) => !typ || p.tracking === typ);
|
||||
|
||||
const columns = [
|
||||
@@ -61,8 +63,11 @@ export default function Products() {
|
||||
{ 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) => catPfad.get(p.category_id) || p.category_name || "",
|
||||
render: (p) => <span className="muted">{catPfad.get(p.category_id) || p.category_name || "–"}</span> },
|
||||
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 || "–"} /> },
|
||||
{ key: "unit", header: "Einheit", width: 200, filterText: einheitText,
|
||||
render: einheitText },
|
||||
{ key: "stock", header: "Bestand", width: 130, align: "num",
|
||||
|
||||
@@ -555,6 +555,10 @@ td select { width: auto; min-width: 0; max-width: 100%; }
|
||||
.dt-pop-opt input[type="checkbox"] { flex: 0 0 auto; width: auto; margin: 0; accent-color: var(--accent); }
|
||||
.dt-pop-opt span { flex: 1 1 auto; min-width: 0; text-align: left; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.dt-tree-cell { display: inline-flex; align-items: center; gap: 4px; max-width: 100%; }
|
||||
/* Kategorie-Pfad: Oberkategorien klein und gedämpft, Blatt normal. */
|
||||
.cat-ancestors { font-size: 0.82em; color: var(--muted); opacity: 0.7; }
|
||||
.cat-leaf { color: var(--text); }
|
||||
.table td.num .cat-path { justify-content: flex-end; }
|
||||
|
||||
/* ---------- EAN-Code-Zeile ---------- */
|
||||
.code-row { display: flex; align-items: center; gap: var(--sp-2); flex: 1; min-width: 0; flex-wrap: wrap; }
|
||||
|
||||
Reference in New Issue
Block a user