Files
Vorrania/ios/Sources/CheckInFormView.swift
Scarriffle 30307d6cfb MHD nur mit Monat und Jahr in Web-UI und iOS-App
Setzt den Backend-Umbau in beiden Oberflaechen um.

Die Genauigkeit gilt jeweils fuer den ganzen Einlager-Vorgang und nicht je
Charge: Auf einer Packung steht entweder ein Tagesdatum oder nur Monat/Jahr,
gemischt kommt das nicht vor. Vorbelegt wird sie aus dem Produkt, laesst sich
aber im Vorgang umstellen.

Web-UI: Umschalter im Kopf der Chargenliste; das Eingabefeld wechselt zwischen
type="date" und type="month". Im Produktformular gibt es das neue Feld
"MHD-Angabe" neben der Gebinde-Bezeichnung. Anzeige laeuft ueber den
Settings-Context, damit das eingestellte Datumsformat erhalten bleibt und
Monatsangaben ueberall als "09/2026" erscheinen (Auslagern, Uebersicht,
Chargentabelle). Die Abgelaufen-Warnung prueft bei Monatsangaben gegen den
Monatsletzten - sonst haette eine Packung schon am Monatsersten als abgelaufen
gegolten.

iOS: Auswahl "Tagesdatum / nur Monat/Jahr" im Einlagern-Formular und beim
Anlegen eines Artikels. SwiftUI hat keinen DatePicker ohne Tag, deshalb ein
eigener MonthYearPicker aus zwei Auswahlfeldern; die Jahresliste reicht zwei
Jahre zurueck (bereits abgelaufene Ware) und fuenfzehn nach vorn (Konserven).
Die Chargenauswahl beim Auslagern zeigt Monatsangaben ebenfalls ohne Tag.

Getestet: Web-Build (vite) und iOS-Geraetebuild laufen fehlerfrei durch, die
App ist auf dem iPhone installiert. Das Verhalten in der Oberflaeche ist noch
nicht von Hand durchgeklickt.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-22 17:57:09 +02:00

233 lines
8.5 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
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"
@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: [String] {
var options = units.filter { $0.kind == product.kind }.map(\.name)
if let size = product.packageSize, size > 0 {
options.append(product.packageLabel ?? "Packung")
}
return options
}
private var packageOptionName: String? {
guard let size = product.packageSize, size > 0 else { return nil }
return product.packageLabel ?? "Packung"
}
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, id: \.self) { Text($0).tag($0) }
}
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)
}
}
}
.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() } }
}
.onAppear {
// Gibt es ein Gebinde, ist das die naheliegende Eingabe.
unit = packageOptionName ?? 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
}
}
}