Files
Vorrania/ios/Sources/CheckInFormView.swift
T
ScarriffleandClaude Opus 5 75899714b3 App: Listendarstellung der Auswahl zurueckgenommen - sie warf aus dem Formular
Beim Anlegen eines Artikels fuehrte die Kategoriewahl zurueck in die
Artikelliste: Das Formular steht in einem Blatt, und die geschobene Auswahl
riss beim Zurueckspringen den ganzen Navigationsstapel mit - das Blatt also
gleich dazu. Der Artikel liess sich so nicht mehr anlegen.

Damit ist .pickerStyle(.navigationLink) an allen acht Stellen wieder
draussen. Die Auswahl ist wieder ein Menue und rueckt mit fuehrenden
Leerzeichen ein, wie vor d7002e2.

Der Umbruch langer Namen bleibt damit ungeloest: Faellt "Schublade Links
Unten" auf eine zweite Zeile, faengt die wieder ganz links an. Die Grenze
steht jetzt als Kommentar an Baumzeile.label, damit der naechste Versuch
nicht wieder in dieselbe Falle laeuft. Funktionierendes Anlegen wiegt
schwerer als saubere Einrueckung.

Der mittige Loeschdialog aus demselben Commit bleibt.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-10-15 21:37:11 +02:00

423 lines
16 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
/// Verweis auf die Charge, deren MHD gerade gescannt wird. Eigener Typ, damit
/// Foundations UUID nicht nachtraeglich Identifiable zugeschrieben bekommt.
struct LineRef: Identifiable {
let id: UUID
}
extension View {
/// Dezente Umrandung fuer Eingabefelder in einem Formular.
///
/// `.textFieldStyle(.roundedBorder)` zeichnet in der dunklen Darstellung
/// einen fast schwarzen Kasten, der sich hart von der Zeile abhebt. Eine
/// Systemfuellung passt sich Hell und Dunkel an und bleibt zurueckhaltend,
/// macht das Feld aber weiterhin als Eingabe erkennbar.
func fieldBox(width: CGFloat) -> some View {
self
.padding(.horizontal, 10)
.padding(.vertical, 6)
.frame(maxWidth: width)
.background(Color(.tertiarySystemFill))
.clipShape(RoundedRectangle(cornerRadius: 8))
}
}
/// Textzeile mit Beschriftung links und sichtbarem Rahmen.
struct LabeledField: View {
let label: String
@Binding var text: String
var keyboard: UIKeyboardType = .default
var body: some View {
HStack {
Text(label)
Spacer(minLength: 12)
TextField(label, text: $text)
.keyboardType(keyboard)
// Linksbündig: bei rechtsbündigen Feldern rendert iOS ein
// getipptes Leerzeichen am Ende nicht, bis ein weiteres Zeichen
// folgt – das wirkt, als würde die Eingabe nicht reagieren.
.multilineTextAlignment(.leading)
.fieldBox(width: 190)
}
}
}
/// Zahleneingabe mit Beschriftung links und sichtbarem Rahmen.
///
/// Ein rechtsbuendiges Textfeld ohne Rahmen liest sich wie fester Text - beim
/// Testen war nicht erkennbar, dass sich die Menge ueberhaupt aendern laesst.
struct QuantityField: View {
let label: String
@Binding var text: String
var suffix: String?
var body: some View {
HStack {
Text(label)
Spacer(minLength: 12)
TextField(label, text: $text)
.keyboardType(.decimalPad)
.multilineTextAlignment(.trailing)
.fieldBox(width: 110)
if let suffix {
Text(suffix)
.foregroundStyle(.secondary)
.frame(minWidth: 60, alignment: .leading)
}
}
}
}
extension Date {
/// Erster Tag des Monats - so wird eine Monatsangabe an den Server geschickt.
var startOfMonth: Date {
let calendar = Calendar.current
let parts = calendar.dateComponents([.year, .month], from: self)
return calendar.date(from: parts) ?? self
}
}
/// Monat und Jahr auswaehlen. SwiftUI kennt keinen DatePicker ohne Tag,
/// deshalb zwei schmale Auswahlfelder nebeneinander.
struct MonthYearPicker: View {
@Binding var date: Date
// Fest deutsch, damit die Monatsnamen auch dann stimmen, wenn das Geraet
// auf einer anderen Sprache steht.
private var calendar: Calendar {
var kalender = Calendar(identifier: .gregorian)
kalender.locale = Locale(identifier: "de_DE")
return kalender
}
private var monthNames: [String] { calendar.monthSymbols }
private var years: [Int] {
let current = calendar.component(.year, from: Date())
// Rueckwaerts fuer bereits abgelaufene Ware, vorwaerts fuer Konserven.
return Array((current - 2)...(current + 15))
}
private var month: Binding<Int> {
Binding(
get: { calendar.component(.month, from: date) },
set: { date = replacing(month: $0, year: calendar.component(.year, from: date)) }
)
}
private var year: Binding<Int> {
Binding(
get: { calendar.component(.year, from: date) },
set: { date = replacing(month: calendar.component(.month, from: date), year: $0) }
)
}
private func replacing(month: Int, year: Int) -> Date {
calendar.date(from: DateComponents(year: year, month: month, day: 1)) ?? date
}
var body: some View {
HStack {
Text("MHD")
Spacer()
Picker("Monat", selection: month) {
ForEach(1...12, id: \.self) { Text(monthNames[$0 - 1]).tag($0) }
}
.labelsHidden()
Picker("Jahr", selection: year) {
ForEach(years, id: \.self) { Text(String($0)).tag($0) }
}
.labelsHidden()
}
}
}
/// Mengen-Erfassung nach dem Scan: mehrere Chargen mit eigenem MHD.
struct CheckInFormView: View {
let product: Product
let units: [Unit]
let locations: [StorageLocation]
var onDone: (String) -> Void
@Environment(\.dismiss) private var dismiss
@EnvironmentObject private var display: DisplaySettings
struct Line: Identifiable {
let id = UUID()
var quantity: String = "1"
var hasDate: Bool = false
var bestBefore: Date = Date()
}
@State private var lines: [Line] = [Line()]
/// Gilt fuer den ganzen Vorgang: Auf einer Packung steht entweder ein
/// Tagesdatum oder nur Monat/Jahr - nicht beides gemischt.
@State private var precision: String = "day"
/// Charge, fuer die gerade das MHD abgescannt wird.
@State private var scanLineId: LineRef?
@State private var unit: String = ""
@State private var locationId: String?
@State private var busy = false
@State private var error: String?
// Lagerort per QR (…/l/<Code>) setzen, statt in der Liste zu suchen.
@State private var locScanShown = false
@State private var locScanPaused = false
@State private var locTorchOn = false
@State private var locTorchLocked = false
@State private var locScanError: String?
@State private var locScanHinweis: String?
/// Einheiten der passenden Art plus das Gebinde des Artikels.
private var unitOptions: [UnitOption] {
// Bei hinterlegter Zweiteinheit auch die Einheiten der Gegenart – das
// Backend rechnet sie ueber die Bruecke am Artikel um.
var arten: Set<String> = [product.kind]
if product.zweitFaktor != nil, let k = product.zweitKind { arten.insert(k) }
var options = units
.filter { arten.contains($0.kind) }
.map { UnitOption(value: $0.name, label: $0.name) }
if let size = product.packageSize, size > 0 {
// Angezeigt wird "Glas"/"Dose", geschickt wird "package".
options.append(UnitOption(value: UnitOption.packageValue,
label: product.packageLabel ?? "Packung"))
}
return options
}
private var hasPackage: Bool { (product.packageSize ?? 0) > 0 }
var body: some View {
Form {
Section {
HStack {
VStack(alignment: .leading) {
Text(product.name).font(.headline)
Text("Bestand: \(format(product.stockInArticleUnits)) \(product.articleUnitLabel)")
.font(.caption).foregroundStyle(.secondary)
}
Spacer()
}
}
Section("Einheit") {
Picker("Einheit", selection: $unit) {
ForEach(unitOptions) { Text($0.label).tag($0.value) }
}
}
if !locations.isEmpty {
Section {
Picker("Lagerort", selection: $locationId) {
Text("– keiner –").tag(String?.none)
ForEach(baum(locations)) { eintrag in
Text(eintrag.label).tag(String?.some(eintrag.item.id))
}
}
Button {
locScanError = nil
locScanPaused = false
locScanShown = true
} label: {
Label("Lagerort-QR scannen", systemImage: "qrcode.viewfinder")
}
if let locScanHinweis {
Label(locScanHinweis, systemImage: "checkmark.circle.fill")
.font(.caption).foregroundStyle(.green)
}
} header: {
Text("Lagerort")
} footer: {
Text("Den QR am Regal/Fach scannen, statt den Ort in der Liste zu suchen.")
}
}
Section("MHD") {
Picker("Angabe", selection: $precision) {
Text("Tagesdatum").tag("day")
Text("nur Monat/Jahr").tag("month")
}
}
// Jede Charge bekommt einen eigenen Abschnitt. Vorher standen alle
// Zeilen gestapelt in einer einzigen Formularzeile und wirkten
// dadurch gequetscht.
ForEach(Array($lines.enumerated()), id: \.element.id) { index, $line in
Section {
QuantityField(label: "Menge", text: $line.quantity)
Toggle("MHD angeben", isOn: $line.hasDate)
if line.hasDate {
if precision == "month" {
MonthYearPicker(date: $line.bestBefore)
} else {
DatePicker("MHD", selection: $line.bestBefore, displayedComponents: .date)
}
if DateScanView.isSupported {
Button {
scanLineId = LineRef(id: line.id)
} label: {
Label("MHD scannen", systemImage: "text.viewfinder")
}
}
}
} header: {
HStack {
Text(lines.count > 1 ? "Charge \(index + 1)" : "Charge")
Spacer()
if lines.count > 1 {
Button(role: .destructive) {
lines.removeAll { $0.id == line.id }
} label: {
Label("Entfernen", systemImage: "trash")
.labelStyle(.iconOnly)
}
.buttonStyle(.borderless)
}
}
}
}
Section {
Button {
lines.append(Line())
} label: {
Label("Weitere Charge (anderes MHD)", systemImage: "plus")
}
}
if let error {
Section { Text(error).foregroundStyle(.red).font(.callout) }
}
Section {
Button(busy ? "Speichern…" : "Einlagern") { Task { await submit() } }
.disabled(busy)
}
}
.navigationTitle("Einlagern")
.navigationBarTitleDisplayMode(.inline)
.toolbar {
ToolbarItem(placement: .topBarLeading) { Button("Abbrechen") { dismiss() } }
}
.sheet(item: $scanLineId) { ziel in
NavigationStack {
DateScanView { datum, erkanntePraezision in
// Nennt der Aufdruck keinen Tag, stellt sich die Eingabe
// sichtbar auf Monat/Jahr um.
if erkanntePraezision == "month" { precision = "month" }
if let index = lines.firstIndex(where: { $0.id == ziel.id }) {
lines[index].bestBefore = datum
lines[index].hasDate = true
}
}
}
}
.fullScreenCover(isPresented: $locScanShown) { locationScannerCover }
.onAppear {
// Gibt es ein Gebinde, ist das die naheliegende Eingabe.
unit = hasPackage ? UnitOption.packageValue : product.unitName
// Am Produkt hinterlegt, ob dort ueblicherweise nur Monat/Jahr steht.
precision = product.datePrecision == "month" ? "month" : "day"
}
}
// MARK: - Lagerort scannen
private var locationScannerCover: some View {
NavigationStack {
ScannerView(onCode: { code in Task { await handleLocationScan(code) } },
isPaused: $locScanPaused, torchOn: $locTorchOn, torchLocked: $locTorchLocked)
.ignoresSafeArea(edges: .bottom)
.overlay(alignment: .bottom) {
VStack(spacing: 6) {
if let locScanError {
Text(locScanError)
.font(.callout).foregroundStyle(.white)
.padding(.horizontal, 12).padding(.vertical, 8)
.background(.red, in: Capsule())
}
Text("QR am Regal/Fach vor die Kamera halten")
.font(.callout)
.padding(10)
.background(.ultraThinMaterial, in: Capsule())
}
.padding(.bottom, 24)
}
.navigationTitle("Lagerort scannen")
.navigationBarTitleDisplayMode(.inline)
.toolbar {
ToolbarItem(placement: .topBarLeading) {
Button("Abbrechen") { locScanShown = false }
}
ToolbarItem(placement: .topBarTrailing) {
TorchButton(isOn: $locTorchOn, locked: $locTorchLocked)
}
}
}
.onAppear { locScanPaused = false; locScanError = nil }
}
/// Aus einem gescannten Lagerort-QR (…/l/<Code>) den Zielort setzen.
private func handleLocationScan(_ code: String) async {
guard let r = code.range(of: "/l/") else {
locScanError = "Das ist kein Lagerort-QR."
return
}
let ziel = String(code[r.upperBound...])
.trimmingCharacters(in: CharacterSet(charactersIn: "/ "))
.uppercased()
if let treffer = locations.first(where: { $0.id == ziel }) {
locationId = treffer.id
locScanHinweis = treffer.name
locScanShown = false
} else {
locScanError = "Dieser Lagerort ist hier nicht bekannt."
}
}
private func format(_ value: Double) -> String {
value == value.rounded() ? String(Int(value)) : String(format: "%.2f", value)
}
private func submit() async {
error = nil
let formatter = DateFormatter()
formatter.dateFormat = "yyyy-MM-dd"
let payloadLines: [CheckInLine] = lines.compactMap { line in
guard let quantity = Double(line.quantity.replacingOccurrences(of: ",", with: ".")),
quantity > 0 else { return nil }
// Bei Monatsangabe genuegt der Monatserste - der Server legt daraus
// den Monatsletzten und merkt sich die Genauigkeit.
let date = precision == "month" ? line.bestBefore.startOfMonth : line.bestBefore
return CheckInLine(
quantity: quantity,
bestBefore: line.hasDate ? formatter.string(from: date) : nil,
bestBeforePrecision: precision,
locationId: locationId
)
}
guard !payloadLines.isEmpty else {
error = "Bitte mindestens eine Menge angeben."
return
}
busy = true
defer { busy = false }
do {
let response = try await APIClient.shared.checkInBatch(
BatchCheckInRequest(productId: product.id, unit: unit, lines: payloadLines)
)
let total = response.productStock / product.articleUnitFactor
// Titel + Detail (zweizeilig im Banner); Einheit mit Mehrzahl.
onDone("Eingelagert\nNeuer Bestand: \(format(total)) \(display.articleUnit(product, count: total))")
dismiss()
} catch {
self.error = error.localizedDescription
}
}
}