Files
Project_Velocity/iOS/velocity-ipad/velocity/Features/Clients/ClientsView.swift
sayan eeb684b46c
All checks were successful
Production Readiness / backend-contracts (push) Successful in 1m47s
Production Readiness / webos-typecheck (push) Successful in 1m50s
Production Readiness / ipad-parse (push) Successful in 1m34s
feat: Ipad app production readiness, Colony orchestration, Social posting (#44)
#38 Ipad app production readiness, Colony orchestration, Social posting

Co-authored-by: Sayan Datta <sayan@Sayans-MacBook-Air.local>
Reviewed-on: #44
2026-05-03 18:30:38 +05:30

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