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:
@@ -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
|
||||||
|
|||||||
@@ -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 (
|
||||||
|
|||||||
@@ -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 (
|
||||||
|
|||||||
@@ -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 (
|
||||||
|
|||||||
@@ -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])
|
||||||
|
|||||||
@@ -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) {
|
||||||
|
|||||||
@@ -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' }}>
|
||||||
|
|||||||
@@ -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 => {
|
||||||
|
|||||||
@@ -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),
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -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),
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -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),
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -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),
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -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,
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -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
@@ -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'] })
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|||||||
@@ -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),
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -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'] })
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|||||||
@@ -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()
|
||||||
|
|||||||
@@ -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],
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
@@ -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 (
|
||||||
|
|||||||
@@ -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() {
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -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() {
|
||||||
|
|||||||
Reference in New Issue
Block a user