import Foundation import Observation @MainActor @Observable final class LeadOverviewViewModel { var searchText = "" { didSet { guard searchText != oldValue else { return } if query.searchText.isEmpty { restoreCachedList() } } } var selection: LeadOverviewSelection = .newest { didSet { updateDisplayedLeads() } } private(set) var leads: [Lead] = [] private(set) var bookmarkedLeads: [Lead] = [] private(set) var isLoading = false private(set) var lastUpdated: Date? private(set) var error: LeadError? private(set) var actionError: LeadError? private(set) var bookmarkingIDs: Set = [] private var loadedLeads: [Lead] = [] private var cachedList: [Lead]? private var cachedListUpdatedAt: Date? private var displayedQuery: LeadQuery? private var generation = 0 private var bookmarkRevision = 0 private var confirmedBookmarks: [String: (revision: Int, value: Bool)] = [:] @ObservationIgnored private let salesCode: String @ObservationIgnored private let load: LoadLeadsUseCase @ObservationIgnored private let search: SearchLeadsUseCase @ObservationIgnored private let bookmark: SetLeadBookmarkUseCase init(salesCode: String, repository: any LeadRepository) { self.salesCode = salesCode load = LoadLeadsUseCase(repository: repository) search = SearchLeadsUseCase(repository: repository) bookmark = SetLeadBookmarkUseCase(repository: repository) } var query: LeadQuery { // Only the search term affects the server request; sorting and filtering stay local. LeadQuery(searchText: searchText).normalized } var hasCurrentResults: Bool { displayedQuery == query } var isSessionExpired: Bool { error == .unauthorized || actionError == .unauthorized } func reload(debounce: Bool = false, useCachedList: Bool = false) async { if useCachedList && query.searchText.isEmpty && cachedList != nil && !isSessionExpired { restoreCachedList() return } generation += 1 let request = generation let requestedBookmarkRevision = bookmarkRevision let requestedQuery = query isLoading = true error = nil defer { if request == generation { isLoading = false } } do { if debounce && !requestedQuery.searchText.isEmpty { try await Task.sleep(for: .milliseconds(300)) } try Task.checkCancellation() let result: [Lead] if requestedQuery == LeadQuery() { result = try await load.execute(salesCode: salesCode) } else { result = try await search.execute(salesCode: salesCode, query: requestedQuery) } try Task.checkCancellation() // Query comparison also protects the interval before the next .task starts. guard request == generation, requestedQuery == query else { return } loadedLeads = result.map { lead in var lead = lead if let confirmed = confirmedBookmarks[lead.id], confirmed.revision > requestedBookmarkRevision { lead.isBookmarked = confirmed.value } return lead } updateDisplayedLeads() displayedQuery = requestedQuery lastUpdated = Date() if requestedQuery.searchText.isEmpty { cachedList = loadedLeads cachedListUpdatedAt = lastUpdated } } catch is CancellationError { // A superseded search must not replace data or present an error. } catch { guard request == generation, requestedQuery == query else { return } self.error = error as? LeadError ?? .unexpected } } func toggleBookmark(_ lead: Lead) async { if let updated = await saveBookmark(lead) { applyBookmark(updated) } } func saveBookmark(_ lead: Lead) async -> Lead? { guard !bookmarkingIDs.contains(lead.id), !isSessionExpired else { return nil } bookmarkingIDs.insert(lead.id) actionError = nil defer { bookmarkingIDs.remove(lead.id) } do { let updated = try await bookmark.execute(!lead.isBookmarked, leadID: lead.id, salesCode: salesCode) try Task.checkCancellation() return updated } catch is CancellationError { return nil } catch { actionError = error as? LeadError ?? .unexpected return nil } } func applyBookmark(_ updated: Lead) { bookmarkRevision += 1 confirmedBookmarks[updated.id] = (bookmarkRevision, updated.isBookmarked) if let index = cachedList?.firstIndex(where: { $0.id == updated.id }) { cachedList?[index] = updated } if let index = loadedLeads.firstIndex(where: { $0.id == updated.id }) { loadedLeads[index] = updated updateDisplayedLeads() } } private func updateDisplayedLeads() { let filtered = selection == .open ? loadedLeads.filter { $0.unsignedContractCount > 0 } : loadedLeads leads = filtered.sorted { lhs, rhs in if selection == .alphabetical { let order = lhs.displayName.localizedStandardCompare(rhs.displayName) return order == .orderedSame ? lhs.id < rhs.id : order == .orderedAscending } return lhs.createdAt == rhs.createdAt ? lhs.id < rhs.id : lhs.createdAt > rhs.createdAt } bookmarkedLeads = leads.filter(\.isBookmarked) } func dismissActionError() { actionError = nil } private func restoreCachedList() { guard let cachedList, !isSessionExpired else { return } // Invalidate in-flight searches before publishing the original list. generation += 1 loadedLeads = cachedList displayedQuery = LeadQuery() lastUpdated = cachedListUpdatedAt error = nil isLoading = false updateDisplayedLeads() } func clearSearchText() { searchText = "" } func resetSearch() { clearSearchText() selection = .newest } }