feat: saved profiles — per-profile notifications, real match scores, action buttons
- Add per-profile notification config (toggle, score threshold, email) with Accordion UI in SavedNeedDetail - NotificationButton: badge only lights up for unseen matches, dismisses on open (localStorage), uses per-profile minScore - SavedNeedDetail: replace deterministicMatchScore with real match data (useMatchesByNeed), sort by score desc, deduplicate by property - SavedProfilesTab & SavedNeedCard: top score bar now shows best real match score instead of criteria confidence - "Treffer" count uses per-profile notificationConfig.minScore (≥80 default) consistently across card, panel, and notification badge - Extract PropertyMatchRow to own file with Anfrage/Merken/Vergleichen/Details→/Zur Einheit→ action buttons - Details → navigates to /demand/results/:matchId using real match ID format (m__propId__prop__needId) - Add InquiryQuickDialog + AddToPipelineDialog to AISearch.tsx so dialogs work from SavedProfilesTab - Org-isolation: useNeeds filters by organizationId, org-mobimo gets 4 own search profiles - Mock data: all 18 org-wincasa need names changed to descriptive profile names; 4 new org-mobimo needs added - useUpdateNeed mutation hook for updating need fields (notificationConfig etc.) Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,160 @@
|
||||
import { memo } from 'react'
|
||||
import { Box, Button, Typography } from '@mui/material'
|
||||
import { Ruler } from 'lucide-react'
|
||||
import { useNavigate } from 'react-router'
|
||||
import { ROUTES } from '../../lib/constants'
|
||||
import type { Property } from '../../domain/property'
|
||||
import type { VerifiedPortfolioResult } from '../../domain/unifiedResult'
|
||||
import type { Match } from '../../domain/match'
|
||||
import { MatchStrength, ResultType } from '../../domain/enums'
|
||||
import { useInquiryStore } from '../../stores/inquiryStore'
|
||||
import { usePipelineStore } from '../../stores/pipelineStore'
|
||||
import { useCompareStore } from '../../stores/compareStore'
|
||||
|
||||
interface Props {
|
||||
property: Property
|
||||
score: number
|
||||
needId: string
|
||||
}
|
||||
|
||||
function buildSyntheticResult(property: Property, needId: string, score: number, matchId: string): VerifiedPortfolioResult {
|
||||
const strength = score >= 85 ? MatchStrength.STRONG : score >= 70 ? MatchStrength.MODERATE : MatchStrength.WEAK
|
||||
const syntheticMatch = {
|
||||
id: matchId,
|
||||
needId,
|
||||
propertyId: property.id,
|
||||
matchScore: score,
|
||||
matchStrength: strength,
|
||||
scoreBreakdown: { hardMatchScore: score, softFactorScore: score, confidenceModifier: 0, dataQualityModifier: 0, totalScore: score },
|
||||
confidenceLevel: score / 100,
|
||||
} as unknown as Match
|
||||
|
||||
return {
|
||||
matchId,
|
||||
needId,
|
||||
matchScore: score,
|
||||
resultType: ResultType.VERIFIED_PORTFOLIO,
|
||||
property,
|
||||
match: syntheticMatch,
|
||||
}
|
||||
}
|
||||
|
||||
export const PropertyMatchRow = memo(function PropertyMatchRow({ property, score, needId }: Props) {
|
||||
const navigate = useNavigate()
|
||||
const openInquiryDialog = useInquiryStore(s => s.openInquiryDialog)
|
||||
const openSavedDialog = usePipelineStore(s => s.openSavedDialog)
|
||||
const { addToCompare, removeFromCompare, isInCompare, isFull } = useCompareStore()
|
||||
|
||||
const scoreColor = score >= 85 ? '#16a34a' : score >= 70 ? '#d97706' : '#dc2626'
|
||||
const scoreBg = score >= 85 ? '#dcfce7' : score >= 70 ? '#fef3c7' : '#fee2e2'
|
||||
// Match IDs in the store use double-underscore format: m__propId__prop__needId
|
||||
const matchId = `m__${property.id}__prop__${needId}`
|
||||
const inCompare = isInCompare(matchId)
|
||||
|
||||
function handleInquire() {
|
||||
openInquiryDialog({
|
||||
propertyTitle: property.title,
|
||||
location: property.location.city,
|
||||
matchScore: score,
|
||||
matchId,
|
||||
propertyId: property.id,
|
||||
areaLabel: property.areaSqm ? `${property.areaSqm} m²` : undefined,
|
||||
rentLabel: property.rentPricePerSqm ? `CHF ${property.rentPricePerSqm}/m²/Jahr` : undefined,
|
||||
})
|
||||
}
|
||||
|
||||
function handleShortlist() {
|
||||
openSavedDialog({
|
||||
resultId: matchId,
|
||||
resultType: 'VERIFIED_PORTFOLIO',
|
||||
title: property.title,
|
||||
matchScore: score,
|
||||
location: property.location.city,
|
||||
propertyId: property.id,
|
||||
propertyAddress: `${property.title}, ${property.location.city}`,
|
||||
areaLabel: property.areaSqm ? `${property.areaSqm} m²` : undefined,
|
||||
rentLabel: property.rentPricePerSqm ? `CHF ${property.rentPricePerSqm}/m²/Jahr` : undefined,
|
||||
})
|
||||
}
|
||||
|
||||
function handleCompare() {
|
||||
if (inCompare) {
|
||||
removeFromCompare(matchId)
|
||||
} else if (!isFull()) {
|
||||
addToCompare(buildSyntheticResult(property, needId, score, matchId))
|
||||
}
|
||||
}
|
||||
|
||||
function handleDetails() {
|
||||
navigate(`/demand/results/${matchId}`)
|
||||
}
|
||||
|
||||
function handleUnit() {
|
||||
navigate(`/demand/property/${property.id}`)
|
||||
}
|
||||
|
||||
return (
|
||||
<Box sx={{ bgcolor: 'white', border: '1px solid #e2e8f0', borderRadius: 1.5, overflow: 'hidden' }}>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1.5, p: 1.25 }}>
|
||||
{property.images?.[0] ? (
|
||||
<Box component="img" src={property.images[0]} alt=""
|
||||
sx={{ width: 52, height: 52, borderRadius: 1, objectFit: 'cover', flexShrink: 0 }} />
|
||||
) : (
|
||||
<Box sx={{ width: 52, height: 52, borderRadius: 1, bgcolor: '#e0e7ff', flexShrink: 0, display: 'flex', alignItems: 'center', justifyContent: 'center' }}>
|
||||
<Ruler size={18} color="#3730a3" />
|
||||
</Box>
|
||||
)}
|
||||
<Box sx={{ flex: 1, minWidth: 0 }}>
|
||||
<Typography variant="body2" sx={{ fontWeight: 600, fontSize: '0.825rem', color: '#0f172a' }} noWrap>
|
||||
{property.title}
|
||||
</Typography>
|
||||
<Typography variant="caption" sx={{ color: '#64748b', fontSize: '0.75rem' }}>
|
||||
{property.location.city} · {property.areaSqm} m²
|
||||
</Typography>
|
||||
</Box>
|
||||
<Box sx={{ bgcolor: scoreBg, color: scoreColor, px: 1, py: 0.375, borderRadius: 1, fontWeight: 700, fontSize: '0.8rem', flexShrink: 0 }}>
|
||||
{score}%
|
||||
</Box>
|
||||
</Box>
|
||||
|
||||
<Box sx={{ display: 'flex', gap: 0.75, px: 1.25, pb: 1.25, flexWrap: 'wrap' }}>
|
||||
<ActionButton onClick={handleInquire} primary>Anfrage</ActionButton>
|
||||
<ActionButton onClick={handleShortlist}>Merken</ActionButton>
|
||||
<ActionButton onClick={handleCompare} disabled={!inCompare && isFull()}>
|
||||
{inCompare ? 'Im Vergleich' : 'Vergleichen'}
|
||||
</ActionButton>
|
||||
<ActionButton onClick={handleDetails}>Details →</ActionButton>
|
||||
<ActionButton onClick={handleUnit}>Zur Einheit →</ActionButton>
|
||||
</Box>
|
||||
</Box>
|
||||
)
|
||||
})
|
||||
|
||||
function ActionButton({ children, onClick, primary, disabled }: {
|
||||
children: React.ReactNode
|
||||
onClick: () => void
|
||||
primary?: boolean
|
||||
disabled?: boolean
|
||||
}) {
|
||||
return (
|
||||
<Button
|
||||
size="small"
|
||||
variant={primary ? 'contained' : 'outlined'}
|
||||
onClick={onClick}
|
||||
disabled={disabled}
|
||||
sx={{
|
||||
textTransform: 'none',
|
||||
fontWeight: 600,
|
||||
fontSize: '0.75rem',
|
||||
py: 0.25,
|
||||
px: 1,
|
||||
minWidth: 0,
|
||||
...(primary
|
||||
? { bgcolor: '#152642', '&:hover': { bgcolor: '#16304d' } }
|
||||
: { borderColor: '#cbd5e1', color: '#475569', '&:hover': { borderColor: '#152642', color: '#152642' } }),
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
</Button>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,78 @@
|
||||
import { memo } from 'react'
|
||||
import { Box, Chip, Paper, Typography } from '@mui/material'
|
||||
import { MapPin } from 'lucide-react'
|
||||
import type { Need } from '../../domain/need'
|
||||
import { ASSET_TYPE_LABELS } from '../../lib/constants'
|
||||
|
||||
interface Props {
|
||||
need: Need
|
||||
selected: boolean
|
||||
matchCount: number
|
||||
topScore: number
|
||||
onClick: () => void
|
||||
}
|
||||
|
||||
function statusConfig(status: Need['status']): { label: string; bg: string; fg: string } {
|
||||
if (status === 'DRAFT') return { label: 'Entwurf', bg: '#fef3c7', fg: '#92400e' }
|
||||
return { label: 'Aktiv', bg: '#dcfce7', fg: '#166534' }
|
||||
}
|
||||
|
||||
export const SavedNeedCard = memo(function SavedNeedCard({ need, selected, matchCount, topScore, onClick }: Props) {
|
||||
const statusCfg = statusConfig(need.status)
|
||||
const locationText = need.preferredLocations.slice(0, 2).join(', ')
|
||||
const scoreColor = topScore >= 80 ? '#16a34a' : topScore >= 65 ? '#d97706' : '#dc2626'
|
||||
|
||||
return (
|
||||
<Paper
|
||||
onClick={onClick}
|
||||
elevation={0}
|
||||
sx={{
|
||||
p: 1.5,
|
||||
borderRadius: 1.5,
|
||||
border: '1px solid',
|
||||
borderColor: selected ? '#152642' : '#e2e8f0',
|
||||
bgcolor: selected ? '#f1f5f9' : 'white',
|
||||
cursor: 'pointer',
|
||||
transition: 'all 0.15s',
|
||||
'&:hover': { borderColor: selected ? '#152642' : '#94a3b8', boxShadow: '0 2px 6px rgba(15,23,42,0.06)' },
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
gap: 0.75,
|
||||
}}
|
||||
>
|
||||
<Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'flex-start', gap: 1 }}>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.75, flexWrap: 'wrap' }}>
|
||||
<Chip label={ASSET_TYPE_LABELS[need.assetType] ?? need.assetType} size="small"
|
||||
sx={{ bgcolor: '#e0e7ff', color: '#3730a3', fontWeight: 600, fontSize: '0.65rem', height: 20 }} />
|
||||
<Chip label={statusCfg.label} size="small"
|
||||
sx={{ bgcolor: statusCfg.bg, color: statusCfg.fg, fontWeight: 600, fontSize: '0.65rem', height: 20 }} />
|
||||
</Box>
|
||||
{matchCount > 0 && (
|
||||
<Box sx={{ bgcolor: '#dcfce7', color: '#15803d', borderRadius: 1, px: 0.75, py: 0.125, fontSize: '0.68rem', fontWeight: 700, flexShrink: 0 }}>
|
||||
{matchCount} Treffer
|
||||
</Box>
|
||||
)}
|
||||
</Box>
|
||||
|
||||
<Typography variant="body2" sx={{ fontWeight: 600, fontSize: '0.85rem', color: '#0f172a', lineHeight: 1.3 }}>
|
||||
{need.companyName}
|
||||
</Typography>
|
||||
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.5, color: '#64748b' }}>
|
||||
<MapPin size={11} />
|
||||
<Typography variant="caption" sx={{ fontSize: '0.75rem' }}>{locationText || '—'}</Typography>
|
||||
</Box>
|
||||
|
||||
{topScore > 0 && (
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
|
||||
<Box sx={{ flex: 1, height: 4, borderRadius: 2, bgcolor: '#f1f5f9', overflow: 'hidden' }}>
|
||||
<Box sx={{ width: `${topScore}%`, height: '100%', bgcolor: scoreColor }} />
|
||||
</Box>
|
||||
<Typography variant="caption" sx={{ fontSize: '0.72rem', fontWeight: 700, color: scoreColor, flexShrink: 0 }}>
|
||||
{topScore}%
|
||||
</Typography>
|
||||
</Box>
|
||||
)}
|
||||
</Paper>
|
||||
)
|
||||
})
|
||||
@@ -0,0 +1,326 @@
|
||||
import { memo, useMemo, useState } from 'react'
|
||||
import {
|
||||
Accordion, AccordionDetails, AccordionSummary,
|
||||
Box, Button, Chip, Divider, LinearProgress, Slider, Switch, TextField, Typography,
|
||||
} from '@mui/material'
|
||||
import { Bell, Calendar, CheckCircle2, ChevronDown, MapPin, Ruler, Target, Wallet } from 'lucide-react'
|
||||
import type { Need, NotificationConfig, UpdateNeedInput } from '../../domain/need'
|
||||
import type { Property } from '../../domain/property'
|
||||
import { ASSET_TYPE_LABELS } from '../../lib/constants'
|
||||
import { confidenceHex } from '../../lib/utils'
|
||||
import { useMatchesByNeed } from '../../hooks/useMatches'
|
||||
import { PropertyMatchRow } from './PropertyMatchRow'
|
||||
|
||||
const WEIGHT_LABELS: Record<string, string> = {
|
||||
area: 'Fläche', location: 'Standort', budget: 'Budget', timing: 'Timing',
|
||||
prestige: 'Prestige', accessibility: 'Erreichbarkeit', expansionPotential: 'Expansionspotenzial',
|
||||
flexibility: 'Flexibilität', visibility: 'Sichtbarkeit', footfall: 'Publikumsverkehr',
|
||||
talentAccess: 'Talentzugang', esg: 'ESG', taxEnvironment: 'Steuerumgebung',
|
||||
}
|
||||
|
||||
function statusConfig(status: Need['status']): { label: string; bg: string; fg: string } {
|
||||
if (status === 'DRAFT') return { label: 'Entwurf', bg: '#fef3c7', fg: '#92400e' }
|
||||
return { label: 'Aktiv', bg: '#dcfce7', fg: '#166534' }
|
||||
}
|
||||
|
||||
interface Props {
|
||||
need: Need
|
||||
properties: Property[]
|
||||
onStart: () => void
|
||||
onEdit: () => void
|
||||
onArchive: () => void
|
||||
onUpdate: (data: UpdateNeedInput) => void
|
||||
}
|
||||
|
||||
export const SavedNeedDetail = memo(function SavedNeedDetail({ need, properties, onStart, onEdit, onArchive, onUpdate }: Props) {
|
||||
const statusCfg = statusConfig(need.status)
|
||||
const conf = need.confidenceInCriteria ?? 0
|
||||
const confColor = confidenceHex(conf)
|
||||
|
||||
const [notif, setNotif] = useState<NotificationConfig>(() => need.notificationConfig ?? { enabled: true, minScore: 80 })
|
||||
const [notifDirty, setNotifDirty] = useState(false)
|
||||
|
||||
function handleNotifChange(patch: Partial<NotificationConfig>) {
|
||||
setNotif(prev => ({ ...prev, ...patch }))
|
||||
setNotifDirty(true)
|
||||
}
|
||||
function handleNotifSave() {
|
||||
onUpdate({ notificationConfig: notif })
|
||||
setNotifDirty(false)
|
||||
}
|
||||
|
||||
const { data: matches = [] } = useMatchesByNeed(need.id)
|
||||
|
||||
const notifThreshold = need.notificationConfig?.minScore ?? 80
|
||||
const matchCount = matches.filter(m => m.matchScore >= notifThreshold).length
|
||||
|
||||
const scoredProperties = useMemo(() => {
|
||||
const seen = new Set<string>()
|
||||
return matches
|
||||
.map(m => {
|
||||
const property = properties.find(p => p.id === m.propertyId)
|
||||
return property ? { property, score: Math.round(m.matchScore) } : null
|
||||
})
|
||||
.filter((x): x is { property: Property; score: number } => x !== null)
|
||||
.sort((a, b) => b.score - a.score)
|
||||
.filter(x => {
|
||||
if (seen.has(x.property.id)) return false
|
||||
seen.add(x.property.id)
|
||||
return true
|
||||
})
|
||||
}, [matches, properties])
|
||||
const topScore = scoredProperties.length > 0 ? scoredProperties[0].score : 0
|
||||
const topProperties = scoredProperties.slice(0, 3)
|
||||
|
||||
const topWeights = useMemo(
|
||||
() => Object.entries(need.weightingProfile).filter(([, v]) => v > 0.03).sort(([, a], [, b]) => b - a).slice(0, 6),
|
||||
[need.weightingProfile],
|
||||
)
|
||||
|
||||
return (
|
||||
<Box sx={{ flex: 1, display: 'flex', flexDirection: 'column', overflow: 'hidden', bgcolor: '#f8fafc' }}>
|
||||
<Box sx={{ flex: 1, overflowY: 'auto', p: 3, display: 'flex', flexDirection: 'column', gap: 2.5 }}>
|
||||
|
||||
<Box>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, mb: 0.75 }}>
|
||||
<Chip label={ASSET_TYPE_LABELS[need.assetType] ?? need.assetType} size="small"
|
||||
sx={{ bgcolor: '#e0e7ff', color: '#3730a3', fontWeight: 600, fontSize: '0.7rem', height: 22 }} />
|
||||
<Chip label={statusCfg.label} size="small"
|
||||
sx={{ bgcolor: statusCfg.bg, color: statusCfg.fg, fontWeight: 600, fontSize: '0.7rem', height: 22 }} />
|
||||
</Box>
|
||||
<Typography variant="h5" sx={{ fontWeight: 700, color: '#0f172a', fontSize: '1.25rem' }}>
|
||||
{need.companyName}
|
||||
</Typography>
|
||||
{need.contactName && (
|
||||
<Typography variant="body2" sx={{ color: '#64748b', mt: 0.25 }}>{need.contactName}</Typography>
|
||||
)}
|
||||
</Box>
|
||||
|
||||
<Box>
|
||||
<SectionLabel>Suchkriterien</SectionLabel>
|
||||
<Box sx={{ mt: 1, display: 'grid', gridTemplateColumns: 'repeat(auto-fit, minmax(200px, 1fr))', gap: 1.25 }}>
|
||||
<CriteriaBox icon={<MapPin size={13} />} label="Standort" value={need.preferredLocations.join(', ') || '—'} />
|
||||
<CriteriaBox icon={<Ruler size={13} />} label="Fläche" value={`${need.requiredArea.min}–${need.requiredArea.max} m²`} />
|
||||
<CriteriaBox icon={<Wallet size={13} />} label="Budget" value={`CHF ${need.budgetRange.maxPerSqm} /m²`} />
|
||||
<CriteriaBox icon={<Calendar size={13} />} label="Einzug ab" value={need.timing.earliestMoveIn || '—'} />
|
||||
</Box>
|
||||
</Box>
|
||||
|
||||
<Box>
|
||||
<Box sx={{ display: 'flex', justifyContent: 'space-between', mb: 0.5 }}>
|
||||
<SectionLabel>Konfidenz der Kriterien</SectionLabel>
|
||||
<Typography variant="caption" sx={{ fontWeight: 700, color: confColor, fontSize: '0.8rem' }}>
|
||||
{Math.round(conf * 100)}%
|
||||
</Typography>
|
||||
</Box>
|
||||
<LinearProgress variant="determinate" value={conf * 100} sx={{
|
||||
height: 6, borderRadius: 3, bgcolor: '#f1f5f9',
|
||||
'& .MuiLinearProgress-bar': { bgcolor: confColor, borderRadius: 3 },
|
||||
}} />
|
||||
</Box>
|
||||
|
||||
{(need.mustCriteriaText?.length ?? 0) > 0 && (
|
||||
<Box>
|
||||
<SectionLabel>Must-have Kriterien</SectionLabel>
|
||||
<Box sx={{ mt: 1, display: 'flex', flexDirection: 'column', gap: 0.5 }}>
|
||||
{need.mustCriteriaText!.map((c, i) => (
|
||||
<Box key={i} sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
|
||||
<CheckCircle2 size={14} color="#16a34a" />
|
||||
<Typography variant="body2" sx={{ fontSize: '0.875rem', color: '#1e293b' }}>{c}</Typography>
|
||||
</Box>
|
||||
))}
|
||||
</Box>
|
||||
</Box>
|
||||
)}
|
||||
|
||||
{topWeights.length > 0 && (
|
||||
<Accordion elevation={0} disableGutters sx={{
|
||||
border: '1px solid #e2e8f0', borderRadius: '8px !important', overflow: 'hidden',
|
||||
'&:before': { display: 'none' }, bgcolor: 'white',
|
||||
}}>
|
||||
<AccordionSummary expandIcon={<ChevronDown size={16} color="#64748b" />}
|
||||
sx={{ px: 2, minHeight: 44, '& .MuiAccordionSummary-content': { my: 0 } }}>
|
||||
<Typography variant="caption" sx={{ color: '#64748b', fontWeight: 700, textTransform: 'uppercase', letterSpacing: 0.5 }}>
|
||||
Gewichtete Präferenzen
|
||||
</Typography>
|
||||
</AccordionSummary>
|
||||
<AccordionDetails sx={{ px: 2, pt: 0, pb: 2 }}>
|
||||
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 1 }}>
|
||||
{topWeights.map(([key, val]) => {
|
||||
const pct = Math.round(val * 100)
|
||||
return (
|
||||
<Box key={key} sx={{ bgcolor: '#f8fafc', border: '1px solid #e2e8f0', borderRadius: 1.5, px: 1.5, py: 1 }}>
|
||||
<Box sx={{ display: 'flex', justifyContent: 'space-between', mb: 0.5 }}>
|
||||
<Typography variant="body2" sx={{ fontWeight: 600, fontSize: '0.85rem' }}>
|
||||
{WEIGHT_LABELS[key] ?? key}
|
||||
</Typography>
|
||||
<Typography variant="caption" sx={{ fontWeight: 700, color: '#152642', fontSize: '0.75rem', bgcolor: '#e0e7ff', px: 1, borderRadius: 1 }}>
|
||||
{pct}%
|
||||
</Typography>
|
||||
</Box>
|
||||
<Box sx={{ height: 5, borderRadius: 3, bgcolor: '#e8e7e4', overflow: 'hidden' }}>
|
||||
<Box sx={{ width: `${pct}%`, height: '100%', bgcolor: '#152642', transition: 'width 0.3s' }} />
|
||||
</Box>
|
||||
</Box>
|
||||
)
|
||||
})}
|
||||
</Box>
|
||||
</AccordionDetails>
|
||||
</Accordion>
|
||||
)}
|
||||
|
||||
<Accordion elevation={0} disableGutters sx={{
|
||||
border: '1px solid #e2e8f0', borderRadius: '8px !important', overflow: 'hidden',
|
||||
'&:before': { display: 'none' }, bgcolor: 'white',
|
||||
}}>
|
||||
<AccordionSummary expandIcon={<ChevronDown size={16} color="#64748b" />}
|
||||
sx={{ px: 2, minHeight: 44, '& .MuiAccordionSummary-content': { my: 0 } }}>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
|
||||
<Bell size={13} color={notif.enabled ? '#152642' : '#94a3b8'} />
|
||||
<Typography variant="caption" sx={{ color: '#64748b', fontWeight: 700, textTransform: 'uppercase', letterSpacing: 0.5 }}>
|
||||
Benachrichtigungen
|
||||
</Typography>
|
||||
{notif.enabled && (
|
||||
<Box sx={{ bgcolor: '#e0e7ff', color: '#3730a3', fontSize: '0.65rem', fontWeight: 700, px: 0.75, borderRadius: 0.75 }}>
|
||||
ab {notif.minScore}%
|
||||
</Box>
|
||||
)}
|
||||
</Box>
|
||||
</AccordionSummary>
|
||||
<AccordionDetails sx={{ px: 2, pt: 0, pb: 2 }}>
|
||||
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 1.75 }}>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}>
|
||||
<Box>
|
||||
<Typography variant="body2" sx={{ fontWeight: 600, fontSize: '0.85rem' }}>Benachrichtigung aktiv</Typography>
|
||||
<Typography variant="caption" sx={{ color: '#64748b', fontSize: '0.75rem' }}>
|
||||
Neue Treffer oberhalb der Schwelle melden
|
||||
</Typography>
|
||||
</Box>
|
||||
<Switch
|
||||
size="small"
|
||||
checked={notif.enabled}
|
||||
onChange={e => handleNotifChange({ enabled: e.target.checked })}
|
||||
sx={{ '& .MuiSwitch-thumb': { bgcolor: notif.enabled ? '#152642' : undefined } }}
|
||||
/>
|
||||
</Box>
|
||||
|
||||
{notif.enabled && (
|
||||
<>
|
||||
<Box>
|
||||
<Box sx={{ display: 'flex', justifyContent: 'space-between', mb: 0.5 }}>
|
||||
<Typography variant="body2" sx={{ fontWeight: 600, fontSize: '0.85rem' }}>Match-Schwelle</Typography>
|
||||
<Typography variant="caption" sx={{ fontWeight: 700, color: '#152642', fontSize: '0.8rem', bgcolor: '#e0e7ff', px: 0.75, borderRadius: 0.75 }}>
|
||||
{notif.minScore}%
|
||||
</Typography>
|
||||
</Box>
|
||||
<Slider
|
||||
value={notif.minScore}
|
||||
min={60} max={95} step={5}
|
||||
onChange={(_, v) => handleNotifChange({ minScore: v as number })}
|
||||
sx={{ color: '#152642', '& .MuiSlider-thumb': { width: 14, height: 14 } }}
|
||||
/>
|
||||
<Box sx={{ display: 'flex', justifyContent: 'space-between' }}>
|
||||
<Typography variant="caption" sx={{ color: '#94a3b8', fontSize: '0.7rem' }}>60%</Typography>
|
||||
<Typography variant="caption" sx={{ color: '#94a3b8', fontSize: '0.7rem' }}>95%</Typography>
|
||||
</Box>
|
||||
</Box>
|
||||
|
||||
<Box>
|
||||
<Typography variant="body2" sx={{ fontWeight: 600, fontSize: '0.85rem', mb: 0.5 }}>
|
||||
E-Mail (optional)
|
||||
</Typography>
|
||||
<TextField
|
||||
size="small"
|
||||
fullWidth
|
||||
placeholder="name@firma.ch"
|
||||
value={notif.emailAddress ?? ''}
|
||||
onChange={e => handleNotifChange({ emailAddress: e.target.value || undefined })}
|
||||
inputProps={{ type: 'email' }}
|
||||
sx={{ '& .MuiOutlinedInput-root': { fontSize: '0.85rem' } }}
|
||||
helperText="Im Produktivsystem wird bei neuen Treffern eine E-Mail ausgelöst"
|
||||
/>
|
||||
</Box>
|
||||
</>
|
||||
)}
|
||||
|
||||
{notifDirty && (
|
||||
<Button
|
||||
variant="contained"
|
||||
size="small"
|
||||
onClick={handleNotifSave}
|
||||
sx={{ textTransform: 'none', fontWeight: 600, bgcolor: '#152642', '&:hover': { bgcolor: '#16304d' }, alignSelf: 'flex-end' }}
|
||||
>
|
||||
Speichern
|
||||
</Button>
|
||||
)}
|
||||
</Box>
|
||||
</AccordionDetails>
|
||||
</Accordion>
|
||||
|
||||
{scoredProperties.length > 0 && (
|
||||
<>
|
||||
<Divider />
|
||||
<Box>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, mb: 1.25 }}>
|
||||
<Target size={14} color="#15803d" />
|
||||
<Typography variant="caption" sx={{ color: '#64748b', fontWeight: 700, textTransform: 'uppercase', letterSpacing: 0.5 }}>
|
||||
Passende Objekte
|
||||
</Typography>
|
||||
<Box sx={{ ml: 'auto', bgcolor: '#dcfce7', color: '#15803d', borderRadius: 1, px: 1, py: 0.125, fontWeight: 700, fontSize: '0.72rem' }}>
|
||||
{matchCount} Treffer
|
||||
</Box>
|
||||
</Box>
|
||||
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 1 }}>
|
||||
{topProperties.map(({ property, score }) => (
|
||||
<PropertyMatchRow key={property.id} property={property} score={score} needId={need.id} />
|
||||
))}
|
||||
</Box>
|
||||
{matchCount > 3 && (
|
||||
<Box
|
||||
onClick={onStart}
|
||||
sx={{ mt: 1.25, textAlign: 'center', cursor: 'pointer', color: '#152642', fontSize: '0.8rem', fontWeight: 600,
|
||||
py: 1, border: '1px dashed #cbd5e1', borderRadius: 1.5, '&:hover': { bgcolor: '#f1f5f9', borderColor: '#152642' }, transition: 'all 0.15s' }}
|
||||
>
|
||||
Alle {matchCount} Ergebnisse anzeigen →
|
||||
</Box>
|
||||
)}
|
||||
</Box>
|
||||
</>
|
||||
)}
|
||||
</Box>
|
||||
|
||||
<Box sx={{ flexShrink: 0, borderTop: '1px solid #e2e8f0', px: 3, py: 2, bgcolor: 'white', display: 'flex', gap: 1.5 }}>
|
||||
<Button variant="outlined" size="large" onClick={onEdit}
|
||||
sx={{ flex: 1, textTransform: 'none', fontWeight: 600, borderColor: '#cbd5e1', color: '#475569' }}>
|
||||
Bearbeiten
|
||||
</Button>
|
||||
<Button variant="contained" size="large" onClick={onStart}
|
||||
sx={{ flex: 2, textTransform: 'none', fontWeight: 600, bgcolor: '#152642', '&:hover': { bgcolor: '#16304d' } }}>
|
||||
Suche starten →
|
||||
</Button>
|
||||
</Box>
|
||||
</Box>
|
||||
)
|
||||
})
|
||||
|
||||
function SectionLabel({ children }: { children: React.ReactNode }) {
|
||||
return (
|
||||
<Typography variant="caption" sx={{ color: '#64748b', fontWeight: 700, textTransform: 'uppercase', letterSpacing: 0.5 }}>
|
||||
{children}
|
||||
</Typography>
|
||||
)
|
||||
}
|
||||
|
||||
function CriteriaBox({ icon, label, value }: { icon: React.ReactNode; label: string; value: string }) {
|
||||
return (
|
||||
<Box sx={{ bgcolor: 'white', border: '1px solid #e2e8f0', borderRadius: 1.5, px: 1.5, py: 1 }}>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.75, color: '#64748b', mb: 0.25 }}>
|
||||
{icon}
|
||||
<Typography variant="caption" sx={{ fontSize: '0.7rem', fontWeight: 600, textTransform: 'uppercase', letterSpacing: 0.5 }}>
|
||||
{label}
|
||||
</Typography>
|
||||
</Box>
|
||||
<Typography variant="body2" sx={{ fontSize: '0.85rem', color: '#0f172a', fontWeight: 500 }}>{value}</Typography>
|
||||
</Box>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,167 @@
|
||||
import { useEffect, useMemo, useState } from 'react'
|
||||
import { Box, Button, CircularProgress, IconButton, Typography, useMediaQuery, useTheme } from '@mui/material'
|
||||
import { ArrowLeft, Plus, Search } from 'lucide-react'
|
||||
import { useNavigate } from 'react-router'
|
||||
import { useNeedProfiles, useArchiveNeed, useUpdateNeed } from '../../hooks/useNeeds'
|
||||
import { useProperties } from '../../hooks/useProperties'
|
||||
import { useMatches } from '../../hooks/useMatches'
|
||||
import { ResultType } from '../../domain/enums'
|
||||
import { ROUTES } from '../../lib/constants'
|
||||
import { EmptyState } from '../ui'
|
||||
import { SavedNeedCard } from './SavedNeedCard'
|
||||
import { SavedNeedDetail } from './SavedNeedDetail'
|
||||
import { needToParsedCriteria } from '../../services/aiSearch/needSearchMapper'
|
||||
|
||||
interface Props {
|
||||
onNewSearch: (prefillNeedId?: string) => void
|
||||
}
|
||||
|
||||
export function SavedProfilesTab({ onNewSearch }: Props) {
|
||||
const navigate = useNavigate()
|
||||
const theme = useTheme()
|
||||
const isMobile = useMediaQuery(theme.breakpoints.down('md'))
|
||||
|
||||
const { data: needs = [], isLoading } = useNeedProfiles()
|
||||
const { data: properties = [] } = useProperties({ resultType: ResultType.VERIFIED_PORTFOLIO })
|
||||
const { data: allMatches = [] } = useMatches()
|
||||
const archiveMutation = useArchiveNeed()
|
||||
const updateMutation = useUpdateNeed()
|
||||
|
||||
const [selectedId, setSelectedId] = useState<string | null>(null)
|
||||
const [mobileView, setMobileView] = useState<'list' | 'detail'>('list')
|
||||
|
||||
const visible = useMemo(
|
||||
() =>
|
||||
needs
|
||||
.filter(n => !n.status || n.status === 'ACTIVE' || n.status === 'DRAFT')
|
||||
.sort((a, b) => {
|
||||
const aActive = !a.status || a.status === 'ACTIVE'
|
||||
const bActive = !b.status || b.status === 'ACTIVE'
|
||||
if (aActive !== bActive) return aActive ? -1 : 1
|
||||
return new Date(b.updatedAt).getTime() - new Date(a.updatedAt).getTime()
|
||||
}),
|
||||
[needs],
|
||||
)
|
||||
|
||||
const { matchCounts, topScores } = useMemo(() => {
|
||||
const counts: Record<string, number> = {}
|
||||
const tops: Record<string, number> = {}
|
||||
for (const n of visible) {
|
||||
const needMatches = allMatches.filter(m => m.needId === n.id)
|
||||
const threshold = n.notificationConfig?.minScore ?? 80
|
||||
counts[n.id] = needMatches.filter(m => m.matchScore >= threshold).length
|
||||
tops[n.id] = needMatches.length > 0 ? Math.round(Math.max(...needMatches.map(m => m.matchScore))) : 0
|
||||
}
|
||||
return { matchCounts: counts, topScores: tops }
|
||||
}, [visible, allMatches])
|
||||
|
||||
useEffect(() => {
|
||||
if (!isMobile && !selectedId && visible.length > 0) setSelectedId(visible[0].id)
|
||||
}, [visible, selectedId, isMobile])
|
||||
|
||||
const selectedNeed = visible.find(n => n.id === selectedId) ?? null
|
||||
|
||||
function handleSelect(id: string) {
|
||||
setSelectedId(id)
|
||||
if (isMobile) setMobileView('detail')
|
||||
}
|
||||
|
||||
function handleStart(needId: string) {
|
||||
navigate(ROUTES.DEMAND.RESULTS, { state: { fromNeedBuilder: true, activeNeedId: needId } })
|
||||
}
|
||||
|
||||
function handleEdit(needId: string) {
|
||||
onNewSearch(needId)
|
||||
}
|
||||
|
||||
if (isLoading) {
|
||||
return <Box sx={{ display: 'flex', justifyContent: 'center', pt: 8 }}><CircularProgress size={24} /></Box>
|
||||
}
|
||||
|
||||
if (visible.length === 0) {
|
||||
return (
|
||||
<Box sx={{ display: 'flex', flexDirection: 'column', alignItems: 'center', pt: 8, gap: 2 }}>
|
||||
<EmptyState title="Keine Suchprofile" description="Sie haben noch keine Suchprofile gespeichert." />
|
||||
<Button variant="outlined" startIcon={<Plus size={16} />} onClick={() => onNewSearch()} sx={{ textTransform: 'none', mt: 1 }}>
|
||||
Neue Suche erstellen
|
||||
</Button>
|
||||
</Box>
|
||||
)
|
||||
}
|
||||
|
||||
const list = (
|
||||
<Box sx={{ width: 280, minWidth: 280, flexShrink: 0, borderRight: '1px solid #e2e8f0', bgcolor: 'white', display: 'flex', flexDirection: 'column', overflow: 'hidden' }}>
|
||||
<Box sx={{ px: 2, py: 1.25, borderBottom: '1px solid #e2e8f0', display: 'flex', alignItems: 'center', gap: 1, flexShrink: 0 }}>
|
||||
<Search size={14} color="#7c3aed" />
|
||||
<Typography variant="body2" sx={{ fontWeight: 600, color: '#0f172a' }}>Suchprofile</Typography>
|
||||
<Box sx={{ ml: 'auto', px: 1, py: 0.125, borderRadius: 1, bgcolor: '#152642', color: 'white', fontWeight: 600, fontSize: '0.7rem' }}>
|
||||
{visible.length}
|
||||
</Box>
|
||||
</Box>
|
||||
<Box sx={{ flex: 1, overflowY: 'auto', p: 1.25, display: 'flex', flexDirection: 'column', gap: 1 }}>
|
||||
{visible.map(n => (
|
||||
<SavedNeedCard
|
||||
key={n.id}
|
||||
need={n}
|
||||
selected={n.id === selectedId}
|
||||
matchCount={matchCounts[n.id] ?? 0}
|
||||
topScore={topScores[n.id] ?? 0}
|
||||
onClick={() => handleSelect(n.id)}
|
||||
/>
|
||||
))}
|
||||
</Box>
|
||||
<Box sx={{ flexShrink: 0, p: 1.5, borderTop: '1px solid #e2e8f0' }}>
|
||||
<Button fullWidth variant="outlined" size="small" startIcon={<Plus size={14} />} onClick={() => onNewSearch()}
|
||||
sx={{ textTransform: 'none', fontSize: '0.8rem' }}>
|
||||
Neue Suche
|
||||
</Button>
|
||||
</Box>
|
||||
</Box>
|
||||
)
|
||||
|
||||
if (isMobile) {
|
||||
if (mobileView === 'detail' && selectedNeed) {
|
||||
return (
|
||||
<Box sx={{ display: 'flex', flexDirection: 'column', height: '100%', overflow: 'hidden' }}>
|
||||
<Box sx={{ px: 1.5, py: 1, borderBottom: '1px solid #e2e8f0', bgcolor: 'white', flexShrink: 0, display: 'flex', alignItems: 'center', gap: 1 }}>
|
||||
<IconButton size="small" onClick={() => setMobileView('list')}><ArrowLeft size={18} /></IconButton>
|
||||
<Typography variant="body2" sx={{ fontWeight: 600, fontSize: '0.85rem' }} noWrap>{selectedNeed.companyName}</Typography>
|
||||
</Box>
|
||||
<Box sx={{ flex: 1, overflow: 'hidden', display: 'flex', flexDirection: 'column' }}>
|
||||
<SavedNeedDetail
|
||||
need={selectedNeed}
|
||||
properties={properties}
|
||||
onStart={() => handleStart(selectedNeed.id)}
|
||||
onEdit={() => handleEdit(selectedNeed.id)}
|
||||
onArchive={() => archiveMutation.mutate(selectedNeed.id)}
|
||||
onUpdate={(data) => updateMutation.mutate({ id: selectedNeed.id, ...data })}
|
||||
/>
|
||||
</Box>
|
||||
</Box>
|
||||
)
|
||||
}
|
||||
return <Box sx={{ height: '100%', overflow: 'hidden' }}>{list}</Box>
|
||||
}
|
||||
|
||||
return (
|
||||
<Box sx={{ display: 'flex', height: '100%', overflow: 'hidden' }}>
|
||||
{list}
|
||||
<Box sx={{ flex: 1, display: 'flex', overflow: 'hidden' }}>
|
||||
{selectedNeed ? (
|
||||
<SavedNeedDetail
|
||||
need={selectedNeed}
|
||||
properties={properties}
|
||||
onStart={() => handleStart(selectedNeed.id)}
|
||||
onEdit={() => handleEdit(selectedNeed.id)}
|
||||
onArchive={() => archiveMutation.mutate(selectedNeed.id)}
|
||||
onUpdate={(data) => updateMutation.mutate({ id: selectedNeed.id, ...data })}
|
||||
/>
|
||||
) : (
|
||||
<Box sx={{ flex: 1, display: 'flex', alignItems: 'center', justifyContent: 'center' }}>
|
||||
<EmptyState icon={<Search size={40} />} title="Profil auswählen" description="Wählen Sie ein Suchprofil, um Details zu sehen." />
|
||||
</Box>
|
||||
)}
|
||||
</Box>
|
||||
</Box>
|
||||
)
|
||||
}
|
||||
@@ -9,3 +9,7 @@ export { NeedCardPreview } from './NeedCardPreview'
|
||||
export { NeedInput } from './NeedInput'
|
||||
export { VoiceNeedInput } from './VoiceNeedInput'
|
||||
export { WeightingEditor } from './WeightingEditor'
|
||||
export { PropertyMatchRow } from './PropertyMatchRow'
|
||||
export { SavedNeedCard } from './SavedNeedCard'
|
||||
export { SavedNeedDetail } from './SavedNeedDetail'
|
||||
export { SavedProfilesTab } from './SavedProfilesTab'
|
||||
|
||||
@@ -1,22 +1,74 @@
|
||||
import { useState } from 'react'
|
||||
import { Badge, IconButton, Popover, Typography } from '@mui/material'
|
||||
import { useMemo, useState } from 'react'
|
||||
import { Badge, Box, Divider, IconButton, Popover, Typography } from '@mui/material'
|
||||
import { Bell } from 'lucide-react'
|
||||
import { useNavigate } from 'react-router'
|
||||
import { useNeedProfiles } from '../../hooks/useNeeds'
|
||||
import { useMatches } from '../../hooks/useMatches'
|
||||
import { ROUTES } from '../../lib/constants'
|
||||
|
||||
function loadSeenCounts(): Record<string, number> {
|
||||
try { return JSON.parse(localStorage.getItem('notif-seen-counts') ?? '{}') }
|
||||
catch { return {} }
|
||||
}
|
||||
|
||||
function saveSeenCounts(counts: Record<string, number>) {
|
||||
localStorage.setItem('notif-seen-counts', JSON.stringify(counts))
|
||||
}
|
||||
|
||||
export function NotificationButton() {
|
||||
const [anchorEl, setAnchorEl] = useState<null | HTMLElement>(null)
|
||||
const [seenCounts, setSeenCounts] = useState<Record<string, number>>(loadSeenCounts)
|
||||
const navigate = useNavigate()
|
||||
|
||||
const { data: needs = [] } = useNeedProfiles()
|
||||
const { data: allMatches = [] } = useMatches()
|
||||
|
||||
const profilesWithMatches = useMemo(() => {
|
||||
const active = needs.filter(n =>
|
||||
(!n.status || n.status === 'ACTIVE' || n.status === 'DRAFT') &&
|
||||
(n.notificationConfig?.enabled !== false),
|
||||
)
|
||||
return active
|
||||
.map(n => {
|
||||
const minScore = n.notificationConfig?.minScore ?? 80
|
||||
return {
|
||||
need: n,
|
||||
count: allMatches.filter(m => m.needId === n.id && m.matchScore >= minScore).length,
|
||||
minScore,
|
||||
}
|
||||
})
|
||||
.filter(x => x.count > 0)
|
||||
}, [needs, allMatches])
|
||||
|
||||
// Badge only lights up for profiles with matches the user hasn't seen yet
|
||||
const newProfiles = useMemo(
|
||||
() => profilesWithMatches.filter(x => x.count > (seenCounts[x.need.id] ?? 0)),
|
||||
[profilesWithMatches, seenCounts],
|
||||
)
|
||||
const badgeCount = newProfiles.length
|
||||
|
||||
function handleOpen(e: React.MouseEvent<HTMLElement>) {
|
||||
setAnchorEl(e.currentTarget)
|
||||
// Mark all current matches as seen
|
||||
const updated = { ...seenCounts }
|
||||
for (const { need, count } of profilesWithMatches) {
|
||||
updated[need.id] = count
|
||||
}
|
||||
setSeenCounts(updated)
|
||||
saveSeenCounts(updated)
|
||||
}
|
||||
|
||||
function handleClose() {
|
||||
setAnchorEl(null)
|
||||
function handleClose() { setAnchorEl(null) }
|
||||
|
||||
function goToResults(needId: string) {
|
||||
navigate(ROUTES.DEMAND.RESULTS, { state: { fromNeedBuilder: true, activeNeedId: needId } })
|
||||
handleClose()
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<IconButton size="small" sx={{ color: '#64748b' }} onClick={handleOpen}>
|
||||
<Badge badgeContent={0} color="error">
|
||||
<Badge badgeContent={badgeCount || null} color="error">
|
||||
<Bell size={20} />
|
||||
</Badge>
|
||||
</IconButton>
|
||||
@@ -27,12 +79,50 @@ export function NotificationButton() {
|
||||
onClose={handleClose}
|
||||
anchorOrigin={{ vertical: 'bottom', horizontal: 'right' }}
|
||||
transformOrigin={{ vertical: 'top', horizontal: 'right' }}
|
||||
slotProps={{ paper: { sx: { width: 280, p: 2 } } }}
|
||||
slotProps={{ paper: { sx: { width: 300, p: 2 } } }}
|
||||
>
|
||||
<Typography variant="subtitle2" sx={{ mb: 1 }}>Benachrichtigungen</Typography>
|
||||
<Typography variant="caption" sx={{ color: 'text.secondary' }}>
|
||||
Keine neuen Benachrichtigungen
|
||||
<Typography variant="subtitle2" sx={{ mb: 1.25, fontWeight: 700, color: '#0f172a' }}>
|
||||
Neue Treffer
|
||||
</Typography>
|
||||
|
||||
{profilesWithMatches.length === 0 ? (
|
||||
<Typography variant="caption" sx={{ color: 'text.secondary' }}>
|
||||
Keine aktiven Benachrichtigungen
|
||||
</Typography>
|
||||
) : (
|
||||
<>
|
||||
{profilesWithMatches.map(({ need, count, minScore }) => (
|
||||
<Box
|
||||
key={need.id}
|
||||
onClick={() => goToResults(need.id)}
|
||||
sx={{
|
||||
display: 'flex', justifyContent: 'space-between', alignItems: 'center',
|
||||
px: 1, py: 0.875, borderRadius: 1, cursor: 'pointer',
|
||||
'&:hover': { bgcolor: '#f8fafc' }, transition: 'background 0.1s',
|
||||
}}
|
||||
>
|
||||
<Box sx={{ minWidth: 0 }}>
|
||||
<Typography variant="body2" sx={{ fontSize: '0.825rem', color: '#1e293b' }} noWrap>
|
||||
{need.companyName}
|
||||
</Typography>
|
||||
<Typography variant="caption" sx={{ color: '#94a3b8', fontSize: '0.7rem' }}>
|
||||
ab {minScore}%
|
||||
</Typography>
|
||||
</Box>
|
||||
<Typography variant="caption" sx={{ color: '#15803d', fontWeight: 700, flexShrink: 0, ml: 1.5, fontSize: '0.78rem' }}>
|
||||
{count} Treffer →
|
||||
</Typography>
|
||||
</Box>
|
||||
))}
|
||||
<Divider sx={{ my: 1.25 }} />
|
||||
<Box
|
||||
onClick={() => { navigate(ROUTES.DEMAND.AI_SEARCH); handleClose() }}
|
||||
sx={{ textAlign: 'center', cursor: 'pointer', color: '#152642', fontSize: '0.8rem', fontWeight: 600, py: 0.25, '&:hover': { color: '#16304d' } }}
|
||||
>
|
||||
Alle Suchprofile anzeigen →
|
||||
</Box>
|
||||
</>
|
||||
)}
|
||||
</Popover>
|
||||
</>
|
||||
)
|
||||
|
||||
@@ -63,6 +63,12 @@ export interface WeightedPreference {
|
||||
description?: string
|
||||
}
|
||||
|
||||
export interface NotificationConfig {
|
||||
enabled: boolean
|
||||
minScore: number // 0–100, default 80
|
||||
emailAddress?: string
|
||||
}
|
||||
|
||||
export interface Need {
|
||||
id: string
|
||||
companyName: string
|
||||
@@ -103,6 +109,7 @@ export interface Need {
|
||||
confidenceInCriteria: number
|
||||
extractedFromText?: string
|
||||
notes?: string
|
||||
notificationConfig?: NotificationConfig
|
||||
organizationId?: string
|
||||
createdAt: string
|
||||
updatedAt: string
|
||||
|
||||
+25
-3
@@ -1,6 +1,7 @@
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
|
||||
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 {
|
||||
refetchOnMount?: boolean | 'always'
|
||||
@@ -8,9 +9,10 @@ interface UseNeedsOptions {
|
||||
}
|
||||
|
||||
export function useNeeds(options?: UseNeedsOptions) {
|
||||
const orgId = useSessionStore(s => s.currentUser?.organizationId)
|
||||
return useQuery({
|
||||
queryKey: ['needs'],
|
||||
queryFn: () => needService.getAll(),
|
||||
queryKey: ['needs', orgId],
|
||||
queryFn: () => needService.getAll({ organizationId: orgId }),
|
||||
select: (res) => res.data ?? [],
|
||||
...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'
|
||||
|
||||
export const mockNeeds: Need[] = [
|
||||
// --- need-001: Innovatech AG — OFFICE Zürich ---
|
||||
// --- need-001: OFFICE Zürich-West ---
|
||||
{
|
||||
id: 'need-001',
|
||||
companyName: 'Innovatech AG',
|
||||
companyName: 'Bürofläche Zürich-West',
|
||||
contactName: 'Sandra Meier',
|
||||
assetType: AssetType.OFFICE,
|
||||
requiredArea: { min: 600, max: 1000 },
|
||||
@@ -38,10 +38,10 @@ export const mockNeeds: Need[] = [
|
||||
updatedAt: '2025-05-01T09:00:00Z',
|
||||
},
|
||||
|
||||
// --- need-002: Schweizer Logistik GmbH — LOGISTICS Basel ---
|
||||
// --- need-002: LOGISTICS Basel ---
|
||||
{
|
||||
id: 'need-002',
|
||||
companyName: 'Schweizer Logistik GmbH',
|
||||
companyName: 'Logistikfläche Basel',
|
||||
contactName: 'Thomas Brun',
|
||||
assetType: AssetType.LOGISTICS,
|
||||
requiredArea: { min: 1500, max: 4000 },
|
||||
@@ -72,10 +72,10 @@ export const mockNeeds: Need[] = [
|
||||
updatedAt: '2025-04-10T11:00:00Z',
|
||||
},
|
||||
|
||||
// --- need-003: Pharma Holding AG — OFFICE Basel ---
|
||||
// --- need-003: OFFICE Basel ---
|
||||
{
|
||||
id: 'need-003',
|
||||
companyName: 'Pharma Holding AG',
|
||||
companyName: 'Bürofläche Basel Repräsentanz',
|
||||
contactName: 'Ursula Schmid',
|
||||
assetType: AssetType.OFFICE,
|
||||
requiredArea: { min: 500, max: 800 },
|
||||
@@ -110,10 +110,10 @@ export const mockNeeds: Need[] = [
|
||||
updatedAt: '2025-05-05T14:00:00Z',
|
||||
},
|
||||
|
||||
// --- need-004: Retailer Zürich AG — RETAIL Zürich ---
|
||||
// --- need-004: RETAIL Zürich ---
|
||||
{
|
||||
id: 'need-004',
|
||||
companyName: 'Retailer Zürich AG',
|
||||
companyName: 'Ladenlokal Zürich Innenstadt',
|
||||
contactName: 'Marco Colombo',
|
||||
assetType: AssetType.RETAIL,
|
||||
requiredArea: { min: 200, max: 500 },
|
||||
@@ -144,10 +144,10 @@ export const mockNeeds: Need[] = [
|
||||
updatedAt: '2025-04-22T08:00:00Z',
|
||||
},
|
||||
|
||||
// --- need-005: TechStart GmbH — OFFICE Zug/Zürich ---
|
||||
// --- need-005: OFFICE Zug/Zürich ---
|
||||
{
|
||||
id: 'need-005',
|
||||
companyName: 'TechStart GmbH',
|
||||
companyName: 'Bürofläche Zug / Zürich',
|
||||
contactName: 'Florian Keller',
|
||||
assetType: AssetType.OFFICE,
|
||||
requiredArea: { min: 300, max: 700 },
|
||||
@@ -179,10 +179,10 @@ export const mockNeeds: Need[] = [
|
||||
updatedAt: '2025-05-08T10:00:00Z',
|
||||
},
|
||||
|
||||
// --- need-006: Lager & Spedition AG — LOGISTICS Winterthur ---
|
||||
// --- need-006: LOGISTICS Winterthur ---
|
||||
{
|
||||
id: 'need-006',
|
||||
companyName: 'Lager & Spedition AG',
|
||||
companyName: 'Lagerfläche Winterthur',
|
||||
contactName: 'Beat Zimmermann',
|
||||
assetType: AssetType.LOGISTICS,
|
||||
requiredArea: { min: 1200, max: 3000 },
|
||||
@@ -209,10 +209,10 @@ export const mockNeeds: Need[] = [
|
||||
updatedAt: '2025-05-09T16:00:00Z',
|
||||
},
|
||||
|
||||
// --- need-007: Creative Studios AG — MIXED Zürich/Bern ---
|
||||
// --- need-007: MIXED Zürich-West ---
|
||||
{
|
||||
id: 'need-007',
|
||||
companyName: 'Creative Studios AG',
|
||||
companyName: 'Gewerbefläche Zürich-West',
|
||||
contactName: 'Nora Hauser',
|
||||
assetType: AssetType.MIXED,
|
||||
requiredArea: { min: 800, max: 1500 },
|
||||
@@ -244,10 +244,10 @@ export const mockNeeds: Need[] = [
|
||||
updatedAt: '2025-05-03T11:00:00Z',
|
||||
},
|
||||
|
||||
// --- need-008: Berner Produzenten GmbH — PRODUCTION Bern ---
|
||||
// --- need-008: PRODUCTION Bern ---
|
||||
{
|
||||
id: 'need-008',
|
||||
companyName: 'Berner Produzenten GmbH',
|
||||
companyName: 'Produktionshalle Bern',
|
||||
contactName: 'Hans Lüthi',
|
||||
assetType: AssetType.PRODUCTION,
|
||||
requiredArea: { min: 2000, max: 4000 },
|
||||
@@ -274,10 +274,10 @@ export const mockNeeds: Need[] = [
|
||||
updatedAt: '2025-04-20T12:00:00Z',
|
||||
},
|
||||
|
||||
// --- need-009: Geneva Commerce SA — RETAIL Genf ---
|
||||
// --- need-009: RETAIL Genf ---
|
||||
{
|
||||
id: 'need-009',
|
||||
companyName: 'Geneva Commerce SA',
|
||||
companyName: 'Commerce Genève Centre',
|
||||
contactName: 'Pierre Dupont',
|
||||
assetType: AssetType.RETAIL,
|
||||
requiredArea: { min: 150, max: 400 },
|
||||
@@ -308,10 +308,10 @@ export const mockNeeds: Need[] = [
|
||||
updatedAt: '2025-04-18T09:00:00Z',
|
||||
},
|
||||
|
||||
// --- need-011: Stadtladen Bern GmbH — RETAIL Bern Innenstadt ---
|
||||
// --- need-011: RETAIL Bern Innenstadt ---
|
||||
{
|
||||
id: 'need-011',
|
||||
companyName: 'Stadtladen Bern GmbH',
|
||||
companyName: 'Ladenlokal Bern Altstadt',
|
||||
contactName: 'Katrin Müller',
|
||||
assetType: AssetType.RETAIL,
|
||||
requiredArea: { min: 200, max: 400 },
|
||||
@@ -342,10 +342,10 @@ export const mockNeeds: Need[] = [
|
||||
updatedAt: '2025-05-18T10:00:00Z',
|
||||
},
|
||||
|
||||
// --- need-010: St.Galler Büros AG — OFFICE St.Gallen ---
|
||||
// --- need-010: OFFICE St.Gallen ---
|
||||
{
|
||||
id: 'need-010',
|
||||
companyName: 'St.Galler Büros AG',
|
||||
companyName: 'Bürofläche St. Gallen',
|
||||
contactName: 'Brigitte Fässler',
|
||||
assetType: AssetType.OFFICE,
|
||||
requiredArea: { min: 400, max: 800 },
|
||||
@@ -377,10 +377,10 @@ export const mockNeeds: Need[] = [
|
||||
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',
|
||||
companyName: 'Velo City GmbH',
|
||||
companyName: 'Ladenlokal Zürich EG Schaufenster',
|
||||
contactName: 'Renato Marchetti',
|
||||
assetType: AssetType.RETAIL,
|
||||
requiredArea: { min: 120, max: 160 },
|
||||
@@ -411,10 +411,10 @@ export const mockNeeds: Need[] = [
|
||||
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',
|
||||
companyName: 'Alp Transit Umzüge GmbH',
|
||||
companyName: 'Bürofläche Zürich-West klein',
|
||||
contactName: 'Renato Marchetti',
|
||||
assetType: AssetType.OFFICE,
|
||||
requiredArea: { min: 140, max: 200 },
|
||||
@@ -445,10 +445,10 @@ export const mockNeeds: Need[] = [
|
||||
updatedAt: '2025-05-22T10:00:00Z',
|
||||
},
|
||||
|
||||
// --- need-012: Gewerbe Solutions AG — LIGHT_INDUSTRIAL Pratteln ---
|
||||
// --- need-012: LIGHT_INDUSTRIAL Pratteln ---
|
||||
{
|
||||
id: 'need-012',
|
||||
companyName: 'Gewerbe Solutions AG',
|
||||
companyName: 'Gewerbefläche Pratteln',
|
||||
contactName: 'Andreas Weber',
|
||||
assetType: AssetType.LIGHT_INDUSTRIAL,
|
||||
requiredArea: { min: 400, max: 750 },
|
||||
@@ -477,10 +477,10 @@ export const mockNeeds: Need[] = [
|
||||
updatedAt: '2026-03-01T09:00:00Z',
|
||||
},
|
||||
|
||||
// --- need-013: Bern Consulting AG — OFFICE Bern ---
|
||||
// --- need-013: OFFICE Bern ---
|
||||
{
|
||||
id: 'need-013',
|
||||
companyName: 'Bern Consulting AG',
|
||||
companyName: 'Bürofläche Bern Bahnhof',
|
||||
contactName: 'Sabine Gerber',
|
||||
assetType: AssetType.OFFICE,
|
||||
requiredArea: { min: 180, max: 320 },
|
||||
@@ -512,10 +512,10 @@ export const mockNeeds: Need[] = [
|
||||
updatedAt: '2026-04-05T10:00:00Z',
|
||||
},
|
||||
|
||||
// --- need-014: Winterthur Handel GmbH — RETAIL Winterthur ---
|
||||
// --- need-014: RETAIL Winterthur ---
|
||||
{
|
||||
id: 'need-014',
|
||||
companyName: 'Winterthur Handel GmbH',
|
||||
companyName: 'Retailfläche Winterthur',
|
||||
contactName: 'Pascal Brunner',
|
||||
assetType: AssetType.RETAIL,
|
||||
requiredArea: { min: 150, max: 250 },
|
||||
@@ -546,10 +546,10 @@ export const mockNeeds: Need[] = [
|
||||
updatedAt: '2026-03-20T11:00:00Z',
|
||||
},
|
||||
|
||||
// --- need-015: Luzern Advisory GmbH — OFFICE Luzern ---
|
||||
// --- need-015: OFFICE Luzern ---
|
||||
{
|
||||
id: 'need-015',
|
||||
companyName: 'Luzern Advisory GmbH',
|
||||
companyName: 'Bürofläche Luzern',
|
||||
contactName: 'Markus Bucher',
|
||||
assetType: AssetType.OFFICE,
|
||||
requiredArea: { min: 350, max: 600 },
|
||||
@@ -581,10 +581,10 @@ export const mockNeeds: Need[] = [
|
||||
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',
|
||||
companyName: 'Wealth Advisory Partners AG',
|
||||
companyName: 'Repräsentanzbüro Zürich Premium',
|
||||
contactName: 'Renato Marchetti',
|
||||
assetType: AssetType.OFFICE,
|
||||
requiredArea: { min: 450, max: 560 },
|
||||
@@ -616,4 +616,138 @@ export const mockNeeds: Need[] = [
|
||||
createdAt: '2025-05-20T10: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 { Box, Typography } from '@mui/material'
|
||||
import { useNavigate } from 'react-router'
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
import { Box, Tab, Tabs, Typography } from '@mui/material'
|
||||
import { useLocation, useNavigate } from 'react-router'
|
||||
import { useQueryClient } from '@tanstack/react-query'
|
||||
import {
|
||||
NeedBuilderProgress,
|
||||
@@ -11,12 +11,15 @@ import {
|
||||
} from '../../components/demand'
|
||||
import { AISearchActionBar } from '../../components/demand/AISearchActionBar'
|
||||
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 { useCreateNeed } from '../../hooks/useNeeds'
|
||||
import { useCreateNeed, useNeed, useNeedProfiles } from '../../hooks/useNeeds'
|
||||
import { useDefaultWeights } from '../../hooks/useWeighting'
|
||||
import { NeedBuilderStep } 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'
|
||||
|
||||
@@ -41,8 +44,25 @@ export default function AISearch() {
|
||||
const [isAnonymous, setIsAnonymous] = useState(false)
|
||||
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)
|
||||
|
||||
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) {
|
||||
setCriteria(next)
|
||||
if (!isManualTextRef.current) {
|
||||
@@ -56,7 +76,6 @@ export default function AISearch() {
|
||||
isManualTextRef.current = text !== ''
|
||||
setIsAutoGen(false)
|
||||
setInputText(text)
|
||||
// Clear stale parse results when user edits the text manually
|
||||
setCriteria({})
|
||||
setParseResult(null)
|
||||
}
|
||||
@@ -198,16 +217,15 @@ export default function AISearch() {
|
||||
const isProcessing = step === NeedBuilderStep.PARSING || step === NeedBuilderStep.SAVING
|
||||
const isSaveStep = step === NeedBuilderStep.READY_TO_SAVE || step === NeedBuilderStep.SAVING
|
||||
|
||||
const overallConfidence = parseResult
|
||||
? (() => {
|
||||
const entries = Object.entries(parseResult.confidenceByField)
|
||||
return entries.length > 0 ? entries.reduce((s, [, v]) => s + v, 0) / entries.length : 0
|
||||
})()
|
||||
: 0
|
||||
const vals = parseResult ? Object.values(parseResult.confidenceByField) : []
|
||||
const overallConfidence = vals.length > 0 ? vals.reduce((s, v) => s + v, 0) / vals.length : 0
|
||||
|
||||
const savedCount = savedProfiles.filter(n => !n.status || n.status === 'ACTIVE' || n.status === 'DRAFT').length
|
||||
|
||||
return (
|
||||
<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 }}>
|
||||
<Typography sx={{ fontWeight: 700, fontSize: '1.125rem', lineHeight: 1.3, color: '#0f172a' }}>Flächensuche</Typography>
|
||||
<Typography variant="body2" color="text.secondary">
|
||||
@@ -215,61 +233,76 @@ export default function AISearch() {
|
||||
</Typography>
|
||||
</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) && (
|
||||
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 3, maxWidth: { md: 900, xl: 1200 }, mx: 'auto' }}>
|
||||
<VoiceNeedInput
|
||||
text={inputText}
|
||||
onTextChange={handleTextChange}
|
||||
isAutoGen={isAutoGen}
|
||||
onAiSubmit={handleAiAutofill}
|
||||
isAnalyzing={step === NeedBuilderStep.PARSING}
|
||||
/>
|
||||
<Box className="grid grid-cols-2 gap-4" sx={{ alignItems: 'start' }}>
|
||||
<NeedInput criteria={criteria} onCriteriaChange={handleCriteriaChange} />
|
||||
<WeightingEditor
|
||||
key={weightingKey}
|
||||
{(step === NeedBuilderStep.IDLE || step === NeedBuilderStep.PARSING) && (
|
||||
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 3, maxWidth: { md: 900, xl: 1200 }, mx: 'auto' }}>
|
||||
<VoiceNeedInput
|
||||
text={inputText}
|
||||
onTextChange={handleTextChange}
|
||||
isAutoGen={isAutoGen}
|
||||
onAiSubmit={handleAiAutofill}
|
||||
isAnalyzing={step === NeedBuilderStep.PARSING}
|
||||
/>
|
||||
<Box className="grid grid-cols-2 gap-4" sx={{ alignItems: 'start' }}>
|
||||
<NeedInput criteria={criteria} onCriteriaChange={handleCriteriaChange} />
|
||||
<WeightingEditor
|
||||
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}
|
||||
onChange={setWeights}
|
||||
assetType={criteria.assetType}
|
||||
parseResult={parseResult}
|
||||
needTitle={needTitle}
|
||||
overallConfidence={overallConfidence}
|
||||
isSaving={step === NeedBuilderStep.SAVING}
|
||||
isAnonymous={isAnonymous}
|
||||
onNeedTitleChange={setNeedTitle}
|
||||
onAnonymousChange={setIsAnonymous}
|
||||
onBack={() => setStep(NeedBuilderStep.IDLE)}
|
||||
onSave={handleSaveProfile}
|
||||
/>
|
||||
</Box>
|
||||
<AISearchActionBar
|
||||
canProceed={canProceed}
|
||||
isSearching={isProcessing && intent === 'search'}
|
||||
isSavingProfile={isProcessing && intent === 'save-profile'}
|
||||
onSearch={() => handleAction('search')}
|
||||
onSaveProfile={() => handleAction('save-profile')}
|
||||
/>
|
||||
)}
|
||||
|
||||
{step === NeedBuilderStep.ERROR && (
|
||||
<NeedBuilderErrorState message={error ?? 'Unbekannter Fehler'} onRetry={handleRetry} />
|
||||
)}
|
||||
</Box>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Save preview step */}
|
||||
{isSaveStep && parseResult && editedCriteria && (
|
||||
<AISearchSavePreview
|
||||
criteria={editedCriteria}
|
||||
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>
|
||||
{tab === 1 && (
|
||||
<Box sx={{ flex: 1, overflow: 'hidden' }}>
|
||||
<SavedProfilesTab onNewSearch={(id) => { setInternalPrefillId(id); setTab(0) }} />
|
||||
</Box>
|
||||
)}
|
||||
</Box>
|
||||
)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user