diff --git a/ios/Sources/APIClient.swift b/ios/Sources/APIClient.swift index 892ba6b..386613f 100644 --- a/ios/Sources/APIClient.swift +++ b/ios/Sources/APIClient.swift @@ -194,6 +194,11 @@ actor APIClient { try await send(try makeRequest("/dashboard/stats"), as: DashboardStats.self) } + /// Die Dashboards des Benutzers - dieselben wie in der Web-Oberflaeche. + func dashboards() async throws -> DashboardList { + try await send(try makeRequest("/dashboard/layouts"), as: DashboardList.self) + } + /// Neueste zuerst. `productId` filtert auf einen Artikel. func movements(limit: Int = 100, productId: Int? = nil) async throws -> [Movement] { var path = "/movements?limit=\(limit)" diff --git a/ios/Sources/DashboardCards.swift b/ios/Sources/DashboardCards.swift new file mode 100644 index 0000000..2e4d2a7 --- /dev/null +++ b/ios/Sources/DashboardCards.swift @@ -0,0 +1,403 @@ +import SwiftUI + +// MARK: - Ein- und Auslagern ohne Umweg + +/// Buchen direkt aus einer Karte heraus: Artikel suchen, Menge, fertig. +/// +/// MHD und Lagerort bleiben bewusst weg - wer die braucht, ist im Scan-Ablauf +/// besser aufgehoben. Hier zaehlt der schnelle Alltagsfall. +struct QuickBookingCard: View { + enum Richtung { case ein, aus } + + let richtung: Richtung + let onChange: () -> Void + + @State private var suche = "" + @State private var treffer: [Product] = [] + @State private var artikel: Product? + @State private var menge = "1" + @State private var meldung: String? + @State private var fehler: String? + @State private var laeuft = false + + private var einlagern: Bool { richtung == .ein } + private var zahl: Double? { + Double(menge.replacingOccurrences(of: ",", with: ".")) + } + private var bereit: Bool { + artikel != nil && (zahl ?? 0) > 0 && !laeuft + } + + var body: some View { + VStack(alignment: .leading, spacing: 10) { + if let artikel { + HStack { + VStack(alignment: .leading, spacing: 2) { + Text(artikel.name).font(.subheadline).bold() + Text("\(formatAmount(artikel.stockInArticleUnits)) \(artikel.articleUnitLabel) im Bestand") + .font(.caption2).foregroundStyle(.secondary) + } + Spacer() + Button { + self.artikel = nil + suche = "" + meldung = nil + } label: { + Image(systemName: "xmark.circle.fill").foregroundStyle(.secondary) + } + .buttonStyle(.plain) + } + } else { + TextField("Artikel suchen…", text: $suche) + .textFieldStyle(.roundedBorder) + .autocorrectionDisabled() + .onChange(of: suche) { _ in Task { await suchen() } } + ForEach(treffer.prefix(4)) { produkt in + Button { + artikel = produkt + treffer = [] + meldung = nil + } label: { + HStack { + Text(produkt.name).font(.callout) + Spacer() + Image(systemName: "plus.circle").foregroundStyle(.secondary) + } + } + .buttonStyle(.plain) + } + } + + HStack(spacing: 10) { + TextField("1", text: $menge) + .textFieldStyle(.roundedBorder) + .keyboardType(.decimalPad) + .frame(width: 80) + Button { + Task { await buchen() } + } label: { + Label(einlagern ? "Einlagern" : "Auslagern", + systemImage: einlagern ? "arrow.down.to.line" : "arrow.up.to.line") + .frame(maxWidth: .infinity) + } + .buttonStyle(.borderedProminent) + .tint(einlagern ? Color.accentColor : .orange) + .disabled(!bereit) + } + + if let meldung { + Text(meldung).font(.caption).foregroundStyle(.green) + } + if let fehler { + Text(fehler).font(.caption).foregroundStyle(.red) + } + } + .padding(.vertical, 4) + } + + private func suchen() async { + let text = suche.trimmingCharacters(in: .whitespaces) + guard text.count >= 2 else { treffer = []; return } + treffer = (try? await APIClient.shared.searchProducts(text)) ?? [] + } + + private func buchen() async { + guard let artikel, let wert = zahl else { return } + laeuft = true + defer { laeuft = false } + fehler = nil + meldung = nil + do { + // "article" ist die Einheit, in der der Artikel gefuehrt wird - + // genau die, die in der Karte steht. + if einlagern { + let zeile = CheckInLine(quantity: wert, bestBefore: nil, + bestBeforePrecision: "day", locationId: nil) + _ = try await APIClient.shared.checkInBatch( + BatchCheckInRequest(productId: artikel.id, unit: "article", lines: [zeile])) + } else { + _ = try await APIClient.shared.checkOut( + CheckOutRequest(productId: artikel.id, quantity: wert, + unit: "article", lotId: nil)) + } + meldung = "\(formatAmount(wert)) \(einlagern ? "eingelagert" : "ausgelagert")." + menge = "1" + // Bestand in der Karte nachziehen, sonst steht dort die alte Zahl. + self.artikel = try? await APIClient.shared.product(id: artikel.id) + onChange() + } catch { + self.fehler = error.localizedDescription + } + } +} + +// MARK: - Schnellzugriff + +struct ActionsCard: View { + @EnvironmentObject private var router: Router + + var body: some View { + HStack(spacing: 10) { + Button { router.route = .checkin } label: { + Label("Einlagern", systemImage: "arrow.down.to.line") + .frame(maxWidth: .infinity) + } + .buttonStyle(.borderedProminent) + Button { router.route = .checkout } label: { + Label("Auslagern", systemImage: "arrow.up.to.line") + .frame(maxWidth: .infinity) + } + .buttonStyle(.bordered) + } + .padding(.vertical, 4) + } +} + +// MARK: - Kennzahlen + +struct StatusCard: View { + let art: String + let stand: Int + + @State private var stats: DashboardStats? + @State private var fehler: String? + + var body: some View { + Group { + if let fehler { + Text(fehler).font(.caption).foregroundStyle(.red) + } else if let stats { + switch art { + case "kpi-products": + Kennzahl(wert: String(stats.productsInStock), label: "Artikel mit Bestand", + detail: "von \(stats.productsTotal)", tint: .accentColor) + case "kpi-units": + Kennzahl(wert: formatAmount(stats.articleUnits), label: "Artikeleinheiten", + detail: "", tint: .accentColor) + default: + HStack(alignment: .top, spacing: 12) { + Kennzahl(wert: String(stats.expiringSoon), label: "Bald ablaufend", + detail: "", tint: stats.expiringSoon > 0 ? .orange : .secondary) + Kennzahl(wert: String(stats.expired), label: "Abgelaufen", + detail: "", tint: stats.expired > 0 ? .red : .secondary) + Kennzahl(wert: String(stats.shoppingItems), label: "Einzukaufen", + detail: "", tint: stats.shoppingItems > 0 ? .orange : .secondary) + } + } + } else { + Text("Lädt…").font(.caption).foregroundStyle(.secondary) + } + } + .task(id: stand) { + do { + stats = try await APIClient.shared.dashboardStats() + fehler = nil + } catch { + self.fehler = error.localizedDescription + } + } + } +} + +struct Kennzahl: View { + let wert: String + let label: String + let detail: String + let tint: Color + + var body: some View { + VStack(alignment: .leading, spacing: 2) { + Text(label).font(.caption2).foregroundStyle(.secondary) + .lineLimit(1).minimumScaleFactor(0.8) + Text(wert).font(.title2).bold().foregroundStyle(tint) + if !detail.isEmpty { + Text(detail).font(.caption2).foregroundStyle(.tertiary) + } + } + .frame(maxWidth: .infinity, alignment: .leading) + } +} + +// MARK: - Listen + +struct ShoppingCard: View { + @EnvironmentObject private var display: DisplaySettings + let stand: Int + + @State private var items: [ShoppingItem] = [] + @State private var fehler: String? + + var body: some View { + Group { + if let fehler { + Text(fehler).font(.caption).foregroundStyle(.red) + } else if items.isEmpty { + Text("Alle Mindestbestände sind gedeckt.") + .font(.caption).foregroundStyle(.secondary) + } else { + VStack(alignment: .leading, spacing: 6) { + ForEach(items.prefix(5)) { item in + HStack { + Text(item.name).font(.callout) + Spacer() + Text("fehlt \(display.amountText(item.deficit, packageSize: item.packageSize, baseUnit: item.baseUnit))") + .font(.caption).foregroundStyle(.orange) + } + } + NavigationLink("Ganze Liste") { ShoppingListView() } + .font(.caption) + } + } + } + .task(id: stand) { + do { + items = try await APIClient.shared.shoppingList() + fehler = nil + } catch { + self.fehler = error.localizedDescription + } + } + } +} + +struct ExpiryCard: View { + @EnvironmentObject private var display: DisplaySettings + let modus: String + let stand: Int + + @State private var items: [ExpiringItem] = [] + @State private var fehler: String? + + /// "expiring" zeigt nur Laufendes, "expired" nur Ueberfaelliges, + /// "expiry-all" beides - wie in der Web-Oberflaeche. + private var gefiltert: [ExpiringItem] { + switch modus { + case "expiring": return items.filter { $0.daysLeft >= 0 } + case "expired": return items.filter { $0.daysLeft < 0 } + default: return items + } + } + + var body: some View { + Group { + if let fehler { + Text(fehler).font(.caption).foregroundStyle(.red) + } else if gefiltert.isEmpty { + Text("Nichts läuft demnächst ab.").font(.caption).foregroundStyle(.secondary) + } else { + VStack(alignment: .leading, spacing: 6) { + ForEach(gefiltert.prefix(5)) { item in + HStack { + VStack(alignment: .leading, spacing: 1) { + Text(item.productName).font(.callout) + Text("MHD \(display.formatBestBefore(item.bestBefore, precision: item.bestBeforePrecision))") + .font(.caption2).foregroundStyle(.secondary) + } + Spacer() + Text(item.daysLeft < 0 ? "abgelaufen" : "\(item.daysLeft) Tage") + .font(.caption).bold() + .foregroundStyle(item.daysLeft < 0 ? .red : .orange) + } + } + NavigationLink("Ganze Liste") { ExpiringView() }.font(.caption) + } + } + } + .task(id: stand) { + do { + items = try await APIClient.shared.expiring() + fehler = nil + } catch { + self.fehler = error.localizedDescription + } + } + } +} + +struct MovementsCard: View { + let stand: Int + + @State private var movements: [Movement] = [] + @State private var fehler: String? + + var body: some View { + Group { + if let fehler { + Text(fehler).font(.caption).foregroundStyle(.red) + } else if movements.isEmpty { + Text("Noch keine Bewegungen.").font(.caption).foregroundStyle(.secondary) + } else { + VStack(alignment: .leading, spacing: 8) { + ForEach(movements.prefix(5)) { movement in + MovementRow(movement: movement) + } + NavigationLink("Ganzer Verlauf") { HistoryView() }.font(.caption) + } + } + } + .task(id: stand) { + do { + movements = try await APIClient.shared.movements(limit: 5) + fehler = nil + } catch { + self.fehler = error.localizedDescription + } + } + } +} + +// MARK: - Ein fester Artikel + +struct ProductCard: View { + let productId: Int? + let stand: Int + + @State private var produkt: Product? + @State private var fehler: String? + + var body: some View { + Group { + if productId == nil { + Text("Kein Artikel gewählt – einstellen in der Web-Oberfläche.") + .font(.caption).foregroundStyle(.secondary) + } else if let fehler { + Text(fehler).font(.caption).foregroundStyle(.red) + } else if let produkt { + NavigationLink { + ProductDetailView(product: produkt) + } label: { + HStack { + VStack(alignment: .leading, spacing: 2) { + Text(produkt.name).font(.callout) + if let hinweis = mindestbestand(produkt) { + Text(hinweis).font(.caption2).foregroundStyle(.orange) + } + } + Spacer() + Text("\(formatAmount(produkt.stockInArticleUnits)) \(produkt.articleUnitLabel)") + .font(.headline) + } + } + } else { + Text("Lädt…").font(.caption).foregroundStyle(.secondary) + } + } + .task(id: "\(productId ?? 0)-\(stand)") { + guard let productId else { return } + do { + produkt = try await APIClient.shared.product(id: productId) + fehler = nil + } catch { + self.fehler = error.localizedDescription + } + } + } + + private func mindestbestand(_ produkt: Product) -> String? { + switch produkt.stockLevel { + case .below: return "unter Mindestbestand" + case .close: return "bald nachkaufen" + case .ok, .none: return nil + } + } +} diff --git a/ios/Sources/DashboardView.swift b/ios/Sources/DashboardView.swift new file mode 100644 index 0000000..11a7c4c --- /dev/null +++ b/ios/Sources/DashboardView.swift @@ -0,0 +1,167 @@ +import SwiftUI + +/// Zeigt die Dashboards, die auch die Web-Oberflaeche kennt. +/// +/// Das Raster (x/y/w/h) laesst sich auf einem Telefon nicht sinnvoll +/// nachbilden - hier stehen die Karten deshalb untereinander, in der +/// Lesereihenfolge des Rasters (erst Zeile, dann Spalte). Zusammengestellt +/// werden Dashboards weiterhin am Rechner; die App zeigt sie. +struct DashboardView: View { + @EnvironmentObject private var session: Session + + @State private var dashboards: [Dashboard] = [] + @State private var auswahl: Int? + @State private var busy = true + @State private var fehler: String? + @State private var manageShown = false + /// Zaehler, der die Karten nach einer Buchung neu laden laesst. + @State private var stand = 0 + + private var aktives: Dashboard? { + dashboards.first { $0.id == auswahl } ?? dashboards.first + } + + /// Rasterreihenfolge: erst von oben nach unten, dann von links nach rechts. + private var karten: [DashboardCard] { + (aktives?.layout ?? []).sorted { + $0.y == $1.y ? $0.x < $1.x : $0.y < $1.y + } + } + + var body: some View { + NavigationStack { + List { + if let fehler { + Section { Text(fehler).foregroundStyle(.red).font(.callout) } + } + + // Mehrere Dashboards: oben umschalten. Bei einem einzigen waere + // die Auswahl nur Beiwerk. + if dashboards.count > 1 { + Section { + Picker("Dashboard", selection: Binding( + get: { aktives?.id ?? 0 }, + set: { auswahl = $0 } + )) { + ForEach(dashboards) { d in + Text(d.name).tag(d.id) + } + } + .pickerStyle(.menu) + } + } + + ForEach(karten) { karte in + Section { + DashboardCardView(karte: karte, stand: stand) { stand += 1 } + } header: { + Text(titel(fuer: karte)) + } + } + + if karten.isEmpty && !busy { + Text("Dieses Dashboard hat keine Karten. Zusammenstellen lässt es sich in der Web-Oberfläche.") + .foregroundStyle(.secondary) + } + } + .navigationTitle(aktives?.name ?? session.activeProfile?.name ?? "Übersicht") + .toolbar { + ToolbarItem(placement: .topBarTrailing) { + ServerMenu(manageShown: $manageShown) + } + } + .sheet(isPresented: $manageShown) { ServerListView() } + .refreshable { await laden() } + .task(id: session.activeProfileID) { await laden() } + } + } + + private func titel(fuer karte: DashboardCard) -> String { + CardCatalog.titel(karte.type) + } + + private func laden() async { + busy = true + defer { busy = false } + do { + let antwort = try await APIClient.shared.dashboards() + dashboards = antwort.dashboards.sorted { $0.position < $1.position } + if auswahl == nil || !dashboards.contains(where: { $0.id == auswahl }) { + auswahl = dashboards.first?.id + } + fehler = nil + } catch { + self.fehler = error.localizedDescription + } + } +} + +/// Beschriftungen der Kartenarten - dieselben wie in web/src/dashboard/cards.jsx. +enum CardCatalog { + static func titel(_ type: String) -> String { + switch type { + case "actions": return "Schnellzugriff" + case "checkin-quick": return "Einlagern" + case "checkout-quick": return "Auslagern" + case "status": return "Status" + case "kpi-products": return "Artikel mit Bestand" + case "kpi-units": return "Artikeleinheiten" + case "expiring": return "Bald ablaufend" + case "expired": return "Abgelaufen" + case "expiry-all": return "Ablauf-Übersicht" + case "shopping": return "Einkaufsliste" + case "movements": return "Letzte Bewegungen" + case "product-stock": return "Artikel im Blick" + case "product-timeline": return "Verlauf eines Artikels" + case "expiry-donut": return "Ablauf-Anteile" + case "category-donut": return "Kategorien-Anteile" + case "category-bars": return "Kategorien nach Zustand" + case "stock-timeline": return "Bestandsverlauf" + case "activity-timeline": return "Ein- und Auslagerungen" + default: return "Karte" + } + } + + /// Karten, die nur als Diagramm Sinn ergeben. Die App zeigt statt eines + /// halbgaren Nachbaus einen ehrlichen Hinweis. + static func istDiagramm(_ type: String) -> Bool { + ["expiry-donut", "category-donut", "category-bars", + "stock-timeline", "activity-timeline"].contains(type) + } +} + +/// Waehlt anhand der Kartenart den passenden Inhalt. +struct DashboardCardView: View { + let karte: DashboardCard + let stand: Int + let onChange: () -> Void + + var body: some View { + switch karte.type { + case "checkin-quick": + QuickBookingCard(richtung: .ein, onChange: onChange) + case "checkout-quick": + QuickBookingCard(richtung: .aus, onChange: onChange) + case "actions": + ActionsCard() + case "status", "kpi-products", "kpi-units": + StatusCard(art: karte.type, stand: stand) + case "shopping": + ShoppingCard(stand: stand) + case "expiring", "expired", "expiry-all": + ExpiryCard(modus: karte.type, stand: stand) + case "movements": + MovementsCard(stand: stand) + case "product-stock", "product-timeline": + ProductCard(productId: karte.props?.productId, stand: stand) + default: + if CardCatalog.istDiagramm(karte.type) { + Label("Diagramme gibt es in der Web-Oberfläche.", systemImage: "chart.xyaxis.line") + .font(.caption).foregroundStyle(.secondary) + } else { + Text("Unbekannte Karte „\(karte.type)“.") + .font(.caption).foregroundStyle(.secondary) + } + } + } +} diff --git a/ios/Sources/Models.swift b/ios/Sources/Models.swift index 8fe0f01..f0390c8 100644 --- a/ios/Sources/Models.swift +++ b/ios/Sources/Models.swift @@ -467,6 +467,55 @@ struct Movement: Codable, Identifiable, Hashable { var isIncoming: Bool { type == "in" } } +// MARK: - Dashboards + +/// Eine Karte auf einem Dashboard. +/// +/// `i` ist die Kennung **dieser** Karte, `type` ihre Art - erst dadurch kann +/// dieselbe Art mehrfach auf einem Dashboard liegen. `props` traegt, was nur +/// diese eine Karte angeht (etwa welcher Artikel gemeint ist). +struct DashboardCard: Codable, Identifiable, Hashable { + let i: String + let type: String + let x: Int + let y: Int + let w: Int + let h: Int + let tage: Int? + let props: CardProps? + + var id: String { i } + + /// Nur die Felder, die die App auswertet. Die Rasterangaben kommen aus der + /// Web-Oberflaeche; hier bestimmen sie allein die Reihenfolge. + struct CardProps: Codable, Hashable { + let productId: Int? + + enum CodingKeys: String, CodingKey { + case productId = "product_id" + } + } +} + +struct Dashboard: Codable, Identifiable, Hashable { + let id: Int + let name: String + let position: Int + let layout: [DashboardCard] +} + +struct DashboardList: Codable { + let dashboards: [Dashboard] + let source: String + let enforced: Bool + let hasDefault: Bool + + enum CodingKeys: String, CodingKey { + case dashboards, source, enforced + case hasDefault = "has_default" + } +} + // MARK: - Stammdaten /// Gebinde (Packung, Glas, ...) mit Einzahl und Mehrzahl. diff --git a/ios/Sources/OverviewView.swift b/ios/Sources/OverviewView.swift deleted file mode 100644 index 9e336d3..0000000 --- a/ios/Sources/OverviewView.swift +++ /dev/null @@ -1,123 +0,0 @@ -import SwiftUI - -/// Startseite: Kennzahlen des Servers und die letzten Bewegungen. -/// -/// Bewusst ohne Diagramme - die Kennzahl selbst ist die Antwort, und jede -/// fuehrt per Tipp dorthin, wo man etwas tun kann. -struct OverviewView: View { - @EnvironmentObject private var session: Session - @EnvironmentObject private var display: DisplaySettings - - @State private var stats: DashboardStats? - @State private var letzte: [Movement] = [] - @State private var busy = true - @State private var error: String? - @State private var manageShown = false - - private let spalten = [GridItem(.flexible()), GridItem(.flexible())] - - var body: some View { - NavigationStack { - List { - if let error { - Section { Text(error).foregroundStyle(.red).font(.callout) } - } - - Section { - LazyVGrid(columns: spalten, spacing: 12) { - NavigationLink { ProductListView() } label: { - StatTile(title: "Artikel mit Bestand", - value: stats.map { String($0.productsInStock) } ?? "–", - detail: stats.map { "von \($0.productsTotal)" } ?? "", - systemImage: "shippingbox", tint: .accentColor) - } - NavigationLink { ShoppingListView() } label: { - StatTile(title: "Einzukaufen", - value: stats.map { String($0.shoppingItems) } ?? "–", - detail: "unter Mindestbestand", - systemImage: "cart", - tint: (stats?.shoppingItems ?? 0) > 0 ? .orange : .secondary) - } - NavigationLink { ExpiringView() } label: { - StatTile(title: "Läuft bald ab", - value: stats.map { String($0.expiringSoon) } ?? "–", - detail: "Chargen", - systemImage: "clock", - tint: (stats?.expiringSoon ?? 0) > 0 ? .orange : .secondary) - } - NavigationLink { ExpiringView() } label: { - StatTile(title: "Abgelaufen", - value: stats.map { String($0.expired) } ?? "–", - detail: "Chargen", - systemImage: "exclamationmark.triangle", - tint: (stats?.expired ?? 0) > 0 ? .red : .secondary) - } - } - .buttonStyle(.plain) - .listRowInsets(EdgeInsets(top: 8, leading: 8, bottom: 8, trailing: 8)) - } - - Section { - ForEach(letzte) { movement in - MovementRow(movement: movement) - } - if letzte.isEmpty && !busy { - Text("Noch keine Bewegungen.").foregroundStyle(.secondary) - } - NavigationLink("Ganzen Verlauf ansehen") { HistoryView() } - } header: { - Text("Letzte Bewegungen") - } - } - .navigationTitle(session.activeProfile?.name ?? "Vorrania") - .toolbar { - ToolbarItem(placement: .topBarTrailing) { - ServerMenu(manageShown: $manageShown) - } - } - .sheet(isPresented: $manageShown) { ServerListView() } - .refreshable { await load() } - .task(id: session.activeProfileID) { await load() } - } - } - - private func load() async { - busy = true - defer { busy = false } - do { - stats = try await APIClient.shared.dashboardStats() - letzte = try await APIClient.shared.movements(limit: 10) - error = nil - } catch { - self.error = error.localizedDescription - } - } -} - -/// Eine Kennzahl als Kachel. -struct StatTile: View { - let title: String - let value: String - let detail: String - let systemImage: String - let tint: Color - - var body: some View { - VStack(alignment: .leading, spacing: 6) { - HStack(spacing: 6) { - Image(systemName: systemImage).font(.caption).foregroundStyle(tint) - Text(title).font(.caption).foregroundStyle(.secondary) - .lineLimit(1).minimumScaleFactor(0.8) - } - Text(value).font(.title).bold().foregroundStyle(tint) - if !detail.isEmpty { - Text(detail).font(.caption2).foregroundStyle(.tertiary) - .lineLimit(1).minimumScaleFactor(0.8) - } - } - .frame(maxWidth: .infinity, alignment: .leading) - .padding(12) - .background(Color(.secondarySystemBackground)) - .clipShape(RoundedRectangle(cornerRadius: 12)) - } -} diff --git a/ios/Sources/RootView.swift b/ios/Sources/RootView.swift index 73bb18c..b6538f1 100644 --- a/ios/Sources/RootView.swift +++ b/ios/Sources/RootView.swift @@ -43,7 +43,7 @@ struct HomeView: View { var body: some View { TabView { - OverviewView() + DashboardView() .tabItem { Label("Start", systemImage: "house") } ScanTabView() diff --git a/ios/Vorrania.xcodeproj/project.pbxproj b/ios/Vorrania.xcodeproj/project.pbxproj index 8ac5d6e..9802224 100644 --- a/ios/Vorrania.xcodeproj/project.pbxproj +++ b/ios/Vorrania.xcodeproj/project.pbxproj @@ -9,7 +9,6 @@ /* Begin PBXBuildFile section */ 0D8629839E465D80EC58E74B /* ProductViews.swift in Sources */ = {isa = PBXBuildFile; fileRef = 369B9841E43E727ACA2E2A2A /* ProductViews.swift */; }; 0E0AE6CC6FBA82B190BBC36F /* CheckInFormView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 99E56353CB483EFB1CCD09D8 /* CheckInFormView.swift */; }; - 191427830389D44062F0A622 /* OverviewView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0BDE41825692274409DDCAC7 /* OverviewView.swift */; }; 25FC3913EFFA37D013328FDE /* CategoryPicker.swift in Sources */ = {isa = PBXBuildFile; fileRef = CDD672F65352DA0D122EC9AE /* CategoryPicker.swift */; }; 39F1F17DCB4E491652D08DEE /* APIClient.swift in Sources */ = {isa = PBXBuildFile; fileRef = 378F1B2DE567B62169E84426 /* APIClient.swift */; }; 44F08407AF69F5B7FD15F932 /* ScannerView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8AF19A735AC17451B57BF5BB /* ScannerView.swift */; }; @@ -20,7 +19,9 @@ 7738E209D4D32BFD514B76F5 /* ListViews.swift in Sources */ = {isa = PBXBuildFile; fileRef = A6785CD9174067EFBB2B9043 /* ListViews.swift */; }; 7E2FA69E0C3BED650E91A7E9 /* AppIcon.icon in Resources */ = {isa = PBXBuildFile; fileRef = 099CCD94907BE0179B7D5742 /* AppIcon.icon */; }; 80B15C315FB4FEBD385A5EA1 /* ServerProfiles.swift in Sources */ = {isa = PBXBuildFile; fileRef = 121D44B9990DA931A9CD5847 /* ServerProfiles.swift */; }; + 83F9AD06177F3BC95421F128 /* DashboardCards.swift in Sources */ = {isa = PBXBuildFile; fileRef = 090485CC54558EB4433376A9 /* DashboardCards.swift */; }; 87A34A269AAD85A19C3F4359 /* ServerListView.swift in Sources */ = {isa = PBXBuildFile; fileRef = EA706060734632DE78FA1073 /* ServerListView.swift */; }; + 926451E72FD13708C5DD657B /* DashboardView.swift in Sources */ = {isa = PBXBuildFile; fileRef = E6E6139CCD0B6C51B3919EE9 /* DashboardView.swift */; }; 96E5A2455B2EC26050E69232 /* MasterDataViews.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1E86178BB91DBB86ADFB284B /* MasterDataViews.swift */; }; 9B0A1A7C19764857BDC6ED26 /* RootView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4741D0E95875919C921945CF /* RootView.swift */; }; A7017E6C0532A650439AED7B /* BestBeforeText.swift in Sources */ = {isa = PBXBuildFile; fileRef = AA022A27118BEEEC5912ECDC /* BestBeforeText.swift */; }; @@ -34,8 +35,8 @@ /* Begin PBXFileReference section */ 0365A16FEAE3F2BEC321E68A /* CheckInView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CheckInView.swift; sourceTree = ""; }; + 090485CC54558EB4433376A9 /* DashboardCards.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DashboardCards.swift; sourceTree = ""; }; 099CCD94907BE0179B7D5742 /* AppIcon.icon */ = {isa = PBXFileReference; lastKnownFileType = wrapper.icon; path = AppIcon.icon; sourceTree = ""; }; - 0BDE41825692274409DDCAC7 /* OverviewView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OverviewView.swift; sourceTree = ""; }; 121D44B9990DA931A9CD5847 /* ServerProfiles.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ServerProfiles.swift; sourceTree = ""; }; 1E86178BB91DBB86ADFB284B /* MasterDataViews.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MasterDataViews.swift; sourceTree = ""; }; 314B1BB7220691A7423E8929 /* Models.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Models.swift; sourceTree = ""; }; @@ -56,6 +57,7 @@ AD2F9406BD0D4F0D30FED345 /* Session.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Session.swift; sourceTree = ""; }; CDD672F65352DA0D122EC9AE /* CategoryPicker.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CategoryPicker.swift; sourceTree = ""; }; DC0DDD06332E567E70CA842F /* CheckOutView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CheckOutView.swift; sourceTree = ""; }; + E6E6139CCD0B6C51B3919EE9 /* DashboardView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DashboardView.swift; sourceTree = ""; }; EA706060734632DE78FA1073 /* ServerListView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ServerListView.swift; sourceTree = ""; }; /* End PBXFileReference section */ @@ -69,6 +71,8 @@ 99E56353CB483EFB1CCD09D8 /* CheckInFormView.swift */, 0365A16FEAE3F2BEC321E68A /* CheckInView.swift */, DC0DDD06332E567E70CA842F /* CheckOutView.swift */, + 090485CC54558EB4433376A9 /* DashboardCards.swift */, + E6E6139CCD0B6C51B3919EE9 /* DashboardView.swift */, 717C8EB336170526F5F3E695 /* DateScanView.swift */, AAC97A555856C6C5DD353E25 /* DisplaySettings.swift */, 7DACFBB69CB536BA7CBBD484 /* HistoryView.swift */, @@ -76,7 +80,6 @@ A75926511854BB8EE316ED3A /* LoginView.swift */, 1E86178BB91DBB86ADFB284B /* MasterDataViews.swift */, 314B1BB7220691A7423E8929 /* Models.swift */, - 0BDE41825692274409DDCAC7 /* OverviewView.swift */, 6F574168AA0F849D46C384EE /* ProductDetailView.swift */, 369B9841E43E727ACA2E2A2A /* ProductViews.swift */, 4741D0E95875919C921945CF /* RootView.swift */, @@ -183,6 +186,8 @@ 0E0AE6CC6FBA82B190BBC36F /* CheckInFormView.swift in Sources */, 4F86620DF8303DB02314DB5A /* CheckInView.swift in Sources */, F6C7E913CAB0FAF1826E6BD3 /* CheckOutView.swift in Sources */, + 83F9AD06177F3BC95421F128 /* DashboardCards.swift in Sources */, + 926451E72FD13708C5DD657B /* DashboardView.swift in Sources */, ED61DAE4824165D6D87F8C1D /* DateScanView.swift in Sources */, D70CB183C868FF0143A6A5E7 /* DisplaySettings.swift in Sources */, 4C486D190D1AB338925D29B5 /* HistoryView.swift in Sources */, @@ -190,7 +195,6 @@ E9EA455CACB6ED5951B114C3 /* LoginView.swift in Sources */, 96E5A2455B2EC26050E69232 /* MasterDataViews.swift in Sources */, 6816C33381DF52A96E5BB303 /* Models.swift in Sources */, - 191427830389D44062F0A622 /* OverviewView.swift in Sources */, AAF27320D2A67C994C29C26F /* ProductDetailView.swift in Sources */, 0D8629839E465D80EC58E74B /* ProductViews.swift in Sources */, 9B0A1A7C19764857BDC6ED26 /* RootView.swift in Sources */,