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

@@ -1,4 +1,4 @@
from fastapi import APIRouter, Depends, HTTPException, status
from fastapi import APIRouter, Depends, File, HTTPException, UploadFile, status
from fastapi.responses import Response
from sqlalchemy.orm import Session
@@ -240,6 +240,63 @@ def get_product_image(
)
@router.put("/{product_id}/image", response_model=ProductOut)
async def upload_product_image(
product_id: int,
file: UploadFile = File(...),
db: Session = Depends(get_db),
_: User = Depends(require_admin),
) -> ProductOut:
"""Eigenes Foto hochladen (Kamera oder Galerie) und beim Artikel ablegen.
``source_url = None`` markiert das Bild als selbst hochgeladen so ersetzt
es der Bildabgleich beim Speichern nicht durch ein Bild aus einer Adresse
(solange keine Bildadresse gesetzt ist).
"""
product = db.get(Product, product_id)
if product is None:
raise HTTPException(status.HTTP_404_NOT_FOUND, "Produkt nicht gefunden")
if file.content_type not in images.ALLOWED_TYPES:
erlaubt = ", ".join(sorted(t.split("/")[-1] for t in images.ALLOWED_TYPES))
raise HTTPException(
status.HTTP_400_BAD_REQUEST,
f"Dieses Bildformat wird nicht unterstützt. Erlaubt sind: {erlaubt}.",
)
data = await file.read()
if not data:
raise HTTPException(status.HTTP_400_BAD_REQUEST, "Die Datei ist leer.")
if len(data) > images.MAX_BYTES:
raise HTTPException(
status.HTTP_400_BAD_REQUEST,
f"Das Bild ist zu groß ({len(data) // 1024} KB). "
f"Erlaubt sind höchstens {images.MAX_BYTES // 1024} KB.",
)
bild = db.get(ProductImage, product.id)
if bild is None:
bild = ProductImage(
product_id=product.id, content_type=file.content_type, data=data, source_url=None
)
db.add(bild)
else:
bild.content_type, bild.data, bild.source_url = file.content_type, data, None
db.commit()
db.refresh(product)
return product_to_out(db, product)
@router.delete("/{product_id}/image", status_code=status.HTTP_204_NO_CONTENT)
def delete_product_image(
product_id: int,
db: Session = Depends(get_db),
_: User = Depends(require_admin),
) -> None:
"""Foto entfernen. Eine gesetzte Bildadresse bleibt bestehen."""
bild = db.get(ProductImage, product_id)
if bild is not None:
db.delete(bild)
db.commit()
@router.post("", response_model=ProductOut, status_code=status.HTTP_201_CREATED)
def create_product(
payload: ProductCreate,

View File

@@ -49,8 +49,26 @@ router = APIRouter(tags=["transfer"])
CSV_FIELDS = [
"barcode", "name", "marke", "einheit", "packungsgroesse", "gebinde",
"gruppe", "kategorie", "mindestbestand", "menge", "menge_einheit", "mhd", "lagerort",
"gruppe", "kategorie", "art", "mindestbestand", "menge", "menge_einheit", "mhd", "lagerort",
]
def _tracking_from_art(text) -> str:
"""Spalte „art" → Verwaltungsart einer (neu angelegten) Kategorie.
Leer/unbekannt ⇒ Lebensmittel (bewahrt das Verhalten für zurückgespielte
Lebensmittel-Exporte).
"""
t = (str(text) if text is not None else "").strip().lower()
if t in ("gegenstand", "gegenstände", "object", "objekt", "non-food", "nonfood"):
return CategoryTracking.object.value
return CategoryTracking.food.value
def _art_label(product: Product) -> str:
if product.category and product.category.tracking == CategoryTracking.object.value:
return "Gegenstand"
return "Lebensmittel"
PACKAGE_TOKENS = {"packung", "package", "pkg", "pack"}
@@ -95,6 +113,7 @@ def export_stock_csv(
product.package_label or "",
product.group.name if product.group else "",
_category_path(db, product.category),
_art_label(product),
product.min_stock if product.min_stock is not None else "",
]
lots = (
@@ -437,7 +456,9 @@ def _get_or_create_product(db: Session, row: dict, created: list[str]) -> Produc
if unit is None:
raise ValueError(f"Unbekannte Einheit: {row.get('einheit')}")
group = _get_or_create_group(db, row.get("gruppe"))
category = _get_or_create_category(db, row.get("kategorie"))
category = _get_or_create_category(
db, row.get("kategorie"), tracking=_tracking_from_art(row.get("art"))
)
product = Product(
barcode=barcode,
name=name,

View File

@@ -121,6 +121,37 @@ actor APIClient {
return try await send(request, as: Product.self)
}
/// Aktuelles Artikelfoto laden (mit Anmeldung). Gibt nil bei 404 zurück.
func productImage(id: Int) async throws -> Data? {
let request = try makeRequest("/products/\(id)/image")
let (data, response) = try await URLSession.shared.data(for: request)
if let http = response as? HTTPURLResponse, http.statusCode == 404 { return nil }
try check(response, data: data)
return data
}
/// Eigenes Foto (Kamera/Galerie) als multipart hochladen.
func uploadProductImage(id: Int, data: Data, contentType: String) async throws -> Product {
var request = try makeRequest("/products/\(id)/image", method: "PUT")
let boundary = "Boundary-\(UUID().uuidString)"
request.setValue("multipart/form-data; boundary=\(boundary)",
forHTTPHeaderField: "Content-Type")
let ext = contentType == "image/png" ? "png" : "jpg"
var body = Data()
body.append("--\(boundary)\r\n".data(using: .utf8)!)
body.append("Content-Disposition: form-data; name=\"file\"; filename=\"foto.\(ext)\"\r\n"
.data(using: .utf8)!)
body.append("Content-Type: \(contentType)\r\n\r\n".data(using: .utf8)!)
body.append(data)
body.append("\r\n--\(boundary)--\r\n".data(using: .utf8)!)
request.httpBody = body
return try await send(request, as: Product.self)
}
func deleteProductImage(id: Int) async throws {
try await sendNoContent(try makeRequest("/products/\(id)/image", method: "DELETE"))
}
func groups() async throws -> [GroupItem] {
try await send(try makeRequest("/groups"), as: [GroupItem].self)
}

View File

@@ -49,6 +49,8 @@ struct ProductDetailView: View {
Section { Text(error).foregroundStyle(.red).font(.callout) }
}
ProductPhotoSection(productId: current.id)
if current.isObject {
ObjectStockSection(product: current, locations: locations, lots: lots,
onChanged: { await reload() })

View File

@@ -0,0 +1,139 @@
import SwiftUI
import PhotosUI
/// Artikelfoto ansehen, per Kamera aufnehmen oder aus der Galerie wählen und
/// hochladen. Wird als Abschnitt in die Produkt-Detailansicht eingebettet.
struct ProductPhotoSection: View {
let productId: Int
@State private var imageData: Data?
@State private var loaded = false
@State private var busy = false
@State private var error: String?
@State private var showCamera = false
@State private var pickerItem: PhotosPickerItem?
var body: some View {
Section("Foto") {
if let imageData, let ui = UIImage(data: imageData) {
Image(uiImage: ui)
.resizable().scaledToFit()
.frame(maxWidth: .infinity)
.frame(maxHeight: 240)
.clipShape(RoundedRectangle(cornerRadius: 8))
} else if loaded {
Text("Noch kein Foto.").foregroundStyle(.secondary)
}
Button { showCamera = true } label: {
Label("Foto machen", systemImage: "camera")
}
PhotosPicker(selection: $pickerItem, matching: .images) {
Label("Aus Galerie", systemImage: "photo.on.rectangle")
}
if imageData != nil {
Button(role: .destructive) { Task { await removePhoto() } } label: {
Label("Foto entfernen", systemImage: "trash")
}
}
if busy { ProgressView() }
if let error { Text(error).foregroundStyle(.red).font(.callout) }
}
.sheet(isPresented: $showCamera) {
CameraPicker { image in Task { await upload(image) } }
.ignoresSafeArea()
}
.onChange(of: pickerItem) { item in
guard let item else { return }
Task {
if let data = try? await item.loadTransferable(type: Data.self),
let ui = UIImage(data: data) {
await upload(ui)
}
pickerItem = nil
}
}
.task { await load() }
}
private func load() async {
imageData = try? await APIClient.shared.productImage(id: productId)
loaded = true
}
private func upload(_ image: UIImage) async {
guard let data = Self.prepared(image) else { return }
busy = true
defer { busy = false }
error = nil
do {
_ = try await APIClient.shared.uploadProductImage(
id: productId, data: data, contentType: "image/jpeg")
imageData = data
} catch {
self.error = error.localizedDescription
}
}
private func removePhoto() async {
busy = true
defer { busy = false }
error = nil
do {
try await APIClient.shared.deleteProductImage(id: productId)
imageData = nil
} catch {
self.error = error.localizedDescription
}
}
/// Foto verkleinern und als JPEG unter die Server-Grenze (2 MB) drücken.
private static func prepared(_ image: UIImage, maxDim: CGFloat = 1600) -> Data? {
let size = image.size
let scale = min(1, maxDim / max(size.width, size.height))
let target = CGSize(width: size.width * scale, height: size.height * scale)
let scaled = UIGraphicsImageRenderer(size: target).image { _ in
image.draw(in: CGRect(origin: .zero, size: target))
}
var quality: CGFloat = 0.8
var data = scaled.jpegData(compressionQuality: quality)
while let d = data, d.count > 1_800_000, quality > 0.3 {
quality -= 0.15
data = scaled.jpegData(compressionQuality: quality)
}
return data
}
}
/// Kamera über UIImagePickerController SwiftUI hat keine eigene Kamera-Ansicht.
/// Ohne Kamera (Simulator) fällt es auf die Fotoauswahl zurück.
struct CameraPicker: UIViewControllerRepresentable {
var onImage: (UIImage) -> Void
@Environment(\.dismiss) private var dismiss
func makeUIViewController(context: Context) -> UIImagePickerController {
let picker = UIImagePickerController()
picker.sourceType = UIImagePickerController.isSourceTypeAvailable(.camera)
? .camera : .photoLibrary
picker.delegate = context.coordinator
return picker
}
func updateUIViewController(_ picker: UIImagePickerController, context: Context) {}
func makeCoordinator() -> Coordinator { Coordinator(self) }
final class Coordinator: NSObject, UINavigationControllerDelegate, UIImagePickerControllerDelegate {
let parent: CameraPicker
init(_ parent: CameraPicker) { self.parent = parent }
func imagePickerController(_ picker: UIImagePickerController,
didFinishPickingMediaWithInfo info: [UIImagePickerController.InfoKey: Any]) {
if let image = info[.originalImage] as? UIImage { parent.onImage(image) }
parent.dismiss()
}
func imagePickerControllerDidCancel(_ picker: UIImagePickerController) {
parent.dismiss()
}
}
}

View File

@@ -14,6 +14,7 @@
38133EE3EC920B462BDAA29F /* NotificationScheduler.swift in Sources */ = {isa = PBXBuildFile; fileRef = 163F5DCB00387215F0EA1F21 /* NotificationScheduler.swift */; };
39F1F17DCB4E491652D08DEE /* APIClient.swift in Sources */ = {isa = PBXBuildFile; fileRef = 378F1B2DE567B62169E84426 /* APIClient.swift */; };
44F08407AF69F5B7FD15F932 /* ScannerView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8AF19A735AC17451B57BF5BB /* ScannerView.swift */; };
4AC141151264AF22701079B6 /* ProductPhotoView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5312AFE88B7284401AA7B0A3 /* ProductPhotoView.swift */; };
4B4A74E8A2964FF8F3D2CE02 /* NotificationSettings.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3731608B8D48960DC98912BC /* NotificationSettings.swift */; };
4C486D190D1AB338925D29B5 /* HistoryView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7DACFBB69CB536BA7CBBD484 /* HistoryView.swift */; };
4F86620DF8303DB02314DB5A /* CheckInView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0365A16FEAE3F2BEC321E68A /* CheckInView.swift */; };
@@ -55,6 +56,7 @@
378F1B2DE567B62169E84426 /* APIClient.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = APIClient.swift; sourceTree = "<group>"; };
4741D0E95875919C921945CF /* RootView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RootView.swift; sourceTree = "<group>"; };
4BF4E4F5524B15728CEEE116 /* Vorrania.app */ = {isa = PBXFileReference; includeInIndex = 0; lastKnownFileType = wrapper.application; path = Vorrania.app; sourceTree = BUILT_PRODUCTS_DIR; };
5312AFE88B7284401AA7B0A3 /* ProductPhotoView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ProductPhotoView.swift; sourceTree = "<group>"; };
660ACEFA22BC77D52FBE736A /* AppDashboards.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppDashboards.swift; sourceTree = "<group>"; };
6F574168AA0F849D46C384EE /* ProductDetailView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ProductDetailView.swift; sourceTree = "<group>"; };
717C8EB336170526F5F3E695 /* DateScanView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DateScanView.swift; sourceTree = "<group>"; };
@@ -104,6 +106,7 @@
939C7B7C2F2F58927ED5F2F1 /* NotificationSettingsView.swift */,
7B5BB49FBEFF88768B0BC902 /* ObjectStockView.swift */,
6F574168AA0F849D46C384EE /* ProductDetailView.swift */,
5312AFE88B7284401AA7B0A3 /* ProductPhotoView.swift */,
369B9841E43E727ACA2E2A2A /* ProductViews.swift */,
4741D0E95875919C921945CF /* RootView.swift */,
8AF19A735AC17451B57BF5BB /* ScannerView.swift */,
@@ -227,6 +230,7 @@
17032C3F3B89BAD6CB249443 /* NotificationSettingsView.swift in Sources */,
D7717F44D2CDB75DC2693B59 /* ObjectStockView.swift in Sources */,
AAF27320D2A67C994C29C26F /* ProductDetailView.swift in Sources */,
4AC141151264AF22701079B6 /* ProductPhotoView.swift in Sources */,
0D8629839E465D80EC58E74B /* ProductViews.swift in Sources */,
9B0A1A7C19764857BDC6ED26 /* RootView.swift in Sources */,
44F08407AF69F5B7FD15F932 /* ScannerView.swift in Sources */,

