7a0909e36a
- 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>
130 lines
4.6 KiB
TypeScript
130 lines
4.6 KiB
TypeScript
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 goToResults(needId: string) {
|
|
navigate(ROUTES.DEMAND.RESULTS, { state: { fromNeedBuilder: true, activeNeedId: needId } })
|
|
handleClose()
|
|
}
|
|
|
|
return (
|
|
<>
|
|
<IconButton size="small" sx={{ color: '#64748b' }} onClick={handleOpen}>
|
|
<Badge badgeContent={badgeCount || null} color="error">
|
|
<Bell size={20} />
|
|
</Badge>
|
|
</IconButton>
|
|
|
|
<Popover
|
|
open={!!anchorEl}
|
|
anchorEl={anchorEl}
|
|
onClose={handleClose}
|
|
anchorOrigin={{ vertical: 'bottom', horizontal: 'right' }}
|
|
transformOrigin={{ vertical: 'top', horizontal: 'right' }}
|
|
slotProps={{ paper: { sx: { width: 300, p: 2 } } }}
|
|
>
|
|
<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>
|
|
</>
|
|
)
|
|
}
|