Add contractors section to residence detail and fix search filtering

- Add GET /contractors/by-residence/:id endpoint integration
- Display contractors on residence detail screen (iOS & Android)
- Fix contractor search/filter to use client-side filtering
- Backend doesn't support search query params, so filter locally

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
Trey t
2025-12-01 20:38:57 -06:00
parent fe2e8275f5
commit e62e7d4371
9 changed files with 295 additions and 44 deletions

View File

@@ -18,14 +18,23 @@ struct ContractorsListView: View {
contractorSpecialties.map { $0.name }
}
var filteredContractors: [ContractorSummary] {
contractors
}
var contractors: [ContractorSummary] {
viewModel.contractors
}
// Client-side filtering since backend doesn't support search/filter params
var filteredContractors: [ContractorSummary] {
contractors.filter { contractor in
let matchesSearch = searchText.isEmpty ||
contractor.name.localizedCaseInsensitiveContains(searchText) ||
(contractor.company?.localizedCaseInsensitiveContains(searchText) ?? false)
let matchesSpecialty = selectedSpecialty == nil ||
contractor.specialties.contains { $0.name == selectedSpecialty }
let matchesFavorite = !showFavoritesOnly || contractor.isFavorite
return matchesSearch && matchesSpecialty && matchesFavorite
}
}
// Check if upgrade screen should be shown (disables add button)
private var shouldShowUpgrade: Bool {
subscriptionCache.shouldShowUpgradePrompt(currentCount: viewModel.contractors.count, limitKey: "contractors")
@@ -65,9 +74,9 @@ struct ContractorsListView: View {
.padding(.vertical, AppSpacing.xs)
}
// Content
// Content - use filteredContractors for client-side filtering
ListAsyncContentView(
items: contractors,
items: filteredContractors,
isLoading: viewModel.isLoading,
errorMessage: viewModel.errorMessage,
content: { contractorList in
@@ -102,20 +111,18 @@ struct ContractorsListView: View {
.toolbar {
ToolbarItem(placement: .navigationBarTrailing) {
HStack(spacing: AppSpacing.sm) {
// Favorites Filter
// Favorites Filter (client-side, no API call needed)
Button(action: {
showFavoritesOnly.toggle()
loadContractors()
}) {
Image(systemName: showFavoritesOnly ? "star.fill" : "star")
.foregroundColor(showFavoritesOnly ? Color.appAccent : Color.appTextSecondary)
}
// Specialty Filter
// Specialty Filter (client-side, no API call needed)
Menu {
Button(action: {
selectedSpecialty = nil
loadContractors()
}) {
Label("All Specialties", systemImage: selectedSpecialty == nil ? "checkmark" : "")
}
@@ -125,7 +132,6 @@ struct ContractorsListView: View {
ForEach(specialties, id: \.self) { specialty in
Button(action: {
selectedSpecialty = specialty
loadContractors()
}) {
Label(specialty, systemImage: selectedSpecialty == specialty ? "checkmark" : "")
}
@@ -167,17 +173,12 @@ struct ContractorsListView: View {
loadContractors()
loadContractorSpecialties()
}
.onChange(of: searchText) { newValue in
loadContractors()
}
// No need for onChange on searchText - filtering is client-side
}
private func loadContractors(forceRefresh: Bool = false) {
viewModel.loadContractors(
specialty: selectedSpecialty,
isFavorite: showFavoritesOnly ? true : nil,
search: searchText.isEmpty ? nil : searchText
)
// Load all contractors, filtering is done client-side
viewModel.loadContractors()
}
private func loadContractorSpecialties() {

View File

@@ -10,6 +10,10 @@ struct ResidenceDetailView: View {
@State private var tasksResponse: TaskColumnsResponse?
@State private var isLoadingTasks = false
@State private var tasksError: String?
@State private var contractors: [ContractorSummary] = []
@State private var isLoadingContractors = false
@State private var contractorsError: String?
@State private var showAddTask = false
@State private var showEditResidence = false
@@ -198,9 +202,12 @@ private extension ResidenceDetailView {
PropertyHeaderCard(residence: residence)
.padding(.horizontal)
.padding(.top)
tasksSection
.padding(.horizontal)
contractorsSection
.padding(.horizontal)
}
.padding(.bottom)
}
@@ -226,6 +233,67 @@ private extension ResidenceDetailView {
.padding()
}
}
@ViewBuilder
var contractorsSection: some View {
VStack(alignment: .leading, spacing: AppSpacing.md) {
// Section Header
HStack(spacing: AppSpacing.sm) {
Image(systemName: "person.2.fill")
.font(.title2)
.foregroundColor(Color.appPrimary)
Text("Contractors")
.font(.title2.weight(.bold))
.foregroundColor(Color.appPrimary)
}
.padding(.top, AppSpacing.sm)
if isLoadingContractors {
HStack {
Spacer()
ProgressView()
Spacer()
}
.padding()
} else if let error = contractorsError {
Text("Error: \(error)")
.foregroundColor(Color.appError)
.padding()
} else if contractors.isEmpty {
// Empty state
VStack(spacing: AppSpacing.md) {
Image(systemName: "person.crop.circle.badge.plus")
.font(.system(size: 48))
.foregroundColor(Color.appTextSecondary.opacity(0.6))
Text("No contractors yet")
.font(.headline)
.foregroundColor(Color.appTextPrimary)
Text("Add contractors from the Contractors tab")
.font(.subheadline)
.foregroundColor(Color.appTextSecondary)
}
.frame(maxWidth: .infinity)
.padding(AppSpacing.xl)
.background(Color.appBackgroundSecondary)
.cornerRadius(AppRadius.lg)
} else {
// Contractors list
VStack(spacing: AppSpacing.sm) {
ForEach(contractors, id: \.id) { contractor in
NavigationLink(destination: ContractorDetailView(contractorId: contractor.id)) {
ContractorCard(
contractor: contractor,
onToggleFavorite: {
// Could implement toggle favorite here if needed
}
)
}
.buttonStyle(PlainButtonStyle())
}
}
}
}
}
}
// MARK: - Toolbars
@@ -299,6 +367,7 @@ private extension ResidenceDetailView {
func loadResidenceData() {
viewModel.getResidence(id: residenceId)
loadResidenceTasks()
loadResidenceContractors()
}
func loadResidenceTasks() {
@@ -365,6 +434,39 @@ private extension ResidenceDetailView {
}
}
}
func loadResidenceContractors() {
guard TokenStorage.shared.getToken() != nil else { return }
isLoadingContractors = true
contractorsError = nil
Task {
do {
let result = try await APILayer.shared.getContractorsByResidence(
residenceId: Int32(Int(residenceId))
)
await MainActor.run {
if let successResult = result as? ApiResultSuccess<NSArray> {
self.contractors = (successResult.data as? [ContractorSummary]) ?? []
self.isLoadingContractors = false
} else if let errorResult = result as? ApiResultError {
self.contractorsError = errorResult.message
self.isLoadingContractors = false
} else {
self.contractorsError = "Failed to load contractors"
self.isLoadingContractors = false
}
}
} catch {
await MainActor.run {
self.contractorsError = error.localizedDescription
self.isLoadingContractors = false
}
}
}
}
}
private struct TasksSectionContainer: View {

View File

@@ -135,4 +135,8 @@ class ResidenceViewModel: ObservableObject {
func clearError() {
errorMessage = nil
}
func loadResidenceContractors(residenceId: Int32) {
sharedViewModel.loadResidenceContractors(residenceId: residenceId)
}
}