iOS zeigt dieselben Dashboards und kann direkt buchen

Die Uebersicht in der App war fest verdrahtet: vier Kacheln, unabhaengig
davon, was am Rechner zusammengestellt wurde. Jetzt liest sie dieselben
Dashboards wie die Web-Oberflaeche und stellt sie nativ dar.

Das Raster laesst sich auf einem Telefon nicht sinnvoll nachbilden, deshalb
stehen die Karten untereinander - in der Lesereihenfolge des Rasters, erst
Zeile, dann Spalte. Zusammengestellt wird weiterhin am Rechner; gibt es
mehrere Dashboards, schaltet oben ein Menue um.

Neu ist die Buchungskarte: Artikel suchen, Menge, ein- oder auslagern, ohne
den Scan-Ablauf zu oeffnen. Nach dem Buchen zieht die Karte den Bestand nach
und laesst die uebrigen Karten neu laden - sonst stuende dort weiter die alte
Zahl.

Diagrammkarten bekommen bewusst keinen halbgaren Nachbau, sondern den
ehrlichen Hinweis, dass es sie in der Web-Oberflaeche gibt.

Geprueft mit der echten Antwort eines laufenden Servers: Sie decodiert in die
Modelle der App, samt zweier Karten derselben Art mit verschiedenen Artikeln.
OverviewView faellt weg, DashboardView tritt an ihre Stelle.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Scarriffle
2026-07-23 19:27:01 +02:00
parent 89598147ed
commit 2ad197df42
7 changed files with 633 additions and 128 deletions

View File

@@ -194,6 +194,11 @@ actor APIClient {
try await send(try makeRequest("/dashboard/stats"), as: DashboardStats.self) 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. /// Neueste zuerst. `productId` filtert auf einen Artikel.
func movements(limit: Int = 100, productId: Int? = nil) async throws -> [Movement] { func movements(limit: Int = 100, productId: Int? = nil) async throws -> [Movement] {
var path = "/movements?limit=\(limit)" var path = "/movements?limit=\(limit)"

View File

@@ -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
}
}
}

View File

@@ -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)
}
}
}
}

View File

@@ -467,6 +467,55 @@ struct Movement: Codable, Identifiable, Hashable {
var isIncoming: Bool { type == "in" } 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 // MARK: - Stammdaten
/// Gebinde (Packung, Glas, ...) mit Einzahl und Mehrzahl. /// Gebinde (Packung, Glas, ...) mit Einzahl und Mehrzahl.

View File

@@ -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))
}
}

View File

