From 044f63446f9785fdd99e72703a8b84e8e9edd601 Mon Sep 17 00:00:00 2001 From: Scarriffle Date: Sat, 25 Jul 2026 16:18:36 +0200 Subject: [PATCH] Inline-Anlage (Kategorien/Felder), Artikelfoto und CSV-Spalte "art" MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 --- backend/app/routers/products.py | 59 +++++++- backend/app/routers/transfer.py | 25 +++- ios/Sources/APIClient.swift | 31 ++++ ios/Sources/ProductDetailView.swift | 2 + ios/Sources/ProductPhotoView.swift | 139 +++++++++++++++++ ios/Vorrania.xcodeproj/project.pbxproj | 4 + web/src/api.js | 7 + web/src/pages/ProductForm.jsx | 197 ++++++++++++++++++++++++- web/src/styles.css | 5 + 9 files changed, 460 insertions(+), 9 deletions(-) create mode 100644 ios/Sources/ProductPhotoView.swift diff --git a/backend/app/routers/products.py b/backend/app/routers/products.py index e962c1a..5f0a913 100644 --- a/backend/app/routers/products.py +++ b/backend/app/routers/products.py @@ -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, diff --git a/backend/app/routers/transfer.py b/backend/app/routers/transfer.py index 482daf3..6f962ec 100644 --- a/backend/app/routers/transfer.py +++ b/backend/app/routers/transfer.py @@ -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, diff --git a/ios/Sources/APIClient.swift b/ios/Sources/APIClient.swift index ca759c6..bfd31c8 100644 --- a/ios/Sources/APIClient.swift +++ b/ios/Sources/APIClient.swift @@ -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) } diff --git a/ios/Sources/ProductDetailView.swift b/ios/Sources/ProductDetailView.swift index 98accf9..4be2943 100644 --- a/ios/Sources/ProductDetailView.swift +++ b/ios/Sources/ProductDetailView.swift @@ -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() }) diff --git a/ios/Sources/ProductPhotoView.swift b/ios/Sources/ProductPhotoView.swift new file mode 100644 index 0000000..2801d1b --- /dev/null +++ b/ios/Sources/ProductPhotoView.swift @@ -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() + } + } +} diff --git a/ios/Vorrania.xcodeproj/project.pbxproj b/ios/Vorrania.xcodeproj/project.pbxproj index 4c47b2e..55ecea1 100644 --- a/ios/Vorrania.xcodeproj/project.pbxproj +++ b/ios/Vorrania.xcodeproj/project.pbxproj @@ -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 = ""; }; 4741D0E95875919C921945CF /* RootView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RootView.swift; sourceTree = ""; }; 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 = ""; }; 660ACEFA22BC77D52FBE736A /* AppDashboards.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppDashboards.swift; sourceTree = ""; }; 6F574168AA0F849D46C384EE /* ProductDetailView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ProductDetailView.swift; sourceTree = ""; }; 717C8EB336170526F5F3E695 /* DateScanView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DateScanView.swift; sourceTree = ""; }; @@ -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 */, diff --git a/web/src/api.js b/web/src/api.js index 8a88fe0..26d1ead 100644 --- a/web/src/api.js +++ b/web/src/api.js @@ -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 }), diff --git a/web/src/pages/ProductForm.jsx b/web/src/pages/ProductForm.jsx index e0c8a8b..bd7dfc5 100644 --- a/web/src/pages/ProductForm.jsx +++ b/web/src/pages/ProductForm.jsx @@ -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() { set("brand", e.target.value)} disabled={readOnly} /> - {!isNew && } + {!isNew && ( +
+ + {isAdmin && ( +
+ + + +
+ )} +
+ )} {offDaten && ( @@ -540,9 +637,50 @@ export default function ProductForm() { ))} -
+ {isAdmin && ( +
+ +
+ )}
+ {isAdmin && newCat && ( +
+

Neue Kategorie / Unterkategorie

+ +
+
+ + + +
+ +
+ )} + {isObject && (<>
- {effFields.length > 0 && ( + {form.category_id !== "" && (

Eigene Felder

+ {isAdmin && ( + + )}
- + {effFields.length > 0 ? ( + + ) : ( +

Für diese Kategorie sind noch keine Felder festgelegt.

+ )} + {isAdmin && newField && ( +
+
+ + +
+ {newField.field_type === "number" && ( + + )} + {newField.field_type === "select" && ( + + )} + + +
+ )}
)} )} diff --git a/web/src/styles.css b/web/src/styles.css index 576b627..f33a90d 100644 --- a/web/src/styles.css +++ b/web/src/styles.css @@ -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; }