Die neue Chargenliste reagierte nur aufs Wischen. Das sieht man nicht, und man kam an die Angaben der Charge ueberhaupt nicht heran - Menge, Fristen und Lagerort standen zwar gekuerzt in der Zeile, aber mehr gab es nicht. Antippen oeffnet jetzt die Charge: Artikel und Marke, Menge (bei einer offenen Packung auch die Menge beim Oeffnen), Zustand, Lagerort, MHD, Anbruchtag und Verbrauchsdatum. Darunter beide Handlungen als sichtbare Knoepfe - damit haengt "Anbrechen" nicht mehr allein an einer Geste, die niemand findet. Das Wischen bleibt als Abkuerzung. Das Verbrauchen-Formular wird aus der Detailansicht BLAETTERND geoeffnet, nicht als zweites Fenster. Ein Sheet im Sheet aufzuziehen und beide gleich wieder zu schliessen ist in SwiftUI eine zuverlaessige Quelle fuer haengende Ansichten; so bleibt es eine Ebene. Nicht kompiliert (kein Xcode hier): geprueft sind Klammer- und Anfuehrungsbalance, dass keine Code-Zeile ein unpaariges Anfuehrungszeichen traegt, und dass vom entfernten Zustand nichts stehengeblieben ist. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
1063 lines
42 KiB
Swift
1063 lines
42 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 ansehen: 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
|
||
// Antippen oeffnet die Charge. Das Wischen allein genuegte
|
||
// nicht: Man sieht es nicht, und man kam gar nicht an die
|
||
// Angaben heran.
|
||
Button {
|
||
ansehen = zeile
|
||
} label: {
|
||
HStack {
|
||
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)
|
||
}
|
||
Spacer()
|
||
Image(systemName: "chevron.right").foregroundStyle(.tertiary)
|
||
}
|
||
}
|
||
.foregroundStyle(.primary)
|
||
// 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: $ansehen) { zeile in
|
||
NavigationStack {
|
||
ChargeDetailSheet(zeile: zeile, locations: locations) { meldung in
|
||
hinweis = meldung
|
||
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
|
||
}
|
||
}
|
||
}
|
||
|
||
/// Eine Charge ansehen – und von hier aus anbrechen oder verbrauchen.
|
||
///
|
||
/// Das Wischen in der Liste allein genügt nicht: Man sieht es nicht, und man
|
||
/// sieht dort auch nicht, was an der Charge dranhängt. Hier stehen alle Angaben
|
||
/// und beide Handlungen als sichtbare Knöpfe.
|
||
struct ChargeDetailSheet: View {
|
||
let zeile: LotRow
|
||
let locations: [StorageLocation]
|
||
/// Meldung nach einer Buchung – die Liste zeigt sie an und lädt neu.
|
||
var onDone: (String) -> Void
|
||
|
||
@EnvironmentObject private var display: DisplaySettings
|
||
@Environment(\.dismiss) private var dismiss
|
||
|
||
@State private var anbrechenGefragt = false
|
||
@State private var busy = false
|
||
@State private var error: String?
|
||
|
||
private var ortText: String {
|
||
guard let id = zeile.locationId,
|
||
let ort = locations.first(where: { $0.id == id }) else { return "ohne Lagerort" }
|
||
return ort.path(in: locations)
|
||
}
|
||
|
||
var body: some View {
|
||
Form {
|
||
if let error {
|
||
Section { Text(error).foregroundStyle(.red).font(.callout) }
|
||
}
|
||
|
||
Section {
|
||
Text(zeile.productName).font(.headline)
|
||
if let marke = zeile.productBrand, !marke.isEmpty {
|
||
Text(marke).font(.caption).foregroundStyle(.secondary)
|
||
}
|
||
}
|
||
|
||
Section("Bestand") {
|
||
LabeledContent("Menge", value: zeile.mengeText)
|
||
if zeile.istAngebrochen, let start = zeile.openedQuantity {
|
||
LabeledContent("Beim Öffnen", value: "\(formatAmount(start)) \(zeile.baseKurz)")
|
||
}
|
||
LabeledContent("Zustand",
|
||
value: zeile.istAngebrochen ? "angebrochen" : "ungeöffnet")
|
||
LabeledContent("Lagerort", value: ortText)
|
||
}
|
||
|
||
Section("Fristen") {
|
||
LabeledContent("MHD", value: zeile.bestBefore == nil
|
||
? "ohne MHD"
|
||
: display.formatBestBefore(zeile.bestBefore,
|
||
precision: zeile.bestBeforePrecision))
|
||
if let geoeffnet = zeile.openedOn {
|
||
LabeledContent("Angebrochen am",
|
||
value: display.formatBestBefore(geoeffnet, precision: "day"))
|
||
}
|
||
if let bis = zeile.consumeBy {
|
||
LabeledContent("Verbrauchen bis",
|
||
value: display.formatBestBefore(bis, precision: "day"))
|
||
}
|
||
}
|
||
|
||
Section {
|
||
// Bewusst ein Weiterblaettern und kein zweites Fenster: Ein
|
||
// Sheet im Sheet aufzuziehen und beide gleich wieder zu
|
||
// schliessen ist in SwiftUI eine zuverlaessige Quelle fuer
|
||
// haengende Ansichten.
|
||
NavigationLink {
|
||
VerbrauchSheet(zeile: zeile) { meldung in
|
||
dismiss()
|
||
onDone(meldung)
|
||
}
|
||
} label: {
|
||
Label("Verbrauchen", systemImage: "minus.circle")
|
||
}
|
||
if !zeile.istAngebrochen {
|
||
Button {
|
||
anbrechenGefragt = true
|
||
} label: {
|
||
Label("Anbrechen", systemImage: "arrow.up.bin")
|
||
}
|
||
.disabled(busy)
|
||
}
|
||
} footer: {
|
||
Text(zeile.istAngebrochen
|
||
? "Diese Packung ist offen. Was du verbrauchst, geht von ihr ab."
|
||
: "Anbrechen spaltet eine Packung ab und gibt ihr ein Verbrauchsdatum; "
|
||
+ "der Rest bleibt ungeöffnet mit seinem MHD.")
|
||
}
|
||
}
|
||
.navigationTitle("Charge")
|
||
.navigationBarTitleDisplayMode(.inline)
|
||
.toolbar {
|
||
ToolbarItem(placement: .topBarLeading) { Button("Fertig") { dismiss() } }
|
||
}
|
||
.confirmationDialog("Eine Packung anbrechen?", isPresented: $anbrechenGefragt,
|
||
titleVisibility: .visible) {
|
||
Button("Anbrechen") { Task { await brichAn() } }
|
||
Button("Abbrechen", role: .cancel) {}
|
||
} 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 brichAn() async {
|
||
busy = true
|
||
defer { busy = false }
|
||
do {
|
||
let offen = try await APIClient.shared.openLot(id: zeile.id)
|
||
dismiss()
|
||
if let bis = offen.consumeBy {
|
||
onDone("Angebrochen – zu verbrauchen bis \(display.formatBestBefore(bis, precision: "day")).")
|
||
} else {
|
||
onDone("Angebrochen.")
|
||
}
|
||
} catch {
|
||
self.error = error.localizedDescription
|
||
}
|
||
}
|
||
}
|