326 lines
13 KiB
Swift
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()
|
|
}
|