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(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(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 } }