Vier Bruchstellen im taeglichen Gebrauch, alle an derselben Wurzel: Nach dem Anlegen endete der Weg, und was danach kam, musste man sich selbst suchen. NFC beim Anlegen: createItems liefert die angelegten Stuecke samt UID laengst zurueck, ItemAddSheet warf sie nur weg. Bei genau EINEM Stueck geht es jetzt direkt zum Tag-Blatt. Bei mehreren bleibt es wie bisher - die App koennte nicht wissen, welches der fuenf Stuecke gerade am Tag haengt. Ohne NFC im Geraet (iPad) ebenso unveraendert. Menge beim Anlegen: Ein Gegenstand, der nur als Menge je Lagerort gezaehlt wird, landete nach dem Anlegen im Nichts. Das Blatt dafuer gab es schon (ObjectAddSheet), nur fuehrte kein Weg dorthin. Jetzt derselbe Uebergang wie bei Einzelstuecken, an beiden Einstiegen. Es holt sich die Lagerorte selbst - ObjectAddSheet nimmt sie als Konstante und laedt sie nicht nach. Loeschen: Der Knopf sitzt unten in der Artikelansicht, nur fuer Admins - die Route verlangt das, ohne Riegel gaebe es nur ein wortkarges 403. Lehnt der Server ab (noch Einzelstuecke oder Bestand), bleibt die Ansicht stehen und seine Begruendung steht unveraendert in der Fehlerzeile. NFC beim Ein- und Auslagern: Ein Tag benennt immer genau ein Einzelstueck, deshalb fuehrt er zur Stueck-Ansicht - beim Auslagern ist "Entfernen (mit Grund)" genau die passende Aktion. Bewusst NICHT ueber resolve() abgekuerzt: das faellt bei unbekannter UID auf lookup(barcode:) durch und boete an, einen Artikel mit einer URL als Barcode anzulegen. Beim Nachschlagen gab es NFC schon, nur unscheinbar unter der Kamerakarte - Knopf abgesetzt und in der Bildunterschrift erwaehnt. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
1087 lines
43 KiB
Swift
1087 lines
43 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?
|
||
@State private var mengeFuer: 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 }
|
||
else if fuehrtMengeJeLagerort(angelegt) { mengeFuer = 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 geht es gleich
|
||
// weiter: zu den Einzelstücken, oder zur Menge je Lagerort.
|
||
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)
|
||
.mengeNachAnlegen($mengeFuer)
|
||
.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))"
|
||
}
|
||
// Marke voran: Die Suche findet Artikel ueber sie, ohne sie in der
|
||
// Zeile bliebe ein Treffer auf "Jack and Jones" unerklaerlich.
|
||
if let marke = product.brand, !marke.isEmpty {
|
||
text = "\(marke) · \(text)"
|
||
}
|
||
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
|
||
}
|
||
}
|
||
}
|