import Combine import SwiftUI struct DashboardView: View { @State private var store = AppStore.shared @State private var session = SessionStore.shared private let refreshTimer = Timer.publish(every: 20, on: .main, in: .common).autoconnect() private let columns = [GridItem(.adaptive(minimum: 220), spacing: 14)] var body: some View { ScrollView { VStack(alignment: .leading, spacing: 20) { header if let error = store.errorMessage { errorBanner(error) } if store.isLoading && store.lastRefreshAt == nil { loadingPanel } else { metricsGrid liveStatusPanel followUpLoadPanel leadFocusPanel inventoryPanel } } .padding(20) } .background(VelocityTheme.background) .scrollContentBackground(.hidden) .task { await store.refresh() } .refreshable { await store.refresh() } .onReceive(refreshTimer) { _ in Task { await store.refresh(silent: true) } } } private var header: some View { HStack(alignment: .top) { VStack(alignment: .leading, spacing: 4) { Text("Dashboard") .font(.system(size: 28, weight: .bold)) .foregroundStyle(VelocityTheme.foreground) Text("Live mobile operator posture for canonical CRM pipeline, inventory, and follow-up load.") .font(.system(size: 12)) .foregroundStyle(VelocityTheme.mutedFg) } Spacer() VStack(alignment: .trailing, spacing: 8) { statusBadge( label: session.isConfigured ? "Live backend" : "Config required", color: session.isConfigured ? VelocityTheme.success : VelocityTheme.warning ) if let lastRefresh = store.lastRefreshAt { Text("Updated \(lastRefresh.relativeShort)") .font(.system(size: 11, weight: .medium)) .foregroundStyle(VelocityTheme.mutedFg) } } } } private var metricsGrid: some View { LazyVGrid(columns: columns, spacing: 14) { MetricCard(title: "Leads", value: "\(store.metrics.leadCount)", subtitle: "Live CRM records", color: VelocityTheme.accent) MetricCard(title: "Whale Leads", value: "\(store.metrics.whaleLeadCount)", subtitle: "Score 90+ or whale qualified", color: VelocityTheme.success) MetricCard(title: "Pending Tasks", value: "\(store.metrics.pendingTaskCount)", subtitle: "Canonical CRM reminders", color: VelocityTheme.warning) MetricCard(title: "Urgent Tasks", value: "\(store.metrics.urgentTaskCount)", subtitle: "High and urgent follow-ups", color: VelocityTheme.danger) MetricCard(title: "Inventory", value: "\(store.metrics.propertyCount)", subtitle: "Property rows available", color: VelocityTheme.warning) MetricCard(title: "Today", value: "\(store.metrics.todayCalendarCount)", subtitle: "Calendar slots scheduled", color: Color(red: 0.60, green: 0.57, blue: 0.99)) } } private var liveStatusPanel: some View { VStack(alignment: .leading, spacing: 14) { HStack { Text("Live Status") .font(.system(size: 16, weight: .semibold)) .foregroundStyle(VelocityTheme.foreground) Spacer() statusBadge(label: session.authModeDescription, color: VelocityTheme.accent) } detailRow(title: "Endpoint", value: session.endpointDisplay) detailRow(title: "Operator", value: session.operatorIdentity) detailRow(title: "Pending CRM tasks", value: "\(store.metrics.pendingTaskCount)") detailRow(title: "Pending insights", value: "\(store.metrics.pendingInsights)") detailRow(title: "Pending transcriptions", value: "\(store.metrics.pendingTranscriptions)") } .padding(20) .glassCard(cornerRadius: 18) } private var followUpLoadPanel: some View { VStack(alignment: .leading, spacing: 14) { Text("Follow-Up Load") .font(.system(size: 16, weight: .semibold)) .foregroundStyle(VelocityTheme.foreground) if store.prioritizedTasks.isEmpty { emptyMessage("No canonical CRM reminder tasks are pending for this operator right now.") } else { ForEach(store.prioritizedTasks.prefix(4)) { task in HStack(alignment: .top, spacing: 14) { VStack(alignment: .leading, spacing: 5) { Text(task.title) .font(.system(size: 14, weight: .semibold)) .foregroundStyle(VelocityTheme.foreground) Text("\(task.ownerLabel) · \(task.dueLabel)") .font(.system(size: 12)) .foregroundStyle(VelocityTheme.mutedFg) Text(taskNote(task)) .font(.system(size: 12)) .foregroundStyle(VelocityTheme.mutedFg) .lineLimit(2) } Spacer() Text(task.priorityLabel) .font(.system(size: 10, weight: .semibold)) .foregroundStyle(priorityColor(for: task.priority)) } .padding(14) .background( RoundedRectangle(cornerRadius: 14) .fill(VelocityTheme.surface) .overlay( RoundedRectangle(cornerRadius: 14) .stroke(VelocityTheme.borderSubtle, lineWidth: 1) ) ) } } } .padding(20) .glassCard(cornerRadius: 18) } private var leadFocusPanel: some View { VStack(alignment: .leading, spacing: 14) { Text("Client Focus") .font(.system(size: 16, weight: .semibold)) .foregroundStyle(VelocityTheme.foreground) if store.highlightedLeads.isEmpty { emptyMessage("No canonical CRM contacts with active lead context have been returned by the backend yet.") } else { ForEach(store.highlightedLeads) { lead in HStack(alignment: .top, spacing: 14) { VStack(alignment: .leading, spacing: 5) { Text(lead.name) .font(.system(size: 14, weight: .semibold)) .foregroundStyle(VelocityTheme.foreground) Text("\(lead.unitInterest) · \(lead.budget)") .font(.system(size: 12)) .foregroundStyle(VelocityTheme.mutedFg) } Spacer() VStack(alignment: .trailing, spacing: 4) { Text("\(lead.score)") .font(.system(size: 20, weight: .bold)) .foregroundStyle(VelocityTheme.foreground) Text(lead.kanbanStatus.replacingOccurrences(of: "_", with: " ").capitalized) .font(.system(size: 10, weight: .medium)) .foregroundStyle(VelocityTheme.accent) } } .padding(14) .background( RoundedRectangle(cornerRadius: 14) .fill(VelocityTheme.surface) .overlay( RoundedRectangle(cornerRadius: 14) .stroke(VelocityTheme.borderSubtle, lineWidth: 1) ) ) } } } .padding(20) .glassCard(cornerRadius: 18) } private var inventoryPanel: some View { VStack(alignment: .leading, spacing: 14) { Text("Inventory Coverage") .font(.system(size: 16, weight: .semibold)) .foregroundStyle(VelocityTheme.foreground) if store.properties.isEmpty { emptyMessage("No live inventory properties are available yet for this operator scope.") } else { ForEach(store.properties.prefix(4)) { property in VStack(alignment: .leading, spacing: 6) { Text(property.projectName) .font(.system(size: 14, weight: .semibold)) .foregroundStyle(VelocityTheme.foreground) Text("\(property.developerName) · \(property.propertyType.capitalized)") .font(.system(size: 12)) .foregroundStyle(VelocityTheme.mutedFg) Text(property.locationSummary) .font(.system(size: 12)) .foregroundStyle(VelocityTheme.mutedFg) } .padding(14) .frame(maxWidth: .infinity, alignment: .leading) .background( RoundedRectangle(cornerRadius: 14) .fill(VelocityTheme.surface) .overlay( RoundedRectangle(cornerRadius: 14) .stroke(VelocityTheme.borderSubtle, lineWidth: 1) ) ) } } } .padding(20) .glassCard(cornerRadius: 18) } private func detailRow(title: String, value: String) -> some View { VStack(alignment: .leading, spacing: 4) { Text(title.uppercased()) .font(.system(size: 10, weight: .semibold)) .tracking(1) .foregroundStyle(VelocityTheme.mutedFg) Text(value) .font(.system(size: 14)) .foregroundStyle(VelocityTheme.foreground) } } private func emptyMessage(_ message: String) -> some View { Text(message) .font(.system(size: 13)) .foregroundStyle(VelocityTheme.mutedFg) .frame(maxWidth: .infinity, alignment: .leading) } private func statusBadge(label: String, color: Color) -> some View { Text(label) .font(.system(size: 11, weight: .semibold)) .foregroundStyle(color) .padding(.horizontal, 10) .padding(.vertical, 6) .background( Capsule() .fill(color.opacity(0.12)) .overlay(Capsule().stroke(color.opacity(0.22), lineWidth: 1)) ) } private var loadingPanel: some View { VStack(alignment: .leading, spacing: 12) { ProgressView() .progressViewStyle(CircularProgressViewStyle(tint: VelocityTheme.accent)) Text("Loading live dashboard data...") .font(.system(size: 14, weight: .semibold)) .foregroundStyle(VelocityTheme.foreground) Text("Velocity is reading canonical CRM contacts, reminders, alerts, calendar events, and inventory summaries from the backend.") .font(.system(size: 12)) .foregroundStyle(VelocityTheme.mutedFg) } .padding(20) .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 priorityColor(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 operator note yet." : note } } private struct MetricCard: View { let title: String let value: String let subtitle: String let color: Color var body: some View { VStack(alignment: .leading, spacing: 10) { Text(title.uppercased()) .font(.system(size: 10, weight: .semibold)) .tracking(1) .foregroundStyle(VelocityTheme.mutedFg) Text(value) .font(.system(size: 28, weight: .bold)) .foregroundStyle(VelocityTheme.foreground) Text(subtitle) .font(.system(size: 12)) .foregroundStyle(VelocityTheme.mutedFg) RoundedRectangle(cornerRadius: 4) .fill(color) .frame(width: 52, height: 4) } .padding(18) .frame(maxWidth: .infinity, alignment: .leading) .glassCard(cornerRadius: 16) } } #Preview { DashboardView() }