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

1224 lines
50 KiB
Swift

import Combine
import SwiftUI
enum OracleMode: String, CaseIterable {
case pipeline = "Pipeline"
case deals = "Deals"
case accountTimeline = "Account Timeline"
case calendarTasks = "Calendar & Tasks"
case teamPerformance = "Team Performance"
case leadMap = "Lead Map"
var icon: String {
switch self {
case .pipeline:
return "square.grid.3x1.below.line.grid.1x2"
case .deals:
return "target"
case .accountTimeline:
return "clock.arrow.circlepath"
case .calendarTasks:
return "calendar"
case .teamPerformance:
return "person.3"
case .leadMap:
return "map"
}
}
}
struct OracleView: View {
@State private var store = AppStore.shared
@State private var selectedMode: OracleMode = OracleModeAvailability.sanitizedProductionSelection(.pipeline)
@State private var selectedClient360: VelocityClient360DTO?
@State private var selectedClient360PersonID: String?
@State private var isClient360Loading = false
@State private var client360Error: String?
@State private var actionError: String?
@State private var actionMessage: String?
@State private var activeTaskMutationID: String?
@State private var activeLeadMutationID: String?
@State private var activeOpportunityMutationID: String?
private let refreshTimer = Timer.publish(every: 20, on: .main, in: .common).autoconnect()
var body: some View {
VStack(alignment: .leading, spacing: 0) {
header
.padding(.horizontal, 24)
.padding(.top, 24)
.padding(.bottom, 16)
modePicker
.padding(.horizontal, 24)
.padding(.bottom, 14)
if let error = store.errorMessage {
errorBanner(error)
.padding(.horizontal, 24)
.padding(.bottom, 14)
}
if let actionError {
errorBanner(actionError)
.padding(.horizontal, 24)
.padding(.bottom, 14)
}
if let actionMessage {
successBanner(actionMessage)
.padding(.horizontal, 24)
.padding(.bottom, 14)
}
ScrollView {
canvas
.padding(.horizontal, 24)
.padding(.bottom, 24)
}
}
.background(VelocityTheme.background)
.task { await store.refresh() }
.refreshable { await store.refresh() }
.onAppear {
selectedMode = OracleModeAvailability.sanitizedProductionSelection(selectedMode)
}
.onReceive(refreshTimer) { _ in
Task { await store.refresh(silent: true) }
}
.sheet(isPresented: client360PresentationBinding) {
client360Sheet
}
}
private var header: some View {
HStack {
VStack(alignment: .leading, spacing: 3) {
Text("Oracle")
.font(.system(size: 28, weight: .bold))
.foregroundStyle(VelocityTheme.foreground)
Text("Live sales intelligence assembled from canonical CRM, communication events, and calendar data.")
.font(.system(size: 12))
.foregroundStyle(VelocityTheme.mutedFg)
}
Spacer()
Text(store.lastRefreshAt?.relativeShort ?? "Awaiting sync")
.font(.system(size: 11, weight: .medium))
.foregroundStyle(VelocityTheme.mutedFg)
}
}
private var modePicker: some View {
ScrollView(.horizontal, showsIndicators: false) {
HStack(spacing: 10) {
ForEach(OracleModeAvailability.productionVisibleModes, id: \.self) { mode in
Button {
selectedMode = mode
} label: {
HStack(spacing: 6) {
Image(systemName: mode.icon)
Text(mode.rawValue)
}
.font(.system(size: 12, weight: .semibold))
.foregroundStyle(selectedMode == mode ? VelocityTheme.foreground : VelocityTheme.mutedFg)
.padding(.horizontal, 14)
.padding(.vertical, 10)
.background(
Capsule()
.fill(selectedMode == mode ? VelocityTheme.accent.opacity(0.16) : VelocityTheme.surface)
.overlay(
Capsule()
.stroke(selectedMode == mode ? VelocityTheme.borderAccent : VelocityTheme.borderSubtle, lineWidth: 1)
)
)
}
.buttonStyle(.plain)
}
}
}
}
@ViewBuilder
private var canvas: some View {
switch OracleModeAvailability.sanitizedProductionSelection(selectedMode) {
case .pipeline:
pipelineCanvas
case .deals:
dealsCanvas
case .accountTimeline:
timelineCanvas
case .calendarTasks:
calendarCanvas
case .teamPerformance, .leadMap:
unavailableCanvas(
title: "Oracle mode unavailable",
message: "This Oracle mode is intentionally hidden from the production iPad scope until a real backend contract exists."
)
}
}
private var pipelineCanvas: some View {
return VStack(alignment: .leading, spacing: 16) {
productionScopeNote
summaryCard(
title: "Pipeline Summary",
body: "This view reads the canonical `/api/crm/kanban` board directly, so the iPad pipeline reflects the real backend stage model and lane counts."
)
if store.kanbanColumns.allSatisfy({ $0.count == 0 }) {
emptyCard("No live canonical pipeline rows are available yet.")
} else {
ForEach(store.kanbanColumns) { column in
VStack(alignment: .leading, spacing: 12) {
HStack {
Text(column.label)
.font(.system(size: 16, weight: .semibold))
.foregroundStyle(VelocityTheme.foreground)
Spacer()
Text("\(column.count)")
.font(.system(size: 11, weight: .semibold))
.foregroundStyle(VelocityTheme.accent)
.padding(.horizontal, 10)
.padding(.vertical, 6)
.background(
Capsule()
.fill(VelocityTheme.accent.opacity(0.12))
.overlay(
Capsule()
.stroke(VelocityTheme.borderAccent, lineWidth: 1)
)
)
}
if column.items.isEmpty {
Text("No canonical leads are currently in this lane.")
.font(.system(size: 12))
.foregroundStyle(VelocityTheme.mutedFg)
}
ForEach(column.items) { lead in
HStack(alignment: .top, spacing: 12) {
Button {
openClient360(for: lead.personId)
} label: {
VStack(alignment: .leading, spacing: 6) {
Text(lead.clientName)
.font(.system(size: 14, weight: .semibold))
.foregroundStyle(VelocityTheme.foreground)
Text("\(lead.buyerTypeLabel) · \(lead.budgetSummary)")
.font(.system(size: 12))
.foregroundStyle(VelocityTheme.mutedFg)
Text("\(lead.urgencyLabel) urgency · \(lead.clientPhone ?? "No phone")")
.font(.system(size: 12))
.foregroundStyle(VelocityTheme.mutedFg)
}
.frame(maxWidth: .infinity, alignment: .leading)
}
.buttonStyle(.plain)
VStack(alignment: .trailing, spacing: 8) {
Text("\(lead.displayIntentScore)")
.font(.system(size: 12, weight: .bold))
.foregroundStyle(VelocityTheme.accent)
if activeLeadMutationID == lead.leadId {
ProgressView()
.progressViewStyle(CircularProgressViewStyle(tint: VelocityTheme.accent))
} else {
leadStageMenu(
leadId: lead.leadId,
personId: lead.personId,
currentStatus: column.status
)
}
}
}
.padding(14)
.frame(maxWidth: .infinity, alignment: .leading)
.background(
RoundedRectangle(cornerRadius: 14)
.fill(VelocityTheme.surface)
.overlay(
RoundedRectangle(cornerRadius: 14)
.stroke(VelocityTheme.borderSubtle, lineWidth: 1)
)
)
}
}
.padding(18)
.glassCard(cornerRadius: 18)
}
}
}
}
private var timelineCanvas: some View {
VStack(alignment: .leading, spacing: 16) {
productionScopeNote
summaryCard(
title: "Account Timeline",
body: "Recent communication events are pulled from the mobile-edge event stream for the highest-priority leads."
)
if store.timelineEvents.isEmpty {
emptyCard("No live communication events were returned for the current lead set.")
} else {
ForEach(store.timelineEvents.prefix(10)) { item in
Button {
openClient360(
personId: store.leads.first(where: { $0.id == item.leadId })?.personId,
missingContextMessage: "This timeline event is missing a client person_id, so Client 360 cannot be opened."
)
} label: {
VStack(alignment: .leading, spacing: 8) {
HStack {
Text(item.leadName)
.font(.system(size: 14, weight: .semibold))
.foregroundStyle(VelocityTheme.foreground)
Spacer()
Text(item.event.channel.replacingOccurrences(of: "_", with: " ").capitalized)
.font(.system(size: 10, weight: .semibold))
.foregroundStyle(VelocityTheme.accent)
}
Text(item.event.summary ?? "No summary available.")
.font(.system(size: 13))
.foregroundStyle(VelocityTheme.foreground)
Text(item.event.timestampDate?.relativeShort ?? item.event.timestamp)
.font(.system(size: 11))
.foregroundStyle(VelocityTheme.mutedFg)
}
.padding(16)
.frame(maxWidth: .infinity, alignment: .leading)
.glassCard(cornerRadius: 16)
}
.buttonStyle(.plain)
}
}
}
}
private var dealsCanvas: some View {
VStack(alignment: .leading, spacing: 16) {
productionScopeNote
summaryCard(
title: "Deals",
body: "This view reads canonical `/api/crm/opportunities` directly so the iPad can verify real opportunity rows from the backend."
)
if store.opportunities.isEmpty {
emptyCard("No live canonical opportunities are available yet for this operator scope.")
} else {
ForEach(store.opportunities.prefix(20)) { opportunity in
HStack(alignment: .top, spacing: 12) {
Button {
openClient360(
personId: opportunity.personId,
missingContextMessage: "This opportunity is missing a client person_id, so Client 360 cannot be opened."
)
} label: {
VStack(alignment: .leading, spacing: 8) {
Text(opportunity.clientName ?? "Canonical opportunity")
.font(.system(size: 14, weight: .semibold))
.foregroundStyle(VelocityTheme.foreground)
Text("\(opportunity.formattedValue) · \(opportunity.projectName ?? "Project pending")")
.font(.system(size: 12))
.foregroundStyle(VelocityTheme.foreground)
Text("\(opportunity.nextAction ?? "Next action pending") · \(opportunity.clientPhone ?? "No phone")")
.font(.system(size: 12))
.foregroundStyle(VelocityTheme.mutedFg)
Text(opportunity.probabilityLabel)
.font(.system(size: 11))
.foregroundStyle(VelocityTheme.mutedFg)
}
.frame(maxWidth: .infinity, alignment: .leading)
}
.buttonStyle(.plain)
VStack(alignment: .trailing, spacing: 8) {
Text(opportunity.stage.replacingOccurrences(of: "_", with: " ").capitalized)
.font(.system(size: 10, weight: .semibold))
.foregroundStyle(VelocityTheme.accent)
if activeOpportunityMutationID == opportunity.opportunityId {
ProgressView()
.progressViewStyle(CircularProgressViewStyle(tint: VelocityTheme.accent))
} else {
opportunityActionsMenu(opportunity)
}
}
}
.padding(16)
.frame(maxWidth: .infinity, alignment: .leading)
.glassCard(cornerRadius: 16)
}
}
}
}
private var calendarCanvas: some View {
VStack(alignment: .leading, spacing: 16) {
productionScopeNote
summaryCard(
title: "Calendar & Tasks",
body: "Confirmed operator calendar events and canonical CRM reminder tasks are shown here without synthetic filler."
)
if store.calendarEvents.isEmpty && store.prioritizedTasks.isEmpty {
emptyCard("No live calendar events or canonical CRM tasks are scheduled yet for this operator.")
} else {
if !store.calendarEvents.isEmpty {
VStack(alignment: .leading, spacing: 12) {
Text("Calendar Events")
.font(.system(size: 15, weight: .semibold))
.foregroundStyle(VelocityTheme.foreground)
ForEach(store.calendarEvents.prefix(10)) { event in
VStack(alignment: .leading, spacing: 8) {
HStack {
Text(event.title)
.font(.system(size: 14, weight: .semibold))
.foregroundStyle(VelocityTheme.foreground)
Spacer()
Text(event.status.capitalized)
.font(.system(size: 10, weight: .semibold))
.foregroundStyle(color(for: event.status))
}
Text(formattedDateRange(event))
.font(.system(size: 12))
.foregroundStyle(VelocityTheme.foreground)
Text(event.location ?? "No location")
.font(.system(size: 12))
.foregroundStyle(VelocityTheme.mutedFg)
}
.padding(16)
.frame(maxWidth: .infinity, alignment: .leading)
.glassCard(cornerRadius: 16)
}
}
}
if !store.prioritizedTasks.isEmpty {
VStack(alignment: .leading, spacing: 12) {
Text("Pending Follow-Up Tasks")
.font(.system(size: 15, weight: .semibold))
.foregroundStyle(VelocityTheme.foreground)
ForEach(store.prioritizedTasks.prefix(8)) { task in
HStack(alignment: .top, spacing: 12) {
Button {
openClient360(
personId: task.personId,
missingContextMessage: "This task is missing a client person_id, so Client 360 cannot be opened."
)
} label: {
VStack(alignment: .leading, spacing: 8) {
Text(task.title)
.font(.system(size: 14, weight: .semibold))
.foregroundStyle(VelocityTheme.foreground)
Text(task.dueLabel)
.font(.system(size: 12))
.foregroundStyle(VelocityTheme.foreground)
Text("\(task.ownerLabel) · \(task.clientPhone ?? "No phone")")
.font(.system(size: 12))
.foregroundStyle(VelocityTheme.mutedFg)
Text(taskNote(task))
.font(.system(size: 12))
.foregroundStyle(VelocityTheme.mutedFg)
.lineLimit(2)
}
.frame(maxWidth: .infinity, alignment: .leading)
}
.buttonStyle(.plain)
VStack(alignment: .trailing, spacing: 8) {
Text(task.priorityLabel)
.font(.system(size: 10, weight: .semibold))
.foregroundStyle(taskColor(for: task.priority))
if activeTaskMutationID == task.reminderId {
ProgressView()
.progressViewStyle(CircularProgressViewStyle(tint: VelocityTheme.accent))
} else {
taskActionsMenu(task)
}
}
}
.padding(16)
.frame(maxWidth: .infinity, alignment: .leading)
.glassCard(cornerRadius: 16)
}
}
}
}
}
}
private var productionScopeNote: some View {
VStack(alignment: .leading, spacing: 8) {
Text("Mobile Oracle Scope")
.font(.system(size: 13, weight: .semibold))
.foregroundStyle(VelocityTheme.foreground)
Text("This production iPad build shows only live-backed Oracle views. Team Performance and Lead Map stay hidden until the backend exposes real mobile contracts.")
.font(.system(size: 12))
.foregroundStyle(VelocityTheme.mutedFg)
}
.padding(16)
.frame(maxWidth: .infinity, alignment: .leading)
.glassCard(cornerRadius: 16)
}
private func unavailableCanvas(title: String, message: String) -> some View {
VStack(alignment: .leading, spacing: 16) {
summaryCard(title: title, body: message)
}
}
private func summaryCard(title: String, body: String) -> some View {
VStack(alignment: .leading, spacing: 8) {
Text(title)
.font(.system(size: 16, weight: .semibold))
.foregroundStyle(VelocityTheme.foreground)
Text(body)
.font(.system(size: 13))
.foregroundStyle(VelocityTheme.mutedFg)
}
.padding(18)
.frame(maxWidth: .infinity, alignment: .leading)
.glassCard(cornerRadius: 18)
}
private func emptyCard(_ message: String) -> some View {
Text(message)
.font(.system(size: 13))
.foregroundStyle(VelocityTheme.mutedFg)
.padding(18)
.frame(maxWidth: .infinity, alignment: .leading)
.glassCard(cornerRadius: 18)
}
private func color(for status: String) -> Color {
switch status.lowercased() {
case "confirmed":
return VelocityTheme.success
case "tentative":
return VelocityTheme.warning
default:
return VelocityTheme.accent
}
}
private func taskColor(for priority: String) -> Color {
switch priority.lowercased() {
case "urgent":
return VelocityTheme.danger
case "high":
return VelocityTheme.warning
default:
return VelocityTheme.accent
}
}
private func taskNote(_ task: VelocityTaskDTO) -> String {
let note = task.notes?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
return note.isEmpty ? "No reminder note provided." : note
}
private func formattedDateRange(_ event: VelocityCalendarEventDTO) -> String {
guard let start = event.startDate else { return event.startAt }
let formatter = DateFormatter()
formatter.dateFormat = "EEE, MMM d · h:mm a"
return formatter.string(from: start)
}
private func opportunityActionsMenu(_ opportunity: VelocityOpportunityDTO) -> some View {
Menu {
Menu("Move Stage") {
ForEach(canonicalOpportunityStages.filter { $0 != opportunity.stage.lowercased() }, id: \.self) { stage in
Button {
mutateOpportunity(
opportunity,
stage: stage,
probability: nil,
nextAction: opportunity.nextAction,
notes: "Moved from the iPad Oracle deal workspace."
)
} label: {
Text(stageLabel(stage))
}
}
}
Menu("Set Probability") {
ForEach([25, 50, 75, 90], id: \.self) { probability in
Button {
mutateOpportunity(
opportunity,
stage: nil,
probability: probability,
nextAction: opportunity.nextAction,
notes: "Probability updated from the iPad Oracle deal workspace."
)
} label: {
Text("\(probability)%")
}
}
}
Button {
mutateOpportunity(
opportunity,
stage: nil,
probability: nil,
nextAction: "Schedule commercial follow-up",
notes: "Next action updated from the iPad Oracle deal workspace."
)
} label: {
Label("Set Follow-Up Action", systemImage: "phone.arrow.up.right")
}
Button {
mutateOpportunity(
opportunity,
stage: "closed_won",
probability: 100,
nextAction: "Complete booking documentation",
notes: "Marked closed won from the iPad Oracle deal workspace."
)
} label: {
Label("Close Won", systemImage: "checkmark.seal")
}
Button(role: .destructive) {
mutateOpportunity(
opportunity,
stage: "closed_lost",
probability: 0,
nextAction: "Capture loss reason",
notes: "Marked closed lost from the iPad Oracle deal workspace."
)
} label: {
Label("Close Lost", systemImage: "xmark.seal")
}
} label: {
menuIcon("ellipsis.circle")
}
}
private func taskActionsMenu(_ task: VelocityTaskDTO) -> some View {
Menu {
if task.status.lowercased() != "done" {
Button {
mutateTask(task, status: "done", dueAt: nil, notes: "Marked done from the iPad Oracle workspace.")
} label: {
Label("Mark Done", systemImage: "checkmark.circle")
}
}
if task.status.lowercased() != "pending" {
Button {
mutateTask(task, status: "pending", dueAt: task.dueAt, notes: "Reopened from the iPad Oracle workspace.")
} label: {
Label("Reopen", systemImage: "arrow.counterclockwise")
}
}
if !["done", "cancelled"].contains(task.status.lowercased()) {
Button {
let snoozeDate = task.nextSnoozeDate(adding: 2 * 60 * 60)
mutateTask(
task,
status: "snoozed",
dueAt: iso8601Timestamp(snoozeDate),
notes: "Snoozed by 2 hours from the iPad Oracle workspace."
)
} label: {
Label("Snooze 2h", systemImage: "clock.badge")
}
Button {
let snoozeDate = task.nextSnoozeDate(adding: 24 * 60 * 60)
mutateTask(
task,
status: "snoozed",
dueAt: iso8601Timestamp(snoozeDate),
notes: "Snoozed by 1 day from the iPad Oracle workspace."
)
} label: {
Label("Snooze 1 Day", systemImage: "moon")
}
}
if task.status.lowercased() != "cancelled" {
Button(role: .destructive) {
mutateTask(task, status: "cancelled", dueAt: nil, notes: "Cancelled from the iPad Oracle workspace.")
} label: {
Label("Cancel Task", systemImage: "xmark.circle")
}
}
} label: {
menuIcon("ellipsis.circle")
}
}
private func leadStageMenu(
leadId: String,
personId: String,
currentStatus: String
) -> some View {
Menu {
ForEach(canonicalLeadStages.filter { $0 != currentStatus.lowercased() }, id: \.self) { status in
Button {
mutateLeadStage(
leadId: leadId,
personId: personId,
status: status,
notes: "Moved from the iPad Oracle pipeline."
)
} label: {
Text(stageLabel(status))
}
}
} label: {
menuIcon("arrow.triangle.swap")
}
}
private func mutateTask(
_ task: VelocityTaskDTO,
status: String,
dueAt: String?,
notes: String
) {
actionError = nil
actionMessage = nil
activeTaskMutationID = task.reminderId
Task {
do {
_ = try await store.updateTaskStatus(
reminderId: task.reminderId,
status: status,
dueAt: dueAt,
notes: notes
)
await refreshClient360IfNeeded(for: task.personId ?? selectedClient360PersonID)
await MainActor.run {
activeTaskMutationID = nil
actionMessage = taskActionMessage(status: status)
}
} catch {
await MainActor.run {
activeTaskMutationID = nil
actionError = error.localizedDescription
}
}
}
}
private func mutateLeadStage(
leadId: String,
personId: String,
status: String,
notes: String
) {
actionError = nil
actionMessage = nil
activeLeadMutationID = leadId
Task {
do {
_ = try await store.updateLeadStage(
leadId: leadId,
status: status,
notes: notes
)
await refreshClient360IfNeeded(for: personId)
await MainActor.run {
activeLeadMutationID = nil
actionMessage = "Lead moved to \(stageLabel(status))."
}
} catch {
await MainActor.run {
activeLeadMutationID = nil
actionError = error.localizedDescription
}
}
}
}
private func mutateOpportunity(
_ opportunity: VelocityOpportunityDTO,
stage: String?,
probability: Int?,
nextAction: String?,
notes: String
) {
actionError = nil
actionMessage = nil
activeOpportunityMutationID = opportunity.opportunityId
Task {
do {
_ = try await store.updateOpportunity(
opportunityId: opportunity.opportunityId,
stage: stage,
probability: probability,
nextAction: nextAction,
notes: notes
)
await refreshClient360IfNeeded(for: opportunity.personId ?? selectedClient360PersonID)
await MainActor.run {
activeOpportunityMutationID = nil
actionMessage = opportunityActionMessage(stage: stage, probability: probability, nextAction: nextAction)
}
} catch {
await MainActor.run {
activeOpportunityMutationID = nil
actionError = error.localizedDescription
}
}
}
}
private func refreshClient360IfNeeded(for personId: String?) async {
guard let personId, selectedClient360PersonID == personId else {
return
}
do {
let snapshot = try await VelocityAPIClient.shared.fetchClient360(personId: personId)
await MainActor.run {
selectedClient360 = snapshot
client360Error = nil
}
} catch {
await MainActor.run {
client360Error = error.localizedDescription
}
}
}
private var canonicalLeadStages: [String] {
[
"new",
"contacted",
"qualified",
"site_visit_scheduled",
"site_visited",
"negotiation",
"booking_initiated",
"booked",
"lost",
"dormant",
]
}
private var canonicalOpportunityStages: [String] {
[
"prospect",
"qualified",
"proposal",
"site_visit",
"negotiation",
"booking",
"agreement",
"closed_won",
"closed_lost",
]
}
private func stageLabel(_ status: String) -> String {
status.replacingOccurrences(of: "_", with: " ").capitalized
}
private func iso8601Timestamp(_ date: Date) -> String {
ISO8601DateFormatter().string(from: date)
}
private var client360PresentationBinding: Binding<Bool> {
Binding(
get: { selectedClient360PersonID != nil },
set: { isPresented in
if !isPresented {
selectedClient360PersonID = nil
selectedClient360 = nil
client360Error = nil
actionMessage = nil
isClient360Loading = false
}
}
)
}
@ViewBuilder
private var client360Sheet: some View {
NavigationStack {
ScrollView {
VStack(alignment: .leading, spacing: 18) {
if isClient360Loading {
ProgressView()
.progressViewStyle(CircularProgressViewStyle(tint: VelocityTheme.accent))
.frame(maxWidth: .infinity, alignment: .center)
.padding(.top, 40)
} else {
if let client360Error {
errorBanner(client360Error)
}
if let actionError {
errorBanner(actionError)
}
if let actionMessage {
successBanner(actionMessage)
}
if let snapshot = selectedClient360 {
client360IdentityCard(snapshot)
client360LeadCard(snapshot)
client360OpportunitiesCard(snapshot)
client360TasksCard(snapshot)
client360InteractionsCard(snapshot)
client360SignalsCard(snapshot)
}
}
}
.padding(20)
}
.background(VelocityTheme.background)
.navigationTitle("Client 360")
.toolbar {
ToolbarItem(placement: .topBarTrailing) {
Button("Done") {
selectedClient360PersonID = nil
selectedClient360 = nil
client360Error = nil
actionMessage = nil
isClient360Loading = false
}
}
}
}
}
private func openClient360(for personId: String) {
selectedClient360PersonID = personId
selectedClient360 = nil
client360Error = nil
actionError = nil
actionMessage = nil
isClient360Loading = true
Task {
do {
let snapshot = try await VelocityAPIClient.shared.fetchClient360(personId: personId)
await MainActor.run {
selectedClient360 = snapshot
client360Error = nil
isClient360Loading = false
}
} catch {
await MainActor.run {
selectedClient360 = nil
client360Error = error.localizedDescription
isClient360Loading = false
}
}
}
}
private func openClient360(personId: String?, missingContextMessage: String) {
guard let personId, !personId.isEmpty else {
actionError = missingContextMessage
actionMessage = nil
return
}
openClient360(for: personId)
}
private func client360IdentityCard(_ snapshot: VelocityClient360DTO) -> some View {
VStack(alignment: .leading, spacing: 10) {
Text(snapshot.identity.fullName)
.font(.system(size: 24, weight: .bold))
.foregroundStyle(VelocityTheme.foreground)
Text("\(snapshot.identity.buyerType.map { $0.replacingOccurrences(of: "_", with: " ").capitalized } ?? "CRM contact") · \(snapshot.identity.primaryPhone ?? "No phone")")
.font(.system(size: 13))
.foregroundStyle(VelocityTheme.mutedFg)
if let email = snapshot.identity.primaryEmail {
Text(email)
.font(.system(size: 12))
.foregroundStyle(VelocityTheme.mutedFg)
}
if !snapshot.identity.personaLabels.isEmpty {
Text(snapshot.identity.personaLabels.joined(separator: " · "))
.font(.system(size: 12, weight: .medium))
.foregroundStyle(VelocityTheme.accent)
}
}
.padding(18)
.frame(maxWidth: .infinity, alignment: .leading)
.glassCard(cornerRadius: 18)
}
private func client360LeadCard(_ snapshot: VelocityClient360DTO) -> some View {
VStack(alignment: .leading, spacing: 10) {
HStack {
Text("Lead Posture")
.font(.system(size: 16, weight: .semibold))
.foregroundStyle(VelocityTheme.foreground)
Spacer()
if let lead = snapshot.currentLead {
if activeLeadMutationID == lead.leadId {
ProgressView()
.progressViewStyle(CircularProgressViewStyle(tint: VelocityTheme.accent))
} else {
leadStageMenu(
leadId: lead.leadId,
personId: snapshot.identity.personId,
currentStatus: lead.status
)
}
}
}
if let lead = snapshot.currentLead {
Text("\(lead.status.replacingOccurrences(of: "_", with: " ").capitalized) · \(lead.urgency?.capitalized ?? "Normal") urgency")
.font(.system(size: 13))
.foregroundStyle(VelocityTheme.foreground)
Text(lead.budgetBand ?? "Budget pending")
.font(.system(size: 12))
.foregroundStyle(VelocityTheme.mutedFg)
if !lead.motivations.isEmpty {
Text("Motivations: \(lead.motivations.joined(separator: ", "))")
.font(.system(size: 12))
.foregroundStyle(VelocityTheme.mutedFg)
}
if !lead.objections.isEmpty {
Text("Objections: \(lead.objections.joined(separator: ", "))")
.font(.system(size: 12))
.foregroundStyle(VelocityTheme.mutedFg)
}
} else {
Text("No active canonical lead context was returned for this client.")
.font(.system(size: 12))
.foregroundStyle(VelocityTheme.mutedFg)
}
}
.padding(18)
.frame(maxWidth: .infinity, alignment: .leading)
.glassCard(cornerRadius: 18)
}
private func client360OpportunitiesCard(_ snapshot: VelocityClient360DTO) -> some View {
VStack(alignment: .leading, spacing: 10) {
Text("Active Opportunities")
.font(.system(size: 16, weight: .semibold))
.foregroundStyle(VelocityTheme.foreground)
if snapshot.activeOpportunities.isEmpty {
Text("No active opportunities are currently attached to this client.")
.font(.system(size: 12))
.foregroundStyle(VelocityTheme.mutedFg)
} else {
ForEach(snapshot.activeOpportunities) { opportunity in
HStack(alignment: .top, spacing: 12) {
VStack(alignment: .leading, spacing: 6) {
Text(opportunity.stage.replacingOccurrences(of: "_", with: " ").capitalized)
.font(.system(size: 13, weight: .semibold))
.foregroundStyle(VelocityTheme.foreground)
Text("\(opportunity.formattedValue) · \(opportunity.nextAction ?? "Next action pending")")
.font(.system(size: 12))
.foregroundStyle(VelocityTheme.mutedFg)
Text(opportunity.probabilityLabel)
.font(.system(size: 11))
.foregroundStyle(VelocityTheme.mutedFg)
}
Spacer()
if activeOpportunityMutationID == opportunity.opportunityId {
ProgressView()
.progressViewStyle(CircularProgressViewStyle(tint: VelocityTheme.accent))
} else {
opportunityActionsMenu(opportunity)
}
}
.padding(.vertical, 4)
}
}
}
.padding(18)
.frame(maxWidth: .infinity, alignment: .leading)
.glassCard(cornerRadius: 18)
}
private func client360TasksCard(_ snapshot: VelocityClient360DTO) -> some View {
VStack(alignment: .leading, spacing: 10) {
Text("Follow-Up Tasks")
.font(.system(size: 16, weight: .semibold))
.foregroundStyle(VelocityTheme.foreground)
if snapshot.tasks.isEmpty {
Text("No pending or snoozed tasks were returned for this client.")
.font(.system(size: 12))
.foregroundStyle(VelocityTheme.mutedFg)
} else {
ForEach(snapshot.tasks) { task in
HStack(alignment: .top, spacing: 12) {
VStack(alignment: .leading, spacing: 6) {
Text(task.title)
.font(.system(size: 13, weight: .semibold))
.foregroundStyle(VelocityTheme.foreground)
Text("\(task.priorityLabel) · \(task.dueLabel)")
.font(.system(size: 12))
.foregroundStyle(VelocityTheme.mutedFg)
}
Spacer()
if activeTaskMutationID == task.reminderId {
ProgressView()
.progressViewStyle(CircularProgressViewStyle(tint: VelocityTheme.accent))
} else {
taskActionsMenu(task)
}
}
.padding(.vertical, 4)
}
}
}
.padding(18)
.frame(maxWidth: .infinity, alignment: .leading)
.glassCard(cornerRadius: 18)
}
private func client360InteractionsCard(_ snapshot: VelocityClient360DTO) -> some View {
VStack(alignment: .leading, spacing: 10) {
Text("Recent Interactions")
.font(.system(size: 16, weight: .semibold))
.foregroundStyle(VelocityTheme.foreground)
if snapshot.recentInteractions.isEmpty {
Text("No recent canonical interactions were returned for this client.")
.font(.system(size: 12))
.foregroundStyle(VelocityTheme.mutedFg)
} else {
ForEach(snapshot.recentInteractions.prefix(5)) { interaction in
VStack(alignment: .leading, spacing: 6) {
Text(interaction.channel.replacingOccurrences(of: "_", with: " ").capitalized)
.font(.system(size: 13, weight: .semibold))
.foregroundStyle(VelocityTheme.foreground)
Text(interaction.summary ?? interaction.interactionType.replacingOccurrences(of: "_", with: " ").capitalized)
.font(.system(size: 12))
.foregroundStyle(VelocityTheme.mutedFg)
}
.padding(.vertical, 4)
}
}
}
.padding(18)
.frame(maxWidth: .infinity, alignment: .leading)
.glassCard(cornerRadius: 18)
}
private func client360SignalsCard(_ snapshot: VelocityClient360DTO) -> some View {
VStack(alignment: .leading, spacing: 10) {
Text("Signals And Actions")
.font(.system(size: 16, weight: .semibold))
.foregroundStyle(VelocityTheme.foreground)
if let score = snapshot.primaryQDScore {
Text("\(score.scoreType.replacingOccurrences(of: "_", with: " ").capitalized) score: \(score.displayScore)")
.font(.system(size: 12))
.foregroundStyle(VelocityTheme.foreground)
}
if !snapshot.riskFlags.isEmpty {
Text("Risk flags: \(snapshot.riskFlags.joined(separator: ", "))")
.font(.system(size: 12))
.foregroundStyle(VelocityTheme.mutedFg)
}
if !snapshot.recommendedNextActions.isEmpty {
Text("Next actions: \(snapshot.recommendedNextActions.joined(separator: " · "))")
.font(.system(size: 12))
.foregroundStyle(VelocityTheme.mutedFg)
}
if !snapshot.propertyInterests.isEmpty {
Text("Primary interest: \(snapshot.propertyInterests[0].projectName)")
.font(.system(size: 12))
.foregroundStyle(VelocityTheme.mutedFg)
}
}
.padding(18)
.frame(maxWidth: .infinity, alignment: .leading)
.glassCard(cornerRadius: 18)
}
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 successBanner(_ message: String) -> some View {
Text(message)
.font(.system(size: 13, weight: .medium))
.foregroundStyle(VelocityTheme.success)
.padding(14)
.frame(maxWidth: .infinity, alignment: .leading)
.background(
RoundedRectangle(cornerRadius: 14)
.fill(VelocityTheme.success.opacity(0.10))
.overlay(
RoundedRectangle(cornerRadius: 14)
.stroke(VelocityTheme.success.opacity(0.22), lineWidth: 1)
)
)
}
private func taskActionMessage(status: String) -> String {
switch status {
case "done":
return "Task marked done."
case "pending":
return "Task reopened."
case "snoozed":
return "Task snoozed."
case "cancelled":
return "Task cancelled."
default:
return "Task updated."
}
}
private func opportunityActionMessage(stage: String?, probability: Int?, nextAction: String?) -> String {
if let stage {
return "Opportunity moved to \(stageLabel(stage))."
}
if let probability {
return "Opportunity probability set to \(probability)%."
}
if nextAction != nil {
return "Opportunity follow-up action updated."
}
return "Opportunity updated."
}
private func menuIcon(_ systemName: String) -> some View {
Image(systemName: systemName)
.font(.system(size: 17, weight: .semibold))
.foregroundStyle(VelocityTheme.mutedFg)
.frame(width: 44, height: 44)
.contentShape(Rectangle())
}
}
#Preview {
OracleView()
}