Files
Project_Velocity/iOS/velocity-ipad/velocity/Features/Communications/CommunicationsView.swift
2026-04-28 11:32:56 +05:30

456 lines
18 KiB
Swift

import Combine
import SwiftUI
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
}
private struct CommunicationAlert: Identifiable {
let id: String
let title: String
let detail: String
let severity: String
let color: Color
}
struct CommunicationsView: View {
@State private var selectedThread: String?
@State private var threads: [CommunicationThread] = []
@State private var alerts: [CommunicationAlert] = []
@State private var isLoading = true
@State private var errorMessage: String?
private let refreshTimer = Timer.publish(every: 15, on: .main, in: .common).autoconnect()
private var activeThread: CommunicationThread? {
threads.first(where: { $0.id == selectedThread })
}
var body: some View {
ScrollView {
VStack(alignment: .leading, spacing: 20) {
header
if let errorMessage {
errorBanner(errorMessage)
}
if isLoading {
loadingPanel
} else {
alertsStrip
HStack(alignment: .top, spacing: 18) {
threadRail
detailPanel
}
}
}
.padding(20)
}
.background(VelocityTheme.background)
.scrollContentBackground(.hidden)
.task { await loadLiveData() }
.refreshable { await loadLiveData() }
.onReceive(refreshTimer) { _ in
Task { await loadLiveData(silent: true) }
}
}
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("Phone, WhatsApp, transcript, and memory edge across canonical CRM contacts with active lead context.")
.font(.system(size: 12))
.foregroundStyle(VelocityTheme.mutedFg)
}
Spacer()
VStack(alignment: .trailing, spacing: 8) {
statusBadge(label: "\(threads.count) live threads", color: VelocityTheme.success)
if let queueAlert = alerts.first(where: { $0.id == "pending_transcriptions" }) {
statusBadge(label: queueAlert.detail, color: queueAlert.color)
}
}
}
}
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) {
Text("Active Threads")
.font(.system(size: 13, weight: .semibold))
.foregroundStyle(VelocityTheme.foreground)
if threads.isEmpty {
detailRow(title: "Live data", value: "No communication events have been captured for the current canonical CRM lead set yet.")
}
ForEach(threads) { thread in
Button {
selectedThread = thread.id
} label: {
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()
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)
)
)
}
.buttonStyle(.plain)
}
}
.padding(18)
.frame(maxWidth: 360, alignment: .topLeading)
.glassCard(cornerRadius: 20)
}
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)
}
}
VStack(alignment: .leading, spacing: 12) {
detailRow(title: "Latest summary", value: activeThread?.summary ?? "No thread selected")
detailRow(title: "Next operator action", value: activeThread?.nextAction ?? "None")
detailRow(title: "Memory extraction", value: activeThread != nil ? "Backed by persisted mobile-edge communication events and live backend alerts." : "No communication memory available.")
detailRow(title: "Suggested response", value: activeThread != nil ? "Use the current thread state, transcript queue, and calendar urgency to choose the next operator action." : "Select a thread to view live context.")
}
VStack(alignment: .leading, spacing: 12) {
Text("Recent activity")
.font(.system(size: 13, weight: .semibold))
.foregroundStyle(VelocityTheme.foreground)
ForEach(alerts.prefix(3)) { alert in
activityCard(icon: alertIcon(for: alert.id), title: alert.title, detail: alert.detail)
}
}
}
.padding(22)
.frame(maxWidth: .infinity, alignment: .topLeading)
.glassCard(cornerRadius: 20)
}
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 activityCard(icon: String, title: String, detail: String) -> some View {
HStack(alignment: .top, spacing: 12) {
ZStack {
RoundedRectangle(cornerRadius: 10)
.fill(VelocityTheme.accent.opacity(0.14))
.frame(width: 38, height: 38)
Image(systemName: icon)
.font(.system(size: 15, weight: .semibold))
.foregroundStyle(VelocityTheme.accent)
}
VStack(alignment: .leading, spacing: 4) {
Text(title)
.font(.system(size: 13, weight: .semibold))
.foregroundStyle(VelocityTheme.foreground)
Text(detail)
.font(.system(size: 12))
.foregroundStyle(VelocityTheme.mutedFg)
}
Spacer()
}
.padding(14)
.background(
RoundedRectangle(cornerRadius: 14)
.fill(VelocityTheme.surface)
.overlay(
RoundedRectangle(cornerRadius: 14)
.stroke(VelocityTheme.borderSubtle, lineWidth: 1)
)
)
}
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 canonical CRM contact summaries, communication events, and alert state from the backend.")
.font(.system(size: 12))
.foregroundStyle(VelocityTheme.mutedFg)
}
.padding(20)
.frame(maxWidth: .infinity, alignment: .leading)
.glassCard(cornerRadius: 20)
}
private func errorBanner(_ message: String) -> some View {
Text(message)
.font(.system(size: 13, weight: .medium))
.foregroundStyle(VelocityTheme.danger)
.padding(14)
.frame(maxWidth: .infinity, alignment: .leading)
.background(
RoundedRectangle(cornerRadius: 14)
.fill(VelocityTheme.danger.opacity(0.10))
.overlay(
RoundedRectangle(cornerRadius: 14)
.stroke(VelocityTheme.danger.opacity(0.22), lineWidth: 1)
)
)
}
private func loadLiveData(silent: Bool = false) async {
if !silent {
isLoading = true
}
do {
async let leadsTask = VelocityAPIClient.shared.fetchLeads()
async let alertsTask = VelocityAPIClient.shared.fetchAlerts()
let leads = try await leadsTask
let alertSnapshot = try await alertsTask
let topLeads = Array(leads.sorted(by: { $0.score > $1.score }).prefix(8))
var fetchedThreads: [CommunicationThread] = []
for lead in topLeads {
let events = (try? await VelocityAPIClient.shared.fetchEvents(for: lead.id, limit: 1)) ?? []
let latest = events.first
fetchedThreads.append(
CommunicationThread(
id: lead.id,
leadName: lead.name,
channel: latest.map { channelLabel($0.channel) } ?? sourceLabel(lead.source),
status: statusLabel(for: lead, event: latest),
summary: latest?.summary ?? "No communication events captured yet for this lead.",
nextAction: nextActionLabel(for: lead, event: latest),
updatedAt: latest.map { relativeShort($0.timestamp) } ?? "No events",
accent: accentColor(for: lead, event: latest)
)
)
}
let fetchedAlerts = buildAlerts(from: alertSnapshot)
await MainActor.run {
threads = fetchedThreads
alerts = fetchedAlerts
if selectedThread == nil || !fetchedThreads.contains(where: { $0.id == selectedThread }) {
selectedThread = fetchedThreads.first?.id
}
errorMessage = nil
isLoading = false
}
} catch {
await MainActor.run {
errorMessage = error.localizedDescription
threads = []
alerts = []
isLoading = false
}
}
}
private func buildAlerts(from snapshot: VelocityAlertSnapshotDTO) -> [CommunicationAlert] {
[
CommunicationAlert(
id: "pending_insights",
title: "Pending insights",
detail: "\(snapshot.pendingInsights) insight recommendations need operator review.",
severity: "Priority",
color: VelocityTheme.danger
),
CommunicationAlert(
id: "pending_transcriptions",
title: "Transcription queue",
detail: "\(snapshot.pendingTranscriptions) transcript jobs waiting.",
severity: "Queue",
color: VelocityTheme.warning
),
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
),
]
}
private func statusLabel(for lead: VelocityLeadDTO, event: VelocityCommunicationEventDTO?) -> String {
if event == nil {
if lead.pendingTaskCount > 0 {
return "Task pending"
}
return "No events yet"
}
if lead.score >= 90 {
return "Whale priority"
}
return lead.kanbanStatus
}
private func nextActionLabel(for lead: VelocityLeadDTO, event: VelocityCommunicationEventDTO?) -> String {
if event?.recordingRef != nil {
return "Review transcript"
}
if lead.pendingTaskCount > 0 {
return lead.pendingTaskCount == 1 ? "Review pending task" : "Review \(lead.pendingTaskCount) tasks"
}
if lead.score >= 90 {
return "Schedule follow-up"
}
return "Update operator note"
}
private func accentColor(for lead: VelocityLeadDTO, event: VelocityCommunicationEventDTO?) -> Color {
if event?.recordingRef != nil {
return VelocityTheme.accent
}
if lead.score >= 90 {
return VelocityTheme.success
}
return VelocityTheme.warning
}
private func alertIcon(for id: String) -> String {
switch id {
case "pending_transcriptions":
return "waveform.badge.mic"
case "calendar_due":
return "calendar.badge.plus"
default:
return "brain.head.profile"
}
}
private func channelLabel(_ value: String) -> String {
value.replacingOccurrences(of: "_", with: " ").capitalized
}
private func sourceLabel(_ value: String) -> String {
value.replacingOccurrences(of: "_", with: " ").capitalized
}
private 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"
}
}
#Preview {
CommunicationsView()
}