Merge Conflicts (#41)

Co-authored-by: Sayan Datta <sayan@Sayans-MacBook-Air.local>
Reviewed-on: sagnik/Project_Velocity#41
This commit is contained in:
2026-04-28 11:32:56 +05:30
parent 61258978e1
commit 7ee51543d9
158 changed files with 23889 additions and 87196 deletions

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