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,160 @@
|
||||
import { memo } from 'react'
|
||||
import { Box, Button, Typography } from '@mui/material'
|
||||
import { Ruler } from 'lucide-react'
|
||||
import { useNavigate } from 'react-router'
|
||||
import { ROUTES } from '../../lib/constants'
|
||||
import type { Property } from '../../domain/property'
|
||||
import type { VerifiedPortfolioResult } from '../../domain/unifiedResult'
|
||||
import type { Match } from '../../domain/match'
|
||||
import { MatchStrength, ResultType } from '../../domain/enums'
|
||||
import { useInquiryStore } from '../../stores/inquiryStore'
|
||||
import { usePipelineStore } from '../../stores/pipelineStore'
|
||||
import { useCompareStore } from '../../stores/compareStore'
|
||||
|
||||
interface Props {
|
||||
property: Property
|
||||
score: number
|
||||
needId: string
|
||||
}
|
||||
|
||||
function buildSyntheticResult(property: Property, needId: string, score: number, matchId: string): VerifiedPortfolioResult {
|
||||
const strength = score >= 85 ? MatchStrength.STRONG : score >= 70 ? MatchStrength.MODERATE : MatchStrength.WEAK
|
||||
const syntheticMatch = {
|
||||
id: matchId,
|
||||
needId,
|
||||
propertyId: property.id,
|
||||
matchScore: score,
|
||||
matchStrength: strength,
|
||||
scoreBreakdown: { hardMatchScore: score, softFactorScore: score, confidenceModifier: 0, dataQualityModifier: 0, totalScore: score },
|
||||
confidenceLevel: score / 100,
|
||||
} as unknown as Match
|
||||
|
||||
return {
|
||||
matchId,
|
||||
needId,
|
||||
matchScore: score,
|
||||
resultType: ResultType.VERIFIED_PORTFOLIO,
|
||||
property,
|
||||
match: syntheticMatch,
|
||||
}
|
||||
}
|
||||
|
||||
export const PropertyMatchRow = memo(function PropertyMatchRow({ property, score, needId }: Props) {
|
||||
const navigate = useNavigate()
|
||||
const openInquiryDialog = useInquiryStore(s => s.openInquiryDialog)
|
||||
const openSavedDialog = usePipelineStore(s => s.openSavedDialog)
|
||||
const { addToCompare, removeFromCompare, isInCompare, isFull } = useCompareStore()
|
||||
|
||||
const scoreColor = score >= 85 ? '#16a34a' : score >= 70 ? '#d97706' : '#dc2626'
|
||||
const scoreBg = score >= 85 ? '#dcfce7' : score >= 70 ? '#fef3c7' : '#fee2e2'
|
||||
// Match IDs in the store use double-underscore format: m__propId__prop__needId
|
||||
const matchId = `m__${property.id}__prop__${needId}`
|
||||
const inCompare = isInCompare(matchId)
|
||||
|
||||
function handleInquire() {
|
||||
openInquiryDialog({
|
||||
propertyTitle: property.title,
|
||||
location: property.location.city,
|
||||
matchScore: score,
|
||||
matchId,
|
||||
propertyId: property.id,
|
||||
areaLabel: property.areaSqm ? `${property.areaSqm} m²` : undefined,
|
||||
rentLabel: property.rentPricePerSqm ? `CHF ${property.rentPricePerSqm}/m²/Jahr` : undefined,
|
||||
})
|
||||
}
|
||||
|
||||
function handleShortlist() {
|
||||
openSavedDialog({
|
||||
resultId: matchId,
|
||||
resultType: 'VERIFIED_PORTFOLIO',
|
||||
title: property.title,
|
||||
matchScore: score,
|
||||
location: property.location.city,
|
||||
propertyId: property.id,
|
||||
propertyAddress: `${property.title}, ${property.location.city}`,
|
||||
areaLabel: property.areaSqm ? `${property.areaSqm} m²` : undefined,
|
||||
rentLabel: property.rentPricePerSqm ? `CHF ${property.rentPricePerSqm}/m²/Jahr` : undefined,
|
||||
})
|
||||
}
|
||||
|
||||
function handleCompare() {
|
||||
if (inCompare) {
|
||||
removeFromCompare(matchId)
|
||||
} else if (!isFull()) {
|
||||
addToCompare(buildSyntheticResult(property, needId, score, matchId))
|
||||
}
|
||||
}
|
||||
|
||||
function handleDetails() {
|
||||
navigate(`/demand/results/${matchId}`)
|
||||
}
|
||||
|
||||
function handleUnit() {
|
||||
navigate(`/demand/property/${property.id}`)
|
||||
}
|
||||
|
||||
return (
|
||||
<Box sx={{ bgcolor: 'white', border: '1px solid #e2e8f0', borderRadius: 1.5, overflow: 'hidden' }}>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1.5, p: 1.25 }}>
|
||||
{property.images?.[0] ? (
|
||||
<Box component="img" src={property.images[0]} alt=""
|
||||
sx={{ width: 52, height: 52, borderRadius: 1, objectFit: 'cover', flexShrink: 0 }} />
|
||||
) : (
|
||||
<Box sx={{ width: 52, height: 52, borderRadius: 1, bgcolor: '#e0e7ff', flexShrink: 0, display: 'flex', alignItems: 'center', justifyContent: 'center' }}>
|
||||
<Ruler size={18} color="#3730a3" />
|
||||
</Box>
|
||||
)}
|
||||
<Box sx={{ flex: 1, minWidth: 0 }}>
|
||||
<Typography variant="body2" sx={{ fontWeight: 600, fontSize: '0.825rem', color: '#0f172a' }} noWrap>
|
||||
{property.title}
|
||||
</Typography>
|
||||
<Typography variant="caption" sx={{ color: '#64748b', fontSize: '0.75rem' }}>
|
||||
{property.location.city} · {property.areaSqm} m²
|
||||
</Typography>
|
||||
</Box>
|
||||
<Box sx={{ bgcolor: scoreBg, color: scoreColor, px: 1, py: 0.375, borderRadius: 1, fontWeight: 700, fontSize: '0.8rem', flexShrink: 0 }}>
|
||||
{score}%
|
||||
</Box>
|
||||
</Box>
|
||||
|
||||
<Box sx={{ display: 'flex', gap: 0.75, px: 1.25, pb: 1.25, flexWrap: 'wrap' }}>
|
||||
<ActionButton onClick={handleInquire} primary>Anfrage</ActionButton>
|
||||
<ActionButton onClick={handleShortlist}>Merken</ActionButton>
|
||||
<ActionButton onClick={handleCompare} disabled={!inCompare && isFull()}>
|
||||
{inCompare ? 'Im Vergleich' : 'Vergleichen'}
|
||||
</ActionButton>
|
||||
<ActionButton onClick={handleDetails}>Details →</ActionButton>
|
||||
<ActionButton onClick={handleUnit}>Zur Einheit →</ActionButton>
|
||||
</Box>
|
||||
</Box>
|
||||
)
|
||||
})
|
||||
|
||||
function ActionButton({ children, onClick, primary, disabled }: {
|
||||
children: React.ReactNode
|
||||
onClick: () => void
|
||||
primary?: boolean
|
||||
disabled?: boolean
|
||||
}) {
|
||||
return (
|
||||
<Button
|
||||
size="small"
|
||||
variant={primary ? 'contained' : 'outlined'}
|
||||
onClick={onClick}
|
||||
disabled={disabled}
|
||||
sx={{
|
||||
textTransform: 'none',
|
||||
fontWeight: 600,
|
||||
fontSize: '0.75rem',
|
||||
py: 0.25,
|
||||
px: 1,
|
||||
minWidth: 0,
|
||||
...(primary
|
||||
? { bgcolor: '#152642', '&:hover': { bgcolor: '#16304d' } }
|
||||
: { borderColor: '#cbd5e1', color: '#475569', '&:hover': { borderColor: '#152642', color: '#152642' } }),
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
</Button>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,78 @@
|
||||
import { memo } from 'react'
|
||||
import { Box, Chip, Paper, Typography } from '@mui/material'
|
||||
import { MapPin } from 'lucide-react'
|
||||
import type { Need } from '../../domain/need'
|
||||
import { ASSET_TYPE_LABELS } from '../../lib/constants'
|
||||
|
||||
interface Props {
|
||||
need: Need
|
||||
selected: boolean
|
||||
matchCount: number
|
||||
topScore: number
|
||||
onClick: () => void
|
||||
}
|
||||
|
||||
function statusConfig(status: Need['status']): { label: string; bg: string; fg: string } {
|
||||
if (status === 'DRAFT') return { label: 'Entwurf', bg: '#fef3c7', fg: '#92400e' }
|
||||
return { label: 'Aktiv', bg: '#dcfce7', fg: '#166534' }
|
||||
}
|
||||
|
||||
export const SavedNeedCard = memo(function SavedNeedCard({ need, selected, matchCount, topScore, onClick }: Props) {
|
||||
const statusCfg = statusConfig(need.status)
|
||||
const locationText = need.preferredLocations.slice(0, 2).join(', ')
|
||||
const scoreColor = topScore >= 80 ? '#16a34a' : topScore >= 65 ? '#d97706' : '#dc2626'
|
||||
|
||||
return (
|
||||
<Paper
|
||||
onClick={onClick}
|
||||
elevation={0}
|
||||
sx={{
|
||||
p: 1.5,
|
||||
borderRadius: 1.5,
|
||||
border: '1px solid',
|
||||
borderColor: selected ? '#152642' : '#e2e8f0',
|
||||
bgcolor: selected ? '#f1f5f9' : 'white',
|
||||
cursor: 'pointer',
|
||||
transition: 'all 0.15s',
|
||||
'&:hover': { borderColor: selected ? '#152642' : '#94a3b8', boxShadow: '0 2px 6px rgba(15,23,42,0.06)' },
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
gap: 0.75,
|
||||
}}
|
||||
>
|
||||
<Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'flex-start', gap: 1 }}>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.75, flexWrap: 'wrap' }}>
|
||||
<Chip label={ASSET_TYPE_LABELS[need.assetType] ?? need.assetType} size="small"
|
||||
sx={{ bgcolor: '#e0e7ff', color: '#3730a3', fontWeight: 600, fontSize: '0.65rem', height: 20 }} />
|
||||
<Chip label={statusCfg.label} size="small"
|
||||
sx={{ bgcolor: statusCfg.bg, color: statusCfg.fg, fontWeight: 600, fontSize: '0.65rem', height: 20 }} />
|
||||
</Box>
|
||||
{matchCount > 0 && (
|
||||
<Box sx={{ bgcolor: '#dcfce7', color: '#15803d', borderRadius: 1, px: 0.75, py: 0.125, fontSize: '0.68rem', fontWeight: 700, flexShrink: 0 }}>
|
||||
{matchCount} Treffer
|
||||
</Box>
|
||||
)}
|
||||
</Box>
|
||||
|
||||
<Typography variant="body2" sx={{ fontWeight: 600, fontSize: '0.85rem', color: '#0f172a', lineHeight: 1.3 }}>
|
||||
{need.companyName}
|
||||
</Typography>
|
||||
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.5, color: '#64748b' }}>
|
||||
<MapPin size={11} />
|
||||
<Typography variant="caption" sx={{ fontSize: '0.75rem' }}>{locationText || '—'}</Typography>
|
||||
</Box>
|
||||
|
||||
{topScore > 0 && (
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
|
||||
<Box sx={{ flex: 1, height: 4, borderRadius: 2, bgcolor: '#f1f5f9', overflow: 'hidden' }}>
|
||||
<Box sx={{ width: `${topScore}%`, height: '100%', bgcolor: scoreColor }} />
|
||||
</Box>
|
||||
<Typography variant="caption" sx={{ fontSize: '0.72rem', fontWeight: 700, color: scoreColor, flexShrink: 0 }}>
|
||||
{topScore}%
|
||||
</Typography>
|
||||
</Box>
|
||||
)}
|
||||
</Paper>
|
||||
)
|
||||
})
|
||||
@@ -0,0 +1,326 @@
|
||||
import { memo, useMemo, useState } from 'react'
|
||||
import {
|
||||
Accordion, AccordionDetails, AccordionSummary,
|
||||
Box, Button, Chip, Divider, LinearProgress, Slider, Switch, TextField, Typography,
|
||||
} from '@mui/material'
|
||||
import { Bell, Calendar, CheckCircle2, ChevronDown, MapPin, Ruler, Target, Wallet } from 'lucide-react'
|
||||
import type { Need, NotificationConfig, UpdateNeedInput } from '../../domain/need'
|
||||
import type { Property } from '../../domain/property'
|
||||
import { ASSET_TYPE_LABELS } from '../../lib/constants'
|
||||
import { confidenceHex } from '../../lib/utils'
|
||||
import { useMatchesByNeed } from '../../hooks/useMatches'
|
||||
import { PropertyMatchRow } from './PropertyMatchRow'
|
||||
|
||||
const WEIGHT_LABELS: Record<string, string> = {
|
||||
area: 'Fläche', location: 'Standort', budget: 'Budget', timing: 'Timing',
|
||||
prestige: 'Prestige', accessibility: 'Erreichbarkeit', expansionPotential: 'Expansionspotenzial',
|
||||
flexibility: 'Flexibilität', visibility: 'Sichtbarkeit', footfall: 'Publikumsverkehr',
|
||||
talentAccess: 'Talentzugang', esg: 'ESG', taxEnvironment: 'Steuerumgebung',
|
||||
}
|
||||
|
||||
function statusConfig(status: Need['status']): { label: string; bg: string; fg: string } {
|
||||
if (status === 'DRAFT') return { label: 'Entwurf', bg: '#fef3c7', fg: '#92400e' }
|
||||
return { label: 'Aktiv', bg: '#dcfce7', fg: '#166534' }
|
||||
}
|
||||
|
||||
interface Props {
|
||||
need: Need
|
||||
properties: Property[]
|
||||
onStart: () => void
|
||||
onEdit: () => void
|
||||
onArchive: () => void
|
||||
onUpdate: (data: UpdateNeedInput) => void
|
||||
}
|
||||
|
||||
export const SavedNeedDetail = memo(function SavedNeedDetail({ need, properties, onStart, onEdit, onArchive, onUpdate }: Props) {
|
||||
const statusCfg = statusConfig(need.status)
|
||||
const conf = need.confidenceInCriteria ?? 0
|
||||
const confColor = confidenceHex(conf)
|
||||
|
||||
const [notif, setNotif] = useState<NotificationConfig>(() => need.notificationConfig ?? { enabled: true, minScore: 80 })
|
||||
const [notifDirty, setNotifDirty] = useState(false)
|
||||
|
||||
function handleNotifChange(patch: Partial<NotificationConfig>) {
|
||||
setNotif(prev => ({ ...prev, ...patch }))
|
||||
setNotifDirty(true)
|
||||
}
|
||||
function handleNotifSave() {
|
||||
onUpdate({ notificationConfig: notif })
|
||||
setNotifDirty(false)
|
||||
}
|
||||
|
||||
const { data: matches = [] } = useMatchesByNeed(need.id)
|
||||
|
||||
const notifThreshold = need.notificationConfig?.minScore ?? 80
|
||||
const matchCount = matches.filter(m => m.matchScore >= notifThreshold).length
|
||||
|
||||
const scoredProperties = useMemo(() => {
|
||||
const seen = new Set<string>()
|
||||
return matches
|
||||
.map(m => {
|
||||
const property = properties.find(p => p.id === m.propertyId)
|
||||
return property ? { property, score: Math.round(m.matchScore) } : null
|
||||
})
|
||||
.filter((x): x is { property: Property; score: number } => x !== null)
|
||||
.sort((a, b) => b.score - a.score)
|
||||
.filter(x => {
|
||||
if (seen.has(x.property.id)) return false
|
||||
seen.add(x.property.id)
|
||||
return true
|
||||
})
|
||||
}, [matches, properties])
|
||||
const topScore = scoredProperties.length > 0 ? scoredProperties[0].score : 0
|
||||
const topProperties = scoredProperties.slice(0, 3)
|
||||
|
||||
const topWeights = useMemo(
|
||||
() => Object.entries(need.weightingProfile).filter(([, v]) => v > 0.03).sort(([, a], [, b]) => b - a).slice(0, 6),
|
||||
[need.weightingProfile],
|
||||
)
|
||||
|
||||
return (
|
||||
<Box sx={{ flex: 1, display: 'flex', flexDirection: 'column', overflow: 'hidden', bgcolor: '#f8fafc' }}>
|
||||
<Box sx={{ flex: 1, overflowY: 'auto', p: 3, display: 'flex', flexDirection: 'column', gap: 2.5 }}>
|
||||
|
||||
<Box>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, mb: 0.75 }}>
|
||||
<Chip label={ASSET_TYPE_LABELS[need.assetType] ?? need.assetType} size="small"
|
||||
sx={{ bgcolor: '#e0e7ff', color: '#3730a3', fontWeight: 600, fontSize: '0.7rem', height: 22 }} />
|
||||
<Chip label={statusCfg.label} size="small"
|
||||
sx={{ bgcolor: statusCfg.bg, color: statusCfg.fg, fontWeight: 600, fontSize: '0.7rem', height: 22 }} />
|
||||
</Box>
|
||||
<Typography variant="h5" sx={{ fontWeight: 700, color: '#0f172a', fontSize: '1.25rem' }}>
|
||||
{need.companyName}
|
||||
</Typography>
|
||||
{need.contactName && (
|
||||
<Typography variant="body2" sx={{ color: '#64748b', mt: 0.25 }}>{need.contactName}</Typography>
|
||||
)}
|
||||
</Box>
|
||||
|
||||
<Box>
|
||||
<SectionLabel>Suchkriterien</SectionLabel>
|
||||
<Box sx={{ mt: 1, display: 'grid', gridTemplateColumns: 'repeat(auto-fit, minmax(200px, 1fr))', gap: 1.25 }}>
|
||||
<CriteriaBox icon={<MapPin size={13} />} label="Standort" value={need.preferredLocations.join(', ') || '—'} />
|
||||
<CriteriaBox icon={<Ruler size={13} />} label="Fläche" value={`${need.requiredArea.min}–${need.requiredArea.max} m²`} />
|
||||
<CriteriaBox icon={<Wallet size={13} />} label="Budget" value={`CHF ${need.budgetRange.maxPerSqm} /m²`} />
|
||||
<CriteriaBox icon={<Calendar size={13} />} label="Einzug ab" value={need.timing.earliestMoveIn || '—'} />
|
||||
</Box>
|
||||
</Box>
|
||||
|
||||
<Box>
|
||||
<Box sx={{ display: 'flex', justifyContent: 'space-between', mb: 0.5 }}>
|
||||
<SectionLabel>Konfidenz der Kriterien</SectionLabel>
|
||||
<Typography variant="caption" sx={{ fontWeight: 700, color: confColor, fontSize: '0.8rem' }}>
|
||||
{Math.round(conf * 100)}%
|
||||
</Typography>
|
||||
</Box>
|
||||
<LinearProgress variant="determinate" value={conf * 100} sx={{
|
||||
height: 6, borderRadius: 3, bgcolor: '#f1f5f9',
|
||||
'& .MuiLinearProgress-bar': { bgcolor: confColor, borderRadius: 3 },
|
||||
}} />
|
||||
</Box>
|
||||
|
||||
{(need.mustCriteriaText?.length ?? 0) > 0 && (
|
||||
<Box>
|
||||
<SectionLabel>Must-have Kriterien</SectionLabel>
|
||||
<Box sx={{ mt: 1, display: 'flex', flexDirection: 'column', gap: 0.5 }}>
|
||||
{need.mustCriteriaText!.map((c, i) => (
|
||||
<Box key={i} sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
|
||||
<CheckCircle2 size={14} color="#16a34a" />
|
||||
<Typography variant="body2" sx={{ fontSize: '0.875rem', color: '#1e293b' }}>{c}</Typography>
|
||||
</Box>
|
||||
))}
|
||||
</Box>
|
||||
</Box>
|
||||
)}
|
||||
|
||||
{topWeights.length > 0 && (
|
||||
<Accordion elevation={0} disableGutters sx={{
|
||||
border: '1px solid #e2e8f0', borderRadius: '8px !important', overflow: 'hidden',
|
||||
'&:before': { display: 'none' }, bgcolor: 'white',
|
||||
}}>
|
||||
<AccordionSummary expandIcon={<ChevronDown size={16} color="#64748b" />}
|
||||
sx={{ px: 2, minHeight: 44, '& .MuiAccordionSummary-content': { my: 0 } }}>
|
||||
<Typography variant="caption" sx={{ color: '#64748b', fontWeight: 700, textTransform: 'uppercase', letterSpacing: 0.5 }}>
|
||||
Gewichtete Präferenzen
|
||||
</Typography>
|
||||
</AccordionSummary>
|
||||
<AccordionDetails sx={{ px: 2, pt: 0, pb: 2 }}>
|
||||
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 1 }}>
|
||||
{topWeights.map(([key, val]) => {
|
||||
const pct = Math.round(val * 100)
|
||||
return (
|
||||
<Box key={key} sx={{ bgcolor: '#f8fafc', border: '1px solid #e2e8f0', borderRadius: 1.5, px: 1.5, py: 1 }}>
|
||||
<Box sx={{ display: 'flex', justifyContent: 'space-between', mb: 0.5 }}>
|
||||
<Typography variant="body2" sx={{ fontWeight: 600, fontSize: '0.85rem' }}>
|
||||
{WEIGHT_LABELS[key] ?? key}
|
||||
</Typography>
|
||||
<Typography variant="caption" sx={{ fontWeight: 700, color: '#152642', fontSize: '0.75rem', bgcolor: '#e0e7ff', px: 1, borderRadius: 1 }}>
|
||||
{pct}%
|
||||
</Typography>
|
||||
</Box>
|
||||
<Box sx={{ height: 5, borderRadius: 3, bgcolor: '#e8e7e4', overflow: 'hidden' }}>
|
||||
<Box sx={{ width: `${pct}%`, height: '100%', bgcolor: '#152642', transition: 'width 0.3s' }} />
|
||||
</Box>
|
||||
</Box>
|
||||
)
|
||||
})}
|
||||
</Box>
|
||||
</AccordionDetails>
|
||||
</Accordion>
|
||||
)}
|
||||
|
||||
<Accordion elevation={0} disableGutters sx={{
|
||||
border: '1px solid #e2e8f0', borderRadius: '8px !important', overflow: 'hidden',
|
||||
'&:before': { display: 'none' }, bgcolor: 'white',
|
||||
}}>
|
||||
<AccordionSummary expandIcon={<ChevronDown size={16} color="#64748b" />}
|
||||
sx={{ px: 2, minHeight: 44, '& .MuiAccordionSummary-content': { my: 0 } }}>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
|
||||
<Bell size={13} color={notif.enabled ? '#152642' : '#94a3b8'} />
|
||||
<Typography variant="caption" sx={{ color: '#64748b', fontWeight: 700, textTransform: 'uppercase', letterSpacing: 0.5 }}>
|
||||
Benachrichtigungen
|
||||
</Typography>
|
||||
{notif.enabled && (
|
||||
<Box sx={{ bgcolor: '#e0e7ff', color: '#3730a3', fontSize: '0.65rem', fontWeight: 700, px: 0.75, borderRadius: 0.75 }}>
|
||||
ab {notif.minScore}%
|
||||
</Box>
|
||||
)}
|
||||
</Box>
|
||||
</AccordionSummary>
|
||||
<AccordionDetails sx={{ px: 2, pt: 0, pb: 2 }}>
|
||||
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 1.75 }}>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}>
|
||||
<Box>
|
||||
<Typography variant="body2" sx={{ fontWeight: 600, fontSize: '0.85rem' }}>Benachrichtigung aktiv</Typography>
|
||||
<Typography variant="caption" sx={{ color: '#64748b', fontSize: '0.75rem' }}>
|
||||
Neue Treffer oberhalb der Schwelle melden
|
||||
</Typography>
|
||||
</Box>
|
||||
<Switch
|
||||
size="small"
|
||||
checked={notif.enabled}
|
||||
onChange={e => handleNotifChange({ enabled: e.target.checked })}
|
||||
sx={{ '& .MuiSwitch-thumb': { bgcolor: notif.enabled ? '#152642' : undefined } }}
|
||||
/>
|
||||
</Box>
|
||||
|
||||
{notif.enabled && (
|
||||
<>
|
||||
<Box>
|
||||
<Box sx={{ display: 'flex', justifyContent: 'space-between', mb: 0.5 }}>
|
||||
<Typography variant="body2" sx={{ fontWeight: 600, fontSize: '0.85rem' }}>Match-Schwelle</Typography>
|
||||
<Typography variant="caption" sx={{ fontWeight: 700, color: '#152642', fontSize: '0.8rem', bgcolor: '#e0e7ff', px: 0.75, borderRadius: 0.75 }}>
|
||||
{notif.minScore}%
|
||||
</Typography>
|
||||
</Box>
|
||||
<Slider
|
||||
value={notif.minScore}
|
||||
min={60} max={95} step={5}
|
||||
onChange={(_, v) => handleNotifChange({ minScore: v as number })}
|
||||
sx={{ color: '#152642', '& .MuiSlider-thumb': { width: 14, height: 14 } }}
|
||||
/>
|
||||
<Box sx={{ display: 'flex', justifyContent: 'space-between' }}>
|
||||
<Typography variant="caption" sx={{ color: '#94a3b8', fontSize: '0.7rem' }}>60%</Typography>
|
||||
<Typography variant="caption" sx={{ color: '#94a3b8', fontSize: '0.7rem' }}>95%</Typography>
|
||||
</Box>
|
||||
</Box>
|
||||
|
||||
<Box>
|
||||
<Typography variant="body2" sx={{ fontWeight: 600, fontSize: '0.85rem', mb: 0.5 }}>
|
||||
E-Mail (optional)
|
||||
</Typography>
|
||||
<TextField
|
||||
size="small"
|
||||
fullWidth
|
||||
placeholder="name@firma.ch"
|
||||
value={notif.emailAddress ?? ''}
|
||||
onChange={e => handleNotifChange({ emailAddress: e.target.value || undefined })}
|
||||
inputProps={{ type: 'email' }}
|
||||
sx={{ '& .MuiOutlinedInput-root': { fontSize: '0.85rem' } }}
|
||||
helperText="Im Produktivsystem wird bei neuen Treffern eine E-Mail ausgelöst"
|
||||
/>
|
||||
</Box>
|
||||
</>
|
||||
)}
|
||||
|
||||
{notifDirty && (
|
||||
<Button
|
||||
variant="contained"
|
||||
size="small"
|
||||
onClick={handleNotifSave}
|
||||
sx={{ textTransform: 'none', fontWeight: 600, bgcolor: '#152642', '&:hover': { bgcolor: '#16304d' }, alignSelf: 'flex-end' }}
|
||||
>
|
||||
Speichern
|
||||
</Button>
|
||||
)}
|
||||
</Box>
|
||||
</AccordionDetails>
|
||||
</Accordion>
|
||||
|
||||
{scoredProperties.length > 0 && (
|
||||
<>
|
||||
<Divider />
|
||||
<Box>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, mb: 1.25 }}>
|
||||
<Target size={14} color="#15803d" />
|
||||
<Typography variant="caption" sx={{ color: '#64748b', fontWeight: 700, textTransform: 'uppercase', letterSpacing: 0.5 }}>
|
||||
Passende Objekte
|
||||
</Typography>
|
||||
<Box sx={{ ml: 'auto', bgcolor: '#dcfce7', color: '#15803d', borderRadius: 1, px: 1, py: 0.125, fontWeight: 700, fontSize: '0.72rem' }}>
|
||||
{matchCount} Treffer
|
||||
</Box>
|
||||
</Box>
|
||||
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 1 }}>
|
||||
{topProperties.map(({ property, score }) => (
|
||||
<PropertyMatchRow key={property.id} property={property} score={score} needId={need.id} />
|
||||
))}
|
||||
</Box>
|
||||
{matchCount > 3 && (
|
||||
<Box
|
||||
onClick={onStart}
|
||||
sx={{ mt: 1.25, textAlign: 'center', cursor: 'pointer', color: '#152642', fontSize: '0.8rem', fontWeight: 600,
|
||||
py: 1, border: '1px dashed #cbd5e1', borderRadius: 1.5, '&:hover': { bgcolor: '#f1f5f9', borderColor: '#152642' }, transition: 'all 0.15s' }}
|
||||
>
|
||||
Alle {matchCount} Ergebnisse anzeigen →
|
||||
</Box>
|
||||
)}
|
||||
</Box>
|
||||
</>
|
||||
)}
|
||||
</Box>
|
||||
|
||||
<Box sx={{ flexShrink: 0, borderTop: '1px solid #e2e8f0', px: 3, py: 2, bgcolor: 'white', display: 'flex', gap: 1.5 }}>
|
||||
<Button variant="outlined" size="large" onClick={onEdit}
|
||||
sx={{ flex: 1, textTransform: 'none', fontWeight: 600, borderColor: '#cbd5e1', color: '#475569' }}>
|
||||
Bearbeiten
|
||||
</Button>
|
||||
<Button variant="contained" size="large" onClick={onStart}
|
||||
sx={{ flex: 2, textTransform: 'none', fontWeight: 600, bgcolor: '#152642', '&:hover': { bgcolor: '#16304d' } }}>
|
||||
Suche starten →
|
||||
</Button>
|
||||
</Box>
|
||||
</Box>
|
||||
)
|
||||
})
|
||||
|
||||
function SectionLabel({ children }: { children: React.ReactNode }) {
|
||||
return (
|
||||
<Typography variant="caption" sx={{ color: '#64748b', fontWeight: 700, textTransform: 'uppercase', letterSpacing: 0.5 }}>
|
||||
{children}
|
||||
</Typography>
|
||||
)
|
||||
}
|
||||
|
||||
function CriteriaBox({ icon, label, value }: { icon: React.ReactNode; label: string; value: string }) {
|
||||
return (
|
||||
<Box sx={{ bgcolor: 'white', border: '1px solid #e2e8f0', borderRadius: 1.5, px: 1.5, py: 1 }}>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.75, color: '#64748b', mb: 0.25 }}>
|
||||
{icon}
|
||||
<Typography variant="caption" sx={{ fontSize: '0.7rem', fontWeight: 600, textTransform: 'uppercase', letterSpacing: 0.5 }}>
|
||||
{label}
|
||||
</Typography>
|
||||
</Box>
|
||||
<Typography variant="body2" sx={{ fontSize: '0.85rem', color: '#0f172a', fontWeight: 500 }}>{value}</Typography>
|
||||
</Box>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -9,3 +9,7 @@ export { NeedCardPreview } from './NeedCardPreview'
|
||||
export { NeedInput } from './NeedInput'
|
||||
export { VoiceNeedInput } from './VoiceNeedInput'
|
||||
export { WeightingEditor } from './WeightingEditor'
|
||||
export { PropertyMatchRow } from './PropertyMatchRow'
|
||||
export { SavedNeedCard } from './SavedNeedCard'
|
||||
export { SavedNeedDetail } from './SavedNeedDetail'
|
||||
export { SavedProfilesTab } from './SavedProfilesTab'
|
||||
|
||||
@@ -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