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,22 +1,74 @@
|
||||
import { useState } from 'react'
|
||||
import { Badge, IconButton, Popover, Typography } from '@mui/material'
|
||||
import { useMemo, useState } from 'react'
|
||||
import { Badge, Box, Divider, IconButton, Popover, Typography } from '@mui/material'
|
||||
import { Bell } from 'lucide-react'
|
||||
import { useNavigate } from 'react-router'
|
||||
import { useNeedProfiles } from '../../hooks/useNeeds'
|
||||
import { useMatches } from '../../hooks/useMatches'
|
||||
import { ROUTES } from '../../lib/constants'
|
||||
|
||||
function loadSeenCounts(): Record<string, number> {
|
||||
try { return JSON.parse(localStorage.getItem('notif-seen-counts') ?? '{}') }
|
||||
catch { return {} }
|
||||
}
|
||||
|
||||
function saveSeenCounts(counts: Record<string, number>) {
|
||||
localStorage.setItem('notif-seen-counts', JSON.stringify(counts))
|
||||
}
|
||||
|
||||
export function NotificationButton() {
|
||||
const [anchorEl, setAnchorEl] = useState<null | HTMLElement>(null)
|
||||
const [seenCounts, setSeenCounts] = useState<Record<string, number>>(loadSeenCounts)
|
||||
const navigate = useNavigate()
|
||||
|
||||
const { data: needs = [] } = useNeedProfiles()
|
||||
const { data: allMatches = [] } = useMatches()
|
||||
|
||||
const profilesWithMatches = useMemo(() => {
|
||||
const active = needs.filter(n =>
|
||||
(!n.status || n.status === 'ACTIVE' || n.status === 'DRAFT') &&
|
||||
(n.notificationConfig?.enabled !== false),
|
||||
)
|
||||
return active
|
||||
.map(n => {
|
||||
const minScore = n.notificationConfig?.minScore ?? 80
|
||||
return {
|
||||
need: n,
|
||||
count: allMatches.filter(m => m.needId === n.id && m.matchScore >= minScore).length,
|
||||
minScore,
|
||||
}
|
||||
})
|
||||
.filter(x => x.count > 0)
|
||||
}, [needs, allMatches])
|
||||
|
||||
// Badge only lights up for profiles with matches the user hasn't seen yet
|
||||
const newProfiles = useMemo(
|
||||
() => profilesWithMatches.filter(x => x.count > (seenCounts[x.need.id] ?? 0)),
|
||||
[profilesWithMatches, seenCounts],
|
||||
)
|
||||
const badgeCount = newProfiles.length
|
||||
|
||||
function handleOpen(e: React.MouseEvent<HTMLElement>) {
|
||||
setAnchorEl(e.currentTarget)
|
||||
// Mark all current matches as seen
|
||||
const updated = { ...seenCounts }
|
||||
for (const { need, count } of profilesWithMatches) {
|
||||
updated[need.id] = count
|
||||
}
|
||||
setSeenCounts(updated)
|
||||
saveSeenCounts(updated)
|
||||
}
|
||||
|
||||
function handleClose() {
|
||||
setAnchorEl(null)
|
||||
function handleClose() { setAnchorEl(null) }
|
||||
|
||||
function goToResults(needId: string) {
|
||||
navigate(ROUTES.DEMAND.RESULTS, { state: { fromNeedBuilder: true, activeNeedId: needId } })
|
||||
handleClose()
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<IconButton size="small" sx={{ color: '#64748b' }} onClick={handleOpen}>
|
||||
<Badge badgeContent={0} color="error">
|
||||
<Badge badgeContent={badgeCount || null} color="error">
|
||||
<Bell size={20} />
|
||||
</Badge>
|
||||
</IconButton>
|
||||
@@ -27,12 +79,50 @@ export function NotificationButton() {
|
||||
onClose={handleClose}
|
||||
anchorOrigin={{ vertical: 'bottom', horizontal: 'right' }}
|
||||
transformOrigin={{ vertical: 'top', horizontal: 'right' }}
|
||||
slotProps={{ paper: { sx: { width: 280, p: 2 } } }}
|
||||
slotProps={{ paper: { sx: { width: 300, p: 2 } } }}
|
||||
>
|
||||
<Typography variant="subtitle2" sx={{ mb: 1 }}>Benachrichtigungen</Typography>
|
||||
<Typography variant="caption" sx={{ color: 'text.secondary' }}>
|
||||
Keine neuen Benachrichtigungen
|
||||
<Typography variant="subtitle2" sx={{ mb: 1.25, fontWeight: 700, color: '#0f172a' }}>
|
||||
Neue Treffer
|
||||
</Typography>
|
||||
|
||||
{profilesWithMatches.length === 0 ? (
|
||||
<Typography variant="caption" sx={{ color: 'text.secondary' }}>
|
||||
Keine aktiven Benachrichtigungen
|
||||
</Typography>
|
||||
) : (
|
||||
<>
|
||||
{profilesWithMatches.map(({ need, count, minScore }) => (
|
||||
<Box
|
||||
key={need.id}
|
||||
onClick={() => goToResults(need.id)}
|
||||
sx={{
|
||||
display: 'flex', justifyContent: 'space-between', alignItems: 'center',
|
||||
px: 1, py: 0.875, borderRadius: 1, cursor: 'pointer',
|
||||
'&:hover': { bgcolor: '#f8fafc' }, transition: 'background 0.1s',
|
||||
}}
|
||||
>
|
||||
<Box sx={{ minWidth: 0 }}>
|
||||
<Typography variant="body2" sx={{ fontSize: '0.825rem', color: '#1e293b' }} noWrap>
|
||||
{need.companyName}
|
||||
</Typography>
|
||||
<Typography variant="caption" sx={{ color: '#94a3b8', fontSize: '0.7rem' }}>
|
||||
ab {minScore}%
|
||||
</Typography>
|
||||
</Box>
|
||||
<Typography variant="caption" sx={{ color: '#15803d', fontWeight: 700, flexShrink: 0, ml: 1.5, fontSize: '0.78rem' }}>
|
||||
{count} Treffer →
|
||||
</Typography>
|
||||
</Box>
|
||||
))}
|
||||
<Divider sx={{ my: 1.25 }} />
|
||||
<Box
|
||||
onClick={() => { navigate(ROUTES.DEMAND.AI_SEARCH); handleClose() }}
|
||||
sx={{ textAlign: 'center', cursor: 'pointer', color: '#152642', fontSize: '0.8rem', fontWeight: 600, py: 0.25, '&:hover': { color: '#16304d' } }}
|
||||
>
|
||||
Alle Suchprofile anzeigen →
|
||||
</Box>
|
||||
</>
|
||||
)}
|
||||
</Popover>
|
||||
</>
|
||||
)
|
||||
|
||||
Reference in New Issue
Block a user