Files
Vorrania/ios/Sources/RootView.swift
T
ScarriffleandClaude Opus 5 ca4e9a2264 Einzelstueck anlegen: im Verlauf sichtbar und vom Artikel aus erreichbar
Zwei Luecken, die zusammen den Eindruck machten, ein angelegtes Einzelstueck
sei verschwunden.

Der Verlauf kannte nur das Verschwinden. remove_item schreibt seit jeher eine
Bewegung, create_items nicht - Abgaenge von Einzelstuecken tauchten also auf,
Zugaenge nie, im Verlauf wie im Dashboard. Jetzt schreibt das Anlegen je Stueck
eine in-Bewegung mit UID (oder der eigenen Notiz, falls angegeben), Lagerort und
Benutzer, spiegelbildlich zum Entfernen. Der Bestand bleibt unberuehrt: er
zaehlt bei Einzelstuecken die Zeilen in items und rechnet nicht aus Bewegungen.

Und der Weg dorthin fehlte. ProductFormView liefert den angelegten Artikel, aber
zwei der drei Aufrufer warfen ihn weg ("{ _ in showNew = false }") - nur der
Einlagern-Weg nutzte ihn. "Einzelstuecke anlegen" steckt ausschliesslich in der
Artikelansicht, die man vom Scan-Tab und aus den Listen nach dem Anlegen gar
nicht erreicht. Man musste den frischen Artikel von Hand wiederfinden.

Bei Gegenstaenden mit eigener UID je Stueck oeffnet sich jetzt direkt das
Anlege-Formular. Gesetzt wird das im onDismiss des Artikelformulars: beides im
selben Zug zu setzen verschluckt SwiftUI, weil das zweite Sheet erscheinen soll,
waehrend das erste noch verschwindet.

Der Parameter von create_items heisst dadurch user statt _; die drei Testaufrufe
sind mitgezogen.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-18 12:12:42 +02:00

302 lines
12 KiB
Swift
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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") }
}
}
.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 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 }
angelegt = nil
}) {
NavigationStack {
ProductFormView(prefillBarcode: nil, groupId: nil) { created in
angelegt = created
showNew = false
}
}
}
.einzelstueckeNachAnlegen($einzelstueckeFuer)
}
}
}
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()
}
.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)
}
}