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:
@@ -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 & 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>
|
||||
)}
|
||||
</>)}
|
||||
|
||||
Reference in New Issue
Block a user