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:
@@ -0,0 +1,167 @@
|
||||
import { useEffect, useMemo, useState } from 'react'
|
||||
import { Box, Button, CircularProgress, IconButton, Typography, useMediaQuery, useTheme } from '@mui/material'
|
||||
import { ArrowLeft, Plus, Search } from 'lucide-react'
|
||||
import { useNavigate } from 'react-router'
|
||||
import { useNeedProfiles, useArchiveNeed, useUpdateNeed } from '../../hooks/useNeeds'
|
||||
import { useProperties } from '../../hooks/useProperties'
|
||||
import { useMatches } from '../../hooks/useMatches'
|
||||
import { ResultType } from '../../domain/enums'
|
||||
import { ROUTES } from '../../lib/constants'
|
||||
import { EmptyState } from '../ui'
|
||||
import { SavedNeedCard } from './SavedNeedCard'
|
||||
import { SavedNeedDetail } from './SavedNeedDetail'
|
||||
import { needToParsedCriteria } from '../../services/aiSearch/needSearchMapper'
|
||||
|
||||
interface Props {
|
||||
onNewSearch: (prefillNeedId?: string) => void
|
||||
}
|
||||
|
||||
export function SavedProfilesTab({ onNewSearch }: Props) {
|
||||
const navigate = useNavigate()
|
||||
const theme = useTheme()
|
||||
const isMobile = useMediaQuery(theme.breakpoints.down('md'))
|
||||
|
||||
const { data: needs = [], isLoading } = useNeedProfiles()
|
||||
const { data: properties = [] } = useProperties({ resultType: ResultType.VERIFIED_PORTFOLIO })
|
||||
const { data: allMatches = [] } = useMatches()
|
||||
const archiveMutation = useArchiveNeed()
|
||||
const updateMutation = useUpdateNeed()
|
||||
|
||||
const [selectedId, setSelectedId] = useState<string | null>(null)
|
||||
const [mobileView, setMobileView] = useState<'list' | 'detail'>('list')
|
||||
|
||||
const visible = useMemo(
|
||||
() =>
|
||||
needs
|
||||
.filter(n => !n.status || n.status === 'ACTIVE' || n.status === 'DRAFT')
|
||||
.sort((a, b) => {
|
||||
const aActive = !a.status || a.status === 'ACTIVE'
|
||||
const bActive = !b.status || b.status === 'ACTIVE'
|
||||
if (aActive !== bActive) return aActive ? -1 : 1
|
||||
return new Date(b.updatedAt).getTime() - new Date(a.updatedAt).getTime()
|
||||
}),
|
||||
[needs],
|
||||
)
|
||||
|
||||
const { matchCounts, topScores } = useMemo(() => {
|
||||
const counts: Record<string, number> = {}
|
||||
const tops: Record<string, number> = {}
|
||||
for (const n of visible) {
|
||||
const needMatches = allMatches.filter(m => m.needId === n.id)
|
||||
const threshold = n.notificationConfig?.minScore ?? 80
|
||||
counts[n.id] = needMatches.filter(m => m.matchScore >= threshold).length
|
||||
tops[n.id] = needMatches.length > 0 ? Math.round(Math.max(...needMatches.map(m => m.matchScore))) : 0
|
||||
}
|
||||
return { matchCounts: counts, topScores: tops }
|
||||
}, [visible, allMatches])
|
||||
|
||||
useEffect(() => {
|
||||
if (!isMobile && !selectedId && visible.length > 0) setSelectedId(visible[0].id)
|
||||
}, [visible, selectedId, isMobile])
|
||||
|
||||
const selectedNeed = visible.find(n => n.id === selectedId) ?? null
|
||||
|
||||
function handleSelect(id: string) {
|
||||
setSelectedId(id)
|
||||
if (isMobile) setMobileView('detail')
|
||||
}
|
||||
|
||||
function handleStart(needId: string) {
|
||||
navigate(ROUTES.DEMAND.RESULTS, { state: { fromNeedBuilder: true, activeNeedId: needId } })
|
||||
}
|
||||
|
||||
function handleEdit(needId: string) {
|
||||
onNewSearch(needId)
|
||||
}
|
||||
|
||||
if (isLoading) {
|
||||
return <Box sx={{ display: 'flex', justifyContent: 'center', pt: 8 }}><CircularProgress size={24} /></Box>
|
||||
}
|
||||
|
||||
if (visible.length === 0) {
|
||||
return (
|
||||
<Box sx={{ display: 'flex', flexDirection: 'column', alignItems: 'center', pt: 8, gap: 2 }}>
|
||||
<EmptyState title="Keine Suchprofile" description="Sie haben noch keine Suchprofile gespeichert." />
|
||||
<Button variant="outlined" startIcon={<Plus size={16} />} onClick={() => onNewSearch()} sx={{ textTransform: 'none', mt: 1 }}>
|
||||
Neue Suche erstellen
|
||||
</Button>
|
||||
</Box>
|
||||
)
|
||||
}
|
||||
|
||||
const list = (
|
||||
<Box sx={{ width: 280, minWidth: 280, flexShrink: 0, borderRight: '1px solid #e2e8f0', bgcolor: 'white', display: 'flex', flexDirection: 'column', overflow: 'hidden' }}>
|
||||
<Box sx={{ px: 2, py: 1.25, borderBottom: '1px solid #e2e8f0', display: 'flex', alignItems: 'center', gap: 1, flexShrink: 0 }}>
|
||||
<Search size={14} color="#7c3aed" />
|
||||
<Typography variant="body2" sx={{ fontWeight: 600, color: '#0f172a' }}>Suchprofile</Typography>
|
||||
<Box sx={{ ml: 'auto', px: 1, py: 0.125, borderRadius: 1, bgcolor: '#152642', color: 'white', fontWeight: 600, fontSize: '0.7rem' }}>
|
||||
{visible.length}
|
||||
</Box>
|
||||
</Box>
|
||||
<Box sx={{ flex: 1, overflowY: 'auto', p: 1.25, display: 'flex', flexDirection: 'column', gap: 1 }}>
|
||||
{visible.map(n => (
|
||||
<SavedNeedCard
|
||||
key={n.id}
|
||||
need={n}
|
||||
selected={n.id === selectedId}
|
||||
matchCount={matchCounts[n.id] ?? 0}
|
||||
topScore={topScores[n.id] ?? 0}
|
||||
onClick={() => handleSelect(n.id)}
|
||||
/>
|
||||
))}
|
||||
</Box>
|
||||
<Box sx={{ flexShrink: 0, p: 1.5, borderTop: '1px solid #e2e8f0' }}>
|
||||
<Button fullWidth variant="outlined" size="small" startIcon={<Plus size={14} />} onClick={() => onNewSearch()}
|
||||
sx={{ textTransform: 'none', fontSize: '0.8rem' }}>
|
||||
Neue Suche
|
||||
</Button>
|
||||
</Box>
|
||||
</Box>
|
||||
)
|
||||
|
||||
if (isMobile) {
|
||||
if (mobileView === 'detail' && selectedNeed) {
|
||||
return (
|
||||
<Box sx={{ display: 'flex', flexDirection: 'column', height: '100%', overflow: 'hidden' }}>
|
||||
<Box sx={{ px: 1.5, py: 1, borderBottom: '1px solid #e2e8f0', bgcolor: 'white', flexShrink: 0, display: 'flex', alignItems: 'center', gap: 1 }}>
|
||||
<IconButton size="small" onClick={() => setMobileView('list')}><ArrowLeft size={18} /></IconButton>
|
||||
<Typography variant="body2" sx={{ fontWeight: 600, fontSize: '0.85rem' }} noWrap>{selectedNeed.companyName}</Typography>
|
||||
</Box>
|
||||
<Box sx={{ flex: 1, overflow: 'hidden', display: 'flex', flexDirection: 'column' }}>
|
||||
<SavedNeedDetail
|
||||
need={selectedNeed}
|
||||
properties={properties}
|
||||
onStart={() => handleStart(selectedNeed.id)}
|
||||
onEdit={() => handleEdit(selectedNeed.id)}
|
||||
onArchive={() => archiveMutation.mutate(selectedNeed.id)}
|
||||
onUpdate={(data) => updateMutation.mutate({ id: selectedNeed.id, ...data })}
|
||||
/>
|
||||
</Box>
|
||||
</Box>
|
||||
)
|
||||
}
|
||||
return <Box sx={{ height: '100%', overflow: 'hidden' }}>{list}</Box>
|
||||
}
|
||||
|
||||
return (
|
||||
<Box sx={{ display: 'flex', height: '100%', overflow: 'hidden' }}>
|
||||
{list}
|
||||
<Box sx={{ flex: 1, display: 'flex', overflow: 'hidden' }}>
|
||||
{selectedNeed ? (
|
||||
<SavedNeedDetail
|
||||
need={selectedNeed}
|
||||
properties={properties}
|
||||
onStart={() => handleStart(selectedNeed.id)}
|
||||
onEdit={() => handleEdit(selectedNeed.id)}
|
||||
onArchive={() => archiveMutation.mutate(selectedNeed.id)}
|
||||
onUpdate={(data) => updateMutation.mutate({ id: selectedNeed.id, ...data })}
|
||||
/>
|
||||
) : (
|
||||
<Box sx={{ flex: 1, display: 'flex', alignItems: 'center', justifyContent: 'center' }}>
|
||||
<EmptyState icon={<Search size={40} />} title="Profil auswählen" description="Wählen Sie ein Suchprofil, um Details zu sehen." />
|
||||
</Box>
|
||||
)}
|
||||
</Box>
|
||||
</Box>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user