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 { Box, Button, CircularProgress, Typography } from '@mui/material'
import { Sparkles } from 'lucide-react' import { Sparkles } from 'lucide-react'
import { aiService } from '../../services/aiService' import { useGenerateOfferEmail } from '../../hooks/useAI'
interface AiOfferEmailButtonProps { interface AiOfferEmailButtonProps {
needTitle: string needTitle: string
@@ -16,33 +15,26 @@ export function AiOfferEmailButton({
matchScores, matchScores,
onGenerated, onGenerated,
}: AiOfferEmailButtonProps) { }: AiOfferEmailButtonProps) {
const [loading, setLoading] = useState(false) const generateOfferEmail = useGenerateOfferEmail()
const [error, setError] = useState<string | null>(null)
const handleClick = async () => { const handleClick = () => {
setLoading(true) generateOfferEmail.mutate(
setError(null) { needTitle, properties: selectedProperties, matchScores },
try { {
const res = await aiService.generateOfferEmail({ onSuccess: (res) => {
needTitle, onGenerated(res.data.subject, res.data.body)
properties: selectedProperties, },
matchScores, },
}) )
onGenerated(res.data.subject, res.data.body)
} catch (e) {
setError(e instanceof Error ? e.message : 'KI-Generierung fehlgeschlagen')
} finally {
setLoading(false)
}
} }
return ( return (
<Box> <Box>
<Button <Button
size="small" size="small"
startIcon={loading ? <CircularProgress size={14} /> : <Sparkles size={14} />} startIcon={generateOfferEmail.isPending ? <CircularProgress size={14} /> : <Sparkles size={14} />}
onClick={handleClick} onClick={handleClick}
disabled={loading} disabled={generateOfferEmail.isPending}
sx={{ sx={{
textTransform: 'none', textTransform: 'none',
color: '#7c3aed', color: '#7c3aed',
@@ -53,9 +45,11 @@ export function AiOfferEmailButton({
> >
KI-Mail generieren KI-Mail generieren
</Button> </Button>
{error && ( {generateOfferEmail.isError && (
<Typography variant="caption" sx={{ color: 'error.main', display: 'block', mt: 0.5 }}> <Typography variant="caption" sx={{ color: 'error.main', display: 'block', mt: 0.5 }}>
{error} {generateOfferEmail.error instanceof Error
? generateOfferEmail.error.message
: 'KI-Generierung fehlgeschlagen'}
</Typography> </Typography>
)} )}
</Box> </Box>
@@ -6,7 +6,7 @@ import {
} from '@mui/material' } from '@mui/material'
import { Plus, Trash2, X } from 'lucide-react' import { Plus, Trash2, X } from 'lucide-react'
import type { OfferReportDraft, ViewingAppointmentOption } from '../../domain/offerReport' 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 { usePropertyById } from '../../hooks/useProperties'
import { useToastStore } from '../../stores/toastStore' import { useToastStore } from '../../stores/toastStore'
import { OfferWizardPdfStep } from './OfferWizardPdfStep' import { OfferWizardPdfStep } from './OfferWizardPdfStep'
@@ -32,10 +32,16 @@ export function OfferCreationWizard({ inquiryId, propertyId, tenantName, onClose
const showToast = useToastStore(s => s.showToast) const showToast = useToastStore(s => s.showToast)
const timerRef = useRef<ReturnType<typeof setInterval> | null>(null) const timerRef = useRef<ReturnType<typeof setInterval> | null>(null)
const createOfferReport = useCreateOfferReport()
const updateOfferReport = useUpdateOfferReport()
const generatePdf = useGenerateOfferReportPdf()
useEffect(() => { useEffect(() => {
offerReportService createOfferReport.mutate(
.create(inquiryId, propertyId, tenantName, property?.title ?? '') { inquiryId, propertyId, tenantName, propertyTitle: property?.title ?? '' },
.then(d => { setDraft(d); setLoading(false) }) { onSuccess: (d) => { setDraft(d); setLoading(false) } },
)
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [inquiryId, propertyId, tenantName, property?.title]) }, [inquiryId, propertyId, tenantName, property?.title])
useEffect(() => () => { if (timerRef.current) clearInterval(timerRef.current) }, []) 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 if (!draft) return
await offerReportService.update(draft.id, { updateOfferReport.mutate(
editableFields: draft.editableFields, { draftId: draft.id, data: { editableFields: draft.editableFields, viewingAppointments: draft.viewingAppointments } },
viewingAppointments: draft.viewingAppointments, {
}) onSuccess: () => {
setStep(3) setStep(3)
setGenerating(true) setGenerating(true)
setProgress(0) setProgress(0)
let p = 0 let p = 0
timerRef.current = setInterval(() => { timerRef.current = setInterval(() => {
p += Math.random() * 18 + 8 p += Math.random() * 18 + 8
if (p >= 100) { if (p >= 100) {
clearInterval(timerRef.current!) clearInterval(timerRef.current!)
setProgress(100) setProgress(100)
setGenerating(false) setGenerating(false)
setReady(true) setReady(true)
offerReportService.generatePdf(draft.id).then(setDraft) generatePdf.mutate(draft.id, { onSuccess: setDraft })
} else { } else {
setProgress(Math.min(100, p)) setProgress(Math.min(100, p))
} }
}, 200) }, 200)
},
},
)
} }
return ( return (
@@ -8,7 +8,7 @@ import { Download, Send, X } from 'lucide-react'
import type { Inquiry } from '../../domain/inquiry' import type { Inquiry } from '../../domain/inquiry'
import type { Property } from '../../domain/property' import type { Property } from '../../domain/property'
import type { InquiryPreparationReportDraft, ReportObjectFieldSelection } from '../../domain/inquiryReport' 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 { useToastStore } from '../../stores/toastStore'
import { ReportObjectFieldSelector } from './ReportObjectFieldSelector' import { ReportObjectFieldSelector } from './ReportObjectFieldSelector'
import { LatentInquiryReportPreview } from './LatentInquiryReportPreview' import { LatentInquiryReportPreview } from './LatentInquiryReportPreview'
@@ -29,11 +29,15 @@ export function PreparationWizard({ inquiryId, inquiry, onClose }: Props) {
const [draft, setDraft] = useState<InquiryPreparationReportDraft | null>(null) const [draft, setDraft] = useState<InquiryPreparationReportDraft | null>(null)
const [generating, setGenerating] = useState(false) const [generating, setGenerating] = useState(false)
const [progress, setProgress] = useState(0) const [progress, setProgress] = useState(0)
const [finalizing, setFinalizing] = useState(false)
const { data: allProperties = [] } = useProperties() const { data: allProperties = [] } = useProperties()
const showToast = useToastStore(s => s.showToast) const showToast = useToastStore(s => s.showToast)
const timerRef = useRef<ReturnType<typeof setInterval> | null>(null) 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 portfolioProps = allProperties.filter(p => p.resultType === ResultType.VERIFIED_PORTFOLIO)
const selectedProperties = draft?.selectedPropertyIds const selectedProperties = draft?.selectedPropertyIds
@@ -46,7 +50,7 @@ export function PreparationWizard({ inquiryId, inquiry, onClose }: Props) {
) )
} }
const handleGenerate = async () => { const handleGenerate = () => {
setStep(1) setStep(1)
setGenerating(true) setGenerating(true)
setProgress(0) setProgress(0)
@@ -57,10 +61,15 @@ export function PreparationWizard({ inquiryId, inquiry, onClose }: Props) {
clearInterval(timerRef.current!) clearInterval(timerRef.current!)
setProgress(100) setProgress(100)
setGenerating(false) setGenerating(false)
inquiryReportService.create(inquiryId, selectedIds).then(d => { createInquiryReport.mutate(
setDraft(d) { inquiryId, selectedPropertyIds: selectedIds },
setStep(2) {
}) onSuccess: (d) => {
setDraft(d)
setStep(2)
},
},
)
} else { } else {
setProgress(Math.min(100, p)) setProgress(Math.min(100, p))
} }
@@ -85,14 +94,21 @@ export function PreparationWizard({ inquiryId, inquiry, onClose }: Props) {
}) })
} }
const handleFinalize = async () => { const handleFinalize = () => {
if (!draft) return if (!draft) return
setFinalizing(true) updateInquiryReport.mutate(
await inquiryReportService.update(draft.id, { editableFields: draft.editableFields, fieldSelections: draft.fieldSelections }) { draftId: draft.id, data: { editableFields: draft.editableFields, fieldSelections: draft.fieldSelections } },
const finalized = await inquiryReportService.finalize(draft.id) {
setDraft(finalized) onSuccess: () => {
setFinalizing(false) finalizeInquiryReport.mutate(draft.id, {
setStep(3) onSuccess: (finalized) => {
setDraft(finalized)
setStep(3)
},
})
},
},
)
} }
return ( return (
@@ -1,9 +1,8 @@
import { useEffect, useState } from 'react'
import { Box, Button, CircularProgress, Divider, Typography } from '@mui/material' import { Box, Button, CircularProgress, Divider, Typography } from '@mui/material'
import { ArrowRight, Building2, Calendar, MapPin, Ruler, Trophy } from 'lucide-react' import { ArrowRight, Building2, Calendar, MapPin, Ruler, Trophy } from 'lucide-react'
import { useNavigate } from 'react-router' import { useNavigate } from 'react-router'
import { usePropertyById } from '../../hooks/useProperties' import { usePropertyById } from '../../hooks/useProperties'
import { matchService } from '../../services/matchService' import { useAdditionalMatchesForInquiry } from '../../hooks/useMatches'
import type { AdditionalPropertyMatch } from '../../domain/additionalMatch' import type { AdditionalPropertyMatch } from '../../domain/additionalMatch'
interface RelatedPropertyCardPanelProps { interface RelatedPropertyCardPanelProps {
@@ -14,16 +13,10 @@ interface RelatedPropertyCardPanelProps {
export function RelatedPropertyCardPanel({ propertyId, inquiryId }: RelatedPropertyCardPanelProps) { export function RelatedPropertyCardPanel({ propertyId, inquiryId }: RelatedPropertyCardPanelProps) {
const { data: property, isLoading } = usePropertyById(propertyId) const { data: property, isLoading } = usePropertyById(propertyId)
const navigate = useNavigate() const navigate = useNavigate()
const [additionalMatches, setAdditionalMatches] = useState<AdditionalPropertyMatch[]>([]) const {
const [loadingMatches, setLoadingMatches] = useState(false) data: additionalMatches = [],
isLoading: loadingMatches,
useEffect(() => { } = useAdditionalMatchesForInquiry(inquiryId, { minScore: 80, excludePropertyId: propertyId })
setLoadingMatches(true)
matchService
.getAdditionalMatchesForInquiry(inquiryId, { minScore: 80, excludePropertyId: propertyId })
.then(setAdditionalMatches)
.finally(() => setLoadingMatches(false))
}, [inquiryId, propertyId])
if (isLoading) { if (isLoading) {
return ( return (
@@ -5,13 +5,13 @@ import { useLocation } from 'react-router'
import { useShallow } from 'zustand/react/shallow' import { useShallow } from 'zustand/react/shallow'
import { useAssistantStore } from '../../stores/assistantStore' import { useAssistantStore } from '../../stores/assistantStore'
import { useSessionStore } from '../../stores/sessionStore' import { useSessionStore } from '../../stores/sessionStore'
import { aiAssistantService } from '../../services/aiAssistantService' import { useAssistantSuggestions, useAssistantAnswer } from '../../hooks/useAssistant'
import { AssistantContextSummary } from './AssistantContextSummary' import { AssistantContextSummary } from './AssistantContextSummary'
import { AssistantMessageList } from './AssistantMessageList' import { AssistantMessageList } from './AssistantMessageList'
import { AssistantPromptSuggestions } from './AssistantPromptSuggestions' import { AssistantPromptSuggestions } from './AssistantPromptSuggestions'
import { AssistantLoadingState } from './AssistantLoadingState' import { AssistantLoadingState } from './AssistantLoadingState'
import { AssistantErrorState } from './AssistantErrorState' import { AssistantErrorState } from './AssistantErrorState'
import type { AssistantContext, SuggestedQuestion } from '../../domain/assistant' import type { AssistantContext } from '../../domain/assistant'
import type { WorkspaceType } from '../../domain/enums' import type { WorkspaceType } from '../../domain/enums'
function resolveWorkspace(pathname: string): WorkspaceType | null { function resolveWorkspace(pathname: string): WorkspaceType | null {
@@ -27,24 +27,13 @@ export function GlobalAIAssistantDrawer() {
const { currentUser } = useSessionStore() const { currentUser } = useSessionStore()
const location = useLocation() const location = useLocation()
const [suggestions, setSuggestions] = useState<SuggestedQuestion[]>([])
const [inputText, setInputText] = useState('') const [inputText, setInputText] = useState('')
const scrollRef = useRef<HTMLDivElement>(null) const scrollRef = useRef<HTMLDivElement>(null)
// Build context from route when drawer opens const answerQuestion = useAssistantAnswer()
useEffect(() => { const { data: suggestions = [] } = useAssistantSuggestions(isOpen ? context : null)
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])
// Refresh suggestions when route changes while open // Build context from route when drawer opens or route changes while open
useEffect(() => { useEffect(() => {
if (!isOpen) return if (!isOpen) return
const ctx: AssistantContext = { const ctx: AssistantContext = {
@@ -54,8 +43,7 @@ export function GlobalAIAssistantDrawer() {
organizationId: currentUser?.organizationId ?? '', organizationId: currentUser?.organizationId ?? '',
} }
setContext(ctx) setContext(ctx)
aiAssistantService.getSuggestions(ctx).then(setSuggestions) }, [isOpen, location.pathname])
}, [location.pathname])
// Auto-scroll on new messages // Auto-scroll on new messages
useEffect(() => { useEffect(() => {
@@ -64,7 +52,7 @@ export function GlobalAIAssistantDrawer() {
} }
}, [messages, isLoading]) }, [messages, isLoading])
const handleQuestion = useCallback(async (question: string) => { const handleQuestion = useCallback((question: string) => {
if (!question.trim() || isLoading) return if (!question.trim() || isLoading) return
setInputText('') setInputText('')
setError(null) setError(null)
@@ -78,26 +66,32 @@ export function GlobalAIAssistantDrawer() {
addMessage(userMsg) addMessage(userMsg)
setLoading(true) setLoading(true)
try { const ctx = context ?? {
const ctx = context ?? { currentRoute: location.pathname,
currentRoute: location.pathname, workspace: resolveWorkspace(location.pathname),
workspace: resolveWorkspace(location.pathname), userRole: currentUser?.role ?? 'VIEWER',
userRole: currentUser?.role ?? 'VIEWER', organizationId: currentUser?.organizationId ?? '',
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)
} }
}, [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) => { const handleKeyDown = (e: React.KeyboardEvent) => {
if (e.key === 'Enter' && !e.shiftKey) { if (e.key === 'Enter' && !e.shiftKey) {
@@ -108,10 +102,6 @@ export function GlobalAIAssistantDrawer() {
const handleClear = () => { const handleClear = () => {
clearConversation() clearConversation()
setSuggestions([])
if (context) {
aiAssistantService.getSuggestions(context).then(setSuggestions)
}
} }
const showSuggestions = suggestions.length > 0 && messages.length === 0 const showSuggestions = suggestions.length > 0 && messages.length === 0
+4 -3
View File
@@ -1,6 +1,6 @@
import { Box, Chip, Typography } from '@mui/material' import { Box, Chip, Typography } from '@mui/material'
import { UserRole } from '../../domain/enums' import { UserRole } from '../../domain/enums'
import { authService } from '../../services/authService' import { useSwitchDemoRole } from '../../hooks/useAuth'
import { useSessionStore } from '../../stores/sessionStore' import { useSessionStore } from '../../stores/sessionStore'
const DEMO_ROLES: { role: UserRole; label: string }[] = [ const DEMO_ROLES: { role: UserRole; label: string }[] = [
@@ -10,9 +10,10 @@ const DEMO_ROLES: { role: UserRole; label: string }[] = [
export function DemoRoleSwitcher() { export function DemoRoleSwitcher() {
const { currentUser } = useSessionStore() const { currentUser } = useSessionStore()
const switchDemoRole = useSwitchDemoRole()
async function handleSwitch(role: UserRole) { function handleSwitch(role: UserRole) {
await authService.switchDemoRole(role) switchDemoRole.mutate(role)
} }
return ( return (
+4 -3
View File
@@ -1,6 +1,6 @@
import { FormControl, MenuItem, Select, Typography } from '@mui/material' import { FormControl, MenuItem, Select, Typography } from '@mui/material'
import type { SelectChangeEvent } from '@mui/material' import type { SelectChangeEvent } from '@mui/material'
import { authService } from '../../services/authService' import { useSwitchOrganization } from '../../hooks/useAuth'
import { useSessionStore } from '../../stores/sessionStore' import { useSessionStore } from '../../stores/sessionStore'
const MOCK_ORGANIZATIONS = [ const MOCK_ORGANIZATIONS = [
@@ -11,9 +11,10 @@ const MOCK_ORGANIZATIONS = [
export function OrganizationSwitcher() { export function OrganizationSwitcher() {
const { activeOrganizationId } = useSessionStore() const { activeOrganizationId } = useSessionStore()
const switchOrganization = useSwitchOrganization()
async function handleChange(e: SelectChangeEvent<string>) { function handleChange(e: SelectChangeEvent<string>) {
await authService.switchOrganization(e.target.value) switchOrganization.mutate(e.target.value)
} }
return ( 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 { RotateCcw } from 'lucide-react'
import { WEIGHTING_KEYS, WEIGHTING_LABELS } from '../../domain/needBuilder' import { WEIGHTING_KEYS, WEIGHTING_LABELS } from '../../domain/needBuilder'
import type { WeightingKey } 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 HARD_KEYS: WeightingKey[] = ['area', 'location', 'budget', 'timing']
const SOFT_KEYS: WeightingKey[] = [ const SOFT_KEYS: WeightingKey[] = [
@@ -74,6 +74,7 @@ function SliderGroup({
export function WeightingEditor({ weights, onChange, assetType }: Props) { export function WeightingEditor({ weights, onChange, assetType }: Props) {
const [raw, setRaw] = useState<Record<WeightingKey, number>>(() => toRaw(weights)) const [raw, setRaw] = useState<Record<WeightingKey, number>>(() => toRaw(weights))
const defaultWeights = useDefaultWeights(assetType)
function handleSlider(key: WeightingKey, value: number) { function handleSlider(key: WeightingKey, value: number) {
const updated = { ...raw, [key]: value } const updated = { ...raw, [key]: value }
@@ -82,9 +83,8 @@ export function WeightingEditor({ weights, onChange, assetType }: Props) {
} }
function handleReset() { function handleReset() {
const defaults = weightingService.getDefaultWeights(assetType) setRaw(toRaw(defaultWeights))
setRaw(toRaw(defaults)) onChange(defaultWeights)
onChange(defaults)
} }
return ( return (
@@ -6,9 +6,9 @@ import { SensitivityBadge } from './SensitivityBadge'
import { SignalReviewStatusBadge } from './SignalReviewStatusBadge' import { SignalReviewStatusBadge } from './SignalReviewStatusBadge'
import { FutureSignalDisclaimer } from './FutureSignalDisclaimer' import { FutureSignalDisclaimer } from './FutureSignalDisclaimer'
import { useUpdateSignalReviewStatus } from '../../hooks/useFutureSignals' import { useUpdateSignalReviewStatus } from '../../hooks/useFutureSignals'
import { useCreateReviewTask } from '../../hooks/useReviewQueue'
import { useShortlistStore } from '../../stores/shortlistStore' import { useShortlistStore } from '../../stores/shortlistStore'
import { useToastStore } from '../../stores/toastStore' import { useToastStore } from '../../stores/toastStore'
import { reviewService } from '../../services/reviewService'
import { ReviewStatus } from '../../domain/enums' import { ReviewStatus } from '../../domain/enums'
import type { FutureSignal } from '../../domain/futureSignal' import type { FutureSignal } from '../../domain/futureSignal'
@@ -51,6 +51,7 @@ interface Props {
export function FutureSignalDetailPanel({ signal, onClose }: Props) { export function FutureSignalDetailPanel({ signal, onClose }: Props) {
const updateStatus = useUpdateSignalReviewStatus() const updateStatus = useUpdateSignalReviewStatus()
const createReviewTask = useCreateReviewTask()
const { openAddDialog } = useShortlistStore() const { openAddDialog } = useShortlistStore()
const showToast = useToastStore((s) => s.showToast) const showToast = useToastStore((s) => s.showToast)
const [reviewTaskSent, setReviewTaskSent] = useState(false) const [reviewTaskSent, setReviewTaskSent] = useState(false)
@@ -75,14 +76,13 @@ export function FutureSignalDetailPanel({ signal, onClose }: Props) {
} }
} }
async function handleSendReview() { function handleSendReview() {
try { createReviewTask.mutate(signal.id, {
await reviewService.createReviewTask(signal.id) onSuccess: () => {
setReviewTaskSent(true) setReviewTaskSent(true)
showToast('Prüfungsaufgabe erstellt.') showToast('Prüfungsaufgabe erstellt.')
} catch { },
showToast('Prüfungsaufgabe konnte nicht erstellt werden.', 'error') })
}
} }
function handleShortlist() { function handleShortlist() {
@@ -2,12 +2,12 @@ import { Box, CircularProgress, Typography } from '@mui/material'
import { useNavigate } from 'react-router' import { useNavigate } from 'react-router'
import { useMatchesByProperty, useApproveMatch } from '../../hooks/useMatches' import { useMatchesByProperty, useApproveMatch } from '../../hooks/useMatches'
import { usePropertyById } from '../../hooks/useProperties' import { usePropertyById } from '../../hooks/useProperties'
import { useCreateReviewTask } from '../../hooks/useReviewQueue'
import { useMatchCenterStore } from '../../stores/matchCenterStore' import { useMatchCenterStore } from '../../stores/matchCenterStore'
import { useCompareStore } from '../../stores/compareStore' import { useCompareStore } from '../../stores/compareStore'
import { useToastStore } from '../../stores/toastStore' import { useToastStore } from '../../stores/toastStore'
import { MatchCardExpanded } from '../match-card/MatchCardExpanded' import { MatchCardExpanded } from '../match-card/MatchCardExpanded'
import { buildMatchCardViewModel } from '../../features/matching/matchCardAdapter' import { buildMatchCardViewModel } from '../../features/matching/matchCardAdapter'
import { reviewService } from '../../services/reviewService'
import { MatchCenterEmptyState } from './MatchCenterEmptyState' import { MatchCenterEmptyState } from './MatchCenterEmptyState'
import { MatchStatusBadge } from './MatchStatusBadge' import { MatchStatusBadge } from './MatchStatusBadge'
import type { MatchCardAction } from '../match-card/MatchCardViewModel' import type { MatchCardAction } from '../match-card/MatchCardViewModel'
@@ -18,6 +18,7 @@ export function MatchBriefingPanel() {
const { selectedPropertyId, selectedNeedId } = useMatchCenterStore() const { selectedPropertyId, selectedNeedId } = useMatchCenterStore()
const { addToCompare } = useCompareStore() const { addToCompare } = useCompareStore()
const approveMatch = useApproveMatch() const approveMatch = useApproveMatch()
const createReviewTask = useCreateReviewTask()
const showToast = useToastStore((s) => s.showToast) const showToast = useToastStore((s) => s.showToast)
const { data: matches = [], isLoading: matchesLoading } = useMatchesByProperty(selectedPropertyId ?? '') const { data: matches = [], isLoading: matchesLoading } = useMatchesByProperty(selectedPropertyId ?? '')
@@ -64,7 +65,7 @@ export function MatchBriefingPanel() {
label: 'Zur Prüfung', label: 'Zur Prüfung',
actionType: 'SEND_REVIEW', actionType: 'SEND_REVIEW',
variant: 'secondary', variant: 'secondary',
onClick: () => { reviewService.createReviewTask(match.id) }, onClick: () => { createReviewTask.mutate(match.id) },
}, },
{ {
id: 'compare', id: 'compare',
@@ -1,7 +1,7 @@
import { useState } from 'react' import { useState } from 'react'
import { Accordion, AccordionDetails, AccordionSummary, Alert, Box, Button, CircularProgress, Typography } from '@mui/material' import { Accordion, AccordionDetails, AccordionSummary, Alert, Box, Button, CircularProgress, Typography } from '@mui/material'
import { ChevronDown, Sparkles } from 'lucide-react' import { ChevronDown, Sparkles } from 'lucide-react'
import { aiService } from '../../services/aiService' import { useGenerateDecisionBrief } from '../../hooks/useAI'
import type { DecisionBrief } from '../../services/aiService' import type { DecisionBrief } from '../../services/aiService'
interface Props { interface Props {
@@ -10,16 +10,15 @@ interface Props {
export function DecisionBriefDraftPanel({ shortlistId }: Props) { export function DecisionBriefDraftPanel({ shortlistId }: Props) {
const [brief, setBrief] = useState<DecisionBrief | null>(null) const [brief, setBrief] = useState<DecisionBrief | null>(null)
const [loading, setLoading] = useState(false) const generateDecisionBrief = useGenerateDecisionBrief()
const loading = generateDecisionBrief.isPending
async function handleGenerate() { function handleGenerate() {
setLoading(true) generateDecisionBrief.mutate(shortlistId, {
try { onSuccess: (resp) => {
const resp = await aiService.generateDecisionBrief(shortlistId) setBrief(resp.data)
setBrief(resp.data) },
} finally { })
setLoading(false)
}
} }
return ( return (
+2 -14
View File
@@ -1,25 +1,13 @@
import { useEffect, useState } from 'react'
import { Alert, Box, CircularProgress, Typography } from '@mui/material' import { Alert, Box, CircularProgress, Typography } from '@mui/material'
import { useNavigate } from 'react-router' import { useNavigate } from 'react-router'
import type { PropertyNeedMatch } from '../../domain/match' import { useNeedMatchesForProperty } from '../../hooks/useMatches'
import { matchService } from '../../services/matchService'
import { useOfferWizardStore } from '../../stores/offerWizardStore' import { useOfferWizardStore } from '../../stores/offerWizardStore'
import { NeedMatchCard } from './NeedMatchCard' import { NeedMatchCard } from './NeedMatchCard'
export function MatchabilityTabPanel({ propertyId }: { propertyId: string }) { export function MatchabilityTabPanel({ propertyId }: { propertyId: string }) {
const [matches, setMatches] = useState<PropertyNeedMatch[]>([])
const [loading, setLoading] = useState(true)
const navigate = useNavigate() const navigate = useNavigate()
const setSelectedProperties = useOfferWizardStore(s => s.setSelectedProperties) const setSelectedProperties = useOfferWizardStore(s => s.setSelectedProperties)
const { data: matches = [], isLoading: loading } = useNeedMatchesForProperty(propertyId, { minScore: 80 })
useEffect(() => {
let cancelled = false
setLoading(true)
matchService.getNeedMatchesForProperty(propertyId, { minScore: 80 }).then(result => {
if (!cancelled) { setMatches(result); setLoading(false) }
})
return () => { cancelled = true }
}, [propertyId])
function handleNeedCardClick() { function handleNeedCardClick() {
setSelectedProperties([propertyId]) setSelectedProperties([propertyId])
+15 -16
View File
@@ -1,10 +1,9 @@
import { useState } from 'react' import { useState } from 'react'
import { Box, Chip, CircularProgress, Divider, Switch, TextField, Typography } from '@mui/material' import { Box, Chip, CircularProgress, Divider, Switch, TextField, Typography } from '@mui/material'
import { Clock, Layers, ShieldCheck, Target, TrendingUp, Users, Zap } from 'lucide-react' import { Clock, Layers, ShieldCheck, Target, TrendingUp, Users, Zap } from 'lucide-react'
import { useQueryClient } from '@tanstack/react-query'
import type { Property } from '../../domain/property' import type { Property } from '../../domain/property'
import { MockupUnitProvider } from '../../provider/MockupUnitProvider' import { MockupUnitProvider } from '../../provider/MockupUnitProvider'
import { propertyService } from '../../services/propertyService' import { useUpdateProperty } from '../../hooks/useProperties'
import { useToastStore } from '../../stores/toastStore' import { useToastStore } from '../../stores/toastStore'
import { DS_BORDER, DS_PRE_MARKET, DS_SURFACE, DS_TEXT } from '../../lib/ds' import { DS_BORDER, DS_PRE_MARKET, DS_SURFACE, DS_TEXT } from '../../lib/ds'
import { floorLabel, SectionTitle } from './PropertyDetailHelpers' import { floorLabel, SectionTitle } from './PropertyDetailHelpers'
@@ -14,7 +13,6 @@ export const MOCK_TODAY = new Date('2026-05-20')
export function PreMarketPanel({ p }: { p: Property }) { export function PreMarketPanel({ p }: { p: Property }) {
const [enabled, setEnabled] = useState(p.schattenmarktRelease?.enabled ?? false) const [enabled, setEnabled] = useState(p.schattenmarktRelease?.enabled ?? false)
const [leadTimeMonths, setLeadTimeMonths] = useState(p.schattenmarktRelease?.leadTimeMonths ?? 6) const [leadTimeMonths, setLeadTimeMonths] = useState(p.schattenmarktRelease?.leadTimeMonths ?? 6)
const [saving, setSaving] = useState(false)
const [unitStates, setUnitStates] = useState<Record<string, { enabled: boolean; availableFrom: string }>>(() => { const [unitStates, setUnitStates] = useState<Record<string, { enabled: boolean; availableFrom: string }>>(() => {
const init: Record<string, { enabled: boolean; availableFrom: string }> = {} const init: Record<string, { enabled: boolean; availableFrom: string }> = {}
for (const u of p.units ?? []) { for (const u of p.units ?? []) {
@@ -26,8 +24,9 @@ export function PreMarketPanel({ p }: { p: Property }) {
return init return init
}) })
const [unitSaving, setUnitSaving] = useState<Record<string, boolean>>({}) const [unitSaving, setUnitSaving] = useState<Record<string, boolean>>({})
const queryClient = useQueryClient() const updateProperty = useUpdateProperty()
const showToast = useToastStore(s => s.showToast) const showToast = useToastStore(s => s.showToast)
const saving = updateProperty.isPending
if (p.resultType !== 'VERIFIED_PORTFOLIO') return null if (p.resultType !== 'VERIFIED_PORTFOLIO') return null
if (!p.leaseEndDate && !p.breakoutOptionDate) 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)) (['Zürich', 'Basel', 'Bern', 'Zug'].some(c => (p.location?.city ?? '').includes(c)) ? 4 : 1))
const highQualityLeads = Math.max(1, Math.floor(demandProfiles * 0.38)) const highQualityLeads = Math.max(1, Math.floor(demandProfiles * 0.38))
async function save(nextEnabled: boolean, nextLeadTime: number) { function save(nextEnabled: boolean, nextLeadTime: number) {
setSaving(true) updateProperty.mutate(
try { { id: p.id, input: { schattenmarktRelease: { enabled: nextEnabled, leadTimeMonths: nextLeadTime } } },
await propertyService.update(p.id, { schattenmarktRelease: { enabled: nextEnabled, leadTimeMonths: nextLeadTime } }) {
await queryClient.invalidateQueries({ queryKey: ['property', p.id] }) onSuccess: () => {
await queryClient.invalidateQueries({ queryKey: ['properties'] }) showToast(nextEnabled ? 'Pre-Market Matching aktiviert.' : 'Pre-Market Matching deaktiviert.', 'success')
showToast(nextEnabled ? 'Pre-Market Matching aktiviert.' : 'Pre-Market Matching deaktiviert.', 'success') },
} catch { onError: () => {
showToast('Fehler beim Speichern.', 'error') showToast('Fehler beim Speichern.', 'error')
} finally { },
setSaving(false) },
} )
} }
function handleToggle(_: React.ChangeEvent<HTMLInputElement>, checked: boolean) { function handleToggle(_: React.ChangeEvent<HTMLInputElement>, checked: boolean) {
+18 -18
View File
@@ -10,12 +10,10 @@ import {
Tabs, Tabs,
Typography, Typography,
} from '@mui/material' } from '@mui/material'
import { useQueryClient } from '@tanstack/react-query'
import { Edit2, Save, X } from 'lucide-react' import { Edit2, Save, X } from 'lucide-react'
import type { UpdatePropertyInput } from '../../domain/property' import type { UpdatePropertyInput } from '../../domain/property'
import { usePropertyById } from '../../hooks/useProperties' import { usePropertyById, useUpdateProperty } from '../../hooks/useProperties'
import { PropertyDetailSkeleton } from './PropertyDetailSkeleton' import { PropertyDetailSkeleton } from './PropertyDetailSkeleton'
import { propertyService } from '../../services/propertyService'
import { useToastStore } from '../../stores/toastStore' import { useToastStore } from '../../stores/toastStore'
import { import {
getAssetTypeColor, getAssetTypeColor,
@@ -38,30 +36,32 @@ export function PropertyDetailView({ propertyId, onClose }: PropertyDetailViewPr
const [tab, setTab] = useState(0) const [tab, setTab] = useState(0)
const [editing, setEditing] = useState(false) const [editing, setEditing] = useState(false)
const [draft, setDraft] = useState<UpdatePropertyInput>({}) const [draft, setDraft] = useState<UpdatePropertyInput>({})
const [saving, setSaving] = useState(false)
const queryClient = useQueryClient()
const { data: property, isLoading } = usePropertyById(propertyId) const { data: property, isLoading } = usePropertyById(propertyId)
const updateProperty = useUpdateProperty()
const showToast = useToastStore(s => s.showToast) const showToast = useToastStore(s => s.showToast)
const saving = updateProperty.isPending
function startEdit() { function startEdit() {
setDraft({}) setDraft({})
setEditing(true) setEditing(true)
} }
async function saveEdit() { function saveEdit() {
if (!property) return if (!property) return
setSaving(true) updateProperty.mutate(
try { { id: property.id, input: draft },
await propertyService.update(property.id, draft) {
await queryClient.invalidateQueries({ queryKey: ['property', propertyId] }) onSuccess: () => {
setEditing(false) setEditing(false)
setDraft({}) setDraft({})
showToast('Objekt gespeichert.', 'success') showToast('Objekt gespeichert.', 'success')
} catch { },
showToast('Fehler beim Speichern.', 'error') onError: () => {
} finally { showToast('Fehler beim Speichern.', 'error')
setSaving(false) },
} },
)
} }
if (isLoading) return <PropertyDetailSkeleton /> 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 { Alert, Box, Button, Chip, CircularProgress, Typography } from '@mui/material'
import { BarChart2, Building2, FileText, Lightbulb, TrendingUp } from 'lucide-react' import { BarChart2, Building2, FileText, Lightbulb, TrendingUp } from 'lucide-react'
import { marketReportService } from '../../services/marketReportService' import { useMarketReport } from '../../hooks/useMarketReport'
import type { MarketReport, SignalCategory } from '../../domain/marketReport' import type { SignalCategory } from '../../domain/marketReport'
import { SignalCard } from './SignalCard' import { SignalCard } from './SignalCard'
import { BerichtDialog } from './BerichtDialog' import { BerichtDialog } from './BerichtDialog'
@@ -18,19 +18,9 @@ const SECTION_CONFIG: { category: SignalCategory; label: string; icon: React.Rea
] ]
export function PropertyMarketSignalsTab({ propertyId }: Props) { export function PropertyMarketSignalsTab({ propertyId }: Props) {
const [report, setReport] = useState<MarketReport | null>(null) const { data: report = null, isLoading: loading } = useMarketReport(propertyId)
const [loading, setLoading] = useState(true)
const [dialogOpen, setDialogOpen] = useState(false) 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) { if (loading) {
return ( return (
<Box sx={{ p: 3, display: 'flex', justifyContent: 'center' }}> <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 { ChevronDown, ChevronUp, Layers, Users } from 'lucide-react'
import { useNavigate } from 'react-router' import { useNavigate } from 'react-router'
import type { Property, PropertyUnit, UnitNeedMatch } from '../../domain/property' 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 { DS_BORDER, DS_PRE_MARKET, DS_SURFACE, DS_TEXT } from '../../lib/ds'
import { floorLabel } from './PropertyDetailHelpers' 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 freeUnits = useMemo(() => (p.units ?? []).filter(u => u.available), [p.units])
const unitMatches = useMemo(() => const unitMatches = useUnitMatchesMap(freeUnits, p)
Object.fromEntries(freeUnits.map(u => [u.id, unitMatchService.getMatchesForUnit(u, p)])),
[freeUnits, p],
)
const selectedFreeUnits = freeUnits.filter(u => selectedIds.has(u.id)) const selectedFreeUnits = freeUnits.filter(u => selectedIds.has(u.id))
const bundle = selectedFreeUnits.length >= 2 ? unitMatchService.buildBundle(selectedFreeUnits) : null const bundle = useUnitBundle(selectedFreeUnits)
const bundleMatches = useMemo(() => const bundleMatches = useBundleMatches(selectedFreeUnits, p)
selectedFreeUnits.length >= 2 ? unitMatchService.getMatchesForBundle(selectedFreeUnits, p) : [],
[selectedFreeUnits, p],
)
const toggleUnit = (id: string) => { const toggleUnit = (id: string) => {
setSelectedIds(prev => { setSelectedIds(prev => {
+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, 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 { needService } from '../services/needService'
import type { CreateNeedInput } from '../domain/need'
export function useNeeds() { export function useNeeds() {
return useQuery({ return useQuery({
@@ -19,3 +20,13 @@ export function useNeed(id: string) {
} }
export const useNeedProfiles = useNeeds 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 { propertyService } from '../services/propertyService'
import { matchService } from '../services/matchService' import { matchService } from '../services/matchService'
import { futureSignalService } from '../services/futureSignalService' import { futureSignalService } from '../services/futureSignalService'
import type { AssetType, ResultType } from '../domain/enums' import type { AssetType, ResultType } from '../domain/enums'
import type { CreatePropertyInput, UpdatePropertyInput } from '../domain/property'
import { STALE_PROPERTIES, STALE_MATCHES, STALE_SIGNALS } from '../lib/constants' import { STALE_PROPERTIES, STALE_MATCHES, STALE_SIGNALS } from '../lib/constants'
interface PropertyFilter { interface PropertyFilter {
@@ -64,3 +65,35 @@ export function usePropertySignals(propertyId: string | null) {
select: (res) => res.data ?? [], 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 // Legacy exports
export function useApproveReviewItem() { export function useApproveReviewItem() {
const queryClient = useQueryClient() 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)
}
+17 -22
View File
@@ -1,5 +1,5 @@
import { useState } from 'react' import { useState } from 'react'
import { useNavigate, Navigate } from 'react-router' import { Navigate } from 'react-router'
import { import {
Box, Box,
Button, Button,
@@ -12,7 +12,7 @@ import {
Typography, Typography,
} from '@mui/material' } from '@mui/material'
import { Building2 } from 'lucide-react' import { Building2 } from 'lucide-react'
import { authService } from '../../services/authService' import { useLogin, useSwitchDemoRole } from '../../hooks/useAuth'
import { useSessionStore } from '../../stores/sessionStore' import { useSessionStore } from '../../stores/sessionStore'
import { UserRole } from '../../domain/enums' import { UserRole } from '../../domain/enums'
@@ -23,42 +23,37 @@ const DEMO_ROLES: { role: UserRole; label: string; description: string }[] = [
export default function LoginScreen() { export default function LoginScreen() {
const { isAuthenticated } = useSessionStore() const { isAuthenticated } = useSessionStore()
const navigate = useNavigate() const loginMutation = useLogin()
const switchDemoRoleMutation = useSwitchDemoRole()
const [email, setEmail] = useState('admin@ideal-sharing.ch') const [email, setEmail] = useState('admin@ideal-sharing.ch')
const [password, setPassword] = useState('') const [password, setPassword] = useState('')
const [loading, setLoading] = useState(false)
const [error, setError] = useState<string | null>(null) const [error, setError] = useState<string | null>(null)
if (isAuthenticated) { if (isAuthenticated) {
return <Navigate to="/" replace /> return <Navigate to="/" replace />
} }
async function handleLogin(e: React.FormEvent) { const loading = loginMutation.isPending || switchDemoRoleMutation.isPending
function handleLogin(e: React.FormEvent) {
e.preventDefault() e.preventDefault()
if (!email) { if (!email) {
setError('Bitte E-Mail-Adresse eingeben.') setError('Bitte E-Mail-Adresse eingeben.')
return return
} }
setLoading(true)
setError(null) setError(null)
try { loginMutation.mutate(
await authService.login(email, password) { email, password },
navigate('/') {
} catch { onError: () => {
setError('Anmeldung fehlgeschlagen. Bitte erneut versuchen.') setError('Anmeldung fehlgeschlagen. Bitte erneut versuchen.')
} finally { },
setLoading(false) },
} )
} }
async function handleDemoLogin(role: UserRole) { function handleDemoLogin(role: UserRole) {
setLoading(true) switchDemoRoleMutation.mutate(role)
try {
await authService.switchDemoRole(role)
navigate('/')
} finally {
setLoading(false)
}
} }
return ( return (
+86 -67
View File
@@ -18,9 +18,9 @@ import {
NeedCardPreview, NeedCardPreview,
NeedBuilderErrorState, NeedBuilderErrorState,
} from '../../components/demand' } from '../../components/demand'
import { aiService } from '../../services/aiService' import { useParseNeed } from '../../hooks/useAI'
import { needService } from '../../services/needService' import { useCreateNeed } from '../../hooks/useNeeds'
import { weightingService } from '../../services/weightingService' import { useDefaultWeights } from '../../hooks/useWeighting'
import { NeedBuilderStep } from '../../domain/needBuilder' import { NeedBuilderStep } from '../../domain/needBuilder'
import type { ParseNeedResult, ParsedNeedCriteria, WeightingKey } from '../../domain/needBuilder' import type { ParseNeedResult, ParsedNeedCriteria, WeightingKey } from '../../domain/needBuilder'
import { generateSummary, buildNeedInput } from '../../services/aiSearch/needSearchMapper' import { generateSummary, buildNeedInput } from '../../services/aiSearch/needSearchMapper'
@@ -35,6 +35,10 @@ export default function AISearch() {
const navigate = useNavigate() const navigate = useNavigate()
const queryClient = useQueryClient() const queryClient = useQueryClient()
const parseNeedMutation = useParseNeed()
const createNeedMutation = useCreateNeed()
const defaultWeights = useDefaultWeights()
const [step, setStep] = useState<typeof NeedBuilderStep[keyof typeof NeedBuilderStep]>(NeedBuilderStep.IDLE) const [step, setStep] = useState<typeof NeedBuilderStep[keyof typeof NeedBuilderStep]>(NeedBuilderStep.IDLE)
const [intent, setIntent] = useState<ActionIntent>('search') const [intent, setIntent] = useState<ActionIntent>('search')
const [inputText, setInputText] = useState('') const [inputText, setInputText] = useState('')
@@ -42,7 +46,7 @@ export default function AISearch() {
const [criteria, setCriteria] = useState<ParsedNeedCriteria>({}) const [criteria, setCriteria] = useState<ParsedNeedCriteria>({})
const [parseResult, setParseResult] = useState<ParseNeedResult | null>(null) const [parseResult, setParseResult] = useState<ParseNeedResult | null>(null)
const [editedCriteria, setEditedCriteria] = useState<ParsedNeedCriteria | null>(null) const [editedCriteria, setEditedCriteria] = useState<ParsedNeedCriteria | null>(null)
const [weights, setWeights] = useState<Record<WeightingKey, number>>(weightingService.getDefaultWeights()) const [weights, setWeights] = useState<Record<WeightingKey, number>>(defaultWeights)
const [weightingKey, setWeightingKey] = useState(0) const [weightingKey, setWeightingKey] = useState(0)
const [needTitle, setNeedTitle] = useState('') const [needTitle, setNeedTitle] = useState('')
const [error, setError] = useState<string | null>(null) const [error, setError] = useState<string | null>(null)
@@ -64,71 +68,85 @@ export default function AISearch() {
setInputText(text) setInputText(text)
} }
async function handleAiAutofill() { function handleAiAutofill() {
setStep(NeedBuilderStep.PARSING) setStep(NeedBuilderStep.PARSING)
setError(null) setError(null)
try { parseNeedMutation.mutate(inputText, {
const resp = await aiService.parseNeed(inputText) onSuccess: (resp) => {
const result = resp.data const result = resp.data
setParseResult(result) setParseResult(result)
setCriteria({ ...result.extractedCriteria }) setCriteria({ ...result.extractedCriteria })
setWeights(result.suggestedWeights as Record<WeightingKey, number>) setWeights(result.suggestedWeights as Record<WeightingKey, number>)
setWeightingKey(k => k + 1)
isManualTextRef.current = false
setInputText('')
setIsAutoGen(false)
setStep(NeedBuilderStep.IDLE)
} catch {
setError('Die KI-Analyse ist fehlgeschlagen.')
setStep(NeedBuilderStep.ERROR)
}
}
// Resolve criteria (parse text if needed), then either search or show save preview
async function handleAction(chosenIntent: ActionIntent) {
setIntent(chosenIntent)
setError(null)
let resolved: ParsedNeedCriteria = criteria
let resolvedResult: ParseNeedResult | null = parseResult
if (!hasStructuredData && inputText.trim()) {
setStep(NeedBuilderStep.PARSING)
try {
const resp = await aiService.parseNeed(inputText)
resolved = resp.data.extractedCriteria
resolvedResult = resp.data
setCriteria(resolved)
setWeights(resp.data.suggestedWeights as Record<WeightingKey, number>)
setWeightingKey(k => k + 1) setWeightingKey(k => k + 1)
isManualTextRef.current = false isManualTextRef.current = false
setInputText('') setInputText('')
setIsAutoGen(false) setIsAutoGen(false)
setParseResult(resp.data) setStep(NeedBuilderStep.IDLE)
} catch { },
onError: () => {
setError('Die KI-Analyse ist fehlgeschlagen.') setError('Die KI-Analyse ist fehlgeschlagen.')
setStep(NeedBuilderStep.ERROR) setStep(NeedBuilderStep.ERROR)
return },
} })
}
// Resolve criteria (parse text if needed), then either search or show save preview
function handleAction(chosenIntent: ActionIntent) {
setIntent(chosenIntent)
setError(null)
const resolved: ParsedNeedCriteria = criteria
const resolvedResult: ParseNeedResult | null = parseResult
if (!hasStructuredData && inputText.trim()) {
setStep(NeedBuilderStep.PARSING)
parseNeedMutation.mutate(inputText, {
onSuccess: (resp) => {
const parsedResolved = resp.data.extractedCriteria
const parsedResult = resp.data
setCriteria(parsedResolved)
setWeights(resp.data.suggestedWeights as Record<WeightingKey, number>)
setWeightingKey(k => k + 1)
isManualTextRef.current = false
setInputText('')
setIsAutoGen(false)
setParseResult(resp.data)
continueAction(chosenIntent, parsedResolved, parsedResult)
},
onError: () => {
setError('Die KI-Analyse ist fehlgeschlagen.')
setStep(NeedBuilderStep.ERROR)
},
})
return
} }
continueAction(chosenIntent, resolved, resolvedResult)
}
function continueAction(
chosenIntent: ActionIntent,
resolved: ParsedNeedCriteria,
resolvedResult: ParseNeedResult | null,
) {
if (chosenIntent === 'search') { if (chosenIntent === 'search') {
// Save as DRAFT and navigate immediately // Save as DRAFT and navigate immediately
setStep(NeedBuilderStep.SAVING) setStep(NeedBuilderStep.SAVING)
try { const conf = resolvedResult
const conf = resolvedResult ? Object.values(resolvedResult.confidenceByField).reduce((s, v) => s + v, 0) /
? Object.values(resolvedResult.confidenceByField).reduce((s, v) => s + v, 0) / Math.max(Object.values(resolvedResult.confidenceByField).length, 1)
Math.max(Object.values(resolvedResult.confidenceByField).length, 1) : 0.5
: 0.5 const input = buildNeedInput(resolved, weights, needTitle, conf, 'DRAFT')
const input = buildNeedInput(resolved, weights, needTitle, conf, 'DRAFT') createNeedMutation.mutate(input, {
const created = await needService.create(input) onSuccess: (created) => {
await queryClient.invalidateQueries({ queryKey: ['needs'] }) queryClient.invalidateQueries({ queryKey: ['matches'] })
await queryClient.invalidateQueries({ queryKey: ['matches'] }) navigate('/demand/results', { state: { fromNeedBuilder: true, activeNeedId: created.data.id } })
navigate('/demand/results', { state: { fromNeedBuilder: true, activeNeedId: created.data.id } }) },
} catch { onError: () => {
setError('Suche fehlgeschlagen.') setError('Suche fehlgeschlagen.')
setStep(NeedBuilderStep.ERROR) setStep(NeedBuilderStep.ERROR)
} },
})
return return
} }
@@ -156,21 +174,22 @@ export default function AISearch() {
setStep(NeedBuilderStep.READY_TO_SAVE) setStep(NeedBuilderStep.READY_TO_SAVE)
} }
async function handleSaveProfile() { function handleSaveProfile() {
if (!editedCriteria || !parseResult) return if (!editedCriteria || !parseResult) return
setStep(NeedBuilderStep.SAVING) setStep(NeedBuilderStep.SAVING)
const entries = Object.entries(parseResult.confidenceByField) const entries = Object.entries(parseResult.confidenceByField)
const conf = entries.length > 0 ? entries.reduce((s, [, v]) => s + v, 0) / entries.length : 0 const conf = entries.length > 0 ? entries.reduce((s, [, v]) => s + v, 0) / entries.length : 0
try { const input = buildNeedInput(editedCriteria, weights, needTitle, conf, 'ACTIVE')
const input = buildNeedInput(editedCriteria, weights, needTitle, conf, 'ACTIVE') createNeedMutation.mutate(input, {
const created = await needService.create(input) onSuccess: (created) => {
await queryClient.invalidateQueries({ queryKey: ['needs'] }) queryClient.invalidateQueries({ queryKey: ['matches'] })
await queryClient.invalidateQueries({ queryKey: ['matches'] }) navigate('/demand/results', { state: { fromNeedBuilder: true, activeNeedId: created.data.id } })
navigate('/demand/results', { state: { fromNeedBuilder: true, activeNeedId: created.data.id } }) },
} catch { onError: () => {
setError('Speichern fehlgeschlagen.') setError('Speichern fehlgeschlagen.')
setStep(NeedBuilderStep.ERROR) setStep(NeedBuilderStep.ERROR)
} },
})
} }
function handleRetry() { function handleRetry() {
+2 -8
View File
@@ -14,10 +14,9 @@ import {
Tooltip, Tooltip,
Typography, Typography,
} from '@mui/material' } from '@mui/material'
import { useQuery } from '@tanstack/react-query'
import { ErrorState, LoadingPage, SectionContainer } from '../../components/ui' import { ErrorState, LoadingPage, SectionContainer } from '../../components/ui'
import { DataQualityBadge, FreshnessIndicator } from '../../components/data-quality' import { DataQualityBadge, FreshnessIndicator } from '../../components/data-quality'
import { propertyService } from '../../services/propertyService' import { useProperties } from '../../hooks/useProperties'
import { getRecommendedActions } from '../../services/dataQualityService' import { getRecommendedActions } from '../../services/dataQualityService'
import { DataFreshness } from '../../domain/enums' import { DataFreshness } from '../../domain/enums'
@@ -32,16 +31,11 @@ function getQualityColor(score: number): 'success' | 'warning' | 'error' {
export default function DataQuality() { export default function DataQuality() {
const [qualityFilter, setQualityFilter] = useState<QualityFilter>('') const [qualityFilter, setQualityFilter] = useState<QualityFilter>('')
const { data: resp, isLoading, error } = useQuery({ const { data: properties = [], isLoading, error } = useProperties()
queryKey: ['properties'],
queryFn: () => propertyService.getAll(),
})
if (isLoading) return <LoadingPage /> if (isLoading) return <LoadingPage />
if (error) return <ErrorState /> if (error) return <ErrorState />
const properties = resp?.data ?? []
const avgScore = properties.length const avgScore = properties.length
? properties.reduce((sum, p) => sum + p.dataQuality.score, 0) / properties.length ? properties.reduce((sum, p) => sum + p.dataQuality.score, 0) / properties.length
: 0 : 0
+42 -55
View File
@@ -1,6 +1,5 @@
import { useState } from 'react' import { useState } from 'react'
import { useNavigate } from 'react-router' import { useNavigate } from 'react-router'
import { useQueryClient } from '@tanstack/react-query'
import { import {
Alert, Alert,
Box, Box,
@@ -17,8 +16,7 @@ import {
Typography, Typography,
} from '@mui/material' } from '@mui/material'
import { Plus, Trash2 } from 'lucide-react' import { Plus, Trash2 } from 'lucide-react'
import { useProperties } from '../../hooks/useProperties' import { useProperties, useUpdateProperty, useRemoveProperty } from '../../hooks/useProperties'
import { propertyService } from '../../services/propertyService'
import type { Property } from '../../domain/property' import type { Property } from '../../domain/property'
const ASSET_LABELS: Record<string, string> = { const ASSET_LABELS: Record<string, string> = {
@@ -37,41 +35,36 @@ function formatDate(iso?: string) {
export default function MyListings() { export default function MyListings() {
const navigate = useNavigate() const navigate = useNavigate()
const qc = useQueryClient()
const { data: listings = [], isLoading } = useProperties({ sourceType: 'DIRECT' }) const { data: listings = [], isLoading } = useProperties({ sourceType: 'DIRECT' })
const updateProperty = useUpdateProperty()
const removeProperty = useRemoveProperty()
const [togglingId, setTogglingId] = useState<string | null>(null)
const [deletingId, setDeletingId] = useState<string | null>(null)
const [confirmDelete, setConfirmDelete] = useState<Property | null>(null) const [confirmDelete, setConfirmDelete] = useState<Property | null>(null)
const [actionError, setActionError] = useState<string | null>(null) const [actionError, setActionError] = useState<string | null>(null)
async function handleToggleStatus(p: Property) { function handleToggleStatus(p: Property) {
setTogglingId(p.id) const next = p.status === 'ACTIVE' ? 'INACTIVE' : 'ACTIVE'
setActionError(null) updateProperty.mutate(
try { { id: p.id, input: { status: next } },
const next = p.status === 'ACTIVE' ? 'INACTIVE' : 'ACTIVE' {
await propertyService.update(p.id, { status: next }) onError: () => {
qc.invalidateQueries({ queryKey: ['properties'] }) setActionError('Status konnte nicht geändert werden.')
} catch { },
setActionError('Status konnte nicht geändert werden.') },
} finally { )
setTogglingId(null)
}
} }
async function handleDelete(p: Property) { function handleDelete(p: Property) {
setDeletingId(p.id) removeProperty.mutate(p.id, {
setActionError(null) onSuccess: () => {
try { setConfirmDelete(null)
await propertyService.remove(p.id) },
qc.invalidateQueries({ queryKey: ['properties'] }) onError: () => {
} catch { setActionError('Inserat konnte nicht gelöscht werden.')
setActionError('Inserat konnte nicht gelöscht werden.') setConfirmDelete(null)
} finally { },
setDeletingId(null) })
setConfirmDelete(null)
}
} }
return ( return (
@@ -184,35 +177,29 @@ export default function MyListings() {
{/* Status toggle */} {/* Status toggle */}
<Box> <Box>
{togglingId === p.id ? ( <Tooltip title={p.status === 'ACTIVE' ? 'Deaktivieren' : 'Aktivieren'}>
<CircularProgress size={16} /> <Switch
) : ( size="small"
<Tooltip title={p.status === 'ACTIVE' ? 'Deaktivieren' : 'Aktivieren'}> checked={p.status === 'ACTIVE'}
<Switch onChange={() => handleToggleStatus(p)}
size="small" disabled={updateProperty.isPending}
checked={p.status === 'ACTIVE'} sx={{ '& .MuiSwitch-thumb': { width: 14, height: 14 } }}
onChange={() => handleToggleStatus(p)} />
sx={{ '& .MuiSwitch-thumb': { width: 14, height: 14 } }} </Tooltip>
/>
</Tooltip>
)}
</Box> </Box>
{/* Delete */} {/* Delete */}
<Box> <Box>
{deletingId === p.id ? ( <Tooltip title="Inserat löschen">
<CircularProgress size={16} /> <IconButton
) : ( size="small"
<Tooltip title="Inserat löschen"> onClick={() => setConfirmDelete(p)}
<IconButton disabled={removeProperty.isPending}
size="small" sx={{ color: '#94a3b8', '&:hover': { color: '#ef4444' } }}
onClick={() => setConfirmDelete(p)} >
sx={{ color: '#94a3b8', '&:hover': { color: '#ef4444' } }} <Trash2 size={15} />
> </IconButton>
<Trash2 size={15} /> </Tooltip>
</IconButton>
</Tooltip>
)}
</Box> </Box>
</Box> </Box>
) )
+32 -34
View File
@@ -1,6 +1,5 @@
import { useState } from 'react' import { useState } from 'react'
import { useLocation, useNavigate } from 'react-router' import { useLocation, useNavigate } from 'react-router'
import { useQueryClient } from '@tanstack/react-query'
import { import {
Alert, Alert,
Box, Box,
@@ -16,8 +15,8 @@ import {
Typography, Typography,
} from '@mui/material' } from '@mui/material'
import { ArrowLeft, CheckCircle, ImagePlus, Sparkles, X } from 'lucide-react' import { ArrowLeft, CheckCircle, ImagePlus, Sparkles, X } from 'lucide-react'
import { propertyService } from '../../services/propertyService' import { useCreateProperty } from '../../hooks/useProperties'
import { parseListingText } from '../../services/aiService' import { useParseListingText } from '../../hooks/useAI'
import { import {
ASSET_TYPE_LABELS, ASSET_TYPE_LABELS,
SOFT_FACTORS, SOFT_FACTORS,
@@ -29,10 +28,12 @@ import { buildCreatePropertyInput } from './newListingMapper'
export default function NewListing() { export default function NewListing() {
const navigate = useNavigate() const navigate = useNavigate()
const qc = useQueryClient()
const { state } = useLocation() as { state: LocationState | null } const { state } = useLocation() as { state: LocationState | null }
const pre = state?.prefill ?? {} const pre = state?.prefill ?? {}
const createProperty = useCreateProperty()
const parseListingMutation = useParseListingText()
// Core fields // Core fields
const [assetType, setAssetType] = useState(pre.assetType ?? 'OFFICE') const [assetType, setAssetType] = useState(pre.assetType ?? 'OFFICE')
const [street, setStreet] = useState(pre.street ?? '') const [street, setStreet] = useState(pre.street ?? '')
@@ -64,34 +65,33 @@ export default function NewListing() {
// AI // AI
const [aiText, setAiText] = useState('') const [aiText, setAiText] = useState('')
const [aiParsing, setAiParsing] = useState(false)
const [aiApplied, setAiApplied] = useState(false) const [aiApplied, setAiApplied] = useState(false)
// Submit // Submit
const [submitting, setSubmitting] = useState(false)
const [error, setError] = useState<string | null>(null) const [error, setError] = useState<string | null>(null)
const [created, setCreated] = useState(false) const [created, setCreated] = useState(false)
const aiParsing = parseListingMutation.isPending
const submitting = createProperty.isPending
const isPrefilled = !!pre.propertyId const isPrefilled = !!pre.propertyId
async function handleAiParse() { function handleAiParse() {
if (!aiText.trim()) return if (!aiText.trim()) return
setAiParsing(true) parseListingMutation.mutate(aiText, {
try { onSuccess: (parsed) => {
const parsed = await parseListingText(aiText) if (parsed.assetType) setAssetType(parsed.assetType)
if (parsed.assetType) setAssetType(parsed.assetType) if (parsed.areaSqm) setAreaSqm(String(parsed.areaSqm))
if (parsed.areaSqm) setAreaSqm(String(parsed.areaSqm)) if (parsed.rentPerSqm) setRentPerSqm(String(parsed.rentPerSqm))
if (parsed.rentPerSqm) setRentPerSqm(String(parsed.rentPerSqm)) if (parsed.city && !city) setCity(parsed.city)
if (parsed.city && !city) setCity(parsed.city) if (parsed.fitOut) setFitOut(parsed.fitOut)
if (parsed.fitOut) setFitOut(parsed.fitOut) if (parsed.parking) setParking(String(parsed.parking))
if (parsed.parking) setParking(String(parsed.parking)) if (parsed.softLevels) {
if (parsed.softLevels) { setSoftLevels(prev => ({ ...prev, ...parsed.softLevels }))
setSoftLevels(prev => ({ ...prev, ...parsed.softLevels })) }
} setAiApplied(true)
setAiApplied(true) },
} finally { })
setAiParsing(false)
}
} }
function addImage() { function addImage() {
@@ -111,11 +111,10 @@ export default function NewListing() {
return null return null
} }
async function handleSubmit() { function handleSubmit() {
const err = validate() const err = validate()
if (err) { setError(err); return } if (err) { setError(err); return }
setError(null) setError(null)
setSubmitting(true)
const input = buildCreatePropertyInput({ const input = buildCreatePropertyInput({
assetType, assetType,
@@ -135,15 +134,14 @@ export default function NewListing() {
images, images,
}) })
try { createProperty.mutate(input, {
await propertyService.create(input) onSuccess: () => {
qc.invalidateQueries({ queryKey: ['properties'] }) setCreated(true)
setCreated(true) },
} catch { onError: () => {
setError('Fehler beim Erstellen des Inserats. Bitte erneut versuchen.') setError('Fehler beim Erstellen des Inserats. Bitte erneut versuchen.')
} finally { },
setSubmitting(false) })
}
} }
function resetForm() { function resetForm() {