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 { 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'
|
||||||
|
|||||||
@@ -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>
|
||||||
</>
|
</>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -63,6 +63,12 @@ export interface WeightedPreference {
|
|||||||
description?: string
|
description?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface NotificationConfig {
|
||||||
|
enabled: boolean
|
||||||
|
minScore: number // 0–100, 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
@@ -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
@@ -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',
|
||||||
|
},
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -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>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user