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>
264 lines
10 KiB
Swift
264 lines
10 KiB
Swift
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
|
||
}
|
||
}
|
||
}
|