Neue "Mindestbestaende"-Ansicht: alle Artikel mit gesetztem Mindestbestand mit Soll vs. Bestand (Anzeigeeinheit) und "niedrig"-Hinweis; Tippen oeffnet den Artikel zum Bearbeiten. Kachel im Listen-Tab ergaenzt. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
273 lines
10 KiB
Swift
273 lines
10 KiB
Swift
import SwiftUI
|
||
|
||
struct RootView: View {
|
||
@EnvironmentObject private var session: Session
|
||
@EnvironmentObject private var router: Router
|
||
@EnvironmentObject private var display: DisplaySettings
|
||
/// Über einen gescannten Universal Link geöffnetes Einzelstück.
|
||
@State private var linkedItem: Item?
|
||
@State private var linkError: String?
|
||
|
||
var body: some View {
|
||
Group {
|
||
if session.isLoggedIn {
|
||
HomeView()
|
||
} else {
|
||
LoginView()
|
||
}
|
||
}
|
||
// Anzeigeeinstellungen (Datumsformat) gehoeren dem Server - bei einem
|
||
// Serverwechsel muessen sie deshalb neu geladen werden, sonst zeigt
|
||
// Server B die Formate von Server A.
|
||
.task(id: session.activeProfileID) {
|
||
if session.isLoggedIn {
|
||
await display.load()
|
||
await session.refreshMe()
|
||
}
|
||
}
|
||
// Shortcut/URL öffnet den passenden Scan-Bildschirm direkt; eine
|
||
// getippte Benachrichtigung öffnet „Bald ablaufend".
|
||
.fullScreenCover(item: $router.route) { route in
|
||
NavigationStack {
|
||
switch route {
|
||
case .checkin: CheckInView()
|
||
case .checkout: CheckOutView()
|
||
case .lookup: LookupScanView()
|
||
case .assign: AssignScanView()
|
||
case .expiring:
|
||
ExpiringView()
|
||
.toolbar {
|
||
ToolbarItem(placement: .topBarLeading) {
|
||
Button("Fertig") { router.route = nil }
|
||
}
|
||
}
|
||
}
|
||
}
|
||
}
|
||
.task(id: router.openItemUid) {
|
||
guard let uid = router.openItemUid else { return }
|
||
// openItemUid NICHT vor dem Abruf zurücksetzen: das ändert die
|
||
// .task-id und SwiftUI bricht dadurch die laufende Anfrage ab
|
||
// (URLError.cancelled). Erst nach dem Laden auf nil setzen.
|
||
let host = session.activeProfile?.url?.host ?? "?"
|
||
if session.isLoggedIn {
|
||
do {
|
||
linkedItem = try await APIClient.shared.itemByUid(uid: uid)
|
||
} catch {
|
||
linkError = "Einzelstück \(uid) auf Server \(host) nicht gefunden. "
|
||
+ "Zeigt der QR-Code auf denselben Server wie der oben gewählte? "
|
||
+ "(\(error.localizedDescription))"
|
||
}
|
||
} else {
|
||
linkError = "Bitte zuerst am Server \(host) anmelden, dann erneut scannen."
|
||
}
|
||
router.openItemUid = nil
|
||
}
|
||
.sheet(item: $linkedItem) { it in
|
||
NavigationStack { ItemEditView(item: it) }
|
||
}
|
||
.alert("Einzelstück öffnen", isPresented: Binding(
|
||
get: { linkError != nil }, set: { if !$0 { linkError = nil } }
|
||
)) {
|
||
Button("OK", role: .cancel) { linkError = nil }
|
||
} message: { Text(linkError ?? "") }
|
||
}
|
||
}
|
||
|
||
extension Route: Identifiable {
|
||
var id: String { rawValue }
|
||
}
|
||
|
||
struct HomeView: View {
|
||
@EnvironmentObject private var session: Session
|
||
|
||
var body: some View {
|
||
TabView {
|
||
DashboardView()
|
||
.tabItem { Label("Start", systemImage: "house") }
|
||
|
||
ScanTabView()
|
||
.tabItem { Label("Scannen", systemImage: "barcode.viewfinder") }
|
||
|
||
ListsTabView()
|
||
.tabItem { Label("Listen", systemImage: "list.bullet") }
|
||
|
||
// Stammdaten gehoeren Administratoren - dieselbe Regel wie in der
|
||
// Web-Oberflaeche (web/src/App.jsx).
|
||
if session.isAdmin {
|
||
AdminTabView()
|
||
.tabItem { Label("Verwaltung", systemImage: "gearshape") }
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
/// Die beiden Kacheln, die frueher die Startseite waren.
|
||
struct ScanTabView: View {
|
||
@EnvironmentObject private var router: Router
|
||
@EnvironmentObject private var session: Session
|
||
@State private var showNew = false
|
||
|
||
var body: some View {
|
||
NavigationStack {
|
||
VStack(spacing: 16) {
|
||
Button {
|
||
router.route = .checkin
|
||
} label: {
|
||
ActionTile(title: "Einlagern", subtitle: "Barcode scannen und Bestand erfassen",
|
||
systemImage: "arrow.down.to.line")
|
||
}
|
||
Button {
|
||
router.route = .checkout
|
||
} label: {
|
||
// "arrow.up.from.line" gibt es als SF Symbol nicht - das Icon
|
||
// blieb dadurch leer und es war nur die Kachelflaeche zu sehen.
|
||
ActionTile(title: "Auslagern", subtitle: "Barcode scannen und Menge entnehmen",
|
||
systemImage: "arrow.up.to.line")
|
||
}
|
||
Button {
|
||
router.route = .lookup
|
||
} label: {
|
||
ActionTile(title: "Nachschlagen",
|
||
subtitle: "Barcode/QR scannen und nur ansehen",
|
||
systemImage: "doc.text.magnifyingglass")
|
||
}
|
||
// Ohne Barcode: gerade bei Gegenstaenden (Kleidung, Werkzeug) gibt
|
||
// es keinen Code zum Scannen. Deshalb hier ein offensichtlicher Weg.
|
||
if session.isAdmin {
|
||
Button {
|
||
showNew = true
|
||
} label: {
|
||
ActionTile(title: "Artikel anlegen",
|
||
subtitle: "Ohne Barcode – Name, Kategorie, eigene Felder",
|
||
systemImage: "plus")
|
||
}
|
||
Button {
|
||
router.route = .assign
|
||
} label: {
|
||
ActionTile(title: "Ort zuordnen",
|
||
subtitle: "Einzelstück-QR scannen, dann Lagerort-QR",
|
||
systemImage: "mappin.and.ellipse")
|
||
}
|
||
}
|
||
Spacer()
|
||
}
|
||
.padding()
|
||
.navigationTitle("Scannen")
|
||
.sheet(isPresented: $showNew) {
|
||
NavigationStack {
|
||
ProductFormView(prefillBarcode: nil, groupId: nil) { _ in
|
||
showNew = false
|
||
}
|
||
}
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
struct ListsTabView: View {
|
||
var body: some View {
|
||
NavigationStack {
|
||
VStack(spacing: 16) {
|
||
// Getrennte Produktlisten wie im Web: Lebensmittel und Gegenstände
|
||
// nebeneinander als halbbreite Kacheln.
|
||
HStack(spacing: 16) {
|
||
NavigationLink {
|
||
ProductListView(fixedType: "food")
|
||
} label: {
|
||
CompactTile(title: "Lebensmittel", systemImage: "fork.knife")
|
||
}
|
||
NavigationLink {
|
||
ProductListView(fixedType: "object")
|
||
} label: {
|
||
CompactTile(title: "Gegenstände", systemImage: "shippingbox")
|
||
}
|
||
}
|
||
NavigationLink {
|
||
ItemListView()
|
||
} label: {
|
||
ActionTile(title: "Einzelstücke", subtitle: "Alle Exemplare – filterbar nach Lagerort",
|
||
systemImage: "tag")
|
||
}
|
||
NavigationLink {
|
||
ShoppingListView()
|
||
} label: {
|
||
ActionTile(title: "Einkaufsliste", subtitle: "Was unter dem Mindestbestand liegt",
|
||
systemImage: "cart")
|
||
}
|
||
NavigationLink {
|
||
MinStockView()
|
||
} label: {
|
||
ActionTile(title: "Mindestbestände", subtitle: "Soll je Artikel ansehen und pflegen",
|
||
systemImage: "checklist")
|
||
}
|
||
NavigationLink {
|
||
ExpiringView()
|
||
} label: {
|
||
ActionTile(title: "Bald ablaufend", subtitle: "Chargen mit knappem MHD",
|
||
systemImage: "clock")
|
||
}
|
||
NavigationLink {
|
||
HistoryView()
|
||
} label: {
|
||
ActionTile(title: "Verlauf", subtitle: "Wer hat wann was ein- und ausgelagert",
|
||
systemImage: "clock.arrow.circlepath")
|
||
}
|
||
Spacer()
|
||
}
|
||
.padding()
|
||
.navigationTitle("Listen")
|
||
}
|
||
}
|
||
}
|
||
|
||
/// Kompakte, halbbreite Kachel (Symbol oben, Titel darunter) für zwei
|
||
/// nebeneinanderstehende Einträge – z.B. Lebensmittel/Gegenstände.
|
||
struct CompactTile: View {
|
||
let title: String
|
||
let systemImage: String
|
||
|
||
var body: some View {
|
||
VStack(spacing: 8) {
|
||
Image(systemName: systemImage)
|
||
.font(.title2)
|
||
.frame(width: 44, height: 44)
|
||
.background(Color.accentColor.opacity(0.15))
|
||
.clipShape(RoundedRectangle(cornerRadius: 10))
|
||
Text(title).font(.headline)
|
||
}
|
||
.frame(maxWidth: .infinity)
|
||
.padding()
|
||
.background(Color(.secondarySystemBackground))
|
||
.clipShape(RoundedRectangle(cornerRadius: 12))
|
||
.foregroundStyle(.primary)
|
||
}
|
||
}
|
||
|
||
struct ActionTile: View {
|
||
let title: String
|
||
let subtitle: String
|
||
let systemImage: String
|
||
|
||
var body: some View {
|
||
HStack(spacing: 14) {
|
||
Image(systemName: systemImage)
|
||
.font(.title2)
|
||
.frame(width: 44, height: 44)
|
||
.background(Color.accentColor.opacity(0.15))
|
||
.clipShape(RoundedRectangle(cornerRadius: 10))
|
||
VStack(alignment: .leading, spacing: 2) {
|
||
Text(title).font(.headline)
|
||
Text(subtitle).font(.caption).foregroundStyle(.secondary)
|
||
.multilineTextAlignment(.leading)
|
||
}
|
||
Spacer()
|
||
Image(systemName: "chevron.right").foregroundStyle(.tertiary)
|
||
}
|
||
.padding()
|
||
.background(Color(.secondarySystemBackground))
|
||
.clipShape(RoundedRectangle(cornerRadius: 12))
|
||
.foregroundStyle(.primary)
|
||
}
|
||
}
|