View File

@@ -147,6 +147,13 @@ export const api = {
addProductBarcode: (id, body) => request(`/products/${id}/barcodes`, { method: "POST", body }),
deleteProductBarcode: (id, code) =>
request(`/products/${id}/barcodes/${encodeURIComponent(code)}`, { method: "DELETE" }),
// Artikelfoto hochladen (Kamera/Galerie) bzw. entfernen.
uploadProductImage: (id, file) => {
const fd = new FormData();
fd.append("file", file);
return request(`/products/${id}/image`, { method: "PUT", formData: fd });
},
deleteProductImage: (id) => request(`/products/${id}/image`, { method: "DELETE" }),
// Bestand
checkIn: (body) => request("/stock/checkin", { method: "POST", body }),

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,8 +637,49 @@ 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">
@@ -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>
{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>
)}
</>)}

View File

@@ -858,3 +858,8 @@ tr.row-active { background: var(--surface-2); }
.card-sub { border: 1px solid var(--border); border-radius: var(--radius); padding: var(--sp-3); background: var(--surface-2); }
.card-sub .card-head { margin-top: 0; }
.card-sub h3 { margin: 0; font-size: 15px; }
/* Artikelfoto mit Aufnahme-/Galerie-Knöpfen */
.produkt-foto { display: flex; flex-direction: column; gap: var(--sp-2); align-items: center; }
.foto-knoepfe { display: flex; flex-wrap: wrap; gap: var(--sp-1); justify-content: center; }
.foto-knoepfe .btn { cursor: pointer; }