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:
@@ -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,
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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() })
|
||||||
|
|||||||
139
ios/Sources/ProductPhotoView.swift
Normal file
139
ios/Sources/ProductPhotoView.swift
Normal 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()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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 */,
|
||||||
|
|||||||
@@ -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 }),
|
||||||
|
|||||||
@@ -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 & 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>
|
||||||
)}
|
)}
|
||||||
</>)}
|
</>)}
|
||||||
|
|||||||
@@ -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; }
|
||||||
|
|||||||
Reference in New Issue
Block a user