LabeledField war rechtsbuendig; iOS rendert bei rechtsbuendigen Feldern ein getipptes Leerzeichen am Ende nicht, bis ein weiteres Zeichen folgt (wirkt, als reagiere die Eingabe nicht). Linksbuendig behebt das und liest sich bei Name/ Marke ohnehin natuerlicher. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
417 lines
16 KiB
Swift
417 lines
16 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 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
|
||
|
||
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] {
|
||
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 {
|
||
Section {
|
||
Picker("Lagerort", selection: $locationId) {
|
||
Text("– keiner –").tag(String?.none)
|
||
ForEach(locations) { location in
|
||
Text(location.path(in: locations)).tag(String?.some(location.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
|
||
onDone("Eingelagert. Neuer Bestand: \(format(total)) \(product.articleUnitLabel)")
|
||
dismiss()
|
||
} catch {
|
||
self.error = error.localizedDescription
|
||
}
|
||
}
|
||
}
|