Files
Project_Velocity/iOS/velocity-ipad/velocity/Features/Dashboard/DashboardView.swift
Sayan Datta fefe8373ec
Some checks failed
Production Readiness / backend-contracts (pull_request) Has been cancelled
Production Readiness / webos-typecheck (pull_request) Has been cancelled
Production Readiness / ipad-parse (pull_request) Has been cancelled
feat: Ipad app features and Dream Weaver for Velocity WebOS
2026-04-28 10:59:07 +05:30

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()
}