Inline-Anlage (Kategorien/Felder), Artikelfoto und CSV-Spalte "art"

- Web: Kategorien/Unterkategorien und eigene Felder direkt im Artikelformular
  anlegen (ohne Umweg über die Kategorien-Seite).
- Artikelfoto per Kamera oder Galerie hochladen – Backend-Endpunkt
  (PUT/DELETE /products/{id}/image), Web (Foto machen / Galerie) und iOS
  (Kamera + PhotosPicker, inline in der Produktansicht angezeigt).
- CSV-Import: neue Spalte "art" (Gegenstand/Lebensmittel) steuert den Modus
  automatisch angelegter Kategorien; Export schreibt sie mit.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Scarriffle
2026-07-25 16:18:36 +02:00
parent 5b952524d7
commit 044f63446f
9 changed files with 460 additions and 9 deletions

View File

@@ -10,7 +10,7 @@ import ProduktBild from "../components/ProduktBild";
import { useToast } from "../toast";
import { useSettings } from "../settings";
import { asTree } from "../categoryTree";
import { DynamicFields } from "../fields";
import { DynamicFields, FIELD_TYPES } from "../fields";
import ObjektBestand from "../components/ObjektBestand";
import {
daysUntil, expiryRowClass, fmt, fromMonthInput, gebinde, isExpired, relativeExpiry,
@@ -52,6 +52,9 @@ export default function ProductForm() {
// Gegenstände: effektive (vererbte) Felder der Kategorie + deren Werte am Artikel.
const [effFields, setEffFields] = useState([]);
const [fieldValues, setFieldValues] = useState({});
// Direkt im Formular neue Kategorien/Felder anlegen.
const [newCat, setNewCat] = useState(null); // { name, parent_id, tracking } | null
const [newField, setNewField] = useState(null); // { label, field_type, unit, options, required } | null
const [error, setError] = useState(null);
const [busy, setBusy] = useState(false);
// Einheit für die Mindestbestand-Eingabe: "package" oder die ID einer Einheit.
@@ -80,6 +83,83 @@ export default function ProductForm() {
setFieldValues((v) => ({ ...v, [fieldId]: value }));
}
// Öffnet den Inline-Dialog „Neue Kategorie" als Unterkategorie der gerade
// gewählten (falls eine gewählt ist), im selben Modus.
function openNewCat() {
setNewCat({
name: "",
parent_id: form.category_id || "",
tracking: currentCategory ? currentCategory.tracking : "object",
});
}
async function createCategoryInline() {
if (!newCat?.name.trim()) return;
setError(null);
try {
const created = await api.createCategory({
name: newCat.name.trim(),
parent_id: newCat.parent_id === "" ? null : Number(newCat.parent_id),
tracking: newCat.tracking,
});
setCategories(await api.listCategories());
set("category_id", String(created.id));
setNewCat(null);
toast(`Kategorie „${created.name}“ angelegt.`);
} catch (err) {
setError(err.message);
}
}
async function createFieldInline() {
if (!newField?.label.trim() || form.category_id === "") return;
setError(null);
try {
await api.createFieldDefinition({
category_id: Number(form.category_id),
label: newField.label.trim(),
field_type: newField.field_type,
unit: newField.field_type === "number" ? (newField.unit || null) : null,
options: newField.field_type === "select"
? newField.options.split(",").map((o) => o.trim()).filter(Boolean)
: null,
required: newField.required,
});
setEffFields(await api.categoryFields(Number(form.category_id)));
setNewField(null);
toast("Feld hinzugefügt.");
} catch (err) {
setError(err.message);
}
}
async function onPickPhoto(e) {
const file = e.target.files?.[0];
e.target.value = ""; // erlaubt, dieselbe Datei erneut zu wählen
if (!file || isNew) return;
setError(null);
try {
await api.uploadProductImage(id, file);
setBildVersion((v) => v + 1);
toast("Foto gespeichert.");
} catch (err) {
setError(err.message);
}
}
async function removePhoto() {
const ok = await confirm({
title: "Foto entfernen?", confirmLabel: "Entfernen", danger: true,
});
if (!ok) return;
try {
await api.deleteProductImage(id);
setBildVersion((v) => v + 1);
} catch (err) {
setError(err.message);
}
}
function canonicalUnitId(unitList, baseUnit) {
const name = CANONICAL_NAME[baseUnit];
const hit = unitList.find((u) => u.name === name);
@@ -431,7 +511,24 @@ export default function ProductForm() {
<input value={form.brand} onChange={(e) => set("brand", e.target.value)} disabled={readOnly} />
</label>
</div>
{!isNew && <ProduktBild productId={id} alt={form.name} version={bildVersion} />}
{!isNew && (
<div className="produkt-foto">
<ProduktBild productId={id} alt={form.name} version={bildVersion} />
{isAdmin && (
<div className="foto-knoepfe">
<label className="btn sm" title="Mit der Kamera aufnehmen">
Foto machen
<input hidden type="file" accept="image/*" capture="environment" onChange={onPickPhoto} />
</label>
<label className="btn sm" title="Aus der Galerie wählen">
Galerie
<input hidden type="file" accept="image/*" onChange={onPickPhoto} />
</label>
<button type="button" className="btn sm ghost" onClick={removePhoto}>Entfernen</button>
</div>
)}
</div>
)}
</div>
{offDaten && (
@@ -540,9 +637,50 @@ export default function ProductForm() {
))}
</select>
</label>
<div className="grow" />
{isAdmin && (
<div className="grow" style={{ display: "flex", alignItems: "flex-end" }}>
<button type="button" className="btn" style={{ marginBottom: 2 }}
onClick={() => (newCat ? setNewCat(null) : openNewCat())}>
<Icon name="plus" size={16} />Neue Kategorie
</button>
</div>
)}
</div>
{isAdmin && newCat && (
<div className="card-sub" style={{ marginBottom: "var(--sp-3)" }}>
<div className="card-head"><Icon name="tag" /><h3>Neue Kategorie / Unterkategorie</h3>
<button type="button" className="btn-icon" style={{ marginLeft: "auto" }}
onClick={() => setNewCat(null)}><Icon name="close" size={16} /></button>
</div>
<div className="row">
<label className="grow">Name
<input value={newCat.name} placeholder="z.B. Powerbank"
onChange={(e) => setNewCat({ ...newCat, name: e.target.value })} />
</label>
<label className="grow">Untergeordnet (optional)
<select value={newCat.parent_id}
onChange={(e) => setNewCat({ ...newCat, parent_id: e.target.value })}>
<option value=""> oberste Ebene </option>
{asTree(categories).map((c) => (
<option key={c.id} value={c.id}>{"— ".repeat(c.depth)}{c.name}</option>
))}
</select>
</label>
<label style={{ width: 150 }}>Art
<select value={newCat.tracking}
onChange={(e) => setNewCat({ ...newCat, tracking: e.target.value })}>
<option value="object">Gegenstand</option>
<option value="food">Lebensmittel</option>
</select>
</label>
</div>
<button type="button" className="btn primary" onClick={createCategoryInline}>
Anlegen &amp; auswählen
</button>
</div>
)}
{isObject && (<>
<div className="row">
<label className="grow">
@@ -565,13 +703,60 @@ export default function ProductForm() {
</div>
</label>
</div>
{effFields.length > 0 && (
{form.category_id !== "" && (
<div style={{ marginTop: "var(--sp-2)" }}>
<div className="card-head" style={{ marginBottom: "var(--sp-2)" }}>
<Icon name="tag" /><h2>Eigene Felder</h2>
{isAdmin && (
<button type="button" className="btn ghost" style={{ marginLeft: "auto" }}
onClick={() => (newField ? setNewField(null)
: setNewField({ label: "", field_type: "text", unit: "", options: "", required: false }))}>
<Icon name="plus" size={16} />Feld
</button>
)}
</div>
<DynamicFields fields={effFields} values={fieldValues}
onChange={setField} disabled={readOnly} />
{effFields.length > 0 ? (
<DynamicFields fields={effFields} values={fieldValues}
onChange={setField} disabled={readOnly} />
) : (
<p className="muted small">Für diese Kategorie sind noch keine Felder festgelegt.</p>
)}
{isAdmin && newField && (
<div className="card-sub" style={{ marginTop: "var(--sp-2)" }}>
<div className="row">
<label className="grow">Feldname
<input value={newField.label} placeholder="z.B. Kapazität"
onChange={(e) => setNewField({ ...newField, label: e.target.value })} />
</label>
<label style={{ width: 190 }}>Typ
<select value={newField.field_type}
onChange={(e) => setNewField({ ...newField, field_type: e.target.value })}>
{FIELD_TYPES.map((t) => <option key={t.value} value={t.value}>{t.label}</option>)}
</select>
</label>
</div>
{newField.field_type === "number" && (
<label>Einheit (optional)
<input value={newField.unit} placeholder="z.B. mAh"
onChange={(e) => setNewField({ ...newField, unit: e.target.value })} />
</label>
)}
{newField.field_type === "select" && (
<label>Auswahlmöglichkeiten (mit Komma trennen)
<input value={newField.options} placeholder="z.B. S, M, L, XL"
onChange={(e) => setNewField({ ...newField, options: e.target.value })} />
</label>
)}
<label className="check-inline">
<input type="checkbox" checked={newField.required}
onChange={(e) => setNewField({ ...newField, required: e.target.checked })} />
<span>Pflichtfeld</span>
</label>
<button type="button" className="btn primary" onClick={createFieldInline}>
Feld hinzufügen
</button>
</div>
)}
</div>
)}
</>)}