import Combine import SwiftUI struct ClientsView: View { @State private var store = AppStore.shared @State private var searchText = "" @State private var selectedClient360: VelocityClient360DTO? @State private var selectedPersonID: String? @State private var isClient360Loading = false @State private var client360Error: String? private let refreshTimer = Timer.publish(every: 30, on: .main, in: .common).autoconnect() var body: some View { VStack(alignment: .leading, spacing: 0) { header .padding(.horizontal, 24) .padding(.top, 24) .padding(.bottom, 16) if let error = store.errorMessage { errorBanner(error) .padding(.horizontal, 24) .padding(.bottom, 14) } ScrollView { VStack(alignment: .leading, spacing: 16) { summaryPanel searchPanel contactsPanel } .padding(.horizontal, 24) .padding(.bottom, 24) } } .background(VelocityTheme.background) .task { await store.refresh() } .refreshable { await store.refresh() } .onReceive(refreshTimer) { _ in Task { await store.refresh(silent: true) } } .sheet(isPresented: client360PresentationBinding) { client360Sheet } } private var header: some View { HStack(alignment: .top) { VStack(alignment: .leading, spacing: 4) { Text("Clients") .font(.system(size: 28, weight: .bold)) .foregroundStyle(VelocityTheme.foreground) Text("Canonical CRM contact workspace backed by `/api/crm/client-data` and client detail APIs.") .font(.system(size: 12)) .foregroundStyle(VelocityTheme.mutedFg) } Spacer() Text(store.lastRefreshAt?.relativeShort ?? "Awaiting sync") .font(.system(size: 11, weight: .medium)) .foregroundStyle(VelocityTheme.mutedFg) } } private var summaryPanel: some View { HStack(spacing: 12) { metricCard("Contacts", value: "\(store.contacts.count)", color: VelocityTheme.accent) metricCard("Active Leads", value: "\(store.leads.count)", color: VelocityTheme.success) metricCard("Open Tasks", value: "\(store.metrics.pendingTaskCount)", color: VelocityTheme.warning) metricCard("High Intent", value: "\(highIntentCount)", color: VelocityTheme.danger) } } private var searchPanel: some View { HStack(spacing: 12) { Image(systemName: "magnifyingglass") .foregroundStyle(VelocityTheme.mutedFg) TextField("Search by name, phone, interest, budget, or status", text: $searchText) .textInputAutocapitalization(.words) .foregroundStyle(VelocityTheme.foreground) if !searchText.isEmpty { Button("Clear") { searchText = "" } .font(.system(size: 12, weight: .semibold)) .foregroundStyle(VelocityTheme.accent) } } .padding(16) .glassCard(cornerRadius: 16) } private var contactsPanel: some View { VStack(alignment: .leading, spacing: 12) { HStack { Text("Canonical Contacts") .font(.system(size: 17, weight: .semibold)) .foregroundStyle(VelocityTheme.foreground) Spacer() Text("\(filteredContacts.count) shown") .font(.system(size: 11, weight: .semibold)) .foregroundStyle(VelocityTheme.mutedFg) } if store.isLoading && store.lastRefreshAt == nil { loadingCard } else if store.contacts.isEmpty { emptyCard("No canonical contacts were returned for this operator scope yet.") } else if filteredContacts.isEmpty { emptyCard("No canonical contacts match this search.") } else { LazyVStack(spacing: 10) { ForEach(filteredContacts) { contact in contactCard(contact) } } } } .padding(18) .glassCard(cornerRadius: 20) } private func contactCard(_ contact: VelocityCanonicalContactListItemDTO) -> some View { Button { openClient360(for: contact.personId) } label: { HStack(alignment: .top, spacing: 14) { ZStack { Circle() .fill(VelocityTheme.accent.opacity(0.14)) .frame(width: 42, height: 42) Text(initials(for: contact.fullName)) .font(.system(size: 13, weight: .bold)) .foregroundStyle(VelocityTheme.accent) } VStack(alignment: .leading, spacing: 7) { HStack { Text(contact.fullName) .font(.system(size: 15, weight: .semibold)) .foregroundStyle(VelocityTheme.foreground) Spacer() Text("\(contact.displayIntentScore)") .font(.system(size: 12, weight: .bold)) .foregroundStyle(VelocityTheme.accent) } Text("\(contact.buyerTypeLabel) · \(contact.leadStatusLabel)") .font(.system(size: 12)) .foregroundStyle(VelocityTheme.foreground) Text("\(contact.budgetSummary) · \(contact.interestSummary)") .font(.system(size: 12)) .foregroundStyle(VelocityTheme.mutedFg) Text("\(contact.contactLine) · \(contact.pendingTasks) pending tasks · \(contact.interactionCount) interactions") .font(.system(size: 11)) .foregroundStyle(VelocityTheme.mutedFg) } } .padding(14) .background( RoundedRectangle(cornerRadius: 16) .fill(VelocityTheme.surface) .overlay( RoundedRectangle(cornerRadius: 16) .stroke(VelocityTheme.borderSubtle, lineWidth: 1) ) ) } .buttonStyle(.plain) } private var loadingCard: some View { HStack(spacing: 12) { ProgressView() .progressViewStyle(CircularProgressViewStyle(tint: VelocityTheme.accent)) Text("Loading canonical contacts...") .font(.system(size: 13, weight: .medium)) .foregroundStyle(VelocityTheme.mutedFg) } .padding(18) .frame(maxWidth: .infinity, alignment: .leading) .background(RoundedRectangle(cornerRadius: 16).fill(VelocityTheme.surface)) } private func emptyCard(_ message: String) -> some View { Text(message) .font(.system(size: 13)) .foregroundStyle(VelocityTheme.mutedFg) .padding(18) .frame(maxWidth: .infinity, alignment: .leading) .background(RoundedRectangle(cornerRadius: 16).fill(VelocityTheme.surface)) } private func metricCard(_ label: String, value: String, color: Color) -> some View { VStack(alignment: .leading, spacing: 8) { Text(label.uppercased()) .font(.system(size: 10, weight: .semibold)) .tracking(1) .foregroundStyle(VelocityTheme.mutedFg) Text(value) .font(.system(size: 21, weight: .bold)) .foregroundStyle(VelocityTheme.foreground) RoundedRectangle(cornerRadius: 3) .fill(color) .frame(width: 42, height: 4) } .padding(16) .frame(maxWidth: .infinity, alignment: .leading) .glassCard(cornerRadius: 16) } private var filteredContacts: [VelocityCanonicalContactListItemDTO] { let normalized = searchText.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() guard !normalized.isEmpty else { return store.contacts } return store.contacts.filter { contact in [ contact.fullName, contact.primaryPhone ?? "", contact.buyerType ?? "", contact.leadStatus ?? "", contact.budgetBand ?? "", contact.primaryInterest ?? "", contact.urgency ?? "", ] .joined(separator: " ") .lowercased() .contains(normalized) } } private var highIntentCount: Int { store.contacts.filter { $0.displayIntentScore >= 80 }.count } private func initials(for name: String) -> String { let initials = name .split(separator: " ") .prefix(2) .compactMap(\.first) return initials.isEmpty ? "C" : String(initials) } private var client360PresentationBinding: Binding { Binding( get: { selectedPersonID != nil }, set: { isPresented in if !isPresented { selectedPersonID = nil selectedClient360 = nil client360Error = nil isClient360Loading = false } } ) } @ViewBuilder private var client360Sheet: some View { NavigationStack { ScrollView { VStack(alignment: .leading, spacing: 16) { if isClient360Loading { ProgressView() .progressViewStyle(CircularProgressViewStyle(tint: VelocityTheme.accent)) .frame(maxWidth: .infinity, alignment: .center) .padding(.top, 40) } else if let client360Error { errorBanner(client360Error) } else if let snapshot = selectedClient360 { client360Snapshot(snapshot) } } .padding(20) } .background(VelocityTheme.background) .navigationTitle("Client 360") .toolbar { ToolbarItem(placement: .topBarTrailing) { Button("Done") { selectedPersonID = nil selectedClient360 = nil client360Error = nil isClient360Loading = false } } } } } private func client360Snapshot(_ snapshot: VelocityClient360DTO) -> some View { VStack(alignment: .leading, spacing: 14) { VStack(alignment: .leading, spacing: 8) { Text(snapshot.identity.fullName) .font(.system(size: 24, weight: .bold)) .foregroundStyle(VelocityTheme.foreground) Text("\(snapshot.identity.primaryPhone ?? "No phone") · \(snapshot.identity.buyerType?.replacingOccurrences(of: "_", with: " ").capitalized ?? "CRM contact")") .font(.system(size: 13)) .foregroundStyle(VelocityTheme.mutedFg) if let email = snapshot.identity.primaryEmail { Text(email) .font(.system(size: 12)) .foregroundStyle(VelocityTheme.mutedFg) } if !snapshot.identity.personaLabels.isEmpty { Text(snapshot.identity.personaLabels.joined(separator: " · ")) .font(.system(size: 12, weight: .semibold)) .foregroundStyle(VelocityTheme.accent) } } .padding(18) .frame(maxWidth: .infinity, alignment: .leading) .glassCard(cornerRadius: 18) VStack(alignment: .leading, spacing: 10) { if let lead = snapshot.currentLead { sectionLine("Lead", value: "\(lead.status.replacingOccurrences(of: "_", with: " ").capitalized) · \(lead.budgetBand ?? "Budget pending")") sectionLine("Urgency", value: lead.urgency?.replacingOccurrences(of: "_", with: " ").capitalized ?? "Normal") if !lead.motivations.isEmpty { Text("Motivations: \(lead.motivations.joined(separator: ", "))") .font(.system(size: 12)) .foregroundStyle(VelocityTheme.mutedFg) } if !lead.objections.isEmpty { Text("Objections: \(lead.objections.joined(separator: ", "))") .font(.system(size: 12)) .foregroundStyle(VelocityTheme.mutedFg) } } else { Text("No active canonical lead context was returned for this client.") .font(.system(size: 12)) .foregroundStyle(VelocityTheme.mutedFg) } sectionLine("Opportunities", value: "\(snapshot.activeOpportunities.count)") sectionLine("Tasks", value: "\(snapshot.tasks.count)") sectionLine("Interactions", value: "\(snapshot.recentInteractions.count)") } .padding(18) .frame(maxWidth: .infinity, alignment: .leading) .glassCard(cornerRadius: 18) if !snapshot.activeOpportunities.isEmpty { client360ListCard(title: "Active Opportunities") { ForEach(snapshot.activeOpportunities) { opportunity in VStack(alignment: .leading, spacing: 5) { Text(opportunity.stage.replacingOccurrences(of: "_", with: " ").capitalized) .font(.system(size: 13, weight: .semibold)) .foregroundStyle(VelocityTheme.foreground) Text("\(opportunity.formattedValue) · \(opportunity.probabilityLabel)") .font(.system(size: 12)) .foregroundStyle(VelocityTheme.mutedFg) Text(opportunity.nextAction ?? "Next action pending") .font(.system(size: 12)) .foregroundStyle(VelocityTheme.mutedFg) } .padding(.vertical, 4) } } } if !snapshot.propertyInterests.isEmpty { client360ListCard(title: "Property Interests") { ForEach(snapshot.propertyInterests) { interest in VStack(alignment: .leading, spacing: 5) { Text(interest.projectName) .font(.system(size: 13, weight: .semibold)) .foregroundStyle(VelocityTheme.foreground) Text([interest.configuration, interest.unitPreference].compactMap { nonEmpty($0) }.joined(separator: " · ")) .font(.system(size: 12)) .foregroundStyle(VelocityTheme.mutedFg) } .padding(.vertical, 4) } } } client360ListCard(title: "Recent Interactions") { if snapshot.recentInteractions.isEmpty { Text("No recent canonical interactions were returned for this client.") .font(.system(size: 12)) .foregroundStyle(VelocityTheme.mutedFg) } else { ForEach(snapshot.recentInteractions) { interaction in VStack(alignment: .leading, spacing: 5) { Text("\(interaction.channel.capitalized) · \(interaction.interactionType.replacingOccurrences(of: "_", with: " ").capitalized)") .font(.system(size: 13, weight: .semibold)) .foregroundStyle(VelocityTheme.foreground) Text(interaction.summary ?? "No summary captured") .font(.system(size: 12)) .foregroundStyle(VelocityTheme.mutedFg) } .padding(.vertical, 4) } } } if !snapshot.tasks.isEmpty || !snapshot.recommendedNextActions.isEmpty || !snapshot.riskFlags.isEmpty { client360ListCard(title: "Operator Actions") { ForEach(snapshot.tasks) { task in sectionLine(task.title, value: "\(task.priorityLabel) · \(task.dueLabel)") } ForEach(snapshot.recommendedNextActions, id: \.self) { action in Text(action) .font(.system(size: 12, weight: .medium)) .foregroundStyle(VelocityTheme.accent) } ForEach(snapshot.riskFlags, id: \.self) { flag in Text(flag.replacingOccurrences(of: "_", with: " ").capitalized) .font(.system(size: 12)) .foregroundStyle(VelocityTheme.warning) } } } } } private func client360ListCard( title: String, @ViewBuilder content: () -> Content ) -> some View { VStack(alignment: .leading, spacing: 10) { Text(title) .font(.system(size: 16, weight: .semibold)) .foregroundStyle(VelocityTheme.foreground) content() } .padding(18) .frame(maxWidth: .infinity, alignment: .leading) .glassCard(cornerRadius: 18) } private func sectionLine(_ title: String, value: String) -> some View { HStack { Text(title) .font(.system(size: 12, weight: .semibold)) .foregroundStyle(VelocityTheme.mutedFg) Spacer() Text(value) .font(.system(size: 12, weight: .semibold)) .foregroundStyle(VelocityTheme.foreground) } } private func nonEmpty(_ value: String?) -> String? { let trimmed = value?.trimmingCharacters(in: .whitespacesAndNewlines) ?? "" return trimmed.isEmpty ? nil : trimmed } private func openClient360(for personId: String) { selectedPersonID = personId selectedClient360 = nil client360Error = nil isClient360Loading = true Task { do { let snapshot = try await VelocityAPIClient.shared.fetchClient360(personId: personId) await MainActor.run { selectedClient360 = snapshot isClient360Loading = false } } catch { await MainActor.run { selectedClient360 = nil client360Error = error.localizedDescription isClient360Loading = false } } } } 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) ) ) } } #Preview { ClientsView() }