diff --git a/src/components/anfragencenter/AiOfferEmailButton.tsx b/src/components/anfragencenter/AiOfferEmailButton.tsx index a25d3a5..48cd1e4 100644 --- a/src/components/anfragencenter/AiOfferEmailButton.tsx +++ b/src/components/anfragencenter/AiOfferEmailButton.tsx @@ -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(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 ( - {error && ( + {generateOfferEmail.isError && ( - {error} + {generateOfferEmail.error instanceof Error + ? generateOfferEmail.error.message + : 'KI-Generierung fehlgeschlagen'} )} diff --git a/src/components/anfragencenter/OfferCreationWizard.tsx b/src/components/anfragencenter/OfferCreationWizard.tsx index bcae3a4..b9d8760 100644 --- a/src/components/anfragencenter/OfferCreationWizard.tsx +++ b/src/components/anfragencenter/OfferCreationWizard.tsx @@ -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 | 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 ( diff --git a/src/components/anfragencenter/PreparationWizard.tsx b/src/components/anfragencenter/PreparationWizard.tsx index 7418906..dd7b538 100644 --- a/src/components/anfragencenter/PreparationWizard.tsx +++ b/src/components/anfragencenter/PreparationWizard.tsx @@ -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(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 | 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 ( diff --git a/src/components/anfragencenter/RelatedPropertyCardPanel.tsx b/src/components/anfragencenter/RelatedPropertyCardPanel.tsx index 6905bc9..5465151 100644 --- a/src/components/anfragencenter/RelatedPropertyCardPanel.tsx +++ b/src/components/anfragencenter/RelatedPropertyCardPanel.tsx @@ -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([]) - 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 ( diff --git a/src/components/assistant/GlobalAIAssistantDrawer.tsx b/src/components/assistant/GlobalAIAssistantDrawer.tsx index 38d5509..c5e5f77 100644 --- a/src/components/assistant/GlobalAIAssistantDrawer.tsx +++ b/src/components/assistant/GlobalAIAssistantDrawer.tsx @@ -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([]) const [inputText, setInputText] = useState('') const scrollRef = useRef(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 diff --git a/src/components/auth/DemoRoleSwitcher.tsx b/src/components/auth/DemoRoleSwitcher.tsx index 0029c03..9d168b3 100644 --- a/src/components/auth/DemoRoleSwitcher.tsx +++ b/src/components/auth/DemoRoleSwitcher.tsx @@ -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 ( diff --git a/src/components/auth/OrganizationSwitcher.tsx b/src/components/auth/OrganizationSwitcher.tsx index 56e0193..5d86dc8 100644 --- a/src/components/auth/OrganizationSwitcher.tsx +++ b/src/components/auth/OrganizationSwitcher.tsx @@ -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) { - await authService.switchOrganization(e.target.value) + function handleChange(e: SelectChangeEvent) { + switchOrganization.mutate(e.target.value) } return ( diff --git a/src/components/demand/WeightingEditor.tsx b/src/components/demand/WeightingEditor.tsx index 017178f..d881ca8 100644 --- a/src/components/demand/WeightingEditor.tsx +++ b/src/components/demand/WeightingEditor.tsx @@ -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>(() => 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 ( diff --git a/src/components/future-signals/FutureSignalDetailPanel.tsx b/src/components/future-signals/FutureSignalDetailPanel.tsx index f7b3895..41743ba 100644 --- a/src/components/future-signals/FutureSignalDetailPanel.tsx +++ b/src/components/future-signals/FutureSignalDetailPanel.tsx @@ -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() { diff --git a/src/components/match-center/MatchBriefingPanel.tsx b/src/components/match-center/MatchBriefingPanel.tsx index 4d68f0a..80bd2d8 100644 --- a/src/components/match-center/MatchBriefingPanel.tsx +++ b/src/components/match-center/MatchBriefingPanel.tsx @@ -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', diff --git a/src/components/shortlist/DecisionBriefDraftPanel.tsx b/src/components/shortlist/DecisionBriefDraftPanel.tsx index b76c4f0..4c736fb 100644 --- a/src/components/shortlist/DecisionBriefDraftPanel.tsx +++ b/src/components/shortlist/DecisionBriefDraftPanel.tsx @@ -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(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 ( diff --git a/src/components/supply/MatchabilityTabPanel.tsx b/src/components/supply/MatchabilityTabPanel.tsx index 5381ef8..97cdc9d 100644 --- a/src/components/supply/MatchabilityTabPanel.tsx +++ b/src/components/supply/MatchabilityTabPanel.tsx @@ -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([]) - 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]) diff --git a/src/components/supply/PreMarketPanel.tsx b/src/components/supply/PreMarketPanel.tsx index bffa551..4055555 100644 --- a/src/components/supply/PreMarketPanel.tsx +++ b/src/components/supply/PreMarketPanel.tsx @@ -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>(() => { const init: Record = {} for (const u of p.units ?? []) { @@ -26,8 +24,9 @@ export function PreMarketPanel({ p }: { p: Property }) { return init }) const [unitSaving, setUnitSaving] = useState>({}) - 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, checked: boolean) { diff --git a/src/components/supply/PropertyDetailView.tsx b/src/components/supply/PropertyDetailView.tsx index 2019ce6..6762c7a 100644 --- a/src/components/supply/PropertyDetailView.tsx +++ b/src/components/supply/PropertyDetailView.tsx @@ -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({}) - 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 diff --git a/src/components/supply/PropertyMarketSignalsTab.tsx b/src/components/supply/PropertyMarketSignalsTab.tsx index 1eab4df..6a247d3 100644 --- a/src/components/supply/PropertyMarketSignalsTab.tsx +++ b/src/components/supply/PropertyMarketSignalsTab.tsx @@ -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(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 ( diff --git a/src/components/supply/UnitStructurePanel.tsx b/src/components/supply/UnitStructurePanel.tsx index 191b8f0..751498f 100644 --- a/src/components/supply/UnitStructurePanel.tsx +++ b/src/components/supply/UnitStructurePanel.tsx @@ -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 => { diff --git a/src/hooks/useAI.ts b/src/hooks/useAI.ts new file mode 100644 index 0000000..aa478e5 --- /dev/null +++ b/src/hooks/useAI.ts @@ -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), + }) +} diff --git a/src/hooks/useAssistant.ts b/src/hooks/useAssistant.ts new file mode 100644 index 0000000..de34855 --- /dev/null +++ b/src/hooks/useAssistant.ts @@ -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), + }) +} diff --git a/src/hooks/useAuth.ts b/src/hooks/useAuth.ts new file mode 100644 index 0000000..9da82b5 --- /dev/null +++ b/src/hooks/useAuth.ts @@ -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), + }) +} diff --git a/src/hooks/useInquiryReport.ts b/src/hooks/useInquiryReport.ts new file mode 100644 index 0000000..39ae3de --- /dev/null +++ b/src/hooks/useInquiryReport.ts @@ -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 + }) => inquiryReportService.update(draftId, data), + }) +} + +export function useFinalizeInquiryReport() { + return useMutation({ + mutationFn: (draftId: string) => inquiryReportService.finalize(draftId), + }) +} diff --git a/src/hooks/useMarketReport.ts b/src/hooks/useMarketReport.ts new file mode 100644 index 0000000..4e5401c --- /dev/null +++ b/src/hooks/useMarketReport.ts @@ -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, + }) +} diff --git a/src/hooks/useMatches.ts b/src/hooks/useMatches.ts index 7cbfb0f..6e3b50f 100644 --- a/src/hooks/useMatches.ts +++ b/src/hooks/useMatches.ts @@ -54,3 +54,24 @@ export function useMatchDetail(id: string) { select: (res) => res.data ?? null, }) } + +export function useNeedMatchesForProperty(propertyId: string, opts?: { minScore?: number }) { + return useQuery({ + queryKey: ['need-matches-for-property', propertyId, opts ?? {}], + queryFn: () => matchService.getNeedMatchesForProperty(propertyId, opts), + staleTime: STALE_MATCHES, + enabled: Boolean(propertyId), + }) +} + +export function useAdditionalMatchesForInquiry( + inquiryId: string | null, + opts?: { minScore?: number; excludePropertyId?: string }, +) { + return useQuery({ + queryKey: ['additional-matches-for-inquiry', inquiryId, opts ?? {}], + queryFn: () => matchService.getAdditionalMatchesForInquiry(inquiryId!, opts), + staleTime: STALE_MATCHES, + enabled: !!inquiryId, + }) +} diff --git a/src/hooks/useNeeds.ts b/src/hooks/useNeeds.ts index b753daa..97b96e7 100644 --- a/src/hooks/useNeeds.ts +++ b/src/hooks/useNeeds.ts @@ -1,5 +1,6 @@ -import { useQuery } from '@tanstack/react-query' +import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query' import { needService } from '../services/needService' +import type { CreateNeedInput } from '../domain/need' export function useNeeds() { return useQuery({ @@ -19,3 +20,13 @@ export function useNeed(id: string) { } export const useNeedProfiles = useNeeds + +export function useCreateNeed() { + const queryClient = useQueryClient() + return useMutation({ + mutationFn: (input: CreateNeedInput) => needService.create(input), + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ['needs'] }) + }, + }) +} diff --git a/src/hooks/useOfferReport.ts b/src/hooks/useOfferReport.ts new file mode 100644 index 0000000..ad4d515 --- /dev/null +++ b/src/hooks/useOfferReport.ts @@ -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 }) => + offerReportService.update(draftId, data), + }) +} + +export function useGenerateOfferReportPdf() { + return useMutation({ + mutationFn: (draftId: string) => offerReportService.generatePdf(draftId), + }) +} diff --git a/src/hooks/useProperties.ts b/src/hooks/useProperties.ts index 9ede0b7..a2b088d 100644 --- a/src/hooks/useProperties.ts +++ b/src/hooks/useProperties.ts @@ -1,8 +1,9 @@ -import { useQuery } from '@tanstack/react-query' +import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query' import { propertyService } from '../services/propertyService' import { matchService } from '../services/matchService' import { futureSignalService } from '../services/futureSignalService' import type { AssetType, ResultType } from '../domain/enums' +import type { CreatePropertyInput, UpdatePropertyInput } from '../domain/property' import { STALE_PROPERTIES, STALE_MATCHES, STALE_SIGNALS } from '../lib/constants' interface PropertyFilter { @@ -64,3 +65,35 @@ export function usePropertySignals(propertyId: string | null) { select: (res) => res.data ?? [], }) } + +export function useUpdateProperty() { + const queryClient = useQueryClient() + return useMutation({ + mutationFn: ({ id, input }: { id: string; input: UpdatePropertyInput }) => + propertyService.update(id, input), + onSuccess: (_, { id }) => { + queryClient.invalidateQueries({ queryKey: ['properties'] }) + queryClient.invalidateQueries({ queryKey: ['property', id] }) + }, + }) +} + +export function useCreateProperty() { + const queryClient = useQueryClient() + return useMutation({ + mutationFn: (input: CreatePropertyInput) => propertyService.create(input), + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ['properties'] }) + }, + }) +} + +export function useRemoveProperty() { + const queryClient = useQueryClient() + return useMutation({ + mutationFn: (id: string) => propertyService.remove(id), + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ['properties'] }) + }, + }) +} diff --git a/src/hooks/useReviewQueue.ts b/src/hooks/useReviewQueue.ts index 78638b7..e1deaa5 100644 --- a/src/hooks/useReviewQueue.ts +++ b/src/hooks/useReviewQueue.ts @@ -75,6 +75,19 @@ export function useAddReviewNote() { }) } +export function useCreateReviewTask() { + const queryClient = useQueryClient() + return useMutation({ + mutationFn: (signalId: string) => reviewService.createReviewTask(signalId), + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: [QK] }) + }, + onError: () => { + useToastStore.getState().showToast('Prüfungsaufgabe konnte nicht erstellt werden.', 'error') + }, + }) +} + // Legacy exports export function useApproveReviewItem() { const queryClient = useQueryClient() diff --git a/src/hooks/useUnitMatches.ts b/src/hooks/useUnitMatches.ts new file mode 100644 index 0000000..c17172c --- /dev/null +++ b/src/hooks/useUnitMatches.ts @@ -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], + ) +} diff --git a/src/hooks/useWeighting.ts b/src/hooks/useWeighting.ts new file mode 100644 index 0000000..6f62ebe --- /dev/null +++ b/src/hooks/useWeighting.ts @@ -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 { + return weightingService.getDefaultWeights(assetType) +} diff --git a/src/pages/auth/LoginScreen.tsx b/src/pages/auth/LoginScreen.tsx index 0e4a5b6..0695256 100644 --- a/src/pages/auth/LoginScreen.tsx +++ b/src/pages/auth/LoginScreen.tsx @@ -1,5 +1,5 @@ import { useState } from 'react' -import { useNavigate, Navigate } from 'react-router' +import { Navigate } from 'react-router' import { Box, Button, @@ -12,7 +12,7 @@ import { Typography, } from '@mui/material' import { Building2 } from 'lucide-react' -import { authService } from '../../services/authService' +import { useLogin, useSwitchDemoRole } from '../../hooks/useAuth' import { useSessionStore } from '../../stores/sessionStore' import { UserRole } from '../../domain/enums' @@ -23,42 +23,37 @@ const DEMO_ROLES: { role: UserRole; label: string; description: string }[] = [ export default function LoginScreen() { const { isAuthenticated } = useSessionStore() - const navigate = useNavigate() + const loginMutation = useLogin() + const switchDemoRoleMutation = useSwitchDemoRole() const [email, setEmail] = useState('admin@ideal-sharing.ch') const [password, setPassword] = useState('') - const [loading, setLoading] = useState(false) const [error, setError] = useState(null) if (isAuthenticated) { return } - async function handleLogin(e: React.FormEvent) { + const loading = loginMutation.isPending || switchDemoRoleMutation.isPending + + function handleLogin(e: React.FormEvent) { e.preventDefault() if (!email) { setError('Bitte E-Mail-Adresse eingeben.') return } - setLoading(true) setError(null) - try { - await authService.login(email, password) - navigate('/') - } catch { - setError('Anmeldung fehlgeschlagen. Bitte erneut versuchen.') - } finally { - setLoading(false) - } + loginMutation.mutate( + { email, password }, + { + onError: () => { + setError('Anmeldung fehlgeschlagen. Bitte erneut versuchen.') + }, + }, + ) } - async function handleDemoLogin(role: UserRole) { - setLoading(true) - try { - await authService.switchDemoRole(role) - navigate('/') - } finally { - setLoading(false) - } + function handleDemoLogin(role: UserRole) { + switchDemoRoleMutation.mutate(role) } return ( diff --git a/src/pages/demand/AISearch.tsx b/src/pages/demand/AISearch.tsx index 83ae48d..6ff09fe 100644 --- a/src/pages/demand/AISearch.tsx +++ b/src/pages/demand/AISearch.tsx @@ -18,9 +18,9 @@ import { NeedCardPreview, NeedBuilderErrorState, } from '../../components/demand' -import { aiService } from '../../services/aiService' -import { needService } from '../../services/needService' -import { weightingService } from '../../services/weightingService' +import { useParseNeed } from '../../hooks/useAI' +import { useCreateNeed } from '../../hooks/useNeeds' +import { useDefaultWeights } from '../../hooks/useWeighting' import { NeedBuilderStep } from '../../domain/needBuilder' import type { ParseNeedResult, ParsedNeedCriteria, WeightingKey } from '../../domain/needBuilder' import { generateSummary, buildNeedInput } from '../../services/aiSearch/needSearchMapper' @@ -35,6 +35,10 @@ export default function AISearch() { const navigate = useNavigate() const queryClient = useQueryClient() + const parseNeedMutation = useParseNeed() + const createNeedMutation = useCreateNeed() + const defaultWeights = useDefaultWeights() + const [step, setStep] = useState(NeedBuilderStep.IDLE) const [intent, setIntent] = useState('search') const [inputText, setInputText] = useState('') @@ -42,7 +46,7 @@ export default function AISearch() { const [criteria, setCriteria] = useState({}) const [parseResult, setParseResult] = useState(null) const [editedCriteria, setEditedCriteria] = useState(null) - const [weights, setWeights] = useState>(weightingService.getDefaultWeights()) + const [weights, setWeights] = useState>(defaultWeights) const [weightingKey, setWeightingKey] = useState(0) const [needTitle, setNeedTitle] = useState('') const [error, setError] = useState(null) @@ -64,71 +68,85 @@ export default function AISearch() { setInputText(text) } - async function handleAiAutofill() { + function handleAiAutofill() { setStep(NeedBuilderStep.PARSING) setError(null) - try { - const resp = await aiService.parseNeed(inputText) - const result = resp.data - setParseResult(result) - setCriteria({ ...result.extractedCriteria }) - setWeights(result.suggestedWeights as Record) - 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) + parseNeedMutation.mutate(inputText, { + onSuccess: (resp) => { + const result = resp.data + setParseResult(result) + setCriteria({ ...result.extractedCriteria }) + setWeights(result.suggestedWeights as Record) setWeightingKey(k => k + 1) isManualTextRef.current = false setInputText('') setIsAutoGen(false) - setParseResult(resp.data) - } catch { + setStep(NeedBuilderStep.IDLE) + }, + onError: () => { setError('Die KI-Analyse ist fehlgeschlagen.') 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) + 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') { // Save as DRAFT and navigate immediately setStep(NeedBuilderStep.SAVING) - try { - const conf = resolvedResult - ? Object.values(resolvedResult.confidenceByField).reduce((s, v) => s + v, 0) / - Math.max(Object.values(resolvedResult.confidenceByField).length, 1) - : 0.5 - const input = buildNeedInput(resolved, weights, needTitle, conf, 'DRAFT') - const created = await needService.create(input) - await queryClient.invalidateQueries({ queryKey: ['needs'] }) - await queryClient.invalidateQueries({ queryKey: ['matches'] }) - navigate('/demand/results', { state: { fromNeedBuilder: true, activeNeedId: created.data.id } }) - } catch { - setError('Suche fehlgeschlagen.') - setStep(NeedBuilderStep.ERROR) - } + const conf = resolvedResult + ? Object.values(resolvedResult.confidenceByField).reduce((s, v) => s + v, 0) / + Math.max(Object.values(resolvedResult.confidenceByField).length, 1) + : 0.5 + const input = buildNeedInput(resolved, weights, needTitle, conf, 'DRAFT') + createNeedMutation.mutate(input, { + onSuccess: (created) => { + queryClient.invalidateQueries({ queryKey: ['matches'] }) + navigate('/demand/results', { state: { fromNeedBuilder: true, activeNeedId: created.data.id } }) + }, + onError: () => { + setError('Suche fehlgeschlagen.') + setStep(NeedBuilderStep.ERROR) + }, + }) return } @@ -156,21 +174,22 @@ export default function AISearch() { setStep(NeedBuilderStep.READY_TO_SAVE) } - async function handleSaveProfile() { + function handleSaveProfile() { if (!editedCriteria || !parseResult) return setStep(NeedBuilderStep.SAVING) const entries = Object.entries(parseResult.confidenceByField) 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 created = await needService.create(input) - await queryClient.invalidateQueries({ queryKey: ['needs'] }) - await queryClient.invalidateQueries({ queryKey: ['matches'] }) - navigate('/demand/results', { state: { fromNeedBuilder: true, activeNeedId: created.data.id } }) - } catch { - setError('Speichern fehlgeschlagen.') - setStep(NeedBuilderStep.ERROR) - } + const input = buildNeedInput(editedCriteria, weights, needTitle, conf, 'ACTIVE') + createNeedMutation.mutate(input, { + onSuccess: (created) => { + queryClient.invalidateQueries({ queryKey: ['matches'] }) + navigate('/demand/results', { state: { fromNeedBuilder: true, activeNeedId: created.data.id } }) + }, + onError: () => { + setError('Speichern fehlgeschlagen.') + setStep(NeedBuilderStep.ERROR) + }, + }) } function handleRetry() { diff --git a/src/pages/supply/DataQuality.tsx b/src/pages/supply/DataQuality.tsx index 105661d..8b1574b 100644 --- a/src/pages/supply/DataQuality.tsx +++ b/src/pages/supply/DataQuality.tsx @@ -14,10 +14,9 @@ import { Tooltip, Typography, } from '@mui/material' -import { useQuery } from '@tanstack/react-query' import { ErrorState, LoadingPage, SectionContainer } from '../../components/ui' import { DataQualityBadge, FreshnessIndicator } from '../../components/data-quality' -import { propertyService } from '../../services/propertyService' +import { useProperties } from '../../hooks/useProperties' import { getRecommendedActions } from '../../services/dataQualityService' import { DataFreshness } from '../../domain/enums' @@ -32,16 +31,11 @@ function getQualityColor(score: number): 'success' | 'warning' | 'error' { export default function DataQuality() { const [qualityFilter, setQualityFilter] = useState('') - const { data: resp, isLoading, error } = useQuery({ - queryKey: ['properties'], - queryFn: () => propertyService.getAll(), - }) + const { data: properties = [], isLoading, error } = useProperties() if (isLoading) return if (error) return - const properties = resp?.data ?? [] - const avgScore = properties.length ? properties.reduce((sum, p) => sum + p.dataQuality.score, 0) / properties.length : 0 diff --git a/src/pages/supply/MyListings.tsx b/src/pages/supply/MyListings.tsx index 8746b6f..4129ae4 100644 --- a/src/pages/supply/MyListings.tsx +++ b/src/pages/supply/MyListings.tsx @@ -1,6 +1,5 @@ import { useState } from 'react' import { useNavigate } from 'react-router' -import { useQueryClient } from '@tanstack/react-query' import { Alert, Box, @@ -17,8 +16,7 @@ import { Typography, } from '@mui/material' import { Plus, Trash2 } from 'lucide-react' -import { useProperties } from '../../hooks/useProperties' -import { propertyService } from '../../services/propertyService' +import { useProperties, useUpdateProperty, useRemoveProperty } from '../../hooks/useProperties' import type { Property } from '../../domain/property' const ASSET_LABELS: Record = { @@ -37,41 +35,36 @@ function formatDate(iso?: string) { export default function MyListings() { const navigate = useNavigate() - const qc = useQueryClient() const { data: listings = [], isLoading } = useProperties({ sourceType: 'DIRECT' }) + const updateProperty = useUpdateProperty() + const removeProperty = useRemoveProperty() - const [togglingId, setTogglingId] = useState(null) - const [deletingId, setDeletingId] = useState(null) const [confirmDelete, setConfirmDelete] = useState(null) const [actionError, setActionError] = useState(null) - async function handleToggleStatus(p: Property) { - setTogglingId(p.id) - setActionError(null) - try { - const next = p.status === 'ACTIVE' ? 'INACTIVE' : 'ACTIVE' - await propertyService.update(p.id, { status: next }) - qc.invalidateQueries({ queryKey: ['properties'] }) - } catch { - setActionError('Status konnte nicht geändert werden.') - } finally { - setTogglingId(null) - } + function handleToggleStatus(p: Property) { + const next = p.status === 'ACTIVE' ? 'INACTIVE' : 'ACTIVE' + updateProperty.mutate( + { id: p.id, input: { status: next } }, + { + onError: () => { + setActionError('Status konnte nicht geändert werden.') + }, + }, + ) } - async function handleDelete(p: Property) { - setDeletingId(p.id) - setActionError(null) - try { - await propertyService.remove(p.id) - qc.invalidateQueries({ queryKey: ['properties'] }) - } catch { - setActionError('Inserat konnte nicht gelöscht werden.') - } finally { - setDeletingId(null) - setConfirmDelete(null) - } + function handleDelete(p: Property) { + removeProperty.mutate(p.id, { + onSuccess: () => { + setConfirmDelete(null) + }, + onError: () => { + setActionError('Inserat konnte nicht gelöscht werden.') + setConfirmDelete(null) + }, + }) } return ( @@ -184,35 +177,29 @@ export default function MyListings() { {/* Status toggle */} - {togglingId === p.id ? ( - - ) : ( - - handleToggleStatus(p)} - sx={{ '& .MuiSwitch-thumb': { width: 14, height: 14 } }} - /> - - )} + + handleToggleStatus(p)} + disabled={updateProperty.isPending} + sx={{ '& .MuiSwitch-thumb': { width: 14, height: 14 } }} + /> + {/* Delete */} - {deletingId === p.id ? ( - - ) : ( - - setConfirmDelete(p)} - sx={{ color: '#94a3b8', '&:hover': { color: '#ef4444' } }} - > - - - - )} + + setConfirmDelete(p)} + disabled={removeProperty.isPending} + sx={{ color: '#94a3b8', '&:hover': { color: '#ef4444' } }} + > + + + ) diff --git a/src/pages/supply/NewListing.tsx b/src/pages/supply/NewListing.tsx index f129b5a..3388b36 100644 --- a/src/pages/supply/NewListing.tsx +++ b/src/pages/supply/NewListing.tsx @@ -1,6 +1,5 @@ import { useState } from 'react' import { useLocation, useNavigate } from 'react-router' -import { useQueryClient } from '@tanstack/react-query' import { Alert, Box, @@ -16,8 +15,8 @@ import { Typography, } from '@mui/material' import { ArrowLeft, CheckCircle, ImagePlus, Sparkles, X } from 'lucide-react' -import { propertyService } from '../../services/propertyService' -import { parseListingText } from '../../services/aiService' +import { useCreateProperty } from '../../hooks/useProperties' +import { useParseListingText } from '../../hooks/useAI' import { ASSET_TYPE_LABELS, SOFT_FACTORS, @@ -29,10 +28,12 @@ import { buildCreatePropertyInput } from './newListingMapper' export default function NewListing() { const navigate = useNavigate() - const qc = useQueryClient() const { state } = useLocation() as { state: LocationState | null } const pre = state?.prefill ?? {} + const createProperty = useCreateProperty() + const parseListingMutation = useParseListingText() + // Core fields const [assetType, setAssetType] = useState(pre.assetType ?? 'OFFICE') const [street, setStreet] = useState(pre.street ?? '') @@ -64,34 +65,33 @@ export default function NewListing() { // AI const [aiText, setAiText] = useState('') - const [aiParsing, setAiParsing] = useState(false) const [aiApplied, setAiApplied] = useState(false) // Submit - const [submitting, setSubmitting] = useState(false) const [error, setError] = useState(null) const [created, setCreated] = useState(false) + const aiParsing = parseListingMutation.isPending + const submitting = createProperty.isPending + const isPrefilled = !!pre.propertyId - async function handleAiParse() { + function handleAiParse() { if (!aiText.trim()) return - setAiParsing(true) - try { - const parsed = await parseListingText(aiText) - if (parsed.assetType) setAssetType(parsed.assetType) - if (parsed.areaSqm) setAreaSqm(String(parsed.areaSqm)) - if (parsed.rentPerSqm) setRentPerSqm(String(parsed.rentPerSqm)) - if (parsed.city && !city) setCity(parsed.city) - if (parsed.fitOut) setFitOut(parsed.fitOut) - if (parsed.parking) setParking(String(parsed.parking)) - if (parsed.softLevels) { - setSoftLevels(prev => ({ ...prev, ...parsed.softLevels })) - } - setAiApplied(true) - } finally { - setAiParsing(false) - } + parseListingMutation.mutate(aiText, { + onSuccess: (parsed) => { + if (parsed.assetType) setAssetType(parsed.assetType) + if (parsed.areaSqm) setAreaSqm(String(parsed.areaSqm)) + if (parsed.rentPerSqm) setRentPerSqm(String(parsed.rentPerSqm)) + if (parsed.city && !city) setCity(parsed.city) + if (parsed.fitOut) setFitOut(parsed.fitOut) + if (parsed.parking) setParking(String(parsed.parking)) + if (parsed.softLevels) { + setSoftLevels(prev => ({ ...prev, ...parsed.softLevels })) + } + setAiApplied(true) + }, + }) } function addImage() { @@ -111,11 +111,10 @@ export default function NewListing() { return null } - async function handleSubmit() { + function handleSubmit() { const err = validate() if (err) { setError(err); return } setError(null) - setSubmitting(true) const input = buildCreatePropertyInput({ assetType, @@ -135,15 +134,14 @@ export default function NewListing() { images, }) - try { - await propertyService.create(input) - qc.invalidateQueries({ queryKey: ['properties'] }) - setCreated(true) - } catch { - setError('Fehler beim Erstellen des Inserats. Bitte erneut versuchen.') - } finally { - setSubmitting(false) - } + createProperty.mutate(input, { + onSuccess: () => { + setCreated(true) + }, + onError: () => { + setError('Fehler beim Erstellen des Inserats. Bitte erneut versuchen.') + }, + }) } function resetForm() {