Vier Bruchstellen im taeglichen Gebrauch, alle an derselben Wurzel: Nach dem Anlegen endete der Weg, und was danach kam, musste man sich selbst suchen. NFC beim Anlegen: createItems liefert die angelegten Stuecke samt UID laengst zurueck, ItemAddSheet warf sie nur weg. Bei genau EINEM Stueck geht es jetzt direkt zum Tag-Blatt. Bei mehreren bleibt es wie bisher - die App koennte nicht wissen, welches der fuenf Stuecke gerade am Tag haengt. Ohne NFC im Geraet (iPad) ebenso unveraendert. Menge beim Anlegen: Ein Gegenstand, der nur als Menge je Lagerort gezaehlt wird, landete nach dem Anlegen im Nichts. Das Blatt dafuer gab es schon (ObjectAddSheet), nur fuehrte kein Weg dorthin. Jetzt derselbe Uebergang wie bei Einzelstuecken, an beiden Einstiegen. Es holt sich die Lagerorte selbst - ObjectAddSheet nimmt sie als Konstante und laedt sie nicht nach. Loeschen: Der Knopf sitzt unten in der Artikelansicht, nur fuer Admins - die Route verlangt das, ohne Riegel gaebe es nur ein wortkarges 403. Lehnt der Server ab (noch Einzelstuecke oder Bestand), bleibt die Ansicht stehen und seine Begruendung steht unveraendert in der Fehlerzeile. NFC beim Ein- und Auslagern: Ein Tag benennt immer genau ein Einzelstueck, deshalb fuehrt er zur Stueck-Ansicht - beim Auslagern ist "Entfernen (mit Grund)" genau die passende Aktion. Bewusst NICHT ueber resolve() abgekuerzt: das faellt bei unbekannter UID auf lookup(barcode:) durch und boete an, einen Artikel mit einer URL als Barcode anzulegen. Beim Nachschlagen gab es NFC schon, nur unscheinbar unter der Kamerakarte - Knopf abgesetzt und in der Bildunterschrift erwaehnt. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
314 lines
12 KiB
Swift
314 lines
12 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()
|
||
// Nach refreshMe, damit eine wirklich abgelaufene Sitzung erst
|
||
// sauber endet, statt hier noch verlaengert zu werden.
|
||
await session.sitzungVerlaengern()
|
||
}
|
||
}
|
||
// 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") }
|
||
}
|
||
}
|
||
.tint(Color.marke)
|
||
}
|
||
}
|
||
|
||
extension Color {
|
||
/// Markenblau (wie App-Icon und Web-Akzent) statt des System-Blaus.
|
||
static let marke = Color(UIColor { trait in
|
||
trait.userInterfaceStyle == .dark
|
||
? UIColor(red: 0.486, green: 0.541, blue: 0.910, alpha: 1) // #7C8AE8
|
||
: UIColor(red: 0.247, green: 0.318, blue: 0.710, alpha: 1) // #3F51B5
|
||
})
|
||
}
|
||
|
||
/// 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
|
||
// Der gerade angelegte Artikel, bis sich das Formular geschlossen hat.
|
||
@State private var angelegt: Product?
|
||
@State private var einzelstueckeFuer: Product?
|
||
@State private var mengeFuer: Product?
|
||
@State private var showKassenzettel = 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")
|
||
}
|
||
Button {
|
||
showKassenzettel = true
|
||
} label: {
|
||
ActionTile(title: "Kassenzettel",
|
||
subtitle: "Bon fotografieren und Lebensmittel einlagern",
|
||
systemImage: "doc.text.viewfinder")
|
||
}
|
||
// 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()
|
||
}
|
||
.buttonStyle(.plain)
|
||
.padding()
|
||
.navigationTitle("Scannen")
|
||
.fullScreenCover(isPresented: $showKassenzettel) { KassenzettelView() }
|
||
.sheet(isPresented: $showNew, onDismiss: {
|
||
if fuehrtEinzelstuecke(angelegt) { einzelstueckeFuer = angelegt }
|
||
else if fuehrtMengeJeLagerort(angelegt) { mengeFuer = angelegt }
|
||
angelegt = nil
|
||
}) {
|
||
NavigationStack {
|
||
ProductFormView(prefillBarcode: nil, groupId: nil) { created in
|
||
angelegt = created
|
||
showNew = false
|
||
}
|
||
}
|
||
}
|
||
.einzelstueckeNachAnlegen($einzelstueckeFuer)
|
||
.mengeNachAnlegen($mengeFuer)
|
||
}
|
||
}
|
||
}
|
||
|
||
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 {
|
||
ChargenListView()
|
||
} label: {
|
||
ActionTile(title: "Chargen", subtitle: "Anbrechen und Teilmengen verbrauchen",
|
||
systemImage: "shippingbox.and.arrow.backward")
|
||
}
|
||
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()
|
||
}
|
||
.buttonStyle(.plain)
|
||
.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.marke.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.marke.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)
|
||
}
|
||
}
|