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 fastapi.responses import Response
from sqlalchemy.orm import Session 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) @router.post("", response_model=ProductOut, status_code=status.HTTP_201_CREATED)
def create_product( def create_product(
payload: ProductCreate, payload: ProductCreate,

View File

@@ -49,8 +49,26 @@ router = APIRouter(tags=["transfer"])
CSV_FIELDS = [ CSV_FIELDS = [
"barcode", "name", "marke", "einheit", "packungsgroesse", "gebinde", "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"} PACKAGE_TOKENS = {"packung", "package", "pkg", "pack"}
@@ -95,6 +113,7 @@ def export_stock_csv(
product.package_label or "", product.package_label or "",
product.group.name if product.group else "", product.group.name if product.group else "",
_category_path(db, product.category), _category_path(db, product.category),
_art_label(product),
product.min_stock if product.min_stock is not None else "", product.min_stock if product.min_stock is not None else "",
] ]
lots = ( lots = (
@@ -437,7 +456,9 @@ def _get_or_create_product(db: Session, row: dict, created: list[str]) -> Produc
if unit is None: if unit is None:
raise ValueError(f"Unbekannte Einheit: {row.get('einheit')}") raise ValueError(f"Unbekannte Einheit: {row.get('einheit')}")
group = _get_or_create_group(db, row.get("gruppe")) 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( product = Product(
barcode=barcode, barcode=barcode,
name=name, name=name,

View File

@@ -121,6 +121,37 @@ actor APIClient {
return try await send(request, as: Product.self) 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] { func groups() async throws -> [GroupItem] {
try await send(try makeRequest("/groups"), as: [GroupItem].self) 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) } Section { Text(error).foregroundStyle(.red).font(.callout) }
} }
ProductPhotoSection(productId: current.id)
if current.isObject { if current.isObject {
ObjectStockSection(product: current, locations: locations, lots: lots, ObjectStockSection(product: current, locations: locations, lots: lots,
onChanged: { await reload() }) 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 */; }; 38133EE3EC920B462BDAA29F /* NotificationScheduler.swift in Sources */ = {isa = PBXBuildFile; fileRef = 163F5DCB00387215F0EA1F21 /* NotificationScheduler.swift */; };
39F1F17DCB4E491652D08DEE /* APIClient.swift in Sources */ = {isa = PBXBuildFile; fileRef = 378F1B2DE567B62169E84426 /* APIClient.swift */; }; 39F1F17DCB4E491652D08DEE /* APIClient.swift in Sources */ = {isa = PBXBuildFile; fileRef = 378F1B2DE567B62169E84426 /* APIClient.swift */; };
44F08407AF69F5B7FD15F932 /* ScannerView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8AF19A735AC17451B57BF5BB /* ScannerView.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 */; }; 4B4A74E8A2964FF8F3D2CE02 /* NotificationSettings.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3731608B8D48960DC98912BC /* NotificationSettings.swift */; };
4C486D190D1AB338925D29B5 /* HistoryView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7DACFBB69CB536BA7CBBD484 /* HistoryView.swift */; }; 4C486D190D1AB338925D29B5 /* HistoryView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7DACFBB69CB536BA7CBBD484 /* HistoryView.swift */; };
4F86620DF8303DB02314DB5A /* CheckInView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0365A16FEAE3F2BEC321E68A /* CheckInView.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>"; }; 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>"; }; 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; }; 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>"; }; 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>"; }; 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>"; }; 717C8EB336170526F5F3E695 /* DateScanView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DateScanView.swift; sourceTree = "<group>"; };
@@ -104,6 +106,7 @@
939C7B7C2F2F58927ED5F2F1 /* NotificationSettingsView.swift */, 939C7B7C2F2F58927ED5F2F1 /* NotificationSettingsView.swift */,
7B5BB49FBEFF88768B0BC902 /* ObjectStockView.swift */, 7B5BB49FBEFF88768B0BC902 /* ObjectStockView.swift */,
6F574168AA0F849D46C384EE /* ProductDetailView.swift */, 6F574168AA0F849D46C384EE /* ProductDetailView.swift */,
5312AFE88B7284401AA7B0A3 /* ProductPhotoView.swift */,
369B9841E43E727ACA2E2A2A /* ProductViews.swift */, 369B9841E43E727ACA2E2A2A /* ProductViews.swift */,
4741D0E95875919C921945CF /* RootView.swift */, 4741D0E95875919C921945CF /* RootView.swift */,
8AF19A735AC17451B57BF5BB /* ScannerView.swift */, 8AF19A735AC17451B57BF5BB /* ScannerView.swift */,
@@ -227,6 +230,7 @@
17032C3F3B89BAD6CB249443 /* NotificationSettingsView.swift in Sources */, 17032C3F3B89BAD6CB249443 /* NotificationSettingsView.swift in Sources */,
D7717F44D2CDB75DC2693B59 /* ObjectStockView.swift in Sources */, D7717F44D2CDB75DC2693B59 /* ObjectStockView.swift in Sources */,
AAF27320D2A67C994C29C26F /* ProductDetailView.swift in Sources */, AAF27320D2A67C994C29C26F /* ProductDetailView.swift in Sources */,
4AC141151264AF22701079B6 /* ProductPhotoView.swift in Sources */,
0D8629839E465D80EC58E74B /* ProductViews.swift in Sources */, 0D8629839E465D80EC58E74B /* ProductViews.swift in Sources */,
9B0A1A7C19764857BDC6ED26 /* RootView.swift in Sources */, 9B0A1A7C19764857BDC6ED26 /* RootView.swift in Sources */,
44F08407AF69F5B7FD15F932 /* ScannerView.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 }), addProductBarcode: (id, body) => request(`/products/${id}/barcodes`, { method: "POST", body }),
deleteProductBarcode: (id, code) => deleteProductBarcode: (id, code) =>
request(`/products/${id}/barcodes/${encodeURIComponent(code)}`, { method: "DELETE" }), 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 // Bestand
checkIn: (body) => request("/stock/checkin", { method: "POST", body }), checkIn: (body) => request("/stock/checkin", { method: "POST", body }),

View File

@@ -10,7 +10,7 @@ import ProduktBild from "../components/ProduktBild";
import { useToast } from "../toast"; import { useToast } from "../toast";
import { useSettings } from "../settings"; import { useSettings } from "../settings";
import { asTree } from "../categoryTree"; import { asTree } from "../categoryTree";
import { DynamicFields } from "../fields"; import { DynamicFields, FIELD_TYPES } from "../fields";
import ObjektBestand from "../components/ObjektBestand"; import ObjektBestand from "../components/ObjektBestand";
import { import {
daysUntil, expiryRowClass, fmt, fromMonthInput, gebinde, isExpired, relativeExpiry, 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. // Gegenstände: effektive (vererbte) Felder der Kategorie + deren Werte am Artikel.
const [effFields, setEffFields] = useState([]); const [effFields, setEffFields] = useState([]);
const [fieldValues, setFieldValues] = 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 [error, setError] = useState(null);
const [busy, setBusy] = useState(false); const [busy, setBusy] = useState(false);
// Einheit für die Mindestbestand-Eingabe: "package" oder die ID einer Einheit. // 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 })); 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) { function canonicalUnitId(unitList, baseUnit) {
const name = CANONICAL_NAME[baseUnit]; const name = CANONICAL_NAME[baseUnit];
const hit = unitList.find((u) => u.name === name); 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} /> <input value={form.brand} onChange={(e) => set("brand", e.target.value)} disabled={readOnly} />
</label> </label>
</div> </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> </div>
{offDaten && ( {offDaten && (
@@ -540,9 +637,50 @@ export default function ProductForm() {
))} ))}
</select> </select>
</label> </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> </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 && (<> {isObject && (<>
<div className="row"> <div className="row">
<label className="grow"> <label className="grow">
@@ -565,13 +703,60 @@ export default function ProductForm() {
</div> </div>
</label> </label>
</div> </div>
{effFields.length > 0 && ( {form.category_id !== "" && (
<div style={{ marginTop: "var(--sp-2)" }}> <div style={{ marginTop: "var(--sp-2)" }}>
<div className="card-head" style={{ marginBottom: "var(--sp-2)" }}> <div className="card-head" style={{ marginBottom: "var(--sp-2)" }}>
<Icon name="tag" /><h2>Eigene Felder</h2> <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> </div>
<DynamicFields fields={effFields} values={fieldValues} {effFields.length > 0 ? (
onChange={setField} disabled={readOnly} /> <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> </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 { 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 .card-head { margin-top: 0; }
.card-sub h3 { margin: 0; font-size: 15px; } .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; }