iOS: Benachrichtigungs-Kategorien laden + Ein-/Auslager-Meldung huebscher
1) In den Benachrichtigungs-Regeln liess sich keine Kategorie waehlen: der Abruf
holte den ganzen Snapshot (Produkte + Ablauf + Kategorien) - schlug ein Teil
fehl, kamen auch die Kategorien leer. snapshot ist jetzt widerstandsfaehig
(Teilausfaelle egal), und die Regel-Bearbeitung holt nur noch die Kategorien.
2) Die gruene Erfolgsmeldung ist jetzt eine saubere, eingerueckte Pille mit Titel
("Eingelagert"/"Ausgelagert") und Detailzeile ("Neuer Bestand: 2 Packungen").
Die Einheit steht in der Mehrzahl (Gebinde-Plural vom Server; Basiseinheiten
wie Gramm bleiben unveraendert).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -141,6 +141,7 @@ struct CheckInFormView: View {
|
|||||||
var onDone: (String) -> Void
|
var onDone: (String) -> Void
|
||||||
|
|
||||||
@Environment(\.dismiss) private var dismiss
|
@Environment(\.dismiss) private var dismiss
|
||||||
|
@EnvironmentObject private var display: DisplaySettings
|
||||||
|
|
||||||
struct Line: Identifiable {
|
struct Line: Identifiable {
|
||||||
let id = UUID()
|
let id = UUID()
|
||||||
@@ -407,7 +408,8 @@ struct CheckInFormView: View {
|
|||||||
BatchCheckInRequest(productId: product.id, unit: unit, lines: payloadLines)
|
BatchCheckInRequest(productId: product.id, unit: unit, lines: payloadLines)
|
||||||
)
|
)
|
||||||
let total = response.productStock / product.articleUnitFactor
|
let total = response.productStock / product.articleUnitFactor
|
||||||
onDone("Eingelagert. Neuer Bestand: \(format(total)) \(product.articleUnitLabel)")
|
// Titel + Detail (zweizeilig im Banner); Einheit mit Mehrzahl.
|
||||||
|
onDone("Eingelagert\nNeuer Bestand: \(format(total)) \(display.articleUnit(product, count: total))")
|
||||||
dismiss()
|
dismiss()
|
||||||
} catch {
|
} catch {
|
||||||
self.error = error.localizedDescription
|
self.error = error.localizedDescription
|
||||||
|
|||||||
@@ -135,7 +135,7 @@ struct CheckInView: View {
|
|||||||
// Einzelstücke werden nicht als Charge eingelagert, sondern als
|
// Einzelstücke werden nicht als Charge eingelagert, sondern als
|
||||||
// physische Exemplare (UID/QR) angelegt – die zählen den Bestand.
|
// physische Exemplare (UID/QR) angelegt – die zählen den Bestand.
|
||||||
ItemAddSheet(productId: item.id, onDone: {
|
ItemAddSheet(productId: item.id, onDone: {
|
||||||
status = "\(item.name): Einzelstück(e) angelegt."
|
status = "Eingelagert\n\(item.name): Einzelstück(e) angelegt."
|
||||||
})
|
})
|
||||||
} else if item.foodLike {
|
} else if item.foodLike {
|
||||||
// Lebensmittel + Verbrauchsgegenstand: Charge mit Menge/MHD.
|
// Lebensmittel + Verbrauchsgegenstand: Charge mit Menge/MHD.
|
||||||
@@ -147,7 +147,7 @@ struct CheckInView: View {
|
|||||||
// Menge je Lagerort (Gegenstand): ohne MHD, direkt Menge + Lagerort.
|
// Menge je Lagerort (Gegenstand): ohne MHD, direkt Menge + Lagerort.
|
||||||
ObjectAddSheet(product: item, locations: locations,
|
ObjectAddSheet(product: item, locations: locations,
|
||||||
einheit: item.unitName.isEmpty ? "Stück" : item.unitName,
|
einheit: item.unitName.isEmpty ? "Stück" : item.unitName,
|
||||||
perform: { status = "\(item.name): Menge eingelagert." })
|
perform: { status = "Eingelagert\n\(item.name): Menge hinzugefügt." })
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -187,15 +187,21 @@ struct CheckInView: View {
|
|||||||
// MARK: - Bausteine
|
// MARK: - Bausteine
|
||||||
|
|
||||||
private func banner(_ text: String, color: Color) -> some View {
|
private func banner(_ text: String, color: Color) -> some View {
|
||||||
Text(text)
|
// Erste Zeile = Titel (fett), Rest = Detail – ergibt eine saubere,
|
||||||
.font(.callout)
|
// rundum eingerückte Melde-Pille statt eines randlosen Balkens.
|
||||||
.padding(10)
|
let zeilen = text.components(separatedBy: "\n")
|
||||||
.frame(maxWidth: .infinity, alignment: .leading)
|
return VStack(alignment: .leading, spacing: 2) {
|
||||||
.background(color.opacity(0.9))
|
Text(zeilen.first ?? text).font(.headline)
|
||||||
.foregroundStyle(.white)
|
if zeilen.count > 1 {
|
||||||
.clipShape(RoundedRectangle(cornerRadius: 10))
|
Text(zeilen.dropFirst().joined(separator: "\n")).font(.subheadline)
|
||||||
.listRowInsets(EdgeInsets())
|
}
|
||||||
.listRowBackground(Color.clear)
|
}
|
||||||
|
.padding(.horizontal, 16).padding(.vertical, 12)
|
||||||
|
.frame(maxWidth: .infinity, alignment: .leading)
|
||||||
|
.foregroundStyle(.white)
|
||||||
|
.background(color, in: RoundedRectangle(cornerRadius: 14))
|
||||||
|
.listRowInsets(EdgeInsets(top: 4, leading: 16, bottom: 4, trailing: 16))
|
||||||
|
.listRowBackground(Color.clear)
|
||||||
}
|
}
|
||||||
|
|
||||||
private func bestandText(_ p: Product) -> String {
|
private func bestandText(_ p: Product) -> String {
|
||||||
|
|||||||
@@ -215,7 +215,7 @@ struct CheckOutFormView: View {
|
|||||||
CheckOutRequest(productId: product.id, quantity: amount, unit: unit, lotId: lotId)
|
CheckOutRequest(productId: product.id, quantity: amount, unit: unit, lotId: lotId)
|
||||||
)
|
)
|
||||||
let total = response.productStock / product.articleUnitFactor
|
let total = response.productStock / product.articleUnitFactor
|
||||||
onDone("Ausgelagert. Neuer Bestand: \(format(total)) \(product.articleUnitLabel)")
|
onDone("Ausgelagert\nNeuer Bestand: \(format(total)) \(display.articleUnit(product, count: total))")
|
||||||
dismiss()
|
dismiss()
|
||||||
} catch {
|
} catch {
|
||||||
self.error = error.localizedDescription
|
self.error = error.localizedDescription
|
||||||
|
|||||||
@@ -13,18 +13,39 @@ final class DisplaySettings: ObservableObject {
|
|||||||
static let fallbackFormat = "de"
|
static let fallbackFormat = "de"
|
||||||
|
|
||||||
@Published private(set) var dateFormat = DisplaySettings.fallbackFormat
|
@Published private(set) var dateFormat = DisplaySettings.fallbackFormat
|
||||||
|
/// Gebinde-Mehrzahlformen (Einzahl -> Mehrzahl), z.B. „Packung" -> „Packungen".
|
||||||
|
@Published private(set) var packagePlural: [String: String] = ["Packung": "Packungen"]
|
||||||
|
|
||||||
private init() {}
|
private init() {}
|
||||||
|
|
||||||
/// Nach der Anmeldung aufrufen. Schlaegt der Abruf fehl, bleibt es beim
|
/// Nach der Anmeldung aufrufen. Schlaegt ein Abruf fehl, bleibt es beim
|
||||||
/// deutschen Standard - an einer fehlenden Einstellung darf die Anzeige
|
/// deutschen Standard - an einer fehlenden Einstellung darf die Anzeige
|
||||||
/// nicht scheitern.
|
/// nicht scheitern.
|
||||||
func load() async {
|
func load() async {
|
||||||
guard let entries = try? await APIClient.shared.settings() else { return }
|
if let entries = try? await APIClient.shared.settings(),
|
||||||
if let entry = entries.first(where: { $0.key == DisplaySettings.dateFormatKey }),
|
let entry = entries.first(where: { $0.key == DisplaySettings.dateFormatKey }),
|
||||||
!entry.value.isEmpty {
|
!entry.value.isEmpty {
|
||||||
dateFormat = entry.value
|
dateFormat = entry.value
|
||||||
}
|
}
|
||||||
|
if let typen = try? await APIClient.shared.packageTypes() {
|
||||||
|
var map = ["Packung": "Packungen"]
|
||||||
|
for t in typen where !t.singular.isEmpty { map[t.singular] = t.plural }
|
||||||
|
packagePlural = map
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Ein Gebinde in der passenden Zahlform: „1 Packung", aber „2 Packungen".
|
||||||
|
func gebinde(_ count: Double, _ singular: String) -> String {
|
||||||
|
count == 1 ? singular : (packagePlural[singular] ?? singular)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Artikeleinheit mit Mehrzahl: bei Packungen die Gebinde-Mehrzahl, sonst die
|
||||||
|
/// Basiseinheit (Gramm/Milliliter/Stück – im Deutschen ohne Mehrzahl).
|
||||||
|
func articleUnit(_ product: Product, count: Double) -> String {
|
||||||
|
if let size = product.packageSize, size > 0 {
|
||||||
|
return gebinde(count, product.packageLabel ?? "Packung")
|
||||||
|
}
|
||||||
|
return product.articleUnitLabel
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - Datum
|
// MARK: - Datum
|
||||||
|
|||||||
@@ -199,17 +199,14 @@ final class NotificationScheduler {
|
|||||||
let token = Keychain.read(account: Session.keychainAccount(for: prof.id))
|
let token = Keychain.read(account: Session.keychainAccount(for: prof.id))
|
||||||
else { continue }
|
else { continue }
|
||||||
let config = NotificationStore.config(for: prof.id)
|
let config = NotificationStore.config(for: prof.id)
|
||||||
do {
|
// snapshot ist widerstandsfaehig: unerreichbare Server (Heimnetz von
|
||||||
let snapshot = try await ServerFetch.snapshot(
|
// unterwegs) liefern leere Listen -> es wird nichts geplant, die
|
||||||
baseURL: url, token: token, horizonDays: Self.horizonDays)
|
// anderen Server trotzdem.
|
||||||
geplant += ExpiryPlanner.plan(
|
let snapshot = await ServerFetch.snapshot(
|
||||||
profileID: prof.id, serverName: prof.name, config: config,
|
baseURL: url, token: token, horizonDays: Self.horizonDays)
|
||||||
snapshot: snapshot, today: heute, horizonDays: Self.horizonDays)
|
geplant += ExpiryPlanner.plan(
|
||||||
} catch {
|
profileID: prof.id, serverName: prof.name, config: config,
|
||||||
// Unerreichbar (Heimnetz von unterwegs) oder Token abgelaufen:
|
snapshot: snapshot, today: heute, horizonDays: Self.horizonDays)
|
||||||
// diesen Server ueberspringen, die anderen trotzdem planen.
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Nach Datum sortieren und auf das iOS-Limit kappen - die naechsten zuerst.
|
// Nach Datum sortieren und auf das iOS-Limit kappen - die naechsten zuerst.
|
||||||
|
|||||||
@@ -152,8 +152,7 @@ struct NotificationSettingsView: View {
|
|||||||
let url = profile.url,
|
let url = profile.url,
|
||||||
let token = Keychain.read(account: Session.keychainAccount(for: profileID))
|
let token = Keychain.read(account: Session.keychainAccount(for: profileID))
|
||||||
else { return }
|
else { return }
|
||||||
categories = (try? await ServerFetch.snapshot(
|
categories = (try? await ServerFetch.categories(baseURL: url, token: token)) ?? []
|
||||||
baseURL: url, token: token, horizonDays: 1).categories) ?? []
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private func pruefeBerechtigung() async {
|
private func pruefeBerechtigung() async {
|
||||||
|
|||||||
@@ -15,12 +15,24 @@ enum ServerFetch {
|
|||||||
let categories: [CategoryItem]
|
let categories: [CategoryItem]
|
||||||
}
|
}
|
||||||
|
|
||||||
static func snapshot(baseURL: URL, token: String, horizonDays: Int) async throws -> Snapshot {
|
static func snapshot(baseURL: URL, token: String, horizonDays: Int) async -> Snapshot {
|
||||||
async let expiring: [ExpiringItem] = get(
|
async let expiring: [ExpiringItem] = get(
|
||||||
"/expiring?days=\(horizonDays)", baseURL: baseURL, token: token)
|
"/expiring?days=\(horizonDays)", baseURL: baseURL, token: token)
|
||||||
async let products: [Product] = get("/products", baseURL: baseURL, token: token)
|
async let products: [Product] = get("/products", baseURL: baseURL, token: token)
|
||||||
async let categories: [CategoryItem] = get("/categories", baseURL: baseURL, token: token)
|
async let categories: [CategoryItem] = get("/categories", baseURL: baseURL, token: token)
|
||||||
return try await Snapshot(expiring: expiring, products: products, categories: categories)
|
// Einzelne Ausfaelle sollen nicht alles kippen (sonst fehlten z.B. die
|
||||||
|
// Kategorien nur, weil die Produktliste langsam war).
|
||||||
|
return await Snapshot(
|
||||||
|
expiring: (try? await expiring) ?? [],
|
||||||
|
products: (try? await products) ?? [],
|
||||||
|
categories: (try? await categories) ?? []
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Nur den Kategorie-Baum eines bestimmten Servers holen (fuer die
|
||||||
|
/// Regel-Bearbeitung in den Benachrichtigungen).
|
||||||
|
static func categories(baseURL: URL, token: String) async throws -> [CategoryItem] {
|
||||||
|
try await get("/categories", baseURL: baseURL, token: token)
|
||||||
}
|
}
|
||||||
|
|
||||||
private static func get<T: Decodable>(
|
private static func get<T: Decodable>(
|
||||||
|
|||||||
Reference in New Issue
Block a user