feat: Ipad app production readiness, Colony orchestration, Social posting (#44)

#38 Ipad app production readiness, Colony orchestration, Social posting

Co-authored-by: Sayan Datta <sayan@Sayans-MacBook-Air.local>
Reviewed-on: sagnik/Project_Velocity#44
This commit is contained in:
2026-05-03 18:30:38 +05:30
parent 59d398abc3
commit eeb684b46c
86 changed files with 20349 additions and 1655 deletions

View File

@@ -1,4 +1,6 @@
import Combine
import AVFoundation
import AudioToolbox
import Speech
import SwiftUI
enum OracleMode: String, CaseIterable {
@@ -27,9 +29,180 @@ enum OracleMode: String, CaseIterable {
}
}
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(.pipeline)
@State private var selectedMode: OracleMode = OracleModeAvailability.sanitizedProductionSelection(.accountTimeline)
@State private var selectedClient360: VelocityClient360DTO?
@State private var selectedClient360PersonID: String?
@State private var isClient360Loading = false
@@ -39,7 +212,11 @@ struct OracleView: View {
@State private var activeTaskMutationID: String?
@State private var activeLeadMutationID: String?
@State private var activeOpportunityMutationID: String?
private let refreshTimer = Timer.publish(every: 20, on: .main, in: .common).autoconnect()
@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) {
@@ -77,17 +254,39 @@ struct OracleView: View {
}
}
.background(VelocityTheme.background)
.task { await store.refresh() }
.refreshable { await store.refresh() }
.task {
await store.refresh()
await loadOracleInsightData(for: selectedMode)
}
.refreshable {
await store.refresh()
await loadOracleInsightData(for: selectedMode)
}
.onAppear {
selectedMode = OracleModeAvailability.sanitizedProductionSelection(selectedMode)
}
.onReceive(refreshTimer) { _ in
Task { await store.refresh(silent: true) }
.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 {
@@ -96,7 +295,7 @@ struct OracleView: View {
Text("Oracle")
.font(.system(size: 28, weight: .bold))
.foregroundStyle(VelocityTheme.foreground)
Text("Live sales intelligence assembled from canonical CRM, communication events, and calendar data.")
Text("Ambient sales intelligence assembled from canonical CRM, communication events, and calendar data.")
.font(.system(size: 12))
.foregroundStyle(VelocityTheme.mutedFg)
}
@@ -148,11 +347,10 @@ struct OracleView: View {
timelineCanvas
case .calendarTasks:
calendarCanvas
case .teamPerformance, .leadMap:
unavailableCanvas(
title: "Oracle mode unavailable",
message: "This Oracle mode is intentionally hidden from the production iPad scope until a real backend contract exists."
)
case .teamPerformance:
teamPerformanceCanvas
case .leadMap:
leadMapCanvas
}
}
@@ -217,9 +415,15 @@ struct OracleView: View {
.buttonStyle(.plain)
VStack(alignment: .trailing, spacing: 8) {
Text("\(lead.displayIntentScore)")
.font(.system(size: 12, weight: .bold))
.foregroundStyle(VelocityTheme.accent)
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()
@@ -382,6 +586,9 @@ struct OracleView: View {
.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))
@@ -424,16 +631,21 @@ struct OracleView: View {
Text("\(task.ownerLabel) · \(task.clientPhone ?? "No phone")")
.font(.system(size: 12))
.foregroundStyle(VelocityTheme.mutedFg)
Text(taskNote(task))
.font(.system(size: 12))
.foregroundStyle(VelocityTheme.mutedFg)
.lineLimit(2)
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))
@@ -461,7 +673,7 @@ struct OracleView: View {
Text("Mobile Oracle Scope")
.font(.system(size: 13, weight: .semibold))
.foregroundStyle(VelocityTheme.foreground)
Text("This production iPad build shows only live-backed Oracle views. Team Performance and Lead Map stay hidden until the backend exposes real mobile contracts.")
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)
}
@@ -470,6 +682,13 @@ struct OracleView: View {
.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)
@@ -485,18 +704,16 @@ struct OracleView: View {
.font(.system(size: 13))
.foregroundStyle(VelocityTheme.mutedFg)
}
.padding(18)
.padding(.vertical, 10)
.frame(maxWidth: .infinity, alignment: .leading)
.glassCard(cornerRadius: 18)
}
private func emptyCard(_ message: String) -> some View {
Text(message)
.font(.system(size: 13))
.foregroundStyle(VelocityTheme.mutedFg)
.padding(18)
.padding(.vertical, 14)
.frame(maxWidth: .infinity, alignment: .leading)
.glassCard(cornerRadius: 18)
}
private func color(for status: String) -> Color {
@@ -533,74 +750,198 @@ struct OracleView: View {
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 {
Menu("Move Stage") {
ForEach(canonicalOpportunityStages.filter { $0 != opportunity.stage.lowercased() }, id: \.self) { stage in
Button {
mutateOpportunity(
opportunity,
stage: stage,
probability: nil,
nextAction: opportunity.nextAction,
notes: "Moved from the iPad Oracle deal workspace."
)
} label: {
Text(stageLabel(stage))
}
}
}
Menu("Set Probability") {
ForEach([25, 50, 75, 90], id: \.self) { probability in
Button {
mutateOpportunity(
opportunity,
stage: nil,
probability: probability,
nextAction: opportunity.nextAction,
notes: "Probability updated from the iPad Oracle deal workspace."
)
} label: {
Text("\(probability)%")
}
}
}
Button {
mutateOpportunity(
opportunity,
stage: nil,
probability: nil,
nextAction: "Schedule commercial follow-up",
notes: "Next action updated from the iPad Oracle deal workspace."
)
editingOpportunity = opportunity
} label: {
Label("Set Follow-Up Action", systemImage: "phone.arrow.up.right")
}
Button {
mutateOpportunity(
opportunity,
stage: "closed_won",
probability: 100,
nextAction: "Complete booking documentation",
notes: "Marked closed won from the iPad Oracle deal workspace."
)
} label: {
Label("Close Won", systemImage: "checkmark.seal")
}
Button(role: .destructive) {
mutateOpportunity(
opportunity,
stage: "closed_lost",
probability: 0,
nextAction: "Capture loss reason",
notes: "Marked closed lost from the iPad Oracle deal workspace."
)
} label: {
Label("Close Lost", systemImage: "xmark.seal")
Label("Edit Deal", systemImage: "square.and.pencil")
}
} label: {
menuIcon("ellipsis.circle")
@@ -669,16 +1010,16 @@ struct OracleView: View {
currentStatus: String
) -> some View {
Menu {
ForEach(canonicalLeadStages.filter { $0 != currentStatus.lowercased() }, id: \.self) { status in
ForEach(leadStageOptions(currentStatus: currentStatus)) { stage in
Button {
mutateLeadStage(
leadId: leadId,
personId: personId,
status: status,
status: stage.value,
notes: "Moved from the iPad Oracle pipeline."
)
} label: {
Text(stageLabel(status))
Text(stage.label)
}
}
} label: {
@@ -773,6 +1114,47 @@ struct OracleView: View {
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 {
@@ -801,39 +1183,21 @@ struct OracleView: View {
}
}
private var canonicalLeadStages: [String] {
[
"new",
"contacted",
"qualified",
"site_visit_scheduled",
"site_visited",
"negotiation",
"booking_initiated",
"booked",
"lost",
"dormant",
]
}
private var canonicalOpportunityStages: [String] {
[
"prospect",
"qualified",
"proposal",
"site_visit",
"negotiation",
"booking",
"agreement",
"closed_won",
"closed_lost",
]
private func 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)
}
@@ -1123,17 +1487,17 @@ struct OracleView: View {
.font(.system(size: 16, weight: .semibold))
.foregroundStyle(VelocityTheme.foreground)
if let score = snapshot.primaryQDScore {
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 !snapshot.riskFlags.isEmpty {
if !store.isShowroomModeEnabled && !snapshot.riskFlags.isEmpty {
Text("Risk flags: \(snapshot.riskFlags.joined(separator: ", "))")
.font(.system(size: 12))
.foregroundStyle(VelocityTheme.mutedFg)
}
if !snapshot.recommendedNextActions.isEmpty {
if !store.isShowroomModeEnabled && !snapshot.recommendedNextActions.isEmpty {
Text("Next actions: \(snapshot.recommendedNextActions.joined(separator: " · "))")
.font(.system(size: 12))
.foregroundStyle(VelocityTheme.mutedFg)
@@ -1218,6 +1582,153 @@ struct OracleView: View {
}
}
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()
}