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? @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 = [] 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 { 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( 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 { 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, 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() }