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