268 lines
11 KiB
Swift
268 lines
11 KiB
Swift
import SwiftUI
|
|
|
|
struct DashboardView: View {
|
|
@State private var store = AppStore.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
|
|
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 leads, inventory, and follow-up load.")
|
|
.font(.system(size: 12))
|
|
.foregroundStyle(VelocityTheme.mutedFg)
|
|
}
|
|
Spacer()
|
|
VStack(alignment: .trailing, spacing: 8) {
|
|
statusBadge(
|
|
label: store.isConfigured ? "Live backend" : "Config required",
|
|
color: store.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: "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: AppConfig.authModeDescription, color: VelocityTheme.accent)
|
|
}
|
|
|
|
detailRow(title: "Endpoint", value: AppConfig.baseURL)
|
|
detailRow(title: "Operator", value: store.operatorIdentity)
|
|
detailRow(title: "Pending insights", value: "\(store.metrics.pendingInsights)")
|
|
detailRow(title: "Pending transcriptions", value: "\(store.metrics.pendingTranscriptions)")
|
|
}
|
|
.padding(20)
|
|
.glassCard(cornerRadius: 18)
|
|
}
|
|
|
|
private var leadFocusPanel: some View {
|
|
VStack(alignment: .leading, spacing: 14) {
|
|
Text("Lead Focus")
|
|
.font(.system(size: 16, weight: .semibold))
|
|
.foregroundStyle(VelocityTheme.foreground)
|
|
|
|
if store.highlightedLeads.isEmpty {
|
|
emptyMessage("No live leads 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 leads, 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 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()
|
|
}
|