forked from sagnik/Project_Velocity
Co-authored-by: Sayan Datta <sayan@Sayans-MacBook-Air.local> Reviewed-on: sagnik/Project_Velocity#41
456 lines
18 KiB
Swift
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()
|
|
}
|