Rueckgabe sichtbar machen, Zubehoerteile am Einzelstueck
RUECKGABE
Der Knopf war da, nur nicht zu finden: ein 16-px-Symbol in einer Spalte ohne
Ueberschrift, 52 px breit, ganz rechts in einer horizontal scrollenden Tabelle.
Beschriftet war er allein ueber title= - ein Tooltip, den auf einem Touchgeraet
nie jemand sieht.
Jetzt traegt er Text ("Zuruecknehmen"), die Spalte heisst "Verleih" und ist
breit genug, und beide Knoepfe haben ein aria-label. Dazu eine neue Seite
"Verliehen" in der Seitenleiste: alles Draussene an einem Ort, Rueckgabe direkt
aus der Liste. Bisher gab es diese Uebersicht nur als zwei Dashboard-Karten, und
die stehen nicht im Standard-Layout - wer sie nicht selbst hinzufuegte, hatte
gar keine.
In der App fuehrte der einzige Weg ueber das Bearbeiten-Formular des Stuecks;
jetzt geht es per Wischgeste und Kontextmenue direkt aus der Liste. Der
Verleih-Filter dort sass hinter einem nackten Symbol und ist beschriftet.
Zurueckgenommen wird nur noch nach Rueckfrage, in Web und App.
ZUBEHOER
Ein Netzteil gehoert zu einem Laptop: items.parent_item_id, genau ein Geraet je
Teil, beliebig viele Teile je Geraet. Eine Spalte genuegt dafuer, es braucht
keine Verknuepfungstabelle. ON DELETE SET NULL, damit das Netzteil den Laptop
ueberlebt statt mitgeloescht zu werden - und weil SQLite Fremdschluessel nicht
erzwingt, loest der Router die Zuordnung beim Loeschen zusaetzlich selbst.
Ringe (A traegt B, B traegt A) weist die Zuordnung mit 409 ab; die Elternkette
wird dafuer hochgelaufen, wie beim Kategoriebaum.
Die Beziehung laeuft ueber eigene Endpunkte und bewusst NICHT ueber ItemUpdate:
dessen Schema sendet immer alle Felder mit, auch leere, damit sich Felder
loeschen lassen. Genau so gingen schon einmal Kaufpreis und Waehrung verloren,
als ein Aufrufer ein Feld vergass.
Beim Verleihen fragt die Oberflaeche, welche Teile mitgehen (vorausgewaehlt:
alles, was im Haus ist), beim Zuruecknehmen kommen sie zusammen zurueck. Ohne
das Spiegelbild kaeme das Geraet zurueck und sein Netzteil bliebe ewig
"verliehen", weil niemand mehr danach sucht. Ungefragt nimmt der Server nie
etwas mit.
In der App sind zubehoer und gehoert_zu optional dekodiert: Die App spricht mit
mehreren Serverprofilen, und ein aelterer Server schickt die Felder nicht - ein
Vorgabewert reicht dafuer nicht, Swift nimmt ihn beim Dekodieren nicht her.
12 neue Tests (320 gesamt). Der Umlauf ist ausserdem gegen ein lokales Backend
im Browser durchgespielt: verleihen mit Zubehoer, Rueckfrage, beide zusammen
zurueck.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5
parent
ca4e9a2264
commit
a3fd9819c4
@@ -264,6 +264,12 @@ actor APIClient {
|
||||
return try await send(request, as: [Item].self)
|
||||
}
|
||||
|
||||
/// Ein Einzelstueck frisch holen - nach dem Zuordnen von Zubehoer ist das
|
||||
/// urspruenglich uebergebene Objekt veraltet, die Ansicht aber noch offen.
|
||||
func item(id: Int) async throws -> Item {
|
||||
try await send(try makeRequest("/items/\(id)"), as: Item.self)
|
||||
}
|
||||
|
||||
/// QR-Auflösung: UID → Einzelstück.
|
||||
func itemByUid(uid: String) async throws -> Item {
|
||||
let escaped = uid.addingPercentEncoding(withAllowedCharacters: .urlPathAllowed) ?? uid
|
||||
@@ -278,6 +284,21 @@ actor APIClient {
|
||||
|
||||
// MARK: - Verleih von Einzelstuecken
|
||||
|
||||
/// Zubehoer zuordnen. Eigener Endpunkt und nicht ueber updateItem: Dessen
|
||||
/// Anfrage sendet bewusst alle Felder mit, auch leere - eine Beziehung
|
||||
/// darueber zu pflegen hiesse, sie bei einem vergessenen Feld zu kappen.
|
||||
func zubehoerZuordnen(itemId: Int, childItemId: Int) async throws -> Item {
|
||||
var request = try makeRequest("/items/\(itemId)/zubehoer", method: "POST")
|
||||
try jsonBody(&request, ZubehoerZuordnenRequest(childItemId: childItemId))
|
||||
return try await send(request, as: Item.self)
|
||||
}
|
||||
|
||||
func zubehoerLoesen(itemId: Int, childItemId: Int) async throws {
|
||||
try await sendNoContent(
|
||||
try makeRequest("/items/\(itemId)/zubehoer/\(childItemId)", method: "DELETE")
|
||||
)
|
||||
}
|
||||
|
||||
/// 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")
|
||||
|
||||
@@ -28,6 +28,8 @@ struct ItemListView: View {
|
||||
}
|
||||
@State private var busy = true
|
||||
@State private var error: String?
|
||||
/// Stück, dessen Rückgabe gerade bestätigt werden soll.
|
||||
@State private var zurueckFuer: Item?
|
||||
|
||||
private struct TreeEntry: Identifiable {
|
||||
let item: CategoryItem
|
||||
@@ -118,6 +120,23 @@ struct ItemListView: View {
|
||||
NavigationLink { ItemEditView(item: it) } label: {
|
||||
ItemRow(item: it, locations: locations)
|
||||
}
|
||||
// Zuruecknehmen ohne Umweg: Bisher fuehrte der einzige Weg ueber
|
||||
// das Bearbeiten-Formular des Stuecks.
|
||||
.swipeActions(edge: .leading) {
|
||||
if it.istVerliehen {
|
||||
Button { zurueckFuer = it } label: {
|
||||
Label("Zurücknehmen", systemImage: "arrow.uturn.down")
|
||||
}
|
||||
.tint(.green)
|
||||
}
|
||||
}
|
||||
.contextMenu {
|
||||
if it.istVerliehen {
|
||||
Button { zurueckFuer = it } label: {
|
||||
Label("Zurücknehmen", systemImage: "arrow.uturn.down")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
.searchable(text: $query, prompt: "Produkt, UID oder Notiz")
|
||||
@@ -128,11 +147,39 @@ struct ItemListView: View {
|
||||
.refreshable { await load() }
|
||||
// Auch beim Zurückkommen aus dem Einzelstück neu laden (z.B. Lagerort geändert).
|
||||
.onAppear { Task { await load() } }
|
||||
.confirmationDialog(
|
||||
zurueckFuer.map { "„\($0.uid)“ zurücknehmen?" } ?? "",
|
||||
isPresented: Binding(get: { zurueckFuer != nil },
|
||||
set: { if !$0 { zurueckFuer = nil } }),
|
||||
titleVisibility: .visible
|
||||
) {
|
||||
Button("Zurücknehmen") { Task { await nimmZurueck() } }
|
||||
Button("Abbrechen", role: .cancel) { zurueckFuer = nil }
|
||||
} message: {
|
||||
if let it = zurueckFuer, let l = it.loan {
|
||||
let mit = it.teile.filter(\.verliehen)
|
||||
Text("Von \(l.borrower)." + (mit.isEmpty ? "" :
|
||||
" Das mitverliehene Zubehör kommt mit zurück: "
|
||||
+ mit.map(\.uid).joined(separator: ", ") + "."))
|
||||
}
|
||||
}
|
||||
.alert("Fehler", isPresented: Binding(get: { error != nil }, set: { if !$0 { error = nil } })) {
|
||||
Button("OK", role: .cancel) {}
|
||||
} message: { Text(error ?? "") }
|
||||
}
|
||||
|
||||
private func nimmZurueck() async {
|
||||
guard let it = zurueckFuer else { return }
|
||||
zurueckFuer = nil
|
||||
do {
|
||||
_ = try await APIClient.shared.returnItem(
|
||||
id: it.id,
|
||||
LoanReturnRequest(mitZubehoer: it.teile.filter(\.verliehen).map(\.id))
|
||||
)
|
||||
await load()
|
||||
} catch { self.error = error.localizedDescription }
|
||||
}
|
||||
|
||||
private var filterMenu: some View {
|
||||
Menu {
|
||||
Menu("Lagerort") {
|
||||
@@ -163,9 +210,11 @@ struct ItemListView: View {
|
||||
}
|
||||
}
|
||||
} label: {
|
||||
Image(systemName: hatFilter
|
||||
Label(hatFilter ? verleihFilter.label : "Filter",
|
||||
systemImage: hatFilter
|
||||
? "line.3.horizontal.decrease.circle.fill"
|
||||
: "line.3.horizontal.decrease.circle")
|
||||
.labelStyle(.titleAndIcon)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+127
-3
@@ -223,6 +223,13 @@ struct ItemEditView: View {
|
||||
@State private var previewURL: URL?
|
||||
@State private var locScanShown = false
|
||||
@State private var nfcShown = false
|
||||
@State private var zurueckFrage = false
|
||||
// Zubehoer wird nachgeladen statt aus `item` genommen: Nach dem Zuordnen
|
||||
// ist das uebergebene Stueck veraltet, die Ansicht aber noch offen.
|
||||
@State private var teile: [ItemKurz] = []
|
||||
@State private var gehoertZu: ItemKurz?
|
||||
@State private var zubehoerWaehlen = false
|
||||
@State private var mitVerleihen: Set<Int> = []
|
||||
// Verleih: der offene Vorgang (nil = im Haus) und die Eingaben dafuer.
|
||||
@State private var loan: Loan?
|
||||
@State private var loanHistory: [Loan] = []
|
||||
@@ -279,7 +286,7 @@ struct ItemEditView: View {
|
||||
LabeledContent("Zurück bis",
|
||||
value: loan.dueOn == nil ? "offen" : anzeigeDatum(loan.dueOn))
|
||||
}
|
||||
Button { Task { await nimmZurueck() } } label: {
|
||||
Button { zurueckFrage = true } label: {
|
||||
Label(busyLoan ? "Moment…" : "Zurücknehmen", systemImage: "arrow.uturn.down")
|
||||
}
|
||||
.disabled(busyLoan)
|
||||
@@ -296,6 +303,40 @@ struct ItemEditView: View {
|
||||
}
|
||||
}
|
||||
|
||||
// Zubehoer: das Netzteil, das zum Laptop gehoert. Ein Teil haengt an
|
||||
// genau einem Geraet, ein Geraet traegt beliebig viele Teile.
|
||||
Section("Zubehör") {
|
||||
if let g = gehoertZu {
|
||||
LabeledContent("Gehört zu") {
|
||||
VStack(alignment: .trailing, spacing: 1) {
|
||||
Text(g.productName ?? "Einzelstück")
|
||||
Text(g.uid).font(.caption.monospaced()).foregroundStyle(.secondary)
|
||||
}
|
||||
}
|
||||
}
|
||||
ForEach(teile) { t in
|
||||
HStack {
|
||||
VStack(alignment: .leading, spacing: 1) {
|
||||
Text(t.productName ?? "Einzelstück")
|
||||
Text(t.uid).font(.caption.monospaced()).foregroundStyle(.secondary)
|
||||
}
|
||||
Spacer()
|
||||
if t.verliehen {
|
||||
Text("verliehen").font(.caption).foregroundStyle(.orange)
|
||||
}
|
||||
}
|
||||
.swipeActions {
|
||||
Button("Lösen", role: .destructive) { Task { await loese(t) } }
|
||||
}
|
||||
}
|
||||
if teile.isEmpty && gehoertZu == nil {
|
||||
Text("Kein Zubehör.").foregroundStyle(.secondary)
|
||||
}
|
||||
Button { zubehoerWaehlen = true } label: {
|
||||
Label("Zubehör hinzufügen", systemImage: "plus")
|
||||
}
|
||||
}
|
||||
|
||||
Section {
|
||||
Picker("Lagerort", selection: $locationId) {
|
||||
Text("– ohne –").tag(String?.none)
|
||||
@@ -400,6 +441,25 @@ struct ItemEditView: View {
|
||||
.sheet(isPresented: $nfcShown) {
|
||||
NavigationStack { NFCSchreibenView(item: item) }
|
||||
}
|
||||
.confirmationDialog(
|
||||
"„\(item.uid)“ zurücknehmen?",
|
||||
isPresented: $zurueckFrage, titleVisibility: .visible
|
||||
) {
|
||||
Button("Zurücknehmen") { Task { await nimmZurueck() } }
|
||||
Button("Abbrechen", role: .cancel) { }
|
||||
} message: {
|
||||
let wer = loan.map { "Von \($0.borrower)." } ?? ""
|
||||
let mit = mitZurueck.isEmpty ? "" :
|
||||
" Das mitverliehene Zubehör kommt mit zurück: "
|
||||
+ mitZurueck.map(\.uid).joined(separator: ", ") + "."
|
||||
Text(wer + mit)
|
||||
}
|
||||
.sheet(isPresented: $zubehoerWaehlen) {
|
||||
// Dieselbe durchsuchbare Liste wie beim Zuordnen eines Lagerorts.
|
||||
ItemPickerSheet { gewaehlt in
|
||||
Task { await haengeAn(gewaehlt) }
|
||||
}
|
||||
}
|
||||
.sheet(isPresented: $showRemove) {
|
||||
NavigationStack { removeSheet }
|
||||
}
|
||||
@@ -445,6 +505,29 @@ struct ItemEditView: View {
|
||||
Text("Der Lagerort bleibt stehen – dorthin gehört das Stück zurück. "
|
||||
+ "Als verliehen zählt es aber nicht mehr zum Bestand.")
|
||||
}
|
||||
// Zubehoer geht nur mit, wenn es angehakt ist. Schon anderweitig
|
||||
// verliehene Teile stehen nicht zur Wahl - die sind gar nicht da.
|
||||
if !teile.filter({ !$0.verliehen }).isEmpty {
|
||||
Section("Zubehör mitgeben?") {
|
||||
ForEach(teile.filter { !$0.verliehen }) { t in
|
||||
Button {
|
||||
if mitVerleihen.contains(t.id) { mitVerleihen.remove(t.id) }
|
||||
else { mitVerleihen.insert(t.id) }
|
||||
} label: {
|
||||
HStack {
|
||||
Image(systemName: mitVerleihen.contains(t.id)
|
||||
? "checkmark.circle.fill" : "circle")
|
||||
.foregroundStyle(Color.marke)
|
||||
VStack(alignment: .leading, spacing: 1) {
|
||||
Text(t.productName ?? "Einzelstück").foregroundStyle(.primary)
|
||||
Text(t.uid).font(.caption.monospaced())
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if let error { Section { Text(error).foregroundStyle(.red).font(.callout) } }
|
||||
Section {
|
||||
Button(busyLoan ? "Moment…" : "Verleihen") { Task { await verleihe() } }
|
||||
@@ -481,6 +564,9 @@ struct ItemEditView: View {
|
||||
hasDue = false
|
||||
due = Date()
|
||||
error = nil
|
||||
// Vorausgewaehlt ist, was im Haus ist: Wer ein Geraet weitergibt, gibt
|
||||
// das Netzteil meist mit. Abwaehlen geht mit einem Tipp.
|
||||
mitVerleihen = Set(teile.filter { !$0.verliehen }.map(\.id))
|
||||
showLend = true
|
||||
}
|
||||
|
||||
@@ -490,19 +576,28 @@ struct ItemEditView: View {
|
||||
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))
|
||||
dueOn: hasDue ? dateToString(due) : nil,
|
||||
mitZubehoer: Array(mitVerleihen)))
|
||||
showLend = false
|
||||
await ladeVerleihe()
|
||||
await ladeZubehoer()
|
||||
onChanged?()
|
||||
} catch { self.error = error.localizedDescription }
|
||||
}
|
||||
|
||||
/// Mitverliehenes Zubehoer, das zusammen mit dem Geraet zurueckkommt.
|
||||
private var mitZurueck: [ItemKurz] { teile.filter { $0.verliehen } }
|
||||
|
||||
private func nimmZurueck() async {
|
||||
busyLoan = true; defer { busyLoan = false }; error = nil
|
||||
do {
|
||||
_ = try await APIClient.shared.returnItem(id: item.id)
|
||||
_ = try await APIClient.shared.returnItem(
|
||||
id: item.id,
|
||||
LoanReturnRequest(mitZubehoer: mitZurueck.map(\.id))
|
||||
)
|
||||
loan = nil
|
||||
await ladeVerleihe()
|
||||
await ladeZubehoer()
|
||||
onChanged?()
|
||||
} catch { self.error = error.localizedDescription }
|
||||
}
|
||||
@@ -527,9 +622,38 @@ struct ItemEditView: View {
|
||||
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
|
||||
teile = item.teile
|
||||
gehoertZu = item.gehoertZu
|
||||
await ladeVerleihe()
|
||||
}
|
||||
|
||||
private func haengeAn(_ gewaehlt: Item) async {
|
||||
error = nil
|
||||
do {
|
||||
_ = try await APIClient.shared.zubehoerZuordnen(
|
||||
itemId: item.id, childItemId: gewaehlt.id
|
||||
)
|
||||
await ladeZubehoer()
|
||||
onChanged?()
|
||||
} catch { self.error = error.localizedDescription }
|
||||
}
|
||||
|
||||
private func loese(_ teil: ItemKurz) async {
|
||||
error = nil
|
||||
do {
|
||||
try await APIClient.shared.zubehoerLoesen(itemId: item.id, childItemId: teil.id)
|
||||
await ladeZubehoer()
|
||||
onChanged?()
|
||||
} catch { self.error = error.localizedDescription }
|
||||
}
|
||||
|
||||
private func ladeZubehoer() async {
|
||||
guard let frisch = try? await APIClient.shared.item(id: item.id) else { return }
|
||||
teile = frisch.teile
|
||||
gehoertZu = frisch.gehoertZu
|
||||
loan = frisch.loan
|
||||
}
|
||||
|
||||
private func save() async {
|
||||
busy = true; defer { busy = false }; error = nil
|
||||
let cents = ItemEditView.parseCents(priceText)
|
||||
|
||||
@@ -1094,18 +1094,55 @@ struct LoanCreateRequest: Encodable {
|
||||
var reason: String? = nil
|
||||
var lentOn: String? = nil
|
||||
var dueOn: String? = nil
|
||||
/// Zubehoerteile, die mitgehen. Leer = nur das Geraet selbst; der Server
|
||||
/// nimmt nie ungefragt etwas mit.
|
||||
var mitZubehoer: [Int] = []
|
||||
|
||||
enum CodingKeys: String, CodingKey {
|
||||
case borrower, reason
|
||||
case lentOn = "lent_on"
|
||||
case dueOn = "due_on"
|
||||
case mitZubehoer = "mit_zubehoer"
|
||||
}
|
||||
}
|
||||
|
||||
struct LoanReturnRequest: Encodable {
|
||||
var returnedOn: String? = nil
|
||||
/// Spiegelbild zu LoanCreateRequest: Ohne das kaeme das Geraet zurueck und
|
||||
/// sein Netzteil bliebe ewig „verliehen".
|
||||
var mitZubehoer: [Int] = []
|
||||
|
||||
enum CodingKeys: String, CodingKey { case returnedOn = "returned_on" }
|
||||
enum CodingKeys: String, CodingKey {
|
||||
case returnedOn = "returned_on"
|
||||
case mitZubehoer = "mit_zubehoer"
|
||||
}
|
||||
}
|
||||
|
||||
struct ZubehoerZuordnenRequest: Encodable {
|
||||
let childItemId: Int
|
||||
enum CodingKeys: String, CodingKey { case childItemId = "child_item_id" }
|
||||
}
|
||||
|
||||
/// Ein verknuepftes Einzelstueck in Kurzform (Zubehoer bzw. das Geraet dazu).
|
||||
///
|
||||
/// Bewusst nicht `Item`: sonst zoege das Geraet seine Teile und jedes Teil
|
||||
/// wieder sein Geraet nach, endlos.
|
||||
struct ItemKurz: Codable, Identifiable, Hashable {
|
||||
let id: Int
|
||||
let uid: String
|
||||
let productId: Int
|
||||
let productName: String?
|
||||
let productBrand: String?
|
||||
let locationName: String?
|
||||
let verliehen: Bool
|
||||
|
||||
enum CodingKeys: String, CodingKey {
|
||||
case id, uid, verliehen
|
||||
case productId = "product_id"
|
||||
case productName = "product_name"
|
||||
case productBrand = "product_brand"
|
||||
case locationName = "location_name"
|
||||
}
|
||||
}
|
||||
|
||||
struct Item: Codable, Identifiable, Hashable {
|
||||
@@ -1130,11 +1167,24 @@ struct Item: Codable, Identifiable, Hashable {
|
||||
/// Der offene Verleih, falls das Stueck gerade draussen ist - sonst nil.
|
||||
/// Der Lagerort steht daneben unveraendert: dorthin gehoert es zurueck.
|
||||
let loan: Loan?
|
||||
/// Zubehoer dieses Geraets und, umgekehrt, das Geraet, zu dem dieses Stueck
|
||||
/// gehoert.
|
||||
///
|
||||
/// Beide optional, und das ist keine Bequemlichkeit: Die App spricht mit
|
||||
/// mehreren Serverprofilen, und ein aelterer Server schickt die Felder gar
|
||||
/// nicht. Ein nicht-optionales Feld mit Vorgabewert reicht dafuer NICHT -
|
||||
/// Swift nimmt den Vorgabewert beim Dekodieren nicht her, es wuerde werfen.
|
||||
var zubehoer: [ItemKurz]?
|
||||
var gehoertZu: ItemKurz?
|
||||
|
||||
/// Zubehoer ohne Optional-Gehampel an der Aufrufstelle.
|
||||
var teile: [ItemKurz] { zubehoer ?? [] }
|
||||
|
||||
var istVerliehen: Bool { loan != nil }
|
||||
|
||||
enum CodingKeys: String, CodingKey {
|
||||
case id, uid, note, currency, documents, loan
|
||||
case id, uid, note, currency, documents, loan, zubehoer
|
||||
case gehoertZu = "gehoert_zu"
|
||||
case productId = "product_id"
|
||||
case locationId = "location_id"
|
||||
case locationName = "location_name"
|
||||
|
||||
Reference in New Issue
Block a user