Verleih-Abschnitt im Einzelstueck: an wen, seit wann, bis wann, dazu Herausgeben bzw. Zuruecknehmen und der Verleih-Verlauf. Pflicht ist nur der Name; Grund und Rueckgabedatum sind abschaltbar, weil "kurz mal mitgenommen" nicht an einem Datum scheitern soll. Der Abschnitt sitzt bewusst oben in ItemEditView, und zwar nicht nur der Wichtigkeit wegen: QR ueber Nachschlagen, eingenaehtes NFC-Tag und ein angetipptes Tag (vorrania://i/<UID>) landen alle in genau dieser Ansicht. Damit ist "scannen und verleihen" ueberall moeglich, ohne einen eigenen Weg dafuer zu bauen. In der Einzelstueck-Liste steht der Verleih unter dem Stueck - ueberfaellig in Rot - und das Filter-Menue kann auf Verliehen, Ueberfaellig oder Im Haus eingrenzen. Wo etwas ist, war bisher immer der Lagerort; ist es verliehen, ist der Entleiher die Antwort, und der Lagerort steht daneben. days_overdue rechnet der Server (GET /loans, ItemOut.loan), damit App und Web nicht getrennt rechnen und nicht auseinanderlaufen. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
876 lines
36 KiB
Swift
876 lines
36 KiB
Swift
import SwiftUI
|
||
import PhotosUI
|
||
import QuickLook
|
||
import UniformTypeIdentifiers
|
||
|
||
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)
|
||
}
|
||
/// "2026-09-20" -> "20.09.2026". Faellt auf den Rohwert zurueck, damit nie
|
||
/// eine leere Zeile steht.
|
||
private func anzeigeDatum(_ iso: String?) -> String {
|
||
guard let iso else { return "–" }
|
||
guard let d = stringToDate(iso) else { return iso }
|
||
let f = DateFormatter()
|
||
f.locale = Locale(identifier: "de_DE")
|
||
f.dateFormat = "dd.MM.yyyy"
|
||
return f.string(from: d)
|
||
}
|
||
|
||
/// Verleihzustand in einer Zeile: „Markus · zurueck bis 20.09.2026".
|
||
/// Ueberfaelliges kommt beim Aufrufer rot daher.
|
||
func verleihZeile(_ loan: Loan) -> String {
|
||
var teile = [loan.borrower]
|
||
if let tage = loan.daysOverdue {
|
||
teile.append("überfällig seit \(tage) \(tage == 1 ? "Tag" : "Tagen")")
|
||
} else if let due = loan.dueOn {
|
||
teile.append("zurück bis \(anzeigeDatum(due))")
|
||
}
|
||
if let grund = loan.reason, !grund.isEmpty { teile.append(grund) }
|
||
return teile.joined(separator: " · ")
|
||
}
|
||
|
||
/// Datumszeile mit einheitlichem Format (dd.MM.yyyy). Die native compact-DatePicker
|
||
/// zeigt das Jahr je nach Feld mal zwei-, mal vierstellig – deshalb hier ein fester
|
||
/// Formatierer und ein grafischer Picker im Popover.
|
||
struct DateRow: View {
|
||
let label: String
|
||
@Binding var date: Date
|
||
@State private var open = false
|
||
|
||
private static let fmt: DateFormatter = {
|
||
let f = DateFormatter()
|
||
f.locale = Locale(identifier: "de_DE")
|
||
f.dateFormat = "dd.MM.yyyy"
|
||
return f
|
||
}()
|
||
|
||
var body: some View {
|
||
Group {
|
||
Button { withAnimation(.easeInOut(duration: 0.15)) { open.toggle() } } label: {
|
||
HStack {
|
||
Text(label).foregroundStyle(.primary)
|
||
Spacer()
|
||
Text(Self.fmt.string(from: date))
|
||
.foregroundStyle(open ? Color.marke : .secondary)
|
||
}
|
||
.contentShape(Rectangle())
|
||
}
|
||
.buttonStyle(.plain)
|
||
if open {
|
||
DatePicker(label, selection: $date, displayedComponents: .date)
|
||
.datePickerStyle(.graphical)
|
||
.labelsHidden()
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
// 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
|
||
/// Bis zur Liste schließen (für „Anlegen & fertig").
|
||
var onFinish: (() -> Void)? = nil
|
||
|
||
@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, onDone: { await refresh() },
|
||
onFinish: onFinish.map { finish in { showAdd = false; finish() } })
|
||
}
|
||
}
|
||
}
|
||
|
||
private func untertitel(_ it: Item) -> String {
|
||
// Ist das Stueck draussen, ist das die wichtigste Angabe - der Lagerort
|
||
// steht daneben, weil es dorthin zurueckgehoert.
|
||
if let loan = it.loan {
|
||
return "verliehen an \(verleihZeile(loan))"
|
||
}
|
||
return [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)
|
||
|
||
/// Lagerort per QR (…/l/<Code>) auswählen – selbstständige Scan-Ansicht, meldet
|
||
/// den getroffenen Lagerort zurück. So kann man einen Ort auch ohne den QR des
|
||
/// Einzelstücks setzen (z.B. bei Kleidung ohne eigenen Code).
|
||
struct LocationScannerView: View {
|
||
let locations: [StorageLocation]
|
||
var onPicked: (StorageLocation) -> Void
|
||
|
||
@Environment(\.dismiss) private var dismiss
|
||
@State private var paused = false
|
||
@State private var torchOn = false
|
||
@State private var torchLocked = false
|
||
@State private var error: String?
|
||
|
||
var body: some View {
|
||
NavigationStack {
|
||
ScannerView(onCode: { code in handle(code) },
|
||
isPaused: $paused, torchOn: $torchOn, torchLocked: $torchLocked)
|
||
.ignoresSafeArea(edges: .bottom)
|
||
.overlay(alignment: .bottom) {
|
||
VStack(spacing: 6) {
|
||
if let error {
|
||
Text(error)
|
||
.font(.callout).foregroundStyle(.white)
|
||
.padding(.horizontal, 12).padding(.vertical, 8)
|
||
.background(.red, in: Capsule())
|
||
}
|
||
Text("QR am Regal/Fach vor die Kamera halten")
|
||
.font(.callout).padding(10)
|
||
.background(.ultraThinMaterial, in: Capsule())
|
||
}
|
||
.padding(.bottom, 24)
|
||
}
|
||
.navigationTitle("Lagerort scannen")
|
||
.navigationBarTitleDisplayMode(.inline)
|
||
.toolbar {
|
||
ToolbarItem(placement: .topBarLeading) { Button("Abbrechen") { dismiss() } }
|
||
ToolbarItem(placement: .topBarTrailing) {
|
||
TorchButton(isOn: $torchOn, locked: $torchLocked)
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
private func handle(_ code: String) {
|
||
guard let r = code.range(of: "/l/") else {
|
||
error = "Das ist kein Lagerort-QR."
|
||
return
|
||
}
|
||
let ziel = String(code[r.upperBound...])
|
||
.trimmingCharacters(in: CharacterSet(charactersIn: "/ "))
|
||
.uppercased()
|
||
if let treffer = locations.first(where: { $0.id == ziel }) {
|
||
onPicked(treffer)
|
||
dismiss()
|
||
} else {
|
||
error = "Dieser Lagerort ist hier nicht bekannt."
|
||
}
|
||
}
|
||
}
|
||
|
||
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?
|
||
@State private var locScanShown = false
|
||
@State private var nfcShown = false
|
||
// Verleih: der offene Vorgang (nil = im Haus) und die Eingaben dafuer.
|
||
@State private var loan: Loan?
|
||
@State private var loanHistory: [Loan] = []
|
||
@State private var showLend = false
|
||
@State private var borrower = ""
|
||
@State private var loanReason = ""
|
||
@State private var hasDue = false
|
||
@State private var due = Date()
|
||
@State private var busyLoan = false
|
||
|
||
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)
|
||
// Für Kleidung: ein eingenähtes NFC-Tag hält, was ein aufgeklebter
|
||
// QR nicht übersteht. Auf dem iPad ausgeblendet – dort gibt es kein NFC.
|
||
if NFCDienst.verfuegbar {
|
||
Button { nfcShown = true } label: {
|
||
Label("NFC-Tag beschreiben", systemImage: "wave.3.right")
|
||
}
|
||
}
|
||
}
|
||
|
||
// Verleih steht vor allem anderen: Ist das Stueck draussen, ist das
|
||
// die Antwort auf „wo ist es?". Alle Scan-Wege der App (QR, NFC,
|
||
// vorrania://i/<UID>) landen in dieser Ansicht - deshalb genuegt der
|
||
// Knopf hier, um „scannen und verleihen" moeglich zu machen.
|
||
Section("Verleih") {
|
||
if let loan {
|
||
LabeledContent("Verliehen an", value: loan.borrower)
|
||
if let grund = loan.reason, !grund.isEmpty {
|
||
LabeledContent("Grund", value: grund)
|
||
}
|
||
LabeledContent("Seit", value: anzeigeDatum(loan.lentOn))
|
||
if let tage = loan.daysOverdue {
|
||
LabeledContent("Zurück bis") {
|
||
Text("\(anzeigeDatum(loan.dueOn)) · überfällig seit "
|
||
+ "\(tage) \(tage == 1 ? "Tag" : "Tagen")")
|
||
.foregroundStyle(.red)
|
||
}
|
||
} else {
|
||
LabeledContent("Zurück bis",
|
||
value: loan.dueOn == nil ? "offen" : anzeigeDatum(loan.dueOn))
|
||
}
|
||
Button { Task { await nimmZurueck() } } label: {
|
||
Label(busyLoan ? "Moment…" : "Zurücknehmen", systemImage: "arrow.uturn.down")
|
||
}
|
||
.disabled(busyLoan)
|
||
} else {
|
||
Text("Im Haus.").foregroundStyle(.secondary)
|
||
Button { starteVerleih() } label: {
|
||
Label("Verleihen", systemImage: "hand.raised")
|
||
}
|
||
}
|
||
if !loanHistory.isEmpty {
|
||
NavigationLink("Verleih-Verlauf (\(loanHistory.count))") {
|
||
LoanHistoryView(uid: item.uid, loans: loanHistory)
|
||
}
|
||
}
|
||
}
|
||
|
||
Section {
|
||
Picker("Lagerort", selection: $locationId) {
|
||
Text("– ohne –").tag(String?.none)
|
||
ForEach(locations) { l in Text(l.path(in: locations)).tag(String?.some(l.id)) }
|
||
}
|
||
// Lagerort per QR setzen – ohne den (evtl. nicht vorhandenen) QR
|
||
// des Einzelstücks. Praktisch z.B. bei Kleidung.
|
||
Button { locScanShown = true } label: {
|
||
Label("Lagerort scannen", systemImage: "qrcode.viewfinder")
|
||
}
|
||
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 { DateRow(label: "Gekauft am", date: $acquired) }
|
||
Toggle("Garantie", isOn: $hasWarranty)
|
||
if hasWarranty { DateRow(label: "Garantie bis", date: $warranty) }
|
||
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() }
|
||
.fullScreenCover(isPresented: $locScanShown) {
|
||
LocationScannerView(locations: locations) { loc in locationId = loc.id }
|
||
.ignoresSafeArea()
|
||
}
|
||
.sheet(isPresented: $nfcShown) {
|
||
NavigationStack { NFCSchreibenView(item: item) }
|
||
}
|
||
.sheet(isPresented: $showRemove) {
|
||
NavigationStack { removeSheet }
|
||
}
|
||
.sheet(isPresented: $showLend) {
|
||
NavigationStack { lendSheet }
|
||
}
|
||
.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)
|
||
}
|
||
|
||
/// Herausgeben: Nur der Name ist Pflicht. Das Rueckgabedatum ist bewusst
|
||
/// abschaltbar - „kurz mal mitgenommen" soll nicht an einem Datum scheitern.
|
||
private var lendSheet: some View {
|
||
Form {
|
||
Section {
|
||
LabeledField(label: "Verliehen an", text: $borrower)
|
||
LabeledField(label: "Grund (optional)", text: $loanReason)
|
||
Toggle("Rückgabe bis", isOn: $hasDue)
|
||
if hasDue { DateRow(label: "Zurück bis", date: $due) }
|
||
} footer: {
|
||
Text("Der Lagerort bleibt stehen – dorthin gehört das Stück zurück. "
|
||
+ "Als verliehen zählt es aber nicht mehr zum Bestand.")
|
||
}
|
||
if let error { Section { Text(error).foregroundStyle(.red).font(.callout) } }
|
||
Section {
|
||
Button(busyLoan ? "Moment…" : "Verleihen") { Task { await verleihe() } }
|
||
.disabled(busyLoan || borrower.trimmingCharacters(in: .whitespaces).isEmpty)
|
||
}
|
||
}
|
||
.navigationTitle("Verleihen")
|
||
.navigationBarTitleDisplayMode(.inline)
|
||
.toolbar {
|
||
ToolbarItem(placement: .topBarLeading) { Button("Abbrechen") { showLend = false } }
|
||
}
|
||
}
|
||
|
||
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 } } }
|
||
}
|
||
|
||
// MARK: - Verleih
|
||
|
||
private func starteVerleih() {
|
||
borrower = ""
|
||
loanReason = ""
|
||
hasDue = false
|
||
due = Date()
|
||
error = nil
|
||
showLend = true
|
||
}
|
||
|
||
private func verleihe() async {
|
||
busyLoan = true; defer { busyLoan = false }; error = nil
|
||
do {
|
||
loan = try await APIClient.shared.lendItem(id: item.id, LoanCreateRequest(
|
||
borrower: borrower.trimmingCharacters(in: .whitespaces),
|
||
reason: loanReason.isEmpty ? nil : loanReason,
|
||
dueOn: hasDue ? dateToString(due) : nil))
|
||
showLend = false
|
||
await ladeVerleihe()
|
||
onChanged?()
|
||
} catch { self.error = error.localizedDescription }
|
||
}
|
||
|
||
private func nimmZurueck() async {
|
||
busyLoan = true; defer { busyLoan = false }; error = nil
|
||
do {
|
||
_ = try await APIClient.shared.returnItem(id: item.id)
|
||
loan = nil
|
||
await ladeVerleihe()
|
||
onChanged?()
|
||
} catch { self.error = error.localizedDescription }
|
||
}
|
||
|
||
/// Offenen Verleih und Historie frisch holen - nach jeder Buchung, damit die
|
||
/// Ansicht nicht auf dem Stand des uebergebenen `item` haengen bleibt.
|
||
private func ladeVerleihe() async {
|
||
loanHistory = (try? await APIClient.shared.itemLoans(id: item.id)) ?? []
|
||
loan = loanHistory.first(where: { $0.istOffen })
|
||
}
|
||
|
||
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 }
|
||
loan = item.loan
|
||
await ladeVerleihe()
|
||
}
|
||
|
||
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
|
||
/// Wenn gesetzt: „Anlegen & fertig" schließt bis zur Liste (nicht nur das Sheet).
|
||
var onFinish: (() -> Void)? = nil
|
||
|
||
@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?
|
||
@State private var locScanShown = false
|
||
|
||
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.path(in: locations)).tag(String?.some(l.id)) }
|
||
}
|
||
Button { locScanShown = true } label: {
|
||
Label("Lagerort scannen", systemImage: "qrcode.viewfinder")
|
||
}
|
||
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 { DateRow(label: "Gekauft am", date: $acquired) }
|
||
Toggle("Garantie", isOn: $hasWarranty)
|
||
if hasWarranty { DateRow(label: "Garantie bis", date: $warranty) }
|
||
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(finish: false) } }.disabled(busy)
|
||
if onFinish != nil {
|
||
Button(busy ? "Anlegen…" : "Anlegen & fertig") { Task { await create(finish: true) } }
|
||
.disabled(busy)
|
||
}
|
||
}
|
||
}
|
||
.navigationTitle("Einzelstücke anlegen")
|
||
.navigationBarTitleDisplayMode(.inline)
|
||
.toolbar { ToolbarItem(placement: .topBarLeading) { Button("Abbrechen") { dismiss() } } }
|
||
.fullScreenCover(isPresented: $locScanShown) {
|
||
LocationScannerView(locations: locations) { loc in locationId = loc.id }
|
||
.ignoresSafeArea()
|
||
}
|
||
.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(finish: Bool) 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()
|
||
if finish, let onFinish { onFinish() } else { dismiss() }
|
||
} catch { self.error = error.localizedDescription }
|
||
}
|
||
}
|
||
|
||
// MARK: - Verleih-Verlauf
|
||
|
||
/// Alle Verleihe eines Stuecks, neueste zuerst. Zurueckgegebene Vorgaenge bleiben
|
||
/// erhalten - so ist nachvollziehbar, wer das Stueck wann hatte.
|
||
struct LoanHistoryView: View {
|
||
let uid: String
|
||
let loans: [Loan]
|
||
|
||
var body: some View {
|
||
List {
|
||
ForEach(loans) { loan in
|
||
VStack(alignment: .leading, spacing: 4) {
|
||
HStack {
|
||
Text(loan.borrower).bold()
|
||
Spacer()
|
||
if loan.istOffen {
|
||
Text(loan.istUeberfaellig ? "überfällig" : "offen")
|
||
.font(.caption)
|
||
.foregroundStyle(loan.istUeberfaellig ? Color.red : .secondary)
|
||
}
|
||
}
|
||
Text(zeitraum(loan)).font(.caption).foregroundStyle(.secondary)
|
||
if let grund = loan.reason, !grund.isEmpty {
|
||
Text(grund).font(.caption).foregroundStyle(.secondary)
|
||
}
|
||
if let von = loan.username {
|
||
Text("herausgegeben von \(von)").font(.caption2).foregroundStyle(.secondary)
|
||
}
|
||
}
|
||
}
|
||
if loans.isEmpty {
|
||
Text("Noch nie verliehen.").foregroundStyle(.secondary)
|
||
}
|
||
}
|
||
.navigationTitle("Verlauf \(uid)")
|
||
.navigationBarTitleDisplayMode(.inline)
|
||
}
|
||
|
||
private func zeitraum(_ loan: Loan) -> String {
|
||
let von = anzeigeDatum(loan.lentOn)
|
||
guard let zurueck = loan.returnedOn else {
|
||
return loan.dueOn == nil
|
||
? "seit \(von)"
|
||
: "seit \(von) · zurück bis \(anzeigeDatum(loan.dueOn))"
|
||
}
|
||
return "\(von) – \(anzeigeDatum(zurueck))"
|
||
}
|
||
}
|