Files
bulkhealth/BulkHealthApp/FieldMappingViews.swift
2026-03-01 11:43:38 +01:00

457 lines
13 KiB
Swift

import SwiftUI
struct FieldMappingListView: View {
@ObservedObject var viewModel: ImportFlowViewModel
@State private var fieldStates: [String: ImportFlowViewModel.FieldMappingState] = [:]
var body: some View {
List {
ForEach(viewModel.template.fields) { field in
NavigationLink {
FieldMappingDetailView(viewModel: viewModel, field: field)
} label: {
let state = fieldStates[field.id] ?? .incomplete
HStack {
VStack(alignment: .leading, spacing: 4) {
Text(field.title)
.font(.headline)
Text(field.targetFormat)
.font(.caption)
.foregroundStyle(.secondary)
}
Spacer()
Text(fieldStateText(state))
.font(.caption2)
.foregroundStyle(color(for: state))
}
}
}
}
.navigationTitle(String(localized: "screen.title.field_mapping"))
.onAppear {
refreshFieldStates()
}
.onChange(of: viewModel.entries.count) { _, _ in
refreshFieldStates()
}
.onChange(of: viewModel.mapping) { _, _ in
refreshFieldStates()
}
.onChange(of: viewModel.fieldFormatters) { _, _ in
refreshFieldStates()
}
.onChange(of: viewModel.moodValenceScale) { _, _ in
refreshFieldStates()
}
}
private func color(for state: ImportFlowViewModel.FieldMappingState) -> Color {
switch state {
case .incomplete: return .secondary
case .partiallyValid: return .yellow
case .valid: return .green
}
}
private func fieldStateText(_ state: ImportFlowViewModel.FieldMappingState) -> String {
switch state {
case .incomplete:
return String(localized: "state.incomplete")
case .partiallyValid:
return String(localized: "state.partial")
case .valid:
return String(localized: "state.valid")
}
}
private func refreshFieldStates() {
var next: [String: ImportFlowViewModel.FieldMappingState] = [:]
for field in viewModel.template.fields {
next[field.id] = viewModel.fieldState(for: field)
}
fieldStates = next
}
}
struct FieldMappingDetailView: View {
@ObservedObject var viewModel: ImportFlowViewModel
let field: TemplateField
var body: some View {
Form {
targetSection
sourceSection
formatterSection
}
.navigationTitle(field.title)
.navigationBarTitleDisplayMode(.inline)
}
private var targetSection: some View {
Section(
header: Text(String(localized: "section.target")),
footer: Text(field.id == "date" ? String(localized: "field.date.helper") : "")
) {
Text(
field.targetExplanation + " " + String.localizedStringWithFormat(
NSLocalizedString("label.example_value", comment: ""),
field.targetExample
)
)
if let allowed = field.targetAllowedValues, !allowed.isEmpty {
Text(allowed)
.font(.caption2)
.foregroundStyle(.secondary)
}
}
}
private var sourceSection: some View {
Section(header: Text(String(localized: "section.mapping"))) {
if viewModel.sourceKeys.isEmpty {
Text(String(localized: "text.mapping.unavailable"))
.foregroundStyle(.secondary)
} else {
ForEach(viewModel.sourceKeys, id: \.self) { sourceKey in
Button {
viewModel.mapping.sourceKeyByFieldID[field.id] = sourceKey
} label: {
VStack(alignment: .leading, spacing: 4) {
HStack {
Text(sourceKey)
Spacer()
if viewModel.mappedSourceKey(for: field.id) == sourceKey {
Image(systemName: "checkmark")
.foregroundStyle(.green)
}
}
Text(viewModel.sourceExample(for: sourceKey))
.font(.caption2)
.foregroundStyle(.secondary)
.lineLimit(1)
}
.frame(maxWidth: .infinity, alignment: .leading)
.contentShape(Rectangle())
}
.buttonStyle(.plain)
}
}
}
}
private var formatterSection: some View {
Section(header: Text(String(localized: "section.formatting"))) {
NavigationLink(String(localized: "button.select_formatter")) {
FormatterSelectionView(viewModel: viewModel, field: field)
}
Text(String.localizedStringWithFormat(
NSLocalizedString("label.formatter_coverage", comment: ""),
viewModel.formattingPreview(for: field).matched,
viewModel.formattingPreview(for: field).total
))
.foregroundStyle(viewModel.formattingPreview(for: field).isPartial ? .yellow : .secondary)
}
}
}
struct FormatterSelectionView: View {
@ObservedObject var viewModel: ImportFlowViewModel
let field: TemplateField
@State private var lookupDraft: [String: String] = [:]
@State private var matchSummaryCache: [FieldFormatterType: (matched: Int, total: Int)] = [:]
var body: some View {
let formatterOptions = viewModel.formatterOptions(for: field)
let selectedFormatter = viewModel.formatter(for: field.id)
let selectedFormatterType = selectedFormatter.formatterType
let previewRows = selectedFormatterType == .integerLookupToNumber ? [] : viewModel.previewRows(for: field, limit: 5)
let previewMatchedCount = previewRows.filter { $0.valid }.count
let previewCoverageColor: Color = previewRows.isEmpty ? .secondary : (previewMatchedCount == previewRows.count ? .green : .yellow)
let sourceHistogram = selectedFormatterType == .integerLookupToNumber ? viewModel.sourceValueHistogram(for: field) : []
let lookupCoverage = lookupCoverage(from: sourceHistogram)
Form {
Section(header: Text(String(localized: "section.formatting"))) {
ForEach(formatterOptions) { formatterType in
Button {
if selectedFormatterType == .integerLookupToNumber {
syncLookupDraftToModel()
}
viewModel.updateFormatter(for: field.id) { $0.formatterType = formatterType }
if formatterType == .integerLookupToNumber {
loadLookupDraftFromModel()
}
} label: {
HStack {
VStack(alignment: .leading, spacing: 4) {
Text(viewModel.formatterDisplayName(formatterType))
Text(String.localizedStringWithFormat(
NSLocalizedString("label.formatter_matches", comment: ""),
matchSummaryCache[formatterType]?.matched ?? 0,
matchSummaryCache[formatterType]?.total ?? 0
))
.font(.caption2)
.foregroundStyle(matchColor(for: formatterType, summary: matchSummaryCache))
}
Spacer()
if selectedFormatterType == formatterType {
Image(systemName: "checkmark")
.foregroundStyle(.green)
}
}
.frame(maxWidth: .infinity, alignment: .leading)
.contentShape(Rectangle())
}
.buttonStyle(.plain)
}
}
if selectedFormatterType == .integerLookupToNumber {
Section(
header: Text(String(localized: "section.value_mapping")),
footer: Text(String(localized: "text.value_mapping_footer"))
) {
if sourceHistogram.isEmpty {
Text(String(localized: "text.no_source_values"))
.foregroundStyle(.secondary)
} else {
ForEach(sourceHistogram, id: \.value) { item in
HStack(spacing: 12) {
Text(item.value)
.frame(width: 44, alignment: .leading)
Text("x\(item.count)")
.font(.caption)
.foregroundStyle(.secondary)
Spacer()
TextField(
String(localized: "placeholder.target_number"),
text: bindingForMappedValue(item.value)
)
.multilineTextAlignment(.trailing)
.keyboardType(.default)
.frame(maxWidth: 120)
statusIcon(for: item.value)
}
}
Text(String.localizedStringWithFormat(
NSLocalizedString("label.lookup_distinct_progress", comment: ""),
lookupCoverage.mappedDistinct,
lookupCoverage.totalDistinct
))
.font(.caption2)
.foregroundStyle(.secondary)
Text(String.localizedStringWithFormat(
NSLocalizedString("label.lookup_rows_progress", comment: ""),
lookupCoverage.mappedRows,
lookupCoverage.totalRows
))
.font(.caption2)
.foregroundStyle(.secondary)
}
}
} else {
Section(header: Text(String(localized: "section.test_area"))) {
Text(String.localizedStringWithFormat(
NSLocalizedString("label.formatter_matches", comment: ""),
previewMatchedCount,
previewRows.count
))
.font(.caption)
.foregroundStyle(previewCoverageColor)
if previewRows.isEmpty {
Text(String(localized: "text.no_source_values"))
.foregroundStyle(.secondary)
} else {
ForEach(Array(previewRows.enumerated()), id: \.offset) { _, row in
VStack(alignment: .leading, spacing: 4) {
Text(row.input)
.font(.caption)
Text(row.output.isEmpty ? String(localized: "text.formatter_empty") : row.output)
.font(.caption2)
.foregroundStyle(row.valid ? .green : .red)
}
}
}
}
}
}
.navigationTitle(String(localized: "screen.title.formatter"))
.navigationBarTitleDisplayMode(.inline)
.onAppear {
refreshMatchSummary()
if selectedFormatterType == .integerLookupToNumber {
loadLookupDraftFromModel()
}
}
.onDisappear {
syncLookupDraftToModel()
}
}
private func matchColor(
for formatterType: FieldFormatterType,
summary: [FieldFormatterType: (matched: Int, total: Int)]
) -> Color {
let item = summary[formatterType] ?? (0, 0)
return item.matched == item.total && item.total > 0 ? .green : .secondary
}
@ViewBuilder
private func statusIcon(for sourceValue: String) -> some View {
let mapped = lookupDraft[sourceValue]?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
if mapped.isEmpty {
Image(systemName: "circle")
.foregroundStyle(.secondary)
} else if viewModel.isValidTargetValue(for: field, value: mapped) {
Image(systemName: "checkmark.circle.fill")
.foregroundStyle(.green)
} else {
Image(systemName: "exclamationmark.circle.fill")
.foregroundStyle(.yellow)
}
}
private func bindingForMappedValue(_ sourceValue: String) -> Binding<String> {
Binding(
get: {
lookupDraft[sourceValue, default: ""]
},
set: { newValue in
lookupDraft[sourceValue] = newValue
}
)
}
private func loadLookupDraftFromModel() {
lookupDraft = viewModel.formatter(for: field.id).integerLookup
}
private func syncLookupDraftToModel() {
viewModel.updateFormatter(for: field.id) { config in
var cleaned: [String: String] = [:]
for (key, value) in lookupDraft {
let trimmed = value.trimmingCharacters(in: .whitespacesAndNewlines)
if !trimmed.isEmpty {
cleaned[key] = trimmed
}
}
config.integerLookup = cleaned
}
refreshMatchSummary()
}
private func lookupCoverage(from histogram: [(value: String, count: Int)]) -> (mappedDistinct: Int, totalDistinct: Int, mappedRows: Int, totalRows: Int) {
let totalRows = histogram.reduce(0) { $0 + $1.count }
guard !histogram.isEmpty else {
return (0, 0, 0, 0)
}
var mappedDistinct = 0
var mappedRows = 0
for item in histogram {
let mapped = lookupDraft[item.value]?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
guard !mapped.isEmpty else {
continue
}
if viewModel.isValidTargetValue(for: field, value: mapped) {
mappedDistinct += 1
mappedRows += item.count
}
}
return (mappedDistinct, histogram.count, mappedRows, totalRows)
}
private func refreshMatchSummary() {
var cache: [FieldFormatterType: (matched: Int, total: Int)] = [:]
for formatterType in viewModel.formatterOptions(for: field) {
cache[formatterType] = viewModel.formatterMatchSummary(for: field, formatterType: formatterType)
}
matchSummaryCache = cache
}
}
struct ImportPlanView: View {
@ObservedObject var viewModel: ImportFlowViewModel
var body: some View {
Form {
Section(String(localized: "section.plan_summary")) {
let summary = viewModel.mappingSummary()
Text(String.localizedStringWithFormat(
NSLocalizedString("label.fields_ready", comment: ""),
summary.validFields,
summary.totalFields
))
Text(String.localizedStringWithFormat(
NSLocalizedString("label.rows_ready", comment: ""),
summary.validRows,
summary.totalRows
))
}
Section(String(localized: "section.template")) {
ForEach(viewModel.template.fields) { field in
VStack(alignment: .leading, spacing: 4) {
Text(field.title)
.font(.headline)
Text(String.localizedStringWithFormat(
NSLocalizedString("label.source_for_field", comment: ""),
viewModel.mappedSourceKey(for: field.id)
))
.font(.caption)
.foregroundStyle(.secondary)
}
}
}
Section(String(localized: "section.commit")) {
Button(String(localized: "button.run_dry_run")) {
viewModel.runDryRun()
}
if let result = viewModel.dryRunResult {
Text(String.localizedStringWithFormat(
NSLocalizedString("label.preview_count", comment: ""),
result.drafts.count
))
if !result.errors.isEmpty {
Text(String.localizedStringWithFormat(
NSLocalizedString("label.errors_count", comment: ""),
result.errors.count
))
.foregroundStyle(.red)
}
if !result.warnings.isEmpty {
Text(String.localizedStringWithFormat(
NSLocalizedString("label.warnings_count", comment: ""),
result.warnings.count
))
.foregroundStyle(.orange)
}
}
Button {
Task {
await viewModel.commitImport()
}
} label: {
if viewModel.isImporting {
ProgressView()
} else {
Text(String(localized: "button.commit"))
}
}
.disabled(viewModel.isImporting)
}
}
.navigationTitle(String(localized: "screen.title.import_plan"))
.navigationBarTitleDisplayMode(.inline)
}
}