import SwiftUI private struct ImportRemediationDraft: Identifiable { let batchId: String let proposal: VelocityImportProposalDTO let workbenchRow: VelocityImportWorkbenchRowDTO? let fields: [String] var id: String { proposal.proposalId } init(batchId: String, proposal: VelocityImportProposalDTO, workbenchRow: VelocityImportWorkbenchRowDTO?) { self.batchId = batchId self.proposal = proposal self.workbenchRow = workbenchRow let canonicalFields: [String] = proposal.payload?.canonicalPayload.map { Array($0.keys) } ?? [] let missingFields = proposal.payload?.missingRequired ?? [] let unresolvedFields = proposal.payload?.unresolvedFields ?? [] let diffFields = workbenchRow?.fieldDiffs.map(\.field) ?? [] let validationFields = workbenchRow?.validation.map(\.field) ?? [] let combinedFields: [String] = canonicalFields + missingFields + unresolvedFields + diffFields + validationFields fields = Array(Set(combinedFields)).sorted() } } struct ImportsView: View { @State private var appStore = AppStore.shared @State private var batches: [VelocityImportBatchSummaryDTO] = [] @State private var selectedBatch: VelocityImportBatchSummaryDTO? @State private var detail: VelocityImportBatchDetailDTO? @State private var workbench: VelocityImportWorkbenchDTO? @State private var isLoading = false @State private var isCommitting = false @State private var activeProposalID: String? @State private var errorMessage: String? @State private var successMessage: String? @State private var remediationDraft: ImportRemediationDraft? var body: some View { HStack(spacing: 0) { batchRail .frame(width: 350) .background(VelocityTheme.sidebarBg) Divider() .background(VelocityTheme.borderSubtle) detailPane } .background(VelocityTheme.background) .task { await appStore.ensureCRMVocabulariesLoaded() await loadBatches(selectFirst: true) } .refreshable { await loadBatches(selectFirst: false) } .sheet(item: $remediationDraft) { draft in ImportRemediationSheet( draft: draft, duplicatePolicies: appStore.crmVocabularies.importDuplicatePolicies ) { decision, notes, fieldOverrides, duplicatePolicy in Task { await reviewProposal( batchId: draft.batchId, proposal: draft.proposal, decision: decision, notes: notes, fieldOverrides: fieldOverrides, duplicatePolicy: duplicatePolicy ) } } } } private var batchRail: some View { VStack(alignment: .leading, spacing: 16) { VStack(alignment: .leading, spacing: 4) { HStack { Text("Imports") .font(.system(size: 26, weight: .bold)) .foregroundStyle(VelocityTheme.foreground) Spacer() } Text("Read-only canonical CRM import review and remediation queue.") .font(.system(size: 12)) .foregroundStyle(VelocityTheme.mutedFg) } .padding(.horizontal, 18) .padding(.top, 22) if let errorMessage { errorBanner(errorMessage) .padding(.horizontal, 18) } if let successMessage { successBanner(successMessage) .padding(.horizontal, 18) } ScrollView { LazyVStack(spacing: 10) { if isLoading && batches.isEmpty { loadingCard("Loading import batches...") } else if batches.isEmpty { emptyCard("No canonical import batches were returned yet.") } else { ForEach(batches) { batch in batchCard(batch) } } } .padding(.horizontal, 18) .padding(.bottom, 18) } } } private var detailPane: some View { ScrollView { VStack(alignment: .leading, spacing: 18) { if let detail { detailHeader(detail) if let workbench { workbenchPanel(workbench) } proposalsPanel(detail) } else if isLoading { loadingCard("Loading import detail...") } else { emptyCard("Select an import batch to review canonical proposals.") } } .padding(24) } .background(VelocityTheme.background) } private func batchCard(_ batch: VelocityImportBatchSummaryDTO) -> some View { Button { Task { await selectBatch(batch) } } label: { VStack(alignment: .leading, spacing: 8) { HStack { Text(batch.displayName) .font(.system(size: 14, weight: .semibold)) .foregroundStyle(VelocityTheme.foreground) .lineLimit(1) Spacer() Text(batch.lifecycleLabel) .font(.system(size: 10, weight: .semibold)) .foregroundStyle(lifecycleColor(batch.lifecycle)) } Text("\(batch.rowCount) rows · \(batch.mappedCount ?? 0) mapped · \(batch.unresolvedCount ?? 0) unresolved") .font(.system(size: 11)) .foregroundStyle(VelocityTheme.mutedFg) Text(batch.sourceSystem ?? "Unknown source") .font(.system(size: 11)) .foregroundStyle(VelocityTheme.mutedFg) } .padding(14) .frame(maxWidth: .infinity, alignment: .leading) .background( RoundedRectangle(cornerRadius: 16) .fill(selectedBatch?.batchId == batch.batchId ? VelocityTheme.accent.opacity(0.14) : VelocityTheme.surface) .overlay( RoundedRectangle(cornerRadius: 16) .stroke(selectedBatch?.batchId == batch.batchId ? VelocityTheme.borderAccent : VelocityTheme.borderSubtle, lineWidth: 1) ) ) } .buttonStyle(.plain) } private func detailHeader(_ detail: VelocityImportBatchDetailDTO) -> some View { VStack(alignment: .leading, spacing: 14) { HStack(alignment: .top) { VStack(alignment: .leading, spacing: 4) { Text(detail.filename ?? "CRM import") .font(.system(size: 24, weight: .bold)) .foregroundStyle(VelocityTheme.foreground) Text("\(detail.rowCount) rows · \(detail.proposalCount) proposals · \(detail.sourceSystem ?? "Unknown source")") .font(.system(size: 12)) .foregroundStyle(VelocityTheme.mutedFg) } Spacer() Text(detail.lifecycle.replacingOccurrences(of: "_", with: " ").capitalized) .font(.system(size: 11, weight: .semibold)) .foregroundStyle(lifecycleColor(detail.lifecycle)) .padding(.horizontal, 10) .padding(.vertical, 6) .background(Capsule().fill(lifecycleColor(detail.lifecycle).opacity(0.12))) } HStack(spacing: 12) { metricCard("Pending", value: "\(detail.proposals.filter { $0.status == "pending" }.count)", color: VelocityTheme.warning) metricCard("Approved", value: "\(detail.proposals.filter { $0.status == "approved" }.count)", color: VelocityTheme.success) metricCard("Rejected", value: "\(detail.proposals.filter { $0.status == "rejected" }.count)", color: VelocityTheme.danger) } Button { Task { await commitSelectedBatch() } } label: { HStack { if isCommitting { ProgressView() .progressViewStyle(CircularProgressViewStyle(tint: .white)) } Text(isCommitting ? "Committing..." : "Commit Approved Proposals") .font(.system(size: 13, weight: .semibold)) } .foregroundStyle(.white) .padding(.horizontal, 14) .padding(.vertical, 10) .background( Capsule() .fill(approvedCount(detail) > 0 && !isCommitting ? VelocityTheme.success : VelocityTheme.subtleFg) ) } .buttonStyle(.plain) .disabled(approvedCount(detail) == 0 || isCommitting) } .padding(18) .glassCard(cornerRadius: 20) } private func proposalsPanel(_ detail: VelocityImportBatchDetailDTO) -> some View { VStack(alignment: .leading, spacing: 12) { Text("Review Proposals") .font(.system(size: 17, weight: .semibold)) .foregroundStyle(VelocityTheme.foreground) if detail.proposals.isEmpty { emptyCard("No proposals were returned for this import batch.") } else { ForEach(detail.proposals) { proposal in proposalCard(proposal, batchId: detail.batchId) } } } .padding(18) .glassCard(cornerRadius: 20) } private func workbenchPanel(_ workbench: VelocityImportWorkbenchDTO) -> some View { VStack(alignment: .leading, spacing: 14) { HStack { VStack(alignment: .leading, spacing: 4) { Text("Remediation Workbench") .font(.system(size: 17, weight: .semibold)) .foregroundStyle(VelocityTheme.foreground) Text("Validation, duplicate detection, and canonical CRM row diffs before commit.") .font(.system(size: 12)) .foregroundStyle(VelocityTheme.mutedFg) } Spacer() Button { Task { if let batchId = detail?.batchId { await refreshWorkbench(batchId: batchId) } } } label: { Image(systemName: "arrow.clockwise") } .buttonStyle(.plain) .foregroundStyle(VelocityTheme.accent) } HStack(spacing: 12) { metricCard("Rows", value: "\(workbench.summary.proposalCount)", color: VelocityTheme.accent) metricCard("Duplicates", value: "\(workbench.summary.duplicateCount)", color: VelocityTheme.warning) metricCard("Errors", value: "\(workbench.summary.validationErrorCount)", color: VelocityTheme.danger) metricCard("Warnings", value: "\(workbench.summary.validationWarningCount)", color: VelocityTheme.warning) } LazyVStack(spacing: 10) { ForEach(workbench.rows.prefix(20)) { row in workbenchRowCard(row) } } } .padding(18) .glassCard(cornerRadius: 20) } private func workbenchRowCard(_ row: VelocityImportWorkbenchRowDTO) -> some View { VStack(alignment: .leading, spacing: 10) { HStack { Text(row.rowNumber.map { "Row \($0)" } ?? "Proposal \(row.proposalId.prefix(8))") .font(.system(size: 13, weight: .semibold)) .foregroundStyle(VelocityTheme.foreground) Spacer() Text("\(confidencePercent(row.confidence))%") .font(.system(size: 11, weight: .bold)) .foregroundStyle(VelocityTheme.accent) Text(row.status.replacingOccurrences(of: "_", with: " ").capitalized) .font(.system(size: 10, weight: .semibold)) .foregroundStyle(lifecycleColor(row.status)) } if !row.validation.isEmpty { VStack(alignment: .leading, spacing: 5) { ForEach(row.validation) { issue in HStack(alignment: .top, spacing: 6) { Image(systemName: issue.severity == "error" ? "xmark.octagon" : "exclamationmark.triangle") .font(.system(size: 11, weight: .semibold)) .foregroundStyle(issue.severity == "error" ? VelocityTheme.danger : VelocityTheme.warning) Text("\(issue.field): \(issue.message)") .font(.system(size: 11)) .foregroundStyle(VelocityTheme.mutedFg) } } } } if let duplicate = row.duplicateCandidates.first { Text("Duplicate candidate: \(duplicate.fullName) · \(duplicate.matchReason) match · \(duplicate.matchScore)%") .font(.system(size: 11, weight: .semibold)) .foregroundStyle(VelocityTheme.warning) } let changedDiffs = row.fieldDiffs.filter(\.changed) if !changedDiffs.isEmpty { VStack(alignment: .leading, spacing: 4) { Text("Changed fields") .font(.system(size: 10, weight: .semibold)) .tracking(1) .foregroundStyle(VelocityTheme.mutedFg) ForEach(changedDiffs.prefix(4)) { diff in Text("\(diff.field): \(diff.existing ?? "-") → \(diff.proposed ?? "-")") .font(.system(size: 11)) .foregroundStyle(VelocityTheme.foreground) .lineLimit(1) } } } } .padding(14) .frame(maxWidth: .infinity, alignment: .leading) .background( RoundedRectangle(cornerRadius: 16) .fill(VelocityTheme.surface) .overlay( RoundedRectangle(cornerRadius: 16) .stroke(row.validation.contains { $0.severity == "error" } ? VelocityTheme.danger.opacity(0.25) : VelocityTheme.borderSubtle, lineWidth: 1) ) ) } private func proposalCard(_ proposal: VelocityImportProposalDTO, batchId: String) -> some View { let rowDiagnostics = workbench?.row(for: proposal.proposalId) return VStack(alignment: .leading, spacing: 10) { HStack { VStack(alignment: .leading, spacing: 3) { Text(proposal.rowLabel) .font(.system(size: 14, weight: .semibold)) .foregroundStyle(VelocityTheme.foreground) Text("\(proposal.confidencePercent)% confidence · \(proposal.status.capitalized)") .font(.system(size: 11)) .foregroundStyle(VelocityTheme.mutedFg) } Spacer() if activeProposalID == proposal.proposalId { ProgressView() .progressViewStyle(CircularProgressViewStyle(tint: VelocityTheme.accent)) } else { proposalActions(proposal, batchId: batchId) } } if let canonical = proposal.payload?.canonicalPayload, !canonical.isEmpty { Text(canonicalPreview(canonical)) .font(.system(size: 12)) .foregroundStyle(VelocityTheme.foreground) .lineLimit(3) } if let missing = proposal.payload?.missingRequired, !missing.isEmpty { Text("Missing: \(missing.joined(separator: ", "))") .font(.system(size: 11)) .foregroundStyle(VelocityTheme.danger) } if let unresolved = proposal.payload?.unresolvedFields, !unresolved.isEmpty { Text("Needs review: \(unresolved.joined(separator: ", "))") .font(.system(size: 11)) .foregroundStyle(VelocityTheme.warning) } if let rowDiagnostics { proposalDiagnostics(rowDiagnostics) } } .padding(14) .background( RoundedRectangle(cornerRadius: 16) .fill(VelocityTheme.surface) .overlay( RoundedRectangle(cornerRadius: 16) .stroke(VelocityTheme.borderSubtle, lineWidth: 1) ) ) } private func proposalDiagnostics(_ row: VelocityImportWorkbenchRowDTO) -> some View { VStack(alignment: .leading, spacing: 5) { if !row.validation.isEmpty { Text("Validation: \(row.validation.map { "\($0.field) \($0.severity)" }.joined(separator: ", "))") .font(.system(size: 11)) .foregroundStyle(row.validation.contains { $0.severity == "error" } ? VelocityTheme.danger : VelocityTheme.warning) } if let duplicate = row.duplicateCandidates.first { Text("Possible duplicate: \(duplicate.fullName) (\(duplicate.matchReason), \(duplicate.matchScore)%)") .font(.system(size: 11)) .foregroundStyle(VelocityTheme.warning) } } } private func proposalActions(_ proposal: VelocityImportProposalDTO, batchId: String) -> some View { HStack(spacing: 8) { Button("Approve") { Task { await reviewProposal( batchId: batchId, proposal: proposal, decision: "approved", duplicatePolicy: defaultDuplicatePolicyValue(for: proposal) ) } } .font(.system(size: 11, weight: .semibold)) .foregroundStyle(VelocityTheme.success) .padding(.horizontal, 10) .padding(.vertical, 8) .contentShape(Rectangle()) .disabled(proposal.status.lowercased() == "approved" || defaultDuplicatePolicyValue(for: proposal) == nil) Button("Reject") { Task { await reviewProposal( batchId: batchId, proposal: proposal, decision: "rejected", duplicatePolicy: defaultDuplicatePolicyValue(for: proposal) ) } } .font(.system(size: 11, weight: .semibold)) .foregroundStyle(VelocityTheme.danger) .padding(.horizontal, 10) .padding(.vertical, 8) .contentShape(Rectangle()) .disabled(proposal.status.lowercased() == "rejected") Button("Needs Info") { Task { await reviewProposal( batchId: batchId, proposal: proposal, decision: "needs_more_info", duplicatePolicy: defaultDuplicatePolicyValue(for: proposal) ) } } .font(.system(size: 11, weight: .semibold)) .foregroundStyle(VelocityTheme.warning) .padding(.horizontal, 10) .padding(.vertical, 8) .contentShape(Rectangle()) Button("Remediate") { remediationDraft = ImportRemediationDraft( batchId: batchId, proposal: proposal, workbenchRow: workbench?.row(for: proposal.proposalId) ) } .font(.system(size: 11, weight: .semibold)) .foregroundStyle(VelocityTheme.accent) .padding(.horizontal, 10) .padding(.vertical, 8) .contentShape(Rectangle()) } } private func defaultDuplicatePolicyValue(for proposal: VelocityImportProposalDTO) -> String? { if let policy = workbench?.rows.first(where: { $0.proposalId == proposal.proposalId })?.duplicatePolicy, appStore.crmVocabularies.importDuplicatePolicies.contains(where: { $0.value == policy }) { return policy } return appStore.crmVocabularies.importDuplicatePolicies.first?.value } private func loadBatches(selectFirst: Bool, silent: Bool = false) async { if !silent { isLoading = true } do { let fetched = try await VelocityAPIClient.shared.fetchImportBatches() await MainActor.run { batches = fetched errorMessage = nil isLoading = false } if selectFirst, selectedBatch == nil, let first = fetched.first { await selectBatch(first) } else if let selectedBatch { await refreshDetail(batchId: selectedBatch.batchId, silent: true) } } catch { await MainActor.run { errorMessage = error.localizedDescription isLoading = false } } } private func selectBatch(_ batch: VelocityImportBatchSummaryDTO) async { await MainActor.run { selectedBatch = batch detail = nil workbench = nil errorMessage = nil successMessage = nil isLoading = true } await refreshDetail(batchId: batch.batchId) } private func refreshDetail(batchId: String, silent: Bool = false) async { if !silent { await MainActor.run { isLoading = true } } do { async let detailTask = VelocityAPIClient.shared.fetchImportBatch(batchId: batchId) async let workbenchTask = VelocityAPIClient.shared.fetchImportWorkbench(batchId: batchId) let fetched = try await detailTask let fetchedWorkbench = try? await workbenchTask await MainActor.run { detail = fetched workbench = fetchedWorkbench errorMessage = nil isLoading = false } } catch { await MainActor.run { errorMessage = error.localizedDescription isLoading = false } } } private func refreshWorkbench(batchId: String) async { do { let fetchedWorkbench = try await VelocityAPIClient.shared.fetchImportWorkbench(batchId: batchId) await MainActor.run { workbench = fetchedWorkbench errorMessage = nil } } catch { await MainActor.run { errorMessage = error.localizedDescription } } } private func reviewProposal( batchId: String, proposal: VelocityImportProposalDTO, decision: String, notes: String = "Reviewed from iPad Imports workspace.", fieldOverrides: [String: String] = [:], duplicatePolicy: String? ) async { guard let duplicatePolicy else { await MainActor.run { errorMessage = "Unable to review import row because backend duplicate policy vocabulary is unavailable." } return } await MainActor.run { activeProposalID = proposal.proposalId errorMessage = nil successMessage = nil } do { _ = try await VelocityAPIClient.shared.reviewImportProposal( batchId: batchId, proposalId: proposal.proposalId, decision: decision, notes: notes, fieldOverrides: fieldOverrides, duplicatePolicy: duplicatePolicy ) await refreshDetail(batchId: batchId, silent: true) await MainActor.run { activeProposalID = nil remediationDraft = nil successMessage = "Proposal \(decision.replacingOccurrences(of: "_", with: " "))." } } catch { await MainActor.run { activeProposalID = nil errorMessage = error.localizedDescription } } } private func commitSelectedBatch() async { guard let batchId = detail?.batchId else { return } await MainActor.run { isCommitting = true errorMessage = nil successMessage = nil } do { let result = try await VelocityAPIClient.shared.commitImportBatch(batchId: batchId) await loadBatches(selectFirst: false, silent: true) await refreshDetail(batchId: batchId, silent: true) await MainActor.run { isCommitting = false successMessage = "Committed \(result.committed), skipped \(result.skipped)." if !result.errors.isEmpty { errorMessage = result.errors.joined(separator: " · ") } } } catch { await MainActor.run { isCommitting = false errorMessage = error.localizedDescription } } } private func approvedCount(_ detail: VelocityImportBatchDetailDTO) -> Int { detail.proposals.filter { $0.status == "approved" }.count } private func confidencePercent(_ value: Double) -> Int { let normalized = value <= 1 ? value * 100 : value return max(0, min(100, Int(normalized.rounded()))) } private func canonicalPreview(_ payload: [String: JSONValue]) -> String { payload .sorted(by: { $0.key < $1.key }) .prefix(5) .map { "\($0.key): \($0.value.stringValue ?? "-")" } .joined(separator: " · ") } private func metricCard(_ label: String, value: String, color: Color) -> some View { VStack(alignment: .leading, spacing: 5) { Text(label.uppercased()) .font(.system(size: 9, weight: .semibold)) .tracking(1) .foregroundStyle(VelocityTheme.mutedFg) Text(value) .font(.system(size: 18, weight: .bold)) .foregroundStyle(VelocityTheme.foreground) RoundedRectangle(cornerRadius: 3) .fill(color) .frame(width: 34, height: 3) } .padding(12) .frame(maxWidth: .infinity, alignment: .leading) .background(RoundedRectangle(cornerRadius: 14).fill(VelocityTheme.surface)) } private func loadingCard(_ message: String) -> some View { HStack(spacing: 10) { ProgressView() .progressViewStyle(CircularProgressViewStyle(tint: VelocityTheme.accent)) Text(message) .font(.system(size: 13)) .foregroundStyle(VelocityTheme.mutedFg) } .padding(16) .frame(maxWidth: .infinity, alignment: .leading) .background(RoundedRectangle(cornerRadius: 16).fill(VelocityTheme.surface)) } private func emptyCard(_ message: String) -> some View { Text(message) .font(.system(size: 13)) .foregroundStyle(VelocityTheme.mutedFg) .padding(16) .frame(maxWidth: .infinity, alignment: .leading) .background(RoundedRectangle(cornerRadius: 16).fill(VelocityTheme.surface)) } private func errorBanner(_ message: String) -> some View { banner(message, color: VelocityTheme.danger) } private func successBanner(_ message: String) -> some View { banner(message, color: VelocityTheme.success) } private func banner(_ message: String, color: Color) -> some View { Text(message) .font(.system(size: 12, weight: .medium)) .foregroundStyle(color) .padding(12) .frame(maxWidth: .infinity, alignment: .leading) .background( RoundedRectangle(cornerRadius: 14) .fill(color.opacity(0.10)) .overlay(RoundedRectangle(cornerRadius: 14).stroke(color.opacity(0.22), lineWidth: 1)) ) } private func lifecycleColor(_ lifecycle: String) -> Color { switch lifecycle.lowercased() { case "committed": return VelocityTheme.success case "failed": return VelocityTheme.danger case "approved", "proposed", "parsed": return VelocityTheme.warning default: return VelocityTheme.accent } } } private struct ImportRemediationSheet: View { let draft: ImportRemediationDraft let duplicatePolicies: [VelocityVocabularyOptionDTO] let onSubmit: (String, String, [String: String], String) -> Void @Environment(\.dismiss) private var dismiss @State private var notes: String @State private var fieldOverrides: [String: String] @State private var duplicatePolicy: String init( draft: ImportRemediationDraft, duplicatePolicies: [VelocityVocabularyOptionDTO], onSubmit: @escaping (String, String, [String: String], String) -> Void ) { self.draft = draft self.duplicatePolicies = duplicatePolicies self.onSubmit = onSubmit _notes = State(initialValue: "") let canonicalPayload = draft.proposal.payload?.canonicalPayload ?? [:] let initialOverrides: [String: String] = Dictionary( uniqueKeysWithValues: draft.fields.map { field in (field, canonicalPayload[field]?.stringValue ?? "") } ) _fieldOverrides = State(initialValue: initialOverrides) let initialPolicy = duplicatePolicies.first(where: { $0.value == draft.workbenchRow?.duplicatePolicy })?.value ?? duplicatePolicies.first?.value ?? "" _duplicatePolicy = State(initialValue: initialPolicy) } var body: some View { NavigationStack { ScrollView { VStack(alignment: .leading, spacing: 18) { VStack(alignment: .leading, spacing: 6) { Text(draft.proposal.rowLabel) .font(.system(size: 24, weight: .bold)) .foregroundStyle(VelocityTheme.foreground) Text("\(draft.proposal.confidencePercent)% confidence · \(draft.proposal.status.replacingOccurrences(of: "_", with: " ").capitalized)") .font(.system(size: 13)) .foregroundStyle(VelocityTheme.mutedFg) } if let workbenchRow = draft.workbenchRow { remediationDiagnostics(workbenchRow) duplicatePolicyPicker(workbenchRow) } if draft.fields.isEmpty { Text("No canonical fields were returned for this proposal.") .font(.system(size: 13)) .foregroundStyle(VelocityTheme.mutedFg) .padding(14) .frame(maxWidth: .infinity, alignment: .leading) .background(RoundedRectangle(cornerRadius: 14).fill(VelocityTheme.surface)) } else { VStack(alignment: .leading, spacing: 12) { Text("Field Corrections") .font(.system(size: 15, weight: .semibold)) .foregroundStyle(VelocityTheme.foreground) ForEach(draft.fields, id: \.self) { field in VStack(alignment: .leading, spacing: 7) { Text(field.replacingOccurrences(of: "_", with: " ").uppercased()) .font(.system(size: 10, weight: .semibold)) .tracking(1.2) .foregroundStyle(VelocityTheme.mutedFg) TextField(field, text: Binding( get: { fieldOverrides[field] ?? "" }, set: { fieldOverrides[field] = $0 } )) .textFieldStyle(.plain) .font(.system(size: 14)) .foregroundStyle(VelocityTheme.foreground) .padding(12) .background(RoundedRectangle(cornerRadius: 12).fill(VelocityTheme.surface)) } } } } VStack(alignment: .leading, spacing: 7) { Text("Review Notes") .font(.system(size: 10, weight: .semibold)) .tracking(1.2) .foregroundStyle(VelocityTheme.mutedFg) TextEditor(text: $notes) .frame(minHeight: 90) .font(.system(size: 14)) .foregroundStyle(VelocityTheme.foreground) .scrollContentBackground(.hidden) .padding(10) .background(RoundedRectangle(cornerRadius: 12).fill(VelocityTheme.surface)) } } .padding(24) } .background(VelocityTheme.background) .toolbar { ToolbarItem(placement: .cancellationAction) { Button("Cancel") { dismiss() } } ToolbarItemGroup(placement: .confirmationAction) { Button("Needs Info") { submit("needs_more_info") } Button("Approve Corrected") { submit("approved") } .fontWeight(.semibold) } } } .presentationDetents([.large]) } private func remediationDiagnostics(_ row: VelocityImportWorkbenchRowDTO) -> some View { VStack(alignment: .leading, spacing: 12) { Text("Validation and Duplicate Preview") .font(.system(size: 15, weight: .semibold)) .foregroundStyle(VelocityTheme.foreground) if row.validation.isEmpty && row.duplicateCandidates.isEmpty && row.fieldDiffs.filter(\.changed).isEmpty { Text("No validation issues, duplicate candidates, or canonical row diffs were returned for this proposal.") .font(.system(size: 12)) .foregroundStyle(VelocityTheme.mutedFg) } ForEach(row.validation) { issue in HStack(alignment: .top, spacing: 8) { Image(systemName: issue.severity == "error" ? "xmark.octagon" : "exclamationmark.triangle") .foregroundStyle(issue.severity == "error" ? VelocityTheme.danger : VelocityTheme.warning) Text("\(issue.field): \(issue.message)") .font(.system(size: 12)) .foregroundStyle(VelocityTheme.mutedFg) } } ForEach(row.duplicateCandidates.prefix(3)) { candidate in VStack(alignment: .leading, spacing: 4) { Text("\(candidate.fullName) · \(candidate.matchReason) match · \(candidate.matchScore)%") .font(.system(size: 12, weight: .semibold)) .foregroundStyle(VelocityTheme.warning) Text([candidate.primaryPhone, candidate.primaryEmail].compactMap { $0?.trimmedNonEmpty }.joined(separator: " · ")) .font(.system(size: 11)) .foregroundStyle(VelocityTheme.mutedFg) } } ForEach(row.fieldDiffs.filter(\.changed).prefix(6)) { diff in Text("\(diff.field): \(diff.existing ?? "-") → \(diff.proposed ?? "-")") .font(.system(size: 11)) .foregroundStyle(VelocityTheme.foreground) } } .padding(14) .frame(maxWidth: .infinity, alignment: .leading) .background(RoundedRectangle(cornerRadius: 14).fill(VelocityTheme.surface)) } private func duplicatePolicyPicker(_ row: VelocityImportWorkbenchRowDTO) -> some View { VStack(alignment: .leading, spacing: 10) { Text("Duplicate Merge Policy") .font(.system(size: 15, weight: .semibold)) .foregroundStyle(VelocityTheme.foreground) Picker("Duplicate policy", selection: $duplicatePolicy) { ForEach(duplicatePolicyOptions()) { policy in Text(policy.label).tag(policy.value) } } .pickerStyle(.segmented) if let guidance = duplicatePolicyOptions().first(where: { $0.value == duplicatePolicy })?.description?.trimmedNonEmpty { Text(guidance) .font(.system(size: 12)) .foregroundStyle(VelocityTheme.mutedFg) } if row.duplicateCandidates.isEmpty { Text("No duplicate candidates were returned by the backend for this row.") .font(.system(size: 11)) .foregroundStyle(VelocityTheme.mutedFg) } else if let candidate = row.duplicateCandidates.first { Text("Strongest candidate: \(candidate.fullName) · \(candidate.matchReason) · \(candidate.matchScore)%") .font(.system(size: 11, weight: .semibold)) .foregroundStyle(VelocityTheme.warning) } } .padding(14) .frame(maxWidth: .infinity, alignment: .leading) .background(RoundedRectangle(cornerRadius: 14).fill(VelocityTheme.surface)) } private func duplicatePolicyOptions() -> [VelocityVocabularyOptionDTO] { guard !duplicatePolicies.contains(where: { $0.value == duplicatePolicy }), let current = duplicatePolicy.trimmedNonEmpty else { return duplicatePolicies } return [ VelocityVocabularyOptionDTO( value: current, label: current.replacingOccurrences(of: "_", with: " ").capitalized, description: "Current backend value", icon: nil ) ] + duplicatePolicies } private func submit(_ decision: String) { let cleanedOverrides = fieldOverrides.compactMapValues { value in value.trimmedNonEmpty } let defaultNote = decision == "needs_more_info" ? "Marked needs more information from iPad Imports remediation." : "Approved with iPad Imports field corrections." guard let selectedPolicy = duplicatePolicy.trimmedNonEmpty else { return } onSubmit(decision, notes.trimmedNonEmpty ?? defaultNote, cleanedOverrides, selectedPolicy) dismiss() } } private extension String { var trimmedNonEmpty: String? { let value = trimmingCharacters(in: .whitespacesAndNewlines) return value.isEmpty ? nil : value } } #Preview { ImportsView() }