diff --git a/web/src/App.jsx b/web/src/App.jsx index 0c1046e..c3b8805 100644 --- a/web/src/App.jsx +++ b/web/src/App.jsx @@ -137,19 +137,19 @@ export default function App() { } /> } /> } /> - } /> + } /> } /> } /> } /> } /> - } /> - } /> + } /> + } /> } /> } /> } /> } /> - } /> - } /> + } /> + } /> } /> } /> } /> diff --git a/web/src/components/DataTable.jsx b/web/src/components/DataTable.jsx new file mode 100644 index 0000000..071d8f1 --- /dev/null +++ b/web/src/components/DataTable.jsx @@ -0,0 +1,293 @@ +import { useEffect, useLayoutEffect, useMemo, useRef, useState } from "react"; +import { createPortal } from "react-dom"; +import Icon from "./Icon"; + +/** + * Excel-artige Tabelle in der bestehenden Designsprache: feste Filterzeile je + * Spalte (Freitext + Dropdown der vorhandenen Werte, über Spalten kombinierbar), + * Spalten per Drag verschieben und in der Breite ziehen, Klick-Sortierung. + * Reihenfolge + Breiten merkt sich der Browser (localStorage, je `id`). + * + * Spalten-Def: { key, header, render(row)→JSX, filterText(row)? (aktiviert Filter), + * sortValue(row)? (Sortierung), align:"num", grow:true (füllt Restbreite), + * fixed:true (kein Reorder/Resize), width, min }. + */ +const DEFAULT_WIDTH = 150; + +function loadPersisted(id) { + try { return JSON.parse(localStorage.getItem(`datatable:${id}`)) || {}; } + catch { return {}; } +} +function savePersisted(id, data) { + try { localStorage.setItem(`datatable:${id}`, JSON.stringify(data)); } catch { /* ignore */ } +} + +export default function DataTable({ + id, columns, rows, getRowKey, rowClassName, + empty = "Nichts gefunden.", toolbarExtra = null, +}) { + const colByKey = useMemo(() => Object.fromEntries(columns.map((c) => [c.key, c])), [columns]); + + const [order, setOrder] = useState(() => { + const p = loadPersisted(id); + const saved = Array.isArray(p.order) ? p.order.filter((k) => colByKey[k]) : []; + const rest = columns.map((c) => c.key).filter((k) => !saved.includes(k)); + return [...saved, ...rest]; + }); + const [widths, setWidths] = useState(() => loadPersisted(id).widths || {}); + const [filters, setFilters] = useState({}); // { key: { text, values:Set|null } } + const [sort, setSort] = useState(null); // { key, dir } + const [filterShown, setFilterShown] = useState(true); + const [openFilter, setOpenFilter] = useState(null); + + useEffect(() => { savePersisted(id, { order, widths }); }, [id, order, widths]); + + // Containerbreite messen, damit die grow-Spalte den Rest füllt. + const wrapRef = useRef(null); + const [containerWidth, setContainerWidth] = useState(0); + useLayoutEffect(() => { + const el = wrapRef.current; + if (!el) return undefined; + const ro = new ResizeObserver(([e]) => setContainerWidth(e.contentRect.width)); + ro.observe(el); + setContainerWidth(el.clientWidth); + return () => ro.disconnect(); + }, []); + + const orderedCols = order.map((k) => colByKey[k]).filter(Boolean); + + const isGrow = (c) => c.grow && widths[c.key] == null; + const fixedWidth = (c) => widths[c.key] ?? c.width ?? DEFAULT_WIDTH; + const growCols = orderedCols.filter(isGrow); + const sumFixed = orderedCols.filter((c) => !isGrow(c)).reduce((s, c) => s + fixedWidth(c), 0); + const remaining = containerWidth - sumFixed - 2; + const widthOf = (c) => (isGrow(c) + ? Math.max(c.min ?? 160, growCols.length ? remaining / growCols.length : 160) + : fixedWidth(c)); + const totalWidth = Math.round(orderedCols.reduce((s, c) => s + widthOf(c), 0)); + + const distinctByKey = useMemo(() => { + const m = {}; + for (const c of columns) { + if (!c.filterText) continue; + const set = new Set(); + for (const r of rows) set.add(String(c.filterText(r) ?? "")); + m[c.key] = [...set].sort((a, b) => a.localeCompare(b, "de")); + } + return m; + }, [columns, rows]); + + const visible = 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; + return true; + })); + if (sort) { + const c = colByKey[sort.key]; + const val = (r) => (c?.sortValue ? c.sortValue(r) : (c?.filterText?.(r) ?? "")); + out = [...out].sort((a, b) => { + const va = val(a); const vb = val(b); + const cmp = (typeof va === "number" && typeof vb === "number") + ? va - vb : String(va).localeCompare(String(vb), "de"); + return sort.dir === "desc" ? -cmp : cmp; + }); + } + return out; + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [rows, order, filters, sort]); + + function toggleSort(c) { + if (!c.sortValue && !c.filterText) return; + setSort((s) => { + if (!s || s.key !== c.key) return { key: c.key, dir: "asc" }; + if (s.dir === "asc") return { key: c.key, dir: "desc" }; + return null; + }); + } + + // --- Resize (Pointer) --- + const resizing = useRef(null); + function startResize(e, c) { + e.preventDefault(); e.stopPropagation(); + resizing.current = { key: c.key, startX: e.clientX, startW: widthOf(c) }; + window.addEventListener("pointermove", onResizeMove); + window.addEventListener("pointerup", endResize); + } + function onResizeMove(e) { + const r = resizing.current; if (!r) return; + const w = Math.max(60, Math.round(r.startW + (e.clientX - r.startX))); + setWidths((old) => ({ ...old, [r.key]: w })); + } + function endResize() { + resizing.current = null; + window.removeEventListener("pointermove", onResizeMove); + window.removeEventListener("pointerup", endResize); + } + + // --- Reorder (HTML5 Drag) --- + const dragKey = useRef(null); + const [dropKey, setDropKey] = useState(null); + function onDragStart(e, c) { + if (resizing.current) { e.preventDefault(); return; } + dragKey.current = c.key; + e.dataTransfer.effectAllowed = "move"; + } + function onDragOver(e, c) { e.preventDefault(); if (c.key !== dropKey) setDropKey(c.key); } + function onDrop(e, c) { + e.preventDefault(); + const from = dragKey.current; const to = c.key; + dragKey.current = null; setDropKey(null); + if (!from || from === to) return; + setOrder((ord) => { + const next = ord.filter((k) => k !== from); + next.splice(next.indexOf(to), 0, from); + return next; + }); + } + + function resetColumns() { + setOrder(columns.map((c) => c.key)); + setWidths({}); + savePersisted(id, {}); + } + + return ( +
+
+ + + + {toolbarExtra} +
+
+ + + {orderedCols.map((c) => )} + + + + {orderedCols.map((c) => ( + + ))} + + {filterShown && ( + + {orderedCols.map((c) => ( + + ))} + + )} + + + {visible.map((r) => ( + + {orderedCols.map((c) => ( + + ))} + + ))} + {visible.length === 0 && ( + + )} + +
onDragStart(e, c)} + onDragOver={(e) => onDragOver(e, c)} + onDrop={(e) => onDrop(e, c)}> + toggleSort(c)}> + {c.header} + {sort?.key === c.key && {sort.dir === "asc" ? "▲" : "▼"}} + + {!c.fixed && startResize(e, c)} />} +
+ {c.filterText ? ( + setFilters((old) => ({ ...old, [c.key]: f }))} + /> + ) : null} +
{c.render(r)}
{empty}
+
+
+ ); +} + +/** Freitext-Feld + Dropdown der vorhandenen Werte (eine Spalte). */ +function FilterCell({ col, filter, distinct, onChange, open, onToggleOpen }) { + const btnRef = useRef(null); + const popRef = useRef(null); + const [pos, setPos] = useState(null); + const [search, setSearch] = useState(""); + const text = filter?.text || ""; + const values = filter?.values || null; // null = alle erlaubt + + useLayoutEffect(() => { + if (open && btnRef.current) { + const r = btnRef.current.getBoundingClientRect(); + setPos({ left: Math.min(r.left, window.innerWidth - 260), top: r.bottom + 4 }); + } + }, [open]); + + useEffect(() => { + if (!open) return undefined; + function onDoc(e) { + if (btnRef.current?.contains(e.target) || popRef.current?.contains(e.target)) return; + onToggleOpen(null); + } + document.addEventListener("mousedown", onDoc); + return () => document.removeEventListener("mousedown", onDoc); + }, [open, onToggleOpen]); + + const shown = distinct.filter((v) => v.toLowerCase().includes(search.toLowerCase())); + const isChecked = (v) => values == null || values.has(v); + const active = Boolean(text) || (values != null && values.size !== distinct.length); + + function toggleValue(v) { + const cur = values == null ? new Set(distinct) : new Set(values); + if (cur.has(v)) cur.delete(v); else cur.add(v); + onChange({ text, values: cur.size === distinct.length ? null : cur }); + } + function setAll(on) { onChange({ text, values: on ? null : new Set() }); } + + return ( +
+ onChange({ text: e.target.value, values })} /> + + {open && pos && createPortal( +
+ setSearch(e.target.value)} autoFocus /> +
+ + +
+
+ {shown.map((v) => ( + + ))} + {shown.length === 0 &&
Keine Werte.
} +
+
, + document.body, + )} +
+ ); +} diff --git a/web/src/pages/Products.jsx b/web/src/pages/Products.jsx index 9b1266a..dd9fb9c 100644 --- a/web/src/pages/Products.jsx +++ b/web/src/pages/Products.jsx @@ -3,10 +3,9 @@ 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 { fmt, gebinde, unitShort } from "../units"; -import { asTree } from "../categoryTree"; -import CategorySelect from "../components/CategorySelect"; // Wie viele Basiseinheiten eine "Artikeleinheit" umfasst (Gebinde oder Produkteinheit). function unitCount(p) { @@ -18,54 +17,78 @@ function countLabel(p, menge) { ? 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; +} export default function Products() { const { isAdmin } = useAuth(); const [products, setProducts] = useState([]); - const [q, setQ] = useState(""); - const [categories, setCategories] = useState([]); - // "" = alle, "0" = ohne Kategorie, sonst die ID (Unterkategorien zaehlen mit). - const [categoryId, setCategoryId] = useState(""); // "" = alle, sonst "food"/"object": trennt Lebensmittel und Gegenstände. const [typ, setTyp] = useState(""); const [error, setError] = useState(null); - async function load(cat = categoryId) { - try { - setProducts(await api.listProducts(q, cat)); - } catch (err) { - setError(err.message); - } - } - useEffect(() => { - load(); - api.listCategories().then(setCategories).catch(() => {}); - // eslint-disable-next-line react-hooks/exhaustive-deps + // Alle Produkte einmal laden; das Filtern erledigt die Tabelle clientseitig. + api.listProducts("", "").then(setProducts).catch((err) => setError(err.message)); }, []); - function onSearch(e) { - e.preventDefault(); - load(); - } - - // Typ umschalten: passt der Kategoriefilter nicht mehr, zurücksetzen. - function changeTyp(neu) { - setTyp(neu); - if (neu && categoryId && categoryId !== "0") { - const cat = categories.find((c) => String(c.id) === String(categoryId)); - if (cat && cat.tracking !== neu) { setCategoryId(""); load(""); } - } - } - const sichtbar = products.filter((p) => !typ || p.tracking === typ); + const columns = [ + { key: "thumb", header: "", fixed: true, width: 56, + render: (p) => }, + { key: "name", header: "Name", grow: true, min: 200, + filterText: (p) => p.name, sortValue: (p) => p.name, + render: (p) => ( + + {p.name} + {p.expired_count > 0 && ( + + {p.expired_count} abgelaufen + + )} + + ) }, + { key: "brand", header: "Marke", width: 150, filterText: (p) => p.brand || "", + render: (p) => {p.brand || "–"} }, + { key: "category", header: "Kategorie", width: 180, filterText: (p) => p.category_name || "", + render: (p) => {p.category_name || "–"} }, + { 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 ( + + + {fmt(p.stock / unitCount(p))} + {p.min_stock != null ? ` / ${fmt(p.min_stock / unitCount(p))}` : ""}{" "} + {countLabel(p, p.stock / unitCount(p))} + + {low && niedrig} + + ); + } }, + { key: "details", header: "", fixed: true, width: 84, align: "num", + render: (p) => Details }, + ]; + return (

