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 (
@@ -5,13 +5,13 @@ import { useLocation } from 'react-router'
import { useShallow } from 'zustand/react/shallow'
import { useAssistantStore } from '../../stores/assistantStore'
import { useSessionStore } from '../../stores/sessionStore'
import { aiAssistantService } from '../../services/aiAssistantService'
import { useAssistantSuggestions, useAssistantAnswer } from '../../hooks/useAssistant'
import { AssistantContextSummary } from './AssistantContextSummary'
import { AssistantMessageList } from './AssistantMessageList'
import { AssistantPromptSuggestions } from './AssistantPromptSuggestions'
import { AssistantLoadingState } from './AssistantLoadingState'
import { AssistantErrorState } from './AssistantErrorState'
import type { AssistantContext, SuggestedQuestion } from '../../domain/assistant'
import type { AssistantContext } from '../../domain/assistant'
import type { WorkspaceType } from '../../domain/enums'
function resolveWorkspace(pathname: string): WorkspaceType | null {
@@ -27,24 +27,13 @@ export function GlobalAIAssistantDrawer() {
const { currentUser } = useSessionStore()
const location = useLocation()
const [suggestions, setSuggestions] = useState<SuggestedQuestion[]>([])
const [inputText, setInputText] = useState('')
const scrollRef = useRef<HTMLDivElement>(null)
// Build context from route when drawer opens
useEffect(() => {
if (!isOpen) return
const ctx: AssistantContext = {
currentRoute: location.pathname,
workspace: resolveWorkspace(location.pathname),
userRole: currentUser?.role ?? 'VIEWER',
organizationId: currentUser?.organizationId ?? '',
}
setContext(ctx)
aiAssistantService.getSuggestions(ctx).then(setSuggestions)
}, [isOpen, location.pathname])
const answerQuestion = useAssistantAnswer()
const { data: suggestions = [] } = useAssistantSuggestions(isOpen ? context : null)
// Refresh suggestions when route changes while open
// Build context from route when drawer opens or route changes while open
useEffect(() => {
if (!isOpen) return
const ctx: AssistantContext = {
@@ -54,8 +43,7 @@ export function GlobalAIAssistantDrawer() {
organizationId: currentUser?.organizationId ?? '',
}
setContext(ctx)
aiAssistantService.getSuggestions(ctx).then(setSuggestions)
}, [location.pathname])
}, [isOpen, location.pathname])
// Auto-scroll on new messages
useEffect(() => {
@@ -64,7 +52,7 @@ export function GlobalAIAssistantDrawer() {
}
}, [messages, isLoading])
const handleQuestion = useCallback(async (question: string) => {
const handleQuestion = useCallback((question: string) => {
if (!question.trim() || isLoading) return
setInputText('')
setError(null)
@@ -78,26 +66,32 @@ export function GlobalAIAssistantDrawer() {
addMessage(userMsg)
setLoading(true)
try {
const ctx = context ?? {
currentRoute: location.pathname,
workspace: resolveWorkspace(location.pathname),
userRole: currentUser?.role ?? 'VIEWER',
organizationId: currentUser?.organizationId ?? '',
}
const answer = await aiAssistantService.answerQuestion(ctx, question)
addMessage({
id: crypto.randomUUID(),
role: 'assistant',
createdAt: new Date().toISOString(),
...answer,
})
} catch {
setError('Antwort konnte nicht generiert werden. Bitte erneut versuchen.')
} finally {
setLoading(false)
const ctx = context ?? {
currentRoute: location.pathname,
workspace: resolveWorkspace(location.pathname),
userRole: currentUser?.role ?? 'VIEWER',
organizationId: currentUser?.organizationId ?? '',
}
}, [context, isLoading, location.pathname, currentUser, addMessage, setLoading, setError])
answerQuestion.mutate(
{ context: ctx, question },
{
onSuccess: (answer) => {
addMessage({
id: crypto.randomUUID(),
role: 'assistant',
createdAt: new Date().toISOString(),
...answer,
})
setLoading(false)
},
onError: () => {
setError('Antwort konnte nicht generiert werden. Bitte erneut versuchen.')
setLoading(false)
},
},
)
}, [context, isLoading, location.pathname, currentUser, addMessage, setLoading, setError, answerQuestion])
const handleKeyDown = (e: React.KeyboardEvent) => {
if (e.key === 'Enter' && !e.shiftKey) {
@@ -108,10 +102,6 @@ export function GlobalAIAssistantDrawer() {
const handleClear = () => {
clearConversation()
setSuggestions([])
if (context) {
aiAssistantService.getSuggestions(context).then(setSuggestions)
}
}
const showSuggestions = suggestions.length > 0 && messages.length === 0
+4 -3
View File
@@ -1,6 +1,6 @@
import { Box, Chip, Typography } from '@mui/material'
import { UserRole } from '../../domain/enums'
import { authService } from '../../services/authService'
import { useSwitchDemoRole } from '../../hooks/useAuth'
import { useSessionStore } from '../../stores/sessionStore'
const DEMO_ROLES: { role: UserRole; label: string }[] = [
@@ -10,9 +10,10 @@ const DEMO_ROLES: { role: UserRole; label: string }[] = [
export function DemoRoleSwitcher() {
const { currentUser } = useSessionStore()
const switchDemoRole = useSwitchDemoRole()
async function handleSwitch(role: UserRole) {
await authService.switchDemoRole(role)
function handleSwitch(role: UserRole) {
switchDemoRole.mutate(role)
}
return (
+4 -3
View File
@@ -1,6 +1,6 @@
import { FormControl, MenuItem, Select, Typography } from '@mui/material'
import type { SelectChangeEvent } from '@mui/material'
import { authService } from '../../services/authService'
import { useSwitchOrganization } from '../../hooks/useAuth'
import { useSessionStore } from '../../stores/sessionStore'
const MOCK_ORGANIZATIONS = [
@@ -11,9 +11,10 @@ const MOCK_ORGANIZATIONS = [
export function OrganizationSwitcher() {
const { activeOrganizationId } = useSessionStore()
const switchOrganization = useSwitchOrganization()
async function handleChange(e: SelectChangeEvent<string>) {
await authService.switchOrganization(e.target.value)
function handleChange(e: SelectChangeEvent<string>) {
switchOrganization.mutate(e.target.value)
}
return (
+4 -4
View File
@@ -3,7 +3,7 @@ import { Box, Button, Card, Divider, Slider, Typography } from '@mui/material'
import { RotateCcw } from 'lucide-react'
import { WEIGHTING_KEYS, WEIGHTING_LABELS } from '../../domain/needBuilder'
import type { WeightingKey } from '../../domain/needBuilder'
import { weightingService } from '../../services/weightingService'
import { useDefaultWeights } from '../../hooks/useWeighting'
const HARD_KEYS: WeightingKey[] = ['area', 'location', 'budget', 'timing']
const SOFT_KEYS: WeightingKey[] = [
@@ -74,6 +74,7 @@ function SliderGroup({
export function WeightingEditor({ weights, onChange, assetType }: Props) {
const [raw, setRaw] = useState<Record<WeightingKey, number>>(() => toRaw(weights))
const defaultWeights = useDefaultWeights(assetType)
function handleSlider(key: WeightingKey, value: number) {
const updated = { ...raw, [key]: value }
@@ -82,9 +83,8 @@ export function WeightingEditor({ weights, onChange, assetType }: Props) {
}
function handleReset() {
const defaults = weightingService.getDefaultWeights(assetType)
setRaw(toRaw(defaults))
onChange(defaults)
setRaw(toRaw(defaultWeights))
onChange(defaultWeights)
}
return (
@@ -6,9 +6,9 @@ import { SensitivityBadge } from './SensitivityBadge'
import { SignalReviewStatusBadge } from './SignalReviewStatusBadge'
import { FutureSignalDisclaimer } from './FutureSignalDisclaimer'
import { useUpdateSignalReviewStatus } from '../../hooks/useFutureSignals'
import { useCreateReviewTask } from '../../hooks/useReviewQueue'
import { useShortlistStore } from '../../stores/shortlistStore'
import { useToastStore } from '../../stores/toastStore'
import { reviewService } from '../../services/reviewService'
import { ReviewStatus } from '../../domain/enums'
import type { FutureSignal } from '../../domain/futureSignal'
@@ -51,6 +51,7 @@ interface Props {
export function FutureSignalDetailPanel({ signal, onClose }: Props) {
const updateStatus = useUpdateSignalReviewStatus()
const createReviewTask = useCreateReviewTask()
const { openAddDialog } = useShortlistStore()
const showToast = useToastStore((s) => s.showToast)
const [reviewTaskSent, setReviewTaskSent] = useState(false)
@@ -75,14 +76,13 @@ export function FutureSignalDetailPanel({ signal, onClose }: Props) {
}
}
async function handleSendReview() {
try {
await reviewService.createReviewTask(signal.id)
setReviewTaskSent(true)
showToast('Prüfungsaufgabe erstellt.')
} catch {
showToast('Prüfungsaufgabe konnte nicht erstellt werden.', 'error')
}
function handleSendReview() {
createReviewTask.mutate(signal.id, {
onSuccess: () => {
setReviewTaskSent(true)
showToast('Prüfungsaufgabe erstellt.')
},
})
}
function handleShortlist() {
@@ -2,12 +2,12 @@ import { Box, CircularProgress, Typography } from '@mui/material'
import { useNavigate } from 'react-router'
import { useMatchesByProperty, useApproveMatch } from '../../hooks/useMatches'
import { usePropertyById } from '../../hooks/useProperties'
import { useCreateReviewTask } from '../../hooks/useReviewQueue'
import { useMatchCenterStore } from '../../stores/matchCenterStore'
import { useCompareStore } from '../../stores/compareStore'
import { useToastStore } from '../../stores/toastStore'
import { MatchCardExpanded } from '../match-card/MatchCardExpanded'
import { buildMatchCardViewModel } from '../../features/matching/matchCardAdapter'
import { reviewService } from '../../services/reviewService'
import { MatchCenterEmptyState } from './MatchCenterEmptyState'
import { MatchStatusBadge } from './MatchStatusBadge'
import type { MatchCardAction } from '../match-card/MatchCardViewModel'
@@ -18,6 +18,7 @@ export function MatchBriefingPanel() {
const { selectedPropertyId, selectedNeedId } = useMatchCenterStore()
const { addToCompare } = useCompareStore()
const approveMatch = useApproveMatch()
const createReviewTask = useCreateReviewTask()
const showToast = useToastStore((s) => s.showToast)
const { data: matches = [], isLoading: matchesLoading } = useMatchesByProperty(selectedPropertyId ?? '')
@@ -64,7 +65,7 @@ export function MatchBriefingPanel() {
label: 'Zur Prüfung',
actionType: 'SEND_REVIEW',
variant: 'secondary',
onClick: () => { reviewService.createReviewTask(match.id) },
onClick: () => { createReviewTask.mutate(match.id) },
},
{
id: 'compare',
@@ -1,7 +1,7 @@
import { useState } from 'react'
import { Accordion, AccordionDetails, AccordionSummary, Alert, Box, Button, CircularProgress, Typography } from '@mui/material'
import { ChevronDown, Sparkles } from 'lucide-react'
import { aiService } from '../../services/aiService'
import { useGenerateDecisionBrief } from '../../hooks/useAI'
import type { DecisionBrief } from '../../services/aiService'
interface Props {
@@ -10,16 +10,15 @@ interface Props {
export function DecisionBriefDraftPanel({ shortlistId }: Props) {
const [brief, setBrief] = useState<DecisionBrief | null>(null)
const [loading, setLoading] = useState(false)
const generateDecisionBrief = useGenerateDecisionBrief()
const loading = generateDecisionBrief.isPending
async function handleGenerate() {
setLoading(true)
try {
const resp = await aiService.generateDecisionBrief(shortlistId)
setBrief(resp.data)
} finally {
setLoading(false)
}
function handleGenerate() {
generateDecisionBrief.mutate(shortlistId, {
onSuccess: (resp) => {
setBrief(resp.data)
},
})
}
return (
+2 -14
View File
@@ -1,25 +1,13 @@
import { useEffect, useState } from 'react'
import { Alert, Box, CircularProgress, Typography } from '@mui/material'
import { useNavigate } from 'react-router'
import type { PropertyNeedMatch } from '../../domain/match'
import { matchService } from '../../services/matchService'
import { useNeedMatchesForProperty } from '../../hooks/useMatches'
import { useOfferWizardStore } from '../../stores/offerWizardStore'
import { NeedMatchCard } from './NeedMatchCard'
export function MatchabilityTabPanel({ propertyId }: { propertyId: string }) {
const [matches, setMatches] = useState<PropertyNeedMatch[]>([])
const [loading, setLoading] = useState(true)
const navigate = useNavigate()
const setSelectedProperties = useOfferWizardStore(s => s.setSelectedProperties)
useEffect(() => {
let cancelled = false
setLoading(true)
matchService.getNeedMatchesForProperty(propertyId, { minScore: 80 }).then(result => {
if (!cancelled) { setMatches(result); setLoading(false) }
})
return () => { cancelled = true }
}, [propertyId])
const { data: matches = [], isLoading: loading } = useNeedMatchesForProperty(propertyId, { minScore: 80 })
function handleNeedCardClick() {
setSelectedProperties([propertyId])
+15 -16
View File
@@ -1,10 +1,9 @@
import { useState } from 'react'
import { Box, Chip, CircularProgress, Divider, Switch, TextField, Typography } from '@mui/material'
import { Clock, Layers, ShieldCheck, Target, TrendingUp, Users, Zap } from 'lucide-react'
import { useQueryClient } from '@tanstack/react-query'
import type { Property } from '../../domain/property'
import { MockupUnitProvider } from '../../provider/MockupUnitProvider'
import { propertyService } from '../../services/propertyService'
import { useUpdateProperty } from '../../hooks/useProperties'
import { useToastStore } from '../../stores/toastStore'
import { DS_BORDER, DS_PRE_MARKET, DS_SURFACE, DS_TEXT } from '../../lib/ds'
import { floorLabel, SectionTitle } from './PropertyDetailHelpers'
@@ -14,7 +13,6 @@ export const MOCK_TODAY = new Date('2026-05-20')
export function PreMarketPanel({ p }: { p: Property }) {
const [enabled, setEnabled] = useState(p.schattenmarktRelease?.enabled ?? false)
const [leadTimeMonths, setLeadTimeMonths] = useState(p.schattenmarktRelease?.leadTimeMonths ?? 6)
const [saving, setSaving] = useState(false)
const [unitStates, setUnitStates] = useState<Record<string, { enabled: boolean; availableFrom: string }>>(() => {
const init: Record<string, { enabled: boolean; availableFrom: string }> = {}
for (const u of p.units ?? []) {
@@ -26,8 +24,9 @@ export function PreMarketPanel({ p }: { p: Property }) {
return init
})
const [unitSaving, setUnitSaving] = useState<Record<string, boolean>>({})
const queryClient = useQueryClient()
const updateProperty = useUpdateProperty()
const showToast = useToastStore(s => s.showToast)
const saving = updateProperty.isPending
if (p.resultType !== 'VERIFIED_PORTFOLIO') return null
if (!p.leaseEndDate && !p.breakoutOptionDate) return null
@@ -54,18 +53,18 @@ export function PreMarketPanel({ p }: { p: Property }) {
(['Zürich', 'Basel', 'Bern', 'Zug'].some(c => (p.location?.city ?? '').includes(c)) ? 4 : 1))
const highQualityLeads = Math.max(1, Math.floor(demandProfiles * 0.38))
async function save(nextEnabled: boolean, nextLeadTime: number) {
setSaving(true)
try {
await propertyService.update(p.id, { schattenmarktRelease: { enabled: nextEnabled, leadTimeMonths: nextLeadTime } })
await queryClient.invalidateQueries({ queryKey: ['property', p.id] })
await queryClient.invalidateQueries({ queryKey: ['properties'] })
showToast(nextEnabled ? 'Pre-Market Matching aktiviert.' : 'Pre-Market Matching deaktiviert.', 'success')
} catch {
showToast('Fehler beim Speichern.', 'error')
} finally {
setSaving(false)
}
function save(nextEnabled: boolean, nextLeadTime: number) {
updateProperty.mutate(
{ id: p.id, input: { schattenmarktRelease: { enabled: nextEnabled, leadTimeMonths: nextLeadTime } } },
{
onSuccess: () => {
showToast(nextEnabled ? 'Pre-Market Matching aktiviert.' : 'Pre-Market Matching deaktiviert.', 'success')
},
onError: () => {
showToast('Fehler beim Speichern.', 'error')
},
},
)
}
function handleToggle(_: React.ChangeEvent<HTMLInputElement>, checked: boolean) {
+18 -18
View File
@@ -10,12 +10,10 @@ import {
Tabs,
Typography,
} from '@mui/material'
import { useQueryClient } from '@tanstack/react-query'
import { Edit2, Save, X } from 'lucide-react'
import type { UpdatePropertyInput } from '../../domain/property'
import { usePropertyById } from '../../hooks/useProperties'
import { usePropertyById, useUpdateProperty } from '../../hooks/useProperties'
import { PropertyDetailSkeleton } from './PropertyDetailSkeleton'
import { propertyService } from '../../services/propertyService'
import { useToastStore } from '../../stores/toastStore'
import {
getAssetTypeColor,
@@ -38,30 +36,32 @@ export function PropertyDetailView({ propertyId, onClose }: PropertyDetailViewPr
const [tab, setTab] = useState(0)
const [editing, setEditing] = useState(false)
const [draft, setDraft] = useState<UpdatePropertyInput>({})
const [saving, setSaving] = useState(false)
const queryClient = useQueryClient()
const { data: property, isLoading } = usePropertyById(propertyId)
const updateProperty = useUpdateProperty()
const showToast = useToastStore(s => s.showToast)
const saving = updateProperty.isPending
function startEdit() {
setDraft({})
setEditing(true)
}
async function saveEdit() {
function saveEdit() {
if (!property) return
setSaving(true)
try {
await propertyService.update(property.id, draft)
await queryClient.invalidateQueries({ queryKey: ['property', propertyId] })
setEditing(false)
setDraft({})
showToast('Objekt gespeichert.', 'success')
} catch {
showToast('Fehler beim Speichern.', 'error')
} finally {
setSaving(false)
}
updateProperty.mutate(
{ id: property.id, input: draft },
{
onSuccess: () => {
setEditing(false)
setDraft({})
showToast('Objekt gespeichert.', 'success')
},
onError: () => {
showToast('Fehler beim Speichern.', 'error')
},
},
)
}
if (isLoading) return <PropertyDetailSkeleton />
@@ -1,8 +1,8 @@
import { useEffect, useState } from 'react'
import { useState } from 'react'
import { Alert, Box, Button, Chip, CircularProgress, Typography } from '@mui/material'
import { BarChart2, Building2, FileText, Lightbulb, TrendingUp } from 'lucide-react'
import { marketReportService } from '../../services/marketReportService'
import type { MarketReport, SignalCategory } from '../../domain/marketReport'
import { useMarketReport } from '../../hooks/useMarketReport'
import type { SignalCategory } from '../../domain/marketReport'
import { SignalCard } from './SignalCard'
import { BerichtDialog } from './BerichtDialog'
@@ -18,19 +18,9 @@ const SECTION_CONFIG: { category: SignalCategory; label: string; icon: React.Rea
]
export function PropertyMarketSignalsTab({ propertyId }: Props) {
const [report, setReport] = useState<MarketReport | null>(null)
const [loading, setLoading] = useState(true)
const { data: report = null, isLoading: loading } = useMarketReport(propertyId)
const [dialogOpen, setDialogOpen] = useState(false)
useEffect(() => {
let cancelled = false
setLoading(true)
marketReportService.getByProperty(propertyId).then(r => {
if (!cancelled) { setReport(r); setLoading(false) }
})
return () => { cancelled = true }
}, [propertyId])
if (loading) {
return (
<Box sx={{ p: 3, display: 'flex', justifyContent: 'center' }}>
+4 -10
View File
@@ -3,7 +3,7 @@ import { Box, Button, Checkbox, Chip, Collapse, Divider, IconButton, Tooltip, Ty
import { ChevronDown, ChevronUp, Layers, Users } from 'lucide-react'
import { useNavigate } from 'react-router'
import type { Property, PropertyUnit, UnitNeedMatch } from '../../domain/property'
import { unitMatchService } from '../../services/unitMatchService'
import { useUnitMatchesMap, useUnitBundle, useBundleMatches } from '../../hooks/useUnitMatches'
import { DS_BORDER, DS_PRE_MARKET, DS_SURFACE, DS_TEXT } from '../../lib/ds'
import { floorLabel } from './PropertyDetailHelpers'
@@ -30,17 +30,11 @@ export function UnitStructurePanel({ p }: { p: Property }) {
const freeUnits = useMemo(() => (p.units ?? []).filter(u => u.available), [p.units])
const unitMatches = useMemo(() =>
Object.fromEntries(freeUnits.map(u => [u.id, unitMatchService.getMatchesForUnit(u, p)])),
[freeUnits, p],
)
const unitMatches = useUnitMatchesMap(freeUnits, p)
const selectedFreeUnits = freeUnits.filter(u => selectedIds.has(u.id))
const bundle = selectedFreeUnits.length >= 2 ? unitMatchService.buildBundle(selectedFreeUnits) : null
const bundleMatches = useMemo(() =>
selectedFreeUnits.length >= 2 ? unitMatchService.getMatchesForBundle(selectedFreeUnits, p) : [],
[selectedFreeUnits, p],
)
const bundle = useUnitBundle(selectedFreeUnits)
const bundleMatches = useBundleMatches(selectedFreeUnits, p)
const toggleUnit = (id: string) => {
setSelectedIds(prev => {