Die Auswahl war nicht flach, weil die Beschriftung gefehlt haette - der volle Pfad stand laengst drin. Sie kam vom Server nach dem BLATTnamen sortiert, und so standen "Garage -> Fach A" und "Keller -> Fach A" irgendwo zwischen allem anderen. Geschwister und Kinder fanden nie zusammen. Neu ist baum() in Baum.swift: bringt eine flache Eltern-Kind-Liste in Baumreihenfolge und rueckt je Ebene ein. Waisen gelten als oberste Ebene, damit nichts lautlos aus der Auswahl faellt. Derselbe Lauf lag bisher dreimal im Projekt (CategoryPicker, TreeMasterView, LocationEditor). Alle drei zeigen jetzt auf den einen - unterm Strich verschwinden mehr Zeilen als dazukommen. Zwei der Kopien waren auch nicht gleichwertig: LocationEditor.pfad hatte den Zyklusschutz von path(in:) nicht und waere bei einer ringfoermigen Elternkette endlos gelaufen. Umgestellt sind neun Auswahlfelder. Die Lagerortzeile beim Kassenzettel zeigte nur den nackten Namen und bekommt den vollen Pfad - dort ist kein Platz zum Einruecken. MARKETING_VERSION stand auf 1.2 in der project.pbxproj, aber auf 1.1 in der project.yml, aus der sie erzeugt wird. Die Quelle nachgezogen, sonst haette das naechste "xcodegen generate" die Anhebung zurueckgedreht. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
423 lines
16 KiB
Swift
423 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
|
||
@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
|
||
}
|
||
}
|
||
}
|