diff --git a/src/components/demand/PropertyMatchRow.tsx b/src/components/demand/PropertyMatchRow.tsx
new file mode 100644
index 0000000..84f95c1
--- /dev/null
+++ b/src/components/demand/PropertyMatchRow.tsx
@@ -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 (
+
+
+ {property.images?.[0] ? (
+
+ ) : (
+
+
+
+ )}
+
+
+ {property.title}
+
+
+ {property.location.city} · {property.areaSqm} m²
+
+
+
+ {score}%
+
+
+
+
+ Anfrage
+ Merken
+
+ {inCompare ? 'Im Vergleich' : 'Vergleichen'}
+
+ Details →
+ Zur Einheit →
+
+
+ )
+})
+
+function ActionButton({ children, onClick, primary, disabled }: {
+ children: React.ReactNode
+ onClick: () => void
+ primary?: boolean
+ disabled?: boolean
+}) {
+ return (
+
+ )
+}
diff --git a/src/components/demand/SavedNeedCard.tsx b/src/components/demand/SavedNeedCard.tsx
new file mode 100644
index 0000000..8d16bc5
--- /dev/null
+++ b/src/components/demand/SavedNeedCard.tsx
@@ -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 (
+
+
+
+
+
+
+ {matchCount > 0 && (
+
+ {matchCount} Treffer
+
+ )}
+
+
+
+ {need.companyName}
+
+
+
+
+ {locationText || '—'}
+
+
+ {topScore > 0 && (
+
+
+
+
+
+ {topScore}%
+
+
+ )}
+
+ )
+})
diff --git a/src/components/demand/SavedNeedDetail.tsx b/src/components/demand/SavedNeedDetail.tsx
new file mode 100644
index 0000000..ea93016
--- /dev/null
+++ b/src/components/demand/SavedNeedDetail.tsx
@@ -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 = {
+ 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(() => need.notificationConfig ?? { enabled: true, minScore: 80 })
+ const [notifDirty, setNotifDirty] = useState(false)
+
+ function handleNotifChange(patch: Partial) {
+ 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()
+ 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 (
+
+
+
+
+
+
+
+
+
+ {need.companyName}
+
+ {need.contactName && (
+ {need.contactName}
+ )}
+
+
+
+ Suchkriterien
+
+ } label="Standort" value={need.preferredLocations.join(', ') || '—'} />
+ } label="Fläche" value={`${need.requiredArea.min}–${need.requiredArea.max} m²`} />
+ } label="Budget" value={`CHF ${need.budgetRange.maxPerSqm} /m²`} />
+ } label="Einzug ab" value={need.timing.earliestMoveIn || '—'} />
+
+
+
+
+
+ Konfidenz der Kriterien
+
+ {Math.round(conf * 100)}%
+
+
+
+
+
+ {(need.mustCriteriaText?.length ?? 0) > 0 && (
+
+ Must-have Kriterien
+
+ {need.mustCriteriaText!.map((c, i) => (
+
+
+ {c}
+
+ ))}
+
+
+ )}
+
+ {topWeights.length > 0 && (
+
+ }
+ sx={{ px: 2, minHeight: 44, '& .MuiAccordionSummary-content': { my: 0 } }}>
+
+ Gewichtete Präferenzen
+
+
+
+
+ {topWeights.map(([key, val]) => {
+ const pct = Math.round(val * 100)
+ return (
+
+
+
+ {WEIGHT_LABELS[key] ?? key}
+
+
+ {pct}%
+
+
+
+
+
+
+ )
+ })}
+
+
+
+ )}
+
+
+ }
+ sx={{ px: 2, minHeight: 44, '& .MuiAccordionSummary-content': { my: 0 } }}>
+
+
+
+ Benachrichtigungen
+
+ {notif.enabled && (
+
+ ab {notif.minScore}%
+
+ )}
+
+
+
+
+
+
+ Benachrichtigung aktiv
+
+ Neue Treffer oberhalb der Schwelle melden
+
+
+ handleNotifChange({ enabled: e.target.checked })}
+ sx={{ '& .MuiSwitch-thumb': { bgcolor: notif.enabled ? '#152642' : undefined } }}
+ />
+
+
+ {notif.enabled && (
+ <>
+
+
+ Match-Schwelle
+
+ {notif.minScore}%
+
+
+ handleNotifChange({ minScore: v as number })}
+ sx={{ color: '#152642', '& .MuiSlider-thumb': { width: 14, height: 14 } }}
+ />
+
+ 60%
+ 95%
+
+
+
+
+
+ E-Mail (optional)
+
+ 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"
+ />
+
+ >
+ )}
+
+ {notifDirty && (
+
+ )}
+
+
+
+
+ {scoredProperties.length > 0 && (
+ <>
+
+
+
+
+
+ Passende Objekte
+
+
+ {matchCount} Treffer
+
+
+
+ {topProperties.map(({ property, score }) => (
+
+ ))}
+
+ {matchCount > 3 && (
+
+ Alle {matchCount} Ergebnisse anzeigen →
+
+ )}
+
+ >
+ )}
+
+
+
+
+
+
+
+ )
+})
+
+function SectionLabel({ children }: { children: React.ReactNode }) {
+ return (
+
+ {children}
+
+ )
+}
+
+function CriteriaBox({ icon, label, value }: { icon: React.ReactNode; label: string; value: string }) {
+ return (
+
+
+ {icon}
+
+ {label}
+
+
+ {value}
+
+ )
+}
diff --git a/src/components/demand/SavedProfilesTab.tsx b/src/components/demand/SavedProfilesTab.tsx
new file mode 100644
index 0000000..2acab85
--- /dev/null
+++ b/src/components/demand/SavedProfilesTab.tsx
@@ -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(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 = {}
+ const tops: Record = {}
+ 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
+ }
+
+ if (visible.length === 0) {
+ return (
+
+
+ } onClick={() => onNewSearch()} sx={{ textTransform: 'none', mt: 1 }}>
+ Neue Suche erstellen
+
+
+ )
+ }
+
+ const list = (
+
+
+
+ Suchprofile
+
+ {visible.length}
+
+
+
+ {visible.map(n => (
+ handleSelect(n.id)}
+ />
+ ))}
+
+
+ } onClick={() => onNewSearch()}
+ sx={{ textTransform: 'none', fontSize: '0.8rem' }}>
+ Neue Suche
+
+
+
+ )
+
+ if (isMobile) {
+ if (mobileView === 'detail' && selectedNeed) {
+ return (
+
+
+ setMobileView('list')}>
+ {selectedNeed.companyName}
+
+
+ handleStart(selectedNeed.id)}
+ onEdit={() => handleEdit(selectedNeed.id)}
+ onArchive={() => archiveMutation.mutate(selectedNeed.id)}
+ onUpdate={(data) => updateMutation.mutate({ id: selectedNeed.id, ...data })}
+ />
+
+
+ )
+ }
+ return {list}
+ }
+
+ return (
+
+ {list}
+
+ {selectedNeed ? (
+ handleStart(selectedNeed.id)}
+ onEdit={() => handleEdit(selectedNeed.id)}
+ onArchive={() => archiveMutation.mutate(selectedNeed.id)}
+ onUpdate={(data) => updateMutation.mutate({ id: selectedNeed.id, ...data })}
+ />
+ ) : (
+
+ } title="Profil auswählen" description="Wählen Sie ein Suchprofil, um Details zu sehen." />
+
+ )}
+
+
+ )
+}
diff --git a/src/components/demand/index.ts b/src/components/demand/index.ts
index 70f64c3..023bc8d 100644
--- a/src/components/demand/index.ts
+++ b/src/components/demand/index.ts
@@ -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'
diff --git a/src/components/layout/NotificationButton.tsx b/src/components/layout/NotificationButton.tsx
index 92ffbed..cb2fa2c 100644
--- a/src/components/layout/NotificationButton.tsx
+++ b/src/components/layout/NotificationButton.tsx
@@ -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 {
+ try { return JSON.parse(localStorage.getItem('notif-seen-counts') ?? '{}') }
+ catch { return {} }
+}
+
+function saveSeenCounts(counts: Record) {
+ localStorage.setItem('notif-seen-counts', JSON.stringify(counts))
+}
export function NotificationButton() {
const [anchorEl, setAnchorEl] = useState(null)
+ const [seenCounts, setSeenCounts] = useState>(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) {
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 (
<>
-
+
@@ -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 } } }}
>
- Benachrichtigungen
-
- Keine neuen Benachrichtigungen
+
+ Neue Treffer
+
+ {profilesWithMatches.length === 0 ? (
+
+ Keine aktiven Benachrichtigungen
+
+ ) : (
+ <>
+ {profilesWithMatches.map(({ need, count, minScore }) => (
+ 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',
+ }}
+ >
+
+
+ {need.companyName}
+
+
+ ab {minScore}%
+
+
+
+ {count} Treffer →
+
+
+ ))}
+
+ { 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 →
+
+ >
+ )}
>
)
diff --git a/src/domain/need.ts b/src/domain/need.ts
index 107e86d..b59961c 100644
--- a/src/domain/need.ts
+++ b/src/domain/need.ts
@@ -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
diff --git a/src/hooks/useNeeds.ts b/src/hooks/useNeeds.ts
index affae18..8c7d964 100644
--- a/src/hooks/useNeeds.ts
+++ b/src/hooks/useNeeds.ts
@@ -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'] })
+ },
+ })
+}
diff --git a/src/mock-data/needs.ts b/src/mock-data/needs.ts
index 65d7bee..50d90a3 100644
--- a/src/mock-data/needs.ts
+++ b/src/mock-data/needs.ts
@@ -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',
+ },
]
diff --git a/src/pages/demand/AISearch.tsx b/src/pages/demand/AISearch.tsx
index b6d331a..45159bc 100644
--- a/src/pages/demand/AISearch.tsx
+++ b/src/pages/demand/AISearch.tsx
@@ -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(null)
+ const [tab, setTab] = useState(0)
+ const { data: savedProfiles = [] } = useNeedProfiles()
+
+ const locationPrefillId = (useLocation().state as { prefillNeedId?: string } | null)?.prefillNeedId
+ const [internalPrefillId, setInternalPrefillId] = useState()
+ 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)
+ 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 (
- {/* Header */}
+
+
Flächensuche
@@ -215,61 +233,76 @@ export default function AISearch() {
-
+ setTab(v)}
+ sx={{ borderBottom: '1px solid #e2e8f0', px: 3, bgcolor: 'white', flexShrink: 0, minHeight: 44 }}
+ >
+
+
+
-
+ {tab === 0 && (
+ <>
+
+
- {/* IDLE: full form */}
- {(step === NeedBuilderStep.IDLE || step === NeedBuilderStep.PARSING) && (
-
-
-
-
-
+
+
+
+
+
+ handleAction('search')}
+ onSaveProfile={() => handleAction('save-profile')}
+ />
+
+ )}
+
+ {isSaveStep && parseResult && editedCriteria && (
+ setStep(NeedBuilderStep.IDLE)}
+ onSave={handleSaveProfile}
/>
-
- handleAction('search')}
- onSaveProfile={() => handleAction('save-profile')}
- />
+ )}
+
+ {step === NeedBuilderStep.ERROR && (
+
+ )}
- )}
+ >
+ )}
- {/* Save preview step */}
- {isSaveStep && parseResult && editedCriteria && (
- setStep(NeedBuilderStep.IDLE)}
- onSave={handleSaveProfile}
- />
- )}
-
- {/* Error */}
- {step === NeedBuilderStep.ERROR && (
-
- )}
-
+ {tab === 1 && (
+
+ { setInternalPrefillId(id); setTab(0) }} />
+
+ )}
)
}