Die Eingabefelder auf der Mindestbestaende-Seite standen je Zeile woanders. Die
Zeile war ein .field-inline, und dort waechst sowohl der Name als auch das
Eingabefeld (.field-inline > input { flex: 1 1 auto }) - beide teilten sich den
Restplatz, also verschob ein laengerer Name das Feld nach rechts und machte es
schmaler. Jetzt ist die Zeile ein Raster mit festen Spalten (Name, Menge,
Einheit, Bestand, Ort, Loeschen); ohne Adminrechte bleiben die letzten beiden
Zellen leer, damit die Zeilen trotzdem fluchten. Schmale Fenster stellen den
Namen ueber die volle Breite.
Die "Details"-Spalte liess sich nicht verschieben: sie war fixed und hatte
zusaetzlich einen leeren Kopf - es gab also nichts zu greifen. Sie ist jetzt
frei und traegt einen sichtbaren Titel. Dasselbe fuer die gleichartigen
Link-Spalten in der Einzelstueck- und der Chargenliste ("Artikel",
"Aufteilen"). Sortieren bleibt dort aus, weil toggleSort ohne sortValue
ohnehin aussteigt. Bild- und Auswahlspalten bleiben fest - die haben keinen
sinnvollen Titel und gehoeren an den Anfang.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
111 lines
5.7 KiB
React
111 lines
5.7 KiB
React
import { useEffect, useMemo, useState } from "react";
|
||
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 { locationOptions, locationPathById } from "../locationPath";
|
||
import { categoryInfoMap } from "../categoryPath";
|
||
import CategoryPathLabel, { pathParts } from "../components/CategoryPathLabel";
|
||
|
||
/**
|
||
* Liste aller Einzelstücke (physische Exemplare, jedes mit eigener UID/Lagerort) –
|
||
* anders als „Produkte", das nur die Katalog-Typen zeigt. Über die DataTable je
|
||
* Spalte filterbar, u.a. nach Lagerort. Lagerort und Shop sind inline änderbar.
|
||
*/
|
||
export default function ItemList() {
|
||
const { isAdmin } = useAuth();
|
||
const [items, setItems] = useState([]);
|
||
const [locations, setLocations] = useState([]);
|
||
const [shops, setShops] = useState([]);
|
||
const [categories, setCategories] = useState([]);
|
||
const [error, setError] = useState(null);
|
||
const [loading, setLoading] = useState(true);
|
||
|
||
async function load() {
|
||
setLoading(true);
|
||
try {
|
||
const [is, ls, ss, cs] = await Promise.all([
|
||
api.listAllItems(), api.listLocations(), api.listShops(), api.listCategories(),
|
||
]);
|
||
setItems(is); setLocations(ls); setShops(ss); setCategories(cs);
|
||
} catch (err) { setError(err.message); } finally { setLoading(false); }
|
||
}
|
||
useEffect(() => { load(); }, []);
|
||
|
||
// 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(); }
|
||
catch (err) { setError(err.message); }
|
||
}
|
||
|
||
const preis = (it) => (it.price_cents != null
|
||
? `${(it.price_cents / 100).toFixed(2)} ${it.currency || ""}`.trim() : "");
|
||
|
||
const columns = [
|
||
{ key: "thumb", header: "", label: "Bild", fixed: true, width: 56,
|
||
render: (it) => <ProduktThumb productId={it.product_id} alt={it.product_name} /> },
|
||
{ key: "uid", header: "UID", width: 110, filterText: (it) => it.uid, sortValue: (it) => it.uid,
|
||
render: (it) => <span className="strong nowrap">{it.uid}</span> },
|
||
{ key: "product", header: "Produkt", grow: true, min: 180,
|
||
filterText: (it) => it.product_name || "", sortValue: (it) => it.product_name || "",
|
||
render: (it) => it.product_name || "–" },
|
||
{ 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) => 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 ? (
|
||
<select value={it.location_id ?? ""} style={{ marginTop: 0 }}
|
||
onChange={(e) => patch(it, { location_id: e.target.value === "" ? null : e.target.value })}>
|
||
<option value="">– ohne –</option>
|
||
{locationOptions(locations).map((o) => <option key={o.id} value={o.id}>{o.label}</option>)}
|
||
</select>
|
||
) : (locationPathById(it.location_id, locations) || "–")) },
|
||
{ key: "shop", header: "Gekauft bei", width: 150, filterText: (it) => it.shop_name || "",
|
||
render: (it) => (isAdmin ? (
|
||
<select value={it.shop_id ?? ""} style={{ marginTop: 0 }}
|
||
onChange={(e) => patch(it, { shop_id: e.target.value === "" ? null : Number(e.target.value) })}>
|
||
<option value="">– unbekannt –</option>
|
||
{shops.map((s) => <option key={s.id} value={s.id}>{s.name}</option>)}
|
||
</select>
|
||
) : (it.shop_name || "–")) },
|
||
{ key: "acquired", header: "Gekauft am", width: 120, filterText: (it) => it.acquired_on || "",
|
||
sortValue: (it) => it.acquired_on || "", render: (it) => it.acquired_on || "–" },
|
||
{ key: "warranty", header: "Garantie bis", width: 120, filterText: (it) => it.warranty_until || "",
|
||
sortValue: (it) => it.warranty_until || "", render: (it) => it.warranty_until || "–" },
|
||
{ key: "price", header: "Preis", width: 110, align: "num",
|
||
sortValue: (it) => it.price_cents ?? -1, filterText: preis,
|
||
render: (it) => preis(it) || "–" },
|
||
{ key: "note", header: "Notiz", width: 160, filterText: (it) => it.note || "",
|
||
render: (it) => <span className="muted">{it.note || "–"}</span> },
|
||
{ key: "open", header: "Artikel", label: "Produkt öffnen", width: 90, align: "num",
|
||
render: (it) => <Link to={`/products/${it.product_id}`}>Produkt</Link> },
|
||
];
|
||
|
||
return (
|
||
<div>
|
||
<div className="page-head">
|
||
<div>
|
||
<h1>Einzelstücke</h1>
|
||
<div className="sub">{items.length} Exemplare · nach Lagerort, Produkt, Kategorie u.a. filterbar</div>
|
||
</div>
|
||
</div>
|
||
{error && <div className="alert error"><Icon name="alert" size={16} />{error}</div>}
|
||
<div className="card">
|
||
<DataTable id="items" columns={columns} rows={items} loading={loading}
|
||
getRowKey={(it) => it.id} empty="Noch keine Einzelstücke." />
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|