forked from sagnik/Project_Velocity
Merge Conflicts (#41)
Co-authored-by: Sayan Datta <sayan@Sayans-MacBook-Air.local> Reviewed-on: sagnik/Project_Velocity#41
This commit is contained in:
490
iOS/velocity-ipad/velocity/Features/Clients/ClientsView.swift
Normal file
490
iOS/velocity-ipad/velocity/Features/Clients/ClientsView.swift
Normal file
@@ -0,0 +1,490 @@
|
||||
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<Bool> {
|
||||
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<Content: View>(
|
||||
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()
|
||||
}
|
||||
Reference in New Issue
Block a user