Web: Excel-artige DataTable + volle Breite; Produkte migriert

Neue wiederverwendbare DataTable: feste Filterzeile je Spalte (Freitext + Dropdown der vorhandenen Werte, ueber Spalten kombinierbar), Spalten per Drag verschieben und in der Breite ziehen, Klick-Sortierung; Reihenfolge/Breiten merkt der Browser (localStorage). Produkte-Seite darauf umgestellt. Tabellenseiten (Produkte/Kategorien/Gruppen/Einheiten/Gebinde) nutzen jetzt die volle Fensterbreite (content--wide); Kategorien-Seitenspalte etwas breiter und die Feldliste bricht um, statt ueber den Container zu laufen. Farbschema unveraendert.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Scarriffle
2026-07-27 14:53:05 +02:00
parent 4887596b43
commit 76acf57edf
4 changed files with 403 additions and 122 deletions

View File

@@ -137,19 +137,19 @@ export default function App() {
<Route path="/d/:id" element={<Protected wide><Dashboard /></Protected>} /> <Route path="/d/:id" element={<Protected wide><Dashboard /></Protected>} />
<Route path="/checkin" element={<Protected><CheckIn /></Protected>} /> <Route path="/checkin" element={<Protected><CheckIn /></Protected>} />
<Route path="/checkout" element={<Protected><CheckOut /></Protected>} /> <Route path="/checkout" element={<Protected><CheckOut /></Protected>} />
<Route path="/products" element={<Protected><Products /></Protected>} /> <Route path="/products" element={<Protected wide><Products /></Protected>} />
<Route path="/products/new" element={<Protected adminOnly><ProductForm /></Protected>} /> <Route path="/products/new" element={<Protected adminOnly><ProductForm /></Protected>} />
<Route path="/products/:id" element={<Protected><ProductForm /></Protected>} /> <Route path="/products/:id" element={<Protected><ProductForm /></Protected>} />
<Route path="/i/:uid" element={<Protected><ItemResolve /></Protected>} /> <Route path="/i/:uid" element={<Protected><ItemResolve /></Protected>} />
<Route path="/l/:id" element={<Protected><LocationResolve /></Protected>} /> <Route path="/l/:id" element={<Protected><LocationResolve /></Protected>} />
<Route path="/groups" element={<Protected adminOnly><Groups /></Protected>} /> <Route path="/groups" element={<Protected adminOnly wide><Groups /></Protected>} />
<Route path="/categories" element={<Protected adminOnly><Categories /></Protected>} /> <Route path="/categories" element={<Protected adminOnly wide><Categories /></Protected>} />
<Route path="/shopping" element={<Protected><ShoppingList /></Protected>} /> <Route path="/shopping" element={<Protected><ShoppingList /></Protected>} />
<Route path="/history" element={<Protected><History /></Protected>} /> <Route path="/history" element={<Protected><History /></Protected>} />
<Route path="/shops" element={<Protected adminOnly><Shops /></Protected>} /> <Route path="/shops" element={<Protected adminOnly><Shops /></Protected>} />
<Route path="/locations" element={<Protected adminOnly><Locations /></Protected>} /> <Route path="/locations" element={<Protected adminOnly><Locations /></Protected>} />
<Route path="/units" element={<Protected adminOnly><Units /></Protected>} /> <Route path="/units" element={<Protected adminOnly wide><Units /></Protected>} />
<Route path="/package-types" element={<Protected adminOnly><PackageTypes /></Protected>} /> <Route path="/package-types" element={<Protected adminOnly wide><PackageTypes /></Protected>} />
<Route path="/users" element={<Protected adminOnly><Users /></Protected>} /> <Route path="/users" element={<Protected adminOnly><Users /></Protected>} />
<Route path="/transfer" element={<Protected adminOnly><Transfer /></Protected>} /> <Route path="/transfer" element={<Protected adminOnly><Transfer /></Protected>} />
<Route path="/settings" element={<Protected adminOnly><Settings /></Protected>} /> <Route path="/settings" element={<Protected adminOnly><Settings /></Protected>} />

View File

@@ -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 (
<div className="dt-root">
<div className="dt-toolbar">
<button type="button" className={`btn sm ${filterShown ? "" : "ghost"}`}
onClick={() => setFilterShown((v) => !v)}>
<Icon name="search" size={14} />Filter
</button>
<button type="button" className="btn sm ghost" onClick={() => setFilters({})}>Filter löschen</button>
<button type="button" className="btn sm ghost" onClick={resetColumns}>Spalten zurücksetzen</button>
{toolbarExtra}
</div>
<div className="table-wrap" ref={wrapRef}>
<table className="table datatable" style={{ width: totalWidth ? `${totalWidth}px` : "100%" }}>
<colgroup>
{orderedCols.map((c) => <col key={c.key} style={{ width: `${Math.round(widthOf(c))}px` }} />)}
</colgroup>
<thead>
<tr>
{orderedCols.map((c) => (
<th key={c.key}
className={`dt-th ${c.align === "num" ? "num" : ""} ${dropKey === c.key ? "drop" : ""} ${sort?.key === c.key ? "sorted" : ""}`}
draggable={!c.fixed}
onDragStart={(e) => onDragStart(e, c)}
onDragOver={(e) => onDragOver(e, c)}
onDrop={(e) => onDrop(e, c)}>
<span className="dt-th-label" onClick={() => toggleSort(c)}>
<span className="dt-th-text">{c.header}</span>
{sort?.key === c.key && <span className="dt-sort">{sort.dir === "asc" ? "▲" : "▼"}</span>}
</span>
{!c.fixed && <span className="dt-resize" onPointerDown={(e) => startResize(e, c)} />}
</th>
))}
</tr>
{filterShown && (
<tr className="dt-filter">
{orderedCols.map((c) => (
<td key={c.key}>
{c.filterText ? (
<FilterCell
col={c}
filter={filters[c.key]}
distinct={distinctByKey[c.key] || []}
open={openFilter === c.key}
onToggleOpen={setOpenFilter}
onChange={(f) => setFilters((old) => ({ ...old, [c.key]: f }))}
/>
) : null}
</td>
))}
</tr>
)}
</thead>
<tbody>
{visible.map((r) => (
<tr key={getRowKey(r)} className={rowClassName?.(r) || ""}>
{orderedCols.map((c) => (
<td key={c.key} className={c.align === "num" ? "num" : ""}>{c.render(r)}</td>
))}
</tr>
))}
{visible.length === 0 && (
<tr><td className="empty" colSpan={orderedCols.length}>{empty}</td></tr>
)}
</tbody>
</table>
</div>
</div>
);
}
/** 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 (
<div className="dt-filter-cell">
<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(
<div ref={popRef} className="dt-pop" style={{ left: pos.left, top: pos.top }}>
<input className="dt-pop-search" placeholder="Suchen…" value={search}
onChange={(e) => setSearch(e.target.value)} autoFocus />
<div className="dt-pop-actions">
<button type="button" className="link-btn" onClick={() => setAll(true)}>Alle</button>
<button type="button" className="link-btn" onClick={() => setAll(false)}>Keine</button>
</div>
<div className="dt-pop-list">
{shown.map((v) => (
<label key={v} className="dt-pop-opt">
<input type="checkbox" checked={isChecked(v)} onChange={() => toggleValue(v)} />
<span>{v === "" ? "(Leere)" : v}</span>
</label>
))}
{shown.length === 0 && <div className="muted small">Keine Werte.</div>}
</div>
</div>,
document.body,
)}
</div>
);
}

View File

@@ -3,10 +3,9 @@ import { Link } from "react-router-dom";
import { api } from "../api"; import { api } from "../api";
import { useAuth } from "../auth"; import { useAuth } from "../auth";
import Icon from "../components/Icon"; import Icon from "../components/Icon";
import DataTable from "../components/DataTable";
import { ProduktThumb } from "../components/ProduktBild"; import { ProduktThumb } from "../components/ProduktBild";
import { fmt, gebinde, unitShort } from "../units"; import { fmt, gebinde, unitShort } from "../units";
import { asTree } from "../categoryTree";
import CategorySelect from "../components/CategorySelect";
// Wie viele Basiseinheiten eine "Artikeleinheit" umfasst (Gebinde oder Produkteinheit). // Wie viele Basiseinheiten eine "Artikeleinheit" umfasst (Gebinde oder Produkteinheit).
function unitCount(p) { function unitCount(p) {
@@ -18,54 +17,78 @@ function countLabel(p, menge) {
? gebinde(menge, p.package_label) ? gebinde(menge, p.package_label)
: (p.unit_name || unitShort(p.base_unit)); : (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() { export default function Products() {
const { isAdmin } = useAuth(); const { isAdmin } = useAuth();
const [products, setProducts] = useState([]); 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. // "" = alle, sonst "food"/"object": trennt Lebensmittel und Gegenstände.
const [typ, setTyp] = useState(""); const [typ, setTyp] = useState("");
const [error, setError] = useState(null); const [error, setError] = useState(null);
async function load(cat = categoryId) {
try {
setProducts(await api.listProducts(q, cat));
} catch (err) {
setError(err.message);
}
}
useEffect(() => { useEffect(() => {
load(); // Alle Produkte einmal laden; das Filtern erledigt die Tabelle clientseitig.
api.listCategories().then(setCategories).catch(() => {}); api.listProducts("", "").then(setProducts).catch((err) => setError(err.message));
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []); }, []);
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 sichtbar = products.filter((p) => !typ || p.tracking === typ);
const columns = [
{ key: "thumb", header: "", 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: 180, filterText: (p) => p.category_name || "",
render: (p) => <span className="muted">{p.category_name || ""}</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: "details", header: "", fixed: true, width: 84, align: "num",
render: (p) => <Link to={`/products/${p.id}`}>Details</Link> },
];
return ( return (
<div> <div>
<div className="page-head"> <div className="page-head">
<div> <div>
<h1>Produkte</h1> <h1>Produkte</h1>
<div className="sub">{sichtbar.length} Produkte{typ ? (typ === "food" ? " · Lebensmittel" : " · Gegenstände") : " im Katalog"}</div> <div className="sub">
{sichtbar.length} Produkte{typ ? (typ === "food" ? " · Lebensmittel" : " · Gegenstände") : " im Katalog"}
</div>
</div> </div>
{isAdmin && ( {isAdmin && (
<Link className="btn primary" to="/products/new"><Icon name="plus" size={16} />Neues Produkt</Link> <Link className="btn primary" to="/products/new"><Icon name="plus" size={16} />Neues Produkt</Link>
@@ -75,91 +98,14 @@ export default function Products() {
{error && <div className="alert error"><Icon name="alert" size={16} />{error}</div>} {error && <div className="alert error"><Icon name="alert" size={16} />{error}</div>}
<div className="segmented" style={{ marginBottom: "var(--sp-3)" }}> <div className="segmented" style={{ marginBottom: "var(--sp-3)" }}>
<button type="button" className={typ === "" ? "active" : ""} onClick={() => changeTyp("")}>Alle</button> <button type="button" className={typ === "" ? "active" : ""} onClick={() => setTyp("")}>Alle</button>
<button type="button" className={typ === "food" ? "active" : ""} onClick={() => changeTyp("food")}>Lebensmittel</button> <button type="button" className={typ === "food" ? "active" : ""} onClick={() => setTyp("food")}>Lebensmittel</button>
<button type="button" className={typ === "object" ? "active" : ""} onClick={() => changeTyp("object")}>Gegenstände</button> <button type="button" className={typ === "object" ? "active" : ""} onClick={() => setTyp("object")}>Gegenstände</button>
</div> </div>
<form className="field-inline" style={{ marginBottom: "var(--sp-4)", maxWidth: 640 }} onSubmit={onSearch}> <div className="card">
<label className="grow" style={{ margin: 0 }}> <DataTable id="products" columns={columns} rows={sichtbar}
<input placeholder="Produkt suchen…" value={q} onChange={(e) => setQ(e.target.value)} /> getRowKey={(p) => p.id} empty="Keine Produkte gefunden." />
</label>
<label style={{ margin: 0, width: 230 }}>
<CategorySelect
value={categoryId}
nodes={asTree(categories).filter((c) => !typ || c.tracking === typ)}
rootLabel="Alle Kategorien"
extraOptions={[{ value: 0, label: "Ohne Kategorie" }]}
onChange={(id) => { const v = id == null ? "" : String(id); setCategoryId(v); load(v); }}
/>
</label>
<button className="btn"><Icon name="search" size={16} />Suchen</button>
</form>
<div className="card" style={{ padding: 0 }}>
<div className="table-wrap">
<table className="table">
<thead>
<tr>
<th></th>
<th>Name</th>
<th>Marke</th>
<th>Kategorie</th>
<th>Einheit</th>
<th className="num">Bestand</th>
<th className="num">Einheiten (ist / soll)</th>
<th></th>
</tr>
</thead>
<tbody>
{sichtbar.map((p) => {
const low = p.min_stock != null && p.stock < p.min_stock;
return (
<tr key={p.id}>
<td className="thumb-cell">
<ProduktThumb productId={p.id} alt={p.name} />
</td>
<td data-label="Name" className="strong">
<span className="cell-row">
<span>{p.name}</span>
{p.expired_count > 0 && (
<span className="badge danger nowrap">
<Icon name="alert" size={12} />{p.expired_count} abgelaufen
</span>
)}
</span>
</td>
<td data-label="Marke" className="muted">{p.brand || ""}</td>
<td data-label="Kategorie" className="muted">{p.category_name || ""}</td>
<td data-label="Einheit">
{p.unit_name || unitShort(p.base_unit)}
{p.package_size
? ` · ${p.package_label || "Packung"} ${fmt(p.package_size)} ${unitShort(p.base_unit)}`
: ""}
</td>
<td data-label="Bestand" className="num">
{fmt(p.stock / (p.unit_factor || 1))} {p.unit_name || unitShort(p.base_unit)}
</td>
<td data-label="Einheiten" className="num">
<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>
</td>
<td className="num"><Link to={`/products/${p.id}`}>Details</Link></td>
</tr>
);
})}
{sichtbar.length === 0 && (
<tr><td colSpan={8} className="empty">Keine Produkte gefunden.</td></tr>
)}
</tbody>
</table>
</div>
</div> </div>
</div> </div>
); );

View File

@@ -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. */ Kartenansicht, obwohl daneben Platz frei waere. */
/* Tabelle bekommt den ganzen restlichen Platz, der Block daneben bleibt schmal /* Tabelle bekommt den ganzen restlichen Platz, der Block daneben bleibt schmal
und gedeckelt - so scrollt die (breite) Tabelle nicht seitwaerts. */ 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) { @media (max-width: 1000px) {
.grid-2.wide-aside { grid-template-columns: minmax(0, 1fr); } .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 td { display: revert; }
.table.table-compact thead { display: table-header-group; } .table.table-compact thead { display: table-header-group; }
.table.table-compact td::before { content: none; } .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 ---------- */ /* ---------- EAN-Code-Zeile ---------- */
.code-row { display: flex; align-items: center; gap: var(--sp-2); flex: 1; min-width: 0; flex-wrap: wrap; } .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; } .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 ---- */ /* ---- Gegenstände (Non-Food): eigene Felder, Bestand je Ort, Umlagern ---- */
.stack { display: flex; flex-direction: column; gap: var(--sp-3); } .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 { 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 { display: flex; align-items: center; gap: var(--sp-2); }
.check-inline input { width: auto; margin: 0; } .check-inline input { width: auto; margin: 0; }
/* Über eine gewählte Oberkategorie automatisch enthaltene Unterkategorie. */ /* Über eine gewählte Oberkategorie automatisch enthaltene Unterkategorie. */