Files
Vorrania/ios/Sources/CheckInFormView.swift
Scarriffle f3ee10e4d4 Einkaufsliste, Ablaufliste und Produktverwaltung in der App
Der Startbildschirm bekommt drei weitere Einstiege: Einkaufsliste (Artikel und
Gruppen unter Mindestbestand), Bald ablaufend (Chargen mit Restfrist, abgelaufene
rot) und Produkte. Alle Listen lassen sich durch Herunterziehen aktualisieren.

In den Produktdetails sind Name, Marke, Packungsgroesse, Gebinde-Bezeichnung und
MHD-Angabe aenderbar. Die Chargen werden mit Menge und MHD aufgelistet; Antippen
oeffnet die Korrektur von Menge und MHD (inklusive Umschalten auf Monat/Jahr),
Wischen loescht eine Charge. Mengen werden in der Leitangabe des Artikels
angezeigt und beim Speichern in Basiseinheiten zurueckgerechnet, weil die
Chargen-Schnittstelle in Basiseinheiten arbeitet.

Dabei ist ein bestehender Fehler aufgefallen und mitbehoben: Fuer das Gebinde
kennt das Backend nur das Schluesselwort "package" (services/conversion.py
akzeptiert package/packung/pkg/pack). Die App hat stattdessen die
Gebinde-Bezeichnung selbst als Einheit geschickt. Bei Produkten mit eigener
Bezeichnung - "Glas", "Dose", "Flasche", "Tuete" - schlugen Einlagern und
Auslagern damit mit "Unbekannte Einheit: Dose" fehl. Nur die Standardbezeichnung
"Packung" funktionierte zufaellig, weil sie in der Liste der Schluesselwoerter
steht. Anzeige und uebertragener Wert sind jetzt getrennt (neuer Typ
UnitOption): angezeigt wird weiterhin "Glas", geschickt wird "package".

Ausserdem die Warnung zu den Bildschirmausrichtungen behoben - das iPad verlangt
alle Ausrichtungen, solange die App nicht auf Vollbild besteht.

Getestet: iOS-Geraetebuild fehler- und warnungsfrei. Die Endpunkte hinter den
neuen Ansichten sind gegen die laufende API geprueft, ebenso das Aendern und
Loeschen von Chargen. Der Einheiten-Fehler wurde an der echten API
nachgestellt ("Unbekannte Einheit: Dose") und mit "package" als 200 bestaetigt.
Die neuen Ansichten habe ich nicht auf dem Geraet bedient.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-22 18:16:15 +02:00

264 lines
10 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 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
private let calendar = Calendar.current
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
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: Int?
@State private var busy = false
@State private var error: String?
/// Einheiten der passenden Art plus das Gebinde des Artikels.
private var unitOptions: [UnitOption] {
var options = units
.filter { $0.kind == product.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 {
Picker("Lagerort", selection: $locationId) {
Text(" keiner ").tag(Int?.none)
ForEach(locations) { location in
Text(location.name).tag(Int?.some(location.id))
}
}
}
}
Section("MHD") {
Picker("Angabe", selection: $precision) {
Text("Tagesdatum").tag("day")
Text("nur Monat/Jahr").tag("month")
}
}
Section("Chargen") {
ForEach($lines) { $line in
VStack(alignment: .leading, spacing: 6) {
HStack {
// Ohne Beschriftung sah die vorbelegte 1 wie eine
// Nummerierung der Charge aus.
Text("Menge")
Spacer(minLength: 12)
TextField("Menge", text: $line.quantity)
.keyboardType(.decimalPad)
.multilineTextAlignment(.trailing)
.frame(maxWidth: 110)
if lines.count > 1 {
Button(role: .destructive) {
lines.removeAll { $0.id == line.id }
} label: { Image(systemName: "trash") }
.buttonStyle(.borderless)
}
}
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")
.font(.callout)
}
.buttonStyle(.borderless)
}
}
}
.padding(.vertical, 2)
}
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
}
}
}
}
.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"
}
}
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
onDone("Eingelagert. Neuer Bestand: \(format(total)) \(product.articleUnitLabel)")
dismiss()
} catch {
self.error = error.localizedDescription
}
}
}