From 7a0909e36a5f60ae43321798b017168b035fb6c5 Mon Sep 17 00:00:00 2001 From: Benjamin Sutter Date: Fri, 19 Jun 2026 20:53:49 +0200 Subject: [PATCH] =?UTF-8?q?feat:=20saved=20profiles=20=E2=80=94=20per-prof?= =?UTF-8?q?ile=20notifications,=20real=20match=20scores,=20action=20button?= =?UTF-8?q?s?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 --- src/components/demand/PropertyMatchRow.tsx | 160 +++++++++ src/components/demand/SavedNeedCard.tsx | 78 +++++ src/components/demand/SavedNeedDetail.tsx | 326 +++++++++++++++++++ src/components/demand/SavedProfilesTab.tsx | 167 ++++++++++ src/components/demand/index.ts | 4 + src/components/layout/NotificationButton.tsx | 108 +++++- src/domain/need.ts | 7 + src/hooks/useNeeds.ts | 28 +- src/mock-data/needs.ts | 206 ++++++++++-- src/pages/demand/AISearch.tsx | 157 +++++---- 10 files changed, 1131 insertions(+), 110 deletions(-) create mode 100644 src/components/demand/PropertyMatchRow.tsx create mode 100644 src/components/demand/SavedNeedCard.tsx create mode 100644 src/components/demand/SavedNeedDetail.tsx create mode 100644 src/components/demand/SavedProfilesTab.tsx 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 ( + + + + + ) + } + + const list = ( + + + + Suchprofile + + {visible.length} + + + + {visible.map(n => ( + handleSelect(n.id)} + /> + ))} + + + + + + ) + + 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) }} /> + + )} ) }