254 lines
9.9 KiB
Swift
254 lines
9.9 KiB
Swift
import Foundation
|
|
import SwiftUI
|
|
|
|
/// Shown after a successful `/api/scan` call. Lets the user review/correct
|
|
/// the AI-extracted fields before persisting the receipt via
|
|
/// `ReceiptsAPI.sync(...)`. Edits are kept in a local `@State` copy so a
|
|
/// failed save never loses what the user typed.
|
|
struct ReceiptReviewView: View {
|
|
@State private var receipt: Receipt
|
|
|
|
@State private var isSaving = false
|
|
@State private var saveErrorMessage: String?
|
|
|
|
@Environment(\.dismiss) private var dismiss
|
|
|
|
init(receipt: Receipt) {
|
|
_receipt = State(initialValue: receipt)
|
|
}
|
|
|
|
var body: some View {
|
|
ZStack {
|
|
Color.zenithBg.ignoresSafeArea()
|
|
|
|
ScrollView {
|
|
VStack(alignment: .leading, spacing: ZenithSpacing.md) {
|
|
ZenithStatusBadge(receipt)
|
|
|
|
// MARK: Belegdaten (Händler / Datum / Betrag)
|
|
VStack(alignment: .leading, spacing: ZenithSpacing.sm) {
|
|
HStack(alignment: .bottom, spacing: ZenithSpacing.xs) {
|
|
ZenithTextField(label: "Händlername", text: $receipt.merchant.name)
|
|
confidenceIndicator(receipt.merchant.confidence)
|
|
}
|
|
|
|
ZenithDivider()
|
|
|
|
HStack(alignment: .bottom, spacing: ZenithSpacing.xs) {
|
|
LabeledFieldShell(label: "Datum") {
|
|
DatePicker("", selection: dateBinding, displayedComponents: [.date])
|
|
.labelsHidden()
|
|
.datePickerStyle(.compact)
|
|
.tint(.zenithBlack)
|
|
}
|
|
confidenceIndicator(receipt.date.confidence)
|
|
}
|
|
|
|
ZenithDivider()
|
|
|
|
HStack(alignment: .bottom, spacing: ZenithSpacing.xs) {
|
|
ZenithAmountField(label: "Betrag", value: totalAmountBinding)
|
|
Text(receipt.currency)
|
|
.zenithLabelMdStyle(color: .zenithMuted)
|
|
.padding(.bottom, ZenithSpacing.unit * 2)
|
|
confidenceIndicator(receipt.totalAmount.confidence)
|
|
}
|
|
}
|
|
.zenithCard()
|
|
|
|
// MARK: Kategorie / Zahlungsart
|
|
VStack(alignment: .leading, spacing: ZenithSpacing.sm) {
|
|
LabeledFieldShell(label: "Kategorie") {
|
|
Menu {
|
|
ForEach(Receipt.ReceiptCategory.allCases, id: \.self) { category in
|
|
Button(category.rawValue) { receipt.suggestedCategory = category }
|
|
}
|
|
} label: {
|
|
menuLabel(receipt.suggestedCategory.rawValue)
|
|
}
|
|
}
|
|
|
|
ZenithDivider()
|
|
|
|
LabeledFieldShell(label: "Zahlungsart") {
|
|
Menu {
|
|
Button("Keine Angabe") { receipt.paymentMethod = nil }
|
|
ForEach(Receipt.PaymentMethod.allCases, id: \.self) { method in
|
|
Button(method.rawValue) { receipt.paymentMethod = method }
|
|
}
|
|
} label: {
|
|
menuLabel(receipt.paymentMethod?.rawValue ?? "Keine Angabe")
|
|
}
|
|
}
|
|
}
|
|
.zenithCard()
|
|
|
|
// MARK: Positionen
|
|
VStack(alignment: .leading, spacing: ZenithSpacing.sm) {
|
|
Text("Positionen").zenithLabelCapsStyle()
|
|
|
|
ForEach(receipt.lineItems.indices, id: \.self) { index in
|
|
if index > 0 { ZenithDivider() }
|
|
HStack(spacing: ZenithSpacing.xs) {
|
|
TextField("Beschreibung", text: $receipt.lineItems[index].description)
|
|
.font(.zenithBodyMd)
|
|
.foregroundStyle(.zenithText)
|
|
Spacer(minLength: ZenithSpacing.unit)
|
|
TextField("Preis", value: $receipt.lineItems[index].price, format: .number)
|
|
.keyboardType(.decimalPad)
|
|
.multilineTextAlignment(.trailing)
|
|
.frame(width: 72)
|
|
.zenithLabelMdStyle()
|
|
Button {
|
|
receipt.lineItems.remove(at: index)
|
|
} label: {
|
|
Image(systemName: "xmark.circle")
|
|
.foregroundStyle(.zenithSubtle)
|
|
}
|
|
}
|
|
.padding(.vertical, ZenithSpacing.unit)
|
|
}
|
|
|
|
Button {
|
|
receipt.lineItems.append(Receipt.LineItem(description: "", quantity: 1, price: 0))
|
|
} label: {
|
|
Label("Position hinzufügen", systemImage: "plus")
|
|
}
|
|
.buttonStyle(.zenithPlain)
|
|
.padding(.top, ZenithSpacing.unit)
|
|
}
|
|
.zenithCard()
|
|
|
|
// MARK: Save
|
|
|
|
Button {
|
|
Task { await save() }
|
|
} label: {
|
|
if isSaving {
|
|
ProgressView()
|
|
.tint(.white)
|
|
} else {
|
|
Text("Speichern")
|
|
}
|
|
}
|
|
.buttonStyle(.zenithPrimary)
|
|
.disabled(isSaving)
|
|
|
|
if let saveErrorMessage {
|
|
Text(saveErrorMessage)
|
|
.zenithBodySmStyle(color: .zenithError)
|
|
}
|
|
}
|
|
.padding(ZenithSpacing.sm)
|
|
}
|
|
}
|
|
.navigationTitle("Beleg prüfen")
|
|
.navigationBarTitleDisplayMode(.inline)
|
|
}
|
|
|
|
// MARK: - Confidence indicator
|
|
|
|
/// Small colored dot + percentage, shown only for fields the backend
|
|
/// flagged as low-confidence (< 70%) — mirrors what the web dashboard
|
|
/// highlights for manual review.
|
|
@ViewBuilder
|
|
private func confidenceIndicator(_ confidence: Double) -> some View {
|
|
if confidence < 0.7 {
|
|
HStack(spacing: 4) {
|
|
Circle()
|
|
.fill(Color.zenithPendingDot)
|
|
.frame(width: 8, height: 8)
|
|
Text("\(Int((confidence * 100).rounded()))%")
|
|
.zenithLabelMdStyle(color: .zenithPendingText)
|
|
}
|
|
.padding(.bottom, ZenithSpacing.unit * 2)
|
|
}
|
|
}
|
|
|
|
/// Text + chevron shown as a `Menu`'s label, styled to match
|
|
/// `ZenithTextField`'s body text instead of the system default.
|
|
private func menuLabel(_ text: String) -> some View {
|
|
HStack {
|
|
Text(text).zenithBodyStyle()
|
|
Spacer()
|
|
Image(systemName: "chevron.up.chevron.down")
|
|
.font(.system(size: 11, weight: .medium))
|
|
.foregroundStyle(.zenithSubtle)
|
|
}
|
|
}
|
|
|
|
// MARK: - Date <-> String("yyyy-MM-dd") binding
|
|
|
|
private static let isoDateFormatter: DateFormatter = {
|
|
let formatter = DateFormatter()
|
|
formatter.calendar = Calendar(identifier: .gregorian)
|
|
formatter.locale = Locale(identifier: "en_US_POSIX")
|
|
formatter.timeZone = TimeZone(identifier: "UTC")
|
|
formatter.dateFormat = "yyyy-MM-dd"
|
|
return formatter
|
|
}()
|
|
|
|
private var dateBinding: Binding<Date> {
|
|
Binding<Date>(
|
|
get: { Self.isoDateFormatter.date(from: receipt.date.isoDate) ?? Date() },
|
|
set: { newValue in receipt.date.isoDate = Self.isoDateFormatter.string(from: newValue) }
|
|
)
|
|
}
|
|
|
|
// MARK: - Total amount Double <-> Double? binding (for ZenithAmountField)
|
|
|
|
private var totalAmountBinding: Binding<Double?> {
|
|
Binding<Double?>(
|
|
get: { receipt.totalAmount.value },
|
|
set: { newValue in
|
|
if let newValue { receipt.totalAmount.value = newValue }
|
|
}
|
|
)
|
|
}
|
|
|
|
// MARK: - Save
|
|
|
|
private func save() async {
|
|
isSaving = true
|
|
saveErrorMessage = nil
|
|
defer { isSaving = false }
|
|
|
|
do {
|
|
try await ReceiptsAPI.sync([receipt])
|
|
dismiss()
|
|
} catch let error as APIError {
|
|
saveErrorMessage = error.localizedDescription
|
|
} catch {
|
|
saveErrorMessage = error.localizedDescription
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Local layout helper: reproduces `ZenithTextField`'s "label-caps text
|
|
/// above a hairline-bottom-border field" shell for non-`TextField` controls
|
|
/// (`DatePicker`, the category/payment `Menu`s) so they read as part of the
|
|
/// same field system instead of falling back to stock iOS control chrome.
|
|
private struct LabeledFieldShell<Content: View>: View {
|
|
let label: String
|
|
let content: Content
|
|
|
|
init(label: String, @ViewBuilder content: () -> Content) {
|
|
self.label = label
|
|
self.content = content()
|
|
}
|
|
|
|
var body: some View {
|
|
VStack(alignment: .leading, spacing: ZenithSpacing.unit) {
|
|
Text(label).zenithLabelCapsStyle()
|
|
|
|
content
|
|
.padding(.vertical, ZenithSpacing.unit * 2)
|
|
.overlay(alignment: .bottom) {
|
|
Rectangle()
|
|
.fill(Color.zenithBorder)
|
|
.frame(height: 1)
|
|
}
|
|
}
|
|
}
|
|
}
|