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:
Benjamin Sutter
2026-06-19 20:53:49 +02:00
parent 6aa4f96bd2
commit 7a0909e36a
10 changed files with 1131 additions and 110 deletions
+160
View File
@@ -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}` : 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}` : 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>
)
}
+78
View File
@@ -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>
)
})
+326
View File
@@ -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}`} />
<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>
)
}
+167
View File
@@ -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>
)
}
+4
View File
@@ -9,3 +9,7 @@ export { NeedCardPreview } from './NeedCardPreview'
export { NeedInput } from './NeedInput' export { NeedInput } from './NeedInput'
export { VoiceNeedInput } from './VoiceNeedInput' export { VoiceNeedInput } from './VoiceNeedInput'
export { WeightingEditor } from './WeightingEditor' export { WeightingEditor } from './WeightingEditor'
export { PropertyMatchRow } from './PropertyMatchRow'
export { SavedNeedCard } from './SavedNeedCard'
export { SavedNeedDetail } from './SavedNeedDetail'
export { SavedProfilesTab } from './SavedProfilesTab'
+99 -9
View File
@@ -1,22 +1,74 @@
import { useState } from 'react' import { useMemo, useState } from 'react'
import { Badge, IconButton, Popover, Typography } from '@mui/material' import { Badge, Box, Divider, IconButton, Popover, Typography } from '@mui/material'
import { Bell } from 'lucide-react' 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() { export function NotificationButton() {
const [anchorEl, setAnchorEl] = useState<null | HTMLElement>(null) 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>) { function handleOpen(e: React.MouseEvent<HTMLElement>) {
setAnchorEl(e.currentTarget) 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() { function handleClose() { setAnchorEl(null) }
setAnchorEl(null)
function goToResults(needId: string) {
navigate(ROUTES.DEMAND.RESULTS, { state: { fromNeedBuilder: true, activeNeedId: needId } })
handleClose()
} }
return ( return (
<> <>
<IconButton size="small" sx={{ color: '#64748b' }} onClick={handleOpen}> <IconButton size="small" sx={{ color: '#64748b' }} onClick={handleOpen}>
<Badge badgeContent={0} color="error"> <Badge badgeContent={badgeCount || null} color="error">
<Bell size={20} /> <Bell size={20} />
</Badge> </Badge>
</IconButton> </IconButton>
@@ -27,12 +79,50 @@ export function NotificationButton() {
onClose={handleClose} onClose={handleClose}
anchorOrigin={{ vertical: 'bottom', horizontal: 'right' }} anchorOrigin={{ vertical: 'bottom', horizontal: 'right' }}
transformOrigin={{ vertical: 'top', 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="subtitle2" sx={{ mb: 1.25, fontWeight: 700, color: '#0f172a' }}>
<Typography variant="caption" sx={{ color: 'text.secondary' }}> Neue Treffer
Keine neuen Benachrichtigungen
</Typography> </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> </Popover>
</> </>
) )
+7
View File
@@ -63,6 +63,12 @@ export interface WeightedPreference {
description?: string description?: string
} }
export interface NotificationConfig {
enabled: boolean
minScore: number // 0100, default 80
emailAddress?: string
}
export interface Need { export interface Need {
id: string id: string
companyName: string companyName: string
@@ -103,6 +109,7 @@ export interface Need {
confidenceInCriteria: number confidenceInCriteria: number
extractedFromText?: string extractedFromText?: string
notes?: string notes?: string
notificationConfig?: NotificationConfig
organizationId?: string organizationId?: string
createdAt: string createdAt: string
updatedAt: string updatedAt: string
+25 -3
View File
@@ -1,6 +1,7 @@
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query' import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
import { needService } from '../services/needService' import { needService } from '../services/needService'
import type { CreateNeedInput } from '../domain/need' import { useSessionStore } from '../stores/sessionStore'
import type { CreateNeedInput, UpdateNeedInput } from '../domain/need'
interface UseNeedsOptions { interface UseNeedsOptions {
refetchOnMount?: boolean | 'always' refetchOnMount?: boolean | 'always'
@@ -8,9 +9,10 @@ interface UseNeedsOptions {
} }
export function useNeeds(options?: UseNeedsOptions) { export function useNeeds(options?: UseNeedsOptions) {
const orgId = useSessionStore(s => s.currentUser?.organizationId)
return useQuery({ return useQuery({
queryKey: ['needs'], queryKey: ['needs', orgId],
queryFn: () => needService.getAll(), queryFn: () => needService.getAll({ organizationId: orgId }),
select: (res) => res.data ?? [], select: (res) => res.data ?? [],
...options, ...options,
}) })
@@ -36,3 +38,23 @@ export function useCreateNeed() {
}, },
}) })
} }
export function useUpdateNeed() {
const queryClient = useQueryClient()
return useMutation({
mutationFn: ({ id, ...data }: { id: string } & UpdateNeedInput) => needService.update(id, data),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['needs'] })
},
})
}
export function useArchiveNeed() {
const queryClient = useQueryClient()
return useMutation({
mutationFn: (id: string) => needService.update(id, { status: 'INACTIVE' }),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['needs'] })
},
})
}
+170 -36
View File
@@ -2,10 +2,10 @@ import { AssetType } from '../domain/enums'
import type { Need } from '../domain/need' import type { Need } from '../domain/need'
export const mockNeeds: Need[] = [ export const mockNeeds: Need[] = [
// --- need-001: Innovatech AG — OFFICE Zürich --- // --- need-001: OFFICE Zürich-West ---
{ {
id: 'need-001', id: 'need-001',
companyName: 'Innovatech AG', companyName: 'Bürofläche Zürich-West',
contactName: 'Sandra Meier', contactName: 'Sandra Meier',
assetType: AssetType.OFFICE, assetType: AssetType.OFFICE,
requiredArea: { min: 600, max: 1000 }, requiredArea: { min: 600, max: 1000 },
@@ -38,10 +38,10 @@ export const mockNeeds: Need[] = [
updatedAt: '2025-05-01T09:00:00Z', updatedAt: '2025-05-01T09:00:00Z',
}, },
// --- need-002: Schweizer Logistik GmbH — LOGISTICS Basel --- // --- need-002: LOGISTICS Basel ---
{ {
id: 'need-002', id: 'need-002',
companyName: 'Schweizer Logistik GmbH', companyName: 'Logistikfläche Basel',
contactName: 'Thomas Brun', contactName: 'Thomas Brun',
assetType: AssetType.LOGISTICS, assetType: AssetType.LOGISTICS,
requiredArea: { min: 1500, max: 4000 }, requiredArea: { min: 1500, max: 4000 },
@@ -72,10 +72,10 @@ export const mockNeeds: Need[] = [
updatedAt: '2025-04-10T11:00:00Z', updatedAt: '2025-04-10T11:00:00Z',
}, },
// --- need-003: Pharma Holding AG — OFFICE Basel --- // --- need-003: OFFICE Basel ---
{ {
id: 'need-003', id: 'need-003',
companyName: 'Pharma Holding AG', companyName: 'Bürofläche Basel Repräsentanz',
contactName: 'Ursula Schmid', contactName: 'Ursula Schmid',
assetType: AssetType.OFFICE, assetType: AssetType.OFFICE,
requiredArea: { min: 500, max: 800 }, requiredArea: { min: 500, max: 800 },
@@ -110,10 +110,10 @@ export const mockNeeds: Need[] = [
updatedAt: '2025-05-05T14:00:00Z', updatedAt: '2025-05-05T14:00:00Z',
}, },
// --- need-004: Retailer Zürich AG — RETAIL Zürich --- // --- need-004: RETAIL Zürich ---
{ {
id: 'need-004', id: 'need-004',
companyName: 'Retailer Zürich AG', companyName: 'Ladenlokal Zürich Innenstadt',
contactName: 'Marco Colombo', contactName: 'Marco Colombo',
assetType: AssetType.RETAIL, assetType: AssetType.RETAIL,
requiredArea: { min: 200, max: 500 }, requiredArea: { min: 200, max: 500 },
@@ -144,10 +144,10 @@ export const mockNeeds: Need[] = [
updatedAt: '2025-04-22T08:00:00Z', updatedAt: '2025-04-22T08:00:00Z',
}, },
// --- need-005: TechStart GmbH — OFFICE Zug/Zürich --- // --- need-005: OFFICE Zug/Zürich ---
{ {
id: 'need-005', id: 'need-005',
companyName: 'TechStart GmbH', companyName: 'Bürofläche Zug / Zürich',
contactName: 'Florian Keller', contactName: 'Florian Keller',
assetType: AssetType.OFFICE, assetType: AssetType.OFFICE,
requiredArea: { min: 300, max: 700 }, requiredArea: { min: 300, max: 700 },
@@ -179,10 +179,10 @@ export const mockNeeds: Need[] = [
updatedAt: '2025-05-08T10:00:00Z', updatedAt: '2025-05-08T10:00:00Z',
}, },
// --- need-006: Lager & Spedition AG — LOGISTICS Winterthur --- // --- need-006: LOGISTICS Winterthur ---
{ {
id: 'need-006', id: 'need-006',
companyName: 'Lager & Spedition AG', companyName: 'Lagerfläche Winterthur',
contactName: 'Beat Zimmermann', contactName: 'Beat Zimmermann',
assetType: AssetType.LOGISTICS, assetType: AssetType.LOGISTICS,
requiredArea: { min: 1200, max: 3000 }, requiredArea: { min: 1200, max: 3000 },
@@ -209,10 +209,10 @@ export const mockNeeds: Need[] = [
updatedAt: '2025-05-09T16:00:00Z', updatedAt: '2025-05-09T16:00:00Z',
}, },
// --- need-007: Creative Studios AG — MIXED Zürich/Bern --- // --- need-007: MIXED Zürich-West ---
{ {
id: 'need-007', id: 'need-007',
companyName: 'Creative Studios AG', companyName: 'Gewerbefläche Zürich-West',
contactName: 'Nora Hauser', contactName: 'Nora Hauser',
assetType: AssetType.MIXED, assetType: AssetType.MIXED,
requiredArea: { min: 800, max: 1500 }, requiredArea: { min: 800, max: 1500 },
@@ -244,10 +244,10 @@ export const mockNeeds: Need[] = [
updatedAt: '2025-05-03T11:00:00Z', updatedAt: '2025-05-03T11:00:00Z',
}, },
// --- need-008: Berner Produzenten GmbH — PRODUCTION Bern --- // --- need-008: PRODUCTION Bern ---
{ {
id: 'need-008', id: 'need-008',
companyName: 'Berner Produzenten GmbH', companyName: 'Produktionshalle Bern',
contactName: 'Hans Lüthi', contactName: 'Hans Lüthi',
assetType: AssetType.PRODUCTION, assetType: AssetType.PRODUCTION,
requiredArea: { min: 2000, max: 4000 }, requiredArea: { min: 2000, max: 4000 },
@@ -274,10 +274,10 @@ export const mockNeeds: Need[] = [
updatedAt: '2025-04-20T12:00:00Z', updatedAt: '2025-04-20T12:00:00Z',
}, },
// --- need-009: Geneva Commerce SA — RETAIL Genf --- // --- need-009: RETAIL Genf ---
{ {
id: 'need-009', id: 'need-009',
companyName: 'Geneva Commerce SA', companyName: 'Commerce Genève Centre',
contactName: 'Pierre Dupont', contactName: 'Pierre Dupont',
assetType: AssetType.RETAIL, assetType: AssetType.RETAIL,
requiredArea: { min: 150, max: 400 }, requiredArea: { min: 150, max: 400 },
@@ -308,10 +308,10 @@ export const mockNeeds: Need[] = [
updatedAt: '2025-04-18T09:00:00Z', updatedAt: '2025-04-18T09:00:00Z',
}, },
// --- need-011: Stadtladen Bern GmbH — RETAIL Bern Innenstadt --- // --- need-011: RETAIL Bern Innenstadt ---
{ {
id: 'need-011', id: 'need-011',
companyName: 'Stadtladen Bern GmbH', companyName: 'Ladenlokal Bern Altstadt',
contactName: 'Katrin Müller', contactName: 'Katrin Müller',
assetType: AssetType.RETAIL, assetType: AssetType.RETAIL,
requiredArea: { min: 200, max: 400 }, requiredArea: { min: 200, max: 400 },
@@ -342,10 +342,10 @@ export const mockNeeds: Need[] = [
updatedAt: '2025-05-18T10:00:00Z', updatedAt: '2025-05-18T10:00:00Z',
}, },
// --- need-010: St.Galler Büros AG — OFFICE St.Gallen --- // --- need-010: OFFICE St.Gallen ---
{ {
id: 'need-010', id: 'need-010',
companyName: 'St.Galler Büros AG', companyName: 'Bürofläche St. Gallen',
contactName: 'Brigitte Fässler', contactName: 'Brigitte Fässler',
assetType: AssetType.OFFICE, assetType: AssetType.OFFICE,
requiredArea: { min: 400, max: 800 }, requiredArea: { min: 400, max: 800 },
@@ -377,10 +377,10 @@ export const mockNeeds: Need[] = [
updatedAt: '2025-05-06T13:00:00Z', updatedAt: '2025-05-06T13:00:00Z',
}, },
// --- need-uc-a: Renato's Fahrradhändler — Use Case A --- // --- need-uc-a: RETAIL Zürich EG ---
{ {
id: 'need-uc-a', id: 'need-uc-a',
companyName: 'Velo City GmbH', companyName: 'Ladenlokal Zürich EG Schaufenster',
contactName: 'Renato Marchetti', contactName: 'Renato Marchetti',
assetType: AssetType.RETAIL, assetType: AssetType.RETAIL,
requiredArea: { min: 120, max: 160 }, requiredArea: { min: 120, max: 160 },
@@ -411,10 +411,10 @@ export const mockNeeds: Need[] = [
updatedAt: '2025-05-22T10:00:00Z', updatedAt: '2025-05-22T10:00:00Z',
}, },
// --- need-uc-b: Renato's Umzugsfirma — Use Case B --- // --- need-uc-b: OFFICE Zürich-West klein ---
{ {
id: 'need-uc-b', id: 'need-uc-b',
companyName: 'Alp Transit Umzüge GmbH', companyName: 'Bürofläche Zürich-West klein',
contactName: 'Renato Marchetti', contactName: 'Renato Marchetti',
assetType: AssetType.OFFICE, assetType: AssetType.OFFICE,
requiredArea: { min: 140, max: 200 }, requiredArea: { min: 140, max: 200 },
@@ -445,10 +445,10 @@ export const mockNeeds: Need[] = [
updatedAt: '2025-05-22T10:00:00Z', updatedAt: '2025-05-22T10:00:00Z',
}, },
// --- need-012: Gewerbe Solutions AG — LIGHT_INDUSTRIAL Pratteln --- // --- need-012: LIGHT_INDUSTRIAL Pratteln ---
{ {
id: 'need-012', id: 'need-012',
companyName: 'Gewerbe Solutions AG', companyName: 'Gewerbefläche Pratteln',
contactName: 'Andreas Weber', contactName: 'Andreas Weber',
assetType: AssetType.LIGHT_INDUSTRIAL, assetType: AssetType.LIGHT_INDUSTRIAL,
requiredArea: { min: 400, max: 750 }, requiredArea: { min: 400, max: 750 },
@@ -477,10 +477,10 @@ export const mockNeeds: Need[] = [
updatedAt: '2026-03-01T09:00:00Z', updatedAt: '2026-03-01T09:00:00Z',
}, },
// --- need-013: Bern Consulting AG — OFFICE Bern --- // --- need-013: OFFICE Bern ---
{ {
id: 'need-013', id: 'need-013',
companyName: 'Bern Consulting AG', companyName: 'Bürofläche Bern Bahnhof',
contactName: 'Sabine Gerber', contactName: 'Sabine Gerber',
assetType: AssetType.OFFICE, assetType: AssetType.OFFICE,
requiredArea: { min: 180, max: 320 }, requiredArea: { min: 180, max: 320 },
@@ -512,10 +512,10 @@ export const mockNeeds: Need[] = [
updatedAt: '2026-04-05T10:00:00Z', updatedAt: '2026-04-05T10:00:00Z',
}, },
// --- need-014: Winterthur Handel GmbH — RETAIL Winterthur --- // --- need-014: RETAIL Winterthur ---
{ {
id: 'need-014', id: 'need-014',
companyName: 'Winterthur Handel GmbH', companyName: 'Retailfläche Winterthur',
contactName: 'Pascal Brunner', contactName: 'Pascal Brunner',
assetType: AssetType.RETAIL, assetType: AssetType.RETAIL,
requiredArea: { min: 150, max: 250 }, requiredArea: { min: 150, max: 250 },
@@ -546,10 +546,10 @@ export const mockNeeds: Need[] = [
updatedAt: '2026-03-20T11:00:00Z', updatedAt: '2026-03-20T11:00:00Z',
}, },
// --- need-015: Luzern Advisory GmbH — OFFICE Luzern --- // --- need-015: OFFICE Luzern ---
{ {
id: 'need-015', id: 'need-015',
companyName: 'Luzern Advisory GmbH', companyName: 'Bürofläche Luzern',
contactName: 'Markus Bucher', contactName: 'Markus Bucher',
assetType: AssetType.OFFICE, assetType: AssetType.OFFICE,
requiredArea: { min: 350, max: 600 }, requiredArea: { min: 350, max: 600 },
@@ -581,10 +581,10 @@ export const mockNeeds: Need[] = [
updatedAt: '2026-04-10T14:00:00Z', updatedAt: '2026-04-10T14:00:00Z',
}, },
// --- need-uc-c: Renato's Vermögensverwalter — Use Case C --- // --- need-uc-c: OFFICE Zürich Premium ---
{ {
id: 'need-uc-c', id: 'need-uc-c',
companyName: 'Wealth Advisory Partners AG', companyName: 'Repräsentanzbüro Zürich Premium',
contactName: 'Renato Marchetti', contactName: 'Renato Marchetti',
assetType: AssetType.OFFICE, assetType: AssetType.OFFICE,
requiredArea: { min: 450, max: 560 }, requiredArea: { min: 450, max: 560 },
@@ -616,4 +616,138 @@ export const mockNeeds: Need[] = [
createdAt: '2025-05-20T10:00:00Z', createdAt: '2025-05-20T10:00:00Z',
updatedAt: '2025-05-22T10:00:00Z', updatedAt: '2025-05-22T10:00:00Z',
}, },
// --- org-mobimo: eigene Suchprofile der Mobimo Management AG ---
{
id: 'need-mob-001',
companyName: 'Bürofläche Zürich City',
contactName: 'Sandra Koch',
assetType: AssetType.OFFICE,
requiredArea: { min: 300, max: 600 },
preferredLocations: ['Zürich', 'Zürich City', 'Zürich Kreis 1', 'Zürich Kreis 4'],
excludedLocations: [],
budgetRange: { maxPerSqm: 480, maxMonthlyTotal: 20000, currency: 'CHF' },
timing: {
earliestMoveIn: '2025-10-01',
latestMoveIn: '2026-03-01',
contractDurationMonths: 60,
flexibleTiming: true,
},
mustCriteriaText: ['Gute ÖV-Anbindung', 'Repräsentative Lage', 'Klimaanlage'],
softFactors: {
minPrestige: 70,
minAccessibility: 80,
requireParking: false,
maxPublicTransportMinutes: 8,
},
weightingProfile: {
area: 0.18, location: 0.24, budget: 0.16, timing: 0.12,
prestige: 0.10, accessibility: 0.08, expansionPotential: 0.03, flexibility: 0.04,
visibility: 0.02, footfall: 0.01, talentAccess: 0.01, esg: 0.01, taxEnvironment: 0.00,
},
confidenceInCriteria: 0.91,
notificationConfig: { enabled: true, minScore: 80, emailAddress: 'sandra.koch@mobimo.ch' },
organizationId: 'org-mobimo',
createdAt: '2025-05-10T09:00:00Z',
updatedAt: '2025-06-01T10:00:00Z',
},
{
id: 'need-mob-002',
companyName: 'Showroom Bern Innenstadt',
contactName: 'Sandra Koch',
assetType: AssetType.RETAIL,
requiredArea: { min: 150, max: 300 },
preferredLocations: ['Bern', 'Bern Innenstadt', 'Bern Gurtengasse', 'Bern Marktgasse'],
excludedLocations: [],
budgetRange: { maxPerSqm: 380, maxMonthlyTotal: 8000, currency: 'CHF' },
timing: {
earliestMoveIn: '2025-11-01',
latestMoveIn: '2026-06-01',
contractDurationMonths: 36,
flexibleTiming: false,
},
mustCriteriaText: ['Schaufenster Erdgeschoss', 'Hoher Publikumsverkehr', 'Liefermöglichkeit'],
softFactors: {
minPrestige: 65,
minAccessibility: 75,
requireParking: false,
maxPublicTransportMinutes: 5,
},
weightingProfile: {
area: 0.14, location: 0.28, budget: 0.15, timing: 0.10,
prestige: 0.08, accessibility: 0.07, expansionPotential: 0.02, flexibility: 0.03,
visibility: 0.08, footfall: 0.04, talentAccess: 0.00, esg: 0.01, taxEnvironment: 0.00,
},
confidenceInCriteria: 0.87,
notificationConfig: { enabled: false, minScore: 75 },
organizationId: 'org-mobimo',
createdAt: '2025-04-20T11:00:00Z',
updatedAt: '2025-05-15T09:00:00Z',
},
{
id: 'need-mob-003',
companyName: 'Lager & Service Schaffhausen',
contactName: 'Sandra Koch',
assetType: AssetType.LOGISTICS,
requiredArea: { min: 800, max: 1500 },
preferredLocations: ['Schaffhausen', 'Neuhausen am Rheinfall', 'Thayngen'],
excludedLocations: [],
budgetRange: { maxPerSqm: 160, currency: 'CHF' },
timing: {
earliestMoveIn: '2026-01-01',
latestMoveIn: '2026-09-01',
contractDurationMonths: 48,
flexibleTiming: true,
},
mustCriteriaText: ['Autobahnanschluss < 10 Min', 'Rampe / Ladetor', 'Büroanteil mind. 80 m²'],
softFactors: {
requireParking: true,
},
weightingProfile: {
area: 0.22, location: 0.20, budget: 0.20, timing: 0.12,
prestige: 0.01, accessibility: 0.12, expansionPotential: 0.05, flexibility: 0.03,
visibility: 0.01, footfall: 0.00, talentAccess: 0.01, esg: 0.02, taxEnvironment: 0.01,
},
confidenceInCriteria: 0.83,
notificationConfig: { enabled: true, minScore: 85 },
organizationId: 'org-mobimo',
createdAt: '2025-06-01T08:00:00Z',
updatedAt: '2025-06-10T14:00:00Z',
},
{
id: 'need-mob-004',
companyName: 'Repräsentanzfläche Zürich-West',
contactName: 'Sandra Koch',
assetType: AssetType.OFFICE,
requiredArea: { min: 500, max: 900 },
preferredLocations: ['Zürich-West', 'Zürich Kreis 5', 'Zürich Kreis 4', 'Zürich Hardbrücke'],
excludedLocations: [],
budgetRange: { maxPerSqm: 520, maxMonthlyTotal: 35000, currency: 'CHF' },
timing: {
earliestMoveIn: '2026-03-01',
latestMoveIn: '2026-12-01',
contractDurationMonths: 84,
flexibleTiming: false,
},
mustCriteriaText: ['Moderner Neubau oder Kernsanierung', 'Kollaborative Fläche', 'Fahrradabstellplätze', 'Minergie oder LEED-zertifiziert'],
softFactors: {
minPrestige: 75,
minAccessibility: 85,
requireParking: false,
maxPublicTransportMinutes: 6,
},
weightingProfile: {
area: 0.16, location: 0.22, budget: 0.14, timing: 0.10,
prestige: 0.12, accessibility: 0.09, expansionPotential: 0.05, flexibility: 0.04,
visibility: 0.02, footfall: 0.01, talentAccess: 0.03, esg: 0.02, taxEnvironment: 0.00,
},
confidenceInCriteria: 0.93,
organizationId: 'org-mobimo',
createdAt: '2025-03-15T13:00:00Z',
updatedAt: '2025-06-05T11:00:00Z',
},
] ]
+95 -62
View File
@@ -1,6 +1,6 @@
import { useRef, useState } from 'react' import { useEffect, useRef, useState } from 'react'
import { Box, Typography } from '@mui/material' import { Box, Tab, Tabs, Typography } from '@mui/material'
import { useNavigate } from 'react-router' import { useLocation, useNavigate } from 'react-router'
import { useQueryClient } from '@tanstack/react-query' import { useQueryClient } from '@tanstack/react-query'
import { import {
NeedBuilderProgress, NeedBuilderProgress,
@@ -11,12 +11,15 @@ import {
} from '../../components/demand' } from '../../components/demand'
import { AISearchActionBar } from '../../components/demand/AISearchActionBar' import { AISearchActionBar } from '../../components/demand/AISearchActionBar'
import { AISearchSavePreview } from '../../components/demand/AISearchSavePreview' import { AISearchSavePreview } from '../../components/demand/AISearchSavePreview'
import { SavedProfilesTab } from '../../components/demand/SavedProfilesTab'
import { AddToPipelineDialog } from '../../components/shortlist'
import { InquiryQuickDialog } from '../../components/match-detail/InquiryQuickDialog'
import { useParseNeed } from '../../hooks/useAI' import { useParseNeed } from '../../hooks/useAI'
import { useCreateNeed } from '../../hooks/useNeeds' import { useCreateNeed, useNeed, useNeedProfiles } from '../../hooks/useNeeds'
import { useDefaultWeights } from '../../hooks/useWeighting' import { useDefaultWeights } from '../../hooks/useWeighting'
import { NeedBuilderStep } from '../../domain/needBuilder' import { NeedBuilderStep } from '../../domain/needBuilder'
import type { ParseNeedResult, ParsedNeedCriteria, WeightingKey } from '../../domain/needBuilder' import type { ParseNeedResult, ParsedNeedCriteria, WeightingKey } from '../../domain/needBuilder'
import { generateSummary, buildNeedInput } from '../../services/aiSearch/needSearchMapper' import { generateSummary, buildNeedInput, needToParsedCriteria } from '../../services/aiSearch/needSearchMapper'
type ActionIntent = 'search' | 'save-profile' type ActionIntent = 'search' | 'save-profile'
@@ -41,8 +44,25 @@ export default function AISearch() {
const [isAnonymous, setIsAnonymous] = useState(false) const [isAnonymous, setIsAnonymous] = useState(false)
const [error, setError] = useState<string | null>(null) const [error, setError] = useState<string | null>(null)
const [tab, setTab] = useState(0)
const { data: savedProfiles = [] } = useNeedProfiles()
const locationPrefillId = (useLocation().state as { prefillNeedId?: string } | null)?.prefillNeedId
const [internalPrefillId, setInternalPrefillId] = useState<string | undefined>()
const prefillNeedId = internalPrefillId ?? locationPrefillId
const { data: prefillNeed } = useNeed(prefillNeedId ?? '')
const isManualTextRef = useRef(false) const isManualTextRef = useRef(false)
useEffect(() => {
if (!prefillNeed) return
setCriteria(needToParsedCriteria(prefillNeed))
setWeights(prefillNeed.weightingProfile as Record<WeightingKey, number>)
setNeedTitle(prefillNeed.companyName)
setWeightingKey(k => k + 1)
setTab(0)
}, [prefillNeed])
function handleCriteriaChange(next: ParsedNeedCriteria) { function handleCriteriaChange(next: ParsedNeedCriteria) {
setCriteria(next) setCriteria(next)
if (!isManualTextRef.current) { if (!isManualTextRef.current) {
@@ -56,7 +76,6 @@ export default function AISearch() {
isManualTextRef.current = text !== '' isManualTextRef.current = text !== ''
setIsAutoGen(false) setIsAutoGen(false)
setInputText(text) setInputText(text)
// Clear stale parse results when user edits the text manually
setCriteria({}) setCriteria({})
setParseResult(null) setParseResult(null)
} }
@@ -198,16 +217,15 @@ export default function AISearch() {
const isProcessing = step === NeedBuilderStep.PARSING || step === NeedBuilderStep.SAVING const isProcessing = step === NeedBuilderStep.PARSING || step === NeedBuilderStep.SAVING
const isSaveStep = step === NeedBuilderStep.READY_TO_SAVE || step === NeedBuilderStep.SAVING const isSaveStep = step === NeedBuilderStep.READY_TO_SAVE || step === NeedBuilderStep.SAVING
const overallConfidence = parseResult const vals = parseResult ? Object.values(parseResult.confidenceByField) : []
? (() => { const overallConfidence = vals.length > 0 ? vals.reduce((s, v) => s + v, 0) / vals.length : 0
const entries = Object.entries(parseResult.confidenceByField)
return entries.length > 0 ? entries.reduce((s, [, v]) => s + v, 0) / entries.length : 0 const savedCount = savedProfiles.filter(n => !n.status || n.status === 'ACTIVE' || n.status === 'DRAFT').length
})()
: 0
return ( return (
<Box sx={{ display: 'flex', flexDirection: 'column', height: '100%', overflow: 'hidden' }}> <Box sx={{ display: 'flex', flexDirection: 'column', height: '100%', overflow: 'hidden' }}>
{/* Header */} <AddToPipelineDialog />
<InquiryQuickDialog />
<Box sx={{ bgcolor: 'white', borderBottom: '1px solid #e8e7e4', px: 3, py: 2.5, flexShrink: 0 }}> <Box sx={{ bgcolor: 'white', borderBottom: '1px solid #e8e7e4', px: 3, py: 2.5, flexShrink: 0 }}>
<Typography sx={{ fontWeight: 700, fontSize: '1.125rem', lineHeight: 1.3, color: '#0f172a' }}>Flächensuche</Typography> <Typography sx={{ fontWeight: 700, fontSize: '1.125rem', lineHeight: 1.3, color: '#0f172a' }}>Flächensuche</Typography>
<Typography variant="body2" color="text.secondary"> <Typography variant="body2" color="text.secondary">
@@ -215,61 +233,76 @@ export default function AISearch() {
</Typography> </Typography>
</Box> </Box>
<NeedBuilderProgress step={step} /> <Tabs
value={tab}
onChange={(_, v: number) => setTab(v)}
sx={{ borderBottom: '1px solid #e2e8f0', px: 3, bgcolor: 'white', flexShrink: 0, minHeight: 44 }}
>
<Tab label="Neue Suche" sx={{ textTransform: 'none', fontWeight: 600, fontSize: '0.875rem', minHeight: 44, py: 0 }} />
<Tab label={`Gespeicherte Profile (${savedCount})`} sx={{ textTransform: 'none', fontWeight: 600, fontSize: '0.875rem', minHeight: 44, py: 0 }} />
</Tabs>
<Box sx={{ flex: 1, overflowY: 'auto', px: 3, py: 3 }}> {tab === 0 && (
<>
<NeedBuilderProgress step={step} />
<Box sx={{ flex: 1, overflowY: 'auto', px: 3, py: 3 }}>
{/* IDLE: full form */} {(step === NeedBuilderStep.IDLE || step === NeedBuilderStep.PARSING) && (
{(step === NeedBuilderStep.IDLE || step === NeedBuilderStep.PARSING) && ( <Box sx={{ display: 'flex', flexDirection: 'column', gap: 3, maxWidth: { md: 900, xl: 1200 }, mx: 'auto' }}>
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 3, maxWidth: { md: 900, xl: 1200 }, mx: 'auto' }}> <VoiceNeedInput
<VoiceNeedInput text={inputText}
text={inputText} onTextChange={handleTextChange}
onTextChange={handleTextChange} isAutoGen={isAutoGen}
isAutoGen={isAutoGen} onAiSubmit={handleAiAutofill}
onAiSubmit={handleAiAutofill} isAnalyzing={step === NeedBuilderStep.PARSING}
isAnalyzing={step === NeedBuilderStep.PARSING} />
/> <Box className="grid grid-cols-2 gap-4" sx={{ alignItems: 'start' }}>
<Box className="grid grid-cols-2 gap-4" sx={{ alignItems: 'start' }}> <NeedInput criteria={criteria} onCriteriaChange={handleCriteriaChange} />
<NeedInput criteria={criteria} onCriteriaChange={handleCriteriaChange} /> <WeightingEditor
<WeightingEditor key={weightingKey}
key={weightingKey} weights={weights}
onChange={setWeights}
assetType={criteria.assetType}
/>
</Box>
<AISearchActionBar
canProceed={canProceed}
isSearching={isProcessing && intent === 'search'}
isSavingProfile={isProcessing && intent === 'save-profile'}
onSearch={() => handleAction('search')}
onSaveProfile={() => handleAction('save-profile')}
/>
</Box>
)}
{isSaveStep && parseResult && editedCriteria && (
<AISearchSavePreview
criteria={editedCriteria}
weights={weights} weights={weights}
onChange={setWeights} parseResult={parseResult}
assetType={criteria.assetType} needTitle={needTitle}
overallConfidence={overallConfidence}
isSaving={step === NeedBuilderStep.SAVING}
isAnonymous={isAnonymous}
onNeedTitleChange={setNeedTitle}
onAnonymousChange={setIsAnonymous}
onBack={() => setStep(NeedBuilderStep.IDLE)}
onSave={handleSaveProfile}
/> />
</Box> )}
<AISearchActionBar
canProceed={canProceed} {step === NeedBuilderStep.ERROR && (
isSearching={isProcessing && intent === 'search'} <NeedBuilderErrorState message={error ?? 'Unbekannter Fehler'} onRetry={handleRetry} />
isSavingProfile={isProcessing && intent === 'save-profile'} )}
onSearch={() => handleAction('search')}
onSaveProfile={() => handleAction('save-profile')}
/>
</Box> </Box>
)} </>
)}
{/* Save preview step */} {tab === 1 && (
{isSaveStep && parseResult && editedCriteria && ( <Box sx={{ flex: 1, overflow: 'hidden' }}>
<AISearchSavePreview <SavedProfilesTab onNewSearch={(id) => { setInternalPrefillId(id); setTab(0) }} />
criteria={editedCriteria} </Box>
weights={weights} )}
parseResult={parseResult}
needTitle={needTitle}
overallConfidence={overallConfidence}
isSaving={step === NeedBuilderStep.SAVING}
isAnonymous={isAnonymous}
onNeedTitleChange={setNeedTitle}
onAnonymousChange={setIsAnonymous}
onBack={() => setStep(NeedBuilderStep.IDLE)}
onSave={handleSaveProfile}
/>
)}
{/* Error */}
{step === NeedBuilderStep.ERROR && (
<NeedBuilderErrorState message={error ?? 'Unbekannter Fehler'} onRetry={handleRetry} />
)}
</Box>
</Box> </Box>
) )
} }