Gegenstands-Verwaltung (Non-Food) neben Lebensmitteln
Die Kategorie bestimmt die Verwaltungsart (food/object). Gegenstaende: Menge je Lagerort statt Chargen/MHD, Umlagern (ohne Grund) und Entfernen mit Pflicht-Grund samt Entnahme-Statistik. Beliebig viele eigene Felder je Kategorie (vererbt an Unterkategorien), "gekauft bei" ueber eine verwaltbare Shop-Liste und ein Produktlink. Barcode-Lookup zusaetzlich ueber Open Products Facts. Der Lebensmittel-Teil (Chargen/MHD/FEFO) bleibt unveraendert. Umgesetzt in Backend (FastAPI, +Tests gruen), Web (React) und iOS (SwiftUI). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -4,21 +4,25 @@ import { useConfirm } from "../confirm";
|
||||
import { useAuth } from "../auth";
|
||||
import Icon from "../components/Icon";
|
||||
import { asTree } from "../categoryTree";
|
||||
import { FIELD_TYPES, fieldTypeLabel } from "../fields";
|
||||
|
||||
const MODUS = { food: "Lebensmittel", object: "Gegenstand" };
|
||||
|
||||
/**
|
||||
* Kategorien ordnen die Artikelliste – mehr nicht.
|
||||
*
|
||||
* Bewusst ohne Bestand, Mindestbestand und EAN-Codes: Das ist Sache der
|
||||
* Gruppen. Genau dieser Unterschied soll auf den ersten Blick sichtbar sein.
|
||||
* Kategorien ordnen die Artikelliste und bestimmen die Verwaltungsart:
|
||||
* „Lebensmittel“ (Chargen + MHD) oder „Gegenstand“ (Menge je Lagerort). Zusätzlich
|
||||
* lassen sich je Kategorie beliebig viele eigene Felder festlegen, die an ihre
|
||||
* Unterkategorien vererbt werden.
|
||||
*/
|
||||
export default function Categories() {
|
||||
const confirm = useConfirm();
|
||||
const { isAdmin } = useAuth();
|
||||
const [categories, setCategories] = useState([]);
|
||||
const [form, setForm] = useState({ name: "", parent_id: "" });
|
||||
const [form, setForm] = useState({ name: "", parent_id: "", tracking: "" });
|
||||
const [error, setError] = useState(null);
|
||||
// Eingeklappte Oberkategorien; ihre Unterkategorien werden ausgeblendet.
|
||||
const [collapsed, setCollapsed] = useState(() => new Set());
|
||||
// Kategorie, deren Felder gerade verwaltet werden.
|
||||
const [feldKat, setFeldKat] = useState(null);
|
||||
|
||||
function toggle(id) {
|
||||
setCollapsed((alt) => {
|
||||
@@ -46,8 +50,9 @@ export default function Categories() {
|
||||
await api.createCategory({
|
||||
name: form.name,
|
||||
parent_id: form.parent_id === "" ? null : Number(form.parent_id),
|
||||
tracking: form.tracking === "" ? null : form.tracking,
|
||||
});
|
||||
setForm({ name: "", parent_id: form.parent_id });
|
||||
setForm({ name: "", parent_id: form.parent_id, tracking: form.tracking });
|
||||
load();
|
||||
} catch (err) {
|
||||
setError(err.message);
|
||||
@@ -70,13 +75,14 @@ export default function Categories() {
|
||||
: "";
|
||||
const ok = await confirm({
|
||||
title: `Kategorie „${category.name}“ löschen?`,
|
||||
message: `Unterkategorien rücken eine Ebene nach oben.${hinweis}`,
|
||||
message: `Unterkategorien rücken eine Ebene nach oben. Eigene Felder dieser Kategorie werden entfernt.${hinweis}`,
|
||||
confirmLabel: "Löschen",
|
||||
danger: true,
|
||||
});
|
||||
if (!ok) return;
|
||||
try {
|
||||
await api.deleteCategory(category.id);
|
||||
if (feldKat && feldKat.id === category.id) setFeldKat(null);
|
||||
load();
|
||||
} catch (err) {
|
||||
setError(err.message);
|
||||
@@ -85,9 +91,6 @@ export default function Categories() {
|
||||
|
||||
const tree = asTree(categories);
|
||||
const nameById = Object.fromEntries(categories.map((c) => [c.id, c.name]));
|
||||
|
||||
// Verschachtelung kann mehrstufig sein: eine Zeile ist versteckt, sobald
|
||||
// irgendein Vorfahre eingeklappt ist – nicht nur der direkte Elternteil.
|
||||
const parentOf = Object.fromEntries(categories.map((c) => [c.id, c.parent_id]));
|
||||
const childCount = (id) => categories.filter((c) => c.parent_id === id).length;
|
||||
function versteckt(id) {
|
||||
@@ -106,9 +109,9 @@ export default function Categories() {
|
||||
<div>
|
||||
<h1>Kategorien</h1>
|
||||
<div className="sub">
|
||||
Ordnen die Artikelliste für den Überblick – „zeig mir alle Süßwaren“.
|
||||
Kategorien haben bewusst keinen Bestand und keine EAN-Codes; das
|
||||
übernehmen die Gruppen.
|
||||
Ordnen die Artikelliste und legen die Verwaltungsart fest:
|
||||
„Lebensmittel“ (Chargen + MHD) oder „Gegenstand“ (Menge je Lagerort).
|
||||
Je Kategorie lassen sich eigene Felder festlegen, die Unterkategorien erben.
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -121,6 +124,7 @@ export default function Categories() {
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Kategorie</th>
|
||||
<th>Art</th>
|
||||
<th className="num">Artikel</th>
|
||||
<th></th>
|
||||
</tr>
|
||||
@@ -130,7 +134,7 @@ export default function Categories() {
|
||||
const kinder = childCount(c.id);
|
||||
const zu = collapsed.has(c.id);
|
||||
return (
|
||||
<tr key={c.id}>
|
||||
<tr key={c.id} className={feldKat && feldKat.id === c.id ? "row-active" : ""}>
|
||||
<td data-label="Kategorie">
|
||||
<span className="cell-row" style={{ paddingLeft: c.depth * 22 }}>
|
||||
{kinder > 0 ? (
|
||||
@@ -155,8 +159,23 @@ export default function Categories() {
|
||||
)}
|
||||
</span>
|
||||
</td>
|
||||
<td data-label="Art">
|
||||
{isAdmin ? (
|
||||
<select value={c.tracking} style={{ marginTop: 0, minWidth: 130 }}
|
||||
onChange={(e) => patch(c, { tracking: e.target.value })}>
|
||||
<option value="food">Lebensmittel</option>
|
||||
<option value="object">Gegenstand</option>
|
||||
</select>
|
||||
) : (
|
||||
<span className="badge">{MODUS[c.tracking] || c.tracking}</span>
|
||||
)}
|
||||
</td>
|
||||
<td data-label="Artikel" className="num muted">{c.product_count}</td>
|
||||
<td className="num">
|
||||
<button className="btn-icon" title="Felder verwalten"
|
||||
onClick={() => setFeldKat(feldKat && feldKat.id === c.id ? null : c)}>
|
||||
<Icon name="tag" size={16} />
|
||||
</button>
|
||||
{isAdmin && (
|
||||
<button className="btn-icon danger" onClick={() => remove(c)} title="Löschen">
|
||||
<Icon name="trash" size={16} />
|
||||
@@ -167,20 +186,20 @@ export default function Categories() {
|
||||
);
|
||||
})}
|
||||
{categories.length === 0 && (
|
||||
<tr><td colSpan={3} className="empty">Noch keine Kategorien.</td></tr>
|
||||
<tr><td colSpan={4} className="empty">Noch keine Kategorien.</td></tr>
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<div className="stack">
|
||||
{isAdmin && (
|
||||
<form className="card" onSubmit={add}>
|
||||
<div className="card-head"><Icon name="tag" /><h2>Neue Kategorie</h2></div>
|
||||
<label>
|
||||
Name
|
||||
<input value={form.name} placeholder="z.B. Süßwaren & Snacks"
|
||||
<input value={form.name} placeholder="z.B. Elektronik"
|
||||
onChange={(e) => setForm({ ...form, name: e.target.value })} required />
|
||||
</label>
|
||||
<label>
|
||||
@@ -195,16 +214,166 @@ export default function Categories() {
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
<label>
|
||||
Verwaltungsart
|
||||
<select value={form.tracking}
|
||||
onChange={(e) => setForm({ ...form, tracking: e.target.value })}>
|
||||
<option value="">automatisch (erbt bzw. Gegenstand)</option>
|
||||
<option value="food">Lebensmittel (Chargen + MHD)</option>
|
||||
<option value="object">Gegenstand (Menge je Lagerort)</option>
|
||||
</select>
|
||||
</label>
|
||||
<button className="btn primary"><Icon name="plus" size={16} />Anlegen</button>
|
||||
<p className="muted small">
|
||||
Unterkategorien sind beliebig tief möglich. Filterst du später auf eine
|
||||
Oberkategorie, erscheinen die Artikel ihrer Unterkategorien mit.
|
||||
Namen lassen sich in der Tabelle direkt ändern.
|
||||
Unterkategorien erben Art und Felder der Oberkategorie. Auf eine
|
||||
Oberkategorie gefiltert erscheinen auch die Artikel ihrer Unterkategorien.
|
||||
</p>
|
||||
</form>
|
||||
)}
|
||||
|
||||
{feldKat && (
|
||||
<CategoryFields
|
||||
key={feldKat.id}
|
||||
category={feldKat}
|
||||
isAdmin={isAdmin}
|
||||
onClose={() => setFeldKat(null)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const EMPTY_FIELD = { label: "", field_type: "text", unit: "", options: "", required: false };
|
||||
|
||||
/** Verwaltung der eigenen Felder einer Kategorie (inkl. der geerbten zur Info). */
|
||||
function CategoryFields({ category, isAdmin, onClose }) {
|
||||
const confirm = useConfirm();
|
||||
const [fields, setFields] = useState([]);
|
||||
const [form, setForm] = useState(EMPTY_FIELD);
|
||||
const [error, setError] = useState(null);
|
||||
|
||||
async function load() {
|
||||
try {
|
||||
setFields(await api.categoryFields(category.id));
|
||||
} catch (err) {
|
||||
setError(err.message);
|
||||
}
|
||||
}
|
||||
useEffect(() => { load(); }, [category.id]);
|
||||
|
||||
async function add(e) {
|
||||
e.preventDefault();
|
||||
setError(null);
|
||||
try {
|
||||
await api.createFieldDefinition({
|
||||
category_id: category.id,
|
||||
label: form.label,
|
||||
field_type: form.field_type,
|
||||
unit: form.field_type === "number" ? (form.unit || null) : null,
|
||||
options: form.field_type === "select"
|
||||
? form.options.split(",").map((o) => o.trim()).filter(Boolean)
|
||||
: null,
|
||||
required: form.required,
|
||||
});
|
||||
setForm(EMPTY_FIELD);
|
||||
load();
|
||||
} catch (err) {
|
||||
setError(err.message);
|
||||
}
|
||||
}
|
||||
|
||||
async function remove(field) {
|
||||
const ok = await confirm({
|
||||
title: `Feld „${field.label}“ löschen?`,
|
||||
message: "Die zu diesem Feld erfassten Werte gehen an allen Artikeln verloren.",
|
||||
confirmLabel: "Löschen",
|
||||
danger: true,
|
||||
});
|
||||
if (!ok) return;
|
||||
try {
|
||||
await api.deleteFieldDefinition(field.id);
|
||||
load();
|
||||
} catch (err) {
|
||||
setError(err.message);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="card">
|
||||
<div className="card-head">
|
||||
<Icon name="tag" />
|
||||
<h2>Felder: {category.name}</h2>
|
||||
<button className="btn-icon" style={{ marginLeft: "auto" }} title="Schließen" onClick={onClose}>
|
||||
<Icon name="close" size={16} />
|
||||
</button>
|
||||
</div>
|
||||
{error && <div className="alert error"><Icon name="alert" size={16} />{error}</div>}
|
||||
|
||||
{fields.length === 0 ? (
|
||||
<p className="muted small">Noch keine Felder für diese Kategorie.</p>
|
||||
) : (
|
||||
<ul className="clean-list">
|
||||
{fields.map((f) => (
|
||||
<li key={f.id} className="cell-row">
|
||||
<span className="strong">{f.label}</span>
|
||||
<span className="badge nowrap">{fieldTypeLabel(f.field_type)}</span>
|
||||
{f.field_type === "number" && f.unit && <span className="muted small">{f.unit}</span>}
|
||||
{f.required && <span className="badge warn nowrap">Pflicht</span>}
|
||||
{f.inherited ? (
|
||||
<span className="badge nowrap" title="stammt aus einer Oberkategorie">geerbt</span>
|
||||
) : (
|
||||
isAdmin && (
|
||||
<button className="btn-icon danger" style={{ marginLeft: "auto" }}
|
||||
title="Feld löschen" onClick={() => remove(f)}>
|
||||
<Icon name="trash" size={15} />
|
||||
</button>
|
||||
)
|
||||
)}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
|
||||
{isAdmin && (
|
||||
<form onSubmit={add} className="stack" style={{ marginTop: "var(--sp-3)" }}>
|
||||
<label>
|
||||
Feldname
|
||||
<input value={form.label} placeholder="z.B. Kapazität"
|
||||
onChange={(e) => setForm({ ...form, label: e.target.value })} required />
|
||||
</label>
|
||||
<label>
|
||||
Typ
|
||||
<select value={form.field_type}
|
||||
onChange={(e) => setForm({ ...form, field_type: e.target.value })}>
|
||||
{FIELD_TYPES.map((t) => (
|
||||
<option key={t.value} value={t.value}>{t.label}</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
{form.field_type === "number" && (
|
||||
<label>
|
||||
Einheit (optional)
|
||||
<input value={form.unit} placeholder="z.B. mAh"
|
||||
onChange={(e) => setForm({ ...form, unit: e.target.value })} />
|
||||
</label>
|
||||
)}
|
||||
{form.field_type === "select" && (
|
||||
<label>
|
||||
Auswahlmöglichkeiten (mit Komma trennen)
|
||||
<input value={form.options} placeholder="z.B. S, M, L, XL"
|
||||
onChange={(e) => setForm({ ...form, options: e.target.value })} />
|
||||
</label>
|
||||
)}
|
||||
<label className="check-inline">
|
||||
<input type="checkbox" checked={form.required}
|
||||
onChange={(e) => setForm({ ...form, required: e.target.checked })} />
|
||||
<span>Pflichtfeld</span>
|
||||
</label>
|
||||
<button className="btn primary"><Icon name="plus" size={16} />Feld hinzufügen</button>
|
||||
</form>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -10,6 +10,8 @@ import ProduktBild from "../components/ProduktBild";
|
||||
import { useToast } from "../toast";
|
||||
import { useSettings } from "../settings";
|
||||
import { asTree } from "../categoryTree";
|
||||
import { DynamicFields } from "../fields";
|
||||
import ObjektBestand from "../components/ObjektBestand";
|
||||
import {
|
||||
daysUntil, expiryRowClass, fmt, fromMonthInput, gebinde, isExpired, relativeExpiry,
|
||||
toMonthInput, unitShort,
|
||||
@@ -18,7 +20,7 @@ import {
|
||||
const EMPTY = {
|
||||
barcode: "", name: "", brand: "", image_url: "",
|
||||
unit_id: "", package_size: "", package_label: "", date_precision: "day", min_stock: "",
|
||||
group_id: "", category_id: "",
|
||||
group_id: "", category_id: "", shop_id: "", product_url: "",
|
||||
};
|
||||
|
||||
// (Gebinde kommen jetzt aus der Verwaltung, siehe api.listPackageTypes)
|
||||
@@ -45,6 +47,11 @@ export default function ProductForm() {
|
||||
const [categories, setCategories] = useState([]);
|
||||
const [units, setUnits] = useState([]);
|
||||
const [gebindearten, setGebindearten] = useState([]);
|
||||
const [locations, setLocations] = useState([]);
|
||||
const [shops, setShops] = useState([]);
|
||||
// Gegenstände: effektive (vererbte) Felder der Kategorie + deren Werte am Artikel.
|
||||
const [effFields, setEffFields] = useState([]);
|
||||
const [fieldValues, setFieldValues] = useState({});
|
||||
const [error, setError] = useState(null);
|
||||
const [busy, setBusy] = useState(false);
|
||||
// Einheit für die Mindestbestand-Eingabe: "package" oder die ID einer Einheit.
|
||||
@@ -59,10 +66,20 @@ export default function ProductForm() {
|
||||
const unitFactor = selectedUnit ? selectedUnit.factor : 1;
|
||||
const unitName = selectedUnit ? selectedUnit.name : "";
|
||||
|
||||
// Die gewählte Kategorie bestimmt die Verwaltungsart. Ohne Kategorie: Lebensmittel.
|
||||
const currentCategory =
|
||||
categories.find((c) => String(c.id) === String(form.category_id)) || null;
|
||||
const mode = currentCategory ? currentCategory.tracking : "food";
|
||||
const isObject = mode === "object";
|
||||
|
||||
function set(k, v) {
|
||||
setForm((f) => ({ ...f, [k]: v }));
|
||||
}
|
||||
|
||||
function setField(fieldId, value) {
|
||||
setFieldValues((v) => ({ ...v, [fieldId]: value }));
|
||||
}
|
||||
|
||||
function canonicalUnitId(unitList, baseUnit) {
|
||||
const name = CANONICAL_NAME[baseUnit];
|
||||
const hit = unitList.find((u) => u.name === name);
|
||||
@@ -203,13 +220,16 @@ export default function ProductForm() {
|
||||
useEffect(() => {
|
||||
async function load() {
|
||||
try {
|
||||
const [gs, us, cs, pts] = await Promise.all([
|
||||
const [gs, us, cs, pts, locs, shs] = await Promise.all([
|
||||
api.listGroups(), api.listUnits(), api.listCategories(), api.listPackageTypes(),
|
||||
api.listLocations(), api.listShops(),
|
||||
]);
|
||||
setGroups(gs);
|
||||
setUnits(us);
|
||||
setCategories(cs);
|
||||
setGebindearten(pts);
|
||||
setLocations(locs);
|
||||
setShops(shs);
|
||||
try {
|
||||
const s = await api.listSettings();
|
||||
const row = s.find((x) => x.key === "expiry_warning_days");
|
||||
@@ -238,7 +258,10 @@ export default function ProductForm() {
|
||||
min_stock: p.min_stock != null ? p.min_stock / f : "",
|
||||
group_id: p.group_id ?? "",
|
||||
category_id: p.category_id ?? "",
|
||||
shop_id: p.shop_id ?? "",
|
||||
product_url: p.product_url || "",
|
||||
});
|
||||
setFieldValues(p.field_values || {});
|
||||
setLots(await api.listLots(id));
|
||||
} else {
|
||||
const bc = searchParams.get("barcode");
|
||||
@@ -255,17 +278,47 @@ export default function ProductForm() {
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [id]);
|
||||
|
||||
// Wechselt die Kategorie, die geltenden (vererbten) Felder nachladen –
|
||||
// aber nur für Gegenstände. Lebensmittel haben keine eigenen Felder.
|
||||
useEffect(() => {
|
||||
const cat = categories.find((c) => String(c.id) === String(form.category_id)) || null;
|
||||
if (!cat || cat.tracking !== "object") {
|
||||
setEffFields([]);
|
||||
return;
|
||||
}
|
||||
let aktiv = true;
|
||||
api.categoryFields(cat.id).then((fs) => { if (aktiv) setEffFields(fs); }).catch(() => {});
|
||||
return () => { aktiv = false; };
|
||||
}, [form.category_id, categories]);
|
||||
|
||||
function buildPayload() {
|
||||
const sel = minUnit || form.unit_id;
|
||||
let minBase = null;
|
||||
if (form.min_stock !== "") {
|
||||
minBase = Number(form.min_stock) * minFactor(sel, form.package_size, units);
|
||||
}
|
||||
return {
|
||||
const base = {
|
||||
barcode: form.barcode || null,
|
||||
name: form.name,
|
||||
brand: form.brand || null,
|
||||
image_url: form.image_url || null,
|
||||
category_id: form.category_id === "" ? null : Number(form.category_id),
|
||||
shop_id: form.shop_id === "" ? null : Number(form.shop_id),
|
||||
product_url: form.product_url.trim() || null,
|
||||
};
|
||||
if (isObject) {
|
||||
// Gegenstände: keine Lebensmittel-Felder (Einheit/Packung/MHD/Gruppe).
|
||||
// Nur die geltenden eigenen Felder mitschicken – Werte fremder Kategorien
|
||||
// (nach einem Kategoriewechsel) fallen dabei weg.
|
||||
const erlaubt = new Set(effFields.map((f) => String(f.id)));
|
||||
const fv = {};
|
||||
Object.entries(fieldValues).forEach(([k, v]) => {
|
||||
if (erlaubt.has(String(k))) fv[k] = v;
|
||||
});
|
||||
return { ...base, field_values: fv };
|
||||
}
|
||||
return {
|
||||
...base,
|
||||
unit_id: form.unit_id === "" ? null : Number(form.unit_id),
|
||||
package_size: form.package_size === "" ? null : Number(form.package_size),
|
||||
package_label: form.package_label.trim() || null,
|
||||
@@ -274,7 +327,6 @@ export default function ProductForm() {
|
||||
min_stock_unit_id: sel === "package" || sel === "" ? null : Number(sel),
|
||||
min_stock_in_packages: sel === "package",
|
||||
group_id: form.group_id === "" ? null : Number(form.group_id),
|
||||
category_id: form.category_id === "" ? null : Number(form.category_id),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -306,7 +358,7 @@ export default function ProductForm() {
|
||||
async function remove() {
|
||||
const ok = await confirm({
|
||||
title: "Produkt löschen?",
|
||||
message: "Alle Chargen dieses Produkts gehen dabei verloren.",
|
||||
message: "Der gesamte Bestand dieses Produkts geht dabei verloren.",
|
||||
confirmLabel: "Löschen",
|
||||
danger: true,
|
||||
});
|
||||
@@ -393,6 +445,7 @@ export default function ProductForm() {
|
||||
onSchliessen={() => setOffDaten(null)}
|
||||
/>
|
||||
)}
|
||||
{!isObject && (<>
|
||||
<div className="row">
|
||||
<label className="grow">
|
||||
Einheit
|
||||
@@ -471,22 +524,58 @@ export default function ProductForm() {
|
||||
</select>
|
||||
</label>
|
||||
</div>
|
||||
</>)}
|
||||
<div className="row">
|
||||
<label className="grow">
|
||||
<span className="tip" title="Nur für den Überblick in der Artikelliste – ohne Einfluss auf Bestände.">
|
||||
<span className="tip" title="Bestimmt zugleich die Verwaltungsart: Gegenstands-Kategorien blenden MHD/Chargen aus und zeigen Menge je Lagerort.">
|
||||
Kategorie
|
||||
</span>
|
||||
<select value={form.category_id} onChange={(e) => set("category_id", e.target.value)}
|
||||
disabled={readOnly}>
|
||||
<option value="">– keine –</option>
|
||||
{asTree(categories).map((c) => (
|
||||
<option key={c.id} value={c.id}>{"— ".repeat(c.depth)}{c.name}</option>
|
||||
<option key={c.id} value={c.id}>
|
||||
{"— ".repeat(c.depth)}{c.name}{c.tracking === "object" ? " · Gegenstand" : ""}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
<div className="grow" />
|
||||
</div>
|
||||
|
||||
{isObject && (<>
|
||||
<div className="row">
|
||||
<label className="grow">
|
||||
Gekauft bei
|
||||
<select value={form.shop_id} onChange={(e) => set("shop_id", e.target.value)}
|
||||
disabled={readOnly}>
|
||||
<option value="">– unbekannt / mehrere –</option>
|
||||
{shops.map((s) => <option key={s.id} value={s.id}>{s.name}</option>)}
|
||||
</select>
|
||||
</label>
|
||||
<label className="grow">
|
||||
Produktlink
|
||||
<div className="field-inline">
|
||||
<input type="url" value={form.product_url} placeholder="https://…"
|
||||
onChange={(e) => set("product_url", e.target.value)} disabled={readOnly} />
|
||||
{form.product_url && (
|
||||
<a className="btn" href={form.product_url} target="_blank" rel="noreferrer"
|
||||
title="Im Onlineshop öffnen">Öffnen</a>
|
||||
)}
|
||||
</div>
|
||||
</label>
|
||||
</div>
|
||||
{effFields.length > 0 && (
|
||||
<div style={{ marginTop: "var(--sp-2)" }}>
|
||||
<div className="card-head" style={{ marginBottom: "var(--sp-2)" }}>
|
||||
<Icon name="tag" /><h2>Eigene Felder</h2>
|
||||
</div>
|
||||
<DynamicFields fields={effFields} values={fieldValues}
|
||||
onChange={setField} disabled={readOnly} />
|
||||
</div>
|
||||
)}
|
||||
</>)}
|
||||
|
||||
{isAdmin && (
|
||||
<div className="field-inline" style={{ marginTop: "var(--sp-2)" }}>
|
||||
<button className="btn primary" disabled={busy}>{busy ? "Speichern…" : "Speichern"}</button>
|
||||
@@ -500,7 +589,21 @@ export default function ProductForm() {
|
||||
{readOnly && <p className="muted small mt-0">Nur Administratoren können Produkte bearbeiten.</p>}
|
||||
</form>
|
||||
|
||||
{!isNew && (
|
||||
{!isNew && (isObject ? (
|
||||
product && (
|
||||
<ObjektBestand
|
||||
product={product}
|
||||
lots={lots}
|
||||
locations={locations}
|
||||
isAdmin={isAdmin}
|
||||
onChanged={async () => {
|
||||
setLots(await api.listLots(id));
|
||||
setProduct(await api.getProduct(id));
|
||||
}}
|
||||
onError={setError}
|
||||
/>
|
||||
)
|
||||
) : (
|
||||
<LotsCard
|
||||
product={product}
|
||||
lots={lots}
|
||||
@@ -513,7 +616,7 @@ export default function ProductForm() {
|
||||
}}
|
||||
onError={setError}
|
||||
/>
|
||||
)}
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Unterhalb der beiden Spalten: die Codes braucht man selten, sie sollen
|
||||
|
||||
131
web/src/pages/Shops.jsx
Normal file
131
web/src/pages/Shops.jsx
Normal file
@@ -0,0 +1,131 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { api } from "../api";
|
||||
import { useConfirm } from "../confirm";
|
||||
import { useAuth } from "../auth";
|
||||
import Icon from "../components/Icon";
|
||||
|
||||
/**
|
||||
* Shops / Bezugsquellen: verwaltbare Liste für „gekauft bei“ an Gegenständen.
|
||||
* Am Artikel ist die Auswahl optional (unbekannt / mehrere Herkünfte = leer).
|
||||
*/
|
||||
export default function Shops() {
|
||||
const confirm = useConfirm();
|
||||
const { isAdmin } = useAuth();
|
||||
const [shops, setShops] = useState([]);
|
||||
const [form, setForm] = useState({ name: "", website: "" });
|
||||
const [error, setError] = useState(null);
|
||||
|
||||
async function load() {
|
||||
try {
|
||||
setShops(await api.listShops());
|
||||
} catch (err) {
|
||||
setError(err.message);
|
||||
}
|
||||
}
|
||||
|
||||
useEffect(() => { load(); }, []);
|
||||
|
||||
async function add(e) {
|
||||
e.preventDefault();
|
||||
setError(null);
|
||||
try {
|
||||
await api.createShop({ name: form.name, website: form.website || null });
|
||||
setForm({ name: "", website: "" });
|
||||
load();
|
||||
} catch (err) {
|
||||
setError(err.message);
|
||||
}
|
||||
}
|
||||
|
||||
async function patch(shop, body) {
|
||||
setError(null);
|
||||
try {
|
||||
await api.updateShop(shop.id, body);
|
||||
load();
|
||||
} catch (err) {
|
||||
setError(err.message);
|
||||
}
|
||||
}
|
||||
|
||||
async function remove(shop) {
|
||||
const hinweis = shop.product_count
|
||||
? ` ${shop.product_count} Artikel verlieren die Bezugsquelle, bleiben aber erhalten.`
|
||||
: "";
|
||||
const ok = await confirm({
|
||||
title: `Shop „${shop.name}“ löschen?`,
|
||||
message: `Die Liste ist nur eine Auswahlhilfe.${hinweis}`,
|
||||
confirmLabel: "Löschen",
|
||||
danger: true,
|
||||
});
|
||||
if (!ok) return;
|
||||
try {
|
||||
await api.deleteShop(shop.id);
|
||||
load();
|
||||
} catch (err) {
|
||||
setError(err.message);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="page-head">
|
||||
<div>
|
||||
<h1>Shops</h1>
|
||||
<div className="sub">
|
||||
Bezugsquellen für „gekauft bei“ an Gegenständen – einmal anlegen, überall auswählbar.
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{error && <div className="alert error"><Icon name="alert" size={16} />{error}</div>}
|
||||
|
||||
<div className="card form-narrow">
|
||||
{isAdmin && (
|
||||
<form onSubmit={add}>
|
||||
<div className="row">
|
||||
<label className="grow">
|
||||
Name
|
||||
<input placeholder="z.B. MediaMarkt" value={form.name}
|
||||
onChange={(e) => setForm({ ...form, name: e.target.value })} required />
|
||||
</label>
|
||||
<label className="grow">
|
||||
Website (optional)
|
||||
<input placeholder="https://…" value={form.website}
|
||||
onChange={(e) => setForm({ ...form, website: e.target.value })} />
|
||||
</label>
|
||||
</div>
|
||||
<button className="btn primary"><Icon name="plus" size={16} />Hinzufügen</button>
|
||||
</form>
|
||||
)}
|
||||
|
||||
<ul className="simple-list">
|
||||
{shops.map((s) => (
|
||||
<li key={s.id}>
|
||||
<span style={{ display: "flex", alignItems: "center", gap: 8, flex: 1 }}>
|
||||
<Icon name="cart" size={15} className="muted" />
|
||||
{isAdmin ? (
|
||||
<input defaultValue={s.name} style={{ marginTop: 0, minWidth: 140 }}
|
||||
onBlur={(e) => {
|
||||
const v = e.target.value.trim();
|
||||
if (v && v !== s.name) patch(s, { name: v });
|
||||
}} />
|
||||
) : <span className="strong">{s.name}</span>}
|
||||
{s.website && (
|
||||
<a href={s.website} target="_blank" rel="noreferrer" className="muted small">
|
||||
{s.website.replace(/^https?:\/\//, "")}
|
||||
</a>
|
||||
)}
|
||||
{s.product_count > 0 && <span className="badge nowrap">{s.product_count} Artikel</span>}
|
||||
</span>
|
||||
{isAdmin && (
|
||||
<button className="btn-icon danger" onClick={() => remove(s)} title="Löschen">
|
||||
<Icon name="trash" size={16} />
|
||||
</button>
|
||||
)}
|
||||
</li>
|
||||
))}
|
||||
{shops.length === 0 && <li className="empty">Noch keine Shops.</li>}
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user