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:
@@ -137,19 +137,19 @@ export default function App() {
|
||||
<Route path="/d/:id" element={<Protected wide><Dashboard /></Protected>} />
|
||||
<Route path="/checkin" element={<Protected><CheckIn /></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/:id" element={<Protected><ProductForm /></Protected>} />
|
||||
<Route path="/i/:uid" element={<Protected><ItemResolve /></Protected>} />
|
||||
<Route path="/l/:id" element={<Protected><LocationResolve /></Protected>} />
|
||||
<Route path="/groups" element={<Protected adminOnly><Groups /></Protected>} />
|
||||
<Route path="/categories" element={<Protected adminOnly><Categories /></Protected>} />
|
||||
<Route path="/groups" element={<Protected adminOnly wide><Groups /></Protected>} />
|
||||
<Route path="/categories" element={<Protected adminOnly wide><Categories /></Protected>} />
|
||||
<Route path="/shopping" element={<Protected><ShoppingList /></Protected>} />
|
||||
<Route path="/history" element={<Protected><History /></Protected>} />
|
||||
<Route path="/shops" element={<Protected adminOnly><Shops /></Protected>} />
|
||||
<Route path="/locations" element={<Protected adminOnly><Locations /></Protected>} />
|
||||
<Route path="/units" element={<Protected adminOnly><Units /></Protected>} />
|
||||
<Route path="/package-types" element={<Protected adminOnly><PackageTypes /></Protected>} />
|
||||
<Route path="/units" element={<Protected adminOnly wide><Units /></Protected>} />
|
||||
<Route path="/package-types" element={<Protected adminOnly wide><PackageTypes /></Protected>} />
|
||||
<Route path="/users" element={<Protected adminOnly><Users /></Protected>} />
|
||||
<Route path="/transfer" element={<Protected adminOnly><Transfer /></Protected>} />
|
||||
<Route path="/settings" element={<Protected adminOnly><Settings /></Protected>} />
|
||||
|
||||
293
web/src/components/DataTable.jsx
Normal file
293
web/src/components/DataTable.jsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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) => <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 (
|
||||
<div>
|
||||
<div className="page-head">
|
||||
<div>
|
||||
<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>
|
||||
{isAdmin && (
|
||||
<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>}
|
||||
|
||||
<div className="segmented" style={{ marginBottom: "var(--sp-3)" }}>
|
||||
<button type="button" className={typ === "" ? "active" : ""} onClick={() => changeTyp("")}>Alle</button>
|
||||
<button type="button" className={typ === "food" ? "active" : ""} onClick={() => changeTyp("food")}>Lebensmittel</button>
|
||||
<button type="button" className={typ === "object" ? "active" : ""} onClick={() => changeTyp("object")}>Gegenstände</button>
|
||||
<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>
|
||||
|
||||
<form className="field-inline" style={{ marginBottom: "var(--sp-4)", maxWidth: 640 }} onSubmit={onSearch}>
|
||||
<label className="grow" style={{ margin: 0 }}>
|
||||
<input placeholder="Produkt suchen…" value={q} onChange={(e) => setQ(e.target.value)} />
|
||||
</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 className="card">
|
||||
<DataTable id="products" columns={columns} rows={sichtbar}
|
||||
getRowKey={(p) => p.id} empty="Keine Produkte gefunden." />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -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. */
|
||||
|
||||
Reference in New Issue
Block a user