forked from sagnik/Project_Velocity
#38 Ipad app production readiness, Colony orchestration, Social posting Co-authored-by: Sayan Datta <sayan@Sayans-MacBook-Air.local> Reviewed-on: sagnik/Project_Velocity#44
966 lines
40 KiB
Swift
966 lines
40 KiB
Swift
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?
|
|
@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<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()
|
|
}
|