In der App gab es beides nicht. Chargen waren ueberhaupt nur innerhalb
eines Artikels zu sehen, und dort nur zum Korrigieren - eine
Chargenliste wie im Web fehlte, und damit jeder Weg, eine Packung
anzubrechen oder eine Teilmenge zu verbrauchen. Das Backend kann das
seit zwei Wochen.
Neu im Tab "Listen": der Eintrag "Chargen" mit allen Chargen ueber alle
Artikel, durchsuchbar, mit Schalter "Nur angebrochene". Je Zeile stehen
Menge, MHD bzw. Verbrauchsdatum, Lagerort und ein Abzeichen
"angebrochen"; bei einer offenen Packung die Restmenge in Basiseinheiten
("980 g von 1.000 g"), denn "0,98 Packungen" sagt niemandem etwas.
Nach links wischen gibt die zwei Buchungen:
"Anbrechen" mit Rueckfrage - eine Packung wird abgespalten und bekommt
ihr Verbrauchsdatum, der Rest bleibt zu und behaelt sein MHD.
"Verbrauchen" oeffnet ein Fenster fuer die VERBRAUCHTE Menge (nicht den
Rest - das ist die Zahl, die man kennt). Die Einheitenauswahl holt die
Hausmasse der Kategorie dazu, "2 EL" ist damit buchbar und landet bei
Mehl als 20 g. War die Charge noch zu, bricht die Buchung sie an; der
Haken dafuer ist abwaehlbar.
Loeschen fehlt in der Liste mit Absicht: Eine Charge verschwindet durchs
Verbrauchen, und das Korrigieren sitzt beim Artikel.
Dort zeigt die Chargenzeile jetzt auch "angebrochen · verbrauchen bis
..." - zwei Zeilen mit gleicher Menge waren sonst nicht zu
unterscheiden.
Alles in bestehende Dateien gelegt, keine neue Quelldatei: Das
mitgelieferte Xcode-Projekt listet die Dateien einzeln, eine neue waere
ohne "xcodegen generate" stillschweigend nicht mitgebaut worden.
Gegen den laufenden Server geprueft, genau mit den Aufrufen, die die App
macht: /lots/rows liefert alle Felder, die das Swift-Modell liest;
{"quantity":null} an /lots/{id}/open spaltet 1000 von 2000 g ab und
setzt consume_by; 2 Essloeffel an /lots/{id}/consume buchen 20 g ab und
die Antwort traegt opened und lot.
Nicht kompiliert: kein Xcode auf dieser Maschine. Geprueft sind Klammer-
und Anfuehrungsbalance (und dabei EIN echter Fehler gefunden: ein
deutsches Anfuehrungszeichen mit geradem Schlusszeichen haette eine
Zeichenkette vorzeitig beendet), dass formatAmount global erreichbar
ist, und dass die benutzten Hilfsfunktionen so heissen. Der Build in
Xcode bleibt die Probe.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
921 lines
36 KiB
Swift
921 lines
36 KiB
Swift
import SwiftUI
|
||
|
||
/// Gemeinsame Zahlenformatierung der Listen.
|
||
func formatAmount(_ value: Double) -> String {
|
||
value == value.rounded() ? String(Int(value)) : String(format: "%.2f", value)
|
||
}
|
||
|
||
/// Kennzeichnung "Gruppe" an einer Listenzeile.
|
||
struct GroupBadge: View {
|
||
var body: some View {
|
||
Text("Gruppe")
|
||
.font(.caption2)
|
||
.padding(.horizontal, 6)
|
||
.padding(.vertical, 2)
|
||
.background(Color.secondary.opacity(0.15))
|
||
.clipShape(Capsule())
|
||
.foregroundStyle(.secondary)
|
||
}
|
||
}
|
||
|
||
/// Zeile mit Symbol links, damit Artikel und Gruppe unterscheidbar sind.
|
||
struct ListRow<Trailing: View>: View {
|
||
let systemImage: String
|
||
let title: String
|
||
let subtitle: String
|
||
@ViewBuilder var trailing: Trailing
|
||
|
||
var body: some View {
|
||
HStack(spacing: 12) {
|
||
Image(systemName: systemImage)
|
||
.font(.body)
|
||
.foregroundStyle(.secondary)
|
||
.frame(width: 24)
|
||
VStack(alignment: .leading, spacing: 2) {
|
||
HStack(spacing: 6) {
|
||
Text(title)
|
||
trailing
|
||
}
|
||
Text(subtitle)
|
||
.font(.caption).foregroundStyle(.secondary)
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
// MARK: - Einkaufsliste
|
||
|
||
struct ShoppingListView: View {
|
||
@EnvironmentObject private var display: DisplaySettings
|
||
|
||
@State private var items: [ShoppingItem] = []
|
||
@State private var groups: [GroupShoppingItem] = []
|
||
@State private var byLocation: [LocationNeeds] = []
|
||
/// Selbst draufgesetzte Posten - von Hand oder aus einem Rezept uebernommen.
|
||
@State private var entries: [ShoppingEntry] = []
|
||
@State private var busy = true
|
||
@State private var error: String?
|
||
|
||
/// Abgehakte verschwinden: Die Liste ist zum Einkaufen da, nicht zum
|
||
/// Nachlesen. Das Aufraeumen ("Erledigte entfernen") macht die
|
||
/// Weboberflaeche.
|
||
private var offeneEintraege: [ShoppingEntry] { entries.filter { !$0.done } }
|
||
|
||
var body: some View {
|
||
List {
|
||
if let error {
|
||
Section { Text(error).foregroundStyle(.red).font(.callout) }
|
||
}
|
||
if !offeneEintraege.isEmpty {
|
||
Section("Selbst draufgesetzt") {
|
||
ForEach(offeneEintraege) { eintrag in
|
||
Button {
|
||
Task { await hake(eintrag) }
|
||
} label: {
|
||
ListRow(
|
||
systemImage: eintrag.istNotiz ? "square.and.pencil" : "shippingbox",
|
||
title: eintrag.name,
|
||
subtitle: eintrag.untertitel
|
||
) {
|
||
if eintrag.istGruppe { GroupBadge() }
|
||
}
|
||
}
|
||
.buttonStyle(.plain)
|
||
}
|
||
}
|
||
}
|
||
if !items.isEmpty {
|
||
Section("Artikel unter Mindestbestand") {
|
||
ForEach(items) { item in
|
||
ListRow(
|
||
systemImage: "shippingbox",
|
||
title: item.name,
|
||
subtitle: "fehlt \(display.amountText(item.deficit, packageSize: item.packageSize, baseUnit: item.baseUnit))"
|
||
) { EmptyView() }
|
||
}
|
||
}
|
||
}
|
||
if !groups.isEmpty {
|
||
Section("Gruppen unter Mindestbestand") {
|
||
ForEach(groups) { group in
|
||
ListRow(
|
||
systemImage: "square.stack.3d.up",
|
||
title: group.name,
|
||
subtitle: "fehlt \(formatAmount(group.deficit)) \(group.unitName)"
|
||
+ " · \(group.productCount) Artikel"
|
||
+ ((group.subgroupCount ?? 0) > 0
|
||
? " · inkl. " + anzahlWort(group.subgroupCount ?? 0, "Untergruppe", "Untergruppen")
|
||
: "")
|
||
) { GroupBadge() }
|
||
}
|
||
}
|
||
}
|
||
ForEach(byLocation) { ort in
|
||
Section("Bedarf: \(ort.locationName)") {
|
||
ForEach(ort.products) { p in
|
||
ListRow(
|
||
systemImage: "shippingbox",
|
||
title: p.name,
|
||
subtitle: "fehlt \(formatAmount(p.deficit)) \(p.unitLabel)"
|
||
) { EmptyView() }
|
||
}
|
||
ForEach(ort.groups) { g in
|
||
ListRow(
|
||
systemImage: "square.stack.3d.up",
|
||
title: g.name,
|
||
subtitle: "fehlt \(formatAmount(g.deficit)) \(g.unitName)"
|
||
) { GroupBadge() }
|
||
}
|
||
}
|
||
}
|
||
if items.isEmpty && groups.isEmpty && byLocation.isEmpty
|
||
&& offeneEintraege.isEmpty && !busy {
|
||
Text("Nichts einzukaufen – alle Mindestbestände sind gedeckt.")
|
||
.foregroundStyle(.secondary)
|
||
}
|
||
}
|
||
.navigationTitle("Einkaufsliste")
|
||
.navigationBarTitleDisplayMode(.inline)
|
||
.refreshable { await load() }
|
||
.task { await load() }
|
||
}
|
||
|
||
/// Abhaken. Der Posten verschwindet sofort; geht das Speichern schief,
|
||
/// kommt er zurueck - lieber ein Ruecksprung als ein falsches Haekchen.
|
||
private func hake(_ eintrag: ShoppingEntry) async {
|
||
let vorher = entries
|
||
entries.removeAll { $0.id == eintrag.id }
|
||
do {
|
||
try await APIClient.shared.setShoppingEntryDone(eintrag.id, done: true)
|
||
} catch {
|
||
entries = vorher
|
||
self.error = error.localizedDescription
|
||
}
|
||
}
|
||
|
||
private func load() async {
|
||
busy = true
|
||
defer { busy = false }
|
||
do {
|
||
// EIN Aufruf statt drei - und der einzige, der die selbst
|
||
// draufgesetzten Posten mitbringt. Die drei Einzelrouten liefern
|
||
// nur die gerechneten Bedarfe; wer nur sie abfragt, sieht die
|
||
// eigenen Eintraege ueberhaupt nicht.
|
||
let alles = try await APIClient.shared.shoppingAll()
|
||
items = alles.products
|
||
groups = alles.groups
|
||
byLocation = alles.byLocation
|
||
entries = alles.entries
|
||
error = nil
|
||
} catch {
|
||
self.error = error.localizedDescription
|
||
}
|
||
}
|
||
}
|
||
|
||
// MARK: - Bald ablaufend
|
||
|
||
struct ExpiringView: View {
|
||
@EnvironmentObject private var display: DisplaySettings
|
||
|
||
@State private var items: [ExpiringItem] = []
|
||
@State private var busy = true
|
||
@State private var error: String?
|
||
|
||
var body: some View {
|
||
List {
|
||
if let error {
|
||
Section { Text(error).foregroundStyle(.red).font(.callout) }
|
||
}
|
||
ForEach(items) { item in
|
||
HStack {
|
||
VStack(alignment: .leading, spacing: 2) {
|
||
Text(item.productName)
|
||
Text("\(item.fristLabel) \(display.formatBestBefore(item.warnDate, precision: item.fristPrecision)) · \(articleAmount(item))")
|
||
.font(.caption).foregroundStyle(.secondary)
|
||
}
|
||
Spacer()
|
||
Text(item.restlaufzeitText)
|
||
.font(.caption).bold()
|
||
.foregroundStyle(item.daysLeft < 0 ? .red : .orange)
|
||
}
|
||
}
|
||
if items.isEmpty && !busy {
|
||
Text("Nichts läuft demnächst ab.").foregroundStyle(.secondary)
|
||
}
|
||
}
|
||
.navigationTitle("Bald ablaufend")
|
||
.navigationBarTitleDisplayMode(.inline)
|
||
.refreshable { await load() }
|
||
.task { await load() }
|
||
}
|
||
|
||
private func load() async {
|
||
busy = true
|
||
defer { busy = false }
|
||
do {
|
||
items = try await APIClient.shared.expiring()
|
||
error = nil
|
||
} catch {
|
||
self.error = error.localizedDescription
|
||
}
|
||
}
|
||
|
||
/// Gebinde ist die Leitangabe, sonst die Produkteinheit.
|
||
private func articleAmount(_ item: ExpiringItem) -> String {
|
||
if let size = item.packageSize, size > 0 {
|
||
return "\(formatAmount(item.quantity / size)) \(item.packageLabel ?? "Packung")"
|
||
}
|
||
let faktor = item.unitFactor == 0 ? 1 : item.unitFactor
|
||
return "\(formatAmount(item.quantity / faktor)) \(item.unitName)"
|
||
}
|
||
|
||
}
|
||
|
||
// MARK: - Mindestbestände
|
||
|
||
/// Übersicht aller Mindestbestände – nach LAGERORT gegliedert.
|
||
///
|
||
/// Ein Mindestbestand ist immer ein Bedarf an einem Ort. „Überall" (egal wo,
|
||
/// Hauptsache im Haus) ist dabei der oberste Ort; Käufe für einen einzelnen
|
||
/// Lagerort decken ihn mit ab. Mengen kommen in Basiseinheiten und werden hier
|
||
/// in die Erfassungseinheit des Ziels umgerechnet.
|
||
///
|
||
/// Artikelzeilen öffnen den Artikel, Gruppenzeilen die Gruppe – beide lassen
|
||
/// sich dort bearbeiten.
|
||
struct MinStockView: View {
|
||
@State private var products: [Product] = []
|
||
@State private var groups: [GroupItem] = []
|
||
@State private var busy = true
|
||
@State private var error: String?
|
||
|
||
private struct MinRow: Identifiable {
|
||
let id: String
|
||
let title: String
|
||
let soll: String
|
||
let bestand: String?
|
||
let productId: Int? // Tippziel (Artikel)
|
||
let groupId: Int? // Tippziel (Gruppe)
|
||
let unter: Bool
|
||
let untergruppen: Int
|
||
}
|
||
|
||
private struct OrtBlock: Identifiable {
|
||
let id: String
|
||
let name: String
|
||
let zeilen: [MinRow]
|
||
}
|
||
|
||
/// Alle Zeilen, gebündelt je Ort. „Überall" (Ort nil) steht zuerst.
|
||
private var bloecke: [OrtBlock] {
|
||
var jeOrt: [String: [MinRow]] = [:]
|
||
var namen: [String: String] = [UEBERALL_ID: UEBERALL_NAME]
|
||
|
||
func merken(_ locationId: String?, _ locationName: String?, _ row: MinRow) {
|
||
let key = locationId ?? UEBERALL_ID
|
||
if let n = locationName, locationId != nil { namen[key] = n }
|
||
jeOrt[key, default: []].append(row)
|
||
}
|
||
|
||
for p in products.sorted(by: { $0.name.localizedCaseInsensitiveCompare($1.name) == .orderedAscending }) {
|
||
let disp = p.unitFactor == 0 ? 1 : p.unitFactor // Basis -> Anzeigeeinheit
|
||
for l in (p.locationMinStocks ?? []) {
|
||
merken(l.locationId, l.locationName, MinRow(
|
||
id: "p\(p.id)l\(l.id)", title: p.name,
|
||
soll: "\(formatAmount(l.minStock / disp)) \(p.unitName)",
|
||
bestand: l.stock.map { "\(formatAmount($0 / disp)) \(p.unitName)" },
|
||
productId: p.id, groupId: nil,
|
||
unter: (l.stock ?? 0) < l.minStock, untergruppen: 0))
|
||
}
|
||
}
|
||
for g in groups.sorted(by: { $0.name.localizedCaseInsensitiveCompare($1.name) == .orderedAscending }) {
|
||
let faktor = g.minFaktor == 0 ? 1 : g.minFaktor
|
||
let einheit = g.minEinheit
|
||
for l in (g.locationMinStocks ?? []) {
|
||
merken(l.locationId, l.locationName, MinRow(
|
||
id: "g\(g.id)l\(l.id)", title: g.name,
|
||
soll: "\(formatAmount(l.minStock / faktor)) \(einheit)",
|
||
bestand: l.stock.map { "\(formatAmount($0 / faktor)) \(einheit)" },
|
||
productId: nil, groupId: g.id,
|
||
unter: (l.stock ?? 0) < l.minStock,
|
||
untergruppen: g.childIds?.count ?? 0))
|
||
}
|
||
}
|
||
|
||
// „Überall" zuerst, danach die Lagerorte alphabetisch.
|
||
let schluessel = jeOrt.keys.sorted { a, b in
|
||
if a == UEBERALL_ID { return true }
|
||
if b == UEBERALL_ID { return false }
|
||
return (namen[a] ?? "").localizedCaseInsensitiveCompare(namen[b] ?? "") == .orderedAscending
|
||
}
|
||
return schluessel.map {
|
||
OrtBlock(id: $0, name: namen[$0] ?? "Lagerort",
|
||
zeilen: jeOrt[$0] ?? [])
|
||
}
|
||
}
|
||
|
||
var body: some View {
|
||
List {
|
||
if !busy && bloecke.isEmpty {
|
||
Text("Noch keine Mindestbestände gesetzt.").foregroundStyle(.secondary)
|
||
}
|
||
ForEach(bloecke) { block in
|
||
Section(block.id == UEBERALL_ID ? "Überall (egal wo)" : block.name) {
|
||
ForEach(block.zeilen) { r in
|
||
if let pid = r.productId, let p = products.first(where: { $0.id == pid }) {
|
||
NavigationLink { ProductDetailView(product: p) } label: { zeile(r) }
|
||
} else if let gid = r.groupId, let g = groups.first(where: { $0.id == gid }) {
|
||
NavigationLink {
|
||
GroupEditor(item: g, all: groups) { Task { await load() } }
|
||
} label: { zeile(r) }
|
||
} else {
|
||
zeile(r)
|
||
}
|
||
}
|
||
}
|
||
}
|
||
}
|
||
.navigationTitle("Mindestbestände")
|
||
.navigationBarTitleDisplayMode(.inline)
|
||
.overlay { if busy && bloecke.isEmpty { ProgressView() } }
|
||
.refreshable { await load() }
|
||
.onAppear { Task { await load() } }
|
||
.alert("Fehler", isPresented: Binding(get: { error != nil }, set: { if !$0 { error = nil } })) {
|
||
Button("OK", role: .cancel) {}
|
||
} message: { Text(error ?? "") }
|
||
}
|
||
|
||
@ViewBuilder
|
||
private func zeile(_ r: MinRow) -> some View {
|
||
HStack(spacing: 12) {
|
||
VStack(alignment: .leading, spacing: 2) {
|
||
HStack(spacing: 6) {
|
||
Text(r.title)
|
||
if r.groupId != nil {
|
||
Text("Gruppe").font(.caption2)
|
||
.padding(.horizontal, 6).padding(.vertical, 1)
|
||
.background(Color.marke.opacity(0.2), in: Capsule())
|
||
}
|
||
if r.untergruppen > 0 {
|
||
Text("inkl. \(r.untergruppen)").font(.caption2)
|
||
.padding(.horizontal, 6).padding(.vertical, 1)
|
||
.background(Color.secondary.opacity(0.15), in: Capsule())
|
||
}
|
||
}
|
||
Text("Soll \(r.soll)" + (r.bestand.map { " · Bestand \($0)" } ?? ""))
|
||
.font(.caption).foregroundStyle(.secondary)
|
||
}
|
||
Spacer()
|
||
if r.unter {
|
||
Text("niedrig").font(.caption2)
|
||
.padding(.horizontal, 8).padding(.vertical, 3)
|
||
.background(Color.orange.opacity(0.2), in: Capsule())
|
||
}
|
||
}
|
||
}
|
||
|
||
private func load() async {
|
||
busy = true
|
||
defer { busy = false }
|
||
do {
|
||
async let ps = APIClient.shared.searchProducts("")
|
||
async let gs = APIClient.shared.groups()
|
||
products = try await ps
|
||
groups = try await gs
|
||
Task { await ProductImageCache.shared.preload(products) }
|
||
} catch {
|
||
self.error = error.localizedDescription
|
||
}
|
||
}
|
||
}
|
||
|
||
// MARK: - Produktliste
|
||
|
||
struct ProductListView: View {
|
||
/// nil = alle, "food" = nur Lebensmittel, "object" = nur Gegenstände.
|
||
/// Getrennte Listen wie im Web (dort über `fixedType`/`p.tracking`).
|
||
var fixedType: String? = nil
|
||
|
||
@EnvironmentObject private var session: Session
|
||
@State private var query = ""
|
||
@State private var products: [Product] = []
|
||
@State private var categories: [CategoryItem] = []
|
||
/// nil = alle, 0 = ohne Kategorie, sonst die ID (Unterkategorien zaehlen mit).
|
||
@State private var categoryId: Int?
|
||
@State private var sortMode: SortMode = .nameAsc
|
||
/// nil = alle; sonst "count"/"individual"/"bulk" (nur in der Gegenstände-Liste).
|
||
@State private var verwaltungFilter: String?
|
||
@State private var busy = false
|
||
@State private var showNew = false
|
||
// Der gerade angelegte Artikel, bis sich das Formular geschlossen hat.
|
||
@State private var angelegt: Product?
|
||
@State private var einzelstueckeFuer: Product?
|
||
|
||
enum SortMode: String, CaseIterable, Identifiable {
|
||
case nameAsc = "Name A–Z"
|
||
case nameDesc = "Name Z–A"
|
||
case updated = "Zuletzt geändert"
|
||
var id: String { rawValue }
|
||
}
|
||
|
||
private let verwaltungOptionen: [(String, String)] = [
|
||
("count", "Menge je Lagerort"),
|
||
("individual", "Einzelstücke"),
|
||
("bulk", "Verbrauchsgegenstand"),
|
||
]
|
||
|
||
/// Verwaltungsart eines Gegenstands.
|
||
private func verwaltung(_ p: Product) -> String {
|
||
p.isIndividual ? "individual" : (p.isBulk ? "bulk" : "count")
|
||
}
|
||
|
||
/// Auf den festen Typ und ggf. die Verwaltungsart gefiltert (wie im Web).
|
||
private var sichtbar: [Product] {
|
||
products.filter { p in
|
||
if let fixedType, (p.isObject ? "object" : "food") != fixedType { return false }
|
||
if let vf = verwaltungFilter, verwaltung(p) != vf { return false }
|
||
return true
|
||
}
|
||
}
|
||
|
||
/// Sichtbare Produkte in der gewählten Reihenfolge.
|
||
private var sortiert: [Product] {
|
||
switch sortMode {
|
||
case .nameAsc:
|
||
return sichtbar.sorted { $0.name.localizedCaseInsensitiveCompare($1.name) == .orderedAscending }
|
||
case .nameDesc:
|
||
return sichtbar.sorted { $0.name.localizedCaseInsensitiveCompare($1.name) == .orderedDescending }
|
||
case .updated:
|
||
// ISO-Strings gleichen Formats sind chronologisch vergleichbar; neueste zuerst.
|
||
return sichtbar.sorted { ($0.updatedAt ?? "") > ($1.updatedAt ?? "") }
|
||
}
|
||
}
|
||
|
||
/// Nur Kategorien des Listentyps (Lebensmittel-Liste zeigt nur Food-Kategorien usw.).
|
||
private var typKategorien: [CategoryItem] {
|
||
guard let fixedType else { return categories }
|
||
return categories.filter { ($0.tracking ?? "food") == fixedType }
|
||
}
|
||
|
||
private var titel: String {
|
||
switch fixedType {
|
||
case "food": return "Lebensmittel"
|
||
case "object": return "Gegenstände"
|
||
default: return "Produkte"
|
||
}
|
||
}
|
||
|
||
private var filterLabel: String {
|
||
if categoryId == 0 { return "Ohne Kategorie" }
|
||
if let id = categoryId, let treffer = categories.first(where: { $0.id == id }) {
|
||
return treffer.name
|
||
}
|
||
return "Alle Kategorien"
|
||
}
|
||
|
||
var body: some View {
|
||
List {
|
||
Section {
|
||
Menu {
|
||
Button("Alle Kategorien") { pick(nil) }
|
||
Button("Ohne Kategorie") { pick(0) }
|
||
Divider()
|
||
ForEach(categoryTree()) { eintrag in
|
||
Button(eintrag.label) { pick(eintrag.item.id) }
|
||
}
|
||
} label: {
|
||
HStack {
|
||
Label("Kategorie", systemImage: "line.3.horizontal.decrease.circle")
|
||
Spacer()
|
||
Text(filterLabel).foregroundStyle(.secondary)
|
||
}
|
||
}
|
||
if fixedType == "object" {
|
||
Picker(selection: $verwaltungFilter) {
|
||
Text("Alle").tag(String?.none)
|
||
ForEach(verwaltungOptionen, id: \.0) { opt in
|
||
Text(opt.1).tag(String?.some(opt.0))
|
||
}
|
||
} label: {
|
||
Label("Verwaltung", systemImage: "square.grid.2x2")
|
||
}
|
||
.pickerStyle(.menu)
|
||
}
|
||
Picker(selection: $sortMode) {
|
||
ForEach(SortMode.allCases) { Text($0.rawValue).tag($0) }
|
||
} label: {
|
||
Label("Sortierung", systemImage: "arrow.up.arrow.down")
|
||
}
|
||
.pickerStyle(.menu)
|
||
}
|
||
|
||
Section {
|
||
ForEach(sortiert) { product in
|
||
NavigationLink {
|
||
ProductDetailView(product: product)
|
||
} label: {
|
||
ProductRow(product: product)
|
||
}
|
||
}
|
||
if sichtbar.isEmpty && !busy {
|
||
Text("Keine Treffer").foregroundStyle(.secondary)
|
||
}
|
||
}
|
||
}
|
||
.searchable(text: $query, prompt: "Artikel suchen")
|
||
.onChange(of: query) { _ in Task { await load() } }
|
||
.navigationTitle(titel)
|
||
.navigationBarTitleDisplayMode(.inline)
|
||
.refreshable { await load() }
|
||
.toolbar {
|
||
if session.isAdmin {
|
||
ToolbarItem(placement: .topBarTrailing) {
|
||
Button { showNew = true } label: {
|
||
Label("Artikel anlegen", systemImage: "plus")
|
||
}
|
||
}
|
||
}
|
||
}
|
||
.sheet(isPresented: $showNew, onDismiss: {
|
||
if fuehrtEinzelstuecke(angelegt) { einzelstueckeFuer = angelegt }
|
||
angelegt = nil
|
||
}) {
|
||
NavigationStack {
|
||
// Ohne Barcode: nur Name + Kategorie nötig. Typ aus der Liste
|
||
// vorbelegen. Nach dem Anlegen schließt sich das Formular, die
|
||
// Liste wird aufgefrischt – und bei Gegenständen mit eigener UID
|
||
// je Stück geht es gleich weiter zum Anlegen der Einzelstücke.
|
||
ProductFormView(prefillBarcode: nil, groupId: nil, initialType: fixedType) { created in
|
||
angelegt = created
|
||
showNew = false
|
||
Task { await load() }
|
||
}
|
||
.toolbar {
|
||
ToolbarItem(placement: .topBarLeading) {
|
||
Button("Abbrechen") { showNew = false }
|
||
}
|
||
}
|
||
}
|
||
}
|
||
.einzelstueckeNachAnlegen($einzelstueckeFuer)
|
||
.task { categories = (try? await APIClient.shared.categories()) ?? [] }
|
||
// Bei jedem Erscheinen neu laden – auch beim Zurückkommen aus der
|
||
// Detailansicht, damit ein dort geänderter Bestand (z.B. neu angelegte
|
||
// Einzelstücke) sofort stimmt und nicht „0" hängen bleibt.
|
||
.onAppear { Task { await load() } }
|
||
}
|
||
|
||
private func pick(_ id: Int?) {
|
||
categoryId = id
|
||
Task { await load() }
|
||
}
|
||
|
||
/// Kategorien in Baumreihenfolge mit eingerueckter Beschriftung.
|
||
private struct TreeEntry: Identifiable {
|
||
let item: CategoryItem
|
||
let depth: Int
|
||
var id: Int { item.id }
|
||
var label: String { String(repeating: " ", count: depth) + item.name }
|
||
}
|
||
|
||
private func categoryTree() -> [TreeEntry] {
|
||
let liste = typKategorien
|
||
let ids = Set(liste.map(\.id))
|
||
var result: [TreeEntry] = []
|
||
|
||
func walk(_ node: CategoryItem, _ depth: Int) {
|
||
result.append(TreeEntry(item: node, depth: depth))
|
||
for child in liste.filter({ $0.parentId == node.id }) {
|
||
walk(child, depth + 1)
|
||
}
|
||
}
|
||
// Waisen (Oberkategorie geloescht) gelten als oberste Ebene.
|
||
for root in liste.filter({ $0.parentId == nil || !ids.contains($0.parentId!) }) {
|
||
walk(root, 0)
|
||
}
|
||
return result
|
||
}
|
||
|
||
private func load() async {
|
||
busy = true
|
||
defer { busy = false }
|
||
let geladen = (try? await APIClient.shared.searchProducts(query, categoryId: categoryId)) ?? []
|
||
products = geladen
|
||
// Bilder im Hintergrund sicherstellen – nur fehlende/geänderte werden geholt,
|
||
// damit die Thumbnails auch nach App-Neustart sofort aus dem Cache kommen.
|
||
Task { await ProductImageCache.shared.preload(geladen) }
|
||
}
|
||
}
|
||
|
||
/// Artikelzeile mit Gebinde und Hinweis auf den Mindestbestand.
|
||
struct ProductRow: View {
|
||
let product: Product
|
||
|
||
var body: some View {
|
||
HStack(spacing: 12) {
|
||
ProductThumb(productId: product.id)
|
||
VStack(alignment: .leading, spacing: 2) {
|
||
Text(product.name)
|
||
Text(bestand)
|
||
.font(.caption).foregroundStyle(.secondary)
|
||
if let hinweis = mindestbestand {
|
||
Text(hinweis).font(.caption2).foregroundStyle(farbe)
|
||
}
|
||
}
|
||
Spacer()
|
||
}
|
||
}
|
||
|
||
private var bestand: String {
|
||
var text = "\(formatAmount(product.stockInArticleUnits)) \(product.articleUnitLabel)"
|
||
// Bei einem Gebinde die Basismenge dahinter, damit "2 Glas" greifbar wird.
|
||
if let size = product.packageSize, size > 0 {
|
||
text += " · à \(formatAmount(size)) \(DisplaySettings.baseUnitLabel(product.baseUnit))"
|
||
}
|
||
return text
|
||
}
|
||
|
||
private var mindestbestand: String? {
|
||
guard let anzeige = product.minStockDisplay else { return nil }
|
||
let einheit = product.minStockUnitLabel ?? ""
|
||
switch product.stockLevel {
|
||
case .below: return "unter Mindestbestand (\(formatAmount(anzeige)) \(einheit))"
|
||
case .close: return "bald nachkaufen (Minimum \(formatAmount(anzeige)) \(einheit))"
|
||
case .ok, .none: return nil
|
||
}
|
||
}
|
||
|
||
private var farbe: Color {
|
||
product.stockLevel == .below ? .red : .orange
|
||
}
|
||
}
|
||
|
||
// MARK: - Chargen
|
||
|
||
/// Alle Chargen über alle Artikel – das Gegenstück zur Chargenliste im Web.
|
||
///
|
||
/// Hier passiert das Anbrechen und der Teilverbrauch. Beides gab es in der App
|
||
/// bisher nirgends: Chargen waren nur im Artikel selbst zu sehen, und dort nur
|
||
/// zum Korrigieren.
|
||
struct ChargenListView: View {
|
||
@EnvironmentObject private var display: DisplaySettings
|
||
|
||
@State private var zeilen: [LotRow] = []
|
||
@State private var locations: [StorageLocation] = []
|
||
@State private var suche = ""
|
||
@State private var nurAngebrochen = false
|
||
@State private var verbrauchen: LotRow?
|
||
@State private var anbrechen: LotRow?
|
||
@State private var busy = true
|
||
@State private var error: String?
|
||
@State private var hinweis: String?
|
||
|
||
private var gefiltert: [LotRow] {
|
||
zeilen.filter { zeile in
|
||
if nurAngebrochen && !zeile.istAngebrochen { return false }
|
||
guard !suche.isEmpty else { return true }
|
||
let text = "\(zeile.productName) \(zeile.productBrand ?? "")".lowercased()
|
||
return text.contains(suche.lowercased())
|
||
}
|
||
}
|
||
|
||
var body: some View {
|
||
List {
|
||
if let error {
|
||
Section { Text(error).foregroundStyle(.red).font(.callout) }
|
||
}
|
||
if let hinweis {
|
||
Section { Text(hinweis).foregroundStyle(.green).font(.callout) }
|
||
}
|
||
Section {
|
||
Toggle("Nur angebrochene", isOn: $nurAngebrochen)
|
||
}
|
||
Section("\(gefiltert.count) Chargen") {
|
||
ForEach(gefiltert) { zeile in
|
||
VStack(alignment: .leading, spacing: 2) {
|
||
HStack(spacing: 6) {
|
||
Text(zeile.productName)
|
||
if zeile.istAngebrochen {
|
||
Text("angebrochen")
|
||
.font(.caption2)
|
||
.padding(.horizontal, 6).padding(.vertical, 2)
|
||
.background(Color.accentColor.opacity(0.15))
|
||
.clipShape(Capsule())
|
||
}
|
||
}
|
||
Text(untertitel(zeile))
|
||
.font(.caption).foregroundStyle(.secondary)
|
||
}
|
||
// Nach links wischen: die beiden Buchungen. Loeschen gibt es
|
||
// hier bewusst nicht - eine Charge verschwindet durchs
|
||
// Verbrauchen, und das Korrigieren sitzt beim Artikel.
|
||
.swipeActions(edge: .trailing) {
|
||
Button {
|
||
verbrauchen = zeile
|
||
} label: { Label("Verbrauchen", systemImage: "minus.circle") }
|
||
.tint(.orange)
|
||
if !zeile.istAngebrochen {
|
||
Button {
|
||
anbrechen = zeile
|
||
} label: { Label("Anbrechen", systemImage: "arrow.up.bin") }
|
||
.tint(.blue)
|
||
}
|
||
}
|
||
}
|
||
if gefiltert.isEmpty && !busy {
|
||
Text(nurAngebrochen ? "Keine angebrochene Packung."
|
||
: "Keine Chargen im Bestand.")
|
||
.foregroundStyle(.secondary)
|
||
}
|
||
}
|
||
}
|
||
.searchable(text: $suche, prompt: "Artikel suchen")
|
||
.navigationTitle("Chargen")
|
||
.navigationBarTitleDisplayMode(.inline)
|
||
.refreshable { await load() }
|
||
.task { await load() }
|
||
.sheet(item: $verbrauchen) { zeile in
|
||
NavigationStack {
|
||
VerbrauchSheet(zeile: zeile) { meldung in
|
||
hinweis = meldung
|
||
Task { await load() }
|
||
}
|
||
}
|
||
}
|
||
.confirmationDialog("Eine Packung anbrechen?",
|
||
isPresented: Binding(get: { anbrechen != nil },
|
||
set: { if !$0 { anbrechen = nil } }),
|
||
titleVisibility: .visible) {
|
||
Button("Anbrechen") {
|
||
if let zeile = anbrechen { Task { await brichAn(zeile) } }
|
||
anbrechen = nil
|
||
}
|
||
Button("Abbrechen", role: .cancel) { anbrechen = nil }
|
||
} message: {
|
||
Text("Eine Packung wird als eigene, offene Charge geführt und bekommt ein Verbrauchsdatum. Der Rest bleibt ungeöffnet mit seinem MHD.")
|
||
}
|
||
}
|
||
|
||
private func untertitel(_ zeile: LotRow) -> String {
|
||
var teile = [zeile.mengeText]
|
||
if zeile.istAngebrochen, let start = zeile.openedQuantity {
|
||
teile.append("von \(formatAmount(start)) \(zeile.baseKurz)")
|
||
}
|
||
if let bis = zeile.consumeBy {
|
||
teile.append("verbrauchen bis \(display.formatBestBefore(bis, precision: "day"))")
|
||
} else if zeile.bestBefore != nil {
|
||
teile.append("MHD \(display.formatBestBefore(zeile.bestBefore, precision: zeile.bestBeforePrecision))")
|
||
}
|
||
if let id = zeile.locationId, let ort = locations.first(where: { $0.id == id }) {
|
||
teile.append(ort.path(in: locations))
|
||
}
|
||
return teile.joined(separator: " · ")
|
||
}
|
||
|
||
private func brichAn(_ zeile: LotRow) async {
|
||
do {
|
||
let offen = try await APIClient.shared.openLot(id: zeile.id)
|
||
if let bis = offen.consumeBy {
|
||
hinweis = "Angebrochen – zu verbrauchen bis \(display.formatBestBefore(bis, precision: "day"))."
|
||
} else {
|
||
hinweis = "Angebrochen."
|
||
}
|
||
error = nil
|
||
await load()
|
||
} catch {
|
||
self.error = error.localizedDescription
|
||
}
|
||
}
|
||
|
||
private func load() async {
|
||
busy = true
|
||
defer { busy = false }
|
||
do {
|
||
zeilen = try await APIClient.shared.allLotRows()
|
||
locations = (try? await APIClient.shared.locations()) ?? []
|
||
error = nil
|
||
} catch {
|
||
self.error = error.localizedDescription
|
||
}
|
||
}
|
||
}
|
||
|
||
/// Teilmenge aus einer Charge verbrauchen – „100 ml genommen“, „2 EL Mehl“.
|
||
///
|
||
/// Eingetippt wird die VERBRAUCHTE Menge, nicht der Rest: Das ist die Zahl, die
|
||
/// man kennt. War die Charge noch zu, wird sie dabei angebrochen; der Haken
|
||
/// dafür ist abwählbar, falls man nur eine Buchung nachträgt.
|
||
struct VerbrauchSheet: View {
|
||
let zeile: LotRow
|
||
var onDone: (String) -> Void
|
||
|
||
@Environment(\.dismiss) private var dismiss
|
||
|
||
@State private var menge = ""
|
||
@State private var einheit = ""
|
||
@State private var notiz = ""
|
||
@State private var alsAngebrochen = true
|
||
@State private var masse: [MeasureAmount] = []
|
||
@State private var busy = false
|
||
@State private var error: String?
|
||
|
||
/// Alles, was als Einheit gewählt werden kann: Basiseinheit, Gebinde und
|
||
/// die Hausmaße der Kategorie.
|
||
private var einheiten: [(token: String, label: String)] {
|
||
var liste: [(token: String, label: String)] = [(zeile.baseToken, zeile.baseKurz)]
|
||
if let p = zeile.packageSize, p > 0 {
|
||
let name = zeile.packageLabel ?? "Packung"
|
||
liste.append(("package", "\(name) (\(formatAmount(p)) \(zeile.baseKurz))"))
|
||
}
|
||
for mass in masse {
|
||
let kurz = mass.short.map { " (\($0))" } ?? ""
|
||
liste.append((mass.token,
|
||
"\(mass.name)\(kurz) ≈ \(formatAmount(mass.amount)) \(kurzFuer(mass.base))"))
|
||
}
|
||
return liste
|
||
}
|
||
|
||
private func kurzFuer(_ base: String) -> String {
|
||
switch base {
|
||
case "gram": return "g"
|
||
case "milliliter": return "ml"
|
||
default: return "Stück"
|
||
}
|
||
}
|
||
|
||
private var zahl: Double? {
|
||
Double(menge.replacingOccurrences(of: ",", with: "."))
|
||
}
|
||
|
||
var body: some View {
|
||
Form {
|
||
if let error {
|
||
Section { Text(error).foregroundStyle(.red).font(.callout) }
|
||
}
|
||
Section {
|
||
Text(zeile.productName).font(.headline)
|
||
Text("aktuell \(zeile.mengeText)").font(.caption).foregroundStyle(.secondary)
|
||
}
|
||
Section("Verbrauchte Menge") {
|
||
QuantityField(label: "Menge", text: $menge)
|
||
Picker("Einheit", selection: $einheit) {
|
||
ForEach(einheiten, id: \.token) { eintrag in
|
||
Text(eintrag.label).tag(eintrag.token)
|
||
}
|
||
}
|
||
}
|
||
if masse.isEmpty {
|
||
Section {
|
||
// Das schließende Anführungszeichen MUSS das typografische
|
||
// sein: Ein gerades " würde die Zeichenkette hier beenden.
|
||
Text("Für die Kategorie dieses Artikels ist kein Hausmaß hinterlegt – „2 EL“ lässt sich deshalb noch nicht buchen. Mengen dafür stehen in der Weboberfläche unter Kategorien.")
|
||
.font(.caption).foregroundStyle(.secondary)
|
||
}
|
||
}
|
||
if !zeile.istAngebrochen {
|
||
Section {
|
||
Toggle("Packung als angebrochen führen", isOn: $alsAngebrochen)
|
||
} footer: {
|
||
Text("Eine Packung wird abgespalten und bekommt ein Verbrauchsdatum; der Rest behält sein MHD. Ohne Haken wird nur die Menge abgebucht.")
|
||
}
|
||
}
|
||
Section("Notiz (freiwillig)") {
|
||
TextField("z.B. für Pfannkuchen", text: $notiz)
|
||
}
|
||
Section {
|
||
Button(busy ? "Wird gebucht…" : "Verbrauchen") { Task { await buche() } }
|
||
.disabled(busy || zahl == nil || (zahl ?? 0) <= 0 || einheit.isEmpty)
|
||
}
|
||
}
|
||
.navigationTitle("Verbrauchen")
|
||
.navigationBarTitleDisplayMode(.inline)
|
||
.toolbar {
|
||
ToolbarItem(placement: .topBarLeading) { Button("Abbrechen") { dismiss() } }
|
||
}
|
||
.task {
|
||
einheit = zeile.baseToken
|
||
masse = (try? await APIClient.shared.productMeasures(productId: zeile.productId)) ?? []
|
||
}
|
||
}
|
||
|
||
private func buche() async {
|
||
guard let wert = zahl, wert > 0 else { return }
|
||
busy = true
|
||
defer { busy = false }
|
||
do {
|
||
let antwort = try await APIClient.shared.consumeLot(
|
||
id: zeile.id, quantity: wert, unit: einheit,
|
||
note: notiz.isEmpty ? nil : notiz,
|
||
autoOpen: zeile.istAngebrochen ? false : alsAngebrochen)
|
||
dismiss()
|
||
if antwort.lot == nil {
|
||
onDone("Verbraucht – die Charge ist damit leer.")
|
||
} else {
|
||
onDone(antwort.opened ? "Verbraucht und angebrochen." : "Verbraucht.")
|
||
}
|
||
} catch {
|
||
self.error = error.localizedDescription
|
||
}
|
||
}
|
||
}
|