refactor(arch): eliminate direct service calls in components — route all through hooks

New hook files:
- useAuth.ts: useLogin, useSwitchDemoRole, useSwitchOrganization
- useAI.ts: useParseNeed, useGenerateOfferEmail, useGenerateDecisionBrief, useParseListingText
- useAssistant.ts: useAssistantSuggestions (useQuery), useAssistantAnswer (useMutation)
- useOfferReport.ts: useOfferReportByInquiry, useCreateOfferReport, useUpdateOfferReport, useGenerateOfferReportPdf
- useInquiryReport.ts: useInquiryReportByInquiry, useCreateInquiryReport, useUpdateInquiryReport, useFinalizeInquiryReport
- useMarketReport.ts: useMarketReport
- useWeighting.ts: useDefaultWeights (synchronous wrapper)
- useUnitMatches.ts: useUnitMatchesMap, useUnitBundle, useBundleMatches (useMemo wrappers)

Extended hooks: useProperties (add update/create/remove mutations),
useNeeds (add useCreateNeed), useMatches (add useNeedMatchesForProperty,
useAdditionalMatchesForInquiry), useReviewQueue (add useCreateReviewTask)

Updated 21 components/pages: all direct service imports replaced with hooks.
Deliberate exception: getRecommendedActions in DataQuality.tsx (pure sync utility, no provider access).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Benjamin Sutter
2026-05-24 14:56:32 +02:00
parent f487435a94
commit e36c5bc979
33 changed files with 666 additions and 407 deletions
+27
View File
@@ -0,0 +1,27 @@
import { useMutation } from '@tanstack/react-query'
import { aiService, parseListingText } from '../services/aiService'
import type { OfferEmailPayload } from '../services/aiService'
export function useParseNeed() {
return useMutation({
mutationFn: (text: string) => aiService.parseNeed(text),
})
}
export function useGenerateOfferEmail() {
return useMutation({
mutationFn: (payload: OfferEmailPayload) => aiService.generateOfferEmail(payload),
})
}
export function useGenerateDecisionBrief() {
return useMutation({
mutationFn: (shortlistId: string) => aiService.generateDecisionBrief(shortlistId),
})
}
export function useParseListingText() {
return useMutation({
mutationFn: (text: string) => parseListingText(text),
})
}
+19
View File
@@ -0,0 +1,19 @@
import { useQuery, useMutation } from '@tanstack/react-query'
import { aiAssistantService } from '../services/aiAssistantService'
import type { AssistantContext } from '../domain/assistant'
export function useAssistantSuggestions(context: AssistantContext | null) {
return useQuery({
queryKey: ['assistant-suggestions', context],
queryFn: () => aiAssistantService.getSuggestions(context!),
enabled: !!context,
staleTime: 30_000,
})
}
export function useAssistantAnswer() {
return useMutation({
mutationFn: ({ context, question }: { context: AssistantContext; question: string }) =>
aiAssistantService.answerQuestion(context, question),
})
}
+31
View File
@@ -0,0 +1,31 @@
import { useMutation } from '@tanstack/react-query'
import { useNavigate } from 'react-router'
import { authService } from '../services/authService'
import type { UserRole } from '../domain/enums'
export function useLogin() {
const navigate = useNavigate()
return useMutation({
mutationFn: ({ email, password }: { email: string; password: string }) =>
authService.login(email, password),
onSuccess: () => {
navigate('/')
},
})
}
export function useSwitchDemoRole() {
const navigate = useNavigate()
return useMutation({
mutationFn: (role: UserRole) => authService.switchDemoRole(role),
onSuccess: () => {
navigate('/')
},
})
}
export function useSwitchOrganization() {
return useMutation({
mutationFn: (organizationId: string) => authService.switchOrganization(organizationId),
})
}
+41
View File
@@ -0,0 +1,41 @@
import { useQuery, useMutation } from '@tanstack/react-query'
import { inquiryReportService } from '../services/inquiryReportService'
import type { InquiryPreparationReportDraft } from '../domain/inquiryReport'
export function useInquiryReportByInquiry(inquiryId: string | null) {
return useQuery({
queryKey: ['inquiry-report', inquiryId],
queryFn: () => inquiryReportService.getByInquiry(inquiryId!),
enabled: !!inquiryId,
})
}
export function useCreateInquiryReport() {
return useMutation({
mutationFn: ({
inquiryId,
selectedPropertyIds,
}: {
inquiryId: string
selectedPropertyIds: string[]
}) => inquiryReportService.create(inquiryId, selectedPropertyIds),
})
}
export function useUpdateInquiryReport() {
return useMutation({
mutationFn: ({
draftId,
data,
}: {
draftId: string
data: Partial<InquiryPreparationReportDraft>
}) => inquiryReportService.update(draftId, data),
})
}
export function useFinalizeInquiryReport() {
return useMutation({
mutationFn: (draftId: string) => inquiryReportService.finalize(draftId),
})
}
+10
View File
@@ -0,0 +1,10 @@
import { useQuery } from '@tanstack/react-query'
import { marketReportService } from '../services/marketReportService'
export function useMarketReport(propertyId: string | null) {
return useQuery({
queryKey: ['market-report', propertyId],
queryFn: () => marketReportService.getByProperty(propertyId!),
enabled: !!propertyId,
})
}
+21
View File
@@ -54,3 +54,24 @@ export function useMatchDetail(id: string) {
select: (res) => res.data ?? null,
})
}
export function useNeedMatchesForProperty(propertyId: string, opts?: { minScore?: number }) {
return useQuery({
queryKey: ['need-matches-for-property', propertyId, opts ?? {}],
queryFn: () => matchService.getNeedMatchesForProperty(propertyId, opts),
staleTime: STALE_MATCHES,
enabled: Boolean(propertyId),
})
}
export function useAdditionalMatchesForInquiry(
inquiryId: string | null,
opts?: { minScore?: number; excludePropertyId?: string },
) {
return useQuery({
queryKey: ['additional-matches-for-inquiry', inquiryId, opts ?? {}],
queryFn: () => matchService.getAdditionalMatchesForInquiry(inquiryId!, opts),
staleTime: STALE_MATCHES,
enabled: !!inquiryId,
})
}
+12 -1
View File
@@ -1,5 +1,6 @@
import { useQuery } from '@tanstack/react-query'
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
import { needService } from '../services/needService'
import type { CreateNeedInput } from '../domain/need'
export function useNeeds() {
return useQuery({
@@ -19,3 +20,13 @@ export function useNeed(id: string) {
}
export const useNeedProfiles = useNeeds
export function useCreateNeed() {
const queryClient = useQueryClient()
return useMutation({
mutationFn: (input: CreateNeedInput) => needService.create(input),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['needs'] })
},
})
}
+40
View File
@@ -0,0 +1,40 @@
import { useQuery, useMutation } from '@tanstack/react-query'
import { offerReportService } from '../services/offerReportService'
import type { OfferReportDraft } from '../domain/offerReport'
export function useOfferReportByInquiry(inquiryId: string | null) {
return useQuery({
queryKey: ['offer-report', inquiryId],
queryFn: () => offerReportService.getByInquiry(inquiryId!),
enabled: !!inquiryId,
})
}
export function useCreateOfferReport() {
return useMutation({
mutationFn: ({
inquiryId,
propertyId,
tenantName,
propertyTitle,
}: {
inquiryId: string
propertyId: string
tenantName?: string
propertyTitle?: string
}) => offerReportService.create(inquiryId, propertyId, tenantName, propertyTitle),
})
}
export function useUpdateOfferReport() {
return useMutation({
mutationFn: ({ draftId, data }: { draftId: string; data: Partial<OfferReportDraft> }) =>
offerReportService.update(draftId, data),
})
}
export function useGenerateOfferReportPdf() {
return useMutation({
mutationFn: (draftId: string) => offerReportService.generatePdf(draftId),
})
}
+34 -1
View File
@@ -1,8 +1,9 @@
import { useQuery } from '@tanstack/react-query'
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
import { propertyService } from '../services/propertyService'
import { matchService } from '../services/matchService'
import { futureSignalService } from '../services/futureSignalService'
import type { AssetType, ResultType } from '../domain/enums'
import type { CreatePropertyInput, UpdatePropertyInput } from '../domain/property'
import { STALE_PROPERTIES, STALE_MATCHES, STALE_SIGNALS } from '../lib/constants'
interface PropertyFilter {
@@ -64,3 +65,35 @@ export function usePropertySignals(propertyId: string | null) {
select: (res) => res.data ?? [],
})
}
export function useUpdateProperty() {
const queryClient = useQueryClient()
return useMutation({
mutationFn: ({ id, input }: { id: string; input: UpdatePropertyInput }) =>
propertyService.update(id, input),
onSuccess: (_, { id }) => {
queryClient.invalidateQueries({ queryKey: ['properties'] })
queryClient.invalidateQueries({ queryKey: ['property', id] })
},
})
}
export function useCreateProperty() {
const queryClient = useQueryClient()
return useMutation({
mutationFn: (input: CreatePropertyInput) => propertyService.create(input),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['properties'] })
},
})
}
export function useRemoveProperty() {
const queryClient = useQueryClient()
return useMutation({
mutationFn: (id: string) => propertyService.remove(id),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['properties'] })
},
})
}
+13
View File
@@ -75,6 +75,19 @@ export function useAddReviewNote() {
})
}
export function useCreateReviewTask() {
const queryClient = useQueryClient()
return useMutation({
mutationFn: (signalId: string) => reviewService.createReviewTask(signalId),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: [QK] })
},
onError: () => {
useToastStore.getState().showToast('Prüfungsaufgabe konnte nicht erstellt werden.', 'error')
},
})
}
// Legacy exports
export function useApproveReviewItem() {
const queryClient = useQueryClient()
+35
View File
@@ -0,0 +1,35 @@
import { useMemo } from 'react'
import { unitMatchService } from '../services/unitMatchService'
import type { Property, PropertyUnit } from '../domain/property'
/**
* Synchronous wrappers — unitMatchService methods are pure synchronous computations.
* useMemo ensures we don't recompute on every render unnecessarily.
*/
export function useUnitMatchesMap(freeUnits: PropertyUnit[], property: Property) {
return useMemo(
() =>
Object.fromEntries(
freeUnits.map(u => [u.id, unitMatchService.getMatchesForUnit(u, property)]),
),
[freeUnits, property],
)
}
export function useUnitBundle(selectedUnits: PropertyUnit[]) {
return useMemo(
() => (selectedUnits.length >= 2 ? unitMatchService.buildBundle(selectedUnits) : null),
[selectedUnits],
)
}
export function useBundleMatches(selectedUnits: PropertyUnit[], property: Property) {
return useMemo(
() =>
selectedUnits.length >= 2
? unitMatchService.getMatchesForBundle(selectedUnits, property)
: [],
[selectedUnits, property],
)
}
+10
View File
@@ -0,0 +1,10 @@
import { weightingService } from '../services/weightingService'
import type { WeightingKey } from '../domain/needBuilder'
/**
* Synchronous wrapper — weightingService.getDefaultWeights is a pure synchronous
* computation with no async/side-effects. No useQuery needed.
*/
export function useDefaultWeights(assetType?: string): Record<WeightingKey, number> {
return weightingService.getDefaultWeights(assetType)
}