import Combine import SwiftUI struct ImportsView: View { @State private var batches: [VelocityImportBatchSummaryDTO] = [] @State private var selectedBatch: VelocityImportBatchSummaryDTO? @State private var detail: VelocityImportBatchDetailDTO? @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() var body: some View { HStack(spacing: 0) { batchRail .frame(width: 350) .background(VelocityTheme.sidebarBg) Divider() .background(VelocityTheme.borderSubtle) detailPane } .background(VelocityTheme.background) .task { await loadBatches(selectFirst: true) } .refreshable { await loadBatches(selectFirst: false) } .onReceive(refreshTimer) { _ in Task { await loadBatches(selectFirst: false, silent: true) } } } 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.") .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) 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 proposalCard(_ proposal: VelocityImportProposalDTO, batchId: String) -> some View { 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) } } .padding(14) .background( RoundedRectangle(cornerRadius: 16) .fill(VelocityTheme.surface) .overlay( RoundedRectangle(cornerRadius: 16) .stroke(VelocityTheme.borderSubtle, lineWidth: 1) ) ) } private func proposalActions(_ proposal: VelocityImportProposalDTO, batchId: String) -> some View { HStack(spacing: 8) { Button("Approve") { Task { await reviewProposal(batchId: batchId, proposal: proposal, decision: "approved") } } .font(.system(size: 11, weight: .semibold)) .foregroundStyle(VelocityTheme.success) .padding(.horizontal, 10) .padding(.vertical, 8) .contentShape(Rectangle()) .disabled(proposal.status.lowercased() == "approved") Button("Reject") { Task { await reviewProposal(batchId: batchId, proposal: proposal, decision: "rejected") } } .font(.system(size: 11, weight: .semibold)) .foregroundStyle(VelocityTheme.danger) .padding(.horizontal, 10) .padding(.vertical, 8) .contentShape(Rectangle()) .disabled(proposal.status.lowercased() == "rejected") } } 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 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 { let fetched = try await VelocityAPIClient.shared.fetchImportBatch(batchId: batchId) await MainActor.run { detail = fetched errorMessage = nil isLoading = false } } catch { await MainActor.run { errorMessage = error.localizedDescription isLoading = false } } } private func reviewProposal( batchId: String, proposal: VelocityImportProposalDTO, decision: String ) async { await MainActor.run { activeProposalID = proposal.proposalId errorMessage = nil successMessage = nil } do { _ = try await VelocityAPIClient.shared.reviewImportProposal( batchId: batchId, proposalId: proposal.proposalId, decision: decision, notes: "Reviewed from iPad Imports workspace." ) await refreshDetail(batchId: batchId, silent: true) await MainActor.run { activeProposalID = nil successMessage = "Proposal \(decision)." } } 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 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 } } } #Preview { ImportsView() }