Add lead workflows, PDF signing, AR projects and photo gallery integration

This commit is contained in:
Dennis Nemec
2026-11-08 17:49:39 +01:00
parent a343bde741
commit 8d18873412
82 changed files with 6401 additions and 34 deletions

View File

@ -0,0 +1,173 @@
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<String> = []
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
}
}