feat: saved profiles — per-profile notifications, real match scores, action buttons

- Add per-profile notification config (toggle, score threshold, email) with Accordion UI in SavedNeedDetail
- NotificationButton: badge only lights up for unseen matches, dismisses on open (localStorage), uses per-profile minScore
- SavedNeedDetail: replace deterministicMatchScore with real match data (useMatchesByNeed), sort by score desc, deduplicate by property
- SavedProfilesTab & SavedNeedCard: top score bar now shows best real match score instead of criteria confidence
- "Treffer" count uses per-profile notificationConfig.minScore (≥80 default) consistently across card, panel, and notification badge
- Extract PropertyMatchRow to own file with Anfrage/Merken/Vergleichen/Details→/Zur Einheit→ action buttons
- Details → navigates to /demand/results/:matchId using real match ID format (m__propId__prop__needId)
- Add InquiryQuickDialog + AddToPipelineDialog to AISearch.tsx so dialogs work from SavedProfilesTab
- Org-isolation: useNeeds filters by organizationId, org-mobimo gets 4 own search profiles
- Mock data: all 18 org-wincasa need names changed to descriptive profile names; 4 new org-mobimo needs added
- useUpdateNeed mutation hook for updating need fields (notificationConfig etc.)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Benjamin Sutter
2026-06-19 20:53:49 +02:00
parent 6aa4f96bd2
commit 7a0909e36a
10 changed files with 1131 additions and 110 deletions
+95 -62
View File
@@ -1,6 +1,6 @@
import { useRef, useState } from 'react'
import { Box, Typography } from '@mui/material'
import { useNavigate } from 'react-router'
import { useEffect, useRef, useState } from 'react'
import { Box, Tab, Tabs, Typography } from '@mui/material'
import { useLocation, useNavigate } from 'react-router'
import { useQueryClient } from '@tanstack/react-query'
import {
NeedBuilderProgress,
@@ -11,12 +11,15 @@ import {
} from '../../components/demand'
import { AISearchActionBar } from '../../components/demand/AISearchActionBar'
import { AISearchSavePreview } from '../../components/demand/AISearchSavePreview'
import { SavedProfilesTab } from '../../components/demand/SavedProfilesTab'
import { AddToPipelineDialog } from '../../components/shortlist'
import { InquiryQuickDialog } from '../../components/match-detail/InquiryQuickDialog'
import { useParseNeed } from '../../hooks/useAI'
import { useCreateNeed } from '../../hooks/useNeeds'
import { useCreateNeed, useNeed, useNeedProfiles } 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'
import { generateSummary, buildNeedInput, needToParsedCriteria } from '../../services/aiSearch/needSearchMapper'
type ActionIntent = 'search' | 'save-profile'
@@ -41,8 +44,25 @@ export default function AISearch() {
const [isAnonymous, setIsAnonymous] = useState(false)
const [error, setError] = useState<string | null>(null)
const [tab, setTab] = useState(0)
const { data: savedProfiles = [] } = useNeedProfiles()
const locationPrefillId = (useLocation().state as { prefillNeedId?: string } | null)?.prefillNeedId
const [internalPrefillId, setInternalPrefillId] = useState<string | undefined>()
const prefillNeedId = internalPrefillId ?? locationPrefillId
const { data: prefillNeed } = useNeed(prefillNeedId ?? '')
const isManualTextRef = useRef(false)
useEffect(() => {
if (!prefillNeed) return
setCriteria(needToParsedCriteria(prefillNeed))
setWeights(prefillNeed.weightingProfile as Record<WeightingKey, number>)
setNeedTitle(prefillNeed.companyName)
setWeightingKey(k => k + 1)
setTab(0)
}, [prefillNeed])
function handleCriteriaChange(next: ParsedNeedCriteria) {
setCriteria(next)
if (!isManualTextRef.current) {
@@ -56,7 +76,6 @@ export default function AISearch() {
isManualTextRef.current = text !== ''
setIsAutoGen(false)
setInputText(text)
// Clear stale parse results when user edits the text manually
setCriteria({})
setParseResult(null)
}
@@ -198,16 +217,15 @@ export default function AISearch() {
const isProcessing = step === NeedBuilderStep.PARSING || step === NeedBuilderStep.SAVING
const isSaveStep = step === NeedBuilderStep.READY_TO_SAVE || step === NeedBuilderStep.SAVING
const overallConfidence = parseResult
? (() => {
const entries = Object.entries(parseResult.confidenceByField)
return entries.length > 0 ? entries.reduce((s, [, v]) => s + v, 0) / entries.length : 0
})()
: 0
const vals = parseResult ? Object.values(parseResult.confidenceByField) : []
const overallConfidence = vals.length > 0 ? vals.reduce((s, v) => s + v, 0) / vals.length : 0
const savedCount = savedProfiles.filter(n => !n.status || n.status === 'ACTIVE' || n.status === 'DRAFT').length
return (
<Box sx={{ display: 'flex', flexDirection: 'column', height: '100%', overflow: 'hidden' }}>
{/* Header */}
<AddToPipelineDialog />
<InquiryQuickDialog />
<Box sx={{ bgcolor: 'white', borderBottom: '1px solid #e8e7e4', px: 3, py: 2.5, flexShrink: 0 }}>
<Typography sx={{ fontWeight: 700, fontSize: '1.125rem', lineHeight: 1.3, color: '#0f172a' }}>Flächensuche</Typography>
<Typography variant="body2" color="text.secondary">
@@ -215,61 +233,76 @@ export default function AISearch() {
</Typography>
</Box>
<NeedBuilderProgress step={step} />
<Tabs
value={tab}
onChange={(_, v: number) => setTab(v)}
sx={{ borderBottom: '1px solid #e2e8f0', px: 3, bgcolor: 'white', flexShrink: 0, minHeight: 44 }}
>
<Tab label="Neue Suche" sx={{ textTransform: 'none', fontWeight: 600, fontSize: '0.875rem', minHeight: 44, py: 0 }} />
<Tab label={`Gespeicherte Profile (${savedCount})`} sx={{ textTransform: 'none', fontWeight: 600, fontSize: '0.875rem', minHeight: 44, py: 0 }} />
</Tabs>
<Box sx={{ flex: 1, overflowY: 'auto', px: 3, py: 3 }}>
{tab === 0 && (
<>
<NeedBuilderProgress step={step} />
<Box sx={{ flex: 1, overflowY: 'auto', px: 3, py: 3 }}>
{/* IDLE: full form */}
{(step === NeedBuilderStep.IDLE || step === NeedBuilderStep.PARSING) && (
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 3, maxWidth: { md: 900, xl: 1200 }, mx: 'auto' }}>
<VoiceNeedInput
text={inputText}
onTextChange={handleTextChange}
isAutoGen={isAutoGen}
onAiSubmit={handleAiAutofill}
isAnalyzing={step === NeedBuilderStep.PARSING}
/>
<Box className="grid grid-cols-2 gap-4" sx={{ alignItems: 'start' }}>
<NeedInput criteria={criteria} onCriteriaChange={handleCriteriaChange} />
<WeightingEditor
key={weightingKey}
{(step === NeedBuilderStep.IDLE || step === NeedBuilderStep.PARSING) && (
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 3, maxWidth: { md: 900, xl: 1200 }, mx: 'auto' }}>
<VoiceNeedInput
text={inputText}
onTextChange={handleTextChange}
isAutoGen={isAutoGen}
onAiSubmit={handleAiAutofill}
isAnalyzing={step === NeedBuilderStep.PARSING}
/>
<Box className="grid grid-cols-2 gap-4" sx={{ alignItems: 'start' }}>
<NeedInput criteria={criteria} onCriteriaChange={handleCriteriaChange} />
<WeightingEditor
key={weightingKey}
weights={weights}
onChange={setWeights}
assetType={criteria.assetType}
/>
</Box>
<AISearchActionBar
canProceed={canProceed}
isSearching={isProcessing && intent === 'search'}
isSavingProfile={isProcessing && intent === 'save-profile'}
onSearch={() => handleAction('search')}
onSaveProfile={() => handleAction('save-profile')}
/>
</Box>
)}
{isSaveStep && parseResult && editedCriteria && (
<AISearchSavePreview
criteria={editedCriteria}
weights={weights}
onChange={setWeights}
assetType={criteria.assetType}
parseResult={parseResult}
needTitle={needTitle}
overallConfidence={overallConfidence}
isSaving={step === NeedBuilderStep.SAVING}
isAnonymous={isAnonymous}
onNeedTitleChange={setNeedTitle}
onAnonymousChange={setIsAnonymous}
onBack={() => setStep(NeedBuilderStep.IDLE)}
onSave={handleSaveProfile}
/>
</Box>
<AISearchActionBar
canProceed={canProceed}
isSearching={isProcessing && intent === 'search'}
isSavingProfile={isProcessing && intent === 'save-profile'}
onSearch={() => handleAction('search')}
onSaveProfile={() => handleAction('save-profile')}
/>
)}
{step === NeedBuilderStep.ERROR && (
<NeedBuilderErrorState message={error ?? 'Unbekannter Fehler'} onRetry={handleRetry} />
)}
</Box>
)}
</>
)}
{/* Save preview step */}
{isSaveStep && parseResult && editedCriteria && (
<AISearchSavePreview
criteria={editedCriteria}
weights={weights}
parseResult={parseResult}
needTitle={needTitle}
overallConfidence={overallConfidence}
isSaving={step === NeedBuilderStep.SAVING}
isAnonymous={isAnonymous}
onNeedTitleChange={setNeedTitle}
onAnonymousChange={setIsAnonymous}
onBack={() => setStep(NeedBuilderStep.IDLE)}
onSave={handleSaveProfile}
/>
)}
{/* Error */}
{step === NeedBuilderStep.ERROR && (
<NeedBuilderErrorState message={error ?? 'Unbekannter Fehler'} onRetry={handleRetry} />
)}
</Box>
{tab === 1 && (
<Box sx={{ flex: 1, overflow: 'hidden' }}>
<SavedProfilesTab onNewSearch={(id) => { setInternalPrefillId(id); setTab(0) }} />
</Box>
)}
</Box>
)
}