#38 Ipad app production readiness, Colony orchestration, Social posting Co-authored-by: Sayan Datta <sayan@Sayans-MacBook-Air.local> Reviewed-on: #44
1706 lines
67 KiB
Swift
1706 lines
67 KiB
Swift
import SwiftUI
|
|
import UIKit
|
|
|
|
private struct ClientEditDraft: Identifiable {
|
|
let id: String
|
|
var fullName: String
|
|
var primaryEmail: String
|
|
var primaryPhone: String
|
|
var buyerType: String
|
|
var leadStatus: String
|
|
var budgetBand: String
|
|
var urgency: String
|
|
|
|
init(snapshot: VelocityClient360DTO) {
|
|
id = snapshot.identity.personId
|
|
fullName = snapshot.identity.fullName
|
|
primaryEmail = snapshot.identity.primaryEmail ?? ""
|
|
primaryPhone = snapshot.identity.primaryPhone ?? ""
|
|
buyerType = snapshot.identity.buyerType ?? ""
|
|
leadStatus = snapshot.currentLead?.status ?? ""
|
|
budgetBand = snapshot.currentLead?.budgetBand ?? ""
|
|
urgency = snapshot.currentLead?.urgency ?? ""
|
|
}
|
|
}
|
|
|
|
private struct ClientTaskDraft: Identifiable {
|
|
let id = UUID().uuidString
|
|
let personId: String
|
|
let leadId: String?
|
|
var title = ""
|
|
var notes = ""
|
|
var dueAt = ""
|
|
var priority: String
|
|
|
|
init(personId: String, leadId: String?, defaultPriority: String) {
|
|
self.personId = personId
|
|
self.leadId = leadId
|
|
priority = defaultPriority
|
|
}
|
|
}
|
|
|
|
private struct ClientOpportunityEditDraft: Identifiable {
|
|
let id: String
|
|
var stage: String
|
|
var valueText: String
|
|
var probabilityText: String
|
|
var expectedCloseDate: String
|
|
var nextAction: String
|
|
var notes: String
|
|
|
|
init(opportunity: VelocityOpportunityDTO) {
|
|
id = opportunity.opportunityId
|
|
stage = opportunity.stage
|
|
valueText = opportunity.value.map { String(format: "%.0f", $0) } ?? ""
|
|
probabilityText = opportunity.probabilityPercent.map(String.init) ?? ""
|
|
expectedCloseDate = opportunity.expectedCloseDate ?? ""
|
|
nextAction = opportunity.nextAction ?? ""
|
|
notes = opportunity.notes ?? ""
|
|
}
|
|
}
|
|
|
|
private enum ClientListFilter: Equatable {
|
|
case all
|
|
case activeLeads
|
|
case openTasks
|
|
case highIntent
|
|
case whale
|
|
case possibleDuplicates
|
|
|
|
var title: String {
|
|
switch self {
|
|
case .all: return "Priority Contacts"
|
|
case .activeLeads: return "Active Leads"
|
|
case .openTasks: return "Open Task Clients"
|
|
case .highIntent: return "High Intent"
|
|
case .whale: return "Whale Leads"
|
|
case .possibleDuplicates: return "Name Matches"
|
|
}
|
|
}
|
|
|
|
var chipTitle: String {
|
|
switch self {
|
|
case .all: return "All contacts"
|
|
case .activeLeads: return "Active leads"
|
|
case .openTasks: return "Open tasks"
|
|
case .highIntent: return "Intent 80+"
|
|
case .whale: return "Intent 90+"
|
|
case .possibleDuplicates: return "Name matches"
|
|
}
|
|
}
|
|
}
|
|
|
|
struct ClientsView: View {
|
|
private let contactPageSize = 100
|
|
@State private var store = AppStore.shared
|
|
@State private var searchText = ""
|
|
@State private var selectedClient360: VelocityClient360DTO?
|
|
@State private var selectedPersonID: String?
|
|
@State private var activeListFilter: ClientListFilter = .all
|
|
@State private var extraContacts: [VelocityCanonicalContactListItemDTO] = []
|
|
@State private var contactTotalCount: Int?
|
|
@State private var canLoadMoreContacts = true
|
|
@State private var isLoadingMoreContacts = false
|
|
@State private var loadMoreError: String?
|
|
@State private var searchResults: [VelocityCanonicalContactListItemDTO]?
|
|
@State private var searchLookupTask: Task<Void, Never>?
|
|
@State private var isRemoteSearchLoading = false
|
|
@State private var searchError: String?
|
|
@State private var isClient360Loading = false
|
|
@State private var isClientMutationSaving = false
|
|
@State private var client360Error: String?
|
|
@State private var clientEditDraft: ClientEditDraft?
|
|
@State private var clientTaskDraft: ClientTaskDraft?
|
|
@State private var opportunityEditDraft: ClientOpportunityEditDraft?
|
|
|
|
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, 154)
|
|
}
|
|
}
|
|
.background(VelocityTheme.background)
|
|
.task {
|
|
await refreshWorkspace()
|
|
consumeRequestedClient360()
|
|
consumeRequestedClientFilter()
|
|
}
|
|
.onChange(of: store.requestedClient360PersonID) { _, _ in
|
|
consumeRequestedClient360()
|
|
}
|
|
.onChange(of: store.requestedClientFilter) { _, _ in
|
|
consumeRequestedClientFilter()
|
|
}
|
|
.onChange(of: searchText) { _, newValue in
|
|
scheduleRemoteSearch(for: newValue)
|
|
}
|
|
.onDisappear {
|
|
searchLookupTask?.cancel()
|
|
}
|
|
.refreshable { await refreshWorkspace() }
|
|
.sheet(isPresented: client360PresentationBinding) {
|
|
client360Sheet
|
|
}
|
|
.sheet(item: $clientEditDraft) { draft in
|
|
ClientEditSheet(
|
|
draft: draft,
|
|
vocabularies: store.crmVocabularies,
|
|
isSaving: isClientMutationSaving
|
|
) { updatedDraft in
|
|
Task { await saveClientEdits(updatedDraft) }
|
|
}
|
|
}
|
|
.sheet(item: $clientTaskDraft) { draft in
|
|
ClientTaskSheet(
|
|
draft: draft,
|
|
priorities: store.crmVocabularies.taskPriorities,
|
|
isSaving: isClientMutationSaving
|
|
) { updatedDraft in
|
|
Task { await createClientTask(updatedDraft) }
|
|
}
|
|
}
|
|
.sheet(item: $opportunityEditDraft) { draft in
|
|
ClientOpportunityEditSheet(draft: draft, isSaving: isClientMutationSaving) { updatedDraft in
|
|
Task { await saveOpportunityEdits(updatedDraft) }
|
|
}
|
|
}
|
|
}
|
|
|
|
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("Manage priority buyers, follow-ups, and deal context from one focused workspace.")
|
|
.font(.system(size: 12))
|
|
.foregroundStyle(VelocityTheme.mutedFg)
|
|
}
|
|
Spacer()
|
|
VStack(alignment: .trailing, spacing: 10) {
|
|
Text(store.lastRefreshAt?.relativeShort ?? "Awaiting sync")
|
|
.font(.system(size: 11, weight: .medium))
|
|
.foregroundStyle(VelocityTheme.mutedFg)
|
|
Button {
|
|
withAnimation(.interactiveSpring(response: 0.35, dampingFraction: 0.86)) {
|
|
store.isShowroomModeEnabled.toggle()
|
|
}
|
|
} label: {
|
|
Label(
|
|
store.isShowroomModeEnabled ? "Privacy: Showroom" : "Privacy: Broker",
|
|
systemImage: store.isShowroomModeEnabled ? "eye.slash" : "eye"
|
|
)
|
|
.font(.system(size: 11, weight: .semibold))
|
|
.foregroundStyle(store.isShowroomModeEnabled ? VelocityTheme.warning : VelocityTheme.accent)
|
|
.padding(.horizontal, 10)
|
|
.padding(.vertical, 7)
|
|
.background(Capsule().fill((store.isShowroomModeEnabled ? VelocityTheme.warning : VelocityTheme.accent).opacity(0.12)))
|
|
}
|
|
.buttonStyle(.plain)
|
|
}
|
|
}
|
|
}
|
|
|
|
private var summaryPanel: some View {
|
|
HStack(spacing: 12) {
|
|
metricCard(
|
|
"Contacts",
|
|
value: "\(allLoadedContacts.count)",
|
|
color: VelocityTheme.accent,
|
|
filter: .all
|
|
)
|
|
metricCard(
|
|
"Active Leads",
|
|
value: "\(activeLeadCount)",
|
|
color: VelocityTheme.success,
|
|
filter: .activeLeads
|
|
)
|
|
metricCard(
|
|
"Open Tasks",
|
|
value: "\(openTaskClientCount)",
|
|
color: VelocityTheme.warning,
|
|
filter: .openTasks
|
|
)
|
|
metricCard(
|
|
"High Intent",
|
|
value: "\(highIntentCount)",
|
|
color: VelocityTheme.danger,
|
|
filter: .highIntent
|
|
)
|
|
}
|
|
}
|
|
|
|
private var searchPanel: some View {
|
|
VStack(alignment: .leading, spacing: 10) {
|
|
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 isRemoteSearchLoading {
|
|
ProgressView()
|
|
.progressViewStyle(CircularProgressViewStyle(tint: VelocityTheme.accent))
|
|
.scaleEffect(0.74)
|
|
}
|
|
if !searchText.isEmpty {
|
|
Button("Clear") {
|
|
searchText = ""
|
|
}
|
|
.font(.system(size: 12, weight: .semibold))
|
|
.foregroundStyle(VelocityTheme.accent)
|
|
}
|
|
}
|
|
|
|
if activeListFilter != .all {
|
|
HStack(spacing: 8) {
|
|
Label(activeListFilter.chipTitle, systemImage: "line.3.horizontal.decrease.circle")
|
|
.font(.system(size: 11, weight: .semibold))
|
|
.foregroundStyle(VelocityTheme.success)
|
|
Text("\(filteredContacts.count) matching")
|
|
.font(.system(size: 11, weight: .medium))
|
|
.foregroundStyle(VelocityTheme.mutedFg)
|
|
Spacer()
|
|
Button("Clear filter") {
|
|
withAnimation(.interactiveSpring(response: 0.35, dampingFraction: 0.86)) {
|
|
activeListFilter = .all
|
|
}
|
|
}
|
|
.font(.system(size: 11, weight: .semibold))
|
|
.foregroundStyle(VelocityTheme.accent)
|
|
}
|
|
.transition(.opacity.combined(with: .move(edge: .top)))
|
|
}
|
|
|
|
if let searchError {
|
|
Text("Live search unavailable. Showing loaded contacts. \(searchError)")
|
|
.font(.system(size: 11, weight: .medium))
|
|
.foregroundStyle(VelocityTheme.warning)
|
|
}
|
|
}
|
|
.padding(16)
|
|
.glassCard(cornerRadius: 16)
|
|
}
|
|
|
|
private var contactsPanel: some View {
|
|
VStack(alignment: .leading, spacing: 12) {
|
|
HStack {
|
|
Text(activeListFilter.title)
|
|
.font(.system(size: 17, weight: .semibold))
|
|
.foregroundStyle(VelocityTheme.foreground)
|
|
Spacer()
|
|
Text(listCountLabel)
|
|
.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 {
|
|
if possibleDuplicateNameCount > 0 && activeListFilter != .possibleDuplicates && searchText.trimmedNonEmpty == nil {
|
|
duplicateReviewBanner
|
|
}
|
|
|
|
LazyVStack(spacing: 10) {
|
|
ForEach(filteredContacts) { contact in
|
|
contactCard(contact)
|
|
}
|
|
}
|
|
|
|
if searchText.trimmedNonEmpty == nil {
|
|
loadMoreControl
|
|
}
|
|
}
|
|
}
|
|
.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)
|
|
if isPossibleDuplicate(contact) && !store.isShowroomModeEnabled {
|
|
duplicateNameBadge
|
|
}
|
|
Spacer()
|
|
contactIntentBadge(contact)
|
|
}
|
|
Text("\(contact.buyerTypeLabel) · \(contact.leadStatusLabel)")
|
|
.font(.system(size: 12))
|
|
.foregroundStyle(VelocityTheme.foreground)
|
|
Text("\(contact.budgetSummary) · \(contact.interestSummary)")
|
|
.font(.system(size: 12))
|
|
.foregroundStyle(VelocityTheme.mutedFg)
|
|
contactActivityLine(contact)
|
|
}
|
|
|
|
Image(systemName: "chevron.right")
|
|
.font(.system(size: 12, weight: .semibold))
|
|
.foregroundStyle(VelocityTheme.subtleFg)
|
|
.padding(.top, 4)
|
|
}
|
|
.padding(14)
|
|
.background(
|
|
RoundedRectangle(cornerRadius: 16)
|
|
.fill(VelocityTheme.surface)
|
|
.overlay(
|
|
RoundedRectangle(cornerRadius: 16)
|
|
.stroke(VelocityTheme.borderSubtle, lineWidth: 1)
|
|
)
|
|
)
|
|
}
|
|
.buttonStyle(.plain)
|
|
.accessibilityLabel("Open dossier for \(contact.fullName), \(contact.leadStatusLabel)")
|
|
}
|
|
|
|
private var duplicateReviewBanner: some View {
|
|
HStack(spacing: 12) {
|
|
Label("\(possibleDuplicateNameCount) name \(possibleDuplicateNameCount == 1 ? "match" : "matches") need review", systemImage: "person.2.badge.gearshape")
|
|
.font(.system(size: 12, weight: .semibold))
|
|
.foregroundStyle(VelocityTheme.warning)
|
|
Text("Marking them keeps the canonical list honest without hiding legitimate buyers.")
|
|
.font(.system(size: 11, weight: .medium))
|
|
.foregroundStyle(VelocityTheme.mutedFg)
|
|
Spacer()
|
|
Button("Review") {
|
|
withAnimation(.interactiveSpring(response: 0.35, dampingFraction: 0.86)) {
|
|
activeListFilter = .possibleDuplicates
|
|
}
|
|
}
|
|
.font(.system(size: 11, weight: .semibold))
|
|
.foregroundStyle(VelocityTheme.accent)
|
|
}
|
|
.padding(12)
|
|
.background(
|
|
RoundedRectangle(cornerRadius: 14)
|
|
.fill(VelocityTheme.warning.opacity(0.08))
|
|
.overlay(
|
|
RoundedRectangle(cornerRadius: 14)
|
|
.stroke(VelocityTheme.warning.opacity(0.18), lineWidth: 1)
|
|
)
|
|
)
|
|
}
|
|
|
|
private var duplicateNameBadge: some View {
|
|
Text("Name match")
|
|
.font(.system(size: 10, weight: .bold))
|
|
.foregroundStyle(VelocityTheme.warning)
|
|
.padding(.horizontal, 7)
|
|
.padding(.vertical, 3)
|
|
.background(Capsule().fill(VelocityTheme.warning.opacity(0.12)))
|
|
}
|
|
|
|
private var loadMoreControl: some View {
|
|
VStack(spacing: 8) {
|
|
if let loadMoreError {
|
|
Text(loadMoreError)
|
|
.font(.system(size: 11, weight: .medium))
|
|
.foregroundStyle(VelocityTheme.warning)
|
|
.frame(maxWidth: .infinity, alignment: .leading)
|
|
}
|
|
|
|
if canLoadMoreContacts {
|
|
Button {
|
|
Task { await loadMoreContacts() }
|
|
} label: {
|
|
HStack(spacing: 8) {
|
|
if isLoadingMoreContacts {
|
|
ProgressView()
|
|
.progressViewStyle(CircularProgressViewStyle(tint: VelocityTheme.accent))
|
|
.scaleEffect(0.74)
|
|
} else {
|
|
Image(systemName: "arrow.down.circle")
|
|
}
|
|
Text(isLoadingMoreContacts ? "Loading more contacts..." : "Load next \(contactPageSize)")
|
|
}
|
|
.font(.system(size: 12, weight: .semibold))
|
|
.foregroundStyle(VelocityTheme.accent)
|
|
.frame(maxWidth: .infinity)
|
|
.padding(.vertical, 12)
|
|
.background(
|
|
RoundedRectangle(cornerRadius: 14)
|
|
.fill(VelocityTheme.accent.opacity(0.08))
|
|
.overlay(
|
|
RoundedRectangle(cornerRadius: 14)
|
|
.stroke(VelocityTheme.accent.opacity(0.16), lineWidth: 1)
|
|
)
|
|
)
|
|
}
|
|
.buttonStyle(.plain)
|
|
.disabled(isLoadingMoreContacts)
|
|
} else {
|
|
Text("All loaded contacts are shown.")
|
|
.font(.system(size: 11, weight: .semibold))
|
|
.foregroundStyle(VelocityTheme.mutedFg)
|
|
.frame(maxWidth: .infinity, alignment: .center)
|
|
.padding(.top, 2)
|
|
}
|
|
}
|
|
.padding(.top, 2)
|
|
}
|
|
|
|
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,
|
|
filter: ClientListFilter
|
|
) -> some View {
|
|
let isSelected = activeListFilter == filter
|
|
return Button {
|
|
UIImpactFeedbackGenerator(style: .light).impactOccurred()
|
|
withAnimation(.interactiveSpring(response: 0.35, dampingFraction: 0.84)) {
|
|
activeListFilter = filter
|
|
}
|
|
} label: {
|
|
VStack(alignment: .leading, spacing: 8) {
|
|
HStack(spacing: 6) {
|
|
Text(label.uppercased())
|
|
.font(.system(size: 10, weight: .semibold))
|
|
.tracking(1)
|
|
.foregroundStyle(isSelected ? color : VelocityTheme.mutedFg)
|
|
Spacer(minLength: 6)
|
|
if isSelected {
|
|
Image(systemName: "checkmark.circle.fill")
|
|
.font(.system(size: 11, weight: .bold))
|
|
.foregroundStyle(color)
|
|
}
|
|
}
|
|
Text(value)
|
|
.font(.system(size: 21, weight: .bold))
|
|
.contentTransition(.numericText())
|
|
.foregroundStyle(VelocityTheme.foreground)
|
|
RoundedRectangle(cornerRadius: 3)
|
|
.fill(color)
|
|
.frame(width: isSelected ? 58 : 42, height: 4)
|
|
}
|
|
.padding(16)
|
|
.frame(maxWidth: .infinity, alignment: .leading)
|
|
.background(
|
|
RoundedRectangle(cornerRadius: 16)
|
|
.fill(Color(red: 0.031, green: 0.039, blue: 0.071).opacity(isSelected ? 0.96 : 0.82))
|
|
.overlay(
|
|
RoundedRectangle(cornerRadius: 16)
|
|
.stroke(isSelected ? color.opacity(0.42) : VelocityTheme.borderAccent, lineWidth: 1)
|
|
)
|
|
.shadow(color: color.opacity(isSelected ? 0.18 : 0), radius: 18, y: 6)
|
|
)
|
|
}
|
|
.buttonStyle(.plain)
|
|
.accessibilityLabel("\(label), \(value)")
|
|
.accessibilityHint("Filters the client list")
|
|
}
|
|
|
|
private var filteredContacts: [VelocityCanonicalContactListItemDTO] {
|
|
let normalized = searchText.trimmingCharacters(in: .whitespacesAndNewlines).lowercased()
|
|
let baseContacts = normalized.isEmpty ? allLoadedContacts : (searchResults ?? allLoadedContacts)
|
|
let scopedContacts: [VelocityCanonicalContactListItemDTO]
|
|
switch activeListFilter {
|
|
case .activeLeads:
|
|
scopedContacts = baseContacts.filter { $0.leadId?.trimmedNonEmpty != nil }
|
|
case .openTasks:
|
|
scopedContacts = baseContacts.filter { $0.pendingTasks > 0 }
|
|
case .highIntent:
|
|
scopedContacts = baseContacts.filter { $0.displayIntentScore >= 80 }
|
|
case .whale:
|
|
scopedContacts = baseContacts.filter { $0.displayIntentScore >= 90 }
|
|
case .possibleDuplicates:
|
|
let duplicateKeys = Self.duplicateNameKeys(in: baseContacts)
|
|
scopedContacts = baseContacts.filter { duplicateKeys.contains(Self.duplicateNameKey(for: $0)) }
|
|
case .all:
|
|
scopedContacts = baseContacts
|
|
}
|
|
guard !normalized.isEmpty else {
|
|
return scopedContacts
|
|
}
|
|
if searchResults != nil {
|
|
return scopedContacts
|
|
}
|
|
return scopedContacts.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 {
|
|
allLoadedContacts.filter { $0.displayIntentScore >= 80 }.count
|
|
}
|
|
|
|
private var activeLeadCount: Int {
|
|
allLoadedContacts.filter { $0.leadId?.trimmedNonEmpty != nil }.count
|
|
}
|
|
|
|
private var openTaskClientCount: Int {
|
|
allLoadedContacts.filter { $0.pendingTasks > 0 }.count
|
|
}
|
|
|
|
private var listCountLabel: String {
|
|
if activeListFilter == .all && searchText.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty {
|
|
if let contactTotalCount {
|
|
return "\(allLoadedContacts.count) of \(contactTotalCount) loaded"
|
|
}
|
|
return "\(allLoadedContacts.count) loaded"
|
|
}
|
|
return "\(filteredContacts.count) shown"
|
|
}
|
|
|
|
private var allLoadedContacts: [VelocityCanonicalContactListItemDTO] {
|
|
var seenIDs: Set<String> = []
|
|
return (store.contacts + extraContacts).filter { contact in
|
|
seenIDs.insert(contact.personId).inserted
|
|
}
|
|
}
|
|
|
|
private var possibleDuplicateNameCount: Int {
|
|
Self.duplicateNameKeys(in: allLoadedContacts).count
|
|
}
|
|
|
|
@ViewBuilder
|
|
private func contactIntentBadge(_ contact: VelocityCanonicalContactListItemDTO) -> some View {
|
|
if store.isShowroomModeEnabled {
|
|
Label("Hidden", systemImage: "eye.slash")
|
|
.labelStyle(.iconOnly)
|
|
.font(.system(size: 12, weight: .semibold))
|
|
.foregroundStyle(VelocityTheme.warning)
|
|
.accessibilityLabel("Buyer-safe details hidden")
|
|
} else {
|
|
Text("Intent \(contact.displayIntentScore)")
|
|
.font(.system(size: 11, weight: .bold))
|
|
.foregroundStyle(VelocityTheme.accent)
|
|
.padding(.horizontal, 8)
|
|
.padding(.vertical, 4)
|
|
.background(Capsule().fill(VelocityTheme.accent.opacity(0.12)))
|
|
}
|
|
}
|
|
|
|
@ViewBuilder
|
|
private func contactActivityLine(_ contact: VelocityCanonicalContactListItemDTO) -> some View {
|
|
if store.isShowroomModeEnabled {
|
|
Text("Buyer-safe preview · contact details hidden")
|
|
.font(.system(size: 11, weight: .medium))
|
|
.foregroundStyle(VelocityTheme.warning)
|
|
} else {
|
|
Text("\(contact.contactLine) · \(contact.pendingTasks) tasks · \(contact.interactionCount) interactions")
|
|
.font(.system(size: 11))
|
|
.foregroundStyle(VelocityTheme.mutedFg)
|
|
}
|
|
}
|
|
|
|
private func isPossibleDuplicate(_ contact: VelocityCanonicalContactListItemDTO) -> Bool {
|
|
Self.duplicateNameKeys(in: allLoadedContacts).contains(Self.duplicateNameKey(for: contact))
|
|
}
|
|
|
|
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 {
|
|
clientBriefErrorState(client360Error)
|
|
} else if let snapshot = selectedClient360 {
|
|
client360Snapshot(snapshot)
|
|
}
|
|
}
|
|
.padding(20)
|
|
}
|
|
.background(VelocityTheme.background)
|
|
.navigationTitle("Client Intelligence Brief")
|
|
.toolbar {
|
|
ToolbarItem(placement: .topBarTrailing) {
|
|
Button("Done") {
|
|
selectedPersonID = nil
|
|
selectedClient360 = nil
|
|
client360Error = nil
|
|
isClient360Loading = false
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
private func clientBriefErrorState(_ message: String) -> some View {
|
|
VStack(alignment: .leading, spacing: 12) {
|
|
errorBanner(clientBriefErrorMessage(message))
|
|
if let selectedPersonID {
|
|
Button {
|
|
openClient360(for: selectedPersonID)
|
|
} label: {
|
|
Label("Retry Client Brief", systemImage: "arrow.clockwise")
|
|
}
|
|
.buttonStyle(.bordered)
|
|
.foregroundStyle(VelocityTheme.accent)
|
|
}
|
|
}
|
|
.frame(maxWidth: .infinity, alignment: .leading)
|
|
}
|
|
|
|
private func client360Snapshot(_ snapshot: VelocityClient360DTO) -> some View {
|
|
VStack(alignment: .leading, spacing: 14) {
|
|
ClientBriefHeader(
|
|
snapshot: snapshot,
|
|
isShowroomModeEnabled: store.isShowroomModeEnabled,
|
|
onEdit: {
|
|
clientEditDraft = ClientEditDraft(snapshot: snapshot)
|
|
},
|
|
onAddTask: {
|
|
clientTaskDraft = ClientTaskDraft(
|
|
personId: snapshot.identity.personId,
|
|
leadId: snapshot.currentLead?.leadId,
|
|
defaultPriority: store.crmVocabularies.taskPriorities.first?.value ?? ""
|
|
)
|
|
}
|
|
)
|
|
|
|
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 !store.isShowroomModeEnabled && !lead.motivations.isEmpty {
|
|
Text("Motivations: \(lead.motivations.joined(separator: ", "))")
|
|
.font(.system(size: 12))
|
|
.foregroundStyle(VelocityTheme.mutedFg)
|
|
}
|
|
if !store.isShowroomModeEnabled && !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 shouldShowBriefCoverageNotice(snapshot) {
|
|
clientBriefCoverageNotice(snapshot)
|
|
}
|
|
|
|
if !snapshot.activeOpportunities.isEmpty {
|
|
client360ListCard(title: "Active Opportunities") {
|
|
ForEach(snapshot.activeOpportunities) { opportunity in
|
|
HStack(alignment: .top, spacing: 10) {
|
|
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)
|
|
if !store.isShowroomModeEnabled {
|
|
Text(opportunity.nextAction ?? "Next action pending")
|
|
.font(.system(size: 12))
|
|
.foregroundStyle(VelocityTheme.mutedFg)
|
|
}
|
|
}
|
|
Spacer()
|
|
if !store.isShowroomModeEnabled {
|
|
Button {
|
|
opportunityEditDraft = ClientOpportunityEditDraft(opportunity: opportunity)
|
|
} label: {
|
|
Image(systemName: "square.and.pencil")
|
|
}
|
|
.buttonStyle(.plain)
|
|
.foregroundStyle(VelocityTheme.accent)
|
|
.accessibilityLabel("Edit opportunity")
|
|
}
|
|
}
|
|
.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)
|
|
}
|
|
}
|
|
}
|
|
|
|
if !store.isShowroomModeEnabled {
|
|
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 !store.isShowroomModeEnabled && (!snapshot.tasks.isEmpty || !snapshot.recommendedNextActions.isEmpty || !snapshot.riskFlags.isEmpty) {
|
|
client360ListCard(title: "Operator Actions") {
|
|
ForEach(snapshot.tasks) { task in
|
|
HStack(spacing: 8) {
|
|
sectionLine(task.title, value: "\(task.priorityLabel) · \(task.dueLabel)")
|
|
if store.pendingSyncTaskIDs.contains(task.reminderId) {
|
|
pendingSyncBadge
|
|
}
|
|
}
|
|
}
|
|
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 shouldShowBriefCoverageNotice(_ snapshot: VelocityClient360DTO) -> Bool {
|
|
!store.isShowroomModeEnabled
|
|
&& snapshot.activeOpportunities.isEmpty
|
|
&& snapshot.propertyInterests.isEmpty
|
|
&& snapshot.recentInteractions.isEmpty
|
|
&& snapshot.tasks.isEmpty
|
|
&& snapshot.recommendedNextActions.isEmpty
|
|
}
|
|
|
|
private func clientBriefCoverageNotice(_ snapshot: VelocityClient360DTO) -> some View {
|
|
HStack(alignment: .center, spacing: 12) {
|
|
Image(systemName: "doc.text.magnifyingglass")
|
|
.font(.system(size: 18, weight: .semibold))
|
|
.foregroundStyle(VelocityTheme.warning)
|
|
.frame(width: 34, height: 34)
|
|
.background(Circle().fill(VelocityTheme.warning.opacity(0.12)))
|
|
VStack(alignment: .leading, spacing: 4) {
|
|
Text("Brief needs more CRM signal")
|
|
.font(.system(size: 13, weight: .semibold))
|
|
.foregroundStyle(VelocityTheme.foreground)
|
|
Text("Profile data loaded, but no opportunities, interactions, property interests, or tasks were returned yet.")
|
|
.font(.system(size: 12))
|
|
.foregroundStyle(VelocityTheme.mutedFg)
|
|
}
|
|
Spacer()
|
|
Button {
|
|
clientTaskDraft = ClientTaskDraft(
|
|
personId: snapshot.identity.personId,
|
|
leadId: snapshot.currentLead?.leadId,
|
|
defaultPriority: store.crmVocabularies.taskPriorities.first?.value ?? ""
|
|
)
|
|
} label: {
|
|
Label("Add Follow-up", systemImage: "checklist")
|
|
}
|
|
.buttonStyle(.bordered)
|
|
.foregroundStyle(VelocityTheme.accent)
|
|
}
|
|
.padding(16)
|
|
.frame(maxWidth: .infinity, alignment: .leading)
|
|
.glassCard(cornerRadius: 18)
|
|
}
|
|
|
|
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 refreshWorkspace() async {
|
|
extraContacts = []
|
|
contactTotalCount = nil
|
|
loadMoreError = nil
|
|
await store.refresh()
|
|
canLoadMoreContacts = store.contacts.count >= contactPageSize
|
|
}
|
|
|
|
private func loadMoreContacts() async {
|
|
guard !isLoadingMoreContacts, canLoadMoreContacts else { return }
|
|
isLoadingMoreContacts = true
|
|
loadMoreError = nil
|
|
do {
|
|
let page = try await VelocityAPIClient.shared.fetchContactPage(
|
|
limit: contactPageSize,
|
|
offset: allLoadedContacts.count
|
|
)
|
|
await MainActor.run {
|
|
contactTotalCount = page.totalCount
|
|
mergeLoadedContacts(page.contacts)
|
|
canLoadMoreContacts = page.hasMore ?? (page.contacts.count >= contactPageSize)
|
|
isLoadingMoreContacts = false
|
|
}
|
|
} catch {
|
|
await MainActor.run {
|
|
loadMoreError = "More contacts could not be loaded. \(error.localizedDescription)"
|
|
isLoadingMoreContacts = false
|
|
}
|
|
}
|
|
}
|
|
|
|
private func mergeLoadedContacts(_ contacts: [VelocityCanonicalContactListItemDTO]) {
|
|
var existingIDs = Set(allLoadedContacts.map(\.personId))
|
|
let newContacts = contacts.filter { contact in
|
|
existingIDs.insert(contact.personId).inserted
|
|
}
|
|
extraContacts.append(contentsOf: newContacts)
|
|
}
|
|
|
|
private func scheduleRemoteSearch(for rawQuery: String) {
|
|
searchLookupTask?.cancel()
|
|
let query = rawQuery.trimmingCharacters(in: .whitespacesAndNewlines)
|
|
guard !query.isEmpty else {
|
|
searchResults = nil
|
|
searchError = nil
|
|
isRemoteSearchLoading = false
|
|
return
|
|
}
|
|
|
|
searchError = nil
|
|
isRemoteSearchLoading = true
|
|
searchLookupTask = Task {
|
|
try? await Task.sleep(nanoseconds: 320_000_000)
|
|
guard !Task.isCancelled else { return }
|
|
|
|
do {
|
|
let contacts = try await VelocityAPIClient.shared.fetchContacts(search: query, limit: 100)
|
|
await MainActor.run {
|
|
guard searchText.trimmingCharacters(in: .whitespacesAndNewlines) == query else { return }
|
|
searchResults = contacts
|
|
searchError = nil
|
|
isRemoteSearchLoading = false
|
|
}
|
|
} catch {
|
|
await MainActor.run {
|
|
guard searchText.trimmingCharacters(in: .whitespacesAndNewlines) == query else { return }
|
|
searchResults = nil
|
|
searchError = error.localizedDescription
|
|
isRemoteSearchLoading = false
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
private func consumeRequestedClient360() {
|
|
guard let personId = store.requestedClient360PersonID?.trimmedNonEmpty else {
|
|
return
|
|
}
|
|
store.requestedClient360PersonID = nil
|
|
openClient360(for: personId)
|
|
}
|
|
|
|
private func consumeRequestedClientFilter() {
|
|
guard let filter = store.requestedClientFilter else {
|
|
return
|
|
}
|
|
store.requestedClientFilter = nil
|
|
withAnimation(.interactiveSpring(response: 0.35, dampingFraction: 0.86)) {
|
|
searchText = ""
|
|
activeListFilter = filter == .whale ? .whale : .all
|
|
}
|
|
}
|
|
|
|
nonisolated private static func duplicateNameKeys(in contacts: [VelocityCanonicalContactListItemDTO]) -> Set<String> {
|
|
let grouped = Dictionary(grouping: contacts, by: duplicateNameKey(for:))
|
|
return Set(grouped.compactMap { key, values in
|
|
key.isEmpty || values.count < 2 ? nil : key
|
|
})
|
|
}
|
|
|
|
nonisolated private static func duplicateNameKey(for contact: VelocityCanonicalContactListItemDTO) -> String {
|
|
contact.fullName
|
|
.trimmingCharacters(in: .whitespacesAndNewlines)
|
|
.lowercased()
|
|
.components(separatedBy: CharacterSet.alphanumerics.inverted)
|
|
.filter { !$0.isEmpty }
|
|
.joined(separator: " ")
|
|
}
|
|
|
|
private func refreshSelectedClient360() async {
|
|
guard let personId = selectedPersonID else { return }
|
|
do {
|
|
let snapshot = try await VelocityAPIClient.shared.fetchClient360(personId: personId)
|
|
await MainActor.run {
|
|
selectedClient360 = snapshot
|
|
client360Error = nil
|
|
}
|
|
await store.refresh(silent: true)
|
|
} catch {
|
|
await MainActor.run {
|
|
client360Error = error.localizedDescription
|
|
}
|
|
}
|
|
}
|
|
|
|
private func saveClientEdits(_ draft: ClientEditDraft) async {
|
|
await MainActor.run {
|
|
isClientMutationSaving = true
|
|
client360Error = nil
|
|
}
|
|
do {
|
|
try await VelocityAPIClient.shared.updateClientData(
|
|
personId: draft.id,
|
|
fullName: draft.fullName.trimmedNonEmpty,
|
|
primaryEmail: draft.primaryEmail.trimmedNonEmpty,
|
|
primaryPhone: draft.primaryPhone.trimmedNonEmpty,
|
|
buyerType: draft.buyerType.trimmedNonEmpty,
|
|
leadStatus: draft.leadStatus.trimmedNonEmpty,
|
|
budgetBand: draft.budgetBand.trimmedNonEmpty,
|
|
urgency: draft.urgency.trimmedNonEmpty
|
|
)
|
|
await MainActor.run {
|
|
clientEditDraft = nil
|
|
isClientMutationSaving = false
|
|
}
|
|
await refreshSelectedClient360()
|
|
} catch {
|
|
await MainActor.run {
|
|
client360Error = error.localizedDescription
|
|
isClientMutationSaving = false
|
|
}
|
|
}
|
|
}
|
|
|
|
private func createClientTask(_ draft: ClientTaskDraft) async {
|
|
guard let title = draft.title.trimmedNonEmpty,
|
|
let priority = draft.priority.trimmedNonEmpty
|
|
else { return }
|
|
await MainActor.run {
|
|
isClientMutationSaving = true
|
|
client360Error = nil
|
|
}
|
|
do {
|
|
try await VelocityAPIClient.shared.createClientTask(
|
|
personId: draft.personId,
|
|
leadId: draft.leadId,
|
|
title: title,
|
|
notes: draft.notes.trimmedNonEmpty,
|
|
dueAt: draft.dueAt.trimmedNonEmpty,
|
|
priority: priority
|
|
)
|
|
await MainActor.run {
|
|
clientTaskDraft = nil
|
|
isClientMutationSaving = false
|
|
}
|
|
await refreshSelectedClient360()
|
|
} catch {
|
|
await MainActor.run {
|
|
client360Error = error.localizedDescription
|
|
isClientMutationSaving = false
|
|
}
|
|
}
|
|
}
|
|
|
|
private func saveOpportunityEdits(_ draft: ClientOpportunityEditDraft) async {
|
|
await MainActor.run {
|
|
isClientMutationSaving = true
|
|
client360Error = nil
|
|
}
|
|
let value = Double(draft.valueText.trimmingCharacters(in: .whitespacesAndNewlines))
|
|
let probability = Int(draft.probabilityText.trimmingCharacters(in: .whitespacesAndNewlines))
|
|
do {
|
|
_ = try await VelocityAPIClient.shared.updateOpportunity(
|
|
opportunityId: draft.id,
|
|
stage: draft.stage.trimmedNonEmpty,
|
|
value: value,
|
|
probability: probability,
|
|
expectedCloseDate: draft.expectedCloseDate.trimmedNonEmpty,
|
|
nextAction: draft.nextAction.trimmedNonEmpty,
|
|
notes: draft.notes.trimmedNonEmpty
|
|
)
|
|
await MainActor.run {
|
|
opportunityEditDraft = nil
|
|
isClientMutationSaving = false
|
|
}
|
|
await refreshSelectedClient360()
|
|
} catch {
|
|
await MainActor.run {
|
|
client360Error = error.localizedDescription
|
|
isClientMutationSaving = false
|
|
}
|
|
}
|
|
}
|
|
|
|
private func clientBriefErrorMessage(_ rawMessage: String) -> String {
|
|
if rawMessage.localizedCaseInsensitiveContains("invalid response") {
|
|
return "We could not load this client brief because the backend response did not match the expected CRM detail format."
|
|
}
|
|
return rawMessage
|
|
}
|
|
|
|
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 var pendingSyncBadge: some View {
|
|
Circle()
|
|
.fill(VelocityTheme.warning)
|
|
.frame(width: 8, height: 8)
|
|
.shadow(color: VelocityTheme.warning.opacity(0.75), radius: 6)
|
|
}
|
|
}
|
|
|
|
private struct ClientBriefHeader: View {
|
|
let snapshot: VelocityClient360DTO
|
|
let isShowroomModeEnabled: Bool
|
|
let onEdit: () -> Void
|
|
let onAddTask: () -> Void
|
|
|
|
var body: some View {
|
|
HStack(alignment: .center, spacing: 20) {
|
|
VStack(alignment: .leading, spacing: 9) {
|
|
Text(snapshot.identity.fullName)
|
|
.font(.system(size: 30, weight: .semibold, design: .default))
|
|
.foregroundStyle(VelocityTheme.foreground)
|
|
Text(headerSubtitle)
|
|
.font(.system(size: 13, weight: .medium))
|
|
.foregroundStyle(VelocityTheme.mutedFg)
|
|
if !isShowroomModeEnabled, let email = snapshot.identity.primaryEmail {
|
|
Text(email)
|
|
.font(.system(size: 12))
|
|
.foregroundStyle(VelocityTheme.mutedFg)
|
|
}
|
|
if !isShowroomModeEnabled && !snapshot.identity.personaLabels.isEmpty {
|
|
Text(snapshot.identity.personaLabels.joined(separator: " · "))
|
|
.font(.system(size: 12, weight: .semibold))
|
|
.foregroundStyle(VelocityTheme.accent)
|
|
}
|
|
|
|
if !isShowroomModeEnabled {
|
|
HStack(spacing: 10) {
|
|
Button(action: onEdit) {
|
|
Label("Edit Client", systemImage: "person.text.rectangle")
|
|
}
|
|
.buttonStyle(.bordered)
|
|
|
|
Button(action: onAddTask) {
|
|
Label("Add Task", systemImage: "checklist")
|
|
}
|
|
.buttonStyle(.bordered)
|
|
}
|
|
.padding(.top, 4)
|
|
}
|
|
}
|
|
|
|
Spacer(minLength: 20)
|
|
|
|
if isShowroomModeEnabled {
|
|
VStack(spacing: 8) {
|
|
Image(systemName: "eye.slash")
|
|
.font(.system(size: 28, weight: .semibold))
|
|
.foregroundStyle(VelocityTheme.warning)
|
|
Text("Buyer-safe")
|
|
.font(.system(size: 12, weight: .semibold))
|
|
.foregroundStyle(VelocityTheme.mutedFg)
|
|
}
|
|
.frame(width: 120, height: 120)
|
|
} else {
|
|
LuxurySentimentRing(score: snapshot.primaryQDScore)
|
|
}
|
|
}
|
|
.padding(22)
|
|
.frame(maxWidth: .infinity, alignment: .leading)
|
|
.background(
|
|
RoundedRectangle(cornerRadius: 22)
|
|
.fill(.ultraThinMaterial)
|
|
.overlay(
|
|
RoundedRectangle(cornerRadius: 22)
|
|
.stroke(Color.white.opacity(0.12), lineWidth: 1)
|
|
)
|
|
)
|
|
.vaultSwipeToShare(asset: VelocityVaultShareAsset(
|
|
leadId: snapshot.currentLead?.leadId,
|
|
assetName: "\(snapshot.identity.fullName) Client Intelligence Brief",
|
|
assetType: "pdf",
|
|
storagePath: "crm/client-360/\(snapshot.identity.personId)"
|
|
))
|
|
.draggable(snapshot.identity.personId)
|
|
}
|
|
|
|
private var headerSubtitle: String {
|
|
let buyerType = snapshot.identity.buyerType?.replacingOccurrences(of: "_", with: " ").capitalized ?? "CRM contact"
|
|
if isShowroomModeEnabled {
|
|
return "\(buyerType) · buyer-safe preview"
|
|
}
|
|
return "\(snapshot.identity.primaryPhone ?? "No phone") · \(buyerType)"
|
|
}
|
|
}
|
|
|
|
private struct LuxurySentimentRing: View {
|
|
let score: VelocityQdScoreDTO?
|
|
@State private var animatedTrim = 0.0
|
|
|
|
private var displayScore: Int {
|
|
score?.displayScore ?? 0
|
|
}
|
|
|
|
var body: some View {
|
|
ZStack {
|
|
Circle()
|
|
.stroke(VelocityTheme.surface.opacity(0.9), lineWidth: 11)
|
|
Circle()
|
|
.trim(from: 0, to: animatedTrim)
|
|
.stroke(
|
|
VelocityTheme.accent,
|
|
style: StrokeStyle(lineWidth: 11, lineCap: .round)
|
|
)
|
|
.rotationEffect(.degrees(-90))
|
|
.shadow(color: VelocityTheme.accent.opacity(0.55), radius: 12)
|
|
VStack(spacing: 2) {
|
|
Text(score == nil ? "--" : "\(displayScore)")
|
|
.font(.system(size: 28, weight: .bold))
|
|
.foregroundStyle(VelocityTheme.foreground)
|
|
Text(score?.scoreType.replacingOccurrences(of: "_", with: " ").capitalized ?? "QD")
|
|
.font(.system(size: 9, weight: .semibold))
|
|
.tracking(1)
|
|
.foregroundStyle(VelocityTheme.mutedFg)
|
|
.lineLimit(1)
|
|
.minimumScaleFactor(0.72)
|
|
}
|
|
}
|
|
.frame(width: 124, height: 124)
|
|
.onAppear {
|
|
withAnimation(.interactiveSpring(response: 0.8, dampingFraction: 0.82)) {
|
|
animatedTrim = Double(displayScore) / 100.0
|
|
}
|
|
}
|
|
.onChange(of: displayScore) { _, newScore in
|
|
withAnimation(.interactiveSpring(response: 0.65, dampingFraction: 0.84)) {
|
|
animatedTrim = Double(newScore) / 100.0
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
private struct ClientEditSheet: View {
|
|
@Environment(\.dismiss) private var dismiss
|
|
@State private var draft: ClientEditDraft
|
|
@State private var validationMessage: String?
|
|
let vocabularies: VelocityCRMVocabulariesDTO
|
|
let isSaving: Bool
|
|
let onSave: (ClientEditDraft) -> Void
|
|
|
|
init(
|
|
draft: ClientEditDraft,
|
|
vocabularies: VelocityCRMVocabulariesDTO,
|
|
isSaving: Bool,
|
|
onSave: @escaping (ClientEditDraft) -> Void
|
|
) {
|
|
_draft = State(initialValue: draft)
|
|
self.vocabularies = vocabularies
|
|
self.isSaving = isSaving
|
|
self.onSave = onSave
|
|
}
|
|
|
|
var body: some View {
|
|
NavigationStack {
|
|
Form {
|
|
Section("Identity") {
|
|
TextField("Full name", text: $draft.fullName)
|
|
TextField("Email", text: $draft.primaryEmail)
|
|
.textInputAutocapitalization(.never)
|
|
.keyboardType(.emailAddress)
|
|
TextField("Phone", text: $draft.primaryPhone)
|
|
.keyboardType(.phonePad)
|
|
vocabularyPicker(
|
|
"Buyer type",
|
|
selection: $draft.buyerType,
|
|
options: optionValues(vocabularies.buyerTypes, current: draft.buyerType, includeEmpty: true),
|
|
emptyLabel: "Unspecified"
|
|
)
|
|
}
|
|
Section {
|
|
vocabularyPicker(
|
|
"Lead status",
|
|
selection: $draft.leadStatus,
|
|
options: optionValues(vocabularies.leadStatuses, current: draft.leadStatus, includeEmpty: true),
|
|
emptyLabel: "Unspecified"
|
|
)
|
|
TextField("Budget band", text: $draft.budgetBand)
|
|
vocabularyPicker(
|
|
"Urgency",
|
|
selection: $draft.urgency,
|
|
options: optionValues(vocabularies.urgencies, current: draft.urgency, includeEmpty: true),
|
|
emptyLabel: "Unspecified"
|
|
)
|
|
} header: {
|
|
Text("Lead Context")
|
|
} footer: {
|
|
Text("Status, buyer type, and urgency values are loaded from the backend CRM vocabulary endpoint.")
|
|
}
|
|
if let validationMessage {
|
|
Section {
|
|
Text(validationMessage)
|
|
.foregroundStyle(VelocityTheme.danger)
|
|
}
|
|
}
|
|
}
|
|
.navigationTitle("Edit Client")
|
|
.toolbar {
|
|
ToolbarItem(placement: .cancellationAction) {
|
|
Button("Cancel") { dismiss() }
|
|
}
|
|
ToolbarItem(placement: .confirmationAction) {
|
|
Button(isSaving ? "Saving..." : "Save") {
|
|
save()
|
|
}
|
|
.disabled(isSaving || draft.fullName.trimmedNonEmpty == nil)
|
|
}
|
|
}
|
|
}
|
|
.presentationDetents([.medium, .large])
|
|
}
|
|
|
|
@ViewBuilder
|
|
private func vocabularyPicker(
|
|
_ title: String,
|
|
selection: Binding<String>,
|
|
options: [VelocityVocabularyOptionDTO],
|
|
emptyLabel: String
|
|
) -> some View {
|
|
Picker(title, selection: selection) {
|
|
ForEach(options) { option in
|
|
Text(option.value.isEmpty ? emptyLabel : option.label)
|
|
.tag(option.value)
|
|
}
|
|
}
|
|
}
|
|
|
|
private func optionValues(
|
|
_ options: [VelocityVocabularyOptionDTO],
|
|
current: String,
|
|
includeEmpty: Bool = false
|
|
) -> [VelocityVocabularyOptionDTO] {
|
|
var resolved = options
|
|
if includeEmpty {
|
|
resolved.insert(VelocityVocabularyOptionDTO(value: "", label: "Unspecified", description: nil, icon: nil), at: 0)
|
|
}
|
|
if let current = current.trimmedNonEmpty,
|
|
!resolved.contains(where: { $0.value == current }) {
|
|
resolved.insert(
|
|
VelocityVocabularyOptionDTO(
|
|
value: current,
|
|
label: current.replacingOccurrences(of: "_", with: " ").capitalized,
|
|
description: "Current backend value",
|
|
icon: nil
|
|
),
|
|
at: includeEmpty ? 1 : 0
|
|
)
|
|
}
|
|
return resolved
|
|
}
|
|
|
|
private func save() {
|
|
if let email = draft.primaryEmail.trimmedNonEmpty,
|
|
!Self.isValidEmail(email) {
|
|
validationMessage = "Enter a valid email address or leave email empty."
|
|
return
|
|
}
|
|
if let phone = draft.primaryPhone.trimmedNonEmpty {
|
|
let digits = phone.filter(\.isNumber)
|
|
if digits.count < 7 {
|
|
validationMessage = "Phone numbers must contain at least 7 digits."
|
|
return
|
|
}
|
|
}
|
|
if let status = draft.leadStatus.trimmedNonEmpty,
|
|
!vocabularies.leadStatuses.map(\.value).contains(status) {
|
|
validationMessage = "Lead status must use a canonical CRM status."
|
|
return
|
|
}
|
|
if let urgency = draft.urgency.trimmedNonEmpty,
|
|
!vocabularies.urgencies.map(\.value).contains(urgency) {
|
|
validationMessage = "Urgency must use a backend CRM urgency value."
|
|
return
|
|
}
|
|
validationMessage = nil
|
|
onSave(draft)
|
|
}
|
|
|
|
private static func isValidEmail(_ value: String) -> Bool {
|
|
let parts = value.split(separator: "@", omittingEmptySubsequences: false)
|
|
guard parts.count == 2, !parts[0].isEmpty, parts[1].contains(".") else {
|
|
return false
|
|
}
|
|
return true
|
|
}
|
|
}
|
|
|
|
private struct ClientTaskSheet: View {
|
|
@Environment(\.dismiss) private var dismiss
|
|
@State private var draft: ClientTaskDraft
|
|
@State private var hasDueDate: Bool
|
|
@State private var dueDate: Date
|
|
@State private var validationMessage: String?
|
|
let priorities: [VelocityVocabularyOptionDTO]
|
|
let isSaving: Bool
|
|
let onSave: (ClientTaskDraft) -> Void
|
|
|
|
init(
|
|
draft: ClientTaskDraft,
|
|
priorities: [VelocityVocabularyOptionDTO],
|
|
isSaving: Bool,
|
|
onSave: @escaping (ClientTaskDraft) -> Void
|
|
) {
|
|
_draft = State(initialValue: draft)
|
|
if let parsedDueDate = draft.dueAt.trimmedNonEmpty.flatMap({ ISO8601DateFormatter().date(from: $0) }) {
|
|
_hasDueDate = State(initialValue: true)
|
|
_dueDate = State(initialValue: parsedDueDate)
|
|
} else {
|
|
_hasDueDate = State(initialValue: false)
|
|
_dueDate = State(initialValue: Calendar.current.date(byAdding: .day, value: 1, to: Date()) ?? Date())
|
|
}
|
|
self.priorities = priorities
|
|
self.isSaving = isSaving
|
|
self.onSave = onSave
|
|
}
|
|
|
|
var body: some View {
|
|
NavigationStack {
|
|
Form {
|
|
Section {
|
|
TextField("Title", text: $draft.title)
|
|
TextField("Notes", text: $draft.notes)
|
|
Toggle("Add due date", isOn: $hasDueDate.animation(.interactiveSpring(response: 0.32, dampingFraction: 0.84)))
|
|
if hasDueDate {
|
|
DatePicker("Due", selection: $dueDate, displayedComponents: [.date, .hourAndMinute])
|
|
}
|
|
Picker("Priority", selection: $draft.priority) {
|
|
ForEach(optionValues(priorities, current: draft.priority)) { priority in
|
|
Text(priority.label)
|
|
.tag(priority.value)
|
|
}
|
|
}
|
|
} header: {
|
|
Text("Task")
|
|
} footer: {
|
|
Text("Priority values are loaded from the backend CRM vocabulary endpoint.")
|
|
}
|
|
if let validationMessage {
|
|
Section {
|
|
Text(validationMessage)
|
|
.foregroundStyle(VelocityTheme.danger)
|
|
}
|
|
}
|
|
}
|
|
.navigationTitle("Add Client Task")
|
|
.toolbar {
|
|
ToolbarItem(placement: .cancellationAction) {
|
|
Button("Cancel") { dismiss() }
|
|
}
|
|
ToolbarItem(placement: .confirmationAction) {
|
|
Button(isSaving ? "Creating..." : "Create") {
|
|
save()
|
|
}
|
|
.disabled(isSaving || draft.title.trimmedNonEmpty == nil || draft.priority.trimmedNonEmpty == nil)
|
|
}
|
|
}
|
|
}
|
|
.presentationDetents([.medium])
|
|
}
|
|
|
|
private func optionValues(
|
|
_ options: [VelocityVocabularyOptionDTO],
|
|
current: String
|
|
) -> [VelocityVocabularyOptionDTO] {
|
|
guard let current = current.trimmedNonEmpty,
|
|
!options.contains(where: { $0.value == current })
|
|
else {
|
|
return options
|
|
}
|
|
return [
|
|
VelocityVocabularyOptionDTO(
|
|
value: current,
|
|
label: current.replacingOccurrences(of: "_", with: " ").capitalized,
|
|
description: "Current backend value",
|
|
icon: nil
|
|
)
|
|
] + options
|
|
}
|
|
|
|
private func save() {
|
|
if !priorities.map(\.value).contains(draft.priority) {
|
|
validationMessage = "Priority must use a backend CRM priority value."
|
|
return
|
|
}
|
|
var resolvedDraft = draft
|
|
resolvedDraft.dueAt = hasDueDate ? ISO8601DateFormatter().string(from: dueDate) : ""
|
|
validationMessage = nil
|
|
onSave(resolvedDraft)
|
|
}
|
|
}
|
|
|
|
private struct ClientOpportunityEditSheet: View {
|
|
@Environment(\.dismiss) private var dismiss
|
|
@State private var draft: ClientOpportunityEditDraft
|
|
@State private var validationMessage: String?
|
|
let isSaving: Bool
|
|
let onSave: (ClientOpportunityEditDraft) -> Void
|
|
|
|
init(
|
|
draft: ClientOpportunityEditDraft,
|
|
isSaving: Bool,
|
|
onSave: @escaping (ClientOpportunityEditDraft) -> Void
|
|
) {
|
|
_draft = State(initialValue: draft)
|
|
self.isSaving = isSaving
|
|
self.onSave = onSave
|
|
}
|
|
|
|
var body: some View {
|
|
NavigationStack {
|
|
Form {
|
|
Section("Deal") {
|
|
TextField("Stage", text: $draft.stage)
|
|
TextField("Value", text: $draft.valueText)
|
|
.keyboardType(.decimalPad)
|
|
TextField("Probability %", text: $draft.probabilityText)
|
|
.keyboardType(.numberPad)
|
|
TextField("Expected close date", text: $draft.expectedCloseDate)
|
|
.textInputAutocapitalization(.never)
|
|
}
|
|
Section("Operator Context") {
|
|
TextField("Next action", text: $draft.nextAction)
|
|
TextField("Notes", text: $draft.notes)
|
|
}
|
|
if let validationMessage {
|
|
Section {
|
|
Text(validationMessage)
|
|
.foregroundStyle(VelocityTheme.danger)
|
|
}
|
|
}
|
|
}
|
|
.navigationTitle("Edit Deal")
|
|
.toolbar {
|
|
ToolbarItem(placement: .cancellationAction) {
|
|
Button("Cancel") { dismiss() }
|
|
}
|
|
ToolbarItem(placement: .confirmationAction) {
|
|
Button(isSaving ? "Saving..." : "Save") {
|
|
save()
|
|
}
|
|
.disabled(isSaving || draft.stage.trimmedNonEmpty == nil)
|
|
}
|
|
}
|
|
}
|
|
.presentationDetents([.medium, .large])
|
|
}
|
|
|
|
private func save() {
|
|
if let valueText = draft.valueText.trimmedNonEmpty,
|
|
Double(valueText) == nil {
|
|
validationMessage = "Deal value must be a number."
|
|
return
|
|
}
|
|
if let probabilityText = draft.probabilityText.trimmedNonEmpty {
|
|
guard let probability = Int(probabilityText), (0...100).contains(probability) else {
|
|
validationMessage = "Probability must be a whole number from 0 to 100."
|
|
return
|
|
}
|
|
}
|
|
if let closeDate = draft.expectedCloseDate.trimmedNonEmpty,
|
|
Self.closeDateFormatter.date(from: closeDate) == nil {
|
|
validationMessage = "Expected close date must use YYYY-MM-DD."
|
|
return
|
|
}
|
|
validationMessage = nil
|
|
onSave(draft)
|
|
}
|
|
|
|
private static let closeDateFormatter: DateFormatter = {
|
|
let formatter = DateFormatter()
|
|
formatter.calendar = Calendar(identifier: .gregorian)
|
|
formatter.locale = Locale(identifier: "en_US_POSIX")
|
|
formatter.dateFormat = "yyyy-MM-dd"
|
|
formatter.isLenient = false
|
|
return formatter
|
|
}()
|
|
}
|
|
|
|
private extension String {
|
|
var trimmedNonEmpty: String? {
|
|
let value = trimmingCharacters(in: .whitespacesAndNewlines)
|
|
return value.isEmpty ? nil : value
|
|
}
|
|
}
|
|
|
|
#Preview {
|
|
ClientsView()
|
|
}
|