feat: Ipad app features and Dream Weaver for Velocity WebOS
This commit is contained in:
467
iOS/velocity-ipad/velocity/Features/Imports/ImportsView.swift
Normal file
467
iOS/velocity-ipad/velocity/Features/Imports/ImportsView.swift
Normal file
@@ -0,0 +1,467 @@
|
||||
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()
|
||||
}
|
||||
Reference in New Issue
Block a user