Files
Project_Velocity/iOS/velocity-ipad/velocity/Features/Communications/CommunicationsView.swift
sayan eeb684b46c
All checks were successful
Production Readiness / backend-contracts (push) Successful in 1m47s
Production Readiness / webos-typecheck (push) Successful in 1m50s
Production Readiness / ipad-parse (push) Successful in 1m34s
feat: Ipad app production readiness, Colony orchestration, Social posting (#44)
#38 Ipad app production readiness, Colony orchestration, Social posting

Co-authored-by: Sayan Datta <sayan@Sayans-MacBook-Air.local>
Reviewed-on: #44
2026-05-03 18:30:38 +05:30

1498 lines
60 KiB
Swift

import SwiftUI
import UIKit
private struct CommunicationThread: Identifiable {
let id: String
let leadName: String
let channel: String
let status: String
let summary: String
let nextAction: String
let updatedAt: String
let accent: Color
let isLinkedToCanonicalPerson: Bool
let personId: String?
let leadId: String?
let phone: String
}
private struct CommunicationAlert: Identifiable {
let id: String
let title: String
let detail: String
let severity: String
let color: Color
}
private enum CommunicationThreadFilter: String, CaseIterable, Identifiable {
case open
case all
case closed
var id: String { rawValue }
var label: String {
switch self {
case .open: return "Open"
case .all: return "All"
case .closed: return "Closed"
}
}
var apiValue: String? {
switch self {
case .open: return "open"
case .all: return nil
case .closed: return "closed"
}
}
}
private struct TranscriptBriefSelection: Identifiable {
let eventID: String
var id: String { eventID }
}
struct CommunicationsView: View {
@State private var appStore = AppStore.shared
@State private var selectedThread: String?
@State private var threads: [CommunicationThread] = []
@State private var messages: [VelocityCommsMessageDTO] = []
@State private var callLogs: [VelocityCommsCallLogDTO] = []
@State private var contacts: [VelocityCanonicalContactListItemDTO] = []
@State private var alerts: [CommunicationAlert] = []
@State private var isLoading = true
@State private var isDetailLoading = false
@State private var isSending = false
@State private var isMutating = false
@State private var errorMessage: String?
@State private var successMessage: String?
@State private var providerSettings: VelocityCommsSettingsDTO?
@State private var unreadTotal = 0
@State private var selectedPersonID = ""
@State private var threadSearchText = ""
@State private var threadFilter: CommunicationThreadFilter = .open
@State private var composerText = ""
@State private var noteText = ""
@State private var taskTitle = ""
@State private var hasTaskDueDate = false
@State private var taskDueDate = Calendar.current.date(byAdding: .hour, value: 2, to: Date()) ?? Date()
@State private var snappingMessageID: String?
@State private var transcriptBriefSelection: TranscriptBriefSelection?
private var activeThread: CommunicationThread? {
threads.first(where: { $0.id == selectedThread })
}
private var defaultTaskPriority: String? {
appStore.crmVocabularies.taskPriorities.first?.value
}
private var outboundDisabledReason: String? {
guard let providerSettings else {
return "Replies are disabled until provider settings load."
}
if providerSettings.isMockProvider {
return "Replies are disabled while Communications is using the mock provider."
}
return nil
}
private var shouldShowRecoveryPanel: Bool {
!isLoading && threads.isEmpty && errorMessage != nil
}
private var shouldShowEmptyPanel: Bool {
!isLoading && threads.isEmpty && errorMessage == nil
}
var body: some View {
ScrollView {
VStack(alignment: .leading, spacing: 20) {
header
if let successMessage {
successBanner(successMessage)
}
if isLoading {
loadingPanel
} else if shouldShowRecoveryPanel {
communicationsRecoveryPanel
} else if shouldShowEmptyPanel {
communicationsEmptyPanel
} else {
if let errorMessage {
syncNoticePanel(errorMessage, color: VelocityTheme.warning, systemImage: "arrow.triangle.2.circlepath")
}
if !alerts.isEmpty {
alertsStrip
}
HStack(alignment: .top, spacing: 18) {
threadRail
detailPanel
}
}
}
.padding(20)
}
.background(VelocityTheme.background)
.scrollContentBackground(.hidden)
.task { await loadLiveData() }
.refreshable { await loadLiveData() }
.onChange(of: threadFilter) { _, _ in
Task { await loadLiveData() }
}
.sheet(item: $transcriptBriefSelection) { selection in
CallIntelligenceBriefView(eventID: selection.eventID, threadID: selectedThread)
}
}
private var header: some View {
HStack(alignment: .top) {
VStack(alignment: .leading, spacing: 4) {
Text("Communications")
.font(.system(size: 28, weight: .bold))
.foregroundStyle(VelocityTheme.foreground)
Text("Manage calls, WhatsApp replies, transcripts, notes, and follow-ups from one focused workspace.")
.font(.system(size: 12))
.foregroundStyle(VelocityTheme.mutedFg)
}
Spacer()
VStack(alignment: .trailing, spacing: 8) {
if threads.count > 0 {
statusBadge(label: "\(threads.count) live threads", color: VelocityTheme.success)
}
if let providerSettings {
statusBadge(
label: providerSettings.isMockProvider ? "Mock provider" : "\(providerSettings.providerLabel) provider",
color: providerSettings.isMockProvider ? VelocityTheme.warning : VelocityTheme.accent
)
}
if unreadTotal > 0 {
statusBadge(label: "\(unreadTotal) unread", color: VelocityTheme.warning)
}
}
}
}
private var alertsStrip: some View {
ScrollView(.horizontal, showsIndicators: false) {
HStack(spacing: 12) {
ForEach(alerts) { alert in
VStack(alignment: .leading, spacing: 8) {
HStack {
Text(alert.severity)
.font(.system(size: 10, weight: .semibold))
.foregroundStyle(alert.color)
Spacer()
Circle()
.fill(alert.color)
.frame(width: 8, height: 8)
}
Text(alert.title)
.font(.system(size: 14, weight: .semibold))
.foregroundStyle(VelocityTheme.foreground)
Text(alert.detail)
.font(.system(size: 12))
.foregroundStyle(VelocityTheme.mutedFg)
.lineLimit(3)
}
.padding(16)
.frame(width: 250, alignment: .leading)
.glassCard(cornerRadius: 16)
}
}
}
}
private var threadRail: some View {
VStack(alignment: .leading, spacing: 12) {
HStack {
Text("Active Threads")
.font(.system(size: 13, weight: .semibold))
.foregroundStyle(VelocityTheme.foreground)
Spacer()
Button {
Task { await loadLiveData() }
} label: {
Image(systemName: "arrow.clockwise")
}
.buttonStyle(.plain)
.foregroundStyle(VelocityTheme.accent)
.accessibilityLabel("Refresh communication threads")
}
threadFilterBar
if threads.isEmpty {
detailRow(title: "No matching threads", value: "Adjust the search or status filter, then refresh.")
}
ForEach(threads) { thread in
Button {
selectedThread = thread.id
appStore.activeCommunicationsThreadID = thread.id
appStore.activeCommunicationsLeadID = thread.leadId
Task { await loadThreadDetail(thread.id) }
} label: {
threadCard(thread)
}
.buttonStyle(.plain)
}
}
.padding(18)
.frame(maxWidth: 360, alignment: .topLeading)
.glassCard(cornerRadius: 20)
}
private var threadFilterBar: some View {
VStack(alignment: .leading, spacing: 10) {
HStack(spacing: 8) {
Image(systemName: "magnifyingglass")
.foregroundStyle(VelocityTheme.mutedFg)
TextField("Search name or phone", text: $threadSearchText)
.textFieldStyle(.plain)
.submitLabel(.search)
.onSubmit {
Task { await loadLiveData() }
}
if threadSearchText.trimmedNonEmpty != nil {
Button {
threadSearchText = ""
Task { await loadLiveData() }
} label: {
Image(systemName: "xmark.circle.fill")
}
.buttonStyle(.plain)
.foregroundStyle(VelocityTheme.mutedFg)
.accessibilityLabel("Clear communication search")
}
}
.font(.system(size: 12))
.padding(.horizontal, 12)
.frame(minHeight: 38)
.background(fieldBackground)
Picker("Thread status", selection: $threadFilter) {
ForEach(CommunicationThreadFilter.allCases) { filter in
Text(filter.label).tag(filter)
}
}
.pickerStyle(.segmented)
}
}
private func threadCard(_ thread: CommunicationThread) -> some View {
VStack(alignment: .leading, spacing: 10) {
HStack {
VStack(alignment: .leading, spacing: 2) {
Text(thread.leadName)
.font(.system(size: 14, weight: .semibold))
.foregroundStyle(VelocityTheme.foreground)
Text(thread.channel)
.font(.system(size: 11))
.foregroundStyle(VelocityTheme.mutedFg)
}
Spacer()
Text(thread.updatedAt)
.font(.system(size: 10, weight: .medium))
.foregroundStyle(VelocityTheme.mutedFg)
}
Text(thread.summary)
.font(.system(size: 12))
.foregroundStyle(VelocityTheme.mutedFg)
.lineLimit(3)
HStack {
Text(thread.status.uppercased())
.font(.system(size: 9, weight: .semibold))
.tracking(1)
.foregroundStyle(thread.accent)
Spacer()
if !thread.isLinkedToCanonicalPerson {
Text("UNLINKED CRM")
.font(.system(size: 9, weight: .semibold))
.tracking(1)
.foregroundStyle(VelocityTheme.danger)
}
Text(thread.nextAction)
.font(.system(size: 10, weight: .medium))
.foregroundStyle(VelocityTheme.foreground)
}
}
.padding(16)
.frame(maxWidth: .infinity, alignment: .leading)
.background(
RoundedRectangle(cornerRadius: 16)
.fill(selectedThread == thread.id ? thread.accent.opacity(0.12) : VelocityTheme.surface)
.overlay(
RoundedRectangle(cornerRadius: 16)
.stroke(selectedThread == thread.id ? thread.accent.opacity(0.25) : VelocityTheme.borderSubtle, lineWidth: 1)
)
)
}
private var detailPanel: some View {
VStack(alignment: .leading, spacing: 16) {
HStack {
VStack(alignment: .leading, spacing: 4) {
Text(activeThread?.leadName ?? "Select a thread")
.font(.system(size: 22, weight: .bold))
.foregroundStyle(VelocityTheme.foreground)
Text(activeThread?.channel ?? "Communication detail")
.font(.system(size: 12))
.foregroundStyle(VelocityTheme.mutedFg)
}
Spacer()
if let thread = activeThread {
statusBadge(label: thread.status, color: thread.accent)
}
}
if let thread = activeThread {
detailSummary(for: thread)
linkPersonPanel(for: thread)
messageHistoryPanel
callTranscriptPanel
composerPanel
notesAndActionsPanel
} else {
detailRow(title: "Thread", value: "Choose an active conversation to load messages, CRM linking, notes, and next actions.")
}
}
.padding(22)
.frame(maxWidth: .infinity, alignment: .topLeading)
.glassCard(cornerRadius: 20)
}
private func detailSummary(for thread: CommunicationThread) -> some View {
VStack(alignment: .leading, spacing: 12) {
detailRow(title: "Latest summary", value: thread.summary)
detailRow(title: "Phone", value: thread.phone)
detailRow(title: "Provider", value: providerSettings.map { "\($0.providerLabel) · \(unreadTotal) unread" } ?? "Provider settings unavailable")
detailRow(title: "CRM link", value: thread.isLinkedToCanonicalPerson ? "Linked to a CRM contact." : "Link this conversation to a CRM contact before operator use.")
}
}
private func linkPersonPanel(for thread: CommunicationThread) -> some View {
VStack(alignment: .leading, spacing: 10) {
Text("CRM Link")
.font(.system(size: 13, weight: .semibold))
.foregroundStyle(VelocityTheme.foreground)
HStack(spacing: 10) {
Picker("Canonical person", selection: $selectedPersonID) {
Text("Select CRM contact").tag("")
ForEach(contacts) { contact in
Text(contactLabel(contact)).tag(contact.personId)
}
}
.pickerStyle(.menu)
.tint(VelocityTheme.accent)
.padding(.horizontal, 12)
.frame(maxWidth: .infinity, minHeight: 44, alignment: .leading)
.background(fieldBackground)
Button {
Task { await linkSelectedPerson(threadID: thread.id) }
} label: {
Label(thread.isLinkedToCanonicalPerson ? "Update Link" : "Link Person", systemImage: "person.crop.circle.badge.checkmark")
}
.buttonStyle(.borderedProminent)
.disabled(isMutating || selectedPersonID.trimmedNonEmpty == nil)
}
}
}
private var messageHistoryPanel: some View {
VStack(alignment: .leading, spacing: 12) {
HStack {
Text("Message Detail")
.font(.system(size: 13, weight: .semibold))
.foregroundStyle(VelocityTheme.foreground)
Spacer()
if isDetailLoading {
ProgressView()
.progressViewStyle(CircularProgressViewStyle(tint: VelocityTheme.accent))
}
}
if messages.isEmpty {
Text("No messages have been stored for this thread yet.")
.font(.system(size: 13))
.foregroundStyle(VelocityTheme.mutedFg)
.padding(14)
.frame(maxWidth: .infinity, alignment: .leading)
.background(fieldBackground)
} else {
VStack(spacing: 10) {
ForEach(messages) { message in
messageBubble(message)
}
}
}
}
}
private var callTranscriptPanel: some View {
VStack(alignment: .leading, spacing: 12) {
Text("Call Transcripts")
.font(.system(size: 13, weight: .semibold))
.foregroundStyle(VelocityTheme.foreground)
if callLogs.isEmpty {
Text("No call logs are attached to this communications thread yet.")
.font(.system(size: 13))
.foregroundStyle(VelocityTheme.mutedFg)
.padding(14)
.frame(maxWidth: .infinity, alignment: .leading)
.background(fieldBackground)
} else {
VStack(spacing: 10) {
ForEach(callLogs) { call in
callLogCard(call)
}
}
}
}
}
private func callLogCard(_ call: VelocityCommsCallLogDTO) -> some View {
VStack(alignment: .leading, spacing: 8) {
HStack {
Text("\(call.direction.capitalized) call")
.font(.system(size: 13, weight: .semibold))
.foregroundStyle(VelocityTheme.foreground)
Spacer()
Text(call.status.capitalized)
.font(.system(size: 10, weight: .semibold))
.foregroundStyle(call.transcriptText?.trimmedNonEmpty == nil ? VelocityTheme.warning : VelocityTheme.success)
}
Text("\(relativeShort(call.startedAt)) · \(durationLabel(call.durationSeconds)) · \(call.provider.capitalized)")
.font(.system(size: 11))
.foregroundStyle(VelocityTheme.mutedFg)
Text(call.transcriptText?.trimmedNonEmpty ?? "Transcript has not been produced yet.")
.font(.system(size: 13))
.foregroundStyle(call.transcriptText?.trimmedNonEmpty == nil ? VelocityTheme.mutedFg : VelocityTheme.foreground)
.lineLimit(6)
if call.transcriptText?.trimmedNonEmpty != nil {
HStack(spacing: 10) {
Button {
transcriptBriefSelection = TranscriptBriefSelection(eventID: call.callId)
} label: {
Label("Call Brief", systemImage: "doc.text.magnifyingglass")
}
.buttonStyle(.bordered)
.disabled(isMutating)
Button {
Task { await addNoteFromTranscript(call) }
} label: {
Label("Create Note", systemImage: "note.text")
}
.buttonStyle(.bordered)
.disabled(isMutating)
Button {
Task { await addTaskFromTranscript(call) }
} label: {
Label("Create Task", systemImage: "checklist")
}
.buttonStyle(.bordered)
.disabled(isMutating)
}
} else if call.recordingUrl?.trimmedNonEmpty != nil || call.callId.trimmedNonEmpty != nil {
HStack {
Spacer()
Button {
Task { await requestTranscript(for: call) }
} label: {
Label("Request Transcript", systemImage: "waveform.badge.mic")
}
.buttonStyle(.bordered)
.disabled(isMutating)
}
}
}
.padding(12)
.frame(maxWidth: .infinity, alignment: .leading)
.background(fieldBackground)
}
private func messageBubble(_ message: VelocityCommsMessageDTO) -> some View {
let isOutbound = message.direction.lowercased() == "outbound"
let isSystem = message.direction.lowercased() == "system"
return HStack {
if isOutbound {
Spacer(minLength: 80)
}
VStack(alignment: .leading, spacing: 6) {
HStack {
Text(message.direction.uppercased())
.font(.system(size: 9, weight: .semibold))
.tracking(1)
.foregroundStyle(isSystem ? VelocityTheme.warning : (isOutbound ? VelocityTheme.accent : VelocityTheme.success))
Spacer()
Text(relativeShort(message.sentAt ?? message.createdAt))
.font(.system(size: 10, weight: .medium))
.foregroundStyle(VelocityTheme.mutedFg)
}
Text(message.body)
.font(.system(size: 13))
.foregroundStyle(VelocityTheme.foreground)
Text("\(message.provider.capitalized) · \(message.deliveryStatus.capitalized)")
.font(.system(size: 10))
.foregroundStyle(VelocityTheme.mutedFg)
}
.padding(12)
.frame(maxWidth: 560, alignment: .leading)
.background(
RoundedRectangle(cornerRadius: 14)
.fill(isOutbound ? VelocityTheme.accent.opacity(0.14) : VelocityTheme.surface)
.overlay(
RoundedRectangle(cornerRadius: 14)
.stroke(isSystem ? VelocityTheme.warning.opacity(0.20) : VelocityTheme.borderSubtle, lineWidth: 1)
)
)
if !isOutbound {
Spacer(minLength: 80)
}
}
.contextMenu {
Button {
Task { await promoteMessageToTask(message) }
} label: {
Label("Promote to Task", systemImage: "checklist")
}
Button {
Task { await logMessageAsObjection(message) }
} label: {
Label("Log Objection", systemImage: "exclamationmark.bubble")
}
}
.swipeActions(edge: .leading, allowsFullSwipe: true) {
Button {
Task { await logMessageAsObjection(message) }
} label: {
Label("Objection", systemImage: "exclamationmark.bubble")
}
.tint(.orange)
}
.offset(x: snappingMessageID == message.messageId ? 140 : 0, y: snappingMessageID == message.messageId ? -22 : 0)
.scaleEffect(snappingMessageID == message.messageId ? 0.82 : 1)
.opacity(snappingMessageID == message.messageId ? 0.55 : 1)
.animation(.interactiveSpring(response: 0.42, dampingFraction: 0.72), value: snappingMessageID)
}
private var composerPanel: some View {
VStack(alignment: .leading, spacing: 10) {
Text("Reply")
.font(.system(size: 13, weight: .semibold))
.foregroundStyle(VelocityTheme.foreground)
TextEditor(text: $composerText)
.font(.system(size: 14))
.foregroundStyle(VelocityTheme.foreground)
.scrollContentBackground(.hidden)
.frame(minHeight: 82)
.padding(10)
.background(fieldBackground)
HStack {
Text(outboundDisabledReason ?? "Outbound messages are sent through the configured WAHA/Evolution provider.")
.font(.system(size: 11))
.foregroundStyle(outboundDisabledReason == nil ? VelocityTheme.mutedFg : VelocityTheme.warning)
Spacer()
Button {
Task { await sendReply() }
} label: {
Label(isSending ? "Sending" : "Send Reply", systemImage: "paperplane.fill")
}
.buttonStyle(.borderedProminent)
.disabled(outboundDisabledReason != nil || isSending || composerText.trimmedNonEmpty == nil || activeThread == nil)
}
}
}
private var notesAndActionsPanel: some View {
HStack(alignment: .top, spacing: 14) {
VStack(alignment: .leading, spacing: 10) {
Text("Operator Note")
.font(.system(size: 13, weight: .semibold))
.foregroundStyle(VelocityTheme.foreground)
TextEditor(text: $noteText)
.font(.system(size: 13))
.foregroundStyle(VelocityTheme.foreground)
.scrollContentBackground(.hidden)
.frame(minHeight: 74)
.padding(10)
.background(fieldBackground)
Button {
Task { await addNote() }
} label: {
Label("Add Note", systemImage: "note.text")
}
.buttonStyle(.bordered)
.disabled(isMutating || noteText.trimmedNonEmpty == nil || activeThread == nil)
}
VStack(alignment: .leading, spacing: 10) {
Text("Next-best Action")
.font(.system(size: 13, weight: .semibold))
.foregroundStyle(VelocityTheme.foreground)
TextField("Create follow-up task", text: $taskTitle)
.textFieldStyle(.plain)
.padding(12)
.background(fieldBackground)
Toggle("Add due time", isOn: $hasTaskDueDate.animation(.interactiveSpring(response: 0.32, dampingFraction: 0.84)))
.font(.system(size: 12, weight: .medium))
.foregroundStyle(VelocityTheme.mutedFg)
if hasTaskDueDate {
DatePicker("Due", selection: $taskDueDate, displayedComponents: [.date, .hourAndMinute])
.datePickerStyle(.compact)
.font(.system(size: 12))
.tint(VelocityTheme.accent)
}
Button {
Task { await addTask() }
} label: {
Label("Create Action", systemImage: "checklist")
}
.buttonStyle(.bordered)
.disabled(isMutating || taskTitle.trimmedNonEmpty == nil || activeThread == nil)
}
}
}
private var fieldBackground: some View {
RoundedRectangle(cornerRadius: 14)
.fill(VelocityTheme.surface)
.overlay(
RoundedRectangle(cornerRadius: 14)
.stroke(VelocityTheme.borderSubtle, lineWidth: 1)
)
}
private func detailRow(title: String, value: String) -> some View {
VStack(alignment: .leading, spacing: 6) {
Text(title.uppercased())
.font(.system(size: 10, weight: .semibold))
.tracking(1)
.foregroundStyle(VelocityTheme.mutedFg)
Text(value)
.font(.system(size: 14))
.foregroundStyle(VelocityTheme.foreground)
}
}
private func statusBadge(label: String, color: Color) -> some View {
Text(label)
.font(.system(size: 11, weight: .semibold))
.foregroundStyle(color)
.padding(.horizontal, 10)
.padding(.vertical, 6)
.background(
Capsule()
.fill(color.opacity(0.12))
.overlay(
Capsule()
.stroke(color.opacity(0.22), lineWidth: 1)
)
)
}
private var loadingPanel: some View {
VStack(alignment: .leading, spacing: 12) {
ProgressView()
.progressViewStyle(CircularProgressViewStyle(tint: VelocityTheme.accent))
Text("Loading live communications...")
.font(.system(size: 14, weight: .semibold))
.foregroundStyle(VelocityTheme.foreground)
Text("Velocity is fetching contacts, conversations, provider health, and follow-up alerts.")
.font(.system(size: 12))
.foregroundStyle(VelocityTheme.mutedFg)
}
.padding(20)
.frame(maxWidth: .infinity, alignment: .leading)
.glassCard(cornerRadius: 20)
}
private var communicationsRecoveryPanel: some View {
VStack(alignment: .leading, spacing: 18) {
HStack(alignment: .top, spacing: 14) {
Image(systemName: "antenna.radiowaves.left.and.right.slash")
.font(.system(size: 24, weight: .semibold))
.foregroundStyle(VelocityTheme.warning)
.frame(width: 42, height: 42)
.background(Circle().fill(VelocityTheme.warning.opacity(0.14)))
VStack(alignment: .leading, spacing: 6) {
Text("Communications sync needs attention")
.font(.system(size: 22, weight: .bold))
.foregroundStyle(VelocityTheme.foreground)
Text(errorMessage ?? "Velocity could not load live communication threads.")
.font(.system(size: 13))
.foregroundStyle(VelocityTheme.mutedFg)
.fixedSize(horizontal: false, vertical: true)
}
Spacer()
Button {
Task { await loadLiveData() }
} label: {
Label("Retry Sync", systemImage: "arrow.clockwise")
}
.buttonStyle(.borderedProminent)
}
HStack(spacing: 12) {
recoveryStep("1", "Confirm the backend is reachable.")
recoveryStep("2", "Open Settings from the dock if provider credentials changed.")
recoveryStep("3", "Seed or ingest conversations for the current CRM lead set.")
}
}
.padding(22)
.frame(maxWidth: .infinity, alignment: .leading)
.glassCard(cornerRadius: 20)
}
private var communicationsEmptyPanel: some View {
VStack(alignment: .leading, spacing: 18) {
HStack(alignment: .top, spacing: 14) {
Image(systemName: "bubble.left.and.bubble.right")
.font(.system(size: 24, weight: .semibold))
.foregroundStyle(VelocityTheme.accent)
.frame(width: 42, height: 42)
.background(Circle().fill(VelocityTheme.accent.opacity(0.14)))
VStack(alignment: .leading, spacing: 6) {
Text("No active conversations yet")
.font(.system(size: 22, weight: .bold))
.foregroundStyle(VelocityTheme.foreground)
Text(emptyStateMessage)
.font(.system(size: 13))
.foregroundStyle(VelocityTheme.mutedFg)
.fixedSize(horizontal: false, vertical: true)
}
Spacer()
Button {
threadSearchText = ""
threadFilter = .open
Task { await loadLiveData() }
} label: {
Label("Refresh", systemImage: "arrow.clockwise")
}
.buttonStyle(.borderedProminent)
}
threadFilterBar
.frame(maxWidth: 420, alignment: .leading)
if let outboundDisabledReason {
syncNoticePanel(outboundDisabledReason, color: VelocityTheme.warning, systemImage: "exclamationmark.triangle")
}
}
.padding(22)
.frame(maxWidth: .infinity, alignment: .leading)
.glassCard(cornerRadius: 20)
}
private var emptyStateMessage: String {
if threadSearchText.trimmedNonEmpty != nil || threadFilter != .open {
return "No conversations match the current search or status filter."
}
return "When calls or WhatsApp events arrive, they will appear here with CRM linking, notes, transcripts, and follow-up actions."
}
private func recoveryStep(_ number: String, _ text: String) -> some View {
HStack(alignment: .top, spacing: 8) {
Text(number)
.font(.system(size: 10, weight: .bold))
.foregroundStyle(VelocityTheme.background)
.frame(width: 20, height: 20)
.background(Circle().fill(VelocityTheme.accent))
Text(text)
.font(.system(size: 12))
.foregroundStyle(VelocityTheme.mutedFg)
.fixedSize(horizontal: false, vertical: true)
}
.padding(12)
.frame(maxWidth: .infinity, alignment: .topLeading)
.background(fieldBackground)
}
private func syncNoticePanel(_ message: String, color: Color, systemImage: String) -> some View {
banner(message, color: color, systemImage: systemImage)
}
private func successBanner(_ message: String) -> some View {
banner(message, color: VelocityTheme.success, systemImage: "checkmark.circle")
}
private func banner(_ message: String, color: Color, systemImage: String) -> some View {
HStack(spacing: 10) {
Image(systemName: systemImage)
Text(message)
}
.font(.system(size: 13, weight: .medium))
.foregroundStyle(color)
.padding(14)
.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 loadLiveData(silent: Bool = false) async {
if !silent {
await MainActor.run { isLoading = true }
}
do {
await appStore.ensureCRMVocabulariesLoaded()
let settings = try? await VelocityAPIClient.shared.fetchCommsSettings()
let query = await MainActor.run { (threadFilter.apiValue, threadSearchText.trimmedNonEmpty) }
let threadList = try await VelocityAPIClient.shared.fetchCommsThreads(
status: query.0,
search: query.1,
limit: 100
)
async let alertsTask = VelocityAPIClient.shared.fetchAlerts()
async let contactsTask = VelocityAPIClient.shared.fetchContacts(limit: 100)
let alertSnapshot = try? await alertsTask
let fetchedContacts = (try? await contactsTask) ?? []
let leadIDPairs: [(String, String)] = fetchedContacts.compactMap { contact -> (String, String)? in
guard let leadID = contact.leadId?.trimmedNonEmpty else { return nil }
return (contact.personId, leadID)
}
let leadIDsByPersonID = Dictionary<String, String>(uniqueKeysWithValues: leadIDPairs)
let fetchedThreads = threadList.threads.map { Self.mapThread($0, leadIDsByPersonID: leadIDsByPersonID) }
let fetchedAlerts = buildAlerts(from: alertSnapshot, settings: settings, threadList: threadList)
await MainActor.run {
threads = fetchedThreads
contacts = fetchedContacts
alerts = fetchedAlerts
providerSettings = settings
unreadTotal = threadList.unreadTotal
if selectedThread == nil || !fetchedThreads.contains(where: { $0.id == selectedThread }) {
selectedThread = fetchedThreads.first?.id
}
if let thread = fetchedThreads.first(where: { $0.id == selectedThread }) {
selectedPersonID = thread.personId ?? selectedPersonID
appStore.activeCommunicationsThreadID = thread.id
appStore.activeCommunicationsLeadID = thread.leadId
}
errorMessage = nil
isLoading = false
}
let threadToLoad = await MainActor.run { selectedThread }
if let threadID = threadToLoad {
await loadThreadDetail(threadID, silent: silent)
}
} catch {
await MainActor.run {
errorMessage = Self.userFacingSyncError(error)
threads = []
messages = []
callLogs = []
alerts = []
unreadTotal = 0
isLoading = false
}
}
}
private func loadThreadDetail(_ threadID: String, silent: Bool = false) async {
if !silent {
await MainActor.run { isDetailLoading = true }
}
do {
async let messageTask = VelocityAPIClient.shared.fetchCommsMessages(threadId: threadID)
async let callTask = VelocityAPIClient.shared.fetchCommsCallLogs(threadId: threadID)
let detail = try await messageTask
let calls = try await callTask
await MainActor.run {
messages = detail.messages
callLogs = calls.calls
selectedPersonID = detail.thread.personId ?? selectedPersonID
let leadIDPairs: [(String, String)] = contacts.compactMap { contact -> (String, String)? in
guard let leadID = contact.leadId?.trimmedNonEmpty else { return nil }
return (contact.personId, leadID)
}
let leadIDsByPersonID = Dictionary<String, String>(uniqueKeysWithValues: leadIDPairs)
if let index = threads.firstIndex(where: { $0.id == threadID }) {
threads[index] = Self.mapThread(detail.thread, leadIDsByPersonID: leadIDsByPersonID)
}
if let thread = threads.first(where: { $0.id == threadID }) {
appStore.activeCommunicationsThreadID = thread.id
appStore.activeCommunicationsLeadID = thread.leadId
}
errorMessage = nil
isDetailLoading = false
}
} catch {
await MainActor.run {
errorMessage = error.localizedDescription
messages = []
callLogs = []
isDetailLoading = false
}
}
}
private func sendReply() async {
let threadID = await MainActor.run { selectedThread }
let message = await MainActor.run { composerText.trimmedNonEmpty }
guard let threadID, let message, outboundDisabledReason == nil else {
return
}
await MainActor.run { isSending = true }
do {
_ = try await VelocityAPIClient.shared.sendCommsMessage(threadId: threadID, body: message)
await MainActor.run {
composerText = ""
successMessage = "Reply sent through the configured communications provider."
errorMessage = nil
isSending = false
}
await loadThreadDetail(threadID)
} catch {
await MainActor.run {
errorMessage = error.localizedDescription
isSending = false
}
}
}
private func linkSelectedPerson(threadID: String) async {
let selectedPerson = await MainActor.run { selectedPersonID.trimmedNonEmpty }
guard let personID = selectedPerson else {
return
}
await MainActor.run { isMutating = true }
do {
_ = try await VelocityAPIClient.shared.linkCommsThread(threadId: threadID, personId: personID)
await MainActor.run {
successMessage = "Conversation linked to the CRM contact."
errorMessage = nil
isMutating = false
}
await loadLiveData(silent: true)
} catch {
await MainActor.run {
errorMessage = error.localizedDescription
isMutating = false
}
}
}
private func addNote() async {
let threadID = await MainActor.run { selectedThread }
let content = await MainActor.run { noteText.trimmedNonEmpty }
guard let threadID, let content else {
return
}
await MainActor.run { isMutating = true }
do {
let result = try await VelocityAPIClient.shared.addCommsNote(threadId: threadID, content: content)
await MainActor.run {
noteText = ""
successMessage = result.canonicalInteractionId == nil
? "Operator note added to the conversation timeline. Link this conversation to a CRM contact to promote future notes."
: "Operator note promoted to the CRM timeline."
errorMessage = nil
isMutating = false
}
await loadThreadDetail(threadID)
} catch {
await MainActor.run {
errorMessage = error.localizedDescription
isMutating = false
}
}
}
private func addTask() async {
let threadID = await MainActor.run { selectedThread }
let title = await MainActor.run { taskTitle.trimmedNonEmpty }
guard let threadID, let title else {
return
}
let dueAt = await MainActor.run {
hasTaskDueDate ? ISO8601DateFormatter().string(from: taskDueDate) : nil
}
guard let priority = await MainActor.run(body: { defaultTaskPriority }) else {
await MainActor.run {
errorMessage = "Unable to add a task because backend task priority vocabulary is unavailable."
}
return
}
await MainActor.run { isMutating = true }
do {
let result = try await VelocityAPIClient.shared.addCommsTask(
threadId: threadID,
title: title,
dueAt: dueAt,
priority: priority
)
await MainActor.run {
taskTitle = ""
hasTaskDueDate = false
taskDueDate = Calendar.current.date(byAdding: .hour, value: 2, to: Date()) ?? Date()
successMessage = result.canonicalReminderId == nil
? "Next action added to the conversation timeline. Link this conversation to a CRM contact to promote future tasks."
: "Next action promoted to the CRM task rail."
errorMessage = nil
isMutating = false
}
await loadThreadDetail(threadID)
} catch {
await MainActor.run {
errorMessage = error.localizedDescription
isMutating = false
}
}
}
private func promoteMessageToTask(_ message: VelocityCommsMessageDTO) async {
let threadID = await MainActor.run { selectedThread }
guard let threadID else { return }
guard let priority = await MainActor.run(body: { defaultTaskPriority }) else {
await MainActor.run {
errorMessage = "Unable to promote this message because backend task priority vocabulary is unavailable."
}
return
}
let title = await MainActor.run {
let name = activeThread?.leadName.trimmedNonEmpty ?? "client"
return "Follow up with \(name)"
}
await MainActor.run {
isMutating = true
withAnimation(.interactiveSpring(response: 0.42, dampingFraction: 0.72)) {
snappingMessageID = message.messageId
}
UIImpactFeedbackGenerator(style: .medium).impactOccurred()
}
do {
let result = try await VelocityAPIClient.shared.addCommsTask(
threadId: threadID,
title: title,
notes: message.body,
priority: priority
)
await MainActor.run {
successMessage = result.canonicalReminderId == nil
? "Message added to the conversation task rail. Link this conversation to a CRM contact to promote future tasks."
: "Message promoted to the CRM task rail."
errorMessage = nil
isMutating = false
snappingMessageID = nil
}
await loadThreadDetail(threadID)
} catch {
await MainActor.run {
errorMessage = error.localizedDescription
isMutating = false
snappingMessageID = nil
}
}
}
private func logMessageAsObjection(_ message: VelocityCommsMessageDTO) async {
let threadID = await MainActor.run { selectedThread }
guard let threadID else { return }
await MainActor.run {
isMutating = true
UIImpactFeedbackGenerator(style: .soft).impactOccurred()
}
do {
let result = try await VelocityAPIClient.shared.addCommsNote(
threadId: threadID,
content: "Objection logged from message \(message.messageId):\n\(message.body)"
)
await MainActor.run {
successMessage = result.canonicalInteractionId == nil
? "Objection logged to the conversation timeline. Link this conversation to a CRM contact to promote future notes."
: "Objection logged to the CRM timeline."
errorMessage = nil
isMutating = false
}
await loadThreadDetail(threadID)
} catch {
await MainActor.run {
errorMessage = error.localizedDescription
isMutating = false
}
}
}
private func addNoteFromTranscript(_ call: VelocityCommsCallLogDTO) async {
guard let transcript = call.transcriptText?.trimmedNonEmpty else {
return
}
let threadID = await MainActor.run { call.threadId ?? activeThread?.id }
guard let threadID else {
return
}
await MainActor.run { isMutating = true }
do {
let result = try await VelocityAPIClient.shared.addCommsNote(
threadId: threadID,
content: "Call transcript note:\n\(transcript)"
)
await MainActor.run {
successMessage = result.canonicalInteractionId == nil
? "Transcript note added to this conversation. Link it to a CRM contact to promote future notes."
: "Transcript note promoted to the CRM timeline."
errorMessage = nil
isMutating = false
}
await loadThreadDetail(threadID)
} catch {
await MainActor.run {
errorMessage = error.localizedDescription
isMutating = false
}
}
}
private func addTaskFromTranscript(_ call: VelocityCommsCallLogDTO) async {
guard let transcript = call.transcriptText?.trimmedNonEmpty else {
return
}
let context = await MainActor.run { (call.threadId ?? activeThread?.id, activeThread?.leadName.trimmedNonEmpty) }
guard let threadID = context.0 else {
return
}
let title = "Review call transcript with \(context.1 ?? "client")"
guard let priority = await MainActor.run(body: { defaultTaskPriority }) else {
await MainActor.run {
errorMessage = "Unable to add a transcript task because backend task priority vocabulary is unavailable."
}
return
}
await MainActor.run { isMutating = true }
do {
let result = try await VelocityAPIClient.shared.addCommsTask(
threadId: threadID,
title: title,
notes: transcript,
priority: priority
)
await MainActor.run {
successMessage = result.canonicalReminderId == nil
? "Transcript follow-up added to this conversation. Link it to a CRM contact to promote future tasks."
: "Transcript follow-up promoted to the CRM task rail."
errorMessage = nil
isMutating = false
}
await loadThreadDetail(threadID)
} catch {
await MainActor.run {
errorMessage = error.localizedDescription
isMutating = false
}
}
}
private func requestTranscript(for call: VelocityCommsCallLogDTO) async {
await MainActor.run { isMutating = true }
do {
_ = try await VelocityAPIClient.shared.requestCommsTranscription(
callId: call.callId,
recordingUrl: call.recordingUrl
)
await MainActor.run {
successMessage = "Transcript request recorded for this call."
errorMessage = nil
isMutating = false
}
if let threadID = call.threadId ?? activeThread?.id {
await loadThreadDetail(threadID)
}
} catch {
await MainActor.run {
errorMessage = error.localizedDescription
isMutating = false
}
}
}
private func buildAlerts(
from snapshot: VelocityAlertSnapshotDTO?,
settings: VelocityCommsSettingsDTO?,
threadList: VelocityCommsThreadListDTO
) -> [CommunicationAlert] {
var built: [CommunicationAlert] = []
if let snapshot {
if snapshot.pendingInsights > 0 {
built.append(
CommunicationAlert(
id: "pending_insights",
title: "Pending insights",
detail: "\(snapshot.pendingInsights) insight recommendations need operator review.",
severity: "Priority",
color: VelocityTheme.danger
)
)
}
if snapshot.pendingTranscriptions > 0 {
built.append(
CommunicationAlert(
id: "pending_transcriptions",
title: "Transcription queue",
detail: "\(snapshot.pendingTranscriptions) transcript jobs waiting.",
severity: "Queue",
color: VelocityTheme.warning
)
)
}
if snapshot.upcomingCalendarEvents24h > 0 {
built.append(
CommunicationAlert(
id: "calendar_due",
title: "Calendar due soon",
detail: "\(snapshot.upcomingCalendarEvents24h) calendar events are due in the next 24 hours.",
severity: "Calendar",
color: VelocityTheme.success
)
)
}
}
if settings == nil {
built.insert(
CommunicationAlert(
id: "provider_unavailable",
title: "Provider settings unavailable",
detail: "Replies are disabled until the provider settings endpoint returns a usable response.",
severity: "Provider",
color: VelocityTheme.warning
),
at: 0
)
}
if settings?.isMockProvider == true {
built.insert(
CommunicationAlert(
id: "mock_provider",
title: "Provider not production",
detail: "Communications is configured with the mock provider. Configure WAHA or Evolution before field use.",
severity: "Provider",
color: VelocityTheme.warning
),
at: 0
)
}
let unlinkedCount = threadList.threads.filter { !$0.isLinkedToCanonicalPerson }.count
if unlinkedCount > 0 {
built.insert(
CommunicationAlert(
id: "unlinked_threads",
title: "CRM linking required",
detail: "\(unlinkedCount) communication threads need CRM contact links.",
severity: "CRM",
color: VelocityTheme.danger
),
at: settings?.isMockProvider == true ? 1 : 0
)
}
return built
}
private static func mapThread(
_ thread: VelocityCommsThreadDTO,
leadIDsByPersonID: [String: String]
) -> CommunicationThread {
let linked = thread.isLinkedToCanonicalPerson
return CommunicationThread(
id: thread.threadId,
leadName: thread.displayTitle,
channel: "\(channelLabel(thread.channel)) · \(thread.provider.capitalized)",
status: thread.status,
summary: thread.lastMessagePreview?.trimmedNonEmpty ?? "No message preview has been captured for this conversation yet.",
nextAction: linked ? "Continue conversation" : "Link CRM person",
updatedAt: relativeShort(thread.lastMessageAt ?? thread.updatedAt),
accent: linked ? VelocityTheme.success : VelocityTheme.danger,
isLinkedToCanonicalPerson: linked,
personId: thread.personId,
leadId: thread.personId.flatMap { leadIDsByPersonID[$0] },
phone: thread.phoneE164
)
}
private func contactLabel(_ contact: VelocityCanonicalContactListItemDTO) -> String {
[
contact.fullName,
contact.primaryPhone?.trimmedNonEmpty,
contact.leadStatus?.trimmedNonEmpty,
]
.compactMap { $0 }
.joined(separator: " · ")
}
private func durationLabel(_ seconds: Int?) -> String {
guard let seconds, seconds > 0 else {
return "Duration pending"
}
if seconds < 60 {
return "\(seconds)s"
}
return "\(seconds / 60)m \(seconds % 60)s"
}
private static func channelLabel(_ value: String) -> String {
value.replacingOccurrences(of: "_", with: " ").capitalized
}
private func relativeShort(_ iso: String) -> String {
Self.relativeShort(iso)
}
private static func relativeShort(_ iso: String) -> String {
let formatter = ISO8601DateFormatter()
guard let date = formatter.date(from: iso) else {
return iso
}
let delta = Int(Date().timeIntervalSince(date))
if delta < 60 { return "now" }
if delta < 3600 { return "\(delta / 60)m ago" }
if delta < 86400 { return "\(delta / 3600)h ago" }
return "\(delta / 86400)d ago"
}
private static func userFacingSyncError(_ error: Error) -> String {
let message = error.localizedDescription.trimmedNonEmpty ?? "The communications backend did not return a usable response."
let lowercased = message.lowercased()
if lowercased.contains("internal server error") {
return "The backend could not load live communication threads. Retry after backend health or provider configuration is restored."
}
if lowercased.contains("not connected") || lowercased.contains("offline") || lowercased.contains("timed out") {
return "Velocity could not reach the communications backend. Check connectivity, then retry sync."
}
return message
}
}
private struct CallIntelligenceBriefView: View {
let eventID: String
let threadID: String?
@State private var dossier: VelocityTranscriptDossierDTO?
@State private var selectedSegmentID: String?
@State private var errorMessage: String?
@State private var isLoading = true
@State private var isSavingQuote = false
var body: some View {
NavigationStack {
ScrollView {
VStack(alignment: .leading, spacing: 18) {
if isLoading {
ProgressView("Loading transcript...")
.progressViewStyle(CircularProgressViewStyle(tint: VelocityTheme.accent))
.foregroundStyle(VelocityTheme.mutedFg)
} else if let errorMessage {
Text(errorMessage)
.font(.system(size: 13, weight: .medium))
.foregroundStyle(VelocityTheme.danger)
} else if let dossier {
header(dossier)
ForEach(dossier.segments) { segment in
segmentRow(segment)
}
}
}
.padding(24)
}
.background(VelocityTheme.background)
.navigationTitle("Call Intelligence Brief")
.task { await load() }
}
}
private func header(_ dossier: VelocityTranscriptDossierDTO) -> some View {
VStack(alignment: .leading, spacing: 8) {
Text("Call Intelligence Brief")
.font(.system(size: 28, weight: .semibold))
.foregroundStyle(VelocityTheme.foreground)
Text("\(dossier.job.provider ?? "Provider") · \(dossier.job.wordCount ?? 0) words · \(dossier.job.status.capitalized)")
.font(.system(size: 12, weight: .medium))
.foregroundStyle(VelocityTheme.mutedFg)
}
}
private func segmentRow(_ segment: VelocityTranscriptSegmentDTO) -> some View {
Button {
withAnimation(.interactiveSpring(response: 0.34, dampingFraction: 0.82)) {
selectedSegmentID = segment.segmentId
}
Task { await saveQuote(segment) }
} label: {
HStack(alignment: .top, spacing: 16) {
Text(segment.speakerLabel ?? (segment.isAgentTurn == true ? "Broker" : "Client"))
.font(.system(size: 11, weight: .semibold))
.foregroundStyle(segment.isAgentTurn == true ? VelocityTheme.accent : VelocityTheme.warning)
.frame(width: 72, alignment: .leading)
Text("\(segment.text)")
.font(.system(size: 18, weight: .regular))
.foregroundStyle(VelocityTheme.foreground)
.lineSpacing(5)
Spacer()
if isSavingQuote && selectedSegmentID == segment.segmentId {
ProgressView()
.progressViewStyle(CircularProgressViewStyle(tint: VelocityTheme.accent))
}
}
.padding(.vertical, 18)
.padding(.horizontal, 4)
.background(
RoundedRectangle(cornerRadius: 16)
.fill(selectedSegmentID == segment.segmentId ? VelocityTheme.accent.opacity(0.10) : .clear)
)
}
.buttonStyle(.plain)
}
@MainActor
private func load() async {
isLoading = true
do {
dossier = try await VelocityAPIClient.shared.fetchTranscriptDossier(eventId: eventID)
errorMessage = nil
} catch {
errorMessage = error.localizedDescription
}
isLoading = false
}
@MainActor
private func saveQuote(_ segment: VelocityTranscriptSegmentDTO) async {
guard let threadID else {
errorMessage = "Open a linked communication thread before extracting transcript quotes."
return
}
isSavingQuote = true
do {
_ = try await VelocityAPIClient.shared.addCommsNote(
threadId: threadID,
content: "Transcript quote:\n\"\(segment.text)\""
)
errorMessage = nil
} catch {
errorMessage = error.localizedDescription
}
isSavingQuote = false
}
}
private extension String {
var trimmedNonEmpty: String? {
let trimmed = trimmingCharacters(in: .whitespacesAndNewlines)
return trimmed.isEmpty ? nil : trimmed
}
}