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:
@@ -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)"
|
||||||
|
|||||||
403
ios/Sources/DashboardCards.swift
Normal file
403
ios/Sources/DashboardCards.swift
Normal 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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
167
ios/Sources/DashboardView.swift
Normal file
167
ios/Sources/DashboardView.swift
Normal 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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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.
|
||||||
|
|||||||
@@ -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))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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()
|
||||||
|
|||||||
@@ -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 */,
|
||||||
|
|||||||
Reference in New Issue
Block a user