import AVFoundation import AudioToolbox import Speech 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 OracleConciergeSheet: View { @State private var transcript = "" @State private var resultText = "" @State private var errorText: String? @State private var isRecording = false @State private var isQuerying = false @State private var speechAuthorized = false @State private var audioEngine = AVAudioEngine() @State private var recognitionRequest: SFSpeechAudioBufferRecognitionRequest? @State private var recognitionTask: SFSpeechRecognitionTask? private let recognizer = SFSpeechRecognizer() var body: some View { VStack(spacing: 0) { VStack(alignment: .leading, spacing: 14) { HStack { VStack(alignment: .leading, spacing: 4) { Text("Oracle Concierge") .font(.system(size: 26, weight: .semibold)) .foregroundStyle(VelocityTheme.foreground) Text("Push to talk. Live query routes to `/api/oracle/query`.") .font(.system(size: 12)) .foregroundStyle(VelocityTheme.mutedFg) } Spacer() Button { isRecording ? stopRecordingAndQuery() : startRecording() } label: { Image(systemName: isRecording ? "stop.fill" : "mic.fill") .font(.system(size: 20, weight: .semibold)) .foregroundStyle(.white) .frame(width: 54, height: 54) .background(Circle().fill(isRecording ? VelocityTheme.danger : VelocityTheme.accent)) .shadow(color: (isRecording ? VelocityTheme.danger : VelocityTheme.accent).opacity(0.45), radius: isRecording ? 18 : 10) } .buttonStyle(.plain) .disabled(!speechAuthorized || isQuerying) } if !transcript.isEmpty { Text(transcript) .font(.system(size: 15, weight: .medium)) .foregroundStyle(VelocityTheme.foreground) .padding(14) .frame(maxWidth: .infinity, alignment: .leading) .background(RoundedRectangle(cornerRadius: 14).fill(VelocityTheme.surface)) .transition(.opacity.combined(with: .scale)) } if isQuerying { ProgressView("Asking Oracle...") .progressViewStyle(CircularProgressViewStyle(tint: VelocityTheme.accent)) .foregroundStyle(VelocityTheme.mutedFg) } else if !resultText.isEmpty { Text(resultText) .font(.system(size: 13)) .foregroundStyle(VelocityTheme.foreground) .padding(14) .frame(maxWidth: .infinity, alignment: .leading) .background(RoundedRectangle(cornerRadius: 14).fill(VelocityTheme.accent.opacity(0.10))) .transition(.move(edge: .top).combined(with: .opacity)) } if let errorText { Text(errorText) .font(.system(size: 12, weight: .medium)) .foregroundStyle(VelocityTheme.danger) } } .padding(24) Divider().background(VelocityTheme.borderSubtle) OracleView() } .background(VelocityTheme.background) .task { await requestSpeechAuthorization() } .animation(.interactiveSpring(response: 0.42, dampingFraction: 0.86), value: isRecording) .animation(.interactiveSpring(response: 0.42, dampingFraction: 0.86), value: resultText) } private func requestSpeechAuthorization() async { let status = await withCheckedContinuation { continuation in SFSpeechRecognizer.requestAuthorization { continuation.resume(returning: $0) } } await MainActor.run { speechAuthorized = status == .authorized if !speechAuthorized { errorText = "Speech recognition permission is required for voice Oracle." } } } private func startRecording() { recognitionTask?.cancel() recognitionTask = nil transcript = "" resultText = "" errorText = nil let session = AVAudioSession.sharedInstance() do { try session.setCategory(.record, mode: .measurement, options: .duckOthers) try session.setActive(true, options: .notifyOthersOnDeactivation) let request = SFSpeechAudioBufferRecognitionRequest() request.shouldReportPartialResults = true recognitionRequest = request let inputNode = audioEngine.inputNode let format = inputNode.outputFormat(forBus: 0) inputNode.removeTap(onBus: 0) inputNode.installTap(onBus: 0, bufferSize: 1024, format: format) { buffer, _ in request.append(buffer) } audioEngine.prepare() try audioEngine.start() withAnimation(.interactiveSpring(response: 0.35, dampingFraction: 0.82)) { isRecording = true } recognitionTask = recognizer?.recognitionTask(with: request) { result, error in Task { @MainActor in if let result { transcript = result.bestTranscription.formattedString } if let error { errorText = error.localizedDescription stopRecording() } } } } catch { errorText = error.localizedDescription stopRecording() } } private func stopRecordingAndQuery() { stopRecording() let prompt = transcript.trimmingCharacters(in: .whitespacesAndNewlines) guard !prompt.isEmpty else { return } Task { await queryOracle(prompt) } } private func stopRecording() { audioEngine.stop() audioEngine.inputNode.removeTap(onBus: 0) recognitionRequest?.endAudio() recognitionTask?.cancel() recognitionRequest = nil recognitionTask = nil withAnimation(.interactiveSpring(response: 0.35, dampingFraction: 0.82)) { isRecording = false } } @MainActor private func queryOracle(_ prompt: String) async { isQuerying = true errorText = nil do { let response = try await VelocityAPIClient.shared.queryOracle(prompt: prompt) resultText = response.displaySummary } catch { errorText = error.localizedDescription } isQuerying = false } } struct OracleView: View { @State private var store = AppStore.shared @State private var selectedMode: OracleMode = OracleModeAvailability.sanitizedProductionSelection(.accountTimeline) @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? @State private var editingOpportunity: VelocityOpportunityDTO? @State private var teamPerformance: VelocityOracleTeamPerformanceDTO? @State private var leadMap: VelocityOracleLeadMapDTO? @State private var isOracleInsightLoading = false @State private var oracleInsightError: String? 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() await loadOracleInsightData(for: selectedMode) } .refreshable { await store.refresh() await loadOracleInsightData(for: selectedMode) } .onAppear { selectedMode = OracleModeAvailability.sanitizedProductionSelection(selectedMode) } .onChange(of: selectedMode) { _, mode in Task { await loadOracleInsightData(for: mode) } } .sheet(isPresented: client360PresentationBinding) { client360Sheet } .sheet(item: $editingOpportunity) { opportunity in OpportunityEditSheet( opportunity: opportunity, stages: store.crmVocabularies.opportunityStages ) { stage, value, probability, expectedCloseDate, nextAction, notes in saveOpportunityEdits( opportunity, stage: stage, value: value, probability: probability, expectedCloseDate: expectedCloseDate, nextAction: nextAction, notes: notes ) } } } private var header: some View { HStack { VStack(alignment: .leading, spacing: 3) { Text("Oracle") .font(.system(size: 28, weight: .bold)) .foregroundStyle(VelocityTheme.foreground) Text("Ambient 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: teamPerformanceCanvas case .leadMap: leadMapCanvas } } 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) { if store.isShowroomModeEnabled { Image(systemName: "eye.slash") .font(.system(size: 12, weight: .semibold)) .foregroundStyle(VelocityTheme.warning) } else { 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() if store.pendingSyncCalendarEventIDs.contains(event.calendarEventId) || event.calendarEventId.hasPrefix("local-") { pendingSyncBadge } 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) if !store.isShowroomModeEnabled { Text(taskNote(task)) .font(.system(size: 12)) .foregroundStyle(VelocityTheme.mutedFg) .lineLimit(2) } } .frame(maxWidth: .infinity, alignment: .leading) } .buttonStyle(.plain) VStack(alignment: .trailing, spacing: 8) { if store.pendingSyncTaskIDs.contains(task.reminderId) { pendingSyncBadge } 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 live-backed Oracle views only. Team Performance and Lead Map now read dedicated mobile Oracle contracts instead of synthetic local projections.") .font(.system(size: 12)) .foregroundStyle(VelocityTheme.mutedFg) } .padding(16) .frame(maxWidth: .infinity, alignment: .leading) .glassCard(cornerRadius: 16) } private var pendingSyncBadge: some View { Circle() .fill(VelocityTheme.warning) .frame(width: 8, height: 8) .shadow(color: VelocityTheme.warning.opacity(0.75), radius: 6) } 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(.vertical, 10) .frame(maxWidth: .infinity, alignment: .leading) } private func emptyCard(_ message: String) -> some View { Text(message) .font(.system(size: 13)) .foregroundStyle(VelocityTheme.mutedFg) .padding(.vertical, 14) .frame(maxWidth: .infinity, alignment: .leading) } 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 var teamPerformanceCanvas: some View { VStack(alignment: .leading, spacing: 16) { productionScopeNote summaryCard( title: "Team Performance", body: "Broker performance is read from canonical users, leads, opportunities, reminders, and interaction activity through `/api/oracle/v1/mobile/team-performance`." ) if isOracleInsightLoading && teamPerformance == nil { progressCard("Loading team performance...") } else if let oracleInsightError { errorBanner(oracleInsightError) } else if let teamPerformance, !teamPerformance.performers.isEmpty { HStack(spacing: 12) { metricPill("Members", "\(teamPerformance.summary.teamMembers)") metricPill("Assigned", "\(teamPerformance.summary.assignedLeads)") metricPill("Open Tasks", "\(teamPerformance.summary.openTasks)") metricPill("Pipeline", moneyLabel(teamPerformance.summary.pipelineValue)) } ForEach(teamPerformance.performers) { performer in VStack(alignment: .leading, spacing: 10) { HStack { VStack(alignment: .leading, spacing: 4) { Text(performer.name) .font(.system(size: 15, weight: .semibold)) .foregroundStyle(VelocityTheme.foreground) Text(performer.email ?? "No email") .font(.system(size: 11)) .foregroundStyle(VelocityTheme.mutedFg) } Spacer() Text("\(Int(performer.conversionRate.rounded()))%") .font(.system(size: 13, weight: .bold)) .foregroundStyle(VelocityTheme.success) } HStack(spacing: 10) { metricPill("Leads", "\(performer.assignedLeads)") metricPill("Deals", "\(performer.activeOpportunities)") metricPill("Tasks", "\(performer.openTasks)") metricPill("Won", moneyLabel(performer.closedWonValue)) } Text("Last activity \(performer.lastActivityAt ?? "not recorded")") .font(.system(size: 11)) .foregroundStyle(VelocityTheme.mutedFg) } .padding(16) .frame(maxWidth: .infinity, alignment: .leading) .glassCard(cornerRadius: 16) } } else { emptyCard("No canonical team performance rows are available for this tenant yet.") } } } private var leadMapCanvas: some View { VStack(alignment: .leading, spacing: 16) { productionScopeNote summaryCard( title: "Lead Map", body: "Lead geography is read from the Oracle lead geo rollup when present, with a canonical CRM city rollup fallback when precise coordinates are not stored." ) if isOracleInsightLoading && leadMap == nil { progressCard("Loading lead map...") } else if let oracleInsightError { errorBanner(oracleInsightError) } else if let leadMap, !leadMap.points.isEmpty { HStack(spacing: 12) { metricPill("Locations", "\(leadMap.summary.locations)") metricPill("Leads", "\(leadMap.summary.leadCount)") metricPill("Hot Leads", "\(leadMap.summary.hotLeadCount)") } VStack(alignment: .leading, spacing: 10) { ForEach(leadMap.points) { point in HStack(spacing: 12) { Circle() .fill(point.hotLeadCount > 0 ? VelocityTheme.danger : VelocityTheme.accent) .frame(width: max(12, min(34, CGFloat(point.leadCount + 10))), height: max(12, min(34, CGFloat(point.leadCount + 10)))) VStack(alignment: .leading, spacing: 4) { Text(point.label) .font(.system(size: 14, weight: .semibold)) .foregroundStyle(VelocityTheme.foreground) Text(store.isShowroomModeEnabled ? "\(point.leadCount) leads · buyer-safe" : "\(point.leadCount) leads · \(point.hotLeadCount) hot · QD \(Int((point.avgQdScore * 100).rounded()))") .font(.system(size: 11)) .foregroundStyle(VelocityTheme.mutedFg) } Spacer() if let latitude = point.latitude, let longitude = point.longitude { Text(String(format: "%.3f, %.3f", latitude, longitude)) .font(.system(size: 10, weight: .medium)) .foregroundStyle(VelocityTheme.mutedFg) } } .padding(12) .background( RoundedRectangle(cornerRadius: 14) .fill(VelocityTheme.surface) .overlay( RoundedRectangle(cornerRadius: 14) .stroke(VelocityTheme.borderSubtle, lineWidth: 1) ) ) } } .padding(16) .glassCard(cornerRadius: 16) } else { emptyCard("No canonical location or city-level CRM lead rollups are available yet.") } } } private func progressCard(_ message: String) -> some View { HStack(spacing: 10) { ProgressView() .progressViewStyle(CircularProgressViewStyle(tint: VelocityTheme.accent)) Text(message) .font(.system(size: 13)) .foregroundStyle(VelocityTheme.mutedFg) } .padding(.vertical, 14) .frame(maxWidth: .infinity, alignment: .leading) } private func metricPill(_ title: String, _ value: String) -> some View { VStack(alignment: .leading, spacing: 4) { Text(title.uppercased()) .font(.system(size: 9, weight: .semibold)) .tracking(0.8) .foregroundStyle(VelocityTheme.mutedFg) Text(value) .font(.system(size: 14, weight: .bold)) .foregroundStyle(VelocityTheme.foreground) } .padding(.vertical, 8) .frame(maxWidth: .infinity, alignment: .leading) } private func moneyLabel(_ value: Double) -> String { if value >= 1_000_000 { return String(format: "AED %.1fM", value / 1_000_000) } if value >= 1_000 { return String(format: "AED %.0fK", value / 1_000) } return String(format: "AED %.0f", value) } private func loadOracleInsightData(for mode: OracleMode, silent: Bool = false) async { guard mode == .teamPerformance || mode == .leadMap else { return } if !silent { await MainActor.run { isOracleInsightLoading = true oracleInsightError = nil } } do { switch mode { case .teamPerformance: let response = try await VelocityAPIClient.shared.fetchOracleTeamPerformance() await MainActor.run { teamPerformance = response } case .leadMap: let response = try await VelocityAPIClient.shared.fetchOracleLeadMap() await MainActor.run { leadMap = response } default: break } await MainActor.run { isOracleInsightLoading = false oracleInsightError = nil } } catch { await MainActor.run { isOracleInsightLoading = false oracleInsightError = error.localizedDescription } } } private func opportunityActionsMenu(_ opportunity: VelocityOpportunityDTO) -> some View { Menu { Button { editingOpportunity = opportunity } label: { Label("Edit Deal", systemImage: "square.and.pencil") } } 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(leadStageOptions(currentStatus: currentStatus)) { stage in Button { mutateLeadStage( leadId: leadId, personId: personId, status: stage.value, notes: "Moved from the iPad Oracle pipeline." ) } label: { Text(stage.label) } } } 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) rewardClosedWonIfNeeded(stage) } } catch { await MainActor.run { activeOpportunityMutationID = nil actionError = error.localizedDescription } } } } private func saveOpportunityEdits( _ opportunity: VelocityOpportunityDTO, stage: String?, value: Double?, probability: Int?, expectedCloseDate: String?, nextAction: String?, notes: String? ) { actionError = nil actionMessage = nil activeOpportunityMutationID = opportunity.opportunityId Task { do { _ = try await store.updateOpportunity( opportunityId: opportunity.opportunityId, stage: stage, value: value, probability: probability, expectedCloseDate: expectedCloseDate, nextAction: nextAction, notes: notes ) await refreshClient360IfNeeded(for: opportunity.personId ?? selectedClient360PersonID) await MainActor.run { activeOpportunityMutationID = nil editingOpportunity = nil actionMessage = "Opportunity updated." rewardClosedWonIfNeeded(stage) } } 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 func leadStageOptions(currentStatus: String) -> [VelocityVocabularyOptionDTO] { store.crmVocabularies.leadStages.filter { $0.value != currentStatus.lowercased() } } private func stageLabel(_ status: String) -> String { status.replacingOccurrences(of: "_", with: " ").capitalized } private func rewardClosedWonIfNeeded(_ stage: String?) { let normalized = stage?.lowercased().replacingOccurrences(of: " ", with: "_") ?? "" guard normalized.contains("closed_won") || normalized == "won" else { return } UIImpactFeedbackGenerator(style: .heavy).impactOccurred(intensity: 1.0) AudioServicesPlaySystemSound(1104) } private func iso8601Timestamp(_ date: Date) -> String { ISO8601DateFormatter().string(from: date) } private var client360PresentationBinding: Binding { 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 !store.isShowroomModeEnabled, let score = snapshot.primaryQDScore { Text("\(score.scoreType.replacingOccurrences(of: "_", with: " ").capitalized) score: \(score.displayScore)") .font(.system(size: 12)) .foregroundStyle(VelocityTheme.foreground) } if !store.isShowroomModeEnabled && !snapshot.riskFlags.isEmpty { Text("Risk flags: \(snapshot.riskFlags.joined(separator: ", "))") .font(.system(size: 12)) .foregroundStyle(VelocityTheme.mutedFg) } if !store.isShowroomModeEnabled && !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()) } } private struct OpportunityEditSheet: View { let opportunity: VelocityOpportunityDTO let stages: [VelocityVocabularyOptionDTO] let onSave: (String?, Double?, Int?, String?, String?, String?) -> Void @Environment(\.dismiss) private var dismiss @State private var stage: String @State private var valueText: String @State private var probabilityText: String @State private var expectedCloseDate: String @State private var nextAction: String @State private var notes: String @State private var validationMessage: String? init( opportunity: VelocityOpportunityDTO, stages: [VelocityVocabularyOptionDTO], onSave: @escaping (String?, Double?, Int?, String?, String?, String?) -> Void ) { self.opportunity = opportunity self.stages = stages self.onSave = onSave _stage = State(initialValue: opportunity.stage) _valueText = State(initialValue: opportunity.value.map { String(format: "%.0f", $0) } ?? "") _probabilityText = State(initialValue: opportunity.probabilityPercent.map(String.init) ?? "") _expectedCloseDate = State(initialValue: opportunity.expectedCloseDate ?? "") _nextAction = State(initialValue: opportunity.nextAction ?? "") _notes = State(initialValue: opportunity.notes ?? "") } var body: some View { NavigationStack { Form { Section("Deal") { Picker("Stage", selection: $stage) { ForEach(stageOptions()) { value in Text(value.label) .tag(value.value) } } TextField("Value", text: $valueText) .keyboardType(.decimalPad) TextField("Probability", text: $probabilityText) .keyboardType(.numberPad) TextField("Expected close date", text: $expectedCloseDate) .textInputAutocapitalization(.never) } Section("Operator Context") { TextField("Next action", text: $nextAction, axis: .vertical) TextField("Notes", text: $notes, axis: .vertical) } if let validationMessage { Section { Text(validationMessage) .foregroundStyle(VelocityTheme.danger) } } } .scrollContentBackground(.hidden) .background(VelocityTheme.background) .navigationTitle("Edit Deal") .toolbar { ToolbarItem(placement: .cancellationAction) { Button("Cancel") { dismiss() } } ToolbarItem(placement: .confirmationAction) { Button("Save") { save() } .fontWeight(.semibold) } } } } private func stageOptions() -> [VelocityVocabularyOptionDTO] { guard !stages.contains(where: { $0.value == stage }) else { return stages } return [ VelocityVocabularyOptionDTO( value: stage, label: stage.replacingOccurrences(of: "_", with: " ").capitalized, description: "Current backend value", icon: nil ) ] + stages } private func save() { let value: Double? if let rawValue = valueText.trimmedNonEmpty { guard let parsedValue = Double(rawValue) else { validationMessage = "Enter a valid numeric opportunity value." return } value = parsedValue } else { value = nil } let probability: Int? if let rawProbability = probabilityText.trimmedNonEmpty { guard let parsedProbability = Int(rawProbability), (0...100).contains(parsedProbability) else { validationMessage = "Probability must be a whole number from 0 to 100." return } probability = parsedProbability } else { probability = nil } if let closeDate = expectedCloseDate.trimmedNonEmpty, !Self.isValidISODate(closeDate) { validationMessage = "Expected close date must be YYYY-MM-DD." return } onSave( stage, value, probability, expectedCloseDate.trimmedNonEmpty, nextAction.trimmedNonEmpty, notes.trimmedNonEmpty ) dismiss() } private static func isValidISODate(_ value: String) -> Bool { let formatter = DateFormatter() formatter.calendar = Calendar(identifier: .gregorian) formatter.locale = Locale(identifier: "en_US_POSIX") formatter.dateFormat = "yyyy-MM-dd" return formatter.date(from: value) != nil } } private extension String { var trimmedNonEmpty: String? { let value = trimmingCharacters(in: .whitespacesAndNewlines) return value.isEmpty ? nil : value } } #Preview { OracleView() }