forked from sagnik/Project_Velocity
1498 lines
60 KiB
Swift
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
|
|
}
|
|
}
|