Produkte

-
{sichtbar.length} Produkte{typ ? (typ === "food" ? " · Lebensmittel" : " · Gegenstände") : " im Katalog"}
+
+ {sichtbar.length} Produkte{typ ? (typ === "food" ? " · Lebensmittel" : " · Gegenstände") : " im Katalog"} +
{isAdmin && ( Neues Produkt @@ -75,91 +98,14 @@ export default function Products() { {error &&
{error}
}
- - - + + +
-
- - - -
- -
-
- - - - - - - - - - - - - - - {sichtbar.map((p) => { - const low = p.min_stock != null && p.stock < p.min_stock; - return ( - - - - - - - - - - - ); - })} - {sichtbar.length === 0 && ( - - )} - -
NameMarkeKategorieEinheitBestandEinheiten (ist / soll)
- - - - {p.name} - {p.expired_count > 0 && ( - - {p.expired_count} abgelaufen - - )} - - {p.brand || "–"}{p.category_name || "–"} - {p.unit_name || unitShort(p.base_unit)} - {p.package_size - ? ` · ${p.package_label || "Packung"} ${fmt(p.package_size)} ${unitShort(p.base_unit)}` - : ""} - - {fmt(p.stock / (p.unit_factor || 1))} {p.unit_name || unitShort(p.base_unit)} - - - - {fmt(p.stock / unitCount(p))} - {p.min_stock != null ? ` / ${fmt(p.min_stock / unitCount(p))}` : ""}{" "} - {countLabel(p, p.stock / unitCount(p))} - - {low && niedrig} - - Details
Keine Produkte gefunden.
-
+
+ p.id} empty="Keine Produkte gefunden." />
); diff --git a/web/src/styles.css b/web/src/styles.css index 9125c0f..4db91e2 100644 --- a/web/src/styles.css +++ b/web/src/styles.css @@ -170,7 +170,7 @@ h2 { font-size: 0.95rem; font-weight: 650; margin: 0 0 var(--sp-3); letter-spaci Kartenansicht, obwohl daneben Platz frei waere. */ /* Tabelle bekommt den ganzen restlichen Platz, der Block daneben bleibt schmal und gedeckelt - so scrollt die (breite) Tabelle nicht seitwaerts. */ -.grid-2.wide-aside { grid-template-columns: minmax(0, 1fr) clamp(300px, 26%, 360px); align-items: start; } +.grid-2.wide-aside { grid-template-columns: minmax(0, 1fr) clamp(320px, 24%, 420px); align-items: start; } @media (max-width: 1000px) { .grid-2.wide-aside { grid-template-columns: minmax(0, 1fr); } } @@ -511,8 +511,47 @@ td select { width: auto; min-width: 0; max-width: 100%; } .table.table-compact td { display: revert; } .table.table-compact thead { display: table-header-group; } .table.table-compact td::before { content: none; } + /* Die Excel-Tabelle bleibt auch schmal eine (quer scrollbare) Tabelle. */ + .table.datatable, .table.datatable tbody, .table.datatable tr, + .table.datatable td { display: revert; } + .table.datatable thead { display: table-header-group; } + .table.datatable td::before { content: none; } } +/* ---------- DataTable (Excel-artige Tabelle) ---------- */ +.dt-root { display: flex; flex-direction: column; gap: var(--sp-3); } +.dt-toolbar { display: flex; gap: var(--sp-2); align-items: center; flex-wrap: wrap; } +.datatable { table-layout: fixed; } +.datatable th, .datatable td { overflow: hidden; text-overflow: ellipsis; } +.dt-th { position: relative; user-select: none; } +.dt-th[draggable="true"] { cursor: grab; } +.dt-th.drop { box-shadow: inset 2px 0 0 var(--accent); } +.dt-th-label { display: inline-flex; align-items: center; gap: 4px; cursor: pointer; max-width: 100%; } +.dt-th-text { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } +.dt-th.sorted .dt-th-label { color: var(--accent); } +.dt-sort { font-size: 0.7em; flex: 0 0 auto; } +.dt-resize { position: absolute; top: 0; right: 0; width: 8px; height: 100%; cursor: col-resize; touch-action: none; } +.dt-resize:hover { background: var(--accent-soft); } +.dt-filter td { padding: 4px 6px; vertical-align: middle; } +.dt-filter-cell { display: flex; gap: 2px; align-items: center; } +.dt-filter-input { flex: 1 1 auto; min-width: 0; width: 100%; margin: 0; padding: 4px 6px; font-size: 0.8rem; } +.dt-filter-btn { + flex: 0 0 auto; width: 22px; height: 28px; line-height: 1; border-radius: var(--radius-sm); + border: 1px solid var(--border); background: var(--surface-2); color: var(--muted); cursor: pointer; +} +.dt-filter-btn.active { border-color: var(--accent); color: var(--accent); background: var(--accent-soft); } +.dt-pop { + position: fixed; z-index: 210; min-width: 200px; max-width: 260px; + background: var(--surface); border: 1px solid var(--border-strong); + border-radius: var(--radius); box-shadow: 0 10px 30px rgba(0, 0, 0, 0.18); padding: var(--sp-2); +} +.dt-pop-search { width: 100%; margin: 0 0 var(--sp-2); padding: 5px 8px; font-size: 0.85rem; } +.dt-pop-actions { display: flex; gap: var(--sp-3); margin-bottom: var(--sp-2); } +.dt-pop-list { max-height: 240px; overflow-y: auto; display: flex; flex-direction: column; } +.dt-pop-opt { display: flex; align-items: center; gap: 6px; padding: 3px 4px; font-size: 0.85rem; cursor: pointer; margin: 0; } +.dt-pop-opt:hover { background: var(--surface-2); } +.dt-pop-opt span { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } + /* ---------- EAN-Code-Zeile ---------- */ .code-row { display: flex; align-items: center; gap: var(--sp-2); flex: 1; min-width: 0; flex-wrap: wrap; } .code-row code { flex: 0 0 auto; } @@ -921,7 +960,10 @@ select.zeitraum:hover { color: var(--text); } /* ---- Gegenstände (Non-Food): eigene Felder, Bestand je Ort, Umlagern ---- */ .stack { display: flex; flex-direction: column; gap: var(--sp-3); } .clean-list { list-style: none; padding: 0; margin: 0; display: flex; flex-direction: column; gap: var(--sp-2); } -.clean-list li { display: flex; align-items: center; gap: var(--sp-2); padding: 6px 8px; background: var(--surface-2); border-radius: 8px; } +.clean-list li { display: flex; align-items: center; gap: var(--sp-2); padding: 6px 8px; background: var(--surface-2); border-radius: 8px; flex-wrap: wrap; } +/* In schmalen Seitenspalten (z.B. Kategorie-Felder) darf die Zeile nicht über + den Container hinauslaufen – lieber der lange Feldname umbrechen. */ +.clean-list li > .strong { min-width: 0; overflow-wrap: anywhere; } .check-inline { display: flex; align-items: center; gap: var(--sp-2); } .check-inline input { width: auto; margin: 0; } /* Über eine gewählte Oberkategorie automatisch enthaltene Unterkategorie. */