334 lines
14 KiB
Swift
334 lines
14 KiB
Swift
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()
|
|
}
|