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>
327 lines
15 KiB
TypeScript
327 lines
15 KiB
TypeScript
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>
|
||
)
|
||
}
|