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:
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user