forked from sagnik/Project_Velocity
Co-authored-by: Sayan Datta <sayan@Sayans-MacBook-Air.local> Reviewed-on: sagnik/Project_Velocity#41
491 lines
20 KiB
Swift
491 lines
20 KiB
Swift
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()
|
|
}
|