feat: Ipad app production readiness, Colony orchestration, Social posting
This commit is contained in:
@@ -1,16 +1,39 @@
|
||||
import Combine
|
||||
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<String>(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?
|
||||
private let refreshTimer = Timer.publish(every: 30, on: .main, in: .common).autoconnect()
|
||||
@State private var remediationDraft: ImportRemediationDraft?
|
||||
|
||||
var body: some View {
|
||||
HStack(spacing: 0) {
|
||||
@@ -24,20 +47,40 @@ struct ImportsView: View {
|
||||
detailPane
|
||||
}
|
||||
.background(VelocityTheme.background)
|
||||
.task { await loadBatches(selectFirst: true) }
|
||||
.task {
|
||||
await appStore.ensureCRMVocabulariesLoaded()
|
||||
await loadBatches(selectFirst: true)
|
||||
}
|
||||
.refreshable { await loadBatches(selectFirst: false) }
|
||||
.onReceive(refreshTimer) { _ in
|
||||
Task { await loadBatches(selectFirst: false, silent: true) }
|
||||
.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) {
|
||||
Text("Imports")
|
||||
.font(.system(size: 26, weight: .bold))
|
||||
.foregroundStyle(VelocityTheme.foreground)
|
||||
Text("Canonical CRM import review and commit queue.")
|
||||
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)
|
||||
}
|
||||
@@ -77,6 +120,9 @@ struct ImportsView: View {
|
||||
VStack(alignment: .leading, spacing: 18) {
|
||||
if let detail {
|
||||
detailHeader(detail)
|
||||
if let workbench {
|
||||
workbenchPanel(workbench)
|
||||
}
|
||||
proposalsPanel(detail)
|
||||
} else if isLoading {
|
||||
loadingCard("Loading import detail...")
|
||||
@@ -195,8 +241,115 @@ struct ImportsView: View {
|
||||
.glassCard(cornerRadius: 20)
|
||||
}
|
||||
|
||||
private func proposalCard(_ proposal: VelocityImportProposalDTO, batchId: String) -> some View {
|
||||
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)
|
||||
@@ -227,6 +380,16 @@ struct ImportsView: View {
|
||||
.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(
|
||||
@@ -239,20 +402,49 @@ struct ImportsView: View {
|
||||
)
|
||||
}
|
||||
|
||||
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") }
|
||||
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")
|
||||
.disabled(proposal.status.lowercased() == "approved" || defaultDuplicatePolicyValue(for: proposal) == nil)
|
||||
|
||||
Button("Reject") {
|
||||
Task { await reviewProposal(batchId: batchId, proposal: proposal, decision: "rejected") }
|
||||
Task {
|
||||
await reviewProposal(
|
||||
batchId: batchId,
|
||||
proposal: proposal,
|
||||
decision: "rejected",
|
||||
duplicatePolicy: defaultDuplicatePolicyValue(for: proposal)
|
||||
)
|
||||
}
|
||||
}
|
||||
.font(.system(size: 11, weight: .semibold))
|
||||
.foregroundStyle(VelocityTheme.danger)
|
||||
@@ -260,9 +452,46 @@ struct ImportsView: View {
|
||||
.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
|
||||
@@ -291,6 +520,7 @@ struct ImportsView: View {
|
||||
await MainActor.run {
|
||||
selectedBatch = batch
|
||||
detail = nil
|
||||
workbench = nil
|
||||
errorMessage = nil
|
||||
successMessage = nil
|
||||
isLoading = true
|
||||
@@ -303,9 +533,13 @@ struct ImportsView: View {
|
||||
await MainActor.run { isLoading = true }
|
||||
}
|
||||
do {
|
||||
let fetched = try await VelocityAPIClient.shared.fetchImportBatch(batchId: batchId)
|
||||
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
|
||||
}
|
||||
@@ -317,11 +551,34 @@ struct ImportsView: View {
|
||||
}
|
||||
}
|
||||
|
||||
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
|
||||
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
|
||||
@@ -332,12 +589,15 @@ struct ImportsView: View {
|
||||
batchId: batchId,
|
||||
proposalId: proposal.proposalId,
|
||||
decision: decision,
|
||||
notes: "Reviewed from iPad Imports workspace."
|
||||
notes: notes,
|
||||
fieldOverrides: fieldOverrides,
|
||||
duplicatePolicy: duplicatePolicy
|
||||
)
|
||||
await refreshDetail(batchId: batchId, silent: true)
|
||||
await MainActor.run {
|
||||
activeProposalID = nil
|
||||
successMessage = "Proposal \(decision)."
|
||||
remediationDraft = nil
|
||||
successMessage = "Proposal \(decision.replacingOccurrences(of: "_", with: " "))."
|
||||
}
|
||||
} catch {
|
||||
await MainActor.run {
|
||||
@@ -379,6 +639,11 @@ struct ImportsView: View {
|
||||
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 })
|
||||
@@ -462,6 +727,239 @@ struct ImportsView: View {
|
||||
}
|
||||
}
|
||||
|
||||
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<String, String>(
|
||||
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()
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user