diff --git a/README.md b/README.md index be68b33..5bacf80 100644 --- a/README.md +++ b/README.md @@ -56,6 +56,9 @@ Auslagern wird automatisch die zuerst ablaufende Charge entnommen (**FEFO**). ### In der iOS-App zusätzlich - **Scannen** von Barcode, **MHD/Datum per Texterkennung** (auch nur Tag+Monat ohne Jahr bei Frischware), **Lagerort-QR** und **Name/Marke per Text-Scan**. +- **Verleihen direkt nach dem Scannen** – QR, eingenähtes NFC-Tag oder + angetipptes Tag landen alle im Einzelstück, wo Herausgeben und Zurücknehmen + gleich oben stehen. - **Foto direkt beim Anlegen** (Kamera/Galerie), **Push-Benachrichtigungen** bei ablaufenden Produkten, lokale Dashboards und Home-Screen-Schnellaktionen. diff --git a/docs/ROADMAP.md b/docs/ROADMAP.md index 87fef2d..97ca3e7 100644 --- a/docs/ROADMAP.md +++ b/docs/ROADMAP.md @@ -122,10 +122,16 @@ Selfhostbare Lebensmittel-Lagerverwaltung. Aufbau in mehreren Schritten. NFC-Tags (`/i/`) leitete vorher nur zum Artikel weiter. Jetzt zeigt es, was man in der Hand hält, und bietet Verleihen bzw. Zurücknehmen an der Stelle an – Gegenstück zur Lagerort-Seite (`/l/`). -- Noch offen: Oberfläche in der iOS-App sowie die **Rückgabe-Erinnerung**. Sie - kann nur den Weg der Ablauf-Meldungen gehen (lokale Benachrichtigung der App - aus einem Server-Snapshot, `GET /loans?due_in=…` ist dafür schon die - Datenquelle) – echtes Push fehlt weiterhin, siehe Schritt 3. +- **iOS-App:** Verleih-Abschnitt im Einzelstück mit Herausgeben und + Zurücknehmen, Verleih-Verlauf, Zustand in der Einzelstück-Liste und ein Filter + *Verliehen / Überfällig / Im Haus*. Weil alle Scan-Wege (QR über + *Nachschlagen*, NFC-Tag, `vorrania://i/`) in derselben Ansicht enden, ist + „scannen und verleihen" damit überall möglich, ohne einen eigenen Weg dafür. +- Noch offen: die **Rückgabe-Erinnerung**. Sie kann nur den Weg der + Ablauf-Meldungen gehen (lokale Benachrichtigung der App aus einem + Server-Snapshot, `GET /loans?due_in=…` ist dafür schon die Datenquelle) – + echtes Push fehlt weiterhin, siehe Schritt 3. Beachten: iOS erlaubt 60 + geplante Meldungen je App, die sich Verleih und MHD dann teilen müssen. ## Schritt 3 – Weitere Ideen – offen - **Ablauf-Push-Benachrichtigungen** via APNs (setzt die App voraus). diff --git a/ios/README.md b/ios/README.md index 4784383..eb51fa3 100644 --- a/ios/README.md +++ b/ios/README.md @@ -6,7 +6,8 @@ REST-API wie die Web-Oberfläche. > **Stand:** Voll nutzbar. Login; Scannen von Barcode, MHD/Datum (per > Texterkennung, auch nur Tag+Monat ohne Jahr), Lagerort-QR und Name/Marke; > Ein-/Auslagern mit mehreren Chargen bzw. Menge je Lagerort; Einzelstücke mit -> UID/QR, Kaufdatum, Garantie und Beleg; Verbrauchsgegenstände als Charge; +> UID/QR, Kaufdatum, Garantie und Beleg – auch **verleihen** und +> zurücknehmen; Verbrauchsgegenstände als Charge; > Artikel anlegen (Open Food/Products Facts oder manuell, mit Foto); getrennte > Listen für Lebensmittel/Gegenstände/Einzelstücke, Kategoriefilter, Sortierung; > Einkaufsliste, Ablaufliste, Mindestbestände; Verlauf mit Sprung zum Produkt; @@ -63,6 +64,23 @@ Es gibt zwei Wege direkt in den Scan-Bildschirm: Beide Wege öffnen die App **direkt in der Kamera**. +## Einzelstücke verleihen + +In der Ansicht eines Einzelstücks steht **Verleih** ganz oben: verliehen an wen, +seit wann, bis wann – und der Knopf zum Herausgeben bzw. Zurücknehmen. Pflicht +ist nur der Name; Grund und Rückgabedatum sind optional, denn „kurz mal +mitgenommen" soll nicht an einem Datum scheitern. + +Dass der Knopf genau dort sitzt, macht **scannen und verleihen** möglich, ohne +einen eigenen Weg dafür zu bauen: Alle Scan-Wege der App enden in dieser +Ansicht – der QR über *Nachschlagen*, ein eingenähtes NFC-Tag über *NFC-Tag +lesen* und ein angetipptes Tag über `vorrania://i/`. + +Der Lagerort bleibt beim Verleihen stehen – dorthin gehört das Stück zurück. Als +verliehen zählt es aber nicht mehr zum Bestand, es ist ja nicht im Haus. In der +Einzelstück-Liste steht der Verleih unter dem Stück (überfällig in Rot) und lässt +sich über das Filter-Menü auf *Verliehen*, *Überfällig* oder *Im Haus* eingrenzen. + ## NFC-Tags für Kleidung Ein aufgeklebtes QR-Etikett übersteht keine Wäsche. Für Kleidungsstücke gibt es deshalb eingenähte NFC-Tags: Einzelstück öffnen ▸ **NFC-Tag beschreiben** ▸ Tag diff --git a/ios/Sources/APIClient.swift b/ios/Sources/APIClient.swift index 8ec11a1..38b2029 100644 --- a/ios/Sources/APIClient.swift +++ b/ios/Sources/APIClient.swift @@ -276,6 +276,34 @@ actor APIClient { return try await send(request, as: Item.self) } + // MARK: - Verleih von Einzelstuecken + + /// Herausgeben. Scheitert mit 409, wenn das Stueck schon verliehen ist. + func lendItem(id: Int, _ payload: LoanCreateRequest) async throws -> Loan { + var request = try makeRequest("/items/\(id)/loan", method: "POST") + try jsonBody(&request, payload) + return try await send(request, as: Loan.self) + } + + /// Rueckgabe buchen. Leeres Datum = heute. + func returnItem(id: Int, _ payload: LoanReturnRequest = LoanReturnRequest()) async throws -> Loan { + var request = try makeRequest("/items/\(id)/return", method: "POST") + try jsonBody(&request, payload) + return try await send(request, as: Loan.self) + } + + /// Verleih-Historie eines Stuecks, neueste zuerst. + func itemLoans(id: Int) async throws -> [Loan] { + try await send(try makeRequest("/items/\(id)/loans"), as: [Loan].self) + } + + /// Offene Verleihe, faelligste zuerst. `dueIn` grenzt auf die ein, deren + /// Rueckgabe in so vielen Tagen ansteht (ueberfaellige sind immer dabei). + func loans(dueIn: Int? = nil) async throws -> [Loan] { + let pfad = dueIn.map { "/loans?due_in=\($0)" } ?? "/loans" + return try await send(try makeRequest(pfad), as: [Loan].self) + } + func removeItem(id: Int, _ payload: ItemRemoveRequest) async throws { var request = try makeRequest("/items/\(id)/remove", method: "POST") try jsonBody(&request, payload) diff --git a/ios/Sources/ItemListView.swift b/ios/Sources/ItemListView.swift index e019eff..9c8a5e4 100644 --- a/ios/Sources/ItemListView.swift +++ b/ios/Sources/ItemListView.swift @@ -10,6 +10,22 @@ struct ItemListView: View { @State private var query = "" @State private var locationFilter: String? // Lagerort-ID @State private var categoryFilter: Int? // Kategorie-ID (Unterkategorien zählen mit) + @State private var verleihFilter: VerleihFilter = .alle + + /// Blickwinkel auf den Verleihzustand – „was ist gerade draußen?" ist die + /// Frage, die man im Alltag an diese Liste stellt. + enum VerleihFilter: String, CaseIterable { + case alle, verliehen, ueberfaellig, imHaus + + var label: String { + switch self { + case .alle: return "Alle" + case .verliehen: return "Verliehen" + case .ueberfaellig: return "Überfällig" + case .imHaus: return "Im Haus" + } + } + } @State private var busy = true @State private var error: String? @@ -73,6 +89,12 @@ struct ItemListView: View { return items.filter { it in if let cat = categoryFilter, !imTeilbaum(it.categoryId, target: cat) { return false } if let loc = locationFilter, it.locationId != loc { return false } + switch verleihFilter { + case .alle: break + case .verliehen: if it.loan == nil { return false } + case .ueberfaellig: if !(it.loan?.istUeberfaellig ?? false) { return false } + case .imHaus: if it.loan != nil { return false } + } if !q.isEmpty { let heu = [it.productName, it.uid, it.note, it.productBrand] .compactMap { $0 }.joined(separator: " ").lowercased() @@ -82,7 +104,9 @@ struct ItemListView: View { } } - private var hatFilter: Bool { locationFilter != nil || categoryFilter != nil } + private var hatFilter: Bool { + locationFilter != nil || categoryFilter != nil || verleihFilter != .alle + } var body: some View { List { @@ -117,6 +141,11 @@ struct ItemListView: View { check(loc.path(in: locations), locationFilter == loc.id) { locationFilter = loc.id } } } + Menu("Verleih") { + ForEach(VerleihFilter.allCases, id: \.self) { f in + check(f.label, verleihFilter == f) { verleihFilter = f } + } + } if !filterKategorien.isEmpty { Menu("Kategorie") { check("Alle", categoryFilter == nil) { categoryFilter = nil } @@ -127,7 +156,9 @@ struct ItemListView: View { } if hatFilter { Divider() - Button(role: .destructive) { locationFilter = nil; categoryFilter = nil } label: { + Button(role: .destructive) { + locationFilter = nil; categoryFilter = nil; verleihFilter = .alle + } label: { Label("Filter zurücksetzen", systemImage: "xmark.circle") } } @@ -175,6 +206,14 @@ struct ItemRow: View { Text("· \(pfad)").font(.caption).foregroundStyle(.secondary).lineLimit(1) } } + // Verliehen ist die Antwort auf „wo ist es?" - deshalb eigene + // Zeile, ueberfaellig in Rot. + if let loan = item.loan { + Text("verliehen an \(verleihZeile(loan))") + .font(.caption) + .foregroundStyle(loan.istUeberfaellig ? Color.red : .secondary) + .lineLimit(2) + } } Spacer() } diff --git a/ios/Sources/ItemViews.swift b/ios/Sources/ItemViews.swift index 55a6695..a7c3be9 100644 --- a/ios/Sources/ItemViews.swift +++ b/ios/Sources/ItemViews.swift @@ -9,6 +9,29 @@ private func dateToString(_ d: Date) -> String { 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 @@ -88,7 +111,12 @@ struct ProductItemsSection: View { } private func untertitel(_ it: Item) -> String { - [it.locationName, it.acquiredOn.map { "gekauft \($0)" }, it.shopName] + // 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 { @@ -195,6 +223,15 @@ struct ItemEditView: View { @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 { @@ -221,6 +258,44 @@ struct ItemEditView: View { } } + // 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/) 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) @@ -328,6 +403,9 @@ struct ItemEditView: View { .sheet(isPresented: $showRemove) { NavigationStack { removeSheet } } + .sheet(isPresented: $showLend) { + NavigationStack { lendSheet } + } .fileImporter(isPresented: $showFileImporter, allowedContentTypes: [.pdf]) { result in if case .success(let url) = result { Task { @@ -354,6 +432,32 @@ struct ItemEditView: View { .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) { @@ -369,6 +473,47 @@ struct ItemEditView: View { .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()) ?? [] @@ -381,6 +526,8 @@ struct ItemEditView: View { 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 { @@ -677,3 +824,52 @@ struct ItemAddSheet: View { } 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))" + } +} diff --git a/ios/Sources/Models.swift b/ios/Sources/Models.swift index 414f1d0..0d52a5d 100644 --- a/ios/Sources/Models.swift +++ b/ios/Sources/Models.swift @@ -1046,6 +1046,68 @@ struct ItemDocument: Codable, Identifiable, Hashable { } } +/// Ein Verleih-Vorgang eines Einzelstuecks. `returnedOn == nil` heisst „noch +/// draussen"; je Stueck gibt es hoechstens einen solchen offenen Verleih. +/// +/// `daysOverdue` rechnet der Server, damit App und Web nicht getrennt rechnen - +/// nil heisst: ohne Frist, zurueck, oder noch nicht faellig. +struct Loan: Codable, Identifiable, Hashable { + let id: Int + let itemId: Int + let borrower: String + let reason: String? + let lentOn: String // "yyyy-MM-dd" + let dueOn: String? + let returnedOn: String? + let createdAt: String + let username: String? + let daysOverdue: Int? + let itemUid: String? + let productId: Int? + let productName: String? + let productBrand: String? + let locationId: String? + let locationName: String? + + var istOffen: Bool { returnedOn == nil } + var istUeberfaellig: Bool { (daysOverdue ?? 0) > 0 } + + enum CodingKeys: String, CodingKey { + case id, borrower, reason, username + case itemId = "item_id" + case lentOn = "lent_on" + case dueOn = "due_on" + case returnedOn = "returned_on" + case createdAt = "created_at" + case daysOverdue = "days_overdue" + case itemUid = "item_uid" + case productId = "product_id" + case productName = "product_name" + case productBrand = "product_brand" + case locationId = "location_id" + case locationName = "location_name" + } +} + +struct LoanCreateRequest: Encodable { + let borrower: String + var reason: String? = nil + var lentOn: String? = nil + var dueOn: String? = nil + + enum CodingKeys: String, CodingKey { + case borrower, reason + case lentOn = "lent_on" + case dueOn = "due_on" + } +} + +struct LoanReturnRequest: Encodable { + var returnedOn: String? = nil + + enum CodingKeys: String, CodingKey { case returnedOn = "returned_on" } +} + struct Item: Codable, Identifiable, Hashable { let id: Int let uid: String @@ -1065,9 +1127,14 @@ struct Item: Codable, Identifiable, Hashable { let categoryId: Int? let categoryName: String? let documents: [ItemDocument] + /// Der offene Verleih, falls das Stueck gerade draussen ist - sonst nil. + /// Der Lagerort steht daneben unveraendert: dorthin gehoert es zurueck. + let loan: Loan? + + var istVerliehen: Bool { loan != nil } enum CodingKeys: String, CodingKey { - case id, uid, note, currency, documents + case id, uid, note, currency, documents, loan case productId = "product_id" case locationId = "location_id" case locationName = "location_name"