@@ -43,7 +43,7 @@ struct HomeView: View {
var body: some View { var body: some View {
TabView { TabView {
OverviewView() DashboardView()
.tabItem { Label("Start", systemImage: "house") } .tabItem { Label("Start", systemImage: "house") }
ScanTabView() ScanTabView()

View File

@@ -9,7 +9,6 @@
/* Begin PBXBuildFile section */ /* Begin PBXBuildFile section */
0D8629839E465D80EC58E74B /* ProductViews.swift in Sources */ = {isa = PBXBuildFile; fileRef = 369B9841E43E727ACA2E2A2A /* ProductViews.swift */; }; 0D8629839E465D80EC58E74B /* ProductViews.swift in Sources */ = {isa = PBXBuildFile; fileRef = 369B9841E43E727ACA2E2A2A /* ProductViews.swift */; };
0E0AE6CC6FBA82B190BBC36F /* CheckInFormView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 99E56353CB483EFB1CCD09D8 /* CheckInFormView.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 */; }; 25FC3913EFFA37D013328FDE /* CategoryPicker.swift in Sources */ = {isa = PBXBuildFile; fileRef = CDD672F65352DA0D122EC9AE /* CategoryPicker.swift */; };
39F1F17DCB4E491652D08DEE /* APIClient.swift in Sources */ = {isa = PBXBuildFile; fileRef = 378F1B2DE567B62169E84426 /* APIClient.swift */; }; 39F1F17DCB4E491652D08DEE /* APIClient.swift in Sources */ = {isa = PBXBuildFile; fileRef = 378F1B2DE567B62169E84426 /* APIClient.swift */; };
44F08407AF69F5B7FD15F932 /* ScannerView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8AF19A735AC17451B57BF5BB /* ScannerView.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 */; }; 7738E209D4D32BFD514B76F5 /* ListViews.swift in Sources */ = {isa = PBXBuildFile; fileRef = A6785CD9174067EFBB2B9043 /* ListViews.swift */; };
7E2FA69E0C3BED650E91A7E9 /* AppIcon.icon in Resources */ = {isa = PBXBuildFile; fileRef = 099CCD94907BE0179B7D5742 /* AppIcon.icon */; }; 7E2FA69E0C3BED650E91A7E9 /* AppIcon.icon in Resources */ = {isa = PBXBuildFile; fileRef = 099CCD94907BE0179B7D5742 /* AppIcon.icon */; };
80B15C315FB4FEBD385A5EA1 /* ServerProfiles.swift in Sources */ = {isa = PBXBuildFile; fileRef = 121D44B9990DA931A9CD5847 /* ServerProfiles.swift */; }; 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 */; }; 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 */; }; 96E5A2455B2EC26050E69232 /* MasterDataViews.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1E86178BB91DBB86ADFB284B /* MasterDataViews.swift */; };
9B0A1A7C19764857BDC6ED26 /* RootView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4741D0E95875919C921945CF /* RootView.swift */; }; 9B0A1A7C19764857BDC6ED26 /* RootView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4741D0E95875919C921945CF /* RootView.swift */; };
A7017E6C0532A650439AED7B /* BestBeforeText.swift in Sources */ = {isa = PBXBuildFile; fileRef = AA022A27118BEEEC5912ECDC /* BestBeforeText.swift */; }; A7017E6C0532A650439AED7B /* BestBeforeText.swift in Sources */ = {isa = PBXBuildFile; fileRef = AA022A27118BEEEC5912ECDC /* BestBeforeText.swift */; };
@@ -34,8 +35,8 @@
/* Begin PBXFileReference section */ /* Begin PBXFileReference section */
0365A16FEAE3F2BEC321E68A /* CheckInView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CheckInView.swift; sourceTree = "<group>"; }; 0365A16FEAE3F2BEC321E68A /* CheckInView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CheckInView.swift; sourceTree = "<group>"; };
090485CC54558EB4433376A9 /* DashboardCards.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DashboardCards.swift; sourceTree = "<group>"; };
099CCD94907BE0179B7D5742 /* AppIcon.icon */ = {isa = PBXFileReference; lastKnownFileType = wrapper.icon; path = AppIcon.icon; sourceTree = "<group>"; }; 099CCD94907BE0179B7D5742 /* AppIcon.icon */ = {isa = PBXFileReference; lastKnownFileType = wrapper.icon; path = AppIcon.icon; sourceTree = "<group>"; };
0BDE41825692274409DDCAC7 /* OverviewView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OverviewView.swift; sourceTree = "<group>"; };
121D44B9990DA931A9CD5847 /* ServerProfiles.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ServerProfiles.swift; sourceTree = "<group>"; }; 121D44B9990DA931A9CD5847 /* ServerProfiles.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ServerProfiles.swift; sourceTree = "<group>"; };
1E86178BB91DBB86ADFB284B /* MasterDataViews.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MasterDataViews.swift; sourceTree = "<group>"; }; 1E86178BB91DBB86ADFB284B /* MasterDataViews.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MasterDataViews.swift; sourceTree = "<group>"; };
314B1BB7220691A7423E8929 /* Models.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Models.swift; sourceTree = "<group>"; }; 314B1BB7220691A7423E8929 /* Models.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Models.swift; sourceTree = "<group>"; };
@@ -56,6 +57,7 @@
AD2F9406BD0D4F0D30FED345 /* Session.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Session.swift; sourceTree = "<group>"; }; AD2F9406BD0D4F0D30FED345 /* Session.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Session.swift; sourceTree = "<group>"; };
CDD672F65352DA0D122EC9AE /* CategoryPicker.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CategoryPicker.swift; sourceTree = "<group>"; }; CDD672F65352DA0D122EC9AE /* CategoryPicker.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CategoryPicker.swift; sourceTree = "<group>"; };
DC0DDD06332E567E70CA842F /* CheckOutView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CheckOutView.swift; sourceTree = "<group>"; }; DC0DDD06332E567E70CA842F /* CheckOutView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CheckOutView.swift; sourceTree = "<group>"; };
E6E6139CCD0B6C51B3919EE9 /* DashboardView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DashboardView.swift; sourceTree = "<group>"; };
EA706060734632DE78FA1073 /* ServerListView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ServerListView.swift; sourceTree = "<group>"; }; EA706060734632DE78FA1073 /* ServerListView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ServerListView.swift; sourceTree = "<group>"; };
/* End PBXFileReference section */ /* End PBXFileReference section */
@@ -69,6 +71,8 @@
99E56353CB483EFB1CCD09D8 /* CheckInFormView.swift */, 99E56353CB483EFB1CCD09D8 /* CheckInFormView.swift */,
0365A16FEAE3F2BEC321E68A /* CheckInView.swift */, 0365A16FEAE3F2BEC321E68A /* CheckInView.swift */,
DC0DDD06332E567E70CA842F /* CheckOutView.swift */, DC0DDD06332E567E70CA842F /* CheckOutView.swift */,
090485CC54558EB4433376A9 /* DashboardCards.swift */,
E6E6139CCD0B6C51B3919EE9 /* DashboardView.swift */,
717C8EB336170526F5F3E695 /* DateScanView.swift */, 717C8EB336170526F5F3E695 /* DateScanView.swift */,
AAC97A555856C6C5DD353E25 /* DisplaySettings.swift */, AAC97A555856C6C5DD353E25 /* DisplaySettings.swift */,
7DACFBB69CB536BA7CBBD484 /* HistoryView.swift */, 7DACFBB69CB536BA7CBBD484 /* HistoryView.swift */,
@@ -76,7 +80,6 @@
A75926511854BB8EE316ED3A /* LoginView.swift */, A75926511854BB8EE316ED3A /* LoginView.swift */,
1E86178BB91DBB86ADFB284B /* MasterDataViews.swift */, 1E86178BB91DBB86ADFB284B /* MasterDataViews.swift */,
314B1BB7220691A7423E8929 /* Models.swift */, 314B1BB7220691A7423E8929 /* Models.swift */,
0BDE41825692274409DDCAC7 /* OverviewView.swift */,
6F574168AA0F849D46C384EE /* ProductDetailView.swift */, 6F574168AA0F849D46C384EE /* ProductDetailView.swift */,
369B9841E43E727ACA2E2A2A /* ProductViews.swift */, 369B9841E43E727ACA2E2A2A /* ProductViews.swift */,
4741D0E95875919C921945CF /* RootView.swift */, 4741D0E95875919C921945CF /* RootView.swift */,
@@ -183,6 +186,8 @@
0E0AE6CC6FBA82B190BBC36F /* CheckInFormView.swift in Sources */, 0E0AE6CC6FBA82B190BBC36F /* CheckInFormView.swift in Sources */,
4F86620DF8303DB02314DB5A /* CheckInView.swift in Sources */, 4F86620DF8303DB02314DB5A /* CheckInView.swift in Sources */,
F6C7E913CAB0FAF1826E6BD3 /* CheckOutView.swift in Sources */, F6C7E913CAB0FAF1826E6BD3 /* CheckOutView.swift in Sources */,
83F9AD06177F3BC95421F128 /* DashboardCards.swift in Sources */,
926451E72FD13708C5DD657B /* DashboardView.swift in Sources */,
ED61DAE4824165D6D87F8C1D /* DateScanView.swift in Sources */, ED61DAE4824165D6D87F8C1D /* DateScanView.swift in Sources */,
D70CB183C868FF0143A6A5E7 /* DisplaySettings.swift in Sources */, D70CB183C868FF0143A6A5E7 /* DisplaySettings.swift in Sources */,
4C486D190D1AB338925D29B5 /* HistoryView.swift in Sources */, 4C486D190D1AB338925D29B5 /* HistoryView.swift in Sources */,
@@ -190,7 +195,6 @@
E9EA455CACB6ED5951B114C3 /* LoginView.swift in Sources */, E9EA455CACB6ED5951B114C3 /* LoginView.swift in Sources */,
96E5A2455B2EC26050E69232 /* MasterDataViews.swift in Sources */, 96E5A2455B2EC26050E69232 /* MasterDataViews.swift in Sources */,
6816C33381DF52A96E5BB303 /* Models.swift in Sources */, 6816C33381DF52A96E5BB303 /* Models.swift in Sources */,
191427830389D44062F0A622 /* OverviewView.swift in Sources */,
AAF27320D2A67C994C29C26F /* ProductDetailView.swift in Sources */, AAF27320D2A67C994C29C26F /* ProductDetailView.swift in Sources */,
0D8629839E465D80EC58E74B /* ProductViews.swift in Sources */, 0D8629839E465D80EC58E74B /* ProductViews.swift in Sources */,
9B0A1A7C19764857BDC6ED26 /* RootView.swift in Sources */, 9B0A1A7C19764857BDC6ED26 /* RootView.swift in Sources */,