Lagerort-IDs sind jetzt ein zufaelliger 10-Zeichen-Code (wie die Einzelstueck-UIDs) statt einer fortlaufenden Zahl - so kollidiert die Stammdaten-Sicherung zwischen zwei Instanzen praktisch nie mehr, und der Code ist zugleich der Inhalt des QR /l/<code>. Alle Fremdschluessel (lots, movements, items, Mindestbestaende, parent_id) ziehen mit; die Umstellung laeuft einmalig und transaktional beim Serverstart (_migrate_locations_to_code) und rollt bei Fehlern komplett zurueck. Vor dem Deploy ein DB-Backup machen. iOS-Einlagern oeffnet nicht mehr sofort die Kamera, sondern ein Formular mit Artikelsuche; die Kamera kommt erst per Button. Im Formular laesst sich der Lagerort zusaetzlich per /l/-QR scannen. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
569 lines
24 KiB
Swift
569 lines
24 KiB
Swift
import SwiftUI
|
||
import CoreImage
|
||
import PhotosUI
|
||
import QuickLook
|
||
import UniformTypeIdentifiers
|
||
|
||
/// QR-Code als Bild (CoreImage). Inhalt ist der Link auf das Einzelstück, damit
|
||
/// ein Scan – auch mit der Systemkamera – die App/Weboberfläche öffnet.
|
||
enum QRImage {
|
||
static func make(_ text: String, scale: CGFloat = 8) -> UIImage? {
|
||
guard let data = text.data(using: .utf8),
|
||
let filter = CIFilter(name: "CIQRCodeGenerator") else { return nil }
|
||
filter.setValue(data, forKey: "inputMessage")
|
||
filter.setValue("M", forKey: "inputCorrectionLevel")
|
||
guard let ci = filter.outputImage?
|
||
.transformed(by: CGAffineTransform(scaleX: scale, y: scale)) else { return nil }
|
||
let context = CIContext()
|
||
guard let cg = context.createCGImage(ci, from: ci.extent) else { return nil }
|
||
return UIImage(cgImage: cg)
|
||
}
|
||
}
|
||
|
||
func qrLink(forUid uid: String) -> String {
|
||
let base = Session.shared.baseURL?.absoluteString
|
||
.trimmingCharacters(in: CharacterSet(charactersIn: "/")) ?? ""
|
||
return base.isEmpty ? uid : "\(base)/i/\(uid)"
|
||
}
|
||
|
||
private func dateToString(_ d: Date) -> String {
|
||
let f = DateFormatter(); f.dateFormat = "yyyy-MM-dd"; return f.string(from: d)
|
||
}
|
||
private func stringToDate(_ s: String) -> Date? {
|
||
let f = DateFormatter(); f.dateFormat = "yyyy-MM-dd"; return f.date(from: s)
|
||
}
|
||
|
||
// MARK: - Liste (in der Produkt-Detailansicht)
|
||
|
||
/// Einzelstücke eines Gegenstands: Liste mit Anlegen; jedes Stück öffnet die
|
||
/// Detail-/Bearbeiten-Ansicht.
|
||
struct ProductItemsSection: View {
|
||
let product: Product
|
||
var onChanged: () async -> Void
|
||
|
||
@State private var items: [Item] = []
|
||
@State private var showAdd = false
|
||
|
||
var body: some View {
|
||
Section("Einzelstücke (\(items.count))") {
|
||
ForEach(items) { it in
|
||
NavigationLink {
|
||
ItemEditView(item: it) { Task { await refresh() } }
|
||
} label: {
|
||
VStack(alignment: .leading, spacing: 2) {
|
||
Text(it.uid).font(.body.monospaced()).bold()
|
||
Text(untertitel(it)).font(.caption).foregroundStyle(.secondary)
|
||
}
|
||
}
|
||
}
|
||
if items.isEmpty {
|
||
Text("Noch keine Einzelstücke.").foregroundStyle(.secondary)
|
||
}
|
||
Button { showAdd = true } label: {
|
||
Label("Einzelstücke anlegen", systemImage: "plus")
|
||
}
|
||
}
|
||
.task(id: product.id) { await reload() }
|
||
.sheet(isPresented: $showAdd) {
|
||
NavigationStack {
|
||
ItemAddSheet(productId: product.id) { await refresh() }
|
||
}
|
||
}
|
||
}
|
||
|
||
private func untertitel(_ it: Item) -> String {
|
||
[it.locationName, it.acquiredOn.map { "gekauft \($0)" }, it.shopName]
|
||
.compactMap { $0 }.joined(separator: " · ")
|
||
}
|
||
private func reload() async {
|
||
items = (try? await APIClient.shared.items(productId: product.id)) ?? []
|
||
}
|
||
private func refresh() async { await reload(); await onChanged() }
|
||
}
|
||
|
||
// MARK: - Detail / Bearbeiten (auch Ziel eines Scans)
|
||
|
||
struct ItemEditView: View {
|
||
let item: Item
|
||
var onChanged: (() -> Void)? = nil
|
||
|
||
@Environment(\.dismiss) private var dismiss
|
||
|
||
@State private var locations: [StorageLocation] = []
|
||
@State private var shops: [ShopItem] = []
|
||
@State private var photo: Data?
|
||
@State private var locationId: String?
|
||
@State private var shopId: Int?
|
||
@State private var hasAcquired = false
|
||
@State private var acquired = Date()
|
||
@State private var hasWarranty = false
|
||
@State private var warranty = Date()
|
||
@State private var note = ""
|
||
@State private var priceText = ""
|
||
@State private var currency = "CHF"
|
||
@State private var documents: [ItemDocument] = []
|
||
@State private var suggWarranty: String?
|
||
@State private var suggPrice: Int?
|
||
@State private var suggPriceCandidates: [Int] = []
|
||
@State private var suggAcquired: String?
|
||
@State private var suggShopId: Int?
|
||
@State private var suggShopName: String?
|
||
@State private var busy = false
|
||
@State private var busyDoc = false
|
||
@State private var error: String?
|
||
@State private var showRemove = false
|
||
@State private var reason = "broken"
|
||
@State private var removeNote = ""
|
||
@State private var showFileImporter = false
|
||
@State private var pickerItem: PhotosPickerItem?
|
||
@State private var previewURL: URL?
|
||
|
||
var body: some View {
|
||
Form {
|
||
Section {
|
||
if let photo, let ui = UIImage(data: photo) {
|
||
Image(uiImage: ui)
|
||
.resizable().scaledToFit()
|
||
.frame(maxWidth: .infinity).frame(maxHeight: 220)
|
||
.clipShape(RoundedRectangle(cornerRadius: 8))
|
||
}
|
||
if let name = item.productName {
|
||
LabeledContent("Artikel", value: name)
|
||
}
|
||
if let brand = item.productBrand, !brand.isEmpty {
|
||
LabeledContent("Marke", value: brand)
|
||
}
|
||
LabeledContent("UID", value: item.uid)
|
||
}
|
||
|
||
Section {
|
||
Picker("Lagerort", selection: $locationId) {
|
||
Text("– ohne –").tag(String?.none)
|
||
ForEach(locations) { l in Text(l.name).tag(String?.some(l.id)) }
|
||
}
|
||
Picker("Gekauft bei", selection: $shopId) {
|
||
Text("– unbekannt –").tag(Int?.none)
|
||
ForEach(shops) { s in Text(s.name).tag(Int?.some(s.id)) }
|
||
}
|
||
Toggle("Kaufdatum", isOn: $hasAcquired)
|
||
if hasAcquired { DatePicker("Gekauft am", selection: $acquired, displayedComponents: .date) }
|
||
Toggle("Garantie", isOn: $hasWarranty)
|
||
if hasWarranty { DatePicker("Garantie bis", selection: $warranty, displayedComponents: .date) }
|
||
LabeledField(label: "Notiz", text: $note)
|
||
}
|
||
|
||
Section("Kaufpreis") {
|
||
HStack {
|
||
TextField("0.00", text: $priceText).keyboardType(.decimalPad)
|
||
Picker("", selection: $currency) {
|
||
Text("CHF").tag("CHF")
|
||
Text("EUR").tag("EUR")
|
||
}
|
||
.pickerStyle(.segmented).frame(width: 130)
|
||
}
|
||
}
|
||
|
||
Section("Belege (Rechnung/Garantieschein)") {
|
||
if hatVorschlag {
|
||
VStack(alignment: .leading, spacing: 6) {
|
||
Text("Im Beleg erkannt:").font(.caption).foregroundStyle(.secondary)
|
||
if let a = suggAcquired { Text("Gekauft am \(a)") }
|
||
if let w = suggWarranty { Text("Garantie bis \(w)") }
|
||
if suggPriceCandidates.count > 1 {
|
||
// Mehrere Beträge gefunden – Auswahl anbieten.
|
||
Picker("Kaufpreis", selection: Binding(
|
||
get: { suggPrice ?? suggPriceCandidates.first ?? 0 },
|
||
set: { suggPrice = $0 }
|
||
)) {
|
||
ForEach(suggPriceCandidates, id: \.self) { c in
|
||
Text("\(ItemEditView.formatCents(c)) \(currency)").tag(c)
|
||
}
|
||
}
|
||
.pickerStyle(.menu)
|
||
} else if let p = suggPrice {
|
||
Text("Kaufpreis \(ItemEditView.formatCents(p)) \(currency)")
|
||
}
|
||
if let sid = suggShopId {
|
||
Text("Shop: \(shops.first(where: { $0.id == sid })?.name ?? "bekannt")")
|
||
} else if let name = suggShopName {
|
||
Text("Shop anlegen: \(name)")
|
||
}
|
||
HStack {
|
||
Button("Übernehmen") { Task { await applySuggestion() } }
|
||
Spacer()
|
||
Button("Verwerfen") { verwerfeVorschlag() }
|
||
.foregroundStyle(.secondary)
|
||
}
|
||
.font(.callout)
|
||
}
|
||
}
|
||
ForEach(documents) { d in
|
||
Button { Task { await openDocument(d) } } label: {
|
||
Label(d.filename, systemImage: "doc.text")
|
||
}
|
||
.swipeActions(edge: .trailing) {
|
||
Button("Löschen", role: .destructive) { Task { await deleteDoc(d) } }
|
||
}
|
||
}
|
||
if documents.isEmpty {
|
||
Text("Noch keine Belege.").foregroundStyle(.secondary)
|
||
}
|
||
PhotosPicker(selection: $pickerItem, matching: .images) {
|
||
Label("Bild hochladen", systemImage: "photo")
|
||
}
|
||
Button { showFileImporter = true } label: {
|
||
Label("PDF hochladen", systemImage: "doc.badge.plus")
|
||
}
|
||
if busyDoc { ProgressView() }
|
||
}
|
||
|
||
if let error { Section { Text(error).foregroundStyle(.red).font(.callout) } }
|
||
|
||
Section {
|
||
Button(busy ? "Speichern…" : "Speichern") { Task { await save() } }.disabled(busy)
|
||
Button("Entfernen (mit Grund)", role: .destructive) { showRemove = true }
|
||
}
|
||
}
|
||
.navigationTitle("Einzelstück")
|
||
.navigationBarTitleDisplayMode(.inline)
|
||
.toolbar { ToolbarItem(placement: .topBarLeading) { Button("Fertig") { dismiss() } } }
|
||
.task { await load() }
|
||
.sheet(isPresented: $showRemove) {
|
||
NavigationStack { removeSheet }
|
||
}
|
||
.fileImporter(isPresented: $showFileImporter, allowedContentTypes: [.pdf]) { result in
|
||
if case .success(let url) = result {
|
||
Task {
|
||
let scoped = url.startAccessingSecurityScopedResource()
|
||
defer { if scoped { url.stopAccessingSecurityScopedResource() } }
|
||
if let data = try? Data(contentsOf: url) {
|
||
await upload(data: data, filename: url.lastPathComponent,
|
||
contentType: "application/pdf")
|
||
}
|
||
}
|
||
} else if case .failure(let err) = result {
|
||
error = err.localizedDescription
|
||
}
|
||
}
|
||
.onChange(of: pickerItem) { neu in
|
||
guard let neu else { return }
|
||
Task {
|
||
if let data = try? await neu.loadTransferable(type: Data.self) {
|
||
await upload(data: data, filename: "foto.jpg", contentType: "image/jpeg")
|
||
}
|
||
pickerItem = nil
|
||
}
|
||
}
|
||
.quickLookPreview($previewURL)
|
||
}
|
||
|
||
private var removeSheet: some View {
|
||
Form {
|
||
Picker("Grund", selection: $reason) {
|
||
ForEach(RemovalReasons.all, id: \.value) { Text($0.label).tag($0.value) }
|
||
}
|
||
LabeledField(label: "Notiz", text: $removeNote)
|
||
Section {
|
||
Button("Entfernen", role: .destructive) { Task { await remove() } }
|
||
}
|
||
}
|
||
.navigationTitle("Entfernen")
|
||
.navigationBarTitleDisplayMode(.inline)
|
||
.toolbar { ToolbarItem(placement: .topBarLeading) { Button("Abbrechen") { showRemove = false } } }
|
||
}
|
||
|
||
private func load() async {
|
||
locations = (try? await APIClient.shared.locations()) ?? []
|
||
shops = (try? await APIClient.shared.shops()) ?? []
|
||
photo = try? await APIClient.shared.productImage(id: item.productId)
|
||
locationId = item.locationId
|
||
shopId = item.shopId
|
||
note = item.note ?? ""
|
||
documents = item.documents
|
||
currency = item.currency ?? "CHF"
|
||
if let p = item.priceCents { priceText = ItemEditView.formatCents(p) }
|
||
if let s = item.acquiredOn, let d = stringToDate(s) { acquired = d; hasAcquired = true }
|
||
if let s = item.warrantyUntil, let d = stringToDate(s) { warranty = d; hasWarranty = true }
|
||
}
|
||
|
||
private func save() async {
|
||
busy = true; defer { busy = false }; error = nil
|
||
let cents = ItemEditView.parseCents(priceText)
|
||
do {
|
||
_ = try await APIClient.shared.updateItem(id: item.id, ItemUpdateRequest(
|
||
locationId: locationId, shopId: shopId,
|
||
acquiredOn: hasAcquired ? dateToString(acquired) : nil,
|
||
warrantyUntil: hasWarranty ? dateToString(warranty) : nil,
|
||
note: note.isEmpty ? nil : note,
|
||
priceCents: cents,
|
||
currency: cents == nil ? nil : currency))
|
||
onChanged?()
|
||
dismiss()
|
||
} catch { self.error = error.localizedDescription }
|
||
}
|
||
|
||
// MARK: - Belege
|
||
|
||
private func upload(data: Data, filename: String, contentType: String) async {
|
||
busyDoc = true
|
||
defer { busyDoc = false }
|
||
do {
|
||
let res = try await APIClient.shared.uploadItemDocument(
|
||
itemId: item.id, data: data, filename: filename, contentType: contentType)
|
||
let s = res.suggestions
|
||
suggWarranty = s.suggestedWarrantyUntil
|
||
suggPrice = s.suggestedPriceCents
|
||
suggPriceCandidates = s.suggestedPriceCandidates
|
||
suggAcquired = s.suggestedAcquiredOn
|
||
suggShopId = s.suggestedShopId
|
||
suggShopName = s.suggestedShopName
|
||
await reloadDocuments()
|
||
} catch { self.error = error.localizedDescription }
|
||
}
|
||
|
||
private func reloadDocuments() async {
|
||
if let fresh = try? await APIClient.shared.itemByUid(uid: item.uid) {
|
||
documents = fresh.documents
|
||
}
|
||
}
|
||
|
||
private func openDocument(_ d: ItemDocument) async {
|
||
do {
|
||
let data = try await APIClient.shared.itemDocumentData(itemId: item.id, docId: d.id)
|
||
let name = d.filename.isEmpty ? "beleg" : d.filename
|
||
let url = FileManager.default.temporaryDirectory.appendingPathComponent(name)
|
||
try data.write(to: url)
|
||
previewURL = url
|
||
} catch { self.error = error.localizedDescription }
|
||
}
|
||
|
||
private func deleteDoc(_ d: ItemDocument) async {
|
||
do {
|
||
try await APIClient.shared.deleteItemDocument(itemId: item.id, docId: d.id)
|
||
await reloadDocuments()
|
||
} catch { self.error = error.localizedDescription }
|
||
}
|
||
|
||
private var hatVorschlag: Bool {
|
||
suggWarranty != nil || suggPrice != nil || suggAcquired != nil
|
||
|| suggShopId != nil || suggShopName != nil
|
||
}
|
||
|
||
private func applySuggestion() async {
|
||
if let a = suggAcquired, let d = stringToDate(a) { acquired = d; hasAcquired = true }
|
||
if let w = suggWarranty, let d = stringToDate(w) { warranty = d; hasWarranty = true }
|
||
if let p = suggPrice { priceText = ItemEditView.formatCents(p) }
|
||
if let sid = suggShopId {
|
||
shopId = sid
|
||
} else if let name = suggShopName,
|
||
let shop = try? await APIClient.shared.createShop(
|
||
NewShopRequest(name: name, website: nil)) {
|
||
if !shops.contains(where: { $0.id == shop.id }) { shops.append(shop) }
|
||
shopId = shop.id
|
||
}
|
||
verwerfeVorschlag()
|
||
}
|
||
|
||
private func verwerfeVorschlag() {
|
||
suggWarranty = nil
|
||
suggPrice = nil
|
||
suggPriceCandidates = []
|
||
suggAcquired = nil
|
||
suggShopId = nil
|
||
suggShopName = nil
|
||
}
|
||
|
||
/// Eingabe in Hauptwährungseinheit → Rappen/Cent.
|
||
static func parseCents(_ s: String) -> Int? {
|
||
let t = s.trimmingCharacters(in: .whitespaces).replacingOccurrences(of: ",", with: ".")
|
||
guard !t.isEmpty, let v = Double(t) else { return nil }
|
||
return Int((v * 100).rounded())
|
||
}
|
||
|
||
static func formatCents(_ c: Int) -> String {
|
||
String(format: "%.2f", Double(c) / 100)
|
||
}
|
||
|
||
private func remove() async {
|
||
do {
|
||
try await APIClient.shared.removeItem(id: item.id, ItemRemoveRequest(
|
||
reason: reason, note: removeNote.isEmpty ? nil : removeNote))
|
||
showRemove = false
|
||
onChanged?()
|
||
dismiss()
|
||
} catch { self.error = error.localizedDescription; showRemove = false }
|
||
}
|
||
}
|
||
|
||
// MARK: - Anlegen
|
||
|
||
struct ItemAddSheet: View {
|
||
let productId: Int
|
||
var onDone: () async -> Void
|
||
|
||
@Environment(\.dismiss) private var dismiss
|
||
|
||
@State private var count = 1
|
||
@State private var locations: [StorageLocation] = []
|
||
@State private var shops: [ShopItem] = []
|
||
@State private var locationId: String?
|
||
@State private var shopId: Int?
|
||
@State private var hasAcquired = false
|
||
@State private var acquired = Date()
|
||
@State private var hasWarranty = false
|
||
@State private var warranty = Date()
|
||
@State private var note = ""
|
||
@State private var priceText = ""
|
||
@State private var currency = "CHF"
|
||
@State private var docData: Data?
|
||
@State private var docName = ""
|
||
@State private var docType = ""
|
||
@State private var newShopName: String?
|
||
@State private var addInfo: String?
|
||
@State private var showFileImporter = false
|
||
@State private var pickerItem: PhotosPickerItem?
|
||
@State private var busy = false
|
||
@State private var error: String?
|
||
|
||
var body: some View {
|
||
Form {
|
||
Section {
|
||
Stepper("Anzahl: \(count)", value: $count, in: 1...200)
|
||
Picker("Lagerort", selection: $locationId) {
|
||
Text("– ohne –").tag(String?.none)
|
||
ForEach(locations) { l in Text(l.name).tag(String?.some(l.id)) }
|
||
}
|
||
Picker("Gekauft bei", selection: $shopId) {
|
||
Text("– unbekannt –").tag(Int?.none)
|
||
ForEach(shops) { s in Text(s.name).tag(Int?.some(s.id)) }
|
||
}
|
||
Toggle("Kaufdatum", isOn: $hasAcquired)
|
||
if hasAcquired { DatePicker("Gekauft am", selection: $acquired, displayedComponents: .date) }
|
||
Toggle("Garantie", isOn: $hasWarranty)
|
||
if hasWarranty { DatePicker("Garantie bis", selection: $warranty, displayedComponents: .date) }
|
||
LabeledField(label: "Notiz", text: $note)
|
||
} footer: {
|
||
Text("Gemeinsame Startwerte – danach je Stück änderbar. Jedes Stück bekommt eine eigene UID + QR.")
|
||
}
|
||
|
||
Section("Kaufpreis") {
|
||
HStack {
|
||
TextField("0.00", text: $priceText).keyboardType(.decimalPad)
|
||
Picker("", selection: $currency) {
|
||
Text("CHF").tag("CHF")
|
||
Text("EUR").tag("EUR")
|
||
}
|
||
.pickerStyle(.segmented).frame(width: 130)
|
||
}
|
||
}
|
||
|
||
Section {
|
||
if docData != nil { Text(docName).font(.callout) }
|
||
PhotosPicker(selection: $pickerItem, matching: .images) {
|
||
Label("Bild wählen", systemImage: "photo")
|
||
}
|
||
Button { showFileImporter = true } label: {
|
||
Label("PDF wählen", systemImage: "doc.badge.plus")
|
||
}
|
||
if let addInfo { Text(addInfo).font(.caption).foregroundStyle(.secondary) }
|
||
if let newShopName {
|
||
Text("Neuer Shop „\(newShopName)“ wird beim Anlegen erstellt.")
|
||
.font(.caption).foregroundStyle(.secondary)
|
||
}
|
||
} header: {
|
||
Text("Beleg (Rechnung/Garantieschein)")
|
||
} footer: {
|
||
Text("Der Beleg wird an das erste angelegte Stück gehängt; aus einem PDF werden Kaufdatum, Garantie, Preis und Shop vorgeschlagen.")
|
||
}
|
||
|
||
if let error { Section { Text(error).foregroundStyle(.red).font(.callout) } }
|
||
Section {
|
||
Button(busy ? "Anlegen…" : "Anlegen") { Task { await create() } }.disabled(busy)
|
||
}
|
||
}
|
||
.navigationTitle("Einzelstücke anlegen")
|
||
.navigationBarTitleDisplayMode(.inline)
|
||
.toolbar { ToolbarItem(placement: .topBarLeading) { Button("Abbrechen") { dismiss() } } }
|
||
.fileImporter(isPresented: $showFileImporter, allowedContentTypes: [.pdf]) { result in
|
||
if case .success(let url) = result {
|
||
Task {
|
||
let scoped = url.startAccessingSecurityScopedResource()
|
||
defer { if scoped { url.stopAccessingSecurityScopedResource() } }
|
||
if let data = try? Data(contentsOf: url) {
|
||
await analyze(data, name: url.lastPathComponent, type: "application/pdf")
|
||
}
|
||
}
|
||
}
|
||
}
|
||
.onChange(of: pickerItem) { neu in
|
||
guard let neu else { return }
|
||
Task {
|
||
if let data = try? await neu.loadTransferable(type: Data.self) {
|
||
await analyze(data, name: "foto.jpg", type: "image/jpeg")
|
||
}
|
||
pickerItem = nil
|
||
}
|
||
}
|
||
.task {
|
||
locations = (try? await APIClient.shared.locations()) ?? []
|
||
shops = (try? await APIClient.shared.shops()) ?? []
|
||
}
|
||
}
|
||
|
||
/// Beleg beim Anlegen analysieren und Felder vorbefüllen (nur bei PDF liefert
|
||
/// der Server Treffer).
|
||
private func analyze(_ data: Data, name: String, type: String) async {
|
||
docData = data
|
||
docName = name
|
||
docType = type
|
||
addInfo = nil
|
||
newShopName = nil
|
||
guard let s = try? await APIClient.shared.analyzeItemDocument(
|
||
data: data, filename: name, contentType: type) else { return }
|
||
var teile: [String] = []
|
||
if let a = s.suggestedAcquiredOn, let d = stringToDate(a) {
|
||
acquired = d; hasAcquired = true; teile.append("Kaufdatum")
|
||
}
|
||
if let w = s.suggestedWarrantyUntil, let d = stringToDate(w) {
|
||
warranty = d; hasWarranty = true; teile.append("Garantie")
|
||
}
|
||
if let p = s.suggestedPriceCents {
|
||
priceText = ItemEditView.formatCents(p); teile.append("Preis")
|
||
}
|
||
if let sid = s.suggestedShopId {
|
||
shopId = sid; teile.append("Shop")
|
||
} else if let sn = s.suggestedShopName {
|
||
newShopName = sn
|
||
}
|
||
addInfo = teile.isEmpty ? "Beleg erkannt – keine Automatik-Treffer."
|
||
: "Übernommen: \(teile.joined(separator: ", "))."
|
||
}
|
||
|
||
private func create() async {
|
||
busy = true; defer { busy = false }; error = nil
|
||
do {
|
||
var sid = shopId
|
||
if sid == nil, let name = newShopName,
|
||
let shop = try? await APIClient.shared.createShop(
|
||
NewShopRequest(name: name, website: nil)) {
|
||
sid = shop.id
|
||
}
|
||
let cents = ItemEditView.parseCents(priceText)
|
||
let created = try await APIClient.shared.createItems(productId: productId, ItemCreateRequest(
|
||
count: count, locationId: locationId, shopId: sid,
|
||
acquiredOn: hasAcquired ? dateToString(acquired) : nil,
|
||
warrantyUntil: hasWarranty ? dateToString(warranty) : nil,
|
||
note: note.isEmpty ? nil : note,
|
||
priceCents: cents,
|
||
currency: cents == nil ? nil : currency))
|
||
if let data = docData, let first = created.first {
|
||
_ = try? await APIClient.shared.uploadItemDocument(
|
||
itemId: first.id, data: data,
|
||
filename: docName.isEmpty ? "beleg" : docName, contentType: docType)
|
||
}
|
||
await onDone()
|
||
dismiss()
|
||
} catch { self.error = error.localizedDescription }
|
||
}
|
||
}
|