Nachtrag zur Detailansicht von gerade eben: Zwei Angaben fehlten, die man an einer Charge sucht. "Seit wann liegt das hier?" - das Einlagerdatum kam im Swift-Modell gar nicht erst an, obwohl /lots/rows es liefert. Und "was ist damit passiert?" - dafuer gibt es jetzt den Weg in den Verlauf des Artikels. Eine einzelne Charge hat im Backend keinen eigenen Verlauf, die Bewegungen haengen am Artikel. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
1079 lines
42 KiB
Swift
1079 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)
|
||
if let seit = zeile.createdAt {
|
||
LabeledContent("Eingelagert",
|
||
value: display.formatBestBefore(String(seit.prefix(10)),
|
||
precision: "day"))
|
||
}
|
||
}
|
||
|
||
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.")
|
||
}
|
||
|
||
Section {
|
||
// Eine einzelne Charge hat keinen eigenen Verlauf im Backend;
|
||
// die Bewegungen haengen am Artikel. Das ist hier die naechste
|
||
// Frage nach "was ist das" - "was ist damit passiert".
|
||
NavigationLink {
|
||
HistoryView(productId: zeile.productId)
|
||
} label: {
|
||
Label("Verlauf dieses Artikels", systemImage: "clock.arrow.circlepath")
|
||
}
|
||
}
|
||
}
|
||
.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
|
||
}
|
||
}
|
||
}
|