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
@@ -1,7 +1,6 @@
import { useState } from 'react'
import { Box, Button, CircularProgress, Typography } from '@mui/material'
import { Sparkles } from 'lucide-react'
import { aiService } from '../../services/aiService'
import { useGenerateOfferEmail } from '../../hooks/useAI'
interface AiOfferEmailButtonProps {
needTitle: string
@@ -16,33 +15,26 @@ export function AiOfferEmailButton({
matchScores,
onGenerated,
}: AiOfferEmailButtonProps) {
const [loading, setLoading] = useState(false)
const [error, setError] = useState<string | null>(null)
const generateOfferEmail = useGenerateOfferEmail()
const handleClick = async () => {
setLoading(true)
setError(null)
try {
const res = await aiService.generateOfferEmail({
needTitle,
properties: selectedProperties,
matchScores,
})
onGenerated(res.data.subject, res.data.body)
} catch (e) {
setError(e instanceof Error ? e.message : 'KI-Generierung fehlgeschlagen')
} finally {
setLoading(false)
}
const handleClick = () => {
generateOfferEmail.mutate(
{ needTitle, properties: selectedProperties, matchScores },
{
onSuccess: (res) => {
onGenerated(res.data.subject, res.data.body)
},
},
)
}
return (
<Box>
<Button
size="small"
startIcon={loading ? <CircularProgress size={14} /> : <Sparkles size={14} />}
startIcon={generateOfferEmail.isPending ? <CircularProgress size={14} /> : <Sparkles size={14} />}
onClick={handleClick}
disabled={loading}
disabled={generateOfferEmail.isPending}
sx={{
textTransform: 'none',
color: '#7c3aed',
@@ -53,9 +45,11 @@ export function AiOfferEmailButton({
>
KI-Mail generieren
</Button>
{error && (
{generateOfferEmail.isError && (
<Typography variant="caption" sx={{ color: 'error.main', display: 'block', mt: 0.5 }}>
{error}
{generateOfferEmail.error instanceof Error
? generateOfferEmail.error.message
: 'KI-Generierung fehlgeschlagen'}
</Typography>
)}
</Box>
@@ -6,7 +6,7 @@ import {
} from '@mui/material'
import { Plus, Trash2, X } from 'lucide-react'
import type { OfferReportDraft, ViewingAppointmentOption } from '../../domain/offerReport'
import { offerReportService } from '../../services/offerReportService'
import { useCreateOfferReport, useUpdateOfferReport, useGenerateOfferReportPdf } from '../../hooks/useOfferReport'
import { usePropertyById } from '../../hooks/useProperties'
import { useToastStore } from '../../stores/toastStore'
import { OfferWizardPdfStep } from './OfferWizardPdfStep'
@@ -32,10 +32,16 @@ export function OfferCreationWizard({ inquiryId, propertyId, tenantName, onClose
const showToast = useToastStore(s => s.showToast)
const timerRef = useRef<ReturnType<typeof setInterval> | null>(null)
const createOfferReport = useCreateOfferReport()
const updateOfferReport = useUpdateOfferReport()
const generatePdf = useGenerateOfferReportPdf()
useEffect(() => {
offerReportService
.create(inquiryId, propertyId, tenantName, property?.title ?? '')
.then(d => { setDraft(d); setLoading(false) })
createOfferReport.mutate(
{ inquiryId, propertyId, tenantName, propertyTitle: property?.title ?? '' },
{ onSuccess: (d) => { setDraft(d); setLoading(false) } },
)
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [inquiryId, propertyId, tenantName, property?.title])
useEffect(() => () => { if (timerRef.current) clearInterval(timerRef.current) }, [])
@@ -70,28 +76,31 @@ export function OfferCreationWizard({ inquiryId, propertyId, tenantName, onClose
})
}
const handleGeneratePdf = async () => {
const handleGeneratePdf = () => {
if (!draft) return
await offerReportService.update(draft.id, {
editableFields: draft.editableFields,
viewingAppointments: draft.viewingAppointments,
})
setStep(3)
setGenerating(true)
setProgress(0)
let p = 0
timerRef.current = setInterval(() => {
p += Math.random() * 18 + 8
if (p >= 100) {
clearInterval(timerRef.current!)
setProgress(100)
setGenerating(false)
setReady(true)
offerReportService.generatePdf(draft.id).then(setDraft)
} else {
setProgress(Math.min(100, p))
}
}, 200)
updateOfferReport.mutate(
{ draftId: draft.id, data: { editableFields: draft.editableFields, viewingAppointments: draft.viewingAppointments } },
{
onSuccess: () => {
setStep(3)
setGenerating(true)
setProgress(0)
let p = 0
timerRef.current = setInterval(() => {
p += Math.random() * 18 + 8
if (p >= 100) {
clearInterval(timerRef.current!)
setProgress(100)
setGenerating(false)
setReady(true)
generatePdf.mutate(draft.id, { onSuccess: setDraft })
} else {
setProgress(Math.min(100, p))
}
}, 200)
},
},
)
}
return (
@@ -8,7 +8,7 @@ import { Download, Send, X } from 'lucide-react'
import type { Inquiry } from '../../domain/inquiry'
import type { Property } from '../../domain/property'
import type { InquiryPreparationReportDraft, ReportObjectFieldSelection } from '../../domain/inquiryReport'
import { inquiryReportService } from '../../services/inquiryReportService'
import { useCreateInquiryReport, useUpdateInquiryReport, useFinalizeInquiryReport } from '../../hooks/useInquiryReport'
import { useToastStore } from '../../stores/toastStore'
import { ReportObjectFieldSelector } from './ReportObjectFieldSelector'
import { LatentInquiryReportPreview } from './LatentInquiryReportPreview'
@@ -29,11 +29,15 @@ export function PreparationWizard({ inquiryId, inquiry, onClose }: Props) {
const [draft, setDraft] = useState<InquiryPreparationReportDraft | null>(null)
const [generating, setGenerating] = useState(false)
const [progress, setProgress] = useState(0)
const [finalizing, setFinalizing] = useState(false)
const { data: allProperties = [] } = useProperties()
const showToast = useToastStore(s => s.showToast)
const timerRef = useRef<ReturnType<typeof setInterval> | null>(null)
const createInquiryReport = useCreateInquiryReport()
const updateInquiryReport = useUpdateInquiryReport()
const finalizeInquiryReport = useFinalizeInquiryReport()
const finalizing = finalizeInquiryReport.isPending
const portfolioProps = allProperties.filter(p => p.resultType === ResultType.VERIFIED_PORTFOLIO)
const selectedProperties = draft?.selectedPropertyIds
@@ -46,7 +50,7 @@ export function PreparationWizard({ inquiryId, inquiry, onClose }: Props) {
)
}
const handleGenerate = async () => {
const handleGenerate = () => {
setStep(1)
setGenerating(true)
setProgress(0)
@@ -57,10 +61,15 @@ export function PreparationWizard({ inquiryId, inquiry, onClose }: Props) {
clearInterval(timerRef.current!)
setProgress(100)
setGenerating(false)
inquiryReportService.create(inquiryId, selectedIds).then(d => {
setDraft(d)
setStep(2)
})
createInquiryReport.mutate(
{ inquiryId, selectedPropertyIds: selectedIds },
{
onSuccess: (d) => {
setDraft(d)
setStep(2)
},
},
)
} else {
setProgress(Math.min(100, p))
}
@@ -85,14 +94,21 @@ export function PreparationWizard({ inquiryId, inquiry, onClose }: Props) {
})
}
const handleFinalize = async () => {
const handleFinalize = () => {
if (!draft) return
setFinalizing(true)
await inquiryReportService.update(draft.id, { editableFields: draft.editableFields, fieldSelections: draft.fieldSelections })
const finalized = await inquiryReportService.finalize(draft.id)
setDraft(finalized)
setFinalizing(false)
setStep(3)
updateInquiryReport.mutate(
{ draftId: draft.id, data: { editableFields: draft.editableFields, fieldSelections: draft.fieldSelections } },
{
onSuccess: () => {
finalizeInquiryReport.mutate(draft.id, {
onSuccess: (finalized) => {
setDraft(finalized)
setStep(3)
},
})
},
},
)
}
return (
@@ -1,9 +1,8 @@
import { useEffect, useState } from 'react'
import { Box, Button, CircularProgress, Divider, Typography } from '@mui/material'
import { ArrowRight, Building2, Calendar, MapPin, Ruler, Trophy } from 'lucide-react'
import { useNavigate } from 'react-router'
import { usePropertyById } from '../../hooks/useProperties'
import { matchService } from '../../services/matchService'
import { useAdditionalMatchesForInquiry } from '../../hooks/useMatches'
import type { AdditionalPropertyMatch } from '../../domain/additionalMatch'
interface RelatedPropertyCardPanelProps {
@@ -14,16 +13,10 @@ interface RelatedPropertyCardPanelProps {
export function RelatedPropertyCardPanel({ propertyId, inquiryId }: RelatedPropertyCardPanelProps) {
const { data: property, isLoading } = usePropertyById(propertyId)
const navigate = useNavigate()
const [additionalMatches, setAdditionalMatches] = useState<AdditionalPropertyMatch[]>([])
const [loadingMatches, setLoadingMatches] = useState(false)
useEffect(() => {
setLoadingMatches(true)
matchService
.getAdditionalMatchesForInquiry(inquiryId, { minScore: 80, excludePropertyId: propertyId })
.then(setAdditionalMatches)
.finally(() => setLoadingMatches(false))
}, [inquiryId, propertyId])
const {
data: additionalMatches = [],
isLoading: loadingMatches,
} = useAdditionalMatchesForInquiry(inquiryId, { minScore: 80, excludePropertyId: propertyId })
if (isLoading) {
return (