Files
Project_Velocity/iOS/velocity/velocity/Features/Oracle/OracleView.swift
2026-04-20 00:46:21 +05:30

326 lines
13 KiB
Swift

import SwiftUI
enum OracleMode: String, CaseIterable {
case pipeline = "Pipeline"
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 .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 = .pipeline
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)
}
ScrollView {
canvas
.padding(.horizontal, 24)
.padding(.bottom, 24)
}
}
.background(VelocityTheme.background)
.task { await store.refresh() }
.refreshable { await store.refresh() }
.onReceive(refreshTimer) { _ in
Task { await store.refresh(silent: true) }
}
}
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 leads, 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(OracleMode.allCases, 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 selectedMode {
case .pipeline:
pipelineCanvas
case .accountTimeline:
timelineCanvas
case .calendarTasks:
calendarCanvas
case .teamPerformance:
unavailableCanvas(
title: "Broker performance feed unavailable",
message: "The current mobile contract does not expose broker-attributed performance rollups yet, so Oracle avoids inventing team metrics here."
)
case .leadMap:
unavailableCanvas(
title: "Lead map route unavailable",
message: "No production geography route exists for mobile Oracle yet. This view stays disabled until a real geo-backed endpoint is added."
)
}
}
private var pipelineCanvas: some View {
let grouped = Dictionary(grouping: store.leads, by: { $0.kanbanStatus.replacingOccurrences(of: "_", with: " ").capitalized })
let stages = grouped.keys.sorted()
return VStack(alignment: .leading, spacing: 16) {
summaryCard(
title: "Pipeline Summary",
body: "This view groups live CRM leads by current kanban status. Whale leads and high-score opportunities float to the top of each lane."
)
if stages.isEmpty {
emptyCard("No live pipeline rows are available yet.")
} else {
ForEach(stages, id: \.self) { stage in
VStack(alignment: .leading, spacing: 12) {
Text(stage)
.font(.system(size: 16, weight: .semibold))
.foregroundStyle(VelocityTheme.foreground)
ForEach((grouped[stage] ?? []).sorted(by: { $0.score > $1.score })) { lead in
VStack(alignment: .leading, spacing: 6) {
HStack {
Text(lead.name)
.font(.system(size: 14, weight: .semibold))
.foregroundStyle(VelocityTheme.foreground)
Spacer()
Text("\(lead.score)")
.font(.system(size: 12, weight: .bold))
.foregroundStyle(VelocityTheme.accent)
}
Text("\(lead.qualification.capitalized) · \(lead.unitInterest)")
.font(.system(size: 12))
.foregroundStyle(VelocityTheme.mutedFg)
Text(lead.budget)
.font(.system(size: 12))
.foregroundStyle(VelocityTheme.mutedFg)
}
.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) {
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
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)
}
}
}
}
private var calendarCanvas: some View {
VStack(alignment: .leading, spacing: 16) {
summaryCard(
title: "Calendar & Tasks",
body: "Confirmed operator calendar events from the live backend appear here without any synthesized task filler."
)
if store.calendarEvents.isEmpty {
emptyCard("No live calendar events are scheduled yet for this operator.")
} else {
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)
}
}
}
}
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 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 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)
)
)
}
}
#Preview {
OracleView()
}