#38 Ipad app production readiness, Colony orchestration, Social posting Co-authored-by: Sayan Datta <sayan@Sayans-MacBook-Air.local> Reviewed-on: #44
1735 lines
72 KiB
Swift
1735 lines
72 KiB
Swift
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<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 !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()
|
|
}
|